diff --git a/.badges/operations.svg b/.badges/operations.svg index 8ba159e44e..f29f7c0811 100644 --- a/.badges/operations.svg +++ b/.badges/operations.svg @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ PARITY entries PARITY entries - 8901 - 8901 + 9318 + 9318 diff --git a/.badges/parity.svg b/.badges/parity.svg index 0fa089a728..ebf24c67a5 100644 --- a/.badges/parity.svg +++ b/.badges/parity.svg @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ parity parity - 168 A · 69 B · 3 C - 168 A · 69 B · 3 C + 169 A · 87 B · 3 C + 169 A · 87 B · 3 C diff --git a/.badges/services.svg b/.badges/services.svg index bb49884d4e..69b929ea37 100644 --- a/.badges/services.svg +++ b/.badges/services.svg @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ AWS services AWS services - 242 - 242 + 261 + 261 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd81f47b54..1a24239cd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,7 @@ jobs: token: ${{ env.GH_CI_TOKEN }} fetch-depth: 0 + # Full-repo lint (go.mod/.golangci.yml changed) exhausts the default runner disk; lint needs no docker images or apt packages. - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: @@ -67,9 +68,9 @@ jobs: android: true dotnet: true haskell: true - large-packages: false - docker-images: false - swap-storage: false + large-packages: true + docker-images: true + swap-storage: true - name: Keep Go temp files and build cache off the /tmp tmpfs run: | @@ -939,6 +940,16 @@ jobs: path: /tmp/gopherstack-tofu-provider-cache key: tofu-providers-${{ runner.os }}-aws5 + - name: Cache OpenTofu provider mirror + id: tofu-mirror-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/tofu-provider-mirror + key: tofu-mirror-${{ runner.os }}-${{ hashFiles('test/terraform/*_test.go', 'test/terraform/fixtures/**') }} + + - name: Point the terraform suite at the provider mirror + run: echo "GOPHERSTACK_TOFU_MIRROR_DIR=$RUNNER_TEMP/tofu-provider-mirror" >> "$GITHUB_ENV" + - name: Cache OpenTofu Google provider uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: diff --git a/.github/workflows/routing-proof.yml b/.github/workflows/routing-proof.yml new file mode 100644 index 0000000000..de818be669 --- /dev/null +++ b/.github/workflows/routing-proof.yml @@ -0,0 +1,75 @@ +name: Routing Proof + +on: + pull_request: + branches: [ main ] + paths: + - 'internal/app/routing*' + - 'internal/routegates/**' + - 'pkgs/service/router*' + - 'testdata/routing/**' + - 'services/*/route*.go' + - 'cmd/routingcorpus/**' + - '.github/workflows/routing-proof.yml' + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +permissions: read-all + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + routing-proof: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: false + swap-storage: false + + - name: Keep Go temp files and build cache off the /tmp tmpfs + run: | + if mountpoint -q /mnt; then base=/mnt; else base="$RUNNER_TEMP"; fi + sudo mkdir -p "$base/gotmp" "$base/gocache" + sudo chown -R "$(id -u):$(id -g)" "$base/gotmp" "$base/gocache" + { + echo "GOTMPDIR=$base/gotmp" + echo "TMPDIR=$base/gotmp" + echo "GOCACHE=$base/gocache" + } >> "$GITHUB_ENV" + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + check-latest: true + cache: true + + - name: Download Go modules (retry proxy stream errors) + run: | + for i in 1 2 3 4 5; do + go mod download && exit 0 + sleep $((i * 15)) + done + exit 1 + + - name: Install Task + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 + with: + version: 3.54.0 + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Routing proof (full, no -short) + run: task routing-proof diff --git a/.golangci.yml b/.golangci.yml index 2ca4f65812..6069492b1d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -516,9 +516,15 @@ linters: # isolation_test.go uses unexported types and must be in the same package. - path: 'elasticache/isolation_test.go' linters: [ testpackage, paralleltest ] + # gremlin_ws_proxy_test.go drives the unexported front against a fake Gremlin Server (no docker). + - path: 'neptune/gremlin_ws_proxy_test\.go' + linters: [ testpackage ] # router_index_test.go checks the unexported index against the unexported linear scan. - path: 'pkgs/service/router_index_test.go' linters: [ testpackage ] + # router_alt_gate_test.go checks the unexported AltGate.admits against a lookup context. + - path: 'pkgs/service/router_alt_gate_test.go' + linters: [ testpackage ] # routing_test.go white-box tests unexported routing helpers and the # injectable RNG field, so it must live in the same package. - path: 'route53/routing_test.go' diff --git a/AZURE.md b/AZURE.md index 9d507a7d8e..0ee31384e2 100644 --- a/AZURE.md +++ b/AZURE.md @@ -86,6 +86,8 @@ That said, MQTT's fixed port (`1883`) happens to fall *outside* `--port-range-st - **M2 (done)** — `services/azuretable`: table CRUD (Create/Delete/List Table), full entity lifecycle (Insert/Get/Query/Replace/Merge/Delete), a hand-written `$filter` lexer/parser/evaluator (eq/ne/lt/le/gt/ge, and/or/not, parentheses, every OData literal form -- string/int/Int64/float/bool/datetime/guid/binary -- against `PartitionKey`/`RowKey`/`Timestamp` and scalar properties), all eight EDM property types (String/Int32/Int64/Double/Boolean/DateTime/Guid/Binary) with `@odata.type` annotation round-tripping matching `azure-sdk-for-go/sdk/data/aztables`'s own client-side inference, and ETag-based optimistic concurrency (If-Match `*`/specific/absent -> upsert), wired into `cli.go` and bound to its own fixed port (10002, Azurite's own Table port; synchronous bind, fail-fast, no `PortAlloc` fallback -- mirrors Blob's M0 and Queue's M1 port strategy, see section 4). Batch (`$batch` multipart/mixed changesets) and continuation-token pagination are deferred -- see `services/azuretable/PARITY.md`. See `services/azuretable/README.md`/`PARITY.md` for current status and known gaps. - **Azure Files (done)** -- `services/azurefile` (PARITY.md is the authority): the file shares data plane (service properties, shares with snapshots/leases/ACLs/soft delete, directories, files with sparse ranges, copy and rename) on fixed port 10009 (`--azure-file-port`), account by path or `.file.`, `.file.` also reachable through the shared storage vhost listener, ARM `primaryEndpoints.file` advertised, share soft delete driven by ARM `fileServices/default` `shareDeleteRetentionPolicy`. Tests are driven through `azure-sdk-for-go/sdk/storage/azfile`. - **Azure Monitor Logs (done)** -- `services/azuremonitor` (PARITY.md is the authority): the Logs Ingestion API (`POST /dataCollectionRules/{immutableId}/streams/{stream}`, gzip, 1 MB limit, stream schema checks that drop bad records like Azure, or reject the upload with `--azure-monitor-strict-ingest`, `transformKql` through the in-tree KQL engine) and the Logs Query API (`/v1/workspaces/{id}/query`, `$batch`, resource-centric queries) on one HTTPS listener, fixed port 10013 (`--azure-monitor-port`), bearer auth (optionally JWT-shape checked). `services/azuremonitor/kql` is a KQL subset engine (tables, let, union, where, project/extend/summarize/sort/top/distinct and about 70 scalar functions); unsupported operators answer 400 SemanticError. ARM `Microsoft.OperationalInsights` (workspaces, tables, shared keys) and `Microsoft.Insights` (data collection endpoints and rules) live in `services/azurearm` over the `MonitorLogs` seam. Tests are driven through `azure-sdk-for-go/sdk/monitor/ingestion/azlogs`, `sdk/monitor/query/azlogs`, `armoperationalinsights` and `armmonitor`. + +- **Microsoft Graph (done)** -- `services/azuregraph` (PARITY.md is the authority): Graph v1.0/beta directory objects (applications, service principals, users, groups, app role assignments, OAuth2 permission grants, owners and members references, `directoryObjects/getByIds`, `me`, `organization`, `domains`) on one HTTPS listener, fixed port 10014 (`--azure-graph-port`). OData `$filter`/`$select`/`$top`/`@odata.nextLink`/`$orderby`/`$count`/`$search` with the `ConsistencyLevel: eventual` rules for advanced queries, alternate keys (`applications(appId='..')`), `addPassword` (secret returned once, PBKDF2 hash stored), and the Graph error envelope. ARM advertises the listener as `microsoftGraphResourceId`, so `terraform-provider-azuread` with `metadata_host` works unchanged (`test/terraform/azure/azuread_test.go`). The ARM token endpoint authenticates `client_id`/`client_secret` of applications registered here and stamps the service principal's object id as `oid`; the seeded dev service principal (the all-zero client id) accepts any secret. `--azure-arm-validate-principals` makes role assignments reject principals that are neither directory objects nor user-assigned managed identities. - **M3 (done)** — `services/cosmosdb`: database/container CRUD (with a single-path partition-key declaration; hierarchical/multi-path partition keys deferred), full document CRUD including upsert (`x-ms-documentdb-is-upsert`) and ETag-based optimistic concurrency (If-Match absent/specific -> upsert/conditional), and a hand-written SQL-subset query engine (tokenizer -> recursive-descent parser -> AST -> executor, directly reusing `services/azuretable/odata_filter.go`'s and `services/s3/select_sql_*.go`'s shape per this section's own M3 rationale below) supporting `SELECT *`/projections with `AS` aliasing, `FROM []`, `WHERE` with the full comparison/boolean/`IS NULL` grammar and `@param` bindings, `ORDER BY`, `TOP`, and `OFFSET`/`LIMIT`. Auth is Cosmos's own master-key HMAC-SHA256 scheme (`services/cosmosdb/masterkey.go`, deliberately NOT `pkgs/azureauth` -- see section 3), opt-in verification via `--cosmosdb-validate-auth`, defaulting to the real Cosmos DB Local Emulator's well-known fixed master key. Bound to its own fixed port (8081, the real emulator's own published default -- NOT an Azurite-style 1000x number, since Cosmos's real emulator is a separate tool; synchronous bind, fail-fast, no `PortAlloc` fallback, and -- unlike M0/M1/M2's ports -- 8081 sits *outside* `--port-range-start`/`--port-range-end`'s own default range, mirroring `services/iot`'s MQTT precedent; see section 4). Fake `x-ms-request-charge`/`x-ms-session-token`/`x-ms-activity-id` headers on every response (real RU accounting is out of scope). Integration tests against `azure-sdk-for-go/sdk/data/azcosmos`; JS/Python SDK cross-compat smoke tests remain M4 scope. See `services/cosmosdb/README.md`/`PARITY.md` for current status and known gaps. **This completes the full 4-service Azure support plan** (Blob, Queue, Table, Cosmos); M4 (docs/polish, cross-SDK e2e) has since landed as well. - **M4 (done)** — Docs/polish: the four services now render under their own **Azure** heading in the root `README.md` services table with curated display names (`cmd/gendocs/categories.go`) rather than falling through to "Other" as auto-title-cased slugs; per-service guides at `docs/services/azureblob.md`/`azurequeue.md`/`azuretable.md`/`cosmosdb.md` following the established AWS guide format; a four-service docker-compose example at `examples/azure-services/` (indexed in `examples/README.md`), with `Dockerfile`/`Dockerfile.goreleaser` now exposing 10000/10001/10002/8081; and the cross-SDK smoke suite from section 7 -- `test/e2e/crosssdk/azure_smoke.mjs` (`@azure/storage-blob`, `@azure/storage-queue`, `@azure/data-tables`, `@azure/cosmos`) and `azure_smoke.py` (`azure-storage-blob`, `azure-storage-queue`, `azure-data-tables`, `azure-cosmos`), driven by `test/e2e/azure_crosssdk_test.go`, which starts gopherstack on ephemeral ports and runs both scripts in CI's `e2e-tests` job. Note: no per-service badges were added -- `.badges/` holds only repo-level aggregate badges (services/operations/parity counts), which already include the Azure services, so "badges/icons" resolved to verifying zero `cmd/gendocs` drift rather than adding new SVGs. The cross-SDK suite immediately earned its keep by catching a real wire-compatibility gap: `Get Blob` accepted only the `Range` header, but the Python/JS SDKs send `x-ms-range` exclusively, so single-range downloads were unreachable from those SDKs despite the Go SDK passing (fixed in `services/azureblob/handler.go`; see that service's `PARITY.md`). diff --git a/Dockerfile b/Dockerfile index fc601821d3..05ebb2a088 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,19 +16,19 @@ RUN go mod download # Copy the source from the current directory to the Working Directory inside the container COPY . . -# Build dashboard2 frontend assets before embedding in Go binary # Build dashboard2 frontend assets before embedding in Go binary # If the SPA was pre-built (e.g. via `task ui-build` before `task demo`), skip the npm build # to avoid OOM inside the container. Fall back to building in-container if assets are absent. -RUN if [ ! -f dashboard/static/spa/index.html ]; then \ +RUN if [ ! -f dashboard/static/spa/index.html.gz ] && [ ! -f dashboard/static/spa/index.html ]; then \ npm --prefix ui ci --include=optional && \ NODE_OPTIONS="--max-old-space-size=8192" npm --prefix ui run build; \ fi # Build the Go app -RUN go build \ +RUN CGO_ENABLED=0 go build \ -tags 'netgo osusergo static_build' \ -trimpath \ + -buildvcs=false \ -ldflags="-w -s -extldflags '-static -fno-PIC'" \ -o gopherstack diff --git a/README.md b/README.md index 146484a2bd..0714e3ef8d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,18 @@ gopherstack snapshot save|load|list # named state snapshots (same machinery a gopherstack stop # stop and remove it (safe to repeat) ``` -`start` flags: `--image`, `--tag`, `--host`, `--bind` (default `127.0.0.1`), `--port` (default `8000`), `-p/--publish` for extra ports, `--data-dir` (mounted at `/data` in the container), `--persist`, `-e/--env KEY=VALUE` (or `KEY` to copy from your shell), `--timeout`, `--name`, `--state-dir`. It waits for `/_gopherstack/health` and, on failure, prints the tail of the container or process log. The pidfile, state and host log live in `$XDG_STATE_HOME/gopherstack` (`~/.local/state/gopherstack`). Commands that talk to a running server use `--endpoint` / `GOPHERSTACK_ENDPOINT` (default `http://localhost:8000`). +`start` flags: `--image`, `--tag`, `--host`, `--bind` (default `127.0.0.1`), `--port` (default `8000`), `-p/--publish` for extra ports, `--data-dir` (mounted at `/data` in the container), `--persist`, `--foreground`, `--docker-socket`, `--docker-socket-path`, `-e/--env KEY=VALUE` (or `KEY` to copy from your shell), `--timeout`, `--name`, `--state-dir`. It waits for `/_gopherstack/health` and, on failure, prints the tail of the container or process log. The pidfile, state and host log live in `$XDG_STATE_HOME/gopherstack` (`~/.local/state/gopherstack`). `--foreground` attaches (container mode): it streams the container's logs, turns SIGINT/SIGTERM into a graceful stop and exits with the container's exit code. `--docker-socket` is an explicit opt-in that mounts the host Docker socket (`--docker-socket-path`, else a unix `DOCKER_HOST`, else `/var/run/docker.sock`) at `/var/run/docker.sock` so engine-backed services can start containers. **Security:** anyone who can reach the server can then control the host Docker daemon, which is root-equivalent on the host; it is off by default and a warning is printed. Neither flag applies to `--host`. `status` lists per service the state, port, health (from `/_localstack/health`), plus uptime and heap/goroutines; `--json` carries the same. All services share one process, so port and uptime are server-wide, and no per-service resource counts are exposed by the server. On Windows, `stop` of a `--host` process sends CTRL_BREAK and falls back to terminating it, which skips the shutdown persistence flush. Commands that talk to a running server use `--endpoint` / `GOPHERSTACK_ENDPOINT` (default `http://localhost:8000`). + +## MCP server for AI assistants + +Gopherstack ships a built-in [Model Context Protocol](https://modelcontextprotocol.io) server so assistants can inspect and control a running instance: health and enabled services, state snapshots, resets, chaos faults, request traces, IAM denials and init hooks. Secrets (IAM secret keys, KMS material, Secrets Manager values, request headers and bodies) are never returned. + +```bash +gopherstack mcp --endpoint http://localhost:8000 # stdio server for MCP clients +gopherstack serve --mcp-enabled # also serve streamable HTTP at /_gopherstack/mcp +``` + +The HTTP endpoint accepts loopback clients only unless `--mcp-token` / `GOPHERSTACK_MCP_TOKEN` is set, in which case every request needs `Authorization: Bearer `. See [docs/mcp.md](docs/mcp.md) for client configuration and the tool list. ## Installation @@ -313,7 +324,9 @@ A single switch picks the default for every container-backed engine (RDS/Aurora, | `--elasticache-engine` | `ELASTICACHE_ENGINE` | `embedded` | ElastiCache engine mode: `embedded` (miniredis), `stub`, or `docker`. | | `--memorydb-engine` | `MEMORYDB_ENGINE` | `embedded` | MemoryDB engine mode: `embedded` (miniredis per cluster, ACL passwords enforced via AUTH), `stub`, or `docker` (no server, as ElastiCache). | | `--kafka-engine` | `KAFKA_ENGINE` | `stub` | MSK engine mode: `stub` (metadata only) or `docker` (real single-node `apache/kafka` broker per provisioned cluster; `KAFKA_BROKER_HOST` overrides the advertised host, default `127.0.0.1`; ports bind loopback only unless it is non-loopback, then all interfaces). | -| `--eks-engine` | `EKS_ENGINE` | `stub` | EKS engine mode: `stub` (metadata only) or `docker` (one privileged single-node `rancher/k3s` container per cluster, tag mapped from the requested version; `DescribeCluster` returns the real endpoint and CA; clusters accept the static admin bearer token `EKS_CLUSTER_TOKEN`, default `gopherstack-eks-cluster-token`; `EKS_CLUSTER_HOST` sets the advertised host, default `127.0.0.1`, and the port binds loopback only unless it is non-loopback, which then requires `EKS_CLUSTER_TOKEN`; `EKS_K3S_IMAGE` overrides the image). | +| `--elbv2-listeners` | `ELBV2_LISTENERS` | `routed` | ELBv2 data plane: `routed` (path/host routing on the emulator port) or `real` (a local socket per listener; HTTPS/TLS terminate with ACM certificates, SNI and security-policy TLS versions; the port map is at `GET /_gopherstack/elbv2/_listeners`). | +| `--elbv2-listener-host` | `ELBV2_LISTENER_HOST` | `127.0.0.1` | Bind address for real ELBv2 listeners. Requested ports below 1024 or already taken map to a port from the shared pool. | +| `--eks-engine` | `EKS_ENGINE` | `stub` | EKS engine mode: `stub` (metadata only) or `docker` (one privileged single-node `rancher/k3s` container per cluster, tag mapped from the requested version; `DescribeCluster` returns the real endpoint and CA; clusters accept the static admin bearer token `EKS_CLUSTER_TOKEN`, default `gopherstack-eks-cluster-token`; `EKS_CLUSTER_HOST` sets the advertised host, default `127.0.0.1`, and the port binds loopback only unless it is non-loopback, which then requires `EKS_CLUSTER_TOKEN`; `EKS_K3S_IMAGE` overrides the image; `EKS_K3S_IMAGE_TAGS=1.31=v1.31.5-k3s1,...` pins the k3s tag per Kubernetes minor, and a version with no image is rejected with `InvalidParameterException`; `EKS_LB_PORTS=8080,8443` publishes those node ports on free host ports; cluster tags `gopherstack:volume-mount=host:container[:ro],...` (absolute paths, no `..`; off unless `EKS_VOLUME_MOUNT_ALLOW=/abs/prefix,...` allowlists the host paths, symlinks resolved) and `gopherstack:lb-ports` apply at `CreateCluster`, and published ports are listed at `GET /_gopherstack/eks/clusters//ports`; `EKS_KUBECONFIG` + `EKS_KUBECONFIG_CONTEXT` front an existing cluster instead of starting k3s: `DescribeCluster` returns its endpoint and CA and `DeleteCluster` never touches it). | | `--mq-engine` | `MQ_ENGINE` | `stub` | Amazon MQ engine mode: `stub` (metadata only) or `docker` (one `rabbitmq:3.13.7-management` or `apache/activemq-classic:5.18.7` container per broker with a user; plaintext endpoints; `MQ_BROKER_HOST` overrides the advertised host, default `127.0.0.1`; ports bind loopback only unless it is non-loopback, then all interfaces). | | `--transfer-sftp` | `TRANSFER_SFTP` | `false` | Serve a real SFTP listener per Transfer Family server. | | `--transfer-sftp-host` | `TRANSFER_SFTP_HOST` | `127.0.0.1` | Host the Transfer Family SFTP listener binds to. | @@ -341,6 +354,8 @@ A single switch picks the default for every container-backed engine (RDS/Aurora, | `--ec2-docker-ssh-host-ip` | `EC2_DOCKER_SSH_HOST_IP` | `127.0.0.1` | Host IP that mapped EC2-docker SSH ports bind to. | | `--ec2-docker-ssh-port-min` | `EC2_DOCKER_SSH_PORT_MIN` | `0` | Lower bound of the host port range for EC2-docker SSH mapping (`0` = let Docker pick). | | `--ec2-docker-ssh-port-max` | `EC2_DOCKER_SSH_PORT_MAX` | `0` | Upper bound of that host port range. | +| `--ec2-docker-flags` | `GOPHERSTACK_EC2_DOCKER_FLAGS` | *(empty)* | Extra docker-run options for every EC2-docker container: `--privileged`, `-e K=V`, `-v src:dst[:ro]`, `--cap-add/--cap-drop X`, `-m/--memory N`, `--cpus N`, `--network NAME`, `-p [ip:]host:ctr[/proto]`. Anything else fails startup. | +| `--ec2-docker-ebs-volumes` | `GOPHERSTACK_EC2_DOCKER_EBS_VOLUMES` | `false` | Map EBS volumes to Docker named volumes mounted at `/mnt/ebs/` (see [`examples/ec2-docker`](examples/ec2-docker)). | #### Lambda & containers @@ -359,6 +374,9 @@ configuration](#lambda-configuration) below. A few more exist: | *(none)* | `CONTAINER_HOST` | *(empty)* | Generic container endpoint override (e.g. a Podman socket URL). Takes precedence over runtime auto-detection. | | `--ecs-runtime` | `GOPHERSTACK_ECS_RUNTIME` | *(unset = no-op)* | Set to `docker` to run ECS tasks as real containers; also driven by `--engines`. Unset or any other value is a no-op runner. | | *(none)* | `GOPHERSTACK_ENABLE_LOCAL_REGISTRY` | `false` (set to `1` to enable) | Run a real embedded Docker Registry v2 backend for ECR instead of the in-memory stub. | +| *(none)* | `GOPHERSTACK_BEDROCK_BACKEND` | `mock` | Bedrock Runtime inference: `mock` returns deterministic placeholder output (not a model); `ollama` forwards Converse/InvokeModel to a local Ollama. | +| *(none)* | `GOPHERSTACK_BEDROCK_OLLAMA_URL` | `http://localhost:11434` | Ollama base URL for the `ollama` Bedrock backend. | +| *(none)* | `GOPHERSTACK_BEDROCK_MODEL_MAP` | *(empty)* | Comma list of `bedrock-id-glob=ollama-model` rules, e.g. `anthropic.claude-*=llama3.2,amazon.titan-embed-*=nomic-embed-text`; unmapped models return ResourceNotFoundException. | #### Per-service tuning (janitor intervals & TTLs) @@ -409,6 +427,7 @@ commonly set via env var: | `SFN_EXECUTION_RETENTION` | `24h` | How long Step Functions execution history is retained. | | `SFN_JANITOR_INTERVAL` | `1m` | Step Functions janitor tick interval. | | `SFN_TASK_TOKEN_TTL` | `1h` | Max lifetime of an unreceived Step Functions task token. | +| `SFN_JSONATA_MAP_OBJECT_ITEMS` | `true` | Accept an object as a JSONata Map `Items` value (the Inline Map docs say array only). | | `SSM_JANITOR_INTERVAL` | `30s` | SSM janitor tick interval. | | `SSM_COMMAND_TTL` | `1h` | TTL for SendCommand results (matches AWS's 1h default). | | `STS_JANITOR_INTERVAL` | `30s` | STS janitor tick interval. | @@ -677,16 +696,16 @@ Every service links to its own page with a coverage breakdown — audited operat | [App Runner](services/apprunner/README.md) | A | 37 | clean | | [Auto Scaling](services/autoscaling/README.md) | A | 66 | 1 gap; 2 structural gaps | | [Batch](services/batch/README.md) | A | 48 | 3 structural gaps | -| [EC2](services/ec2/README.md) | A | — | 22 families; 2 gaps; 18 structural gaps; 5 deferred | +| [EC2](services/ec2/README.md) | A | — | 22 families; 5 gaps; 18 structural gaps; 5 deferred | | [Elastic Beanstalk](services/elasticbeanstalk/README.md) | A | 47 | 4 structural gaps | -| [Lambda](services/lambda/README.md) | A | — | 11 families; 1 gap; 10 structural gaps | +| [Lambda](services/lambda/README.md) | A | — | 11 families; 5 gaps; 11 structural gaps | ### Containers | Service | Parity | PARITY Entries | Notes | |---|---|---|---| | [ECR](services/ecr/README.md) | A | 58 | 2 structural gaps; 2 deferred | -| [ECS](services/ecs/README.md) | A | 65 | 5 structural gaps | +| [ECS](services/ecs/README.md) | A | 65 | 5 gaps; 5 structural gaps | | [EKS](services/eks/README.md) | A | 71 | 1 gap; 5 structural gaps; 1 deferred | ### Storage @@ -709,18 +728,18 @@ Every service links to its own page with a coverage breakdown — audited operat |---|---|---|---| | [DAX](services/dax/README.md) | A | 21 | 1 structural gap; 1 deferred | | [DocumentDB](services/docdb/README.md) | A | 55 | 2 gaps; 5 structural gaps; 1 deferred | -| [DynamoDB](services/dynamodb/README.md) | A | — | 15 families; 1 gap; 3 structural gaps; 2 deferred | +| [DynamoDB](services/dynamodb/README.md) | A | — | 15 families; 3 gaps; 3 structural gaps; 2 deferred | | [DynamoDB Streams](services/dynamodbstreams/README.md) | A | 4 | clean | | [ElastiCache](services/elasticache/README.md) | A | 75 | 2 gaps; 4 structural gaps; 1 deferred | | [MemoryDB](services/memorydb/README.md) | A | 45 | 3 gaps; 2 structural gaps; 3 deferred | -| [Neptune](services/neptune/README.md) | A | — | 13 families; 7 gaps; 2 structural gaps; 2 deferred | +| [Neptune](services/neptune/README.md) | A | — | 13 families; 6 gaps; 2 structural gaps; 2 deferred | | [QLDB](services/qldb/README.md) | Removed | — | removed service | | [QLDB Session](services/qldbsession/README.md) | Removed | — | removed service | -| [RDS](services/rds/README.md) | A | 52 | 6 gaps; 7 structural gaps | +| [RDS](services/rds/README.md) | A | 52 | 7 gaps; 7 structural gaps | | [RDS Data](services/rdsdata/README.md) | A | 7 | 4 structural gaps | | [Redshift](services/redshift/README.md) | A | 9 | 5 gaps; 5 structural gaps | | [Redshift Data](services/redshiftdata/README.md) | A | 12 | 4 structural gaps; 1 deferred | -| [Timestream Query](services/timestreamquery/README.md) | A | 12 | 2 structural gaps; 1 deferred | +| [Timestream Query](services/timestreamquery/README.md) | A | 12 | 6 gaps; 1 structural gap | | [Timestream Write](services/timestreamwrite/README.md) | A | 19 | 1 structural gap | ### Networking & Content Delivery @@ -733,7 +752,7 @@ Every service links to its own page with a coverage breakdown — audited operat | [ARC Zonal Shift](services/arczonalshift/README.md) | A | 15 | 1 structural gap | | [App Mesh](services/appmesh/README.md) | A | 38 | 1 structural gap | | [Cloud Map](services/servicediscovery/README.md) | A | 30 | 4 structural gaps; 1 deferred | -| [CloudFront](services/cloudfront/README.md) | A | 60 | 3 gaps; 4 structural gaps; 4 deferred | +| [CloudFront](services/cloudfront/README.md) | A | 60 | 4 gaps; 4 structural gaps; 4 deferred | | [CloudWatch Network Monitor](services/networkmonitor/README.md) | A | 12 | 1 deferred | | [ELB (Classic)](services/elb/README.md) | A | 29 | 1 gap; 1 deferred | | [ELBv2](services/elbv2/README.md) | A | 51 | 2 gaps; 2 structural gaps; 2 deferred | @@ -759,19 +778,19 @@ Every service links to its own page with a coverage breakdown — audited operat | [SNS](services/sns/README.md) | A | 34 | 1 structural gap; 1 deferred | | [SQS](services/sqs/README.md) | A | 20 | 2 gaps; 1 structural gap | | [SWF](services/swf/README.md) | A | 39 | clean | -| [Step Functions](services/stepfunctions/README.md) | A | 37 | 4 gaps; 3 structural gaps | +| [Step Functions](services/stepfunctions/README.md) | A | 37 | 5 gaps; 1 structural gap | | [WorkMail](services/workmail/README.md) | A | 92 | 2 gaps; 3 structural gaps | ### Analytics | Service | Parity | PARITY Entries | Notes | |---|---|---|---| -| [Athena](services/athena/README.md) | A | 26 | 7 gaps; 6 structural gaps; 1 deferred | +| [Athena](services/athena/README.md) | A | 27 | 10 gaps; 6 structural gaps; 1 deferred | | [Clean Rooms](services/cleanrooms/README.md) | A | — | 18 families; 5 structural gaps | | [EMR](services/emr/README.md) | A | 65 | 7 structural gaps | | [EMR Serverless](services/emrserverless/README.md) | A | 22 | 1 structural gap | | [Elasticsearch](services/elasticsearch/README.md) | A | 51 | 1 structural gap | -| [Glue](services/glue/README.md) | A | 59 | 4 gaps; 5 structural gaps; 2 deferred | +| [Glue](services/glue/README.md) | A | 59 | 5 gaps; 5 structural gaps; 2 deferred | | [Glue DataBrew](services/databrew/README.md) | A | 44 | 1 structural gap | | [Kinesis](services/kinesis/README.md) | A | 39 | 1 gap; 5 structural gaps | | [Kinesis Analytics](services/kinesisanalytics/README.md) | A | 20 | clean | @@ -792,12 +811,12 @@ Every service links to its own page with a coverage breakdown — audited operat | [Detective](services/detective/README.md) | A | 29 | 4 structural gaps; 2 deferred | | [GuardDuty](services/guardduty/README.md) | A | 66 | 1 gap; 3 structural gaps | | [Inspector](services/inspector2/README.md) | A | 13 | 4 structural gaps; 1 deferred | -| [KMS](services/kms/README.md) | A | 54 | 1 gap; 1 structural gap; 1 deferred | +| [KMS](services/kms/README.md) | A | 54 | 2 gaps; 1 structural gap; 1 deferred | | [Macie](services/macie2/README.md) | A | 81 | clean | | [Secrets Manager](services/secretsmanager/README.md) | A | 24 | 1 structural gap; 2 deferred | | [Security Hub](services/securityhub/README.md) | A | 120 | 4 gaps; 4 structural gaps | | [Shield](services/shield/README.md) | A | 36 | 1 structural gap; 1 deferred | -| [Verified Permissions](services/verifiedpermissions/README.md) | A | 34 | 2 gaps; 1 structural gap | +| [Verified Permissions](services/verifiedpermissions/README.md) | A | 34 | 2 gaps | | [WAF](services/waf/README.md) | A | 4 | 3 structural gaps | | [WAFv2](services/wafv2/README.md) | A | 59 | 2 gaps; 2 structural gaps | @@ -808,7 +827,7 @@ Every service links to its own page with a coverage breakdown — audited operat | [Cognito Identity](services/cognitoidentity/README.md) | A | 23 | 1 gap; 4 deferred | | [Cognito Identity Provider](services/cognitoidp/README.md) | A | 71 | 6 gaps; 3 structural gaps | | [Directory Service](services/directoryservice/README.md) | A | 80 | 1 gap; 2 structural gaps; 1 deferred | -| [IAM](services/iam/README.md) | A | 38 | 2 gaps; 4 structural gaps | +| [IAM](services/iam/README.md) | A | 38 | 4 gaps; 4 structural gaps | | [IAM Access Analyzer](services/accessanalyzer/README.md) | A | 39 | 1 gap; 5 structural gaps; 1 deferred | | [IAM Identity Center (SSO)](services/ssoadmin/README.md) | A | 56 | 1 gap | | [IAM Identity Center OIDC](services/ssooidc/README.md) | B | 4 | 1 gap; 1 structural gap | @@ -851,7 +870,7 @@ Every service links to its own page with a coverage breakdown — audited operat | [CodeCommit](services/codecommit/README.md) | A | 80 | 1 gap; 1 structural gap | | [CodeConnections](services/codeconnections/README.md) | A | 27 | 1 gap | | [CodeDeploy](services/codedeploy/README.md) | A | 47 | 3 structural gaps; 1 deferred | -| [CodePipeline](services/codepipeline/README.md) | A | 22 | 3 gaps; 7 structural gaps; 1 deferred | +| [CodePipeline](services/codepipeline/README.md) | A | 22 | 6 gaps; 7 structural gaps; 1 deferred | | [CodeStar Connections](services/codestarconnections/README.md) | A | 27 | 1 gap; 2 structural gaps | | [Serverless Application Repository](services/serverlessrepo/README.md) | A | 14 | clean | | [X-Ray](services/xray/README.md) | A | 38 | 2 gaps; 6 structural gaps; 1 deferred | @@ -862,7 +881,7 @@ Every service links to its own page with a coverage breakdown — audited operat |---|---|---|---| | [Bedrock](services/bedrock/README.md) | A | 80 | 1 gap; 3 structural gaps | | [Bedrock Agent](services/bedrockagent/README.md) | A | 77 | 2 gaps; 3 structural gaps; 2 deferred | -| [Bedrock Runtime](services/bedrockruntime/README.md) | A | 11 | 3 gaps; 5 structural gaps | +| [Bedrock Runtime](services/bedrockruntime/README.md) | A | 11 | 4 gaps; 5 structural gaps | | [Comprehend](services/comprehend/README.md) | A | 28 | 1 gap; 1 structural gap; 1 deferred | | [Forecast](services/forecast/README.md) | A | 21 | 1 gap | | [Personalize](services/personalize/README.md) | A | 74 | clean | @@ -899,7 +918,7 @@ Every service links to its own page with a coverage breakdown — audited operat | Service | Parity | PARITY Entries | Notes | |---|---|---|---| | [DataSync](services/datasync/README.md) | A | 53 | 2 gaps; 3 structural gaps; 1 deferred | -| [Database Migration Service](services/dms/README.md) | A | 97 | 3 gaps; 3 structural gaps | +| [Database Migration Service](services/dms/README.md) | A | 97 | 8 gaps; 3 structural gaps | | [Transfer Family](services/transfer/README.md) | A | — | 20 families; 4 structural gaps | ### Azure @@ -910,7 +929,7 @@ Every service links to its own page with a coverage breakdown — audited operat | [Azure Blob Storage](services/azureblob/README.md) | C | 29 | 5 gaps; 3 deferred | | [Azure Cache for Redis](services/azureredis/README.md) | B | 11 | 13 gaps | | [Azure Container Registry](services/azurecontainerregistry/README.md) | B | 24 | 10 gaps | -| [Azure Cosmos DB](services/cosmosdb/README.md) | B | 32 | 9 gaps | +| [Azure Cosmos DB](services/cosmosdb/README.md) | A | 35 | clean | | [Azure Event Grid](services/azureeventgrid/README.md) | B | 9 | 15 gaps | | [Azure Event Hubs](services/azureeventhubs/README.md) | B | 15 | 9 gaps | | [Azure Files](services/azurefile/README.md) | B | 38 | 11 gaps | @@ -921,32 +940,39 @@ Every service links to its own page with a coverage breakdown — audited operat | [Azure SQL Database](services/azuresql/README.md) | B | 14 | 11 gaps | | [Azure Service Bus](services/azureservicebus/README.md) | B | 25 | 3 gaps; 4 deferred | | [Azure Table Storage](services/azuretable/README.md) | C | 14 | 4 gaps; 3 deferred | +| [Microsoft Graph](services/azuregraph/README.md) | B | 28 | 10 gaps | ### GCP | Service | Parity | PARITY Entries | Notes | |---|---|---|---| +| [API Gateway](services/gcpapigateway/README.md) | B | 25 | 7 gaps | | [Artifact Registry](services/gcpartifactregistry/README.md) | B | 40 | 13 gaps | | [BigQuery](services/gcpbigquery/README.md) | B | 23 | 8 gaps | | [Cloud Bigtable](services/gcpbigtable/README.md) | B | 43 | 8 gaps | | [Cloud Build](services/gcpcloudbuild/README.md) | B | 20 | 11 gaps | | [Cloud DNS](services/gcpdns/README.md) | B | — | 8 families; 5 gaps | +| [Cloud Datastore](services/gcpdatastore/README.md) | B | 13 | 5 gaps | | [Cloud Functions](services/gcpfunctions/README.md) | B | 8 | 8 gaps | | [Cloud IAM](services/gcpiam/README.md) | B | 31 | 5 gaps | | [Cloud Logging](services/gcplogging/README.md) | B | 40 | 7 gaps | | [Cloud Monitoring](services/gcpmonitoring/README.md) | B | 53 | 11 gaps | | [Cloud Run](services/gcprun/README.md) | B | 20 | 5 gaps | -| [Cloud SQL](services/gcpsql/README.md) | B | 38 | 7 gaps | +| [Cloud SQL](services/gcpsql/README.md) | B | 42 | 7 gaps | | [Cloud Scheduler](services/gcpscheduler/README.md) | B | 10 | 4 gaps | | [Cloud Spanner](services/gcpspanner/README.md) | B | 18 | 6 gaps | | [Cloud Tasks](services/gcpcloudtasks/README.md) | B | 20 | 3 gaps | | [Compute Engine](services/gcpcompute/README.md) | B | — | 8 families; 10 gaps | | [Eventarc](services/gcpeventarc/README.md) | B | 24 | 10 gaps | +| [Google Cloud Batch](services/gcpbatch/README.md) | B | 14 | 9 gaps | | [Google Cloud Firestore](services/gcpfirestore/README.md) | B | 15 | 4 gaps | | [Google Cloud KMS](services/gcpkms/README.md) | B | 35 | 4 gaps | | [Google Cloud Pub/Sub](services/gcppubsub/README.md) | B | 38 | 6 gaps | +| [Google Cloud Resource Manager and Service Usage](services/gcpresourcemanager/README.md) | B | 39 | 9 gaps | | [Google Cloud Storage](services/gcpstorage/README.md) | B | 28 | 8 gaps | | [Google Secret Manager](services/gcpsecretmanager/README.md) | B | 18 | 5 gaps | +| [Identity Platform](services/gcpidentitytoolkit/README.md) | B | 1 | 6 gaps | +| [Kubernetes Engine](services/gcpcontainer/README.md) | B | — | 5 families; 5 gaps | | [Memorystore for Redis](services/gcpredis/README.md) | B | 14 | 7 gaps | | [Workflows](services/gcpworkflows/README.md) | B | 13 | 8 gaps | @@ -954,10 +980,22 @@ Every service links to its own page with a coverage breakdown — audited operat | Service | Parity | PARITY Entries | Notes | |---|---|---|---| +| [OCI API Gateway](services/ociapigateway/README.md) | B | 22 | 10 gaps | +| [OCI Artifacts and Container Registry](services/ociartifacts/README.md) | B | 37 | 7 gaps | +| [OCI Container Engine for Kubernetes](services/ocicontainerengine/README.md) | B | 20 | 9 gaps | +| [OCI Container Instances](services/ocicontainerinstances/README.md) | B | 18 | 7 gaps | | [OCI Core Services](services/ocicore/README.md) | B | 135 | 11 gaps | +| [OCI Events](services/ocievents/README.md) | B | 6 | 6 gaps | | [OCI Functions](services/ocifunctions/README.md) | B | 16 | 9 gaps | | [OCI Identity](services/ociidentity/README.md) | B | 70 | 9 gaps | +| [OCI Load Balancer](services/ociloadbalancer/README.md) | B | 61 | 9 gaps | +| [OCI Logging](services/ocilogging/README.md) | B | 26 | 8 gaps | +| [OCI Monitoring](services/ocimonitoring/README.md) | B | 11 | 9 gaps | +| [OCI MySQL HeatWave Database Service](services/ocimysql/README.md) | B | 32 | 9 gaps | +| [OCI NoSQL Database Cloud Service](services/ocinosql/README.md) | B | 21 | 9 gaps | +| [OCI Notifications](services/ocinotifications/README.md) | B | 16 | 7 gaps | | [OCI Object Storage](services/ociobjectstorage/README.md) | B | 45 | 11 gaps | +| [OCI Queue](services/ociqueue/README.md) | B | 19 | 9 gaps | | [OCI Streaming](services/ocistreaming/README.md) | B | 30 | 9 gaps | | [OCI Vault](services/ocivault/README.md) | B | 45 | 11 gaps | @@ -968,7 +1006,7 @@ Every service links to its own page with a coverage breakdown — audited operat | [Amp](services/amp/README.md) | B | 44 | 9 gaps; 2 structural gaps | | [AppStream 2.0](services/appstream/README.md) | A | 44 | 5 structural gaps | | [Appintegrations](services/appintegrations/README.md) | B | 23 | 3 gaps; 1 structural gap | -| [Azurearm](services/azurearm/README.md) | B | 20 | 14 gaps; 3 deferred | +| [Azurearm](services/azurearm/README.md) | B | 20 | 69 gaps; 3 deferred | | [Azurestoragevhost](services/azurestoragevhost/README.md) | B | 2 | 2 gaps; 1 deferred | | [Bcmdataexports](services/bcmdataexports/README.md) | B | 12 | 6 gaps | | [Bedrockagentcore](services/bedrockagentcore/README.md) | B | 30 | 11 gaps; 2 structural gaps | @@ -1115,6 +1153,7 @@ cdk deploy - [Architecture](docs/architecture/README.md) - [Examples](examples/README.md) - [Chaos Testing](docs/chaos.md) +- [MCP server](docs/mcp.md) ## Development @@ -1138,10 +1177,9 @@ memory. Tunables: `LINT_BATCH` (packages per invocation, default 15), `LINT_TIME `golangci-lint` holds the lock). The golangci-lint cache is reused, never cleaned. **Build cache.** `task gocache-trim` deletes `GOCACHE` entries older than -`GOCACHE_TRIM_MINUTES` (default 180), or runs `go clean -cache` if the cache exceeds -`GOCACHE_CLEAN_GB` (default 200), and prints before/after sizes. `lint-changed`, `build-check`, +`GOCACHE_TRIM_MINUTES` (default 180) and prints before/after sizes; it never wipes the whole cache. `lint-changed`, `build-check`, `lint`, `test` and `integration-test` run it automatically when the `GOCACHE` filesystem has -less than `GOCACHE_MIN_FREE_GB` (default 5) free. +less than `GOCACHE_MIN_FREE_GB` (default 60) free (or the cache exceeds `GOCACHE_MAX_GB`=150, which deletes entries older than `GOCACHE_AGED_MINUTES`=720). ## Versioning diff --git a/Taskfile.yml b/Taskfile.yml index 2e3bdc9b80..eecd1b307a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -269,7 +269,7 @@ tasks: - go tool govulncheck -mode=binary bin/{{.BINARY_NAME}}-linux gocache-trim: - desc: Trim old GOCACHE entries (GOCACHE_TRIM_MINUTES=180; go clean -cache if over GOCACHE_CLEAN_GB=200) + desc: Trim old GOCACHE entries (GOCACHE_TRIM_MINUTES=180; never a full wipe) silent: true cmds: - bash scripts/gocache-trim.sh @@ -280,6 +280,12 @@ tasks: cmds: - bash scripts/lint-changed.sh + lint-changed-test: + desc: Repro test for scripts/lint-changed.sh (findings printed, tool failures flagged) + silent: true + cmds: + - bash scripts/lint_changed_test.sh + lint-wip: desc: Fast lint and vet only packages touched in working tree and HEAD commit silent: true @@ -305,6 +311,11 @@ tasks: - bash scripts/gocache-trim.sh --if-low - go tool gotestsum --format pkgname -- -race -shuffle on -short ./... + routing-proof: + desc: Full (non-short) routing proof sweep, -race + cmds: + - go tool gotestsum --format pkgname -- -race -run 'Routing|GatesHold' -timeout 60m ./internal/app/ + leak-test: desc: Leak tests cmds: @@ -433,7 +444,7 @@ tasks: cfn-props-gen: desc: Regenerate cfn_property_schemas.json from the full CFN spec (SPEC=/path/to/CloudFormationResourceSpecification.json) cmds: - - go run ./cmd/cfnpropgen -spec {{.SPEC}} -types services/cloudformation/cfn_provisionable_types.json -out services/cloudformation/cfn_property_schemas.json + - go run ./cmd/cfnpropgen -spec {{.SPEC}} -types services/cloudformation/cfn_provisionable_types.json -out services/cloudformation/cfn_property_schemas.json -required-out services/cloudformation/cfn_required_properties.json cfn-attrs-spec-refresh: desc: Refresh the CloudFormation resource spec fixture (then run cfn-attrs-gen) diff --git a/bench/README.md b/bench/README.md index 2164cd1da3..cd9ab2eff5 100644 --- a/bench/README.md +++ b/bench/README.md @@ -50,6 +50,77 @@ BenchmarkSecretsManager_CreateSecret-4 50000 24856 ns/op 40 BenchmarkSecretsManager_GetSecretValue-4 200000 6131 ns/op 1664 B/op 28 allocs/op ``` +## End-to-end benchmarks (full router + middleware) + +`internal/app/e2e_bench_test.go`, `e2e_bench_sns_test.go`, `shared_path_server_bench_test.go` +and `bench_ddb_server_path_test.go` drive real `aws-sdk-go-v2` clients (S3, DynamoDB, SNS) +through the production Echo stack (routing, SigV4 scope parsing, telemetry, error mirroring, +cloudtrail capture, compression) over an in-memory `RoundTripper`. `leak_e2e_test.go` holds the +bounded-growth (goroutines/heap/series) leak tests for the same paths. + +```bash +go test -run '^$' -bench 'BenchmarkE2E_' -benchmem -cpu=4 ./internal/app/ +go test -run '^$' -bench 'BenchmarkServerPath' -benchmem -cpu=4 ./internal/app/ # server only, no SDK client +go test -run '^$' -bench 'BenchmarkE2E_S3/^head$' -cpuprofile cpu.prof -o app.test ./internal/app/ +go tool pprof -top -cum -focus='echo/v5.\(\*Echo\).ServeHTTP' app.test cpu.prof # server side only +``` + +Baseline (2026-10-30, `-cpu=4 -benchtime=300x`, shared 4-core box under load; ns/op is +indicative, allocs/op is exact): + +| Benchmark | ns/op | B/op | allocs/op | +|-----------|------:|-----:|----------:| +| E2E_S3/put_1KiB | 125 670 | 85 652 | 639 | +| E2E_S3/get_1KiB | 103 318 | 42 193 | 574 | +| E2E_S3/head | 90 622 | 41 348 | 531 | +| E2E_S3/list_prefix_delim_10k | 346 097 | 167 969 | 2 646 | +| E2E_S3/list_prefix_page (1000 keys) | 770 920 | 342 518 | 6 110 | +| E2E_S3/copy | 261 515 | 195 063 | 634 | +| E2E_S3/delete_objects_1000 | 3 671 798 | 1 335 628 | 22 649 | +| E2E_S3/multipart_3parts (5 MiB x2 + 1 KiB) | 39 916 568 | 11 834 908 | 3 368 | +| E2E_DynamoDB/putitem | 75 484 | 38 973 | 436 | +| E2E_DynamoDB/getitem | 75 851 | 36 873 | 417 | +| E2E_DynamoDB/updateitem_expr | 83 794 | 48 026 | 476 | +| E2E_DynamoDB/query_filter | 229 046 | 87 845 | 916 | +| E2E_DynamoDB/scan_filter_10k | 1 963 561 | 93 073 | 1 163 | +| E2E_DynamoDB/batchwrite_25 | 189 398 | 137 535 | 1 472 | +| E2E_DynamoDB/batchget_100 | 382 658 | 237 436 | 3 100 | +| E2E_DynamoDB/transactwrite_10 | 216 190 | 110 053 | 1 300 | +| ServerPath/ddb_getitem (server only) | ~24 000 | 9 106 | 96 | +| ServerPath/s3_getobject (server only) | ~35 000-47 000 | 9 180 | 123 | + +Profile findings (cpu, alloc_space, alloc_objects, server side of the SDK round trip): + +- The SDK client is about 70% of CPU and about 65% of allocations of an e2e small-object call + (SigV4 payload sha256, checksum middleware, XML/JSON (de)serialisation). Server-side gains + are therefore diluted roughly 3:1 in the e2e numbers; use `BenchmarkServerPath` to judge them. +- Large S3 payloads are dominated by hashing: server MD5 (ETag, unavoidable) and client sha256. + `multipart_3parts` is about 18 ms MD5 plus about 16 ms client sha256 for 10 MiB. +- Server middleware (logger, apptrace, telemetry, cloudtrail capture, region/principal, + chaos, compress) is flat: about 50 allocations per request, no single frame above 1.5% of + allocations or 3% of CPU. +- `Router.Lookup` is the largest single server frame: about 23% of server CPU for an S3 object + request, because S3 is the lowest-priority catch-all and about 47 ungated REST/host/SigV4 + matchers run first (DynamoDB is target-gated and runs 1). Each matcher is 100-200 ns; + there is no cheap hot spot, only gating each matcher with its own prefix gate + (`internal/routegates/path_gates.txt`), which needs a per-service proof. +- OR-alternative gates (`routegates.AltGates`, `service.AltGate`: target, path prefix/suffix, host, + user-agent, content-type, query, SigV4 scope) cut S3 object lookups from 38 to 23 matchers; + path-bucketed prefix lists cut per-candidate gate checks. `BenchmarkRouterLookupS3Object` + (min over runs): Lookup about 2.3-2.9 us to 1.56 us; `ServerPath/s3_getobject` 24.2 to 21.7 us + (-10%, median -13%, p=0.017), `s3_putobject` 37.7 to 35.5 us. Still ungated: APIGateway + (custom-domain state), OpenSearch (endpoint strategy), query-protocol services. +- `scan_filter_10k` is memory-latency bound in `expr.Evaluator` small-map lookups + (`item[attr]`, then `{"N": ...}` unwrap); no allocation to remove. +- `list_prefix_*`, `batchget_100`, `delete_objects_1000`: server frames are under 10% of CPU; + the allocations counted belong to the SDK response decoder. +- `ServerPathMixed` / `ServerPathMixedParallel` round-robin DynamoDB, STS, SNS, SQS, EC2, S3 and API Gateway requests + (serial and `b.RunParallel`). `TestLeakAbortedRequests` covers cancelled, half-read and half-sent requests. +- Sharing one form parse across partition scan and SNS/EC2 `Extract*` (`ReadBody`+`ParseFormBody`): -2 to -7 + allocs/op on query services, ns/op unchanged, B/op +3% (presized body buffer); reverted. +- Allocation-free apigateway custom-domain Host check was tried: -3 allocs/op on the server + path, not measurable in ns/op (below the 5% bar) and reverted. + ## Comparing with LocalStack To compare Gopherstack latency against [LocalStack](https://localstack.cloud/): diff --git a/cmd/cfnpropgen/main.go b/cmd/cfnpropgen/main.go index a68d2210c3..59e8584569 100644 --- a/cmd/cfnpropgen/main.go +++ b/cmd/cfnpropgen/main.go @@ -12,11 +12,13 @@ import ( "flag" "fmt" "os" + "slices" ) type propertySpec struct { PrimitiveType string `json:"PrimitiveType"` Type string `json:"Type"` + Required bool `json:"Required"` } type resourceTypeSpec struct { @@ -31,6 +33,7 @@ func main() { specPath := flag.String("spec", "", "full CloudFormation resource specification JSON") typesPath := flag.String("types", "", "JSON array of resource types to include") outPath := flag.String("out", "", "output schema JSON") + reqPath := flag.String("required-out", "", "output required-properties JSON (optional)") flag.Parse() if *specPath == "" || *typesPath == "" || *outPath == "" { @@ -38,7 +41,7 @@ func main() { os.Exit(1) } - if err := run(*specPath, *typesPath, *outPath); err != nil { + if err := run(*specPath, *typesPath, *outPath, *reqPath); err != nil { fmt.Fprintln(os.Stderr, "cfnpropgen:", err) os.Exit(1) } @@ -53,7 +56,7 @@ func readJSON(path string, v any) error { return json.Unmarshal(data, v) } -func run(specPath, typesPath, outPath string) error { +func run(specPath, typesPath, outPath, reqPath string) error { var spec resourceSpec if err := readJSON(specPath, &spec); err != nil { return fmt.Errorf("load spec: %w", err) @@ -69,7 +72,56 @@ func run(specPath, typesPath, outPath string) error { return fmt.Errorf("render: %w", err) } - return os.WriteFile(outPath, append(out, '\n'), 0o600) + if err = os.WriteFile(outPath, append(out, '\n'), 0o600); err != nil { + return err + } + + if reqPath == "" { + return nil + } + + req, err := json.MarshalIndent(buildRequired(&spec, types), "", " ") + if err != nil { + return fmt.Errorf("render required: %w", err) + } + + return os.WriteFile(reqPath, append(req, '\n'), 0o600) +} + +// registryRequired are required keys the registry schema enforces but the +// legacy spec leaves optional (metric-math alarms in the spec). +var registryRequired = map[string][]string{ //nolint:gochecknoglobals // static table + "AWS::CloudWatch::Alarm": {"ComparisonOperator", "EvaluationPeriods"}, +} + +// registryOptional are spec-required keys the registry schema does not require. +var registryOptional = map[string][]string{ //nolint:gochecknoglobals // static table + "AWS::SageMaker::Workteam": {"Description"}, +} + +// buildRequired lists each type's spec-required properties, sorted. +func buildRequired(spec *resourceSpec, types []string) map[string][]string { + out := map[string][]string{} + + for _, name := range types { + req := slices.Clone(registryRequired[name]) + + for prop, ps := range spec.ResourceTypes[name].Properties { + if ps.Required && !slices.Contains(registryOptional[name], prop) { + req = append(req, prop) + } + } + + if len(req) > 0 { + slices.Sort(req) + + req = slices.Compact(req) + + out[name] = req + } + } + + return out } // buildSchemas keeps a type only when the spec documents its Properties. diff --git a/cmd/cfnpropgen/main_test.go b/cmd/cfnpropgen/main_test.go index 647768347d..6b6ac64ed0 100644 --- a/cmd/cfnpropgen/main_test.go +++ b/cmd/cfnpropgen/main_test.go @@ -44,3 +44,26 @@ func TestBuildSchemas(t *testing.T) { got := buildSchemas(spec, []string{"AWS::A::B", "AWS::C::D", "AWS::E::F"}) assert.Equal(t, map[string]map[string]string{"AWS::A::B": {"N": "string"}}, got) } + +func TestBuildRequired(t *testing.T) { + t.Parallel() + + spec := &resourceSpec{ResourceTypes: map[string]resourceTypeSpec{ + "AWS::A::B": {Properties: map[string]propertySpec{ + "Z": {PrimitiveType: "String", Required: true}, + "A": {PrimitiveType: "String", Required: true}, + "O": {PrimitiveType: "String"}, + }}, + "AWS::SageMaker::Workteam": {Properties: map[string]propertySpec{"Description": {Required: true}}}, + "AWS::CloudWatch::Alarm": {Properties: map[string]propertySpec{"Namespace": {}}}, + "AWS::C::D": {Properties: map[string]propertySpec{"N": {}}}, + }} + + got := buildRequired(spec, []string{ + "AWS::A::B", "AWS::C::D", "AWS::SageMaker::Workteam", "AWS::CloudWatch::Alarm", "AWS::E::F", + }) + assert.Equal(t, map[string][]string{ + "AWS::A::B": {"A", "Z"}, + "AWS::CloudWatch::Alarm": {"ComparisonOperator", "EvaluationPeriods"}, + }, got) +} diff --git a/cmd/gendocs/categories.go b/cmd/gendocs/categories.go index ef92df7fc7..873a2f8d1b 100644 --- a/cmd/gendocs/categories.go +++ b/cmd/gendocs/categories.go @@ -23,6 +23,7 @@ const ( slugAzureQueue = "azurequeue" slugAzureFile = "azurefile" slugAzureMonitor = "azuremonitor" + slugAzureGraph = "azuregraph" slugAzureTable = "azuretable" slugAzureServiceBus = "azureservicebus" slugAzureEventHubs = "azureeventhubs" @@ -40,6 +41,18 @@ const ( slugOCIIdentity = "ociidentity" slugOCIFunctions = "ocifunctions" slugOCICore = "ocicore" + slugOCILogging = "ocilogging" + slugOCIMonitoring = "ocimonitoring" + slugOCIQueue = "ociqueue" + slugOCINotify = "ocinotifications" + slugOCIContainerEng = "ocicontainerengine" + slugOCIMySQL = "ocimysql" + slugOCINoSQL = "ocinosql" + slugOCIAPIGateway = "ociapigateway" + slugOCIEvents = "ocievents" + slugOCILoadBalancer = "ociloadbalancer" + slugOCIArtifacts = "ociartifacts" + slugOCIInstances = "ocicontainerinstances" slugGCPPubSub = "gcppubsub" slugGCPSecretMgr = "gcpsecretmanager" slugGCPKMS = "gcpkms" @@ -58,10 +71,16 @@ const ( slugGCPEventarc = "gcpeventarc" slugGCPWorkflows = "gcpworkflows" slugGCPDNS = "gcpdns" + slugGCPDatastore = "gcpdatastore" slugGCPCompute = "gcpcompute" + slugGCPContainer = "gcpcontainer" slugGCPCloudBuild = "gcpcloudbuild" slugGCPFirestore = "gcpfirestore" slugGCPIAM = "gcpiam" + slugGCPIdentity = "gcpidentitytoolkit" + slugGCPAPIGateway = "gcpapigateway" + slugGCPBatch = "gcpbatch" + slugGCPResourceMgr = "gcpresourcemanager" ) // categoryGroups is the curated slug -> category assignment for every @@ -75,6 +94,9 @@ const ( func ociSlugs() []string { return []string{ slugOCIObjectStore, slugOCIVault, slugOCIStreaming, slugOCIIdentity, slugOCIFunctions, slugOCICore, + slugOCIQueue, slugOCINotify, slugOCIContainerEng, slugOCIMySQL, slugOCILogging, slugOCIMonitoring, + slugOCINoSQL, slugOCIAPIGateway, slugOCIEvents, slugOCILoadBalancer, slugOCIArtifacts, + slugOCIInstances, } } @@ -153,6 +175,7 @@ func categoryGroups() []categoryGroup { slugAzureQueue, slugAzureFile, slugAzureMonitor, + slugAzureGraph, slugAzureTable, slugAzureServiceBus, slugAzureEventHubs, @@ -170,7 +193,8 @@ func categoryGroups() []categoryGroup { slugGCPFirestore, slugGCPIAM, slugGCPRun, slugGCPFunctions, slugGCPBigQuery, slugGCPSQL, slugGCPLogging, slugGCPMonitoring, slugGCPRedis, slugGCPSpanner, slugGCPArtifactReg, slugGCPCloudBuild, slugGCPBigtable, slugGCPEventarc, slugGCPWorkflows, slugGCPDNS, - slugGCPCompute, + slugGCPCompute, slugGCPDatastore, slugGCPContainer, + slugGCPIdentity, slugGCPAPIGateway, slugGCPBatch, slugGCPResourceMgr, }}, {"OCI", ociSlugs()}, {"Other", []string{ @@ -244,6 +268,7 @@ func displayNamesA() map[string]string { slugAzureQueue: "Azure Queue Storage", slugAzureFile: "Azure Files", slugAzureMonitor: "Azure Monitor Logs", + slugAzureGraph: "Microsoft Graph", slugAzureTable: "Azure Table Storage", slugAzureServiceBus: "Azure Service Bus", slugAzureEventHubs: "Azure Event Hubs", @@ -279,75 +304,93 @@ func displayNamesA() map[string]string { func displayNamesB() map[string]string { return map[string]string{ - "cognitoidp": "Cognito Identity Provider", - "comprehend": "Comprehend", - slugCosmosDB: "Azure Cosmos DB", - slugGCPStorage: "Google Cloud Storage", - slugOCIObjectStore: "OCI Object Storage", - slugOCIVault: "OCI Vault", - slugOCIStreaming: "OCI Streaming", - slugOCIIdentity: "OCI Identity", - slugOCIFunctions: "OCI Functions", - slugOCICore: "OCI Core Services", - slugGCPPubSub: "Google Cloud Pub/Sub", - slugGCPSecretMgr: "Google Secret Manager", - slugGCPKMS: "Google Cloud KMS", - slugGCPCloudTasks: "Cloud Tasks", - slugGCPScheduler: "Cloud Scheduler", - slugGCPRun: "Cloud Run", - slugGCPFunctions: "Cloud Functions", - slugGCPBigQuery: "BigQuery", - slugGCPSQL: "Cloud SQL", - slugGCPLogging: "Cloud Logging", - slugGCPMonitoring: "Cloud Monitoring", - slugGCPRedis: "Memorystore for Redis", - slugGCPArtifactReg: "Artifact Registry", - slugGCPSpanner: "Cloud Spanner", - slugGCPBigtable: "Cloud Bigtable", - slugGCPEventarc: "Eventarc", - slugGCPWorkflows: "Workflows", - slugGCPDNS: "Cloud DNS", - slugGCPCompute: "Compute Engine", - slugGCPCloudBuild: "Cloud Build", - slugGCPFirestore: "Google Cloud Firestore", - slugGCPIAM: "Cloud IAM", - "databrew": "Glue DataBrew", - "datasync": "DataSync", - "dax": "DAX", - "detective": "Detective", - "directoryservice": "Directory Service", - "dlm": "Data Lifecycle Manager", - "dms": "Database Migration Service", - "docdb": "DocumentDB", - "dynamodb": "DynamoDB", - "dynamodbstreams": "DynamoDB Streams", - "ec2": "EC2", - "ecr": "ECR", - "ecs": "ECS", - "efs": "EFS", - "eks": "EKS", - "elasticache": "ElastiCache", - "elasticbeanstalk": "Elastic Beanstalk", - "elasticsearch": "Elasticsearch", - "elb": "ELB (Classic)", - "elbv2": "ELBv2", - "emr": "EMR", - "emrserverless": "EMR Serverless", - "eventbridge": "EventBridge", - "firehose": "Kinesis Data Firehose", - "fis": "Fault Injection Simulator", - "forecast": "Forecast", - "fsx": "FSx", - "glacier": "S3 Glacier", - "glue": "Glue", - "guardduty": "GuardDuty", - "iam": "IAM", - "identitystore": "Identity Store", - "inspector2": "Inspector", - "iot": "IoT Core", - "iotanalytics": "IoT Analytics", - "iotdataplane": "IoT Data Plane", - "iotwireless": "IoT Wireless", + "cognitoidp": "Cognito Identity Provider", + "comprehend": "Comprehend", + slugCosmosDB: "Azure Cosmos DB", + slugGCPStorage: "Google Cloud Storage", + slugOCIObjectStore: "OCI Object Storage", + slugOCIVault: "OCI Vault", + slugOCIStreaming: "OCI Streaming", + slugOCIIdentity: "OCI Identity", + slugOCIFunctions: "OCI Functions", + slugOCICore: "OCI Core Services", + slugOCILogging: "OCI Logging", + slugOCIMonitoring: "OCI Monitoring", + slugOCIQueue: "OCI Queue", + slugOCINotify: "OCI Notifications", + slugOCIContainerEng: "OCI Container Engine for Kubernetes", + slugOCIMySQL: "OCI MySQL HeatWave Database Service", + slugOCINoSQL: "OCI NoSQL Database Cloud Service", + slugOCIAPIGateway: "OCI API Gateway", + slugOCIEvents: "OCI Events", + slugOCILoadBalancer: "OCI Load Balancer", + slugOCIArtifacts: "OCI Artifacts and Container Registry", + slugOCIInstances: "OCI Container Instances", + slugGCPPubSub: "Google Cloud Pub/Sub", + slugGCPSecretMgr: "Google Secret Manager", + slugGCPKMS: "Google Cloud KMS", + slugGCPCloudTasks: "Cloud Tasks", + slugGCPScheduler: "Cloud Scheduler", + slugGCPRun: "Cloud Run", + slugGCPFunctions: "Cloud Functions", + slugGCPBigQuery: "BigQuery", + slugGCPSQL: "Cloud SQL", + slugGCPLogging: "Cloud Logging", + slugGCPMonitoring: "Cloud Monitoring", + slugGCPRedis: "Memorystore for Redis", + slugGCPArtifactReg: "Artifact Registry", + slugGCPSpanner: "Cloud Spanner", + slugGCPBigtable: "Cloud Bigtable", + slugGCPEventarc: "Eventarc", + slugGCPWorkflows: "Workflows", + slugGCPDNS: "Cloud DNS", + slugGCPDatastore: "Cloud Datastore", + slugGCPCompute: "Compute Engine", + slugGCPContainer: "Kubernetes Engine", + slugGCPCloudBuild: "Cloud Build", + slugGCPFirestore: "Google Cloud Firestore", + slugGCPIAM: "Cloud IAM", + slugGCPIdentity: "Identity Platform", + slugGCPAPIGateway: "API Gateway", + slugGCPBatch: "Google Cloud Batch", + slugGCPResourceMgr: "Google Cloud Resource Manager and Service Usage", + "databrew": "Glue DataBrew", + "datasync": "DataSync", + "dax": "DAX", + "detective": "Detective", + "directoryservice": "Directory Service", + "dlm": "Data Lifecycle Manager", + "dms": "Database Migration Service", + "docdb": "DocumentDB", + "dynamodb": "DynamoDB", + "dynamodbstreams": "DynamoDB Streams", + "ec2": "EC2", + "ecr": "ECR", + "ecs": "ECS", + "efs": "EFS", + "eks": "EKS", + "elasticache": "ElastiCache", + "elasticbeanstalk": "Elastic Beanstalk", + "elasticsearch": "Elasticsearch", + "elb": "ELB (Classic)", + "elbv2": "ELBv2", + "emr": "EMR", + "emrserverless": "EMR Serverless", + "eventbridge": "EventBridge", + "firehose": "Kinesis Data Firehose", + "fis": "Fault Injection Simulator", + "forecast": "Forecast", + "fsx": "FSx", + "glacier": "S3 Glacier", + "glue": "Glue", + "guardduty": "GuardDuty", + "iam": "IAM", + "identitystore": "Identity Store", + "inspector2": "Inspector", + "iot": "IoT Core", + "iotanalytics": "IoT Analytics", + "iotdataplane": "IoT Data Plane", + "iotwireless": "IoT Wireless", } } diff --git a/cmd/routingcorpus/altgates.go b/cmd/routingcorpus/altgates.go new file mode 100644 index 0000000000..2ea2d46811 --- /dev/null +++ b/cmd/routingcorpus/altgates.go @@ -0,0 +1,182 @@ +package main + +import "strings" + +const ( + noteAlt = "special:altgate" + formCT = "application/x-www-form-urlencoded" + lambdaURLID = "abcdef0123456789abcdef0123456789" + scopeR53RC = "route53-recovery-cluster" + r53rcRootPath = "/route53-recovery-cluster/abc/us-east-1/" +) + +type altAdder func(method, h, uri, auth, target, headers string) + +func presignedURI(uri, scope string) string { + sep := "?" + if strings.Contains(uri, "?") { + sep = "&" + } + + return uri + sep + "X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F" + scope + "%2Faws4_request&X-Amz-Signature=00" +} + +// addAltGateRows exercises each OR-gate alternative alone, absent, and via presigned query auth. +func (c *collector) addAltGateRows() { + add := func(method, h, uri, auth, target, headers string) { + c.add(row{method, h, uri, auth, json11, target, "{}", headers, noteAlt}) + } + + c.addScopeRows(add) + c.addTargetRows(add) + c.addUserAgentRows(add) + c.addCognitoRows(add) + c.addLambdaRows(add) + c.addPathRows(add) + c.addFormRows() + c.addQueryRows(add) +} + +func (c *collector) addScopeRows(add altAdder) { + scopes := []string{"memorydb", scopeR53RC, "cognito-idp", "lambda", "s3", ""} + + for _, m := range []string{methodGet, methodPost} { + for _, scope := range scopes { + if m != methodPost && scope == scopeR53RC { + continue + } + + for _, uri := range []string{"/", r53rcRootPath, "/x"} { + add(m, host, uri, scope, "", "") + add(m, host, presignedURI(uri, scope), "", "", "") + } + } + } +} + +func (c *collector) addTargetRows(add altAdder) { + targets := []string{ + "AmazonMemoryDB.DescribeClusters", "ToggleCustomerAPI.GetRoutingControlState", "AWSLambda.ListFunctions", + "AWSCognitoIdentityProviderService.InitiateAuth", "AWSLambda20150331.ListFunctions", "AmazonMemoryDB", + "AmazonEventBridge.ListRules", "AWSEvents.ListRules", "AWSSchemas.ListRegistries", "ResourceGroups.ListGroups", + "AmazonPersonalize.ListDatasets", "AmazonPersonalizeRuntime.GetRecommendations", "AmazonSSM.ListDocuments", + "EC2WindowsMessageDeliveryService.Poll", "AmazonEC2ContainerRegistry_V20150921.DescribeRepositories", + "AWSScheduler.ListSchedules", "AmazonSQS.ListQueues", + } + + for _, t := range targets { + add(methodPost, host, "/", "", t, "") + add(methodPost, host, "/", "s3", t, "") + add(methodPost, host, "/q/000000000000/name", "sqs", t, "") + } +} + +func (c *collector) addUserAgentRows(add altAdder) { + agents := []string{ + "User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0", "User-Agent:MediaStoreData", + "User-Agent:x MEDIASTORE-DATA y", "User-Agent:aws-sdk-go-v2 api/mediastore#1", + "X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data", "X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData", + "User-Agent:İmediastoredata", + } + + for _, ua := range agents { + for _, uri := range []string{"/", "/obj", "/a/b?x=1"} { + for _, m := range []string{methodGet, methodPut, methodDelete, methodHead} { + add(m, host, uri, "mediastore", "", ua) + add(m, host, uri, "", "", ua) + } + } + } +} + +func (c *collector) addCognitoRows(add altAdder) { + paths := []string{ + "/oauth2/token", "/oauth2/authorize?client_id=c", "/oauth2/userInfo", "/oauth2/revoke", "/oauth2/idpresponse", + "/login?client_id=c", "/logout?client_id=c", "/saml2/idpresponse", "/saml2/logout", "/saml2/metadata", + "/us-east-1_abc/saml2/metadata", "/us-east-1_abc/.well-known/jwks.json", + "/us-east-1_abc/.well-known/openid-configuration", "/.well-known/jwks.json", + "/.well-known/openid-configuration", "//oauth2/token", "/a/b/.well-known/jwks.json", + } + + for _, uri := range paths { + for _, m := range []string{methodGet, methodPost, methodOptions} { + add(m, host, uri, "", "", "") + add(m, host, uri, "cognito-idp", "", "") + add(m, "auth.localhost:4566", uri, "", "", "") + } + } +} + +func (c *collector) addLambdaRows(add altAdder) { + hosts := []string{ + lambdaURLID + ".lambda-url.us-east-1.on.aws", lambdaURLID + ".lambda-url.eu-west-1.localhost:4566", + strings.ToUpper(lambdaURLID) + ".LAMBDA-URL.us-east-1.ON.AWS", lambdaURLID + ".lambda-url.us-east-1.on.aws:443", + lambdaURLID + ".lambda-url.us-east-1.example.com", "lambda-url.us-east-1.on.aws", "x.lambda-url..on.aws", + } + + for _, h := range hosts { + for _, uri := range []string{"/", "/a/b?x=1", lambdaFunctionsPath} { + add(methodGet, h, uri, "", "", "") + add(methodPost, h, uri, "s3", "", "") + } + } + + paths := []string{ + lambdaFunctionsPath, "/2016-08-19/account-settings", "/2025-12-01/durable-executions", + "/2026-07-09/resource-policy/arn", "/2020-04-22/code-signing-configs", "/2018-10-31/layers", + "/2017-03-31/tags/arn", "/2099-01-01/functions", "/2015-03-31/event-source-mappings", + } + + for _, uri := range paths { + add(methodGet, host, uri, "", "", "") + add(methodGet, host, uri, "lambda", "", "") + add(methodGet, host, uri, "s3", "", "") + } +} + +func (c *collector) addPathRows(add altAdder) { + paths := []string{ + "/v1/registries", "/v1/discover", "//v1/policy", "/v1/discoverers/id/d1/start", "/v1/registries/name/r/schemas", + "/groups", "/get-group", "/update-group-query", "/list-group-resources", "/resources/search", + "/resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags", "/ungroup-resources", + "/recommendations", "/personalize-ranking", "/action-recommendations", "/ssmmessages/v1/data-channel/x", + "/v2/", "/v2", "/v2/_catalog", "/v2/repo/manifests/latest", "/v2/repo/blobs/sha256:x", "/v2/repo/tags/list", + "/schedules", "/schedules/x", "/schedule-groups/g", + "/tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx", + "/tags/arn:aws:sns:us-east-1:000000000000:t", "/SimpleNotificationService-abc.pem", + } + + for _, uri := range paths { + for _, m := range []string{methodGet, methodPost, methodPut, methodDelete} { + add(m, host, uri, "", "", "") + add(m, host, uri, "s3", "", "") + } + } +} + +func (c *collector) addFormRows() { + bodies := []string{ + "Action=ListQueues&Version=2012-11-05", "Action=GetCallerIdentity&Version=2011-06-15", + "Action=ListTopics&Version=2010-03-31", "Action=Bogus", "", + } + + for _, q := range bodies { + for _, ct := range []string{formCT, "APPLICATION/X-WWW-FORM-URLENCODED", "application/json", ""} { + for _, auth := range []string{"sts", "sns", "sqs", ""} { + c.add(row{methodPost, host, "/", auth, ct, "", q, "", noteAlt}) + } + } + } +} + +func (c *collector) addQueryRows(add altAdder) { + queries := []string{ + "?Action=ConfirmSubscription&TopicArn=arn%3Aaws%3Asns%3Aus-east-1%3A000000000000%3At&Token=x", + "?Act%69on=ConfirmSubscription&TopicArn=x", "?Action=ListTopics", "?x=1", "?X-Amz-Credential=a%2Fb", + } + + for _, q := range queries { + add(methodGet, host, "/"+q, "", "", "") + add(methodGet, host, "/bucket/key"+q, "s3", "", "") + } +} diff --git a/cmd/routingcorpus/expand.go b/cmd/routingcorpus/expand.go index 046077f58e..3ead047a31 100644 --- a/cmd/routingcorpus/expand.go +++ b/cmd/routingcorpus/expand.go @@ -2,7 +2,10 @@ package main import ( "fmt" + "sort" "strings" + + "github.com/blackbirdworks/gopherstack/internal/routegates" ) const ( @@ -64,6 +67,8 @@ func expandRows(sdk []row) []row { c.addSpecials() c.addAppStream(groups["appstream"]) + c.addS3Lookalikes() + c.addAltGateRows() return c.out } @@ -141,30 +146,54 @@ func (c *collector) addS3Hosts(rows []row) { func (c *collector) addSpecials() { specials := [][3]string{ - {methodGet, host, "/"}, {methodGet, host, "/health"}, {methodGet, host, "/_localstack/health"}, - {methodGet, host, "/_localstack/info"}, {methodGet, host, "/_gopherstack/health"}, + {methodGet, host, "/"}, + {methodGet, host, "/health"}, + {methodGet, host, "/_localstack/health"}, + {methodGet, host, "/_localstack/info"}, + {methodGet, host, "/_gopherstack/health"}, { methodGet, host, "/_gopherstack/chaos/faults", - }, {methodGet, host, "/dashboard"}, {methodGet, host, "/dashboard/"}, - {methodGet, host, "/dashboard/s3"}, {methodGet, host, "/dashboard/api/v1/x"}, {methodGet, host, "/metrics"}, - {methodGet, host, "/favicon.ico"}, {methodGet, host, "/robots.txt"}, {methodGet, host, "/nonexistent/path"}, - {methodGet, host, "/mybucket"}, {methodGet, host, keyPath}, {"PUT", host, "/mybucket"}, - {"HEAD", host, keyPath}, {"OPTIONS", host, keyPath}, {"OPTIONS", host, "/"}, - {methodPost, host, "/"}, {methodGet, host, "/_aws/ses"}, {methodGet, host, "/_aws/sqs/messages"}, - {"DELETE", host, "/_aws/ses"}, {methodGet, host, "/_aws/cloudwatch/metrics/raw"}, + }, + {methodGet, host, "/dashboard"}, + {methodGet, host, "/dashboard/"}, + {methodGet, host, "/dashboard/s3"}, + {methodGet, host, "/dashboard/api/v1/x"}, + {methodGet, host, "/metrics"}, + {methodGet, host, "/favicon.ico"}, + {methodGet, host, "/robots.txt"}, + {methodGet, host, "/nonexistent/path"}, + {methodGet, host, "/mybucket"}, + {methodGet, host, keyPath}, + {methodPut, host, "/mybucket"}, + {methodHead, host, keyPath}, + {methodOptions, host, keyPath}, + {methodOptions, host, "/"}, + {methodPost, host, "/"}, + {methodGet, host, "/_aws/ses"}, + {methodGet, host, "/_aws/sqs/messages"}, + {methodDelete, host, "/_aws/ses"}, + {methodGet, host, "/_aws/cloudwatch/metrics/raw"}, {methodPost, host, "/_localstack/state/reset"}, {methodGet, host, "/_aws/sqs/messages/us-east-1/000000000000/q"}, - {methodGet, host, "/swagger"}, {methodGet, host, "/tags/arn%3Aaws%3Aservice%3Aus-east-1%3A000000000000%3Ax"}, - {methodGet, host, "/resourcepolicy/x"}, {methodGet, host, "/flows"}, {methodGet, host, "/agents"}, - {methodGet, host, "/prompts"}, {methodGet, host, "/2015-03-31/functions/"}, {methodGet, host, "/restapis"}, + {methodGet, host, "/swagger"}, + {methodGet, host, "/tags/arn%3Aaws%3Aservice%3Aus-east-1%3A000000000000%3Ax"}, + {methodGet, host, "/resourcepolicy/x"}, + {methodGet, host, "/flows"}, + {methodGet, host, "/agents"}, + {methodGet, host, "/prompts"}, + {methodGet, host, lambdaFunctionsPath}, + {methodGet, host, "/restapis"}, { methodGet, host, "/v2/apis", - }, {methodGet, host, "/2013-04-01/hostedzone"}, {methodGet, "s3.localhost:4566", "/"}, - {methodGet, "s3.us-east-1.amazonaws.com", "/"}, {methodGet, "mybucket.s3.amazonaws.com", "/key"}, + }, + {methodGet, host, "/2013-04-01/hostedzone"}, + {methodGet, "s3.localhost:4566", "/"}, + {methodGet, "s3.us-east-1.amazonaws.com", "/"}, + {methodGet, "mybucket.s3.amazonaws.com", "/key"}, {methodGet, host, "/mybucket?versioning"}, } @@ -211,3 +240,47 @@ func (c *collector) addAppStream(rows []row) { }) } } + +const noteS3Lookalike = "special:s3lookalike" + +// lookalikeBuckets returns bucket names that collide with other services' first path segments. +func lookalikeBuckets() []string { + return []string{ + "tags", "2015-03-31", "restapis", "functions", "model", "things", "policies", "proxy", "api", "_admin", + "apikeys", "domainnames", "usageplans", "clientcertificates", "vpclinks", "sdktypes", "rules", "cluster", + "controlpanel", "routingcontrol", "safetyrule", "zonalshifts", "cells", "identities", "invocations", "mcp", + "memories", "runtimes", "target-policies", "layers", "event-source-mappings", "2018-10-31", "2020-04-22", + } +} + +// addS3Lookalikes adds S3 object requests whose bucket name equals another service's path prefix. +func (c *collector) addS3Lookalikes() { + names := map[string]bool{} + + for _, b := range lookalikeBuckets() { + names[b] = true + } + + for _, prefixes := range routegates.PathGates() { + for _, p := range prefixes { + if seg := strings.Trim(p, "/"); seg != "" && !strings.Contains(seg, "/") { + names[seg] = true + } + } + } + + sorted := make([]string, 0, len(names)) + for n := range names { + sorted = append(sorted, n) + } + + sort.Strings(sorted) + + for _, b := range sorted { + obj := "/" + b + "/key.txt" + c.add(row{methodGet, host, obj, "s3", "", "", "", "", noteS3Lookalike}) + c.add(row{methodPut, host, obj, "s3", "application/octet-stream", "", "body", "", noteS3Lookalike}) + c.add(row{methodGet, host, obj, "", "", "", "", fmt.Sprintf(uaFmt, "s3"), noteS3Lookalike}) + c.add(row{methodHead, host, "/" + b, "s3", "", "", "", "", noteS3Lookalike}) + } +} diff --git a/cmd/routingcorpus/sdk.go b/cmd/routingcorpus/sdk.go index df6fdec437..1d93f3dc53 100644 --- a/cmd/routingcorpus/sdk.go +++ b/cmd/routingcorpus/sdk.go @@ -7,19 +7,24 @@ import ( ) const ( - corpusFields = 10 - requestFields = 8 - host = "localhost:4566" - uaFmt = "User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/%s#1.0.0" - cborHeader = "Smithy-Protocol:rpc-v2-cbor" - maxPlaceChars = 6 - methodGet = "GET" - methodPost = "POST" - json10 = "application/x-amz-json-1.0" - json11 = "application/x-amz-json-1.1" - scopeExecuteAPI = "execute-api" - keyPath = "/mybucket/key.txt" - bucketSegs = 2 + corpusFields = 10 + requestFields = 8 + host = "localhost:4566" + uaFmt = "User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/%s#1.0.0" + cborHeader = "Smithy-Protocol:rpc-v2-cbor" + maxPlaceChars = 6 + methodGet = "GET" + methodPost = "POST" + methodPut = "PUT" + methodHead = "HEAD" + methodDelete = "DELETE" + methodOptions = "OPTIONS" + json10 = "application/x-amz-json-1.0" + json11 = "application/x-amz-json-1.1" + scopeExecuteAPI = "execute-api" + keyPath = "/mybucket/key.txt" + lambdaFunctionsPath = "/2015-03-31/functions/" + bucketSegs = 2 ) // row is one corpus request; note tags its origin and is not written out. diff --git a/dashboard/azureaci_proxy.go b/dashboard/azureaci_proxy.go index 1045167a07..fd3c71364b 100644 --- a/dashboard/azureaci_proxy.go +++ b/dashboard/azureaci_proxy.go @@ -53,7 +53,7 @@ func (h *DashboardHandler) registerAzureACIRoutes() { } q := url.Values{} - for _, k := range []string{"api-version", "tail"} { + for _, k := range []string{armQueryVersion, "tail"} { if v := r.URL.Query().Get(k); v != "" { q.Set(k, v) } diff --git a/dashboard/azureaks_proxy.go b/dashboard/azureaks_proxy.go new file mode 100644 index 0000000000..e5ea9d152e --- /dev/null +++ b/dashboard/azureaks_proxy.go @@ -0,0 +1,59 @@ +package dashboard + +import ( + "net/http" + "slices" +) + +const ( + azureAKSPrefix = "/dashboard/api/v1/azure/aks" + aksPxNamespace = "microsoft.containerservice" + aksPxClusters = "managedclusters" + aksPxPools = "agentpools" + aksPxUpgrades = "upgradeprofiles" + aksPxActionOnly = 1 + aksPxChildItem = 2 + aksPxUpgradeName = "default" +) + +var aksPxPowerActions = []string{aciPxStart, aciPxStop} //nolint:gochecknoglobals // lookup table + +// registerAzureAKSRoutes exposes managed clusters and their agent pools by dispatching in-process to the ARM +// handler. Only reads, start/stop, agent pool PUT (scaling) and cluster deletion are reachable; credential, +// run-command and maintenance actions are not, and profile secrets are scrubbed from every response. +func (h *DashboardHandler) registerAzureAKSRoutes() { + h.registerARMProxy(armProxy{ + name: "Azure Kubernetes Service", prefix: azureAKSPrefix, + queryKeys: []string{armQueryVersion}, + methods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + allowed: aksProxyAllowed, + }) +} + +func aksProxyAllowed(method string, p []string) bool { + typ, name, rest, ok := armScope(p, aksPxNamespace) + if !ok || typ != aksPxClusters { + return false + } + + if name == "" { + return method == http.MethodGet + } + + switch len(rest) { + case 0: + return method == http.MethodGet || method == http.MethodDelete + case aksPxActionOnly: + return (method == http.MethodGet && rest[0] == aksPxPools) || + (method == http.MethodPost && slices.Contains(aksPxPowerActions, rest[0])) + case aksPxChildItem: + switch method { + case http.MethodGet: + return rest[0] == aksPxPools || (rest[0] == aksPxUpgrades && rest[1] == aksPxUpgradeName) + case http.MethodPut: + return rest[0] == aksPxPools + } + } + + return false +} diff --git a/dashboard/azureaks_proxy_test.go b/dashboard/azureaks_proxy_test.go new file mode 100644 index 0000000000..19ad9faac0 --- /dev/null +++ b/dashboard/azureaks_proxy_test.go @@ -0,0 +1,145 @@ +package dashboard_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" +) + +const ( + aksAPI = "?api-version=2025-05-01" + aksProv = armBase + "Microsoft.ContainerService" + aksCl = aksProv + "/managedClusters/prod" + aksProxy = "/dashboard/api/v1/azure/aks" +) + +func newAKSDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + return newARMDashboard(t, []struct{ path, body string }{ + {"/subscriptions/" + armSub + "/resourceGroups/demo?api-version=2021-04-01", `{"location":"eastus"}`}, + {aksCl + aksAPI, `{"location":"eastus","identity":{"type":"SystemAssigned"},"properties":{` + + `"dnsPrefix":"prod","kubernetesVersion":"1.35","servicePrincipalProfile":{"clientId":"cid","secret":"sp-s3cret"},` + + `"windowsProfile":{"adminUsername":"azureuser","adminPassword":"win-p4ssw0rd"},` + + `"agentPoolProfiles":[{"name":"system","mode":"System","vmSize":"Standard_D4s_v3","count":2,` + + `"osType":"Linux","availabilityZones":["1","2"]}]}}`}, + }) +} + +func TestAzureAKSProxy(t *testing.T) { + t.Parallel() + + list := aksProxy + "/subscriptions/" + armSub + "/providers/Microsoft.ContainerService/managedClusters" + px := aksProxy + aksCl + + tests := []struct { + name string + steps []gcpStep + }{ + {"read", []gcpStep{ + {http.MethodGet, list + aksAPI, "", `"name":"prod"`, 200}, + {http.MethodGet, px + aksAPI, "", `"powerState":{"code":"Running"}`, 200}, + {http.MethodGet, px + "/agentPools" + aksAPI, "", `"name":"system"`, 200}, + {http.MethodGet, px + "/agentPools/system" + aksAPI, "", `"count":2`, 200}, + {http.MethodGet, px + "/upgradeProfiles/default" + aksAPI, "", `"controlPlaneProfile"`, 200}, + }}, + {"power", []gcpStep{ + {http.MethodPost, px + "/stop" + aksAPI, "", "", http.StatusAccepted}, + {http.MethodGet, px + aksAPI, "", `"powerState":{"code":"Stopped"}`, 200}, + {http.MethodPost, px + "/start" + aksAPI, "", "", http.StatusAccepted}, + {http.MethodGet, px + aksAPI, "", `"powerState":{"code":"Running"}`, 200}, + }}, + {"scale", []gcpStep{ + {http.MethodPut, px + "/agentPools/system" + aksAPI, + `{"properties":{"mode":"System","vmSize":"Standard_D4s_v3","count":4,"osType":"Linux",` + + `"availabilityZones":["1","2"]}}`, `"count":4`, 200}, + {http.MethodGet, px + "/agentPools/system" + aksAPI, "", `"count":4`, 200}, + }}, + {"delete", []gcpStep{ + {http.MethodDelete, px + aksAPI, "", "", http.StatusAccepted}, + {http.MethodGet, px + aksAPI, "", "ResourceNotFound", 404}, + }}, + { + "rejects", + []gcpStep{ + {http.MethodPost, px + "/listClusterAdminCredential" + aksAPI, "", "unsupported", 400}, + {http.MethodPost, px + "/listClusterUserCredential" + aksAPI, "", "unsupported", 400}, + {http.MethodPost, px + "/runCommand" + aksAPI, `{}`, "unsupported", 400}, + {http.MethodPost, px + "/agentPools/system/upgradeNodeImageVersion" + aksAPI, "", "unsupported", 400}, + {http.MethodDelete, px + "/agentPools/system" + aksAPI, "", "unsupported", 400}, + {http.MethodPut, px + aksAPI, `{}`, "unsupported", 400}, + {http.MethodPut, list + aksAPI, `{}`, "unsupported", 400}, + {http.MethodDelete, list + aksAPI, "", "unsupported", 400}, + {http.MethodGet, px + "/accessProfiles/clusterAdmin" + aksAPI, "", "unsupported", 400}, + {http.MethodGet, px + "/agentPools/../x" + aksAPI, "", "unsupported", 400}, + { + http.MethodGet, + aksProxy + "/subscriptions/" + armSub + "/providers/Microsoft.Compute/virtualMachines" + aksAPI, + "", + "unsupported", + 400, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, newAKSDashboard(t), tt.steps) + }) + } + + t.Run("credentials stay server side", func(t *testing.T) { + t.Parallel() + + h := newAKSDashboard(t) + for _, p := range []string{px + aksAPI, list + aksAPI, px + "/agentPools" + aksAPI} { + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext(httptest.NewRequest(http.MethodGet, p, nil), rec))) + + for _, secret := range []string{"sp-s3cret", "win-p4ssw0rd", "kubeconfig", "certificate-authority-data"} { + assert.NotContains(t, rec.Body.String(), secret, p) + } + } + }) + + t.Run("scale round trips the pool as read", func(t *testing.T) { + t.Parallel() + + h := newAKSDashboard(t) + pool := px + "/agentPools/system" + aksAPI + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext(httptest.NewRequest(http.MethodGet, pool, nil), rec))) + + var doc map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &doc)) + + props, ok := doc["properties"].(map[string]any) + require.True(t, ok) + + props["count"] = 5 + body, err := json.Marshal(map[string]any{"properties": props}) + require.NoError(t, err) + + runGCPSteps(t, h, []gcpStep{ + {http.MethodPut, pool, string(body), `"count":5`, 200}, + }) + }) + + t.Run("unserved", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, list+aksAPI, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/azureapim_proxy.go b/dashboard/azureapim_proxy.go new file mode 100644 index 0000000000..e2cd376069 --- /dev/null +++ b/dashboard/azureapim_proxy.go @@ -0,0 +1,373 @@ +package dashboard + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + azureAPIMPrefix = "/dashboard/api/v1/azure/apim" + apimPxNamespace = "microsoft.apimanagement" + apimPxService = "service" + apimPxVersion = "2024-05-01" + apimPxGateway = "/_apim/" + apimPxInvoke = "invoke" + apimPxInvokeSegs = 9 + apimPxNameIdx = 7 + apimPxVerbIdx = 8 + apimPxPolicy = "policy" + apimPxPolicies = "policies" + apimPxAPIs = "apis" + apimPxActive = "active" + apimPxKeyHeader = "Ocp-Apim-Subscription-Key" + apimPxKeyQuery = "subscription-key" + apimPxPrimaryKey = "primaryKey" + apimPxSecret = "secret" + apimPxMaxBody = ociAPIGWMaxTryJSON + 1<<12 +) + +//nolint:gochecknoglobals // immutable allow-lists +var ( + apimPxShapes = []string{ + "apis", "apis/*", "apis/*/operations", "apis/*/operations/*", "apis/*/policies", "apis/*/policies/policy", + "apis/*/operations/*/policies/policy", "products", "products/*", "products/*/apis", armPxSubsSeg, + armPxSubsSeg + "/*", "namedvalues", "namedvalues/*", "policies/policy", + } + apimPxNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,89}$`) + apimPxQueries = []string{armQueryVersion, "format", "$top", "$skip"} +) + +// registerAzureAPIMRoutes serves the API Management page by dispatching in-process to the ARM handler. Only reads of +// services, APIs, operations, products, subscriptions, named values and policies are reachable (never listSecrets), +// and a "try it" call replays one request through the gateway of a selected service with the chosen subscription's +// key injected server-side; keys and secret named-value values never reach the browser. +func (h *DashboardHandler) registerAzureAPIMRoutes() { + h.registerARMProxy(armProxy{ + name: "API Management", prefix: azureAPIMPrefix, + queryKeys: apimPxQueries, + methods: []string{http.MethodGet}, + allowed: apimProxyAllowed, + view: apimProxyView, + }) + + var inner *echo.Echo + + if h.config.AzureARMOps != nil { + inner = echo.New() + inner.Any("/*", h.config.AzureARMOps.Handler()) + } + + h.SubRouter.Add(http.MethodPost, azureAPIMPrefix+"/*", func(c *echo.Context) error { + if inner == nil { + return azureACIFail( + c, http.StatusServiceUnavailable, errServiceUnavailable, "API Management is not enabled", + ) + } + + return (&apimTry{c: c, inner: inner}).serve() + }) +} + +func apimProxyAllowed(method string, p []string) bool { + if method != http.MethodGet { + return false + } + + typ, name, rest, ok := armScope(p, apimPxNamespace) + if !ok || typ != apimPxService { + return false + } + + if name == "" || len(rest) == 0 { + return true + } + + return apimProxyChild(rest) +} + +// apimProxyChild reports whether the segments below a service form one of the readable shapes; resource ids are +// wildcards, except the fixed policy id. +func apimProxyChild(rest []string) bool { + shape := make([]string, len(rest)) + + for i, seg := range rest { + shape[i] = seg + if i%2 == 1 && rest[i-1] != apimPxPolicies { + shape[i] = "*" + } + } + + return slices.Contains(apimPxShapes, strings.Join(shape, "/")) +} + +// apimProxyView masks named-value secrets before the generic scrub drops the secret flag. +func apimProxyView(doc any) { + switch t := doc.(type) { + case []any: + for _, x := range t { + apimProxyView(x) + } + case map[string]any: + if props, ok := t["properties"].(map[string]any); ok { + if sec, _ := props[apimPxSecret].(bool); sec { + props["isSecret"] = true + + delete(props, "value") + } + } + + for _, x := range t { + if _, isMap := x.(map[string]any); isMap { + apimProxyView(x) + } else if _, isList := x.([]any); isList { + apimProxyView(x) + } + } + } +} + +type apimTryIn struct { + Subscription string `json:"subscription"` + API string `json:"api"` + ociAPIGWTryIn +} + +type apimTry struct { + c *echo.Context + inner *echo.Echo +} + +func (t *apimTry) fail(status int, code, msg string) error { + return azureACIFail(t.c, status, code, msg) +} + +func (t *apimTry) serve() error { + parts, ok := apimTryParts(t.c.Request().URL.EscapedPath()) + if !ok { + return t.fail(http.StatusBadRequest, "InvalidRequestUri", "unsupported API Management proxy request") + } + + in, rel, ok := t.decode() + if !ok { + return t.fail(http.StatusBadRequest, "InvalidRequestContent", "invalid API Management try-it request") + } + + svc := "/" + strings.Join(parts[:apimPxVerbIdx], "/") + ctx := t.c.Request().Context() + + if rec := t.arm(ctx, http.MethodGet, svc); rec.Code != http.StatusOK { + return t.relayError(rec) + } + + key, status, msg := t.subscriptionKey(ctx, svc, in.Subscription) + if status != 0 { + return t.fail(status, "SubscriptionUnusable", msg) + } + + return t.replay(ctx, replayArgs{ + name: parts[apimPxNameIdx], rel: rel, in: in, key: key, api: t.apiInfo(ctx, svc, in.API), + }) +} + +// apimTryParts validates the escaped path below the proxy prefix: .../service/{name}/invoke. +func apimTryParts(escaped string) ([]string, bool) { + parts := strings.Split(strings.TrimPrefix(escaped, azureAPIMPrefix+"/"), "/") + if len(parts) != apimPxInvokeSegs { + return nil, false + } + + for i, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || (i%2 == 1 && !apimPxNameRE.MatchString(dec)) { + return nil, false + } + } + + return parts, apimTryShape(strings.Split(strings.ToLower(strings.Join(parts, "/")), "/")) +} + +// decode reads the try-it body and returns it with the validated "/path?query" relative to the gateway. +func (t *apimTry) decode() (apimTryIn, string, bool) { + var in apimTryIn + if !ociDecodeBodyN(t.c, &in, apimPxMaxBody) { + return in, "", false + } + + rel, ok := ociAPIGWTryPath(in.ociAPIGWTryIn) + ok = ok && ociAPIGWMethodOK(in.Method) && len(in.Headers) <= ociAPIGWMaxHeaders && + len(in.Body) <= ociAPIGWMaxReqBody && + (in.Subscription == "" || apimPxNameRE.MatchString(in.Subscription)) && + (in.API == "" || apimPxNameRE.MatchString(in.API)) + + return in, rel, ok +} + +func apimTryShape(low []string) bool { + return low[0] == armPxSubsSeg && low[2] == armPxRGsSeg && low[4] == armPxProvSeg && low[5] == apimPxNamespace && + low[6] == apimPxService && low[apimPxVerbIdx] == apimPxInvoke +} + +func (t *apimTry) relayError(rec *httptest.ResponseRecorder) error { + t.c.Response().Header().Set("Content-Type", "application/json") + + return t.c.JSONBlob(rec.Code, rec.Body.Bytes()) +} + +// arm performs one ARM call against the in-process handler with no caller-supplied bytes. +func (t *apimTry) arm(ctx context.Context, method, path string) *httptest.ResponseRecorder { + req := httptest.NewRequestWithContext(ctx, method, path+"?"+armQueryVersion+"="+apimPxVersion, + bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", echo.MIMEApplicationJSON) + + rec := httptest.NewRecorder() + t.inner.ServeHTTP(rec, req) + + return rec +} + +// subscriptionKey resolves the primary key of an active subscription; empty with status 0 when none was selected. +func (t *apimTry) subscriptionKey(ctx context.Context, svc, sub string) (string, int, string) { + if sub == "" { + return "", 0, "" + } + + base := svc + "/subscriptions/" + url.PathEscape(sub) + + rec := t.arm(ctx, http.MethodGet, base) + if rec.Code != http.StatusOK { + return "", http.StatusNotFound, "Subscription not found" + } + + var doc struct { + Properties map[string]any `json:"properties"` + } + + if json.Unmarshal(rec.Body.Bytes(), &doc) != nil || doc.Properties["state"] != apimPxActive { + return "", http.StatusConflict, "Subscription is not active" + } + + rec = t.arm(ctx, http.MethodPost, base+"/listSecrets") + + var keys map[string]any + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &keys) != nil { + return "", http.StatusConflict, "Subscription key is unavailable" + } + + key, _ := keys[apimPxPrimaryKey].(string) + if key == "" { + return "", http.StatusConflict, "Subscription key is unavailable" + } + + return key, 0, "" +} + +// apimAPIInfo is what the replay needs from the selected API: its key parameter names and whether it is served over +// TLS (an API limited to http is replayed without TLS, as the gateway matches the protocol). +type apimAPIInfo struct { + header string + query string + tls bool +} + +func (t *apimTry) apiInfo(ctx context.Context, svc, api string) apimAPIInfo { + def := apimAPIInfo{header: apimPxKeyHeader, query: apimPxKeyQuery, tls: true} + if api == "" { + return def + } + + rec := t.arm(ctx, http.MethodGet, svc+"/"+apimPxAPIs+"/"+url.PathEscape(api)) + + var doc struct { + Properties struct { + Names struct { + Header string `json:"header"` + Query string `json:"query"` + } `json:"subscriptionKeyParameterNames"` + Protocols []string `json:"protocols"` + } `json:"properties"` + } + + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &doc) != nil { + return def + } + + n := doc.Properties.Names + def.header, def.query = orDefault(headerToken(n.Header), apimPxKeyHeader), orDefault(n.Query, apimPxKeyQuery) + + if p := doc.Properties.Protocols; len(p) > 0 { + def.tls = slices.ContainsFunc(p, func(s string) bool { return strings.EqualFold(s, "https") }) + } + + return def +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + + return v +} + +func headerToken(name string) string { + if !ociAPIGWHeaderOK(name) { + return "" + } + + return name +} + +type replayArgs struct { + name string + rel string + key string + in apimTryIn + api apimAPIInfo +} + +func (t *apimTry) replay(ctx context.Context, a replayArgs) error { + name, rel, in, key := a.name, a.rel, a.in, a.key + hdrName, queryName := a.api.header, a.api.query + path, rawQuery, _ := strings.Cut(rel, "?") + q, _ := url.ParseQuery(rawQuery) + + if key != "" { + q.Del(queryName) + } + + target := apimPxGateway + url.PathEscape(name) + path + if len(q) > 0 { + target += "?" + q.Encode() + } + + req := httptest.NewRequestWithContext(ctx, in.Method, target, io.NopCloser(strings.NewReader(in.Body))) + if a.api.tls { + req.TLS = &tls.ConnectionState{} + } + + for _, hd := range in.Headers { + if ociAPIGWHeaderOK(hd.Name) && (key == "" || !strings.EqualFold(hd.Name, hdrName)) { + req.Header.Add(hd.Name, hd.Value) + } + } + + if key != "" { + req.Header.Set(hdrName, key) + } + + rec := httptest.NewRecorder() + t.inner.ServeHTTP(rec, req) + + return t.c.JSON(http.StatusOK, ociAPIGWTryOut(rec, key)) +} diff --git a/dashboard/azureapim_proxy_test.go b/dashboard/azureapim_proxy_test.go new file mode 100644 index 0000000000..4f0d2a1a71 --- /dev/null +++ b/dashboard/azureapim_proxy_test.go @@ -0,0 +1,312 @@ +package dashboard_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/azurearm" +) + +const ( + apimPxBase = "/dashboard/api/v1/azure/apim" + apimSvcPath = "/subscriptions/" + armSub + "/resourceGroups/demo/providers/Microsoft.ApiManagement/service/gw1" + apimVer = "?api-version=2024-05-01" + apimNamedVal = "top-secret-named-value" +) + +type apimEnv struct { + dash *echo.Echo + key string + key2 string +} + +func newAPIMDashboard(t *testing.T) *apimEnv { + t.Helper() + + svc, err := (&azurearm.Provider{}).Init(&service.AppContext{}) + require.NoError(t, err) + + armH, ok := svc.(*azurearm.Handler) + require.True(t, ok) + t.Cleanup(func() { armH.Shutdown(context.Background()) }) + + pr, found := armH.Registry.ProviderNamed("Microsoft.ApiManagement") + require.True(t, found) + + prov, isAPIM := pr.(*azurearm.APIMProvider) + require.True(t, isAPIM) + + var skew atomic.Int64 + + prov.SetClock(func() time.Time { return time.Now().Add(time.Duration(skew.Load())) }) + + be := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Echo-Key", r.Header.Get("Ocp-Apim-Subscription-Key")) + _ = json.NewEncoder(w).Encode(map[string]any{"path": r.URL.Path, "header": r.Header, "query": r.URL.RawQuery}) + })) + t.Cleanup(be.Close) + + arm := echo.New() + arm.Any("/*", armH.Handler()) + + put := func(path, body string) { + rec := flexDo(t, arm, http.MethodPut, path, body) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", path, rec.Body.String()) + } + + put("/subscriptions/"+armSub+"/resourceGroups/demo?api-version=2021-04-01", `{"location":"westus"}`) + put(apimSvcPath+apimVer, `{"location":"westus","sku":{"name":"Developer","capacity":1},`+ + `"properties":{"publisherEmail":"ops@example.com","publisherName":"Ops"}}`) + skew.Add(int64(time.Hour)) + + put(apimSvcPath+"/apis/echo"+apimVer, `{"properties":{"displayName":"Echo","path":"echo","serviceUrl":"`+be.URL+ + `/base","protocols":["https"],"subscriptionRequired":true}}`) + put(apimSvcPath+"/apis/plain"+apimVer, `{"properties":{"displayName":"Plain","path":"plain","serviceUrl":"`+be.URL+ + `","protocols":["http"],"subscriptionRequired":false}}`) + put(apimSvcPath+"/apis/echo/operations/get-item"+apimVer, + `{"properties":{"displayName":"Get item","method":"GET","urlTemplate":"/items/{id}"}}`) + put(apimSvcPath+"/apis/plain/operations/x"+apimVer, + `{"properties":{"displayName":"x","method":"GET","urlTemplate":"/x"}}`) + put(apimSvcPath+"/subscriptions/k1"+apimVer, `{"properties":{"displayName":"k1","scope":"/apis","state":"active"}}`) + put(apimSvcPath+"/subscriptions/off"+apimVer, + `{"properties":{"displayName":"off","scope":"/apis","state":"suspended"}}`) + put(apimSvcPath+"/namedValues/hush"+apimVer, + `{"properties":{"displayName":"hush","value":"`+apimNamedVal+`","secret":true}}`) + put(apimSvcPath+"/namedValues/shown"+apimVer, `{"properties":{"displayName":"shown","value":"visible-value"}}`) + put(apimSvcPath+"/policies/policy"+apimVer, `{"properties":{"format":"rawxml","value":`+ + `"`+ + `"}}`) + + rec := flexDo(t, arm, http.MethodPost, apimSvcPath+"/subscriptions/k1/listSecrets"+apimVer, `{}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var keys struct { + Primary string `json:"primaryKey"` + Secondary string `json:"secondaryKey"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &keys)) + require.NotEmpty(t, keys.Primary) + + dash := echo.New() + dash.Any("/*", dashboard.NewHandler(dashboard.Config{AzureARMOps: armH}).Handler()) + + return &apimEnv{dash: dash, key: keys.Primary, key2: keys.Secondary} +} + +func TestAzureAPIMProxyReads(t *testing.T) { + t.Parallel() + + env := newAPIMDashboard(t) + px := apimPxBase + apimSvcPath + + tests := []struct { + name string + path string + want string + }{ + { + "service list", + apimPxBase + "/subscriptions/" + armSub + "/providers/Microsoft.ApiManagement/service" + apimVer, + `"gatewayUrl"`, + }, + {"service", px + apimVer, `"name":"gw1"`}, + {"apis", px + "/apis" + apimVer, `"displayName":"Echo"`}, + {"api", px + "/apis/echo" + apimVer, `"subscriptionRequired":true`}, + {"operations", px + "/apis/echo/operations" + apimVer, `"urlTemplate":"/items/{id}"`}, + {"subscriptions", px + "/subscriptions" + apimVer, `"state":"active"`}, + {"subscription", px + "/subscriptions/k1" + apimVer, `"displayName":"k1"`}, + {"named values", px + "/namedValues" + apimVer, `"displayName":"shown"`}, + {"service policy", px + "/policies/policy" + apimVer + "&format=rawxml", `"format":"rawxml"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := flexDo(t, env.dash, http.MethodGet, tt.path, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), tt.want) + }) + } + + t.Run("secrets stay server-side", func(t *testing.T) { + t.Parallel() + + for _, p := range []string{"/subscriptions", "/subscriptions/k1", "/namedValues", "/namedValues/hush", ""} { + rec := flexDo(t, env.dash, http.MethodGet, px+p+apimVer, "") + assert.NotContains(t, rec.Body.String(), env.key, p) + assert.NotContains(t, rec.Body.String(), env.key2, p) + assert.NotContains(t, rec.Body.String(), apimNamedVal, p) + assert.NotContains(t, rec.Body.String(), "primaryKey", p) + } + + rec := flexDo(t, env.dash, http.MethodGet, px+"/namedValues"+apimVer, "") + assert.Contains(t, rec.Body.String(), `"isSecret":true`) + assert.Contains(t, rec.Body.String(), "visible-value") + }) +} + +func TestAzureAPIMProxyRejects(t *testing.T) { + t.Parallel() + + env := newAPIMDashboard(t) + px := apimPxBase + apimSvcPath + + tests := []struct { + name string + method string + path string + body string + want int + }{ + {"list secrets via post", http.MethodPost, px + "/subscriptions/k1/listSecrets" + apimVer, `{}`, 400}, + {"list secrets via get", http.MethodGet, px + "/subscriptions/k1/listSecrets" + apimVer, "", 400}, + {"list value", http.MethodPost, px + "/namedValues/hush/listValue" + apimVer, `{}`, 400}, + {"put service", http.MethodPut, px + apimVer, `{}`, 405}, + {"delete api", http.MethodDelete, px + "/apis/echo" + apimVer, "", 405}, + {"backends", http.MethodGet, px + "/backends" + apimVer, "", 400}, + { + "other provider", + http.MethodGet, + apimPxBase + armBase + "Microsoft.Storage/storageAccounts" + apimVer, + "", + 400, + }, + {"traversal", http.MethodGet, px + "/apis/%2e%2e" + apimVer, "", 400}, + {"encoded slash", http.MethodGet, px + "/apis/a%2fb" + apimVer, "", 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := flexDo(t, env.dash, tt.method, tt.path, tt.body) + assert.Equal(t, tt.want, rec.Code, rec.Body.String()) + assert.NotContains(t, rec.Body.String(), env.key) + }) + } +} + +func TestAzureAPIMTryIt(t *testing.T) { + t.Parallel() + + env := newAPIMDashboard(t) + invoke := apimPxBase + apimSvcPath + "/invoke" + + type out struct { + Headers map[string]string `json:"headers"` + Body string `json:"body"` + Status float64 `json:"status"` + } + + try := func(t *testing.T, body string) (*httptest.ResponseRecorder, out) { + t.Helper() + + rec := flexDo(t, env.dash, http.MethodPost, invoke, body) + + var o out + + if rec.Code == http.StatusOK { + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &o)) + } + + return rec, o + } + + t.Run("subscription key is injected server-side", func(t *testing.T) { + t.Parallel() + + rec, o := try(t, `{"method":"GET","path":"/echo/items/7","subscription":"k1","api":"echo",`+ + `"headers":[{"name":"Ocp-Apim-Subscription-Key","value":"user-supplied"}]}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.InDelta(t, 200, o.Status, 0, o.Body) + assert.Contains(t, o.Body, "[redacted]") + assert.Contains(t, o.Headers["X-Echo-Key"], "[redacted]") + assert.NotContains(t, rec.Body.String(), env.key) + assert.NotContains(t, rec.Body.String(), "user-supplied") + }) + + t.Run("without a subscription the gateway refuses", func(t *testing.T) { + t.Parallel() + + rec, o := try(t, `{"method":"GET","path":"/echo/items/7","api":"echo"}`) + require.Equal(t, http.StatusOK, rec.Code) + assert.InDelta(t, 401, o.Status, 0) + }) + + t.Run("http-only api is replayed without tls", func(t *testing.T) { + t.Parallel() + + _, o := try(t, `{"method":"GET","path":"/plain/x","api":"plain"}`) + assert.InDelta(t, 200, o.Status, 0, o.Body) + }) + + t.Run("rejections", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + body string + want int + }{ + {"unknown subscription", invoke, `{"method":"GET","path":"/echo/x","subscription":"nope"}`, 404}, + {"suspended subscription", invoke, `{"method":"GET","path":"/echo/x","subscription":"off"}`, 409}, + {"unknown service", apimPxBase + strings.Replace(apimSvcPath, "gw1", "other", 1) + "/invoke", + `{"method":"GET","path":"/echo/x"}`, 404}, + {"bad method", invoke, `{"method":"TRACE","path":"/echo/x"}`, 400}, + {"relative path", invoke, `{"method":"GET","path":"echo/x"}`, 400}, + {"absolute url", invoke, `{"method":"GET","path":"http://evil.example/x"}`, 400}, + {"dot segments", invoke, `{"method":"GET","path":"/echo/../x"}`, 400}, + {"path with query", invoke, `{"method":"GET","path":"/echo/x?a=b"}`, 400}, + {"bad subscription id", invoke, `{"method":"GET","path":"/echo/x","subscription":"../x"}`, 400}, + { + "oversize body", + invoke, + `{"method":"POST","path":"/echo/x","body":"` + strings.Repeat("a", 70000) + `"}`, + 400, + }, + {"not json", invoke, `nope`, 400}, + {"wrong verb", apimPxBase + apimSvcPath + "/listSecrets", `{}`, 400}, + {"wrong provider", apimPxBase + armBase + "Microsoft.Storage/storageAccounts/x/invoke", `{}`, 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := flexDo(t, env.dash, http.MethodPost, tt.path, tt.body) + assert.Equal(t, tt.want, rec.Code, rec.Body.String()) + assert.NotContains(t, rec.Body.String(), env.key) + }) + } + }) + + t.Run("unavailable without arm", func(t *testing.T) { + t.Parallel() + + e := echo.New() + e.Any("/*", dashboard.NewHandler(dashboard.Config{}).Handler()) + + rec := flexDo(t, e, http.MethodPost, invoke, `{}`) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + _, err := io.Copy(io.Discard, rec.Body) + require.NoError(t, err) + }) +} diff --git a/dashboard/azureappservice_proxy.go b/dashboard/azureappservice_proxy.go new file mode 100644 index 0000000000..116e781722 --- /dev/null +++ b/dashboard/azureappservice_proxy.go @@ -0,0 +1,145 @@ +package dashboard + +import ( + "net/http" + "net/url" + "slices" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + azureAppServiceProxyPrefix = "/dashboard/api/v1/azure/appservice" + azureAppServiceMaxBodyBytes = 1 << 20 + + appPxSubList = 5 + appPxSite = 8 + appPxNamespace = "microsoft.web" + appPxSitesType = "sites" + appPxSlots = "slots" + appPxSlotItem = 2 + appPxConfig = "config" + appPxSticky = "slotconfignames" + appPxConfigLen = 2 +) + +var appPxActions = []string{ //nolint:gochecknoglobals // read-only lookup table + aciPxStart, aciPxStop, aciPxRestart, "slotsswap", "applyslotconfig", "resetslotconfig", "slotsdiffs", +} + +// registerAzureAppServiceRoutes exposes Microsoft.Web web apps and their slots to the SPA by dispatching +// in-process to the ARM handler, so responses keep the real shape. Only site and slot reads, the sticky-setting +// names, lifecycle actions and the swap actions are reachable; application settings never pass through. +func (h *DashboardHandler) registerAzureAppServiceRoutes() { + var inner *echo.Echo + + if h.config.AzureARMOps != nil { + inner = echo.New() + inner.Any("/*", h.config.AzureARMOps.Handler()) + } + + serve := func(c *echo.Context) error { + if inner == nil { + return azureACIFail(c, http.StatusServiceUnavailable, errServiceUnavailable, + "Azure App Service is not enabled") + } + + r := c.Request() + + armPath, ok := azureAppServiceProxyPath(r.Method, + strings.TrimPrefix(r.URL.EscapedPath(), azureAppServiceProxyPrefix)) + if !ok { + return azureACIFail(c, http.StatusBadRequest, "InvalidRequestUri", + "unsupported App Service proxy request") + } + + q := url.Values{} + if v := r.URL.Query().Get("api-version"); v != "" { + q.Set("api-version", v) + } + + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(armPath), armPath + fwd.URL.RawQuery = q.Encode() + fwd.RequestURI = "" + fwd.Body = http.MaxBytesReader(c.Response(), r.Body, azureAppServiceMaxBodyBytes) + inner.ServeHTTP(c.Response(), fwd) + + return nil + } + + for _, method := range []string{http.MethodGet, http.MethodPost} { + h.SubRouter.Add(method, azureAppServiceProxyPrefix+"/*", serve) + } +} + +// azureAppServiceProxyPath validates the proxied ARM path against the Microsoft.Web site routes. +func azureAppServiceProxyPath(method, raw string) (string, bool) { + parts := strings.Split(strings.TrimPrefix(raw, "/"), "/") + + for _, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || dec == "" || dec == "." || dec == ".." || strings.ContainsAny(dec, "/:") { + return "", false + } + } + + if !appProxyAllowed(method, strings.Split(strings.ToLower(strings.TrimPrefix(raw, "/")), "/")) { + return "", false + } + + return "/" + strings.Join(parts, "/"), true +} + +func appProxyAllowed(method string, p []string) bool { + n := len(p) + if n < appPxSubList || p[0] != azureServiceBusSubs { + return false + } + + if n == appPxSubList { + return method == http.MethodGet && p[2] == earcProvidersKind && p[3] == appPxNamespace && + p[4] == appPxSitesType + } + + if !appProxyIsSite(p) { + return false + } + + rest := p[appPxSite:] + if len(rest) >= appPxSlotItem && rest[0] == appPxSlots { + return appProxySlotOp(method, rest[appPxSlotItem:]) + } + + if len(rest) == 1 && rest[0] == appPxSlots { + return method == http.MethodGet + } + + return appProxySiteOp(method, rest) +} + +func appProxyIsSite(p []string) bool { + return len(p) >= appPxSite && p[2] == "resourcegroups" && p[4] == earcProvidersKind && + p[5] == appPxNamespace && p[6] == appPxSitesType +} + +// appProxySlotOp allows the operations below sites/{name}/slots/{slot}; rest is empty for the slot itself. +func appProxySlotOp(method string, rest []string) bool { + if len(rest) == 0 { + return method == http.MethodGet + } + + return appProxySiteOp(method, rest) +} + +func appProxySiteOp(method string, rest []string) bool { + switch method { + case http.MethodGet: + return len(rest) == 0 || (len(rest) == appPxConfigLen && rest[0] == appPxConfig && rest[1] == appPxSticky) + case http.MethodPost: + return len(rest) == 1 && slices.Contains(appPxActions, rest[0]) + } + + return false +} diff --git a/dashboard/azureappservice_proxy_test.go b/dashboard/azureappservice_proxy_test.go new file mode 100644 index 0000000000..b7819d7d36 --- /dev/null +++ b/dashboard/azureappservice_proxy_test.go @@ -0,0 +1,207 @@ +package dashboard_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/pkgs/aadauth" + "github.com/blackbirdworks/gopherstack/services/azurearm" +) + +const ( + appSub = "00000000-0000-0000-0000-000000000000" + appAPI = "?api-version=2023-12-01" + appRG = "/subscriptions/" + appSub + "/resourceGroups/demo" + appWeb = appRG + "/providers/Microsoft.Web" + appSite = appWeb + "/sites/shop" + appProxy = "/dashboard/api/v1/azure/appservice" +) + +// fakeFunctionApps is an in-memory data plane for the Microsoft.Web provider. +type fakeFunctionApps struct { + azurearm.FunctionApps + + apps map[string]*azurearm.FunctionAppInfo + cfg map[string]map[string]string + mu sync.Mutex +} + +func newFakeFunctionApps() *fakeFunctionApps { + return &fakeFunctionApps{ + apps: map[string]*azurearm.FunctionAppInfo{}, cfg: map[string]map[string]string{}, + } +} + +func (f *fakeFunctionApps) PutApp(name string, _ azurearm.FunctionAppConfig) (azurearm.FunctionAppInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if _, ok := f.apps[name]; !ok { + f.apps[name] = &azurearm.FunctionAppInfo{ + Created: time.Now(), HostName: name + ".azurewebsites.net", SCMHost: name + ".scm.azurewebsites.net", + } + f.cfg[name] = map[string]string{} + } + + return *f.apps[name], nil +} + +func (f *fakeFunctionApps) DeleteApp(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + + delete(f.apps, name) + delete(f.cfg, name) + + return nil +} + +func (f *fakeFunctionApps) AppInfo(name string) (azurearm.FunctionAppInfo, bool) { + f.mu.Lock() + defer f.mu.Unlock() + + a, ok := f.apps[name] + if !ok { + return azurearm.FunctionAppInfo{}, false + } + + return *a, true +} + +func (f *fakeFunctionApps) Settings(name string) (map[string]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.cfg[name], nil +} + +func (f *fakeFunctionApps) SetSettings(name string, s map[string]string) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.cfg[name] = s + + return nil +} + +func (f *fakeFunctionApps) SetStopped(name string, stopped bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if a, ok := f.apps[name]; ok { + a.Stopped = stopped + } + + return nil +} + +func (f *fakeFunctionApps) Restart(string) error { return nil } + +func (f *fakeFunctionApps) SwapContent(string, string) error { return nil } + +func (f *fakeFunctionApps) WarmUp(context.Context, string) error { return nil } + +func newAppServiceDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + backend := azurearm.NewInMemoryBackend() + registry := azurearm.NewRegistry(backend) + registry.Register(azurearm.NewWebProvider(newFakeFunctionApps())) + + issuer, err := aadauth.NewIssuer() + require.NoError(t, err) + + h := dashboard.NewHandler(dashboard.Config{ + AzureARMOps: azurearm.NewHandler(backend, registry, issuer, azurearm.DefaultSettings()), + }) + + plan := appWeb + "/serverfarms/plan" + + for _, s := range []struct{ path, body string }{ + {appRG + "?api-version=2021-04-01", `{"location":"eastus"}`}, + {plan + appAPI, `{"location":"eastus","kind":"linux","sku":{"name":"S1","tier":"Standard"},` + + `"properties":{"reserved":true}}`}, + {appSite + appAPI, `{"location":"eastus","kind":"app,linux","properties":{"serverFarmId":"` + plan + `",` + + `"siteConfig":{"linuxFxVersion":"NODE|20-lts"}}}`}, + {appSite + "/slots/staging" + appAPI, `{"location":"eastus","kind":"app,linux","properties":{}}`}, + {appSite + "/config/slotConfigNames" + appAPI, `{"properties":{"appSettingNames":["STICKY_ONE"]}}`}, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/dashboard/api/v1/azure/arm"+s.path, strings.NewReader(s.body)) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", s.path, rec.Body.String()) + } + + return h +} + +func TestAzureAppServiceProxy(t *testing.T) { + t.Parallel() + + px := appProxy + appSite + list := appProxy + "/subscriptions/" + appSub + "/providers/Microsoft.Web/sites" + appAPI + + tests := []struct { + name string + steps []gcpStep + }{ + {"read", []gcpStep{ + {http.MethodGet, list, "", `"name":"shop"`, 200}, + {http.MethodGet, px + appAPI, "", `"defaultHostName"`, 200}, + {http.MethodGet, px + "/slots" + appAPI, "", "shop/staging", 200}, + {http.MethodGet, px + "/slots/staging" + appAPI, "", "shop/staging", 200}, + {http.MethodGet, px + "/config/slotConfigNames" + appAPI, "", "STICKY_ONE", 200}, + }}, + {"lifecycle", []gcpStep{ + {http.MethodPost, px + "/stop" + appAPI, "", "", 200}, + {http.MethodGet, px + appAPI, "", `"state":"Stopped"`, 200}, + {http.MethodPost, px + "/start" + appAPI, "", "", 200}, + {http.MethodPost, px + "/restart" + appAPI, "", "", 200}, + {http.MethodPost, px + "/slots/staging/stop" + appAPI, "", "", 200}, + {http.MethodGet, px + "/slots/staging" + appAPI, "", `"state":"Stopped"`, 200}, + }}, + {"swap", []gcpStep{ + {http.MethodPost, px + "/slots/staging/slotsswap" + appAPI, `{"targetSlot":"production"}`, "", 200}, + {http.MethodPost, px + "/slots/staging/applySlotConfig" + appAPI, `{"targetSlot":"production"}`, "", 200}, + {http.MethodGet, px + "/slots/staging" + appAPI, "", `"targetSwapSlot":"production"`, 200}, + {http.MethodPost, px + "/slots/staging/resetSlotConfig" + appAPI, "", "", 200}, + {http.MethodPost, px + "/slots/staging/slotsdiffs" + appAPI, `{"targetSlot":"production"}`, `"value"`, 200}, + }}, + {"rejects", []gcpStep{ + {http.MethodDelete, px + appAPI, "", "", http.StatusMethodNotAllowed}, + {http.MethodPut, px + appAPI, `{}`, "", http.StatusMethodNotAllowed}, + {http.MethodPost, px + "/config/appsettings/list" + appAPI, "", "unsupported", 400}, + {http.MethodGet, px + "/config/appsettings" + appAPI, "", "unsupported", 400}, + {http.MethodPost, px + "/publishxml" + appAPI, "", "unsupported", 400}, + {http.MethodGet, appProxy + "/subscriptions/" + appSub + "/providers/Microsoft.Storage/x" + appAPI, "", + "unsupported", 400}, + {http.MethodGet, px + "/slots/../config/web" + appAPI, "", "unsupported", 400}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, newAppServiceDashboard(t), tt.steps) + }) + } + + t.Run("unserved", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, list, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/azurearm_inproc_proxy.go b/dashboard/azurearm_inproc_proxy.go new file mode 100644 index 0000000000..6b036ebd61 --- /dev/null +++ b/dashboard/azurearm_inproc_proxy.go @@ -0,0 +1,222 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + armPxMaxBody = 1 << 20 + armPxSubList = 5 + armPxItem = 8 + armPxNSSub = 3 + armPxTypeSub = 4 + armPxNSRG = 5 + armPxTypeRG = 6 + armPxSubsSeg = "subscriptions" + armPxRGsSeg = "resourcegroups" + armPxProvSeg = "providers" + armPxSecretsKy = "secrets" + armQueryVersion = "api-version" +) + +var armPxSecretKeys = []string{ //nolint:gochecknoglobals // immutable allowlist + "adminpassword", "customdata", "sharedkey", "daprainstrumentationkey", "dapraiconnectionstring", + "primarykey", "secondarykey", "protectedsettings", "secret", +} + +// armProxy describes one in-process dispatch to the ARM handler: which resource types and operations the page may +// reach. Everything else answers 400, so the proxy never forwards arbitrary ARM calls. +type armProxy struct { + allowed func(method string, p []string) bool + body func(method string, p []string, in []byte) ([]byte, bool) + view func(doc any) + name string + prefix string + queryKeys []string + methods []string +} + +func (h *DashboardHandler) registerARMProxy(px armProxy) { + var inner *echo.Echo + + if h.config.AzureARMOps != nil { + inner = echo.New() + inner.Any("/*", h.config.AzureARMOps.Handler()) + } + + serve := func(c *echo.Context) error { + if inner == nil { + return azureACIFail(c, http.StatusServiceUnavailable, errServiceUnavailable, px.name+" is not enabled") + } + + r := c.Request() + + armPath, ok := armProxyPath(r.Method, strings.TrimPrefix(r.URL.EscapedPath(), px.prefix), px.allowed) + if !ok { + return azureACIFail(c, http.StatusBadRequest, "InvalidRequestUri", "unsupported "+px.name+" proxy request") + } + + q := url.Values{} + + for _, k := range px.queryKeys { + if v := r.URL.Query().Get(k); v != "" { + q.Set(k, v) + } + } + + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(armPath), armPath + fwd.URL.RawQuery = q.Encode() + fwd.RequestURI = "" + fwd.Body = http.MaxBytesReader(c.Response(), r.Body, armPxMaxBody) + + if px.body != nil && !armRewriteBody(fwd, px.body, armPath) { + return azureACIFail( + c, + http.StatusBadRequest, + "InvalidRequestContent", + "invalid "+px.name+" proxy request body", + ) + } + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + return armProxyRelay(c, rec, px.view) + } + + for _, m := range px.methods { + h.SubRouter.Add(m, px.prefix+"/*", serve) + } +} + +// armRewriteBody replaces the forwarded body with one rebuilt by rebuild from the caller's JSON. +func armRewriteBody(fwd *http.Request, rebuild func(string, []string, []byte) ([]byte, bool), armPath string) bool { + in, err := io.ReadAll(fwd.Body) + if err != nil { + return false + } + + out, ok := rebuild(fwd.Method, strings.Split(strings.ToLower(strings.TrimPrefix(armPath, "/")), "/"), in) + if !ok { + return false + } + + fwd.Body, fwd.ContentLength = io.NopCloser(bytes.NewReader(out)), int64(len(out)) + fwd.Header = fwd.Header.Clone() + fwd.Header.Set("Content-Type", "application/json") + + return true +} + +// armProxyPath validates the proxied ARM path segments and the allowlist. +func armProxyPath(method, raw string, allowed func(string, []string) bool) (string, bool) { + parts := strings.Split(strings.TrimPrefix(raw, "/"), "/") + + for _, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || dec == "" || dec == "." || dec == ".." || strings.ContainsAny(dec, "/:") { + return "", false + } + } + + if !allowed(method, strings.Split(strings.ToLower(strings.TrimPrefix(raw, "/")), "/")) { + return "", false + } + + return "/" + strings.Join(parts, "/"), true +} + +// armProxyRelay copies the ARM response, removing secret-bearing properties before they reach the browser. +func armProxyRelay(c *echo.Context, rec *httptest.ResponseRecorder, view func(any)) error { + body := rec.Body.Bytes() + + if rec.Code < http.StatusMultipleChoices && len(body) > 0 { + body = armScrubBody(body, view) + } + + c.Response().Header().Set("Content-Type", "application/json") + + return c.JSONBlob(rec.Code, body) +} + +// armScrubBody applies the optional view and the generic scrub to a JSON body; anything else is returned as is. +func armScrubBody(body []byte, view func(any)) []byte { + var doc any + if json.Unmarshal(body, &doc) != nil { + return body + } + + if view != nil { + view(doc) + } + + armScrub(doc) + + if out, err := json.Marshal(doc); err == nil { + return out + } + + return body +} + +func armScrub(v any) { + switch t := v.(type) { + case []any: + for _, x := range t { + armScrub(x) + } + case map[string]any: + for k, x := range t { + switch { + case slices.Contains(armPxSecretKeys, strings.ToLower(k)): + delete(t, k) + case k == armPxSecretsKy: + armDropValues(x) + default: + armScrub(x) + } + } + } +} + +// armDropValues removes the value of every entry of a secrets array. +func armDropValues(v any) { + list, ok := v.([]any) + if !ok { + return + } + + for _, e := range list { + if m, isMap := e.(map[string]any); isMap { + delete(m, "value") + } + } +} + +// armScope splits a lower-cased proxied path below /subscriptions/{s} into the resource type and the segments after +// the resource name. A subscription-level list (/providers/{ns}/{type}) has no rest and name "". +func armScope(p []string, ns string) (string, string, []string, bool) { + if len(p) < armPxSubList || p[0] != armPxSubsSeg { + return "", "", nil, false + } + + if len(p) == armPxSubList { + return p[armPxTypeSub], "", nil, p[2] == armPxProvSeg && p[armPxNSSub] == ns + } + + if len(p) < armPxItem || p[2] != armPxRGsSeg || p[4] != armPxProvSeg || p[armPxNSRG] != ns { + return "", "", nil, false + } + + return p[armPxTypeRG], p[armPxItem-1], p[armPxItem:], true +} diff --git a/dashboard/azurecontainerapps_proxy.go b/dashboard/azurecontainerapps_proxy.go new file mode 100644 index 0000000000..a213e3271e --- /dev/null +++ b/dashboard/azurecontainerapps_proxy.go @@ -0,0 +1,81 @@ +package dashboard + +import ( + "net/http" + "slices" +) + +const ( + azureContainerAppsPrefix = "/dashboard/api/v1/azure/containerapps" + acaPxNamespace = "microsoft.app" + acaPxApps = "containerapps" + acaPxEnvs = "managedenvironments" + acaPxJobs = "jobs" + acaPxRevisions = "revisions" + acaPxReplicas = "replicas" + acaPxExecutions = "executions" + acaPxStart = "start" + acaPxStop = "stop" + acaPxRevItem = 2 + acaPxRevChild = 3 + acaPxActionOnly = 1 +) + +var ( + acaPxAppActions = []string{acaPxStart, acaPxStop} //nolint:gochecknoglobals // lookup table + acaPxRevActions = []string{"activate", "deactivate", aciPxRestart} //nolint:gochecknoglobals // lookup table +) + +// registerAzureContainerAppsRoutes exposes Microsoft.App environments, container apps with their revisions and +// replicas, and jobs by dispatching in-process to the ARM handler. Only reads, app start/stop, revision +// activate/deactivate/restart and job start are reachable; secrets are never listed. +func (h *DashboardHandler) registerAzureContainerAppsRoutes() { + h.registerARMProxy(armProxy{ + name: "Azure Container Apps", prefix: azureContainerAppsPrefix, + queryKeys: []string{armQueryVersion}, + methods: []string{http.MethodGet, http.MethodPost}, + allowed: acaProxyAllowed, + }) +} + +func acaProxyAllowed(method string, p []string) bool { + typ, name, rest, ok := armScope(p, acaPxNamespace) + if !ok || !slices.Contains([]string{acaPxApps, acaPxEnvs, acaPxJobs}, typ) { + return false + } + + if name == "" || len(rest) == 0 { + return method == http.MethodGet + } + + switch typ { + case acaPxApps: + return acaProxyAppOp(method, rest) + case acaPxJobs: + return (method == http.MethodGet && len(rest) == acaPxActionOnly && rest[0] == acaPxExecutions) || + (method == http.MethodPost && len(rest) == acaPxActionOnly && rest[0] == acaPxStart) + } + + return false +} + +func acaProxyAppOp(method string, rest []string) bool { + switch method { + case http.MethodGet: + switch len(rest) { + case acaPxActionOnly, acaPxRevItem: + return rest[0] == acaPxRevisions + case acaPxRevChild: + return rest[0] == acaPxRevisions && rest[2] == acaPxReplicas + } + case http.MethodPost: + switch len(rest) { + case acaPxActionOnly: + return slices.Contains(acaPxAppActions, rest[0]) + case acaPxRevChild: + return rest[0] == acaPxRevisions && slices.Contains(acaPxRevActions, rest[2]) + } + } + + return false +} diff --git a/dashboard/azurecontainerapps_proxy_test.go b/dashboard/azurecontainerapps_proxy_test.go new file mode 100644 index 0000000000..0307cb239b --- /dev/null +++ b/dashboard/azurecontainerapps_proxy_test.go @@ -0,0 +1,130 @@ +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" +) + +const ( + acaAPI = "?api-version=2025-07-01" + acaProv = armBase + "Microsoft.App" + acaEnv = acaProv + "/managedEnvironments/env" + acaApp = acaProv + "/containerApps/web" + acaJob = acaProv + "/jobs/nightly" + acaProxy = "/dashboard/api/v1/azure/containerapps" +) + +func newContainerAppsDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + template := `{"containers":[{"name":"main","image":"nginx:1","resources":{"cpu":0.5,"memory":"1Gi"}}],` + + `"scale":{"minReplicas":1,"maxReplicas":3}}` + + return newARMDashboard(t, []struct{ path, body string }{ + {"/subscriptions/" + armSub + "/resourceGroups/demo?api-version=2021-04-01", `{"location":"eastus"}`}, + {acaEnv + acaAPI, `{"location":"local","properties":{}}`}, + {acaApp + acaAPI, `{"location":"local","properties":{"environmentId":"` + acaEnv + `",` + + `"configuration":{"activeRevisionsMode":"Multiple","ingress":{"external":true,"targetPort":80},` + + `"secrets":[{"name":"token","value":"s3cr3t-value"}]},"template":` + template + `}}`}, + {acaJob + acaAPI, `{"location":"local","properties":{"environmentId":"` + acaEnv + `",` + + `"configuration":{"triggerType":"Manual","replicaTimeout":60,"manualTriggerConfig":{}},"template":` + + template + `}}`}, + }) +} + +func TestAzureContainerAppsProxy(t *testing.T) { + t.Parallel() + + px := acaProxy + acaApp + list := acaProxy + "/subscriptions/" + armSub + "/providers/Microsoft.App/" + + tests := []struct { + name string + steps []gcpStep + }{ + {"read", []gcpStep{ + {http.MethodGet, list + "containerApps" + acaAPI, "", `"name":"web"`, 200}, + {http.MethodGet, list + "managedEnvironments" + acaAPI, "", `"defaultDomain"`, 200}, + {http.MethodGet, list + "jobs" + acaAPI, "", `"name":"nightly"`, 200}, + {http.MethodGet, px + acaAPI, "", `"fqdn"`, 200}, + {http.MethodGet, px + "/revisions" + acaAPI, "", `"trafficWeight"`, 200}, + {http.MethodGet, acaProxy + acaJob + "/executions" + acaAPI, "", `"value"`, 200}, + }}, + {"app lifecycle", []gcpStep{ + {http.MethodPost, px + "/stop" + acaAPI, "", "", 200}, + {http.MethodGet, px + acaAPI, "", `"runningStatus":"Stopped"`, 200}, + {http.MethodPost, px + "/start" + acaAPI, "", "", 200}, + {http.MethodGet, px + acaAPI, "", `"runningStatus":"Running"`, 200}, + }}, + {"job start", []gcpStep{ + {http.MethodPost, acaProxy + acaJob + "/start" + acaAPI, `{}`, `"name"`, 200}, + {http.MethodGet, acaProxy + acaJob + "/executions" + acaAPI, "", `"status"`, 200}, + }}, + {"rejects", []gcpStep{ + {http.MethodDelete, px + acaAPI, "", "", http.StatusMethodNotAllowed}, + {http.MethodPut, px + acaAPI, `{}`, "", http.StatusMethodNotAllowed}, + {http.MethodPost, px + "/listSecrets" + acaAPI, "", "unsupported", 400}, + {http.MethodPost, acaProxy + acaJob + "/listSecrets" + acaAPI, "", "unsupported", 400}, + {http.MethodPost, px + "/revisions/x/delete" + acaAPI, "", "unsupported", 400}, + {http.MethodGet, px + "/authConfigs" + acaAPI, "", "unsupported", 400}, + {http.MethodGet, acaProxy + "/subscriptions/" + armSub + "/providers/Microsoft.Web/sites" + acaAPI, "", + "unsupported", 400}, + {http.MethodGet, px + "/revisions/../../x" + acaAPI, "", "unsupported", 400}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, newContainerAppsDashboard(t), tt.steps) + }) + } + + t.Run("revision actions", func(t *testing.T) { + t.Parallel() + + h := newContainerAppsDashboard(t) + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext( + httptest.NewRequest(http.MethodGet, px+"/revisions"+acaAPI, nil), rec))) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + name := jsonField(t, rec.Body.Bytes(), "value", 0, "name") + rev := px + "/revisions/" + name + + runGCPSteps(t, h, []gcpStep{ + {http.MethodPost, rev + "/restart" + acaAPI, "", "", 200}, + {http.MethodPost, rev + "/deactivate" + acaAPI, "", "", 200}, + {http.MethodGet, rev + acaAPI, "", `"active":false`, 200}, + {http.MethodPost, rev + "/activate" + acaAPI, "", "", 200}, + {http.MethodGet, rev + acaAPI, "", `"active":true`, 200}, + }) + }) + + t.Run("secrets stay server side", func(t *testing.T) { + t.Parallel() + + h := newContainerAppsDashboard(t) + for _, p := range []string{px + acaAPI, list + "containerApps" + acaAPI, px + "/revisions" + acaAPI} { + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext(httptest.NewRequest(http.MethodGet, p, nil), rec))) + assert.NotContains(t, rec.Body.String(), "s3cr3t-value", p) + } + }) + + t.Run("unserved", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, list+"containerApps"+acaAPI, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/azureflexdb_proxy.go b/dashboard/azureflexdb_proxy.go new file mode 100644 index 0000000000..47a6b191df --- /dev/null +++ b/dashboard/azureflexdb_proxy.go @@ -0,0 +1,168 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "net/netip" + "regexp" + "slices" + "strings" +) + +const ( + azureFlexDBPrefix = "/dashboard/api/v1/azure/flexdb" + flexPxServers = "flexibleservers" + flexPxDatabases = "databases" + flexPxFirewall = "firewallrules" + flexPxConfigs = "configurations" + flexPxRGList = 7 + flexPxRGIdx = 2 + flexPxRGProvIdx = 4 + flexPxRGNSIdx = 5 + flexPxRGTypeIdx = 6 + flexPxChildItem = 2 +) + +//nolint:gochecknoglobals // immutable allow-lists +var ( + flexPxNamespaces = []string{ + "microsoft.dbforpostgresql", + "microsoft.dbformysql", + } + flexPxActions = []string{ + aciPxStart, + aciPxStop, + aciPxRestart, + } + flexPxLists = []string{ + flexPxDatabases, + flexPxFirewall, + flexPxConfigs, + } + flexPxNameRE = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`) +) + +// registerAzureFlexDBRoutes exposes PostgreSQL and MySQL flexible servers by dispatching in-process to the ARM +// handler. Reads, start/stop/restart, database and firewall rule PUT/DELETE are reachable; every write forwards a +// body rebuilt from fixed fields, and administrator passwords are scrubbed from every response. +func (h *DashboardHandler) registerAzureFlexDBRoutes() { + h.registerARMProxy(armProxy{ + name: "Database Flexible Server", prefix: azureFlexDBPrefix, + queryKeys: []string{armQueryVersion}, + methods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + allowed: flexProxyAllowed, + body: flexProxyBody, + }) +} + +func flexProxyAllowed(method string, p []string) bool { + if len(p) == flexPxRGList { + return method == http.MethodGet && p[0] == armPxSubsSeg && p[flexPxRGIdx] == armPxRGsSeg && + p[flexPxRGProvIdx] == armPxProvSeg && slices.Contains(flexPxNamespaces, p[flexPxRGNSIdx]) && + p[flexPxRGTypeIdx] == flexPxServers + } + + for _, ns := range flexPxNamespaces { + typ, name, rest, ok := armScope(p, ns) + if !ok || typ != flexPxServers { + continue + } + + return flexProxyScoped(method, name, rest) + } + + return false +} + +func flexProxyScoped(method, name string, rest []string) bool { + if name == "" { + return method == http.MethodGet + } + + switch len(rest) { + case 0: + return method == http.MethodGet + case 1: + return (method == http.MethodGet && slices.Contains(flexPxLists, rest[0])) || + (method == http.MethodPost && slices.Contains(flexPxActions, rest[0])) + case flexPxChildItem: + switch method { + case http.MethodGet: + return slices.Contains(flexPxLists, rest[0]) + case http.MethodPut, http.MethodDelete: + return rest[0] == flexPxDatabases || rest[0] == flexPxFirewall + } + } + + return false +} + +// flexProxyBody rebuilds the body of every write; reads keep the (empty) original. +func flexProxyBody(method string, p []string, in []byte) ([]byte, bool) { + switch method { + case http.MethodPost: + return []byte("{}"), true + case http.MethodPut: + var props map[string]any + + if p[len(p)-flexPxChildItem] == flexPxDatabases { + props = flexDatabaseProps(in) + } else { + props = flexFirewallProps(in) + } + + if props == nil { + return nil, false + } + + b, err := json.Marshal(map[string]any{"properties": props}) + + return b, err == nil + } + + return in, true +} + +type flexPropsIn struct { + Properties map[string]string `json:"properties"` +} + +func flexDatabaseProps(in []byte) map[string]any { + var req flexPropsIn + if json.Unmarshal(in, &req) != nil { + return nil + } + + out := map[string]any{} + + for _, k := range []string{"charset", "collation"} { + v := req.Properties[k] + if v == "" { + continue + } + + if !flexPxNameRE.MatchString(v) { + return nil + } + + out[k] = v + } + + return out +} + +func flexFirewallProps(in []byte) map[string]any { + var req flexPropsIn + if json.Unmarshal(in, &req) != nil { + return nil + } + + start, err1 := netip.ParseAddr(strings.TrimSpace(req.Properties["startIpAddress"])) + end, err2 := netip.ParseAddr(strings.TrimSpace(req.Properties["endIpAddress"])) + + if err1 != nil || err2 != nil || !start.Is4() || !end.Is4() { + return nil + } + + return map[string]any{"startIpAddress": start.String(), "endIpAddress": end.String()} +} diff --git a/dashboard/azureflexdb_proxy_test.go b/dashboard/azureflexdb_proxy_test.go new file mode 100644 index 0000000000..49ca696ae4 --- /dev/null +++ b/dashboard/azureflexdb_proxy_test.go @@ -0,0 +1,254 @@ +package dashboard_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/azurearm" +) + +const ( + flexAPIPG = "?api-version=2025-08-01" + flexAPIMy = "?api-version=2024-12-30" + flexPxBase = "/dashboard/api/v1/azure/flexdb" + flexPxSecret = "Adm1n-Secret-pw!" + flexPgProv = armBase + "Microsoft.DBforPostgreSQL/flexibleServers" + flexMyProv = armBase + "Microsoft.DBforMySQL/flexibleServers" + flexPgServer = flexPgProv + "/pgone" + flexMyServer = flexMyProv + "/myone" +) + +// flexEnv is an ARM-backed dashboard whose flexible-server clock advances on demand so async operations settle. +type flexEnv struct { + *echo.Echo + + skew atomic.Int64 +} + +func (f *flexEnv) advance() { f.skew.Add(int64(time.Hour)) } + +func newFlexDashboard(t *testing.T) *flexEnv { + t.Helper() + + svc, err := (&azurearm.Provider{}).Init(&service.AppContext{}) + require.NoError(t, err) + + armH, ok := svc.(*azurearm.Handler) + require.True(t, ok) + t.Cleanup(func() { armH.Shutdown(context.Background()) }) + + env := &flexEnv{Echo: echo.New()} + clock := func() time.Time { return time.Now().Add(time.Duration(env.skew.Load())) } + + for _, ns := range []string{"Microsoft.DBforPostgreSQL", "Microsoft.DBforMySQL"} { + p, found := armH.Registry.ProviderNamed(ns) + require.True(t, found) + + fp, isFlex := p.(*azurearm.FlexDBProvider) + require.True(t, isFlex) + fp.SetClock(clock) + } + + env.Any("/*", dashboard.NewHandler(dashboard.Config{AzureARMOps: armH}).Handler()) + + seeds := []struct{ path, body string }{ + {"/subscriptions/" + armSub + "/resourceGroups/demo?api-version=2021-04-01", `{"location":"eastus"}`}, + { + flexPgServer + flexAPIPG, + `{"location":"eastus","sku":{"name":"Standard_B1ms","tier":"Burstable"},"properties":{` + + `"administratorLogin":"pgadmin","administratorLoginPassword":"` + flexPxSecret + `","version":"16",` + + `"storage":{"storageSizeGB":32}}}`, + }, + { + flexMyServer + flexAPIMy, + `{"location":"eastus","sku":{"name":"Standard_B1ms","tier":"Burstable"},"properties":{` + + `"administratorLogin":"myadmin","administratorLoginPassword":"` + flexPxSecret + `","version":"8.0.21",` + + `"storage":{"storageSizeGB":20}}}`, + }, + { + flexPgServer + "/configurations/max_connections" + flexAPIPG, + `{"properties":{"value":"150","source":"user-override"}}`, + }, + } + + for i, s := range seeds { + if i == 1 { + env.advance() + } + + rec := flexDo(t, env.Echo, http.MethodPut, "/dashboard/api/v1/azure/arm"+s.path, s.body) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", s.path, rec.Body.String()) + } + + env.advance() + + return env +} + +func flexDo(t *testing.T, e *echo.Echo, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + return rec +} + +func TestAzureFlexDBProxy(t *testing.T) { + t.Parallel() + + px := flexPxBase + flexPgServer + my := flexPxBase + flexMyServer + subList := flexPxBase + "/subscriptions/" + armSub + "/providers/Microsoft.DBforPostgreSQL/flexibleServers" + rgList := flexPxBase + flexPgProv + + tests := []struct { + name string + steps []gcpStep + }{ + {"read", []gcpStep{ + {http.MethodGet, subList + flexAPIPG, "", `"name":"pgone"`, 200}, + { + http.MethodGet, + flexPxBase + "/subscriptions/" + armSub + "/providers/Microsoft.DBforMySQL/flexibleServers" + flexAPIMy, + "", + `"name":"myone"`, + 200, + }, + {http.MethodGet, rgList + flexAPIPG, "", `"name":"pgone"`, 200}, + {http.MethodGet, px + flexAPIPG, "", `"administratorLogin":"pgadmin"`, 200}, + {http.MethodGet, my + flexAPIMy, "", `"administratorLogin":"myadmin"`, 200}, + {http.MethodGet, px + "/databases" + flexAPIPG, "", `"name":"postgres"`, 200}, + {http.MethodGet, px + "/firewallRules" + flexAPIPG, "", `value`, 200}, + {http.MethodGet, px + "/configurations" + flexAPIPG, "", `"name":"max_connections"`, 200}, + {http.MethodGet, px + "/configurations/max_connections" + flexAPIPG, "", `"value":"150"`, 200}, + }}, + {"children", []gcpStep{ + { + http.MethodPut, + px + "/databases/orders" + flexAPIPG, + `{"properties":{"charset":"UTF8","collation":"en_US.utf8"}}`, + `"name":"orders"`, + 201, + }, + {http.MethodGet, px + "/databases" + flexAPIPG, "", `"name":"orders"`, 200}, + {http.MethodDelete, px + "/databases/orders" + flexAPIPG, "", "", http.StatusAccepted}, + { + http.MethodPut, + px + "/firewallRules/office" + flexAPIPG, + `{"properties":{"startIpAddress":"10.0.0.1","endIpAddress":"10.0.0.9"}}`, + `"startIpAddress":"10.0.0.1"`, + 201, + }, + {http.MethodGet, px + "/firewallRules" + flexAPIPG, "", `"name":"office"`, 200}, + {http.MethodDelete, px + "/firewallRules/office" + flexAPIPG, "", "", http.StatusAccepted}, + }}, + {"power", []gcpStep{ + {http.MethodPost, px + "/stop" + flexAPIPG, "", "", http.StatusAccepted}, + {http.MethodGet, px + flexAPIPG, "", `"state":"Stopped"`, 200}, + {http.MethodPost, px + "/start" + flexAPIPG, "", "", http.StatusAccepted}, + { + http.MethodPost, + px + "/restart" + flexAPIPG, + `{"restartWithFailover":true,"failoverMode":"x"}`, + "", + http.StatusAccepted, + }, + {http.MethodPost, my + "/restart" + flexAPIMy, "", "", http.StatusAccepted}, + }}, + {"rejects", []gcpStep{ + {http.MethodPut, px + flexAPIPG, `{}`, "unsupported", 400}, + {http.MethodDelete, px + flexAPIPG, "", "unsupported", 400}, + { + http.MethodPut, + px + "/configurations/max_connections" + flexAPIPG, + `{"properties":{"value":"1"}}`, + "unsupported", + 400, + }, + {http.MethodPost, px + "/failover" + flexAPIPG, "", "unsupported", 400}, + {http.MethodPost, px + "/resetAdminPassword" + flexAPIPG, `{}`, "unsupported", 400}, + {http.MethodPost, px + "/administrators/x" + flexAPIPG, `{}`, "unsupported", 400}, + {http.MethodGet, px + "/administrators" + flexAPIPG, "", "unsupported", 400}, + {http.MethodGet, px + "/databases/../x" + flexAPIPG, "", "unsupported", 400}, + {http.MethodPut, subList + flexAPIPG, `{}`, "unsupported", 400}, + { + http.MethodPut, + px + "/databases/bad" + flexAPIPG, + `{"properties":{"charset":"UTF8; DROP"}}`, + "invalid", + 400, + }, + { + http.MethodPut, + px + "/firewallRules/bad" + flexAPIPG, + `{"properties":{"startIpAddress":"nope","endIpAddress":"1.1.1.1"}}`, + "invalid", + 400, + }, + {http.MethodPut, px + "/firewallRules/bad" + flexAPIPG, `not json`, "invalid", 400}, + { + http.MethodGet, + flexPxBase + "/subscriptions/" + armSub + "/providers/Microsoft.Compute/virtualMachines" + flexAPIPG, + "", + "unsupported", + 400, + }, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newFlexDashboard(t) + + for i, s := range tt.steps { + e.advance() + + rec := flexDo(t, e.Echo, s.method, s.path, s.body) + assert.Equal(t, s.wantCode, rec.Code, "step %d %s %s: %s", i, s.method, s.path, rec.Body.String()) + assert.Contains(t, rec.Body.String(), s.wantBody, "step %d", i) + } + }) + } + + t.Run("passwords stay server side", func(t *testing.T) { + t.Parallel() + + e := newFlexDashboard(t) + + for _, p := range []string{subList + flexAPIPG, rgList + flexAPIPG, px + flexAPIPG, my + flexAPIMy} { + body := flexDo(t, e.Echo, http.MethodGet, p, "").Body.String() + assert.NotContains(t, body, flexPxSecret, p) + assert.NotContains(t, strings.ToLower(body), "loginpassword", p) + } + }) + + t.Run("write bodies are rebuilt", func(t *testing.T) { + t.Parallel() + + e := newFlexDashboard(t) + rec := flexDo(t, e.Echo, http.MethodPut, px+"/databases/extra"+flexAPIPG, + `{"properties":{"charset":"UTF8","collation":"en_US.utf8"},"id":"evil","type":"x","extra":{"a":1}}`) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + + got := flexDo(t, e.Echo, http.MethodGet, px+"/databases/extra"+flexAPIPG, "").Body.String() + assert.Contains(t, got, `"charset":"UTF8"`) + assert.NotContains(t, got, "evil") + }) +} diff --git a/dashboard/azurefunctions_proxy.go b/dashboard/azurefunctions_proxy.go index 22ec8bfe42..ed2a6374ab 100644 --- a/dashboard/azurefunctions_proxy.go +++ b/dashboard/azurefunctions_proxy.go @@ -232,11 +232,11 @@ func (a *azureFunctionsAPI) lifecycle(c *echo.Context) error { var err error switch c.Param("action") { - case "start": + case aciPxStart: err = a.b.SetStopped(name, false) case "stop": err = a.b.SetStopped(name, true) - case "restart": + case aciPxRestart: err = a.b.Restart(name) default: return cloudJSONFail(c, http.StatusBadRequest, cloudStatusBadArg, "unsupported action") diff --git a/dashboard/azureinproc_proxy_test.go b/dashboard/azureinproc_proxy_test.go new file mode 100644 index 0000000000..c1faf8b550 --- /dev/null +++ b/dashboard/azureinproc_proxy_test.go @@ -0,0 +1,132 @@ +package dashboard_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/azurearm" +) + +const ( + armSub = "00000000-0000-0000-0000-000000000000" + armBase = "/subscriptions/" + armSub + "/resourceGroups/demo/providers/" +) + +// idleRuntime runs "containers" until they are removed. +type idleRuntime struct { + boxes map[string]chan struct{} + mu sync.Mutex + seq int +} + +func (r *idleRuntime) Pull(context.Context, string, *azurearm.ACICredential) (bool, error) { + return false, nil +} + +func (r *idleRuntime) Start(context.Context, container.Spec) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + r.seq++ + id := "idle-" + string(rune('a'+r.seq)) + r.boxes[id] = make(chan struct{}) + + return id, nil +} + +func (r *idleRuntime) Wait(ctx context.Context, id string) (int, error) { + r.mu.Lock() + gone := r.boxes[id] + r.mu.Unlock() + + select { + case <-gone: + case <-ctx.Done(): + } + + return 0, nil +} + +func (r *idleRuntime) Logs(context.Context, string, string) (string, error) { return "", nil } + +func (r *idleRuntime) Remove(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if c, ok := r.boxes[id]; ok { + close(c) + delete(r.boxes, id) + } + + return nil +} + +// newARMDashboard serves the real Resource Manager (compute, network, container apps) on an idle container runtime +// and seeds it with the given PUTs, issued through the dashboard's own ARM proxy. +func newARMDashboard(t *testing.T, seeds []struct{ path, body string }) *dashboard.DashboardHandler { + t.Helper() + + svc, err := (&azurearm.Provider{}).Init(&service.AppContext{}) + require.NoError(t, err) + + armH, ok := svc.(*azurearm.Handler) + require.True(t, ok) + + ip, ok := armH.Registry.ProviderNamed("Microsoft.ContainerInstance") + require.True(t, ok) + + aci, ok := ip.(*azurearm.ContainerInstanceProvider) + require.True(t, ok) + + rt := &idleRuntime{boxes: map[string]chan struct{}{}} + aci.Engine().SetRuntime(func() (azurearm.ACIRuntime, error) { return rt, nil }) + t.Cleanup(func() { armH.Shutdown(context.Background()) }) + + h := dashboard.NewHandler(dashboard.Config{AzureARMOps: armH}) + + for _, s := range seeds { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/dashboard/api/v1/azure/arm"+s.path, strings.NewReader(s.body)) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", s.path, rec.Body.String()) + } + + return h +} + +// jsonField walks a decoded JSON document by object keys and array indexes. +func jsonField(t *testing.T, raw []byte, path ...any) string { + t.Helper() + + var doc any + require.NoError(t, json.Unmarshal(raw, &doc)) + + for _, p := range path { + switch k := p.(type) { + case string: + m, ok := doc.(map[string]any) + require.True(t, ok, "not an object at %q", k) + doc = m[k] + case int: + a, ok := doc.([]any) + require.True(t, ok && k < len(a), "no element %d", k) + doc = a[k] + } + } + + s, ok := doc.(string) + require.True(t, ok, "not a string: %v", doc) + + return s +} diff --git a/dashboard/azurevm_proxy.go b/dashboard/azurevm_proxy.go new file mode 100644 index 0000000000..7924a2af10 --- /dev/null +++ b/dashboard/azurevm_proxy.go @@ -0,0 +1,60 @@ +package dashboard + +import ( + "net/http" + "slices" +) + +const ( + azureVMPrefix = "/dashboard/api/v1/azure/vm" + vmPxCompute = "microsoft.compute" + vmPxNetwork = "microsoft.network" + vmPxVMs = "virtualmachines" + vmPxInstance = "instanceview" + vmPxChildOnly = 1 +) + +var ( + vmPxPowerActions = []string{aciPxStart, "poweroff", "deallocate", aciPxRestart} //nolint:gochecknoglobals // lookup + vmPxComputeTypes = []string{vmPxVMs, "disks"} //nolint:gochecknoglobals // lookup + vmPxNetworkTypes = []string{ //nolint:gochecknoglobals // lookup + "virtualnetworks", "networksecuritygroups", "networkinterfaces", "publicipaddresses", + } +) + +// registerAzureVMRoutes exposes virtual machines, disks and the network resources they attach to by dispatching +// in-process to the ARM handler. Only reads, a VM's instance view and the power actions are reachable. +func (h *DashboardHandler) registerAzureVMRoutes() { + h.registerARMProxy(armProxy{ + name: "Azure Virtual Machines", prefix: azureVMPrefix, + queryKeys: []string{armQueryVersion, "statusOnly"}, + methods: []string{http.MethodGet, http.MethodPost}, + allowed: vmProxyAllowed, + }) +} + +func vmProxyAllowed(method string, p []string) bool { + if typ, name, rest, ok := armScope(p, vmPxCompute); ok && slices.Contains(vmPxComputeTypes, typ) { + return vmProxyComputeOp(method, typ, name, rest) + } + + typ, name, rest, ok := armScope(p, vmPxNetwork) + + return ok && slices.Contains(vmPxNetworkTypes, typ) && method == http.MethodGet && (name == "" || len(rest) == 0) +} + +func vmProxyComputeOp(method, typ, name string, rest []string) bool { + if name == "" || len(rest) == 0 { + return method == http.MethodGet + } + + if typ != vmPxVMs || len(rest) != vmPxChildOnly { + return false + } + + if method == http.MethodGet { + return rest[0] == vmPxInstance + } + + return method == http.MethodPost && slices.Contains(vmPxPowerActions, rest[0]) +} diff --git a/dashboard/azurevm_proxy_test.go b/dashboard/azurevm_proxy_test.go new file mode 100644 index 0000000000..5feb053cc2 --- /dev/null +++ b/dashboard/azurevm_proxy_test.go @@ -0,0 +1,145 @@ +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" +) + +const ( + vmComputeAPI = "?api-version=2024-07-01" + vmNetworkAPI = "?api-version=2024-05-01" + vmNet = armBase + "Microsoft.Network" + vmVNet = vmNet + "/virtualNetworks/vnet" + vmSubnet = vmVNet + "/subnets/default" + vmPIP = vmNet + "/publicIPAddresses/pip" + vmNIC = vmNet + "/networkInterfaces/web-nic" + vmVM = armBase + "Microsoft.Compute/virtualMachines/web" + vmProxy = "/dashboard/api/v1/azure/vm" + vmPassword = "P@ssw0rd-Correct-1" +) + +func newVMDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + rule := `{"name":"ssh","properties":{"priority":100,"direction":"Inbound","access":"Allow","protocol":"Tcp",` + + `"sourcePortRange":"*","destinationPortRange":"22","sourceAddressPrefix":"*","destinationAddressPrefix":"*"}}` + + return newARMDashboard(t, []struct{ path, body string }{ + {"/subscriptions/" + armSub + "/resourceGroups/demo?api-version=2021-04-01", `{"location":"eastus"}`}, + {vmNet + "/networkSecurityGroups/nsg" + vmNetworkAPI, + `{"location":"eastus","properties":{"securityRules":[` + rule + `]}}`}, + { + vmVNet + vmNetworkAPI, + `{"location":"eastus","properties":{"addressSpace":{"addressPrefixes":["10.0.0.0/16"]},` + + `"subnets":[{"name":"default","properties":{"addressPrefix":"10.0.0.0/24"}}]}}`, + }, + {vmPIP + vmNetworkAPI, `{"location":"eastus","sku":{"name":"Standard"},` + + `"properties":{"publicIPAllocationMethod":"Static"}}`}, + {vmNIC + vmNetworkAPI, `{"location":"eastus","properties":{"ipConfigurations":[{"name":"ipc","properties":{` + + `"subnet":{"id":"` + vmSubnet + `"},"privateIPAllocationMethod":"Dynamic",` + + `"publicIPAddress":{"id":"` + vmPIP + `"}}}]}}`}, + {vmVM + vmComputeAPI, `{"location":"eastus","properties":{"hardwareProfile":{"vmSize":"Standard_B2s"},` + + `"storageProfile":{"imageReference":{"publisher":"Canonical","offer":"0001-com-ubuntu-server-jammy",` + + `"sku":"22_04-lts","version":"latest"},"osDisk":{"createOption":"FromImage","name":"web-os"}},` + + `"osProfile":{"computerName":"web","adminUsername":"azureuser","adminPassword":"` + vmPassword + `",` + + `"customData":"c2VjcmV0LWluaXQ=","linuxConfiguration":{"disablePasswordAuthentication":false}},` + + `"networkProfile":{"networkInterfaces":[{"id":"` + vmNIC + `"}]}}}`}, + }) +} + +func TestAzureVMProxy(t *testing.T) { + t.Parallel() + + px := vmProxy + vmVM + subs := vmProxy + "/subscriptions/" + armSub + "/providers/Microsoft." + + tests := []struct { + name string + steps []gcpStep + }{ + {"read", []gcpStep{ + { + http.MethodGet, + subs + "Compute/virtualMachines" + vmComputeAPI + "&statusOnly=true", + "", + "PowerState/running", + 200, + }, + {http.MethodGet, px + vmComputeAPI, "", `"vmSize":"Standard_B2s"`, 200}, + {http.MethodGet, px + "/instanceView" + vmComputeAPI, "", "PowerState/running", 200}, + {http.MethodGet, subs + "Compute/disks" + vmComputeAPI, "", `"name":"web-os"`, 200}, + {http.MethodGet, subs + "Network/virtualNetworks" + vmNetworkAPI, "", `"10.0.0.0/16"`, 200}, + {http.MethodGet, subs + "Network/networkSecurityGroups" + vmNetworkAPI, "", `"ssh"`, 200}, + {http.MethodGet, subs + "Network/networkInterfaces" + vmNetworkAPI, "", `"web-nic"`, 200}, + {http.MethodGet, subs + "Network/publicIPAddresses" + vmNetworkAPI, "", `"ipAddress"`, 200}, + {http.MethodGet, vmProxy + vmVNet + vmNetworkAPI, "", `"name":"default"`, 200}, + }}, + {"power", []gcpStep{ + {http.MethodPost, px + "/powerOff" + vmComputeAPI, "", "", http.StatusAccepted}, + {http.MethodGet, px + "/instanceView" + vmComputeAPI, "", "PowerState/stopped", 200}, + {http.MethodPost, px + "/start" + vmComputeAPI, "", "", http.StatusAccepted}, + {http.MethodGet, px + "/instanceView" + vmComputeAPI, "", "PowerState/running", 200}, + {http.MethodPost, px + "/restart" + vmComputeAPI, "", "", http.StatusAccepted}, + {http.MethodPost, px + "/deallocate" + vmComputeAPI, "", "", http.StatusAccepted}, + {http.MethodGet, px + "/instanceView" + vmComputeAPI, "", "PowerState/deallocated", 200}, + }}, + {"rejects", []gcpStep{ + {http.MethodDelete, px + vmComputeAPI, "", "", http.StatusMethodNotAllowed}, + {http.MethodPut, px + vmComputeAPI, `{}`, "", http.StatusMethodNotAllowed}, + {http.MethodPost, px + "/runCommand" + vmComputeAPI, `{}`, "unsupported", 400}, + {http.MethodPost, px + "/generalize" + vmComputeAPI, "", "unsupported", 400}, + {http.MethodPost, px + "/capture" + vmComputeAPI, "", "unsupported", 400}, + { + http.MethodPost, + subs + "Network/virtualNetworks/vnet/subnets/default" + vmNetworkAPI, + "", + "unsupported", + 400, + }, + {http.MethodPost, vmProxy + vmNIC + vmNetworkAPI, "", "unsupported", 400}, + {http.MethodGet, subs + "Storage/storageAccounts" + vmNetworkAPI, "", "unsupported", 400}, + {http.MethodGet, subs + "Compute/snapshots" + vmComputeAPI, "", "unsupported", 400}, + {http.MethodGet, px + "/../x" + vmComputeAPI, "", "unsupported", 400}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, newVMDashboard(t), tt.steps) + }) + } + + t.Run("secrets stay server side", func(t *testing.T) { + t.Parallel() + + h := newVMDashboard(t) + for _, p := range []string{ + px + vmComputeAPI, subs + "Compute/virtualMachines" + vmComputeAPI + "&statusOnly=true", + px + "/instanceView" + vmComputeAPI, + } { + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext(httptest.NewRequest(http.MethodGet, p, nil), rec))) + assert.Equal(t, http.StatusOK, rec.Code, p) + assert.NotContains(t, rec.Body.String(), vmPassword, p) + assert.NotContains(t, rec.Body.String(), "c2VjcmV0LWluaXQ=", p) + assert.NotContains(t, rec.Body.String(), "adminPassword", p) + } + }) + + t.Run("unserved", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, subs+"Compute/virtualMachines"+vmComputeAPI, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/gcp_proxy_common.go b/dashboard/gcp_proxy_common.go index f27ab679c3..336efbbd6e 100644 --- a/dashboard/gcp_proxy_common.go +++ b/dashboard/gcp_proxy_common.go @@ -170,6 +170,8 @@ func gcpHTTPStatus(code codes.Code) (int, string) { return http.StatusBadRequest, "INVALID_ARGUMENT" case codes.NotFound: return http.StatusNotFound, "NOT_FOUND" + case codes.Aborted: + return http.StatusConflict, "ABORTED" case codes.AlreadyExists: return http.StatusConflict, "ALREADY_EXISTS" case codes.FailedPrecondition: @@ -219,13 +221,38 @@ func (h *DashboardHandler) registerConsolePageRoutes() { h.registerAzureFunctionsRoutes() h.registerGCPWorkflowsRoutes() h.registerGCPDNSRoutes() + h.registerGCPDatastoreRoutes() h.registerAzureFileRoutes() h.registerOCIObjectStorageRoutes() h.registerOCIVaultRoutes() h.registerOCIStreamingRoutes() h.registerOCIIdentityRoutes() h.registerOCIFunctionsRoutes() + h.registerOCICoreRoutes() h.registerAzureMonitorRoutes() h.registerGCPComputeRoutes() h.registerAzureACIRoutes() + h.registerAzureAppServiceRoutes() + h.registerGCPContainerRoutes() + h.registerOCIQueueRoutes() + h.registerOCINotificationsRoutes() + h.registerAzureContainerAppsRoutes() + h.registerAzureVMRoutes() + h.registerAzureAKSRoutes() + h.registerOCIContainerEngineRoutes() + h.registerOCIMySQLRoutes() + h.registerOCILoggingRoutes() + h.registerOCIMonitoringRoutes() + h.registerOCINoSQLRoutes() + h.registerOCIAPIGatewayRoutes() + h.registerOCIEventsRoutes() + h.registerOCILoadBalancerRoutes() + h.registerOCIArtifactsRoutes() + h.registerOCIContainerInstancesRoutes() + h.registerGCPIdentityToolkitRoutes() + h.registerAzureFlexDBRoutes() + h.registerGCPAPIGatewayRoutes() + h.registerGCPBatchRoutes() + h.registerGCPResourceManagerRoutes() + h.registerAzureAPIMRoutes() } diff --git a/dashboard/gcpapigateway_proxy.go b/dashboard/gcpapigateway_proxy.go new file mode 100644 index 0000000000..42cfd4d2f6 --- /dev/null +++ b/dashboard/gcpapigateway_proxy.go @@ -0,0 +1,271 @@ +package dashboard + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/blackbirdworks/gopherstack/services/gcpapigateway" +) + +const ( + gcpAPIGWPrefix = "/dashboard/api/v1/gcp/apigateway" + gcpAPIGWScopeSegs = 4 + gcpAPIGWListSegs = 5 + gcpAPIGWItemSegs = 6 + gcpAPIGWSubSegs = 7 + gcpAPIGWSubItem = 8 + gcpAPIGWLocIdx = 3 + gcpAPIGWKindIdx = 4 + gcpAPIGWSubKindIdx = 6 + gcpAPIGWKeyParam = "key" + gcpAPIGWMaxKey = 256 + gcpAPIGWInvoke = "invoke" + gcpAPIGWAPIs = "apis" + gcpAPIGWOps = "operations" + gcpAPIGWGlobal = "global" + gcpAPIGWMaxBody = ociAPIGWMaxTryJSON + 2*gcpAPIGWMaxKey +) + +var gcpAPIGWIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + +// registerGCPAPIGatewayRoutes serves the API Gateway page by calling the control plane in-process. Reads cover APIs, +// configs, gateways and operations; the only writes are creating a gateway from an existing config (body rebuilt from +// fixed fields) and deleting a gateway. "Try it" replays one request through the in-process data plane of a selected +// ACTIVE gateway; the destination is never taken from the request and an API key is only ever forwarded as `key`. +func (h *DashboardHandler) registerGCPAPIGatewayRoutes() { + var route gcpRESTRoute + + if ops := h.config.GCPAPIGatewayOps; ops != nil { + px := &gcpAPIGWProxy{api: ops.API(), ops: ops.OperationsAPI(), be: ops.Backend, data: ops.Backend.DataPlane()} + route = px.route + } + + h.registerGCPRESTProxy(gcpAPIGWPrefix, route) +} + +type gcpAPIGWProxy struct { + api apigatewaypb.ApiGatewayServiceServer + ops longrunningpb.OperationsServer + be *gcpapigateway.InMemoryBackend + data http.Handler +} + +func (a *gcpAPIGWProxy) route(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + if len(r.segs) == 1 && r.segs[0] == gcpRESTProjectsSeg && r.method == http.MethodGet && r.verb == "" { + return structOf(map[string]any{gcpRESTProjectsSeg: a.be.ProjectIDs()}) + } + + n := len(r.segs) + if n < gcpAPIGWListSegs || n > gcpAPIGWSubItem || r.segs[0] != gcpRESTProjectsSeg || + r.segs[gcpAPIGWScopeSegs-2] != "locations" || !gcpAPIGWIDs(r.segs[1], r.segs[gcpAPIGWLocIdx]) { + return nil, errGCPUnsupported() + } + + switch r.segs[gcpAPIGWKindIdx] { + case gcpAPIGWAPIs: + return a.apis(ctx, r) + case "gateways": + return a.gateways(ctx, r) + case gcpAPIGWOps: + return a.operations(ctx, r) + } + + return nil, errGCPUnsupported() +} + +func gcpAPIGWIDs(ids ...string) bool { + for _, id := range ids { + if id != "-" && !gcpAPIGWIDRE.MatchString(id) { + return false + } + } + + return true +} + +func (a *gcpAPIGWProxy) apis(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + n := len(r.segs) + if r.method != http.MethodGet || r.verb != "" || (n > gcpAPIGWListSegs && !gcpAPIGWIDs(r.segs[gcpAPIGWListSegs])) { + return nil, errGCPUnsupported() + } + + switch { + case n == gcpAPIGWListSegs: + return a.api.ListApis(ctx, &apigatewaypb.ListApisRequest{ + Parent: r.name(gcpAPIGWScopeSegs), PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), + }) + case n == gcpAPIGWItemSegs: + return a.api.GetApi(ctx, &apigatewaypb.GetApiRequest{Name: r.name(n)}) + case n == gcpAPIGWSubSegs && r.segs[gcpAPIGWSubKindIdx] == "configs": + return a.api.ListApiConfigs(ctx, &apigatewaypb.ListApiConfigsRequest{ + Parent: r.name(gcpAPIGWItemSegs), PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), + }) + case n == gcpAPIGWSubItem && r.segs[gcpAPIGWSubKindIdx] == "configs" && gcpAPIGWIDs(r.segs[n-1]): + return a.api.GetApiConfig(ctx, &apigatewaypb.GetApiConfigRequest{ + Name: r.name(n), View: apigatewaypb.GetApiConfigRequest_FULL, + }) + } + + return nil, errGCPUnsupported() +} + +func (a *gcpAPIGWProxy) gateways(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + n := len(r.segs) + + switch { + case n == gcpAPIGWListSegs && r.verb == "" && r.method == http.MethodGet: + return a.api.ListGateways(ctx, &apigatewaypb.ListGatewaysRequest{ + Parent: r.name(gcpAPIGWScopeSegs), PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), + }) + case n == gcpAPIGWListSegs && r.verb == "" && r.method == http.MethodPost: + return a.createGateway(ctx, r) + case n != gcpAPIGWItemSegs || !gcpAPIGWIDs(r.segs[n-1]): + return nil, errGCPUnsupported() + case r.verb == "" && r.method == http.MethodGet: + return a.api.GetGateway(ctx, &apigatewaypb.GetGatewayRequest{Name: r.name(n)}) + case r.verb == "" && r.method == http.MethodDelete: + return a.api.DeleteGateway(ctx, &apigatewaypb.DeleteGatewayRequest{Name: r.name(n)}) + case r.verb == gcpAPIGWInvoke && r.method == http.MethodPost: + return a.tryIt(ctx, r) + } + + return nil, errGCPUnsupported() +} + +// createGateway forwards only the display name and the config reference of the caller's body. +func (a *gcpAPIGWProxy) createGateway(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + in := &apigatewaypb.Gateway{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + id := r.query.Get("gatewayId") + cfg := in.GetApiConfig() + prefix := "projects/" + r.segs[1] + "/locations/" + gcpAPIGWGlobal + "/apis/" + + if !gcpAPIGWIDs(id) || !strings.HasPrefix(cfg, prefix) || len(cfg) > len(prefix)+2*gcpAPIGWMaxKey || + strings.Count(cfg, "/") != strings.Count(prefix, "/")+2 || strings.Contains(cfg, "..") { + return nil, status.Error(codes.InvalidArgument, "gatewayId and a project api_config are required") + } + + return a.api.CreateGateway(ctx, &apigatewaypb.CreateGatewayRequest{ + Parent: r.name(gcpAPIGWScopeSegs), GatewayId: id, + Gateway: &apigatewaypb.Gateway{DisplayName: in.GetDisplayName(), ApiConfig: cfg}, + }) +} + +func (a *gcpAPIGWProxy) operations(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + n := len(r.segs) + if r.method != http.MethodGet || r.verb != "" { + return nil, errGCPUnsupported() + } + + switch { + case n == gcpAPIGWListSegs: + return a.ops.ListOperations(ctx, &longrunningpb.ListOperationsRequest{ + Name: r.name(gcpAPIGWScopeSegs), PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), + }) + case n == gcpAPIGWItemSegs && gcpAPIGWIDs(r.segs[n-1]): + return a.ops.GetOperation(ctx, &longrunningpb.GetOperationRequest{Name: r.name(n)}) + } + + return nil, errGCPUnsupported() +} + +type gcpAPIGWTryIn struct { + APIKey string `json:"apiKey"` + ociAPIGWTryIn +} + +func (a *gcpAPIGWProxy) tryIt(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + bad := status.Error(codes.InvalidArgument, "invalid API Gateway try-it request") + + var in gcpAPIGWTryIn + if len(r.body) > gcpAPIGWMaxBody || json.Unmarshal(r.body, &in) != nil { + return nil, bad + } + + target, ok := gcpAPIGWTryTarget(r.segs[1], r.segs[gcpAPIGWLocIdx], r.segs[len(r.segs)-1], in) + if !ok { + return nil, bad + } + + gw, err := a.api.GetGateway(ctx, &apigatewaypb.GetGatewayRequest{Name: r.name(gcpAPIGWItemSegs)}) + if err != nil { + return nil, err + } + + if gw.GetState() != apigatewaypb.Gateway_ACTIVE { + return nil, status.Error(codes.FailedPrecondition, "Gateway is in state "+gw.GetState().String()) + } + + req := httptest.NewRequestWithContext(ctx, in.Method, target, strings.NewReader(in.Body)) + + for _, hd := range in.Headers { + if ociAPIGWHeaderOK(hd.Name) { + req.Header.Add(hd.Name, hd.Value) + } + } + + rec := httptest.NewRecorder() + a.data.ServeHTTP(rec, req) + + return structOf(ociAPIGWTryOut(rec, in.APIKey)) +} + +// gcpAPIGWTryTarget validates the request and returns the path-style data-plane URL of one gateway, with the API key +// (when given) as the only `key` query parameter. +func gcpAPIGWTryTarget(project, location, gateway string, in gcpAPIGWTryIn) (string, bool) { + rel, ok := ociAPIGWTryPath(in.ociAPIGWTryIn) + if !ok || !ociAPIGWMethodOK(in.Method) || len(in.Headers) > ociAPIGWMaxHeaders { + return "", false + } + + if len(in.Body) > ociAPIGWMaxReqBody || len(in.APIKey) > gcpAPIGWMaxKey || + strings.ContainsAny(in.APIKey, "\r\n\x00") { + return "", false + } + + path, rawQuery, _ := strings.Cut(rel, "?") + q, _ := url.ParseQuery(rawQuery) + + if in.APIKey != "" { + q.Set(gcpAPIGWKeyParam, in.APIKey) + } + + target := gcpapigateway.PathPrefix + url.PathEscape(project) + "/" + url.PathEscape(location) + "/" + + url.PathEscape(gateway) + path + if len(q) > 0 { + target += "?" + q.Encode() + } + + return target, true +} + +// structOf converts a JSON-compatible value into the protobuf Struct the REST proxy renders. +func structOf(v any) (proto.Message, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, status.Error(codes.Internal, "unreadable response") + } + + s := &structpb.Struct{} + if err = (protojson.UnmarshalOptions{}).Unmarshal(raw, s); err != nil { + return nil, status.Error(codes.Internal, "unreadable response") + } + + return s, nil +} diff --git a/dashboard/gcpapigateway_proxy_test.go b/dashboard/gcpapigateway_proxy_test.go new file mode 100644 index 0000000000..4aeeb4659f --- /dev/null +++ b/dashboard/gcpapigateway_proxy_test.go @@ -0,0 +1,305 @@ +package dashboard_test + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/gcpapigateway" +) + +const ( + agwProject = "agw-proj" + agwBase = "/dashboard/api/v1/gcp/apigateway/v1/projects/" + agwProject + agwGlobal = agwBase + "/locations/global" + agwRegion = agwBase + "/locations/us-central1" + agwGW = agwRegion + "/gateways/edge" + agwKey = "s3cr3t-api-key" +) + +const agwSpec = "swagger: \"2.0\"\ninfo:\n title: t\n version: \"1.0\"\nx-google-backend:\n address: %s\n" + + "securityDefinitions:\n api_key:\n type: apiKey\n name: key\n in: query\npaths:\n" + + " /open:\n get:\n responses:\n \"200\":\n description: ok\n" + + " /locked:\n get:\n security:\n - api_key: []\n responses:\n \"200\":\n" + + " description: ok\n" + +const agwBadSpec = "swagger: \"2.0\"\ninfo:\n title: t\n version: \"1.0\"\npaths:\n /x:\n get:\n" + + " responses:\n \"200\":\n description: ok\n" + +func newAPIGatewayDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + be := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Echo-Query", r.URL.RawQuery) + _ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path, "query": r.URL.RawQuery}) + })) + t.Cleanup(be.Close) + + b := gcpapigateway.NewInMemoryBackend() + b.SetTiming(gcpapigateway.Timing{}) + + _, err := b.CreateAPI(&apigatewaypb.CreateApiRequest{ + Parent: "projects/" + agwProject + "/locations/global", ApiId: "orders", + Api: &apigatewaypb.Api{DisplayName: "Orders"}, + }) + require.NoError(t, err) + + cfg := func(id, doc string) { + _, cerr := b.CreateAPIConfig(&apigatewaypb.CreateApiConfigRequest{ + Parent: "projects/" + agwProject + "/locations/global/apis/orders", ApiConfigId: id, + ApiConfig: &apigatewaypb.ApiConfig{OpenapiDocuments: []*apigatewaypb.ApiConfig_OpenApiDocument{{ + Document: &apigatewaypb.ApiConfig_File{Path: "openapi.yaml", Contents: []byte(doc)}, + }}}, + }) + require.NoError(t, cerr) + } + + cfg("good", strings.Replace(agwSpec, "%s", be.URL, 1)) + cfg("bad", agwBadSpec) + + _, err = b.CreateGateway(&apigatewaypb.CreateGatewayRequest{ + Parent: "projects/" + agwProject + "/locations/us-central1", GatewayId: "edge", + Gateway: &apigatewaypb.Gateway{ + ApiConfig: "projects/" + agwProject + "/locations/global/apis/orders/configs/good", + }, + }) + require.NoError(t, err) + + return dashboard.NewHandler(dashboard.Config{GCPAPIGatewayOps: gcpapigateway.NewHandler(b)}) +} + +func agwDo(t *testing.T, h *dashboard.DashboardHandler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + + return rec +} + +func TestGCPAPIGatewayProxy(t *testing.T) { + t.Parallel() + + t.Run("reads", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, newAPIGatewayDashboard(t), []gcpStep{ + {http.MethodGet, "/dashboard/api/v1/gcp/apigateway/v1/projects", "", agwProject, 200}, + {http.MethodGet, agwGlobal + "/apis", "", `"displayName":"Orders"`, 200}, + {http.MethodGet, agwGlobal + "/apis/orders", "", `"managedService"`, 200}, + {http.MethodGet, agwGlobal + "/apis/orders/configs", "", `configs/bad"`, 200}, + {http.MethodGet, agwGlobal + "/apis/orders/configs/good", "", `"openapiDocuments"`, 200}, + {http.MethodGet, agwRegion + "/gateways", "", `"defaultHostname"`, 200}, + {http.MethodGet, agwGW, "", `"state":"ACTIVE"`, 200}, + {http.MethodGet, agwRegion + "/operations", "", `"operations"`, 200}, + {http.MethodGet, agwGlobal + "/operations", "", `"diagnostics"`, 200}, + {http.MethodGet, agwBase + "/locations/-/gateways", "", `gateways/edge`, 200}, + }) + }) + + t.Run("config view carries the document", func(t *testing.T) { + t.Parallel() + + rec := agwDo(t, newAPIGatewayDashboard(t), http.MethodGet, agwGlobal+"/apis/orders/configs/good", "") + require.Equal(t, http.StatusOK, rec.Code) + + var cfg struct { + Docs []struct { + Document struct { + Contents string `json:"contents"` + } `json:"document"` + } `json:"openapiDocuments"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cfg)) + require.Len(t, cfg.Docs, 1) + + raw, err := base64.StdEncoding.DecodeString(cfg.Docs[0].Document.Contents) + require.NoError(t, err) + assert.Contains(t, string(raw), "securityDefinitions") + }) + + t.Run("create and delete a gateway", func(t *testing.T) { + t.Parallel() + + h := newAPIGatewayDashboard(t) + cfg := "projects/" + agwProject + "/locations/global/apis/orders/configs/good" + + rec := agwDo( + t, + h, + http.MethodPost, + agwRegion+"/gateways?gatewayId=second", + `{"displayName":"Second","apiConfig":"`+cfg+`","labels":{"x":"y"},"name":"projects/evil/locations/x/gateways/z"}`, + ) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + got := agwDo(t, h, http.MethodGet, agwRegion+"/gateways/second", "") + require.Equal(t, http.StatusOK, got.Code, got.Body.String()) + assert.Contains(t, got.Body.String(), `"displayName":"Second"`) + assert.NotContains(t, got.Body.String(), `"x":"y"`) + + del := agwDo(t, h, http.MethodDelete, agwRegion+"/gateways/second", "") + require.Equal(t, http.StatusOK, del.Code, del.Body.String()) + assert.Contains(t, del.Body.String(), "operations/") + }) + + t.Run("rejects", func(t *testing.T) { + t.Parallel() + + cfg := "projects/" + agwProject + "/locations/global/apis/orders/configs/good" + other := "projects/other/locations/global/apis/orders/configs/good" + + runGCPSteps(t, newAPIGatewayDashboard(t), []gcpStep{ + {http.MethodPost, agwGlobal + "/apis", `{}`, "unsupported", 404}, + {http.MethodDelete, agwGlobal + "/apis/orders", "", "unsupported", 404}, + {http.MethodPatch, agwGW, `{}`, "unsupported", 404}, + {http.MethodPost, agwGW, `{}`, "unsupported", 404}, + {http.MethodPost, agwRegion + "/operations", `{}`, "unsupported", 404}, + {http.MethodGet, agwGlobal + "/apis/orders/configs/good:getIamPolicy", "", "unsupported", 404}, + {http.MethodGet, agwGlobal + "/secrets", "", "unsupported", 404}, + {http.MethodGet, agwBase + "/locations/global/apis/%2e%2e", "", "unsupported", 400}, + {http.MethodPost, agwRegion + "/gateways?gatewayId=g1", `{"apiConfig":"` + other + `"}`, "api_config", 400}, + {http.MethodPost, agwRegion + "/gateways", `{"apiConfig":"` + cfg + `"}`, "api_config", 400}, + { + http.MethodPost, + agwRegion + "/gateways?gatewayId=g1", + `{"apiConfig":"` + cfg + `/../x"}`, + "api_config", + 400, + }, + {http.MethodPost, agwRegion + "/gateways?gatewayId=g1", `nope`, "invalid", 400}, + }) + }) + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, agwGlobal + "/apis", "", "", http.StatusServiceUnavailable}, + }) + }) +} + +func TestGCPAPIGatewayTryIt(t *testing.T) { + t.Parallel() + + type out struct { + Headers map[string]string `json:"headers"` + Body string `json:"body"` + Status float64 `json:"status"` + } + + try := func(t *testing.T, h *dashboard.DashboardHandler, gw, body string) (*httptest.ResponseRecorder, out) { + t.Helper() + + rec := agwDo(t, h, http.MethodPost, gw+":invoke", body) + + var o out + + if rec.Code == http.StatusOK { + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &o)) + } + + return rec, o + } + + t.Run("open route", func(t *testing.T) { + t.Parallel() + + rec, o := try(t, newAPIGatewayDashboard(t), agwGW, `{"method":"GET","path":"/open","query":"a=1"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.InDelta(t, 200, o.Status, 0) + assert.Contains(t, o.Body, `"path":"/open"`) + }) + + t.Run("api key reaches the gateway and never comes back", func(t *testing.T) { + t.Parallel() + + h := newAPIGatewayDashboard(t) + + _, denied := try(t, h, agwGW, `{"method":"GET","path":"/locked"}`) + assert.InDelta(t, 401, denied.Status, 0) + + rec, o := try(t, h, agwGW, `{"method":"GET","path":"/locked","query":"key=ignored","apiKey":"`+agwKey+`"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.InDelta(t, 200, o.Status, 0) + assert.Contains(t, o.Body, "[redacted]") + assert.NotContains(t, rec.Body.String(), agwKey) + assert.NotContains(t, rec.Body.String(), "ignored") + }) + + t.Run("hop-by-hop headers are not forwarded", func(t *testing.T) { + t.Parallel() + + rec, o := try(t, newAPIGatewayDashboard(t), agwGW, `{"method":"GET","path":"/open","headers":[`+ + `{"name":"Connection","value":"close"},{"name":"X-A","value":"1"}]}`) + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, o.Headers, "Connection") + assert.Contains(t, o.Headers, "X-Echo-Query") + }) + + t.Run("rejections", func(t *testing.T) { + t.Parallel() + + h := newAPIGatewayDashboard(t) + tests := []struct { + name string + gw string + body string + want int + }{ + {"unknown gateway", agwRegion + "/gateways/nope", `{"method":"GET","path":"/open"}`, 404}, + {"bad method", agwGW, `{"method":"TRACE","path":"/open"}`, 400}, + {"relative path", agwGW, `{"method":"GET","path":"open"}`, 400}, + {"absolute url", agwGW, `{"method":"GET","path":"http://evil.example/x"}`, 400}, + {"dot segments", agwGW, `{"method":"GET","path":"/a/../x"}`, 400}, + {"path with query", agwGW, `{"method":"GET","path":"/open?a=b"}`, 400}, + {"fragment", agwGW, `{"method":"GET","path":"/open","query":"a#b"}`, 400}, + {"key with newline", agwGW, `{"method":"GET","path":"/open","apiKey":"a\nb"}`, 400}, + { + "oversize body", + agwGW, + `{"method":"POST","path":"/open","body":"` + strings.Repeat("a", 70000) + `"}`, + 400, + }, + {"not json", agwGW, `nope`, 400}, + {"gateway traversal", agwRegion + "/gateways/%2e%2e", `{"method":"GET","path":"/open"}`, 404}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := agwDo(t, h, http.MethodPost, tt.gw+":invoke", tt.body) + assert.Equal(t, tt.want, rec.Code, rec.Body.String()) + }) + } + }) + + t.Run("destination is never taken from the request", func(t *testing.T) { + t.Parallel() + + h := newAPIGatewayDashboard(t) + rec := agwDo(t, h, http.MethodPost, agwGW+":invoke", + `{"method":"GET","path":"/open","host":"evil.example","url":"http://evil.example/"}`) + require.Equal(t, http.StatusOK, rec.Code) + + b, err := io.ReadAll(rec.Body) + require.NoError(t, err) + assert.NotContains(t, string(b), "evil.example") + }) +} diff --git a/dashboard/gcpbatch_proxy.go b/dashboard/gcpbatch_proxy.go new file mode 100644 index 0000000000..e9065445ef --- /dev/null +++ b/dashboard/gcpbatch_proxy.go @@ -0,0 +1,227 @@ +package dashboard + +import ( + "context" + "net/http" + "regexp" + "strings" + + "cloud.google.com/go/batch/apiv1/batchpb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +const ( + gcpBatchPrefix = "/dashboard/api/v1/gcp/batch" + gcpBatchListSegs = 5 + gcpBatchItemSegs = 6 + gcpBatchTasksSegs = 9 + gcpBatchLocIdx = 3 + gcpBatchKindIdx = 4 + gcpBatchGroupIdx = 7 + gcpBatchMaxCmds = 32 + gcpBatchMaxCmdLen = 2048 + gcpBatchMaxVars = 32 + gcpBatchMaxVarLen = 4096 + gcpBatchMaxTasks = 1000 + gcpBatchGroup = "group0" + gcpBatchOrder = "create_time desc" + gcpBatchVerbCancel = "cancel" +) + +var gcpBatchIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + +// registerGCPBatchRoutes serves the Cloud Batch page by calling the control plane in-process. Reads cover jobs and the +// tasks of the single task group; writes are creating a job (body rebuilt from the image, commands, task count, +// parallelism, labels and plain variables only), cancelling and deleting one. Secret variables are never forwarded. +func (h *DashboardHandler) registerGCPBatchRoutes() { + var route gcpRESTRoute + + if ops := h.config.GCPBatchOps; ops != nil { + route = (&gcpBatchProxy{api: ops.API(), be: ops.Backend}).route + } + + h.registerGCPRESTProxy(gcpBatchPrefix, route) +} + +type gcpBatchProxy struct { + api batchpb.BatchServiceServer + be *gcpbatch.InMemoryBackend +} + +func gcpBatchIDs(ids ...string) bool { + for _, id := range ids { + if id != "-" && !gcpBatchIDRE.MatchString(id) { + return false + } + } + + return true +} + +func (p *gcpBatchProxy) route(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + if len(r.segs) == 1 && r.segs[0] == gcpRESTProjectsSeg && r.method == http.MethodGet && r.verb == "" { + return structOf(map[string]any{gcpRESTProjectsSeg: p.be.ProjectIDs()}) + } + + n := len(r.segs) + if n < gcpBatchListSegs || n > gcpBatchTasksSegs || !gcpBatchScoped(r.segs) { + return nil, errGCPUnsupported() + } + + switch { + case n == gcpBatchListSegs: + return p.collection(ctx, r) + case !gcpBatchIDs(r.segs[gcpBatchListSegs]): + return nil, errGCPUnsupported() + case n == gcpBatchItemSegs: + return p.job(ctx, r) + case gcpBatchTasksPath(r): + return p.api.ListTasks(ctx, &batchpb.ListTasksRequest{ + Parent: r.name(gcpBatchGroupIdx + 1), PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), + }) + } + + return nil, errGCPUnsupported() +} + +func (p *gcpBatchProxy) collection(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + parent := r.name(gcpBatchKindIdx) + + switch { + case r.verb == "" && r.method == http.MethodGet: + return p.api.ListJobs(ctx, &batchpb.ListJobsRequest{ + Parent: parent, PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), OrderBy: gcpBatchOrder, + }) + case r.verb == "" && r.method == http.MethodPost: + return p.createJob(ctx, r, parent) + } + + return nil, errGCPUnsupported() +} + +func gcpBatchScoped(segs []string) bool { + return segs[0] == gcpRESTProjectsSeg && segs[2] == cbLocationsKind && segs[gcpBatchKindIdx] == "jobs" && + gcpBatchIDs(segs[1], segs[gcpBatchLocIdx]) +} + +func gcpBatchTasksPath(r gcpRESTCall) bool { + n := len(r.segs) + + return n == gcpBatchTasksSegs && r.verb == "" && r.method == http.MethodGet && + r.segs[gcpBatchGroupIdx-1] == "taskGroups" && r.segs[gcpBatchGroupIdx] == gcpBatchGroup && + r.segs[n-1] == "tasks" +} + +func (p *gcpBatchProxy) job(ctx context.Context, r gcpRESTCall) (proto.Message, error) { + name := r.name(gcpBatchItemSegs) + + switch { + case r.verb == "" && r.method == http.MethodGet: + return p.api.GetJob(ctx, &batchpb.GetJobRequest{Name: name}) + case r.verb == "" && r.method == http.MethodDelete: + return p.api.DeleteJob(ctx, &batchpb.DeleteJobRequest{Name: name}) + case r.verb == gcpBatchVerbCancel && r.method == http.MethodPost: + return p.api.CancelJob(ctx, &batchpb.CancelJobRequest{Name: name}) + } + + return nil, errGCPUnsupported() +} + +func gcpBatchBad(msg string) error { return status.Error(codes.InvalidArgument, msg) } + +// createJob forwards one container runnable with plain variables; every other field of the caller's body is dropped. +func (p *gcpBatchProxy) createJob(ctx context.Context, r gcpRESTCall, parent string) (proto.Message, error) { + in := &batchpb.Job{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + id := r.query.Get("jobId") + if id == "" || !gcpBatchIDs(id) { + return nil, gcpBatchBad("jobId is required") + } + + g, box, err := gcpBatchSingle(in) + if err != nil { + return nil, err + } + + vars := g.GetTaskSpec().GetEnvironment().GetVariables() + if err = gcpBatchLimits(g, box, vars); err != nil { + return nil, err + } + + job := gcpBatchRebuild(in.GetLabels(), g, box, vars) + + return p.api.CreateJob(ctx, &batchpb.CreateJobRequest{Parent: parent, JobId: id, Job: job}) +} + +func gcpBatchRebuild( + labels map[string]string, g *batchpb.TaskGroup, box *batchpb.Runnable_Container, vars map[string]string, +) *batchpb.Job { + run := &batchpb.Runnable_Container{ImageUri: box.GetImageUri(), Commands: box.GetCommands()} + + return &batchpb.Job{ + Labels: labels, + TaskGroups: []*batchpb.TaskGroup{{ + TaskCount: g.GetTaskCount(), Parallelism: g.GetParallelism(), + TaskSpec: &batchpb.TaskSpec{ + Runnables: []*batchpb.Runnable{{Executable: &batchpb.Runnable_Container_{Container: run}}}, + Environment: &batchpb.Environment{Variables: vars}, + }, + }}, + LogsPolicy: &batchpb.LogsPolicy{Destination: batchpb.LogsPolicy_CLOUD_LOGGING}, + } +} + +func gcpBatchSingle(in *batchpb.Job) (*batchpb.TaskGroup, *batchpb.Runnable_Container, error) { + if len(in.GetTaskGroups()) != 1 { + return nil, nil, gcpBatchBad("exactly one task group is required") + } + + g := in.GetTaskGroups()[0] + rs := g.GetTaskSpec().GetRunnables() + + if len(rs) != 1 || rs[0].GetContainer() == nil { + return nil, nil, gcpBatchBad("exactly one container runnable is required") + } + + return g, rs[0].GetContainer(), nil +} + +func gcpBatchLimits(g *batchpb.TaskGroup, box *batchpb.Runnable_Container, vars map[string]string) error { + switch { + case box.GetImageUri() == "" || len(box.GetImageUri()) > gcpBatchMaxCmdLen, + len(box.GetCommands()) > gcpBatchMaxCmds, len(vars) > gcpBatchMaxVars, + g.GetTaskCount() < 0, g.GetTaskCount() > gcpBatchMaxTasks, g.GetParallelism() < 0: + return gcpBatchBad("image, commands, variables, taskCount or parallelism out of range") + case tooLong(box.GetCommands(), gcpBatchMaxCmdLen), tooLongVars(vars): + return gcpBatchBad("command or variable too long") + } + + return nil +} + +func tooLong(ss []string, limit int) bool { + for _, s := range ss { + if len(s) > limit { + return true + } + } + + return false +} + +func tooLongVars(vars map[string]string) bool { + for k, v := range vars { + if len(k) > gcpBatchMaxCmdLen || len(v) > gcpBatchMaxVarLen || strings.ContainsRune(k, 0) { + return true + } + } + + return false +} diff --git a/dashboard/gcpbatch_proxy_test.go b/dashboard/gcpbatch_proxy_test.go new file mode 100644 index 0000000000..28ae567199 --- /dev/null +++ b/dashboard/gcpbatch_proxy_test.go @@ -0,0 +1,97 @@ +package dashboard_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +const ( + batchProject = "batch-proj" + batchBase = "/dashboard/api/v1/gcp/batch/v1/projects/" + batchProject + "/locations/us-central1" + batchJob = batchBase + "/jobs/hello" + batchBody = `{"labels":{"team":"data"},"taskGroups":[{"taskCount":"3","parallelism":"2","taskSpec":{` + + `"runnables":[{"container":{"imageUri":"alpine:3","commands":["echo","hi"]}}],` + + `"environment":{"variables":{"A":"b"},"secretVariables":{"S":"projects/p/secrets/s/versions/1"}}}}],` + + `"name":"projects/evil/locations/x/jobs/z",` + + `"allocationPolicy":{"serviceAccount":{"email":"x@y.iam.gserviceaccount.com"}}}` +) + +func newBatchDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + b := gcpbatch.NewInMemoryBackend() + t.Cleanup(b.Close) + + return dashboard.NewHandler(dashboard.Config{GCPBatchOps: gcpbatch.NewHandler(b)}) +} + +func TestGCPBatchProxy(t *testing.T) { + t.Parallel() + + t.Run("create read cancel delete", func(t *testing.T) { + t.Parallel() + + h := newBatchDashboard(t) + + runGCPSteps(t, h, []gcpStep{ + {http.MethodPost, batchBase + "/jobs?jobId=hello", batchBody, `/jobs/hello"`, 200}, + {http.MethodGet, "/dashboard/api/v1/gcp/batch/v1/projects", "", batchProject, 200}, + {http.MethodGet, batchBase + "/jobs", "", `jobs/hello`, 200}, + {http.MethodGet, batchBase + "/jobs/hello", "", `"taskCount":"3"`, 200}, + {http.MethodGet, batchBase + "/jobs/hello/taskGroups/group0/tasks", "", `"tasks"`, 200}, + {http.MethodGet, batchBase + "/jobs/hello/taskGroups/other/tasks", "", "unsupported", 404}, + {http.MethodPost, batchBase + "/jobs/hello:cancel", "", "operations/", 200}, + {http.MethodPost, batchBase + "/jobs/hello:cancel", "", "cannot be cancelled", 400}, + {http.MethodDelete, batchBase + "/jobs/hello", "", "operations/", 200}, + }) + }) + + t.Run("body is rebuilt from fixed fields", func(t *testing.T) { + t.Parallel() + + h := newBatchDashboard(t) + rec := agwDo(t, h, http.MethodPost, batchBase+"/jobs?jobId=hello", batchBody) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + got := agwDo(t, h, http.MethodGet, batchJob, "") + require.Equal(t, http.StatusOK, got.Code, got.Body.String()) + assert.Contains(t, got.Body.String(), `"A":"b"`) + assert.Contains(t, got.Body.String(), `"team":"data"`) + assert.NotContains(t, got.Body.String(), "secretVariables") + assert.NotContains(t, got.Body.String(), "serviceAccount") + assert.NotContains(t, got.Body.String(), "evil") + }) + + t.Run("rejects", func(t *testing.T) { + t.Parallel() + + script := `{"taskGroups":[{"taskSpec":{"runnables":[{"script":{"text":"echo"}}]}}]}` + + runGCPSteps(t, newBatchDashboard(t), []gcpStep{ + {http.MethodPost, batchBase + "/jobs", batchBody, "jobId", 400}, + {http.MethodPost, batchBase + "/jobs?jobId=x", `{}`, "task group", 400}, + {http.MethodPost, batchBase + "/jobs?jobId=x", script, "container runnable", 400}, + {http.MethodPost, batchBase + "/jobs?jobId=x", `nope`, "invalid", 400}, + {http.MethodPatch, batchJob, `{}`, "unsupported", 404}, + {http.MethodPut, batchBase + "/jobs", `{}`, "unsupported", 404}, + {http.MethodGet, batchBase + "/operations", "", "unsupported", 404}, + {http.MethodGet, batchJob + ":getIamPolicy", "", "unsupported", 404}, + {http.MethodGet, batchBase + "/jobs/%2e%2e", "", "unsupported", 400}, + {http.MethodGet, batchBase + "/jobs/ne", "", "not found", 404}, + }) + }) + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, batchBase + "/jobs", "", "", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/gcpcontainer_proxy.go b/dashboard/gcpcontainer_proxy.go new file mode 100644 index 0000000000..703988b307 --- /dev/null +++ b/dashboard/gcpcontainer_proxy.go @@ -0,0 +1,169 @@ +package dashboard + +import ( + "context" + "net/http" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +const ( + gcpContainerProxyPrefix = "/dashboard/api/v1/gcp/container" + + gkeLocationsIdx = 2 + gkeKindIdx = 4 + gkeColl = 5 + gkeItem = 6 + gkePoolColl = 7 + gkePoolItem = 8 + gkeClusters = "clusters" + gkeOperations = "operations" + gkeNodePools = "nodePools" + gkeVerbSetSize = "setSize" + gkeVerbSizes = "poolSizes" +) + +// registerGCPContainerRoutes exposes the Kubernetes Engine API to the SPA as REST JSON, calling the backend +// in-process. Only reads, node pool resize and cluster deletion are reachable; cluster credentials are removed. +func (h *DashboardHandler) registerGCPContainerRoutes() { + var route gcpRESTRoute + + if ops := h.config.GCPContainerOps; ops != nil { + route = (&gcpContainerAPI{b: ops.Backend}).route + } + + h.registerGCPRESTProxy(gcpContainerProxyPrefix, route) +} + +type gcpContainerAPI struct { + b *gcpcontainer.InMemoryBackend +} + +func (a *gcpContainerAPI) route(_ context.Context, r gcpRESTCall) (proto.Message, error) { + n := len(r.segs) + if n < gkeColl || n > gkePoolItem || r.segs[0] != gcpRESTProjectsSeg || + r.segs[gkeLocationsIdx] != schedLocationsKind { + return nil, errGCPUnsupported() + } + + switch r.segs[gkeKindIdx] { + case gkeClusters: + return a.clusters(r) + case gkeOperations: + return a.operations(r) + } + + return nil, errGCPUnsupported() +} + +func (a *gcpContainerAPI) operations(r gcpRESTCall) (proto.Message, error) { + if r.method != http.MethodGet || r.verb != "" { + return nil, errGCPUnsupported() + } + + switch len(r.segs) { + case gkeColl: + return a.b.ListOperations(&containerpb.ListOperationsRequest{Parent: r.name(gkeKindIdx)}) + case gkeItem: + return a.b.GetOperation(&containerpb.GetOperationRequest{Name: r.name(gkeItem)}) + } + + return nil, errGCPUnsupported() +} + +func (a *gcpContainerAPI) clusters(r gcpRESTCall) (proto.Message, error) { + switch len(r.segs) { + case gkeColl: + if r.method == http.MethodGet && r.verb == "" { + out, err := a.b.ListClusters(&containerpb.ListClustersRequest{Parent: r.name(gkeKindIdx)}) + if err != nil { + return nil, err + } + + for _, c := range out.GetClusters() { + scrubGKECluster(c) + } + + return out, nil + } + case gkeItem: + return a.cluster(r) + case gkePoolColl: + if r.method == http.MethodGet && r.verb == "" && r.segs[gkeItem] == gkeNodePools { + return a.b.ListNodePools(&containerpb.ListNodePoolsRequest{Parent: r.name(gkeItem)}) + } + case gkePoolItem: + return a.nodePool(r) + } + + return nil, errGCPUnsupported() +} + +func (a *gcpContainerAPI) cluster(r gcpRESTCall) (proto.Message, error) { + name := r.name(gkeItem) + + switch { + case r.method == http.MethodGet && r.verb == "": + c, err := a.b.GetCluster(&containerpb.GetClusterRequest{Name: name}) + if err != nil { + return nil, err + } + + scrubGKECluster(c) + + return c, nil + case r.method == http.MethodGet && r.verb == gkeVerbSizes: + sizes, err := a.b.NodePoolSizes(name) + if err != nil { + return nil, err + } + + fields := make(map[string]any, len(sizes)) + for pool, size := range sizes { + fields[pool] = float64(size) + } + + return structpb.NewStruct(fields) + case r.method == http.MethodDelete && r.verb == "": + return a.b.DeleteCluster(&containerpb.DeleteClusterRequest{Name: name}) + } + + return nil, errGCPUnsupported() +} + +func (a *gcpContainerAPI) nodePool(r gcpRESTCall) (proto.Message, error) { + name := r.name(gkePoolItem) + + if r.segs[gkeItem] != gkeNodePools { + return nil, errGCPUnsupported() + } + + switch { + case r.method == http.MethodGet && r.verb == "": + return a.b.GetNodePool(&containerpb.GetNodePoolRequest{Name: name}) + case r.method == http.MethodPost && r.verb == gkeVerbSetSize: + in := &containerpb.SetNodePoolSizeRequest{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + in.Name = name + + return a.b.SetNodePoolSize(in) + } + + return nil, errGCPUnsupported() +} + +// scrubGKECluster keeps only the public CA certificate of the cluster's master auth. +func scrubGKECluster(c *containerpb.Cluster) { + if c.GetMasterAuth() == nil { + return + } + + c.MasterAuth = &containerpb.MasterAuth{ClusterCaCertificate: c.GetMasterAuth().GetClusterCaCertificate()} +} diff --git a/dashboard/gcpcontainer_proxy_test.go b/dashboard/gcpcontainer_proxy_test.go new file mode 100644 index 0000000000..c004d38eb1 --- /dev/null +++ b/dashboard/gcpcontainer_proxy_test.go @@ -0,0 +1,128 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +const gkeBase = "/dashboard/api/v1/gcp/container/v1/projects/p/locations" + +func newGKEDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC) + b := gcpcontainer.NewInMemoryBackend() + b.SetClock(func() time.Time { return now }) + + _, err := b.CreateCluster(&containerpb.CreateClusterRequest{ + Parent: "projects/p/locations/us-central1-a", + Cluster: &containerpb.Cluster{ + Name: "c1", InitialNodeCount: 2, + MasterAuth: &containerpb.MasterAuth{ + Username: "admin", + Password: "correct-horse-battery", + ClientKey: "private-key", + }, + }, + }) + require.NoError(t, err) + + now = now.Add(time.Hour) + + return dashboard.NewHandler(dashboard.Config{GCPContainerOps: gcpcontainer.NewHandler(b)}) +} + +func TestGCPContainerProxy(t *testing.T) { + t.Parallel() + + cluster := gkeBase + "/us-central1-a/clusters/c1" + + tests := []struct { + name string + steps []gcpStep + }{ + {"clusters", []gcpStep{ + {http.MethodGet, gkeBase + "/-/clusters", "", `"name":"c1"`, 200}, + {http.MethodGet, gkeBase + "/-/clusters", "", `"status":"RUNNING"`, 200}, + {http.MethodGet, cluster, "", `"name":"c1"`, 200}, + {http.MethodGet, cluster + "/nodePools", "", `"name":"default-pool"`, 200}, + {http.MethodGet, cluster + "/nodePools/default-pool", "", `"name":"default-pool"`, 200}, + {http.MethodGet, cluster + "/nodePools/missing", "", "NOT_FOUND", 404}, + }}, + {"pool sizes", []gcpStep{ + {http.MethodGet, cluster + ":poolSizes", "", `"default-pool":2`, 200}, + }}, + {"resize", []gcpStep{ + { + http.MethodPost, + cluster + "/nodePools/default-pool:setSize", + `{"nodeCount":5000}`, + "INVALID_ARGUMENT", + 400, + }, + { + http.MethodPost, + cluster + "/nodePools/default-pool:setSize", + `{"nodeCount":4}`, + "SET_NODE_POOL_SIZE", + 200, + }, + {http.MethodGet, cluster + ":poolSizes", "", `"default-pool":4`, 200}, + }}, + {"operations", []gcpStep{ + {http.MethodGet, gkeBase + "/-/operations", "", "CREATE_CLUSTER", 200}, + }}, + {"delete", []gcpStep{ + {http.MethodDelete, cluster, "", "DELETE_CLUSTER", 200}, + }}, + {"rejects", []gcpStep{ + {http.MethodPost, gkeBase + "/us-central1-a/clusters", `{}`, "NOT_FOUND", 404}, + {http.MethodPut, cluster, `{}`, "NOT_FOUND", 404}, + {http.MethodPost, cluster + ":setMasterAuth", `{}`, "NOT_FOUND", 404}, + {http.MethodPost, cluster + "/nodePools/default-pool:delete", ``, "NOT_FOUND", 404}, + {http.MethodDelete, cluster + "/nodePools/default-pool", ``, "NOT_FOUND", 404}, + {http.MethodGet, gkeBase + "/us-central1-a/other", "", "NOT_FOUND", 404}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, newGKEDashboard(t), tt.steps) + }) + } + + t.Run("credentials", func(t *testing.T) { + t.Parallel() + + h := newGKEDashboard(t) + for _, p := range []string{cluster, gkeBase + "/-/clusters"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, p, nil) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + assert.NotContains(t, rec.Body.String(), "correct-horse-battery") + assert.NotContains(t, rec.Body.String(), "private-key") + assert.NotContains(t, rec.Body.String(), "admin") + } + }) + + t.Run("unavailable", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, gkeBase+"/-/clusters", nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/gcpdatastore_proxy.go b/dashboard/gcpdatastore_proxy.go new file mode 100644 index 0000000000..0151a768a8 --- /dev/null +++ b/dashboard/gcpdatastore_proxy.go @@ -0,0 +1,101 @@ +package dashboard + +import ( + "context" + "net/http" + + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/services/gcpdatastore" +) + +const ( + gcpDatastoreProxyPrefix = "/dashboard/api/v1/gcp/datastore" + + dsProjectSegs = 2 + dsIndexesSegs = 3 + dsIndexItem = 4 + dsIndexesKind = "indexes" + dsVerbQuery = "runQuery" + dsVerbLookup = "lookup" + dsVerbAggQuery = "runAggregationQuery" +) + +// registerGCPDatastoreRoutes exposes the Datastore entity browser to the SPA: read-only queries, lookups +// and the composite-index listing, called in-process against the backend. +func (h *DashboardHandler) registerGCPDatastoreRoutes() { + var route gcpRESTRoute + + if ops := h.config.GCPDatastoreOps; ops != nil { + route = (&gcpDatastoreAPI{b: ops.Backend}).route + } + + h.registerGCPRESTProxy(gcpDatastoreProxyPrefix, route) +} + +type gcpDatastoreAPI struct { + b *gcpdatastore.InMemoryBackend +} + +func (a *gcpDatastoreAPI) route(_ context.Context, r gcpRESTCall) (proto.Message, error) { + if len(r.segs) < dsProjectSegs || r.segs[0] != gcpRESTProjectsSeg { + return nil, errGCPUnsupported() + } + + project := r.segs[1] + + switch len(r.segs) { + case dsProjectSegs: + if r.method == http.MethodPost { + return a.project(project, r) + } + case dsIndexesSegs: + if r.method == http.MethodGet && r.verb == "" && r.segs[2] == dsIndexesKind { + return a.b.ListIndexes(&adminpb.ListIndexesRequest{ + ProjectId: project, PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), + }) + } + case dsIndexItem: + if r.method == http.MethodGet && r.verb == "" && r.segs[2] == dsIndexesKind { + return a.b.GetIndex(&adminpb.GetIndexRequest{ProjectId: project, IndexId: r.segs[3]}) + } + } + + return nil, errGCPUnsupported() +} + +func (a *gcpDatastoreAPI) project(project string, r gcpRESTCall) (proto.Message, error) { + switch r.verb { + case dsVerbQuery: + in := &datastorepb.RunQueryRequest{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + in.ProjectId = project + + return a.b.RunQuery(in) + case dsVerbAggQuery: + in := &datastorepb.RunAggregationQueryRequest{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + in.ProjectId = project + + return a.b.RunAggregationQuery(in) + case dsVerbLookup: + in := &datastorepb.LookupRequest{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + in.ProjectId = project + + return a.b.Lookup(in) + } + + return nil, errGCPUnsupported() +} diff --git a/dashboard/gcpdatastore_proxy_test.go b/dashboard/gcpdatastore_proxy_test.go new file mode 100644 index 0000000000..804bb176c3 --- /dev/null +++ b/dashboard/gcpdatastore_proxy_test.go @@ -0,0 +1,120 @@ +package dashboard_test + +import ( + "net/http" + "testing" + + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/gcpdatastore" +) + +func TestGCPDatastoreProxy(t *testing.T) { + t.Parallel() + + const base = "/dashboard/api/v1/gcp/datastore/v1/projects/p" + + seed := func(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + d := gcpdatastore.NewHandler(gcpdatastore.NewInMemoryBackend()) + key := &datastorepb.Key{ + PartitionId: &datastorepb.PartitionId{ProjectId: "p"}, + Path: []*datastorepb.Key_PathElement{ + {Kind: "Task", IdType: &datastorepb.Key_PathElement_Name{Name: "t1"}}, + }, + } + _, err := d.Backend.Commit(&datastorepb.CommitRequest{ + ProjectId: "p", Mode: datastorepb.CommitRequest_NON_TRANSACTIONAL, + Mutations: []*datastorepb.Mutation{{Operation: &datastorepb.Mutation_Insert{Insert: &datastorepb.Entity{ + Key: key, + Properties: map[string]*datastorepb.Value{ + "title": {ValueType: &datastorepb.Value_StringValue{StringValue: "write tests"}}, + "note": { + ValueType: &datastorepb.Value_StringValue{StringValue: "n"}, + ExcludeFromIndexes: true, + }, + }, + }}}}, + }) + require.NoError(t, err) + + _, err = d.Backend.CreateIndex(&adminpb.CreateIndexRequest{ + ProjectId: "p", + Index: &adminpb.Index{ + Kind: "Task", Ancestor: adminpb.Index_NONE, + Properties: []*adminpb.Index_IndexedProperty{{Name: "title", Direction: adminpb.Index_ASCENDING}}, + }, + }) + require.NoError(t, err) + + return dashboard.NewHandler(dashboard.Config{GCPDatastoreOps: d}) + } + + tests := []struct { + name string + steps []gcpStep + }{ + {"kinds", []gcpStep{ + { + http.MethodPost, + base + ":runQuery", + `{"query":{"kind":[{"name":"__kind__"}],"projection":[]}}`, + `"Task"`, + 200, + }, + }}, + {"namespaces", []gcpStep{ + {http.MethodPost, base + ":runQuery", `{"query":{"kind":[{"name":"__namespace__"}]}}`, `"batch"`, 200}, + }}, + {"entities", []gcpStep{ + {http.MethodPost, base + ":runQuery", `{"query":{"kind":[{"name":"Task"}]}}`, `"write tests"`, 200}, + { + http.MethodPost, + base + ":runQuery", + `{"query":{"kind":[{"name":"Task"}]}}`, + `"excludeFromIndexes":true`, + 200, + }, + }}, + {"gql", []gcpStep{ + {http.MethodPost, base + ":runQuery", + `{"gqlQuery":{"allowLiterals":true,"queryString":"SELECT * FROM Task WHERE title = 'write tests'"}}`, + `"write tests"`, 200}, + {http.MethodPost, base + ":runQuery", + `{"gqlQuery":{"allowLiterals":true,"queryString":"SELEKT"}}`, "INVALID_ARGUMENT", 400}, + }}, + {"lookup", []gcpStep{ + {http.MethodPost, base + ":lookup", + `{"keys":[{"path":[{"kind":"Task","name":"t1"}]}]}`, `"found"`, 200}, + }}, + {"indexes", []gcpStep{ + {http.MethodGet, base + "/indexes", "", `"kind":"Task"`, 200}, + }}, + {"rejects", []gcpStep{ + {http.MethodPost, base + ":commit", `{}`, "unsupported", 404}, + {http.MethodPost, base + ":beginTransaction", `{}`, "unsupported", 404}, + {http.MethodDelete, base + "/indexes/1", "", "unsupported", 404}, + {http.MethodGet, base + "/other", "", "unsupported", 404}, + {http.MethodPost, base + ":runQuery", `{bad`, "invalid request body", 400}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, seed(t), tt.steps) + }) + } + + t.Run("unavailable", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, base + "/indexes", "", "ServiceUnavailable", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/gcpfirestore_proxy.go b/dashboard/gcpfirestore_proxy.go index b4cc0affc0..9be6a9eec1 100644 --- a/dashboard/gcpfirestore_proxy.go +++ b/dashboard/gcpfirestore_proxy.go @@ -112,7 +112,7 @@ func (a *gcpFirestoreAPI) collection(ctx context.Context, r gcpRESTCall) (proto. case http.MethodGet: return a.api.ListDocuments(ctx, &firestorepb.ListDocumentsRequest{ Parent: parent, CollectionId: id, PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), - OrderBy: r.query.Get("orderBy"), ShowMissing: r.query.Get("showMissing") == "true", + OrderBy: r.query.Get("orderBy"), ShowMissing: r.query.Get("showMissing") == queryTrue, }) case http.MethodPost: doc := &firestorepb.Document{} diff --git a/dashboard/gcpidentitytoolkit_proxy.go b/dashboard/gcpidentitytoolkit_proxy.go new file mode 100644 index 0000000000..a9d25ddcab --- /dev/null +++ b/dashboard/gcpidentitytoolkit_proxy.go @@ -0,0 +1,331 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "slices" + "strconv" + "time" + + "github.com/labstack/echo/v5" +) + +const ( + gcpIDPDisableKey = "disableUser" + gcpIDPLocalID = "localId" + gcpIDPMaxOffset = 1 << 30 + gcpIDPPrefix = "/dashboard/api/v1/gcp/identitytoolkit" + gcpIDPProjects = "projects" + gcpIDPUsers = usersResource + gcpIDPOOB = "oobcodes" + gcpIDPTenants = "tenants" + gcpIDPConfig = "config" + gcpIDPAdminToken = "Bearer dashboard" + gcpIDPTenantKey = "tenantId" + gcpIDPUserSegs = 4 + gcpIDPActionSegs = 5 + gcpIDPProjSegs = 3 + gcpIDPDefLimit = 100 + gcpIDPMaxLimit = 500 + gcpIDPMaxUID = 128 + gcpIDPMaxClaims = 1000 +) + +//nolint:gochecknoglobals // immutable allow-list +var ( + gcpIDPIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + gcpIDPActions = []string{iamVerbDisable, iamVerbEnable, "revoke", "claims"} +) + +// registerGCPIdentityToolkitRoutes serves the Identity Platform page by dispatching in-process to the emulator +// with a fixed admin credential. Only user listing, creation, disable/enable, deletion, custom claims and token +// revocation, stored out-of-band codes, tenants and the email/anonymous sign-in toggles are reachable. Every write +// forwards a body rebuilt from fixed fields, and every response is rebuilt from a field allow-list so password +// hashes, salts, signing keys and API keys never reach the browser. +func (h *DashboardHandler) registerGCPIdentityToolkitRoutes() { + var inner *echo.Echo + + ops := h.config.GCPIdentityToolkitOps + if ops != nil { + inner = echo.New() + inner.Any("/*", ops.Handler()) + } + + serve := ociGuard(inner != nil, "Identity Platform", func(c *echo.Context) error { + r := c.Request() + + parts, ok := ociSplitPath(r.URL.EscapedPath()[len(gcpIDPPrefix):]) + if !ok { + return ociInvalid(c, "unsupported Identity Platform proxy request") + } + + if len(parts) == 1 && parts[0] == gcpIDPProjects && r.Method == http.MethodGet { + return c.JSON(http.StatusOK, map[string]any{gcpIDPProjects: ops.Backend.ProjectIDs()}) + } + + px := &gcpIDPRequest{c: c, inner: inner, project: "", tenant: r.URL.Query().Get(gcpIDPTenantKey)} + + return px.route(parts) + }) + + for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete} { + h.SubRouter.Add(m, gcpIDPPrefix+"/*", serve) + } +} + +type gcpIDPRequest struct { + c *echo.Context + inner *echo.Echo + project string + tenant string +} + +func (p *gcpIDPRequest) reject() error { + return ociInvalid(p.c, "unsupported Identity Platform proxy request") +} + +func (p *gcpIDPRequest) route(parts []string) error { + if !p.scoped(parts) { + return p.reject() + } + + p.project = parts[1] + method := p.c.Request().Method + + switch len(parts) { + case gcpIDPProjSegs: + return p.collection(method, parts[2]) + case gcpIDPUserSegs: + if method == http.MethodDelete && parts[2] == gcpIDPUsers && validUID(parts[3]) { + return p.deleteUser(parts[3]) + } + case gcpIDPActionSegs: + if method == http.MethodPost && parts[2] == gcpIDPUsers && validUID(parts[3]) && + slices.Contains(gcpIDPActions, parts[4]) { + return p.userAction(parts[3], parts[4]) + } + } + + return p.reject() +} + +func (p *gcpIDPRequest) scoped(parts []string) bool { + return len(parts) >= gcpIDPProjSegs && parts[0] == gcpIDPProjects && gcpIDPIDRE.MatchString(parts[1]) && + (p.tenant == "" || gcpIDPIDRE.MatchString(p.tenant)) +} + +func validUID(uid string) bool { + return uid != "" && len(uid) <= gcpIDPMaxUID && uid != "." && uid != ".." +} + +func (p *gcpIDPRequest) collection(method, kind string) error { + switch { + case method == http.MethodGet && kind == gcpIDPUsers: + return p.listUsers() + case method == http.MethodPost && kind == gcpIDPUsers: + return p.createUser() + case method == http.MethodGet && kind == gcpIDPOOB: + return p.listOOB() + case method == http.MethodGet && kind == gcpIDPTenants: + return p.listTenants() + case method == http.MethodGet && kind == gcpIDPConfig: + return p.getConfig() + case method == http.MethodPatch && kind == gcpIDPConfig: + return p.patchConfig() + } + + return p.reject() +} + +// call dispatches one upstream request with the fixed admin credential and no caller-supplied bytes. +func (p *gcpIDPRequest) call(method, path, rawQuery string, body any) (*httptest.ResponseRecorder, error) { + var payload []byte + + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err //nolint:wrapcheck // surfaced as a 400 by the caller + } + + payload = b + } + + r := p.c.Request() + fwd := r.Clone(r.Context()) + fwd.Method, fwd.URL.Path, fwd.URL.RawPath, fwd.URL.RawQuery = method, path, "", rawQuery + fwd.RequestURI = "" + fwd.Body, fwd.ContentLength = io.NopCloser(bytes.NewReader(payload)), int64(len(payload)) + fwd.Header = http.Header{"Authorization": {gcpIDPAdminToken}, "Content-Type": {echo.MIMEApplicationJSON}} + + rec := httptest.NewRecorder() + p.inner.ServeHTTP(rec, fwd) + + return rec, nil +} + +// upstream relays a failed upstream response; it reports true when the response was an error. +func (p *gcpIDPRequest) failed(rec *httptest.ResponseRecorder) (bool, error) { + if rec.Code < http.StatusBadRequest { + return false, nil + } + + p.c.Response().Header().Set("Content-Type", "application/json") + + return true, p.c.JSONBlob(rec.Code, rec.Body.Bytes()) +} + +// run performs an upstream call and hands a successful body to view, which rebuilds the browser response. +func (p *gcpIDPRequest) run(method, path, rawQuery string, body any, view func([]byte) (any, error)) error { + rec, err := p.call(method, path, rawQuery, body) + if err != nil { + return ociInvalid(p.c, "invalid Identity Platform proxy request body") + } + + if done, ferr := p.failed(rec); done { + return ferr + } + + out, err := view(rec.Body.Bytes()) + if err != nil { + return cloudJSONFail(p.c, http.StatusBadGateway, "UNAVAILABLE", "unreadable Identity Platform response") + } + + return p.c.JSON(http.StatusOK, out) +} + +func (p *gcpIDPRequest) base() string { return "/v1/projects/" + url.PathEscape(p.project) } + +func (p *gcpIDPRequest) listUsers() error { + limit, ok := boundedInt(p.c.QueryParam("limit"), gcpIDPDefLimit, 1, gcpIDPMaxLimit) + offset, ok2 := boundedInt(p.c.QueryParam("offset"), 0, 0, gcpIDPMaxOffset) + + if !ok || !ok2 { + return ociInvalid(p.c, "invalid limit or offset") + } + + body := map[string]any{ + gcpIDPTenantKey: p.tenant, "limit": strconv.Itoa(limit), "offset": strconv.Itoa(offset), + "sortBy": "CREATED_AT", "order": "DESC", + } + + return p.run(http.MethodPost, p.base()+"/accounts:query", "", body, idpUsersView) +} + +func (p *gcpIDPRequest) createUser() error { + var in struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"displayName"` + EmailVerified bool `json:"emailVerified"` + } + + if !ociDecodeBody(p.c, &in) || in.Email == "" || in.Password == "" { + return ociInvalid(p.c, "email and password are required") + } + + body := map[string]any{ + "email": in.Email, "password": in.Password, "emailVerified": in.EmailVerified, + gcpIDPTenantKey: p.tenant, + } + if in.DisplayName != "" { + body["displayName"] = in.DisplayName + } + + return p.run(http.MethodPost, p.base()+"/accounts", "", body, idpLocalIDView) +} + +func (p *gcpIDPRequest) deleteUser(uid string) error { + body := map[string]any{gcpIDPLocalID: uid, gcpIDPTenantKey: p.tenant} + + return p.run(http.MethodPost, p.base()+"/accounts:delete", "", body, idpEmptyView) +} + +func (p *gcpIDPRequest) userAction(uid, action string) error { + body := map[string]any{gcpIDPLocalID: uid, gcpIDPTenantKey: p.tenant} + + switch action { + case iamVerbDisable, iamVerbEnable: + body[gcpIDPDisableKey] = action == iamVerbDisable + case "revoke": + body["validSince"] = strconv.FormatInt(time.Now().Unix(), 10) + default: + var in struct { + Claims map[string]any `json:"claims"` + } + + if !ociDecodeBody(p.c, &in) || in.Claims == nil { + return ociInvalid(p.c, "claims must be a JSON object") + } + + raw, err := json.Marshal(in.Claims) + if err != nil || len(raw) > gcpIDPMaxClaims { + return ociInvalid(p.c, "claims must be a JSON object under 1000 bytes") + } + + body["customAttributes"] = string(raw) + } + + return p.run(http.MethodPost, p.base()+"/accounts:update", "", body, idpLocalIDView) +} + +func (p *gcpIDPRequest) listOOB() error { + q := url.Values{} + if p.tenant != "" { + q.Set(gcpIDPTenantKey, p.tenant) + } + + path := "/emulator/v1/projects/" + url.PathEscape(p.project) + "/oobCodes" + + return p.run(http.MethodGet, path, q.Encode(), nil, idpOOBView) +} + +func (p *gcpIDPRequest) listTenants() error { + q := url.Values{} + + for _, k := range []string{"pageSize", "pageToken"} { + if v := p.c.QueryParam(k); v != "" { + q.Set(k, v) + } + } + + return p.run(http.MethodGet, "/v2/projects/"+url.PathEscape(p.project)+"/tenants", q.Encode(), nil, idpTenantsView) +} + +func (p *gcpIDPRequest) getConfig() error { + return p.run(http.MethodGet, "/v2/projects/"+url.PathEscape(p.project)+"/config", "", nil, idpConfigView) +} + +func (p *gcpIDPRequest) patchConfig() error { + var in struct { + Email *bool `json:"emailEnabled"` + Anonymous *bool `json:"anonymousEnabled"` + } + + if !ociDecodeBody(p.c, &in) || (in.Email == nil && in.Anonymous == nil) { + return ociInvalid(p.c, "emailEnabled or anonymousEnabled is required") + } + + signIn, mask := map[string]any{}, []string{} + + if in.Email != nil { + signIn["email"] = map[string]any{"enabled": *in.Email} + + mask = append(mask, "signIn.email.enabled") + } + + if in.Anonymous != nil { + signIn["anonymous"] = map[string]any{"enabled": *in.Anonymous} + + mask = append(mask, "signIn.anonymous.enabled") + } + + q := url.Values{"updateMask": {joinComma(mask)}} + + return p.run(http.MethodPatch, "/v2/projects/"+url.PathEscape(p.project)+"/config", q.Encode(), + map[string]any{"signIn": signIn}, idpConfigView) +} diff --git a/dashboard/gcpidentitytoolkit_proxy_test.go b/dashboard/gcpidentitytoolkit_proxy_test.go new file mode 100644 index 0000000000..31c3e5e6b1 --- /dev/null +++ b/dashboard/gcpidentitytoolkit_proxy_test.go @@ -0,0 +1,185 @@ +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/gcpidentitytoolkit" +) + +const ( + idpBase = "/dashboard/api/v1/gcp/identitytoolkit" + idpProject = idpBase + "/projects/demo-proj" + idpPassword = "Sup3rSecret!pw" + idpUserUID = "uid-ada" +) + +func idpSeed(t *testing.T, e *echo.Echo, method, path, body string) { + t.Helper() + + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer owner") + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", path, rec.Body.String()) +} + +func newIDPDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + ops := gcpidentitytoolkit.NewHandler(gcpidentitytoolkit.NewInMemoryBackend()) + e := echo.New() + e.Any("/*", ops.Handler()) + + idpSeed(t, e, http.MethodPost, "/v1/projects/demo-proj/accounts", + `{"localId":"`+idpUserUID+`","email":"ada@example.com","password":"`+idpPassword+`","displayName":"Ada"}`) + idpSeed(t, e, http.MethodPost, "/v1/projects/demo-proj/accounts:sendOobCode", + `{"requestType":"PASSWORD_RESET","email":"ada@example.com"}`) + idpSeed(t, e, http.MethodPost, "/v2/projects/demo-proj/tenants", `{"displayName":"acme-tenant"}`) + + return dashboard.NewHandler(dashboard.Config{GCPIdentityToolkitOps: ops}) +} + +func TestGCPIdentityToolkitProxy(t *testing.T) { + t.Parallel() + + user := idpProject + "/users/" + idpUserUID + + tests := []struct { + name string + steps []gcpStep + }{ + {"read", []gcpStep{ + {http.MethodGet, idpBase + "/projects", "", "demo-proj", 200}, + {http.MethodGet, idpProject + "/users", "", `"email":"ada@example.com"`, 200}, + {http.MethodGet, idpProject + "/users?limit=1&offset=0", "", `"hasPassword":true`, 200}, + {http.MethodGet, idpProject + "/oobcodes", "", `"requestType":"PASSWORD_RESET"`, 200}, + {http.MethodGet, idpProject + "/tenants", "", `"displayName":"acme-tenant"`, 200}, + {http.MethodGet, idpProject + "/config", "", `"emailEnabled":true`, 200}, + }}, + {"user actions", []gcpStep{ + {http.MethodPost, user + "/disable", "", idpUserUID, 200}, + {http.MethodGet, idpProject + "/users", "", `"disabled":true`, 200}, + {http.MethodPost, user + "/enable", "", idpUserUID, 200}, + {http.MethodPost, user + "/claims", `{"claims":{"role":"admin"}}`, idpUserUID, 200}, + {http.MethodGet, idpProject + "/users", "", `role`, 200}, + {http.MethodPost, user + "/revoke", "", idpUserUID, 200}, + {http.MethodGet, idpProject + "/users", "", `"validSince"`, 200}, + {http.MethodDelete, user, "", "{}", 200}, + {http.MethodDelete, user, "", "USER_NOT_FOUND", 400}, + }}, + {"create", []gcpStep{ + { + http.MethodPost, + idpProject + "/users", + `{"email":"bob@example.com","password":"` + idpPassword + `"}`, + `"localId"`, + 200, + }, + {http.MethodGet, idpProject + "/users", "", `"email":"bob@example.com"`, 200}, + { + http.MethodPost, + idpProject + "/users", + `{"email":"bob@example.com","password":"` + idpPassword + `"}`, + "EMAIL_EXISTS", + 400, + }, + {http.MethodPost, idpProject + "/users", `{"email":"x@example.com"}`, "required", 400}, + }}, + {"tenant scope", []gcpStep{ + {http.MethodGet, idpProject + "/users?tenantId=nope-tenant", "", "TENANT_NOT_FOUND", 404}, + }}, + {"config toggles", []gcpStep{ + {http.MethodPatch, idpProject + "/config", `{"anonymousEnabled":false}`, `"anonymousEnabled":false`, 200}, + {http.MethodGet, idpProject + "/config", "", `"emailEnabled":true`, 200}, + { + http.MethodPatch, + idpProject + "/config", + `{"emailEnabled":false,"anonymousEnabled":true}`, + `"emailEnabled":false`, + 200, + }, + {http.MethodPatch, idpProject + "/config", `{}`, "required", 400}, + }}, + {"claims validation", []gcpStep{ + {http.MethodPost, user + "/claims", `{"claims":"not-an-object"}`, "claims", 400}, + {http.MethodPost, user + "/claims", `{}`, "claims", 400}, + {http.MethodPost, user + "/claims", `{"claims":{"sub":"x"}}`, "FORBIDDEN_CLAIM", 400}, + {http.MethodPost, user + "/claims", `{"claims":{"big":"` + strings.Repeat("a", 1100) + `"}}`, "1000", 400}, + }}, + {"rejects", []gcpStep{ + {http.MethodPost, idpBase + "/projects", `{}`, "unsupported", 400}, + {http.MethodPost, idpProject + "/accounts:batchGet", `{}`, "unsupported", 400}, + {http.MethodPost, idpProject + "/users/" + idpUserUID + "/signIn", `{}`, "unsupported", 400}, + {http.MethodPost, idpProject + "/users/" + idpUserUID + "/update", `{}`, "unsupported", 400}, + {http.MethodDelete, idpProject + "/users", "", "unsupported", 400}, + {http.MethodDelete, idpProject + "/oobcodes", "", "unsupported", 400}, + {http.MethodDelete, idpProject + "/tenants", "", "unsupported", 400}, + {http.MethodPost, idpProject + "/tenants", `{}`, "unsupported", 400}, + {http.MethodGet, idpBase + "/projects/..%2fx/users", "", "unsupported", 400}, + {http.MethodGet, idpBase + "/projects/bad%20id/users", "", "unsupported", 400}, + {http.MethodGet, idpProject + "/users?tenantId=a%2Fb", "", "unsupported", 400}, + {http.MethodGet, idpProject + "/users?limit=0", "", "invalid", 400}, + {http.MethodGet, idpProject + "/users?limit=100000", "", "invalid", 400}, + {http.MethodGet, idpBase + "/token", "", "unsupported", 400}, + {http.MethodGet, idpBase + "/", "", "unsupported", 400}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, newIDPDashboard(t), tt.steps) + }) + } + + t.Run("hashes and keys stay server side", func(t *testing.T) { + t.Parallel() + + h := newIDPDashboard(t) + + for _, p := range []string{ + idpProject + "/users", idpProject + "/config", idpProject + "/tenants", + } { + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext(httptest.NewRequest(http.MethodGet, p, nil), rec))) + + low := strings.ToLower(rec.Body.String()) + for _, banned := range []string{ + "passwordhash", "salt", "signerkey", "apikey", "fake-api-key", "hashconfig", strings.ToLower(idpPassword), + } { + assert.NotContains(t, low, banned, p) + } + } + }) + + t.Run("create does not echo the password", func(t *testing.T) { + t.Parallel() + + h := newIDPDashboard(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, idpProject+"/users", + strings.NewReader(`{"email":"c@example.com","password":"`+idpPassword+`"}`)) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), idpPassword) + }) + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, idpProject + "/users", "", "", http.StatusServiceUnavailable}, + {http.MethodGet, idpBase + "/projects", "", "", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/gcpidentitytoolkit_views.go b/dashboard/gcpidentitytoolkit_views.go new file mode 100644 index 0000000000..796dc2625a --- /dev/null +++ b/dashboard/gcpidentitytoolkit_views.go @@ -0,0 +1,194 @@ +package dashboard + +import ( + "encoding/json" + "strconv" + "strings" +) + +type idpProvider struct { + ProviderID string `json:"providerId,omitempty"` + RawID string `json:"rawId,omitempty"` + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` +} + +// idpUser is the allow-listed view of an account; password hashes and salts have no field here. +type idpUser struct { + PasswordUpdatedAt *float64 `json:"passwordUpdatedAt,omitempty"` + LocalID string `json:"localId"` + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PhotoURL string `json:"photoUrl,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + CustomAttributes string `json:"customAttributes,omitempty"` + TenantID string `json:"tenantId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastLoginAt string `json:"lastLoginAt,omitempty"` + LastRefreshAt string `json:"lastRefreshAt,omitempty"` + ValidSince string `json:"validSince,omitempty"` + Providers []idpProvider `json:"providers"` + EmailVerified bool `json:"emailVerified"` + Disabled bool `json:"disabled"` + HasPassword bool `json:"hasPassword"` +} + +type idpUserWire struct { + ProviderUserInfo []idpProvider `json:"providerUserInfo"` + PasswordHash string `json:"passwordHash"` + idpUser +} + +func idpUsersView(raw []byte) (any, error) { + var in struct { + RecordsCount string `json:"recordsCount"` + Users []idpUserWire `json:"userInfo"` + } + + if err := json.Unmarshal(raw, &in); err != nil { + return nil, err //nolint:wrapcheck // surfaced as a 502 by the caller + } + + users := make([]idpUser, 0, len(in.Users)) + + for _, w := range in.Users { + u := w.idpUser + u.HasPassword = w.PasswordHash != "" + u.Providers = w.ProviderUserInfo + + if u.Providers == nil { + u.Providers = []idpProvider{} + } + + users = append(users, u) + } + + total, _ := strconv.Atoi(in.RecordsCount) + + return map[string]any{gcpIDPUsers: users, "total": max(total, len(users))}, nil +} + +func idpLocalIDView(raw []byte) (any, error) { + var in struct { + LocalID string `json:"localId"` + } + + if err := json.Unmarshal(raw, &in); err != nil { + return nil, err //nolint:wrapcheck // surfaced as a 502 by the caller + } + + return map[string]string{gcpIDPLocalID: in.LocalID}, nil +} + +func idpEmptyView([]byte) (any, error) { return map[string]string{}, nil } + +func idpOOBView(raw []byte) (any, error) { + var in struct { + Codes []struct { + Email string `json:"email"` + OobCode string `json:"oobCode"` + OobLink string `json:"oobLink"` + RequestType string `json:"requestType"` + } `json:"oobCodes"` + } + + if err := json.Unmarshal(raw, &in); err != nil { + return nil, err //nolint:wrapcheck // surfaced as a 502 by the caller + } + + type code struct { + Email string `json:"email"` + OobCode string `json:"oobCode"` + OobLink string `json:"oobLink"` + RequestType string `json:"requestType"` + } + + out := make([]code, 0, len(in.Codes)) + for _, c := range in.Codes { + out = append(out, code(c)) + } + + return map[string]any{"oobCodes": out}, nil +} + +func idpTenantsView(raw []byte) (any, error) { + var in struct { + NextPageToken string `json:"nextPageToken"` + Tenants []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + AllowPasswordSignup bool `json:"allowPasswordSignup"` + EnableEmailLinkSignin bool `json:"enableEmailLinkSignin"` + EnableAnonymousUser bool `json:"enableAnonymousUser"` + } `json:"tenants"` + } + + if err := json.Unmarshal(raw, &in); err != nil { + return nil, err //nolint:wrapcheck // surfaced as a 502 by the caller + } + + type tenant struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + AllowPasswordSignup bool `json:"allowPasswordSignup"` + EnableEmailLinkSignin bool `json:"enableEmailLinkSignin"` + EnableAnonymousUser bool `json:"enableAnonymousUser"` + } + + out := make([]tenant, 0, len(in.Tenants)) + + for _, t := range in.Tenants { + out = append(out, tenant{ + ID: t.Name[strings.LastIndex(t.Name, "/")+1:], Name: t.Name, DisplayName: t.DisplayName, + AllowPasswordSignup: t.AllowPasswordSignup, EnableEmailLinkSignin: t.EnableEmailLinkSignin, + EnableAnonymousUser: t.EnableAnonymousUser, + }) + } + + return map[string]any{"tenants": out, "nextPageToken": in.NextPageToken}, nil +} + +// idpConfigView keeps only the sign-in toggles; the API key and hash parameters stay server side. +func idpConfigView(raw []byte) (any, error) { + var in struct { + Name string `json:"name"` + Domains []string `json:"authorizedDomains"` + SignIn struct { + Email struct { + Enabled bool `json:"enabled"` + } `json:"email"` + Anonymous struct { + Enabled bool `json:"enabled"` + } `json:"anonymous"` + } `json:"signIn"` + } + + if err := json.Unmarshal(raw, &in); err != nil { + return nil, err //nolint:wrapcheck // surfaced as a 502 by the caller + } + + if in.Domains == nil { + in.Domains = []string{} + } + + return struct { + Name string `json:"name"` + Domains []string `json:"authorizedDomains"` + EmailEnabled bool `json:"emailEnabled"` + AnonymousEnabled bool `json:"anonymousEnabled"` + }{in.Name, in.Domains, in.SignIn.Email.Enabled, in.SignIn.Anonymous.Enabled}, nil +} + +func joinComma(s []string) string { return strings.Join(s, ",") } + +// boundedInt parses an optional integer query value within [lo, hi]; empty yields def. +func boundedInt(raw string, def, lo, hi int) (int, bool) { + if raw == "" { + return def, true + } + + n, err := strconv.Atoi(raw) + + return n, err == nil && n >= lo && n <= hi +} diff --git a/dashboard/gcpresourcemanager_proxy.go b/dashboard/gcpresourcemanager_proxy.go new file mode 100644 index 0000000000..82c924d06e --- /dev/null +++ b/dashboard/gcpresourcemanager_proxy.go @@ -0,0 +1,404 @@ +package dashboard + +import ( + "bytes" + "context" + "encoding/base64" + "net/http" + "regexp" + "slices" + "strings" + + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +const ( + gcpRMPrefix = "/dashboard/api/v1/gcp/resourcemanager" + gcpRMItemSegs = 2 + gcpRMServicesSegs = 3 + gcpRMServiceSegs = 4 + gcpRMServicesIdx = 2 + gcpRMServiceIdx = 3 + gcpRMMaxMember = 256 + gcpRMKindProjects = "projects" + gcpRMKindFolders = "folders" + gcpRMKindOrgs = "organizations" + gcpRMVerbMove = "move" + gcpRMVerbUndelete = "undelete" + gcpRMVerbEnable = "enable" + gcpRMVerbDisable = "disable" + gcpRMVerbGetIAM = "getIamPolicy" + gcpRMVerbAddIAM = "addMember" + gcpRMVerbDelIAM = "removeMember" + gcpRMIAMVersion = 3 +) + +var ( + gcpRMIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,62}$`) + gcpRMParentRE = regexp.MustCompile(`^(organizations|folders)/[A-Za-z0-9]{1,64}$`) + gcpRMKeyRE = regexp.MustCompile(`^(organizations|projects)/[A-Za-z0-9-]{1,64}$`) + gcpRMValueRE = regexp.MustCompile(`^tagKeys/[0-9]{1,32}$`) + gcpRMBindingRE = regexp.MustCompile( + `^//cloudresourcemanager\.googleapis\.com/(projects|folders|organizations)/[A-Za-z0-9-]{1,64}$`, + ) + gcpRMServiceRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) + gcpRMRoleRE = regexp.MustCompile( + `^(roles/[A-Za-z0-9_.]{1,128}|(projects|organizations)/[A-Za-z0-9_-]{1,64}/roles/[A-Za-z0-9_.]{1,128})$`, + ) + gcpRMStateRE = regexp.MustCompile(`^(ENABLED|DISABLED)$`) +) + +// registerGCPResourceManagerRoutes serves the Resource Manager page by calling the control plane in-process. Reads +// cover the hierarchy, IAM policies, Service Usage and tags; writes are rebuilt from fixed fields. +func (h *DashboardHandler) registerGCPResourceManagerRoutes() { + var route gcpRESTRoute + + if ops := h.config.GCPResourceManagerOps; ops != nil { + route = (&gcpRMProxy{be: ops.Backend, enforce: ops.Enforcement}).route + } + + h.registerGCPRESTProxy(gcpRMPrefix, route) +} + +type gcpRMProxy struct { + be *gcpresourcemanager.InMemoryBackend + enforce func() gcpenforce.Settings +} + +func gcpRMBad(msg string) error { return status.Error(codes.InvalidArgument, msg) } + +func gcpRMKind(k string) bool { + return k == gcpRMKindProjects || k == gcpRMKindFolders || k == gcpRMKindOrgs +} + +func (p *gcpRMProxy) route(_ context.Context, r gcpRESTCall) (proto.Message, error) { + n := len(r.segs) + + switch { + case n == 1: + return p.collection(r) + case n == gcpRMItemSegs && gcpRMKind(r.segs[0]) && gcpRMIDRE.MatchString(r.segs[1]): + return p.item(r) + case n >= gcpRMServicesSegs && r.segs[0] == gcpRMKindProjects && gcpRMIDRE.MatchString(r.segs[1]) && + r.segs[gcpRMServicesIdx] == "services": + return p.services(r) + } + + return nil, errGCPUnsupported() +} + +func (p *gcpRMProxy) collection(r gcpRESTCall) (proto.Message, error) { + if r.verb != "" { + return nil, errGCPUnsupported() + } + + key := r.segs[0] + + if r.method == http.MethodPost { + switch key { + case gcpRMKindFolders: + return p.createFolder(r) + case gcpRMKindProjects: + return p.createProject(r) + } + + return nil, errGCPUnsupported() + } + + if r.method != http.MethodGet { + return nil, errGCPUnsupported() + } + + if key == "enforcement" { + return structOf(p.enforce()) + } + + return p.list(r, key) +} + +func (p *gcpRMProxy) list(r gcpRESTCall, key string) (proto.Message, error) { + parent := r.query.Get("parent") + size, token := r.pageSize(), r.query.Get("pageToken") + + switch { + case key == gcpRMKindOrgs: + return p.be.SearchOrganizations(&resourcemanagerpb.SearchOrganizationsRequest{PageSize: size, PageToken: token}) + case key == gcpRMKindFolders && gcpRMParentRE.MatchString(parent): + return p.be.ListFolders(&resourcemanagerpb.ListFoldersRequest{ + Parent: parent, PageSize: size, PageToken: token, ShowDeleted: true, + }) + case key == gcpRMKindProjects && parent == "": + return p.be.SearchProjects(&resourcemanagerpb.SearchProjectsRequest{ + Query: "state:ACTIVE state:DELETE_REQUESTED", PageSize: size, PageToken: token, + }) + case key == gcpRMKindProjects && gcpRMParentRE.MatchString(parent): + return p.be.ListProjects(&resourcemanagerpb.ListProjectsRequest{ + Parent: parent, PageSize: size, PageToken: token, ShowDeleted: true, + }) + case key == "tagKeys" && gcpRMKeyRE.MatchString(parent): + return p.be.ListTagKeys(&resourcemanagerpb.ListTagKeysRequest{Parent: parent, PageSize: size, PageToken: token}) + case key == "tagValues" && gcpRMValueRE.MatchString(parent): + return p.be.ListTagValues(&resourcemanagerpb.ListTagValuesRequest{ + Parent: parent, PageSize: size, PageToken: token, + }) + case key == "tagBindings" && gcpRMBindingRE.MatchString(parent): + return p.be.ListTagBindings(&resourcemanagerpb.ListTagBindingsRequest{ + Parent: parent, PageSize: size, PageToken: token, + }) + } + + return nil, errGCPUnsupported() +} + +func (p *gcpRMProxy) createFolder(r gcpRESTCall) (proto.Message, error) { + in := &resourcemanagerpb.Folder{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + if !gcpRMParentRE.MatchString(in.GetParent()) { + return nil, gcpRMBad("parent must be organizations/ID or folders/ID") + } + + return p.be.CreateFolder(&resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: in.GetParent(), DisplayName: in.GetDisplayName()}, + }) +} + +func (p *gcpRMProxy) createProject(r gcpRESTCall) (proto.Message, error) { + in := &resourcemanagerpb.Project{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + if in.GetParent() != "" && !gcpRMParentRE.MatchString(in.GetParent()) { + return nil, gcpRMBad("parent must be organizations/ID or folders/ID") + } + + return p.be.CreateProject(&resourcemanagerpb.CreateProjectRequest{Project: &resourcemanagerpb.Project{ + ProjectId: in.GetProjectId(), DisplayName: in.GetDisplayName(), Parent: in.GetParent(), Labels: in.GetLabels(), + }}) +} + +func (p *gcpRMProxy) item(r gcpRESTCall) (proto.Message, error) { + kind, name := r.segs[0], r.name(gcpRMItemSegs) + + switch { + case r.verb == "" && r.method == http.MethodGet: + return p.get(kind, name) + case r.verb == "" && r.method == http.MethodDelete && kind != gcpRMKindOrgs: + return p.remove(kind, name) + case r.method != http.MethodPost: + return nil, errGCPUnsupported() + case r.verb == gcpRMVerbGetIAM: + return p.be.GetIamPolicy(&iampb.GetIamPolicyRequest{ + Resource: name, Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: gcpRMIAMVersion}, + }) + case r.verb == gcpRMVerbAddIAM, r.verb == gcpRMVerbDelIAM: + return p.editMember(r, name) + case kind == gcpRMKindOrgs: + return nil, errGCPUnsupported() + case r.verb == gcpRMVerbMove: + return p.move(r, kind, name) + case r.verb == gcpRMVerbUndelete: + if kind == gcpRMKindProjects { + return p.be.UndeleteProject(&resourcemanagerpb.UndeleteProjectRequest{Name: name}) + } + + return p.be.UndeleteFolder(&resourcemanagerpb.UndeleteFolderRequest{Name: name}) + } + + return nil, errGCPUnsupported() +} + +func (p *gcpRMProxy) get(kind, name string) (proto.Message, error) { + switch kind { + case gcpRMKindProjects: + return p.be.GetProject(name) + case gcpRMKindFolders: + return p.be.GetFolder(name) + } + + return p.be.GetOrganization(name) +} + +func (p *gcpRMProxy) remove(kind, name string) (proto.Message, error) { + if kind == gcpRMKindProjects { + return p.be.DeleteProject(&resourcemanagerpb.DeleteProjectRequest{Name: name}) + } + + return p.be.DeleteFolder(&resourcemanagerpb.DeleteFolderRequest{Name: name}) +} + +func (p *gcpRMProxy) move(r gcpRESTCall, kind, name string) (proto.Message, error) { + in := &resourcemanagerpb.MoveProjectRequest{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + if !gcpRMParentRE.MatchString(in.GetDestinationParent()) { + return nil, gcpRMBad("destinationParent must be organizations/ID or folders/ID") + } + + if kind == gcpRMKindProjects { + return p.be.MoveProject(&resourcemanagerpb.MoveProjectRequest{ + Name: name, DestinationParent: in.GetDestinationParent(), + }) + } + + return p.be.MoveFolder(&resourcemanagerpb.MoveFolderRequest{ + Name: name, DestinationParent: in.GetDestinationParent(), + }) +} + +// editMember adds or removes one member of one unconditioned binding, writing back with the caller's etag so a stale +// view is rejected by the policy store (ABORTED). +func (p *gcpRMProxy) editMember(r gcpRESTCall, resource string) (proto.Message, error) { + in := &structpb.Struct{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + f := in.GetFields() + role, member, etag := f["role"].GetStringValue(), f["member"].GetStringValue(), f["etag"].GetStringValue() + + switch { + case !gcpRMRoleRE.MatchString(role): + return nil, gcpRMBad("role must be roles/NAME or a custom role name") + case member == "" || len(member) > gcpRMMaxMember || strings.ContainsAny(member, " \t\r\n\x00"): + return nil, gcpRMBad("member is required") + case etag == "": + return nil, gcpRMBad("etag is required") + } + + cur, err := p.be.GetIamPolicy(&iampb.GetIamPolicyRequest{ + Resource: resource, Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: gcpRMIAMVersion}, + }) + if err != nil { + return nil, err + } + + want, err := base64.StdEncoding.DecodeString(etag) + if err != nil { + return nil, gcpRMBad("etag is not valid") + } + + if !bytes.Equal(want, cur.GetEtag()) { + return nil, status.Error(codes.Aborted, "The policy was changed by another request; reload and retry.") + } + + pol := &iampb.Policy{Version: cur.GetVersion(), Etag: cur.GetEtag(), AuditConfigs: cur.GetAuditConfigs()} + for _, b := range cur.GetBindings() { + pol.Bindings = append(pol.Bindings, &iampb.Binding{ + Role: b.GetRole(), Members: slices.Clone(b.GetMembers()), Condition: b.GetCondition(), + }) + } + + if r.verb == gcpRMVerbAddIAM { + gcpRMAddMember(pol, role, member) + } else if err = gcpRMRemoveMember(pol, role, member); err != nil { + return nil, err + } + + return p.be.SetIamPolicy(&iampb.SetIamPolicyRequest{Resource: resource, Policy: pol}) +} + +func gcpRMAddMember(pol *iampb.Policy, role, member string) { + for _, b := range pol.GetBindings() { + if b.GetRole() == role && b.GetCondition() == nil { + if !slices.Contains(b.GetMembers(), member) { + b.Members = append(b.Members, member) + } + + return + } + } + + pol.Bindings = append(pol.Bindings, &iampb.Binding{Role: role, Members: []string{member}}) +} + +func gcpRMRemoveMember(pol *iampb.Policy, role, member string) error { + for i, b := range pol.GetBindings() { + if b.GetRole() != role || b.GetCondition() != nil || !slices.Contains(b.GetMembers(), member) { + continue + } + + left := slices.DeleteFunc(b.GetMembers(), func(m string) bool { return m == member }) + if len(left) == 0 { + pol.Bindings = slices.Delete(pol.GetBindings(), i, i+1) + } else { + b.Members = left + } + + return nil + } + + return status.Error(codes.NotFound, "that member does not hold that role") +} + +func (p *gcpRMProxy) services(r gcpRESTCall) (proto.Message, error) { + project := r.name(gcpRMItemSegs) + + switch len(r.segs) { + case gcpRMServicesSegs: + return p.listServices(r, project) + case gcpRMServiceSegs: + if !gcpRMServiceRE.MatchString(r.segs[gcpRMServiceIdx]) { + return nil, errGCPUnsupported() + } + + return p.serviceAction(r, r.name(gcpRMServiceSegs)) + } + + return nil, errGCPUnsupported() +} + +func (p *gcpRMProxy) listServices(r gcpRESTCall, project string) (proto.Message, error) { + if r.verb != "" || r.method != http.MethodGet { + return nil, errGCPUnsupported() + } + + filter := "" + if s := r.query.Get("state"); s != "" { + if !gcpRMStateRE.MatchString(s) { + return nil, gcpRMBad("state must be ENABLED or DISABLED") + } + + filter = "state:" + s + } + + return p.be.ListServices(&serviceusagepb.ListServicesRequest{ + Parent: project, Filter: filter, PageSize: r.pageSize(), PageToken: r.query.Get("pageToken"), + }) +} + +func (p *gcpRMProxy) serviceAction(r gcpRESTCall, name string) (proto.Message, error) { + switch { + case r.method == http.MethodGet && r.verb == "": + return p.be.GetService(name) + case r.method != http.MethodPost: + return nil, errGCPUnsupported() + case r.verb == gcpRMVerbEnable: + return p.be.EnableService(&serviceusagepb.EnableServiceRequest{Name: name}) + case r.verb == gcpRMVerbDisable: + in := &serviceusagepb.DisableServiceRequest{} + if err := r.unmarshal(in); err != nil { + return nil, err + } + + return p.be.DisableService(&serviceusagepb.DisableServiceRequest{ + Name: name, DisableDependentServices: in.GetDisableDependentServices(), + }) + } + + return nil, errGCPUnsupported() +} diff --git a/dashboard/gcpresourcemanager_proxy_test.go b/dashboard/gcpresourcemanager_proxy_test.go new file mode 100644 index 0000000000..7471e3cd87 --- /dev/null +++ b/dashboard/gcpresourcemanager_proxy_test.go @@ -0,0 +1,237 @@ +package dashboard_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +const ( + rmBase = "/dashboard/api/v1/gcp/resourcemanager/v1" + rmOrg = "organizations/" + gcpresourcemanager.DefaultOrganizationID +) + +func newRMDashboard(t *testing.T, enforce gcpenforce.Settings) *dashboard.DashboardHandler { + t.Helper() + + b := gcpresourcemanager.NewInMemoryBackend() + h := gcpresourcemanager.NewHandler(b) + h.SetEnforcement(enforce) + + return dashboard.NewHandler(dashboard.Config{GCPResourceManagerOps: h}) +} + +func TestGCPResourceManagerProxy(t *testing.T) { + t.Parallel() + + t.Run("hierarchy lifecycle", func(t *testing.T) { + t.Parallel() + + h := newRMDashboard(t, gcpenforce.Settings{}) + + runGCPSteps(t, h, []gcpStep{ + {http.MethodGet, rmBase + "/organizations", "", rmOrg, 200}, + { + http.MethodPost, + rmBase + "/folders", + `{"parent":"` + rmOrg + `","displayName":"Team Alpha","name":"folders/9"}`, + "operations/", + 200, + }, + {http.MethodGet, rmBase + "/folders?parent=" + rmOrg, "", "Team Alpha", 200}, + { + http.MethodPost, + rmBase + "/projects", + `{"projectId":"alpha-proj","displayName":"Alpha","parent":"` + rmOrg + `",` + + `"labels":{"env":"dev"},"state":"DELETE_REQUESTED"}`, + "operations/", + 200, + }, + {http.MethodGet, rmBase + "/projects?parent=" + rmOrg, "", `"env":"dev"`, 200}, + {http.MethodGet, rmBase + "/projects/alpha-proj", "", `"state":"ACTIVE"`, 200}, + {http.MethodDelete, rmBase + "/projects/alpha-proj", "", "operations/", 200}, + {http.MethodGet, rmBase + "/projects/alpha-proj", "", "DELETE_REQUESTED", 200}, + {http.MethodPost, rmBase + "/projects/alpha-proj:undelete", "", "operations/", 200}, + {http.MethodGet, rmBase + "/enforcement", "", `"projects":false`, 200}, + {http.MethodGet, rmBase + "/projects", "", "gopherstack-test", 200}, + }) + }) + + t.Run("services", func(t *testing.T) { + t.Parallel() + + h := newRMDashboard(t, gcpenforce.Settings{}) + svc := rmBase + "/projects/gopherstack-test/services" + + runGCPSteps(t, h, []gcpStep{ + {http.MethodGet, svc + "?state=DISABLED", "", "pubsub.googleapis.com", 200}, + {http.MethodPost, svc + "/pubsub.googleapis.com:enable", "", "operations/", 200}, + {http.MethodGet, svc + "/pubsub.googleapis.com", "", `"state":"ENABLED"`, 200}, + {http.MethodGet, svc + "?state=bogus", "", "state must be", 400}, + {http.MethodPost, svc + "/pubsub.googleapis.com:disable", `{"disableDependentServices":true,"name":"x"}`, + "operations/", 200}, + {http.MethodPost, svc + "/pubsub.googleapis.com:purge", "", "unsupported", 404}, + }) + }) + + t.Run("dependents block disable", func(t *testing.T) { + t.Parallel() + + h := newRMDashboard(t, gcpenforce.Settings{}) + svc := rmBase + "/projects/gopherstack-test/services" + + runGCPSteps(t, h, []gcpStep{ + {http.MethodPost, svc + "/container.googleapis.com:enable", "", "operations/", 200}, + {http.MethodPost, svc + "/compute.googleapis.com:disable", `{}`, "", 400}, + }) + }) + + t.Run("iam etag", func(t *testing.T) { + t.Parallel() + + h := newRMDashboard(t, gcpenforce.Settings{}) + res := rmBase + "/projects/gopherstack-test" + + rec := agwDo(t, h, http.MethodPost, res+":getIamPolicy", "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var pol struct { + Etag string `json:"etag"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &pol)) + + add := `{"role":"roles/viewer","member":"user:a@example.com","etag":"` + pol.Etag + `"}` + + first := agwDo(t, h, http.MethodPost, res+":addMember", add) + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + assert.Contains(t, first.Body.String(), "user:a@example.com") + + stale := agwDo(t, h, http.MethodPost, res+":addMember", add) + assert.Equal(t, http.StatusConflict, stale.Code, stale.Body.String()) + assert.Contains(t, stale.Body.String(), "ABORTED") + + var cur struct { + Etag string `json:"etag"` + } + + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &cur)) + + del := `{"role":"roles/viewer","member":"user:a@example.com","etag":"` + cur.Etag + `"}` + runGCPSteps(t, h, []gcpStep{ + {http.MethodPost, res + ":removeMember", del, `"etag"`, 200}, + { + http.MethodPost, + res + ":removeMember", + `{"role":"roles/viewer","member":"user:a@example.com","etag":"eA=="}`, + "changed by another", + 409, + }, + }) + }) + + t.Run("rebuilds bodies", func(t *testing.T) { + t.Parallel() + + h := newRMDashboard(t, gcpenforce.Settings{}) + rec := agwDo(t, h, http.MethodPost, rmBase+"/folders", + `{"parent":"`+rmOrg+`","displayName":"Rebuilt","name":"folders/1","state":"DELETE_REQUESTED","etag":"zz"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + got := agwDo(t, h, http.MethodGet, rmBase+"/folders?parent="+rmOrg, "") + assert.Contains(t, got.Body.String(), `"state":"ACTIVE"`) + assert.NotContains(t, got.Body.String(), "zz") + }) + + t.Run("rejects", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, newRMDashboard(t, gcpenforce.Settings{}), []gcpStep{ + { + http.MethodPost, + rmBase + "/folders", + `{"parent":"projects/x","displayName":"abc"}`, + "parent must be", + 400, + }, + {http.MethodPost, rmBase + "/projects", `{"projectId":"abcdef","parent":"bad"}`, "parent must be", 400}, + {http.MethodPost, rmBase + "/projects", `nope`, "invalid", 400}, + {http.MethodGet, rmBase + "/projects?parent=projects/x", "", "unsupported", 404}, + {http.MethodGet, rmBase + "/folders?parent=folders/1/../2", "", "unsupported", 404}, + {http.MethodDelete, rmBase + "/organizations/1", "", "unsupported", 404}, + {http.MethodPost, rmBase + "/organizations/1:move", `{}`, "unsupported", 404}, + {http.MethodPatch, rmBase + "/projects/gopherstack-test", `{}`, "unsupported", 404}, + {http.MethodPut, rmBase + "/folders", `{}`, "unsupported", 404}, + {http.MethodGet, rmBase + "/tagKeys?parent=folders/1", "", "unsupported", 404}, + {http.MethodGet, rmBase + "/operations/x", "", "unsupported", 404}, + {http.MethodGet, rmBase + "/projects/gopherstack-test/services/a%2Fb", "", "unsupported", 404}, + {http.MethodGet, rmBase + "/projects/gopherstack-test/billingInfo", "", "unsupported", 404}, + {http.MethodPost, rmBase + "/projects/gopherstack-test:setIamPolicy", `{}`, "unsupported", 404}, + { + http.MethodPost, + rmBase + "/projects/gopherstack-test:addMember", + `{"role":"admin","member":"user:a","etag":"e"}`, + "role must be", + 400, + }, + { + http.MethodPost, + rmBase + "/projects/gopherstack-test:addMember", + `{"role":"roles/viewer","member":"user:a"}`, + "etag is required", + 400, + }, + {http.MethodPost, rmBase + "/projects/gopherstack-test:move", `{"destinationParent":"projects/x"}`, + "destinationParent", 400}, + }) + }) + + t.Run("move and tags", func(t *testing.T) { + t.Parallel() + + h := newRMDashboard(t, gcpenforce.Settings{}) + + runGCPSteps(t, h, []gcpStep{ + { + http.MethodPost, + rmBase + "/folders", + `{"parent":"` + rmOrg + `","displayName":"Dest"}`, + "operations/", + 200, + }, + {http.MethodGet, rmBase + "/tagKeys?parent=" + rmOrg, "", "", 200}, + { + http.MethodGet, + rmBase + "/tagBindings?parent=//cloudresourcemanager.googleapis.com/projects/gopherstack-test", + "", + "", + 200, + }, + {http.MethodPost, rmBase + "/projects/gopherstack-test:move", `{"destinationParent":"` + rmOrg + `"}`, + "operations/", 200}, + }) + }) + + t.Run("enforcement banner", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, newRMDashboard(t, gcpenforce.Settings{Projects: true, ServiceEnablement: true}), []gcpStep{ + {http.MethodGet, rmBase + "/enforcement", "", `"serviceEnablement":true`, 200}, + }) + }) + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, rmBase + "/organizations", "", "", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/oci_common.go b/dashboard/oci_common.go index f668449f44..8cffd69d9b 100644 --- a/dashboard/oci_common.go +++ b/dashboard/oci_common.go @@ -21,9 +21,10 @@ const ( ociKeyRules = "rules" ociMimeOctet = "application/octet-stream" - ociProxyPrefix = "/dashboard/api/v1/oci" - ociListLimit = 1000 - ociMaxListPages = 100 + ociProxyPrefix = "/dashboard/api/v1/oci" + ociCompartmentsKey = "compartments" + ociListLimit = 1000 + ociMaxListPages = 100 ) // ociAPIError extracts the HTTP status, code and message of any OCI service error. diff --git a/dashboard/oci_inproc_proxy.go b/dashboard/oci_inproc_proxy.go new file mode 100644 index 0000000000..c6f96ed0c0 --- /dev/null +++ b/dashboard/oci_inproc_proxy.go @@ -0,0 +1,71 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociInprocMaxBody = 1 << 14 + ociActionsSeg = "actions" + ociNamespaceKey = "namespace" +) + +// ociSplitPath splits a proxied path (below /api) into segments, rejecting empty, dot and encoded-slash segments. +func ociSplitPath(raw string) ([]string, bool) { + parts := strings.Split(strings.TrimPrefix(raw, "/"), "/") + + for _, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || dec == "" || dec == "." || dec == ".." || strings.Contains(dec, "/") { + return nil, false + } + } + + return parts, true +} + +// ociDispatch forwards a request to the emulator under apiPath with body (nil keeps the caller's body, capped). +func ociDispatch(c *echo.Context, inner *echo.Echo, apiPath string, body []byte) error { + r := c.Request() + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(apiPath), apiPath + fwd.RequestURI = "" + + if body == nil { + fwd.Body = http.MaxBytesReader(c.Response(), r.Body, ociInprocMaxBody) + } else { + fwd.Body, fwd.ContentLength = io.NopCloser(bytes.NewReader(body)), int64(len(body)) + fwd.Header = fwd.Header.Clone() + fwd.Header.Set("Content-Type", "application/json") + } + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + return ociRelay(c, rec, "opc-next-page", "etag", "opc-request-id", "opc-work-request-id") +} + +// ociDecodeBody decodes a capped JSON request body into v. +func ociDecodeBody(c *echo.Context, v any) bool { + r := c.Request() + + return json.NewDecoder(http.MaxBytesReader(c.Response(), r.Body, ociInprocMaxBody)).Decode(v) == nil +} + +// ociDecodeBodyN is ociDecodeBody with an explicit size cap. +func ociDecodeBodyN(c *echo.Context, v any, limit int64) bool { + return json.NewDecoder(http.MaxBytesReader(c.Response(), c.Request().Body, limit)).Decode(v) == nil +} + +// ociInvalid answers 400 for a request outside the proxy's allow-list. +func ociInvalid(c *echo.Context, what string) error { + return cloudJSONFail(c, http.StatusBadRequest, "InvalidParameter", what) +} diff --git a/dashboard/ociapigateway_proxy.go b/dashboard/ociapigateway_proxy.go new file mode 100644 index 0000000000..e4078cc5aa --- /dev/null +++ b/dashboard/ociapigateway_proxy.go @@ -0,0 +1,233 @@ +package dashboard + +import ( + "bytes" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "unicode/utf8" + + "github.com/labstack/echo/v5" +) + +const ( + ociAPIGWPrefix = ociProxyPrefix + "/apigateway" + ociAPIGWAPIPrefix = "/20190501" + ociAPIGWDataPrefix = "/_apigw/" + ociAPIGWItemSegs = 2 + ociAPIGWInvokeSegs = 3 + ociAPIGWMaxReqBody = 1 << 16 + ociAPIGWMaxRespBody = 1 << 18 + ociAPIGWMaxHeaders = 32 + ociAPIGWMaxPath = 2048 + ociAPIGWActiveState = "ACTIVE" + ociAPIGWMaxTryJSON = 2 * ociAPIGWMaxReqBody + ociAPIGWGateways = "gateways" + ociAPIGWDeployments = "deployments" + ociAPIGWCerts = "certificates" +) + +// registerOCIAPIGatewayRoutes serves the API Gateway page by dispatching in-process to the emulator's handler without +// request signing. Only gateway, deployment and certificate reads are reachable, plus a "try it" call that is +// replayed against the in-process data plane of one existing gateway selected by id; the destination is never taken +// from the request. Certificate private keys are discarded by the emulator. +func (h *DashboardHandler) registerOCIAPIGatewayRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + ops := h.config.OCIAPIGatewayOps + if ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI API Gateway", next) } + + h.SubRouter.Add(http.MethodGet, ociAPIGWPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociAPIGWPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok { + return ociInvalid(c, "unsupported API Gateway proxy request") + } + + if r.Method == http.MethodPost && len(parts) == ociAPIGWInvokeSegs && parts[0] == ociAPIGWGateways && + parts[2] == "invoke" { + return ociAPIGWTryIt(c, inner, ops.Backend.GatewayState, parts[1]) + } + + if r.Method != http.MethodGet || !ociAPIGWReadable(parts) { + return ociInvalid(c, "unsupported API Gateway proxy request") + } + + return ociDispatch(c, inner, ociAPIGWAPIPrefix+raw, nil) + }) + + h.SubRouter.Add(http.MethodGet, ociAPIGWPrefix+"/api/*", serve) + h.SubRouter.Add(http.MethodPost, ociAPIGWPrefix+"/api/*", serve) +} + +func ociAPIGWReadable(parts []string) bool { + switch len(parts) { + case 1: + return slices.Contains([]string{ociAPIGWGateways, ociAPIGWDeployments, ociAPIGWCerts}, parts[0]) + case ociAPIGWItemSegs: + return slices.Contains([]string{ociAPIGWGateways, ociAPIGWDeployments}, parts[0]) + } + + return false +} + +type ociAPIGWTryHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type ociAPIGWTryIn struct { + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Body string `json:"body"` + Headers []ociAPIGWTryHeader `json:"headers"` +} + +// ociAPIGWHopByHop are never forwarded in either direction (RFC 9110 section 7.6.1, plus Host and Content-Length, +// which the proxy sets itself). +var ociAPIGWHopByHop = []string{ //nolint:gochecknoglobals // immutable header deny-list + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Proxy-Connection", "Te", "Trailer", + "Transfer-Encoding", "Upgrade", "Host", "Content-Length", +} + +func ociAPIGWTryIt(c *echo.Context, inner *echo.Echo, state func(string) (string, bool), gatewayID string) error { + var in ociAPIGWTryIn + if !ociDecodeBodyN(c, &in, ociAPIGWMaxTryJSON) { + return ociInvalid(c, "invalid API Gateway try-it request") + } + + rel, ok := ociAPIGWTryPath(in) + if !ok || !ociAPIGWMethodOK(in.Method) || len(in.Headers) > ociAPIGWMaxHeaders || + len(in.Body) > ociAPIGWMaxReqBody { + return ociInvalid(c, "invalid API Gateway try-it request") + } + + st, found := state(gatewayID) + if !found { + return cloudJSONFail(c, http.StatusNotFound, "NotAuthorizedOrNotFound", "Gateway not found") + } + + if st != ociAPIGWActiveState { + return cloudJSONFail(c, http.StatusConflict, "IncorrectState", "Gateway is in state "+st) + } + + req := httptest.NewRequestWithContext(c.Request().Context(), in.Method, + ociAPIGWDataPrefix+url.PathEscape(gatewayID)+rel, strings.NewReader(in.Body)) + req.Host = "dashboard.invalid" + + for _, hd := range in.Headers { + if ociAPIGWHeaderOK(hd.Name) { + req.Header.Add(hd.Name, hd.Value) + } + } + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, req) + + return c.JSON(http.StatusOK, ociAPIGWTryOut(rec)) +} + +func ociAPIGWMethodOK(m string) bool { + return slices.Contains([]string{ + http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, + http.MethodHead, http.MethodOptions, + }, m) +} + +// ociAPIGWTryPath validates the path and query and returns "/path?query" relative to the gateway. +func ociAPIGWTryPath(in ociAPIGWTryIn) (string, bool) { + if !strings.HasPrefix(in.Path, "/") || len(in.Path)+len(in.Query) > ociAPIGWMaxPath { + return "", false + } + + if in.Path != "/" { + if _, ok := ociSplitPath(in.Path); !ok { + return "", false + } + } + + if strings.ContainsAny(in.Path, "?#\\") || strings.ContainsAny(in.Query, "#\r\n") { + return "", false + } + + if _, err := url.ParseQuery(in.Query); err != nil { + return "", false + } + + if in.Query == "" { + return in.Path, true + } + + return in.Path + "?" + in.Query, true +} + +func ociAPIGWHeaderOK(name string) bool { + if name == "" || strings.ContainsAny(name, " :\r\n\t") { + return false + } + + return !slices.ContainsFunc(ociAPIGWHopByHop, func(h string) bool { return strings.EqualFold(h, name) }) +} + +// ociAPIGWTryOut renders a data-plane response for the browser; every occurrence of a secret is masked. +func ociAPIGWTryOut(rec *httptest.ResponseRecorder, secrets ...string) map[string]any { + headers := map[string]string{} + + for k, v := range rec.Header() { + if ociAPIGWHeaderOK(k) { + headers[k] = ociAPIGWMask(strings.Join(v, ", "), secrets) + } + } + + raw := ociAPIGWMaskBytes(rec.Body.Bytes()[:min(rec.Body.Len(), ociAPIGWMaxRespBody)], secrets) + out := map[string]any{ + "status": rec.Code, "headers": headers, "truncated": rec.Body.Len() > ociAPIGWMaxRespBody, + } + + if utf8.Valid(raw) { + out["body"], out["encoding"] = string(raw), "utf8" + } else { + out["body"], out["encoding"] = base64.StdEncoding.EncodeToString(raw), "base64" + } + + return out +} + +func ociAPIGWMask(s string, secrets []string) string { + for _, sec := range secrets { + if sec != "" { + s = strings.ReplaceAll(s, sec, "[redacted]") + } + } + + return s +} + +func ociAPIGWMaskBytes(b []byte, secrets []string) []byte { + for _, sec := range secrets { + if sec != "" { + b = bytes.ReplaceAll(b, []byte(sec), []byte("[redacted]")) + } + } + + return b +} diff --git a/dashboard/ociapigateway_proxy_test.go b/dashboard/ociapigateway_proxy_test.go new file mode 100644 index 0000000000..8e546a6a4a --- /dev/null +++ b/dashboard/ociapigateway_proxy_test.go @@ -0,0 +1,223 @@ +package dashboard_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ociapigateway" +) + +const ( + gwComp = "ocid1.compartment.oc1..gw" + gwBase = "/dashboard/api/v1/oci/apigateway" + gwAPI = gwBase + "/api" +) + +func gwCert(t *testing.T) (string, string) { + t.Helper() + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "api.example.test"}, + DNSNames: []string{ + "api.example.test", + }, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + } + + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &k.PublicKey, k) + require.NoError(t, err) + + kb, err := x509.MarshalECPrivateKey(k) + require.NoError(t, err) + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})) +} + +func newGatewayDashboard(t *testing.T) (*dashboard.DashboardHandler, string, string) { + t.Helper() + + b := ociapigateway.NewInMemoryBackend() + b.SetTransition(0) + + ops := ociapigateway.NewHandler(b) + e := echo.New() + e.Any("/*", ops.Trusted()) + + post := func(path string, body map[string]any) []byte { + raw, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(raw)))) + require.Less(t, rec.Code, http.StatusBadRequest, rec.Body.String()) + + return rec.Body.Bytes() + } + + var gw struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(post("/20190501/gateways", map[string]any{ + "compartmentId": gwComp, "endpointType": "PUBLIC", "displayName": "edge", "subnetId": "ocid1.subnet.oc1..s", + }), &gw)) + + var dep struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(post("/20190501/deployments", map[string]any{ + "compartmentId": gwComp, "gatewayId": gw.ID, "pathPrefix": "/v1", "displayName": "orders", + "specification": map[string]any{"routes": []map[string]any{{ + "path": "/hello", "methods": []string{"GET"}, + "backend": map[string]any{"type": "STOCK_RESPONSE_BACKEND", "status": 200, "body": "hi there"}, + }}}, + }), &dep)) + + certPEM, keyPEM := gwCert(t) + post("/20190501/certificates", map[string]any{ + "compartmentId": gwComp, "displayName": "tls", "certificate": certPEM, "privateKey": keyPEM, + }) + + return dashboard.NewHandler(dashboard.Config{OCIAPIGatewayOps: ops}), gw.ID, keyPEM +} + +func gwTry(t *testing.T, h *dashboard.DashboardHandler, id, body string) *httptest.ResponseRecorder { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, gwAPI+"/gateways/"+id+"/invoke", strings.NewReader(body)) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + + return rec +} + +func TestOCIAPIGatewayProxy(t *testing.T) { + t.Parallel() + + cq := "?compartmentId=" + gwComp + + t.Run("read", func(t *testing.T) { + t.Parallel() + + h, id, key := newGatewayDashboard(t) + runGCPSteps(t, h, []gcpStep{ + {http.MethodGet, gwBase + "/compartments", "", gwComp, 200}, + {http.MethodGet, gwAPI + "/gateways" + cq, "", `"displayName":"edge"`, 200}, + {http.MethodGet, gwAPI + "/gateways/" + id, "", `"hostname"`, 200}, + {http.MethodGet, gwAPI + "/deployments" + cq + "&gatewayId=" + id, "", `"pathPrefix":"/v1"`, 200}, + {http.MethodGet, gwAPI + "/certificates" + cq, "", `"api.example.test"`, 200}, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, gwAPI+"/certificates"+cq, nil) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + assert.NotContains(t, rec.Body.String(), "PRIVATE KEY") + assert.NotContains(t, rec.Body.String(), strings.TrimSpace(strings.Split(key, "\n")[1])) + }) + + t.Run("rejects", func(t *testing.T) { + t.Parallel() + + h, id, _ := newGatewayDashboard(t) + runGCPSteps(t, h, []gcpStep{ + {http.MethodPost, gwAPI + "/gateways", `{}`, "unsupported", 400}, + {http.MethodPost, gwAPI + "/gateways/" + id, `{}`, "unsupported", 400}, + {http.MethodPost, gwAPI + "/gateways/" + id + "/actions/changeCompartment", `{}`, "unsupported", 400}, + {http.MethodGet, gwAPI + "/certificates/c1", "", "unsupported", 400}, + {http.MethodGet, gwAPI + "/workRequests", "", "unsupported", 400}, + {http.MethodGet, gwAPI + "/gateways/%2e%2e", "", "unsupported", 400}, + {http.MethodPost, gwAPI + "/deployments", `{}`, "unsupported", 400}, + }) + }) + + t.Run("try it", func(t *testing.T) { + t.Parallel() + + h, id, _ := newGatewayDashboard(t) + + rec := gwTry(t, h, id, `{"method":"GET","path":"/v1/hello","headers":[{"name":"X-Test","value":"1"},`+ + `{"name":"Host","value":"evil.example"},{"name":"Connection","value":"close"}]}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var out struct { + Headers map[string]string `json:"headers"` + Body string `json:"body"` + Encoding string `json:"encoding"` + Status int `json:"status"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + assert.Equal(t, 200, out.Status) + assert.Equal(t, "hi there", out.Body) + assert.Equal(t, "utf8", out.Encoding) + assert.NotContains(t, out.Headers, "Connection") + + miss := gwTry(t, h, id, `{"method":"GET","path":"/v1/nope"}`) + assert.Contains(t, miss.Body.String(), `"status":404`) + }) + + t.Run("try it rejections", func(t *testing.T) { + t.Parallel() + + h, id, _ := newGatewayDashboard(t) + + tests := []struct { + name string + id string + body string + want int + }{ + {"unknown gateway", "ocid1.apigateway.oc1..nope", `{"method":"GET","path":"/v1/hello"}`, 404}, + {"bad method", id, `{"method":"TRACE","path":"/v1/hello"}`, 400}, + {"relative path", id, `{"method":"GET","path":"v1/hello"}`, 400}, + {"absolute url", id, `{"method":"GET","path":"http://evil.example/x"}`, 400}, + {"dot segments", id, `{"method":"GET","path":"/v1/../x"}`, 400}, + {"encoded slash", id, `{"method":"GET","path":"/v1%2fhello"}`, 400}, + {"path with query", id, `{"method":"GET","path":"/v1/hello?a=b"}`, 400}, + {"fragment", id, `{"method":"GET","path":"/v1/hello","query":"a=b#x"}`, 400}, + {"gateway id traversal", "%2e%2e", `{"method":"GET","path":"/v1/hello"}`, 400}, + { + "oversize body", + id, + `{"method":"POST","path":"/v1/hello","body":"` + strings.Repeat("a", 70000) + `"}`, + 400, + }, + {"not json", id, `nope`, 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, gwTry(t, h, tt.id, tt.body).Code) + }) + } + }) + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, gwAPI + "/gateways", "", "", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/ociartifacts_proxy.go b/dashboard/ociartifacts_proxy.go new file mode 100644 index 0000000000..778c1bf188 --- /dev/null +++ b/dashboard/ociartifacts_proxy.go @@ -0,0 +1,514 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociArtPrefix = ociProxyPrefix + "/artifacts" + ociArtAPIPrefix = "/20160918" + ociArtRepos = "containerRepositories" + ociArtImages = "containerImages" + ociArtSigs = "containerImageSignatures" + ociArtConfig = "containerConfiguration" + ociArtGenRepos = "genericRepositories" + ociArtArtifacts = "genericArtifacts" + ociArtRestore = "restore" + ociArtContent = "content" + ociArtMaxName = 255 + ociArtMaxReadme = 1 << 16 + ociArtMaxDesc = 1024 + ociArtMaxQuery = 512 + ociArtMaxDownload = 64 << 20 + ociArtItemSegs = 2 + ociArtSubSegs = 3 + ociArtMarkdown = "TEXT_MARKDOWN" + ociArtPlain = "TEXT_PLAIN" + ociArtFileFallbk = "artifact" + ociArtKeyName = "displayName" + ociArtKeyPage = "page" + ociArtKeyLimit = "limit" + ociArtMiBShift = 20 +) + +var ( + ociArtIDRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,255}$`) + ociArtFileRe = regexp.MustCompile(`[^A-Za-z0-9._-]+`) +) + +// ociArtQueryKeys are the only query parameters each listing forwards; everything else is dropped. +// +//nolint:gochecknoglobals // immutable allow-list +var ociArtQueryKeys = map[string][]string{ + ociArtRepos: {ociKeyCompartment, ociArtKeyName, ociArtKeyPage, ociArtKeyLimit}, + ociArtImages: {ociKeyCompartment, "repositoryId", "lifecycleState", ociArtKeyPage, ociArtKeyLimit}, + ociArtSigs: {ociKeyCompartment, "imageId", ociArtKeyPage, ociArtKeyLimit}, + ociArtConfig: {ociKeyCompartment}, + ociArtGenRepos: {ociKeyCompartment, ociArtKeyPage, ociArtKeyLimit}, + ociArtArtifacts: {ociKeyCompartment, "repositoryId", ociArtKeyPage, ociArtKeyLimit}, +} + +// ociArtCall is one allow-listed request: the emulator path, a rebuilt body, and how to post-process the answer. +type ociArtCall struct { + body func(c *echo.Context) ([]byte, bool) + path string + coll string + download bool + scrub bool +} + +// registerOCIArtifactsRoutes serves the Artifacts and Container Registry page by dispatching in-process to the +// emulator's control plane without request signing. Only the container repository, image, signature, container +// configuration and generic repository and artifact operations the page uses are reachable, each write rebuilt +// from fixed fields. The Docker Registry v2 data plane, the registry token endpoint and the admin key endpoints are +// not routed, so no auth token or bearer token can pass through. Signature message and signature blobs are +// stripped, and artifact content is only served as a size-capped attachment. +func (h *DashboardHandler) registerOCIArtifactsRoutes() { + var inner *echo.Echo + + ops := h.config.OCIArtifactsOps + if ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI Artifacts", next) } + + h.SubRouter.Add(http.MethodGet, ociArtPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: ops.Backend.Compartments()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociArtPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok { + return ociInvalid(c, "unsupported Artifacts proxy request") + } + + call, ok := ociArtResolve(r.Method, parts) + if !ok { + return ociInvalid(c, "unsupported Artifacts proxy request") + } + + var body []byte + + if call.body != nil { + if body, ok = call.body(c); !ok { + return ociInvalid(c, "invalid Artifacts request") + } + } + + r.Header.Del("Authorization") + r.URL.RawQuery = ociArtQuery(r.URL.Query(), ociArtQueryKeys[call.coll]) + + if call.download { + return ociArtDownload(c, inner, call.path) + } + + rec := ociArtForward(c, inner, call.path, body) + if call.scrub && rec.Code == http.StatusOK { + rec.Body = bytes.NewBuffer(ociArtScrubSignatures(rec.Body.Bytes())) + } + + return ociRelay(c, rec, "opc-next-page", "etag", "opc-request-id") + }) + + for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete} { + h.SubRouter.Add(m, ociArtPrefix+"/api/*", serve) + } +} + +// ociArtIDOK checks the resource id segment, the only caller-chosen part of a path. +func ociArtIDOK(parts []string) bool { + return len(parts) < ociArtItemSegs || ociArtIDRe.MatchString(unescapedPath(parts[1])) +} + +// ociArtResolve maps a proxy request to its emulator call; false means the request is outside the allow-list. +func ociArtResolve(method string, parts []string) (ociArtCall, bool) { + if len(parts) > ociArtSubSegs || !ociArtIDOK(parts) { + return ociArtCall{}, false + } + + var ( + call ociArtCall + ok bool + ) + + switch parts[0] { + case ociArtRepos: + call, ok = ociArtRepoCall(method, parts) + case ociArtImages: + call, ok = ociArtImageCall(method, parts) + case ociArtSigs: + call, ok = ociArtCall{ + path: "/container/imageSignatures", + scrub: true, + }, method == http.MethodGet && + len(parts) == 1 + case ociArtConfig: + call, ok = ociArtConfigCall(method, parts) + case ociArtGenRepos: + call, ok = ociArtGenRepoCall(method, parts) + case ociArtArtifacts: + call, ok = ociArtArtifactCall(method, parts) + } + + call.coll = parts[0] + call.path = ociArtAPIPrefix + call.path + + return call, ok +} + +func ociArtRepoCall(method string, parts []string) (ociArtCall, bool) { + switch { + case len(parts) == 1 && method == http.MethodGet: + return ociArtCall{path: "/container/repositories"}, true + case len(parts) == 1 && method == http.MethodPost: + return ociArtCall{path: "/container/repositories", body: ociArtCreateRepoBody}, true + case len(parts) != ociArtItemSegs: + return ociArtCall{}, false + } + + item := "/container/repositories/" + parts[1] + + switch method { + case http.MethodGet, http.MethodDelete: + return ociArtCall{path: item}, true + case http.MethodPut: + return ociArtCall{path: item, body: ociArtUpdateRepoBody}, true + } + + return ociArtCall{}, false +} + +func ociArtImageCall(method string, parts []string) (ociArtCall, bool) { + switch { + case len(parts) == 1 && method == http.MethodGet: + return ociArtCall{path: "/container/images"}, true + case len(parts) == ociArtItemSegs && (method == http.MethodGet || method == http.MethodDelete): + return ociArtCall{path: "/container/images/" + parts[1]}, true + case len(parts) == ociArtSubSegs && parts[2] == ociArtRestore && method == http.MethodPost: + return ociArtCall{path: "/container/images/" + parts[1] + "/actions/restore", body: ociArtRestoreBody}, true + } + + return ociArtCall{}, false +} + +func ociArtConfigCall(method string, parts []string) (ociArtCall, bool) { + if len(parts) != 1 { + return ociArtCall{}, false + } + + switch method { + case http.MethodGet: + return ociArtCall{path: "/container/configuration"}, true + case http.MethodPut: + return ociArtCall{path: "/container/configuration", body: ociArtConfigBody}, true + } + + return ociArtCall{}, false +} + +func ociArtGenRepoCall(method string, parts []string) (ociArtCall, bool) { + switch { + case len(parts) == 1 && method == http.MethodGet: + return ociArtCall{path: "/repositories"}, true + case len(parts) == 1 && method == http.MethodPost: + return ociArtCall{path: "/repositories", body: ociArtCreateGenRepoBody}, true + case len(parts) == ociArtItemSegs && (method == http.MethodGet || method == http.MethodDelete): + return ociArtCall{path: "/repositories/" + parts[1]}, true + } + + return ociArtCall{}, false +} + +func ociArtArtifactCall(method string, parts []string) (ociArtCall, bool) { + switch { + case len(parts) == 1 && method == http.MethodGet: + return ociArtCall{path: "/generic/artifacts"}, true + case len(parts) == ociArtItemSegs && (method == http.MethodGet || method == http.MethodDelete): + return ociArtCall{path: "/generic/artifacts/" + parts[1]}, true + case len(parts) == ociArtSubSegs && parts[2] == ociArtContent && method == http.MethodGet: + return ociArtCall{path: "/generic/artifacts/" + parts[1] + "/content", download: true}, true + } + + return ociArtCall{}, false +} + +// ociArtQuery keeps only the allowed parameters, each bounded in length. +func ociArtQuery(in url.Values, keys []string) string { + out := url.Values{} + + for _, k := range keys { + if v := in.Get(k); v != "" && len(v) <= ociArtMaxQuery { + out.Set(k, v) + } + } + + return out.Encode() +} + +// ociArtForward runs one call against the emulator and records the answer. A nil body forwards no body. +func ociArtForward(c *echo.Context, inner *echo.Echo, apiPath string, body []byte) *httptest.ResponseRecorder { + r := c.Request() + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(apiPath), apiPath + fwd.RequestURI, fwd.Body, fwd.ContentLength = "", http.NoBody, 0 + fwd.Header = fwd.Header.Clone() + + if len(body) > 0 { + fwd.Body, fwd.ContentLength = io.NopCloser(bytes.NewReader(body)), int64(len(body)) + fwd.Header.Set("Content-Type", "application/json") + } + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + return rec +} + +func ociArtName(s string) bool { return s != "" && len(s) <= ociArtMaxName } + +type ociArtReadmeIn struct { + Content *string `json:"content"` + Format string `json:"format"` +} + +// build returns the wire readme, or false when it is oversized or has an unknown format. +func (in *ociArtReadmeIn) build() (map[string]any, bool) { + if in == nil || in.Content == nil { + return nil, true + } + + format := in.Format + if format == "" { + format = ociArtMarkdown + } + + if len(*in.Content) > ociArtMaxReadme || (format != ociArtMarkdown && format != ociArtPlain) { + return nil, false + } + + return map[string]any{"content": *in.Content, "format": format}, true +} + +func ociArtMarshal(v map[string]any) ([]byte, bool) { + b, err := json.Marshal(v) + + return b, err == nil +} + +func ociArtCreateRepoBody(c *echo.Context) ([]byte, bool) { + var in struct { + IsPublic *bool `json:"isPublic"` + IsImmutable *bool `json:"isImmutable"` + Readme *ociArtReadmeIn `json:"readme"` + Compartment string `json:"compartmentId"` + DisplayName string `json:"displayName"` + } + + if !ociDecodeBody(c, &in) || !ociArtName(in.Compartment) || !ociArtName(in.DisplayName) { + return nil, false + } + + readme, ok := in.Readme.build() + if !ok { + return nil, false + } + + out := map[string]any{ + ociKeyCompartment: in.Compartment, ociArtKeyName: in.DisplayName, + "isPublic": in.IsPublic != nil && *in.IsPublic, "isImmutable": in.IsImmutable != nil && *in.IsImmutable, + } + + if readme != nil { + out["readme"] = readme + } + + return ociArtMarshal(out) +} + +func ociArtUpdateRepoBody(c *echo.Context) ([]byte, bool) { + var in struct { + IsPublic *bool `json:"isPublic"` + IsImmutable *bool `json:"isImmutable"` + Readme *ociArtReadmeIn `json:"readme"` + } + + if !ociDecodeBody(c, &in) { + return nil, false + } + + readme, ok := in.Readme.build() + if !ok { + return nil, false + } + + out := map[string]any{} + + if in.IsPublic != nil { + out["isPublic"] = *in.IsPublic + } + + if in.IsImmutable != nil { + out["isImmutable"] = *in.IsImmutable + } + + if readme != nil { + out["readme"] = readme + } + + if len(out) == 0 { + return nil, false + } + + return ociArtMarshal(out) +} + +func ociArtRestoreBody(c *echo.Context) ([]byte, bool) { + var in struct { + Version *string `json:"version"` + } + + err := json.NewDecoder(http.MaxBytesReader(c.Response(), c.Request().Body, ociInprocMaxBody)).Decode(&in) + if err != nil && !errors.Is(err, io.EOF) { + return nil, false + } + + if in.Version == nil { + return nil, true + } + + if !ociArtName(*in.Version) { + return nil, false + } + + return ociArtMarshal(map[string]any{"version": *in.Version}) +} + +func ociArtConfigBody(c *echo.Context) ([]byte, bool) { + var in struct { + OnFirstPush *bool `json:"isRepositoryCreatedOnFirstPush"` + } + + if !ociDecodeBody(c, &in) || in.OnFirstPush == nil { + return nil, false + } + + return ociArtMarshal(map[string]any{"isRepositoryCreatedOnFirstPush": *in.OnFirstPush}) +} + +func ociArtCreateGenRepoBody(c *echo.Context) ([]byte, bool) { + var in struct { + IsImmutable *bool `json:"isImmutable"` + Compartment string `json:"compartmentId"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + } + + if !ociDecodeBody(c, &in) || !ociArtName(in.Compartment) || !ociArtName(in.DisplayName) || + len(in.Description) > ociArtMaxDesc { + return nil, false + } + + out := map[string]any{ + ociKeyCompartment: in.Compartment, ociArtKeyName: in.DisplayName, "repositoryType": "GENERIC", + "isImmutable": in.IsImmutable != nil && *in.IsImmutable, + } + + if in.Description != "" { + out["description"] = in.Description + } + + return ociArtMarshal(out) +} + +// ociArtScrubSignatures drops the base64 message and signature of every signature in a listing. +func ociArtScrubSignatures(raw []byte) []byte { + var env struct { + Items []map[string]any `json:"items"` + } + + if json.Unmarshal(raw, &env) != nil { + return []byte("{}") + } + + for _, it := range env.Items { + delete(it, "message") + delete(it, "signature") + } + + if env.Items == nil { + env.Items = []map[string]any{} + } + + out, err := json.Marshal(env) + if err != nil { + return []byte("{}") + } + + return out +} + +// ociArtDownload answers an artifact content call as a size-capped attachment. The cap is checked on the artifact +// record before its bytes are fetched. +func ociArtDownload(c *echo.Context, inner *echo.Echo, contentPath string) error { + meta := ociArtForward(c, inner, strings.TrimSuffix(contentPath, "/"+ociArtContent), nil) + if meta.Code != http.StatusOK { + return ociRelay(c, meta, "opc-request-id") + } + + var art struct { + Path string `json:"artifactPath"` + Size int64 `json:"sizeInBytes"` + } + + _ = json.Unmarshal(meta.Body.Bytes(), &art) + + if art.Size > ociArtMaxDownload { + return cloudJSONFail(c, http.StatusRequestEntityTooLarge, "PayloadTooLarge", + "artifact exceeds the "+strconv.Itoa(ociArtMaxDownload>>ociArtMiBShift)+" MiB dashboard download limit") + } + + content := ociArtForward(c, inner, contentPath, nil) + if content.Code != http.StatusOK { + return ociRelay(c, content, "opc-request-id") + } + + h := c.Response().Header() + h.Set("Content-Disposition", `attachment; filename="`+ociArtFileName(art.Path)+`"`) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Content-Security-Policy", "sandbox; default-src 'none'") + h.Set("Cache-Control", "no-store") + + return c.Blob(http.StatusOK, ociMimeOctet, content.Body.Bytes()) +} + +// ociArtFileName reduces an artifact path to a safe download file name. +func ociArtFileName(artifactPath string) string { + base := artifactPath[strings.LastIndex(artifactPath, "/")+1:] + base = strings.Trim(ociArtFileRe.ReplaceAllString(base, "_"), "._") + + if base == "" { + return ociArtFileFallbk + } + + if len(base) > ociArtMaxName { + base = base[:ociArtMaxName] + } + + return base +} diff --git a/dashboard/ociartifacts_proxy_test.go b/dashboard/ociartifacts_proxy_test.go new file mode 100644 index 0000000000..8bfe628fe5 --- /dev/null +++ b/dashboard/ociartifacts_proxy_test.go @@ -0,0 +1,381 @@ +package dashboard_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/pkgs/ociregistry/ocitest" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" +) + +const ( + artComp = "ocid1.compartment.oc1..art" + artBase = "/dashboard/api/v1/oci/artifacts" + artAPI = artBase + "/api" + artMsg = "TOP-SECRET-SIGNED-MESSAGE" + artSig = "TOP-SECRET-SIGNATURE-VALUE" +) + +type artFixture struct { + h *dashboard.DashboardHandler + ops *ociartifacts.Handler + srvURL string + imageID string + digest string +} + +func artDo(t *testing.T, h *dashboard.DashboardHandler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + + return lbDo(t, h, method, path, body) +} + +func (f artFixture) direct(t *testing.T, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + + e := echo.New() + e.Any("/*", f.ops.Trusted()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + e.ServeHTTP(rec, req) + + return rec +} + +func newArtDashboard(t *testing.T) artFixture { + t.Helper() + + be := ociartifacts.NewInMemoryBackend() + be.SetContext(t.Context()) + + ops := ociartifacts.NewHandler(be) + + e := echo.New() + e.Any("/*", ops.Handler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + login := base64.StdEncoding.EncodeToString([]byte(ociartifacts.DefaultNamespace + "/alice:a-registry-auth-token")) + reg := ocitest.Client{T: t, Base: srv.URL, HTTP: testclient.New(t), Prepare: func(r *http.Request) { + r.Header.Set("Authorization", "Basic "+login) + }} + img := reg.PushImage(ociartifacts.DefaultNamespace+"/web/api", "v1") + + f := artFixture{ + h: dashboard.NewHandler(dashboard.Config{OCIArtifactsOps: ops}), ops: ops, srvURL: srv.URL, + digest: ocitest.Digest(img.Body), + } + + rec := artDo(t, f.h, http.MethodGet, artAPI+"/containerImages?compartmentId="+artTenancy(t, f), "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var list struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Len(t, list.Items, 1) + f.imageID = list.Items[0].ID + + return f +} + +func artTenancy(t *testing.T, f artFixture) string { + t.Helper() + + rec := artDo(t, f.h, http.MethodGet, artBase+"/compartments", "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var out struct { + Compartments []string `json:"compartments"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.NotEmpty(t, out.Compartments) + + return out.Compartments[0] +} + +func TestOCIArtifactsProxyRead(t *testing.T) { + t.Parallel() + + f := newArtDashboard(t) + tenancy := artTenancy(t, f) + + runGCPSteps(t, f.h, []gcpStep{ + {http.MethodGet, artBase + "/compartments", "", tenancy, 200}, + { + http.MethodGet, + artAPI + "/containerRepositories?compartmentId=" + tenancy, + "", + `"displayName":"web/api"`, + 200, + }, + {http.MethodGet, artAPI + "/containerImages?compartmentId=" + tenancy, "", f.digest, 200}, + {http.MethodGet, artAPI + "/containerImages/" + f.imageID, "", `"layers"`, 200}, + { + http.MethodGet, + artAPI + "/containerConfiguration?compartmentId=" + tenancy, + "", + `"namespace":"gopherstack"`, + 200, + }, + {http.MethodGet, artAPI + "/genericRepositories?compartmentId=" + tenancy, "", "items", 200}, + {http.MethodGet, artAPI + "/containerImages/missing", "", "NotAuthorizedOrNotFound", 404}, + }) +} + +func TestOCIArtifactsProxyRepositoryActions(t *testing.T) { + t.Parallel() + + f := newArtDashboard(t) + + create := artDo(t, f.h, http.MethodPost, artAPI+"/containerRepositories", + `{"compartmentId":"`+artComp+`","displayName":"team/svc","isPublic":true,"readme":{"content":"# hi"},`+ + `"freeformTags":{"forged":"yes"},"createdBy":"forged","id":"forged"}`) + require.Equal(t, http.StatusOK, create.Code, create.Body.String()) + + var repo struct { + Tags map[string]string `json:"freeformTags"` + Readme struct { + Format string `json:"format"` + } `json:"readme"` + ID string `json:"id"` + CreatedBy string `json:"createdBy"` + Public bool `json:"isPublic"` + Immutable bool `json:"isImmutable"` + } + + require.NoError(t, json.Unmarshal(create.Body.Bytes(), &repo)) + assert.NotEqual(t, "forged", repo.ID) + assert.NotEqual(t, "forged", repo.CreatedBy) + assert.Empty(t, repo.Tags) + assert.True(t, repo.Public) + assert.False(t, repo.Immutable) + assert.Equal(t, "TEXT_MARKDOWN", repo.Readme.Format) + + upd := artDo(t, f.h, http.MethodPut, artAPI+"/containerRepositories/"+repo.ID, + `{"isImmutable":true,"isPublic":false,"freeformTags":{"forged":"yes"},"compartmentId":"x"}`) + require.Equal(t, http.StatusOK, upd.Code, upd.Body.String()) + require.NoError(t, json.Unmarshal(upd.Body.Bytes(), &repo)) + assert.True(t, repo.Immutable) + assert.False(t, repo.Public) + assert.Empty(t, repo.Tags) + + assert.Equal(t, http.StatusNoContent, + artDo(t, f.h, http.MethodDelete, artAPI+"/containerRepositories/"+repo.ID, "").Code) + assert.Equal( + t, + http.StatusNotFound, + artDo(t, f.h, http.MethodGet, artAPI+"/containerRepositories/"+repo.ID, "").Code, + ) +} + +func TestOCIArtifactsProxyImageDeleteRestoreAndConfig(t *testing.T) { + t.Parallel() + + f := newArtDashboard(t) + tenancy := artTenancy(t, f) + img := artAPI + "/containerImages/" + f.imageID + + assert.Equal(t, http.StatusNoContent, artDo(t, f.h, http.MethodDelete, img, "").Code) + assert.NotContains(t, + artDo(t, f.h, http.MethodGet, artAPI+"/containerImages?compartmentId="+tenancy, "").Body.String(), f.digest) + assert.Contains(t, + artDo(t, f.h, http.MethodGet, artAPI+"/containerImages?compartmentId="+tenancy+"&lifecycleState=DELETED", ""). + Body.String(), f.digest) + + restored := artDo(t, f.h, http.MethodPost, img+"/restore", "") + require.Equal(t, http.StatusOK, restored.Code, restored.Body.String()) + assert.Contains(t, restored.Body.String(), `"lifecycleState":"AVAILABLE"`) + + cfg := artDo(t, f.h, http.MethodPut, artAPI+"/containerConfiguration?compartmentId="+tenancy, + `{"isRepositoryCreatedOnFirstPush":false,"namespace":"forged"}`) + require.Equal(t, http.StatusOK, cfg.Code, cfg.Body.String()) + assert.Contains(t, cfg.Body.String(), `"isRepositoryCreatedOnFirstPush":false`) + assert.Contains(t, cfg.Body.String(), `"namespace":"gopherstack"`) +} + +func TestOCIArtifactsProxySignaturesAreScrubbed(t *testing.T) { + t.Parallel() + + f := newArtDashboard(t) + tenancy := artTenancy(t, f) + enc := base64.StdEncoding.EncodeToString + + body, err := json.Marshal(map[string]string{ + "compartmentId": tenancy, "imageId": f.imageID, "kmsKeyId": "ocid1.key.oc1..kmskey0001", + "kmsKeyVersionId": "ocid1.keyversion.oc1..ver", "message": enc([]byte(artMsg)), + "signature": enc([]byte(artSig)), "signingAlgorithm": "SHA_256_RSA_PKCS_PSS", + }) + require.NoError(t, err) + + made := f.direct(t, http.MethodPost, "/20160918/container/imageSignatures", string(body)) + require.Equal(t, http.StatusOK, made.Code, made.Body.String()) + require.Contains(t, made.Body.String(), enc([]byte(artSig)), "control plane keeps the blob") + + for _, q := range []string{"", "&imageId=" + f.imageID} { + rec := artDo(t, f.h, http.MethodGet, artAPI+"/containerImageSignatures?compartmentId="+tenancy+q, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "ocid1.key.oc1..kmskey0001") + assert.Contains(t, rec.Body.String(), "SHA_256_RSA_PKCS_PSS") + assert.NotContains(t, rec.Body.String(), enc([]byte(artSig))) + assert.NotContains(t, rec.Body.String(), enc([]byte(artMsg))) + assert.NotContains(t, rec.Body.String(), `"signature"`) + assert.NotContains(t, rec.Body.String(), `"message"`) + } +} + +func TestOCIArtifactsProxyGenericLifecycleAndDownload(t *testing.T) { + t.Parallel() + + f := newArtDashboard(t) + + made := artDo(t, f.h, http.MethodPost, artAPI+"/genericRepositories", + `{"compartmentId":"`+artComp+`","displayName":"files","description":"d","isImmutable":false,"id":"forged"}`) + require.Equal(t, http.StatusOK, made.Code, made.Body.String()) + + var repo struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(made.Body.Bytes(), &repo)) + assert.NotEqual(t, "forged", repo.ID) + + put := f.direct(t, http.MethodPut, + "/20160918/generic/repositories/"+repo.ID+"/artifactPaths/dir/bad%22name%20x.html/versions/1.0/content", + "") + require.Equal(t, http.StatusOK, put.Code, put.Body.String()) + + list := artDo(t, f.h, http.MethodGet, + artAPI+"/genericArtifacts?compartmentId="+artComp+"&repositoryId="+repo.ID, "") + require.Equal(t, http.StatusOK, list.Code, list.Body.String()) + + var arts struct { + Items []struct { + ID string `json:"id"` + Sha256 string `json:"sha256"` + } `json:"items"` + } + + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &arts)) + require.Len(t, arts.Items, 1) + assert.NotEmpty(t, arts.Items[0].Sha256) + + dl := artDo(t, f.h, http.MethodGet, artAPI+"/genericArtifacts/"+arts.Items[0].ID+"/content", "") + require.Equal(t, http.StatusOK, dl.Code, dl.Body.String()) + assert.Equal(t, "", dl.Body.String()) + assert.Equal(t, "application/octet-stream", dl.Header().Get("Content-Type")) + assert.Equal(t, `attachment; filename="bad_name_x.html"`, dl.Header().Get("Content-Disposition")) + assert.Equal(t, "nosniff", dl.Header().Get("X-Content-Type-Options")) + assert.Contains(t, dl.Header().Get("Content-Security-Policy"), "sandbox") + + missing := artDo(t, f.h, http.MethodGet, artAPI+"/genericArtifacts/nope/content", "") + assert.Equal(t, http.StatusNotFound, missing.Code) + + assert.Equal(t, http.StatusNoContent, + artDo(t, f.h, http.MethodDelete, artAPI+"/genericArtifacts/"+arts.Items[0].ID, "").Code) + assert.Equal( + t, + http.StatusNoContent, + artDo(t, f.h, http.MethodDelete, artAPI+"/genericRepositories/"+repo.ID, "").Code, + ) +} + +func TestOCIArtifactsProxyRejects(t *testing.T) { + t.Parallel() + + f := newArtDashboard(t) + img := artAPI + "/containerImages/" + f.imageID + repos := artAPI + "/containerRepositories" + + tests := []struct { + name string + method string + path string + body string + }{ + {"registry data plane", http.MethodGet, artAPI + "/v2/gopherstack/web/api/manifests/v1", ""}, + {"token endpoint", http.MethodGet, artAPI + "/docker/token", ""}, + {"admin keys", http.MethodPost, artAPI + "/_gopherstack/oci/keys", `{}`}, + {"unknown collection", http.MethodGet, artAPI + "/shapes", ""}, + {"update image", http.MethodPut, img, `{}`}, + {"create image", http.MethodPost, artAPI + "/containerImages", `{}`}, + {"remove version", http.MethodPost, img + "/removeVersion", `{"version":"v1"}`}, + {"restore wrong verb", http.MethodGet, img + "/restore", ""}, + {"delete signature", http.MethodDelete, artAPI + "/containerImageSignatures/x", ""}, + {"create signature", http.MethodPost, artAPI + "/containerImageSignatures", `{}`}, + {"change compartment", http.MethodPost, repos + "/x/actions/changeCompartment", `{}`}, + {"update generic artifact", http.MethodPut, artAPI + "/genericArtifacts/x", `{}`}, + {"upload content", http.MethodPut, artAPI + "/genericArtifacts/x/content", "data"}, + {"delete content", http.MethodDelete, artAPI + "/genericArtifacts/x/content", ""}, + {"too many segments", http.MethodGet, repos + "/x/y/z", ""}, + {"dot segments", http.MethodGet, repos + "/%2e%2e", ""}, + {"encoded slash", http.MethodGet, repos + "/a%2fb", ""}, + {"bad id chars", http.MethodGet, repos + "/a%20b", ""}, + {"delete collection", http.MethodDelete, repos, ""}, + {"bad create body", http.MethodPost, repos, `nope`}, + {"create without name", http.MethodPost, repos, `{"compartmentId":"c"}`}, + { + "create bad readme format", + http.MethodPost, + repos, + `{"compartmentId":"c","displayName":"d","readme":{"content":"x","format":"HTML"}}`, + }, + {"empty update", http.MethodPut, repos + "/x", `{"freeformTags":{"a":"b"}}`}, + {"config without flag", http.MethodPut, artAPI + "/containerConfiguration?compartmentId=c", `{}`}, + {"generic create without name", http.MethodPost, artAPI + "/genericRepositories", `{"compartmentId":"c"}`}, + {"restore bad body", http.MethodPost, img + "/restore", `nope`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := artDo(t, f.h, tt.method, tt.path, tt.body) + assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + } +} + +func TestOCIArtifactsProxyDropsAuthorizationAndUnknownQuery(t *testing.T) { + t.Parallel() + + f := newArtDashboard(t) + tenancy := artTenancy(t, f) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + artAPI+"/containerRepositories?compartmentId="+tenancy+"&limit=1&x=y", + nil, + ) + req.Header.Set("Authorization", "Basic ZmFrZTpmYWtl") + require.NoError(t, f.h.Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) +} + +func TestOCIArtifactsProxyUnavailable(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, artAPI + "/containerRepositories?compartmentId=c", "", "", http.StatusServiceUnavailable}, + {http.MethodGet, artBase + "/compartments", "", "", http.StatusServiceUnavailable}, + {http.MethodGet, artAPI + "/genericArtifacts/x/content", "", "", http.StatusServiceUnavailable}, + }) +} diff --git a/dashboard/ocicontainerengine_proxy.go b/dashboard/ocicontainerengine_proxy.go new file mode 100644 index 0000000000..8986db70ac --- /dev/null +++ b/dashboard/ocicontainerengine_proxy.go @@ -0,0 +1,152 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociOKEPrefix = ociProxyPrefix + "/containerengine" + ociOKEAPIPrefix = "/20180222" + ociOKEMaxBody = 1 << 12 + ociOKEItemSegs = 2 + ociOKEChildSegs = 3 + ociOKEClusters = "clusters" + ociOKEPools = "nodePools" + ociOKEWorkReqs = "workRequests" + ociOKEWRLogs = "logs" + ociOKEWRErrors = "errors" +) + +// registerOCIContainerEngineRoutes serves the Container Engine page by dispatching in-process to the emulator's +// handler without request signing. Only cluster, node pool and work request reads, cluster deletion and node pool +// resizing are reachable; kubeconfig generation is not, and a resize forwards nothing but the new size. +func (h *DashboardHandler) registerOCIContainerEngineRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCIContainerEngineOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { + return ociGuard(inner != nil, "OCI Container Engine", next) + } + + h.SubRouter.Add(http.MethodGet, ociOKEPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociOKEPrefix+"/api") + + if !ociOKEAllowed(r.Method, raw) { + return cloudJSONFail( + c, + http.StatusBadRequest, + "InvalidParameter", + "unsupported Container Engine proxy request", + ) + } + + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(ociOKEAPIPrefix+raw), ociOKEAPIPrefix+raw + fwd.RequestURI = "" + fwd.Body = http.MaxBytesReader(c.Response(), r.Body, ociOKEMaxBody) + + if r.Method == http.MethodPut { + body, ok := ociOKEResizeBody(fwd.Body) + if !ok { + return cloudJSONFail(c, http.StatusBadRequest, "InvalidParameter", + "a node pool update must set nodeConfigDetails.size or quantityPerSubnet") + } + + fwd.Body, fwd.ContentLength = io.NopCloser(bytes.NewReader(body)), int64(len(body)) + } + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + return ociRelay(c, rec, "opc-next-page", "etag", "opc-request-id", "opc-work-request-id") + }) + + for _, m := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + h.SubRouter.Add(m, ociOKEPrefix+"/api/*", serve) + } +} + +// ociOKEAllowed validates a proxied path (below /api) against the allowed operations. +func ociOKEAllowed(method, raw string) bool { + parts := strings.Split(strings.TrimPrefix(raw, "/"), "/") + + for _, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || dec == "" || dec == "." || dec == ".." || strings.Contains(dec, "/") { + return false + } + } + + switch method { + case http.MethodGet: + return ociOKEAllowedRead(parts) + case http.MethodDelete: + return len(parts) == ociOKEItemSegs && parts[0] == ociOKEClusters + case http.MethodPut: + return len(parts) == ociOKEItemSegs && parts[0] == ociOKEPools + } + + return false +} + +func ociOKEAllowedRead(parts []string) bool { + switch len(parts) { + case 1, ociOKEItemSegs: + return slices.Contains([]string{ociOKEClusters, ociOKEPools, ociOKEWorkReqs}, parts[0]) + case ociOKEChildSegs: + return parts[0] == ociOKEWorkReqs && slices.Contains([]string{ociOKEWRLogs, ociOKEWRErrors}, parts[2]) + } + + return false +} + +// ociOKEResizeBody rebuilds a node pool update from the requested size alone. +func ociOKEResizeBody(in io.Reader) ([]byte, bool) { + var req struct { + NodeConfig *struct { + Size *int `json:"size"` + } `json:"nodeConfigDetails"` + Quantity *int `json:"quantityPerSubnet"` + } + + if err := json.NewDecoder(in).Decode(&req); err != nil { + return nil, false + } + + var out any + + switch { + case req.NodeConfig != nil && req.NodeConfig.Size != nil: + out = map[string]any{"nodeConfigDetails": map[string]any{"size": *req.NodeConfig.Size}} + case req.Quantity != nil: + out = map[string]any{"quantityPerSubnet": *req.Quantity} + default: + return nil, false + } + + body, err := json.Marshal(out) + + return body, err == nil +} diff --git a/dashboard/ocicontainerengine_proxy_test.go b/dashboard/ocicontainerengine_proxy_test.go new file mode 100644 index 0000000000..ff1f28aa91 --- /dev/null +++ b/dashboard/ocicontainerengine_proxy_test.go @@ -0,0 +1,183 @@ +package dashboard_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" +) + +const ( + okeComp = "ocid1.compartment.oc1..aaaa" + okeBase = "/dashboard/api/v1/oci/containerengine" +) + +func okeSeed(t *testing.T, e *echo.Echo, method, path, body string) []byte { + t.Helper() + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body))) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", path, rec.Body.String()) + + return rec.Body.Bytes() +} + +func newOKEDashboard(t *testing.T) (*dashboard.DashboardHandler, string, string) { + t.Helper() + + b := ocicontainerengine.NewInMemoryBackend() + b.SetTransition(0) + t.Cleanup(b.Close) + + ops := ocicontainerengine.NewHandler(b) + e := echo.New() + e.Any("/*", ops.Trusted()) + + okeSeed(t, e, http.MethodPost, "/20180222/clusters", + `{"name":"prod","compartmentId":"`+okeComp+`","kubernetesVersion":"v1.34.2",`+ + `"vcnId":"ocid1.vcn.oc1.iad.vcna","endpointConfig":{"subnetId":"ocid1.subnet.oc1.iad.sub1"}}`) + + var clusters []struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(okeSeed(t, e, http.MethodGet, + "/20180222/clusters?compartmentId="+okeComp, ""), &clusters)) + require.Len(t, clusters, 1) + + okeSeed(t, e, http.MethodPost, "/20180222/nodePools", + `{"name":"workers","clusterId":"`+clusters[0].ID+`","compartmentId":"`+okeComp+`",`+ + `"nodeShape":"VM.Standard.E4.Flex","kubernetesVersion":"v1.34.2",`+ + `"nodeShapeConfig":{"ocpus":2,"memoryInGBs":16},`+ + `"nodeSourceDetails":{"sourceType":"IMAGE","imageId":"ocid1.image.oc1.iad.ol8"},`+ + `"nodeConfigDetails":{"size":2,"placementConfigs":[{"availabilityDomain":"Uocm:US-ASHBURN-AD-1",`+ + `"subnetId":"ocid1.subnet.oc1.iad.sub2"}]}}`) + + var pools []struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(okeSeed(t, e, http.MethodGet, + "/20180222/nodePools?compartmentId="+okeComp, ""), &pools)) + require.Len(t, pools, 1) + + return dashboard.NewHandler(dashboard.Config{OCIContainerEngineOps: ops}), clusters[0].ID, pools[0].ID +} + +func TestOCIContainerEngineProxy(t *testing.T) { + t.Parallel() + + api := okeBase + "/api" + + tests := []struct { + steps func(cluster, pool string) []gcpStep + name string + }{ + {name: "read", steps: func(cluster, pool string) []gcpStep { + return []gcpStep{ + {http.MethodGet, okeBase + "/compartments", "", okeComp, 200}, + {http.MethodGet, api + "/clusters?compartmentId=" + okeComp, "", `"name":"prod"`, 200}, + {http.MethodGet, api + "/clusters/" + cluster, "", `"kubernetesVersion":"v1.34.2"`, 200}, + {http.MethodGet, api + "/nodePools?compartmentId=" + okeComp + "&clusterId=" + cluster, "", + `"name":"workers"`, 200}, + {http.MethodGet, api + "/nodePools/" + pool, "", `"nodes"`, 200}, + {http.MethodGet, api + "/workRequests?compartmentId=" + okeComp, "", "CLUSTER_CREATE", 200}, + {http.MethodGet, api + "/clusters/ocid1.cluster.oc1..nope", "", "NotAuthorizedOrNotFound", 404}, + } + }}, + {name: "scale", steps: func(_, pool string) []gcpStep { + return []gcpStep{ + { + http.MethodPut, + api + "/nodePools/" + pool, + `{"nodeConfigDetails":{"size":4}}`, + "", + http.StatusAccepted, + }, + {http.MethodGet, api + "/nodePools/" + pool, "", `"size":4`, 200}, + {http.MethodPut, api + "/nodePools/" + pool, `{"nodeConfigDetails":{"size":99999}}`, "size", 400}, + } + }}, + {name: "scale forwards only the size", steps: func(_, pool string) []gcpStep { + return []gcpStep{ + { + http.MethodPut, + api + "/nodePools/" + pool, + `{"name":"renamed","nodeShape":"VM.Standard2.1","nodeConfigDetails":{"size":3}}`, + "", + http.StatusAccepted, + }, + {http.MethodGet, api + "/nodePools/" + pool, "", `"name":"workers"`, 200}, + {http.MethodPut, api + "/nodePools/" + pool, `{"name":"renamed"}`, "nodeConfigDetails.size", 400}, + {http.MethodPut, api + "/nodePools/" + pool, `not json`, "nodeConfigDetails.size", 400}, + } + }}, + {name: "delete", steps: func(cluster, _ string) []gcpStep { + return []gcpStep{ + {http.MethodDelete, api + "/clusters/" + cluster, "", "", http.StatusAccepted}, + {http.MethodGet, api + "/workRequests?compartmentId=" + okeComp, "", "CLUSTER_DELETE", 200}, + } + }}, + {name: "rejects", steps: func(cluster, pool string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/clusters", `{}`, "", http.StatusMethodNotAllowed}, + {http.MethodPut, api + "/clusters/" + cluster, `{}`, "unsupported", 400}, + {http.MethodDelete, api + "/nodePools/" + pool, "", "unsupported", 400}, + {http.MethodDelete, api + "/nodePools/" + pool + "/node/n1", "", "unsupported", 400}, + { + http.MethodPost, + api + "/clusters/" + cluster + "/kubeconfig/content", + `{}`, + "", + http.StatusMethodNotAllowed, + }, + {http.MethodGet, api + "/clusters/" + cluster + "/kubeconfig/content", "", "unsupported", 400}, + {http.MethodGet, api + "/clusterOptions/all", "", "unsupported", 400}, + {http.MethodGet, api + "/clusters/%2e%2e", "", "unsupported", 400}, + {http.MethodGet, "/dashboard/api/v1/oci/containerengine/api/", "", "unsupported", 400}, + } + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, cluster, pool := newOKEDashboard(t) + runGCPSteps(t, h, tt.steps(cluster, pool)) + }) + } + + t.Run("credentials stay server side", func(t *testing.T) { + t.Parallel() + + h, cluster, pool := newOKEDashboard(t) + for _, p := range []string{ + api + "/clusters?compartmentId=" + okeComp, api + "/clusters/" + cluster, api + "/nodePools/" + pool, + } { + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext(httptest.NewRequest(http.MethodGet, p, nil), rec))) + + for _, secret := range []string{"token", "caPem", "BEGIN CERTIFICATE", "kubeconfig"} { + assert.NotContains(t, rec.Body.String(), secret, p) + } + } + }) + + t.Run("unserved", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, api+"/clusters?compartmentId="+okeComp, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/ocicontainerinstances_proxy.go b/dashboard/ocicontainerinstances_proxy.go new file mode 100644 index 0000000000..95cb0e859d --- /dev/null +++ b/dashboard/ocicontainerinstances_proxy.go @@ -0,0 +1,627 @@ +package dashboard + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "github.com/labstack/echo/v5" + + idbackend "github.com/blackbirdworks/gopherstack/services/ociidentity" +) + +const ( + ociCIPrefix = ociProxyPrefix + "/containerinstances" + ociCIAPIPrefix = "/20210415" + ociCICoreAPI = "/20160918" + ociCIInstances = "containerInstances" + ociCIShapes = "containerInstanceShapes" + ociCIContainers = "containers" + ociCIWorkReqs = "workRequests" + ociCIActions = "actions" + ociCIRetrieveLogs = "retrieveLogs" + ociCIErrors = "errors" + ociCIMaxBody = 1 << 16 + ociCILogCap = 256 << 10 + ociCIMaxContainer = 8 + ociCIMaxEntries = 64 + ociCIMaxArg = 1024 + ociCIMaxEnvValue = 4096 + ociCIMaxName = 255 + ociCIItemSegs = 2 + ociCIActionSegs = 4 + ociCISubSegs = 3 + ociCIKeyInstance = "containerInstanceId" + ociCIKeyOcpus = "ocpus" + ociCIKeyMemory = "memoryInGBs" + ociCIKeyEnv = "environmentVariables" + ociCIKeyEnvNames = "environmentVariableNames" + ociCIKeyDisplay = "displayName" + ociCIKeyIsPrev = "isPrevious" + ociCIKeyState = "lifecycleState" + ociCIKeyItems = "items" + ociCIKeyName = "name" + ociCIKeyLimit = "limit" + ociCIKeyPage = "page" + ociCIKeyStatus = "status" +) + +var ( + ociCIIDRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,255}$`) + ociCIEnvNmRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]{0,127}$`) +) + +func ociCIRestartOK(p string) bool { + switch p { + case "", "ALWAYS", "NEVER", "ON_FAILURE": + return true + } + + return false +} + +// ociCIQueryKeys are the only query parameters each listing forwards; everything else is dropped. +// +//nolint:gochecknoglobals // immutable allow-list +var ociCIQueryKeys = map[string][]string{ + ociCIInstances: {ociKeyCompartment, ociCIKeyState, ociCIKeyDisplay, ociCIKeyLimit, ociCIKeyPage}, + ociCIShapes: {ociKeyCompartment, ociCIKeyLimit, ociCIKeyPage}, + ociCIContainers: {ociKeyCompartment, ociCIKeyInstance, ociCIKeyState, ociCIKeyLimit, ociCIKeyPage}, + ociCIWorkReqs: {ociKeyCompartment, "resourceId", ociCIKeyStatus, ociCIKeyLimit, ociCIKeyPage}, + ociCIErrors: {ociCIKeyLimit, ociCIKeyPage}, + ociCIRetrieveLogs: { + ociCIKeyIsPrev, + }, +} + +type ociCICall struct { + body func(c *echo.Context) ([]byte, bool) + path string + coll string + logs bool +} + +// registerOCIContainerInstancesRoutes serves the Container Instances page by dispatching in-process to the +// emulator's control plane without request signing. Only the instance, container, shape, work request and log +// operations the page uses are reachable, each write rebuilt from fixed fields. Environment variable values, +// image pull secret credentials and config file contents are removed from every answer before it leaves. +func (h *DashboardHandler) registerOCIContainerInstancesRoutes() { + var inner *echo.Echo + + ops := h.config.OCIContainerInstancesOps + if ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { + return ociGuard(inner != nil, "OCI Container Instances", next) + } + + h.SubRouter.Add(http.MethodGet, ociCIPrefix+"/compartments", guard(func(c *echo.Context) error { + comps := ops.Backend.Compartments() + if !slices.Contains(comps, idbackend.TenancyID) { + comps = slices.Insert(comps, 0, idbackend.TenancyID) + } + + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps}) + })) + + h.SubRouter.Add(http.MethodGet, ociCIPrefix+"/engine", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{"engine": ops.Backend.EngineMode()}) + })) + + h.SubRouter.Add(http.MethodGet, ociCIPrefix+"/availabilitydomains", guard(h.ociCIAvailabilityDomains)) + h.SubRouter.Add(http.MethodGet, ociCIPrefix+"/subnets", guard(h.ociCISubnets)) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + + parts, ok := ociSplitPath(strings.TrimPrefix(r.URL.EscapedPath(), ociCIPrefix+"/api")) + if !ok { + return ociInvalid(c, "unsupported Container Instances proxy request") + } + + call, ok := ociCIResolve(r.Method, parts) + if !ok { + return ociInvalid(c, "unsupported Container Instances proxy request") + } + + var body []byte + + if call.body != nil { + if body, ok = call.body(c); !ok { + return ociInvalid(c, "invalid Container Instances request") + } + } + + r.Header.Del("Authorization") + r.URL.RawQuery = ociCIQuery(call, r.URL.Query()) + + rec := ociArtForward(c, inner, call.path, body) + + if call.logs { + return ociCILogs(c, rec) + } + + if rec.Code < http.StatusMultipleChoices { + rec.Body = bytes.NewBuffer(ociCIScrubJSON(rec.Body.Bytes())) + } + + return ociRelay(c, rec, "opc-next-page", "etag", "opc-request-id", "opc-work-request-id") + }) + + for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete} { + h.SubRouter.Add(m, ociCIPrefix+"/api/*", serve) + } +} + +func ociCIQuery(call ociCICall, in url.Values) string { + keys := ociCIQueryKeys[call.coll] + if call.logs { + keys = ociCIQueryKeys[ociCIRetrieveLogs] + } + + return ociArtQuery(in, keys) +} + +// ociCIResolve maps a proxy request to its emulator call; false means the request is outside the allow-list. +func ociCIResolve(method string, parts []string) (ociCICall, bool) { + if len(parts) > ociCIActionSegs || (len(parts) >= ociCIItemSegs && !ociCIIDOK(parts[1])) { + return ociCICall{}, false + } + + var ( + call ociCICall + ok bool + ) + + switch parts[0] { + case ociCIInstances: + call, ok = ociCIInstanceCall(method, parts) + case ociCIShapes: + call, ok = ociCICall{path: "/containerInstanceShapes"}, method == http.MethodGet && len(parts) == 1 + case ociCIContainers: + call, ok = ociCIContainerCall(method, parts) + case ociCIWorkReqs: + call, ok = ociCIWorkReqCall(method, parts) + } + + call.coll = parts[0] + if len(parts) == ociCISubSegs && parts[2] == ociCIErrors { + call.coll = ociCIErrors + } + + call.path = ociCIAPIPrefix + call.path + + return call, ok +} + +func ociCIInstanceCall(method string, parts []string) (ociCICall, bool) { + switch { + case len(parts) == 1 && method == http.MethodGet: + return ociCICall{path: "/containerInstances"}, true + case len(parts) == 1 && method == http.MethodPost: + return ociCICall{path: "/containerInstances", body: ociCICreateBody}, true + case len(parts) == ociCIItemSegs: + item := "/containerInstances/" + parts[1] + + switch method { + case http.MethodGet, http.MethodDelete: + return ociCICall{path: item}, true + case http.MethodPut: + return ociCICall{path: item, body: ociCIUpdateBody}, true + } + case len(parts) == ociCISubSegs+1 && method == http.MethodPost && parts[2] == ociCIActions && + ociCIActionOK(parts[3]): + return ociCICall{path: "/containerInstances/" + parts[1] + "/actions/" + parts[3]}, true + } + + return ociCICall{}, false +} + +func ociCIContainerCall(method string, parts []string) (ociCICall, bool) { + switch { + case len(parts) == 1 && method == http.MethodGet: + return ociCICall{path: "/containers"}, true + case len(parts) == ociCIItemSegs && method == http.MethodGet: + return ociCICall{path: "/containers/" + parts[1]}, true + case len(parts) == ociCISubSegs+1 && method == http.MethodPost && parts[2] == ociCIActions && + parts[3] == ociCIRetrieveLogs: + return ociCICall{path: "/containers/" + parts[1] + "/actions/retrieveLogs", logs: true}, true + } + + return ociCICall{}, false +} + +func ociCIWorkReqCall(method string, parts []string) (ociCICall, bool) { + if method != http.MethodGet { + return ociCICall{}, false + } + + switch { + case len(parts) == 1: + return ociCICall{path: "/workRequests"}, true + case len(parts) == ociCIItemSegs: + return ociCICall{path: "/workRequests/" + parts[1]}, true + case len(parts) == ociCISubSegs && parts[2] == ociCIErrors: + return ociCICall{path: "/workRequests/" + parts[1] + "/errors"}, true + } + + return ociCICall{}, false +} + +func ociCIIDOK(seg string) bool { return ociCIIDRe.MatchString(unescapedPath(seg)) } + +func ociCIActionOK(a string) bool { return a == aciPxStart || a == aciPxStop || a == "restart" } + +func ociCIName(s string) bool { return s != "" && len(s) <= ociCIMaxName } + +func ociCIStrings(in []string) bool { + return len(in) <= ociCIMaxEntries && !slices.ContainsFunc(in, func(s string) bool { return len(s) > ociCIMaxArg }) +} + +type ociCIContainerIn struct { + Env map[string]string `json:"environmentVariables"` + Resource *struct { + Vcpus *float64 `json:"vcpusLimit"` + Mem *float64 `json:"memoryLimitInGBs"` + } `json:"resourceConfig"` + ImageURL string `json:"imageUrl"` + DisplayName string `json:"displayName"` + Command []string `json:"command"` + Arguments []string `json:"arguments"` +} + +// build returns the rebuilt wire container, or false when a field is out of bounds. +func (in ociCIContainerIn) build() (map[string]any, bool) { + if !ociCIName(in.ImageURL) || len(in.DisplayName) > ociCIMaxName || !ociCIStrings(in.Command) || + !ociCIStrings(in.Arguments) || len(in.Env) > ociCIMaxEntries { + return nil, false + } + + out := map[string]any{"imageUrl": in.ImageURL} + if in.DisplayName != "" { + out[ociCIKeyDisplay] = in.DisplayName + } + + if len(in.Command) > 0 { + out["command"] = in.Command + } + + if len(in.Arguments) > 0 { + out["arguments"] = in.Arguments + } + + if len(in.Env) > 0 { + if !ociCIEnvOK(in.Env) { + return nil, false + } + + out[ociCIKeyEnv] = in.Env + } + + if rc := in.resource(); rc != nil { + out["resourceConfig"] = rc + } + + return out, true +} + +func (in ociCIContainerIn) resource() map[string]any { + if in.Resource == nil { + return nil + } + + rc := map[string]any{} + if in.Resource.Vcpus != nil { + rc["vcpusLimit"] = *in.Resource.Vcpus + } + + if in.Resource.Mem != nil { + rc["memoryLimitInGBs"] = *in.Resource.Mem + } + + if len(rc) == 0 { + return nil + } + + return rc +} + +func ociCIEnvOK(env map[string]string) bool { + for k, v := range env { + if !ociCIEnvNmRe.MatchString(k) || len(v) > ociCIMaxEnvValue { + return false + } + } + + return true +} + +type ociCICreateIn struct { + ShapeConfig *struct { + Ocpus *float64 `json:"ocpus"` + Memory *float64 `json:"memoryInGBs"` + } `json:"shapeConfig"` + Graceful *int64 `json:"gracefulShutdownTimeoutInSeconds"` + Compartment string `json:"compartmentId"` + AD string `json:"availabilityDomain"` + DisplayName string `json:"displayName"` + Shape string `json:"shape"` + Restart string `json:"containerRestartPolicy"` + Containers []ociCIContainerIn `json:"containers"` + Vnics []struct { + SubnetID string `json:"subnetId"` + } `json:"vnics"` +} + +func (in *ociCICreateIn) valid() bool { + return ociCIName(in.Compartment) && ociCIName(in.Shape) && ociCIName(in.AD) && + len(in.DisplayName) <= ociCIMaxName && ociCIRestartOK(in.Restart) && + in.ShapeConfig != nil && in.ShapeConfig.Ocpus != nil && + len(in.Containers) > 0 && len(in.Containers) <= ociCIMaxContainer && + len(in.Vnics) == 1 && ociCIName(in.Vnics[0].SubnetID) +} + +func (in *ociCICreateIn) build() (map[string]any, bool) { + sc := map[string]any{ociCIKeyOcpus: *in.ShapeConfig.Ocpus} + if in.ShapeConfig.Memory != nil { + sc[ociCIKeyMemory] = *in.ShapeConfig.Memory + } + + ctrs := make([]map[string]any, 0, len(in.Containers)) + + for _, ci := range in.Containers { + built, ok := ci.build() + if !ok { + return nil, false + } + + ctrs = append(ctrs, built) + } + + out := map[string]any{ + ociKeyCompartment: in.Compartment, "availabilityDomain": in.AD, "shape": in.Shape, "shapeConfig": sc, + "containers": ctrs, "vnics": []map[string]any{{"subnetId": in.Vnics[0].SubnetID}}, + } + + if in.DisplayName != "" { + out[ociCIKeyDisplay] = in.DisplayName + } + + if in.Restart != "" { + out["containerRestartPolicy"] = in.Restart + } + + if in.Graceful != nil && *in.Graceful >= 0 { + out["gracefulShutdownTimeoutInSeconds"] = *in.Graceful + } + + return out, true +} + +func ociCICreateBody(c *echo.Context) ([]byte, bool) { + var in ociCICreateIn + + if !ociDecodeBodyN(c, &in, ociCIMaxBody) || !in.valid() { + return nil, false + } + + out, ok := in.build() + if !ok { + return nil, false + } + + return ociArtMarshal(out) +} + +func ociCIUpdateBody(c *echo.Context) ([]byte, bool) { + var in struct { + DisplayName *string `json:"displayName"` + } + + if !ociDecodeBody(c, &in) || in.DisplayName == nil || !ociCIName(*in.DisplayName) { + return nil, false + } + + return ociArtMarshal(map[string]any{ociCIKeyDisplay: *in.DisplayName}) +} + +// ociCIScrubJSON removes secret-bearing fields from any answer: environment values become a sorted name list, +// config file data becomes its size, pull secrets keep only type, registry and vault secret id, and health check +// header values are dropped. +func ociCIScrubJSON(raw []byte) []byte { + if len(bytes.TrimSpace(raw)) == 0 { + return raw + } + + var doc any + if json.Unmarshal(raw, &doc) != nil { + return []byte("{}") + } + + ociCIScrub(doc) + + out, err := json.Marshal(doc) + if err != nil { + return []byte("{}") + } + + return out +} + +func ociCIScrub(v any) { + switch t := v.(type) { + case []any: + for _, x := range t { + ociCIScrub(x) + } + case map[string]any: + ociCIScrubMap(t) + + for _, x := range t { + ociCIScrub(x) + } + } +} + +func ociCIScrubMap(m map[string]any) { + if env, ok := m[ociCIKeyEnv].(map[string]any); ok { + names := make([]string, 0, len(env)) + for k := range env { + names = append(names, k) + } + + slices.Sort(names) + m[ociCIKeyEnvNames] = names + } + + delete(m, ociCIKeyEnv) + delete(m, "username") + delete(m, "password") + + if cfgs, ok := m["configs"].([]any); ok { + for i, f := range cfgs { + cfgs[i] = ociCIConfigSummary(f) + } + } + + if hs, ok := m["headers"].([]any); ok { + for i, f := range hs { + fm, _ := f.(map[string]any) + hs[i] = map[string]any{ociCIKeyName: fm[ociCIKeyName]} + } + } + + if secrets, ok := m["imagePullSecrets"].([]any); ok { + for i, s := range secrets { + sm, _ := s.(map[string]any) + kept := map[string]any{} + + for _, k := range []string{"secretType", "registryEndpoint", "secretId"} { + if val, has := sm[k]; has { + kept[k] = val + } + } + + secrets[i] = kept + } + } +} + +func ociCIConfigSummary(f any) map[string]any { + fm, _ := f.(map[string]any) + data, _ := fm["data"].(string) + + size := len(data) + if dec, err := base64.StdEncoding.DecodeString(data); err == nil { + size = len(dec) + } + + out := map[string]any{"sizeInBytes": size} + for _, k := range []string{"fileName", "path"} { + if v, ok := fm[k]; ok { + out[k] = v + } + } + + return out +} + +// ociCILogs answers RetrieveLogs as size-capped plain text; failures keep the emulator's JSON error envelope. +func ociCILogs(c *echo.Context, rec *httptest.ResponseRecorder) error { + if rec.Code != http.StatusOK { + return ociRelay(c, rec, "opc-request-id") + } + + logs := rec.Body.Bytes() + total := len(logs) + + h := c.Response().Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Content-Security-Policy", "sandbox; default-src 'none'") + h.Set("Cache-Control", "no-store") + + if total > ociCILogCap { + logs = logs[total-ociCILogCap:] + for len(logs) > 0 && !utf8.RuneStart(logs[0]) { + logs = logs[1:] + } + + h.Set("X-Logs-Truncated", "true") + } + + h.Set("X-Logs-Total-Bytes", strconv.Itoa(total)) + + return c.Blob(http.StatusOK, "text/plain; charset=utf-8", logs) +} + +func (h *DashboardHandler) ociCIAvailabilityDomains(c *echo.Context) error { + items := []any{} + + if ops := h.config.OCIIdentityOps; ops != nil { + ads, err := ops.Backend.ListAvailabilityDomains(idbackend.TenancyID) + if err != nil { + return ociFail(c, err) + } + + for _, ad := range ads { + items = append(items, map[string]any{ociCIKeyName: ad.Name}) + } + } + + return c.JSON(http.StatusOK, map[string]any{ociCIKeyItems: items}) +} + +// ociCISubnets lists the subnets of one compartment from OCI Core, read only, reduced to what a picker needs. +func (h *DashboardHandler) ociCISubnets(c *echo.Context) error { + ops := h.config.OCICoreOps + comp := c.QueryParam(ociKeyCompartment) + + if ops == nil || !ociCIName(comp) { + return c.JSON(http.StatusOK, map[string]any{ociCIKeyItems: []any{}}) + } + + inner := echo.New() + inner.Any("/*", ops.Trusted()) + + r := c.Request() + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = ociCICoreAPI+"/subnets", "" + fwd.URL.RawQuery = url.Values{ociKeyCompartment: {comp}, ociCIKeyLimit: {"1000"}}.Encode() + fwd.RequestURI, fwd.Method, fwd.Body, fwd.ContentLength = "", http.MethodGet, http.NoBody, 0 + fwd.Header = fwd.Header.Clone() + fwd.Header.Del("Authorization") + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + if rec.Code != http.StatusOK { + return c.JSON(http.StatusOK, map[string]any{ociCIKeyItems: []any{}}) + } + + var subnets []map[string]any + if json.Unmarshal(rec.Body.Bytes(), &subnets) != nil { + return c.JSON(http.StatusOK, map[string]any{ociCIKeyItems: []any{}}) + } + + items := make([]any, 0, len(subnets)) + + for _, s := range subnets { + items = append(items, map[string]any{ + "id": s["id"], ociCIKeyDisplay: s[ociCIKeyDisplay], "cidrBlock": s["cidrBlock"], + "vcnId": s["vcnId"], ociCIKeyState: s[ociCIKeyState], + }) + } + + return c.JSON(http.StatusOK, map[string]any{ociCIKeyItems: items}) +} diff --git a/dashboard/ocicontainerinstances_proxy_test.go b/dashboard/ocicontainerinstances_proxy_test.go new file mode 100644 index 0000000000..19ad734e66 --- /dev/null +++ b/dashboard/ocicontainerinstances_proxy_test.go @@ -0,0 +1,523 @@ +package dashboard_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" + "github.com/blackbirdworks/gopherstack/services/ocicore" + "github.com/blackbirdworks/gopherstack/services/ociidentity" +) + +const ( + ciComp = "ocid1.compartment.oc1..cicomp" + ciBase = "/dashboard/api/v1/oci/containerinstances" + ciAPI = ciBase + "/api" + ciAD = "Uocm:US-ASHBURN-AD-1" + ciSubnet = "ocid1.subnet.oc1..cisubnet" + ciEnvValue = "SUPER-SECRET-ENV-VALUE" + ciPassword = "SUPER-SECRET-REGISTRY-PASSWORD" + ciUsername = "SUPER-SECRET-REGISTRY-USER" + ciHeaderVal = "SUPER-SECRET-HEADER-VALUE" + ciConfigTxt = "SUPER-SECRET-CONFIG-CONTENT" +) + +type ciFakeRuntime struct{ logs string } + +func (r ciFakeRuntime) Pull(context.Context, string, *ocicontainerinstances.Credential) error { + return nil +} + +func (r ciFakeRuntime) Start(context.Context, container.Spec) (string, error) { return "c1", nil } + +func (r ciFakeRuntime) Wait(ctx context.Context, _ string) (int, error) { + <-ctx.Done() + + return 0, ctx.Err() +} + +func (r ciFakeRuntime) Logs(context.Context, string) (string, error) { return r.logs, nil } +func (r ciFakeRuntime) Kill(context.Context, string) error { return nil } +func (r ciFakeRuntime) Remove(context.Context, string) error { return nil } + +type ciFixture struct { + h *dashboard.DashboardHandler + ops *ocicontainerinstances.Handler +} + +func newCIDashboard(t *testing.T, mutate func(*ocicontainerinstances.InMemoryBackend)) ciFixture { + t.Helper() + + be := ocicontainerinstances.NewInMemoryBackend() + be.SetTransition(0) + + if mutate != nil { + mutate(be) + } + + t.Cleanup(be.Close) + + ops := ocicontainerinstances.NewHandler(be) + core := ocicore.NewHandler(ocicore.NewInMemoryBackend()) + + return ciFixture{ + h: dashboard.NewHandler(dashboard.Config{ + OCIContainerInstancesOps: ops, OCICoreOps: core, + OCIIdentityOps: ociidentity.NewHandler(ociidentity.NewInMemoryBackend()), + }), + ops: ops, + } +} + +func (f ciFixture) direct(t *testing.T, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + + e := echo.New() + e.Any("/*", f.ops.Trusted()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + e.ServeHTTP(rec, req) + + return rec +} + +func (f ciFixture) proxy(t *testing.T, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + + return lbDo(t, f.h, method, path, body) +} + +// settle runs the due state transitions until the instance reports state. +func (f ciFixture) settle(t *testing.T, id, state string) { + t.Helper() + + require.Eventually(t, func() bool { + f.ops.Backend.Sweep() + + rec := f.direct(t, http.MethodGet, "/20210415/containerInstances/"+id, "") + + var got struct { + State string `json:"lifecycleState"` + } + + return json.Unmarshal(rec.Body.Bytes(), &got) == nil && got.State == state + }, 10*time.Second, 5*time.Millisecond) +} + +const ciSecretsCreate = `{ + "compartmentId":"` + ciComp + `","availabilityDomain":"` + ciAD + `","shape":"CI.Standard.E4.Flex", + "displayName":"secrets","shapeConfig":{"ocpus":1,"memoryInGBs":4}, + "vnics":[{"subnetId":"` + ciSubnet + `"}], + "volumes":[{"name":"cfg","volumeType":"CONFIGFILE","configs":[{"fileName":"app.conf","path":"etc","data":"%s"}]}], + "imagePullSecrets":[{"secretType":"BASIC","registryEndpoint":"registry.example.com","username":"` + ciUsername + + `","password":"` + ciPassword + `"}], + "containers":[{"imageUrl":"alpine:3.20","displayName":"main","environmentVariables":{"TOKEN":"` + ciEnvValue + `"}, + "volumeMounts":[{"volumeName":"cfg","mountPath":"/etc/app"}], + "healthChecks":[{"healthCheckType":"HTTP","port":8080,"path":"/", + "headers":[{"name":"X-Auth","value":"` + ciHeaderVal + `"}]}]}]}` + +func ciCreateDirect(t *testing.T, f ciFixture) string { + t.Helper() + + cfg := base64.StdEncoding.EncodeToString([]byte(ciConfigTxt)) + rec := f.direct(t, http.MethodPost, "/20210415/containerInstances", strings.Replace(ciSecretsCreate, "%s", cfg, 1)) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + var inst struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &inst)) + f.settle(t, inst.ID, "ACTIVE") + + return inst.ID +} + +func TestOCIContainerInstancesProxyScrubbing(t *testing.T) { + t.Parallel() + + f := newCIDashboard(t, nil) + id := ciCreateDirect(t, f) + + direct := f.direct(t, http.MethodGet, "/20210415/containerInstances/"+id, "") + require.Contains(t, direct.Body.String(), ciConfigTxt[:0]+"app.conf") + + var ctrList struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + + rec := f.proxy(t, http.MethodGet, ciAPI+"/containers?compartmentId="+ciComp+"&containerInstanceId="+id, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &ctrList)) + require.Len(t, ctrList.Items, 1) + + rawCtr := f.direct(t, http.MethodGet, "/20210415/containers/"+ctrList.Items[0].ID, "") + require.Contains( + t, + rawCtr.Body.String(), + ciEnvValue, + "fixture must hold the secret for the scrub to prove anything", + ) + + tests := []struct { + name string + path string + want []string + }{ + { + "instance", ciAPI + "/containerInstances/" + id, + []string{ + `"secretType":"BASIC"`, `"registryEndpoint":"registry.example.com"`, `"sizeInBytes":27`, + `"fileName":"app.conf"`, + }, + }, + {"instance list", ciAPI + "/containerInstances?compartmentId=" + ciComp, []string{`"displayName":"secrets"`}}, + { + "container", ciAPI + "/containers/" + ctrList.Items[0].ID, + []string{`"environmentVariableNames":["TOKEN"]`, `"name":"X-Auth"`}, + }, + {"container list", ciAPI + "/containers?compartmentId=" + ciComp, []string{`"displayName":"main"`}}, + {"work requests", ciAPI + "/workRequests?compartmentId=" + ciComp + "&resourceId=" + id, []string{`"status"`}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := f.proxy(t, http.MethodGet, tt.path, "") + require.Equal(t, http.StatusOK, got.Code, got.Body.String()) + + body := got.Body.String() + for _, w := range tt.want { + assert.Contains(t, body, w) + } + + for _, secret := range []string{ + ciEnvValue, ciPassword, ciUsername, ciHeaderVal, ciConfigTxt, + base64.StdEncoding.EncodeToString([]byte(ciConfigTxt)), `"environmentVariables"`, `"password"`, + } { + assert.NotContains(t, body, secret) + } + }) + } +} + +func TestOCIContainerInstancesProxyCreateRebuild(t *testing.T) { + t.Parallel() + + f := newCIDashboard(t, nil) + + body := `{"compartmentId":"` + ciComp + `","availabilityDomain":"` + ciAD + `","shape":"CI.Standard.E4.Flex", + "displayName":"web","shapeConfig":{"ocpus":1,"memoryInGBs":4,"extra":1},"containerRestartPolicy":"NEVER", + "vnics":[{"subnetId":"` + ciSubnet + `","isPublicIpAssigned":true}], + "freeformTags":{"owner":"mallory"},"securityContext":{"securityContextType":"LINUX","runAsUser":0}, + "volumes":[{"name":"v","volumeType":"EMPTYDIR"}], + "imagePullSecrets":[{"secretType":"BASIC","registryEndpoint":"r","username":"u","password":"p"}], + "containers":[{"imageUrl":"alpine:3.20","command":["sh"],"arguments":["-c","echo hi"], + "environmentVariables":{"A":"1"},"isResourcePrincipalDisabled":true,"freeformTags":{"k":"v"}, + "workingDirectory":"/","securityContext":{"runAsUser":0}}]}` + + rec := f.proxy(t, http.MethodPost, ciAPI+"/containerInstances", body) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + var inst struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &inst)) + + raw := f.direct(t, http.MethodGet, "/20210415/containerInstances/"+inst.ID, "").Body.String() + for _, dropped := range []string{"mallory", "securityContext", "EMPTYDIR", "registryEndpoint", "runAsUser"} { + assert.NotContains(t, raw, dropped) + } + + assert.Contains(t, raw, `"containerRestartPolicy":"NEVER"`) + + ctr := f.direct(t, http.MethodGet, "/20210415/containers?compartmentId="+ciComp, "").Body.String() + assert.NotContains(t, ctr, "freeformTags") +} + +func TestOCIContainerInstancesProxyBadWrites(t *testing.T) { + t.Parallel() + + f := newCIDashboard(t, nil) + good := `{"compartmentId":"` + ciComp + `","availabilityDomain":"` + ciAD + `","shape":"CI.Standard.E4.Flex", + "shapeConfig":{"ocpus":1},"vnics":[{"subnetId":"` + ciSubnet + `"}],"containers":[{"imageUrl":"alpine"}]` + + tests := []struct { + name string + body string + }{ + {"not json", `nope`}, + {"no container", strings.Replace(good, `"containers":[{"imageUrl":"alpine"}]`, `"containers":[]`, 1) + "}"}, + {"no image", strings.Replace(good, `"imageUrl":"alpine"`, `"imageUrl":""`, 1) + "}"}, + { + "two vnics", + strings.Replace( + good, + `"vnics":[{"subnetId":"`+ciSubnet+`"}]`, + `"vnics":[{"subnetId":"a"},{"subnetId":"b"}]`, + 1, + ) + "}", + }, + {"no ocpus", strings.Replace(good, `"shapeConfig":{"ocpus":1}`, `"shapeConfig":{}`, 1) + "}"}, + {"bad restart", good + `,"containerRestartPolicy":"SOMETIMES"}`}, + { + "bad env name", + strings.Replace( + good, + `{"imageUrl":"alpine"}`, + `{"imageUrl":"alpine","environmentVariables":{"a b":"1"}}`, + 1, + ) + "}", + }, + {"long env value", strings.Replace(good, `{"imageUrl":"alpine"}`, + `{"imageUrl":"alpine","environmentVariables":{"A":"`+strings.Repeat("x", 5000)+`"}}`, 1) + "}"}, + {"too many containers", strings.Replace(good, `[{"imageUrl":"alpine"}]`, + "["+strings.Repeat(`{"imageUrl":"a"},`, 8)+`{"imageUrl":"a"}]`, 1) + "}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := f.proxy(t, http.MethodPost, ciAPI+"/containerInstances", tt.body) + assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "invalid Container Instances request") + }) + } + + empty := f.direct(t, http.MethodGet, "/20210415/containerInstances?compartmentId="+ciComp, "") + assert.NotContains(t, empty.Body.String(), "alpine") + assert.JSONEq(t, `{"items":[]}`, empty.Body.String()) +} + +func TestOCIContainerInstancesProxyAllowList(t *testing.T) { + t.Parallel() + + f := newCIDashboard(t, nil) + id := "ocid1.x.oc1..abc" + + tests := []struct { + method string + path string + }{ + {http.MethodPatch, ciAPI + "/containerInstances/" + id}, + {http.MethodPost, ciAPI + "/containerInstances/" + id + "/actions/changeCompartment"}, + {http.MethodPost, ciAPI + "/containerInstances/" + id + "/actions/explode"}, + {http.MethodGet, ciAPI + "/containerInstances/" + id + "/actions/start"}, + {http.MethodPut, ciAPI + "/containers/" + id}, + {http.MethodDelete, ciAPI + "/containers/" + id}, + {http.MethodGet, ciAPI + "/containers/" + id + "/actions/retrieveLogs"}, + {http.MethodPost, ciAPI + "/workRequests"}, + {http.MethodDelete, ciAPI + "/workRequests/" + id}, + {http.MethodGet, ciAPI + "/workRequests/" + id + "/logs"}, + {http.MethodPost, ciAPI + "/containerInstanceShapes"}, + {http.MethodGet, ciAPI + "/unknown"}, + {http.MethodGet, ciAPI + "/containerInstances/%2e%2e"}, + {http.MethodGet, ciAPI + "/containerInstances/a%2fb"}, + {http.MethodGet, ciAPI + "/containerInstances/bad%20id"}, + {http.MethodGet, ciAPI + "/containerInstances/" + id + "/a/b/c"}, + {http.MethodGet, ciAPI + "/_gopherstack/oci/keys"}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + t.Parallel() + + rec := f.proxy(t, tt.method, tt.path, "{}") + assert.Contains(t, []int{http.StatusBadRequest, http.StatusMethodNotAllowed}, rec.Code, rec.Body.String()) + }) + } +} + +func TestOCIContainerInstancesProxyUnavailable(t *testing.T) { + t.Parallel() + + h := dashboard.NewHandler(dashboard.Config{}) + + for _, p := range []string{ + "/compartments", "/engine", "/availabilitydomains", "/subnets", "/api/containerInstances?compartmentId=x", + } { + rec := lbDo(t, h, http.MethodGet, ciBase+p, "") + assert.Equal(t, http.StatusServiceUnavailable, rec.Code, p) + } +} + +func TestOCIContainerInstancesProxyLifecycle(t *testing.T) { + t.Parallel() + + f := newCIDashboard(t, nil) + id := ciCreateDirect(t, f) + inst := ciAPI + "/containerInstances/" + id + + rec := f.proxy(t, http.MethodPost, inst+"/actions/stop", "") + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + assert.NotEmpty(t, rec.Header().Get("Opc-Work-Request-Id")) + f.settle(t, id, "INACTIVE") + + rec = f.proxy(t, http.MethodPost, inst+"/actions/start", "") + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + f.settle(t, id, "ACTIVE") + + rec = f.proxy(t, http.MethodPost, inst+"/actions/restart", "") + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + f.settle(t, id, "ACTIVE") + + rec = f.proxy(t, http.MethodPut, inst, `{"displayName":"renamed","freeformTags":{"x":"y"}}`) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + assert.NotContains(t, f.direct(t, http.MethodGet, "/20210415/containerInstances/"+id, "").Body.String(), `"x":"y"`) + assert.Contains(t, f.proxy(t, http.MethodGet, inst, "").Body.String(), `"displayName":"renamed"`) + + rec = f.proxy(t, http.MethodPut, inst, `{"displayName":""}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + rec = f.proxy(t, http.MethodGet, ciAPI+"/workRequests?compartmentId="+ciComp+"&resourceId="+id, "") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "percentComplete") + + rec = f.proxy(t, http.MethodDelete, inst, "") + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + f.settle(t, id, "DELETED") +} + +func TestOCIContainerInstancesProxyLogsStateEngine(t *testing.T) { + t.Parallel() + + f := newCIDashboard(t, nil) + id := ciCreateDirect(t, f) + + var ctrs struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + + rec := f.proxy(t, http.MethodGet, ciAPI+"/containers?compartmentId="+ciComp+"&containerInstanceId="+id, "") + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &ctrs)) + require.Len(t, ctrs.Items, 1) + + rec = f.proxy(t, http.MethodPost, ciAPI+"/containers/"+ctrs.Items[0].ID+"/actions/retrieveLogs", "") + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Contains(t, rec.Body.String(), "IncorrectState") + assert.Contains(t, rec.Body.String(), "container logs need the docker engine") + + assert.JSONEq(t, `{"engine":"state"}`, f.proxy(t, http.MethodGet, ciBase+"/engine", "").Body.String()) +} + +func TestOCIContainerInstancesProxyLogsDockerEngine(t *testing.T) { + t.Parallel() + + logs := strings.Repeat("0123456789é\n", 40000) + "\nlast line" + f := newCIDashboard(t, func(be *ocicontainerinstances.InMemoryBackend) { + be.SetEngine(ocicontainerinstances.NewEngine(func() (ocicontainerinstances.Runtime, error) { + return ciFakeRuntime{logs: logs}, nil + })) + }) + + assert.JSONEq(t, `{"engine":"docker"}`, f.proxy(t, http.MethodGet, ciBase+"/engine", "").Body.String()) + + id := ciCreateDirect(t, f) + + var ctrs struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + + rec := f.proxy(t, http.MethodGet, ciAPI+"/containers?compartmentId="+ciComp+"&containerInstanceId="+id, "") + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &ctrs)) + require.Len(t, ctrs.Items, 1) + + rec = f.proxy(t, http.MethodPost, ciAPI+"/containers/"+ctrs.Items[0].ID+"/actions/retrieveLogs", "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + assert.Equal(t, "text/plain; charset=utf-8", rec.Header().Get("Content-Type")) + assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) + assert.Contains(t, rec.Header().Get("Content-Security-Policy"), "sandbox") + assert.Equal(t, "true", rec.Header().Get("X-Logs-Truncated")) + assert.LessOrEqual(t, rec.Body.Len(), 256<<10) + assert.Positive(t, rec.Body.Len()) + assert.Greater(t, len(logs), 256<<10) + assert.True(t, strings.HasSuffix(rec.Body.String(), "last line"), "the tail is kept") + assert.True( + t, + strings.HasPrefix(rec.Body.String(), "0123456789é\n") || strings.HasPrefix(rec.Body.String(), "é\n") || + strings.Contains(rec.Body.String()[:20], "\n"), + ) +} + +func TestOCIContainerInstancesProxyHelpers(t *testing.T) { + t.Parallel() + + f := newCIDashboard(t, nil) + + rec := f.proxy(t, http.MethodGet, ciBase+"/compartments", "") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), ociidentity.TenancyID) + + rec = f.proxy(t, http.MethodGet, ciBase+"/availabilitydomains", "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "AD-1") + + rec = f.proxy(t, http.MethodGet, ciAPI+"/containerInstanceShapes?compartmentId="+ciComp, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "CI.Standard.E4.Flex") + + rec = f.proxy(t, http.MethodGet, ciBase+"/subnets?compartmentId="+ciComp, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.JSONEq(t, `{"items":[]}`, rec.Body.String()) +} + +func TestOCIContainerInstancesProxySubnets(t *testing.T) { + t.Parallel() + + be := ocicore.NewInMemoryBackend() + core := ocicore.NewHandler(be) + + vcn, _, aerr := be.CreateVcn( + ocicore.CreateVcnIn{CompartmentID: ciComp, DisplayName: "vcn", CidrBlock: "10.0.0.0/16"}, + ) + require.Nil(t, aerr) + + vcnJSON, err := json.Marshal(vcn) + require.NoError(t, err) + + var v struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(vcnJSON, &v)) + + _, _, aerr = be.CreateSubnet(ocicore.CreateSubnetIn{ + CompartmentID: ciComp, VcnID: v.ID, DisplayName: "sub-a", CidrBlock: "10.0.1.0/24", + }) + require.Nil(t, aerr) + + h := dashboard.NewHandler(dashboard.Config{ + OCIContainerInstancesOps: ocicontainerinstances.NewHandler(ocicontainerinstances.NewInMemoryBackend()), + OCICoreOps: core, + }) + + rec := lbDo(t, h, http.MethodGet, ciBase+"/subnets?compartmentId="+ciComp, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"displayName":"sub-a"`) + assert.Contains(t, rec.Body.String(), `"cidrBlock":"10.0.1.0/24"`) + assert.NotContains(t, rec.Body.String(), "securityListIds") +} diff --git a/dashboard/ocicore_proxy.go b/dashboard/ocicore_proxy.go new file mode 100644 index 0000000000..0436952892 --- /dev/null +++ b/dashboard/ocicore_proxy.go @@ -0,0 +1,150 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociCorePrefix = ociProxyPrefix + "/core" + ociCoreAPIPrefix = "/20160918" + ociCoreMaxBody = 1 << 16 + ociCoreItemSegs = 2 +) + +var ociCoreReadKinds = []string{ //nolint:gochecknoglobals // immutable allowlist + "vcns", "subnets", "routeTables", "securityLists", "networkSecurityGroups", "internetGateways", + "natGateways", "serviceGateways", "dhcps", "localPeeringGateways", "publicIps", "privateIps", + btInstancesKind, "vnicAttachments", "vnics", "volumes", "bootVolumes", "volumeAttachments", + "bootVolumeAttachments", "volumeBackups", "shapes", "images", +} + +var ociCoreInstanceActions = []string{ //nolint:gochecknoglobals // immutable + "START", "STOP", "SOFTSTOP", "SOFTRESET", "RESET", +} + +// registerOCICoreRoutes serves the Core Services page by dispatching in-process to the emulator's handler +// without request signing. Only the listed read paths and instance power actions are reachable. +func (h *DashboardHandler) registerOCICoreRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCICoreOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { + return ociGuard(inner != nil, "OCI Core Services", next) + } + + h.SubRouter.Add(http.MethodGet, ociCorePrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + + apiPath, ok := ociCoreProxyPath(r.Method, strings.TrimPrefix(r.URL.EscapedPath(), ociCorePrefix+"/api"), + r.URL.Query()) + if !ok { + return cloudJSONFail( + c, + http.StatusBadRequest, + "InvalidParameter", + "unsupported Core Services proxy request", + ) + } + + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(apiPath), apiPath + fwd.RequestURI = "" + fwd.Body = http.MaxBytesReader(c.Response(), r.Body, ociCoreMaxBody) + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + return ociCoreRelay(c, rec) + }) + + h.SubRouter.Add(http.MethodGet, ociCorePrefix+"/api/*", serve) + h.SubRouter.Add(http.MethodPost, ociCorePrefix+"/api/*", serve) +} + +// ociCoreRelay copies the emulator's response, masking values that must not reach the browser: iSCSI CHAP +// secrets and instance user data. +func ociCoreRelay(c *echo.Context, rec *httptest.ResponseRecorder) error { + for _, k := range []string{"opc-next-page", "etag", "opc-request-id"} { + if v := rec.Header().Get(k); v != "" { + c.Response().Header().Set(k, v) + } + } + + body := rec.Body.Bytes() + + if rec.Code < http.StatusMultipleChoices && len(body) > 0 { + var doc any + if err := json.Unmarshal(body, &doc); err == nil { + ociCoreScrub(doc) + + if out, merr := json.Marshal(doc); merr == nil { + body = out + } + } + } + + return c.JSONBlob(rec.Code, body) +} + +func ociCoreScrub(v any) { + switch t := v.(type) { + case []any: + for _, x := range t { + ociCoreScrub(x) + } + case map[string]any: + delete(t, "chapSecret") + + for _, key := range []string{"metadata", "extendedMetadata"} { + if md, ok := t[key].(map[string]any); ok { + if _, has := md["user_data"]; has { + md["user_data"] = ociConfigMask + } + } + } + } +} + +// ociCoreProxyPath maps a path below the proxy to the emulator's /20160918 path, enforcing the allowlist. +func ociCoreProxyPath(method, raw string, query url.Values) (string, bool) { + parts := strings.Split(strings.TrimPrefix(raw, "/"), "/") + + for _, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || dec == "" || dec == "." || dec == ".." || strings.Contains(dec, "/") { + return "", false + } + } + + if !slices.Contains(ociCoreReadKinds, parts[0]) { + return "", false + } + + if method == http.MethodPost { + if len(parts) != ociCoreItemSegs || parts[0] != btInstancesKind || + !slices.Contains(ociCoreInstanceActions, strings.ToUpper(query.Get("action"))) { + return "", false + } + } + + return ociCoreAPIPrefix + "/" + strings.Join(parts, "/"), true +} diff --git a/dashboard/ocicore_proxy_test.go b/dashboard/ocicore_proxy_test.go new file mode 100644 index 0000000000..120784208f --- /dev/null +++ b/dashboard/ocicore_proxy_test.go @@ -0,0 +1,131 @@ +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocicore" +) + +const ( + ociCoreComp = "ocid1.compartment.oc1..aaaa" + ociCoreBase = "/dashboard/api/v1/oci/core" +) + +func newOCICoreDashboard(t *testing.T) (*dashboard.DashboardHandler, *ocicore.Handler) { + t.Helper() + + c := ocicore.NewHandler(ocicore.NewInMemoryBackend()) + c.EnforceAuth = true + + _, _, aerr := c.Backend.CreateVcn(ocicore.CreateVcnIn{ + CompartmentID: ociCoreComp, DisplayName: "vcn-one", CidrBlock: "10.0.0.0/16", + }) + require.Nil(t, aerr) + + return dashboard.NewHandler(dashboard.Config{OCICoreOps: c}), c +} + +func TestOCICoreProxy(t *testing.T) { + t.Parallel() + + api := ociCoreBase + "/api" + q := "?compartmentId=" + ociCoreComp + + tests := []struct { + name string + steps []gcpStep + }{ + {"compartments", []gcpStep{ + {http.MethodGet, ociCoreBase + "/compartments", "", ociCoreComp, 200}, + }}, + {"vcns", []gcpStep{ + {http.MethodGet, api + "/vcns" + q, "", `"displayName":"vcn-one"`, 200}, + {http.MethodGet, api + "/subnets" + q, "", "[]", 200}, + }}, + {"rejects", []gcpStep{ + {http.MethodDelete, api + "/vcns/x", "", "", http.StatusMethodNotAllowed}, + {http.MethodPost, api + "/vcns", `{}`, "unsupported", 400}, + {http.MethodPost, api + "/instances/x?action=TERMINATE", ``, "unsupported", 400}, + {http.MethodGet, api + "/adminthing", "", "unsupported", 400}, + {http.MethodGet, api + "/vcns/%2e%2e", "", "unsupported", 400}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, _ := newOCICoreDashboard(t) + runGCPSteps(t, h, tt.steps) + }) + } + + t.Run("unavailable", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, api+"/vcns"+q, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} + +func TestOCICoreProxyInstanceActions(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + b := ocicore.NewInMemoryBackend() + b.SetClock(func() time.Time { return now }) + + vcn, _, aerr := b.CreateVcn(ocicore.CreateVcnIn{ + CompartmentID: ociCoreComp, DisplayName: "v", CidrBlock: "10.0.0.0/16", + }) + require.Nil(t, aerr) + + subnet, _, aerr := b.CreateSubnet(ocicore.CreateSubnetIn{ + CompartmentID: ociCoreComp, VcnID: vcn.(*ocicore.Vcn).ID, CidrBlock: "10.0.1.0/24", DisplayName: "s", + }) + require.Nil(t, aerr) + + imgs, aerr := b.ListImages(ocicore.ImageListOpts{ + Compartment: ociCoreComp, OS: "Oracle Linux", OSVersion: "9", Shape: "VM.Standard.E4.Flex", + }) + require.Nil(t, aerr) + require.NotEmpty(t, imgs.Data) + + inst, _, aerr := b.LaunchInstance(ocicore.LaunchInstanceIn{ + CompartmentID: ociCoreComp, AvailabilityDomain: "Abcd:US-ASHBURN-AD-1", Shape: "VM.Standard.E4.Flex", + DisplayName: "web", Metadata: map[string]string{"user_data": "c2VjcmV0LXRva2Vu"}, + ShapeConfig: &ocicore.ShapeConfigIn{Ocpus: new(2.0)}, + SourceDetails: &ocicore.SourceDetails{SourceType: "image", ImageID: imgs.Data[0].(*ocicore.Image).ID}, + CreateVnicDetails: &ocicore.CreateVnicIn{SubnetID: subnet.(*ocicore.Subnet).ID}, + }) + require.Nil(t, aerr) + + now = now.Add(time.Minute) + + h := dashboard.NewHandler(dashboard.Config{OCICoreOps: ocicore.NewHandler(b)}) + path := ociCoreBase + "/api/instances/" + inst.(*ocicore.Instance).ID + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), "c2VjcmV0LXRva2Vu") + assert.Contains(t, rec.Body.String(), "user_data") + + runGCPSteps(t, h, []gcpStep{ + {http.MethodGet, ociCoreBase + "/api/instances?compartmentId=" + ociCoreComp, "", `"displayName":"web"`, 200}, + {http.MethodPost, path + "?action=STOP", "", `"lifecycleState":"STOPPING"`, 200}, + {http.MethodPost, path + "?action=START", "", "IncorrectState", http.StatusConflict}, + {http.MethodPost, path + "?action=TERMINATE", "", "unsupported", http.StatusBadRequest}, + }) +} diff --git a/dashboard/ocievents_proxy.go b/dashboard/ocievents_proxy.go new file mode 100644 index 0000000000..6e08fcb14a --- /dev/null +++ b/dashboard/ocievents_proxy.go @@ -0,0 +1,181 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociEventsPrefix = ociProxyPrefix + "/events" + ociEventsAPIPrefix = "/20181201" + ociEventsDeliveries = "/_gopherstack/oci/events/deliveries" + ociEventsMaxActions = 10 + ociEventsItemSegs = 2 + ociEventsEnabledKey = "isEnabled" +) + +// registerOCIEventsRoutes serves the Events page by dispatching in-process to the emulator's handler without +// request signing. Reachable: rule list/get/create/delete, an enable toggle and the delivery log. Every write +// forwards a body rebuilt from a fixed set of fields. +func (h *DashboardHandler) registerOCIEventsRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCIEventsOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI Events", next) } + + h.SubRouter.Add(http.MethodGet, ociEventsPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + h.SubRouter.Add(http.MethodGet, ociEventsPrefix+"/deliveries", guard(func(c *echo.Context) error { + return ociDispatch(c, inner, ociEventsDeliveries, nil) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociEventsPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok || !ociEventsAllowed(r.Method, parts) { + return ociInvalid(c, "unsupported Events proxy request") + } + + body, ok := ociEventsBody(c, r.Method) + if !ok { + return ociInvalid(c, "invalid Events proxy request body") + } + + return ociDispatch(c, inner, ociEventsAPIPrefix+raw, body) + }) + + for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete} { + h.SubRouter.Add(m, ociEventsPrefix+"/api/*", serve) + } +} + +func ociEventsAllowed(method string, parts []string) bool { + if parts[0] != ociKeyRules { + return false + } + + switch len(parts) { + case 1: + return method == http.MethodGet || method == http.MethodPost + case ociEventsItemSegs: + return slices.Contains([]string{http.MethodGet, http.MethodPut, http.MethodDelete}, method) + } + + return false +} + +// ociEventsBody builds the forwarded body of a write; nil keeps the (empty) original. +func ociEventsBody(c *echo.Context, method string) ([]byte, bool) { + switch method { + case http.MethodPost: + return ociEventsCreateBody(c) + case http.MethodPut: + return ociEventsToggleBody(c) + } + + return nil, true +} + +type ociEventsActionIn struct { + IsEnabled *bool `json:"isEnabled"` + ActionType string `json:"actionType"` + Description string `json:"description"` + TopicID string `json:"topicId"` + StreamID string `json:"streamId"` + FunctionID string `json:"functionId"` +} + +func (a ociEventsActionIn) rebuild() (map[string]any, bool) { + if a.IsEnabled == nil { + return nil, false + } + + out := map[string]any{"actionType": a.ActionType, ociEventsEnabledKey: *a.IsEnabled} + if a.Description != "" { + out["description"] = a.Description + } + + switch a.ActionType { + case "ONS": + out["topicId"] = a.TopicID + case "OSS": + out["streamId"] = a.StreamID + case "FAAS": + out["functionId"] = a.FunctionID + default: + return nil, false + } + + return out, true +} + +func ociEventsCreateBody(c *echo.Context) ([]byte, bool) { + var in struct { + IsEnabled *bool `json:"isEnabled"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Condition string `json:"condition"` + Actions struct { + Actions []ociEventsActionIn `json:"actions"` + } `json:"actions"` + } + + if !ociDecodeBody(c, &in) || in.CompartmentID == "" || in.DisplayName == "" || in.IsEnabled == nil || + len(in.Actions.Actions) > ociEventsMaxActions { + return nil, false + } + + acts := make([]map[string]any, 0, len(in.Actions.Actions)) + + for _, a := range in.Actions.Actions { + m, ok := a.rebuild() + if !ok { + return nil, false + } + + acts = append(acts, m) + } + + out := map[string]any{ + ociKeyCompartment: in.CompartmentID, "displayName": in.DisplayName, "condition": in.Condition, + ociEventsEnabledKey: *in.IsEnabled, "actions": map[string]any{"actions": acts}, + } + if in.Description != "" { + out["description"] = in.Description + } + + b, err := json.Marshal(out) + + return b, err == nil +} + +func ociEventsToggleBody(c *echo.Context) ([]byte, bool) { + var in struct { + IsEnabled *bool `json:"isEnabled"` + } + + if !ociDecodeBody(c, &in) || in.IsEnabled == nil { + return nil, false + } + + b, err := json.Marshal(map[string]any{ociEventsEnabledKey: *in.IsEnabled}) + + return b, err == nil +} diff --git a/dashboard/ocievents_proxy_test.go b/dashboard/ocievents_proxy_test.go new file mode 100644 index 0000000000..dcf5ebff60 --- /dev/null +++ b/dashboard/ocievents_proxy_test.go @@ -0,0 +1,166 @@ +package dashboard_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocievents" +) + +const ( + evComp = "ocid1.compartment.oc1..events" + evBase = "/dashboard/api/v1/oci/events" + evAPI = evBase + "/api" + evTopic = "ocid1.onstopic.oc1.iad.topic1" +) + +func newEventsDashboard(t *testing.T) (*dashboard.DashboardHandler, *ocievents.InMemoryBackend) { + t.Helper() + + b := ocievents.NewInMemoryBackend() + ops := ocievents.NewHandler(b) + + return dashboard.NewHandler(dashboard.Config{OCIEventsOps: ops}), b +} + +func evRuleBody(extra string) string { + return `{"compartmentId":"` + evComp + `","displayName":"r1","isEnabled":true,` + + `"condition":"{\"eventType\":\"com.oraclecloud.objectstorage.createobject\"}",` + extra + + `"actions":{"actions":[{"actionType":"ONS","topicId":"` + evTopic + `","isEnabled":true}]}}` +} + +func TestOCIEventsProxy(t *testing.T) { + t.Parallel() + + cq := "?compartmentId=" + evComp + + tests := []struct { + name string + steps []gcpStep + }{ + {name: "create read toggle delete", steps: []gcpStep{ + {http.MethodPost, evAPI + "/rules", evRuleBody(""), `"displayName":"r1"`, 200}, + {http.MethodGet, evBase + "/compartments", "", evComp, 200}, + {http.MethodGet, evAPI + "/rules" + cq, "", `"displayName":"r1"`, 200}, + {http.MethodGet, evBase + "/deliveries", "", "[]", 200}, + }}, + {name: "body rebuild", steps: []gcpStep{ + { + http.MethodPost, + evAPI + "/rules", + evRuleBody(`"freeformTags":{"x":"y"},"definedTags":{"a":{"b":"c"}},`), + `"displayName":"r1"`, + 200, + }, + {http.MethodGet, evAPI + "/rules" + cq, "", `"displayName":"r1"`, 200}, + }}, + {name: "rejects", steps: []gcpStep{ + {http.MethodPut, evAPI + "/rules", `{}`, "unsupported", 400}, + {http.MethodPost, evAPI + "/rules/x/actions/changeCompartment", `{}`, "unsupported", 400}, + {http.MethodGet, evAPI + "/other", "", "unsupported", 400}, + {http.MethodGet, evAPI + "/rules/%2e%2e", "", "unsupported", 400}, + {http.MethodPost, evAPI + "/rules/x", `{}`, "unsupported", 400}, + {http.MethodPost, evAPI + "/rules", `{"compartmentId":"c"}`, "invalid", 400}, + {http.MethodPut, evAPI + "/rules/x", `{"displayName":"n"}`, "invalid", 400}, + { + http.MethodPost, + evAPI + "/rules", + `{"compartmentId":"c","displayName":"n","isEnabled":true,"condition":"{}",` + + `"actions":{"actions":[{"actionType":"EMAIL","isEnabled":true}]}}`, + "invalid", + 400, + }, + { + http.MethodPost, + evAPI + "/rules", + `{"compartmentId":"c","displayName":"n","isEnabled":true,"condition":"{}",` + + `"actions":{"actions":[{"actionType":"ONS","topicId":"t"}]}}`, + "invalid", + 400, + }, + }}, + {name: "backend errors surface", steps: []gcpStep{ + { + http.MethodPost, + evAPI + "/rules", + `{"compartmentId":"c","displayName":"n","isEnabled":true,"condition":"not json",` + + `"actions":{"actions":[{"actionType":"ONS","topicId":"t","isEnabled":true}]}}`, + "condition is not valid JSON", + 400, + }, + {http.MethodGet, evAPI + "/rules/ocid1.eventrule.oc1..none", "", "", 404}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, _ := newEventsDashboard(t) + runGCPSteps(t, h, tt.steps) + }) + } + + t.Run("toggle and delete", func(t *testing.T) { + t.Parallel() + + h, b := newEventsDashboard(t) + runGCPSteps(t, h, []gcpStep{ + {http.MethodPost, evAPI + "/rules", evRuleBody(""), `"displayName":"r1"`, 200}, + }) + + rules, err := b.ListRules(evComp, "", "", ocievents.ListOpts{}) + require.NoError(t, err) + require.Len(t, rules.Data, 1) + + id := rules.Data[0].ID + runGCPSteps(t, h, []gcpStep{ + { + http.MethodPut, + evAPI + "/rules/" + id, + `{"isEnabled":false,"displayName":"hijack"}`, + `"isEnabled":false`, + 200, + }, + {http.MethodGet, evAPI + "/rules/" + id, "", `"displayName":"r1"`, 200}, + {http.MethodDelete, evAPI + "/rules/" + id, "", "", http.StatusNoContent}, + }) + }) + + t.Run("delivery log", func(t *testing.T) { + t.Parallel() + + h, b := newEventsDashboard(t) + runGCPSteps(t, h, []gcpStep{ + {http.MethodPost, evAPI + "/rules", evRuleBody(""), `"displayName":"r1"`, 200}, + }) + + b.Emit(ocievents.Event{ + EventType: "com.oraclecloud.objectstorage.createobject", CompartmentID: evComp, + Source: "ObjectStorage", ResourceName: "o", + }) + b.Wait() + + runGCPSteps(t, h, []gcpStep{ + { + http.MethodGet, + evBase + "/deliveries", + "", + `"eventType":"com.oraclecloud.objectstorage.createobject"`, + 200, + }, + }) + }) + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, evAPI + "/rules", "", "", http.StatusServiceUnavailable}, + {http.MethodGet, evBase + "/deliveries", "", "", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/ociidentity_proxy.go b/dashboard/ociidentity_proxy.go index 8950369360..640836ed10 100644 --- a/dashboard/ociidentity_proxy.go +++ b/dashboard/ociidentity_proxy.go @@ -82,7 +82,7 @@ func (a *ociIdentityAPI) compartments(c *echo.Context) error { return ociFail(c, err) } - return c.JSON(http.StatusOK, map[string]any{"compartments": items}) + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: items}) } func (a *ociIdentityAPI) users(c *echo.Context) error { diff --git a/dashboard/ociloadbalancer_proxy.go b/dashboard/ociloadbalancer_proxy.go new file mode 100644 index 0000000000..4dc6e13ad8 --- /dev/null +++ b/dashboard/ociloadbalancer_proxy.go @@ -0,0 +1,537 @@ +package dashboard + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "maps" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "slices" + "strings" + "time" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" +) + +const ( + ociLBPrefix = ociProxyPrefix + "/loadbalancer" + ociLBAPIPrefix = "/20170115" + ociLBColl = "loadBalancers" + ociLBSets = "backendSets" + ociLBBackends = "backends" + ociLBListeners = "listeners" + ociLBCerts = "certificates" + ociLBHealth = "health" + ociLBWorkReqs = "workRequests" + ociLBInvoke = "invoke" + ociLBMaxReqBody = 1 << 16 + ociLBMaxRespBody = 1 << 18 + ociLBMaxHeaders = 32 + ociLBMaxTryJSON = 2 * ociLBMaxReqBody + ociLBMaxName = 255 + ociLBMaxPort = 65535 + ociLBMinMbps = 10 + ociLBMaxMbps = 8000 + ociLBTryTimeout = 10 * time.Second + ociLBItemSegs = 2 + ociLBSubSegs = 3 + ociLBSetHealth = 5 + ociLBBackendSegs = 6 + ociLBBackendHlth = 7 + ociLBProbeMillis = 1000 + ociLBFlexShape = "flexible" + ociLBActive = "ACTIVE" + ociLBFirstSet = "backend-set-1" + ociLBFirstLsnr = "listener-1" + ociLBMaxHostLen = 253 + ociLBHTTP = "HTTP" + ociLBBackup = "backup" + ociLBTCP = "TCP" +) + +// registerOCILoadBalancerRoutes serves the Load Balancer page by dispatching in-process to the emulator's handler +// without request signing. Reads, backend add/update/remove and load balancer create/delete are reachable, each +// write rebuilt from fixed fields; a "try it" call goes to the loopback data-plane address of one existing +// plain-text listener chosen by id, never to a caller-supplied host. Certificate bodies are reduced to subjects and +// expiry before they leave the proxy. +func (h *DashboardHandler) registerOCILoadBalancerRoutes() { + var inner *echo.Echo + + ops := h.config.OCILoadBalancerOps + if ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI Load Balancer", next) } + + h.SubRouter.Add(http.MethodGet, ociLBPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: ops.Backend.Compartments()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociLBPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok || !ociLBAllowed(r.Method, parts) { + return ociInvalid(c, "unsupported Load Balancer proxy request") + } + + if r.Method == http.MethodPost && parts[len(parts)-1] == ociLBInvoke { + return ociLBTryIt(c, ops.Backend, unescapedPath(parts[1]), unescapedPath(parts[3])) + } + + if r.Method != http.MethodGet { + r.URL.RawQuery = "" + + return ociLBWrite(c, inner, raw, parts) + } + + return ociLBRead(c, inner, raw, parts) + }) + + for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete} { + h.SubRouter.Add(m, ociLBPrefix+"/api/*", serve) + } +} + +func ociLBAllowed(method string, parts []string) bool { + if parts[0] != ociLBColl { + return false + } + + switch method { + case http.MethodGet: + return ociLBReadable(parts) + case http.MethodPost: + return len(parts) == 1 || ociLBChild(parts, ociLBSetHealth, ociLBSets, ociLBBackends) || + ociLBChild(parts, ociLBSetHealth, ociLBListeners, ociLBInvoke) + case http.MethodPut: + return ociLBChild(parts, ociLBBackendSegs, ociLBSets, ociLBBackends) + case http.MethodDelete: + return len(parts) == ociLBItemSegs || ociLBChild(parts, ociLBBackendSegs, ociLBSets, ociLBBackends) + } + + return false +} + +// ociLBChild reports whether parts has n segments with the given collection at 2 and sub-collection at 4. +func ociLBChild(parts []string, n int, coll, sub string) bool { + return len(parts) == n && parts[2] == coll && parts[4] == sub +} + +func ociLBReadable(parts []string) bool { + switch len(parts) { + case 1, ociLBItemSegs: + return true + case ociLBSubSegs: + return slices.Contains([]string{ociLBHealth, ociLBWorkReqs, ociLBCerts}, parts[2]) + case ociLBSetHealth: + return parts[2] == ociLBSets && parts[4] == ociLBHealth + case ociLBBackendHlth: + return parts[2] == ociLBSets && parts[4] == ociLBBackends && parts[6] == ociLBHealth + } + + return false +} + +func ociLBRead(c *echo.Context, inner *echo.Echo, raw string, parts []string) error { + r := c.Request() + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(ociLBAPIPrefix+raw), ociLBAPIPrefix+raw + fwd.RequestURI, fwd.Body = "", http.NoBody + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + if rec.Code == http.StatusOK && + (len(parts) == ociLBItemSegs || (len(parts) == ociLBSubSegs && parts[2] == ociLBCerts)) { + rec.Body = bytes.NewBuffer(ociLBScrubCerts(rec.Body.Bytes())) + } + + return ociRelay(c, rec, "opc-next-page", "etag", "opc-request-id") +} + +func ociLBWrite(c *echo.Context, inner *echo.Echo, raw string, parts []string) error { + var ( + body []byte + ok = true + ) + + switch { + case c.Request().Method == http.MethodPost && len(parts) == 1: + body, ok = ociLBCreateBody(c) + case c.Request().Method == http.MethodPost: + body, ok = ociLBBackendBody(c, false) + case c.Request().Method == http.MethodPut: + body, ok = ociLBBackendBody(c, true) + default: + body = []byte{} + } + + if !ok { + return ociInvalid(c, "invalid Load Balancer request") + } + + return ociDispatch(c, inner, ociLBAPIPrefix+raw, body) +} + +func ociLBName(s string) bool { return s != "" && len(s) <= ociLBMaxName } + +func ociLBPortOK(p *int) bool { return p != nil && *p >= 1 && *p <= ociLBMaxPort } + +// ociLBCreateBody rebuilds a CreateLoadBalancer request from the form fields alone: a flexible shape, one subnet and +// an optional first HTTP listener with a round-robin backend set. +func ociLBCreateBody(c *echo.Context) ([]byte, bool) { + var in struct { + Private *bool `json:"isPrivate"` + ListenerPort *int `json:"listenerPort"` + MinMbps *int `json:"minimumBandwidthInMbps"` + MaxMbps *int `json:"maximumBandwidthInMbps"` + Compartment string `json:"compartmentId"` + DisplayName string `json:"displayName"` + SubnetID string `json:"subnetId"` + } + + if !ociDecodeBody(c, &in) || !ociLBName(in.Compartment) || !ociLBName(in.DisplayName) || !ociLBName(in.SubnetID) { + return nil, false + } + + lo, hi := ociLBMinMbps, ociLBMinMbps + if in.MinMbps != nil { + lo = *in.MinMbps + } + + if in.MaxMbps != nil { + hi = *in.MaxMbps + } + + if lo < ociLBMinMbps || hi > ociLBMaxMbps || lo > hi || (in.ListenerPort != nil && !ociLBPortOK(in.ListenerPort)) { + return nil, false + } + + out := map[string]any{ + "compartmentId": in.Compartment, "displayName": in.DisplayName, "shapeName": ociLBFlexShape, + "shapeDetails": map[string]any{"minimumBandwidthInMbps": lo, "maximumBandwidthInMbps": hi}, + "subnetIds": []string{in.SubnetID}, "isPrivate": in.Private != nil && *in.Private, + } + + if in.ListenerPort != nil { + out["backendSets"] = map[string]any{ociLBFirstSet: map[string]any{ + "policy": "ROUND_ROBIN", + "healthChecker": map[string]any{ + "protocol": ociLBHTTP, + "urlPath": "/", + "intervalInMillis": ociLBProbeMillis, + "retries": 1, + }, + }} + out["listeners"] = map[string]any{ociLBFirstLsnr: map[string]any{ + "defaultBackendSetName": ociLBFirstSet, "protocol": ociLBHTTP, "port": *in.ListenerPort, + }} + } + + b, err := json.Marshal(out) + + return b, err == nil +} + +type ociLBBackendIn struct { + Port *int `json:"port"` + Weight *int `json:"weight"` + MaxConn *int `json:"maxConnections"` + Backup *bool `json:"backup"` + Drain *bool `json:"drain"` + Offline *bool `json:"offline"` + IP string `json:"ipAddress"` +} + +// fields returns the fixed update fields, or false when an UpdateBackend omits one the OCI API requires. +func (in ociLBBackendIn) update() (map[string]any, bool) { + if in.Weight == nil || in.Backup == nil || in.Drain == nil || in.Offline == nil { + return nil, false + } + + out := map[string]any{"weight": *in.Weight, "backup": *in.Backup, "drain": *in.Drain, "offline": *in.Offline} + if in.MaxConn != nil { + out["maxConnections"] = *in.MaxConn + } + + return out, true +} + +func (in ociLBBackendIn) create() (map[string]any, bool) { + if _, err := netip.ParseAddr(in.IP); err != nil || !ociLBPortOK(in.Port) { + return nil, false + } + + out := map[string]any{"ipAddress": in.IP, "port": *in.Port} + if in.Weight != nil { + out["weight"] = *in.Weight + } + + if in.MaxConn != nil { + out["maxConnections"] = *in.MaxConn + } + + for k, v := range map[string]*bool{ociLBBackup: in.Backup, "drain": in.Drain, "offline": in.Offline} { + if v != nil { + out[k] = *v + } + } + + return out, true +} + +// ociLBBackendBody rebuilds a CreateBackend (update=false) or UpdateBackend (update=true) request from fixed fields. +func ociLBBackendBody(c *echo.Context, update bool) ([]byte, bool) { + var in ociLBBackendIn + + if !ociDecodeBody(c, &in) || (in.Weight != nil && *in.Weight < 1) || (in.MaxConn != nil && *in.MaxConn < 1) { + return nil, false + } + + build := in.create + if update { + build = in.update + } + + out, ok := build() + if !ok { + return nil, false + } + + b, err := json.Marshal(out) + + return b, err == nil +} + +// ociLBScrubCerts reduces certificate bodies (a name-keyed map in a load balancer, a list from the listing) to +// subjects and validity; PEM text never reaches the browser. +func ociLBScrubCerts(raw []byte) []byte { + var v any + if json.Unmarshal(raw, &v) != nil { + return raw + } + + switch t := v.(type) { + case []any: + for i, e := range t { + t[i] = ociLBCertSummary(e) + } + case map[string]any: + if certs, ok := t["certificates"].(map[string]any); ok { + for k, e := range certs { + certs[k] = ociLBCertSummary(e) + } + } + } + + out, err := json.Marshal(v) + if err != nil { + return []byte("{}") + } + + return out +} + +func ociLBCertSummary(e any) map[string]any { + m, _ := e.(map[string]any) + name, _ := m["certificateName"].(string) + pub, _ := m["publicCertificate"].(string) + ca, _ := m["caCertificate"].(string) + out := map[string]any{"certificateName": name, "hasCaCertificate": ca != ""} + + if cert := ociLBFirstCert(pub); cert != nil { + names := append([]string{}, cert.DNSNames...) + if cert.Subject.CommonName != "" && !slices.Contains(names, cert.Subject.CommonName) { + names = append([]string{cert.Subject.CommonName}, names...) + } + + out["subjectNames"], out["issuer"] = names, cert.Issuer.CommonName + out["notBefore"], out["notAfter"] = cert.NotBefore.UTC(). + Format(time.RFC3339), + cert.NotAfter.UTC(). + Format(time.RFC3339) + } + + return out +} + +func ociLBFirstCert(pemText string) *x509.Certificate { + rest := []byte(pemText) + + for { + var blk *pem.Block + + blk, rest = pem.Decode(rest) + if blk == nil { + return nil + } + + if blk.Type == "CERTIFICATE" { + if c, err := x509.ParseCertificate(blk.Bytes); err == nil { + return c + } + } + } +} + +type ociLBTryIn struct { + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Body string `json:"body"` + VirtualHost string `json:"virtualHost"` + Headers []ociAPIGWTryHeader `json:"headers"` +} + +func ociLBTryValid(in ociLBTryIn) (string, bool) { + rel, ok := ociAPIGWTryPath(ociAPIGWTryIn{Method: in.Method, Path: in.Path, Query: in.Query}) + + return rel, ok && ociAPIGWMethodOK(in.Method) && len(in.Headers) <= ociLBMaxHeaders && + len(in.Body) <= ociLBMaxReqBody && ociLBHostOK(in.VirtualHost) +} + +// ociLBHostOK accepts an empty or plain DNS-style name; it only ever becomes the Host header, never the destination. +func ociLBHostOK(h string) bool { + if len(h) > ociLBMaxHostLen { + return false + } + + for i, r := range h { + alnum := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' + if !alnum && (i == 0 || i == len(h)-1 || (r != '.' && r != '-')) { + return false + } + } + + return true +} + +// ociLBTryIt replays a request against the loopback data-plane address of one plain-text listener. The destination +// comes from the backend, never from the request, and every dial is pinned to it. +func ociLBTryIt(c *echo.Context, ops lbTargets, lbID, listener string) error { + var in ociLBTryIn + if !ociDecodeBodyN(c, &in, ociLBMaxTryJSON) { + return ociInvalid(c, "invalid Load Balancer try-it request") + } + + rel, ok := ociLBTryValid(in) + if !ok { + return ociInvalid(c, "invalid Load Balancer try-it request") + } + + state, exists := ops.LoadBalancerState(lbID) + if !exists { + return cloudJSONFail(c, http.StatusNotFound, "NotAuthorizedOrNotFound", "Load balancer not found") + } + + if state != ociLBActive { + return cloudJSONFail(c, http.StatusConflict, "IncorrectState", "Load balancer is in state "+state) + } + + t, found := ops.ListenerTarget(lbID, listener) + if !found { + return cloudJSONFail(c, http.StatusNotFound, "NotAuthorizedOrNotFound", "Listener not found or not serving") + } + + if t.TLS || (t.Protocol != ociLBHTTP && t.Protocol != ociLBTCP) { + return cloudJSONFail(c, http.StatusConflict, "IncorrectState", + "Try it supports plain-text HTTP and TCP listeners only") + } + + if !ociLBLoopback(t.Address) { + return cloudJSONFail(c, http.StatusBadGateway, "InternalServerError", "listener address is not loopback") + } + + return ociLBSend(c, t.Address, rel, in) +} + +type lbTargets interface { + LoadBalancerState(id string) (string, bool) + ListenerTarget(lbID, listener string) (ociloadbalancer.Target, bool) +} + +func ociLBLoopback(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + + ip, err := netip.ParseAddr(host) + + return err == nil && ip.IsLoopback() +} + +// ociLBPinnedBase is a placeholder authority; the transport dials the emulator-owned loopback address instead. +const ociLBPinnedBase = "http://listener.invalid/" + +func ociLBSend(c *echo.Context, addr, rel string, in ociLBTryIn) error { + ctx, cancel := context.WithTimeout(c.Request().Context(), ociLBTryTimeout) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, in.Method, ociLBPinnedBase+strings.TrimPrefix(rel, "/"), strings.NewReader(in.Body), + ) + if err != nil { + return ociInvalid(c, "invalid Load Balancer try-it request") + } + + req.Host = addr + if in.VirtualHost != "" { + req.Host = in.VirtualHost + } + + for _, hd := range in.Headers { + if ociAPIGWHeaderOK(hd.Name) { + req.Header.Add(hd.Name, hd.Value) + } + } + + tr := httpclient.NewTransport() + tr.Proxy = nil + tr.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + + return d.DialContext(ctx, network, addr) + } + + defer tr.CloseIdleConnections() + + client := &http.Client{ + Transport: tr, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + + resp, err := client.Do(req) + if err != nil { + return cloudJSONFail( + c, + http.StatusBadGateway, + "InternalServerError", + "request to listener failed: "+err.Error(), + ) + } + + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, ociLBMaxRespBody+1)) + + rec := httptest.NewRecorder() + rec.Code = resp.StatusCode + maps.Copy(rec.Header(), resp.Header) + _, _ = rec.Body.Write(body) + + return c.JSON(http.StatusOK, ociAPIGWTryOut(rec)) +} diff --git a/dashboard/ociloadbalancer_proxy_test.go b/dashboard/ociloadbalancer_proxy_test.go new file mode 100644 index 0000000000..cd91f7903a --- /dev/null +++ b/dashboard/ociloadbalancer_proxy_test.go @@ -0,0 +1,421 @@ +package dashboard_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" +) + +const ( + lbComp = "ocid1.compartment.oc1..lb" + lbBase = "/dashboard/api/v1/oci/loadbalancer" + lbAPI = lbBase + "/api" + + lbCreateHead = `{"compartmentId":"c","displayName":"d","subnetId":"s"` +) + +type lbFixture struct { + h *dashboard.DashboardHandler + id string + key string + addrs []string +} + +func lbCert(t *testing.T) (string, string) { + t.Helper() + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "lb.example.test"}, + DNSNames: []string{"lb.example.test"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + } + + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &k.PublicKey, k) + require.NoError(t, err) + + kb, err := x509.MarshalECPrivateKey(k) + require.NoError(t, err) + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})) +} + +func lbBackendServer(t *testing.T, name string) string { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Backend", name) + _, _ = fmt.Fprintf(w, "%s %s host=%s", name, r.URL.Path, r.Host) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + return u.Host +} + +func newLBDashboard(t *testing.T) lbFixture { + t.Helper() + + be := ociloadbalancer.NewInMemoryBackend() + be.SetTransition(0) + t.Cleanup(be.Close) + + ops := ociloadbalancer.NewHandler(be) + e := echo.New() + e.Any("/*", ops.Trusted()) + + certPEM, keyPEM := lbCert(t) + + backends := make([]map[string]any, 0, 2) + addrs := make([]string, 0, 2) + + for _, n := range []string{"alpha", "beta"} { + host := lbBackendServer(t, n) + addrs = append(addrs, host) + + ip, port, _ := strings.Cut(host, ":") + + var p int + + _, err := fmt.Sscanf(port, "%d", &p) + require.NoError(t, err) + + backends = append(backends, map[string]any{"ipAddress": ip, "port": p}) + } + + raw, err := json.Marshal(map[string]any{ + "compartmentId": lbComp, "displayName": "edge", "shapeName": "flexible", "isPrivate": false, + "shapeDetails": map[string]any{"minimumBandwidthInMbps": 10, "maximumBandwidthInMbps": 100}, + "subnetIds": []string{"ocid1.subnet.oc1..s"}, + "certificates": map[string]any{"tls": map[string]any{"publicCertificate": certPEM, "privateKey": keyPEM}}, + "backendSets": map[string]any{"web": map[string]any{ + "policy": "ROUND_ROBIN", "backends": backends, + "healthChecker": map[string]any{"protocol": "HTTP", "urlPath": "/", "intervalInMillis": 1000}, + }}, + "listeners": map[string]any{ + "http": map[string]any{"defaultBackendSetName": "web", "protocol": "HTTP", "port": 80}, + "tls": map[string]any{ + "defaultBackendSetName": "web", "protocol": "HTTP", "port": 443, + "sslConfiguration": map[string]any{"certificateName": "tls"}, + }, + }, + }) + require.NoError(t, err) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/20170115/loadBalancers", strings.NewReader(string(raw)))) + require.Less(t, rec.Code, http.StatusBadRequest, rec.Body.String()) + + h := dashboard.NewHandler(dashboard.Config{OCILoadBalancerOps: ops}) + f := lbFixture{h: h, key: keyPEM, addrs: addrs} + + list := lbDo(t, h, http.MethodGet, lbAPI+"/loadBalancers?compartmentId="+lbComp, "") + + var items []struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &items), list.Body.String()) + require.Len(t, items, 1) + f.id = items[0].ID + + return f +} + +func lbDo(t *testing.T, h *dashboard.DashboardHandler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + + return rec +} + +func lbTry(t *testing.T, f lbFixture, id, listener, body string) *httptest.ResponseRecorder { + t.Helper() + + return lbDo(t, f.h, http.MethodPost, lbAPI+"/loadBalancers/"+id+"/listeners/"+listener+"/invoke", body) +} + +func TestOCILoadBalancerProxyRead(t *testing.T) { + t.Parallel() + + f := newLBDashboard(t) + lb := lbAPI + "/loadBalancers/" + f.id + + runGCPSteps(t, f.h, []gcpStep{ + {http.MethodGet, lbBase + "/compartments", "", lbComp, 200}, + {http.MethodGet, lb, "", `"gopherstackAddress":"127.0.0.1:`, 200}, + {http.MethodGet, lb + "/health", "", `"totalBackendSetCount":1`, 200}, + {http.MethodGet, lb + "/workRequests", "", "CreateLoadBalancer", 200}, + {http.MethodGet, lb + "/backendSets/web/health", "", `"totalBackendCount":2`, 200}, + { + http.MethodGet, + lb + "/backendSets/web/backends/" + url.PathEscape(f.addrs[0]) + "/health", + "", + `"status"`, + 200, + }, + }) + + for _, path := range []string{lb, lb + "/certificates"} { + rec := lbDo(t, f.h, http.MethodGet, lbAPI+strings.TrimPrefix(path, lbAPI), "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"lb.example.test"`) + assert.Contains(t, rec.Body.String(), `"notAfter"`) + assert.NotContains(t, rec.Body.String(), "PRIVATE KEY") + assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE") + assert.NotContains(t, rec.Body.String(), "publicCertificate") + assert.NotContains(t, rec.Body.String(), strings.TrimSpace(strings.Split(f.key, "\n")[1])) + } +} + +func TestOCILoadBalancerProxyRejects(t *testing.T) { + t.Parallel() + + f := newLBDashboard(t) + lb := lbAPI + "/loadBalancers/" + f.id + + tests := []struct { + name string + method string + path string + body string + }{ + {"unknown collection", http.MethodGet, lbAPI + "/shapes", ""}, + {"update load balancer", http.MethodPut, lb, `{"displayName":"x"}`}, + {"change compartment", http.MethodPost, lb + "/changeCompartment", `{}`}, + {"update shape", http.MethodPut, lb + "/updateShape", `{}`}, + {"delete backend set", http.MethodDelete, lb + "/backendSets/web", ""}, + {"create backend set", http.MethodPost, lb + "/backendSets", `{}`}, + {"create listener", http.MethodPost, lb + "/listeners", `{}`}, + {"delete certificate", http.MethodDelete, lb + "/certificates/tls", ""}, + {"create certificate", http.MethodPost, lb + "/certificates", `{}`}, + {"update health checker", http.MethodPut, lb + "/backendSets/web/healthChecker", `{}`}, + {"get one backend", http.MethodGet, lb + "/backendSets/web/backends/a", ""}, + {"dot segments", http.MethodGet, lbAPI + "/loadBalancers/%2e%2e", ""}, + {"encoded slash", http.MethodGet, lbAPI + "/loadBalancers/a%2fb", ""}, + {"bad create body", http.MethodPost, lbAPI + "/loadBalancers", `nope`}, + {"create without name", http.MethodPost, lbAPI + "/loadBalancers", `{"compartmentId":"c","subnetId":"s"}`}, + { + "create bandwidth too high", http.MethodPost, lbAPI + "/loadBalancers", + lbCreateHead + `,"minimumBandwidthInMbps":10,"maximumBandwidthInMbps":9000}`, + }, + { + "create bad port", http.MethodPost, lbAPI + "/loadBalancers", + lbCreateHead + `,"listenerPort":70000}`, + }, + {"backend bad ip", http.MethodPost, lb + "/backendSets/web/backends", `{"ipAddress":"nope","port":80}`}, + {"backend bad port", http.MethodPost, lb + "/backendSets/web/backends", `{"ipAddress":"127.0.0.1","port":0}`}, + { + "backend zero weight", + http.MethodPost, + lb + "/backendSets/web/backends", + `{"ipAddress":"127.0.0.1","port":80,"weight":0}`, + }, + {"update missing flags", http.MethodPut, lb + "/backendSets/web/backends/x", `{"weight":2}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := lbDo(t, f.h, tt.method, tt.path, tt.body) + assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + } +} + +func TestOCILoadBalancerProxyActions(t *testing.T) { + t.Parallel() + + f := newLBDashboard(t) + lb := lbAPI + "/loadBalancers/" + f.id + set := lb + "/backendSets/web" + name := url.PathEscape(f.addrs[0]) + + add := lbDo(t, f.h, http.MethodPost, set+"/backends", + `{"ipAddress":"127.0.0.1","port":9,"weight":2,"junk":"ignored","name":"forged"}`) + require.Less(t, add.Code, http.StatusBadRequest, add.Body.String()) + + got := lbDo(t, f.h, http.MethodGet, lb, "") + assert.Contains(t, got.Body.String(), `"name":"127.0.0.1:9"`) + + upd := lbDo( + t, + f.h, + http.MethodPut, + set+"/backends/"+name, + `{"weight":3,"backup":false,"drain":true,"offline":true}`, + ) + require.Less(t, upd.Code, http.StatusBadRequest, upd.Body.String()) + + var detail struct { + BackendSets map[string]struct { + Backends []struct { + Name string `json:"name"` + Weight int `json:"weight"` + Drain bool `json:"drain"` + Offline bool `json:"offline"` + } `json:"backends"` + } `json:"backendSets"` + } + + require.NoError(t, json.Unmarshal(lbDo(t, f.h, http.MethodGet, lb, "").Body.Bytes(), &detail)) + + var found bool + + for _, b := range detail.BackendSets["web"].Backends { + if b.Name == f.addrs[0] { + found = true + + assert.Equal(t, 3, b.Weight) + assert.True(t, b.Drain) + assert.True(t, b.Offline) + } + } + + assert.True(t, found) + + del := lbDo(t, f.h, http.MethodDelete, set+"/backends/"+url.PathEscape("127.0.0.1:9"), "") + require.Less(t, del.Code, http.StatusBadRequest, del.Body.String()) + assert.NotContains(t, lbDo(t, f.h, http.MethodGet, lb, "").Body.String(), `"name":"127.0.0.1:9"`) + + create := lbDo(t, f.h, http.MethodPost, lbAPI+"/loadBalancers", + `{"compartmentId":"`+lbComp+`","displayName":"second","subnetId":"ocid1.subnet.oc1..s","listenerPort":8080,`+ + `"minimumBandwidthInMbps":10,"maximumBandwidthInMbps":10,"isDeleteProtectionEnabled":true,"shapeName":"100Mbps"}`) + require.Less(t, create.Code, http.StatusBadRequest, create.Body.String()) + + list := lbDo(t, f.h, http.MethodGet, lbAPI+"/loadBalancers?compartmentId="+lbComp+"&displayName=second", "") + + var items []struct { + ID string `json:"id"` + Shape string `json:"shapeName"` + DeleteProtected bool `json:"isDeleteProtectionEnabled"` + } + + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &items), list.Body.String()) + require.Len(t, items, 1) + assert.Equal(t, "flexible", items[0].Shape) + assert.False(t, items[0].DeleteProtected) + + require.Less( + t, + lbDo(t, f.h, http.MethodDelete, lbAPI+"/loadBalancers/"+items[0].ID, "").Code, + http.StatusBadRequest, + ) +} + +func TestOCILoadBalancerProxyTryItRoundRobin(t *testing.T) { + t.Parallel() + + f := newLBDashboard(t) + seen := map[string]int{} + + for range 4 { + rec := lbTry(t, f, f.id, "http", `{"method":"GET","path":"/hello","query":"a=1",`+ + `"headers":[{"name":"Host","value":"evil.example"},{"name":"Connection","value":"close"}]}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var out struct { + Headers map[string]string `json:"headers"` + Body string `json:"body"` + Status int `json:"status"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + assert.Equal(t, 200, out.Status) + assert.NotContains(t, out.Headers, "Connection") + assert.NotContains(t, out.Body, "evil.example") + seen[out.Headers["X-Backend"]]++ + } + + assert.Equal(t, map[string]int{"alpha": 2, "beta": 2}, seen) +} + +func TestOCILoadBalancerProxyTryItVirtualHost(t *testing.T) { + t.Parallel() + + f := newLBDashboard(t) + rec := lbTry(t, f, f.id, "http", `{"method":"GET","path":"/","virtualHost":"app.example.test"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "host=app.example.test") +} + +func TestOCILoadBalancerProxyTryItRejects(t *testing.T) { + t.Parallel() + + f := newLBDashboard(t) + + tests := []struct { + name string + id string + listener string + body string + want int + }{ + {"unknown load balancer", "ocid1.loadbalancer.oc1..nope", "http", `{"method":"GET","path":"/"}`, 404}, + {"unknown listener", f.id, "nope", `{"method":"GET","path":"/"}`, 404}, + {"tls listener", f.id, "tls", `{"method":"GET","path":"/"}`, 409}, + {"bad method", f.id, "http", `{"method":"TRACE","path":"/"}`, 400}, + {"relative path", f.id, "http", `{"method":"GET","path":"x"}`, 400}, + {"absolute url", f.id, "http", `{"method":"GET","path":"http://evil.example/x"}`, 400}, + {"dot segments", f.id, "http", `{"method":"GET","path":"/a/../x"}`, 400}, + {"encoded slash", f.id, "http", `{"method":"GET","path":"/a%2fb"}`, 400}, + {"path with query", f.id, "http", `{"method":"GET","path":"/a?b=c"}`, 400}, + {"fragment", f.id, "http", `{"method":"GET","path":"/","query":"a=b#x"}`, 400}, + {"host with port", f.id, "http", `{"method":"GET","path":"/","virtualHost":"evil.example:80"}`, 400}, + {"host with slash", f.id, "http", `{"method":"GET","path":"/","virtualHost":"a/b"}`, 400}, + {"id traversal", "%2e%2e", "http", `{"method":"GET","path":"/"}`, 400}, + { + "oversize body", + f.id, + "http", + `{"method":"POST","path":"/","body":"` + strings.Repeat("a", 70000) + `"}`, + 400, + }, + {"not json", f.id, "http", `nope`, 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, lbTry(t, f, tt.id, tt.listener, tt.body).Code) + }) + } +} + +func TestOCILoadBalancerProxyUnavailable(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, lbAPI + "/loadBalancers?compartmentId=c", "", "", http.StatusServiceUnavailable}, + {http.MethodGet, lbBase + "/compartments", "", "", http.StatusServiceUnavailable}, + }) +} diff --git a/dashboard/ocilogging_proxy.go b/dashboard/ocilogging_proxy.go new file mode 100644 index 0000000000..4810406047 --- /dev/null +++ b/dashboard/ocilogging_proxy.go @@ -0,0 +1,167 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" +) + +const ( + ociLoggingPrefix = ociProxyPrefix + "/logging" + ociLoggingMgmt = "/20200531" + ociLoggingIngest = "/20200831" + ociLoggingSearch = "/20190909/search" + ociLoggingGroups = "logGroups" + ociLoggingLogs = "logs" + ociLoggingSearchK = "search" + ociLoggingGroupN = 2 + ociLoggingLogsN = 3 + ociLoggingLogN = 4 + ociLoggingPushN = 4 + ociLoggingMaxData = 4096 +) + +// registerOCILoggingRoutes serves the Logging page by dispatching in-process to the emulator's handler without +// request signing. Only log group and log reads, enabling or disabling a log, searching, and pushing a single test +// entry are reachable; writes forward a body rebuilt from a fixed set of fields. +func (h *DashboardHandler) registerOCILoggingRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCILoggingOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI Logging", next) } + + h.SubRouter.Add(http.MethodGet, ociLoggingPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociLoggingPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok { + return ociInvalid(c, "unsupported Logging proxy request") + } + + apiPath, body, ok := ociLoggingRewrite(c, r.Method, parts, raw) + if !ok { + return ociInvalid(c, "unsupported Logging proxy request") + } + + return ociDispatch(c, inner, apiPath, body) + }) + + for _, m := range []string{http.MethodGet, http.MethodPut, http.MethodPost} { + h.SubRouter.Add(m, ociLoggingPrefix+"/api/*", serve) + } +} + +// ociLoggingRewrite maps an allowed request to the emulator path and the body to forward (nil keeps the original). +func ociLoggingRewrite(c *echo.Context, method string, parts []string, raw string) (string, []byte, bool) { + switch method { + case http.MethodGet: + return ociLoggingMgmt + raw, nil, ociLoggingReadable(parts) + case http.MethodPut: + return ociLoggingToggle(c, parts, raw) + case http.MethodPost: + return ociLoggingPost(c, parts) + } + + return "", nil, false +} + +func ociLoggingToggle(c *echo.Context, parts []string, raw string) (string, []byte, bool) { + if len(parts) != ociLoggingLogN || parts[0] != ociLoggingGroups || parts[2] != ociLoggingLogs { + return "", nil, false + } + + var in struct { + Enabled *bool `json:"isEnabled"` + } + + if !ociDecodeBody(c, &in) || in.Enabled == nil { + return "", nil, false + } + + b, err := json.Marshal(map[string]any{"isEnabled": *in.Enabled}) + + return ociLoggingMgmt + raw, b, err == nil +} + +func ociLoggingPost(c *echo.Context, parts []string) (string, []byte, bool) { + switch { + case len(parts) == 1 && parts[0] == ociLoggingSearchK: + return ociLoggingSearchRequest(c) + case len(parts) == ociLoggingPushN && parts[0] == ociLoggingLogs && parts[2] == ociActionsSeg && + parts[3] == "push": + return ociLoggingPushRequest(c, parts[1]) + } + + return "", nil, false +} + +func ociLoggingReadable(parts []string) bool { + if parts[0] != ociLoggingGroups { + return false + } + + switch len(parts) { + case 1, ociLoggingGroupN: + return true + case ociLoggingLogsN, ociLoggingLogN: + return parts[2] == ociLoggingLogs + } + + return false +} + +func ociLoggingSearchRequest(c *echo.Context) (string, []byte, bool) { + var in struct { + TimeStart *time.Time `json:"timeStart"` + TimeEnd *time.Time `json:"timeEnd"` + SearchQuery string `json:"searchQuery"` + } + + if !ociDecodeBody(c, &in) { + return "", nil, false + } + + b, err := json.Marshal(map[string]any{ + "timeStart": in.TimeStart, "timeEnd": in.TimeEnd, "searchQuery": in.SearchQuery, "isReturnFieldInfo": true, + }) + + return ociLoggingSearch, b, err == nil +} + +func ociLoggingPushRequest(c *echo.Context, logID string) (string, []byte, bool) { + var in struct { + Data string `json:"data"` + } + + if !ociDecodeBody(c, &in) || in.Data == "" || len(in.Data) > ociLoggingMaxData { + return "", nil, false + } + + now := time.Now().UTC() + b, err := json.Marshal(map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{ + "source": "dashboard", "type": "dashboard.test", "defaultlogentrytime": now, + "entries": []any{map[string]any{"data": in.Data, "id": uuid.NewString(), "time": now}}, + }}, + }) + + return ociLoggingIngest + "/logs/" + logID + "/actions/push", b, err == nil +} diff --git a/dashboard/ocilogging_proxy_test.go b/dashboard/ocilogging_proxy_test.go new file mode 100644 index 0000000000..730f221f06 --- /dev/null +++ b/dashboard/ocilogging_proxy_test.go @@ -0,0 +1,145 @@ +package dashboard_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocilogging" +) + +const ( + logComp = "ocid1.compartment.oc1..aaaa" + logBase = "/dashboard/api/v1/oci/logging" +) + +func logSeed(t *testing.T, e *echo.Echo, method, path, body string) []byte { + t.Helper() + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body))) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", path, rec.Body.String()) + + return rec.Body.Bytes() +} + +func newLoggingDashboard(t *testing.T) (*dashboard.DashboardHandler, string, string) { + t.Helper() + + b := ocilogging.NewInMemoryBackend() + b.SetTransition(0) + + ops := ocilogging.NewHandler(b) + e := echo.New() + e.Any("/*", ops.Trusted()) + + logSeed(t, e, http.MethodPost, "/20200531/logGroups", + `{"compartmentId":"`+logComp+`","displayName":"apps"}`) + + var groups []struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(logSeed(t, e, http.MethodGet, + "/20200531/logGroups?compartmentId="+logComp, ""), &groups)) + require.Len(t, groups, 1) + + logSeed(t, e, http.MethodPost, "/20200531/logGroups/"+groups[0].ID+"/logs", + `{"displayName":"web","logType":"CUSTOM","isEnabled":true}`) + + var logs []struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(logSeed(t, e, http.MethodGet, + "/20200531/logGroups/"+groups[0].ID+"/logs", ""), &logs)) + require.Len(t, logs, 1) + + return dashboard.NewHandler(dashboard.Config{OCILoggingOps: ops}), groups[0].ID, logs[0].ID +} + +func TestOCILoggingProxy(t *testing.T) { + t.Parallel() + + api := logBase + "/api" + span := func() string { + now := time.Now().UTC() + + return `"timeStart":"` + now.Add(-time.Hour).Format(time.RFC3339) + `","timeEnd":"` + + now.Add(time.Hour).Format(time.RFC3339) + `"` + } + + tests := []struct { + steps func(group, log string) []gcpStep + name string + }{ + {name: "read", steps: func(group, log string) []gcpStep { + return []gcpStep{ + {http.MethodGet, logBase + "/compartments", "", logComp, 200}, + {http.MethodGet, api + "/logGroups?compartmentId=" + logComp, "", `"displayName":"apps"`, 200}, + {http.MethodGet, api + "/logGroups/" + group, "", `"displayName":"apps"`, 200}, + {http.MethodGet, api + "/logGroups/" + group + "/logs", "", `"displayName":"web"`, 200}, + {http.MethodGet, api + "/logGroups/" + group + "/logs/" + log, "", `"logType":"CUSTOM"`, 200}, + {http.MethodGet, api + "/logGroups/ocid1.loggroup.oc1..nope", "", "NotAuthorizedOrNotFound", 404}, + } + }}, + {name: "enable and disable", steps: func(group, log string) []gcpStep { + path := api + "/logGroups/" + group + "/logs/" + log + + return []gcpStep{ + {http.MethodPut, path, `{"isEnabled":false,"displayName":"renamed"}`, "", http.StatusAccepted}, + {http.MethodGet, path, "", `"isEnabled":false`, 200}, + {http.MethodGet, path, "", `"displayName":"web"`, 200}, + {http.MethodPut, path, `{"displayName":"renamed"}`, "unsupported", 400}, + {http.MethodPut, path, `{"isEnabled":true}`, "", http.StatusAccepted}, + } + }}, + {name: "push and search", steps: func(_, log string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/logs/" + log + "/actions/push", `{"data":"hello dashboard"}`, "", 200}, + {http.MethodPost, api + "/search?limit=10", `{` + span() + `,"searchQuery":"search \"` + logComp + + `\" | where logContent = '*hello*'"}`, "hello dashboard", 200}, + {http.MethodPost, api + "/search", `{` + span() + `,"searchQuery":"search \"` + logComp + + `\" | select x"}`, "", 400}, + {http.MethodPost, api + "/logs/" + log + "/actions/push", `{"data":""}`, "unsupported", 400}, + } + }}, + {name: "rejects", steps: func(group, log string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/logGroups", `{}`, "unsupported", 400}, + {http.MethodPut, api + "/logGroups/" + group, `{}`, "unsupported", 400}, + {http.MethodGet, api + "/unifiedAgentConfigurations", "", "unsupported", 400}, + {http.MethodGet, api + "/search", "", "unsupported", 400}, + {http.MethodPost, api + "/logs/" + log + "/actions/other", `{}`, "unsupported", 400}, + {http.MethodGet, api + "/logGroups/%2e%2e", "", "unsupported", 400}, + {http.MethodGet, api + "/", "", "unsupported", 400}, + } + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, group, log := newLoggingDashboard(t) + runGCPSteps(t, h, tt.steps(group, log)) + }) + } + + t.Run("unserved", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, api+"/logGroups?compartmentId="+logComp, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/ocimonitoring_proxy.go b/dashboard/ocimonitoring_proxy.go new file mode 100644 index 0000000000..101a08faf2 --- /dev/null +++ b/dashboard/ocimonitoring_proxy.go @@ -0,0 +1,133 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v5" +) + +const ( + ociMonitoringPrefix = ociProxyPrefix + "/monitoring" + ociMonitoringAPIPrefix = "/20180401" + ociMonitoringAlarms = "alarms" + ociMonitoringMetrics = "metrics" + ociMonitoringStatus = "status" + ociMonitoringItemSegs = 2 + ociMonitoringActSegs = 3 +) + +// registerOCIMonitoringRoutes serves the Monitoring page by dispatching in-process to the emulator's handler without +// request signing. Only alarm reads, metric listing and metric queries are reachable; the two POSTs forward a body +// rebuilt from a fixed set of fields. +func (h *DashboardHandler) registerOCIMonitoringRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCIMonitoringOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI Monitoring", next) } + + h.SubRouter.Add(http.MethodGet, ociMonitoringPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociMonitoringPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok { + return ociInvalid(c, "unsupported Monitoring proxy request") + } + + body, ok := ociMonitoringBody(c, r.Method, parts) + if !ok { + return ociInvalid(c, "unsupported Monitoring proxy request") + } + + return ociDispatch(c, inner, ociMonitoringAPIPrefix+raw, body) + }) + + h.SubRouter.Add(http.MethodGet, ociMonitoringPrefix+"/api/*", serve) + h.SubRouter.Add(http.MethodPost, ociMonitoringPrefix+"/api/*", serve) +} + +func ociMonitoringReadable(parts []string) bool { + if parts[0] != ociMonitoringAlarms { + return false + } + + switch len(parts) { + case 1: + return true + case ociMonitoringItemSegs: + return true + case ociMonitoringActSegs: + return parts[2] == "history" + } + + return false +} + +func ociMonitoringListBody(c *echo.Context) ([]byte, bool) { + var in struct { + Namespace *string `json:"namespace"` + Name *string `json:"name"` + ResourceGroup *string `json:"resourceGroup"` + } + + if !ociDecodeBody(c, &in) { + return nil, false + } + + b, err := json.Marshal(map[string]any{ + ociNamespaceKey: in.Namespace, "name": in.Name, "resourceGroup": in.ResourceGroup, + }) + + return b, err == nil +} + +func ociMonitoringSummarizeBody(c *echo.Context) ([]byte, bool) { + var in struct { + StartTime *time.Time `json:"startTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` + ResourceGroup *string `json:"resourceGroup,omitempty"` + Namespace string `json:"namespace"` + Query string `json:"query"` + Resolution string `json:"resolution,omitempty"` + } + + if !ociDecodeBody(c, &in) { + return nil, false + } + + b, err := json.Marshal(in) + + return b, err == nil +} + +// ociMonitoringBody validates a request and builds the body to forward (nil keeps the original). +func ociMonitoringBody(c *echo.Context, method string, parts []string) ([]byte, bool) { + switch { + case method == http.MethodGet: + return nil, ociMonitoringReadable(parts) + case method != http.MethodPost || len(parts) != ociMonitoringActSegs || parts[0] != ociMonitoringMetrics || + parts[1] != ociActionsSeg: + return nil, false + case parts[2] == "listMetrics": + return ociMonitoringListBody(c) + case parts[2] == "summarizeMetricsData": + return ociMonitoringSummarizeBody(c) + } + + return nil, false +} diff --git a/dashboard/ocimonitoring_proxy_test.go b/dashboard/ocimonitoring_proxy_test.go new file mode 100644 index 0000000000..d05bef329b --- /dev/null +++ b/dashboard/ocimonitoring_proxy_test.go @@ -0,0 +1,132 @@ +package dashboard_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocimonitoring" +) + +const ( + monComp = "ocid1.compartment.oc1..aaaa" + monBase = "/dashboard/api/v1/oci/monitoring" +) + +func monSeed(t *testing.T, e *echo.Echo, method, path, body string) []byte { + t.Helper() + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body))) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", path, rec.Body.String()) + + return rec.Body.Bytes() +} + +func newMonitoringDashboard(t *testing.T) (*dashboard.DashboardHandler, string) { + t.Helper() + + b := ocimonitoring.NewInMemoryBackend() + ops := ocimonitoring.NewHandler(b) + e := echo.New() + e.Any("/*", ops.Trusted()) + + now := time.Now().UTC().Truncate(time.Second) + points := make([]string, 0, 3) + + for i, v := range []int{10, 90, 95} { + points = append(points, `{"timestamp":"`+now.Add(-time.Duration(3-i)*time.Minute).Format(time.RFC3339)+ + `","value":`+strconv.Itoa(v)+`}`) + } + + monSeed(t, e, http.MethodPost, "/20180401/metrics", `{"metricData":[{"namespace":"app","compartmentId":"`+monComp+ + `","name":"cpu","dimensions":{"host":"a"},"datapoints":[`+strings.Join(points, ",")+`]}]}`) + monSeed(t, e, http.MethodPost, "/20180401/alarms", `{"displayName":"cpu-high","compartmentId":"`+monComp+ + `","metricCompartmentId":"`+monComp+`","namespace":"app","query":"cpu[1m].max() > 80","severity":"CRITICAL",`+ + `"destinations":["ocid1.onstopic.oc1..topic"],"isEnabled":true}`) + + var alarms []struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(monSeed(t, e, http.MethodGet, + "/20180401/alarms?compartmentId="+monComp, ""), &alarms)) + require.Len(t, alarms, 1) + + return dashboard.NewHandler(dashboard.Config{OCIMonitoringOps: ops}), alarms[0].ID +} + +func TestOCIMonitoringProxy(t *testing.T) { + t.Parallel() + + api := monBase + "/api" + span := func() string { + now := time.Now().UTC() + + return `"startTime":"` + now.Add(-time.Hour).Format(time.RFC3339) + `","endTime":"` + + now.Add(time.Minute).Format(time.RFC3339) + `"` + } + + tests := []struct { + steps func(alarm string) []gcpStep + name string + }{ + {name: "alarms", steps: func(alarm string) []gcpStep { + return []gcpStep{ + {http.MethodGet, monBase + "/compartments", "", monComp, 200}, + {http.MethodGet, api + "/alarms?compartmentId=" + monComp, "", `"displayName":"cpu-high"`, 200}, + {http.MethodGet, api + "/alarms/status?compartmentId=" + monComp, "", `"status":`, 200}, + {http.MethodGet, api + "/alarms/" + alarm, "", `"namespace":"app"`, 200}, + {http.MethodGet, api + "/alarms/" + alarm + "/history", "", `"entries"`, 200}, + {http.MethodGet, api + "/alarms/ocid1.alarm.oc1..nope", "", "NotAuthorizedOrNotFound", 404}, + } + }}, + {name: "metrics", steps: func(string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/metrics/actions/listMetrics?compartmentId=" + monComp, + `{"namespace":"app","sortBy":"NAME"}`, `"name":"cpu"`, 200}, + {http.MethodPost, api + "/metrics/actions/summarizeMetricsData?compartmentId=" + monComp, + `{"namespace":"app","query":"cpu[1m].max()",` + span() + `}`, `"aggregatedDatapoints"`, 200}, + {http.MethodPost, api + "/metrics/actions/summarizeMetricsData?compartmentId=" + monComp, + `{"namespace":"app","query":"nonsense"}`, "", 400}, + } + }}, + {name: "rejects", steps: func(alarm string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/metrics", `{"metricData":[]}`, "unsupported", 400}, + {http.MethodPost, api + "/alarms", `{}`, "unsupported", 400}, + {http.MethodGet, api + "/metrics/actions/listMetrics", "", "unsupported", 400}, + {http.MethodGet, api + "/alarms/" + alarm + "/other", "", "unsupported", 400}, + {http.MethodGet, api + "/alarms/%2e%2e", "", "unsupported", 400}, + {http.MethodGet, api + "/", "", "unsupported", 400}, + } + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, alarm := newMonitoringDashboard(t) + runGCPSteps(t, h, tt.steps(alarm)) + }) + } + + t.Run("unserved", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, api+"/alarms?compartmentId="+monComp, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/ocimysql_proxy.go b/dashboard/ocimysql_proxy.go new file mode 100644 index 0000000000..549ad8f913 --- /dev/null +++ b/dashboard/ocimysql_proxy.go @@ -0,0 +1,126 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociMySQLPrefix = ociProxyPrefix + "/mysql" + ociMySQLAPIPrefix = "/20190415" + ociMySQLSystems = "dbSystems" + ociMySQLBackups = "backups" + ociMySQLConfigs = "configurations" + ociMySQLItemSegs = 2 + ociMySQLActSegs = 4 +) + +// registerOCIMySQLRoutes serves the MySQL HeatWave page by dispatching in-process to the emulator's handler without +// request signing. Only DB system, backup and configuration reads, start/stop/restart, delete and manual backup +// creation are reachable; every write forwards a body rebuilt from a fixed set of fields, and the emulator never +// returns admin passwords. +func (h *DashboardHandler) registerOCIMySQLRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCIMySQLOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI MySQL", next) } + + h.SubRouter.Add(http.MethodGet, ociMySQLPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociMySQLPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok || !ociMySQLAllowed(r.Method, parts) { + return ociInvalid(c, "unsupported MySQL proxy request") + } + + body, ok := ociMySQLBody(c, r.Method, parts) + if !ok { + return ociInvalid(c, "invalid MySQL proxy request body") + } + + return ociDispatch(c, inner, ociMySQLAPIPrefix+raw, body) + }) + + for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { + h.SubRouter.Add(m, ociMySQLPrefix+"/api/*", serve) + } +} + +func ociMySQLAllowed(method string, parts []string) bool { + switch method { + case http.MethodGet: + return ociMySQLReadable(parts) + case http.MethodDelete: + return len(parts) == ociMySQLItemSegs && parts[0] == ociMySQLSystems + case http.MethodPost: + if len(parts) == 1 { + return parts[0] == ociMySQLBackups + } + + return len(parts) == ociMySQLActSegs && parts[0] == ociMySQLSystems && parts[2] == ociActionsSeg && + slices.Contains([]string{"start", "stop", "restart"}, parts[3]) + } + + return false +} + +// ociMySQLBody builds the forwarded body of a POST; nil keeps the (empty) original. +func ociMySQLBody(c *echo.Context, method string, parts []string) ([]byte, bool) { + if method != http.MethodPost { + return nil, true + } + + if len(parts) == 1 { + var in struct { + DBSystemID string `json:"dbSystemId"` + DisplayName string `json:"displayName"` + } + + if !ociDecodeBody(c, &in) || in.DBSystemID == "" { + return nil, false + } + + out := map[string]any{"dbSystemId": in.DBSystemID} + if in.DisplayName != "" { + out["displayName"] = in.DisplayName + } + + b, err := json.Marshal(out) + + return b, err == nil + } + + if parts[3] == "start" { + return []byte("{}"), true + } + + return []byte(`{"shutdownType":"IMMEDIATE"}`), true +} + +func ociMySQLReadable(parts []string) bool { + switch len(parts) { + case 1: + return slices.Contains([]string{ociMySQLSystems, ociMySQLBackups, ociMySQLConfigs}, parts[0]) + case ociMySQLItemSegs: + return slices.Contains([]string{ociMySQLSystems, ociMySQLConfigs}, parts[0]) + } + + return false +} diff --git a/dashboard/ocimysql_proxy_test.go b/dashboard/ocimysql_proxy_test.go new file mode 100644 index 0000000000..3b9b6ce1f8 --- /dev/null +++ b/dashboard/ocimysql_proxy_test.go @@ -0,0 +1,149 @@ +package dashboard_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocimysql" +) + +const ( + mysqlComp = "ocid1.compartment.oc1..aaaa" + mysqlBase = "/dashboard/api/v1/oci/mysql" + mysqlPass = "Sup3rSecret!pw" +) + +func mysqlSeed(t *testing.T, e *echo.Echo, method, path, body string) []byte { + t.Helper() + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body))) + require.Less(t, rec.Code, http.StatusBadRequest, "%s: %s", path, rec.Body.String()) + + return rec.Body.Bytes() +} + +func newMySQLDashboard(t *testing.T) (*dashboard.DashboardHandler, string) { + t.Helper() + + b := ocimysql.NewInMemoryBackend() + b.SetTransition(0) + + ops := ocimysql.NewHandler(b) + e := echo.New() + e.Any("/*", ops.Trusted()) + + mysqlSeed(t, e, http.MethodPost, "/20190415/dbSystems", + `{"compartmentId":"`+mysqlComp+`","displayName":"orders","shapeName":"MySQL.2",`+ + `"subnetId":"ocid1.subnet.oc1.iad.suba","adminUsername":"dbadmin","adminPassword":"`+mysqlPass+`"}`) + + var systems []struct { + ID string `json:"id"` + } + + require.NoError(t, json.Unmarshal(mysqlSeed(t, e, http.MethodGet, + "/20190415/dbSystems?compartmentId="+mysqlComp, ""), &systems)) + require.Len(t, systems, 1) + + return dashboard.NewHandler(dashboard.Config{OCIMySQLOps: ops}), systems[0].ID +} + +func TestOCIMySQLProxy(t *testing.T) { + t.Parallel() + + api := mysqlBase + "/api" + + tests := []struct { + steps func(id string) []gcpStep + name string + }{ + {name: "read", steps: func(id string) []gcpStep { + return []gcpStep{ + {http.MethodGet, mysqlBase + "/compartments", "", mysqlComp, 200}, + {http.MethodGet, api + "/dbSystems?compartmentId=" + mysqlComp, "", `"displayName":"orders"`, 200}, + {http.MethodGet, api + "/dbSystems/" + id, "", `"dataStorage"`, 200}, + {http.MethodGet, api + "/configurations?compartmentId=" + mysqlComp, "", `"type":"DEFAULT"`, 200}, + {http.MethodGet, api + "/backups?compartmentId=" + mysqlComp, "", "[", 200}, + {http.MethodGet, api + "/dbSystems/ocid1.mysqldbsystem.oc1..nope", "", "NotAuthorizedOrNotFound", 404}, + } + }}, + {name: "lifecycle", steps: func(id string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/dbSystems/" + id + "/actions/stop", "", "", http.StatusAccepted}, + {http.MethodGet, api + "/dbSystems/" + id, "", `"lifecycleState":"INACTIVE"`, 200}, + {http.MethodPost, api + "/dbSystems/" + id + "/actions/start", "", "", http.StatusAccepted}, + {http.MethodPost, api + "/dbSystems/" + id + "/actions/restart", "", "", http.StatusAccepted}, + {http.MethodDelete, api + "/dbSystems/" + id, "", "", http.StatusAccepted}, + } + }}, + {name: "backup", steps: func(id string) []gcpStep { + return []gcpStep{ + { + http.MethodPost, + api + "/backups", + `{"dbSystemId":"` + id + `","displayName":"manual","retentionInDays":9999}`, + `"displayName":"manual"`, + 200, + }, + { + http.MethodGet, + api + "/backups?compartmentId=" + mysqlComp + "&dbSystemId=" + id, + "", + `"manual"`, + 200, + }, + {http.MethodPost, api + "/backups", `{"displayName":"orphan"}`, "", 400}, + } + }}, + {name: "rejects", steps: func(id string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/dbSystems", `{}`, "unsupported", 400}, + {http.MethodPost, api + "/dbSystems/" + id, `{}`, "unsupported", 400}, + {http.MethodPost, api + "/dbSystems/" + id + "/actions/changeCompartment", `{}`, "unsupported", 400}, + {http.MethodDelete, api + "/backups/b1", "", "unsupported", 400}, + {http.MethodGet, api + "/channels", "", "unsupported", 400}, + {http.MethodGet, api + "/dbSystems/%2e%2e", "", "unsupported", 400}, + {http.MethodGet, api + "/", "", "unsupported", 400}, + } + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, id := newMySQLDashboard(t) + runGCPSteps(t, h, tt.steps(id)) + }) + } + + t.Run("passwords stay server side", func(t *testing.T) { + t.Parallel() + + h, id := newMySQLDashboard(t) + + for _, p := range []string{api + "/dbSystems?compartmentId=" + mysqlComp, api + "/dbSystems/" + id} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, p, nil) + require.NoError(t, h.Handler()(echo.NewContext(req, rec))) + assert.NotContains(t, strings.ToLower(rec.Body.String()), "password") + assert.NotContains(t, rec.Body.String(), mysqlPass) + } + }) + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, api + "/dbSystems", "", "", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/ocinosql_proxy.go b/dashboard/ocinosql_proxy.go new file mode 100644 index 0000000000..43197493d6 --- /dev/null +++ b/dashboard/ocinosql_proxy.go @@ -0,0 +1,188 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociNoSQLPrefix = ociProxyPrefix + "/nosql" + ociNoSQLAPIPrefix = "/20190828" + ociNoSQLTables = "tables" + ociNoSQLQuery = "query" + ociNoSQLItemSegs = 2 + ociNoSQLSubSegs = 3 + ociNoSQLMaxUnits = 1 << 20 + ociKeyCompartment = "compartmentId" + ociKeyName = "name" +) + +// registerOCINoSQLRoutes serves the NoSQL page by dispatching in-process to the emulator's handler without request +// signing. Reachable: table reads/create/drop, index and usage reads, row put/get/delete and SELECT queries. Every +// write forwards a body rebuilt from a fixed set of fields. +func (h *DashboardHandler) registerOCINoSQLRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCINoSQLOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI NoSQL", next) } + + h.SubRouter.Add(http.MethodGet, ociNoSQLPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociNoSQLPrefix+"/api") + + parts, ok := ociSplitPath(raw) + if !ok || !ociNoSQLAllowed(r.Method, parts) { + return ociInvalid(c, "unsupported NoSQL proxy request") + } + + body, ok := ociNoSQLBody(c, r.Method, parts) + if !ok { + return ociInvalid(c, "invalid NoSQL proxy request body") + } + + return ociDispatch(c, inner, ociNoSQLAPIPrefix+raw, body) + }) + + for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete} { + h.SubRouter.Add(m, ociNoSQLPrefix+"/api/*", serve) + } +} + +func ociNoSQLAllowed(method string, parts []string) bool { + if parts[0] == ociNoSQLQuery { + return method == http.MethodPost && len(parts) == 1 + } + + if parts[0] != ociNoSQLTables { + return false + } + + switch len(parts) { + case 1: + return method == http.MethodGet || method == http.MethodPost + case ociNoSQLItemSegs: + return method == http.MethodGet || method == http.MethodDelete + case ociNoSQLSubSegs: + switch parts[2] { + case "indexes", "usage": + return method == http.MethodGet + case "rows": + return method == http.MethodGet || method == http.MethodPut || method == http.MethodDelete + } + } + + return false +} + +// ociNoSQLBody builds the forwarded body of a write; nil keeps the (empty) original. +func ociNoSQLBody(c *echo.Context, method string, parts []string) ([]byte, bool) { + switch { + case parts[0] == ociNoSQLQuery: + return ociNoSQLQueryBody(c) + case method == http.MethodPost: + return ociNoSQLCreateBody(c) + case method == http.MethodPut: + return ociNoSQLRowBody(c) + } + + return nil, true +} + +func ociNoSQLCreateBody(c *echo.Context) ([]byte, bool) { + var in struct { + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + DDL string `json:"ddlStatement"` + Limits struct { + Mode string `json:"capacityMode"` + Read int `json:"maxReadUnits"` + Write int `json:"maxWriteUnits"` + GB int `json:"maxStorageInGBs"` + } `json:"tableLimits"` + } + + if !ociDecodeBody(c, &in) || in.CompartmentID == "" || in.Name == "" || in.DDL == "" { + return nil, false + } + + l := in.Limits + if (l.Mode != "PROVISIONED" && l.Mode != "ON_DEMAND") || + l.Read < 0 || l.Write < 0 || l.GB < 0 || l.Read > ociNoSQLMaxUnits || l.Write > ociNoSQLMaxUnits || + l.GB > ociNoSQLMaxUnits { + return nil, false + } + + limits := map[string]any{"capacityMode": l.Mode, "maxStorageInGBs": l.GB} + if l.Mode == "PROVISIONED" { + limits["maxReadUnits"], limits["maxWriteUnits"] = l.Read, l.Write + } + + b, err := json.Marshal(map[string]any{ + ociKeyCompartment: in.CompartmentID, ociKeyName: in.Name, "ddlStatement": in.DDL, "tableLimits": limits, + }) + + return b, err == nil +} + +func ociNoSQLRowBody(c *echo.Context) ([]byte, bool) { + var in struct { + CompartmentID string `json:"compartmentId"` + Option string `json:"option"` + Value json.RawMessage `json:"value"` + } + + if !ociDecodeBody(c, &in) || in.CompartmentID == "" || !bytes.HasPrefix(bytes.TrimSpace(in.Value), []byte("{")) { + return nil, false + } + + if in.Option != "" && in.Option != "IF_ABSENT" && in.Option != "IF_PRESENT" { + return nil, false + } + + out := map[string]any{ociKeyCompartment: in.CompartmentID, "value": in.Value} + if in.Option != "" { + out["option"] = in.Option + } + + b, err := json.Marshal(out) + + return b, err == nil +} + +func ociNoSQLQueryBody(c *echo.Context) ([]byte, bool) { + var in struct { + CompartmentID string `json:"compartmentId"` + Statement string `json:"statement"` + Consistency string `json:"consistency"` + } + + if !ociDecodeBody(c, &in) || in.CompartmentID == "" || + !strings.HasPrefix(strings.ToLower(strings.TrimSpace(in.Statement)), "select") { + return nil, false + } + + out := map[string]any{ociKeyCompartment: in.CompartmentID, "statement": in.Statement} + if in.Consistency == "EVENTUAL" || in.Consistency == "ABSOLUTE" { + out["consistency"] = in.Consistency + } + + b, err := json.Marshal(out) + + return b, err == nil +} diff --git a/dashboard/ocinosql_proxy_test.go b/dashboard/ocinosql_proxy_test.go new file mode 100644 index 0000000000..cf2e58ae9d --- /dev/null +++ b/dashboard/ocinosql_proxy_test.go @@ -0,0 +1,157 @@ +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocinosql" +) + +const ( + nosqlComp = "ocid1.compartment.oc1..nosql" + nosqlBase = "/dashboard/api/v1/oci/nosql" + nosqlAPI = nosqlBase + "/api" +) + +func newNoSQLDashboard(t *testing.T) *dashboard.DashboardHandler { + t.Helper() + + b := ocinosql.NewInMemoryBackend() + b.SetTransition(0) + + ops := ocinosql.NewHandler(b) + e := echo.New() + e.Any("/*", ops.Trusted()) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/20190828/tables", strings.NewReader( + `{"compartmentId":"`+nosqlComp+`","name":"users","ddlStatement":"CREATE TABLE users (id INTEGER, name STRING, `+ + `PRIMARY KEY(id))","tableLimits":{"maxReadUnits":10,"maxWriteUnits":10,"maxStorageInGBs":1}}`))) + require.Less(t, rec.Code, http.StatusBadRequest, rec.Body.String()) + + return dashboard.NewHandler(dashboard.Config{OCINoSQLOps: ops}) +} + +func TestOCINoSQLProxy(t *testing.T) { + t.Parallel() + + tbl := nosqlAPI + "/tables/users" + cq := "?compartmentId=" + nosqlComp + + tests := []struct { + name string + steps []gcpStep + }{ + {name: "read", steps: []gcpStep{ + {http.MethodGet, nosqlBase + "/compartments", "", nosqlComp, 200}, + {http.MethodGet, nosqlAPI + "/tables" + cq, "", `"name":"users"`, 200}, + {http.MethodGet, tbl + cq, "", `"ddlStatement"`, 200}, + {http.MethodGet, tbl + "/indexes" + cq, "", `"items"`, 200}, + {http.MethodGet, tbl + "/usage" + cq, "", `"items"`, 200}, + }}, + {name: "rows and query", steps: []gcpStep{ + { + http.MethodPut, + tbl + "/rows", + `{"compartmentId":"` + nosqlComp + `","value":{"id":1,"name":"ada"}}`, + `"version"`, + 200, + }, + {http.MethodGet, tbl + "/rows" + cq + "&key=id:1", "", `"ada"`, 200}, + { + http.MethodPost, + nosqlAPI + "/query", + `{"compartmentId":"` + nosqlComp + `","statement":"SELECT * FROM users"}`, + `"ada"`, + 200, + }, + {http.MethodDelete, tbl + "/rows" + cq + "&key=id:1", "", `"isSuccess":true`, 200}, + }}, + {name: "create and drop", steps: []gcpStep{ + { + http.MethodPost, + nosqlAPI + "/tables", + `{"compartmentId":"` + nosqlComp + `","name":"logs","ddlStatement":` + + `"CREATE TABLE logs (id INTEGER, PRIMARY KEY(id))",` + + `"tableLimits":{"capacityMode":"ON_DEMAND","maxStorageInGBs":5}}`, + "", + http.StatusAccepted, + }, + {http.MethodGet, nosqlAPI + "/tables/logs" + cq, "", `"capacityMode":"ON_DEMAND"`, 200}, + {http.MethodDelete, nosqlAPI + "/tables/logs" + cq, "", "", http.StatusAccepted}, + }}, + {name: "body rebuild", steps: []gcpStep{ + { + http.MethodPost, + nosqlAPI + "/tables", + `{"compartmentId":"` + nosqlComp + `","name":"evil",` + + `"ddlStatement":"CREATE TABLE evil (id INTEGER, PRIMARY KEY(id))",` + + `"tableLimits":{"capacityMode":"ON_DEMAND","maxStorageInGBs":1},` + + `"freeformTags":{"x":"y"},"isAutoReclaimable":true}`, + "", + http.StatusAccepted, + }, + {http.MethodGet, nosqlAPI + "/tables/evil" + cq, "", `"isAutoReclaimable":false`, 200}, + }}, + {name: "rejects", steps: []gcpStep{ + {http.MethodPut, tbl + cq, `{}`, "unsupported", 400}, + {http.MethodPost, tbl + "/actions/changeCompartment", `{}`, "unsupported", 400}, + {http.MethodPost, tbl + "/indexes", `{}`, "unsupported", 400}, + {http.MethodGet, nosqlAPI + "/workRequests", "", "unsupported", 400}, + {http.MethodGet, nosqlAPI + "/query/prepare", "", "unsupported", 400}, + {http.MethodGet, nosqlAPI + "/tables/%2e%2e", "", "unsupported", 400}, + { + http.MethodPost, + nosqlAPI + "/query", + `{"compartmentId":"c","statement":"DROP TABLE users"}`, + "invalid", + 400, + }, + { + http.MethodPost, + nosqlAPI + "/query", + `{"compartmentId":"c","statement":" insert into users values(1)"}`, + "invalid", + 400, + }, + { + http.MethodPost, + nosqlAPI + "/tables", + `{"compartmentId":"c","name":"t","ddlStatement":"x","tableLimits":{"capacityMode":"WEIRD"}}`, + "invalid", + 400, + }, + { + http.MethodPost, + nosqlAPI + "/tables", + `{"compartmentId":"c","name":"t","ddlStatement":"x",` + + `"tableLimits":{"capacityMode":"PROVISIONED","maxReadUnits":-1}}`, + "invalid", + 400, + }, + {http.MethodPut, tbl + "/rows", `{"compartmentId":"c","value":[1]}`, "invalid", 400}, + {http.MethodPut, tbl + "/rows", `{"compartmentId":"c","value":{},"option":"DROP"}`, "invalid", 400}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runGCPSteps(t, newNoSQLDashboard(t), tt.steps) + }) + } + + t.Run("unavailable without the service", func(t *testing.T) { + t.Parallel() + + runGCPSteps(t, dashboard.NewHandler(dashboard.Config{}), []gcpStep{ + {http.MethodGet, nosqlAPI + "/tables", "", "", http.StatusServiceUnavailable}, + }) + }) +} diff --git a/dashboard/ocinotifications_proxy.go b/dashboard/ocinotifications_proxy.go new file mode 100644 index 0000000000..83f269e17e --- /dev/null +++ b/dashboard/ocinotifications_proxy.go @@ -0,0 +1,109 @@ +package dashboard + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + + "github.com/labstack/echo/v5" +) + +const ( + ociNotifPrefix = ociProxyPrefix + "/notifications" + ociNotifAPIPrefix = "/20181201" + ociNotifDeliveries = "/_gopherstack/oci/deliveries" + ociNotifMaxBody = 1 << 12 + ociNotifItemSegs = 2 + ociNotifSubSegs = 3 + ociNotifTopics = "topics" + ociNotifSubs = "subscriptions" + ociNotifDelivered = "deliveries" +) + +// registerOCINotificationsRoutes serves the Notifications page by dispatching in-process to the emulator's handler +// without request signing. Only topic and subscription reads, recorded deliveries, a subscription confirmation and +// resending a confirmation are reachable; publishing and deleting are not. +func (h *DashboardHandler) registerOCINotificationsRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + if ops := h.config.OCINotificationsOps; ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI Notifications", next) } + + h.SubRouter.Add(http.MethodGet, ociNotifPrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociNotifPrefix+"/api") + + if !ociNotifAllowed(r.Method, raw) { + return cloudJSONFail( + c, + http.StatusBadRequest, + "InvalidParameter", + "unsupported Notifications proxy request", + ) + } + + target := ociNotifAPIPrefix + raw + if raw == "/"+ociNotifDelivered { + target = ociNotifDeliveries + } + + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(target), target + fwd.RequestURI = "" + fwd.Body = http.MaxBytesReader(c.Response(), r.Body, ociNotifMaxBody) + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + return ociRelay(c, rec, "opc-next-page", "etag", "opc-request-id") + }) + + h.SubRouter.Add(http.MethodGet, ociNotifPrefix+"/api/*", serve) + h.SubRouter.Add(http.MethodPost, ociNotifPrefix+"/api/*", serve) +} + +func ociNotifAllowed(method, raw string) bool { + parts := strings.Split(strings.TrimPrefix(raw, "/"), "/") + + for _, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || dec == "" || dec == "." || dec == ".." || strings.Contains(dec, "/") { + return false + } + } + + switch method { + case http.MethodGet: + return ociNotifReadAllowed(parts) + case http.MethodPost: + return len(parts) == ociNotifSubSegs && parts[0] == ociNotifSubs && parts[2] == "resendConfirmation" + } + + return false +} + +func ociNotifReadAllowed(parts []string) bool { + switch len(parts) { + case 1: + return parts[0] == ociNotifTopics || parts[0] == ociNotifSubs || parts[0] == ociNotifDelivered + case ociNotifItemSegs: + return parts[0] == ociNotifTopics || parts[0] == ociNotifSubs + case ociNotifSubSegs: + return parts[0] == ociNotifSubs && parts[2] == "confirmation" + } + + return false +} diff --git a/dashboard/ocinotifications_proxy_test.go b/dashboard/ocinotifications_proxy_test.go new file mode 100644 index 0000000000..d28ec93170 --- /dev/null +++ b/dashboard/ocinotifications_proxy_test.go @@ -0,0 +1,135 @@ +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ocinotifications" +) + +const ( + ociNotifComp = "ocid1.compartment.oc1..aaaa" + ociNotifBase = "/dashboard/api/v1/oci/notifications" +) + +func newOCINotificationsDashboard(t *testing.T) (*dashboard.DashboardHandler, string, string) { + t.Helper() + + b := ocinotifications.NewInMemoryBackend() + b.SetTransition(0) + + topic, err := b.CreateTopic( + ocinotifications.CreateTopicIn{Name: "alerts", Compartment: ociNotifComp}, + "http://localhost:8157", + ) + require.NoError(t, err) + + sub, err := b.CreateSubscription(ocinotifications.CreateSubscriptionIn{ + TopicID: topic.TopicID, Compartment: ociNotifComp, Protocol: "EMAIL", Endpoint: "ops@example.com", + }, "http://localhost:8157") + require.NoError(t, err) + + return dashboard.NewHandler( + dashboard.Config{OCINotificationsOps: ocinotifications.NewHandler(b)}, + ), topic.TopicID, sub.ID +} + +func TestOCINotificationsProxy(t *testing.T) { + t.Parallel() + + api := ociNotifBase + "/api" + q := "?compartmentId=" + ociNotifComp + + tests := []struct { + steps func(topic, sub string) []gcpStep + name string + }{ + {name: "read", steps: func(topic, sub string) []gcpStep { + return []gcpStep{ + {http.MethodGet, ociNotifBase + "/compartments", "", ociNotifComp, 200}, + {http.MethodGet, api + "/topics" + q, "", `"name":"alerts"`, 200}, + {http.MethodGet, api + "/topics/" + topic, "", `"name":"alerts"`, 200}, + {http.MethodGet, api + "/subscriptions" + q, "", `"endpoint":"ops@example.com"`, 200}, + {http.MethodGet, api + "/subscriptions/" + sub, "", `"lifecycleState":"PENDING"`, 200}, + } + }}, + {name: "deliveries", steps: func(topic, sub string) []gcpStep { + return []gcpStep{ + {http.MethodGet, api + "/deliveries", "", `"kind":"CONFIRMATION"`, 200}, + { + http.MethodGet, + api + "/deliveries", + "", + `"confirmationUrl":"http://localhost:8157/20181201/subscriptions/` + sub, + 200, + }, + {http.MethodGet, api + "/deliveries?topicId=" + topic, "", `"topicName":"alerts"`, 200}, + {http.MethodGet, api + "/deliveries?topicId=ocid1.onstopic.oc1..other", "", "null", 200}, + } + }}, + {name: "resend", steps: func(_, sub string) []gcpStep { + return []gcpStep{ + {http.MethodPost, api + "/subscriptions/" + sub + "/resendConfirmation", "", `"id":"` + sub, 200}, + {http.MethodGet, api + "/deliveries", "", `"kind":"CONFIRMATION"`, 200}, + } + }}, + {name: "rejects", steps: func(topic, sub string) []gcpStep { + return []gcpStep{ + {http.MethodDelete, api + "/topics/" + topic, "", "", http.StatusMethodNotAllowed}, + {http.MethodPost, api + "/topics/" + topic + "/messages", `{"body":"x"}`, "unsupported", 400}, + {http.MethodPost, api + "/topics", `{}`, "unsupported", 400}, + {http.MethodPost, api + "/subscriptions", `{}`, "unsupported", 400}, + {http.MethodGet, api + "/subscriptions/" + sub + "/unsubscription", "", "unsupported", 400}, + {http.MethodGet, api + "/admin", "", "unsupported", 400}, + {http.MethodGet, api + "/topics/%2e%2e", "", "unsupported", 400}, + } + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, topic, sub := newOCINotificationsDashboard(t) + runGCPSteps(t, h, tt.steps(topic, sub)) + }) + } + + t.Run("confirm link", func(t *testing.T) { + t.Parallel() + + h, _, sub := newOCINotificationsDashboard(t) + rec := httptest.NewRecorder() + require.NoError( + t, + h.Handler()(echo.NewContext(httptest.NewRequest(http.MethodGet, api+"/deliveries", nil), rec)), + ) + + _, rest, ok := strings.Cut(rec.Body.String(), `"confirmationUrl":"`) + require.True(t, ok) + + link, _, _ := strings.Cut(rest, `"`) + _, query, _ := strings.Cut(strings.ReplaceAll(link, `\u0026`, "&"), "?") + + runGCPSteps(t, h, []gcpStep{ + {http.MethodGet, api + "/subscriptions/" + sub + "/confirmation?" + query, "", sub, 200}, + {http.MethodGet, api + "/subscriptions/" + sub, "", `"lifecycleState":"ACTIVE"`, 200}, + }) + }) + + t.Run("unavailable", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, api+"/topics"+q, nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/ociobjectstorage_proxy.go b/dashboard/ociobjectstorage_proxy.go index 283f7564e3..3b8f55c409 100644 --- a/dashboard/ociobjectstorage_proxy.go +++ b/dashboard/ociobjectstorage_proxy.go @@ -66,7 +66,7 @@ func (h *DashboardHandler) registerOCIObjectStorageRoutes() { func (a *ociObjectStorageAPI) ns() string { return a.b.Namespace() } func (a *ociObjectStorageAPI) namespace(c *echo.Context) error { - return c.JSON(http.StatusOK, map[string]string{"namespace": a.ns()}) + return c.JSON(http.StatusOK, map[string]string{ociNamespaceKey: a.ns()}) } func (a *ociObjectStorageAPI) buckets(c *echo.Context) error { diff --git a/dashboard/ociqueue_proxy.go b/dashboard/ociqueue_proxy.go new file mode 100644 index 0000000000..95ec402332 --- /dev/null +++ b/dashboard/ociqueue_proxy.go @@ -0,0 +1,137 @@ +package dashboard + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/services/ociqueue" +) + +const ( + ociQueuePrefix = ociProxyPrefix + "/queue" + ociQueueAPIPrefix = "/20210201" + ociQueueMaxBody = 1 << 12 + ociQueueIDSegs = 2 + ociQueueSubSegs = 3 + ociQueueActSegs = 4 + ociQueuePeekMax = 20 + ociQueueKind = "queues" + ociQueueStats = "stats" + ociQueuePeek = "peek" + ociQueuePurge = "purge" +) + +// registerOCIQueueRoutes serves the Queue page by dispatching in-process to the emulator's handler without request +// signing. Only queue reads, stats and purge are reachable; messages are read through a side-effect-free peek and +// never carry receipts. +func (h *DashboardHandler) registerOCIQueueRoutes() { + var ( + inner *echo.Echo + comps func() []string + ) + + ops := h.config.OCIQueueOps + if ops != nil { + inner = echo.New() + inner.Any("/*", ops.Trusted()) + comps = ops.Backend.Compartments + } + + guard := func(next echo.HandlerFunc) echo.HandlerFunc { return ociGuard(inner != nil, "OCI Queue", next) } + + h.SubRouter.Add(http.MethodGet, ociQueuePrefix+"/compartments", guard(func(c *echo.Context) error { + return c.JSON(http.StatusOK, map[string]any{ociCompartmentsKey: comps()}) + })) + + serve := guard(func(c *echo.Context) error { + r := c.Request() + raw := strings.TrimPrefix(r.URL.EscapedPath(), ociQueuePrefix+"/api") + + parts, ok := ociQueueParts(r.Method, raw) + if !ok { + return cloudJSONFail(c, http.StatusBadRequest, "InvalidParameter", "unsupported Queue proxy request") + } + + if len(parts) == ociQueueSubSegs && parts[2] == ociQueuePeek { + return ociQueueServePeek(c, ops.Backend, unescapedPath(parts[1])) + } + + fwd := r.Clone(r.Context()) + fwd.URL.Path, fwd.URL.RawPath = unescapedPath(ociQueueAPIPrefix+raw), ociQueueAPIPrefix+raw + fwd.RequestURI = "" + fwd.Body = http.MaxBytesReader(c.Response(), r.Body, ociQueueMaxBody) + + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, fwd) + + return ociRelay(c, rec, "opc-next-page", "etag", "opc-request-id", "opc-work-request-id") + }) + + h.SubRouter.Add(http.MethodGet, ociQueuePrefix+"/api/*", serve) + h.SubRouter.Add(http.MethodPost, ociQueuePrefix+"/api/*", serve) +} + +// ociQueueParts validates a proxied path (below /api) against the allowed queue operations and returns its segments. +func ociQueueParts(method, raw string) ([]string, bool) { + parts := strings.Split(strings.TrimPrefix(raw, "/"), "/") + + for _, p := range parts { + dec, err := url.PathUnescape(p) + if err != nil || dec == "" || dec == "." || dec == ".." || strings.Contains(dec, "/") { + return nil, false + } + } + + if parts[0] != ociQueueKind { + return nil, false + } + + switch method { + case http.MethodGet: + switch len(parts) { + case 1, ociQueueIDSegs: + return parts, true + case ociQueueSubSegs: + return parts, parts[2] == ociQueueStats || parts[2] == ociQueuePeek + } + case http.MethodPost: + return parts, len(parts) == ociQueueActSegs && parts[2] == ociActionsSeg && parts[3] == ociQueuePurge + } + + return nil, false +} + +func ociQueueServePeek(c *echo.Context, b *ociqueue.InMemoryBackend, id string) error { + limit, err := strconv.Atoi(c.QueryParam("limit")) + if err != nil || limit < 1 || limit > ociQueuePeekMax { + limit = ociQueuePeekMax + } + + msgs, err := b.Peek(id, c.QueryParam("dlq") == queryTrue, limit) + if err != nil { + if ae, ok := errors.AsType[*ociqueue.APIError](err); ok { + return cloudJSONFail(c, ae.Status, ae.Code, ae.Message) + } + + return cloudJSONFail(c, http.StatusInternalServerError, "InternalServerError", "peek failed") + } + + return c.JSON(http.StatusOK, map[string]any{"messages": msgs}) +} + +// ociRelay copies the emulator's response and the listed headers. +func ociRelay(c *echo.Context, rec *httptest.ResponseRecorder, headers ...string) error { + for _, k := range headers { + if v := rec.Header().Get(k); v != "" { + c.Response().Header().Set(k, v) + } + } + + return c.JSONBlob(rec.Code, rec.Body.Bytes()) +} diff --git a/dashboard/ociqueue_proxy_test.go b/dashboard/ociqueue_proxy_test.go new file mode 100644 index 0000000000..8e68ca09d0 --- /dev/null +++ b/dashboard/ociqueue_proxy_test.go @@ -0,0 +1,136 @@ +package dashboard_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/dashboard" + "github.com/blackbirdworks/gopherstack/services/ociqueue" +) + +const ( + ociQueueComp = "ocid1.compartment.oc1..aaaa" + ociQueueBase = "/dashboard/api/v1/oci/queue" +) + +func newOCIQueueDashboard(t *testing.T) (*dashboard.DashboardHandler, string) { + t.Helper() + + b := ociqueue.NewInMemoryBackend() + b.SetTransition(0) + + two := 2 + _, err := b.CreateQueue(ociqueue.CreateQueueIn{Name: "jobs", Compartment: ociQueueComp, DLQCount: &two}) + require.NoError(t, err) + + pg, err := b.ListQueues(ociQueueComp, "", "", "", "", ociqueue.ListOpts{}) + require.NoError(t, err) + require.Len(t, pg.Data, 1) + + id := pg.Data[0].ID + ops := ociqueue.NewHandler(b) + e := echo.New() + e.Any("/*", ops.Handler()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/20210201/queues/"+id+"/messages", + strings.NewReader(`{"messages":[{"content":"first"},{"content":"second"}]}`)) + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + return dashboard.NewHandler(dashboard.Config{OCIQueueOps: ops}), id +} + +func TestOCIQueueProxy(t *testing.T) { + t.Parallel() + + api := ociQueueBase + "/api" + + tests := []struct { + steps func(id string) []gcpStep + name string + }{ + {name: "read", steps: func(id string) []gcpStep { + return []gcpStep{ + {http.MethodGet, ociQueueBase + "/compartments", "", ociQueueComp, 200}, + {http.MethodGet, api + "/queues?compartmentId=" + ociQueueComp, "", `"displayName":"jobs"`, 200}, + {http.MethodGet, api + "/queues/" + id, "", `"deadLetterQueueDeliveryCount":2`, 200}, + {http.MethodGet, api + "/queues/" + id + "/stats", "", `"visibleMessages":2`, 200}, + } + }}, + {name: "peek", steps: func(id string) []gcpStep { + return []gcpStep{ + {http.MethodGet, api + "/queues/" + id + "/peek", "", `"content":"first"`, 200}, + {http.MethodGet, api + "/queues/" + id + "/peek?limit=1", "", `"content":"first"`, 200}, + {http.MethodGet, api + "/queues/" + id + "/peek?dlq=true", "", `"messages":[]`, 200}, + {http.MethodGet, api + "/queues/" + id + "/stats", "", `"inFlightMessages":0`, 200}, + { + http.MethodGet, + api + "/queues/ocid1.queue.oc1..nope/peek", + "", + "NotAuthorizedOrNotFound", + http.StatusNotFound, + }, + } + }}, + {name: "purge", steps: func(id string) []gcpStep { + return []gcpStep{ + { + http.MethodPost, + api + "/queues/" + id + "/actions/purge", + `{"purgeType":"NORMAL"}`, + "", + http.StatusAccepted, + }, + {http.MethodGet, api + "/queues/" + id + "/stats", "", `"visibleMessages":0`, 200}, + {http.MethodPost, api + "/queues/" + id + "/actions/purge", `{"purgeType":"x"}`, "purgeType", 400}, + } + }}, + {name: "rejects", steps: func(id string) []gcpStep { + return []gcpStep{ + {http.MethodDelete, api + "/queues/" + id, "", "", http.StatusMethodNotAllowed}, + {http.MethodPost, api + "/queues", `{}`, "unsupported", 400}, + {http.MethodGet, api + "/queues/" + id + "/messages", "", "unsupported", 400}, + {http.MethodPost, api + "/queues/" + id + "/messages", `{}`, "unsupported", 400}, + {http.MethodPost, api + "/queues/" + id + "/actions/updateMessages", `{}`, "unsupported", 400}, + {http.MethodGet, api + "/workRequests", "", "unsupported", 400}, + {http.MethodGet, api + "/queues/%2e%2e", "", "unsupported", 400}, + } + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, id := newOCIQueueDashboard(t) + runGCPSteps(t, h, tt.steps(id)) + }) + } + + t.Run("peek keeps messages receivable", func(t *testing.T) { + t.Parallel() + + h, id := newOCIQueueDashboard(t) + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.NewContext( + httptest.NewRequest(http.MethodGet, api+"/queues/"+id+"/peek", nil), rec))) + assert.NotContains(t, rec.Body.String(), "receipt") + assert.Contains(t, rec.Body.String(), `"deliveryCount":0`) + }) + + t.Run("unavailable", func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, api+"/queues?compartmentId=x", nil) + require.NoError(t, dashboard.NewHandler(dashboard.Config{}).Handler()(echo.NewContext(req, rec))) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/dashboard/ocistreaming_proxy.go b/dashboard/ocistreaming_proxy.go index 9971012b94..66bd73b4d1 100644 --- a/dashboard/ocistreaming_proxy.go +++ b/dashboard/ocistreaming_proxy.go @@ -186,6 +186,6 @@ func (a *ociStreamingAPI) messages(c *echo.Context) error { } return c.JSON(http.StatusOK, map[string]any{ - "messages": items, "base": window.Base, "end": window.End, "start": start, + "messages": items, "base": window.Base, "end": window.End, aciPxStart: start, }) } diff --git a/dashboard/provider.go b/dashboard/provider.go index b3026a7582..1769675206 100644 --- a/dashboard/provider.go +++ b/dashboard/provider.go @@ -41,22 +41,28 @@ import ( "github.com/blackbirdworks/gopherstack/services/efs" "github.com/blackbirdworks/gopherstack/services/eks" "github.com/blackbirdworks/gopherstack/services/eventbridge" + "github.com/blackbirdworks/gopherstack/services/gcpapigateway" "github.com/blackbirdworks/gopherstack/services/gcpartifactregistry" + "github.com/blackbirdworks/gopherstack/services/gcpbatch" "github.com/blackbirdworks/gopherstack/services/gcpbigquery" "github.com/blackbirdworks/gopherstack/services/gcpbigtable" "github.com/blackbirdworks/gopherstack/services/gcpcloudbuild" "github.com/blackbirdworks/gopherstack/services/gcpcloudtasks" "github.com/blackbirdworks/gopherstack/services/gcpcompute" + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" + "github.com/blackbirdworks/gopherstack/services/gcpdatastore" "github.com/blackbirdworks/gopherstack/services/gcpdns" "github.com/blackbirdworks/gopherstack/services/gcpeventarc" "github.com/blackbirdworks/gopherstack/services/gcpfirestore" "github.com/blackbirdworks/gopherstack/services/gcpfunctions" "github.com/blackbirdworks/gopherstack/services/gcpiam" + "github.com/blackbirdworks/gopherstack/services/gcpidentitytoolkit" "github.com/blackbirdworks/gopherstack/services/gcpkms" "github.com/blackbirdworks/gopherstack/services/gcplogging" "github.com/blackbirdworks/gopherstack/services/gcpmonitoring" "github.com/blackbirdworks/gopherstack/services/gcppubsub" "github.com/blackbirdworks/gopherstack/services/gcpredis" + "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" "github.com/blackbirdworks/gopherstack/services/gcprun" "github.com/blackbirdworks/gopherstack/services/gcpscheduler" "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" @@ -69,9 +75,22 @@ import ( "github.com/blackbirdworks/gopherstack/services/kinesis" "github.com/blackbirdworks/gopherstack/services/kms" "github.com/blackbirdworks/gopherstack/services/lambda" + "github.com/blackbirdworks/gopherstack/services/ociapigateway" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" + "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" + "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" + "github.com/blackbirdworks/gopherstack/services/ocicore" + "github.com/blackbirdworks/gopherstack/services/ocievents" "github.com/blackbirdworks/gopherstack/services/ocifunctions" "github.com/blackbirdworks/gopherstack/services/ociidentity" + "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" + "github.com/blackbirdworks/gopherstack/services/ocilogging" + "github.com/blackbirdworks/gopherstack/services/ocimonitoring" + "github.com/blackbirdworks/gopherstack/services/ocimysql" + "github.com/blackbirdworks/gopherstack/services/ocinosql" + "github.com/blackbirdworks/gopherstack/services/ocinotifications" "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" + "github.com/blackbirdworks/gopherstack/services/ociqueue" "github.com/blackbirdworks/gopherstack/services/ocistreaming" "github.com/blackbirdworks/gopherstack/services/ocivault" "github.com/blackbirdworks/gopherstack/services/route53" @@ -350,6 +369,10 @@ func linkConsoleExtras(conf *Config, cfg any) { conf.GCPDNSOps, _ = getHandler[*gcpdns.Handler](p.GetGCPDNSHandler()) } + if p, ok := cfg.(interface{ GetGCPDatastoreHandler() service.Registerable }); ok { + conf.GCPDatastoreOps, _ = getHandler[*gcpdatastore.Handler](p.GetGCPDatastoreHandler()) + } + if p, ok := cfg.(interface{ GetGCPComputeHandler() service.Registerable }); ok { conf.GCPComputeOps, _ = getHandler[*gcpcompute.Handler](p.GetGCPComputeHandler()) } @@ -374,6 +397,12 @@ func linkConsoleExtras(conf *Config, cfg any) { conf.OCIIdentityOps, _ = getHandler[*ociidentity.Handler](p.GetOCIIdentityHandler()) } + if p, ok := cfg.(interface{ GetOCICoreHandler() service.Registerable }); ok { + conf.OCICoreOps, _ = getHandler[*ocicore.Handler](p.GetOCICoreHandler()) + } + + linkContainerQueueExtras(conf, cfg) + if p, ok := cfg.(interface{ GetOCIFunctionsHandler() service.Registerable }); ok { conf.OCIFunctionsOps, _ = getHandler[*ocifunctions.Handler](p.GetOCIFunctionsHandler()) } @@ -382,3 +411,80 @@ func linkConsoleExtras(conf *Config, cfg any) { conf.AzureMonitorOps, _ = getHandler[*azuremonitor.Handler](p.GetAzureMonitorHandler()) } } + +// linkContainerQueueExtras links the Kubernetes Engine, Queue and Notifications handlers used by their proxies. +func linkContainerQueueExtras(conf *Config, cfg any) { + if p, ok := cfg.(interface{ GetGCPContainerHandler() service.Registerable }); ok { + conf.GCPContainerOps, _ = getHandler[*gcpcontainer.Handler](p.GetGCPContainerHandler()) + } + + if p, ok := cfg.(interface{ GetOCIQueueHandler() service.Registerable }); ok { + conf.OCIQueueOps, _ = getHandler[*ociqueue.Handler](p.GetOCIQueueHandler()) + } + + if p, ok := cfg.(interface{ GetGCPIdentityToolkitHandler() service.Registerable }); ok { + conf.GCPIdentityToolkitOps, _ = getHandler[*gcpidentitytoolkit.Handler](p.GetGCPIdentityToolkitHandler()) + } + + if p, ok := cfg.(interface{ GetGCPAPIGatewayHandler() service.Registerable }); ok { + conf.GCPAPIGatewayOps, _ = getHandler[*gcpapigateway.Handler](p.GetGCPAPIGatewayHandler()) + } + + if p, ok := cfg.(interface{ GetGCPResourceManagerHandler() service.Registerable }); ok { + conf.GCPResourceManagerOps, _ = getHandler[*gcpresourcemanager.Handler](p.GetGCPResourceManagerHandler()) + } + + if p, ok := cfg.(interface{ GetGCPBatchHandler() service.Registerable }); ok { + conf.GCPBatchOps, _ = getHandler[*gcpbatch.Handler](p.GetGCPBatchHandler()) + } + + if p, ok := cfg.(interface{ GetOCIContainerEngineHandler() service.Registerable }); ok { + conf.OCIContainerEngineOps, _ = getHandler[*ocicontainerengine.Handler](p.GetOCIContainerEngineHandler()) + } + + linkOCIObservabilityExtras(conf, cfg) + + if p, ok := cfg.(interface{ GetOCINotificationsHandler() service.Registerable }); ok { + conf.OCINotificationsOps, _ = getHandler[*ocinotifications.Handler](p.GetOCINotificationsHandler()) + } +} + +// linkOCIObservabilityExtras links the MySQL HeatWave, Logging and Monitoring handlers used by their proxies. +func linkOCIObservabilityExtras(conf *Config, cfg any) { + if p, ok := cfg.(interface{ GetOCIMySQLHandler() service.Registerable }); ok { + conf.OCIMySQLOps, _ = getHandler[*ocimysql.Handler](p.GetOCIMySQLHandler()) + } + + if p, ok := cfg.(interface{ GetOCILoggingHandler() service.Registerable }); ok { + conf.OCILoggingOps, _ = getHandler[*ocilogging.Handler](p.GetOCILoggingHandler()) + } + + if p, ok := cfg.(interface{ GetOCIMonitoringHandler() service.Registerable }); ok { + conf.OCIMonitoringOps, _ = getHandler[*ocimonitoring.Handler](p.GetOCIMonitoringHandler()) + } + + if p, ok := cfg.(interface{ GetOCINoSQLHandler() service.Registerable }); ok { + conf.OCINoSQLOps, _ = getHandler[*ocinosql.Handler](p.GetOCINoSQLHandler()) + } + + if p, ok := cfg.(interface{ GetOCIAPIGatewayHandler() service.Registerable }); ok { + conf.OCIAPIGatewayOps, _ = getHandler[*ociapigateway.Handler](p.GetOCIAPIGatewayHandler()) + } + + if p, ok := cfg.(interface{ GetOCILoadBalancerHandler() service.Registerable }); ok { + conf.OCILoadBalancerOps, _ = getHandler[*ociloadbalancer.Handler](p.GetOCILoadBalancerHandler()) + } + + if p, ok := cfg.(interface{ GetOCIArtifactsHandler() service.Registerable }); ok { + conf.OCIArtifactsOps, _ = getHandler[*ociartifacts.Handler](p.GetOCIArtifactsHandler()) + } + + if p, ok := cfg.(interface{ GetOCIContainerInstancesHandler() service.Registerable }); ok { + conf.OCIContainerInstancesOps, _ = getHandler[*ocicontainerinstances.Handler]( + p.GetOCIContainerInstancesHandler()) + } + + if p, ok := cfg.(interface{ GetOCIEventsHandler() service.Registerable }); ok { + conf.OCIEventsOps, _ = getHandler[*ocievents.Handler](p.GetOCIEventsHandler()) + } +} diff --git a/dashboard/spa_serve.go b/dashboard/spa_serve.go new file mode 100644 index 0000000000..b6718644f6 --- /dev/null +++ b/dashboard/spa_serve.go @@ -0,0 +1,94 @@ +package dashboard + +import ( + "compress/gzip" + "io" + "io/fs" + "mime" + "net/http" + "path" + "strconv" + "strings" + + "github.com/labstack/echo/v5" +) + +const spaGzipSuffix = ".gz" + +// spaStat reports whether rel exists in fsys as a file, plain or pre-gzipped. +func spaStat(fsys fs.FS, rel string) bool { + for _, name := range []string{rel, rel + spaGzipSuffix} { + if info, err := fs.Stat(fsys, name); err == nil && !info.IsDir() { + return true + } + } + + return false +} + +// serveSPAFile serves rel from fsys. Assets stored as rel+".gz" (see +// ui/scripts/compress-spa.mjs) are sent verbatim to gzip-capable clients and +// inflated for the rest. It reports whether anything was served. +func serveSPAFile(c *echo.Context, fsys fs.FS, rel string) bool { + if _, err := fs.Stat(fsys, rel); err == nil { + http.ServeFileFS(c.Response(), c.Request(), fsys, rel) + + return true + } + + f, err := fsys.Open(rel + spaGzipSuffix) + if err != nil { + return false + } + defer f.Close() + + req, h := c.Request(), c.Response().Header() + if ct := mime.TypeByExtension(path.Ext(rel)); ct != "" { + h.Set("Content-Type", ct) + } else { + h.Set("Content-Type", "application/octet-stream") + } + + h.Add("Vary", "Accept-Encoding") + + var src io.Reader = f + + if acceptsGzip(req.Header.Get("Accept-Encoding")) { + h.Set("Content-Encoding", "gzip") + + if info, statErr := f.Stat(); statErr == nil { + h.Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + } + } else { + zr, zerr := gzip.NewReader(f) + if zerr != nil { + http.Error(c.Response(), "corrupt embedded asset", http.StatusInternalServerError) + + return true + } + defer zr.Close() + + src = zr + } + + c.Response().WriteHeader(http.StatusOK) + + if req.Method != http.MethodHead { + _, _ = io.Copy(c.Response(), src) + } + + return true +} + +func acceptsGzip(header string) bool { + for part := range strings.SplitSeq(header, ",") { + name, params, _ := strings.Cut(strings.TrimSpace(part), ";") + if !strings.EqualFold(strings.TrimSpace(name), "gzip") { + continue + } + + return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(params), "q=")) != "0" + } + + return false +} diff --git a/dashboard/spa_serve_test.go b/dashboard/spa_serve_test.go new file mode 100644 index 0000000000..381fac9690 --- /dev/null +++ b/dashboard/spa_serve_test.go @@ -0,0 +1,124 @@ +package dashboard //nolint:testpackage // covers unexported SPA serving helpers + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gzipBytes(t *testing.T, s string) []byte { + t.Helper() + + var buf bytes.Buffer + + zw := gzip.NewWriter(&buf) + _, err := zw.Write([]byte(s)) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + return buf.Bytes() +} + +func TestServeSPAFile(t *testing.T) { + t.Parallel() + + const body = "console.log('hello');" + + fsys := fstest.MapFS{ + "app.js.gz": {Data: gzipBytes(t, body)}, + "plain.css": {Data: []byte("a{}")}, + } + + tests := []struct { + name string + rel string + acceptEnc string + method string + wantBody string + wantEncoding string + wantServed bool + }{ + { + name: "gzip client gets raw gz", rel: "app.js", acceptEnc: "gzip, br", + method: http.MethodGet, wantEncoding: "gzip", wantServed: true, + }, + { + name: "no encoding inflated", rel: "app.js", acceptEnc: "", + method: http.MethodGet, wantBody: body, wantServed: true, + }, + { + name: "gzip q0 inflated", rel: "app.js", acceptEnc: "gzip;q=0", + method: http.MethodGet, wantBody: body, wantServed: true, + }, + {name: "head has no body", rel: "app.js", method: http.MethodHead, wantServed: true}, + { + name: "plain file", rel: "plain.css", acceptEnc: "gzip", + method: http.MethodGet, wantBody: "a{}", wantServed: true, + }, + {name: "missing", rel: "nope.js", method: http.MethodGet, wantServed: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(tt.method, "/dashboard/"+tt.rel, nil) + if tt.acceptEnc != "" { + req.Header.Set("Accept-Encoding", tt.acceptEnc) + } + + rec := httptest.NewRecorder() + c := echo.NewContext(req, rec) + + assert.Equal(t, tt.wantServed, serveSPAFile(c, fsys, tt.rel)) + + if !tt.wantServed { + return + } + + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tt.wantEncoding, res.Header.Get("Content-Encoding")) + + got, err := io.ReadAll(res.Body) + require.NoError(t, err) + + if tt.wantEncoding == "gzip" { + zr, zerr := gzip.NewReader(bytes.NewReader(got)) + require.NoError(t, zerr) + + got, err = io.ReadAll(zr) + require.NoError(t, err) + assert.Contains(t, res.Header.Get("Content-Type"), "javascript") + + tt.wantBody = body + } + + assert.Equal(t, tt.wantBody, string(got)) + }) + } +} + +func TestSPAStat(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "a.js.gz": {Data: []byte("x")}, + "b.css": {Data: []byte("x")}, + "dir/c": {Data: []byte("x")}, + } + + assert.True(t, spaStat(fsys, "a.js")) + assert.True(t, spaStat(fsys, "b.css")) + assert.False(t, spaStat(fsys, "dir")) + assert.False(t, spaStat(fsys, "missing")) +} diff --git a/dashboard/static/icons/azure-aks.svg b/dashboard/static/icons/azure-aks.svg new file mode 100644 index 0000000000..9e98fd2cbf --- /dev/null +++ b/dashboard/static/icons/azure-aks.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/azure-apim.svg b/dashboard/static/icons/azure-apim.svg new file mode 100644 index 0000000000..b0f4b1c132 --- /dev/null +++ b/dashboard/static/icons/azure-apim.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/azure-appservice.svg b/dashboard/static/icons/azure-appservice.svg new file mode 100644 index 0000000000..111ee71f33 --- /dev/null +++ b/dashboard/static/icons/azure-appservice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/azure-containerapps.svg b/dashboard/static/icons/azure-containerapps.svg new file mode 100644 index 0000000000..68a9a6479b --- /dev/null +++ b/dashboard/static/icons/azure-containerapps.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/azure-flexdb.svg b/dashboard/static/icons/azure-flexdb.svg new file mode 100644 index 0000000000..fc9093520a --- /dev/null +++ b/dashboard/static/icons/azure-flexdb.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/azure-vm.svg b/dashboard/static/icons/azure-vm.svg new file mode 100644 index 0000000000..9ae9818d48 --- /dev/null +++ b/dashboard/static/icons/azure-vm.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/gcp-apigateway.svg b/dashboard/static/icons/gcp-apigateway.svg new file mode 100644 index 0000000000..837b08afeb --- /dev/null +++ b/dashboard/static/icons/gcp-apigateway.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/gcp-batch.svg b/dashboard/static/icons/gcp-batch.svg new file mode 100644 index 0000000000..f32a64924b --- /dev/null +++ b/dashboard/static/icons/gcp-batch.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/gcp-container.svg b/dashboard/static/icons/gcp-container.svg new file mode 100644 index 0000000000..7f2c4ff72e --- /dev/null +++ b/dashboard/static/icons/gcp-container.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/gcp-datastore.svg b/dashboard/static/icons/gcp-datastore.svg new file mode 100644 index 0000000000..5463a434af --- /dev/null +++ b/dashboard/static/icons/gcp-datastore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/gcp-identitytoolkit.svg b/dashboard/static/icons/gcp-identitytoolkit.svg new file mode 100644 index 0000000000..0bc1db66d6 --- /dev/null +++ b/dashboard/static/icons/gcp-identitytoolkit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/gcp-resourcemanager.svg b/dashboard/static/icons/gcp-resourcemanager.svg new file mode 100644 index 0000000000..cc1b6ca242 --- /dev/null +++ b/dashboard/static/icons/gcp-resourcemanager.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-apigateway.svg b/dashboard/static/icons/oci-apigateway.svg new file mode 100644 index 0000000000..3cd3d13831 --- /dev/null +++ b/dashboard/static/icons/oci-apigateway.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-artifacts.svg b/dashboard/static/icons/oci-artifacts.svg new file mode 100644 index 0000000000..450582f274 --- /dev/null +++ b/dashboard/static/icons/oci-artifacts.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-containerengine.svg b/dashboard/static/icons/oci-containerengine.svg new file mode 100644 index 0000000000..9443e48ce9 --- /dev/null +++ b/dashboard/static/icons/oci-containerengine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-containerinstances.svg b/dashboard/static/icons/oci-containerinstances.svg new file mode 100644 index 0000000000..19014e58ff --- /dev/null +++ b/dashboard/static/icons/oci-containerinstances.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-core.svg b/dashboard/static/icons/oci-core.svg new file mode 100644 index 0000000000..ed302aa716 --- /dev/null +++ b/dashboard/static/icons/oci-core.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-events.svg b/dashboard/static/icons/oci-events.svg new file mode 100644 index 0000000000..bb28a3bea9 --- /dev/null +++ b/dashboard/static/icons/oci-events.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-loadbalancer.svg b/dashboard/static/icons/oci-loadbalancer.svg new file mode 100644 index 0000000000..187f32b1f9 --- /dev/null +++ b/dashboard/static/icons/oci-loadbalancer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-logging.svg b/dashboard/static/icons/oci-logging.svg new file mode 100644 index 0000000000..acb6da0350 --- /dev/null +++ b/dashboard/static/icons/oci-logging.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-monitoring.svg b/dashboard/static/icons/oci-monitoring.svg new file mode 100644 index 0000000000..a094be23d0 --- /dev/null +++ b/dashboard/static/icons/oci-monitoring.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-mysql.svg b/dashboard/static/icons/oci-mysql.svg new file mode 100644 index 0000000000..fff6fbff8a --- /dev/null +++ b/dashboard/static/icons/oci-mysql.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-nosql.svg b/dashboard/static/icons/oci-nosql.svg new file mode 100644 index 0000000000..62d51068df --- /dev/null +++ b/dashboard/static/icons/oci-nosql.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-notifications.svg b/dashboard/static/icons/oci-notifications.svg new file mode 100644 index 0000000000..0147c72c25 --- /dev/null +++ b/dashboard/static/icons/oci-notifications.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/static/icons/oci-queue.svg b/dashboard/static/icons/oci-queue.svg new file mode 100644 index 0000000000..45e73fa0fe --- /dev/null +++ b/dashboard/static/icons/oci-queue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/ui.go b/dashboard/ui.go index 41968e934a..21a764e3a4 100644 --- a/dashboard/ui.go +++ b/dashboard/ui.go @@ -96,22 +96,28 @@ import ( ebbackend "github.com/blackbirdworks/gopherstack/services/eventbridge" firehosebackend "github.com/blackbirdworks/gopherstack/services/firehose" fisbackend "github.com/blackbirdworks/gopherstack/services/fis" + gcpapigatewaybackend "github.com/blackbirdworks/gopherstack/services/gcpapigateway" gcpartifactregistrybackend "github.com/blackbirdworks/gopherstack/services/gcpartifactregistry" + gcpbatchbackend "github.com/blackbirdworks/gopherstack/services/gcpbatch" gcpbigquerybackend "github.com/blackbirdworks/gopherstack/services/gcpbigquery" gcpbigtablebackend "github.com/blackbirdworks/gopherstack/services/gcpbigtable" gcpcloudbuildbackend "github.com/blackbirdworks/gopherstack/services/gcpcloudbuild" gcpcloudtasksbackend "github.com/blackbirdworks/gopherstack/services/gcpcloudtasks" gcpcomputebackend "github.com/blackbirdworks/gopherstack/services/gcpcompute" + gcpcontainerbackend "github.com/blackbirdworks/gopherstack/services/gcpcontainer" + gcpdatastorebackend "github.com/blackbirdworks/gopherstack/services/gcpdatastore" gcpdnsbackend "github.com/blackbirdworks/gopherstack/services/gcpdns" gcpeventarcbackend "github.com/blackbirdworks/gopherstack/services/gcpeventarc" gcpfirestorebackend "github.com/blackbirdworks/gopherstack/services/gcpfirestore" gcpfunctionsbackend "github.com/blackbirdworks/gopherstack/services/gcpfunctions" gcpiambackend "github.com/blackbirdworks/gopherstack/services/gcpiam" + gcpidtkbackend "github.com/blackbirdworks/gopherstack/services/gcpidentitytoolkit" gcpkmsbackend "github.com/blackbirdworks/gopherstack/services/gcpkms" gcploggingbackend "github.com/blackbirdworks/gopherstack/services/gcplogging" gcpmonitoringbackend "github.com/blackbirdworks/gopherstack/services/gcpmonitoring" gcppubsubbackend "github.com/blackbirdworks/gopherstack/services/gcppubsub" gcpredisbackend "github.com/blackbirdworks/gopherstack/services/gcpredis" + gcprmbackend "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" gcprunbackend "github.com/blackbirdworks/gopherstack/services/gcprun" gcpschedulerbackend "github.com/blackbirdworks/gopherstack/services/gcpscheduler" gcpsecretmanagerbackend "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" @@ -142,9 +148,22 @@ import ( mqbackend "github.com/blackbirdworks/gopherstack/services/mq" mwaabackend "github.com/blackbirdworks/gopherstack/services/mwaa" neptunebackend "github.com/blackbirdworks/gopherstack/services/neptune" + ociapigatewaybackend "github.com/blackbirdworks/gopherstack/services/ociapigateway" + ociartifactsbackend "github.com/blackbirdworks/gopherstack/services/ociartifacts" + ocicontainerenginebackend "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" + ocicontainerinstancesbackend "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ocieventsbackend "github.com/blackbirdworks/gopherstack/services/ocievents" ocifunctionsbackend "github.com/blackbirdworks/gopherstack/services/ocifunctions" ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ociloadbalancerbackend "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" + ocilogging "github.com/blackbirdworks/gopherstack/services/ocilogging" + ocimonitoring "github.com/blackbirdworks/gopherstack/services/ocimonitoring" + ocimysqlbackend "github.com/blackbirdworks/gopherstack/services/ocimysql" + ocinosqlbackend "github.com/blackbirdworks/gopherstack/services/ocinosql" + ocinotificationsbackend "github.com/blackbirdworks/gopherstack/services/ocinotifications" ociobjectstoragebackend "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" + ociqueuebackend "github.com/blackbirdworks/gopherstack/services/ociqueue" ocistreamingbackend "github.com/blackbirdworks/gopherstack/services/ocistreaming" ocivaultbackend "github.com/blackbirdworks/gopherstack/services/ocivault" opensearchbackend "github.com/blackbirdworks/gopherstack/services/opensearch" @@ -568,6 +587,8 @@ type Config struct { // GCPWorkflowsOps and GCPDNSOps are called in-process by their proxies. GCPWorkflowsOps *gcpworkflowsbackend.Handler GCPDNSOps *gcpdnsbackend.Handler + // GCPDatastoreOps is called in-process by the /dashboard/api/v1/gcp/datastore proxy. + GCPDatastoreOps *gcpdatastorebackend.Handler // GCPComputeOps is dispatched in-process by the /dashboard/api/v1/gcp/compute proxy. GCPComputeOps *gcpcomputebackend.Handler // OCIObjectStorageOps, OCIVaultOps, OCIStreamingOps and OCIIdentityOps are called in-process by their proxies. @@ -578,6 +599,37 @@ type Config struct { // OCIFunctionsOps and AzureMonitorOps are called in-process by their proxies. OCIFunctionsOps *ocifunctionsbackend.Handler AzureMonitorOps *azuremonitorbackend.Handler + // OCICoreOps is dispatched in-process by the /dashboard/api/v1/oci/core proxy. + OCICoreOps *ocicorebackend.Handler + // GCPContainerOps, OCIQueueOps and OCINotificationsOps are called in-process by their proxies. + GCPContainerOps *gcpcontainerbackend.Handler + OCIQueueOps *ociqueuebackend.Handler + OCINotificationsOps *ocinotificationsbackend.Handler + // GCPIdentityToolkitOps is dispatched in-process by the /dashboard/api/v1/gcp/identitytoolkit proxy. + GCPIdentityToolkitOps *gcpidtkbackend.Handler + // GCPAPIGatewayOps is called in-process by the /dashboard/api/v1/gcp/apigateway proxy. + GCPAPIGatewayOps *gcpapigatewaybackend.Handler + // GCPResourceManagerOps is called in-process by the /dashboard/api/v1/gcp/resourcemanager proxy. + GCPResourceManagerOps *gcprmbackend.Handler + // GCPBatchOps is called in-process by the /dashboard/api/v1/gcp/batch proxy. + GCPBatchOps *gcpbatchbackend.Handler + // OCIContainerEngineOps is dispatched in-process by the /dashboard/api/v1/oci/containerengine proxy. + OCIContainerEngineOps *ocicontainerenginebackend.Handler + // OCIMySQLOps, OCILoggingOps and OCIMonitoringOps are dispatched in-process by their proxies. + OCIMySQLOps *ocimysqlbackend.Handler + OCILoggingOps *ocilogging.Handler + OCIMonitoringOps *ocimonitoring.Handler + // OCINoSQLOps and OCIAPIGatewayOps are dispatched in-process by their proxies. + OCINoSQLOps *ocinosqlbackend.Handler + OCIAPIGatewayOps *ociapigatewaybackend.Handler + // OCIEventsOps is dispatched in-process by its proxy. + OCIEventsOps *ocieventsbackend.Handler + // OCILoadBalancerOps is dispatched in-process by its proxy. + OCILoadBalancerOps *ociloadbalancerbackend.Handler + // OCIArtifactsOps is dispatched in-process by its proxy. + OCIArtifactsOps *ociartifactsbackend.Handler + // OCIContainerInstancesOps is dispatched in-process by its proxy. + OCIContainerInstancesOps *ocicontainerinstancesbackend.Handler // AzureFileOps is dispatched in-process by the /dashboard/api/v1/azure/file proxy. AzureFileOps *azurefilebackend.Handler // AzureFunctionsOps is called in-process by the /dashboard/api/v1/azure/functions proxy. @@ -2253,7 +2305,9 @@ func (h *DashboardHandler) spaFallbackHandler() echo.HandlerFunc { } // Fallback to index.html for client-side routing - http.ServeFileFS(c.Response(), c.Request(), spaSubFS, "index.html") + if !serveSPAFile(c, spaSubFS, "index.html") { + http.NotFound(c.Response(), c.Request()) + } return nil } @@ -2268,21 +2322,11 @@ func trySPAAsset(c *echo.Context, spaFS fs.FS, reqPath string) bool { relPath := strings.TrimPrefix(path.Clean(cleanPath), "/") - file, openErr := spaFS.Open(relPath) - if openErr != nil { - return false - } - - stat, statErr := file.Stat() - _ = file.Close() - - if statErr != nil || stat.IsDir() { + if !spaStat(spaFS, relPath) { return false } - http.ServeFileFS(c.Response(), c.Request(), spaFS, relPath) - - return true + return serveSPAFile(c, spaFS, relPath) } // RouteMatcher returns a function that determines if the request should be routed to this service. diff --git a/desktop/go.mod b/desktop/go.mod index 985133623b..7d2eccae26 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -8,13 +8,17 @@ require ( ) require ( + cloud.google.com/go/apigateway v1.17.0 // indirect cloud.google.com/go/artifactregistry v1.27.0 // indirect cloud.google.com/go/auth v0.24.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/batch v1.24.1 // indirect cloud.google.com/go/bigtable v1.58.0 // indirect cloud.google.com/go/cloudbuild v1.34.0 // indirect cloud.google.com/go/cloudtasks v1.20.0 // indirect cloud.google.com/go/compute/metadata v0.10.0 // indirect + cloud.google.com/go/container v1.56.0 // indirect + cloud.google.com/go/datastore v1.25.0 // indirect cloud.google.com/go/eventarc v1.26.0 // indirect cloud.google.com/go/firestore v1.26.0 // indirect cloud.google.com/go/functions v1.26.0 // indirect @@ -25,9 +29,11 @@ require ( cloud.google.com/go/monitoring v1.31.0 // indirect cloud.google.com/go/pubsub/v2 v2.7.0 // indirect cloud.google.com/go/redis v1.26.0 // indirect + cloud.google.com/go/resourcemanager v1.18.1 // indirect cloud.google.com/go/run v1.23.0 // indirect cloud.google.com/go/scheduler v1.17.0 // indirect cloud.google.com/go/secretmanager v1.22.0 // indirect + cloud.google.com/go/serviceusage v1.16.1 // indirect cloud.google.com/go/spanner v1.96.1 // indirect cloud.google.com/go/workflows v1.21.0 // indirect connectrpc.com/connect v1.21.0 // indirect @@ -102,12 +108,14 @@ require ( github.com/aws/aws-sdk-go-v2/service/workspaces v1.81.4 // indirect github.com/aws/smithy-go v1.28.5 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/cedar-policy/cedar-go v1.8.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/coreos/go-semver v0.3.1 // indirect github.com/distribution/distribution/v3 v3.1.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2/v2 v2.8.4 // indirect @@ -121,25 +129,31 @@ require ( github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-mysql-org/go-mysql v1.16.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-sql-driver/mysql v1.10.1 // indirect github.com/go-stomp/stomp/v3 v3.1.5 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.3 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20261012231050-2af12c3f4d87 // indirect github.com/google/s2a-go v0.1.11 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.23 // indirect - github.com/googleapis/gax-go/v2 v2.26.2 // indirect + github.com/googleapis/gax-go/v2 v2.27.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/iskorotkov/avro/v2 v2.35.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.11.0 // indirect @@ -155,6 +169,8 @@ require ( github.com/moby/moby/api v1.56.1 // indirect github.com/moby/moby/client v0.6.2 // indirect github.com/mochi-mqtt/server/v2 v2.7.9 // indirect + github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -163,6 +179,9 @@ require ( github.com/parquet-go/jsonlite v1.5.5 // indirect github.com/parquet-go/parquet-go v0.32.0 // indirect github.com/pierrec/lz4/v4 v4.1.33 // indirect + github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 // indirect + github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20260504140133-511dba1dbe17 // indirect github.com/pkg/sftp v1.13.11 // indirect github.com/prometheus/client_golang v1.25.0 // indirect github.com/prometheus/client_model v0.6.3 // indirect @@ -176,6 +195,8 @@ require ( github.com/redis/go-redis/v9 v9.23.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.6.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shopspring/decimal v1.5.0 // indirect github.com/sirupsen/logrus v1.10.2 // indirect github.com/tidwall/gjson v1.20.0 // indirect @@ -185,7 +206,13 @@ require ( github.com/twmb/franz-go/pkg/kmsg v1.15.0 // indirect github.com/twpayne/go-geom v1.7.0 // indirect github.com/vektah/gqlparser/v2 v2.5.62 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/gopher-lua v1.1.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.9.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.72.0 // indirect @@ -210,20 +237,24 @@ require ( go.opentelemetry.io/otel/trace v1.47.0 // indirect go.opentelemetry.io/proto/otlp v1.11.1 // indirect go.uber.org/atomic v1.12.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect - google.golang.org/api v0.302.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/api v0.304.0 // indirect google.golang.org/genproto v0.0.0-20261012220534-4341edf3f941 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect google.golang.org/grpc v1.84.0 // indirect google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.78.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/desktop/go.sum b/desktop/go.sum index 18fe839b38..be0874c61d 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -2,12 +2,16 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/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/apigateway v1.17.0 h1:cG8FLt7mBkqDZpwf4l6dL4Ax6EtP4WbV/ix5Oy0ZaFg= +cloud.google.com/go/apigateway v1.17.0/go.mod h1:dulzWUXhPZvcFqI6ddDvAtn0xui8vm8/ZnqQFHcAs6k= cloud.google.com/go/artifactregistry v1.27.0 h1:EBgdk9sXNAVyWJ3hnTmWRdlmKhGDKCjkJbLoF3EsyWE= cloud.google.com/go/artifactregistry v1.27.0/go.mod h1:r0rZgbNmn5ppdqwUMF182yc7EFH6vabJMbXM+PlfW9A= cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs= cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c= cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/batch v1.24.1 h1:GP1fSB77EVbxa6r3nDubRvASqYkFzsvn8iq2PJ3yNDQ= +cloud.google.com/go/batch v1.24.1/go.mod h1:YR1Im5HR58fI+AbPPNcYLxnItFgqf4G5xuzjkjNWiLs= cloud.google.com/go/bigquery v1.85.0 h1:zsFsa8jOVkU4c7CWE1cbrfsemtNbM3YRUmtFRYXYN58= cloud.google.com/go/bigquery v1.85.0/go.mod h1:oBma1P5/b1Jtd8xRLKoyTeNIMlACGHbSMLudzxHGHgc= cloud.google.com/go/bigtable v1.58.0 h1:fkf7bI0DBkKN85uRrfEEpkbufSc48b4PVIZCRud3xRk= @@ -20,6 +24,10 @@ cloud.google.com/go/compute v1.72.0 h1:cwmwK/ADu7G1DNRN2Dt+qTpdg1hrAqzArBSmgSFum cloud.google.com/go/compute v1.72.0/go.mod h1:L8Z2360/cq2L/Z3q1o/D/jTFkzbgKLbgERpOm4/cepE= cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +cloud.google.com/go/container v1.56.0 h1:ySw+ybbeCaoEoYunWQjpcA96n+NG9echRil1/BMirIQ= +cloud.google.com/go/container v1.56.0/go.mod h1:S30jo4FsHqHtBPQb+X1APLJl8hq0K2ajdVessfC4kQA= +cloud.google.com/go/datastore v1.25.0 h1:zUjMnCLCcRZVDSdQIXsbnNCl1SVRNw5Jm0J77gPaPKs= +cloud.google.com/go/datastore v1.25.0/go.mod h1:jvJVNe+S2nHVIndV1H/B4s9K3MLsTMqOKlxSrzHTxB4= cloud.google.com/go/eventarc v1.26.0 h1:qrJ8Hx96KLDqU9yQdmAly6Cq1w0d5t/SOOpDWRFrfNQ= cloud.google.com/go/eventarc v1.26.0/go.mod h1:vjPsVSad+d/UNOd4k/3/E9CX/5p8k0ChjxoBU7H6I2I= cloud.google.com/go/firestore v1.26.0 h1:7Y6wn4aj5JXl2DAsKSTpLzYKPrfrIbhgQnHDjNOJ3sQ= @@ -40,12 +48,16 @@ cloud.google.com/go/pubsub/v2 v2.7.0 h1:MFrBTZZa6PDWZzCi4NJRsHKMm2w0a4oAaYNqwjgb cloud.google.com/go/pubsub/v2 v2.7.0/go.mod h1:JaFvWNVRk3Knoil/4M1ECeLOaI9D8drbmJWypQlK5aM= cloud.google.com/go/redis v1.26.0 h1:RgLGd28s+EBqXxwk82SMn0aU0WvlmFezpniY9MGhgNQ= cloud.google.com/go/redis v1.26.0/go.mod h1:nHRRMFXWJv7GfVQEbg0sQzN1MGIkl/q0mSOYG2l6Ylg= +cloud.google.com/go/resourcemanager v1.18.1 h1:xdaDmfzM08PA2Zs8hU8ej1TXQTJAcRoHbvQpaAEPYj0= +cloud.google.com/go/resourcemanager v1.18.1/go.mod h1:LjIioOpdFo5J1aFG9R51YqeBN5L55vElAG70dzFD/wg= cloud.google.com/go/run v1.23.0 h1:JvTkOn+8h+QicGzBPWjgSrwpTcs2b6zJkusfEnmvkGo= cloud.google.com/go/run v1.23.0/go.mod h1:ZCGrir3QdVgAJsVChr2nlQTfaknro8nwpEZwJDRHHhI= cloud.google.com/go/scheduler v1.17.0 h1:jFAEvWG9QDwDWYMgrXZBFi+f1EehetCmM0VE7OpPqJw= cloud.google.com/go/scheduler v1.17.0/go.mod h1:16h1vcu+XuadWlJjfKaUn3TWyzedOH+RGoylY2EQix8= cloud.google.com/go/secretmanager v1.22.0 h1:c9nPLiK4IZeT/zDyLjvNaBw1BHNkp0Ysybj1FfFIAPQ= cloud.google.com/go/secretmanager v1.22.0/go.mod h1:aDN9cW5x6Y8QVj32snakZv96vYyW7Nf1P+eqZGH8408= +cloud.google.com/go/serviceusage v1.16.1 h1:V5ULuLqQ+vu5FqkNB/66FlsyudFdofA5tI4C3U0p/rQ= +cloud.google.com/go/serviceusage v1.16.1/go.mod h1:2klVRUAhaQNJbIVYlOcWC2D0R/J1TTUQFqCLyaaIs+8= cloud.google.com/go/spanner v1.96.1 h1:pLmq5/Fj+Yu6YGRBz9oMPm3ZRQIeG14FYBWFwhZZaIA= cloud.google.com/go/spanner v1.96.1/go.mod h1:VqMSp/YlTofUq1q6ekkXBAmhAs6X860YL0I8wo7i+k0= cloud.google.com/go/storage v1.69.0 h1:jAAMC1411HEh78nKsU0Zns+eFj3TnhjAWIhg5Ud/XBM= @@ -58,6 +70,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +firebase.google.com/go/v4 v4.22.0 h1:+7ClMN69jOITseDXC7q0OKjMkRl3ZEJwZaLB900mvYs= +firebase.google.com/go/v4 v4.22.0/go.mod h1:tXyH/bqgOst15PmgHUSFaLX9woyDwclTtz/cRX+NocQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= @@ -82,22 +96,46 @@ github.com/Azure/azure-sdk-for-go/sdk/monitor/ingestion/azlogs v1.1.0 h1:Q+tp/BW github.com/Azure/azure-sdk-for-go/sdk/monitor/ingestion/azlogs v1.1.0/go.mod h1:et3yi6OrdxM8YK0pfOwpHSLf4gWypxQVWh4T9wRzg3k= github.com/Azure/azure-sdk-for-go/sdk/monitor/query/azlogs v1.2.0 h1:KzTYJVNtaApcR6Yav9kVRXXwtVpMakAqyOpmiwjtO90= github.com/Azure/azure-sdk-for-go/sdk/monitor/query/azlogs v1.2.0/go.mod h1:a+dxW5k1ZbYaibMYrFuhZEZELPgZslm81QR4CMchMX0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2 v2.1.0 h1:WYADp5XlioccEnBBK9sVUaHVno76l7WeTcWCumN86kM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2 v2.1.0/go.mod h1:PK8v1aAd2Wx6eTcbUYhYstGpspqNqhZYiM8GLFdq2A0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v4 v4.0.0 h1:sS//OfnHmbzZ4P9g/XpGLzaa+Hg1eYxmkAJ5MRZJDqM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v4 v4.0.0/go.mod h1:rM90CKcOtgGp+kdA33rPdmu5qRx+zvsBonck3ajhT+A= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v6 v6.0.0 h1:vhXsRicy0J6KLMsd7sAL6ll2CMEag2V2tIpuVI2CZP0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v6 v6.0.0/go.mod h1:8bqI53aVbpaMreTHLfgG5VcqQKmfBc99UZtPozthauQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v9 v9.0.0 h1:GhX9IxyAokIUIIYs4v7PjK+tTUM72dpIjtDhB8OkQ9s= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v9 v9.0.0/go.mod h1:uStAlZ315xBw8h/xv12/hsAEo+hFlwZH4MeD0YVaUp4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0 h1:+dIXMjlifRbG3d01DF8dwckUSXADuW5dgBNt1fbkpv0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0/go.mod h1:FN0UJ15tJ7kV7JYrYAleEq44Ew1cUiyLcJrfrTxHGd0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0 h1:DWlwvVV5r/Wy1561nZ3wrpI1/vDIBRY/Wd1HWaRBZWA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0/go.mod h1:E7ltexgRDmeJ0fJWv0D/HLwY2xbDdN+uv+X2uZtOx3w= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v9 v9.7.0 h1:Su+wzyfj14nd/cDvZ/B0WJYY+OGT0elZw5yrWY/3pZQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v9 v9.7.0/go.mod h1:NJWUwxR3ahbD53ia/cTjBSr+0hYHCTjSSoIZ0A2cvLo= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid/v2 v2.3.0 h1:8JkRfARpQbzTzD+HGQAf67VIqQg3qXLIcAqtPYr/V0Q= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid/v2 v2.3.0/go.mod h1:NOM/LtCJfoU69VkmxPxV6PMxDm/MsOvmsao/5V5Rhgs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor v0.12.0 h1:qmeVFr8V9dDzhLZDDgdqyOEl+XLhhMpm7xrOajxqKuk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor v0.12.0/go.mod h1:x/aYmuTilxu4yQMFj2r3ZE3iiO2wxYFFmh6dRg2IWiQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 h1:L7G3dExHBgUxsO3qpTGhk/P2dgnYyW48yn7AO33Tbek= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0/go.mod h1:Ms6gYEy0+A2knfKrwdatsggTXYA2+ICKug8w7STorFw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers/v2 v2.0.0 h1:iyx6jFFyRo8/9FQiyHRTjoUluopwt+9lvOtRBhfB7Y8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers/v2 v2.0.0/go.mod h1:LSdxfHhUzKllvM+sYDf1wrcQrbInrF4iXJ/NMk7fK90= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v8 v8.0.0 h1:7QO7GhGat25QEYL4h607O9zNNTUlAv8PbSesW6Ol5Gg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v8 v8.0.0/go.mod h1:mCqeYzwyjn/pw0JVqHJMIzfUQJrlcV0YjTg5b0NK+F0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights/v2 v2.0.2 h1:SFLbQmpdytToYZQJw5NqrZRwHPIGJmf5ZgjStbLfUuU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights/v2 v2.0.2/go.mod h1:H3EFkhcVTisidszwtIkRDggjS2HmOIA26J3g8hDdHAY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v2 v2.1.1 h1:iu8l6XmviyuNbBQ/WO5shgLBU2+g18Cdw1iE1Xk2+ig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v2 v2.1.1/go.mod h1:8Anbzn23yMdpl2DDY4qnFEPY9Vf6CoLphIt4mX59McI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis/v3 v3.3.0 h1:EkL5dmUoy1OlzVfsbkcHayOvOJgheyRYL3wM/RHizzg= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis/v3 v3.3.0/go.mod h1:DiazWkJHUUKUZGpIdV7JhDTjebBxdfsJ386dE5w7G3o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armlocks v1.2.0 h1:CMp8GwmUfS/Stg5KBgduD8rPIk9GNj1HMaID/gUAJYg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armlocks v1.2.0/go.mod h1:GE1wqa9Ny9eZ8wHtHqbCE7mMsFfVbdEY0itmzYV8JEg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0 h1:FKYUd0TZfjjWtdk7LR+6rI64yNY1gMe9rNV40Y2Fh4o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0/go.mod h1:w987ZDGBg74HZu5sAqaBEhDTlvlJlxPggIhBiDviYhE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 h1:S087deZ0kP1RUg4pU7w9U9xpUedTCbOtz+mnd0+hrkQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0/go.mod h1:B4cEyXrWBmbfMDAPnpJ1di7MAt5DKP57jPEObAvZChg= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= @@ -136,6 +174,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= @@ -596,6 +636,8 @@ github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k= github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= +github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -620,9 +662,13 @@ github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/distribution/distribution/v3 v3.1.2 h1:/Bv2YIqqSR00HiO49FT93hovy6r9IlrzwUDsJjCN5qg= @@ -665,6 +711,8 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-mysql-org/go-mysql v1.16.0 h1:odv4Ygtc1WHJv3uUF2aoJdE1RS7tA0sD3ET91ZAWQIg= +github.com/go-mysql-org/go-mysql v1.16.0/go.mod h1:VjBTZTTDKL8OMXUAhNbg3VHaVVq9HOXJEBLpAKBFIfE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= @@ -673,16 +721,20 @@ github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9 github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-stomp/stomp/v3 v3.1.5 h1:Pikz1OSusmSKUm5mRKYfXQZaDatfZ+EnBBA1JJ2xENQ= github.com/go-stomp/stomp/v3 v3.1.5/go.mod h1:ztzZej6T2W4Y6FlD+Tb5n7HQP3/O5UNQiuC169pIp10= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.3 h1:IQNSXULh6Nc4ZH8ETJ5Mdsj8loUFCWSBnsjmJkyjUB8= github.com/godbus/dbus/v5 v5.2.3/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k= -github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -694,10 +746,14 @@ github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/ github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20261012231050-2af12c3f4d87 h1:P18JvwDzBYAeC8SIfnee/YJzKk1dysuN6rUwmF6CxZY= github.com/google/pprof v0.0.0-20261012231050-2af12c3f4d87/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM= github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM= @@ -706,8 +762,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.23 h1:J+VGF1wjAM2dU2kKUXZxJdDppRLReWP7KWcclfngQhc= github.com/googleapis/enterprise-certificate-proxy v0.3.23/go.mod h1:Y8NO/15GNLkrXI3OR5xOFmeU3XLX1sLSdtlXgJ0zmIw= -github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5L5izk8= -github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= +github.com/googleapis/gax-go/v2 v2.27.0 h1:qe2iQOUzxb3fd6x6ewpIEixPQhJ+0Ihc/RRZhKA/2g0= +github.com/googleapis/gax-go/v2 v2.27.0/go.mod h1:RofiqDw3ehcG4L0H1zO3D2Itpwd3/uS/7wdmbNDF1DE= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -722,6 +778,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/iskorotkov/avro/v2 v2.35.0 h1:UJoOjHeInrzB09APUZP28nJxyWeKenrPctDVx3VS3C4= +github.com/iskorotkov/avro/v2 v2.35.0/go.mod h1:msR3h6CL3GwE4r6s8LgN1dUUHlm8mHx8tVvCZF8H5Xo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -785,6 +843,10 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= @@ -805,6 +867,12 @@ github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayM github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg= github.com/pierrec/lz4/v4 v4.1.33 h1:GjG1TJ1V4IzKP8L96muuuDNpTwd7D+l2ccXrjAbe014= github.com/pierrec/lz4/v4 v4.1.33/go.mod h1:7SE9MC2STkNtL4PIwGhjmyVwvILaGI9/COYQNBhKM/c= +github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 h1:cLgCk5mwDG9lDH+dPK8TmEliTjyGJwwKN0qevWAl8IY= +github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2/go.mod h1:ktAJCA9lxrHHjVyVl2pKJFvzBnq2eZbb+CUOjBRPlXo= +github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 h1:A2bEfgSb7hLwR9mxDszgGKweF+xY9YoTDG+8RjdFjDE= +github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1/go.mod h1:pxfz2oJfAuhwrb3/rcLqD//GS/5gRP4gD022iP3cEO0= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260504140133-511dba1dbe17 h1:cfAVPis6GP6lxQgm1WGaNGi4rVXTB4KDvYf96LjqRCM= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260504140133-511dba1dbe17/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= @@ -836,10 +904,14 @@ github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBP github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc= @@ -851,8 +923,15 @@ github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G8 github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= @@ -877,17 +956,28 @@ github.com/vektah/gqlparser/v2 v2.5.62 h1:GJP/emj1wIWKs0U3SHcAgzknWgylTqQ1qH2gNL github.com/vektah/gqlparser/v2 v2.5.62/go.mod h1:JNK+plRwKdXLsF/qPFPe5tE0z4s1WeroD9S5LR8um/Q= github.com/wailsapp/wails/v3 v3.0.0 h1:lBe89kFu23bVz1dc1H9v0srkHQgWsy7BAKpH4sLQuDw= github.com/wailsapp/wails/v3 v3.0.0/go.mod h1:U3p3/QfSer2XxXM3XWhwis9O7LvvuNKN/AcGoEFD+K8= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.9.2 h1:OCAdjI37pC/SVxucz/9CM8zD4jCdegtsY5QcKzYFCgE= +go.mongodb.org/mongo-driver/v2 v2.9.2/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -944,32 +1034,42 @@ go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrF go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 h1:pQo5TwmxKJvI8k39uYS2Gqkk8OXWxX/TMSis2Twp1AQ= golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -977,16 +1077,22 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20261009151841-97e8ea77aa2a h1:wnveMBKHwx8/nIb8WYT5P9vtefdXOvIWxzycCkc5n6Q= golang.org/x/telemetry v0.0.0-20261009151841-97e8ea77aa2a/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= @@ -994,6 +1100,7 @@ golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1003,8 +1110,11 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.302.0 h1:/eENsEv3+GE0QiEmXjdaLI84Mt76LNjGz4vp4E7sRVw= -google.golang.org/api v0.302.0/go.mod h1:dcjRWAy1QdjGb3Z67Qu0gdBWH2gL8Qoy/RI2ZVuvdeE= +google.golang.org/api v0.304.0 h1:2R1dYSiIVQRGcKN901u9yPFEdJu5h90oGY5X4TcgbmA= +google.golang.org/api v0.304.0/go.mod h1:dcjRWAy1QdjGb3Z67Qu0gdBWH2gL8Qoy/RI2ZVuvdeE= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20261012220534-4341edf3f941 h1:CXq2XEi5aEyLjjPZIwqrmQH8ZY4ctUwK7uyAx3mjX/U= google.golang.org/genproto v0.0.0-20261012220534-4341edf3f941/go.mod h1:3gK0ehnhP0V7/x3ypGoLyT3d0n6TSi/EcsphIjHrcz4= google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 h1:DS/w3N8rsfrwxwgGSj6WhMsPo7aNQGmDx74J81zd0NQ= @@ -1018,6 +1128,8 @@ google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/docs/chaos.md b/docs/chaos.md index 312607793e..941b18de2f 100644 --- a/docs/chaos.md +++ b/docs/chaos.md @@ -70,6 +70,26 @@ Replaces the entire fault configuration. - `probability`: Float between `0.0` and `1.0`. A value of `0` is treated as `1.0` (always fire). - `error`: Custom HTTP error. Defaults to 503 `ServiceUnavailable` if not provided. +### Effect rules: `dynamodb:transaction-conflict` + +A rule with an `effect` is not an HTTP error; the owning service applies it. `dynamodb:transaction-conflict` makes the targeted DynamoDB items behave as if an unfinished `TransactWriteItems` held them, so the documented conflict errors fire (`TransactionConflictException` for `PutItem`/`UpdateItem`/`DeleteItem`, `TransactionCanceledException` with `TransactionConflict` reasons for transactions, `UnprocessedItems` for `BatchWriteItem`). `GetItem` is never affected. Without a rule, DynamoDB still raises these errors when requests genuinely overlap. + +```json +[ + { + "effect": "dynamodb:transaction-conflict", + "operation": "PutItem", + "target": {"table": "orders", "key": {"pk": "order-1"}}, + "probability": 0.3, + "holdOps": 2, + "holdMs": 500 + } +] +``` + +- `target.table` / `target.key` narrow the rule (key values are the scalar string form of the key attributes); `operation` and `region` work as for other rules. +- `holdOps`: after the first forced conflict on an item, the next N operations on it conflict too. `holdMs`: the same for N milliseconds. + ### Other Fault Endpoints - `GET /_gopherstack/chaos/faults`: Returns current fault rules. diff --git a/docs/engines.md b/docs/engines.md index 30ddeac03b..d57b1e0c7d 100644 --- a/docs/engines.md +++ b/docs/engines.md @@ -1,7 +1,7 @@ # Engine modes Container-backed services (RDS/Aurora, MSK, Amazon MQ, OpenSearch, Elasticsearch, DocumentDB, Neptune, -Redshift, EKS, ElastiCache, MemoryDB, CodeBuild, Timestream for InfluxDB, ECS tasks) default to in-memory +Redshift, EKS, ElastiCache, MemoryDB, CodeBuild, Glue interactive sessions, Timestream for InfluxDB, ECS tasks) default to in-memory stubs. `--engines` (env `GOPHERSTACK_ENGINES`) switches them all at once: | Value | Behaviour | @@ -21,6 +21,42 @@ A per-service flag (`--rds-engine`, ...), its env var (`RDS_ENGINE`, ...) or a n Resolved modes are not written to persisted config. +## Containers left by a crashed gopherstack + +Every container started through `pkgs/container` carries `gopherstack.managed=true`, `gopherstack.owner`, +`gopherstack.pid` (`pid:starttime` of the launcher) and, with persistence off, `gopherstack.ephemeral=true`. +Services add `gopherstack.service` and, when they can re-attach, `gopherstack.resource`. The owner is derived from +the data dir when persistence is on (stable across restarts) and is fresh per run otherwise. + +At startup, before services restore: + +- Same-owner containers whose launcher process is gone and that have no `gopherstack.resource` are removed. +- Same-owner, resource-labelled containers are held; a service restoring that resource adopts it (`ClaimOrphan`), + and anything unclaimed after 3 minutes is removed. +- Another owner's containers are removed only when `gopherstack.ephemeral=true` and the recorded process (pid and + start time) is dead. Live processes, other owners and unlabelled containers are never touched. + +Adopting services: Cloud SQL (gcpsql), EC2 docker instances, ECS docker tasks and AKS clusters. + +- EC2: a restored pending or running instance claims its container by instance id; it is adopted when the container + is the recorded one, runs the same AMI and is running, and the recorded address is refreshed from it. Not adopted + (container removed): providers mapping EBS volumes, and instances behind security-group relays (no resource key). + An instance's env-based metadata endpoint points at a per-run port, so it is stale after adoption unless the + link-local relay is on. +- ECS: a restored RUNNING task claims one container per container definition (`taskArn|containerName`). All must + exist, run, and use the definition's image; then exit, health and log watching resume (logs from adoption time on). + Otherwise every claimed container is removed and the task is STOPPED ("Task containers were lost when the + emulator restarted"). Containers behind security-group relays are never adopted. +- Batch: containers carry the identity and service labels but no resource key. A job lost to a crash is failed + ("Container execution was interrupted") and its container removed at the next startup. + +- AKS: a restored running cluster claims its k3s node by `/` (lower case). The node's CA + is read back from the container and its API server must answer before it is used; otherwise it is removed and a + fresh node starts on the cluster's data volume (`gopherstack-aks-`), which keeps the Kubernetes state. The + volume is removed with the cluster, and at restore when no restored cluster owns it. + +RDS, EKS, GKE and OKE label the service and are cleaned up at startup, then relaunch as before. + ## Not covered Lambda already starts containers on demand and the EC2 docker provider has its own `--ec2-provider` flag; neither diff --git a/docs/iam-policy-stream.md b/docs/iam-policy-stream.md index 2625d6cbba..6f688169c7 100644 --- a/docs/iam-policy-stream.md +++ b/docs/iam-policy-stream.md @@ -12,9 +12,12 @@ Allow statement that would have permitted the request. - `--iam-audit` (`GOPHERSTACK_IAM_AUDIT` or `IAM_SOFT_MODE`): evaluate and record but never deny; denials are logged as `IAM audit: request would be denied`. Works without `--enforce-iam`. +- `--iam-enforce-scps` (`GOPHERSTACK_IAM_ENFORCE_SCPS`, default true): with enforcement on, + also evaluate Organizations SCPs for member-account principals (management account and + service-linked roles exempt). Deny log lines carry `reason` (`identity-policy`, `resource-policy`, -`permission-boundary`, `implicit-deny`, `unresolved-principal`) and `matchedSid`. +`permission-boundary`, `service-control-policy`, `implicit-deny`, `unresolved-principal`) and `matchedSid`. ## Endpoints diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000000..fffe82ee1e --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,79 @@ +# MCP server + +Gopherstack embeds a [Model Context Protocol](https://modelcontextprotocol.io) server +(built on the official Go SDK) that lets AI assistants inspect and control a running +instance. Every tool is backed by an existing admin endpoint; nothing new is exposed. + +## Transports + +### stdio + +`gopherstack mcp` speaks MCP over stdin/stdout and proxies a running instance's admin +API (`--endpoint`, `GOPHERSTACK_ENDPOINT`, default `http://localhost:8000`). + +```json +{ + "mcpServers": { + "gopherstack": { + "command": "gopherstack", + "args": ["mcp", "--endpoint", "http://localhost:8000"] + } + } +} +``` + +### Streamable HTTP + +Opt in with `--mcp-enabled` (`GOPHERSTACK_MCP=true`). The server then answers MCP at +`/_gopherstack/mcp` on the main port. + +```json +{ + "mcpServers": { + "gopherstack": { + "type": "http", + "url": "http://localhost:8000/_gopherstack/mcp", + "headers": { "Authorization": "Bearer ${GOPHERSTACK_MCP_TOKEN}" } + } + } +} +``` + +## Authentication + +| `--mcp-token` | Who may connect | +| --- | --- | +| unset (default) | Loopback clients only; others get `403`. A published container port is not loopback to the server, so set a token there. | +| set (or `GOPHERSTACK_MCP_TOKEN`) | Any client presenting `Authorization: Bearer `; others get `401`. | + +The stdio transport inherits the trust of whoever launches it. + +## Tools + +| Tool | Hints | Backing endpoint | +| --- | --- | --- | +| `gopherstack_health` | read-only | `GET /_gopherstack/health` | +| `snapshot_list` | read-only | `GET /_gopherstack/pods` | +| `snapshot_save` | additive | `POST /_gopherstack/pods/:name` | +| `snapshot_load` | destructive | `POST /_gopherstack/pods/:name/load` | +| `snapshot_delete` | destructive | `DELETE /_gopherstack/pods/:name` | +| `reset_state` | destructive | `POST /_gopherstack/reset` | +| `chaos_list_faults` | read-only | `GET /_gopherstack/chaos/faults` | +| `chaos_set_faults` | destructive | `POST` / `PATCH /_gopherstack/chaos/faults` | +| `chaos_clear_faults` | destructive | `POST /_gopherstack/chaos/faults/clear` | +| `query_traces` | read-only | `GET /_gopherstack/requests` | +| `iam_denials` | read-only | `GET /_gopherstack/iam/policy-stream?decision=deny` | +| `init_hooks_list` | read-only | `GET /_localstack/init` | +| `init_hooks_run` | destructive | `POST /_gopherstack/init/run` (loopback peers only) | + +`init_hooks_run` runs a whole stage or one script (`stage`, optional `script` file name) +that is already present in the configured stage directories; names are matched against +discovered scripts, so paths, traversal and inline content are rejected. It returns each +script's exit code and at most 16 KiB of output. + +`--mcp-read-only` (`GOPHERSTACK_MCP_READ_ONLY`, and `gopherstack mcp --read-only`) +registers only the read-only tools, hiding every snapshot/reset/chaos/init-run tool. + +`query_traces` returns method, path, service, operation, status, duration and error +details only; headers, bodies and signature query parameters are dropped. The +resource `gopherstack://info` returns version, uptime and enabled services. diff --git a/docs/services/cosmosdb.md b/docs/services/cosmosdb.md index 6ba8db9f67..5097fddd67 100644 --- a/docs/services/cosmosdb.md +++ b/docs/services/cosmosdb.md @@ -36,6 +36,7 @@ Cosmos DB runs on its own dedicated, synchronously-bound TCP port, mirroring the | Validate auth | `false` | `--cosmosdb-validate-auth` | `COSMOSDB_VALIDATE_AUTH` | | Enforce throughput | `false` | `--cosmosdb-enforce-throughput` | `COSMOSDB_ENFORCE_THROUGHPUT` | | Serverless | `false` | `--cosmosdb-serverless` | `COSMOSDB_SERVERLESS` | +| Resource-token max lifetime | `86400` (600..86400; `18000` is the 2020 Create Permission page cap) | `--cosmosdb-resource-token-max-seconds` | `COSMOSDB_RESOURCE_TOKEN_MAX_SECONDS` | | TLS (self-signed) | `false` | (env only) | `COSMOSDB_TLS=true` | | Extra TLS hosts | none | (env only) | `COSMOSDB_TLS_HOSTS=name1,10.0.0.5` | diff --git a/examples/azure-keyvault-app/go.mod b/examples/azure-keyvault-app/go.mod index 5ab534ade0..d3dd35ded2 100644 --- a/examples/azure-keyvault-app/go.mod +++ b/examples/azure-keyvault-app/go.mod @@ -11,6 +11,6 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/text v0.43.0 // indirect ) diff --git a/examples/azure-keyvault-app/go.sum b/examples/azure-keyvault-app/go.sum index 9bca34993b..92261b85af 100644 --- a/examples/azure-keyvault-app/go.sum +++ b/examples/azure-keyvault-app/go.sum @@ -26,8 +26,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= diff --git a/examples/azure-servicebus-cosmos-worker/go.mod b/examples/azure-servicebus-cosmos-worker/go.mod index f2d4aad2a0..5175e002cd 100644 --- a/examples/azure-servicebus-cosmos-worker/go.mod +++ b/examples/azure-servicebus-cosmos-worker/go.mod @@ -9,6 +9,6 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/text v0.43.0 // indirect ) diff --git a/examples/azure-servicebus-cosmos-worker/go.sum b/examples/azure-servicebus-cosmos-worker/go.sum index 673d5c1c47..69a6ff616e 100644 --- a/examples/azure-servicebus-cosmos-worker/go.sum +++ b/examples/azure-servicebus-cosmos-worker/go.sum @@ -22,8 +22,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= diff --git a/examples/ec2-docker/README.md b/examples/ec2-docker/README.md index 89617d4ab7..e983b9d8a4 100644 --- a/examples/ec2-docker/README.md +++ b/examples/ec2-docker/README.md @@ -123,5 +123,47 @@ The provider is configured via environment variables / CLI flags: | `--ec2-docker-ssh-host-ip` | `EC2_DOCKER_SSH_HOST_IP` | `127.0.0.1` | Host IP that mapped sshd ports bind to (use `0.0.0.0` to expose externally). | | `--ec2-docker-ssh-port-min` | `EC2_DOCKER_SSH_PORT_MIN` | `0` | Lower bound of the host TCP port range used to map sshd (`0` = let Docker pick). | | `--ec2-docker-ssh-port-max` | `EC2_DOCKER_SSH_PORT_MAX` | `0` | Upper bound of the host TCP port range used to map sshd. | +| `--ec2-docker-flags` | `GOPHERSTACK_EC2_DOCKER_FLAGS` | _(empty)_ | Extra docker-run options (see below). | +| `--ec2-docker-ebs-volumes` | `GOPHERSTACK_EC2_DOCKER_EBS_VOLUMES` | `false` | Back EBS volumes with Docker named volumes (see below). | | `--dns-addr` | `DNS_ADDR` | _(empty)_ | Bind address for the embedded DNS server (e.g. `:10053`). Empty = disabled. | | `--dns-resolve-ip` | `DNS_RESOLVE_IP` | `127.0.0.1` | IP address synthetic hostnames resolve to. | + +## AMIs and Docker images + +With the docker provider, `RunInstances` starts the Docker image bound to the requested AMI. An AMI id that +does not exist fails with `InvalidAMIID.NotFound`. + +- **Seeded public AMIs** map to a default image by name: Amazon Linux 2023 to `amazonlinux:2023`, Amazon Linux 2 + (and EKS/ECS-optimized AL2) to `amazonlinux:2`, Amazon Linux 1 to `amazonlinux:1`, Ubuntu 20.04 / 22.04 / 24.04 to + `ubuntu:20.04` / `ubuntu:22.04` / `ubuntu:24.04`. Windows and Bottlerocket have no Linux container equivalent and + use `--ec2-docker-image`. +- **CreateImage** from a running docker-backed instance runs `docker commit` and tags the result + `gopherstack-ec2/:`; instances launched from that AMI start from the snapshot. +- **Local images as AMIs**: any local image tagged `gopherstack-ec2/:` (`` is `ami-` plus at + least 8 hex digits) is registered as a self-owned AMI named `` on the next `DescribeImages` or `RunInstances`. + Tag one yourself with `docker tag myimage gopherstack-ec2/web:ami-0123456789abcdef0`. Images in this namespace are + never pulled. +- **RegisterImage** with `ImageLocation=docker://` binds the new AMI to that image; `CopyImage` copies the + binding. An AMI with no binding runs `--ec2-docker-image`. + +## Extra docker flags + +`--ec2-docker-flags` (`GOPHERSTACK_EC2_DOCKER_FLAGS`) takes a quoted, docker-run style string, for example +`--privileged -e MODE=test -v /srv/data:/data:ro --cap-add NET_ADMIN -m 512m --cpus 1.5 -p 8080:80`. It is parsed +into Docker API fields and never passed to a shell. Supported: `--privileged`, `-e/--env K=V`, `-v/--volume +src:dst[:ro|rw]`, `--cap-add`, `--cap-drop`, `-m/--memory N[b|k|m|g]`, `--cpus N`, `--network/--net NAME`, +`-p/--publish [hostIP:]hostPort:containerPort[/tcp|udp]` (container port 22/tcp is reserved for sshd). Any other +flag stops startup with an error. `--network` is used only when `--ec2-docker-network` is empty. + +## EBS volumes + +With `--ec2-docker-ebs-volumes`, each EBS volume gets one Docker named volume `gopherstack-ebs-`, labelled +with its volume id and owner, mounted at `/mnt/ebs/` (`/dev/sdf` is `/mnt/ebs/sdf`). + +- `BlockDeviceMappings` at launch are real mounts created with the container. The root device is the container + filesystem and has no Docker volume. +- `AttachVolume` on a running instance copies the volume contents into the container at that path; `DetachVolume` + (and termination) copies them back and removes the path. Docker cannot add a mount to a live container, so data + written to a runtime-attached volume reaches the Docker volume at detach or terminate, not continuously. +- `DeleteVolume`, and `DeleteOnTermination` at termination, remove the Docker volume. A retained volume keeps its data + and can be attached to another instance. diff --git a/examples/ec2-docker/demo.sh b/examples/ec2-docker/demo.sh index d10082f475..e9b881741f 100755 --- a/examples/ec2-docker/demo.sh +++ b/examples/ec2-docker/demo.sh @@ -11,7 +11,7 @@ DNS_ADDR="${DNS_ADDR:-127.0.0.1}" DNS_PORT="${DNS_PORT:-10053}" KEY_NAME="${KEY_NAME:-ec2-docker-demo}" KEY_FILE="${KEY_FILE:-/tmp/${KEY_NAME}.pem}" -AMI_ID="${AMI_ID:-ami-0abcdef1234567890}" +AMI_ID="${AMI_ID:-ami-0c55b159cbfafe1f0}" export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-test}" export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-test}" diff --git a/examples/gcp-bigquery-gcs-load/go.mod b/examples/gcp-bigquery-gcs-load/go.mod index 639614e468..8f44646b40 100644 --- a/examples/gcp-bigquery-gcs-load/go.mod +++ b/examples/gcp-bigquery-gcs-load/go.mod @@ -53,7 +53,7 @@ require ( golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 // indirect golang.org/x/mod v0.42.0 // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect diff --git a/examples/gcp-bigquery-gcs-load/go.sum b/examples/gcp-bigquery-gcs-load/go.sum index ea7a316385..a912031d65 100644 --- a/examples/gcp-bigquery-gcs-load/go.sum +++ b/examples/gcp-bigquery-gcs-load/go.sum @@ -119,8 +119,8 @@ golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 h1:pQo5TwmxKJvI8k39uYS2Gqkk8 golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= diff --git a/examples/gcp-run-pubsub-push/go.mod b/examples/gcp-run-pubsub-push/go.mod index 08e73c66b3..4b374eeeef 100644 --- a/examples/gcp-run-pubsub-push/go.mod +++ b/examples/gcp-run-pubsub-push/go.mod @@ -33,7 +33,7 @@ require ( go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/otel/trace v1.47.0 // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect diff --git a/examples/gcp-run-pubsub-push/go.sum b/examples/gcp-run-pubsub-push/go.sum index 56797c793c..2ea33af0d3 100644 --- a/examples/gcp-run-pubsub-push/go.sum +++ b/examples/gcp-run-pubsub-push/go.sum @@ -122,8 +122,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= diff --git a/go.mod b/go.mod index ac123351a6..f690410a4c 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,16 @@ module github.com/blackbirdworks/gopherstack go 1.27.3 require ( + cloud.google.com/go/apigateway v1.17.0 cloud.google.com/go/artifactregistry v1.27.0 + cloud.google.com/go/batch v1.24.1 cloud.google.com/go/bigquery v1.85.0 cloud.google.com/go/bigtable v1.58.0 cloud.google.com/go/cloudbuild v1.34.0 cloud.google.com/go/cloudtasks v1.20.0 cloud.google.com/go/compute v1.72.0 + cloud.google.com/go/container v1.56.0 + cloud.google.com/go/datastore v1.25.0 cloud.google.com/go/eventarc v1.26.0 cloud.google.com/go/firestore v1.26.0 cloud.google.com/go/functions v1.26.0 @@ -19,13 +23,16 @@ require ( cloud.google.com/go/monitoring v1.31.0 cloud.google.com/go/pubsub/v2 v2.7.0 cloud.google.com/go/redis v1.26.0 + cloud.google.com/go/resourcemanager v1.18.1 cloud.google.com/go/run v1.23.0 cloud.google.com/go/scheduler v1.17.0 cloud.google.com/go/secretmanager v1.22.0 + cloud.google.com/go/serviceusage v1.16.1 cloud.google.com/go/spanner v1.96.1 cloud.google.com/go/storage v1.69.0 cloud.google.com/go/workflows v1.21.0 connectrpc.com/connect v1.21.0 + firebase.google.com/go/v4 v4.22.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.2.0 github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.5.0 @@ -36,14 +43,26 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/messaging/eventgrid/aznamespaces v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/monitor/ingestion/azlogs v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/monitor/query/azlogs v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2 v2.1.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v4 v4.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v6 v6.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v9 v9.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v9 v9.7.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid/v2 v2.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor v0.12.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers/v2 v2.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v8 v8.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights/v2 v2.0.2 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v2 v2.1.1 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis/v3 v3.3.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armlocks v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0 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/azcertificates v1.5.0 @@ -264,17 +283,21 @@ require ( github.com/aws/aws-sdk-go-v2/service/workspaces v1.81.4 github.com/aws/aws-sdk-go-v2/service/xray v1.45.3 github.com/aws/smithy-go v1.28.5 + github.com/blevesearch/snowballstem v0.9.0 github.com/cedar-policy/cedar-go v1.8.0 + github.com/containerd/errdefs v1.0.0 github.com/distribution/distribution/v3 v3.1.2 github.com/dop251/goja v0.0.0-20261012212121-0c308c5ed11b github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/go-mysql-org/go-mysql v1.16.0 github.com/go-sql-driver/mysql v1.10.1 github.com/go-stomp/stomp/v3 v3.1.5 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/googleapis/gax-go/v2 v2.26.2 + github.com/googleapis/gax-go/v2 v2.27.0 github.com/gorilla/websocket v1.5.3 + github.com/iskorotkov/avro/v2 v2.35.0 github.com/jackc/pgx/v5 v5.11.0 github.com/klauspost/compress v1.20.3 github.com/labstack/echo/v5 v5.4.1 @@ -283,6 +306,7 @@ require ( github.com/moby/moby/api v1.56.1 github.com/moby/moby/client v0.6.2 github.com/mochi-mqtt/server/v2 v2.7.9 + github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/mxschmitt/playwright-go v0.6201.1 github.com/opencontainers/image-spec v1.1.1 github.com/oracle/oci-go-sdk/v65 v65.126.2 @@ -298,13 +322,14 @@ require ( github.com/twmb/franz-go v1.22.1 github.com/twmb/franz-go/pkg/kmsg v1.15.0 github.com/vektah/gqlparser/v2 v2.5.62 + go.mongodb.org/mongo-driver/v2 v2.9.2 go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.58.0 golang.org/x/mod v0.42.0 - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/sync v0.24.0 golang.org/x/text v0.43.0 - google.golang.org/api v0.302.0 + google.golang.org/api v0.304.0 google.golang.org/genproto v0.0.0-20261012220534-4341edf3f941 google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 @@ -332,6 +357,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/Microsoft/go-winio v0.6.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr/antlr4 v0.0.0-20181218183524-be58ebffde8e // indirect @@ -351,10 +377,10 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.2.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/distribution/reference v0.6.0 // indirect @@ -369,7 +395,7 @@ require ( github.com/ebitengine/purego v0.11.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect @@ -378,12 +404,17 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-stack/stack v1.8.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect - github.com/gofrs/flock v0.10.0 // indirect + github.com/gofrs/flock v0.13.1 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/flatbuffers v25.12.19+incompatible // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20261012231050-2af12c3f4d87 // indirect github.com/google/s2a-go v0.1.11 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect @@ -401,7 +432,7 @@ require ( github.com/kr/fs v0.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/magiconair/properties v1.18.12 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.3.3 // indirect @@ -410,6 +441,7 @@ require ( github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.2.1 // indirect github.com/moby/term v0.5.2 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.1.0 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.0 // indirect @@ -418,6 +450,9 @@ require ( github.com/parquet-go/bitpack v1.1.0 // indirect github.com/parquet-go/jsonlite v1.5.5 // indirect github.com/pierrec/lz4/v4 v4.1.33 // indirect + github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 // indirect + github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20260504140133-511dba1dbe17 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect github.com/prometheus/common v0.72.1 // indirect @@ -427,6 +462,8 @@ require ( github.com/redis/go-redis/extra/redisotel/v9 v9.23.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.6.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shirou/gopsutil/v4 v4.26.9 // indirect github.com/shopspring/decimal v1.5.0 // indirect github.com/sirupsen/logrus v1.10.2 // indirect @@ -438,6 +475,10 @@ require ( github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/twpayne/go-geom v1.7.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/gopher-lua v1.1.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect @@ -469,6 +510,8 @@ require ( go.opentelemetry.io/otel/trace v1.47.0 // indirect go.opentelemetry.io/proto/otlp v1.11.1 // indirect go.uber.org/atomic v1.12.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 // indirect @@ -480,6 +523,8 @@ require ( golang.org/x/tools v0.52.0 // indirect golang.org/x/vuln v1.7.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gotest.tools/gotestsum v1.13.0 // indirect modernc.org/libc v1.78.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index ba07f693fe..e99052d112 100644 --- a/go.sum +++ b/go.sum @@ -3,12 +3,16 @@ cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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/apigateway v1.17.0 h1:cG8FLt7mBkqDZpwf4l6dL4Ax6EtP4WbV/ix5Oy0ZaFg= +cloud.google.com/go/apigateway v1.17.0/go.mod h1:dulzWUXhPZvcFqI6ddDvAtn0xui8vm8/ZnqQFHcAs6k= cloud.google.com/go/artifactregistry v1.27.0 h1:EBgdk9sXNAVyWJ3hnTmWRdlmKhGDKCjkJbLoF3EsyWE= cloud.google.com/go/artifactregistry v1.27.0/go.mod h1:r0rZgbNmn5ppdqwUMF182yc7EFH6vabJMbXM+PlfW9A= cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs= cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c= cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/batch v1.24.1 h1:GP1fSB77EVbxa6r3nDubRvASqYkFzsvn8iq2PJ3yNDQ= +cloud.google.com/go/batch v1.24.1/go.mod h1:YR1Im5HR58fI+AbPPNcYLxnItFgqf4G5xuzjkjNWiLs= cloud.google.com/go/bigquery v1.85.0 h1:zsFsa8jOVkU4c7CWE1cbrfsemtNbM3YRUmtFRYXYN58= cloud.google.com/go/bigquery v1.85.0/go.mod h1:oBma1P5/b1Jtd8xRLKoyTeNIMlACGHbSMLudzxHGHgc= cloud.google.com/go/bigtable v1.58.0 h1:fkf7bI0DBkKN85uRrfEEpkbufSc48b4PVIZCRud3xRk= @@ -21,8 +25,12 @@ cloud.google.com/go/compute v1.72.0 h1:cwmwK/ADu7G1DNRN2Dt+qTpdg1hrAqzArBSmgSFum cloud.google.com/go/compute v1.72.0/go.mod h1:L8Z2360/cq2L/Z3q1o/D/jTFkzbgKLbgERpOm4/cepE= cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +cloud.google.com/go/container v1.56.0 h1:ySw+ybbeCaoEoYunWQjpcA96n+NG9echRil1/BMirIQ= +cloud.google.com/go/container v1.56.0/go.mod h1:S30jo4FsHqHtBPQb+X1APLJl8hq0K2ajdVessfC4kQA= cloud.google.com/go/datacatalog v1.32.0 h1:fyYn8ODkGil5y3zTIqgIhOfzTu1ACaU2o+C750CO6Ac= cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM= +cloud.google.com/go/datastore v1.25.0 h1:zUjMnCLCcRZVDSdQIXsbnNCl1SVRNw5Jm0J77gPaPKs= +cloud.google.com/go/datastore v1.25.0/go.mod h1:jvJVNe+S2nHVIndV1H/B4s9K3MLsTMqOKlxSrzHTxB4= cloud.google.com/go/eventarc v1.26.0 h1:qrJ8Hx96KLDqU9yQdmAly6Cq1w0d5t/SOOpDWRFrfNQ= cloud.google.com/go/eventarc v1.26.0/go.mod h1:vjPsVSad+d/UNOd4k/3/E9CX/5p8k0ChjxoBU7H6I2I= cloud.google.com/go/firestore v1.26.0 h1:7Y6wn4aj5JXl2DAsKSTpLzYKPrfrIbhgQnHDjNOJ3sQ= @@ -43,12 +51,16 @@ cloud.google.com/go/pubsub/v2 v2.7.0 h1:MFrBTZZa6PDWZzCi4NJRsHKMm2w0a4oAaYNqwjgb cloud.google.com/go/pubsub/v2 v2.7.0/go.mod h1:JaFvWNVRk3Knoil/4M1ECeLOaI9D8drbmJWypQlK5aM= cloud.google.com/go/redis v1.26.0 h1:RgLGd28s+EBqXxwk82SMn0aU0WvlmFezpniY9MGhgNQ= cloud.google.com/go/redis v1.26.0/go.mod h1:nHRRMFXWJv7GfVQEbg0sQzN1MGIkl/q0mSOYG2l6Ylg= +cloud.google.com/go/resourcemanager v1.18.1 h1:xdaDmfzM08PA2Zs8hU8ej1TXQTJAcRoHbvQpaAEPYj0= +cloud.google.com/go/resourcemanager v1.18.1/go.mod h1:LjIioOpdFo5J1aFG9R51YqeBN5L55vElAG70dzFD/wg= cloud.google.com/go/run v1.23.0 h1:JvTkOn+8h+QicGzBPWjgSrwpTcs2b6zJkusfEnmvkGo= cloud.google.com/go/run v1.23.0/go.mod h1:ZCGrir3QdVgAJsVChr2nlQTfaknro8nwpEZwJDRHHhI= cloud.google.com/go/scheduler v1.17.0 h1:jFAEvWG9QDwDWYMgrXZBFi+f1EehetCmM0VE7OpPqJw= cloud.google.com/go/scheduler v1.17.0/go.mod h1:16h1vcu+XuadWlJjfKaUn3TWyzedOH+RGoylY2EQix8= cloud.google.com/go/secretmanager v1.22.0 h1:c9nPLiK4IZeT/zDyLjvNaBw1BHNkp0Ysybj1FfFIAPQ= cloud.google.com/go/secretmanager v1.22.0/go.mod h1:aDN9cW5x6Y8QVj32snakZv96vYyW7Nf1P+eqZGH8408= +cloud.google.com/go/serviceusage v1.16.1 h1:V5ULuLqQ+vu5FqkNB/66FlsyudFdofA5tI4C3U0p/rQ= +cloud.google.com/go/serviceusage v1.16.1/go.mod h1:2klVRUAhaQNJbIVYlOcWC2D0R/J1TTUQFqCLyaaIs+8= cloud.google.com/go/spanner v1.96.1 h1:pLmq5/Fj+Yu6YGRBz9oMPm3ZRQIeG14FYBWFwhZZaIA= cloud.google.com/go/spanner v1.96.1/go.mod h1:VqMSp/YlTofUq1q6ekkXBAmhAs6X860YL0I8wo7i+k0= cloud.google.com/go/storage v1.69.0 h1:jAAMC1411HEh78nKsU0Zns+eFj3TnhjAWIhg5Ud/XBM= @@ -63,12 +75,14 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +firebase.google.com/go/v4 v4.22.0 h1:+7ClMN69jOITseDXC7q0OKjMkRl3ZEJwZaLB900mvYs= +firebase.google.com/go/v4 v4.22.0/go.mod h1:tXyH/bqgOst15PmgHUSFaLX9woyDwclTtz/cRX+NocQ= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0 h1:w3gDUJlJ0BfBsHH02DCBDwbkmMSXJqBj6nBOCo9iocY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0/go.mod h1:7scl8mG/IFLVjJ9ef/ltdND2T1z0CkgVSaQoUxLvmeU= github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.2.0 h1:80ijeACessBdODMsTjR0zf7t1Uvhc+hCqcXc0BcW9ZA= github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.2.0/go.mod h1:RPzAQTYyoren3gG5K0QSiWTSUhGCB47LY//1MGdODv0= github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.5.0 h1:wtCn7MemMD9eo4/NdpJ6S/MFD2BV2CDwoEfvl5th2vM= @@ -89,12 +103,22 @@ github.com/Azure/azure-sdk-for-go/sdk/monitor/ingestion/azlogs v1.1.0 h1:Q+tp/BW github.com/Azure/azure-sdk-for-go/sdk/monitor/ingestion/azlogs v1.1.0/go.mod h1:et3yi6OrdxM8YK0pfOwpHSLf4gWypxQVWh4T9wRzg3k= github.com/Azure/azure-sdk-for-go/sdk/monitor/query/azlogs v1.2.0 h1:KzTYJVNtaApcR6Yav9kVRXXwtVpMakAqyOpmiwjtO90= github.com/Azure/azure-sdk-for-go/sdk/monitor/query/azlogs v1.2.0/go.mod h1:a+dxW5k1ZbYaibMYrFuhZEZELPgZslm81QR4CMchMX0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2 v2.1.0 h1:WYADp5XlioccEnBBK9sVUaHVno76l7WeTcWCumN86kM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2 v2.1.0/go.mod h1:PK8v1aAd2Wx6eTcbUYhYstGpspqNqhZYiM8GLFdq2A0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v4 v4.0.0 h1:sS//OfnHmbzZ4P9g/XpGLzaa+Hg1eYxmkAJ5MRZJDqM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v4 v4.0.0/go.mod h1:rM90CKcOtgGp+kdA33rPdmu5qRx+zvsBonck3ajhT+A= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v6 v6.0.0 h1:vhXsRicy0J6KLMsd7sAL6ll2CMEag2V2tIpuVI2CZP0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v6 v6.0.0/go.mod h1:8bqI53aVbpaMreTHLfgG5VcqQKmfBc99UZtPozthauQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v9 v9.0.0 h1:GhX9IxyAokIUIIYs4v7PjK+tTUM72dpIjtDhB8OkQ9s= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v9 v9.0.0/go.mod h1:uStAlZ315xBw8h/xv12/hsAEo+hFlwZH4MeD0YVaUp4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0 h1:+dIXMjlifRbG3d01DF8dwckUSXADuW5dgBNt1fbkpv0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0/go.mod h1:FN0UJ15tJ7kV7JYrYAleEq44Ew1cUiyLcJrfrTxHGd0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0 h1:DWlwvVV5r/Wy1561nZ3wrpI1/vDIBRY/Wd1HWaRBZWA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0/go.mod h1:E7ltexgRDmeJ0fJWv0D/HLwY2xbDdN+uv+X2uZtOx3w= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v9 v9.7.0 h1:Su+wzyfj14nd/cDvZ/B0WJYY+OGT0elZw5yrWY/3pZQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v9 v9.7.0/go.mod h1:NJWUwxR3ahbD53ia/cTjBSr+0hYHCTjSSoIZ0A2cvLo= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid/v2 v2.3.0 h1:8JkRfARpQbzTzD+HGQAf67VIqQg3qXLIcAqtPYr/V0Q= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid/v2 v2.3.0/go.mod h1:NOM/LtCJfoU69VkmxPxV6PMxDm/MsOvmsao/5V5Rhgs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0 h1:4hGvxD72TluuFIXVr8f4XkKZfqAa7Pj61t0jmQ7+kes= @@ -107,12 +131,26 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5. github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor v0.12.0 h1:qmeVFr8V9dDzhLZDDgdqyOEl+XLhhMpm7xrOajxqKuk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor v0.12.0/go.mod h1:x/aYmuTilxu4yQMFj2r3ZE3iiO2wxYFFmh6dRg2IWiQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 h1:L7G3dExHBgUxsO3qpTGhk/P2dgnYyW48yn7AO33Tbek= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0/go.mod h1:Ms6gYEy0+A2knfKrwdatsggTXYA2+ICKug8w7STorFw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers/v2 v2.0.0 h1:iyx6jFFyRo8/9FQiyHRTjoUluopwt+9lvOtRBhfB7Y8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers/v2 v2.0.0/go.mod h1:LSdxfHhUzKllvM+sYDf1wrcQrbInrF4iXJ/NMk7fK90= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v8 v8.0.0 h1:7QO7GhGat25QEYL4h607O9zNNTUlAv8PbSesW6Ol5Gg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v8 v8.0.0/go.mod h1:mCqeYzwyjn/pw0JVqHJMIzfUQJrlcV0YjTg5b0NK+F0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights/v2 v2.0.2 h1:SFLbQmpdytToYZQJw5NqrZRwHPIGJmf5ZgjStbLfUuU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights/v2 v2.0.2/go.mod h1:H3EFkhcVTisidszwtIkRDggjS2HmOIA26J3g8hDdHAY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v2 v2.1.1 h1:iu8l6XmviyuNbBQ/WO5shgLBU2+g18Cdw1iE1Xk2+ig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v2 v2.1.1/go.mod h1:8Anbzn23yMdpl2DDY4qnFEPY9Vf6CoLphIt4mX59McI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis/v3 v3.3.0 h1:EkL5dmUoy1OlzVfsbkcHayOvOJgheyRYL3wM/RHizzg= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis/v3 v3.3.0/go.mod h1:DiazWkJHUUKUZGpIdV7JhDTjebBxdfsJ386dE5w7G3o= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0 h1:67nFqWXpo0x5Nz0XEb1yI7s8D+EHy8NsTinYw9sZnLk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0/go.mod h1:fewgRjNVE84QVVh798sIMFb7gPXPp7NmnekGnboSnXk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armlocks v1.2.0 h1:CMp8GwmUfS/Stg5KBgduD8rPIk9GNj1HMaID/gUAJYg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armlocks v1.2.0/go.mod h1:GE1wqa9Ny9eZ8wHtHqbCE7mMsFfVbdEY0itmzYV8JEg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0 h1:FKYUd0TZfjjWtdk7LR+6rI64yNY1gMe9rNV40Y2Fh4o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0/go.mod h1:w987ZDGBg74HZu5sAqaBEhDTlvlJlxPggIhBiDviYhE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE= @@ -141,11 +179,11 @@ github.com/Azure/go-amqp v1.7.0 h1:9VlH/LEWr386XWWJRNON0eslFqSClYBXP4HewvIqkDQ= github.com/Azure/go-amqp v1.7.0/go.mod h1:pCJaHsvRlmmFUpxyQbh2qPkUFqYJeRBTqJSHKJadvPg= github.com/Azure/go-ansiterm v0.0.0-20261008234906-119c4d577770 h1:n3LUIJHwvkpfvfkt0dD7at4iYfQD24ML2jflWi3r2ew= github.com/Azure/go-ansiterm v0.0.0-20261008234906-119c4d577770/go.mod h1:DMNF6Kxv+EcuUvVuNkvC3f+BjEEmc/Fn9aU+qiD8zpU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI= -github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 h1:0O6j18nQDoIff6/mtosT5LNU2WehEwp+LqfX6699oqY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1/go.mod h1:xdYAf5bjkOpsd3auA8riiJW4vneBubt9caavL7626jQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0 h1:BzsL0qE7LvtTEtXG7Dt5NS1EP0CQwI21HZfj9aGghhw= @@ -160,6 +198,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= @@ -622,6 +662,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= +github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -649,6 +691,8 @@ github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -699,8 +743,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -714,6 +758,8 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-mysql-org/go-mysql v1.16.0 h1:odv4Ygtc1WHJv3uUF2aoJdE1RS7tA0sD3ET91ZAWQIg= +github.com/go-mysql-org/go-mysql v1.16.0/go.mod h1:VjBTZTTDKL8OMXUAhNbg3VHaVVq9HOXJEBLpAKBFIfE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -725,14 +771,19 @@ github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-stomp/stomp/v3 v3.1.5 h1:Pikz1OSusmSKUm5mRKYfXQZaDatfZ+EnBBA1JJ2xENQ= github.com/go-stomp/stomp/v3 v3.1.5/go.mod h1:ztzZej6T2W4Y6FlD+Tb5n7HQP3/O5UNQiuC169pIp10= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k= -github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -756,8 +807,11 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= @@ -770,8 +824,11 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20261012231050-2af12c3f4d87 h1:P18JvwDzBYAeC8SIfnee/YJzKk1dysuN6rUwmF6CxZY= @@ -787,8 +844,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.23 h1:J+VGF1wjAM2dU2kKUXZxJdDppRLReWP7KWcclfngQhc= github.com/googleapis/enterprise-certificate-proxy v0.3.23/go.mod h1:Y8NO/15GNLkrXI3OR5xOFmeU3XLX1sLSdtlXgJ0zmIw= -github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5L5izk8= -github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= +github.com/googleapis/gax-go/v2 v2.27.0 h1:qe2iQOUzxb3fd6x6ewpIEixPQhJ+0Ihc/RRZhKA/2g0= +github.com/googleapis/gax-go/v2 v2.27.0/go.mod h1:RofiqDw3ehcG4L0H1zO3D2Itpwd3/uS/7wdmbNDF1DE= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -803,6 +860,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/iskorotkov/avro/v2 v2.35.0 h1:UJoOjHeInrzB09APUZP28nJxyWeKenrPctDVx3VS3C4= +github.com/iskorotkov/avro/v2 v2.35.0/go.mod h1:msR3h6CL3GwE4r6s8LgN1dUUHlm8mHx8tVvCZF8H5Xo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -840,9 +899,8 @@ github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qk github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.18.12 h1:sT9zQpvTB3B4gzrX0tmZNTEaGyg8Zw55MFYRE32Mr9I= github.com/magiconair/properties v1.18.12/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/microsoft/go-mssqldb v1.11.2 h1:FCgeBIK8um2+X4tbun6Q71N1KsfyCDPKY41e1yGVjSE= @@ -873,6 +931,10 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxschmitt/playwright-go v0.6201.1 h1:KBBDopE+IIdjAp2FIk6zhLcKCCSSx/T6rGjKZmfo/Bs= @@ -897,6 +959,12 @@ github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayM github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg= github.com/pierrec/lz4/v4 v4.1.33 h1:GjG1TJ1V4IzKP8L96muuuDNpTwd7D+l2ccXrjAbe014= github.com/pierrec/lz4/v4 v4.1.33/go.mod h1:7SE9MC2STkNtL4PIwGhjmyVwvILaGI9/COYQNBhKM/c= +github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 h1:cLgCk5mwDG9lDH+dPK8TmEliTjyGJwwKN0qevWAl8IY= +github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2/go.mod h1:ktAJCA9lxrHHjVyVl2pKJFvzBnq2eZbb+CUOjBRPlXo= +github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 h1:A2bEfgSb7hLwR9mxDszgGKweF+xY9YoTDG+8RjdFjDE= +github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1/go.mod h1:pxfz2oJfAuhwrb3/rcLqD//GS/5gRP4gD022iP3cEO0= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260504140133-511dba1dbe17 h1:cfAVPis6GP6lxQgm1WGaNGi4rVXTB4KDvYf96LjqRCM= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260504140133-511dba1dbe17/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= @@ -931,10 +999,14 @@ github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBP github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc= @@ -948,6 +1020,7 @@ github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnO github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -955,6 +1028,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= @@ -977,11 +1052,20 @@ github.com/twpayne/go-geom v1.7.0 h1:zMHTHNlenoHAc2ZSiHFh7SMoG0Wdw8gF0orBnrBhfjE github.com/twpayne/go-geom v1.7.0/go.mod h1:y9Q2YcKKaC2PHXwtRvwEunOPx5Oa+U8aAxPmFvscROk= github.com/vektah/gqlparser/v2 v2.5.62 h1:GJP/emj1wIWKs0U3SHcAgzknWgylTqQ1qH2gNL+wSWc= github.com/vektah/gqlparser/v2 v2.5.62/go.mod h1:JNK+plRwKdXLsF/qPFPe5tE0z4s1WeroD9S5LR8um/Q= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -990,6 +1074,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.9.2 h1:OCAdjI37pC/SVxucz/9CM8zD4jCdegtsY5QcKzYFCgE= +go.mongodb.org/mongo-driver/v2 v2.9.2/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -1046,10 +1132,15 @@ go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrF go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= @@ -1057,6 +1148,7 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1066,6 +1158,7 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1075,9 +1168,11 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= @@ -1085,6 +1180,7 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1096,17 +1192,22 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20261009151841-97e8ea77aa2a h1:wnveMBKHwx8/nIb8WYT5P9vtefdXOvIWxzycCkc5n6Q= golang.org/x/telemetry v0.0.0-20261009151841-97e8ea77aa2a/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= @@ -1118,6 +1219,7 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= @@ -1134,10 +1236,12 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.302.0 h1:/eENsEv3+GE0QiEmXjdaLI84Mt76LNjGz4vp4E7sRVw= -google.golang.org/api v0.302.0/go.mod h1:dcjRWAy1QdjGb3Z67Qu0gdBWH2gL8Qoy/RI2ZVuvdeE= +google.golang.org/api v0.304.0 h1:2R1dYSiIVQRGcKN901u9yPFEdJu5h90oGY5X4TcgbmA= +google.golang.org/api v0.304.0/go.mod h1:dcjRWAy1QdjGb3Z67Qu0gdBWH2gL8Qoy/RI2ZVuvdeE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= @@ -1163,11 +1267,15 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/app/acm_rgtapi_tags_wiring_test.go b/internal/app/acm_rgtapi_tags_wiring_test.go index 55123d23aa..ce82d206b3 100644 --- a/internal/app/acm_rgtapi_tags_wiring_test.go +++ b/internal/app/acm_rgtapi_tags_wiring_test.go @@ -30,7 +30,7 @@ func TestInitializeServices_ACMTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/adapters.go b/internal/app/adapters.go index e1b2c0ed99..75492269e9 100644 --- a/internal/app/adapters.go +++ b/internal/app/adapters.go @@ -192,3 +192,17 @@ func (a *azureKeyVaultsAdapter) SoftDeleteVault(name string) { a.backend.SoftDel func (a *azureKeyVaultsAdapter) RecoverVault(name string) { a.backend.RecoverVault(name) } func (a *azureKeyVaultsAdapter) PurgeVault(name string) { a.backend.PurgeVault(name) } + +// SecretValue reads a secret's value for container app keyVaultUrl references. +func (a *azureKeyVaultsAdapter) SecretValue(vault, name, version string) (string, error) { + s, err := a.backend.GetSecret(vault, "", name, version) + if err != nil { + return "", err + } + + if s.Value == nil { + return "", nil + } + + return *s.Value, nil +} diff --git a/internal/app/adapters_cosmos.go b/internal/app/adapters_cosmos.go index 9e575a519a..97f06b39a7 100644 --- a/internal/app/adapters_cosmos.go +++ b/internal/app/adapters_cosmos.go @@ -19,7 +19,10 @@ type azureCosmosAdapter struct { handler *cosmosdb.Handler } -var _ azurearm.CosmosResources = (*azureCosmosAdapter)(nil) +var ( + _ azurearm.CosmosResources = (*azureCosmosAdapter)(nil) + _ azurearm.CosmosPolicyResources = (*azureCosmosAdapter)(nil) +) func (a *azureCosmosAdapter) Endpoint() (int, bool) { return a.handler.Port, a.handler.TLS } func (a *azureCosmosAdapter) MasterKey() string { return a.handler.MasterKey } @@ -40,6 +43,17 @@ func (a *azureCosmosAdapter) SetRegions(regions []azurearm.CosmosRegion, multiWr setter.SetAccountRegions(out) } +func (a *azureCosmosAdapter) SetAccountPolicies(p azurearm.CosmosAccountPolicies) { + setter, ok := a.handler.Backend.(interface { + SetAccountPolicies(cosmosdb.AccountPolicies) + }) + if !ok { + return + } + + setter.SetAccountPolicies(cosmosdb.AccountPolicies(p)) +} + func cosmosErr(err error) error { switch { case err == nil: diff --git a/internal/app/amp_rgtapi_tags_wiring_test.go b/internal/app/amp_rgtapi_tags_wiring_test.go index e54dd5b13e..26c261aa62 100644 --- a/internal/app/amp_rgtapi_tags_wiring_test.go +++ b/internal/app/amp_rgtapi_tags_wiring_test.go @@ -20,7 +20,7 @@ func TestInitializeServices_AMPTagsWiring(t *testing.T) { appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/apigateway_rgtapi_tags_wiring_test.go b/internal/app/apigateway_rgtapi_tags_wiring_test.go index 84b7e2e624..5f19fa4011 100644 --- a/internal/app/apigateway_rgtapi_tags_wiring_test.go +++ b/internal/app/apigateway_rgtapi_tags_wiring_test.go @@ -32,7 +32,7 @@ func TestInitializeServices_APIGatewayTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/applicationautoscaling_dynamodb_wiring_test.go b/internal/app/applicationautoscaling_dynamodb_wiring_test.go index b3ee8f4156..b0be70f5ea 100644 --- a/internal/app/applicationautoscaling_dynamodb_wiring_test.go +++ b/internal/app/applicationautoscaling_dynamodb_wiring_test.go @@ -28,7 +28,7 @@ func TestInitializeServices_ApplicationAutoscalingDynamoDBWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/appsync_rgtapi_tags_wiring_test.go b/internal/app/appsync_rgtapi_tags_wiring_test.go index 0aa13de2ca..177ccc17a9 100644 --- a/internal/app/appsync_rgtapi_tags_wiring_test.go +++ b/internal/app/appsync_rgtapi_tags_wiring_test.go @@ -29,7 +29,7 @@ func TestInitializeServices_AppSyncTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/appsync_sigv4_wiring_test.go b/internal/app/appsync_sigv4_wiring_test.go index b58a496415..dd9aec6ea8 100644 --- a/internal/app/appsync_sigv4_wiring_test.go +++ b/internal/app/appsync_sigv4_wiring_test.go @@ -66,7 +66,7 @@ func TestInitializeServices_AppSyncSigV4SecretWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) h, ok := serviceByName(services)["AppSync"].(*appsyncbackend.Handler) diff --git a/internal/app/arc_recovery_test.go b/internal/app/arc_recovery_test.go index 1f788786fb..d25f5ed712 100644 --- a/internal/app/arc_recovery_test.go +++ b/internal/app/arc_recovery_test.go @@ -5,7 +5,6 @@ import ( "log/slog" "net" - "net/http" "net/http/httptest" "strconv" "testing" @@ -25,6 +24,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/chaos" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // arcStack serves the full production composition root on a real listener, so cluster endpoints resolve back to it. @@ -39,7 +39,7 @@ func arcStack(t *testing.T) aws.Config { cli := &CLI{AccountID: "000000000000", Region: regionA, Port: ":" + strconv.Itoa(port)} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices( + services, err := initializeServicesT(t, &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, ) require.NoError(t, err) @@ -56,6 +56,7 @@ func arcStack(t *testing.T) aws.Config { WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()). RouteHandler(), ) @@ -66,7 +67,7 @@ func arcStack(t *testing.T) aws.Config { cfg, err := awscfg.LoadDefaultConfig(t.Context(), awscfg.WithRegion("us-west-2"), - awscfg.WithHTTPClient(http.DefaultClient), + awscfg.WithHTTPClient(testclient.New(t)), awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) require.NoError(t, err) diff --git a/internal/app/asg_ec2_launch_template_wiring_test.go b/internal/app/asg_ec2_launch_template_wiring_test.go index 6f0feeb212..e1a34dc3c7 100644 --- a/internal/app/asg_ec2_launch_template_wiring_test.go +++ b/internal/app/asg_ec2_launch_template_wiring_test.go @@ -42,7 +42,7 @@ func newASGEC2LaunchTemplateWiringClients(t *testing.T) (*ec2sdk.Client, *assdk. } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/athena_engine.go b/internal/app/athena_engine.go index ad229ce558..f6e3a8e924 100644 --- a/internal/app/athena_engine.go +++ b/internal/app/athena_engine.go @@ -160,3 +160,23 @@ func (a *athenaGlueAdapter) CreatePartitionDefs(db, table string, parts []athena return fmt.Errorf("%w: %s", errGluePartition, perrs[0].ErrorDetail.ErrorMessage) } + +var _ interface { + CommitTableMetadata(db, name, expected, next string) error +} = (*athenaGlueAdapter)(nil) + +// CommitTableMetadata swaps an Iceberg table's metadata_location, failing when it moved from expected. +func (a *athenaGlueAdapter) CommitTableMetadata(db, name, expected, next string) error { + t, err := a.glueTable(db, name) + if err != nil { + return err + } + + err = a.backend.SwapTableParameters(t.DatabaseName, t.Name, "metadata_location", expected, + map[string]string{"metadata_location": next, "previous_metadata_location": expected}) + if errors.Is(err, gluebackend.ErrTableParameterChanged) { + return athenabackend.ErrCatalogCommitConflict + } + + return err +} diff --git a/internal/app/athena_s3tables.go b/internal/app/athena_s3tables.go new file mode 100644 index 0000000000..9fa3c00937 --- /dev/null +++ b/internal/app/athena_s3tables.go @@ -0,0 +1,79 @@ +package app + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + athenabackend "github.com/blackbirdworks/gopherstack/services/athena" + s3tablesbackend "github.com/blackbirdworks/gopherstack/services/s3tables" +) + +var _ athenabackend.S3TablesSource = (*athenaS3TablesAdapter)(nil) + +// athenaS3TablesAdapter serves Athena's s3tablescatalog/ federated catalog from S3 Tables. +type athenaS3TablesAdapter struct { + handler *s3tablesbackend.Handler +} + +// wireAthenaStorage connects Athena to S3 (result objects, table data) and to S3 Tables. +func wireAthenaStorage(byName map[string]service.Registerable) { + wireAthenaS3(byName["Athena"], byName["S3"]) + wireAthenaS3Tables(byName["Athena"], byName["S3tables"]) +} + +// wireAthenaS3Tables lets Athena query and append to S3 Tables through the federated catalog. +func wireAthenaS3Tables(athenaReg, s3tablesReg service.Registerable) { + athenaH, ok := athenaReg.(*athenabackend.Handler) + if !ok { + return + } + + athenaBk, bkOk := athenaH.Backend.(*athenabackend.InMemoryBackend) + if !bkOk { + return + } + + tablesH, tOk := s3tablesReg.(*s3tablesbackend.Handler) + if !tOk { + return + } + + athenaBk.SetS3TablesSource(&athenaS3TablesAdapter{handler: tablesH}) +} + +func (a *athenaS3TablesAdapter) GetTableInfo( + region, bucket, namespace, table string, +) (*athenabackend.S3TableInfo, error) { + bk := a.handler.BackendFor(region) + + t, err := bk.GetTable(bk.TableBucketARN(bucket), []string{namespace}, table) + if err != nil { + return nil, err + } + + return &athenabackend.S3TableInfo{MetadataLocation: t.MetadataLocation, Location: t.WarehouseLocation}, nil +} + +func (a *athenaS3TablesAdapter) CommitMetadataLocation(region, bucket, namespace, table, expected, next string) error { + bk := a.handler.BackendFor(region) + arn := bk.TableBucketARN(bucket) + + t, err := bk.GetTable(arn, []string{namespace}, table) + if err != nil { + return err + } + + if t.MetadataLocation != expected { + return athenabackend.ErrCatalogCommitConflict + } + + if _, err = bk.UpdateTableMetadataLocation(arn, []string{namespace}, table, next, t.VersionToken); err != nil { + if errors.Is(err, s3tablesbackend.ErrTableVersionConflict) { + return athenabackend.ErrCatalogCommitConflict + } + + return err + } + + return nil +} diff --git a/internal/app/azureappservice_slots_sdk_test.go b/internal/app/azureappservice_slots_sdk_test.go new file mode 100644 index 0000000000..ca8b98ce6e --- /dev/null +++ b/internal/app/azureappservice_slots_sdk_test.go @@ -0,0 +1,802 @@ +package app + +import ( + "archive/zip" + "bytes" + "errors" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const slotSDKApp = "swapapp" + +func (s *fnSDKStack) createPlanSKU(t *testing.T, name, kind, sku, tier string) *armappservice.Plan { + t.Helper() + + poller, err := s.factory.NewPlansClient().BeginCreateOrUpdate(t.Context(), webSDKRG, name, armappservice.Plan{ + Location: new("local"), Kind: new(kind), + SKU: &armappservice.SKUDescription{Name: new(sku), Tier: new(tier)}, + Properties: &armappservice.PlanProperties{Reserved: new(true)}, + }, nil) + require.NoError(t, err) + + res, err := poller.PollUntilDone(t.Context(), pollOpts()) + require.NoError(t, err) + + return &res.Plan +} + +func (s *fnSDKStack) createSlot(t *testing.T, app, slot string, props *armappservice.SiteProperties) error { + t.Helper() + + poller, err := s.factory.NewWebAppsClient().BeginCreateOrUpdateSlot(t.Context(), webSDKRG, app, slot, + armappservice.Site{Location: new("local"), Kind: new("app,linux"), Properties: props}, nil) + if err != nil { + return err + } + + _, err = poller.PollUntilDone(t.Context(), pollOpts()) + + return err +} + +// slotStack is a Standard-tier web app "swapapp" with a "staging" slot, both running the same stack. +type slotStack struct { + *fnSDKStack + planID string +} + +func newSlotStack(t *testing.T) *slotStack { + t.Helper() + + s := newFnSDKStack(t) + plan := s.createPlanSKU(t, "slotplan", "linux", "S1", "Standard") + _, err := s.createWebApp(t, slotSDKApp, *plan.ID, &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}) + require.NoError(t, err) + + err = s.createSlot(t, slotSDKApp, "staging", &armappservice.SiteProperties{ + SiteConfig: &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}, + }) + require.NoError(t, err) + + return &slotStack{fnSDKStack: s, planID: *plan.ID} +} + +func (s *slotStack) host(slot string) string { + if slot == "" { + return slotSDKApp + "." + fnSDKDomain + } + + return slotSDKApp + "-" + slot + "." + fnSDKDomain +} + +func (s *slotStack) deploy(t *testing.T, slot, marker string) { + t.Helper() + + name := slotSDKApp + if slot != "" { + name += "/" + slot + } + + user, pass, _, err := s.backend.PublishingCredentials(name) + require.NoError(t, err) + + zipped := zipOfFiles(t, map[string]string{"server.js": "1", "marker.txt": marker}) + scmHost := slotSDKApp + ".scm." + fnSDKDomain + + if slot != "" { + scmHost = slotSDKApp + "-" + slot + ".scm." + fnSDKDomain + } + + code, body := s.call(t, http.MethodPost, scmHost, "/api/zipdeploy", zipped, user, pass) + require.Equal(t, http.StatusOK, code, body) +} + +func (s *slotStack) get(t *testing.T, slot, path string) string { + t.Helper() + + code, body := s.call(t, http.MethodGet, s.host(slot), path, nil, "", "") + require.Equal(t, http.StatusOK, code, body) + + return body +} + +func (s *slotStack) setStagingSettings(t *testing.T, kv map[string]string) { + t.Helper() + + props := map[string]*string{} + for k, v := range kv { + props[k] = new(v) + } + + _, err := s.factory.NewWebAppsClient().UpdateApplicationSettingsSlot( + t.Context(), webSDKRG, slotSDKApp, "staging", armappservice.StringDictionary{Properties: props}, nil) + require.NoError(t, err) +} + +func (s *slotStack) setProdSettings(t *testing.T, kv map[string]string) { + t.Helper() + + props := map[string]*string{} + for k, v := range kv { + props[k] = new(v) + } + + _, err := s.factory.NewWebAppsClient().UpdateApplicationSettings( + t.Context(), webSDKRG, slotSDKApp, armappservice.StringDictionary{Properties: props}, nil) + require.NoError(t, err) +} + +func (s *slotStack) setConnStrings(t *testing.T, slot string, kv map[string]string) { + t.Helper() + + props := map[string]*armappservice.ConnStringValueTypePair{} + for k, v := range kv { + props[k] = &armappservice.ConnStringValueTypePair{ + Value: new(v), + Type: new(armappservice.ConnectionStringTypeSQLAzure), + } + } + + dict := armappservice.ConnectionStringDictionary{Properties: props} + apps := s.factory.NewWebAppsClient() + + var err error + if slot == "" { + _, err = apps.UpdateConnectionStrings(t.Context(), webSDKRG, slotSDKApp, dict, nil) + } else { + _, err = apps.UpdateConnectionStringsSlot(t.Context(), webSDKRG, slotSDKApp, slot, dict, nil) + } + + require.NoError(t, err) +} + +func (s *slotStack) stickyNames(t *testing.T, appSettings, connStrings []string) { + t.Helper() + + names := &armappservice.SlotConfigNames{} + for _, n := range appSettings { + names.AppSettingNames = append(names.AppSettingNames, new(n)) + } + + for _, n := range connStrings { + names.ConnectionStringNames = append(names.ConnectionStringNames, new(n)) + } + + _, err := s.factory.NewWebAppsClient().UpdateSlotConfigurationNames( + t.Context(), webSDKRG, slotSDKApp, armappservice.SlotConfigNamesResource{Properties: names}, nil) + require.NoError(t, err) +} + +func (s *slotStack) swap(t *testing.T, source, target string) error { + t.Helper() + + entity := armappservice.CsmSlotEntity{TargetSlot: new(target), PreserveVnet: new(false)} + apps := s.factory.NewWebAppsClient() + + if source == "" { + poller, err := apps.BeginSwapSlotWithProduction(t.Context(), webSDKRG, slotSDKApp, entity, nil) + if err != nil { + return err + } + + _, err = poller.PollUntilDone(t.Context(), pollOpts()) + + return err + } + + poller, err := apps.BeginSwapSlot(t.Context(), webSDKRG, slotSDKApp, source, entity, nil) + if err != nil { + return err + } + + _, err = poller.PollUntilDone(t.Context(), pollOpts()) + + return err +} + +func responseCode(err error) int { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + return respErr.StatusCode + } + + return 0 +} + +// step runs one stage of a scenario whose stages build on each other, so they cannot run in parallel. +func step(t *testing.T, name string, fn func()) { + t.Helper() + t.Log("step:", name) + fn() +} + +func slotErr(t *testing.T, app, slot string, props *armappservice.SiteProperties, s *fnSDKStack) func() error { + t.Helper() + + return func() error { + err := s.createSlot(t, app, slot, props) + + return err + } +} + +func TestAzureAppServiceARMSDK_SlotCRUD(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + ctx := t.Context() + apps := s.factory.NewWebAppsClient() + + got, err := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, slotSDKApp+"/staging", *got.Name) + assert.Equal(t, "Microsoft.Web/sites/slots", *got.Type) + assert.Equal(t, slotSDKApp+"-staging."+fnSDKDomain+":10012", *got.Properties.DefaultHostName) + assert.Contains(t, got.Properties.EnabledHostNames, new(slotSDKApp+"-staging.scm."+fnSDKDomain+":10012")) + assert.Equal(t, s.planID, *got.Properties.ServerFarmID, "a slot runs in its site's plan") + assert.Equal(t, "Running", *got.Properties.State) + + var names []string + + pager := apps.NewListSlotsPager(webSDKRG, slotSDKApp, nil) + for pager.More() { + page, pageErr := pager.NextPage(ctx) + require.NoError(t, pageErr) + + for _, v := range page.Value { + names = append(names, *v.Name) + } + } + + assert.Equal(t, []string{slotSDKApp + "/staging"}, names) + + prodSites := s.factory.NewWebAppsClient().NewListByResourceGroupPager(webSDKRG, nil) + + var siteNames []string + + for prodSites.More() { + page, pageErr := prodSites.NextPage(ctx) + require.NoError(t, pageErr) + + for _, v := range page.Value { + siteNames = append(siteNames, *v.Name) + } + } + + assert.Equal(t, []string{slotSDKApp}, siteNames, "slots are not listed as sites") + + step(t, "settings are per slot", func() { + s.setProdSettings(t, map[string]string{"WHO": "prod"}) + s.setStagingSettings(t, map[string]string{"WHO": "stg"}) + + slotList, listErr := apps.ListApplicationSettingsSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, listErr) + assert.Equal(t, "stg", *slotList.Properties["WHO"]) + + prodList, listErr := apps.ListApplicationSettings(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, listErr) + assert.Equal(t, "prod", *prodList.Properties["WHO"]) + + s.deploy(t, "", "prod") + s.deploy(t, "staging", "stg") + assert.Equal(t, "prod", s.get(t, "", "/env/WHO")) + assert.Equal(t, "stg", s.get(t, "staging", "/env/WHO")) + assert.Equal(t, "staging", s.get(t, "staging", "/env/WEBSITE_SLOT_NAME")) + assert.Equal(t, "Production", s.get(t, "", "/env/WEBSITE_SLOT_NAME")) + }) + + step(t, "start stop restart", func() { + _, stopErr := apps.StopSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, stopErr) + + code, _ := s.call(t, http.MethodGet, s.host("staging"), "/", nil, "", "") + assert.Equal(t, http.StatusForbidden, code) + + stopped, getErr := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, getErr) + assert.Equal(t, "Stopped", *stopped.Properties.State) + assert.Equal(t, "Running", *s.mustGetProd(t).Properties.State, "production is not affected") + + _, startErr := apps.StartSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, startErr) + _, restartErr := apps.RestartSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, restartErr) + assert.Equal(t, "stg", s.get(t, "staging", "/marker")) + }) + + step(t, "web config", func() { + _, cfgErr := apps.UpdateConfigurationSlot( + ctx, + webSDKRG, + slotSDKApp, + "staging", + armappservice.SiteConfigResource{ + Properties: &armappservice.SiteConfig{WebSocketsEnabled: new(true)}, + }, + nil, + ) + require.NoError(t, cfgErr) + + cfg, getErr := apps.GetConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, getErr) + assert.True(t, *cfg.Properties.WebSocketsEnabled) + assert.Equal(t, "$"+slotSDKApp+"__staging", *cfg.Properties.PublishingUsername) + }) + + step(t, "deleting a slot", func() { + delErr := s.createSlot(t, slotSDKApp, "temp", nil) + require.NoError(t, delErr) + + _, delErr = apps.DeleteSlot(ctx, webSDKRG, slotSDKApp, "temp", nil) + require.NoError(t, delErr) + + _, getErr := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "temp", nil) + require.Error(t, getErr) + assert.Equal(t, http.StatusNotFound, responseCode(getErr)) + + _, ok := s.backend.App(slotSDKApp + "/temp") + assert.False(t, ok) + }) + + step(t, "deleting the site deletes its slots", func() { + _, ok := s.backend.App(slotSDKApp + "/staging") + require.True(t, ok) + + _, delErr := apps.Delete(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, delErr) + + _, ok = s.backend.App(slotSDKApp + "/staging") + assert.False(t, ok) + + _, getErr := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + assert.Equal(t, http.StatusNotFound, responseCode(getErr)) + }) +} + +func (s *fnSDKStack) mustGetProd(t *testing.T) *armappservice.Site { + t.Helper() + + got, err := s.factory.NewWebAppsClient().Get(t.Context(), webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + + return &got.Site +} + +func TestAzureAppServiceARMSDK_SlotSwapStickySettings(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + ctx := t.Context() + apps := s.factory.NewWebAppsClient() + + s.stickyNames(t, []string{"STICKY"}, []string{"StickyDB"}) + + names, err := apps.ListSlotConfigurationNames(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + require.Len(t, names.Properties.AppSettingNames, 1) + assert.Equal(t, "STICKY", *names.Properties.AppSettingNames[0]) + + s.setProdSettings(t, map[string]string{ + "SWAPPED": "from-prod", "STICKY": "prod-only", "FUNCTIONS_EXTENSION_VERSION": "~4", + }) + s.setStagingSettings(t, map[string]string{ + "SWAPPED": "from-staging", "STICKY": "staging-only", "FUNCTIONS_EXTENSION_VERSION": "~3", + }) + s.setConnStrings(t, "", map[string]string{"DB": "prod-db", "StickyDB": "prod-sticky-db"}) + s.setConnStrings(t, "staging", map[string]string{"DB": "stg-db", "StickyDB": "stg-sticky-db"}) + s.deploy(t, "", "prod-content") + s.deploy(t, "staging", "staging-content") + + require.Equal(t, "prod-content", s.get(t, "", "/marker")) + startedBefore := s.rt.started() + + require.NoError(t, s.swap(t, "staging", "production")) + + assert.Equal(t, "staging-content", s.get(t, "", "/marker"), "production serves the swapped-in content") + assert.Equal(t, "prod-content", s.get(t, "staging", "/marker"), "staging holds the previous production") + assert.Greater(t, s.rt.started(), startedBefore, "both apps restarted") + + assert.Equal(t, "from-staging", s.get(t, "", "/env/SWAPPED")) + assert.Equal(t, "from-prod", s.get(t, "staging", "/env/SWAPPED")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY"), "slot settings stay with the slot") + assert.Equal(t, "staging-only", s.get(t, "staging", "/env/STICKY")) + assert.Equal(t, "~4", s.get(t, "", "/env/FUNCTIONS_EXTENSION_VERSION"), "_EXTENSION_VERSION settings never swap") + assert.Equal(t, "~3", s.get(t, "staging", "/env/FUNCTIONS_EXTENSION_VERSION")) + + prodConn, err := apps.ListConnectionStrings(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + assert.Equal(t, "stg-db", *prodConn.Properties["DB"].Value) + assert.Equal(t, "prod-sticky-db", *prodConn.Properties["StickyDB"].Value) + + slotConn, err := apps.ListConnectionStringsSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, "prod-db", *slotConn.Properties["DB"].Value) + assert.Equal(t, "stg-sticky-db", *slotConn.Properties["StickyDB"].Value) + + prod := s.mustGetProd(t) + require.NotNil(t, prod.Properties.SlotSwapStatus) + assert.Equal(t, "staging", *prod.Properties.SlotSwapStatus.SourceSlotName) + assert.Equal(t, "production", *prod.Properties.SlotSwapStatus.DestinationSlotName) + + step(t, "swapping back restores the previous production", func() { + require.NoError(t, s.swap(t, "staging", "production")) + assert.Equal(t, "prod-content", s.get(t, "", "/marker")) + assert.Equal(t, "from-prod", s.get(t, "", "/env/SWAPPED")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + }) + + step(t, "the production slot may be the swap source", func() { + require.NoError(t, s.swap(t, "", "staging")) + assert.Equal(t, "staging-content", s.get(t, "", "/marker")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + }) + + step(t, "web config follows the content", func() { + _, cfgErr := apps.UpdateConfigurationSlot( + ctx, + webSDKRG, + slotSDKApp, + "staging", + armappservice.SiteConfigResource{ + Properties: &armappservice.SiteConfig{LinuxFxVersion: new("NODE|22-lts"), AlwaysOn: new(true)}, + }, + nil, + ) + require.NoError(t, cfgErr) + require.NoError(t, s.swap(t, "staging", "production")) + + prodCfg, getErr := apps.GetConfiguration(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, getErr) + assert.Equal(t, "NODE|22-lts", *prodCfg.Properties.LinuxFxVersion) + assert.False(t, *prodCfg.Properties.AlwaysOn, "alwaysOn is not swapped") + + slotCfg, getErr := apps.GetConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, getErr) + assert.Equal(t, "NODE|20-lts", *slotCfg.Properties.LinuxFxVersion) + assert.True(t, *slotCfg.Properties.AlwaysOn) + }) +} + +func TestAzureAppServiceARMSDK_SlotSwapWithPreview(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + ctx := t.Context() + apps := s.factory.NewWebAppsClient() + entity := armappservice.CsmSlotEntity{TargetSlot: new("production"), PreserveVnet: new(false)} + + s.stickyNames(t, []string{"STICKY"}, nil) + s.setProdSettings(t, map[string]string{"SWAPPED": "from-prod", "STICKY": "prod-only"}) + s.setStagingSettings(t, map[string]string{"SWAPPED": "from-staging", "STICKY": "staging-only"}) + s.deploy(t, "", "prod-content") + s.deploy(t, "staging", "staging-content") + + _, err := apps.ApplySlotConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", entity, nil) + require.NoError(t, err) + + step(t, "first phase runs the source with the target's slot settings", func() { + assert.Equal(t, "prod-only", s.get(t, "staging", "/env/STICKY")) + assert.Equal(t, "from-staging", s.get(t, "staging", "/env/SWAPPED")) + assert.Equal(t, "staging-content", s.get(t, "staging", "/marker"), "content has not moved yet") + assert.Equal(t, "prod-content", s.get(t, "", "/marker")) + }) + + step(t, "a prepared slot cannot be edited", func() { + _, setErr := apps.UpdateApplicationSettingsSlot(ctx, webSDKRG, slotSDKApp, "staging", + armappservice.StringDictionary{Properties: map[string]*string{"X": new("1")}}, nil) + require.Error(t, setErr) + assert.Equal(t, http.StatusConflict, responseCode(setErr)) + assert.Contains(t, setErr.Error(), "prepared for swap") + + _, applyErr := apps.ApplySlotConfigToProduction(ctx, webSDKRG, slotSDKApp, + armappservice.CsmSlotEntity{TargetSlot: new("staging"), PreserveVnet: new(false)}, nil) + require.Error(t, applyErr, "the target cannot start another preview") + }) + + step(t, "reset cancels", func() { + _, resetErr := apps.ResetSlotConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, resetErr) + + assert.Equal(t, "staging-only", s.get(t, "staging", "/env/STICKY")) + assert.Equal(t, "prod-content", s.get(t, "", "/marker")) + + s.setStagingSettings(t, map[string]string{"SWAPPED": "from-staging", "STICKY": "staging-only", "X": "1"}) + }) + + step(t, "complete after the preview", func() { + _, applyErr := apps.ApplySlotConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", entity, nil) + require.NoError(t, applyErr) + require.NoError(t, s.swap(t, "staging", "production")) + + assert.Equal(t, "staging-content", s.get(t, "", "/marker")) + assert.Equal(t, "prod-content", s.get(t, "staging", "/marker")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + assert.Equal(t, "staging-only", s.get(t, "staging", "/env/STICKY"), "the slot's own setting is back") + assert.Equal(t, "1", s.get(t, "", "/env/X")) + + s.setStagingSettings(t, map[string]string{"STICKY": "staging-only", "X": "2"}) + }) + + step(t, "production as the preview source", func() { + _, prodApplyErr := apps.ApplySlotConfigToProduction(ctx, webSDKRG, slotSDKApp, + armappservice.CsmSlotEntity{TargetSlot: new("staging"), PreserveVnet: new(false)}, nil) + require.NoError(t, prodApplyErr) + assert.Equal(t, "staging-only", s.get(t, "", "/env/STICKY")) + + _, resetErr := apps.ResetProductionSlotConfig(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, resetErr) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + }) +} + +func TestAzureAppServiceARMSDK_SlotDifferences(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + apps := s.factory.NewWebAppsClient() + + s.stickyNames(t, []string{"STICKY"}, nil) + s.setProdSettings(t, map[string]string{"A": "prod", "STICKY": "p", "SAME": "x"}) + s.setStagingSettings(t, map[string]string{"A": "stg", "STICKY": "s", "SAME": "x", "ONLYSTG": "1"}) + + pager := apps.NewListSlotDifferencesSlotPager(webSDKRG, slotSDKApp, "staging", + armappservice.CsmSlotEntity{TargetSlot: new("production"), PreserveVnet: new(false)}, nil) + + got := map[string][2]string{} + + for pager.More() { + page, err := pager.NextPage(t.Context()) + require.NoError(t, err) + + for _, d := range page.Value { + assert.Equal(t, "AppSetting", *d.Properties.SettingType) + + var target string + if d.Properties.ValueInTargetSlot != nil { + target = *d.Properties.ValueInTargetSlot + } + + got[*d.Properties.SettingName] = [2]string{*d.Properties.ValueInCurrentSlot, target} + } + } + + assert.Equal(t, map[string][2]string{"A": {"stg", "prod"}, "ONLYSTG": {"1", ""}}, got, + "only swapped settings that differ are listed") +} + +func TestAzureAppServiceARMSDK_SlotLimits(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind string + siteKind string + sku, tier string + want int + }{ + {"free", "linux", "app,linux", "F1", "Free", 0}, + {"basic", "linux", "app,linux", "B1", "Basic", 0}, + {"standard", "linux", "app,linux", "S1", "Standard", 5}, + {"premium v3", "linux", "app,linux", "P1v3", "PremiumV3", 20}, + {"isolated", "linux", "app,linux", "I1v2", "IsolatedV2", 20}, + {"function consumption", "functionapp", "functionapp,linux", "Y1", "Dynamic", 1}, + {"function premium", "functionapp", "functionapp,linux", "EP1", "ElasticPremium", 2}, + {"function flex", "functionapp", "functionapp,linux", "FC1", "FlexConsumption", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newFnSDKStack(t) + plan := s.createPlanSKU(t, "limitplan", tt.kind, tt.sku, tt.tier) + + poller, err := s.factory.NewWebAppsClient().BeginCreateOrUpdate(t.Context(), webSDKRG, "limited", + armappservice.Site{ + Location: new("local"), Kind: new(tt.siteKind), + Properties: &armappservice.SiteProperties{ + ServerFarmID: new(*plan.ID), + SiteConfig: &armappservice.SiteConfig{LinuxFxVersion: new(limitFx(tt.siteKind))}, + }, + }, nil) + require.NoError(t, err) + _, err = poller.PollUntilDone(t.Context(), pollOpts()) + require.NoError(t, err) + + for i := range tt.want { + slotErr := s.createSlot(t, "limited", "s"+string(rune('a'+i)), nil) + require.NoError(t, slotErr, "slot %d is within the limit", i+1) + } + + overErr := s.createSlot(t, "limited", "over", nil) + require.Error(t, overErr) + assert.Equal(t, http.StatusConflict, responseCode(overErr)) + + if tt.want > 0 { + updErr := s.createSlot(t, "limited", "sa", nil) + require.NoError(t, updErr, "updating an existing slot is not a new slot") + } + }) + } +} + +func limitFx(kind string) string { + if kind == "app,linux" { + return "NODE|20-lts" + } + + return "NODE|20" +} + +func TestAzureAppServiceARMSDK_SlotErrors(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + apps := s.factory.NewWebAppsClient() + + err := s.createSlot(t, slotSDKApp, "second", &armappservice.SiteProperties{ + SiteConfig: &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}, + }) + require.NoError(t, err) + + tests := []struct { + fn func() error + name string + want int + }{ + {func() error { return s.swap(t, "staging", "staging") }, "swap with itself", http.StatusBadRequest}, + {func() error { return s.swap(t, "staging", "nope") }, "unknown target", http.StatusNotFound}, + {func() error { return s.swap(t, "nope", "production") }, "unknown source", http.StatusNotFound}, + {func() error { return s.swap(t, "", "production") }, "production with production", http.StatusBadRequest}, + {slotErr(t, slotSDKApp, "production", nil, s.fnSDKStack), "reserved name", http.StatusBadRequest}, + {slotErr(t, slotSDKApp, "bad_name", nil, s.fnSDKStack), "bad characters", http.StatusBadRequest}, + { + slotErr(t, slotSDKApp, "averyveryverylongslotname", nil, s.fnSDKStack), + "name too long", + http.StatusBadRequest, + }, + {slotErr(t, "ghost", "x", nil, s.fnSDKStack), "unknown site", http.StatusNotFound}, + { + slotErr( + t, + slotSDKApp, + "other", + &armappservice.SiteProperties{ServerFarmID: new(s.planID + "x")}, + s.fnSDKStack, + ), + "different plan", + http.StatusBadRequest, + }, + { + func() error { + _, e := apps.GetSlot(t.Context(), webSDKRG, slotSDKApp, "nope", nil) + + return e + }, + "get unknown", + http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fnErr := tt.fn() + require.Error(t, fnErr) + assert.Equal(t, tt.want, responseCode(fnErr), fnErr.Error()) + }) + } + + step(t, "a swap between two slots", func() { + s.deploy(t, "staging", "one") + s.deploy(t, "second", "two") + require.NoError(t, s.swap(t, "staging", "second")) + assert.Equal(t, "two", s.get(t, "staging", "/marker")) + assert.Equal(t, "one", s.get(t, "second", "/marker")) + }) + + step(t, "a slot may not take a site's hostname", func() { + plan := s.planID + _, siteErr := s.createWebApp( + t, + slotSDKApp+"-staging", + plan, + &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}, + ) + require.Error(t, siteErr) + assert.Equal(t, http.StatusConflict, responseCode(siteErr)) + }) + + step(t, "cloning copies settings, not content", func() { + s.setStagingSettings(t, map[string]string{"CLONED": "yes", "OVERRIDE": "src"}) + s.deploy(t, "staging", "src-content") + + src := armappservice.CloningInfo{ + SourceWebAppID: new(*s.mustGetSlot(t, "staging").ID), + AppSettingsOverrides: map[string]*string{"OVERRIDE": new("dst")}, + } + cloneErr := s.createSlot(t, slotSDKApp, "clone", &armappservice.SiteProperties{CloningInfo: &src}) + require.NoError(t, cloneErr) + + list, listErr := apps.ListApplicationSettingsSlot(t.Context(), webSDKRG, slotSDKApp, "clone", nil) + require.NoError(t, listErr) + assert.Equal(t, "yes", *list.Properties["CLONED"]) + assert.Equal(t, "dst", *list.Properties["OVERRIDE"]) + + code, body := s.call(t, http.MethodGet, s.host("clone"), "/", nil, "", "") + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, body, "waiting for your content") + }) +} + +func (s *fnSDKStack) mustGetSlot(t *testing.T, slot string) *armappservice.Site { + t.Helper() + + got, err := s.factory.NewWebAppsClient().GetSlot(t.Context(), webSDKRG, slotSDKApp, slot, nil) + require.NoError(t, err) + + return &got.Site +} + +func TestAzureAppServiceARMSDK_SlotPersistence(t *testing.T) { + t.Parallel() + + src := newSlotStack(t) + src.stickyNames(t, []string{"STICKY"}, nil) + src.setStagingSettings(t, map[string]string{"A": "slot"}) + src.deploy(t, "staging", "staged") + require.NoError(t, src.arm.Restore(t.Context(), src.arm.Snapshot(t.Context())), "own snapshot restores") + + dst := newFnSDKStack(t) + require.NoError(t, dst.arm.Restore(t.Context(), src.arm.Snapshot(t.Context()))) + require.NoError(t, dst.backend.Restore(t.Context(), src.backend.Snapshot(t.Context()))) + + got, err := dst.factory.NewWebAppsClient().GetSlot(t.Context(), webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, slotSDKApp+"/staging", *got.Name) + + names, err := dst.factory.NewWebAppsClient().ListSlotConfigurationNames(t.Context(), webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + assert.Equal(t, "STICKY", *names.Properties.AppSettingNames[0]) + + list, err := dst.factory.NewWebAppsClient(). + ListApplicationSettingsSlot(t.Context(), webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, "slot", *list.Properties["A"]) + + d := &slotStack{fnSDKStack: dst} + assert.Equal(t, "staged", d.get(t, "staging", "/marker"), "the restored slot serves its restored package") +} + +func zipOfFiles(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + + for name, content := range files { + w, err := zw.Create(name) + require.NoError(t, err) + + _, err = w.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, zw.Close()) + + return buf.Bytes() +} + +func (f *fnFakeRuntime) started() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.specs) +} diff --git a/internal/app/azureeventgrid_wiring.go b/internal/app/azureeventgrid_wiring.go index 79c40e7d3b..abc83479ce 100644 --- a/internal/app/azureeventgrid_wiring.go +++ b/internal/app/azureeventgrid_wiring.go @@ -28,6 +28,7 @@ func wireAzureARMAndEventGrid(byName map[string]service.Registerable) { wireAzureFunctions(byName) wireAzureFile(byName) wireAzureMonitor(byName) + wireAzureGraph(byName) } // wireAzureEventGrid connects Event Grid to ARM's Microsoft.EventGrid provider and to diff --git a/internal/app/azurefunctions_sdk_test.go b/internal/app/azurefunctions_sdk_test.go index fdbf8ceb6b..0be80e6a42 100644 --- a/internal/app/azurefunctions_sdk_test.go +++ b/internal/app/azurefunctions_sdk_test.go @@ -9,6 +9,8 @@ import ( "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" "testing" @@ -25,6 +27,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/container" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/azurearm" "github.com/blackbirdworks/gopherstack/services/azurefunctions" ) @@ -66,6 +69,27 @@ func (f *fnFakeRuntime) CreateAndStart(_ context.Context, spec container.Spec) ( return } + if name, ok := strings.CutPrefix(r.URL.Path, "/env/"); ok { + for _, e := range spec.Env { + if k, v, _ := strings.Cut(e, "="); k == name { + _, _ = w.Write([]byte(v)) + } + } + + return + } + + if r.URL.Path == "/marker" { + for _, m := range spec.Mounts { + if dir, _, found := strings.Cut(m, ":/home/site/wwwroot"); found { + raw, _ := os.ReadFile(filepath.Join(dir, "marker.txt")) + _, _ = w.Write(raw) + } + } + + return + } + if r.URL.Path == "/admin/functions" { _, _ = w.Write([]byte(`[{"name":"hello","language":"node","isDisabled":false,"config":{"bindings":` + `[{"type":"httpTrigger","authLevel":"function","direction":"in","name":"req","route":"greet"}]}},` + @@ -126,6 +150,7 @@ func (f *fnFakeRuntime) lastEnv(site string) map[string]string { } type fnSDKStack struct { + arm *azurearm.Handler factory *armappservice.ClientFactory backend *azurefunctions.InMemoryBackend rt *fnFakeRuntime @@ -168,7 +193,7 @@ func newFnSDKStack(t *testing.T) *fnSDKStack { }) require.NoError(t, err) - return &fnSDKStack{factory: f, backend: backend, rt: rt, fnSrv: fnSrv} + return &fnSDKStack{arm: armH, factory: f, backend: backend, rt: rt, fnSrv: fnSrv} } // call sends a request to the Functions listener addressed to host. @@ -184,7 +209,7 @@ func (s *fnSDKStack) call(t *testing.T, method, host, path string, body []byte, req.SetBasicAuth(user, pass) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/internal/app/azurefunctions_wiring.go b/internal/app/azurefunctions_wiring.go index 9aacada89a..ce108a4c1a 100644 --- a/internal/app/azurefunctions_wiring.go +++ b/internal/app/azurefunctions_wiring.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/azurearm" @@ -61,7 +62,7 @@ func (a *azureFunctionAppsAdapter) PutApp( Kind: cfg.Kind, LinuxFx: cfg.LinuxFx, AppCommandLine: cfg.AppCommandLine, HealthCheckPath: cfg.HealthCheckPath, } - if err := azurefunctions.ValidateConfig(appCfg); err != nil { + if err := azurefunctions.ValidateConfig(appCfg); err != nil && (!isSlotName(name) || cfg.LinuxFx != "") { return azurearm.FunctionAppInfo{}, fmt.Errorf("%w: %w", azurearm.ErrWebInvalidSiteConfig, err) } @@ -160,3 +161,13 @@ func (a *azureFunctionAppsAdapter) PublishingCredentials(name string) (string, s return user, password, scmURI, mapFunctionsError(err) } + +func isSlotName(name string) bool { return strings.Contains(name, azurefunctions.SlotSep) } + +func (a *azureFunctionAppsAdapter) SwapContent(first, second string) error { + return mapFunctionsError(a.backend.SwapContent(first, second)) +} + +func (a *azureFunctionAppsAdapter) WarmUp(ctx context.Context, name string) error { + return mapFunctionsError(a.backend.WarmUp(ctx, name)) +} diff --git a/internal/app/azuregraph_port_reservation_test.go b/internal/app/azuregraph_port_reservation_test.go new file mode 100644 index 0000000000..e8771725f0 --- /dev/null +++ b/internal/app/azuregraph_port_reservation_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + azuregraphbackend "github.com/blackbirdworks/gopherstack/services/azuregraph" +) + +func TestReserveFixedServicePorts_AzureGraph(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + wantBlockedFromPool bool + }{ + {"default port collides with default pool range", azuregraphbackend.DefaultPort, true}, + {"custom port outside the pool range", 9996, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(10000, 10100) + require.NoError(t, err) + + cli := CLI{AzureGraph: azuregraphbackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantBlockedFromPool, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/azuregraph_wiring.go b/internal/app/azuregraph_wiring.go new file mode 100644 index 0000000000..2c5d7049ed --- /dev/null +++ b/internal/app/azuregraph_wiring.go @@ -0,0 +1,53 @@ +package app + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/azurearm" + "github.com/blackbirdworks/gopherstack/services/azuregraph" +) + +// GetAzureGraphSettings returns Microsoft Graph settings (azuregraph.ConfigProvider). +func (c *CLI) GetAzureGraphSettings() azuregraph.Settings { return c.AzureGraph } + +// GetAzureGraphPort returns the Graph listener's configured port (azurearm.GraphPortProvider). +func (c *CLI) GetAzureGraphPort() int { return c.AzureGraph.Port } + +// wireAzureGraph shares ARM's token issuer with Graph, lets the ARM token endpoint authenticate the secrets of +// applications registered in Graph, and lets role assignments resolve principals against the Graph directory. +func wireAzureGraph(byName map[string]service.Registerable) { + graphH, ok := byName["AzureGraph"].(*azuregraph.Handler) + if !ok { + return + } + + armH, ok := byName["AzureARM"].(*azurearm.Handler) + if !ok { + return + } + + if armH.Issuer != nil { + graphH.Issuer = armH.Issuer + } + + armH.SetClientAuthenticator(&graphClientAuth{backend: graphH.Backend}) + armH.SetPrincipalDirectory(graphH.Backend) +} + +// graphClientAuth adapts the Graph directory to services/azurearm.ClientAuthenticator. +type graphClientAuth struct { + backend *azuregraph.Backend +} + +func (a *graphClientAuth) AuthenticateClient(clientID, secret string) (string, bool, error) { + oid, known, err := a.backend.AuthenticateClient(clientID, secret) + + if ce, ok := errors.AsType[*azuregraph.ClientAuthError](err); ok { + return "", known, &azurearm.ClientAuthError{ + Name: ce.Name, Description: ce.Description, Status: ce.Status, Code: ce.Code, + } + } + + return oid, known, err +} diff --git a/internal/app/azuregraph_wiring_test.go b/internal/app/azuregraph_wiring_test.go new file mode 100644 index 0000000000..30ff349c3c --- /dev/null +++ b/internal/app/azuregraph_wiring_test.go @@ -0,0 +1,222 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/azurearm" + "github.com/blackbirdworks/gopherstack/services/azuregraph" +) + +type graphARMConfig struct{} + +func (graphARMConfig) GetAzureARMSettings() azurearm.Settings { + s := azurearm.DefaultSettings() + s.ValidatePrincipals = true + + return s +} + +type graphStack struct { + arm *httptest.Server + graph *httptest.Server +} + +func newGraphStack(t *testing.T) *graphStack { + t.Helper() + + armSvc, err := (&azurearm.Provider{}).Init(&service.AppContext{Config: graphARMConfig{}}) + require.NoError(t, err) + + armH, ok := armSvc.(*azurearm.Handler) + require.True(t, ok) + + settings := azuregraph.DefaultSettings() + settings.ValidateTokens = true + graphH := azuregraph.NewHandler(azuregraph.NewBackend(settings), settings) + + wireAzureGraph(map[string]service.Registerable{"AzureARM": armH, "AzureGraph": graphH}) + + ae, ge := echo.New(), echo.New() + ae.Any("/*", armH.Handler()) + ge.Any("/*", graphH.Handler()) + + s := &graphStack{arm: httptest.NewTLSServer(ae), graph: httptest.NewTLSServer(ge)} + t.Cleanup(s.arm.Close) + t.Cleanup(s.graph.Close) + + return s +} + +func do( + t *testing.T, + srv *httptest.Server, + method, path, contentType, body string, + headers ...string, +) (int, map[string]any) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, srv.URL+path, strings.NewReader(body)) + require.NoError(t, err) + + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var out map[string]any + _ = json.Unmarshal(raw, &out) + + return resp.StatusCode, out +} + +func (s *graphStack) token(t *testing.T, clientID, secret string) (int, map[string]any) { + t.Helper() + + form := url.Values{ + "grant_type": {"client_credentials"}, "client_id": {clientID}, "client_secret": {secret}, + "scope": {"https://graph.microsoft.com/.default"}, + } + + return do(t, s.arm, http.MethodPost, "/"+azuregraph.DefaultTenantID+"/oauth2/v2.0/token", + "application/x-www-form-urlencoded", form.Encode()) +} + +func (s *graphStack) call(t *testing.T, bearer, method, path, body string) (int, map[string]any) { + t.Helper() + + return do(t, s.graph, method, path, "application/json", body, "Authorization", "Bearer "+bearer) +} + +func TestAzureGraphClientCredentialsAndPrincipals(t *testing.T) { + t.Parallel() + + s := newGraphStack(t) + + status, boot := s.token(t, "", "") + require.Equal(t, http.StatusOK, status) + + admin, _ := boot["access_token"].(string) + + status, app := s.call(t, admin, http.MethodPost, "/v1.0/applications", `{"displayName":"daemon"}`) + require.Equal(t, http.StatusCreated, status) + + appID, _ := app["appId"].(string) + + status, sp := s.call(t, admin, http.MethodPost, "/v1.0/servicePrincipals", `{"appId":"`+appID+`"}`) + require.Equal(t, http.StatusCreated, status) + + spID, _ := sp["id"].(string) + + status, pw := s.call( + t, + admin, + http.MethodPost, + "/v1.0/applications/"+app["id"].(string)+"/addPassword", + `{}`, + ) //nolint:forcetypeassert // created above + require.Equal(t, http.StatusOK, status) + + secret, _ := pw["secretText"].(string) + + t.Run("token endpoint verifies registered client secrets", func(t *testing.T) { + t.Parallel() + + code, body := s.token(t, appID, "wrong") + assert.Equal(t, http.StatusUnauthorized, code) + assert.Equal(t, "invalid_client", body["error"]) + assert.Equal(t, []any{float64(7000215)}, body["error_codes"]) + + code, body = s.token(t, appID, secret) + require.Equal(t, http.StatusOK, code) + + tok, _ := body["access_token"].(string) + code, got := s.call(t, tok, http.MethodGet, "/v1.0/servicePrincipals(appId='"+appID+"')", "") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, spID, got["id"]) + + code, got = s.call(t, tok, http.MethodGet, "/v1.0/me", "") + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal( + t, + "BadRequest", + got["error"].(map[string]any)["code"], + ) //nolint:forcetypeassert // error envelope + }) + + t.Run("graph rejects tokens for another audience", func(t *testing.T) { + t.Parallel() + + form := url.Values{ + "client_id": {appID}, + "client_secret": {secret}, + "scope": {"https://management.azure.com/.default"}, + } + _, body := do(t, s.arm, http.MethodPost, "/"+azuregraph.DefaultTenantID+"/oauth2/v2.0/token", + "application/x-www-form-urlencoded", form.Encode()) + + arm, _ := body["access_token"].(string) + code, _ := s.call(t, arm, http.MethodGet, "/v1.0/applications", "") + assert.Equal(t, http.StatusUnauthorized, code) + }) + + t.Run("role assignments resolve principals in the directory", func(t *testing.T) { + t.Parallel() + + path := "/subscriptions/" + azurearm.DefaultSubscriptionID + "/providers/Microsoft.Authorization/roleAssignments/" + role := "/subscriptions/" + azurearm.DefaultSubscriptionID + + "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" + + put := func(name, principal string) (int, map[string]any) { + return do(t, s.arm, http.MethodPut, path+name+"?api-version=2022-04-01", "application/json", + `{"properties":{"roleDefinitionId":"`+role+`","principalId":"`+principal+`"}}`) + } + + code, body := put("66666666-6666-6666-6666-666666666666", spID) + require.Equal(t, http.StatusCreated, code, body) + assert.Equal( + t, + "ServicePrincipal", + body["properties"].(map[string]any)["principalType"], + ) //nolint:forcetypeassert // ARM envelope + + code, body = put("77777777-7777-7777-7777-777777777777", "99999999-9999-9999-9999-999999999999") + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal( + t, + "PrincipalNotFound", + body["error"].(map[string]any)["code"], + ) //nolint:forcetypeassert // ARM envelope + }) + + t.Run("environment metadata points at the graph listener", func(t *testing.T) { + t.Parallel() + + code, meta := do(t, s.arm, http.MethodGet, "/metadata/endpoints?api-version=2022-09-01", "", "") + require.Equal(t, http.StatusOK, code) + + res, _ := meta["microsoftGraphResourceId"].(string) + assert.True(t, strings.HasSuffix(res, ":10014"), res) + }) +} diff --git a/internal/app/backup_restore_report_wiring_test.go b/internal/app/backup_restore_report_wiring_test.go index 3e309ad95c..7a411b649a 100644 --- a/internal/app/backup_restore_report_wiring_test.go +++ b/internal/app/backup_restore_report_wiring_test.go @@ -38,7 +38,7 @@ func newBackupWiringEnv(t *testing.T) backupWiringEnv { appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/chaos_dynamodb.go b/internal/app/chaos_dynamodb.go new file mode 100644 index 0000000000..80c7e6c27d --- /dev/null +++ b/internal/app/chaos_dynamodb.go @@ -0,0 +1,19 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// wireDynamoDBFaultStore hands the chaos store to DynamoDB so its dynamodb:transaction-conflict rules apply. +func wireDynamoDBFaultStore(services []service.Registerable, store *chaos.FaultStore) { + if store == nil { + return + } + + for _, svc := range services { + if h, ok := svc.(interface{ SetChaosFaultStore(*chaos.FaultStore) }); ok { + h.SetChaosFaultStore(store) + } + } +} diff --git a/internal/app/chaos_dynamodb_test.go b/internal/app/chaos_dynamodb_test.go new file mode 100644 index 0000000000..9138788222 --- /dev/null +++ b/internal/app/chaos_dynamodb_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/dynamodb" +) + +func TestWireDynamoDBFaultStore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wire bool + wantConflict bool + }{ + {name: "wired store drives conflicts", wire: true, wantConflict: true}, + {name: "unwired store leaves behaviour unchanged"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db := dynamodb.NewInMemoryDB() + db.SetDefaultRegion("us-east-1") + + store := chaos.NewFaultStore() + store.SetRules([]chaos.FaultRule{{ + Effect: chaos.EffectDynamoDBTransactionConflict, + Target: &chaos.EffectTarget{Table: "tbl"}, + }}) + + if tt.wire { + wireDynamoDBFaultStore([]service.Registerable{dynamodb.NewHandler(db)}, store) + } + + _, err := db.CreateTable(t.Context(), &sdk.CreateTableInput{ + TableName: aws.String("tbl"), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, + }, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, + }, + BillingMode: types.BillingModePayPerRequest, + }) + require.NoError(t, err) + + _, err = db.PutItem(t.Context(), &sdk.PutItemInput{ + TableName: aws.String("tbl"), + Item: map[string]types.AttributeValue{"pk": &types.AttributeValueMemberS{Value: "a"}}, + }) + + if tt.wantConflict { + require.ErrorContains(t, err, "TransactionConflictException") + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/app/cli.go b/internal/app/cli.go index 42a843e75f..acc58566b0 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -58,6 +58,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/endpointstrategy" snsevents "github.com/blackbirdworks/gopherstack/pkgs/events" "github.com/blackbirdworks/gopherstack/pkgs/extensions" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/pkgs/iampolicystream" "github.com/blackbirdworks/gopherstack/pkgs/inithooks" @@ -98,6 +99,7 @@ import ( azureeventhubsbackend "github.com/blackbirdworks/gopherstack/services/azureeventhubs" azurefilebackend "github.com/blackbirdworks/gopherstack/services/azurefile" azurefunctionsbackend "github.com/blackbirdworks/gopherstack/services/azurefunctions" + azuregraphbackend "github.com/blackbirdworks/gopherstack/services/azuregraph" azurekeyvaultbackend "github.com/blackbirdworks/gopherstack/services/azurekeyvault" azuremonitorbackend "github.com/blackbirdworks/gopherstack/services/azuremonitor" azurequeuebackend "github.com/blackbirdworks/gopherstack/services/azurequeue" @@ -170,22 +172,28 @@ import ( fisbackend "github.com/blackbirdworks/gopherstack/services/fis" forecastbackend "github.com/blackbirdworks/gopherstack/services/forecast" fsxbackend "github.com/blackbirdworks/gopherstack/services/fsx" + gcpapigatewaybackend "github.com/blackbirdworks/gopherstack/services/gcpapigateway" gcparbackend "github.com/blackbirdworks/gopherstack/services/gcpartifactregistry" + gcpbatchbackend "github.com/blackbirdworks/gopherstack/services/gcpbatch" gcpbigquerybackend "github.com/blackbirdworks/gopherstack/services/gcpbigquery" gcpbigtablebackend "github.com/blackbirdworks/gopherstack/services/gcpbigtable" gcpcloudbuildbackend "github.com/blackbirdworks/gopherstack/services/gcpcloudbuild" gcpcloudtasksbackend "github.com/blackbirdworks/gopherstack/services/gcpcloudtasks" gcpcomputebackend "github.com/blackbirdworks/gopherstack/services/gcpcompute" + gcpcontainerbackend "github.com/blackbirdworks/gopherstack/services/gcpcontainer" + gcpdatastorebackend "github.com/blackbirdworks/gopherstack/services/gcpdatastore" gcpdnsbackend "github.com/blackbirdworks/gopherstack/services/gcpdns" gcpeventarcbackend "github.com/blackbirdworks/gopherstack/services/gcpeventarc" gcpfirestorebackend "github.com/blackbirdworks/gopherstack/services/gcpfirestore" gcpfunctionsbackend "github.com/blackbirdworks/gopherstack/services/gcpfunctions" gcpiambackend "github.com/blackbirdworks/gopherstack/services/gcpiam" + gcpidtkbackend "github.com/blackbirdworks/gopherstack/services/gcpidentitytoolkit" gcpkmsbackend "github.com/blackbirdworks/gopherstack/services/gcpkms" gcploggingbackend "github.com/blackbirdworks/gopherstack/services/gcplogging" gcpmonitoringbackend "github.com/blackbirdworks/gopherstack/services/gcpmonitoring" gcppubsubbackend "github.com/blackbirdworks/gopherstack/services/gcppubsub" gcpredisbackend "github.com/blackbirdworks/gopherstack/services/gcpredis" + gcprmbackend "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" gcprunbackend "github.com/blackbirdworks/gopherstack/services/gcprun" gcpschedulerbackend "github.com/blackbirdworks/gopherstack/services/gcpscheduler" gcpsecretmanagerbackend "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" @@ -234,10 +242,22 @@ import ( networkmanagerbackend "github.com/blackbirdworks/gopherstack/services/networkmanager" networkmonitorbackend "github.com/blackbirdworks/gopherstack/services/networkmonitor" oambackend "github.com/blackbirdworks/gopherstack/services/oam" + ociapigateway "github.com/blackbirdworks/gopherstack/services/ociapigateway" + ociartifacts "github.com/blackbirdworks/gopherstack/services/ociartifacts" + ocioke "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" + ocicontainerinstances "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ocievents "github.com/blackbirdworks/gopherstack/services/ocievents" ocifunctionsbackend "github.com/blackbirdworks/gopherstack/services/ocifunctions" ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ociloadbalancer "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" + ocilogging "github.com/blackbirdworks/gopherstack/services/ocilogging" + ocimonitoring "github.com/blackbirdworks/gopherstack/services/ocimonitoring" + ocimysqlbackend "github.com/blackbirdworks/gopherstack/services/ocimysql" + ocinosql "github.com/blackbirdworks/gopherstack/services/ocinosql" + ocinotificationsbackend "github.com/blackbirdworks/gopherstack/services/ocinotifications" ociobjectstoragebackend "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" + ociqueuebackend "github.com/blackbirdworks/gopherstack/services/ociqueue" ocistreamingbackend "github.com/blackbirdworks/gopherstack/services/ocistreaming" ocivaultbackend "github.com/blackbirdworks/gopherstack/services/ocivault" omicsbackend "github.com/blackbirdworks/gopherstack/services/omics" @@ -457,9 +477,28 @@ type CLI struct { azureFileHandler service.Registerable gcpWorkflowsHandler service.Registerable gcpDNSHandler service.Registerable + gcpDatastoreHandler service.Registerable gcpComputeHandler service.Registerable ociObjectStorageHandler service.Registerable ociFunctionsHandler service.Registerable + ociCoreHandler service.Registerable + gcpContainerHandler service.Registerable + gcpIdentityToolkitHandler service.Registerable + gcpAPIGatewayHandler service.Registerable + gcpBatchHandler service.Registerable + gcpResourceManagerHandler service.Registerable + ociQueueHandler service.Registerable + ociNotificationsHandler service.Registerable + ociContainerEngineHandler service.Registerable + ociMySQLHandler service.Registerable + ociLoggingHandler service.Registerable + ociMonitoringHandler service.Registerable + ociNoSQLHandler service.Registerable + ociAPIGatewayHandler service.Registerable + ociEventsHandler service.Registerable + ociLoadBalancerHandler service.Registerable + ociArtifactsHandler service.Registerable + ociContainerInstancesHandler service.Registerable azureMonitorHandler service.Registerable ociVaultHandler service.Registerable ociStreamingHandler service.Registerable @@ -553,137 +592,168 @@ type CLI struct { portAlloc *portalloc.Allocator engineModes map[string]string localstackInit *localstackInitState - EngineSwitch EngineSettings `embed:""` - ServiceFilter ServiceFilterSettings `embed:""` - PersistModes PersistModeSettings `embed:""` - CORS CORSSettings `embed:"" prefix:"cors-"` - Network NetworkSettings `embed:""` - Region string ` name:"region" env:"REGION" default:"us-east-1" help:"AWS region (also read from AWS_DEFAULT_REGION and AWS_REGION)."` //nolint:lll // config struct tags are intentionally verbose - DNSResolveIP string ` name:"dns-resolve-ip" env:"DNS_RESOLVE_IP" default:"127.0.0.1" help:"IP address synthetic hostnames resolve to."` //nolint:lll // config struct tags are intentionally verbose - KafkaEngine string ` name:"kafka-engine" env:"KAFKA_ENGINE" default:"stub" help:"MSK engine mode: stub (metadata only) or docker (real Kafka broker per cluster)."` //nolint:lll // config struct tags are intentionally verbose - FlinkEngine string ` name:"flink-engine" env:"FLINK_ENGINE" default:"stub" help:"Managed Flink engine mode: stub (state only) or docker (real Flink cluster and job per running application)."` //nolint:lll // config struct tags are intentionally verbose - MWAAEngine string ` name:"mwaa-engine" env:"MWAA_ENGINE" default:"stub" help:"MWAA engine mode: stub (state only) or docker (real Apache Airflow container per environment)."` //nolint:lll // config struct tags are intentionally verbose - MQEngine string ` name:"mq-engine" env:"MQ_ENGINE" default:"stub" help:"Amazon MQ engine mode: stub (metadata only) or docker (real RabbitMQ/ActiveMQ container per broker)."` //nolint:lll // config struct tags are intentionally verbose - EKSEngine string ` name:"eks-engine" env:"EKS_ENGINE" default:"stub" help:"EKS engine mode: stub (metadata only) or docker (real single-node k3s cluster per CreateCluster)."` //nolint:lll // config struct tags are intentionally verbose - RDSEngine string ` name:"rds-engine" env:"RDS_ENGINE" default:"stub" help:"RDS engine mode: stub (metadata only) or docker (real database container)."` //nolint:lll // config struct tags are intentionally verbose - SageMakerEngine string ` name:"sagemaker-engine" env:"SAGEMAKER_ENGINE" default:"stub" help:"SageMaker engine mode: stub (control plane only; InvokeEndpoint is unavailable) or docker (endpoints and jobs run model, training, processing and transform containers)."` //nolint:lll // config struct tags are intentionally verbose - DocDBEngine string ` name:"docdb-engine" env:"DOCDB_ENGINE" default:"stub" help:"DocumentDB engine mode: stub (metadata only) or docker (real MongoDB container per cluster)."` //nolint:lll // config struct tags are intentionally verbose - RedshiftEngine string ` name:"redshift-engine" env:"REDSHIFT_ENGINE" default:"stub" help:"Redshift engine mode: stub (metadata only) or docker (real Postgres container per cluster; Data API runs SQL against it)."` //nolint:lll // config struct tags are intentionally verbose - NeptuneEngine string ` name:"neptune-engine" env:"NEPTUNE_ENGINE" default:"stub" help:"Neptune engine mode: stub (metadata only), embedded (in-process Gremlin, openCypher and Bolt on one graph) or docker (NEPTUNE_GREMLIN=container: a Gremlin Server container per cluster)."` //nolint:lll // config struct tags are intentionally verbose - EC2Provider string ` name:"ec2-provider" env:"EC2_PROVIDER" default:"inmemory" help:"EC2 compute provider: inmemory (stub) or docker (launches real containers as instances)."` //nolint:lll // config struct tags are intentionally verbose - EC2DockerImage string ` name:"ec2-docker-image" env:"EC2_DOCKER_IMAGE" default:"amazonlinux:2" help:"Docker image used by the EC2 docker provider when launching instances."` //nolint:lll // config struct tags are intentionally verbose - EC2DockerNetwork string ` name:"ec2-docker-network" env:"EC2_DOCKER_NETWORK" default:"" help:"Docker network EC2 docker-provider containers attach to (empty = daemon default bridge)."` //nolint:lll // config struct tags are intentionally verbose - EC2DockerSSHHostIP string ` name:"ec2-docker-ssh-host-ip" env:"EC2_DOCKER_SSH_HOST_IP" default:"127.0.0.1" help:"Host IP that mapped EC2-docker SSH ports bind to (use 0.0.0.0 to expose externally)."` //nolint:lll // config struct tags are intentionally verbose - TransferSFTPHost string ` name:"transfer-sftp-host" env:"TRANSFER_SFTP_HOST" default:"127.0.0.1" help:"Host the Transfer Family SFTP listener binds to."` //nolint:lll // config struct tags are intentionally verbose - Port string ` name:"port" env:"PORT" default:"8000" help:"HTTP server port."` //nolint:lll // config struct tags are intentionally verbose - DataDir string ` name:"data-dir" env:"GOPHERSTACK_DATA_DIR" default:"" help:"Directory for persistence data files (default: ~/.gopherstack/data, or /data in containers)."` //nolint:lll // config struct tags are intentionally verbose - DNSListenAddr string ` name:"dns-addr" env:"DNS_ADDR" default:"" help:"Address for embedded DNS server (e.g. :10053). Empty = disabled."` //nolint:lll // config struct tags are intentionally verbose - LogLevel string ` name:"log-level" env:"LOG_LEVEL" default:"info" help:"Log level (debug|info|warn|error)."` //nolint:lll // config struct tags are intentionally verbose - SigV4Secret string ` name:"sigv4-secret" env:"SIGV4_SECRET" default:"test" help:"Secret access key SigV4 validation signs against (used only when --validate-sigv4 is set)."` //nolint:lll // config struct tags are intentionally verbose - OpenSearchEngine string ` name:"opensearch-engine" env:"OPENSEARCH_ENGINE" default:"stub" help:"OpenSearch engine mode: stub (API-only) or docker."` //nolint:lll // config struct tags are intentionally verbose - ElasticsearchEngine string ` name:"elasticsearch-engine" env:"ELASTICSEARCH_ENGINE" default:"stub" help:"Elasticsearch engine mode: stub (API-only) or docker."` //nolint:lll // config struct tags are intentionally verbose - MemoryDBEngine string ` name:"memorydb-engine" env:"MEMORYDB_ENGINE" default:"embedded" help:"MemoryDB engine mode: embedded (miniredis), stub, or docker."` //nolint:lll // config struct tags are intentionally verbose - AccountID string ` name:"account-id" env:"ACCOUNT_ID" default:"000000000000" help:"Mock AWS account ID used in ARNs."` //nolint:lll // config struct tags are intentionally verbose - TLSCertFile string ` name:"tls-cert" env:"TLS_CERT" default:"" help:"Path to a TLS certificate (PEM). Enables an HTTPS listener; requires --tls-key. Empty = HTTP only."` //nolint:lll // config struct tags are intentionally verbose - TLSKeyFile string ` name:"tls-key" env:"TLS_KEY" default:"" help:"Path to a TLS private key (PEM). Required with --tls-cert."` //nolint:lll // config struct tags are intentionally verbose - Compression string ` name:"compression" env:"COMPRESSION" default:"on" help:"Runtime response compression: zstd/br/gzip for the dashboard, gzip for DynamoDB clients that opt in." enum:"on,off"` //nolint:lll // config struct tags are intentionally verbose - ElastiCacheEngine string ` name:"elasticache-engine" env:"ELASTICACHE_ENGINE" default:"embedded" help:"ElastiCache engine mode: embedded (miniredis), stub, or docker."` //nolint:lll // config struct tags are intentionally verbose - Pods PodsConfig `embed:"" prefix:"pod-"` - Extensions extensions.Settings `embed:""` - DockerRegistry registryauth.Settings `embed:""` - OCIObjectStorage ociobjectstoragebackend.Settings `embed:"" prefix:"oci-objectstorage-"` - InitScripts []string ` name:"init-script" env:"INIT_SCRIPTS" help:"Shell scripts to run on startup (may be specified multiple times)."` //nolint:lll // config struct tags are intentionally verbose - InitDirs []string ` name:"init-dir" env:"INIT_DIR" help:"Extra init-hook root holding boot.d/start.d/ready.d/shutdown.d (repeatable; /etc/localstack/init and /etc/gopherstack/init are always checked)."` //nolint:lll // config struct tags are intentionally verbose - S3InitBuckets []string ` name:"s3-bucket" env:"S3_BUCKETS" help:"S3 bucket names to create on startup (may be specified multiple times or as a comma-separated list)."` //nolint:lll // config struct tags are intentionally verbose - ExtraListen []string ` name:"extra-listen" env:"GOPHERSTACK_EXTRA_LISTEN" help:"Additional ports to serve the same endpoint on (comma-separated)."` //nolint:lll // config struct tags are intentionally verbose - GCPCloudTasks gcpcloudtasksbackend.Settings `embed:"" prefix:"gcp-cloudtasks-"` - GCPScheduler gcpschedulerbackend.Settings `embed:"" prefix:"gcp-scheduler-"` - GCPRun gcprunbackend.Settings `embed:"" prefix:"gcp-run-"` - GCPFunctions gcpfunctionsbackend.Settings `embed:"" prefix:"gcp-functions-"` - Transparent TransparentSettings `embed:"" prefix:"transparent-"` - AzureEventGrid azureeventgridbackend.Settings `embed:"" prefix:"azure-eventgrid-"` - AzureMonitor azuremonitorbackend.Settings `embed:"" prefix:"azure-monitor-"` - CodeBuild codebuildbackend.Settings `embed:"" prefix:"codebuild-"` - AzureKeyVault azurekeyvaultbackend.Settings `embed:"" prefix:"azure-keyvault-"` - S3 s3backend.Settings `embed:"" prefix:"s3-"` - AzureAppConfig azureappconfigbackend.Settings `embed:"" prefix:"azure-appconfig-"` - AzureACR azureacrbackend.Settings `embed:"" prefix:"azure-acr-"` - AzureFunctions azurefunctionsbackend.Settings `embed:"" prefix:"azure-functions-"` - AzureRedis azureredisbackend.Settings `embed:"" prefix:"azure-redis-"` - AzureSQL azuresqlbackend.Settings `embed:"" prefix:"azure-sql-"` - CosmosDB cosmosdbbackend.Settings `embed:"" prefix:"cosmosdb-"` - GCPArtifactRegistry gcparbackend.Settings `embed:"" prefix:"gcp-artifacts-"` - GCPSQL gcpsqlbackend.Settings `embed:"" prefix:"gcp-sql-"` - AgentCore agentcorebackend.Settings `embed:"" prefix:"agentcore-"` - GCPCloudBuild gcpcloudbuildbackend.Settings `embed:"" prefix:"gcp-cloudbuild-"` - GCPSpanner gcpspannerbackend.Settings `embed:"" prefix:"gcp-spanner-"` - SES sesbackend.Settings `embed:"" prefix:"ses-"` - DynamoDB ddbbackend.Settings `embed:"" prefix:"dynamodb-"` - AzureARM azurearmbackend.Settings `embed:"" prefix:"azure-arm-"` - Lambda lambdabackend.Settings `embed:"" prefix:"lambda-"` - Lifecycle cliconfig.LifecycleSettings `embed:"" prefix:"lifecycle-"` - Batch batchbackend.Settings `embed:"" prefix:"batch-"` - EC2 ec2backend.Settings `embed:"" prefix:"ec2-"` - StepFunctions sfnbackend.Settings `embed:"" prefix:"stepfunctions-"` - Backup backupbackend.Settings `embed:"" prefix:"backup-"` - SSM ssmbackend.Settings `embed:"" prefix:"ssm-"` - XRay xraybackend.Settings `embed:"" prefix:"xray-"` - FIS fisbackend.Settings `embed:"" prefix:"fis-"` - EMR emrbackend.Settings `embed:"" prefix:"emr-"` - Athena athenabackend.Settings `embed:"" prefix:"athena-"` - CloudWatchLogs cwlogsbackend.Settings `embed:"" prefix:"cloudwatchlogs-"` - AzureServiceBus azureservicebusbackend.Settings `embed:"" prefix:"azure-servicebus-"` - AzureEventHubs azureeventhubsbackend.Settings `embed:"" prefix:"azure-eventhubs-"` - IoT iotbackend.Settings `embed:"" prefix:"iot-"` - AzureBlob azureblobbackend.Settings `embed:"" prefix:"azure-blob-"` - KMS kmsbackend.Settings `embed:"" prefix:"kms-"` - Kinesis kinesisbackend.Settings `embed:"" prefix:"kinesis-"` - STS stsbackend.Settings `embed:"" prefix:"sts-"` - AzureQueue azurequeuebackend.Settings `embed:"" prefix:"azure-queue-"` - AzureFile azurefilebackend.Settings `embed:"" prefix:"azure-file-"` - AzureTable azuretablebackend.Settings `embed:"" prefix:"azure-table-"` - GCPStorage gcpstoragebackend.Settings `embed:"" prefix:"gcp-storage-"` - GCPPubSub gcppubsubbackend.Settings `embed:"" prefix:"gcp-pubsub-"` - GCPSecretManager gcpsecretmanagerbackend.Settings `embed:"" prefix:"gcp-secretmanager-"` - GCPKMS gcpkmsbackend.Settings `embed:"" prefix:"gcp-kms-"` - GCPFirestore gcpfirestorebackend.Settings `embed:"" prefix:"gcp-firestore-"` - AzureStorageVHost azurestoragevhostbackend.Settings `embed:"" prefix:"azure-storage-vhost-"` - GCPIAM gcpiambackend.Settings `embed:"" prefix:"gcp-iam-"` - GCPBigQuery gcpbigquerybackend.Settings `embed:"" prefix:"gcp-bigquery-"` - GCPLogging gcploggingbackend.Settings `embed:"" prefix:"gcp-logging-"` - GCPMonitoring gcpmonitoringbackend.Settings `embed:"" prefix:"gcp-monitoring-"` - GCPRedis gcpredisbackend.Settings `embed:"" prefix:"gcp-redis-"` - GCPBigtable gcpbigtablebackend.Settings `embed:"" prefix:"gcp-bigtable-"` - GCPEventarc gcpeventarcbackend.Settings `embed:"" prefix:"gcp-eventarc-"` - GCPWorkflows gcpworkflowsbackend.Settings `embed:"" prefix:"gcp-workflows-"` - GCPDNS gcpdnsbackend.Settings `embed:"" prefix:"gcp-dns-"` - GCPCompute gcpcomputebackend.Settings `embed:"" prefix:"gcp-compute-"` - PortRangeStart int ` name:"port-range-start" env:"PORT_RANGE_START" default:"10000" help:"Start of the port range for resource endpoints."` //nolint:lll // config struct tags are intentionally verbose - PortRangeEnd int ` name:"port-range-end" env:"PORT_RANGE_END" default:"10100" help:"End (exclusive) of the port range for resource endpoints."` //nolint:lll // config struct tags are intentionally verbose - EC2DockerSSHPortMin int ` name:"ec2-docker-ssh-port-min" env:"EC2_DOCKER_SSH_PORT_MIN" default:"0" help:"Lower bound of the host TCP port range used to map EC2-docker SSH (0 = let Docker pick)."` //nolint:lll // config struct tags are intentionally verbose - EC2DockerSSHPortMax int ` name:"ec2-docker-ssh-port-max" env:"EC2_DOCKER_SSH_PORT_MAX" default:"0" help:"Upper bound of the host TCP port range used to map EC2-docker SSH."` //nolint:lll // config struct tags are intentionally verbose - ElasticsearchProcessingDelay time.Duration ` name:"es-processing-delay" env:"ELASTICSEARCH_PROCESSING_DELAY" default:"0s" help:"Elasticsearch domain Processing window; 0 settles instantly."` //nolint:lll // config struct tags are intentionally verbose - InitScriptTimeout time.Duration ` name:"init-timeout" env:"INIT_TIMEOUT" default:"30s" help:"Per-script timeout for init hooks."` //nolint:lll // config struct tags are intentionally verbose - JanitorTimeout time.Duration ` name:"janitor-timeout" env:"JANITOR_TIMEOUT" default:"30s" help:"Per-task timeout for janitor operations (TTL sweeps, table cleaners, etc.). Zero disables per-task timeouts. Higher values prevent deadlocks; lower values keep the janitor loop responsive."` //nolint:lll // config struct tags are intentionally verbose - LatencyMs int ` name:"latency-ms" env:"LATENCY_MS" default:"0" help:"Inject random latency [0,N) ms per request (0 = disabled). Values near the 30 s write timeout may cause connection errors."` //nolint:lll // config struct tags are intentionally verbose - AutoPurgeTTL time.Duration ` name:"auto-purge-ttl" env:"AUTO_PURGE_TTL" help:"If set, automatically reset all services on a timer based on the TTL (e.g., 10m)."` //nolint:lll // config struct tags are intentionally verbose - EnforceIAM bool ` name:"enforce-iam" env:"GOPHERSTACK_ENFORCE_IAM,ENFORCE_IAM" default:"false" help:"Enable IAM policy enforcement. When true, every AWS API request is evaluated against attached IAM policies."` //nolint:lll // config struct tags are intentionally verbose - EnforceSecurityGroups bool ` name:"enforce-security-groups" env:"GOPHERSTACK_ENFORCE_SECURITY_GROUPS" default:"false" help:"Enforce EC2 security-group ingress on container-backed endpoints (RDS, EC2, DocDB, MSK, caches, MQ, Neptune, ECS); ingress only. Default off."` //nolint:lll // config struct tags are intentionally verbose - EnforceServiceQuotas bool ` name:"enforce-service-quotas" env:"GOPHERSTACK_ENFORCE_SERVICE_QUOTAS" default:"false" help:"Make S3, EC2, IAM and DynamoDB reject creates beyond the Service Quotas applied limit. Default off."` //nolint:lll // config struct tags are intentionally verbose - IAMAudit bool ` name:"iam-audit" env:"GOPHERSTACK_IAM_AUDIT,IAM_SOFT_MODE" default:"false" help:"IAM audit (soft) mode: evaluate and record every request in the IAM Policy Stream but never deny."` //nolint:lll // config struct tags are intentionally verbose - LocalstackCompat bool ` name:"localstack-compat" env:"GOPHERSTACK_LOCALSTACK_COMPAT" default:"false" help:"LocalStack drop-in mode: also listen on 4566 (auto-enabled when LOCALSTACK_*, GATEWAY_LISTEN, LS_LOG or PERSISTENCE is set)."` //nolint:lll // config struct tags are intentionally verbose - Persist bool ` name:"persist" env:"PERSIST" default:"false" help:"Enable snapshot-based persistence across restarts."` //nolint:lll // config struct tags are intentionally verbose - Demo bool ` name:"demo" env:"DEMO" default:"false" help:"Load demo data on startup."` //nolint:lll // config struct tags are intentionally verbose - TLS bool ` name:"tls" env:"TLS" default:"false" help:"Serve over HTTPS. With --tls-cert/--tls-key uses those files; otherwise a self-signed certificate is generated on demand."` //nolint:lll // config struct tags are intentionally verbose - TransferSFTP bool ` name:"transfer-sftp" env:"TRANSFER_SFTP" default:"false" help:"Serve a real SFTP listener per Transfer Family server."` //nolint:lll // config struct tags are intentionally verbose - ValidateSigV4 bool ` name:"validate-sigv4" env:"VALIDATE_SIGV4" default:"false" help:"Cryptographically validate AWS SigV4 request signatures (opt-in). Signed requests whose signature does not match --sigv4-secret are rejected."` //nolint:lll // config struct tags are intentionally verbose - SQS sqsbackend.Settings `embed:"" prefix:"sqs-"` - Localstack LocalstackSettings `embed:"" prefix:"localstack-"` - OCIVault ocivaultbackend.Settings `embed:"" prefix:"oci-vault-"` - OCIStreaming ocistreamingbackend.Settings `embed:"" prefix:"oci-streaming-"` - OCIIdentity ociidentitybackend.Settings `embed:"" prefix:"oci-identity-"` - OCIFunctions ocifunctionsbackend.Settings `embed:"" prefix:"oci-functions-"` - OCICore ocicorebackend.Settings `embed:"" prefix:"oci-core-"` + EngineSwitch EngineSettings `embed:""` + ServiceFilter ServiceFilterSettings `embed:""` + PersistModes PersistModeSettings `embed:""` + CORS CORSSettings `embed:"" prefix:"cors-"` + Network NetworkSettings `embed:""` + ELBv2 ELBv2Settings `embed:"" prefix:"elbv2-"` + Region string ` name:"region" env:"REGION" default:"us-east-1" help:"AWS region (also read from AWS_DEFAULT_REGION and AWS_REGION)."` //nolint:lll // config struct tags are intentionally verbose + DNSResolveIP string ` name:"dns-resolve-ip" env:"DNS_RESOLVE_IP" default:"127.0.0.1" help:"IP address synthetic hostnames resolve to."` //nolint:lll // config struct tags are intentionally verbose + KafkaEngine string ` name:"kafka-engine" env:"KAFKA_ENGINE" default:"stub" help:"MSK engine mode: stub (metadata only) or docker (real Kafka broker per cluster)."` //nolint:lll // config struct tags are intentionally verbose + FlinkEngine string ` name:"flink-engine" env:"FLINK_ENGINE" default:"stub" help:"Managed Flink engine mode: stub (state only) or docker (real Flink cluster and job per running application)."` //nolint:lll // config struct tags are intentionally verbose + MWAAEngine string ` name:"mwaa-engine" env:"MWAA_ENGINE" default:"stub" help:"MWAA engine mode: stub (state only) or docker (real Apache Airflow container per environment)."` //nolint:lll // config struct tags are intentionally verbose + MQEngine string ` name:"mq-engine" env:"MQ_ENGINE" default:"stub" help:"Amazon MQ engine mode: stub (metadata only) or docker (real RabbitMQ/ActiveMQ container per broker)."` //nolint:lll // config struct tags are intentionally verbose + EKSEngine string ` name:"eks-engine" env:"EKS_ENGINE" default:"stub" help:"EKS engine mode: stub (metadata only) or docker (real single-node k3s cluster per CreateCluster)."` //nolint:lll // config struct tags are intentionally verbose + GKEEngine string ` name:"gke-engine" env:"GKE_ENGINE" default:"stub" help:"GKE engine mode: stub (control plane only) or docker (real single-node k3s cluster per CreateCluster)."` //nolint:lll // config struct tags are intentionally verbose + RDSEngine string ` name:"rds-engine" env:"RDS_ENGINE" default:"stub" help:"RDS engine mode: stub (metadata only) or docker (real database container)."` //nolint:lll // config struct tags are intentionally verbose + DMSEngine string ` name:"dms-engine" env:"DMS_ENGINE" default:"stub" help:"DMS engine mode: stub (task state only) or docker (real full-load and CDC data movement)."` //nolint:lll // config struct tags are intentionally verbose + SageMakerEngine string ` name:"sagemaker-engine" env:"SAGEMAKER_ENGINE" default:"stub" help:"SageMaker engine mode: stub (control plane only; InvokeEndpoint is unavailable) or docker (endpoints and jobs run model, training, processing and transform containers)."` //nolint:lll // config struct tags are intentionally verbose + DocDBEngine string ` name:"docdb-engine" env:"DOCDB_ENGINE" default:"stub" help:"DocumentDB engine mode: stub (metadata only) or docker (real MongoDB container per cluster)."` //nolint:lll // config struct tags are intentionally verbose + RedshiftEngine string ` name:"redshift-engine" env:"REDSHIFT_ENGINE" default:"stub" help:"Redshift engine mode: stub (metadata only) or docker (real Postgres container per cluster; Data API runs SQL against it)."` //nolint:lll // config struct tags are intentionally verbose + NeptuneEngine string ` name:"neptune-engine" env:"NEPTUNE_ENGINE" default:"stub" help:"Neptune engine mode: stub (metadata only), embedded (in-process Gremlin, openCypher and Bolt on one graph) or docker (NEPTUNE_GREMLIN=container: a Gremlin Server container per cluster)."` //nolint:lll // config struct tags are intentionally verbose + EC2Provider string ` name:"ec2-provider" env:"EC2_PROVIDER" default:"inmemory" help:"EC2 compute provider: inmemory (stub) or docker (launches real containers as instances)."` //nolint:lll // config struct tags are intentionally verbose + EC2DockerImage string ` name:"ec2-docker-image" env:"EC2_DOCKER_IMAGE" default:"amazonlinux:2" help:"Docker image used by the EC2 docker provider when launching instances."` //nolint:lll // config struct tags are intentionally verbose + EC2DockerNetwork string ` name:"ec2-docker-network" env:"EC2_DOCKER_NETWORK" default:"" help:"Docker network EC2 docker-provider containers attach to (empty = daemon default bridge)."` //nolint:lll // config struct tags are intentionally verbose + EC2DockerSSHHostIP string ` name:"ec2-docker-ssh-host-ip" env:"EC2_DOCKER_SSH_HOST_IP" default:"127.0.0.1" help:"Host IP that mapped EC2-docker SSH ports bind to (use 0.0.0.0 to expose externally)."` //nolint:lll // config struct tags are intentionally verbose + EC2DockerFlags string ` name:"ec2-docker-flags" env:"GOPHERSTACK_EC2_DOCKER_FLAGS" default:"" help:"Extra docker-run options for EC2 docker containers (--privileged, -e, -v, --cap-add/--cap-drop, -m, --cpus, --network, -p); others are rejected at startup."` //nolint:lll // config struct tags are intentionally verbose + TransferSFTPHost string ` name:"transfer-sftp-host" env:"TRANSFER_SFTP_HOST" default:"127.0.0.1" help:"Host the Transfer Family SFTP listener binds to."` //nolint:lll // config struct tags are intentionally verbose + Port string ` name:"port" env:"PORT" default:"8000" help:"HTTP server port."` //nolint:lll // config struct tags are intentionally verbose + DataDir string ` name:"data-dir" env:"GOPHERSTACK_DATA_DIR" default:"" help:"Directory for persistence data files (default: ~/.gopherstack/data, or /data in containers)."` //nolint:lll // config struct tags are intentionally verbose + DNSListenAddr string ` name:"dns-addr" env:"DNS_ADDR" default:"" help:"Address for embedded DNS server (e.g. :10053). Empty = disabled."` //nolint:lll // config struct tags are intentionally verbose + LogLevel string ` name:"log-level" env:"LOG_LEVEL" default:"info" help:"Log level (debug|info|warn|error)."` //nolint:lll // config struct tags are intentionally verbose + SigV4Secret string ` name:"sigv4-secret" env:"SIGV4_SECRET" default:"test" help:"Secret access key SigV4 validation signs against (used only when --validate-sigv4 is set)."` //nolint:lll // config struct tags are intentionally verbose + OpenSearchEngine string ` name:"opensearch-engine" env:"OPENSEARCH_ENGINE" default:"stub" help:"OpenSearch engine mode: stub (API-only) or docker."` //nolint:lll // config struct tags are intentionally verbose + ElasticsearchEngine string ` name:"elasticsearch-engine" env:"ELASTICSEARCH_ENGINE" default:"stub" help:"Elasticsearch engine mode: stub (API-only) or docker."` //nolint:lll // config struct tags are intentionally verbose + MemoryDBEngine string ` name:"memorydb-engine" env:"MEMORYDB_ENGINE" default:"embedded" help:"MemoryDB engine mode: embedded (miniredis), stub, or docker."` //nolint:lll // config struct tags are intentionally verbose + AccountID string ` name:"account-id" env:"ACCOUNT_ID" default:"000000000000" help:"Mock AWS account ID used in ARNs."` //nolint:lll // config struct tags are intentionally verbose + TLSCertFile string ` name:"tls-cert" env:"TLS_CERT" default:"" help:"Path to a TLS certificate (PEM). Enables an HTTPS listener; requires --tls-key. Empty = HTTP only."` //nolint:lll // config struct tags are intentionally verbose + TLSKeyFile string ` name:"tls-key" env:"TLS_KEY" default:"" help:"Path to a TLS private key (PEM). Required with --tls-cert."` //nolint:lll // config struct tags are intentionally verbose + Compression string ` name:"compression" env:"COMPRESSION" default:"on" help:"Runtime response compression: zstd/br/gzip for the dashboard, gzip for DynamoDB clients that opt in." enum:"on,off"` //nolint:lll // config struct tags are intentionally verbose + ElastiCacheEngine string ` name:"elasticache-engine" env:"ELASTICACHE_ENGINE" default:"embedded" help:"ElastiCache engine mode: embedded (miniredis), stub, or docker."` //nolint:lll // config struct tags are intentionally verbose + Pods PodsConfig `embed:"" prefix:"pod-"` + MCP MCPConfig `embed:"" prefix:"mcp-"` + Extensions extensions.Settings `embed:""` + DockerRegistry registryauth.Settings `embed:""` + OCIObjectStorage ociobjectstoragebackend.Settings `embed:"" prefix:"oci-objectstorage-"` + InitScripts []string ` name:"init-script" env:"INIT_SCRIPTS" help:"Shell scripts to run on startup (may be specified multiple times)."` //nolint:lll // config struct tags are intentionally verbose + InitDirs []string ` name:"init-dir" env:"INIT_DIR" help:"Extra init-hook root holding boot.d/start.d/ready.d/shutdown.d (repeatable; /etc/localstack/init and /etc/gopherstack/init are always checked)."` //nolint:lll // config struct tags are intentionally verbose + S3InitBuckets []string ` name:"s3-bucket" env:"S3_BUCKETS" help:"S3 bucket names to create on startup (may be specified multiple times or as a comma-separated list)."` //nolint:lll // config struct tags are intentionally verbose + ExtraListen []string ` name:"extra-listen" env:"GOPHERSTACK_EXTRA_LISTEN" help:"Additional ports to serve the same endpoint on (comma-separated)."` //nolint:lll // config struct tags are intentionally verbose + GCPCloudTasks gcpcloudtasksbackend.Settings `embed:"" prefix:"gcp-cloudtasks-"` + GCPScheduler gcpschedulerbackend.Settings `embed:"" prefix:"gcp-scheduler-"` + GCPRun gcprunbackend.Settings `embed:"" prefix:"gcp-run-"` + GCPFunctions gcpfunctionsbackend.Settings `embed:"" prefix:"gcp-functions-"` + VerifiedPermissions verifiedpermissionsbackend.Settings `embed:"" prefix:"verifiedpermissions-"` + Transparent TransparentSettings `embed:"" prefix:"transparent-"` + AzureEventGrid azureeventgridbackend.Settings `embed:"" prefix:"azure-eventgrid-"` + AzureMonitor azuremonitorbackend.Settings `embed:"" prefix:"azure-monitor-"` + AzureGraph azuregraphbackend.Settings `embed:"" prefix:"azure-graph-"` + CodeBuild codebuildbackend.Settings `embed:"" prefix:"codebuild-"` + Glue gluebackend.Settings `embed:"" prefix:"glue-"` + GCPIdentityToolkit gcpidtkbackend.Settings `embed:"" prefix:"gcp-identitytoolkit-"` + AzureKeyVault azurekeyvaultbackend.Settings `embed:"" prefix:"azure-keyvault-"` + S3 s3backend.Settings `embed:"" prefix:"s3-"` + AzureAppConfig azureappconfigbackend.Settings `embed:"" prefix:"azure-appconfig-"` + AzureACR azureacrbackend.Settings `embed:"" prefix:"azure-acr-"` + AzureFunctions azurefunctionsbackend.Settings `embed:"" prefix:"azure-functions-"` + AzureRedis azureredisbackend.Settings `embed:"" prefix:"azure-redis-"` + AzureSQL azuresqlbackend.Settings `embed:"" prefix:"azure-sql-"` + CosmosDB cosmosdbbackend.Settings `embed:"" prefix:"cosmosdb-"` + GCPArtifactRegistry gcparbackend.Settings `embed:"" prefix:"gcp-artifacts-"` + GCPSQL gcpsqlbackend.Settings `embed:"" prefix:"gcp-sql-"` + OCIContainerEngine ocioke.Settings `embed:"" prefix:"oci-oke-"` + OCIContainerInstances ocicontainerinstances.Settings `embed:""` + OCIMySQL ocimysqlbackend.Settings `embed:"" prefix:"oci-mysql-"` + AgentCore agentcorebackend.Settings `embed:"" prefix:"agentcore-"` + GCPCloudBuild gcpcloudbuildbackend.Settings `embed:"" prefix:"gcp-cloudbuild-"` + GCPBatch gcpbatchbackend.Settings `embed:"" prefix:"gcp-batch-"` + GCPResourceManager gcprmbackend.Settings `embed:"" prefix:"gcp-resourcemanager-"` + GCPSpanner gcpspannerbackend.Settings `embed:"" prefix:"gcp-spanner-"` + SES sesbackend.Settings `embed:"" prefix:"ses-"` + DynamoDB ddbbackend.Settings `embed:"" prefix:"dynamodb-"` + AzureARM azurearmbackend.Settings `embed:"" prefix:"azure-arm-"` + Lambda lambdabackend.Settings `embed:"" prefix:"lambda-"` + Lifecycle cliconfig.LifecycleSettings `embed:"" prefix:"lifecycle-"` + Batch batchbackend.Settings `embed:"" prefix:"batch-"` + EC2 ec2backend.Settings `embed:"" prefix:"ec2-"` + StepFunctions sfnbackend.Settings `embed:"" prefix:"stepfunctions-"` + Backup backupbackend.Settings `embed:"" prefix:"backup-"` + SSM ssmbackend.Settings `embed:"" prefix:"ssm-"` + XRay xraybackend.Settings `embed:"" prefix:"xray-"` + FIS fisbackend.Settings `embed:"" prefix:"fis-"` + EMR emrbackend.Settings `embed:"" prefix:"emr-"` + Athena athenabackend.Settings `embed:"" prefix:"athena-"` + CloudWatchLogs cwlogsbackend.Settings `embed:"" prefix:"cloudwatchlogs-"` + AzureServiceBus azureservicebusbackend.Settings `embed:"" prefix:"azure-servicebus-"` + AzureEventHubs azureeventhubsbackend.Settings `embed:"" prefix:"azure-eventhubs-"` + IoT iotbackend.Settings `embed:"" prefix:"iot-"` + AzureBlob azureblobbackend.Settings `embed:"" prefix:"azure-blob-"` + KMS kmsbackend.Settings `embed:"" prefix:"kms-"` + Kinesis kinesisbackend.Settings `embed:"" prefix:"kinesis-"` + STS stsbackend.Settings `embed:"" prefix:"sts-"` + AzureQueue azurequeuebackend.Settings `embed:"" prefix:"azure-queue-"` + AzureFile azurefilebackend.Settings `embed:"" prefix:"azure-file-"` + AzureTable azuretablebackend.Settings `embed:"" prefix:"azure-table-"` + GCPStorage gcpstoragebackend.Settings `embed:"" prefix:"gcp-storage-"` + GCPPubSub gcppubsubbackend.Settings `embed:"" prefix:"gcp-pubsub-"` + GCPSecretManager gcpsecretmanagerbackend.Settings `embed:"" prefix:"gcp-secretmanager-"` + GCPKMS gcpkmsbackend.Settings `embed:"" prefix:"gcp-kms-"` + GCPFirestore gcpfirestorebackend.Settings `embed:"" prefix:"gcp-firestore-"` + AzureStorageVHost azurestoragevhostbackend.Settings `embed:"" prefix:"azure-storage-vhost-"` + GCPIAM gcpiambackend.Settings `embed:"" prefix:"gcp-iam-"` + GCPBigQuery gcpbigquerybackend.Settings `embed:"" prefix:"gcp-bigquery-"` + GCPLogging gcploggingbackend.Settings `embed:"" prefix:"gcp-logging-"` + GCPMonitoring gcpmonitoringbackend.Settings `embed:"" prefix:"gcp-monitoring-"` + GCPRedis gcpredisbackend.Settings `embed:"" prefix:"gcp-redis-"` + GCPBigtable gcpbigtablebackend.Settings `embed:"" prefix:"gcp-bigtable-"` + GCPEventarc gcpeventarcbackend.Settings `embed:"" prefix:"gcp-eventarc-"` + GCPWorkflows gcpworkflowsbackend.Settings `embed:"" prefix:"gcp-workflows-"` + GCPDNS gcpdnsbackend.Settings `embed:"" prefix:"gcp-dns-"` + GCPDatastore gcpdatastorebackend.Settings `embed:"" prefix:"gcp-datastore-"` + GCPCompute gcpcomputebackend.Settings `embed:"" prefix:"gcp-compute-"` + GCPContainer gcpcontainerbackend.Settings `embed:"" prefix:"gcp-container-"` + GCPAPIGateway gcpapigatewaybackend.Settings `embed:"" prefix:"gcp-apigateway-"` + PortRangeStart int ` name:"port-range-start" env:"PORT_RANGE_START" default:"10000" help:"Start of the port range for resource endpoints."` //nolint:lll // config struct tags are intentionally verbose + PortRangeEnd int ` name:"port-range-end" env:"PORT_RANGE_END" default:"10100" help:"End (exclusive) of the port range for resource endpoints."` //nolint:lll // config struct tags are intentionally verbose + EC2DockerSSHPortMin int ` name:"ec2-docker-ssh-port-min" env:"EC2_DOCKER_SSH_PORT_MIN" default:"0" help:"Lower bound of the host TCP port range used to map EC2-docker SSH (0 = let Docker pick)."` //nolint:lll // config struct tags are intentionally verbose + EC2DockerSSHPortMax int ` name:"ec2-docker-ssh-port-max" env:"EC2_DOCKER_SSH_PORT_MAX" default:"0" help:"Upper bound of the host TCP port range used to map EC2-docker SSH."` //nolint:lll // config struct tags are intentionally verbose + ElasticsearchProcessingDelay time.Duration ` name:"es-processing-delay" env:"ELASTICSEARCH_PROCESSING_DELAY" default:"0s" help:"Elasticsearch domain Processing window; 0 settles instantly."` //nolint:lll // config struct tags are intentionally verbose + InitScriptTimeout time.Duration ` name:"init-timeout" env:"INIT_TIMEOUT" default:"30s" help:"Per-script timeout for init hooks."` //nolint:lll // config struct tags are intentionally verbose + JanitorTimeout time.Duration ` name:"janitor-timeout" env:"JANITOR_TIMEOUT" default:"30s" help:"Per-task timeout for janitor operations (TTL sweeps, table cleaners, etc.). Zero disables per-task timeouts. Higher values prevent deadlocks; lower values keep the janitor loop responsive."` //nolint:lll // config struct tags are intentionally verbose + LatencyMs int ` name:"latency-ms" env:"LATENCY_MS" default:"0" help:"Inject random latency [0,N) ms per request (0 = disabled). Values near the 30 s write timeout may cause connection errors."` //nolint:lll // config struct tags are intentionally verbose + AutoPurgeTTL time.Duration ` name:"auto-purge-ttl" env:"AUTO_PURGE_TTL" help:"If set, automatically reset all services on a timer based on the TTL (e.g., 10m)."` //nolint:lll // config struct tags are intentionally verbose + EnforceIAM bool ` name:"enforce-iam" env:"GOPHERSTACK_ENFORCE_IAM,ENFORCE_IAM" default:"false" help:"Enable IAM policy enforcement. When true, every AWS API request is evaluated against attached IAM policies."` //nolint:lll // config struct tags are intentionally verbose + EC2DockerEBSVolumes bool ` name:"ec2-docker-ebs-volumes" env:"GOPHERSTACK_EC2_DOCKER_EBS_VOLUMES" default:"false" help:"Map EC2 EBS volumes to Docker named volumes mounted at /mnt/ebs/ in EC2 docker-provider containers."` //nolint:lll // config struct tags are intentionally verbose + EnforceSecurityGroups bool ` name:"enforce-security-groups" env:"GOPHERSTACK_ENFORCE_SECURITY_GROUPS" default:"false" help:"Enforce EC2 security-group ingress on container-backed endpoints (RDS, EC2, DocDB, MSK, caches, MQ, Neptune, ECS); ingress only. Default off."` //nolint:lll // config struct tags are intentionally verbose + EnforceServiceQuotas bool ` name:"enforce-service-quotas" env:"GOPHERSTACK_ENFORCE_SERVICE_QUOTAS" default:"false" help:"Make S3, EC2, IAM and DynamoDB reject creates beyond the Service Quotas applied limit. Default off."` //nolint:lll // config struct tags are intentionally verbose + IAMAudit bool ` name:"iam-audit" env:"GOPHERSTACK_IAM_AUDIT,IAM_SOFT_MODE" default:"false" help:"IAM audit (soft) mode: evaluate and record every request in the IAM Policy Stream but never deny."` //nolint:lll // config struct tags are intentionally verbose + IAMEnforceSCPs bool ` name:"iam-enforce-scps" env:"GOPHERSTACK_IAM_ENFORCE_SCPS" default:"true" help:"With IAM enforcement on, also evaluate Organizations service control policies for member-account principals. Set false to skip SCPs."` //nolint:lll // config struct tags are intentionally verbose + LocalstackCompat bool ` name:"localstack-compat" env:"GOPHERSTACK_LOCALSTACK_COMPAT" default:"false" help:"LocalStack drop-in mode: also listen on 4566 (auto-enabled when LOCALSTACK_*, GATEWAY_LISTEN, LS_LOG or PERSISTENCE is set)."` //nolint:lll // config struct tags are intentionally verbose + Persist bool ` name:"persist" env:"PERSIST" default:"false" help:"Enable snapshot-based persistence across restarts."` //nolint:lll // config struct tags are intentionally verbose + Demo bool ` name:"demo" env:"DEMO" default:"false" help:"Load demo data on startup."` //nolint:lll // config struct tags are intentionally verbose + TLS bool ` name:"tls" env:"TLS" default:"false" help:"Serve over HTTPS. With --tls-cert/--tls-key uses those files; otherwise a self-signed certificate is generated on demand."` //nolint:lll // config struct tags are intentionally verbose + TransferSFTP bool ` name:"transfer-sftp" env:"TRANSFER_SFTP" default:"false" help:"Serve a real SFTP listener per Transfer Family server."` //nolint:lll // config struct tags are intentionally verbose + ValidateSigV4 bool ` name:"validate-sigv4" env:"VALIDATE_SIGV4" default:"false" help:"Cryptographically validate AWS SigV4 request signatures (opt-in). Signed requests whose signature does not match --sigv4-secret are rejected."` //nolint:lll // config struct tags are intentionally verbose + GCPEnforce gcpenforce.Settings `embed:"" prefix:"gcp-enforce-"` + CloudFormation CloudFormationSettings `embed:"" prefix:"cloudformation-"` + SQS sqsbackend.Settings `embed:"" prefix:"sqs-"` + EventBridge ebbackend.Settings `embed:"" prefix:"eventbridge-"` + Localstack LocalstackSettings `embed:"" prefix:"localstack-"` + OCIVault ocivaultbackend.Settings `embed:"" prefix:"oci-vault-"` + OCIStreaming ocistreamingbackend.Settings `embed:"" prefix:"oci-streaming-"` + OCIIdentity ociidentitybackend.Settings `embed:"" prefix:"oci-identity-"` + OCIFunctions ocifunctionsbackend.Settings `embed:"" prefix:"oci-functions-"` + OCICore ocicorebackend.Settings `embed:"" prefix:"oci-core-"` + OCIQueue ociqueuebackend.Settings `embed:"" prefix:"oci-queue-"` + OCINotifications ocinotificationsbackend.Settings `embed:"" prefix:"oci-notifications-"` + OCILogging ocilogging.Settings `embed:"" prefix:"oci-logging-"` + OCIMonitoring ocimonitoring.Settings `embed:"" prefix:"oci-monitoring-"` + OCINoSQL ocinosql.Settings `embed:"" prefix:"oci-nosql-"` + OCIAPIGateway ociapigateway.Settings `embed:"" prefix:"oci-apigateway-"` + OCIEvents ocievents.Settings `embed:"" prefix:"oci-events-"` + OCILoadBalancer ociloadbalancer.Settings `embed:"" prefix:"oci-loadbalancer-"` + OCIArtifacts ociartifacts.Settings `embed:"" prefix:"oci-artifacts-"` } // GetGlobalConfig returns the centralised account ID and region (config.Provider). @@ -812,12 +882,38 @@ func (c *CLI) GetGCPEventarcSettings() gcpeventarcbackend.Settings { return c.GC // GetGCPWorkflowsSettings returns Google Cloud Workflows settings (gcpworkflows.ConfigProvider). func (c *CLI) GetGCPWorkflowsSettings() gcpworkflowsbackend.Settings { return c.GCPWorkflows } +// GetGCPDatastoreSettings returns Google Cloud Datastore settings (gcpdatastore.ConfigProvider). +func (c *CLI) GetGCPDatastoreSettings() gcpdatastorebackend.Settings { return c.GCPDatastore } + // GetGCPDNSSettings returns Google Cloud DNS settings (gcpdns.ConfigProvider). func (c *CLI) GetGCPDNSSettings() gcpdnsbackend.Settings { return c.GCPDNS } // GetGCPComputeSettings returns Compute Engine settings (gcpcompute.ConfigProvider). func (c *CLI) GetGCPComputeSettings() gcpcomputebackend.Settings { return c.GCPCompute } +// GetGCPIdentityToolkitSettings returns Identity Platform settings (gcpidentitytoolkit.ConfigProvider). +func (c *CLI) GetGCPIdentityToolkitSettings() gcpidtkbackend.Settings { + return c.GCPIdentityToolkit +} + +// GetGCPAPIGatewaySettings returns API Gateway settings (gcpapigateway.ConfigProvider). +func (c *CLI) GetGCPAPIGatewaySettings() gcpapigatewaybackend.Settings { return c.GCPAPIGateway } + +// GetGCPBatchSettings returns Batch settings (gcpbatch.ConfigProvider). +func (c *CLI) GetGCPBatchSettings() gcpbatchbackend.Settings { return c.GCPBatch } + +// GetGCPResourceManagerSettings returns Resource Manager and Service Usage settings (gcprm.ConfigProvider). +func (c *CLI) GetGCPResourceManagerSettings() gcprmbackend.Settings { return c.GCPResourceManager } + +// GetGCPEnforceSettings returns the opt-in GCP project and API-enablement enforcement switches. +func (c *CLI) GetGCPEnforceSettings() gcpenforce.Settings { return c.GCPEnforce } + +// GetGCPContainerSettings returns Kubernetes Engine settings (gcpcontainer.ConfigProvider). +func (c *CLI) GetGCPContainerSettings() gcpcontainerbackend.Settings { return c.GCPContainer } + +// GetGCPContainerEngine returns the Kubernetes Engine engine mode (gcpcontainer.EngineConfigProvider). +func (c *CLI) GetGCPContainerEngine() string { return c.engineMode("gke-engine", c.GKEEngine) } + // GetGCPCloudBuildSettings returns Google Cloud Build settings (gcpcloudbuild.ConfigProvider). func (c *CLI) GetGCPCloudBuildSettings() gcpcloudbuildbackend.Settings { return c.GCPCloudBuild } @@ -878,6 +974,36 @@ func (c *CLI) GetOCIStreamingSettings() ocistreamingbackend.Settings { return c.OCIStreaming } +// GetOCIQueueSettings returns OCI Queue settings (ociqueue.ConfigProvider). +func (c *CLI) GetOCIQueueSettings() ociqueuebackend.Settings { + return c.OCIQueue +} + +// GetOCIContainerEngineSettings returns OKE settings (ocicontainerengine.ConfigProvider). +func (c *CLI) GetOCIContainerEngineSettings() ocioke.Settings { + return c.OCIContainerEngine +} + +// GetOCIContainerEngineEngine returns the OKE engine mode (ocicontainerengine.EngineConfigProvider). +func (c *CLI) GetOCIContainerEngineEngine() string { + return c.engineMode("oci-oke-engine", c.OCIContainerEngine.Engine) +} + +// GetOCIMySQLSettings returns OCI MySQL settings (ocimysql.ConfigProvider). +func (c *CLI) GetOCIMySQLSettings() ocimysqlbackend.Settings { + return c.OCIMySQL +} + +// GetOCIMySQLEngine returns the OCI MySQL engine mode (ocimysql.EngineConfigProvider). +func (c *CLI) GetOCIMySQLEngine() string { + return c.engineMode("oci-mysql-engine", c.OCIMySQL.Engine) +} + +// GetOCINotificationsSettings returns OCI Notifications settings (ocinotifications.ConfigProvider). +func (c *CLI) GetOCINotificationsSettings() ocinotificationsbackend.Settings { + return c.OCINotifications +} + // GetAzureTableSettings returns Azure Table settings (azuretable.ConfigProvider). func (c *CLI) GetAzureTableSettings() azuretablebackend.Settings { return c.AzureTable @@ -973,6 +1099,11 @@ func (c *CLI) EndpointStrategies() endpointstrategy.Config { return c.Endpoints.Config(c.GetS3Endpoint()) } +// GetEventBridgeSettings returns EventBridge settings (eventbridge.ConfigProvider). +func (c *CLI) GetEventBridgeSettings() ebbackend.Settings { + return c.EventBridge +} + // GetSQSSettings returns SQS settings (sqs.SettingsProvider). func (c *CLI) GetSQSSettings() sqsbackend.Settings { return c.SQS @@ -1343,6 +1474,11 @@ func (c *CLI) GetBackupSettings() backupbackend.Settings { return c.Backup } +// GetVerifiedPermissionsSettings returns Verified Permissions settings (verifiedpermissions.ConfigProvider). +func (c *CLI) GetVerifiedPermissionsSettings() verifiedpermissionsbackend.Settings { + return c.VerifiedPermissions +} + // GetBatchSettings returns Batch settings (batch.ConfigProvider). func (c *CLI) GetBatchSettings() batchbackend.Settings { return c.Batch @@ -1361,6 +1497,14 @@ func (c *CLI) GetCodeBuildSettings() codebuildbackend.Settings { return st } +// GetGlueSettings returns Glue settings (glue.ConfigProvider). +func (c *CLI) GetGlueSettings() gluebackend.Settings { + st := c.Glue + st.Engine = c.engineMode("glue-engine", st.Engine) + + return st +} + // GetEC2Settings returns EC2 settings (ec2.ConfigProvider). func (c *CLI) GetEC2Settings() ec2backend.Settings { return c.EC2 @@ -1387,6 +1531,8 @@ func (c *CLI) GetEC2DockerComputeConfig() ec2backend.DockerComputeConfig { Region: c.Region, SSHPortMin: c.EC2DockerSSHPortMin, SSHPortMax: c.EC2DockerSSHPortMax, + Flags: c.EC2DockerFlags, + EBSVolumes: c.EC2DockerEBSVolumes, } } @@ -1537,6 +1683,9 @@ func (c *CLI) GetMQEngine() string { return c.engineMode("mq-engine", c.MQEngine // GetRDSEngine returns the RDS engine mode (rds.EngineModeConfig). func (c *CLI) GetRDSEngine() string { return c.engineMode("rds-engine", c.RDSEngine) } +// GetDMSEngine returns the DMS engine mode (dms.EngineModeConfig). +func (c *CLI) GetDMSEngine() string { return c.engineMode("dms-engine", c.DMSEngine) } + // GetSageMakerEngine returns the SageMaker engine mode (sagemaker.EngineModeConfig). func (c *CLI) GetSageMakerEngine() string { return c.engineMode("sagemaker-engine", c.SageMakerEngine) } @@ -2383,6 +2532,7 @@ type rootCLI struct { Snapshot SnapshotCmd `cmd:"" help:"Save, load and list state snapshots (alias of pod)."` Pod PodCmd `cmd:"" help:"Manage named state snapshots (pods) on a running server."` Replicate ReplicateCmd `cmd:"" help:"Copy resources from real AWS into a running server."` + Mcp McpCmd `cmd:"" help:"Serve MCP tools over stdio for a running Gopherstack."` Serve CLI `cmd:"" help:"Start the Gopherstack server." default:"withargs"` } @@ -2459,7 +2609,7 @@ func Run() { "pod versions ", "pod delete ", "pod inspect ", - "replicate": + "replicate", "mcp": err = kctx.Run() default: // Opt-in pprof server for local profiling / PGO data collection. @@ -2471,6 +2621,10 @@ func Run() { cancel() // release signal-handler goroutine resources if err != nil { + if code, ok := lifecycleExitCode(err); ok { + os.Exit(code) + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -2587,9 +2741,15 @@ func fixedServicePorts(cli CLI) []fixedServicePort { {"GCPBigtable", "gcpbigtable", cli.GCPBigtable.Port}, {"GCPEventarc", "gcpeventarc", cli.GCPEventarc.Port}, {"GCPWorkflows", "gcpworkflows", cli.GCPWorkflows.Port}, + {"GCPDatastore", "gcpdatastore", cli.GCPDatastore.Port}, {"GCPDNS", "gcpdns", cli.GCPDNS.Port}, {"GCPDNSServer", "gcpdns-server", cli.GCPDNS.DNSPort}, {"GCPCompute", "gcpcompute", cli.GCPCompute.Port}, + {"GCPContainer", "gcpcontainer", cli.GCPContainer.Port}, + {"GCPIdentityToolkit", "gcpidentitytoolkit", cli.GCPIdentityToolkit.Port}, + {"GCPAPIGateway", "gcpapigateway", cli.GCPAPIGateway.Port}, + {"GCPBatch", "gcpbatch", cli.GCPBatch.Port}, + {"GCPResourceManager", "gcpresourcemanager", cli.GCPResourceManager.Port}, {"GCPLogging", "gcplogging", cli.GCPLogging.Port}, {"GCPMonitoring", "gcpmonitoring", cli.GCPMonitoring.Port}, {"GCPRedis", "gcpredis", cli.GCPRedis.Port}, @@ -2613,6 +2773,7 @@ func fixedServicePorts(cli CLI) []fixedServicePort { {"AzureContainerRegistry", "azurecontainerregistry", cli.AzureACR.Port}, {"AzureFunctions", "azurefunctions", cli.AzureFunctions.Port}, {"AzureARM", "azurearm", cli.AzureARM.Port}, + {"AzureGraph", "azuregraph", cli.AzureGraph.Port}, {"AzureKeyVault", "azurekeyvault", cli.AzureKeyVault.Port}, {"OCIObjectStorage", "ociobjectstorage", cli.OCIObjectStorage.Port}, {"OCIVault", "ocivault", cli.OCIVault.Port}, @@ -2620,6 +2781,18 @@ func fixedServicePorts(cli CLI) []fixedServicePort { {"OCIIdentity", "ociidentity", cli.OCIIdentity.Port}, {"OCIFunctions", "ocifunctions", cli.OCIFunctions.Port}, {"OCICore", "ocicore", cli.OCICore.Port}, + {"OCILogging", "ocilogging", cli.OCILogging.Port}, + {"OCIMonitoring", "ocimonitoring", cli.OCIMonitoring.Port}, + {"OCIQueue", "ociqueue", cli.OCIQueue.Port}, + {"OCINotifications", "ocinotifications", cli.OCINotifications.Port}, + {"OCIContainerEngine", "ocicontainerengine", cli.OCIContainerEngine.Port}, + {"OCIMySQL", "ocimysql", cli.OCIMySQL.Port}, + {"OCINoSQL", "ocinosql", cli.OCINoSQL.Port}, + {"OCIAPIGateway", "ociapigateway", cli.OCIAPIGateway.Port}, + {"OCIEvents", "ocievents", cli.OCIEvents.Port}, + {"OCILoadBalancer", "ociloadbalancer", cli.OCILoadBalancer.Port}, + {"OCIArtifacts", "ociartifacts", cli.OCIArtifacts.Port}, + {"OCIContainerInstances", "ocicontainerinstances", cli.OCIContainerInstances.Port}, } } @@ -2992,6 +3165,7 @@ func buildEchoServer( registerEC2IMDSEndpoints(e, services) registerSSOEndpoints(e, services) registerEC2NetworkEndpoints(e, services) + registerEKSEndpoints(e, services) registerPricingEndpoints(e, services) e.POST("/_gopherstack/snapshot", buildSnapshotHandler(persistManager)) e.POST("/_gopherstack/load", buildLoadHandler(persistManager)) @@ -3002,6 +3176,7 @@ func buildEchoServer( registerPodEndpoints(ctx, log, e, cli.Pods, cli.resolvedDataDir(), persistManager, services) registerReplicateEndpoints(ctx, e) + registerMCPEndpoint(ctx, log, e, cli.MCP) registerWebsiteRoutes(e, services) @@ -3350,6 +3525,9 @@ func storeCLIHandlers(cli *CLI, services []service.Registerable) { func storeCLIRecentHandlers(cli *CLI, byName map[string]service.Registerable) { cli.iotDataPlaneHandler = byName["IoTDataPlane"] cli.apiGatewayMgmtHandler = byName["APIGatewayManagementAPI"] + cli.ociLoadBalancerHandler = byName["OCILoadBalancer"] + cli.ociArtifactsHandler = byName["OCIArtifacts"] + cli.ociContainerInstancesHandler = byName["OCIContainerInstances"] storeAdditionalCLIHandlers(cli, byName) } @@ -3382,6 +3560,7 @@ func storeGCPHandlers(cli *CLI, byName map[string]service.Registerable) { cli.gcpKMSHandler = byName["GCPKMS"] cli.gcpWorkflowsHandler = byName["GCPWorkflows"] cli.gcpDNSHandler = byName["GCPDNS"] + cli.gcpDatastoreHandler = byName["GCPDatastore"] cli.gcpComputeHandler = byName["GCPCompute"] cli.azureFileHandler = byName["AzureFile"] cli.ociObjectStorageHandler = byName["OCIObjectStorage"] @@ -3389,6 +3568,18 @@ func storeGCPHandlers(cli *CLI, byName map[string]service.Registerable) { cli.ociStreamingHandler = byName["OCIStreaming"] cli.ociIdentityHandler = byName["OCIIdentity"] cli.ociFunctionsHandler = byName["OCIFunctions"] + cli.ociCoreHandler = byName["OCICore"] + cli.gcpContainerHandler = byName["GCPContainer"] + cli.gcpIdentityToolkitHandler = byName["GCPIdentityToolkit"] + cli.ociQueueHandler = byName["OCIQueue"] + cli.ociNotificationsHandler = byName["OCINotifications"] + cli.ociContainerEngineHandler = byName["OCIContainerEngine"] + cli.ociMySQLHandler = byName["OCIMySQL"] + cli.ociLoggingHandler = byName["OCILogging"] + cli.ociMonitoringHandler = byName["OCIMonitoring"] + cli.ociNoSQLHandler = byName["OCINoSQL"] + cli.ociAPIGatewayHandler = byName["OCIAPIGateway"] + cli.ociEventsHandler = byName["OCIEvents"] cli.azureMonitorHandler = byName["AzureMonitor"] } @@ -3470,6 +3661,9 @@ func storeCLILatestHandlers(cli *CLI, byName map[string]service.Registerable) { cli.mqHandler = byName["MQ"] cli.mediastoreHandler = byName["MediaStore"] cli.mediastoredataHandler = byName["MediaStoreData"] + cli.gcpAPIGatewayHandler = byName["GCPAPIGateway"] + cli.gcpBatchHandler = byName["GCPBatch"] + cli.gcpResourceManagerHandler = byName["GCPResourceManager"] storeCLINewestHandlers(cli, byName) } @@ -3606,6 +3800,16 @@ func wireCrossServiceDependencies( wireGovernanceIntegrations(byName, services) wireOCIStreamingVault(byName) wireOCIIdentityKeys(byName) + wireOCILoggingMonitoring(byName) + wireOCIQueueNotifications(byName) + wireOCIContainerEngine(byName) + wireOCIMySQL(byName) + wireOCINoSQL(byName) + wireOCIAPIGateway(byName) + wireOCIEvents(byName) + wireOCILoadBalancer(byName) + wireOCIArtifacts(byName) + wireOCIContainerInstances(byName) } // wireOCIStreamingVault lets OCI Streaming validate customEncryptionKey references against OCI Vault keys. @@ -3618,6 +3822,27 @@ func wireOCIStreamingVault(byName map[string]service.Registerable) { } } +// wireOCIQueueNotifications lets OCI Queue validate customEncryptionKeyId against OCI Vault keys and lets OCI +// Notifications invoke OCI Functions for ORACLE_FUNCTIONS subscriptions. +func wireOCIQueueNotifications(byName map[string]service.Registerable) { + if queueH, ok := byName["OCIQueue"].(*ociqueuebackend.Handler); ok { + if vaultH, found := byName["OCIVault"].(*ocivaultbackend.Handler); found { + queueH.Backend.SetKeyChecker(vaultH.Backend.KeyState) + } + } + + if notifyH, ok := byName["OCINotifications"].(*ocinotificationsbackend.Handler); ok { + if fnH, found := byName["OCIFunctions"].(*ocifunctionsbackend.Handler); found { + notifyH.Backend.SetFunctionInvoker( + func(ctx context.Context, fnID string, h http.Header, body []byte) error { + _, err := fnH.Backend.Invoke(ctx, fnID, ocifunctionsbackend.InvokeIn{Header: h, Body: body}) + + return err + }) + } + } +} + // wireOCIIdentityKeys lets API keys uploaded through OCI Identity authenticate requests to every OCI service. func wireOCIIdentityKeys(byName map[string]service.Registerable) { identityH, ok := byName["OCIIdentity"].(*ociidentitybackend.Handler) @@ -3645,6 +3870,24 @@ func wireOCIIdentityKeys(byName map[string]service.Registerable) { h.Backend.Auth.Link(identityH.Backend.Auth) h.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) } + + if h, found := byName["OCIQueue"].(*ociqueuebackend.Handler); found { + h.Backend.Auth.Link(identityH.Backend.Auth) + } + + if h, found := byName["OCINotifications"].(*ocinotificationsbackend.Handler); found { + h.Backend.Auth.Link(identityH.Backend.Auth) + } + + if h, found := byName["OCIContainerEngine"].(*ocioke.Handler); found { + h.Backend.Auth.Link(identityH.Backend.Auth) + h.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + } + + if h, found := byName["OCIMySQL"].(*ocimysqlbackend.Handler); found { + h.Backend.Auth.Link(identityH.Backend.Auth) + h.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + } } // wireMessagingAndEventingIntegrations wires SNS, SQS, EventBridge, and S3 @@ -3853,7 +4096,8 @@ func wireComputeAndObservabilityIntegrations(appCtx *service.AppContext, byName // Wire Athena → S3 so a succeeded query execution's result is actually written // to ResultConfiguration.OutputLocation instead of only being stored/echoed. - wireAthenaS3(byName["Athena"], byName["S3"]) + // It also wires the s3tablescatalog/ federated catalog to S3 Tables. + wireAthenaStorage(byName) // Wire S3 → Lambda so a function deployed from Code.S3Bucket/S3Key // actually starts instead of failing with ErrLambdaUnavailable: S3 code @@ -4627,7 +4871,7 @@ func wireStorageAndSecretsIntegrations(byName map[string]service.Registerable) { wireSecretsManagerKMS(byName["SecretsManager"], byName["KMS"]) // Route RDS Data API calls for docker-backed Aurora clusters to their real database. - wireRDSData(byName["RDSData"], byName["RDS"], byName["SecretsManager"]) + wireRDSIntegrations(byName) wireManagedMasterSecrets(byName) wireSubnetLookups(byName) wireDirectConnectMacSecSecrets(byName) @@ -4691,7 +4935,7 @@ func wireStorageAndSecretsIntegrations(byName map[string]service.Registerable) { // being marked Succeeded unconditionally with no cross-service call at // all (gopherstack-cb9l). wireCodePipelineBackends(byName) - wireGlueS3(byName["Glue"], byName["S3"]) + wireGlueBackends(byName) } func wireCodePipelineBackends(byName map[string]service.Registerable) { @@ -4699,6 +4943,7 @@ func wireCodePipelineBackends(byName map[string]service.Registerable) { wireCodePipelineLambda(byName["CodePipeline"], byName["Lambda"]) wireCodePipelineCodeDeploy(byName["CodePipeline"], byName["CodeDeploy"]) wireCodePipelineS3(byName["CodePipeline"], byName["S3"]) + wireCodePipelineActionBackends(byName) } // wireAppConfigDeployments wires the AppConfigData backend as AppConfig's @@ -4800,6 +5045,7 @@ func wireGovernanceIntegrations(byName map[string]service.Registerable, services wireEKSTokenAuth(byName) wireEKSPodIdentity(byName) wireCognitoIdentity(byName["CognitoIdentity"], byName["STS"], byName["CognitoIDP"]) + wireVerifiedPermissionsCognito(byName["VerifiedPermissions"], byName["CognitoIDP"]) // Collect all services implementing FISActionProvider and register them with the FIS backend. wireFISActionProviders(byName["FIS"], services) @@ -4842,6 +5088,7 @@ func registerCloudFormationAndDashboard( wireLightsailCloudFormation(byName["Lightsail"], cfnSvc) wireServiceCatalogEngine(byName["ServiceCatalog"], cfnSvc) wireGlobalAcceleratorCloudFormation(byName["GlobalAccelerator"], cfnSvc) + wireCodePipelineCloudFormation(byName["CodePipeline"], cfnSvc) // Wire CloudFormation -> Organizations so SERVICE_MANAGED StackSets can // expand DeploymentTargets.OrganizationalUnitIds against the real OU @@ -5035,6 +5282,7 @@ func getLeadingRecentServiceProviders() []service.Provider { &azurequeuebackend.Provider{}, &azurefilebackend.Provider{}, &azuremonitorbackend.Provider{}, + &azuregraphbackend.Provider{}, &azuretablebackend.Provider{}, &azureservicebusbackend.Provider{}, &azureeventhubsbackend.Provider{}, @@ -5053,7 +5301,19 @@ func getLeadingRecentServiceProviders() []service.Provider { &ocistreamingbackend.Provider{}, &ociidentitybackend.Provider{}, &ocifunctionsbackend.Provider{}, + &ocilogging.Provider{}, + &ocimonitoring.Provider{}, &ocicorebackend.Provider{}, + &ociqueuebackend.Provider{}, + &ocinotificationsbackend.Provider{}, + &ocioke.Provider{}, + &ocimysqlbackend.Provider{}, + &ocinosql.Provider{}, + &ociapigateway.Provider{}, + &ocievents.Provider{}, + &ociloadbalancer.Provider{}, + &ociartifacts.Provider{}, + &ocicontainerinstances.Provider{}, &gcppubsubbackend.Provider{}, &gcpsecretmanagerbackend.Provider{}, &gcpkmsbackend.Provider{}, @@ -5067,8 +5327,14 @@ func getLeadingRecentServiceProviders() []service.Provider { &gcpbigtablebackend.Provider{}, &gcpeventarcbackend.Provider{}, &gcpworkflowsbackend.Provider{}, + &gcpdatastorebackend.Provider{}, &gcpdnsbackend.Provider{}, &gcpcomputebackend.Provider{}, + &gcpcontainerbackend.Provider{}, + &gcpidtkbackend.Provider{}, + &gcpapigatewaybackend.Provider{}, + &gcpbatchbackend.Provider{}, + &gcprmbackend.Provider{}, &gcploggingbackend.Provider{}, &gcpmonitoringbackend.Provider{}, &gcpredisbackend.Provider{}, @@ -5249,6 +5515,7 @@ func purgeAllServices( // startBackgroundWorkers starts all background workers from services. func startBackgroundWorkers(ctx context.Context, services []service.Registerable) { log := logger.Load(ctx) + ctx = gcpenforce.WithGateFrom(ctx, services) for _, svc := range services { if worker, ok := svc.(service.BackgroundWorker); ok { @@ -8165,7 +8432,7 @@ func wireTimestreamQueryTags(tsqReg, tswReg service.Registerable) { if tswH, tswOk := tswReg.(*timestreamwritebackend.Handler); tswOk { tsqBk.SetTagWriteBackend(×treamTagWriter{handler: tswH}) - tsqBk.SetCatalog(×treamCatalog{handler: tswH}) + tsqBk.SetRecordSource(newTimestreamRecordSource(tswH)) } } @@ -12072,8 +12339,9 @@ func wireTaggingAWSConfig(bk resourcegroupstaggingapibackend.StorageBackend, reg wireRegionalTagging(bk, regionalTagSpec[*awsconfigbackend.InMemoryBackend]{ arnService: "config", resourceTypeOf: arnResourceType("config"), - backendFor: func(region string) *awsconfigbackend.InMemoryBackend { - return h.BackendFor(region) + backendFor: h.BackendFor, + backendForCtx: func(ctx context.Context, region string) *awsconfigbackend.InMemoryBackend { + return h.BackendForAccount(awsmeta.Account(ctx), region) }, list: func(b *awsconfigbackend.InMemoryBackend) []taggedARNEntry { return taggedEntries(b.TaggedResources()) }, tag: func(b *awsconfigbackend.InMemoryBackend, _ context.Context, arn string, tags map[string]string) error { @@ -12898,6 +13166,7 @@ func setupChaosAndRegistry( faultStore := cli.faultStore chaosGroup := e.Group("/_gopherstack/chaos") wireFISFaultStore(cli.fisHandler, faultStore) + wireDynamoDBFaultStore(services, faultStore) registry, err := setupRegistry( e, @@ -12910,6 +13179,7 @@ func setupChaosAndRegistry( cli.Compression != compressionOff, cli.IAMAudit, cli.IAMPolicyStream(), + scpProviderFor(cli.IAMEnforceSCPs, services), chainInterceptors(cli.serviceGateInterceptor(), cli.extensionInterceptor()), ) if err != nil { @@ -12923,6 +13193,7 @@ func setupChaosAndRegistry( wireCloudControlHandlers(e, services) wireServiceRoleAuthorizer(services, cli.EnforceIAM) wireRDSIAMAuth(e, services, cli.EnforceIAM) + wireNeptuneIAMAuth(services, cli.EnforceIAM) wireDocDBCA(e, services) wireKafkaAuth(e, services, cli.EnforceIAM) @@ -13008,6 +13279,7 @@ func setupRegistry( compression bool, iamAudit bool, iamRecorder *iampolicystream.Recorder, + scps iambackend.SCPProvider, interceptor service.Interceptor, ) (*service.Registry, error) { registry := service.NewRegistry() @@ -13051,6 +13323,7 @@ func setupRegistry( ActionExtractors: buildActionExtractors(services), Recorder: iamRecorder, Audit: iamAudit, + SCPs: scps, } registry.Use(service.Middleware(iambackend.EnforcementMiddleware(iamBackend, ecfg))) @@ -13071,6 +13344,7 @@ func setupRegistry( WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()). WithInterceptor(interceptor) e.Use(router.RouteHandler()) @@ -13849,7 +14123,9 @@ func wireAzureARMResourceProviders(armReg, sbReg, kvReg, cosmosReg, ehReg servic wireAzureARMEventHubs(armReg, ehReg) if kvH, kvOk := kvReg.(*azurekeyvaultbackend.Handler); kvOk { - armH.Registry.SetKeyVaults(&azureKeyVaultsAdapter{backend: kvH.Backend}) + kvAdapter := &azureKeyVaultsAdapter{backend: kvH.Backend} + armH.Registry.SetKeyVaults(kvAdapter) + armH.Registry.SetKeyVaultSecrets(kvAdapter) } if cosmosH, cosmosOk := cosmosReg.(*cosmosdbbackend.Handler); cosmosOk { @@ -14781,6 +15057,8 @@ func setupPersistence( // waiting for the first live request to touch it. service.SeedRegions(defaultRegion) + reconcileContainers(ctx) + if restore { m.RestoreAll(ctx) } @@ -14792,6 +15070,8 @@ func initPersistenceManager(ctx context.Context, cli *CLI) (*persistence.Manager log := logger.Load(ctx) var store persistence.Store = persistence.NullStore{} + initContainerIdentity(cli.Persist && !cli.Demo, cli.resolvedDataDir()) + if cli.Persist && !cli.Demo { fs, err := cli.createPersistenceStore() if err != nil { diff --git a/internal/app/cli_test.go b/internal/app/cli_test.go index 42ef6ad609..c3403cb41f 100644 --- a/internal/app/cli_test.go +++ b/internal/app/cli_test.go @@ -24,6 +24,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/persistence" "github.com/blackbirdworks/gopherstack/pkgs/service" svctags "github.com/blackbirdworks/gopherstack/pkgs/tags" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" accessanalyzerbackend "github.com/blackbirdworks/gopherstack/services/accessanalyzer" appconfigbackend "github.com/blackbirdworks/gopherstack/services/appconfig" applicationautoscalingbackend "github.com/blackbirdworks/gopherstack/services/applicationautoscaling" @@ -113,20 +114,40 @@ import ( // when shutdown itself completes on time (gopherstack-becu). const shutdownWaitTimeout = shutdownTimeout + 3*time.Second -// waitForServerReady polls the health endpoint instead of a fixed sleep, -// since startup time varies under load. +// waitForServerReady polls health until 200, bounded by the test deadline rather +// than a wall-clock budget: startup time grows with every service and CI load. func waitForServerReady(t *testing.T, port int) { t.Helper() - require.Eventually(t, func() bool { - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/_gopherstack/health", port)) - if err != nil { - return false + waitForEndpoint(t, fmt.Sprintf("http://localhost:%d/_gopherstack/health", port), func(resp *http.Response) bool { + return resp.StatusCode == http.StatusOK + }) +} + +// waitForEndpoint polls url until ready accepts the response, bounded by the test context. +func waitForEndpoint(t *testing.T, url string, ready func(*http.Response) bool) { + t.Helper() + + client := testclient.New(t) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + + for { + if resp, err := client.Get(url); err == nil { + ok := ready(resp) + resp.Body.Close() + + if ok { + return + } } - resp.Body.Close() - return resp.StatusCode == http.StatusOK - }, 3*time.Second, 50*time.Millisecond, "server did not become ready") + select { + case <-t.Context().Done(): + t.Fatalf("server did not become ready: %v", t.Context().Err()) + case <-tick.C: + } + } } // parseCLI parses the given args (key=value env pairs) into a CLI value @@ -3072,17 +3093,9 @@ func TestHealthEndpoint_GoroutineAndMemStats(t *testing.T) { errCh <- run(ctx, cli) }() - require.Eventually(t, func() bool { - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/_gopherstack/health", port)) - if err != nil { - return false - } - resp.Body.Close() - - return resp.StatusCode == http.StatusOK - }, 3*time.Second, 50*time.Millisecond, "server did not become ready") + waitForServerReady(t, port) - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/_gopherstack/health", port)) + resp, err := testclient.New(t).Get(fmt.Sprintf("http://localhost:%d/_gopherstack/health", port)) require.NoError(t, err) defer resp.Body.Close() @@ -3124,17 +3137,9 @@ func TestLocalstackCompatibilityEndpoints(t *testing.T) { errCh <- run(ctx, cli) }() - client := &http.Client{Timeout: 2 * time.Second} - - require.Eventually(t, func() bool { - resp, err := client.Get(fmt.Sprintf("http://localhost:%d/_gopherstack/health", port)) - if err != nil { - return false - } - resp.Body.Close() + waitForServerReady(t, port) - return resp.StatusCode == http.StatusOK - }, 3*time.Second, 50*time.Millisecond, "server did not become ready") + client := &http.Client{Timeout: 2 * time.Second} tests := []struct { validate func(t *testing.T, body map[string]any) @@ -3200,20 +3205,11 @@ func TestLocalstackCompatibilityEndpoints(t *testing.T) { } // READY runs after the listener accepts, so wait for it before asserting stage state. - require.Eventually(t, func() bool { - resp, err := client.Get(fmt.Sprintf("http://localhost:%d/_localstack/init/ready", port)) - if err != nil { - return false - } - defer resp.Body.Close() - + waitForEndpoint(t, fmt.Sprintf("http://localhost:%d/_localstack/init/ready", port), func(resp *http.Response) bool { var body map[string]any - if json.NewDecoder(resp.Body).Decode(&body) != nil { - return false - } - return body["completed"] == true - }, 10*time.Second, 20*time.Millisecond) + return json.NewDecoder(resp.Body).Decode(&body) == nil && body["completed"] == true + }) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/app/cloud_dashboard_handlers.go b/internal/app/cloud_dashboard_handlers.go index b73b209ac6..30be537570 100644 --- a/internal/app/cloud_dashboard_handlers.go +++ b/internal/app/cloud_dashboard_handlers.go @@ -64,6 +64,11 @@ func (c *CLI) GetGCPWorkflowsHandler() service.Registerable { return c.gcpWorkfl //nolint:ireturn // architecturally required to return interface func (c *CLI) GetGCPDNSHandler() service.Registerable { return c.gcpDNSHandler } +// GetGCPDatastoreHandler returns the GCPDatastore handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetGCPDatastoreHandler() service.Registerable { return c.gcpDatastoreHandler } + // GetGCPComputeHandler returns the GCPCompute handler for the dashboard proxy. // //nolint:ireturn // architecturally required to return interface @@ -99,7 +104,92 @@ func (c *CLI) GetOCIIdentityHandler() service.Registerable { return c.ociIdentit //nolint:ireturn // architecturally required to return interface func (c *CLI) GetOCIFunctionsHandler() service.Registerable { return c.ociFunctionsHandler } +// GetOCICoreHandler returns the OCICore handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCICoreHandler() service.Registerable { return c.ociCoreHandler } + +// GetGCPContainerHandler returns the GCPContainer handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetGCPContainerHandler() service.Registerable { return c.gcpContainerHandler } + +// GetOCIQueueHandler returns the OCIQueue handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIQueueHandler() service.Registerable { return c.ociQueueHandler } + +// GetOCINotificationsHandler returns the OCINotifications handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCINotificationsHandler() service.Registerable { return c.ociNotificationsHandler } + +// GetOCIContainerEngineHandler returns the OCIContainerEngine handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIContainerEngineHandler() service.Registerable { return c.ociContainerEngineHandler } + // GetAzureMonitorHandler returns the AzureMonitor handler for the dashboard proxy. // //nolint:ireturn // architecturally required to return interface func (c *CLI) GetAzureMonitorHandler() service.Registerable { return c.azureMonitorHandler } + +// GetOCIMySQLHandler returns the OCIMySQL handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIMySQLHandler() service.Registerable { return c.ociMySQLHandler } + +// GetOCILoggingHandler returns the OCILogging handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCILoggingHandler() service.Registerable { return c.ociLoggingHandler } + +// GetOCIMonitoringHandler returns the OCIMonitoring handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIMonitoringHandler() service.Registerable { return c.ociMonitoringHandler } + +// GetOCINoSQLHandler returns the OCINoSQL handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCINoSQLHandler() service.Registerable { return c.ociNoSQLHandler } + +// GetOCIAPIGatewayHandler returns the OCIAPIGateway handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIAPIGatewayHandler() service.Registerable { return c.ociAPIGatewayHandler } + +// GetOCILoadBalancerHandler returns the OCILoadBalancer handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCILoadBalancerHandler() service.Registerable { return c.ociLoadBalancerHandler } + +// GetOCIArtifactsHandler returns the OCIArtifacts handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIArtifactsHandler() service.Registerable { return c.ociArtifactsHandler } + +// GetOCIEventsHandler returns the OCIEvents handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIEventsHandler() service.Registerable { return c.ociEventsHandler } + +// GetGCPIdentityToolkitHandler returns the GCPIdentityToolkit handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetGCPIdentityToolkitHandler() service.Registerable { return c.gcpIdentityToolkitHandler } + +// GetGCPAPIGatewayHandler returns the GCPAPIGateway handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetGCPAPIGatewayHandler() service.Registerable { return c.gcpAPIGatewayHandler } + +// GetGCPResourceManagerHandler returns the GCPResourceManager handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetGCPResourceManagerHandler() service.Registerable { return c.gcpResourceManagerHandler } + +// GetGCPBatchHandler returns the GCPBatch handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetGCPBatchHandler() service.Registerable { return c.gcpBatchHandler } diff --git a/internal/app/cloudcontrol_ec2_client.go b/internal/app/cloudcontrol_ec2_client.go index 44131731e0..ae3680617f 100644 --- a/internal/app/cloudcontrol_ec2_client.go +++ b/internal/app/cloudcontrol_ec2_client.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "net/http" + "sync" "github.com/aws/aws-sdk-go-v2/aws" @@ -13,29 +14,37 @@ import ( // ccEC2Client calls the in-process EC2 handler over the ec2Query wire without the generated SDK client. type ccEC2Client struct { - svc *sdkwire.Service + svc func() (*sdkwire.Service, error) tgt sdkwire.Target } -func newCCEC2Client(handler http.Handler, cfg aws.Config) (*ccEC2Client, error) { - svc, ok := sdkwire.Lookup("ec2") - if !ok { - return nil, fmt.Errorf("%w: ec2", sdkwire.ErrUnknownOp) - } - +// newCCEC2Client defers building the EC2 schema (~5 MB) to the first call. +func newCCEC2Client(handler http.Handler, cfg aws.Config) *ccEC2Client { return &ccEC2Client{ - svc: svc, + svc: sync.OnceValues(func() (*sdkwire.Service, error) { + svc, ok := sdkwire.Lookup("ec2") + if !ok { + return nil, fmt.Errorf("%w: ec2", sdkwire.ErrUnknownOp) + } + + return svc, nil + }), tgt: sdkwire.Target{ Handler: handler, Credentials: cfg.Credentials, Endpoint: aws.ToString(cfg.BaseEndpoint), Region: cfg.Region, }, - }, nil + } } func (c *ccEC2Client) call(ctx context.Context, op string, params map[string]any) (map[string]any, error) { - out, err := c.svc.Call(ctx, c.tgt, op, params) + svc, err := c.svc() + if err != nil { + return nil, err + } + + out, err := svc.Call(ctx, c.tgt, op, params) if err != nil { return nil, err } diff --git a/internal/app/cloudcontrol_handlers.go b/internal/app/cloudcontrol_handlers.go index 79f6860d16..78b3e3bc6b 100644 --- a/internal/app/cloudcontrol_handlers.go +++ b/internal/app/cloudcontrol_handlers.go @@ -189,9 +189,7 @@ func wireCloudControlHandlers(e http.Handler, services []service.Registerable) { bk.RegisterTypeHandler("AWS::DynamoDB::Table", &ccTable{client: dynamodb.NewFromConfig(cfg)}) bk.RegisterTypeHandler("AWS::Logs::LogGroup", &ccLogGroup{client: cloudwatchlogs.NewFromConfig(cfg)}) wireCloudControlMoreHandlers(bk, cfg) - if ec2c, err := newCCEC2Client(e, cfg); err == nil { - wireCloudControlInfraHandlers(bk, e, cfg, ec2c) - } + wireCloudControlInfraHandlers(bk, e, cfg, newCCEC2Client(e, cfg)) wireCloudControlProvisionedTypes(bk, services) } diff --git a/internal/app/cloudcontrol_stream_wait_whitebox_test.go b/internal/app/cloudcontrol_stream_wait_whitebox_test.go index 0493206ce6..be60b2ee79 100644 --- a/internal/app/cloudcontrol_stream_wait_whitebox_test.go +++ b/internal/app/cloudcontrol_stream_wait_whitebox_test.go @@ -1,10 +1,13 @@ package app import ( + "context" + "io" "net/http" - "net/http/httptest" + "strings" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -59,31 +62,36 @@ func TestAwaitStream(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - var n atomic.Int32 + synctest.Test(t, func(t *testing.T) { + var n atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - i := min(int(n.Add(1))-1, len(tt.bodies)-1) - w.Header().Set("Content-Type", "application/x-amz-json-1.1") - w.WriteHeader(tt.status[i]) - _, _ = w.Write([]byte(tt.bodies[i])) - })) - t.Cleanup(srv.Close) + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { + i := min(int(n.Add(1))-1, len(tt.bodies)-1) - c := kinesis.NewFromConfig(aws.Config{ - Region: "us-east-1", BaseEndpoint: aws.String(srv.URL), - Credentials: credentials.NewStaticCredentialsProvider("a", "b", ""), - }) + return &http.Response{ + StatusCode: tt.status[i], + Header: http.Header{"Content-Type": {"application/x-amz-json-1.1"}}, + Body: io.NopCloser(strings.NewReader(tt.bodies[i])), + }, nil + }) + + c := kinesis.NewFromConfig(aws.Config{ + Region: "us-east-1", BaseEndpoint: aws.String("http://kinesis.invalid"), + HTTPClient: &http.Client{Transport: rt}, + Credentials: credentials.NewStaticCredentialsProvider("a", "b", ""), + }) - err := awaitStream(t.Context(), c, "s", tt.gone, 300*time.Millisecond) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } + err := awaitStream(context.Background(), c, "s", tt.gone, 300*time.Millisecond) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } - if tt.wantCall > 0 { - assert.Equal(t, tt.wantCall, n.Load()) - } + if tt.wantCall > 0 { + assert.Equal(t, tt.wantCall, n.Load()) + } + }) }) } } @@ -93,3 +101,7 @@ const ( active = `{"StreamDescription":{"StreamStatus":"ACTIVE"}}` notFound = `{"__type":"ResourceNotFoundException","message":"gone"}` ) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/internal/app/cloudformation_settings.go b/internal/app/cloudformation_settings.go new file mode 100644 index 0000000000..bad6d4f0b7 --- /dev/null +++ b/internal/app/cloudformation_settings.go @@ -0,0 +1,9 @@ +package app + +// CloudFormationSettings configures CloudFormation template validation. +type CloudFormationSettings struct { + ValidateProperties bool `name:"validate-properties" env:"GOPHERSTACK_CLOUDFORMATION_VALIDATE_PROPERTIES" default:"true" negatable:"" help:"Reject unknown, wrong-typed and missing required resource properties at stack operations, like CloudFormation. Disable to accept any property."` //nolint:lll // config struct tags are intentionally verbose +} + +// GetCloudFormationValidateProperties reports whether resource property validation is on. +func (c *CLI) GetCloudFormationValidateProperties() bool { return c.CloudFormation.ValidateProperties } diff --git a/internal/app/cloudformation_settings_test.go b/internal/app/cloudformation_settings_test.go new file mode 100644 index 0000000000..bfdcd43163 --- /dev/null +++ b/internal/app/cloudformation_settings_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "testing" + + "github.com/alecthomas/kong" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCloudFormationValidatePropertiesFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want bool + }{ + {name: "default_on", want: true}, + {name: "explicit_false", args: []string{"--cloudformation-validate-properties=false"}}, + {name: "negated", args: []string{"--no-cloudformation-validate-properties"}}, + {name: "explicit_true", args: []string{"--cloudformation-validate-properties"}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var root rootCLI + + parser, err := kong.New(&root, kong.Name("gopherstack"), kong.Writers(nil, nil)) + require.NoError(t, err) + _, err = parser.Parse(tt.args) + require.NoError(t, err) + + assert.Equal(t, tt.want, root.Serve.GetCloudFormationValidateProperties()) + }) + } +} diff --git a/internal/app/cloudfront_dataplane.go b/internal/app/cloudfront_dataplane.go index 90737653fa..0d14f32c17 100644 --- a/internal/app/cloudfront_dataplane.go +++ b/internal/app/cloudfront_dataplane.go @@ -1,6 +1,8 @@ package app import ( + "context" + "errors" "net/http" "github.com/labstack/echo/v5" @@ -8,6 +10,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/service" cloudfrontbackend "github.com/blackbirdworks/gopherstack/services/cloudfront" elbv2backend "github.com/blackbirdworks/gopherstack/services/elbv2" + lambdabackend "github.com/blackbirdworks/gopherstack/services/lambda" s3backend "github.com/blackbirdworks/gopherstack/services/s3" ) @@ -25,6 +28,31 @@ func (f cloudFrontALBForwarder) ServeALB(w http.ResponseWriter, r *http.Request) return true } +// cloudFrontLambdaEdge runs Lambda@Edge associations through the Lambda backend. +type cloudFrontLambdaEdge struct { + bk *lambdabackend.InMemoryBackend +} + +func (e cloudFrontLambdaEdge) InvokeEdge( + ctx context.Context, name, version string, payload []byte, +) ([]byte, string, error) { + out, _, fnErr, status, err := e.bk.InvokeFunctionWithQualifier( + ctx, name, version, "", "", lambdabackend.InvocationTypeRequestResponse, payload, + ) + + if errors.Is(err, lambdabackend.ErrInvocationTimeout) || status == http.StatusTooManyRequests { + return nil, "", cloudfrontbackend.ErrEdgeLimitExceeded + } + + return out, fnErr, err +} + +func (e cloudFrontLambdaEdge) EdgeVersionExists(name, version string) bool { + _, err := e.bk.GetFunctionByQualifier(name, version) + + return err == nil +} + // wireCloudFrontDataPlane lets distributions read S3 origins and ALB origins, resolved at request time. func wireCloudFrontDataPlane(byName map[string]service.Registerable) { cf, ok := byName["CloudFront"].(*cloudfrontbackend.Handler) @@ -39,6 +67,15 @@ func wireCloudFrontDataPlane(byName map[string]service.Registerable) { return nil }) + cf.SetLambdaEdge(func() cloudfrontbackend.LambdaEdge { + if h, found := byName["Lambda"].(*lambdabackend.Handler); found { + if bk, isMem := h.Backend.(*lambdabackend.InMemoryBackend); isMem { + return cloudFrontLambdaEdge{bk: bk} + } + } + + return nil + }) cf.SetALBForwarder(func() cloudfrontbackend.ALBForwarder { if h, found := byName["ELBv2"].(*elbv2backend.Handler); found { return cloudFrontALBForwarder{h: h} diff --git a/internal/app/cloudfront_lambda_edge_test.go b/internal/app/cloudfront_lambda_edge_test.go new file mode 100644 index 0000000000..103da78910 --- /dev/null +++ b/internal/app/cloudfront_lambda_edge_test.go @@ -0,0 +1,163 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc/portalloctest" + "github.com/blackbirdworks/gopherstack/pkgs/service" + cloudfrontbackend "github.com/blackbirdworks/gopherstack/services/cloudfront" + lambdabackend "github.com/blackbirdworks/gopherstack/services/lambda" +) + +const edgeDistributionConfig = `` + + `%sedgetrue` + + `1o127.0.0.1` + + `9443` + + `http-only` + + `oallow-all` + + `4135ea2d-6df8-44a3-9df3-4b5a84be39ad` + + `1` + + `%sviewer-request` + + `` + +const edgeARNPrefix = "arn:aws:lambda:us-east-1:123456789012:function:" + +func newEdgeWiring(t *testing.T, rt container.Runtime) (*cloudfrontbackend.Handler, *lambdabackend.InMemoryBackend) { + t.Helper() + + cfBackend := cloudfrontbackend.NewInMemoryBackend(t.Context(), "123456789012", "us-east-1") + t.Cleanup(cfBackend.Close) + + cfHandler := cloudfrontbackend.NewHandler(cfBackend) + bk := lambdabackend.NewInMemoryBackend(rt, portalloctest.New(t, 10), lambdabackend.DefaultSettings(), + "123456789012", "us-east-1") + t.Cleanup(func() { bk.Close(context.Background()) }) + + wireCloudFrontDataPlane(map[string]service.Registerable{ + "CloudFront": cfHandler, "Lambda": lambdabackend.NewHandler(bk), + }) + + return cfHandler, bk +} + +func createEdgeFunction(t *testing.T, bk *lambdabackend.InMemoryBackend, name, code string) { + t.Helper() + + require.NoError(t, bk.CreateFunction(&lambdabackend.FunctionConfiguration{ + FunctionName: name, PackageType: lambdabackend.PackageTypeZip, Runtime: "python3.12", + Handler: "index.handler", ZipData: zipOf(t, "index.py", code), Timeout: 30, + })) + + _, err := bk.PublishVersion(name, "") + require.NoError(t, err) +} + +func TestWireCloudFrontLambdaEdge_Associations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + arn string + wantCode int + }{ + {name: "published_version", arn: edgeARNPrefix + "edge-fn:1", wantCode: http.StatusCreated}, + {name: "unpublished_version", arn: edgeARNPrefix + "edge-fn:2", wantCode: http.StatusBadRequest}, + {name: "unknown_function", arn: edgeARNPrefix + "missing:1", wantCode: http.StatusBadRequest}, + {name: "latest", arn: edgeARNPrefix + "edge-fn:$LATEST", wantCode: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfHandler, bk := newEdgeWiring(t, nil) + createEdgeFunction(t, bk, "edge-fn", "def handler(e, c):\n return {}\n") + + body := fmt.Sprintf(edgeDistributionConfig, "ref-"+tt.name, tt.arn) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/2020-05-31/distribution", + strings.NewReader(body)) + rec := httptest.NewRecorder() + require.NoError(t, cfHandler.Handler()(echo.New().NewContext(req, rec))) + + assert.Equal(t, tt.wantCode, rec.Code, rec.Body.String()) + + if tt.wantCode == http.StatusBadRequest { + assert.Contains(t, rec.Body.String(), "InvalidLambdaFunctionAssociation") + } + }) + } +} + +func TestWireCloudFrontLambdaEdge_UnrunnableFunctionIs503(t *testing.T) { + t.Parallel() + + cfHandler, bk := newEdgeWiring(t, nil) + createEdgeFunction(t, bk, "edge-fn", "def handler(e, c):\n return {}\n") + + raw := fmt.Sprintf(edgeDistributionConfig, "ref-503", edgeARNPrefix+"edge-fn:1") + dist, err := cfHandler.Backend.CreateDistribution("ref-503", "edge", true, []byte(raw)) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/x", nil) + req.Host = dist.DomainName + rec := httptest.NewRecorder() + require.NoError(t, cfHandler.Handler()(echo.New().NewContext(req, rec))) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + assert.Equal(t, "LambdaExecutionError from cloudfront", rec.Header().Get("X-Cache")) +} + +const edgeDockerHandler = `def handler(event, context): + req = event["Records"][0]["cf"]["request"] + return {"status": "200", "statusDescription": "OK", + "headers": {"x-edge": [{"value": "python"}]}, "body": "hello " + req["uri"]} +` + +func TestWireCloudFrontLambdaEdge_ViewerRequestGeneratedResponseDocker(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("docker test") + } + + if err := exec.CommandContext(t.Context(), "docker", "image", "inspect", efsDockerRuntimeImage).Run(); err != nil { + t.Skipf("lambda runtime image %s not available locally: %v", efsDockerRuntimeImage, err) + } + + rt, err := container.NewRuntime(container.Config{Logger: slog.Default(), PoolSize: 2, IdleTimeout: time.Minute}) + if err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + t.Cleanup(func() { _ = rt.Close() }) + + cfHandler, bk := newEdgeWiring(t, rt) + createEdgeFunction(t, bk, "edge-docker", edgeDockerHandler) + + raw := fmt.Sprintf(edgeDistributionConfig, "ref-docker", edgeARNPrefix+"edge-docker:1") + dist, err := cfHandler.Backend.CreateDistribution("ref-docker", "edge", true, []byte(raw)) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/page", nil) + req.Host = dist.DomainName + rec := httptest.NewRecorder() + require.NoError(t, cfHandler.Handler()(echo.New().NewContext(req, rec))) + + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, "hello /page", rec.Body.String()) + assert.Equal(t, "python", rec.Header().Get("X-Edge")) + assert.Equal(t, "LambdaGeneratedResponse from cloudfront", rec.Header().Get("X-Cache")) +} diff --git a/internal/app/cloudwatch_firehose_wiring_test.go b/internal/app/cloudwatch_firehose_wiring_test.go index 4f864ebe5b..daf2c11a59 100644 --- a/internal/app/cloudwatch_firehose_wiring_test.go +++ b/internal/app/cloudwatch_firehose_wiring_test.go @@ -38,7 +38,7 @@ func TestInitializeServices_CloudWatchFirehoseWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/codedeploy_ec2_wiring_test.go b/internal/app/codedeploy_ec2_wiring_test.go index 5baa1b7ee4..204bb86736 100644 --- a/internal/app/codedeploy_ec2_wiring_test.go +++ b/internal/app/codedeploy_ec2_wiring_test.go @@ -46,7 +46,7 @@ func TestInitializeServices_CodeDeployEC2Wiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/codepipeline_action_wiring.go b/internal/app/codepipeline_action_wiring.go new file mode 100644 index 0000000000..7d5116a350 --- /dev/null +++ b/internal/app/codepipeline_action_wiring.go @@ -0,0 +1,371 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/service" + cfnbackend "github.com/blackbirdworks/gopherstack/services/cloudformation" + codecommitbackend "github.com/blackbirdworks/gopherstack/services/codecommit" + codepipelinebackend "github.com/blackbirdworks/gopherstack/services/codepipeline" + ecrbackend "github.com/blackbirdworks/gopherstack/services/ecr" + ecsbackend "github.com/blackbirdworks/gopherstack/services/ecs" +) + +// wireCodePipelineActionBackends gives CodePipeline the ECS, ECR and CodeCommit backends its +// Deploy/ECS, Source/ECR and Source/CodeCommit actions drive. +func wireCodePipelineActionBackends(byName map[string]service.Registerable) { + cpH, ok := byName["CodePipeline"].(*codepipelinebackend.Handler) + if !ok { + return + } + + if h, hok := byName["ECS"].(*ecsbackend.Handler); hok { + cpH.Backend.SetECSDeployer(&codepipelineECSDeployer{handler: h}) + } + + if h, hok := byName["ECR"].(*ecrbackend.Handler); hok { + cpH.Backend.SetImageSource(&codepipelineImageSource{handler: h}) + } + + if h, hok := byName["CodeCommit"].(*codecommitbackend.Handler); hok { + cpH.Backend.SetRepositoryReader(&codepipelineRepositoryReader{handler: h}) + } +} + +// wireCodePipelineCloudFormation gives CodePipeline CloudFormation for Deploy/CloudFormation actions. It runs +// after CloudFormation is initialised, which is later than the other action backends. +func wireCodePipelineCloudFormation(codepipelineReg, cfnReg service.Registerable) { + cpH, ok := codepipelineReg.(*codepipelinebackend.Handler) + if !ok { + return + } + + if h, hok := cfnReg.(*cfnbackend.Handler); hok { + cpH.Backend.SetStackDeployer(&codepipelineStackDeployer{handler: h}) + } +} + +type codepipelineStackDeployer struct { + handler *cfnbackend.Handler +} + +func (d *codepipelineStackDeployer) backend(ctx context.Context) cfnbackend.StorageBackend { + return d.handler.BackendFor(awsmeta.Region(ctx)) +} + +func cpStackParameters(m map[string]string) []cfnbackend.Parameter { + out := make([]cfnbackend.Parameter, 0, len(m)) + for k, v := range m { + out = append(out, cfnbackend.Parameter{ParameterKey: k, ParameterValue: v}) + } + + return out +} + +func cpStackTags(m map[string]string) []cfnbackend.Tag { + out := make([]cfnbackend.Tag, 0, len(m)) + for k, v := range m { + out = append(out, cfnbackend.Tag{Key: k, Value: v}) + } + + return out +} + +func (d *codepipelineStackDeployer) DescribeStack( + ctx context.Context, name string, +) (*codepipelinebackend.StackState, error) { + bk := d.backend(ctx) + + stack, err := bk.DescribeStack(name) + if errors.Is(err, cfnbackend.ErrStackNotFound) { + return nil, nil //nolint:nilnil // absent stack is reported as nil by contract + } + + if err != nil { + return nil, err + } + + st := &codepipelinebackend.StackState{ + Status: stack.StackStatus, + Reason: stack.StackStatusReason, + Outputs: make(map[string]string, len(stack.Outputs)), + } + + for _, o := range stack.Outputs { + st.Outputs[o.OutputKey] = o.OutputValue + } + + if st.Reason == "" && (strings.Contains(st.Status, "FAILED") || strings.Contains(st.Status, "ROLLBACK")) { + st.Reason = firstFailedStackEvent(bk, name) + } + + return st, nil +} + +func firstFailedStackEvent(bk cfnbackend.StorageBackend, name string) string { + events, err := bk.DescribeStackEvents(name, "") + if err != nil { + return "" + } + + for _, e := range events.Data { + if strings.HasSuffix(e.ResourceStatus, "_FAILED") && e.ResourceStatusReason != "" { + return fmt.Sprintf("%s (%s): %s", e.LogicalResourceID, e.ResourceType, e.ResourceStatusReason) + } + } + + return "" +} + +func cpStackOptions(req codepipelinebackend.StackRequest) cfnbackend.StackOptions { + return cfnbackend.StackOptions{ + Capabilities: req.Capabilities, + RoleARN: req.RoleARN, + Tags: cpStackTags(req.Tags), + StackPolicyBody: req.StackPolicy, + } +} + +func (d *codepipelineStackDeployer) CreateStack(ctx context.Context, req codepipelinebackend.StackRequest) error { + _, err := d.backend(ctx).CreateStack(ctx, req.StackName, req.TemplateBody, cpStackParameters(req.Parameters), + cpStackOptions(req)) + + return err +} + +func (d *codepipelineStackDeployer) UpdateStack(ctx context.Context, req codepipelinebackend.StackRequest) error { + _, err := d.backend(ctx).UpdateStack(ctx, req.StackName, req.TemplateBody, cpStackParameters(req.Parameters), + cpStackOptions(req)) + if errors.Is(err, cfnbackend.ErrNoUpdates) { + return codepipelinebackend.ErrNoStackUpdates + } + + return err +} + +func (d *codepipelineStackDeployer) DeleteStack(ctx context.Context, name string) error { + return d.backend(ctx).DeleteStack(ctx, name) +} + +func (d *codepipelineStackDeployer) CreateChangeSet( + ctx context.Context, req codepipelinebackend.StackRequest, changeSetName string, forNewStack bool, +) error { + csType := "UPDATE" + if forNewStack { + csType = "CREATE" + } + + _, err := d.backend(ctx).CreateChangeSet(ctx, req.StackName, changeSetName, req.TemplateBody, "", + cpStackParameters(req.Parameters), req.Capabilities, cpStackTags(req.Tags), + cfnbackend.CreateChangeSetOptions{ChangeSetType: csType, RoleARN: req.RoleARN}) + + return err +} + +func (d *codepipelineStackDeployer) DescribeChangeSet( + ctx context.Context, stackName, changeSetName string, +) (*codepipelinebackend.ChangeSetState, error) { + cs, err := d.backend(ctx).DescribeChangeSet(stackName, changeSetName) + if errors.Is(err, cfnbackend.ErrChangeSetNotFound) || errors.Is(err, cfnbackend.ErrStackNotFound) { + return nil, nil //nolint:nilnil // absent change set is reported as nil by contract + } + + if err != nil { + return nil, err + } + + return &codepipelinebackend.ChangeSetState{Status: cs.Status, Reason: cs.StatusReason}, nil +} + +func (d *codepipelineStackDeployer) DeleteChangeSet(ctx context.Context, stackName, changeSetName string) error { + return d.backend(ctx).DeleteChangeSet(stackName, changeSetName) +} + +func (d *codepipelineStackDeployer) ExecuteChangeSet(ctx context.Context, stackName, changeSetName string) error { + return d.backend(ctx).ExecuteChangeSet(ctx, stackName, changeSetName, false, false) +} + +type codepipelineECSDeployer struct { + handler *ecsbackend.Handler +} + +func (d *codepipelineECSDeployer) backend(ctx context.Context) ecsbackend.Backend { + return d.handler.BackendFor(awsmeta.Region(ctx)) +} + +func (d *codepipelineECSDeployer) DescribeService( + ctx context.Context, cluster, service string, +) (*codepipelinebackend.ECSServiceState, error) { + services, failures, err := d.backend(ctx).DescribeServices(cluster, []string{service}) + if err != nil { + return nil, err + } + + if len(services) == 0 || len(failures) > 0 || services[0].Status == "INACTIVE" { + return nil, nil //nolint:nilnil // absent service is reported as nil by contract + } + + st := &codepipelinebackend.ECSServiceState{TaskDefinition: services[0].TaskDefinition} + + for _, dep := range services[0].Deployments { + st.Deployments = append(st.Deployments, codepipelinebackend.ECSDeploymentState{ + TaskDefinition: dep.TaskDefinition, + Status: dep.Status, + RolloutState: dep.RolloutState, + RolloutStateReason: dep.RolloutStateReason, + DesiredCount: dep.DesiredCount, + RunningCount: dep.RunningCount, + FailedTasks: dep.FailedTasks, + }) + } + + return st, nil +} + +func (d *codepipelineECSDeployer) RegisterRevisionWithImages( + ctx context.Context, baseTaskDefinition string, images map[string]string, +) (string, error) { + bk := d.backend(ctx) + + base, err := bk.DescribeTaskDefinition(baseTaskDefinition) + if err != nil { + return "", err + } + + containers := make([]ecsbackend.ContainerDefinition, len(base.ContainerDefinitions)) + copy(containers, base.ContainerDefinitions) + + replaced := 0 + + for i := range containers { + if img, ok := images[containers[i].Name]; ok { + containers[i].Image = img + replaced++ + } + } + + if replaced != len(images) { + return "", fmt.Errorf("%w: image definitions name containers that are not in task definition %s", + ecsbackend.ErrInvalidParameter, base.TaskDefinitionArn) + } + + rev, err := bk.RegisterTaskDefinition(ecsbackend.RegisterTaskDefinitionInput{ + Family: base.Family, + TaskRoleArn: base.TaskRoleArn, + ExecutionRoleArn: base.ExecutionRoleArn, + NetworkMode: base.NetworkMode, + CPU: base.CPU, + Memory: base.Memory, + PlatformFamily: base.PlatformFamily, + IpcMode: base.IpcMode, + PidMode: base.PidMode, + ContainerDefinitions: containers, + Volumes: base.Volumes, + PlacementConstraints: base.PlacementConstraints, + RequiresCompatibilities: base.RequiresCompatibilities, + InferenceAccelerators: base.InferenceAccelerators, + ProxyConfiguration: base.ProxyConfiguration, + RuntimePlatform: base.RuntimePlatform, + EphemeralStorage: base.EphemeralStorage, + EnableFaultInjection: base.EnableFaultInjection, + }) + if err != nil { + return "", err + } + + return rev.TaskDefinitionArn, nil +} + +func (d *codepipelineECSDeployer) UpdateServiceTaskDefinition( + ctx context.Context, cluster, service, taskDefinition string, +) error { + _, err := d.backend(ctx).UpdateService(ecsbackend.UpdateServiceInput{ + Cluster: cluster, Service: service, TaskDefinition: &taskDefinition, + }) + + return err +} + +type codepipelineImageSource struct { + handler *ecrbackend.Handler +} + +func (s *codepipelineImageSource) DescribeImage( + ctx context.Context, repository, tag string, +) (*codepipelinebackend.ImageInfo, error) { + bk := s.handler.BackendFor(awsmeta.Region(ctx)) + + repos, err := bk.DescribeRepositories(ctx, []string{repository}) + if err != nil { + return nil, err + } + + if len(repos) == 0 { + return nil, fmt.Errorf("%w: %s", ecrbackend.ErrRepositoryNotFound, repository) + } + + images, err := bk.DescribeImages(ctx, repository, []ecrbackend.ImageIdentifier{{ImageTag: tag}}) + if err != nil { + return nil, err + } + + if len(images) == 0 { + return nil, fmt.Errorf("%w: %s:%s", ecrbackend.ErrImageNotFound, repository, tag) + } + + img := images[0] + + return &codepipelinebackend.ImageInfo{ + PushedAt: img.ImagePushedAt, + RegistryID: repos[0].RegistryID, + RepositoryName: repos[0].RepositoryName, + RepositoryURI: repos[0].RepositoryURI, + Digest: img.ImageDigest, + Tags: img.Tags, + SizeInBytes: img.ImageSizeInBytes, + }, nil +} + +type codepipelineRepositoryReader struct { + handler *codecommitbackend.Handler +} + +func (r *codepipelineRepositoryReader) Snapshot( + ctx context.Context, repository, branch string, +) (*codepipelinebackend.RepositorySnapshot, error) { + bk := r.handler.RegionHandler(awsmeta.Region(ctx)).Backend + + br, err := bk.GetBranch(repository, branch) + if err != nil { + return nil, err + } + + files, err := bk.GetFolderFiles(repository, br.CommitID, "") + if err != nil { + return nil, err + } + + snap := &codepipelinebackend.RepositorySnapshot{ + CommitID: br.CommitID, + Files: make(map[string][]byte, len(files)), + } + + for _, f := range files { + full, ferr := bk.GetFile(repository, br.CommitID, f.FilePath) + if ferr != nil { + return nil, ferr + } + + snap.Files[f.FilePath] = full.FileContent + } + + if c, cerr := bk.GetCommit(repository, br.CommitID); cerr == nil { + snap.CommitMessage = c.Message + } + + return snap, nil +} diff --git a/internal/app/codepipeline_actions_test.go b/internal/app/codepipeline_actions_test.go new file mode 100644 index 0000000000..14103e0709 --- /dev/null +++ b/internal/app/codepipeline_actions_test.go @@ -0,0 +1,636 @@ +package app + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "maps" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudformation" + "github.com/aws/aws-sdk-go-v2/service/codecommit" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + cptypes "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const cfnPipelineTemplate = `{"Parameters":{"Env":{"Type":"String","Default":"dev"}},` + + `"Resources":{"Topic":{"Type":"AWS::SNS::Topic"}},` + + `"Outputs":{"EnvOut":{"Value":{"Ref":"Env"}}}}` + +func pipelineZip(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + + for name, body := range files { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte(body)) + require.NoError(t, err) + } + + require.NoError(t, zw.Close()) + + return buf.Bytes() +} + +type actionPipelineEnv struct { + fx *sfnFixture + s3c *s3.Client + cpc *codepipeline.Client +} + +func newActionPipelineEnv(t *testing.T) *actionPipelineEnv { + t.Helper() + + fx := newSFNFixture(t) + env := &actionPipelineEnv{ + fx: fx, + s3c: s3.NewFromConfig(fx.cfg, func(o *s3.Options) { o.UsePathStyle = true }), + cpc: codepipeline.NewFromConfig(fx.cfg), + } + + for _, b := range []string{"src-bkt", "art-bkt", "out-bkt"} { + _, err := env.s3c.CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String(b)}) + require.NoError(t, err) + } + + return env +} + +func (e *actionPipelineEnv) putSource(t *testing.T, files map[string]string) { + t.Helper() + + _, err := e.s3c.PutObject(t.Context(), &s3.PutObjectInput{ + Bucket: aws.String("src-bkt"), Key: aws.String("app.zip"), Body: bytes.NewReader(pipelineZip(t, files)), + }) + require.NoError(t, err) +} + +func (e *actionPipelineEnv) createPipeline(t *testing.T, name string, stages ...cptypes.StageDeclaration) error { + t.Helper() + + _, err := e.cpc.CreatePipeline( + t.Context(), + &codepipeline.CreatePipelineInput{Pipeline: &cptypes.PipelineDeclaration{ + Name: aws.String(name), + RoleArn: aws.String("arn:aws:iam::000000000000:role/cp"), + ArtifactStore: &cptypes.ArtifactStore{ + Type: cptypes.ArtifactStoreTypeS3, Location: aws.String("art-bkt"), + }, + Stages: stages, + }}, + ) + + return err +} + +func s3SourceStage(out string) cptypes.StageDeclaration { + return s3Declaration("Source", "Source", "Source", "S3", + map[string]string{"S3Bucket": "src-bkt", "S3ObjectKey": "app.zip", "PollForSourceChanges": "false"}, "", out) +} + +// run starts the pipeline and waits for a terminal execution status. +func (e *actionPipelineEnv) run( + t *testing.T, + name string, +) (cptypes.PipelineExecutionStatus, map[string]cptypes.ActionExecutionDetail) { + t.Helper() + + ctx := t.Context() + + started, err := e.cpc.StartPipelineExecution(ctx, &codepipeline.StartPipelineExecutionInput{Name: aws.String(name)}) + require.NoError(t, err) + + var status cptypes.PipelineExecutionStatus + + require.Eventually(t, func() bool { + out, gErr := e.cpc.GetPipelineExecution(ctx, &codepipeline.GetPipelineExecutionInput{ + PipelineName: aws.String(name), PipelineExecutionId: started.PipelineExecutionId, + }) + if gErr != nil { + return false + } + + status = out.PipelineExecution.Status + + return status != cptypes.PipelineExecutionStatusInProgress + }, 15*time.Second, 50*time.Millisecond) + + list, err := e.cpc.ListActionExecutions(ctx, &codepipeline.ListActionExecutionsInput{ + PipelineName: aws.String(name), + Filter: &cptypes.ActionExecutionFilter{PipelineExecutionId: started.PipelineExecutionId}, + }) + require.NoError(t, err) + + actions := map[string]cptypes.ActionExecutionDetail{} + for _, d := range list.ActionExecutionDetails { + actions[aws.ToString(d.ActionName)] = d + } + + return status, actions +} + +func (e *actionPipelineEnv) object(t *testing.T, key string) string { + t.Helper() + + out, err := e.s3c.GetObject(t.Context(), &s3.GetObjectInput{Bucket: aws.String("out-bkt"), Key: aws.String(key)}) + require.NoError(t, err) + + defer out.Body.Close() + + body, err := io.ReadAll(out.Body) + require.NoError(t, err) + + return string(body) +} + +func extractStage(in string) cptypes.StageDeclaration { + return s3Declaration("Publish", "Publish", "Deploy", "S3", + map[string]string{"BucketName": "out-bkt", "Extract": "true", "ObjectKey": "out"}, in, "") +} + +func errorMessage(d cptypes.ActionExecutionDetail) string { + if d.Output == nil || d.Output.ExecutionResult == nil || d.Output.ExecutionResult.ErrorDetails == nil { + return "" + } + + return aws.ToString(d.Output.ExecutionResult.ErrorDetails.Message) +} + +func TestCodePipelineCloudFormationDeploy(t *testing.T) { + t.Parallel() + + tests := []struct { + files map[string]string + cfg map[string]string + name string + wantOutput string + wantErr string + wantStatus cptypes.PipelineExecutionStatus + wantStack bool + wantDeleted bool + seedStack bool + }{ + { + name: "create update writes outputs", + files: map[string]string{"template.json": cfnPipelineTemplate, "params.json": `{"env":"prod"}`}, + cfg: map[string]string{ + "ActionMode": "CREATE_UPDATE", "StackName": "cp-stack", "TemplatePath": "SrcOut::template.json", + "OutputFileName": "StackOutputs.json", "Capabilities": "CAPABILITY_IAM", + "ParameterOverrides": `{"Env":{"Fn::GetParam":["SrcOut","params.json","env"]}}`, + }, + wantStatus: cptypes.PipelineExecutionStatusSucceeded, + wantOutput: `{"EnvOut":"prod"}`, + wantStack: true, + }, + { + name: "template configuration parameters", + files: map[string]string{ + "template.json": cfnPipelineTemplate, + "conf.json": `{"Parameters":{"Env":"staging"}}`, + }, + cfg: map[string]string{ + "ActionMode": "CREATE_UPDATE", "StackName": "cp-stack", "TemplatePath": "SrcOut::template.json", + "TemplateConfiguration": "SrcOut::conf.json", "OutputFileName": "StackOutputs.json", + }, + wantStatus: cptypes.PipelineExecutionStatusSucceeded, + wantOutput: `{"EnvOut":"staging"}`, + wantStack: true, + }, + { + name: "invalid template fails with reason", + files: map[string]string{"template.json": `{"Resources":{"X":{"Type":"AWS::Fake::Nothing"}}}`}, + cfg: map[string]string{ + "ActionMode": "CREATE_UPDATE", "StackName": "cp-stack", "TemplatePath": "SrcOut::template.json", + }, + wantStatus: cptypes.PipelineExecutionStatusFailed, + wantErr: "Fake", + }, + { + name: "missing template file fails", + files: map[string]string{"other.json": `{}`}, + cfg: map[string]string{ + "ActionMode": "CREATE_UPDATE", "StackName": "cp-stack", "TemplatePath": "SrcOut::template.json", + }, + wantStatus: cptypes.PipelineExecutionStatusFailed, + wantErr: "template.json", + }, + { + name: "delete only removes stack", + files: map[string]string{"template.json": cfnPipelineTemplate}, + cfg: map[string]string{ + "ActionMode": "DELETE_ONLY", "StackName": "cp-stack", + }, + seedStack: true, + wantStatus: cptypes.PipelineExecutionStatusSucceeded, + wantDeleted: true, + }, + { + name: "delete only absent stack succeeds", + files: map[string]string{"template.json": cfnPipelineTemplate}, + cfg: map[string]string{ + "ActionMode": "DELETE_ONLY", "StackName": "cp-stack", + }, + wantStatus: cptypes.PipelineExecutionStatusSucceeded, + wantDeleted: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newActionPipelineEnv(t) + cfn := cloudformation.NewFromConfig(env.fx.cfg) + env.putSource(t, tt.files) + + if tt.seedStack { + _, err := cfn.CreateStack(t.Context(), &cloudformation.CreateStackInput{ + StackName: aws.String("cp-stack"), TemplateBody: aws.String(cfnPipelineTemplate), + }) + require.NoError(t, err) + } + + deploy := s3Declaration("Deploy", "Deploy", "Deploy", "CloudFormation", tt.cfg, "SrcOut", "CfnOut") + stages := []cptypes.StageDeclaration{s3SourceStage("SrcOut"), deploy} + + if tt.wantOutput != "" { + stages = append(stages, extractStage("CfnOut")) + } else { + stages[1].Actions[0].OutputArtifacts = nil + } + + require.NoError(t, env.createPipeline(t, "cfn", stages...)) + + status, actions := env.run(t, "cfn") + assert.Equal(t, tt.wantStatus, status) + + if tt.wantErr != "" { + assert.Equal(t, cptypes.ActionExecutionStatusFailed, actions["Deploy"].Status) + assert.Contains(t, errorMessage(actions["Deploy"]), tt.wantErr) + } + + if tt.wantStack { + out, err := cfn.DescribeStacks( + t.Context(), + &cloudformation.DescribeStacksInput{StackName: aws.String("cp-stack")}, + ) + require.NoError(t, err) + require.Len(t, out.Stacks, 1) + assert.Equal(t, "CREATE_COMPLETE", string(out.Stacks[0].StackStatus)) + assert.Equal(t, tt.wantOutput, env.object(t, "out/StackOutputs.json")) + assert.Equal( + t, + tt.wantOutput == `{"EnvOut":"prod"}`, + actions["Deploy"].Output.OutputVariables["EnvOut"] == "prod", + ) + } + + if tt.wantDeleted { + ls, err := cfn.ListStacks(t.Context(), &cloudformation.ListStacksInput{}) + require.NoError(t, err) + + for _, s := range ls.StackSummaries { + assert.NotEqual(t, "CREATE_COMPLETE", string(s.StackStatus), "stack must not stay live") + } + + if tt.seedStack { + require.NotEmpty(t, ls.StackSummaries) + assert.Equal(t, "DELETE_COMPLETE", string(ls.StackSummaries[0].StackStatus)) + } + } + }) + } +} + +func TestCodePipelineCloudFormationChangeSets(t *testing.T) { + t.Parallel() + + env := newActionPipelineEnv(t) + cfn := cloudformation.NewFromConfig(env.fx.cfg) + env.putSource(t, map[string]string{"template.json": cfnPipelineTemplate}) + + base := map[string]string{"StackName": "cs-stack", "ChangeSetName": "cs", "TemplatePath": "SrcOut::template.json"} + replace := mergeStringMaps(base, map[string]string{"ActionMode": "CHANGE_SET_REPLACE"}) + execute := mergeStringMaps(base, map[string]string{"ActionMode": "CHANGE_SET_EXECUTE", "OutputFileName": "o.json"}) + + require.NoError(t, env.createPipeline(t, "cs", + s3SourceStage("SrcOut"), + s3Declaration("Plan", "Plan", "Deploy", "CloudFormation", replace, "SrcOut", ""), + s3Declaration("Apply", "Apply", "Deploy", "CloudFormation", execute, "SrcOut", "CfnOut"), + extractStage("CfnOut"), + )) + + status, actions := env.run(t, "cs") + assert.Equal(t, cptypes.PipelineExecutionStatusSucceeded, status) + assert.Equal(t, cptypes.ActionExecutionStatusSucceeded, actions["Apply"].Status) + assert.JSONEq(t, `{"EnvOut":"dev"}`, env.object(t, "out/o.json")) + + out, err := cfn.DescribeStacks(t.Context(), &cloudformation.DescribeStacksInput{StackName: aws.String("cs-stack")}) + require.NoError(t, err) + require.Len(t, out.Stacks, 1) + assert.Equal(t, "CREATE_COMPLETE", string(out.Stacks[0].StackStatus)) +} + +func mergeStringMaps(a, b map[string]string) map[string]string { + out := make(map[string]string, len(a)+len(b)) + maps.Copy(out, a) + maps.Copy(out, b) + + return out +} + +func TestCodePipelineECSDeploy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + defs string + wantStatus cptypes.PipelineExecutionStatus + wantErr string + wantImage string + }{ + { + name: "new revision with new image", + defs: `[{"name":"web","imageUri":"nginx:2.0"}]`, + wantStatus: cptypes.PipelineExecutionStatusSucceeded, + wantImage: "nginx:2.0", + }, + { + name: "unknown container fails", + defs: `[{"name":"nope","imageUri":"nginx:2.0"}]`, + wantStatus: cptypes.PipelineExecutionStatusFailed, + wantErr: "not in task definition", + }, + { + name: "malformed image definitions fail", + defs: `{"name":"web"}`, + wantStatus: cptypes.PipelineExecutionStatusFailed, + wantErr: "image definitions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newActionPipelineEnv(t) + ecsc := ecs.NewFromConfig(env.fx.cfg) + ctx := t.Context() + + _, err := ecsc.CreateCluster(ctx, &ecs.CreateClusterInput{ClusterName: aws.String("c1")}) + require.NoError(t, err) + + _, err = ecsc.RegisterTaskDefinition(ctx, &ecs.RegisterTaskDefinitionInput{ + Family: aws.String("app"), + ContainerDefinitions: []ecstypes.ContainerDefinition{ + {Name: aws.String("web"), Image: aws.String("nginx:1.0"), Memory: aws.Int32(128)}, + }, + }) + require.NoError(t, err) + + _, err = ecsc.CreateService(ctx, &ecs.CreateServiceInput{ + Cluster: aws.String("c1"), ServiceName: aws.String("svc"), TaskDefinition: aws.String("app:1"), + DesiredCount: aws.Int32(1), + }) + require.NoError(t, err) + + env.putSource(t, map[string]string{"imagedefinitions.json": tt.defs}) + + require.NoError(t, env.createPipeline(t, "ecs", + s3SourceStage("SrcOut"), + s3Declaration("Deploy", "Deploy", "Deploy", "ECS", + map[string]string{"ClusterName": "c1", "ServiceName": "svc"}, "SrcOut", ""), + )) + + status, actions := env.run(t, "ecs") + assert.Equal(t, tt.wantStatus, status) + + if tt.wantErr != "" { + assert.Contains(t, errorMessage(actions["Deploy"]), tt.wantErr) + + return + } + + svc, err := ecsc.DescribeServices(ctx, &ecs.DescribeServicesInput{ + Cluster: aws.String("c1"), Services: []string{"svc"}, + }) + require.NoError(t, err) + require.Len(t, svc.Services, 1) + assert.Contains(t, aws.ToString(svc.Services[0].TaskDefinition), "task-definition/app:2") + + td, err := ecsc.DescribeTaskDefinition( + ctx, + &ecs.DescribeTaskDefinitionInput{TaskDefinition: aws.String("app:2")}, + ) + require.NoError(t, err) + require.Len(t, td.TaskDefinition.ContainerDefinitions, 1) + assert.Equal(t, tt.wantImage, aws.ToString(td.TaskDefinition.ContainerDefinitions[0].Image)) + + old, err := ecsc.DescribeTaskDefinition( + ctx, + &ecs.DescribeTaskDefinitionInput{TaskDefinition: aws.String("app:1")}, + ) + require.NoError(t, err) + assert.Equal(t, "nginx:1.0", aws.ToString(old.TaskDefinition.ContainerDefinitions[0].Image)) + }) + } +} + +func TestCodePipelineECRSource(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tag string + wantStatus cptypes.PipelineExecutionStatus + wantErr string + }{ + {name: "default tag latest", tag: "", wantStatus: cptypes.PipelineExecutionStatusSucceeded}, + {name: "explicit tag", tag: "v1", wantStatus: cptypes.PipelineExecutionStatusSucceeded}, + { + name: "missing tag fails", + tag: "nope", + wantStatus: cptypes.PipelineExecutionStatusFailed, + wantErr: "does not exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newActionPipelineEnv(t) + ecrc := ecr.NewFromConfig(env.fx.cfg) + ctx := t.Context() + + repo, err := ecrc.CreateRepository(ctx, &ecr.CreateRepositoryInput{RepositoryName: aws.String("img")}) + require.NoError(t, err) + + for _, tag := range []string{"latest", "v1"} { + _, err = ecrc.PutImage(ctx, &ecr.PutImageInput{ + RepositoryName: aws.String( + "img", + ), ImageTag: aws.String(tag), ImageManifest: aws.String(`{"schemaVersion":2}`), + }) + require.NoError(t, err) + } + + cfg := map[string]string{"RepositoryName": "img"} + if tt.tag != "" { + cfg["ImageTag"] = tt.tag + } + + require.NoError(t, env.createPipeline(t, "ecr", + s3Declaration("Source", "Source", "Source", "ECR", cfg, "", "ImgOut"), + extractStage("ImgOut"), + )) + + status, actions := env.run(t, "ecr") + assert.Equal(t, tt.wantStatus, status) + + if tt.wantErr != "" { + assert.Contains(t, errorMessage(actions["Source"]), tt.wantErr) + + return + } + + var detail map[string]any + require.NoError(t, json.Unmarshal([]byte(env.object(t, "out/imageDetail.json")), &detail)) + + imgs, err := ecrc.DescribeImages(ctx, &ecr.DescribeImagesInput{ + RepositoryName: aws.String( + "img", + ), ImageIds: []ecrtypes.ImageIdentifier{{ImageTag: aws.String("latest")}}, + }) + require.NoError(t, err) + require.NotEmpty(t, imgs.ImageDetails) + + digest := aws.ToString(imgs.ImageDetails[0].ImageDigest) + assert.Equal(t, digest, detail["ImageDigest"]) + assert.Equal(t, aws.ToString(repo.Repository.RepositoryUri)+"@"+digest, detail["ImageURI"]) + assert.Equal(t, "img", detail["RepositoryName"]) + assert.Equal(t, "1.0", detail["Version"]) + assert.Equal(t, aws.ToString(repo.Repository.RegistryId), detail["RegistryId"]) + assert.Contains(t, detail, "ImagePushedAt") + assert.Contains(t, detail, "ImageSizeInBytes") + assert.NotEmpty(t, detail["ImageTags"]) + assert.Equal(t, digest, actions["Source"].Output.OutputVariables["ImageDigest"]) + }) + } +} + +func TestCodePipelineCodeCommitSource(t *testing.T) { + t.Parallel() + + env := newActionPipelineEnv(t) + ccc := codecommit.NewFromConfig(env.fx.cfg) + ctx := t.Context() + + _, err := ccc.CreateRepository(ctx, &codecommit.CreateRepositoryInput{RepositoryName: aws.String("repo")}) + require.NoError(t, err) + + put, err := ccc.PutFile(ctx, &codecommit.PutFileInput{ + RepositoryName: aws.String("repo"), BranchName: aws.String("main"), FilePath: aws.String("dir/app.txt"), + FileContent: []byte("hello"), CommitMessage: aws.String("first"), + }) + require.NoError(t, err) + + require.NoError(t, env.createPipeline(t, "cc", + s3Declaration("Source", "Source", "Source", "CodeCommit", + map[string]string{"RepositoryName": "repo", "BranchName": "main"}, "", "Code"), + extractStage("Code"), + )) + + status, actions := env.run(t, "cc") + assert.Equal(t, cptypes.PipelineExecutionStatusSucceeded, status) + assert.Equal(t, "hello", env.object(t, "out/dir/app.txt")) + assert.Equal(t, aws.ToString(put.CommitId), actions["Source"].Output.OutputVariables["CommitId"]) +} + +func TestCodePipelineUnsupportedBuiltinActions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category string + provider string + version string + cfg map[string]string + wantCreate string + wantExecFail string + }{ + { + name: "unknown provider rejected", + category: "Deploy", + provider: "Teleport", + version: "1", + wantCreate: "InvalidActionDeclarationException", + }, + { + name: "unknown version rejected", + category: "Deploy", + provider: "ECS", + version: "9", + wantCreate: "InvalidActionDeclarationException", + }, + { + name: "bad cfn action mode rejected", category: "Deploy", provider: "CloudFormation", version: "1", + cfg: map[string]string{ + "ActionMode": "BOGUS", + "StackName": "s", + }, wantCreate: "InvalidActionDeclarationException", + }, + { + name: "valid but unimplemented fails", category: "Deploy", provider: "ElasticBeanstalk", version: "1", + cfg: map[string]string{"ApplicationName": "a", "EnvironmentName": "e"}, + wantExecFail: "not supported by this emulator", + }, + { + name: "codestar connection fails", category: "Source", provider: "CodeStarSourceConnection", version: "1", + cfg: map[string]string{ + "ConnectionArn": "arn:aws:codeconnections:us-east-1:000000000000:connection/abc", + "FullRepositoryId": "o/r", + "BranchName": "main", + }, + wantExecFail: "third-party repository content", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newActionPipelineEnv(t) + decl := s3Declaration("Stage", "Act", tt.category, tt.provider, tt.cfg, "", "") + decl.Actions[0].ActionTypeId.Version = aws.String(tt.version) + + err := env.createPipeline(t, "p", decl) + if tt.wantCreate != "" { + require.ErrorContains(t, err, tt.wantCreate) + + return + } + + require.NoError(t, err) + + status, actions := env.run(t, "p") + assert.Equal(t, cptypes.PipelineExecutionStatusFailed, status) + assert.Equal(t, cptypes.ActionExecutionStatusFailed, actions["Act"].Status) + assert.Contains(t, errorMessage(actions["Act"]), tt.wantExecFail) + }) + } +} diff --git a/internal/app/cognitoidentity_sts_wiring_test.go b/internal/app/cognitoidentity_sts_wiring_test.go index d4f46a768d..d03c4f3435 100644 --- a/internal/app/cognitoidentity_sts_wiring_test.go +++ b/internal/app/cognitoidentity_sts_wiring_test.go @@ -41,7 +41,7 @@ func TestInitializeServices_CognitoIdentityCredentialsAcceptedBySTS(t *testing.T cli := &CLI{AccountID: "000000000000", Region: "us-east-1"} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices( + services, err := initializeServicesT(t, &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, ) require.NoError(t, err) @@ -191,7 +191,7 @@ func TestInitializeServices_CognitoIdentityCustomRoleAndPrincipalTags(t *testing cli := &CLI{AccountID: "000000000000", Region: "us-east-1"} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices( + services, err := initializeServicesT(t, &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, ) require.NoError(t, err) diff --git a/internal/app/cross_region.go b/internal/app/cross_region.go index a9e159618a..631b8a7fc6 100644 --- a/internal/app/cross_region.go +++ b/internal/app/cross_region.go @@ -164,6 +164,7 @@ func asBackend[B any](v any, home B) B { // regionalTagSpec describes one region-isolated service's tagging surface. type regionalTagSpec[B any] struct { backendFor func(region string) B + backendForCtx func(ctx context.Context, region string) B list func(B) []taggedARNEntry tag func(B, context.Context, string, map[string]string) error untag func(B, context.Context, string, []string) error @@ -171,12 +172,21 @@ type regionalTagSpec[B any] struct { arnService string } +// resolve picks the backend for region, scoped to the caller's account when the service is per-account. +func (s regionalTagSpec[B]) resolve(ctx context.Context, region string) B { + if s.backendForCtx != nil { + return s.backendForCtx(ctx, region) + } + + return s.backendFor(region) +} + // wireRegionalTagging lists the request region's resources and tags or untags in the ARN's region. func wireRegionalTagging[B any](bk resourcegroupstaggingapibackend.StorageBackend, spec regionalTagSpec[B]) { registerTaggingService( bk, func(ctx context.Context) []resourcegroupstaggingapibackend.TaggedResource { - items := spec.list(spec.backendFor(awsmeta.Region(ctx))) + items := spec.list(spec.resolve(ctx, awsmeta.Region(ctx))) out := make([]resourcegroupstaggingapibackend.TaggedResource, 0, len(items)) for _, item := range items { @@ -189,10 +199,10 @@ func wireRegionalTagging[B any](bk resourcegroupstaggingapibackend.StorageBacken }, spec.arnService, func(ctx context.Context, arn string, newTags map[string]string) error { - return spec.tag(spec.backendFor(originRegion(ctx, arn)), ctx, arn, newTags) + return spec.tag(spec.resolve(ctx, originRegion(ctx, arn)), ctx, arn, newTags) }, func(ctx context.Context, arn string, keys []string) error { - return spec.untag(spec.backendFor(originRegion(ctx, arn)), ctx, arn, keys) + return spec.untag(spec.resolve(ctx, originRegion(ctx, arn)), ctx, arn, keys) }, ) } diff --git a/internal/app/cross_service_region_test.go b/internal/app/cross_service_region_test.go index 9b7d939b75..0acfa03936 100644 --- a/internal/app/cross_service_region_test.go +++ b/internal/app/cross_service_region_test.go @@ -35,7 +35,7 @@ func crossRegionServices(t *testing.T) map[string]service.Registerable { cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), PortAlloc: portAlloc, }) require.NoError(t, err) diff --git a/internal/app/dev_endpoints.go b/internal/app/dev_endpoints.go index c2bcfb7f70..f5fcedc79e 100644 --- a/internal/app/dev_endpoints.go +++ b/internal/app/dev_endpoints.go @@ -9,6 +9,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/service" cwbackend "github.com/blackbirdworks/gopherstack/services/cloudwatch" + lambdabackend "github.com/blackbirdworks/gopherstack/services/lambda" sesbackend "github.com/blackbirdworks/gopherstack/services/ses" sesv2backend "github.com/blackbirdworks/gopherstack/services/sesv2" sqsbackend "github.com/blackbirdworks/gopherstack/services/sqs" @@ -153,6 +154,7 @@ func registerLocalstackDevEndpoints(e *echo.Echo, services []service.Registerabl e.GET("/_aws/ses", buildSESRetrospectionGet(services)) e.DELETE("/_aws/ses", buildSESRetrospectionDelete(services)) e.POST("/_localstack/state/reset", buildResetHandler(services)) + e.GET("/_aws/lambda/runtimes", serveLambdaRuntimes) for _, svc := range services { if r, ok := svc.(interface{ RegisterInspectionRoutes(*echo.Echo) }); ok { @@ -169,3 +171,13 @@ func registerLocalstackDevEndpoints(e *echo.Echo, services []service.Registerabl } } } + +// serveLambdaRuntimes serves GET /_aws/lambda/runtimes?filter=supported|deprecated|all. +func serveLambdaRuntimes(c *echo.Context) error { + runtimes, err := lambdabackend.RuntimeIdentifiers(c.QueryParam("filter")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{keyMessageField: err.Error()}) + } + + return c.JSON(http.StatusOK, map[string][]string{"Runtimes": runtimes}) +} diff --git a/internal/app/dev_endpoints_test.go b/internal/app/dev_endpoints_test.go index 3a233d3565..522d97d25c 100644 --- a/internal/app/dev_endpoints_test.go +++ b/internal/app/dev_endpoints_test.go @@ -25,6 +25,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/chaos" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func devEndpointsServer(t *testing.T) (*httptest.Server, aws.Config) { @@ -35,7 +36,7 @@ func devEndpointsServer(t *testing.T) (*httptest.Server, aws.Config) { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) @@ -67,7 +68,7 @@ func devDo(t *testing.T, method, url, accept string) (int, []byte) { req.Header.Set("Accept", accept) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -359,3 +360,71 @@ func TestLocalstackStateResetAlias(t *testing.T) { }) } } + +func TestLambdaRuntimesEndpoint(t *testing.T) { + t.Parallel() + + srv, _ := devEndpointsServer(t) + + tests := []struct { + name string + query string + has string + hasNot string + wantStatus int + }{ + {name: "default", query: "", has: "python3.12", hasNot: "python3.7", wantStatus: http.StatusOK}, + { + name: "supported", + query: "?filter=supported", + has: "nodejs22.x", + hasNot: "nodejs12.x", + wantStatus: http.StatusOK, + }, + { + name: "deprecated", + query: "?filter=deprecated", + has: "nodejs12.x", + hasNot: "python3.12", + wantStatus: http.StatusOK, + }, + {name: "all", query: "?filter=all", has: "python3.7", wantStatus: http.StatusOK}, + {name: "invalid", query: "?filter=nope", wantStatus: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodGet, + srv.URL+"/_aws/lambda/runtimes"+tt.query, + nil, + ) + require.NoError(t, err) + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + require.Equal(t, tt.wantStatus, resp.StatusCode) + + if tt.wantStatus != http.StatusOK { + return + } + + var out struct { + Runtimes []string `json:"Runtimes"` + } + + require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) + assert.Contains(t, out.Runtimes, tt.has) + + if tt.hasNot != "" { + assert.NotContains(t, out.Runtimes, tt.hasNot) + } + }) + } +} diff --git a/internal/app/dms_engine_docker_test.go b/internal/app/dms_engine_docker_test.go new file mode 100644 index 0000000000..888f126beb --- /dev/null +++ b/internal/app/dms_engine_docker_test.go @@ -0,0 +1,788 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net" + "net/http/httptest" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/databasemigrationservice" + dmstypes "github.com/aws/aws-sdk-go-v2/service/databasemigrationservice/types" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/kinesis" + "github.com/aws/aws-sdk-go-v2/service/rds" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/go-sql-driver/mysql" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const ( + dmsDockerWait = 5 * time.Minute + dmsPoll = 500 * time.Millisecond + dmsMasterPass = "firstpass1" + dmsRoleARN = "arn:aws:iam::000000000000:role/dms" +) + +func requireDMSDocker(t *testing.T, images ...string) { + t.Helper() + + if testing.Short() { + t.Skip("real database containers are skipped in -short runs") + } + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not found") + } + + for _, img := range images { + if err := exec.CommandContext(t.Context(), "docker", "image", "inspect", img).Run(); err != nil { + t.Skipf("%s not present", img) + } + } +} + +type dmsHarness struct { + rds *rds.Client + dms *databasemigrationservice.Client + cfg aws.Config +} + +func newDMSHarness(t *testing.T) *dmsHarness { + t.Helper() + + cli := CLI{RDSEngine: "docker", DMSEngine: "docker"} + log := buildLogger("") + cli.AccountID, cli.Region = "000000000000", "us-east-1" + cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServicesT(t, &service.AppContext{ + Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, + }) + require.NoError(t, err) + + e := buildEchoServer(t.Context(), log, nil, services, cli) + require.NoError(t, setupChaosAndRegistry(e, log, &cli, services)) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + cfg, err := awscfg.LoadDefaultConfig( + t.Context(), + awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + awscfg.WithBaseEndpoint(srv.URL), + ) + require.NoError(t, err) + + return &dmsHarness{cfg: cfg, rds: rds.NewFromConfig(cfg), dms: databasemigrationservice.NewFromConfig(cfg)} +} + +type dbEndpoint struct { + host string + port int32 +} + +func (h *dmsHarness) logicalGroup(t *testing.T, name string) { + t.Helper() + + _, err := h.rds.CreateDBParameterGroup(t.Context(), &rds.CreateDBParameterGroupInput{ + DBParameterGroupName: aws.String(name), DBParameterGroupFamily: aws.String("postgres16"), + Description: aws.String("logical replication for DMS"), + }) + require.NoError(t, err) + + _, err = h.rds.ModifyDBParameterGroup(t.Context(), &rds.ModifyDBParameterGroupInput{ + DBParameterGroupName: aws.String(name), + Parameters: []rdstypes.Parameter{{ + ParameterName: aws.String("rds.logical_replication"), ParameterValue: aws.String("1"), + ApplyMethod: rdstypes.ApplyMethodPendingReboot, + }}, + }) + require.NoError(t, err) +} + +func (h *dmsHarness) createDB(t *testing.T, id, engine, version string, group ...string) dbEndpoint { + t.Helper() + + ctx := t.Context() + in := &rds.CreateDBInstanceInput{ + DBInstanceIdentifier: aws.String(id), Engine: aws.String(engine), EngineVersion: aws.String(version), + DBInstanceClass: aws.String("db.t3.micro"), AllocatedStorage: aws.Int32(20), DBName: aws.String("appdb"), + MasterUsername: aws.String("master"), MasterUserPassword: aws.String(dmsMasterPass), + } + if len(group) > 0 { + in.DBParameterGroupName = aws.String(group[0]) + } + + _, err := h.rds.CreateDBInstance(ctx, in) + require.NoError(t, err) + t.Cleanup(func() { + bg := context.WithoutCancel(ctx) + _, _ = h.rds.DeleteDBInstance(bg, &rds.DeleteDBInstanceInput{ + DBInstanceIdentifier: aws.String(id), SkipFinalSnapshot: aws.Bool(true), + }) + require.Eventually(t, func() bool { + _, derr := h.rds.DescribeDBInstances( + bg, + &rds.DescribeDBInstancesInput{DBInstanceIdentifier: aws.String(id)}, + ) + + return derr != nil + }, time.Minute, dmsPoll) + }) + + var ep dbEndpoint + + require.Eventually(t, func() bool { + out, derr := h.rds.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{DBInstanceIdentifier: aws.String(id)}) + if derr != nil || len(out.DBInstances) == 0 { + return false + } + + inst := out.DBInstances[0] + if aws.ToString(inst.DBInstanceStatus) == "failed" { + require.FailNow(t, "database instance failed") + } + + if aws.ToString(inst.DBInstanceStatus) != "available" || inst.Endpoint == nil { + return false + } + + ep = dbEndpoint{host: aws.ToString(inst.Endpoint.Address), port: aws.ToInt32(inst.Endpoint.Port)} + + return true + }, dmsDockerWait, dmsPoll) + + return ep +} + +func openDB(t *testing.T, kind string, ep dbEndpoint) *sql.DB { + t.Helper() + + addr := net.JoinHostPort(ep.host, strconv.Itoa(int(ep.port))) + + var ( + db *sql.DB + err error + ) + + if kind == "postgres" { + db, err = sql.Open("pgx", "postgres://master:"+dmsMasterPass+"@"+addr+"/appdb?sslmode=disable") + } else { + c := mysql.NewConfig() + c.User, c.Passwd, c.Net, c.Addr, c.DBName = "master", dmsMasterPass, "tcp", addr, "appdb" + + connector, cerr := mysql.NewConnector(c) + require.NoError(t, cerr) + + db = sql.OpenDB(connector) + } + + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + return db +} + +func mustExec(t *testing.T, db *sql.DB, stmts ...string) { + t.Helper() + + for _, s := range stmts { + _, err := db.ExecContext(t.Context(), s) + require.NoError(t, err, s) + } +} + +func (h *dmsHarness) endpoint( + t *testing.T, id string, kind dmstypes.ReplicationEndpointTypeValue, engine string, db *dbEndpoint, + mut func(*databasemigrationservice.CreateEndpointInput), +) string { + t.Helper() + + in := &databasemigrationservice.CreateEndpointInput{ + EndpointIdentifier: aws.String(id), EndpointType: kind, EngineName: aws.String(engine), + } + + if db != nil { + in.ServerName, in.Port = aws.String(db.host), aws.Int32(db.port) + in.Username, in.Password, in.DatabaseName = aws.String("master"), aws.String(dmsMasterPass), aws.String("appdb") + } + + if mut != nil { + mut(in) + } + + out, err := h.dms.CreateEndpoint(t.Context(), in) + require.NoError(t, err) + + return aws.ToString(out.Endpoint.EndpointArn) +} + +func (h *dmsHarness) instance(t *testing.T) string { + t.Helper() + + out, err := h.dms.CreateReplicationInstance(t.Context(), &databasemigrationservice.CreateReplicationInstanceInput{ + ReplicationInstanceIdentifier: aws.String("ri"), ReplicationInstanceClass: aws.String("dms.t3.micro"), + }) + require.NoError(t, err) + + return aws.ToString(out.ReplicationInstance.ReplicationInstanceArn) +} + +func (h *dmsHarness) task(t *testing.T, id, src, tgt, ri, migration, mappings string) string { + t.Helper() + + out, err := h.dms.CreateReplicationTask(t.Context(), &databasemigrationservice.CreateReplicationTaskInput{ + ReplicationTaskIdentifier: aws.String( + id, + ), + SourceEndpointArn: aws.String(src), + TargetEndpointArn: aws.String(tgt), + ReplicationInstanceArn: aws.String(ri), + MigrationType: dmstypes.MigrationTypeValue(migration), + TableMappings: aws.String(mappings), + }) + require.NoError(t, err) + + arn := aws.ToString(out.ReplicationTask.ReplicationTaskArn) + _, err = h.dms.StartReplicationTask(t.Context(), &databasemigrationservice.StartReplicationTaskInput{ + ReplicationTaskArn: aws.String( + arn, + ), + StartReplicationTaskType: dmstypes.StartReplicationTaskTypeValueStartReplication, + }) + require.NoError(t, err) + + return arn +} + +func (h *dmsHarness) describe(t *testing.T, arn string) dmstypes.ReplicationTask { + t.Helper() + + out, err := h.dms.DescribeReplicationTasks(t.Context(), &databasemigrationservice.DescribeReplicationTasksInput{ + Filters: []dmstypes.Filter{{Name: aws.String("replication-task-arn"), Values: []string{arn}}}, + }) + require.NoError(t, err) + require.Len(t, out.ReplicationTasks, 1) + + return out.ReplicationTasks[0] +} + +func (h *dmsHarness) awaitStatus(t *testing.T, arn, want string) dmstypes.ReplicationTask { + t.Helper() + + var got dmstypes.ReplicationTask + + require.Eventually(t, func() bool { + got = h.describe(t, arn) + if aws.ToString(got.Status) == "failed" { + require.FailNow(t, "task failed: "+aws.ToString(got.LastFailureMessage)) + } + + return aws.ToString(got.Status) == want + }, dmsDockerWait, dmsPoll) + + return got +} + +func (h *dmsHarness) stop(t *testing.T, arn string) { + t.Helper() + + _, err := h.dms.StopReplicationTask(t.Context(), &databasemigrationservice.StopReplicationTaskInput{ + ReplicationTaskArn: aws.String(arn), + }) + require.NoError(t, err) + h.awaitStatus(t, arn, "stopped") +} + +func (h *dmsHarness) tableStats(t *testing.T, arn string) map[string]dmstypes.TableStatistics { + t.Helper() + + out, err := h.dms.DescribeTableStatistics(t.Context(), &databasemigrationservice.DescribeTableStatisticsInput{ + ReplicationTaskArn: aws.String(arn), + }) + require.NoError(t, err) + + m := map[string]dmstypes.TableStatistics{} + for _, s := range out.TableStatistics { + m[aws.ToString(s.SchemaName)+"."+aws.ToString(s.TableName)] = s + } + + return m +} + +func queryRows(t *testing.T, db *sql.DB, q string) [][]string { + t.Helper() + + rows, err := db.QueryContext(t.Context(), q) + if err != nil { + return nil + } + defer rows.Close() + + cols, _ := rows.Columns() + + var out [][]string + + for rows.Next() { + vals := make([]sql.NullString, len(cols)) + ptrs := make([]any, len(cols)) + + for i := range vals { + ptrs[i] = &vals[i] + } + + if rows.Scan(ptrs...) != nil { + return nil + } + + r := make([]string, len(cols)) + for i, v := range vals { + r[i] = v.String + } + + out = append(out, r) + } + + if rows.Err() != nil { + return nil + } + + return out +} + +func awaitRows(t *testing.T, db *sql.DB, q string, want [][]string) { + t.Helper() + + var got [][]string + + require.Eventually(t, func() bool { + got = queryRows(t, db, q) + + return assert.ObjectsAreEqual(want, got) + }, dmsDockerWait, dmsPoll, "query %q want %v last %v", q, want, got) +} + +const ( + schemaRename = `{"rule-type":"transformation","rule-id":"2","rule-name":"2","rule-target":"schema",` + + `"rule-action":"rename","object-locator":{"schema-name":"%s"},"value":"%s"}` + selectItems = `{"rule-type":"selection","rule-id":"1","rule-name":"1","rule-action":"include",` + + `"object-locator":{"schema-name":"%s","table-name":"items"}}` +) + +func mappingRules(rules ...string) string { return `{"rules":[` + strings.Join(rules, ",") + `]}` } + +func renameMapping() string { + return mappingRules( + fmt.Sprintf(selectItems, "appdb"), + fmt.Sprintf(schemaRename, "appdb", "dst"), + `{"rule-type":"transformation","rule-id":"3","rule-name":"3","rule-target":"table","rule-action":"rename",`+ + `"object-locator":{"schema-name":"appdb","table-name":"items"},"value":"items_copy"}`) +} + +func pgRenameMapping() string { + return mappingRules( + fmt.Sprintf(selectItems, "public"), + fmt.Sprintf(schemaRename, "public", "appdb"), + `{"rule-type":"transformation","rule-id":"3","rule-name":"3","rule-target":"column",`+ + `"rule-action":"remove-column","object-locator":{"schema-name":"public","table-name":"items",`+ + `"column-name":"note"}}`) +} + +func TestDMSDockerMySQLToPostgres(t *testing.T) { + t.Parallel() + requireDMSDocker(t, "mysql:8.4", "postgres:16") + + h := newDMSHarness(t) + srcEP, tgtEP := h.createDB(t, "src", "mysql", "8.4"), h.createDB(t, "tgt", "postgres", "16") + src, tgt := openDB(t, "mysql", srcEP), openDB(t, "postgres", tgtEP) + + mustExec(t, src, + "CREATE TABLE items (id INT PRIMARY KEY, name VARCHAR(40), qty INT UNSIGNED)", + "CREATE TABLE ignored (id INT PRIMARY KEY)", + "INSERT INTO items VALUES (1,'alpha',10),(2,'beta',20)") + + ri := h.instance(t) + srcARN := h.endpoint(t, "src-ep", dmstypes.ReplicationEndpointTypeValueSource, "mysql", &srcEP, nil) + tgtARN := h.endpoint(t, "tgt-ep", dmstypes.ReplicationEndpointTypeValueTarget, "postgres", &tgtEP, nil) + + for _, ep := range []string{srcARN, tgtARN} { + out, err := h.dms.TestConnection(t.Context(), &databasemigrationservice.TestConnectionInput{ + ReplicationInstanceArn: aws.String(ri), EndpointArn: aws.String(ep), + }) + require.NoError(t, err) + assert.Equal(t, "successful", aws.ToString(out.Connection.Status)) + } + + bad := srcEP + bad.port = 1 + badARN := h.endpoint(t, "bad-ep", dmstypes.ReplicationEndpointTypeValueSource, "mysql", &bad, nil) + conn, err := h.dms.TestConnection(t.Context(), &databasemigrationservice.TestConnectionInput{ + ReplicationInstanceArn: aws.String(ri), EndpointArn: aws.String(badARN), + }) + require.NoError(t, err) + assert.Equal(t, "failed", aws.ToString(conn.Connection.Status)) + assert.Contains(t, aws.ToString(conn.Connection.LastFailureMessage), "connect") + assert.NotContains(t, aws.ToString(conn.Connection.LastFailureMessage), dmsMasterPass) + + taskARN := h.task(t, "mysql-to-pg", srcARN, tgtARN, ri, "full-load-and-cdc", renameMapping()) + + running := h.awaitStatus(t, taskARN, "running") + require.NotNil(t, running.ReplicationTaskStats) + + awaitRows(t, tgt, "SELECT id::text, name, qty::text FROM dst.items_copy ORDER BY id", + [][]string{{"1", "alpha", "10"}, {"2", "beta", "20"}}) + + require.Eventually(t, func() bool { + return aws.ToString(h.tableStats(t, taskARN)["appdb.items"].TableState) == "Table completed" + }, dmsDockerWait, dmsPoll) + + st := h.tableStats(t, taskARN)["appdb.items"] + assert.EqualValues(t, 2, st.FullLoadRows) + assert.NotContains(t, h.tableStats(t, taskARN), "appdb.ignored") + + stats := h.describe(t, taskARN).ReplicationTaskStats + require.NotNil(t, stats) + assert.EqualValues(t, 1, stats.TablesLoaded) + assert.EqualValues(t, 100, stats.FullLoadProgressPercent) + assert.Positive(t, stats.ElapsedTimeMillis) + + mustExec(t, src, + "INSERT INTO items VALUES (3,'gamma',30)", + "UPDATE items SET name='ALPHA', qty=11 WHERE id=1", + "DELETE FROM items WHERE id=2") + awaitRows(t, tgt, "SELECT id::text, name, qty::text FROM dst.items_copy ORDER BY id", + [][]string{{"1", "ALPHA", "11"}, {"3", "gamma", "30"}}) + + require.Eventually(t, func() bool { + s := h.tableStats(t, taskARN)["appdb.items"] + + return s.Inserts == 1 && s.Updates == 1 && s.Deletes == 1 + }, dmsDockerWait, dmsPoll) + + _, err = h.dms.StopReplicationTask(t.Context(), &databasemigrationservice.StopReplicationTaskInput{ + ReplicationTaskArn: aws.String(taskARN), + }) + require.NoError(t, err) + + stopped := h.awaitStatus(t, taskARN, "stopped") + assert.Equal(t, "Stop Reason NORMAL", aws.ToString(stopped.StopReason)) + + mustExec(t, src, "INSERT INTO items VALUES (4,'late',40)") + assert.Equal(t, [][]string{{"1", "ALPHA", "11"}, {"3", "gamma", "30"}}, + queryRows(t, tgt, "SELECT id::text, name, qty::text FROM dst.items_copy ORDER BY id")) +} + +func TestDMSDockerPostgresToMySQL(t *testing.T) { + t.Parallel() + requireDMSDocker(t, "mysql:8.4", "postgres:16") + + h := newDMSHarness(t) + h.logicalGroup(t, "pg-logical") + + srcEP, tgtEP := h.createDB(t, "src", "postgres", "16", "pg-logical"), h.createDB(t, "tgt", "mysql", "8.4") + src, tgt := openDB(t, "postgres", srcEP), openDB(t, "mysql", tgtEP) + + mustExec(t, src, + "CREATE TABLE items (id integer PRIMARY KEY, name text, note text, price numeric(8,2), seen timestamp)", + "INSERT INTO items VALUES (1,'alpha','n1',1.50,'2024-01-02 03:04:05')") + + ri := h.instance(t) + srcARN := h.endpoint(t, "src-ep", dmstypes.ReplicationEndpointTypeValueSource, "postgres", &srcEP, nil) + tgtARN := h.endpoint(t, "tgt-ep", dmstypes.ReplicationEndpointTypeValueTarget, "mysql", &tgtEP, nil) + taskARN := h.task(t, "pg-to-mysql", srcARN, tgtARN, ri, "full-load-and-cdc", pgRenameMapping()) + + h.awaitStatus(t, taskARN, "running") + q := "SELECT id, name, price, seen FROM appdb.items ORDER BY id" + awaitRows(t, tgt, q, [][]string{{"1", "alpha", "1.50", "2024-01-02 03:04:05.000000"}}) + + cols := queryRows(t, tgt, "SELECT column_name FROM information_schema.columns "+ + "WHERE table_schema='appdb' AND table_name='items' ORDER BY ordinal_position") + assert.Equal(t, [][]string{{"id"}, {"name"}, {"price"}, {"seen"}}, cols) + + mustExec(t, src, + "INSERT INTO items VALUES (2,'beta','n2',2.25,'2024-02-03 04:05:06')", + "UPDATE items SET name='ALPHA' WHERE id=1", + "DELETE FROM items WHERE id=1") + awaitRows(t, tgt, q, [][]string{{"2", "beta", "2.25", "2024-02-03 04:05:06.000000"}}) + + require.Eventually(t, func() bool { + s := h.tableStats(t, taskARN)["public.items"] + + return s.Inserts == 1 && s.Updates == 1 && s.Deletes == 1 + }, dmsDockerWait, dmsPoll) + + _, err := h.dms.StopReplicationTask(t.Context(), &databasemigrationservice.StopReplicationTaskInput{ + ReplicationTaskArn: aws.String(taskARN), + }) + require.NoError(t, err) + h.awaitStatus(t, taskARN, "stopped") + + _, err = h.dms.DeleteReplicationTask(t.Context(), &databasemigrationservice.DeleteReplicationTaskInput{ + ReplicationTaskArn: aws.String(taskARN), + }) + require.NoError(t, err) + require.Eventually(t, func() bool { + return len(queryRows(t, src, "SELECT slot_name FROM pg_replication_slots")) == 0 + }, time.Minute, dmsPoll) +} + +func tableMapping(table string) string { + return `{"rules":[{"rule-type":"selection","rule-id":"1","rule-name":"1",` + + `"object-locator":{"schema-name":"appdb","table-name":"` + table + `"},"rule-action":"include"}]}` +} + +func TestDMSDockerMySQLToInProcessTargets(t *testing.T) { + t.Parallel() + requireDMSDocker(t, "mysql:8.4") + + h := newDMSHarness(t) + srcEP := h.createDB(t, "src", "mysql", "8.4") + src := openDB(t, "mysql", srcEP) + + for _, tbl := range []string{"s3_full", "s3_cdc", "kin", "ddb"} { + mustExec(t, src, + "CREATE TABLE "+tbl+" (id INT PRIMARY KEY, name VARCHAR(40))", + "INSERT INTO "+tbl+" VALUES (1,'alpha'),(2,'be,ta')") + } + + ri := h.instance(t) + srcARN := h.endpoint(t, "src-ep", dmstypes.ReplicationEndpointTypeValueSource, "mysql", &srcEP, nil) + + s3c := s3.NewFromConfig(h.cfg, func(o *s3.Options) { o.UsePathStyle = true }) + _, err := s3c.CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("dms-out")}) + require.NoError(t, err) + + getObject := func(key string) (string, bool) { + out, gerr := s3c.GetObject(t.Context(), &s3.GetObjectInput{Bucket: aws.String("dms-out"), Key: aws.String(key)}) + if gerr != nil { + return "", false + } + defer out.Body.Close() + + b, _ := io.ReadAll(out.Body) + + return string(b), true + } + + s3Endpoint := func(id, folder string) string { + return h.endpoint(t, id, dmstypes.ReplicationEndpointTypeValueTarget, "s3", nil, + func(in *databasemigrationservice.CreateEndpointInput) { + in.S3Settings = &dmstypes.S3Settings{ + BucketName: aws.String("dms-out"), BucketFolder: aws.String(folder), + ServiceAccessRoleArn: aws.String(dmsRoleARN), + } + }) + } + + t.Run("s3 csv", func(t *testing.T) { + t.Parallel() + + arn := h.task(t, "to-s3", srcARN, s3Endpoint("s3-ep", "full"), ri, "full-load", tableMapping("s3_full")) + + done := h.awaitStatus(t, arn, "stopped") + assert.Equal(t, "Stop Reason FULL_LOAD_ONLY_FINISHED", aws.ToString(done.StopReason)) + + body, ok := getObject("full/appdb/s3_full/LOAD00000001.csv") + require.True(t, ok) + assert.Equal(t, "1,alpha\n2,\"be,ta\"\n", body) + assert.EqualValues(t, 2, h.tableStats(t, arn)["appdb.s3_full"].FullLoadRows) + }) + + t.Run("s3 cdc csv", func(t *testing.T) { + t.Parallel() + + arn := h.task(t, "to-s3-cdc", srcARN, s3Endpoint("s3-cdc-ep", "cdc"), ri, "full-load-and-cdc", + tableMapping("s3_cdc")) + h.awaitStatus(t, arn, "running") + require.Eventually(t, func() bool { + _, ok := getObject("cdc/appdb/s3_cdc/LOAD00000001.csv") + + return ok + }, dmsDockerWait, dmsPoll) + + mustExec(t, src, "INSERT INTO s3_cdc VALUES (3,'gamma')", "DELETE FROM s3_cdc WHERE id=1") + + collect := func() string { + out, lerr := s3c.ListObjectsV2(t.Context(), &s3.ListObjectsV2Input{ + Bucket: aws.String("dms-out"), Prefix: aws.String("cdc/appdb/s3_cdc/"), + }) + require.NoError(t, lerr) + + var all strings.Builder + + for _, o := range out.Contents { + if !strings.Contains(aws.ToString(o.Key), "LOAD") { + b, _ := getObject(aws.ToString(o.Key)) + all.WriteString(b) + } + } + + return all.String() + } + + require.Eventually(t, func() bool { + all := collect() + + return strings.Contains(all, "I,3,gamma\n") && strings.Contains(all, "D,1,alpha\n") + }, dmsDockerWait, dmsPoll) + + h.stop(t, arn) + }) + + t.Run("kinesis json", func(t *testing.T) { + t.Parallel() + + kc := kinesis.NewFromConfig(h.cfg) + _, kerr := kc.CreateStream(t.Context(), &kinesis.CreateStreamInput{ + StreamName: aws.String("dms-stream"), ShardCount: aws.Int32(1), + }) + require.NoError(t, kerr) + + var desc *kinesis.DescribeStreamSummaryOutput + + require.Eventually(t, func() bool { + var derr error + + desc, derr = kc.DescribeStreamSummary(t.Context(), &kinesis.DescribeStreamSummaryInput{ + StreamName: aws.String("dms-stream"), + }) + + return derr == nil && desc.StreamDescriptionSummary.StreamStatus == "ACTIVE" + }, time.Minute, dmsPoll) + + tgt := h.endpoint(t, "kin-ep", dmstypes.ReplicationEndpointTypeValueTarget, "kinesis", nil, + func(in *databasemigrationservice.CreateEndpointInput) { + in.KinesisSettings = &dmstypes.KinesisSettings{ + StreamArn: desc.StreamDescriptionSummary.StreamARN, ServiceAccessRoleArn: aws.String(dmsRoleARN), + MessageFormat: dmstypes.MessageFormatValueJsonUnformatted, + } + }) + arn := h.task(t, "to-kinesis", srcARN, tgt, ri, "full-load-and-cdc", tableMapping("kin")) + h.awaitStatus(t, arn, "running") + mustExec(t, src, "INSERT INTO kin VALUES (9,'omega')") + + it, kerr := kc.GetShardIterator(t.Context(), &kinesis.GetShardIteratorInput{ + StreamName: aws.String("dms-stream"), ShardId: aws.String("shardId-000000000000"), + ShardIteratorType: "TRIM_HORIZON", + }) + require.NoError(t, kerr) + + var got []dmsKinesisRec + + iter := it.ShardIterator + + require.Eventually(t, func() bool { + out, gerr := kc.GetRecords(t.Context(), &kinesis.GetRecordsInput{ShardIterator: iter}) + require.NoError(t, gerr) + + iter = out.NextShardIterator + + for _, r := range out.Records { + var x dmsKinesisRec + require.NoError(t, json.Unmarshal(r.Data, &x)) + got = append(got, x) + } + + return len(got) >= 3 + }, dmsDockerWait, dmsPoll) + + ops := map[string]int{} + for _, r := range got { + assert.Equal(t, "data", r.Metadata["record-type"]) + assert.Equal(t, "appdb", r.Metadata["schema-name"]) + assert.Equal(t, "kin", r.Metadata["table-name"]) + ops[r.Metadata["operation"].(string)]++ + } + + assert.Equal(t, 2, ops["load"]) + assert.Equal(t, 1, ops["insert"]) + assert.EqualValues(t, 9, lastWithOp(got, "insert").Data["id"]) + + h.stop(t, arn) + }) + + t.Run("dynamodb", func(t *testing.T) { + t.Parallel() + + tgt := h.endpoint(t, "ddb-ep", dmstypes.ReplicationEndpointTypeValueTarget, "dynamodb", nil, + func(in *databasemigrationservice.CreateEndpointInput) { + in.DynamoDbSettings = &dmstypes.DynamoDbSettings{ServiceAccessRoleArn: aws.String(dmsRoleARN)} + }) + arn := h.task(t, "to-ddb", srcARN, tgt, ri, "full-load", tableMapping("ddb")) + h.awaitStatus(t, arn, "stopped") + + out, derr := dynamodb.NewFromConfig(h.cfg).Scan(t.Context(), &dynamodb.ScanInput{TableName: aws.String("ddb")}) + require.NoError(t, derr) + assert.Equal(t, int32(2), out.Count) + }) +} + +type dmsKinesisRec struct { + Data map[string]any `json:"data"` + Metadata map[string]any `json:"metadata"` +} + +func lastWithOp(recs []dmsKinesisRec, op string) dmsKinesisRec { + var out dmsKinesisRec + + for _, r := range recs { + if r.Metadata["operation"] == op { + out = r + } + } + + return out +} + +func TestDMSDockerPostgresCDCNeedsLogicalReplication(t *testing.T) { + t.Parallel() + requireDMSDocker(t, "postgres:16") + + h := newDMSHarness(t) + srcEP := h.createDB(t, "src", "postgres", "16") + mustExec(t, openDB(t, "postgres", srcEP), "CREATE TABLE items (id integer PRIMARY KEY)") + + s3c := s3.NewFromConfig(h.cfg, func(o *s3.Options) { o.UsePathStyle = true }) + _, err := s3c.CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("dms-out")}) + require.NoError(t, err) + + ri := h.instance(t) + srcARN := h.endpoint(t, "src-ep", dmstypes.ReplicationEndpointTypeValueSource, "postgres", &srcEP, nil) + tgt := h.endpoint(t, "s3-ep", dmstypes.ReplicationEndpointTypeValueTarget, "s3", nil, + func(in *databasemigrationservice.CreateEndpointInput) { + in.S3Settings = &dmstypes.S3Settings{ + BucketName: aws.String("dms-out"), ServiceAccessRoleArn: aws.String(dmsRoleARN), + } + }) + arn := h.task(t, "pg-cdc", srcARN, tgt, ri, "full-load-and-cdc", mappingRules(fmt.Sprintf(selectItems, "public"))) + + var task dmstypes.ReplicationTask + + require.Eventually(t, func() bool { + task = h.describe(t, arn) + + return aws.ToString(task.Status) == "failed" + }, dmsDockerWait, dmsPoll) + + assert.Contains(t, aws.ToString(task.LastFailureMessage), "wal_level is replica, logical is required") + assert.Equal(t, "Stop Reason FATAL_ERROR", aws.ToString(task.StopReason)) +} diff --git a/internal/app/dms_stores.go b/internal/app/dms_stores.go new file mode 100644 index 0000000000..5a9ded327e --- /dev/null +++ b/internal/app/dms_stores.go @@ -0,0 +1,165 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" + "github.com/blackbirdworks/gopherstack/pkgs/service" + dmsbackend "github.com/blackbirdworks/gopherstack/services/dms" + ddbbackend "github.com/blackbirdworks/gopherstack/services/dynamodb" + kinesisbackend "github.com/blackbirdworks/gopherstack/services/kinesis" + s3backend "github.com/blackbirdworks/gopherstack/services/s3" +) + +// wireRDSIntegrations connects the RDS Data API and DMS to the services they read and write. +func wireRDSIntegrations(byName map[string]service.Registerable) { + wireRDSData(byName["RDSData"], byName["RDS"], byName["SecretsManager"]) + wireDMSStores(byName) +} + +// wireDMSStores lets DMS replication tasks write into the in-process S3, Kinesis and DynamoDB backends. +func wireDMSStores(byName map[string]service.Registerable) { + dmsH, ok := byName["DMS"].(*dmsbackend.Handler) + if !ok { + return + } + + var stores dmsengine.Stores + + if h, hok := byName["S3"].(*s3backend.S3Handler); hok { + if bk, bok := h.Backend.(*s3backend.InMemoryBackend); bok { + stores.Objects = &dmsObjects{bk: bk} + } + } + + if h, hok := byName["Kinesis"].(*kinesisbackend.Handler); hok { + if bk, bok := h.Backend.(*kinesisbackend.InMemoryBackend); bok { + stores.Streams = &dmsStreams{bk: bk} + } + } + + if h, hok := byName["DynamoDB"].(*ddbbackend.DynamoDBHandler); hok { + if bk, bok := h.Backend.(*ddbbackend.InMemoryDB); bok { + stores.Dynamo = &dmsDynamo{db: bk} + } + } + + dmsH.Backend.SetStores(stores) +} + +type dmsObjects struct{ bk *s3backend.InMemoryBackend } + +func (o *dmsObjects) PutObject(ctx context.Context, bucket, key string, body []byte) error { + _, err := o.bk.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(body), + }) + + return err +} + +func (o *dmsObjects) CheckBucket(ctx context.Context, bucket string) error { + _, err := o.bk.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}) + + return err +} + +type dmsStreams struct { + bk *kinesisbackend.InMemoryBackend +} + +func (s *dmsStreams) PutRecord(ctx context.Context, stream, partitionKey string, data []byte) error { + _, err := s.bk.PutRecord(ctx, &kinesisbackend.PutRecordInput{ + StreamName: stream, PartitionKey: partitionKey, Data: data, + }) + + return err +} + +func (s *dmsStreams) CheckStream(ctx context.Context, stream string) error { + _, err := s.bk.DescribeStream(ctx, &kinesisbackend.DescribeStreamInput{StreamName: stream}) + + return err +} + +type dmsDynamo struct{ db *ddbbackend.InMemoryDB } + +var errDynamoKeys = errors.New("DynamoDB target tables take one or two key attributes") + +func attrValue(v dmsengine.DynamoValue) ddbtypes.AttributeValue { + switch v.Type { + case "N": + return &ddbtypes.AttributeValueMemberN{Value: v.S} + case "B": + return &ddbtypes.AttributeValueMemberB{Value: v.B} + default: + return &ddbtypes.AttributeValueMemberS{Value: v.S} + } +} + +func attrMap(m map[string]dmsengine.DynamoValue) map[string]ddbtypes.AttributeValue { + out := make(map[string]ddbtypes.AttributeValue, len(m)) + for k, v := range m { + out[k] = attrValue(v) + } + + return out +} + +func (d *dmsDynamo) CreateTable(ctx context.Context, name string, keys []dmsengine.DynamoKey) error { + if len(keys) == 0 || len(keys) > 2 { + return errDynamoKeys + } + + schema := make([]ddbtypes.KeySchemaElement, len(keys)) + defs := make([]ddbtypes.AttributeDefinition, len(keys)) + + for i, k := range keys { + kt, st := ddbtypes.KeyTypeHash, ddbtypes.ScalarAttributeTypeS + if i == 1 { + kt = ddbtypes.KeyTypeRange + } + + if k.Number { + st = ddbtypes.ScalarAttributeTypeN + } + + schema[i] = ddbtypes.KeySchemaElement{AttributeName: aws.String(k.Name), KeyType: kt} + defs[i] = ddbtypes.AttributeDefinition{AttributeName: aws.String(k.Name), AttributeType: st} + } + + _, err := d.db.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String(name), KeySchema: schema, AttributeDefinitions: defs, + BillingMode: ddbtypes.BillingModePayPerRequest, + }) + if err != nil { + return fmt.Errorf("create DynamoDB table %s: %w", name, err) + } + + return nil +} + +func (d *dmsDynamo) DropTable(ctx context.Context, name string) error { + _, err := d.db.DeleteTable(ctx, &dynamodb.DeleteTableInput{TableName: aws.String(name)}) + + return err +} + +func (d *dmsDynamo) PutItem(ctx context.Context, table string, item map[string]dmsengine.DynamoValue) error { + _, err := d.db.PutItem(ctx, &dynamodb.PutItemInput{TableName: aws.String(table), Item: attrMap(item)}) + + return err +} + +func (d *dmsDynamo) DeleteItem(ctx context.Context, table string, key map[string]dmsengine.DynamoValue) error { + _, err := d.db.DeleteItem(ctx, &dynamodb.DeleteItemInput{TableName: aws.String(table), Key: attrMap(key)}) + + return err +} diff --git a/internal/app/dynamodb_kinesis_wiring_test.go b/internal/app/dynamodb_kinesis_wiring_test.go index fb8a12a74b..3393a410f1 100644 --- a/internal/app/dynamodb_kinesis_wiring_test.go +++ b/internal/app/dynamodb_kinesis_wiring_test.go @@ -39,7 +39,7 @@ func TestInitializeServices_DynamoDBKinesisWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/e2e_bench_test.go b/internal/app/e2e_bench_test.go index cd6957a64c..87939a7e1a 100644 --- a/internal/app/e2e_bench_test.go +++ b/internal/app/e2e_bench_test.go @@ -401,6 +401,23 @@ func BenchmarkE2E_DynamoDB(b *testing.B) { } }) + b.Run("scan_filter_fn_10k", func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + _, err := c.Scan(ctx, &sdkdynamodb.ScanInput{ + TableName: aws.String(e2eDDBTable), + FilterExpression: aws.String("begins_with(#n, :p) AND val > :v AND attribute_exists(pk)"), + ExpressionAttributeNames: map[string]string{"#n": "name"}, + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":p": &ddbtypes.AttributeValueMemberS{Value: "name 9"}, + ":v": &ddbtypes.AttributeValueMemberN{Value: "9000"}, + }, + }) + require.NoError(b, err) + } + }) + b.Run("batchwrite_25", func(b *testing.B) { b.ReportAllocs() diff --git a/internal/app/ec2_ecs_region_wiring_test.go b/internal/app/ec2_ecs_region_wiring_test.go index 552705470f..c46f7df877 100644 --- a/internal/app/ec2_ecs_region_wiring_test.go +++ b/internal/app/ec2_ecs_region_wiring_test.go @@ -51,7 +51,7 @@ func TestInitializeServices_EC2ECSRegionWiring(t *testing.T) { cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), PortAlloc: portAlloc, }) require.NoError(t, err) diff --git a/internal/app/ec2_imds_test.go b/internal/app/ec2_imds_test.go index 607f3fc413..5d1ce61124 100644 --- a/internal/app/ec2_imds_test.go +++ b/internal/app/ec2_imds_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ec2backend "github.com/blackbirdworks/gopherstack/services/ec2" ) @@ -206,7 +207,7 @@ func rawIMDSGet(t *testing.T, url string) int { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() @@ -261,7 +262,7 @@ func TestEC2IMDSTerminateClosesListener(t *testing.T) { req, rErr := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) require.NoError(t, rErr) - resp, dErr := http.DefaultClient.Do(req) + resp, dErr := testclient.New(t).Do(req) if dErr != nil { return true } diff --git a/internal/app/ecs_efs_wiring_test.go b/internal/app/ecs_efs_wiring_test.go new file mode 100644 index 0000000000..6e3ee39f0e --- /dev/null +++ b/internal/app/ecs_efs_wiring_test.go @@ -0,0 +1,59 @@ +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + efsbackend "github.com/blackbirdworks/gopherstack/services/efs" +) + +func TestECSEFSResolverWiring(t *testing.T) { + t.Parallel() + + byName := newWiredServices(t, &CLI{AccountID: "000000000000", Region: "us-east-1", Port: ":8129"}) + efsBk := byName["EFS"].(*efsbackend.Handler).Backend + efsBk.SetStorageRoot(t.TempDir()) + + fs, err := efsBk.CreateFileSystem(t.Context(), efsbackend.CreateFileSystemRequest{CreationToken: "ecs-wire"}) + require.NoError(t, err) + + ap, err := efsBk.CreateAccessPoint(t.Context(), efsbackend.CreateAccessPointRequest{ + FileSystemID: fs.FileSystemID, + PosixUser: &efsbackend.PosixUser{UID: 10, GID: 20}, + }) + require.NoError(t, err) + + tests := []struct { + byName map[string]service.Registerable + name string + ap string + wantErr bool + }{ + {name: "access_point", byName: byName, ap: ap.AccessPointID}, + {name: "unknown_access_point", byName: byName, ap: "fsap-0000000000000000", wantErr: true}, + {name: "efs_missing", byName: map[string]service.Registerable{}, ap: ap.AccessPointID, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + m, rerr := (&ecsEFS{byName: tt.byName}).ResolveVolume(t.Context(), "us-east-1", fs.FileSystemID, tt.ap, "") + if tt.wantErr { + require.Error(t, rerr) + + return + } + + require.NoError(t, rerr) + assert.True(t, m.HasPosixUser) + assert.EqualValues(t, 10, m.UID) + assert.NotEmpty(t, m.HostDir) + }) + } + + assert.NotNil(t, ecsBackendOf(byName), "ecs backend present for wiring") +} diff --git a/internal/app/ecs_managed_scaling_test.go b/internal/app/ecs_managed_scaling_test.go index 4dad52ee8a..41769720aa 100644 --- a/internal/app/ecs_managed_scaling_test.go +++ b/internal/app/ecs_managed_scaling_test.go @@ -27,7 +27,7 @@ func TestWireECSManagedScaling_ScalesGroupAndPlacesTasks(t *testing.T) { cli := &CLI{AccountID: "000000000000"} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices( + services, err := initializeServicesT(t, &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, ) require.NoError(t, err) diff --git a/internal/app/ecs_runtime.go b/internal/app/ecs_runtime.go index 7acaeac0ba..9f06a0c3e4 100644 --- a/internal/app/ecs_runtime.go +++ b/internal/app/ecs_runtime.go @@ -3,17 +3,106 @@ package app import ( "context" "errors" + "io" + "sync" "time" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/labstack/echo/v5" "github.com/blackbirdworks/gopherstack/pkgs/service" ecsbackend "github.com/blackbirdworks/gopherstack/services/ecs" + efsbackend "github.com/blackbirdworks/gopherstack/services/efs" ebbackend "github.com/blackbirdworks/gopherstack/services/eventbridge" + s3backend "github.com/blackbirdworks/gopherstack/services/s3" + smbackend "github.com/blackbirdworks/gopherstack/services/secretsmanager" + ssmbackend "github.com/blackbirdworks/gopherstack/services/ssm" stsbackend "github.com/blackbirdworks/gopherstack/services/sts" ) -var errECSSTSUnavailable = errors.New("sts backend unavailable") +var ( + errECSSTSUnavailable = errors.New("sts backend unavailable") + errECSSecretsUnavailable = errors.New("secrets backend unavailable") + errECSSSMUnavailable = errors.New("ssm backend unavailable") + errECSS3Unavailable = errors.New("s3 backend unavailable") +) + +// ecsSecrets adapts Secrets Manager and SSM to ecs.SecretsResolver, resolved at call time. +type ecsSecrets struct { + byName map[string]service.Registerable +} + +func (a *ecsSecrets) SecretString( + ctx context.Context, + region, secretID, versionStage, versionID string, +) (string, error) { + h, ok := a.byName["SecretsManager"].(*smbackend.Handler) + if !ok { + return "", errECSSecretsUnavailable + } + + bk, ok := h.Backend.(*smbackend.InMemoryBackend) + if !ok { + return "", errECSSecretsUnavailable + } + + out, err := bk.GetSecretValue(smbackend.WithRegion(ctx, region), &smbackend.GetSecretValueInput{ + SecretID: secretID, VersionStage: versionStage, VersionID: versionID, + }) + if err != nil { + return "", err + } + + return out.SecretString, nil +} + +func (a *ecsSecrets) ParameterValue(ctx context.Context, region, name string) (string, error) { + h, ok := a.byName["SSM"].(*ssmbackend.Handler) + if !ok { + return "", errECSSecretsUnavailable + } + + bk, ok := h.Backend.(*ssmbackend.InMemoryBackend) + if !ok { + return "", errECSSecretsUnavailable + } + + out, err := bk.GetParameter(ssmbackend.WithRegion(ctx, region), &ssmbackend.GetParameterInput{ + Name: name, WithDecryption: true, + }) + if err != nil { + return "", err + } + + return out.Parameter.Value, nil +} + +// ecsEnvFiles adapts S3 to ecs.EnvFileStore, resolved at call time. +type ecsEnvFiles struct { + byName map[string]service.Registerable +} + +func (a *ecsEnvFiles) ObjectBytes(ctx context.Context, bucket, key string) ([]byte, error) { + h, ok := a.byName["S3"].(*s3backend.S3Handler) + if !ok { + return nil, errECSS3Unavailable + } + + bk, ok := h.Backend.(*s3backend.InMemoryBackend) + if !ok { + return nil, errECSS3Unavailable + } + + out, err := bk.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}) + if err != nil { + return nil, err + } + + defer out.Body.Close() + + return io.ReadAll(out.Body) +} // ecsEventPublisher puts ECS state-change events on the default bus. EventBridge // is resolved at call time, never captured in Provider.Init. @@ -96,6 +185,10 @@ func wireECSRuntime(cli *CLI, byName map[string]service.Registerable) { bk.SetEventPublisher(&ecsEventPublisher{byName: byName}) bk.SetRoleCredentialIssuer(&ecsTaskCredentials{byName: byName}) + bk.SetSecretsResolver(&ecsSecrets{byName: byName}) + bk.SetEFSResolver(&ecsEFS{byName: byName}) + bk.SetEnvFileStore(&ecsEnvFiles{byName: byName}) + bk.SetExecSessionStarter(&ecsExecSessions{byName: byName}) if cli != nil && cli.Port != "" { bk.SetEndpointURL(lambdaEndpointURL(cli.Lambda.DockerHost, cli.Port)) @@ -115,3 +208,71 @@ func registerECSTaskEndpoints(e *echo.Echo, services []service.Registerable) { e.GET("/_gopherstack/ecs/metadata/:task/:container/*", h.ServeTaskMetadata) } } + +// ecsExecSessions adapts SSM Session Manager data channels to ecs.ExecSessionStarter. +type ecsExecSessions struct { + byName map[string]service.Registerable + once sync.Once +} + +func (a *ecsExecSessions) StartExecSession( + ctx context.Context, req ecsbackend.ExecSessionRequest, +) (ecsbackend.ExecSessionGrant, error) { + h, ok := a.byName["SSM"].(*ssmbackend.Handler) + if !ok { + return ecsbackend.ExecSessionGrant{}, errECSSSMUnavailable + } + + bk, ok := h.Backend.(*ssmbackend.InMemoryBackend) + if !ok { + return ecsbackend.ExecSessionGrant{}, errECSSSMUnavailable + } + + a.once.Do(func() { + if bk.ShellsEnabled() { + return + } + + if shells, err := ssmbackend.NewDockerShellOpener(); err == nil { + bk.SetShellOpener(shells) + } + }) + + out, err := bk.StartContainerSession(ssmbackend.WithRegion(ctx, req.Region), ssmbackend.ContainerSessionInput{ + Target: req.Target, ContainerID: req.ContainerID, Command: req.Command, StreamBase: req.StreamBase, + }) + if err != nil { + return ecsbackend.ExecSessionGrant{}, err + } + + return ecsbackend.ExecSessionGrant{ + SessionID: out.SessionID, StreamURL: out.StreamURL, TokenValue: out.TokenValue, + }, nil +} + +// ecsEFS adapts EFS to ecs.EFSResolver; the EFS backend is resolved at call time. +type ecsEFS struct { + byName map[string]service.Registerable +} + +func (a *ecsEFS) ResolveVolume( + _ context.Context, region, fsID, accessPointID, rootDirectory string, +) (ecsbackend.EFSVolumeMount, error) { + h, ok := a.byName["EFS"].(*efsbackend.Handler) + if !ok { + return ecsbackend.EFSVolumeMount{}, errLambdaEFSUnavailable + } + + if region == "" { + region = h.Backend.Region() + } + + m, err := h.Backend.ResolveVolumeMount(region, fsID, accessPointID, rootDirectory) + if err != nil { + return ecsbackend.EFSVolumeMount{}, err + } + + return ecsbackend.EFSVolumeMount{ + HostDir: m.HostDir, UID: m.UID, GID: m.GID, SecondaryGIDs: m.SecondaryGIDs, HasPosixUser: m.HasPosixUser, + }, nil +} diff --git a/internal/app/ecs_utilization_metrics_test.go b/internal/app/ecs_utilization_metrics_test.go index 1e633821a7..a5b33d544e 100644 --- a/internal/app/ecs_utilization_metrics_test.go +++ b/internal/app/ecs_utilization_metrics_test.go @@ -3,6 +3,7 @@ package app import ( "context" "testing" + "time" cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/stretchr/testify/require" @@ -51,6 +52,17 @@ func TestServiceMetrics_ECSUtilization(t *testing.T) { require.NoError(t, err) require.NoError(t, bk.StartTaskForService("util", "web", td.TaskDefinitionArn)) + require.Eventually(t, func() bool { + arns, listErr := bk.ListTasks("util") + if listErr != nil || len(arns) == 0 { + return false + } + + tasks, _, descErr := bk.DescribeTasks("util", arns) + + return descErr == nil && len(tasks) == 1 && tasks[0].LastStatus == "RUNNING" + }, metricWaitFor, 10*time.Millisecond) + ecsbackend.NewReconciler(bk).RunOnce(t.Context()) d := map[string]string{"ClusterName": "util", "ServiceName": "web"} diff --git a/internal/app/efs_subnet_addresses_test.go b/internal/app/efs_subnet_addresses_test.go index 74bd96f6d6..af1441e798 100644 --- a/internal/app/efs_subnet_addresses_test.go +++ b/internal/app/efs_subnet_addresses_test.go @@ -39,7 +39,7 @@ func TestInitializeServices_EFSNoFreeAddresses(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - services, err := initializeServices(newTestAppContext(t, 19800, 19900)) + services, err := initializeServicesT(t, newTestAppContext(t, 19800, 19900)) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/eks_ports.go b/internal/app/eks_ports.go new file mode 100644 index 0000000000..b8e3dee6d4 --- /dev/null +++ b/internal/app/eks_ports.go @@ -0,0 +1,19 @@ +package app + +import ( + "net/http" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + eksbackend "github.com/blackbirdworks/gopherstack/services/eks" +) + +// registerEKSEndpoints serves GET /_gopherstack/eks/clusters//ports. +func registerEKSEndpoints(e *echo.Echo, services []service.Registerable) { + for _, svc := range services { + if h, ok := svc.(*eksbackend.Handler); ok { + e.Add(http.MethodGet, "/_gopherstack/eks/clusters/:name/ports", h.ServePublishedPorts) + } + } +} diff --git a/internal/app/elb_ec2_acm_wiring_test.go b/internal/app/elb_ec2_acm_wiring_test.go index 3dfbac6da0..7c71636e53 100644 --- a/internal/app/elb_ec2_acm_wiring_test.go +++ b/internal/app/elb_ec2_acm_wiring_test.go @@ -40,7 +40,7 @@ func TestInitializeServices_ELBEC2ACMWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/elb_instance_health_test.go b/internal/app/elb_instance_health_test.go index 4fa5cdf4cc..b4e02e5c57 100644 --- a/internal/app/elb_instance_health_test.go +++ b/internal/app/elb_instance_health_test.go @@ -60,7 +60,7 @@ func TestInitializeServices_ELBInstanceHealthFromEC2(t *testing.T) { appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/elbv2_ec2_acm_wiring_test.go b/internal/app/elbv2_ec2_acm_wiring_test.go index d00cda3627..3ef8e8d621 100644 --- a/internal/app/elbv2_ec2_acm_wiring_test.go +++ b/internal/app/elbv2_ec2_acm_wiring_test.go @@ -43,7 +43,7 @@ func TestInitializeServices_ELBv2EC2ACMWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/elbv2_listeners.go b/internal/app/elbv2_listeners.go new file mode 100644 index 0000000000..2d78322969 --- /dev/null +++ b/internal/app/elbv2_listeners.go @@ -0,0 +1,43 @@ +package app + +import ( + "context" + + acmbackend "github.com/blackbirdworks/gopherstack/services/acm" +) + +// ELBv2Settings selects how ELBv2 listeners carry traffic. +type ELBv2Settings struct { + Listeners string `name:"listeners" env:"ELBV2_LISTENERS" default:"routed" enum:"routed,real" help:"ELBv2 data plane: routed (path/host routing on the emulator port) or real (a local socket per listener, TLS termination for HTTPS/TLS)."` //nolint:lll // config struct tags are intentionally verbose + ListenerHost string `name:"listener-host" env:"ELBV2_LISTENER_HOST" default:"127.0.0.1" help:"Bind address for real ELBv2 listeners. Requested ports below 1024 or already taken map to a port from the shared pool; see GET /_gopherstack/elbv2/_listeners."` //nolint:lll // config struct tags are intentionally verbose +} + +// GetELBv2Listeners returns the ELBv2 data-plane mode (elbv2.ListenerModeConfig). +func (c *CLI) GetELBv2Listeners() string { return c.ELBv2.Listeners } + +// GetELBv2ListenerHost returns the real-listener bind address (elbv2.ListenerModeConfig). +func (c *CLI) GetELBv2ListenerHost() string { return c.ELBv2.ListenerHost } + +// CertificateKeyPair returns the ACM certificate body with its chain and private key. +// IAM server certificates carry no private key in the emulator, so they cannot terminate TLS. +func (a *elbv2CertificateResolverAdapter) CertificateKeyPair(certARN string) ([]byte, []byte, bool) { + if a.acmBackend == nil { + return nil, nil, false + } + + return acmKeyPair(a.acmBackend, certARN) +} + +func acmKeyPair(b *acmbackend.InMemoryBackend, certARN string) ([]byte, []byte, bool) { + cert, err := b.DescribeCertificate(context.Background(), certARN) + if err != nil || cert.PrivateKey == "" || cert.CertificateBody == "" { + return nil, nil, false + } + + pemBytes := cert.CertificateBody + if cert.CertificateChain != "" { + pemBytes += "\n" + cert.CertificateChain + } + + return []byte(pemBytes), []byte(cert.PrivateKey), true +} diff --git a/internal/app/elbv2_real_listeners_test.go b/internal/app/elbv2_real_listeners_test.go new file mode 100644 index 0000000000..a4d66e3a21 --- /dev/null +++ b/internal/app/elbv2_real_listeners_test.go @@ -0,0 +1,161 @@ +package app + +import ( + "context" + "crypto/tls" + "crypto/x509" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + elbv2sdk "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/devtls" + "github.com/blackbirdworks/gopherstack/pkgs/service" + acmbackend "github.com/blackbirdworks/gopherstack/services/acm" + elbv2backend "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +func TestInitializeServices_ELBv2RealListenersTerminateACMCert(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode string + wantTLS bool + }{ + {name: "real mode terminates tls with imported acm cert", mode: "real", wantTLS: true}, + {name: "routed mode binds nothing", mode: "routed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cli := &CLI{AccountID: "000000000000", ELBv2: ELBv2Settings{Listeners: tt.mode}} + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServicesT(t, &service.AppContext{ + Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), + }) + require.NoError(t, err) + + byName := serviceByName(services) + elbH, ok := byName["ELBv2"].(*elbv2backend.Handler) + require.True(t, ok) + + t.Cleanup(func() { elbH.Shutdown(context.Background()) }) + + acmH, ok := byName["ACM"].(*acmbackend.Handler) + require.True(t, ok) + + certPEM, keyPEM, err := devtls.GenerateSelfSignedCertPEM("127.0.0.1") + require.NoError(t, err) + + cert, err := acmH.Backend.ImportCertificate(t.Context(), string(certPEM), string(keyPEM), "", "") + require.NoError(t, err) + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("from-target")) + })) + t.Cleanup(target.Close) + + e := echo.New() + registry := service.NewRegistry() + require.NoError(t, registry.Register(elbH)) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + awsCfg, err := awscfg.LoadDefaultConfig(t.Context(), awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", ""))) + require.NoError(t, err) + + client := elbv2sdk.NewFromConfig(awsCfg, func(o *elbv2sdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }) + + lb, err := client.CreateLoadBalancer( + t.Context(), + &elbv2sdk.CreateLoadBalancerInput{Name: aws.String("real-lb")}, + ) + require.NoError(t, err) + + tgOut, err := client.CreateTargetGroup(t.Context(), &elbv2sdk.CreateTargetGroupInput{ + Name: aws.String("real-tg"), TargetType: elbv2types.TargetTypeEnumIp, + Protocol: elbv2types.ProtocolEnumHttp, Port: aws.Int32(80), VpcId: aws.String("vpc-1"), + }) + require.NoError(t, err) + + tgArn := aws.ToString(tgOut.TargetGroups[0].TargetGroupArn) + host, port := splitHostPort(t, target.Listener.Addr().String()) + + _, err = client.RegisterTargets(t.Context(), &elbv2sdk.RegisterTargetsInput{ + TargetGroupArn: aws.String(tgArn), + Targets: []elbv2types.TargetDescription{{Id: aws.String(host), Port: aws.Int32(port)}}, + }) + require.NoError(t, err) + + lOut, err := client.CreateListener(t.Context(), &elbv2sdk.CreateListenerInput{ + LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn, Protocol: elbv2types.ProtocolEnumHttps, + Port: aws.Int32(443), SslPolicy: aws.String("ELBSecurityPolicy-TLS13-1-2-2021-06"), + Certificates: []elbv2types.Certificate{{CertificateArn: aws.String(cert.ARN)}}, + DefaultActions: []elbv2types.Action{ + {Type: elbv2types.ActionTypeEnumForward, TargetGroupArn: aws.String(tgArn)}, + }, + }) + require.NoError(t, err) + + be, ok := elbH.Backend.(*elbv2backend.InMemoryBackend) + require.True(t, ok) + + ep, bound := be.ListenerEndpoint(aws.ToString(lOut.Listeners[0].ListenerArn)) + require.Equal(t, tt.wantTLS, bound) + + if !tt.wantTLS { + return + } + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(certPEM)) + + tr := &http.Transport{ + DisableKeepAlives: true, + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + } + t.Cleanup(tr.CloseIdleConnections) + + resp, err := (&http.Client{Transport: tr}).Get("https://" + ep.Address + "/") + require.NoError(t, err) + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "from-target", string(body)) + }) + } +} + +func splitHostPort(t *testing.T, addr string) (string, int32) { + t.Helper() + + h, p, err := net.SplitHostPort(addr) + require.NoError(t, err) + + n, err := strconv.Atoi(p) + require.NoError(t, err) + + return h, int32(n) +} diff --git a/internal/app/elbv2_region_subnet_test.go b/internal/app/elbv2_region_subnet_test.go index 6d935201f2..f6e999d572 100644 --- a/internal/app/elbv2_region_subnet_test.go +++ b/internal/app/elbv2_region_subnet_test.go @@ -22,7 +22,7 @@ func TestELBv2DefaultSubnetZoneFollowsLoadBalancerRegion(t *testing.T) { cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), PortAlloc: portAlloc, }) require.NoError(t, err) diff --git a/internal/app/emrserverless_rgtapi_tags_wiring_test.go b/internal/app/emrserverless_rgtapi_tags_wiring_test.go index 7f32f00f33..121b6e6693 100644 --- a/internal/app/emrserverless_rgtapi_tags_wiring_test.go +++ b/internal/app/emrserverless_rgtapi_tags_wiring_test.go @@ -29,7 +29,7 @@ func TestInitializeServices_EmrServerlessTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/endpoint_strategy_test.go b/internal/app/endpoint_strategy_test.go index 0efd8f21f0..2ae3b4768e 100644 --- a/internal/app/endpoint_strategy_test.go +++ b/internal/app/endpoint_strategy_test.go @@ -40,7 +40,7 @@ func strategyServer(t *testing.T, mutate func(*CLI)) *echo.Echo { cli.portAlloc = setupPortAllocatorWithReservations(ctx, log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: ctx, PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/engines.go b/internal/app/engines.go index 765c5ed914..85d0bb7837 100644 --- a/internal/app/engines.go +++ b/internal/app/engines.go @@ -61,7 +61,14 @@ func engineEntries() []engineEntry { {func(c *CLI) *string { return &c.MWAAEngine }, "mwaa-engine", "MWAA_ENGINE", enginesStub}, {func(c *CLI) *string { return &c.MQEngine }, "mq-engine", "MQ_ENGINE", enginesStub}, {func(c *CLI) *string { return &c.EKSEngine }, "eks-engine", "EKS_ENGINE", enginesStub}, + {func(c *CLI) *string { return &c.GKEEngine }, "gke-engine", "GKE_ENGINE", enginesStub}, + { + func(c *CLI) *string { return &c.OCIContainerEngine.Engine }, + "oci-oke-engine", "OKE_ENGINE", enginesStub, + }, + {func(c *CLI) *string { return &c.OCIMySQL.Engine }, "oci-mysql-engine", "OCI_MYSQL_ENGINE", enginesStub}, {func(c *CLI) *string { return &c.RDSEngine }, "rds-engine", "RDS_ENGINE", enginesStub}, + {func(c *CLI) *string { return &c.DMSEngine }, "dms-engine", "DMS_ENGINE", enginesStub}, {func(c *CLI) *string { return &c.DocDBEngine }, "docdb-engine", "DOCDB_ENGINE", enginesStub}, {func(c *CLI) *string { return &c.RedshiftEngine }, "redshift-engine", "REDSHIFT_ENGINE", enginesStub}, {func(c *CLI) *string { return &c.NeptuneEngine }, "neptune-engine", "NEPTUNE_ENGINE", enginesStub}, @@ -72,6 +79,7 @@ func engineEntries() []engineEntry { }, {func(c *CLI) *string { return &c.SageMakerEngine }, "sagemaker-engine", "SAGEMAKER_ENGINE", enginesStub}, {func(c *CLI) *string { return &c.CodeBuild.Engine }, "codebuild-engine", "CODEBUILD_ENGINE", enginesStub}, + {func(c *CLI) *string { return &c.Glue.Engine }, "glue-engine", "GLUE_ENGINE", enginesStub}, } } diff --git a/internal/app/error_wire_guards_test.go b/internal/app/error_wire_guards_test.go index d96751f426..1f551b32b4 100644 --- a/internal/app/error_wire_guards_test.go +++ b/internal/app/error_wire_guards_test.go @@ -344,6 +344,7 @@ func errorProbeUnreachable() map[string]string { "AzureARM": "non-AWS", "AzureKeyVault": "non-AWS", "AzureMonitor": "non-AWS", + "AzureGraph": "non-AWS", "AzureEventGrid": "non-AWS", "AzureStorageVHost": "non-AWS", "AzureTable": "non-AWS", @@ -366,7 +367,13 @@ func errorProbeUnreachable() map[string]string { "GCPEventarc": "non-AWS", "GCPWorkflows": "non-AWS", "GCPDNS": "non-AWS", + "GCPDatastore": "non-AWS", "GCPCompute": "non-AWS", + "GCPContainer": "non-AWS", + "GCPIdentityToolkit": "non-AWS", + "GCPAPIGateway": "non-AWS", + "GCPBatch": "non-AWS", + "GCPResourceManager": "non-AWS", "GCPCloudBuild": "non-AWS", "AzureRedis": "non-AWS", "AzureSQL": "non-AWS", diff --git a/internal/app/eventbridge_settings_wiring_test.go b/internal/app/eventbridge_settings_wiring_test.go new file mode 100644 index 0000000000..1f7cfac133 --- /dev/null +++ b/internal/app/eventbridge_settings_wiring_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + ebbackend "github.com/blackbirdworks/gopherstack/services/eventbridge" +) + +func TestEventBridgeSettingsReachBackend(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + allow bool + wantErr bool + }{ + {name: "default rejects", allow: false, wantErr: true}, + {name: "flag allows", allow: true, wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cli := &CLI{AccountID: "000000000000", Region: "us-east-1"} + cli.EventBridge.AllowScheduleOnCustomBus = tt.allow + + services, err := initializeServicesT(t, + &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, + ) + require.NoError(t, err) + + var bk *ebbackend.InMemoryBackend + + for _, s := range services { + if h, ok := s.(*ebbackend.Handler); ok { + bk, _ = h.Backend.(*ebbackend.InMemoryBackend) + } + } + + require.NotNil(t, bk) + + _, err = bk.CreateEventBus(t.Context(), ebbackend.CreateEventBusParams{Name: "custom"}) + require.NoError(t, err) + + _, err = bk.PutRule(t.Context(), ebbackend.PutRuleInput{ + Name: "r", EventBusName: "custom", ScheduleExpression: "rate(5 minutes)", + }) + if tt.wantErr { + require.ErrorIs(t, err, ebbackend.ErrInvalidParameter) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/app/extensions_test.go b/internal/app/extensions_test.go index 7f9e542000..09700b9906 100644 --- a/internal/app/extensions_test.go +++ b/internal/app/extensions_test.go @@ -21,6 +21,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/extensions" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) type extTestService struct { @@ -111,7 +112,7 @@ func TestExtensionRoutingAndHooks(t *testing.T) { t.Cleanup(func() { cli.stopExtensions(context.WithoutCancel(t.Context())) }) get := func(path string) (int, string, http.Header) { - resp, gerr := http.Get(srv.URL + path) + resp, gerr := testclient.New(t).Get(srv.URL + path) require.NoError(t, gerr) defer resp.Body.Close() diff --git a/internal/app/firehose_kinesis_wiring_test.go b/internal/app/firehose_kinesis_wiring_test.go index 5b148a9369..6f3d230d8b 100644 --- a/internal/app/firehose_kinesis_wiring_test.go +++ b/internal/app/firehose_kinesis_wiring_test.go @@ -47,7 +47,7 @@ func TestInitializeServices_FirehoseKinesisSourceWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/firehose_redshift_wiring_test.go b/internal/app/firehose_redshift_wiring_test.go index 9ef415a952..2814dae6a6 100644 --- a/internal/app/firehose_redshift_wiring_test.go +++ b/internal/app/firehose_redshift_wiring_test.go @@ -40,7 +40,7 @@ func TestInitializeServices_FirehoseRedshiftWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/fis_target_resolution_test.go b/internal/app/fis_target_resolution_test.go index 7f616ecc75..e352dba002 100644 --- a/internal/app/fis_target_resolution_test.go +++ b/internal/app/fis_target_resolution_test.go @@ -70,7 +70,7 @@ func TestInitializeServices_FISTargetResolution(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - services, err := initializeServices(newTestAppContext(t, 19700, 19800)) + services, err := initializeServicesT(t, newTestAppContext(t, 19700, 19800)) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/gcpapigateway_port_reservation_test.go b/internal/app/gcpapigateway_port_reservation_test.go new file mode 100644 index 0000000000..f2ff2ecdd3 --- /dev/null +++ b/internal/app/gcpapigateway_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + gcpapigatewaybackend "github.com/blackbirdworks/gopherstack/services/gcpapigateway" +) + +func TestReserveFixedServicePorts_GCPAPIGateway(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", gcpapigatewaybackend.DefaultPort, 8000, 9000, true}, + {"port outside pool range", gcpapigatewaybackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{GCPAPIGateway: gcpapigatewaybackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/gcpbatch_port_reservation_test.go b/internal/app/gcpbatch_port_reservation_test.go new file mode 100644 index 0000000000..8f140bdbab --- /dev/null +++ b/internal/app/gcpbatch_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + gcpbatchbackend "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +func TestReserveFixedServicePorts_GCPBatch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", gcpbatchbackend.DefaultPort, 8000, 9000, true}, + {"port outside pool range", gcpbatchbackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{GCPBatch: gcpbatchbackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/gcpbatch_wiring.go b/internal/app/gcpbatch_wiring.go new file mode 100644 index 0000000000..51de140420 --- /dev/null +++ b/internal/app/gcpbatch_wiring.go @@ -0,0 +1,28 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + gcpbatchbackend "github.com/blackbirdworks/gopherstack/services/gcpbatch" + gcploggingbackend "github.com/blackbirdworks/gopherstack/services/gcplogging" + gcpsecretmanagerbackend "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" +) + +// wireGCPBatch connects Batch to Secret Manager (secret_variables) and Cloud Logging (CLOUD_LOGGING logs policy). +func wireGCPBatch(byName map[string]service.Registerable) { + bt, ok := byName["GCPBatch"].(*gcpbatchbackend.Handler) + if !ok { + return + } + + var svc gcpbatchbackend.Services + + if sm, isSM := byName["GCPSecretManager"].(*gcpsecretmanagerbackend.Handler); isSM { + svc.Secrets = cloudBuildSecrets{b: sm.Backend} + } + + if lg, isLG := byName["GCPLogging"].(*gcploggingbackend.Handler); isLG { + svc.Logs = lg.Backend + } + + bt.Backend.SetServices(svc) +} diff --git a/internal/app/gcpcloudbuild_wiring.go b/internal/app/gcpcloudbuild_wiring.go index 7449b0de5a..30d91c63f0 100644 --- a/internal/app/gcpcloudbuild_wiring.go +++ b/internal/app/gcpcloudbuild_wiring.go @@ -103,6 +103,9 @@ func wireGCPStorageConsumers(byName map[string]service.Registerable) { wireGCPSecretRotation(byName) wireGCPWorkflows(byName) wireGCPRun(byName) + wireGCPContainer(byName) + wireGCPIdentityToolkit(byName) + wireGCPBatch(byName) } // wireGCPCloudBuild connects Cloud Build to Cloud Storage, Pub/Sub, Cloud Logging, Secret Manager and the diff --git a/internal/app/gcpcontainer_port_reservation_test.go b/internal/app/gcpcontainer_port_reservation_test.go new file mode 100644 index 0000000000..28434b06ff --- /dev/null +++ b/internal/app/gcpcontainer_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + gcpcontainerbackend "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +func TestReserveFixedServicePorts_GCPContainer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", gcpcontainerbackend.DefaultPort, 8000, 9000, true}, + {"port outside pool range", gcpcontainerbackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{GCPContainer: gcpcontainerbackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/gcpcontainer_wiring.go b/internal/app/gcpcontainer_wiring.go new file mode 100644 index 0000000000..9ea86a51d4 --- /dev/null +++ b/internal/app/gcpcontainer_wiring.go @@ -0,0 +1,20 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + gcpcomputebackend "github.com/blackbirdworks/gopherstack/services/gcpcompute" + gcpcontainerbackend "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +// wireGCPContainer validates cluster networks, subnetworks, secondary ranges and machine types against the +// Compute Engine emulator. +func wireGCPContainer(byName map[string]service.Registerable) { + gke, ok := byName["GCPContainer"].(*gcpcontainerbackend.Handler) + if !ok { + return + } + + if ce, isCE := byName["GCPCompute"].(*gcpcomputebackend.Handler); isCE { + gke.Backend.SetCompute(ce.Backend) + } +} diff --git a/internal/app/gcpdatastore_port_reservation_test.go b/internal/app/gcpdatastore_port_reservation_test.go new file mode 100644 index 0000000000..61e5370e16 --- /dev/null +++ b/internal/app/gcpdatastore_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + gcpdatastorebackend "github.com/blackbirdworks/gopherstack/services/gcpdatastore" +) + +func TestReserveFixedServicePorts_GCPDatastore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", gcpdatastorebackend.DefaultPort, 8000, 9000, true}, + {"port outside pool range", gcpdatastorebackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{GCPDatastore: gcpdatastorebackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/gcpenforce_wiring_test.go b/internal/app/gcpenforce_wiring_test.go new file mode 100644 index 0000000000..505d087570 --- /dev/null +++ b/internal/app/gcpenforce_wiring_test.go @@ -0,0 +1,336 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "strings" + "testing" + + "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + gcpcomputebackend "github.com/blackbirdworks/gopherstack/services/gcpcompute" + gcppubsubbackend "github.com/blackbirdworks/gopherstack/services/gcppubsub" + gcprmbackend "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" + gcpsecretmanagerbackend "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" +) + +const ( + enfProject = "enforce-proj" + enfPubSub = "pubsub.googleapis.com" + enfSecrets = "secretmanager.googleapis.com" + enfCompute = "compute.googleapis.com" + enfUnknownID = "no-such-project" +) + +type enforceEnv struct { + rm *gcprmbackend.Handler + pubsub *gcppubsubbackend.Handler + secrets *gcpsecretmanagerbackend.Handler + compute *gcpcomputebackend.Handler +} + +func startEnforceEnv(t *testing.T, enf gcpenforce.Settings) *enforceEnv { + t.Helper() + + cli := &CLI{ + AccountID: "000000000000", + Region: "us-east-1", + GCPEnforce: enf, + GCPResourceManager: gcprmbackend.Settings{ + Domain: "x.test", DeleteRetention: gcprmbackend.DefaultDeleteRetention, + }, + } + portAlloc, err := portalloc.New(19700, 19800) + require.NoError(t, err) + + services, err := initializeServicesT(t, &service.AppContext{ + Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), PortAlloc: portAlloc, + }) + require.NoError(t, err) + + byName := serviceByName(services) + ctx := gcpenforce.WithGateFrom(t.Context(), services) + + env := &enforceEnv{} + env.rm, _ = byName["GCPResourceManager"].(*gcprmbackend.Handler) + env.pubsub, _ = byName["GCPPubSub"].(*gcppubsubbackend.Handler) + env.secrets, _ = byName["GCPSecretManager"].(*gcpsecretmanagerbackend.Handler) + env.compute, _ = byName["GCPCompute"].(*gcpcomputebackend.Handler) + + for _, h := range []interface { + StartWorker(context.Context) error + Shutdown(context.Context) + }{env.pubsub, env.secrets, env.compute} { + require.NoError(t, h.StartWorker(ctx)) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + } + + return env +} + +func (e *enforceEnv) createProject(t *testing.T, id string) { + t.Helper() + + _, err := e.rm.Backend.CreateProject(&resourcemanagerpb.CreateProjectRequest{ + Project: &resourcemanagerpb.Project{ProjectId: id, DisplayName: id + " name"}, + }) + require.NoError(t, err) +} + +func (e *enforceEnv) toggle(t *testing.T, project, svc string, enable bool) { + t.Helper() + + name := "projects/" + project + "/services/" + svc + + var err error + if enable { + _, err = e.rm.Backend.EnableService(&serviceusagepb.EnableServiceRequest{Name: name}) + } else { + _, err = e.rm.Backend.DisableService(&serviceusagepb.DisableServiceRequest{Name: name}) + } + + require.NoError(t, err) +} + +func (e *enforceEnv) publisher(t *testing.T) pubsubpb.PublisherClient { + t.Helper() + + conn, err := grpc.NewClient(e.pubsub.Addr(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return pubsubpb.NewPublisherClient(conn) +} + +func errorInfoOf(t *testing.T, err error) *errdetails.ErrorInfo { + t.Helper() + + for _, d := range status.Convert(err).Details() { + if ei, ok := d.(*errdetails.ErrorInfo); ok { + return ei + } + } + + require.Fail(t, "no ErrorInfo in status details", "%v", err) + + return nil +} + +func TestGCPEnforcementGRPC(t *testing.T) { + t.Parallel() + + env := startEnforceEnv(t, gcpenforce.Settings{Projects: true, ServiceEnablement: true}) + pub := env.publisher(t) + topic := &pubsubpb.Topic{Name: "projects/" + enfProject + "/topics/topic-1"} + + _, err := pub.CreateTopic(t.Context(), &pubsubpb.Topic{Name: "projects/" + enfUnknownID + "/topics/topic-1"}) + require.Error(t, err) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + ei := errorInfoOf(t, err) + assert.Equal(t, "CONSUMER_INVALID", ei.GetReason()) + assert.Equal(t, "googleapis.com", ei.GetDomain()) + assert.Equal(t, enfPubSub, ei.GetMetadata()["service"]) + assert.Equal(t, "projects/"+enfUnknownID, ei.GetMetadata()["consumer"]) + + env.createProject(t, enfProject) + + _, err = pub.CreateTopic(t.Context(), topic) + require.Error(t, err, "project exists but pubsub is not enabled") + assert.Equal(t, "SERVICE_DISABLED", errorInfoOf(t, err).GetReason()) + + env.toggle(t, enfProject, enfPubSub, true) + + _, err = pub.CreateTopic(t.Context(), topic) + require.NoError(t, err) + + env.toggle(t, enfProject, enfPubSub, false) + + _, err = pub.GetTopic(t.Context(), &pubsubpb.GetTopicRequest{Topic: topic.GetName()}) + require.Error(t, err) + + ei = errorInfoOf(t, err) + assert.Equal(t, "SERVICE_DISABLED", ei.GetReason()) + assert.Equal(t, enfPubSub, ei.GetMetadata()["service"]) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +func TestGCPEnforcementProjectsOnlyIgnoresEnablement(t *testing.T) { + t.Parallel() + + env := startEnforceEnv(t, gcpenforce.Settings{Projects: true}) + pub := env.publisher(t) + + _, err := pub.CreateTopic(t.Context(), &pubsubpb.Topic{Name: "projects/" + enfUnknownID + "/topics/topic-1"}) + require.Error(t, err) + + env.createProject(t, enfProject) + + _, err = pub.CreateTopic(t.Context(), &pubsubpb.Topic{Name: "projects/" + enfProject + "/topics/topic-1"}) + require.NoError(t, err, "pubsub is not enabled but only the project check is on") + + _, err = env.rm.Backend.DeleteProject(&resourcemanagerpb.DeleteProjectRequest{Name: "projects/" + enfProject}) + require.NoError(t, err) + + _, err = pub.GetTopic(t.Context(), &pubsubpb.GetTopicRequest{Topic: "projects/" + enfProject + "/topics/topic-1"}) + require.Error(t, err, "DELETE_REQUESTED projects are rejected") + assert.Equal(t, "CONSUMER_INVALID", errorInfoOf(t, err).GetReason()) +} + +func TestGCPEnforcementOffKeepsAcceptingAnyProject(t *testing.T) { + t.Parallel() + + env := startEnforceEnv(t, gcpenforce.Settings{}) + pub := env.publisher(t) + + _, err := pub.CreateTopic(t.Context(), &pubsubpb.Topic{Name: "projects/" + enfUnknownID + "/topics/topic-1"}) + require.NoError(t, err) + assert.Nil(t, env.rm.EnforcementGate()) +} + +type restEnvelope struct { + Error struct { + Status string `json:"status"` + Message string `json:"message"` + Details []struct { + Metadata map[string]string `json:"metadata"` + Type string `json:"@type"` + Reason string `json:"reason"` + Domain string `json:"domain"` + } `json:"details"` + Code int `json:"code"` + } `json:"error"` +} + +func getREST(t *testing.T, url string) (int, restEnvelope) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) + require.NoError(t, err) + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var env restEnvelope + + _ = json.Unmarshal(body, &env) + + return resp.StatusCode, env +} + +func TestGCPEnforcementREST(t *testing.T) { + t.Parallel() + + env := startEnforceEnv(t, gcpenforce.Settings{ServiceEnablement: true}) + env.createProject(t, enfProject) + + tests := []struct { + name string + base string + path string + service string + }{ + { + "gcprest transcoded secretmanager", + "http://" + env.secrets.Addr(), "/v1/projects/" + enfProject + "/secrets", enfSecrets, + }, + { + "http handler compute", + "http://" + env.compute.Addr(), "/compute/v1/projects/" + enfProject + "/zones", enfCompute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + code, body := getREST(t, tt.base+tt.path) + assert.Equal(t, http.StatusForbidden, code) + assert.Equal(t, "PERMISSION_DENIED", body.Error.Status) + assert.Equal(t, http.StatusForbidden, body.Error.Code) + require.NotEmpty(t, body.Error.Details) + + ei := body.Error.Details[0] + assert.Equal(t, "type.googleapis.com/google.rpc.ErrorInfo", ei.Type) + assert.Equal(t, "SERVICE_DISABLED", ei.Reason) + assert.Equal(t, "googleapis.com", ei.Domain) + assert.Equal(t, tt.service, ei.Metadata["service"]) + + unknown, ub := getREST(t, tt.base+replaceProject(tt.path, enfUnknownID)) + assert.Equal(t, http.StatusForbidden, unknown) + require.NotEmpty(t, ub.Error.Details) + assert.Equal(t, "CONSUMER_INVALID", ub.Error.Details[0].Reason) + + env.toggle(t, enfProject, tt.service, true) + + code, _ = getREST(t, tt.base+tt.path) + assert.NotEqual(t, http.StatusForbidden, code) + }) + } +} + +func replaceProject(path, project string) string { + return strings.Replace(path, "/projects/"+enfProject+"/", "/projects/"+project+"/", 1) +} + +func TestCLIValidateRejectsEnforcementWithoutResourceManager(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + wantErr bool + }{ + {"off and filtered", CLI{ + ServiceFilter: ServiceFilterSettings{DisableServices: []string{"GCPResourceManager"}}, + }, false}, + {"projects on, rm filtered out", CLI{ + GCPEnforce: gcpenforce.Settings{Projects: true}, + ServiceFilter: ServiceFilterSettings{DisableServices: []string{"GCPResourceManager"}}, + }, true}, + {"enablement on, rm not in allow list", CLI{ + GCPEnforce: gcpenforce.Settings{ServiceEnablement: true}, + ServiceFilter: ServiceFilterSettings{Services: []string{"S3"}}, + }, true}, + {"enablement on, rm allowed", CLI{ + GCPEnforce: gcpenforce.Settings{ServiceEnablement: true}, + ServiceFilter: ServiceFilterSettings{Services: []string{"S3", "GCPResourceManager"}}, + }, false}, + {"on and no filter", CLI{GCPEnforce: gcpenforce.Settings{Projects: true}}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.cli.Validate() + if tt.wantErr { + require.ErrorIs(t, err, errEnforceNeedsResourceManager) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/app/gcpfunctions_events_wiring_test.go b/internal/app/gcpfunctions_events_wiring_test.go new file mode 100644 index 0000000000..15da9b5bff --- /dev/null +++ b/internal/app/gcpfunctions_events_wiring_test.go @@ -0,0 +1,249 @@ +package app + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "sync" + "testing" + "time" + + "cloud.google.com/go/firestore/apiv1/firestorepb" + "cloud.google.com/go/functions/apiv2/functionspb" + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" + "github.com/blackbirdworks/gopherstack/pkgs/service" + gcpfirestorebackend "github.com/blackbirdworks/gopherstack/services/gcpfirestore" + gcpfunctionsbackend "github.com/blackbirdworks/gopherstack/services/gcpfunctions" + gcpsecretmanagerbackend "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" + gcpstoragebackend "github.com/blackbirdworks/gopherstack/services/gcpstorage" +) + +type seenEvent struct { + typ, subject, source string + contentType string + body []byte +} + +// eventRuntime serves each container's published port and reports every CloudEvent it receives. +type eventRuntime struct { + events chan seenEvent + specs chan container.Spec + boxes map[string]*http.Server + mu sync.Mutex + seq int +} + +func (r *eventRuntime) CreateAndStart(_ context.Context, spec container.Spec) (string, error) { + _, host, _, err := container.ParsePortSpec(spec.Ports[0]) + if err != nil { + return "", err + } + + l, err := net.Listen("tcp", net.JoinHostPort(container.LoopbackHost, host)) + if err != nil { + return "", err + } + + srv := &http.Server{Handler: http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { + if req.UserAgent() == gcpserve.ProbeUserAgent { + return + } + + body, _ := io.ReadAll(req.Body) + r.events <- seenEvent{ + typ: req.Header.Get("Ce-Type"), subject: req.Header.Get("Ce-Subject"), + source: req.Header.Get("Ce-Source"), contentType: req.Header.Get("Content-Type"), body: body, + } + })} + + go func() { _ = srv.Serve(l) }() + + r.mu.Lock() + defer r.mu.Unlock() + + r.seq++ + id := fmt.Sprintf("ev-%d", r.seq) + + if r.boxes == nil { + r.boxes = map[string]*http.Server{} + } + + r.boxes[id] = srv + r.specs <- spec + + return id, nil +} + +func (r *eventRuntime) StopAndRemove(_ context.Context, id string) error { + r.mu.Lock() + srv := r.boxes[id] + delete(r.boxes, id) + r.mu.Unlock() + + if srv != nil { + _ = srv.Close() + } + + return nil +} + +func nextSeen(t *testing.T, ch <-chan seenEvent) seenEvent { + t.Helper() + + select { + case ev := <-ch: + return ev + case <-time.After(15 * time.Second): + require.FailNow(t, "no event arrived") + + return seenEvent{} + } +} + +func TestWireGCPFunctions_StorageFirestoreAndSecrets(t *testing.T) { + t.Parallel() + + const ( + loc = "projects/p/locations/us-central1" + dbPath = "projects/p/databases/(default)" + ) + + gcs := gcpstoragebackend.NewHandler(gcpstoragebackend.NewInMemoryBackend()) + fs := gcpfirestorebackend.NewHandler(gcpfirestorebackend.NewInMemoryBackend()) + sm := gcpsecretmanagerbackend.NewHandler(gcpsecretmanagerbackend.NewInMemoryBackend()) + fns := gcpfunctionsbackend.NewHandler(gcpfunctionsbackend.NewInMemoryBackend()) + rt := &eventRuntime{events: make(chan seenEvent, 16), specs: make(chan container.Spec, 16)} + fns.Backend.SetRuntime(func() (gcpfunctionsbackend.Runner, error) { return rt, nil }) + t.Cleanup(fns.Backend.Close) + + wireGCPFunctions(map[string]service.Registerable{ + "GCPFunctions": fns, "GCPStorage": gcs, "GCPFirestore": fs, "GCPSecretManager": sm, + }) + + _, err := gcs.Backend.CreateBucket("p", gcpstoragebackend.Patch{"name": json.RawMessage(`"src"`)}) + require.NoError(t, err) + _, err = gcs.Backend.CreateBucket("p", gcpstoragebackend.Patch{"name": json.RawMessage(`"inbox"`)}) + require.NoError(t, err) + + _, err = sm.Backend.CreateSecret(&secretmanagerpb.CreateSecretRequest{ + Parent: "projects/p", SecretId: "token", Secret: automaticSecret(), + }) + require.NoError(t, err) + _, err = sm.Backend.AddSecretVersion(&secretmanagerpb.AddSecretVersionRequest{ + Parent: "projects/p/secrets/token", Payload: &secretmanagerpb.SecretPayload{Data: []byte("hunter2")}, + }) + require.NoError(t, err) + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + w, err := zw.Create("index.js") + require.NoError(t, err) + _, err = w.Write([]byte("exports.h = () => {};")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + _, err = gcs.Backend.PutObject("src", "fn.zip", buf.Bytes(), gcpstoragebackend.Patch{}, + gcpstoragebackend.Preconds{}) + require.NoError(t, err) + + create := func(id string, et *functionspb.EventTrigger, secrets bool) { + t.Helper() + + fn := &functionspb.Function{ + BuildConfig: &functionspb.BuildConfig{ + Runtime: "nodejs22", EntryPoint: "h", + Source: &functionspb.Source{Source: &functionspb.Source_StorageSource{ + StorageSource: &functionspb.StorageSource{Bucket: "src", Object: "fn.zip"}, + }}, + }, + ServiceConfig: &functionspb.ServiceConfig{}, + EventTrigger: et, + } + if secrets { + fn.ServiceConfig.SecretEnvironmentVariables = []*functionspb.SecretEnvVar{{Key: "TOKEN", Secret: "token"}} + } + + _, cerr := fns.Backend.CreateFunction( + &functionspb.CreateFunctionRequest{Parent: loc, FunctionId: id, Function: fn}, + ) + require.NoError(t, cerr) + fns.Backend.Drain() + } + + create("onobject", &functionspb.EventTrigger{ + EventType: "google.cloud.storage.object.v1.finalized", + EventFilters: []*functionspb.EventFilter{{Attribute: "bucket", Value: "inbox"}}, + }, true) + + spec := <-rt.specs + assert.Contains(t, spec.Env, "TOKEN=hunter2") + + _, err = gcs.Backend.PutObject( + "inbox", + "a.txt", + []byte("hello"), + gcpstoragebackend.Patch{}, + gcpstoragebackend.Preconds{}, + ) + require.NoError(t, err) + + ev := nextSeen(t, rt.events) + assert.Equal(t, "google.cloud.storage.object.v1.finalized", ev.typ) + assert.Equal(t, "objects/a.txt", ev.subject) + + var obj map[string]any + + require.NoError(t, json.Unmarshal(ev.body, &obj)) + assert.Equal(t, "inbox", obj["bucket"]) + assert.Equal(t, "a.txt", obj["name"]) + + _, err = fns.Backend.CreateFunction(&functionspb.CreateFunctionRequest{ + Parent: loc, FunctionId: "nobucket", Function: &functionspb.Function{ + BuildConfig: &functionspb.BuildConfig{ + Runtime: "nodejs22", EntryPoint: "h", + Source: &functionspb.Source{Source: &functionspb.Source_StorageSource{ + StorageSource: &functionspb.StorageSource{Bucket: "src", Object: "fn.zip"}, + }}, + }, + EventTrigger: &functionspb.EventTrigger{ + EventType: "google.cloud.storage.object.v1.finalized", + EventFilters: []*functionspb.EventFilter{{Attribute: "bucket", Value: "ghost"}}, + }, + }, + }) + require.Error(t, err, "a trigger on a bucket that does not exist is rejected") + + create("ondoc", &functionspb.EventTrigger{ + EventType: "google.cloud.firestore.document.v1.created", + EventFilters: []*functionspb.EventFilter{ + {Attribute: "database", Value: "(default)"}, + {Attribute: "document", Value: "users/{id}", Operator: "match-path-pattern"}, + }, + }, false) + <-rt.specs + + _, err = fs.Backend.CreateDocument(&firestorepb.CreateDocumentRequest{ + Parent: dbPath + "/documents", CollectionId: "users", DocumentId: "alice", + Document: &firestorepb.Document{Fields: map[string]*firestorepb.Value{ + "name": {ValueType: &firestorepb.Value_StringValue{StringValue: "Alice"}}, + }}, + }) + require.NoError(t, err) + + ev = nextSeen(t, rt.events) + assert.Equal(t, "google.cloud.firestore.document.v1.created", ev.typ) + assert.Equal(t, "documents/users/alice", ev.subject) + assert.Equal(t, "application/protobuf", ev.contentType) + assert.Contains(t, string(ev.body), dbPath+"/documents/users/alice") +} diff --git a/internal/app/gcpfunctions_wiring.go b/internal/app/gcpfunctions_wiring.go index 6ae0f74dfe..49a68a11e7 100644 --- a/internal/app/gcpfunctions_wiring.go +++ b/internal/app/gcpfunctions_wiring.go @@ -4,8 +4,10 @@ import ( "fmt" "github.com/blackbirdworks/gopherstack/pkgs/service" + gcpfirestorebackend "github.com/blackbirdworks/gopherstack/services/gcpfirestore" gcpfunctionsbackend "github.com/blackbirdworks/gopherstack/services/gcpfunctions" gcppubsubbackend "github.com/blackbirdworks/gopherstack/services/gcppubsub" + gcpsecretmanagerbackend "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" gcpstoragebackend "github.com/blackbirdworks/gopherstack/services/gcpstorage" ) @@ -23,7 +25,15 @@ func (r gcsObjectReader) ReadObject(bucket, object string) ([]byte, error) { return o.Data, nil } -// wireGCPFunctions connects Cloud Functions to Pub/Sub (event triggers) and GCS (storage_source archives). +// BucketExists lets storage triggers be checked against the GCS emulator. +func (r gcsObjectReader) BucketExists(bucket string) bool { + _, err := r.b.GetBucket(bucket, gcpstoragebackend.Preconds{}) + + return err == nil +} + +// wireGCPFunctions connects Cloud Functions to Pub/Sub, GCS and Firestore (event triggers and storage_source +// archives) and Secret Manager (secret environment variables and volumes). func wireGCPFunctions(byName map[string]service.Registerable) { fns, ok := byName["GCPFunctions"].(*gcpfunctionsbackend.Handler) if !ok { @@ -36,5 +46,30 @@ func wireGCPFunctions(byName map[string]service.Registerable) { if gcs, isGCS := byName["GCPStorage"].(*gcpstoragebackend.Handler); isGCS { fns.Backend.SetObjectReader(gcsObjectReader{b: gcs.Backend}) + fns.Backend.SetBucketLookup(gcsObjectReader{b: gcs.Backend}) + gcs.Backend.ObserveObjects(func(ev gcpstoragebackend.ObjectEvent) { + fns.Backend.HandleStorageEvent(gcpfunctionsbackend.StorageEvent{ + Type: string(ev.Type), Bucket: ev.Object.Bucket, Name: ev.Object.Name, Data: ev.Data(), Time: ev.Time, + }) + }) + } + + if fs, isFS := byName["GCPFirestore"].(*gcpfirestorebackend.Handler); isFS { + fs.Backend.ObserveDocuments(func(ev gcpfirestorebackend.DocumentEvent) { + fns.Backend.HandleDocumentEvent(gcpfunctionsbackend.DocumentEvent{ + Kind: string(ev.Kind), Old: ev.Old, New: ev.New, UpdateMask: ev.UpdateMask, Time: ev.Time, + }) + }) + } + + if sm, isSM := byName["GCPSecretManager"].(*gcpsecretmanagerbackend.Handler); isSM { + fns.Backend.SetSecretResolver(func(versionName string) ([]byte, error) { + resp, err := sm.Backend.AccessSecretVersion(versionName) + if err != nil { + return nil, err //nolint:wrapcheck // the status message is shown to the user as is + } + + return resp.GetPayload().GetData(), nil + }) } } diff --git a/internal/app/gcpidentitytoolkit_port_reservation_test.go b/internal/app/gcpidentitytoolkit_port_reservation_test.go new file mode 100644 index 0000000000..5caf468601 --- /dev/null +++ b/internal/app/gcpidentitytoolkit_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + gcpidtkbackend "github.com/blackbirdworks/gopherstack/services/gcpidentitytoolkit" +) + +func TestReserveFixedServicePorts_GCPIdentityToolkit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", gcpidtkbackend.DefaultPort, 8000, 9000, true}, + {"port outside pool range", gcpidtkbackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{GCPIdentityToolkit: gcpidtkbackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/gcpidentitytoolkit_wiring.go b/internal/app/gcpidentitytoolkit_wiring.go new file mode 100644 index 0000000000..8ac57c6518 --- /dev/null +++ b/internal/app/gcpidentitytoolkit_wiring.go @@ -0,0 +1,19 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + gcpiambackend "github.com/blackbirdworks/gopherstack/services/gcpiam" + gcpidtkbackend "github.com/blackbirdworks/gopherstack/services/gcpidentitytoolkit" +) + +// wireGCPIdentityToolkit lets Identity Platform verify RS256 custom tokens against Cloud IAM service account keys. +func wireGCPIdentityToolkit(byName map[string]service.Registerable) { + idp, ok := byName["GCPIdentityToolkit"].(*gcpidtkbackend.Handler) + if !ok { + return + } + + if iam, isIAM := byName["GCPIAM"].(*gcpiambackend.Handler); isIAM { + idp.Backend.SetCustomTokenKeys(iam.Backend.VerificationKeys) + } +} diff --git a/internal/app/gcpresourcemanager_port_reservation_test.go b/internal/app/gcpresourcemanager_port_reservation_test.go new file mode 100644 index 0000000000..754d09d76d --- /dev/null +++ b/internal/app/gcpresourcemanager_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + gcprmbackend "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +func TestReserveFixedServicePorts_GCPResourceManager(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", gcprmbackend.DefaultPort, 8000, 9000, true}, + {"port outside pool range", gcprmbackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{GCPResourceManager: gcprmbackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/gcpresourcemanager_wiring_test.go b/internal/app/gcpresourcemanager_wiring_test.go new file mode 100644 index 0000000000..010c8f9d1b --- /dev/null +++ b/internal/app/gcpresourcemanager_wiring_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + gcprmbackend "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +func TestGCPResourceManagerSettingsReachTheProvider(t *testing.T) { + t.Parallel() + + cli := &CLI{GCPResourceManager: gcprmbackend.Settings{ + OrganizationID: "42", Domain: "corp.example", SeedProjects: "wired-one,wired-two", + DeleteRetention: time.Hour, Port: 8999, + }} + + assert.Equal(t, cli.GCPResourceManager, cli.GetGCPResourceManagerSettings()) + + reg, err := (&gcprmbackend.Provider{}).Init(&service.AppContext{Config: cli}) + require.NoError(t, err) + + h, ok := reg.(*gcprmbackend.Handler) + require.True(t, ok) + assert.Equal(t, 8999, h.Port) + assert.Equal(t, []string{"wired-one", "wired-two"}, h.Backend.ProjectIDs()) + assert.Equal(t, "organizations/42", h.Backend.OrganizationName()) +} + +func TestGCPResourceManagerProviderRejectsNilContext(t *testing.T) { + t.Parallel() + + _, err := (&gcprmbackend.Provider{}).Init(nil) + require.ErrorIs(t, err, gcprmbackend.ErrNilAppContext) +} diff --git a/internal/app/glacier_select_s3_wiring_test.go b/internal/app/glacier_select_s3_wiring_test.go index a45cb77ca9..7c1855501f 100644 --- a/internal/app/glacier_select_s3_wiring_test.go +++ b/internal/app/glacier_select_s3_wiring_test.go @@ -44,7 +44,7 @@ func TestInitializeServices_GlacierSelectS3Wiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/glue_ram_policy_share_wiring_test.go b/internal/app/glue_ram_policy_share_wiring_test.go index 86a97fe12d..e882bfdc1c 100644 --- a/internal/app/glue_ram_policy_share_wiring_test.go +++ b/internal/app/glue_ram_policy_share_wiring_test.go @@ -40,7 +40,7 @@ func TestInitializeServices_GlueRAMPolicyShareWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/glue_secrets_wiring.go b/internal/app/glue_secrets_wiring.go new file mode 100644 index 0000000000..a4191334e0 --- /dev/null +++ b/internal/app/glue_secrets_wiring.go @@ -0,0 +1,51 @@ +package app + +import ( + "context" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + gluebackend "github.com/blackbirdworks/gopherstack/services/glue" + smbackend "github.com/blackbirdworks/gopherstack/services/secretsmanager" +) + +type glueSecrets struct{ sm *smbackend.InMemoryBackend } + +func (g glueSecrets) SecretString(ctx context.Context, region, secretID string) (string, error) { + out, err := g.sm.GetSecretValue( + smbackend.WithRegion(ctx, region), + &smbackend.GetSecretValueInput{SecretID: secretID}, + ) + if err != nil { + return "", err + } + + return out.SecretString, nil +} + +// wireGlueBackends wires the S3 and Secrets Manager backends crawlers read. +func wireGlueBackends(byName map[string]service.Registerable) { + wireGlueS3(byName["Glue"], byName["S3"]) + wireGlueSecrets(byName["Glue"], byName["SecretsManager"]) +} + +// wireGlueSecrets lets Glue crawlers resolve connection SECRET_ID credentials. +func wireGlueSecrets(glueReg, smReg service.Registerable) { + glueH, ok := glueReg.(*gluebackend.Handler) + if !ok { + return + } + + glueBk, ok := glueH.Backend.(*gluebackend.InMemoryBackend) + if !ok { + return + } + + smH, ok := smReg.(*smbackend.Handler) + if !ok { + return + } + + if smBk, ok2 := smH.Backend.(*smbackend.InMemoryBackend); ok2 { + glueBk.SetSecretsBackend(glueSecrets{sm: smBk}) + } +} diff --git a/internal/app/iam_policy_stream.go b/internal/app/iam_policy_stream.go index 6478665f8c..fd798e3b8b 100644 --- a/internal/app/iam_policy_stream.go +++ b/internal/app/iam_policy_stream.go @@ -11,6 +11,9 @@ import ( "github.com/labstack/echo/v5" "github.com/blackbirdworks/gopherstack/pkgs/iampolicystream" + "github.com/blackbirdworks/gopherstack/pkgs/service" + iambackend "github.com/blackbirdworks/gopherstack/services/iam" + organizationsbackend "github.com/blackbirdworks/gopherstack/services/organizations" ) const ( @@ -182,3 +185,20 @@ func iamPolicySuggestHandler(rec *iampolicystream.Recorder) echo.HandlerFunc { return c.JSON(http.StatusOK, iampolicystream.Suggest(rec.Snapshot(f.Match), all)) } } + +// scpProviderFor returns the Organizations backend as the SCP source, or nil when SCP enforcement is off. +func scpProviderFor(enabled bool, services []service.Registerable) iambackend.SCPProvider { + if !enabled { + return nil + } + + for _, svc := range services { + if h, ok := svc.(*organizationsbackend.Handler); ok { + if p, isProvider := h.Backend.(iambackend.SCPProvider); isProvider { + return p + } + } + } + + return nil +} diff --git a/internal/app/iam_policy_stream_e2e_test.go b/internal/app/iam_policy_stream_e2e_test.go index c78c313f75..8486a83441 100644 --- a/internal/app/iam_policy_stream_e2e_test.go +++ b/internal/app/iam_policy_stream_e2e_test.go @@ -2,7 +2,6 @@ package app import ( "encoding/json" - "net/http" "net/http/httptest" "testing" @@ -17,6 +16,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/chaos" "github.com/blackbirdworks/gopherstack/pkgs/iampolicystream" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func newEnforcedIAMServer(t *testing.T) *httptest.Server { @@ -28,7 +28,7 @@ func newEnforcedIAMServer(t *testing.T) *httptest.Server { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) @@ -105,7 +105,8 @@ func TestIAMPolicyStreamEndToEnd(t *testing.T) { _, err = s3c.GetObject(t.Context(), &s3.GetObjectInput{Bucket: aws.String("b"), Key: aws.String("k")}) require.Error(t, err) - resp, err := http.Get(srv.URL + iamPolicyStreamPath + "?principal=arn:aws:iam::000000000000:user/alice") + resp, err := testclient.New(t). + Get(srv.URL + iamPolicyStreamPath + "?principal=arn:aws:iam::000000000000:user/alice") require.NoError(t, err) defer resp.Body.Close() diff --git a/internal/app/iam_policy_stream_test.go b/internal/app/iam_policy_stream_test.go index 4750e144ab..8ca5ac932d 100644 --- a/internal/app/iam_policy_stream_test.go +++ b/internal/app/iam_policy_stream_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/iampolicystream" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func streamFixture() (*echo.Echo, *iampolicystream.Recorder) { @@ -127,7 +128,7 @@ func TestIAMPolicyStreamFollow(t *testing.T) { req.Header.Set("Accept", tt.accept) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/internal/app/iam_scp_test.go b/internal/app/iam_scp_test.go new file mode 100644 index 0000000000..aadc9c3af7 --- /dev/null +++ b/internal/app/iam_scp_test.go @@ -0,0 +1,351 @@ +package app + +import ( + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/organizations" + orgtypes "github.com/aws/aws-sdk-go-v2/service/organizations/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/sqs" + "github.com/aws/smithy-go" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/iampolicystream" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + iambackend "github.com/blackbirdworks/gopherstack/services/iam" +) + +const ( + scpFullAccessID = "p-FullAWSAccess" + scpAdminPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}` +) + +type scpFixture struct { + srv *httptest.Server + iamBk *iambackend.InMemoryBackend + rec *iampolicystream.Recorder + orgs *organizations.Client + rootID string + ouID string + accountID string +} + +func newSCPServer( + t *testing.T, enforceSCPs bool, +) (*httptest.Server, *iampolicystream.Recorder, *iambackend.InMemoryBackend) { + t.Helper() + + rec := iampolicystream.New(200) + log := buildLogger("") + cli := CLI{ + AccountID: "000000000000", Region: "us-east-1", EnforceIAM: true, IAMEnforceSCPs: enforceSCPs, + iamStream: rec, + } + cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServicesT(t, &service.AppContext{ + Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, + }) + require.NoError(t, err) + + e := buildEchoServer(t.Context(), log, nil, services, cli) + require.NoError(t, setupChaosAndRegistry(e, log, &cli, services)) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + iamBk, _ := findIAMBackend(services).(*iambackend.InMemoryBackend) + require.NotNil(t, iamBk) + + return srv, rec, iamBk +} + +func scpConfig(t *testing.T, url, key, secret, region, account string) aws.Config { + t.Helper() + + cfg := awsConfigFor(t, url, key, secret) + cfg.Region = region + cfg.HTTPClient = testclient.New(t) + + if account != "" { + cfg.APIOptions = append(cfg.APIOptions, smithyhttp.AddHeaderValue(awsmeta.AccountHeader, account)) + } + + return cfg +} + +func newSCPFixture(t *testing.T, enforceSCPs bool) *scpFixture { + t.Helper() + + srv, rec, iamBk := newSCPServer(t, enforceSCPs) + orgs := organizations.NewFromConfig(scpConfig(t, srv.URL, "test", "test", "us-east-1", "")) + + _, err := orgs.CreateOrganization(t.Context(), &organizations.CreateOrganizationInput{ + FeatureSet: orgtypes.OrganizationFeatureSetAll, + }) + require.NoError(t, err) + + roots, err := orgs.ListRoots(t.Context(), &organizations.ListRootsInput{}) + require.NoError(t, err) + require.Len(t, roots.Roots, 1) + + f := &scpFixture{srv: srv, iamBk: iamBk, rec: rec, orgs: orgs, rootID: aws.ToString(roots.Roots[0].Id)} + + ou, err := orgs.CreateOrganizationalUnit(t.Context(), &organizations.CreateOrganizationalUnitInput{ + ParentId: aws.String(f.rootID), Name: aws.String("workloads"), + }) + require.NoError(t, err) + + f.ouID = aws.ToString(ou.OrganizationalUnit.Id) + + st, err := orgs.CreateAccount(t.Context(), &organizations.CreateAccountInput{ + AccountName: aws.String("member"), Email: aws.String("member@example.com"), + }) + require.NoError(t, err) + + f.accountID = aws.ToString(st.CreateAccountStatus.AccountId) + require.NotEmpty(t, f.accountID) + + _, err = orgs.MoveAccount(t.Context(), &organizations.MoveAccountInput{ + AccountId: aws.String( + f.accountID, + ), + SourceParentId: aws.String(f.rootID), + DestinationParentId: aws.String(f.ouID), + }) + require.NoError(t, err) + + return f +} + +type scpCaller struct{ key, secret, account string } + +func (f *scpFixture) adminUser(t *testing.T, account, name string) scpCaller { + t.Helper() + + bk := f.iamBk.ForAccount(account) + + _, err := bk.CreateUser(name, "/", "") + require.NoError(t, err) + require.NoError(t, bk.PutUserPolicy(name, "admin", scpAdminPolicy)) + + k, err := bk.CreateAccessKey(name) + require.NoError(t, err) + + return scpCaller{k.AccessKeyID, k.SecretAccessKey, account} +} + +func (f *scpFixture) createSCP(t *testing.T, name, doc string) string { + t.Helper() + + out, err := f.orgs.CreatePolicy(t.Context(), &organizations.CreatePolicyInput{ + Name: aws.String(name), Description: aws.String(name), Content: aws.String(doc), + Type: orgtypes.PolicyTypeServiceControlPolicy, + }) + require.NoError(t, err) + + return aws.ToString(out.Policy.PolicySummary.Id) +} + +func (f *scpFixture) attach(t *testing.T, policyID, target string) { + t.Helper() + + _, err := f.orgs.AttachPolicy(t.Context(), &organizations.AttachPolicyInput{ + PolicyId: aws.String(policyID), TargetId: aws.String(target), + }) + require.NoError(t, err) +} + +func (f *scpFixture) detach(t *testing.T, policyID, target string) { + t.Helper() + + _, err := f.orgs.DetachPolicy(t.Context(), &organizations.DetachPolicyInput{ + PolicyId: aws.String(policyID), TargetId: aws.String(target), + }) + require.NoError(t, err) +} + +func (f *scpFixture) s3(t *testing.T, c scpCaller) *s3.Client { + t.Helper() + + return s3.NewFromConfig(scpConfig(t, f.srv.URL, c.key, c.secret, "us-east-1", c.account), + func(o *s3.Options) { o.UsePathStyle = true }) +} + +func accessDeniedMessage(t *testing.T, err error) string { + t.Helper() + + var ae smithy.APIError + require.ErrorAs(t, err, &ae) + require.Contains(t, []string{"AccessDenied", "AccessDeniedException"}, ae.ErrorCode()) + + return ae.ErrorMessage() +} + +func TestSCPEnforcement(t *testing.T) { + t.Parallel() + + const denyDelete = `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteBucket","Resource":"*"}]}` + + tests := []struct { + run func(t *testing.T, f *scpFixture, member scpCaller) + name string + off bool + }{ + { + name: "explicit_deny_blocks_admin_member", + run: func(t *testing.T, f *scpFixture, member scpCaller) { + t.Helper() + + pid := f.createSCP(t, "deny-delete", denyDelete) + f.attach(t, pid, f.ouID) + + c := f.s3(t, member) + _, err := c.CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("scp-b")}) + require.NoError(t, err) + + _, err = c.DeleteBucket(t.Context(), &s3.DeleteBucketInput{Bucket: aws.String("scp-b")}) + require.Error(t, err) + + msg := accessDeniedMessage(t, err) + assert.Contains(t, msg, "is not authorized to perform: s3:DeleteBucket") + assert.Contains(t, msg, "with an explicit deny in a service control policy: arn:aws:organizations::") + assert.Contains(t, msg, pid) + + var denied *iampolicystream.Event + + for _, ev := range f.rec.Snapshot(func(iampolicystream.Event) bool { return true }) { + if ev.Decision == iampolicystream.DecisionDeny { + denied = &ev + } + } + + require.NotNil(t, denied) + assert.Equal(t, iampolicystream.ReasonServiceControl, denied.Reason) + assert.Equal(t, iampolicystream.DenyExplicit, denied.DenyType) + require.NotNil(t, denied.Policy) + assert.Equal(t, iampolicystream.PolicySCP, denied.Policy.Type) + assert.Contains(t, denied.Policy.Arn, pid) + }, + }, + { + name: "management_account_exempt", + run: func(t *testing.T, f *scpFixture, _ scpCaller) { + t.Helper() + + pid := f.createSCP(t, "deny-delete", denyDelete) + f.attach(t, pid, f.rootID) + + mgmt := f.adminUser(t, "", "mgmt-admin") + c := f.s3(t, mgmt) + _, err := c.CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("mgmt-b")}) + require.NoError(t, err) + + _, err = c.DeleteBucket(t.Context(), &s3.DeleteBucketInput{Bucket: aws.String("mgmt-b")}) + require.NoError(t, err) + }, + }, + { + name: "allow_list_without_full_access_denies_others", + run: func(t *testing.T, f *scpFixture, member scpCaller) { + t.Helper() + + allow := f.createSCP(t, "ec2-only", `{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Action":"ec2:*","Resource":"*"}]}`) + f.attach(t, allow, f.ouID) + f.detach(t, scpFullAccessID, f.ouID) + + _, err := f.s3(t, member).CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("x-b")}) + require.Error(t, err) + assert.Contains(t, accessDeniedMessage(t, err), + "because no service control policy allows the s3:CreateBucket action") + }, + }, + { + name: "allow_list_still_permits_listed_actions", + run: func(t *testing.T, f *scpFixture, member scpCaller) { + t.Helper() + + allow := f.createSCP(t, "s3-only", `{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`) + f.attach(t, allow, f.ouID) + f.detach(t, scpFullAccessID, f.ouID) + + _, err := f.s3(t, member).CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("ok-b")}) + require.NoError(t, err) + }, + }, + { + name: "requested_region_condition", + run: func(t *testing.T, f *scpFixture, member scpCaller) { + t.Helper() + + pid := f.createSCP(t, "region-lock", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny",`+ + `"Action":"sqs:*","Resource":"*","Condition":{"StringNotEquals":`+ + `{"aws:RequestedRegion":"us-east-1"}}}]}`) + f.attach(t, pid, f.rootID) + + east := sqs.NewFromConfig( + scpConfig(t, f.srv.URL, member.key, member.secret, "us-east-1", member.account), + ) + _, err := east.ListQueues(t.Context(), &sqs.ListQueuesInput{}) + require.NoError(t, err) + + west := sqs.NewFromConfig( + scpConfig(t, f.srv.URL, member.key, member.secret, "us-west-2", member.account), + ) + _, err = west.ListQueues(t.Context(), &sqs.ListQueuesInput{}) + require.Error(t, err) + assert.Contains(t, accessDeniedMessage(t, err), "explicit deny in a service control policy") + }, + }, + { + name: "policy_type_disabled_has_no_effect", + run: func(t *testing.T, f *scpFixture, member scpCaller) { + t.Helper() + + _, err := f.orgs.DisablePolicyType(t.Context(), &organizations.DisablePolicyTypeInput{ + RootId: aws.String(f.rootID), PolicyType: orgtypes.PolicyTypeServiceControlPolicy, + }) + require.NoError(t, err) + + _, err = f.s3(t, member).CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("off-b")}) + require.NoError(t, err) + }, + }, + { + name: "flag_off_has_no_effect", + off: true, + run: func(t *testing.T, f *scpFixture, member scpCaller) { + t.Helper() + + pid := f.createSCP(t, "deny-delete", denyDelete) + f.attach(t, pid, f.ouID) + + c := f.s3(t, member) + _, err := c.CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String("flag-b")}) + require.NoError(t, err) + + _, err = c.DeleteBucket(t.Context(), &s3.DeleteBucketInput{Bucket: aws.String("flag-b")}) + require.NoError(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newSCPFixture(t, !tt.off) + tt.run(t, f, f.adminUser(t, f.accountID, "member-admin")) + }) + } +} diff --git a/internal/app/init_stages.go b/internal/app/init_stages.go index 8a4e05f331..f3fc3f24d3 100644 --- a/internal/app/init_stages.go +++ b/internal/app/init_stages.go @@ -2,11 +2,16 @@ package app import ( "context" + "encoding/json" + "errors" "log/slog" "net" + "net/http" "strings" "time" + "github.com/labstack/echo/v5" + "github.com/blackbirdworks/gopherstack/pkgs/inithooks" ) @@ -14,6 +19,7 @@ const ( initSchemeHTTP = "http" initListenPoll = 50 * time.Millisecond initListenTimeout = time.Minute + initRunMaxBody = 4 << 10 ) // initStageRoots returns the stage-directory roots: defaults plus --init-dir. @@ -104,3 +110,65 @@ func runStartupHooks(ctx context.Context, cli *CLI, log *slog.Logger) { createS3InitBuckets(ctx, cli, log) launchReadyStage(ctx, cli) } + +type initRunRequest struct { + Stage string `json:"stage"` + Script string `json:"script"` +} + +type initRunResult struct { + Stage string `json:"stage"` + Name string `json:"name"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + ExitCode int `json:"exit_code"` + Truncated bool `json:"truncated,omitempty"` +} + +// buildInitRunHandler runs registered init scripts on demand (loopback peers +// only); script is matched against names found in the configured stage dirs. +func buildInitRunHandler(st *localstackInitState) echo.HandlerFunc { + return func(c *echo.Context) error { + if !isLoopbackPeer(c.Request().RemoteAddr) { + return c.JSON(http.StatusForbidden, map[string]string{ + keyMessageField: "running init hooks is only accepted from a loopback address", + }) + } + + var req initRunRequest + if err := json.NewDecoder(http.MaxBytesReader(c.Response(), c.Request().Body, initRunMaxBody)). + Decode(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{keyMessageField: "invalid request body"}) + } + + var m *inithooks.Manager + if st != nil { + m = st.mgr.Load() + } + + if m == nil { + return c.JSON(http.StatusConflict, map[string]string{keyMessageField: "init stages are not active"}) + } + + runs, err := m.RunNamed(c.Request().Context(), inithooks.Stage(req.Stage), req.Script) + + switch { + case errors.Is(err, inithooks.ErrUnknownStage): + return c.JSON(http.StatusBadRequest, map[string]string{keyMessageField: err.Error()}) + case errors.Is(err, inithooks.ErrUnknownScript): + return c.JSON(http.StatusNotFound, map[string]string{keyMessageField: err.Error()}) + case err != nil: + return c.JSON(http.StatusInternalServerError, map[string]string{keyMessageField: err.Error()}) + } + + out := make([]initRunResult, 0, len(runs)) + for _, r := range runs { + out = append(out, initRunResult{ + Stage: string(r.Stage), Name: r.Name, Output: r.Output, + Error: r.Error, ExitCode: r.ExitCode, Truncated: r.Truncated, + }) + } + + return c.JSON(http.StatusOK, map[string]any{"results": out}) + } +} diff --git a/internal/app/insight_rule_transformed_test.go b/internal/app/insight_rule_transformed_test.go index de56bf0806..cd11fa4842 100644 --- a/internal/app/insight_rule_transformed_test.go +++ b/internal/app/insight_rule_transformed_test.go @@ -48,7 +48,7 @@ func TestInitializeServices_InsightRuleTransformedLogs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - services, err := initializeServices(newTestAppContext(t, 20000, 20100)) + services, err := initializeServicesT(t, newTestAppContext(t, 20000, 20100)) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/iot_rule_http_test.go b/internal/app/iot_rule_http_test.go index 3bd88f7d81..2cf7c45a6e 100644 --- a/internal/app/iot_rule_http_test.go +++ b/internal/app/iot_rule_http_test.go @@ -16,6 +16,8 @@ import ( iottypes "github.com/aws/aws-sdk-go-v2/service/iot/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) type iotHTTPHit struct { @@ -192,7 +194,7 @@ func TestIoTTopicRuleDestinationConfirmation(t *testing.T) { return } - if resp, derr := http.DefaultClient.Do(req); derr == nil { + if resp, derr := testclient.New(t).Do(req); derr == nil { resp.Body.Close() } } diff --git a/internal/app/iotanalytics_lambda_iot_wiring_test.go b/internal/app/iotanalytics_lambda_iot_wiring_test.go index cf88a07265..50adaf0c81 100644 --- a/internal/app/iotanalytics_lambda_iot_wiring_test.go +++ b/internal/app/iotanalytics_lambda_iot_wiring_test.go @@ -45,7 +45,7 @@ func TestInitializeServices_IoTAnalyticsLambdaIoTWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/kinesis_channel_s3_delivery_wiring_test.go b/internal/app/kinesis_channel_s3_delivery_wiring_test.go index 7e73d430ed..c20181d411 100644 --- a/internal/app/kinesis_channel_s3_delivery_wiring_test.go +++ b/internal/app/kinesis_channel_s3_delivery_wiring_test.go @@ -46,7 +46,7 @@ func TestInitializeServices_KinesisChannelS3DeliveryWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/kinesisanalytics_kinesis_s3_wiring_test.go b/internal/app/kinesisanalytics_kinesis_s3_wiring_test.go index 0646a2cf12..bc4ef1118c 100644 --- a/internal/app/kinesisanalytics_kinesis_s3_wiring_test.go +++ b/internal/app/kinesisanalytics_kinesis_s3_wiring_test.go @@ -56,7 +56,7 @@ func TestInitializeServices_KinesisAnalyticsKinesisS3Wiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/lambda_ecr_wiring_test.go b/internal/app/lambda_ecr_wiring_test.go index c0a165522a..053a322373 100644 --- a/internal/app/lambda_ecr_wiring_test.go +++ b/internal/app/lambda_ecr_wiring_test.go @@ -47,7 +47,7 @@ func TestInitializeServices_LambdaECRWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/lambda_esm_s3_wiring_test.go b/internal/app/lambda_esm_s3_wiring_test.go index 6485633629..1b8a4a418f 100644 --- a/internal/app/lambda_esm_s3_wiring_test.go +++ b/internal/app/lambda_esm_s3_wiring_test.go @@ -35,7 +35,7 @@ func TestLambdaESMS3Destination(t *testing.T) { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/lambda_extensions_docker_test.go b/internal/app/lambda_extensions_docker_test.go new file mode 100644 index 0000000000..85957509eb --- /dev/null +++ b/internal/app/lambda_extensions_docker_test.go @@ -0,0 +1,230 @@ +package app + +import ( + "archive/zip" + "bytes" + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc/portalloctest" + lambdabackend "github.com/blackbirdworks/gopherstack/services/lambda" +) + +const extensionSource = `package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +var cl = new(http.Client) + +func logLine(s string) { + f, err := os.OpenFile("/ext-out/external.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintln(f, s) +} + +func main() { + api := os.Getenv("AWS_LAMBDA_RUNTIME_API") + req, _ := http.NewRequest(http.MethodPost, "http://"+api+"/2020-01-01/extension/register", + strings.NewReader(` + "`" + `{"events":["INVOKE","SHUTDOWN"]}` + "`" + `)) + req.Header.Set("Lambda-Extension-Name", filepath.Base(os.Args[0])) + resp, err := cl.Do(req) + if err != nil { + os.Exit(1) + } + id := resp.Header.Get("Lambda-Extension-Identifier") + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + logLine("REGISTERED " + string(body)) + for { + nreq, _ := http.NewRequest(http.MethodGet, "http://"+api+"/2020-01-01/extension/event/next", nil) + nreq.Header.Set("Lambda-Extension-Identifier", id) + nresp, err := cl.Do(nreq) + if err != nil { + os.Exit(1) + } + raw, _ := io.ReadAll(nresp.Body) + nresp.Body.Close() + var ev map[string]any + _ = json.Unmarshal(raw, &ev) + logLine(string(raw)) + if ev["eventType"] == "SHUTDOWN" { + return + } + } +} +` + +const extensionHandler = `import json, os, threading, urllib.request + +def _internal(): + api = os.environ["AWS_LAMBDA_RUNTIME_API"] + req = urllib.request.Request("http://%s/2020-01-01/extension/register" % api, + data=json.dumps({"events": ["INVOKE"]}).encode(), headers={"Lambda-Extension-Name": "internal-ext"}) + ident = urllib.request.urlopen(req).headers["Lambda-Extension-Identifier"] + def loop(): + while True: + r = urllib.request.Request("http://%s/2020-01-01/extension/event/next" % api, + headers={"Lambda-Extension-Identifier": ident}) + body = urllib.request.urlopen(r).read().decode() + with open("/ext-out/internal.log", "a") as f: + f.write(body + "\n") + threading.Thread(target=loop, daemon=True).start() + +_internal() + +def handler(event, context): + return {"flag": os.environ.get("EXT_FLAG"), "arn": context.invoked_function_arn} +` + +func buildExtensionBinary(t *testing.T) []byte { + t.Helper() + + dir := t.TempDir() + src := filepath.Join(dir, "main.go") + require.NoError(t, os.WriteFile(src, []byte(extensionSource), 0o600)) + + out := filepath.Join(dir, "ext-bin") + cmd := exec.CommandContext(t.Context(), "go", "build", "-o", out, src) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOFLAGS=", "GOWORK=off", "GO111MODULE=off") + + if combined, err := cmd.CombinedOutput(); err != nil { + t.Skipf("cannot build extension binary: %v: %s", err, combined) + } + + bin, err := os.ReadFile(out) + require.NoError(t, err) + + return bin +} + +func extensionLayerZip(t *testing.T, bin []byte) []byte { + t.Helper() + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + hdr := &zip.FileHeader{Name: "extensions/ext-bin", Method: zip.Deflate} + hdr.SetMode(0o755) + + w, err := zw.CreateHeader(hdr) + require.NoError(t, err) + _, err = w.Write(bin) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + return buf.Bytes() +} + +func TestLambdaExtensionsAndDockerFlagsDocker(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("docker test") + } + + if err := exec.CommandContext(t.Context(), "docker", "image", "inspect", efsDockerRuntimeImage).Run(); err != nil { + t.Skipf("lambda runtime image %s not available locally: %v", efsDockerRuntimeImage, err) + } + + rt, err := container.NewRuntime(container.Config{ + Logger: slog.Default(), PoolSize: 2, IdleTimeout: time.Minute, + }) + if err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + t.Cleanup(func() { _ = rt.Close() }) + + outDir := t.TempDir() + require.NoError(t, os.Chmod(outDir, 0o777)) // #nosec G302 -- container root writes here + + settings := lambdabackend.DefaultSettings() + settings.DockerFlags = "-e EXT_FLAG=visible -v " + outDir + ":/ext-out --privileged" + + pa := portalloctest.New(t, 20) + bk := lambdabackend.NewInMemoryBackend(rt, pa, settings, "000000000000", "us-east-1") + t.Cleanup(func() { bk.Close(context.Background()) }) + + layer, err := bk.PublishLayerVersion(&lambdabackend.PublishLayerVersionInput{ + LayerName: "ext-layer", + Content: &lambdabackend.LayerVersionContentInput{ZipFile: extensionLayerZip(t, buildExtensionBinary(t))}, + }) + require.NoError(t, err) + + require.NoError(t, bk.CreateFunction(&lambdabackend.FunctionConfiguration{ + FunctionName: "ext-fn", + PackageType: lambdabackend.PackageTypeZip, + Runtime: "python3.12", + Handler: "ext_handler.handler", + ZipData: zipOf(t, "ext_handler.py", extensionHandler), + Timeout: 60, + Layers: []*lambdabackend.FunctionLayer{{Arn: layer.LayerVersionArn}}, + })) + + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + + var firstReq string + + for range 2 { + out, status, invokeErr := bk.InvokeFunction( + ctx, + "ext-fn", + lambdabackend.InvocationTypeRequestResponse, + []byte(`{}`), + ) + require.NoError(t, invokeErr) + require.Equal(t, 200, status) + assert.Contains(t, string(out), `"flag": "visible"`, "LAMBDA_DOCKER_FLAGS -e reached the container") + assert.Contains(t, string(out), "arn:aws:lambda:us-east-1:000000000000:function:ext-fn") + + if firstReq == "" { + firstReq = string(out) + } + } + + readLog := func(name string) string { + b, _ := os.ReadFile(filepath.Join(outDir, name)) + + return string(b) + } + + require.Eventually(t, func() bool { + return strings.Count(readLog("external.log"), `"eventType":"INVOKE"`) == 2 && + strings.Count(readLog("internal.log"), `"eventType":"INVOKE"`) == 2 + }, 30*time.Second, 50*time.Millisecond, "external=%q internal=%q", readLog("external.log"), readLog("internal.log")) + + assert.Contains(t, readLog("external.log"), `"functionName":"ext-fn"`) + assert.NotContains(t, readLog("external.log"), "SHUTDOWN") + + require.NoError(t, bk.DeleteFunction("ext-fn")) + + require.Eventually(t, func() bool { + return strings.Contains(readLog("external.log"), `"eventType":"SHUTDOWN"`) + }, 30*time.Second, 50*time.Millisecond, "external=%q", readLog("external.log")) + + assert.Contains(t, readLog("external.log"), `"shutdownReason":"spindown"`) + assert.NotContains(t, readLog("internal.log"), "SHUTDOWN", "internal extensions cannot register for SHUTDOWN") +} diff --git a/internal/app/lambda_runtime_env_test.go b/internal/app/lambda_runtime_env_test.go index 72d1f6824e..abcfe3a25d 100644 --- a/internal/app/lambda_runtime_env_test.go +++ b/internal/app/lambda_runtime_env_test.go @@ -72,7 +72,7 @@ func newWiredServices(t *testing.T, cli *CLI) map[string]service.Registerable { portAlloc := portalloctest.New(t, 50) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), PortAlloc: portAlloc, }) require.NoError(t, err) diff --git a/internal/app/lambda_s3_code_wiring_test.go b/internal/app/lambda_s3_code_wiring_test.go index 841742a963..128e8c9a22 100644 --- a/internal/app/lambda_s3_code_wiring_test.go +++ b/internal/app/lambda_s3_code_wiring_test.go @@ -64,7 +64,7 @@ func TestInitializeServices_LambdaS3CodeWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/leak_abort_test.go b/internal/app/leak_abort_test.go new file mode 100644 index 0000000000..c864f54ed9 --- /dev/null +++ b/internal/app/leak_abort_test.go @@ -0,0 +1,112 @@ +package app + +import ( + "bytes" + "context" + "io" + "net/http" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdks3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/require" +) + +// TestLeakAbortedRequests drives client-cancelled, half-read and half-sent +// requests through the full router stack and asserts goroutines and heap stay bounded. +// +//nolint:paralleltest // measures process-wide heap and goroutine counts +func TestLeakAbortedRequests(t *testing.T) { + skipLeakInShort(t) + + env := newLeakEnv(t) + c := leakS3(env) + ctx := context.Background() + big := bytes.Repeat([]byte("s"), 256<<10) + + mustS3Bucket(t, c, "leak-abort", false) + _, err := c.PutObject(ctx, &sdks3.PutObjectInput{ + Bucket: aws.String("leak-abort"), Key: aws.String("big"), Body: bytes.NewReader(big), + }) + require.NoError(t, err) + + endpoint := aws.ToString(env.cfg.BaseEndpoint) + url := endpoint + "/leak-abort/big" + hc := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} + t.Cleanup(hc.CloseIdleConnections) + + tests := []struct { + op func(t *testing.T, i int) + name string + }{ + {name: "cancel_before_response", op: func(t *testing.T, _ int) { + t.Helper() + + rctx, cancel := context.WithCancel(ctx) + cancel() + + req, rerr := http.NewRequestWithContext(rctx, http.MethodGet, url, nil) + require.NoError(t, rerr) + + resp, derr := hc.Do(req) + if derr == nil { + _ = resp.Body.Close() + } + }}, + {name: "read_partial_body_then_close", op: func(t *testing.T, _ int) { + t.Helper() + + rctx, cancel := context.WithCancel(ctx) + defer cancel() + + req, rerr := http.NewRequestWithContext(rctx, http.MethodGet, url, nil) + require.NoError(t, rerr) + + resp, derr := hc.Do(req) + require.NoError(t, derr) + + _, rerr = io.CopyN(io.Discard, resp.Body, 1024) + require.NoError(t, rerr) + cancel() + _ = resp.Body.Close() + }}, + {name: "abort_upload_midstream", op: func(t *testing.T, i int) { + t.Helper() + + rctx, cancel := context.WithCancel(ctx) + defer cancel() + + pr, pw := io.Pipe() + upURL := endpoint + "/leak-abort/up" + strconv.Itoa(i%4) + req, rerr := http.NewRequestWithContext(rctx, http.MethodPut, upURL, pr) + require.NoError(t, rerr) + + req.ContentLength = int64(len(big)) + + done := make(chan struct{}) + + go func() { + defer close(done) + + resp, derr := hc.Do(req) + if derr == nil { + _ = resp.Body.Close() + } + }() + + _, werr := pw.Write(big[:4096]) + require.NoError(t, werr) + cancel() + _ = pw.CloseWithError(context.Canceled) + <-done + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + budget := leakBudget{heapBytes: 2 << 20, heapObjs: 10_000, series: 40} + assertNoLeak(t, env, budget, func(i int) { tt.op(t, i) }) + }) + } +} diff --git a/internal/app/leak_e2e_test.go b/internal/app/leak_e2e_test.go index 7fafb8c54a..0279cab46b 100644 --- a/internal/app/leak_e2e_test.go +++ b/internal/app/leak_e2e_test.go @@ -74,7 +74,7 @@ func newLeakEnv(t *testing.T) *leakEnv { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) @@ -309,6 +309,36 @@ func TestLeakS3(t *testing.T) { require.NoError(t, err) } }, settle: true}, + {name: "multipart_abandoned_bucket_delete", op: func(t *testing.T, c *sdks3.Client) func(int) { + t.Helper() + + return func(i int) { + b := aws.String("leak-mpa-" + strconv.Itoa(i)) + _, err := c.CreateBucket(ctx, &sdks3.CreateBucketInput{Bucket: b}) + require.NoError(t, err) + + cm, err := c.CreateMultipartUpload(ctx, &sdks3.CreateMultipartUploadInput{ + Bucket: b, Key: aws.String("mp"), + }) + require.NoError(t, err) + + _, err = c.UploadPart(ctx, &sdks3.UploadPartInput{ + Bucket: b, Key: aws.String("mp"), UploadId: cm.UploadId, + PartNumber: aws.Int32(1), Body: bytes.NewReader(payload), + }) + require.NoError(t, err) + + _, err = c.DeleteBucket(ctx, &sdks3.DeleteBucketInput{Bucket: b}) + if err != nil { + _, err = c.AbortMultipartUpload(ctx, &sdks3.AbortMultipartUploadInput{ + Bucket: b, Key: aws.String("mp"), UploadId: cm.UploadId, + }) + require.NoError(t, err) + _, err = c.DeleteBucket(ctx, &sdks3.DeleteBucketInput{Bucket: b}) + require.NoError(t, err) + } + } + }, settle: true}, {name: "versioned_put_delete_version", op: func(t *testing.T, c *sdks3.Client) func(int) { t.Helper() mustS3Bucket(t, c, "leak-ver", true) diff --git a/internal/app/lifecycle_cmd.go b/internal/app/lifecycle_cmd.go index 5b26b98f06..090fa06b9c 100644 --- a/internal/app/lifecycle_cmd.go +++ b/internal/app/lifecycle_cmd.go @@ -26,6 +26,10 @@ const ( lifecycleStateFilePerm = 0o600 lifecycleDefaultTail = 100 lifecycleLogTailMaxByte = 1 << 20 + lifecycleSocketInside = "/var/run/docker.sock" + lifecycleSchemeHTTPS = "https" + lifecycleStopGrace = 2 * lifecycleStopTimeout + localstackAvailable = "available" ) var ( @@ -34,6 +38,8 @@ var ( errLifecycleStartFail = errors.New("gopherstack failed to become ready") errLifecycleBadEnv = errors.New("invalid --env, want KEY=VALUE or KEY") errLifecycleNoRuntime = errors.New("container runtime does not support this operation") + errLifecycleHostOnly = errors.New("--foreground and --docker-socket apply to container mode, not --host") + errLifecycleSocketPath = errors.New("--docker-socket-path requires --docker-socket") ) // LifecycleTarget identifies the managed instance on disk. @@ -44,17 +50,20 @@ type LifecycleTarget struct { // StartCmd is `gopherstack start`. type StartCmd struct { - LifecycleTarget `embed:""` - Image string ` name:"image" env:"GOPHERSTACK_IMAGE" default:"ghcr.io/blackbirdworks/gopherstack" help:"Container image repository."` //nolint:lll // config struct tags are intentionally verbose - Tag string ` name:"tag" env:"GOPHERSTACK_TAG" default:"latest" help:"Image tag."` //nolint:lll // config struct tags are intentionally verbose - Bind string ` name:"bind" default:"127.0.0.1" help:"Host address to bind published ports to (default loopback)."` //nolint:lll // config struct tags are intentionally verbose - Port string ` name:"port" env:"PORT" default:"8000" help:"Host port for the main HTTP endpoint."` //nolint:lll // config struct tags are intentionally verbose - DataDir string ` name:"data-dir" help:"Host directory mounted as the data dir (default with --persist: state dir)."` //nolint:lll // config struct tags are intentionally verbose - Publish []string ` name:"publish" help:"Extra container ports to publish, [HOSTPORT:]CONTAINERPORT (repeatable)." short:"p"` //nolint:lll // config struct tags are intentionally verbose - Env []string ` name:"env" help:"Environment for the server, KEY=VALUE or KEY (copied from this shell)." short:"e"` //nolint:lll // config struct tags are intentionally verbose - Timeout time.Duration ` name:"timeout" default:"90s" help:"How long to wait for the server to become healthy."` //nolint:lll // config struct tags are intentionally verbose - Host bool ` name:"host" help:"Run as a background host process instead of a container."` //nolint:lll // config struct tags are intentionally verbose - Persist bool ` name:"persist" help:"Enable snapshot-based persistence across restarts."` //nolint:lll // config struct tags are intentionally verbose + LifecycleTarget `embed:""` + Image string ` name:"image" env:"GOPHERSTACK_IMAGE" default:"ghcr.io/blackbirdworks/gopherstack" help:"Container image repository."` //nolint:lll // config struct tags are intentionally verbose + Tag string ` name:"tag" env:"GOPHERSTACK_TAG" default:"latest" help:"Image tag."` //nolint:lll // config struct tags are intentionally verbose + Bind string ` name:"bind" default:"127.0.0.1" help:"Host address to bind published ports to (default loopback)."` //nolint:lll // config struct tags are intentionally verbose + Port string ` name:"port" env:"PORT" default:"8000" help:"Host port for the main HTTP endpoint."` //nolint:lll // config struct tags are intentionally verbose + DataDir string ` name:"data-dir" help:"Host directory mounted as the data dir (default with --persist: state dir)."` //nolint:lll // config struct tags are intentionally verbose + DockerSocketPath string ` name:"docker-socket-path" help:"Host Docker socket to mount with --docker-socket (default: unix DOCKER_HOST, else /var/run/docker.sock)."` //nolint:lll // config struct tags are intentionally verbose + Publish []string ` name:"publish" help:"Extra container ports to publish, [HOSTPORT:]CONTAINERPORT (repeatable)." short:"p"` //nolint:lll // config struct tags are intentionally verbose + Env []string ` name:"env" help:"Environment for the server, KEY=VALUE or KEY (copied from this shell)." short:"e"` //nolint:lll // config struct tags are intentionally verbose + Timeout time.Duration ` name:"timeout" default:"90s" help:"How long to wait for the server to become healthy."` //nolint:lll // config struct tags are intentionally verbose + Host bool ` name:"host" help:"Run as a background host process instead of a container."` //nolint:lll // config struct tags are intentionally verbose + Foreground bool ` name:"foreground" help:"Attach: stream container logs, stop on SIGINT/SIGTERM, exit with the container's exit code (container mode only)."` //nolint:lll // config struct tags are intentionally verbose + DockerSocket bool ` name:"docker-socket" help:"SECURITY: mount the host Docker socket into the container for engine features (Lambda, ECS, RDS, ...). Grants the server root-equivalent control of the host Docker daemon; off by default. Container mode only."` //nolint:lll // config struct tags are intentionally verbose + Persist bool ` name:"persist" help:"Enable snapshot-based persistence across restarts."` //nolint:lll // config struct tags are intentionally verbose } // StopCmd is `gopherstack stop`. diff --git a/internal/app/lifecycle_deps.go b/internal/app/lifecycle_deps.go index e65d806616..5dd472537a 100644 --- a/internal/app/lifecycle_deps.go +++ b/internal/app/lifecycle_deps.go @@ -99,30 +99,3 @@ func spawnHostProcess(spec hostSpawn) (int, error) { return pid, nil } - -func processAlive(pid int) bool { - p, err := os.FindProcess(pid) - if err != nil { - return false - } - - return p.Signal(syscall.Signal(0)) == nil -} - -func terminateProcess(pid int, force bool) error { - p, err := os.FindProcess(pid) - if err != nil { - return fmt.Errorf("find process %d: %w", pid, err) - } - - sig := os.Interrupt - if force { - sig = os.Kill - } - - if err = p.Signal(sig); err != nil { - return fmt.Errorf("signal process %d: %w", pid, err) - } - - return nil -} diff --git a/internal/app/lifecycle_detach_windows.go b/internal/app/lifecycle_detach_windows.go index 621f34c283..f7b675ccad 100644 --- a/internal/app/lifecycle_detach_windows.go +++ b/internal/app/lifecycle_detach_windows.go @@ -2,6 +2,13 @@ package app -import "os/exec" +import ( + "os/exec" + "syscall" +) -func detachProcess(*exec.Cmd) {} +const winCreateNewProcessGroup = 0x200 + +func detachProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: winCreateNewProcessGroup} +} diff --git a/internal/app/lifecycle_foreground.go b/internal/app/lifecycle_foreground.go new file mode 100644 index 0000000000..009428d175 --- /dev/null +++ b/internal/app/lifecycle_foreground.go @@ -0,0 +1,128 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +const lifecycleDefaultSocket = "/var/run/docker.sock" + +// lifecycleExitError carries a container's non-zero exit code to the process exit status. +type lifecycleExitError struct{ Code int } + +func (e *lifecycleExitError) Error() string { + return fmt.Sprintf("container exited with code %d", e.Code) +} + +// lifecycleExitCode reports the exit status requested by err, if any. +func lifecycleExitCode(err error) (int, bool) { + if ee, ok := errors.AsType[*lifecycleExitError](err); ok { + return ee.Code, true + } + + return 0, false +} + +func (c *StartCmd) validate() error { + if c.Host && (c.Foreground || c.DockerSocket) { + return errLifecycleHostOnly + } + + if c.DockerSocketPath != "" && !c.DockerSocket { + return errLifecycleSocketPath + } + + return nil +} + +func (c *StartCmd) dockerSocketPath(getenv func(string) string) string { + if c.DockerSocketPath != "" { + return c.DockerSocketPath + } + + if p, ok := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://"); ok && p != "" { + return p + } + + return lifecycleDefaultSocket +} + +type exitResult struct { + err error + code int +} + +// attach streams the container's logs until it exits or ctx is cancelled (SIGINT/SIGTERM), +// then stops it gracefully and reports its exit code. +func (d *lifecycleDeps) attach( + ctx context.Context, + rt lifecycleRuntime, + waiter container.ExitWaiter, + target LifecycleTarget, + id string, +) error { + bg := context.WithoutCancel(ctx) + logCtx, stopLogs := context.WithCancel(bg) + logsDone := make(chan struct{}) + + go func() { + defer close(logsDone) + + _ = rt.ReadLogs(logCtx, id, "", true, d.Out, d.Err) + }() + + waitCtx, stopWait := context.WithCancel(bg) + defer stopWait() + + exited := make(chan exitResult, 1) + + go func() { + code, err := waiter.WaitExit(waitCtx, id) + exited <- exitResult{code: code, err: err} + }() + + var res exitResult + + select { + case res = <-exited: + case <-ctx.Done(): + stopCtx, cancel := context.WithTimeout(bg, lifecycleStopGrace) + defer cancel() + + if err := rt.StopAndRemove(stopCtx, id); err != nil && !notFoundErr(err) { + stopLogs() + <-logsDone + + return fmt.Errorf("stop container: %w", err) + } + + select { + case res = <-exited: + case <-stopCtx.Done(): + res.err = stopCtx.Err() + } + } + + stopLogs() + <-logsDone + + if err := rt.StopAndRemove(bg, id); err != nil && !notFoundErr(err) && ctx.Err() == nil { + fmt.Fprintf(d.Err, "warning: remove container: %v\n", err) + } + + d.dropState(target) + + if res.err != nil && res.code == 0 { + return fmt.Errorf("wait for container: %w", res.err) + } + + if res.code != 0 { + return &lifecycleExitError{Code: res.code} + } + + return nil +} diff --git a/internal/app/lifecycle_foreground_test.go b/internal/app/lifecycle_foreground_test.go new file mode 100644 index 0000000000..b9d30e8e1a --- /dev/null +++ b/internal/app/lifecycle_foreground_test.go @@ -0,0 +1,378 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +var errKillFake = errors.New("no console") + +// fgRuntime fakes a runtime whose container stays up until exited or stopped. +type fgRuntime struct { + ready *atomic.Bool + exit chan int + fakeLifecycleRuntime + stopOnce sync.Once + stopCode int +} + +func newFGRuntime() *fgRuntime { + return &fgRuntime{ready: &atomic.Bool{}, exit: make(chan int, 1)} +} + +func (f *fgRuntime) CreateAndStart(ctx context.Context, spec container.Spec) (string, error) { + f.ready.Store(true) + + return f.fakeLifecycleRuntime.CreateAndStart(ctx, spec) +} + +func (f *fgRuntime) StopAndRemove(ctx context.Context, id string) error { + if f.ready.Load() && len(f.exit) == 0 { + f.stopOnce.Do(func() { f.exit <- f.stopCode }) + } + + return f.fakeLifecycleRuntime.StopAndRemove(ctx, id) +} + +func (f *fgRuntime) ReadLogs(ctx context.Context, _, _ string, follow bool, stdout, _ io.Writer) error { + _, _ = stdout.Write([]byte("hello from container\n")) + + if follow { + <-ctx.Done() + } + + return nil +} + +func (f *fgRuntime) WaitExit(ctx context.Context, _ string) (int, error) { + select { + case code := <-f.exit: + f.exit <- code + + return code, nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func readyServer(t *testing.T, ready *atomic.Bool) string { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if !ready.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + + return + } + + _ = json.NewEncoder(w).Encode(healthResponse{Status: "ok"}) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + return u.Port() +} + +func TestLifecycleForeground(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantCode int + cancel bool + exitCode int + stopCode int + }{ + {name: "container exits zero", exitCode: 0}, + {name: "container exit code propagates", exitCode: 3, wantCode: 3}, + {name: "signal stops gracefully", cancel: true, stopCode: 0}, + {name: "signal stop non zero", cancel: true, stopCode: 143, wantCode: 143}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := newFGRuntime() + rt.stopCode = tt.stopCode + d, out, dir := testLifecycleDeps(t, rt) + port := readyServer(t, rt.ready) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + if !tt.cancel { + rt.exit <- tt.exitCode + } + + cmd := StartCmd{ + StateDir: dir, Tag: "latest", Bind: "127.0.0.1", Port: port, + Timeout: lifecycleReadyTimeout, Foreground: true, + } + + done := make(chan error, 1) + started := make(chan struct{}) + d.Out = &syncWriter{w: out, started: started, marker: "gopherstack started"} + + go func() { done <- cmd.run(ctx, d) }() + + if tt.cancel { + <-started + cancel() + } + + err := <-done + code, isExit := lifecycleExitCode(err) + + if tt.wantCode == 0 { + require.NoError(t, err) + } else { + require.True(t, isExit, "err=%v", err) + assert.Equal(t, tt.wantCode, code) + } + + assert.Contains(t, out.String(), "hello from container") + _, ok := d.loadState(cmd.LifecycleTarget) + assert.False(t, ok, "state dropped") + }) + } +} + +func TestLifecycleForegroundNeedsWaiter(t *testing.T) { + t.Parallel() + + rt := &fakeLifecycleRuntime{} + d, _, dir := testLifecycleDeps(t, rt) + cmd := StartCmd{ + StateDir: dir, Tag: "latest", Bind: "127.0.0.1", Port: "1", + Timeout: lifecycleReadyTimeout, Foreground: true, + } + + require.ErrorIs(t, cmd.run(t.Context(), d), errLifecycleNoRuntime) + assert.Empty(t, rt.specs) +} + +func TestLifecycleStartValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + want error + name string + cmd StartCmd + }{ + {name: "host foreground", cmd: StartCmd{Host: true, Foreground: true}, want: errLifecycleHostOnly}, + {name: "host socket", cmd: StartCmd{Host: true, DockerSocket: true}, want: errLifecycleHostOnly}, + {name: "path without flag", cmd: StartCmd{DockerSocketPath: "/x"}, want: errLifecycleSocketPath}, + {name: "ok", cmd: StartCmd{DockerSocket: true, Foreground: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.cmd.validate() + if tt.want == nil { + require.NoError(t, err) + + return + } + + require.ErrorIs(t, err, tt.want) + }) + } +} + +func TestLifecycleDockerSocketMount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dockerEnv string + wantMount string + cmd StartCmd + noSocket bool + }{ + {name: "default off", noSocket: true}, + { + name: "default path", cmd: StartCmd{DockerSocket: true}, + wantMount: "/var/run/docker.sock:/var/run/docker.sock", + }, + { + name: "docker host env", cmd: StartCmd{DockerSocket: true}, dockerEnv: "unix:///run/user/1/docker.sock", + wantMount: "/run/user/1/docker.sock:/var/run/docker.sock", + }, + { + name: "explicit path wins", cmd: StartCmd{DockerSocket: true, DockerSocketPath: "/s.sock"}, + dockerEnv: "unix:///other", wantMount: "/s.sock:/var/run/docker.sock", + }, + { + name: "tcp docker host ignored", cmd: StartCmd{DockerSocket: true}, dockerEnv: "tcp://h:2375", + wantMount: "/var/run/docker.sock:/var/run/docker.sock", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := &fakeLifecycleRuntime{} + d, out, dir := testLifecycleDeps(t, rt) + d.Getenv = func(k string) string { + if k == "DOCKER_HOST" { + return tt.dockerEnv + } + + return "" + } + + cmd := tt.cmd + cmd.StateDir, cmd.Tag, cmd.Bind, cmd.Timeout = dir, "latest", "127.0.0.1", lifecycleReadyTimeout + cmd.Port = "1" + + // readiness fails on port 1; only the created spec matters here. + cmd.Timeout = 1 + _ = cmd.run(t.Context(), d) + + require.Len(t, rt.specs, 1) + + spec := rt.specs[0] + if tt.noSocket { + assert.NotContains(t, strings.Join(spec.Mounts, ","), "docker.sock") + assert.NotContains(t, strings.Join(spec.Env, ","), "DOCKER_HOST") + assert.NotContains(t, out.String(), "warning") + + return + } + + assert.Contains(t, spec.Mounts, tt.wantMount) + assert.Contains(t, spec.Env, "DOCKER_HOST=unix:///var/run/docker.sock") + assert.Contains(t, out.String(), "root-equivalent") + }) + } +} + +type fakeKiller struct { + breakErr error + calls []string +} + +func (f *fakeKiller) CtrlBreak(int) error { + f.calls = append(f.calls, "break") + + return f.breakErr +} + +func (f *fakeKiller) Kill(int) error { + f.calls = append(f.calls, "kill") + + return nil +} + +func TestTerminateWith(t *testing.T) { + t.Parallel() + + tests := []struct { + breakErr error + name string + want []string + force bool + }{ + {name: "graceful", want: []string{"break"}}, + {name: "graceful falls back to kill", breakErr: errKillFake, want: []string{"break", "kill"}}, + {name: "force kills only", force: true, want: []string{"kill"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + k := &fakeKiller{breakErr: tt.breakErr} + require.NoError(t, terminateWith(k, 42, tt.force)) + assert.Equal(t, tt.want, k.calls) + }) + } +} + +func TestLifecycleStatusServiceHealth(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_gopherstack/health": + _ = json.NewEncoder(w).Encode(healthResponse{ + Status: "ok", Version: "v1", UptimeSecs: 5, Goroutines: 12, HeapAllocB: 3 << 20, + Services: []string{"S3", "SQS"}, Disabled: []string{"SNS"}, + }) + case "/_localstack/health": + _ = json.NewEncoder(w).Encode(localstackHealthResponse{Services: map[string]string{"s3": "available"}}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + d, out, dir := testLifecycleDeps(t, nil) + cmd := StatusCmd{StateDir: dir, Endpoint: srv.URL, JSON: true} + require.NoError(t, cmd.run(t.Context(), d)) + + var rep statusReport + require.NoError(t, json.Unmarshal(out.Bytes(), &rep)) + + got := map[string]serviceState{} + for _, s := range rep.Clouds["aws"] { + got[s.Name] = s + } + + assert.Equal(t, serviceState{Name: "S3", State: "enabled", Port: u.Port(), Health: "healthy"}, got["S3"]) + assert.Equal(t, "unavailable", got["SQS"].Health) + assert.Equal(t, serviceState{Name: "SNS", State: "disabled", Health: "disabled"}, got["SNS"]) + assert.Equal(t, 12, rep.Goroutines) + assert.Equal(t, uint64(3<<20), rep.HeapAllocB) + + out.Reset() + cmd.JSON = false + require.NoError(t, cmd.run(t.Context(), d)) + assert.Regexp(t, `aws\s+S3\s+enabled\s+`+u.Port()+`\s+healthy`, out.String()) + assert.Regexp(t, `aws\s+SNS\s+disabled\s+-\s+disabled`, out.String()) + assert.Contains(t, out.String(), "3.0 MiB heap, 12 goroutines") +} + +// syncWriter closes started once marker is written, serialising writes. +type syncWriter struct { + w io.Writer + started chan struct{} + marker string + mu sync.Mutex + once sync.Once +} + +func (s *syncWriter) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + n, err := s.w.Write(p) + if strings.Contains(string(p), s.marker) { + s.once.Do(func() { close(s.started) }) + } + + return n, err +} diff --git a/internal/app/lifecycle_proc_kill.go b/internal/app/lifecycle_proc_kill.go new file mode 100644 index 0000000000..039c4388f2 --- /dev/null +++ b/internal/app/lifecycle_proc_kill.go @@ -0,0 +1,25 @@ +package app + +import "fmt" + +// procKiller is the host-process control surface; Windows has no POSIX signals. +type procKiller interface { + CtrlBreak(pid int) error + Kill(pid int) error +} + +// terminateWith asks for a graceful stop and falls back to Kill when none can be delivered +// (no shared console); a forced stop always kills. +func terminateWith(k procKiller, pid int, force bool) error { + if !force { + if k.CtrlBreak(pid) == nil { + return nil + } + } + + if err := k.Kill(pid); err != nil { + return fmt.Errorf("kill process %d: %w", pid, err) + } + + return nil +} diff --git a/internal/app/lifecycle_proc_unix.go b/internal/app/lifecycle_proc_unix.go new file mode 100644 index 0000000000..1a4134118a --- /dev/null +++ b/internal/app/lifecycle_proc_unix.go @@ -0,0 +1,36 @@ +//go:build !windows + +package app + +import ( + "fmt" + "os" + "syscall" +) + +func processAlive(pid int) bool { + p, err := os.FindProcess(pid) + if err != nil { + return false + } + + return p.Signal(syscall.Signal(0)) == nil +} + +func terminateProcess(pid int, force bool) error { + p, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find process %d: %w", pid, err) + } + + sig := os.Interrupt + if force { + sig = os.Kill + } + + if err = p.Signal(sig); err != nil { + return fmt.Errorf("signal process %d: %w", pid, err) + } + + return nil +} diff --git a/internal/app/lifecycle_proc_windows.go b/internal/app/lifecycle_proc_windows.go new file mode 100644 index 0000000000..93e554d147 --- /dev/null +++ b/internal/app/lifecycle_proc_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package app + +import ( + "fmt" + "math" + "os" + "syscall" +) + +const ( + winProcessQueryLimited = 0x1000 + winStillActive = 259 + winCtrlBreakEvent = 1 +) + +var procGenerateCtrlEvent = syscall.NewLazyDLL("kernel32.dll").NewProc("GenerateConsoleCtrlEvent") + +type winKiller struct{} + +func (winKiller) CtrlBreak(pid int) error { + r, _, err := procGenerateCtrlEvent.Call(uintptr(winCtrlBreakEvent), uintptr(pid)) + if r == 0 { + return fmt.Errorf("ctrl-break pid %d: %w", pid, err) + } + + return nil +} + +func (winKiller) Kill(pid int) error { + p, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find process %d: %w", pid, err) + } + + return p.Kill() +} + +func processAlive(pid int) bool { + if pid <= 0 || pid > math.MaxInt32 { + return false + } + + h, err := syscall.OpenProcess(winProcessQueryLimited, false, uint32(pid)) + if err != nil { + return false + } + defer syscall.CloseHandle(h) + + var code uint32 + if syscall.GetExitCodeProcess(h, &code) != nil { + return false + } + + return code == winStillActive +} + +func terminateProcess(pid int, force bool) error { return terminateWith(winKiller{}, pid, force) } diff --git a/internal/app/lifecycle_run.go b/internal/app/lifecycle_run.go index 832384d356..9ba2fe5363 100644 --- a/internal/app/lifecycle_run.go +++ b/internal/app/lifecycle_run.go @@ -172,6 +172,10 @@ func (c *StartCmd) Run(d *lifecycleDeps) error { func (c *StartCmd) run(ctx context.Context, d *lifecycleDeps) error { endpoint := endpointFor(c.Bind, c.Port) + if err := c.validate(); err != nil { + return err + } + if _, err := d.health(ctx, endpoint); err == nil { fmt.Fprintf(d.Out, "gopherstack is already running at %s\n", endpoint) @@ -216,13 +220,23 @@ func (c *StartCmd) runContainer(ctx context.Context, d *lifecycleDeps, endpoint, return err } + var waiter container.ExitWaiter + + if c.Foreground { + var ok bool + if waiter, ok = rt.(container.ExitWaiter); !ok { + return fmt.Errorf("%w: cannot wait for exit", errLifecycleNoRuntime) + } + } + _ = rt.StopAndRemove(ctx, c.name()) env := append([]string{"PORT=" + lifecycleContainerPort}, extra...) spec := container.Spec{ - Image: c.Image + ":" + c.Tag, - Name: c.name(), - Ports: c.publishPorts(), + Image: c.Image + ":" + c.Tag, + Name: c.name(), + Ports: c.publishPorts(), + Unmanaged: true, } if c.Persist { @@ -234,6 +248,14 @@ func (c *StartCmd) runContainer(ctx context.Context, d *lifecycleDeps, endpoint, spec.Mounts = []string{dataDir + ":" + lifecycleContainerData} } + if c.DockerSocket { + sock := c.dockerSocketPath(d.Getenv) + spec.Mounts = append(spec.Mounts, sock+":"+lifecycleSocketInside) + env = append(env, "DOCKER_HOST=unix://"+lifecycleSocketInside) + fmt.Fprintf(d.Err, "warning: mounting %s gives the server root-equivalent control of the host Docker daemon\n", + sock) + } + spec.Env = env id, err := rt.CreateAndStart(ctx, spec) @@ -261,6 +283,10 @@ func (c *StartCmd) runContainer(ctx context.Context, d *lifecycleDeps, endpoint, fmt.Fprintf(d.Out, "gopherstack started (container %s, %s) at %s\n", shortID(id), spec.Image, endpoint) + if c.Foreground { + return d.attach(ctx, rt, waiter, c.LifecycleTarget, id) + } + return nil } @@ -415,11 +441,55 @@ type statusReport struct { EnabledCount int `json:"enabled_services"` DisabledCount int `json:"disabled_services"` Running bool `json:"running"` + Goroutines int `json:"goroutines,omitempty"` + HeapAllocB uint64 `json:"heap_alloc_bytes,omitempty"` } type serviceState struct { - Name string `json:"name"` - State string `json:"state"` + Name string `json:"name"` + State string `json:"state"` + Port string `json:"port,omitempty"` + Health string `json:"health"` +} + +const ( + bytesPerMiB = 1 << 20 + healthHealthy = "healthy" + healthUnavailable = "unavailable" + healthDisabled = "disabled" +) + +func endpointPort(endpoint string) string { + u, err := url.Parse(endpoint) + if err != nil { + return "" + } + + if p := u.Port(); p != "" { + return p + } + + if u.Scheme == lifecycleSchemeHTTPS { + return "443" + } + + return "80" +} + +// serviceHealth returns the per-service availability from the LocalStack-compatible health route, +// or nil when the server does not serve it. +func (d *lifecycleDeps) serviceHealth(ctx context.Context, endpoint string) map[string]string { + body, code, err := d.request(ctx, http.MethodGet, strings.TrimRight(endpoint, "/")+"/_localstack/health") + if err != nil || code != http.StatusOK { + return nil + } + + var r localstackHealthResponse + if json.Unmarshal(body, &r) != nil { + return nil + } + + return r.Services } func cloudOf(name string) string { @@ -433,17 +503,33 @@ func cloudOf(name string) string { } } -func buildStatus(endpoint string, h healthResponse, st *lifecycleState) statusReport { +func buildStatus( + endpoint string, h healthResponse, st *lifecycleState, svcHealth map[string]string, +) statusReport { r := statusReport{ Running: true, Endpoint: endpoint, Version: h.Version, UptimeSecs: h.UptimeSecs, Uptime: (time.Duration(h.UptimeSecs) * time.Second).String(), Managed: st, - Clouds: map[string][]serviceState{}, EnabledCount: len(h.Services), DisabledCount: len(h.Disabled), + Clouds: map[string][]serviceState{}, Goroutines: h.Goroutines, HeapAllocB: h.HeapAllocB, + EnabledCount: len(h.Services), DisabledCount: len(h.Disabled), } + port := endpointPort(endpoint) + add := func(names []string, state string) { for _, n := range names { c := cloudOf(n) - r.Clouds[c] = append(r.Clouds[c], serviceState{Name: n, State: state}) + ss := serviceState{Name: n, State: state, Health: healthDisabled} + + if state == "enabled" { + ss.Port = port + ss.Health = healthHealthy + + if svcHealth != nil && svcHealth[strings.ToLower(n)] != localstackAvailable { + ss.Health = healthUnavailable + } + } + + r.Clouds[c] = append(r.Clouds[c], ss) } } @@ -488,7 +574,7 @@ func (c *StatusCmd) run(ctx context.Context, d *lifecycleDeps) error { return errLifecycleNotRunning } - rep := buildStatus(endpoint, h, managed) + rep := buildStatus(endpoint, h, managed, d.serviceHealth(ctx, endpoint)) if c.JSON { enc := json.NewEncoder(d.Out) enc.SetIndent("", " ") @@ -512,14 +598,21 @@ func (r statusReport) print(w io.Writer) { } } - fmt.Fprintf(w, "Services: %d enabled, %d disabled\n\n", r.EnabledCount, r.DisabledCount) + fmt.Fprintf(w, "Memory: %.1f MiB heap, %d goroutines\n", float64(r.HeapAllocB)/bytesPerMiB, r.Goroutines) + fmt.Fprintf(w, "Services: %d enabled, %d disabled (one process: shared port and uptime)\n\n", + r.EnabledCount, r.DisabledCount) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) //nolint:mnd // tabwriter padding - fmt.Fprintln(tw, "CLOUD\tSERVICE\tSTATE") + fmt.Fprintln(tw, "CLOUD\tSERVICE\tSTATE\tPORT\tHEALTH") for _, cloud := range []string{"aws", "azure", "gcp"} { for _, s := range r.Clouds[cloud] { - fmt.Fprintf(tw, "%s\t%s\t%s\n", cloud, s.Name, s.State) + port := s.Port + if port == "" { + port = "-" + } + + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", cloud, s.Name, s.State, port, s.Health) } } diff --git a/internal/app/lightsail_cloudformation_wiring_test.go b/internal/app/lightsail_cloudformation_wiring_test.go index 70279e6182..3c1002aab3 100644 --- a/internal/app/lightsail_cloudformation_wiring_test.go +++ b/internal/app/lightsail_cloudformation_wiring_test.go @@ -30,7 +30,7 @@ func TestInitializeServices_LightsailCloudFormationWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/localstack_chaos_test.go b/internal/app/localstack_chaos_test.go index b36057c2af..af197dbc71 100644 --- a/internal/app/localstack_chaos_test.go +++ b/internal/app/localstack_chaos_test.go @@ -10,6 +10,8 @@ import ( "github.com/aws/smithy-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestLocalStackChaosAPIInjectsFaultsIntoSDKCalls(t *testing.T) { @@ -25,7 +27,7 @@ func TestLocalStackChaosAPIInjectsFaultsIntoSDKCalls(t *testing.T) { ) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/internal/app/localstack_compat.go b/internal/app/localstack_compat.go index b6b4cce524..5dacdc2e9d 100644 --- a/internal/app/localstack_compat.go +++ b/internal/app/localstack_compat.go @@ -528,6 +528,7 @@ func buildLocalstackPluginsHandler(services []service.Registerable) echo.Handler func registerLocalstackInternalEndpoints(e *echo.Echo, cli *CLI, services []service.Registerable) { e.GET("/_localstack/init", buildLocalstackInitHandler(cli.localstackInit, "")) e.GET("/_localstack/init/:stage", buildLocalstackInitHandler(cli.localstackInit, "")) + e.POST("/_gopherstack/init/run", buildInitRunHandler(cli.localstackInit)) e.GET("/_localstack/config", buildLocalstackConfigGet(cli)) e.POST("/_localstack/config", buildLocalstackConfigPost()) e.GET("/_localstack/diagnose", buildLocalstackDiagnoseHandler(cli, services)) diff --git a/internal/app/localstack_options.go b/internal/app/localstack_options.go index df2bfd0a18..daf8b14c1b 100644 --- a/internal/app/localstack_options.go +++ b/internal/app/localstack_options.go @@ -109,7 +109,6 @@ func localstackUnsupportedRuntime() []string { "S3FILES_EXPORT_DELAY_SECONDS", "SNS_SES_SENDER_ADDRESS", "SQS_CLOUDWATCH_METRICS_REPORT_INTERVAL", - "VERIFIEDPERMISSIONS_DISABLE_JWT_VERIFICATION", "DISABLE_CORS_HEADERS", "ENABLE_CONFIG_UPDATES", "SMTP_EMAIL", diff --git a/internal/app/mcp_wiring.go b/internal/app/mcp_wiring.go new file mode 100644 index 0000000000..fdc035e583 --- /dev/null +++ b/internal/app/mcp_wiring.go @@ -0,0 +1,74 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/labstack/echo/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/blackbirdworks/gopherstack/internal/mcpserver" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/version" +) + +const mcpPath = "/_gopherstack/mcp" + +// MCPConfig configures the built-in Model Context Protocol server. +type MCPConfig struct { + Token string `name:"token" env:"GOPHERSTACK_MCP_TOKEN" help:"Bearer token required by the MCP endpoint. Without it only loopback clients are accepted."` //nolint:lll // config struct tags are intentionally verbose + Enabled bool `name:"enabled" env:"GOPHERSTACK_MCP" help:"Serve MCP (streamable HTTP) at /_gopherstack/mcp."` //nolint:lll // config struct tags are intentionally verbose + ReadOnly bool `name:"read-only" env:"GOPHERSTACK_MCP_READ_ONLY" help:"Expose only read-only MCP tools (no snapshots, resets, chaos or init hook runs)."` //nolint:lll // config struct tags are intentionally verbose +} + +// registerMCPEndpoint mounts the MCP endpoint on e; its tools call e in-process. +func registerMCPEndpoint(ctx context.Context, log *slog.Logger, e *echo.Echo, cfg MCPConfig) { + if !cfg.Enabled { + return + } + + opts := mcpserver.InProcessOptions(e, version.Get()) + opts.ReadOnly = cfg.ReadOnly + h := mcpserver.NewHTTPHandler(opts, cfg.Token) + e.Any(mcpPath, echo.WrapHandler(h)) + e.Any(mcpPath+"/*", echo.WrapHandler(h)) + + if log != nil { + log.InfoContext(ctx, "MCP server enabled", "path", mcpPath, "token_required", cfg.Token != "") + } +} + +// McpCmd is `gopherstack mcp`: an MCP stdio server proxying a running instance. +type McpCmd struct { + PodTarget ` embed:""` + ReadOnly bool ` name:"read-only" env:"GOPHERSTACK_MCP_READ_ONLY" help:"Expose only read-only MCP tools."` +} + +// Run serves MCP on stdin/stdout until the client disconnects. +func (c *McpCmd) Run(d *lifecycleDeps) error { + ctx, cancel := d.NewContext() + defer cancel() + + return c.serve(ctx, d.HTTP, &mcp.StdioTransport{}) +} + +func (c *McpCmd) serve(ctx context.Context, client mcpserver.Doer, t mcp.Transport) error { + if client == nil { + client = httpclient.New(lifecycleHTTPTimeout) + } + + srv := mcpserver.New(mcpserver.Options{ + Doer: client, + BaseURL: strings.TrimRight(c.Endpoint, "/"), + Version: version.Get(), + ReadOnly: c.ReadOnly, + }) + + if err := mcpserver.ServeStdio(ctx, srv, t); err != nil { + return fmt.Errorf("mcp stdio: %w", err) + } + + return nil +} diff --git a/internal/app/mcp_wiring_test.go b/internal/app/mcp_wiring_test.go new file mode 100644 index 0000000000..224befdc8e --- /dev/null +++ b/internal/app/mcp_wiring_test.go @@ -0,0 +1,538 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sqs" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/iampolicystream" + "github.com/blackbirdworks/gopherstack/pkgs/inithooks" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" +) + +type mcpStack struct { + srv *httptest.Server + cli *CLI + cfg aws.Config + initRoot string +} + +func newMCPStack(t *testing.T, mcpCfg MCPConfig) *mcpStack { + t.Helper() + + log := buildLogger("") + cli := CLI{AccountID: "000000000000", Region: "us-east-1", MCP: mcpCfg} + cli.Pods.Dir = t.TempDir() + cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) + cli.faultStore = chaos.NewFaultStore() + cli.localstackInit = newLocalstackInitState() + initRoot := t.TempDir() + cli.localstackInit.setManager(inithooks.NewManager(inithooks.ManagerConfig{Roots: []string{initRoot}})) + _ = cli.IAMPolicyStream() + + services, err := initializeServicesT(t, &service.AppContext{ + Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, + }) + require.NoError(t, err) + + mgr := persistence.NewManager(t.Context(), persistence.NullStore{}) + setupPersistence(t.Context(), mgr, services, false, cli.Region) + + e := buildEchoServer(t.Context(), log, mgr, services, cli) + require.NoError(t, setupChaosAndRegistry(e, log, &cli, services)) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + cfg, err := awscfg.LoadDefaultConfig( + t.Context(), + awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + awscfg.WithBaseEndpoint(srv.URL), + ) + require.NoError(t, err) + + return &mcpStack{srv: srv, cfg: cfg, cli: &cli, initRoot: initRoot} +} + +func (s *mcpStack) httpSession(t *testing.T) *mcp.ClientSession { + t.Helper() + + c := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil) + cs, err := c.Connect(t.Context(), &mcp.StreamableClientTransport{ + Endpoint: s.srv.URL + mcpPath, + HTTPClient: testclient.New(t), + DisableStandaloneSSE: true, + }, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + + return cs +} + +func (s *mcpStack) session(t *testing.T, stdio bool) *mcp.ClientSession { + t.Helper() + + if stdio { + return s.stdioSession(t) + } + + return s.httpSession(t) +} + +func (s *mcpStack) stdioSession(t *testing.T) *mcp.ClientSession { + t.Helper() + + ct, st := mcp.NewInMemoryTransports() + cmd := &McpCmd{} + cmd.Endpoint = s.srv.URL + ctx, cancel := context.WithCancel(t.Context()) + + done := make(chan error, 1) + go func() { done <- cmd.serve(ctx, testclient.New(t), st) }() + + t.Cleanup(func() { + cancel() + <-done + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil) + cs, err := c.Connect(t.Context(), ct, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + + return cs +} + +func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) (map[string]any, string) { + t.Helper() + + res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{Name: name, Arguments: args}) + require.NoError(t, err) + + raw, err := json.Marshal(res) + require.NoError(t, err) + require.False(t, res.IsError, "tool %s: %s", name, raw) + + out, _ := res.StructuredContent.(map[string]any) + + return out, string(raw) +} + +func mcpExpectedTools() map[string]string { + return map[string]string{ + "gopherstack_health": "readOnly", + "snapshot_list": "readOnly", + "snapshot_save": "additive", + "snapshot_load": "destructive", + "snapshot_delete": "destructive", + "reset_state": "destructive", + "chaos_list_faults": "readOnly", + "chaos_set_faults": "destructive", + "chaos_clear_faults": "destructive", + "query_traces": "readOnly", + "iam_denials": "readOnly", + "init_hooks_list": "readOnly", + "init_hooks_run": "destructive", + } +} + +func TestMCPToolsList(t *testing.T) { + t.Parallel() + + s := newMCPStack(t, MCPConfig{Enabled: true}) + + tests := []struct { + name string + stdio bool + }{ + {name: "http"}, + {name: "stdio", stdio: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cs := s.session(t, tt.stdio) + res, err := cs.ListTools(t.Context(), nil) + require.NoError(t, err) + + got := map[string]*mcp.Tool{} + for _, tool := range res.Tools { + got[tool.Name] = tool + } + + require.Len(t, got, len(mcpExpectedTools())) + + for name, kind := range mcpExpectedTools() { + tool, ok := got[name] + require.True(t, ok, name) + assert.NotEmpty(t, tool.Description, name) + assert.NotNil(t, tool.InputSchema, name) + assert.NotNil(t, tool.OutputSchema, name) + require.NotNil(t, tool.Annotations, name) + + switch kind { + case "readOnly": + assert.True(t, tool.Annotations.ReadOnlyHint, name) + case "destructive": + assert.False(t, tool.Annotations.ReadOnlyHint, name) + require.NotNil(t, tool.Annotations.DestructiveHint, name) + assert.True(t, *tool.Annotations.DestructiveHint, name) + default: + assert.False(t, tool.Annotations.ReadOnlyHint, name) + require.NotNil(t, tool.Annotations.DestructiveHint, name) + assert.False(t, *tool.Annotations.DestructiveHint, name) + } + } + }) + } +} + +func TestMCPToolsHappyPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stdio bool + }{ + {name: "http"}, + {name: "stdio", stdio: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newMCPStack(t, MCPConfig{Enabled: true}) + cs := s.session(t, tt.stdio) + + out, _ := callTool(t, cs, "gopherstack_health", nil) + assert.Equal(t, "ok", out["status"]) + assert.Contains(t, out["services"], "SQS") + + queue, err := sqs.NewFromConfig(s.cfg). + CreateQueue(t.Context(), &sqs.CreateQueueInput{QueueName: aws.String("mcp-q")}) + require.NoError(t, err) + require.NotNil(t, queue.QueueUrl) + + _, _ = callTool(t, cs, "snapshot_save", map[string]any{"name": "snap1", "message": "m"}) + + list, raw := callTool(t, cs, "snapshot_list", nil) + assert.Contains(t, raw, "snap1") + assert.NotNil(t, list["data"]) + + _, _ = callTool(t, cs, "reset_state", map[string]any{"service": "sqs"}) + + q, err := sqs.NewFromConfig(s.cfg).ListQueues(t.Context(), &sqs.ListQueuesInput{}) + require.NoError(t, err) + assert.Empty(t, q.QueueUrls) + + _, _ = callTool(t, cs, "snapshot_load", map[string]any{"name": "snap1"}) + + q, err = sqs.NewFromConfig(s.cfg).ListQueues(t.Context(), &sqs.ListQueuesInput{}) + require.NoError(t, err) + assert.Len(t, q.QueueUrls, 1) + + _, _ = callTool(t, cs, "snapshot_delete", map[string]any{"name": "snap1"}) + _, raw = callTool(t, cs, "snapshot_list", nil) + assert.NotContains(t, raw, "snap1") + + _, _ = callTool(t, cs, "chaos_set_faults", map[string]any{"rules": []map[string]any{ + { + "service": "sqs", + "operation": "ListQueues", + "error": map[string]any{"code": "Throttling", "statusCode": 429}, + }, + }}) + faults, _ := callTool(t, cs, "chaos_list_faults", nil) + assert.Len(t, faults["rules"], 1) + + _, _ = sqs.NewFromConfig(s.cfg).ListQueues(t.Context(), &sqs.ListQueuesInput{}) + + traces, _ := callTool( + t, + cs, + "query_traces", + map[string]any{"service": "sqs", "operation": "ListQueues", "status": "4xx", "limit": 5}, + ) + require.NotEmpty(t, traces["traces"]) + + _, _ = callTool(t, cs, "chaos_clear_faults", nil) + faults, _ = callTool(t, cs, "chaos_list_faults", nil) + assert.Empty(t, faults["rules"]) + + s.cli.IAMPolicyStream().Record(iampolicystream.Event{ + Service: "s3", Action: "s3:GetObject", Resource: "*", Principal: "arn:aws:iam::000000000000:user/alice", + Decision: iampolicystream.DecisionDeny, Reason: iampolicystream.ReasonImplicitDeny, + }) + s.cli.IAMPolicyStream().Record(iampolicystream.Event{ + Service: "s3", Action: "s3:PutObject", Resource: "*", Principal: "bob", + Decision: iampolicystream.DecisionAllow, Reason: iampolicystream.ReasonIdentityPolicy, + }) + + denials, _ := callTool(t, cs, "iam_denials", map[string]any{"service": "s3"}) + assert.Len(t, denials["events"], 1) + + hooks, _ := callTool(t, cs, "init_hooks_list", nil) + assert.NotNil(t, hooks["data"]) + + res, err := cs.ReadResource(t.Context(), &mcp.ReadResourceParams{URI: "gopherstack://info"}) + require.NoError(t, err) + require.Len(t, res.Contents, 1) + assert.Contains(t, res.Contents[0].Text, `"status":"ok"`) + }) + } +} + +func TestMCPToolErrors(t *testing.T) { + t.Parallel() + + s := newMCPStack(t, MCPConfig{Enabled: true}) + cs := s.httpSession(t) + + tests := []struct { + args map[string]any + name string + tool string + }{ + {name: "missing_snapshot", tool: "snapshot_load", args: map[string]any{"name": "nope"}}, + {name: "bad_status", tool: "query_traces", args: map[string]any{"status": "weird"}}, + {name: "bad_since", tool: "iam_denials", args: map[string]any{"since": "yesterday"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{Name: tt.tool, Arguments: tt.args}) + require.NoError(t, err) + assert.True(t, res.IsError) + }) + } +} + +func TestMCPEndpointMountAndAuth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + cfg MCPConfig + wantStatus int + }{ + {name: "disabled", cfg: MCPConfig{}, wantStatus: http.StatusNotFound}, + {name: "loopback_no_token", cfg: MCPConfig{Enabled: true}, wantStatus: http.StatusOK}, + {name: "token_missing", cfg: MCPConfig{Enabled: true, Token: "s3cret"}, wantStatus: http.StatusUnauthorized}, + { + name: "token_wrong", + cfg: MCPConfig{Enabled: true, Token: "s3cret"}, + token: "nope", + wantStatus: http.StatusUnauthorized, + }, + {name: "token_ok", cfg: MCPConfig{Enabled: true, Token: "s3cret"}, token: "s3cret", wantStatus: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newMCPStack(t, tt.cfg) + body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{` + + `"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}` + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + s.srv.URL+mcpPath, + strings.NewReader(body), + ) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + if tt.token != "" { + req.Header.Set("Authorization", "Bearer "+tt.token) + } + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, tt.wantStatus, resp.StatusCode) + }) + } +} + +func TestMCPNeverLeaksSecrets(t *testing.T) { + t.Parallel() + + s := newMCPStack(t, MCPConfig{Enabled: true}) + cs := s.httpSession(t) + iamc := iam.NewFromConfig(s.cfg) + + _, err := iamc.CreateUser(t.Context(), &iam.CreateUserInput{UserName: aws.String("leaky")}) + require.NoError(t, err) + + key, err := iamc.CreateAccessKey(t.Context(), &iam.CreateAccessKeyInput{UserName: aws.String("leaky")}) + require.NoError(t, err) + + secret := aws.ToString(key.AccessKey.SecretAccessKey) + require.NotEmpty(t, secret) + + presigned := s.srv.URL + "/mcp-bucket/key?X-Amz-Signature=sigvalue123" + + "&X-Amz-Credential=credvalue456&X-Amz-Security-Token=tokvalue789" + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, presigned, http.NoBody) + require.NoError(t, err) + req.Header.Set( + "Authorization", + "AWS4-HMAC-SHA256 Credential=AKIAHEADERKEY/20250101/us-east-1/s3/aws4_request, Signature=headersig", + ) + req.Header.Set("X-Amz-Security-Token", "headertoken") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + + _, _ = callTool(t, cs, "snapshot_save", map[string]any{"name": "leaktest"}) + + tests := []struct { + args map[string]any + tool string + }{ + {tool: "gopherstack_health"}, + {tool: "snapshot_list"}, + {tool: "query_traces", args: map[string]any{"limit": 100}}, + {tool: "iam_denials"}, + {tool: "init_hooks_list"}, + {tool: "chaos_list_faults"}, + } + + for _, tt := range tests { + t.Run(tt.tool, func(t *testing.T) { + t.Parallel() + + _, raw := callTool(t, cs, tt.tool, tt.args) + for _, banned := range []string{secret, "sigvalue123", "credvalue456", "tokvalue789", "headertoken", "headersig"} { + assert.NotContains(t, raw, banned) + } + }) + } +} + +func TestMCPInitHooksRun(t *testing.T) { + t.Parallel() + + tests := []struct { + args map[string]any + name string + wantOut string + wantErr bool + wantExit float64 + }{ + {name: "named_ok", args: map[string]any{"stage": "ready", "script": "ok.sh"}, wantOut: "hello"}, + {name: "named_fail", args: map[string]any{"stage": "READY", "script": "fail.sh"}, wantOut: "boom", wantExit: 3}, + {name: "whole_stage", args: map[string]any{"stage": "READY"}, wantOut: "hello"}, + {name: "unknown_script", args: map[string]any{"stage": "READY", "script": "nope.sh"}, wantErr: true}, + {name: "traversal", args: map[string]any{"stage": "READY", "script": "../ready.d/ok.sh"}, wantErr: true}, + {name: "absolute", args: map[string]any{"stage": "READY", "script": "/bin/true"}, wantErr: true}, + {name: "bad_stage", args: map[string]any{"stage": "NOPE"}, wantErr: true}, + } + + for _, tt := range tests { + for _, stdio := range []bool{false, true} { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newMCPStack(t, MCPConfig{Enabled: true}) + dir := filepath.Join(s.initRoot, "ready.d") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ok.sh"), []byte("echo hello\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "fail.sh"), []byte("echo boom\nexit 3\n"), 0o600)) + + cs := s.session(t, stdio) + + if tt.wantErr { + res, err := cs.CallTool( + t.Context(), + &mcp.CallToolParams{Name: "init_hooks_run", Arguments: tt.args}, + ) + require.NoError(t, err) + assert.True(t, res.IsError) + + return + } + + out, raw := callTool(t, cs, "init_hooks_run", tt.args) + assert.Contains(t, raw, tt.wantOut) + + data, _ := out["data"].(map[string]any) + results, _ := data["results"].([]any) + require.NotEmpty(t, results) + + if tt.name != "whole_stage" { + first, _ := results[0].(map[string]any) + assert.InDelta(t, tt.wantExit, first["exit_code"], 0) + } + }) + } + } +} + +func TestMCPReadOnlyHidesMutatingTools(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stdio bool + }{{name: "http"}, {name: "stdio", stdio: true}} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newMCPStack(t, MCPConfig{Enabled: true, ReadOnly: true}) + cs := s.httpSession(t) + + if tt.stdio { + cmd := &McpCmd{ReadOnly: true} + cmd.Endpoint = s.srv.URL + ct, st := mcp.NewInMemoryTransports() + + go func() { _ = cmd.serve(t.Context(), testclient.New(t), st) }() + + c := mcp.NewClient(&mcp.Implementation{Name: "t", Version: "0"}, nil) + var err error + cs, err = c.Connect(t.Context(), ct, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + } + + res, err := cs.ListTools(t.Context(), nil) + require.NoError(t, err) + require.NotEmpty(t, res.Tools) + + for _, tool := range res.Tools { + assert.True(t, tool.Annotations.ReadOnlyHint, tool.Name) + } + }) + } +} diff --git a/internal/app/mgn_s3_import_wiring_test.go b/internal/app/mgn_s3_import_wiring_test.go index a1c6ab0f8d..4dce525065 100644 --- a/internal/app/mgn_s3_import_wiring_test.go +++ b/internal/app/mgn_s3_import_wiring_test.go @@ -46,7 +46,7 @@ func TestInitializeServices_MGNS3ImportWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/mq_rgtapi_untag_wiring_test.go b/internal/app/mq_rgtapi_untag_wiring_test.go index 3e8c75bcb4..3065d6dd5d 100644 --- a/internal/app/mq_rgtapi_untag_wiring_test.go +++ b/internal/app/mq_rgtapi_untag_wiring_test.go @@ -68,7 +68,7 @@ func TestInitializeServices_MQUntagWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/multi_account_test.go b/internal/app/multi_account_test.go index 5da62eb6c0..b8d4916fc5 100644 --- a/internal/app/multi_account_test.go +++ b/internal/app/multi_account_test.go @@ -4,7 +4,6 @@ import ( "github.com/blackbirdworks/gopherstack/internal/routegates" "log/slog" - "net/http" "net/http/httptest" "testing" @@ -25,6 +24,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/chaos" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" iambackend "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -57,7 +57,7 @@ func newMultiAccountStackWith(t *testing.T, enforce bool) *multiAccountStack { cli := &CLI{AccountID: "000000000000", Region: "us-east-1"} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices( + services, err := initializeServicesT(t, &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, ) require.NoError(t, err) @@ -91,6 +91,7 @@ func newMultiAccountStackWith(t *testing.T, enforce bool) *multiAccountStack { WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()). RouteHandler(), ) @@ -105,7 +106,7 @@ func (s *multiAccountStack) config(t *testing.T, key, token string, headers map[ cfg, err := awscfg.LoadDefaultConfig(t.Context(), awscfg.WithRegion("us-east-1"), - awscfg.WithHTTPClient(http.DefaultClient), + awscfg.WithHTTPClient(testclient.New(t)), awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(key, "secret", token)), ) require.NoError(t, err) diff --git a/internal/app/neptune_iam_auth.go b/internal/app/neptune_iam_auth.go new file mode 100644 index 0000000000..21b569e65c --- /dev/null +++ b/internal/app/neptune_iam_auth.go @@ -0,0 +1,57 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + iambackend "github.com/blackbirdworks/gopherstack/services/iam" + "github.com/blackbirdworks/gopherstack/services/neptune" + stsbackend "github.com/blackbirdworks/gopherstack/services/sts" +) + +// wireNeptuneIAMAuth connects Neptune data-plane IAM database authentication to IAM and STS. +func wireNeptuneIAMAuth(services []service.Registerable, enforceIAM bool) { + var ( + nepH *neptune.Handler + iamH *iambackend.Handler + stsH *stsbackend.Handler + ) + + for _, svc := range services { + switch h := svc.(type) { + case *neptune.Handler: + nepH = h + case *iambackend.Handler: + iamH = h + case *stsbackend.Handler: + stsH = h + } + } + + if nepH == nil { + return + } + + bk, ok := nepH.Backend.(interface{ SetIAMAuth(neptune.IAMAuthResolver) }) + if !ok { + return + } + + bk.SetIAMAuth(func() neptune.IAMAuthConfig { return neptuneIAMAuthConfig(iamH, stsH, enforceIAM) }) +} + +func neptuneIAMAuthConfig(iamH *iambackend.Handler, stsH *stsbackend.Handler, enforceIAM bool) neptune.IAMAuthConfig { + cfg := neptune.IAMAuthConfig{Enforce: enforceIAM} + + if iamH != nil { + if ids, ok := iamH.Backend.(neptune.IAMAuthIdentityProvider); ok { + cfg.Identities = ids + } + } + + if stsH != nil { + if ss, ok := stsH.Backend.(neptune.IAMAuthSessionProvider); ok { + cfg.Sessions = ss + } + } + + return cfg +} diff --git a/internal/app/ociapigateway_wiring.go b/internal/app/ociapigateway_wiring.go new file mode 100644 index 0000000000..d0ad4cfc96 --- /dev/null +++ b/internal/app/ociapigateway_wiring.go @@ -0,0 +1,63 @@ +package app + +import ( + "context" + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + ociapigateway "github.com/blackbirdworks/gopherstack/services/ociapigateway" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ocifunctions "github.com/blackbirdworks/gopherstack/services/ocifunctions" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" +) + +// GetOCIAPIGatewaySettings returns OCI API Gateway settings (ociapigateway.ConfigProvider). +func (c *CLI) GetOCIAPIGatewaySettings() ociapigateway.Settings { return c.OCIAPIGateway } + +// ociAPIGatewayFunctions adapts the OCI Functions backend to the API Gateway FunctionInvoker interface. +type ociAPIGatewayFunctions struct { + fn *ocifunctions.InMemoryBackend +} + +var errAPIGatewayFnStatus = errors.New("function invocation failed") + +func (f ociAPIGatewayFunctions) InvokeFunction( + ctx context.Context, functionID string, in ociapigateway.FunctionRequest, +) (ociapigateway.FunctionResponse, error) { + out, err := f.fn.Invoke(ctx, functionID, ocifunctions.InvokeIn{Header: in.Header, URL: in.Path, Body: in.Body}) + if err != nil { + return ociapigateway.FunctionResponse{}, err + } + + if out.Status == 0 { + return ociapigateway.FunctionResponse{}, errAPIGatewayFnStatus + } + + return ociapigateway.FunctionResponse{Status: out.Status, Header: out.Header, Body: out.Body}, nil +} + +// wireOCIAPIGateway shares OCI Identity keys and compartments, validates subnets against OCI Core and +// routes function backends and authorizers to OCI Functions. +func wireOCIAPIGateway(byName map[string]service.Registerable) { + gwH, ok := byName["OCIAPIGateway"].(*ociapigateway.Handler) + if !ok { + return + } + + if identityH, found := byName["OCIIdentity"].(*ociidentitybackend.Handler); found { + gwH.Backend.Auth.Link(identityH.Backend.Auth) + gwH.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + } + + if coreH, found := byName["OCICore"].(*ocicorebackend.Handler); found { + gwH.Backend.SetSubnetChecker(func(id string) bool { + _, live := coreH.Backend.LookupSubnet(id) + + return live + }) + } + + if fnH, found := byName["OCIFunctions"].(*ocifunctions.Handler); found { + gwH.Backend.SetFunctionInvoker(ociAPIGatewayFunctions{fn: fnH.Backend}) + } +} diff --git a/internal/app/ociapigateway_wiring_test.go b/internal/app/ociapigateway_wiring_test.go new file mode 100644 index 0000000000..4f24bab27c --- /dev/null +++ b/internal/app/ociapigateway_wiring_test.go @@ -0,0 +1,59 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ociapigateway "github.com/blackbirdworks/gopherstack/services/ociapigateway" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" +) + +func TestReserveFixedServicePorts_OCIAPIGateway(t *testing.T) { + t.Parallel() + + assert.Equal(t, 8163, ociapigateway.DefaultPort) + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCIAPIGateway: ociapigateway.Settings{Port: ociapigateway.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ociapigateway.DefaultPort)) + }) + } +} + +func TestWireOCIAPIGatewaySettingsAndLinking(t *testing.T) { + t.Parallel() + + idH := ociidentitybackend.NewHandler(ociidentitybackend.NewInMemoryBackend()) + gwH := ociapigateway.NewHandler(ociapigateway.NewInMemoryBackend()) + + assert.NotPanics(t, func() { + wireOCIAPIGateway(map[string]service.Registerable{"OCIIdentity": idH, "OCIAPIGateway": gwH}) + wireOCIAPIGateway(map[string]service.Registerable{"OCIAPIGateway": gwH}) + }) + + cli := CLI{OCIAPIGateway: ociapigateway.Settings{Port: 8163}} + assert.Equal(t, 8163, cli.GetOCIAPIGatewaySettings().Port) +} diff --git a/internal/app/ociartifacts_wiring.go b/internal/app/ociartifacts_wiring.go new file mode 100644 index 0000000000..0ba3b87796 --- /dev/null +++ b/internal/app/ociartifacts_wiring.go @@ -0,0 +1,31 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ociobjectstorage "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" +) + +// GetOCIArtifactsSettings returns OCI Artifacts settings (ociartifacts.ConfigProvider). +func (c *CLI) GetOCIArtifactsSettings() ociartifacts.Settings { return c.OCIArtifacts } + +// wireOCIArtifacts shares OCI Identity keys, compartments and the tenancy root, and the Object Storage +// namespace used in registry paths. +func wireOCIArtifacts(byName map[string]service.Registerable) { + artH, ok := byName["OCIArtifacts"].(*ociartifacts.Handler) + if !ok { + return + } + + if identityH, found := byName["OCIIdentity"].(*ociidentitybackend.Handler); found { + artH.Backend.Auth.Link(identityH.Backend.Auth) + artH.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + artH.Backend.SetCompartmentTree(identityH.Backend.CompartmentWithin) + artH.Backend.SetTenancy(ociidentitybackend.TenancyID) + } + + if osH, found := byName["OCIObjectStorage"].(*ociobjectstorage.Handler); found { + artH.Backend.SetNamespace(osH.Backend.Namespace()) + } +} diff --git a/internal/app/ociartifacts_wiring_test.go b/internal/app/ociartifacts_wiring_test.go new file mode 100644 index 0000000000..54c4807d82 --- /dev/null +++ b/internal/app/ociartifacts_wiring_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ociobjectstorage "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" +) + +func TestReserveFixedServicePorts_OCIArtifacts(t *testing.T) { + t.Parallel() + + assert.Equal(t, 8166, ociartifacts.DefaultPort) + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCIArtifacts: ociartifacts.Settings{Port: ociartifacts.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ociartifacts.DefaultPort)) + }) + } +} + +func TestWireOCIArtifactsSettingsAndLinking(t *testing.T) { + t.Parallel() + + idH := ociidentitybackend.NewHandler(ociidentitybackend.NewInMemoryBackend()) + osH := ociobjectstorage.NewHandler(ociobjectstorage.NewInMemoryBackend()) + osH.Backend.SetNamespace("tenantns") + artH := ociartifacts.NewHandler(ociartifacts.NewInMemoryBackend()) + + assert.NotPanics(t, func() { + wireOCIArtifacts( + map[string]service.Registerable{"OCIIdentity": idH, "OCIObjectStorage": osH, "OCIArtifacts": artH}, + ) + wireOCIArtifacts(map[string]service.Registerable{"OCIArtifacts": artH}) + }) + + assert.Equal(t, "tenantns", artH.Backend.Namespace()) + + cli := CLI{OCIArtifacts: ociartifacts.Settings{Port: 8166}} + assert.Equal(t, 8166, cli.GetOCIArtifactsSettings().Port) +} diff --git a/internal/app/ocicontainerengine_wire_test.go b/internal/app/ocicontainerengine_wire_test.go new file mode 100644 index 0000000000..ba6ee077b8 --- /dev/null +++ b/internal/app/ocicontainerengine_wire_test.go @@ -0,0 +1,121 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocioke "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ocivaultbackend "github.com/blackbirdworks/gopherstack/services/ocivault" +) + +const okeTestCompartment = "ocid1.compartment.oc1..c" + +func TestReserveFixedServicePorts_OCIContainerEngine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCIContainerEngine: ocioke.Settings{Port: ocioke.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ocioke.DefaultPort)) + }) + } +} + +func TestWireOCIContainerEngineAgainstCore(t *testing.T) { + t.Parallel() + + coreH := ocicorebackend.NewHandler(ocicorebackend.NewInMemoryBackend()) + okeH := ocioke.NewHandler(ocioke.NewInMemoryBackend()) + vaultH := ocivaultbackend.NewHandler(ocivaultbackend.NewInMemoryBackend()) + + wireOCIContainerEngine(map[string]service.Registerable{ + "OCICore": coreH, "OCIContainerEngine": okeH, "OCIVault": vaultH, + }) + + okeH.Backend.SetTransition(0) + + vcn, _, aerr := coreH.Backend.CreateVcn(ocicorebackend.CreateVcnIn{ + CompartmentID: okeTestCompartment, CidrBlocks: []string{"10.0.0.0/16"}, + }) + require.Nil(t, aerr) + + vcnID := vcn.(*ocicorebackend.Vcn).ID //nolint:forcetypeassert // CreateVcn returns *Vcn + + sub, _, aerr := coreH.Backend.CreateSubnet(ocicorebackend.CreateSubnetIn{ + CompartmentID: okeTestCompartment, VcnID: vcnID, CidrBlock: "10.0.1.0/24", + }) + require.Nil(t, aerr) + + subID := sub.(*ocicorebackend.Subnet).ID //nolint:forcetypeassert // CreateSubnet returns *Subnet + + cluster := func(name, subnet string) ocioke.CreateClusterIn { + return ocioke.CreateClusterIn{ + Name: name, Compartment: okeTestCompartment, Version: "v1.34.2", VcnID: &vcnID, + EndpointConfig: &ocioke.EndpointConfig{SubnetID: subnet}, + } + } + + _, err := okeH.Backend.CreateCluster(cluster("ghost", "ocid1.subnet.oc1..ghost")) + require.Error(t, err, "subnets must exist in OCI Core") + + _, err = okeH.Backend.CreateCluster(cluster("real", subID)) + require.NoError(t, err) + + list, err := okeH.Backend.ListClusters(ocioke.ClusterListIn{Compartment: okeTestCompartment}) + require.NoError(t, err) + require.Len(t, list.Data, 1) + + opts, err := okeH.Backend.GetNodePoolOptions(ocioke.NodePoolOptionsIn{ID: "all"}) + require.NoError(t, err) + assert.Contains(t, opts.Shapes, "VM.Standard.E4.Flex") + assert.NotEmpty(t, opts.Images) + require.NotEmpty(t, opts.Sources) + + img := ocicorebackend.CatalogImages()[0] + pool := func(shape string) ocioke.CreateNodePoolIn { + size := 1 + + return ocioke.CreateNodePoolIn{ + Compartment: okeTestCompartment, ClusterID: list.Data[0].ID, Name: "p", Shape: shape, + Source: &ocioke.NodeSource{ImageID: img.ID}, + NodeConfig: &ocioke.NodeConfigIn{ + Size: &size, + Placements: []ocioke.Placement{{AvailabilityDom: "Uocm:US-ASHBURN-AD-1", SubnetID: subID}}, + }, + } + } + + _, err = okeH.Backend.CreateNodePool(pool("VM.Nope")) + require.Error(t, err, "shapes come from the OCI Core catalogue") + + _, err = okeH.Backend.CreateNodePool(pool("VM.Standard2.1")) + require.NoError(t, err) + + got, _, err := okeH.Backend.GetCluster(list.Data[0].ID) + require.NoError(t, err) + assert.Equal(t, "ACTIVE", got.LifecycleState) + assert.Equal(t, "10.0.1.5:6443", got.Endpoints.PrivateEndpoint, "endpoint address comes from the subnet CIDR") +} diff --git a/internal/app/ocicontainerengine_wiring.go b/internal/app/ocicontainerengine_wiring.go new file mode 100644 index 0000000000..c1be1c096b --- /dev/null +++ b/internal/app/ocicontainerengine_wiring.go @@ -0,0 +1,85 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocioke "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ocivaultbackend "github.com/blackbirdworks/gopherstack/services/ocivault" +) + +// ociCoreNetwork adapts the OCI Core backend to the OKE Network interface. +type ociCoreNetwork struct { + core *ocicorebackend.InMemoryBackend +} + +func (n ociCoreNetwork) Subnet(id string) (ocioke.SubnetInfo, bool) { + s, ok := n.core.LookupSubnet(id) + + return ocioke.SubnetInfo{ + ID: s.ID, VcnID: s.VcnID, AvailabilityDomain: s.AvailabilityDomain, CIDR: s.CidrBlock, + }, ok +} + +func (n ociCoreNetwork) VCNExists(id string) bool { return n.core.VcnExists(id) } + +func shapeInfo(s ocicorebackend.ShapeRef) ocioke.ShapeInfo { + return ocioke.ShapeInfo{ + Name: s.Name, Arch: s.Arch, Flexible: s.Flexible, MinOcpus: s.MinOcpus, MaxOcpus: s.MaxOcpus, + MaxMemoryGBs: s.MaxMemoryGBs, DefaultMemPerOcpu: s.DefaultMemPerOcpu, + } +} + +func imageInfo(i ocicorebackend.ImageRef) ocioke.ImageInfo { + return ocioke.ImageInfo{ID: i.ID, Name: i.Name, OS: i.OS, OSVersion: i.OSVersion, Arch: i.Arch} +} + +func (n ociCoreNetwork) Shape(name string) (ocioke.ShapeInfo, bool) { + s, ok := ocicorebackend.LookupShape(name) + + return shapeInfo(s), ok +} + +func (n ociCoreNetwork) Shapes() []ocioke.ShapeInfo { + refs := ocicorebackend.CatalogShapes() + out := make([]ocioke.ShapeInfo, 0, len(refs)) + + for _, s := range refs { + out = append(out, shapeInfo(s)) + } + + return out +} + +func (n ociCoreNetwork) Image(id string) (ocioke.ImageInfo, bool) { + i, ok := n.core.LookupImage(id) + + return imageInfo(i), ok +} + +func (n ociCoreNetwork) Images() []ocioke.ImageInfo { + refs := ocicorebackend.CatalogImages() + out := make([]ocioke.ImageInfo, 0, len(refs)) + + for _, i := range refs { + out = append(out, imageInfo(i)) + } + + return out +} + +// wireOCIContainerEngine lets OKE validate subnets, shapes and images against OCI Core and kmsKeyId +// references against OCI Vault. +func wireOCIContainerEngine(byName map[string]service.Registerable) { + okeH, ok := byName["OCIContainerEngine"].(*ocioke.Handler) + if !ok { + return + } + + if coreH, found := byName["OCICore"].(*ocicorebackend.Handler); found { + okeH.Backend.SetNetwork(ociCoreNetwork{core: coreH.Backend}) + } + + if vaultH, found := byName["OCIVault"].(*ocivaultbackend.Handler); found { + okeH.Backend.SetKeyChecker(vaultH.Backend.KeyState) + } +} diff --git a/internal/app/ocicontainerinstances_wiring.go b/internal/app/ocicontainerinstances_wiring.go new file mode 100644 index 0000000000..d5fb56fe89 --- /dev/null +++ b/internal/app/ocicontainerinstances_wiring.go @@ -0,0 +1,47 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocicontainerinstances "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ocivaultbackend "github.com/blackbirdworks/gopherstack/services/ocivault" +) + +// GetOCIContainerInstancesSettings returns OCI Container Instances settings (ocicontainerinstances.ConfigProvider). +func (c *CLI) GetOCIContainerInstancesSettings() ocicontainerinstances.Settings { + return c.OCIContainerInstances +} + +// wireOCIContainerInstances shares OCI Identity keys and compartments, checks VNIC subnets in OCI Core and reads +// VAULT image pull secrets from OCI Vault. +func wireOCIContainerInstances(byName map[string]service.Registerable) { + h, ok := byName["OCIContainerInstances"].(*ocicontainerinstances.Handler) + if !ok { + return + } + + if identityH, found := byName["OCIIdentity"].(*ociidentitybackend.Handler); found { + h.Backend.Auth.Link(identityH.Backend.Auth) + h.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + } + + if coreH, found := byName["OCICore"].(*ocicorebackend.Handler); found { + h.Backend.SetSubnetChecker(func(id string) bool { + _, live := coreH.Backend.LookupSubnet(id) + + return live + }) + } + + if vaultH, found := byName["OCIVault"].(*ocivaultbackend.Handler); found { + h.Backend.SetSecretReader(vaultH.Backend.SecretContent) + } +} + +// GetOCIContainerInstancesHandler returns the OCIContainerInstances handler for the dashboard proxy. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetOCIContainerInstancesHandler() service.Registerable { + return c.ociContainerInstancesHandler +} diff --git a/internal/app/ocicontainerinstances_wiring_test.go b/internal/app/ocicontainerinstances_wiring_test.go new file mode 100644 index 0000000000..cb1130c9ab --- /dev/null +++ b/internal/app/ocicontainerinstances_wiring_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocicontainerinstances "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" +) + +func TestReserveFixedServicePorts_OCIContainerInstances(t *testing.T) { + t.Parallel() + + assert.Equal(t, 8167, ocicontainerinstances.DefaultPort) + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCIContainerInstances: ocicontainerinstances.Settings{Port: ocicontainerinstances.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ocicontainerinstances.DefaultPort)) + }) + } +} + +func TestWireOCIContainerInstancesSettingsAndLinking(t *testing.T) { + t.Parallel() + + idH := ociidentitybackend.NewHandler(ociidentitybackend.NewInMemoryBackend()) + h := ocicontainerinstances.NewHandler(ocicontainerinstances.NewInMemoryBackend()) + + assert.NotPanics(t, func() { + wireOCIContainerInstances(map[string]service.Registerable{"OCIIdentity": idH, "OCIContainerInstances": h}) + wireOCIContainerInstances(map[string]service.Registerable{"OCIContainerInstances": h}) + }) + + cli := CLI{OCIContainerInstances: ocicontainerinstances.Settings{Port: 8167, Engine: "docker"}} + assert.Equal(t, 8167, cli.GetOCIContainerInstancesSettings().Port) + assert.Equal(t, "docker", cli.GetOCIContainerInstancesSettings().Engine) +} diff --git a/internal/app/ocievents_wiring.go b/internal/app/ocievents_wiring.go new file mode 100644 index 0000000000..0181da10c4 --- /dev/null +++ b/internal/app/ocievents_wiring.go @@ -0,0 +1,116 @@ +package app + +import ( + "context" + "errors" + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocievents "github.com/blackbirdworks/gopherstack/services/ocievents" + ocifunctions "github.com/blackbirdworks/gopherstack/services/ocifunctions" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ocinotifications "github.com/blackbirdworks/gopherstack/services/ocinotifications" + ociobjectstorage "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" + ocistreaming "github.com/blackbirdworks/gopherstack/services/ocistreaming" +) + +const eventSourceObjectStorage = "objectstorage" + +const eventContentType = "application/json" + +var errEventFunctionStatus = errors.New("function invocation failed") + +// GetOCIEventsSettings returns OCI Events settings (ocievents.ConfigProvider). +func (c *CLI) GetOCIEventsSettings() ocievents.Settings { return c.OCIEvents } + +type ociEventsNotifier struct { + b *ocinotifications.InMemoryBackend +} + +func (n ociEventsNotifier) TopicActive(id string) bool { return n.b.TopicActive(id) } + +func (n ociEventsNotifier) PublishEvent(topicID, body string) error { + _, err := n.b.PublishMessage(topicID, "JSON", ocinotifications.PublishIn{Body: &body}) + + return err +} + +type ociEventsStreamer struct { + b *ocistreaming.InMemoryBackend +} + +func (s ociEventsStreamer) StreamActive(id string) bool { return s.b.StreamActive(id) } + +func (s ociEventsStreamer) AppendMessage(streamID string, key, value []byte) error { + return s.b.AppendMessage(streamID, key, value) +} + +type ociEventsFunctions struct { + b *ocifunctions.InMemoryBackend +} + +func (f ociEventsFunctions) FunctionActive(id string) bool { return f.b.FunctionActive(id) } + +func (f ociEventsFunctions) InvokeFunction(ctx context.Context, functionID string, body []byte) error { + out, err := f.b.Invoke(ctx, functionID, ocifunctions.InvokeIn{ + Header: http.Header{"Content-Type": {eventContentType}}, URL: "/", Body: body, + }) + if err != nil { + return err + } + + if out.Status < http.StatusOK || out.Status >= http.StatusMultipleChoices { + return errEventFunctionStatus + } + + return nil +} + +func objectEvent(e ociobjectstorage.ObjectEvent) ocievents.Event { + return ocievents.Event{ + Time: e.Time, EventType: e.EventType, Source: eventSourceObjectStorage, CompartmentID: e.CompartmentID, + ResourceName: e.ObjectName, AdditionalDetails: map[string]any{ + "eTag": e.ETag, "namespace": e.Namespace, "archivalState": nil, "bucketName": e.BucketName, + "bucketId": e.BucketID, + }, + } +} + +func functionsEvent(e ocifunctions.ResourceEvent) ocievents.Event { + return ocievents.Event{ + Time: e.Time, EventType: e.EventType, Source: "functions", CompartmentID: e.CompartmentID, + ResourceName: e.ResourceName, ResourceID: e.ResourceID, + } +} + +// wireOCIEvents shares OCI Identity keys and compartments, connects the action targets (Notifications, +// Streaming, Functions) and hooks the event producers (Object Storage, Functions). +func wireOCIEvents(byName map[string]service.Registerable) { + evH, ok := byName["OCIEvents"].(*ocievents.Handler) + if !ok { + return + } + + if identityH, found := byName["OCIIdentity"].(*ociidentitybackend.Handler); found { + evH.Backend.Auth.Link(identityH.Backend.Auth) + evH.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + evH.Backend.SetCompartmentTree(identityH.Backend.CompartmentWithin) + } + + if h, found := byName["OCINotifications"].(*ocinotifications.Handler); found { + evH.Backend.SetNotifier(ociEventsNotifier{b: h.Backend}) + } + + if h, found := byName["OCIStreaming"].(*ocistreaming.Handler); found { + evH.Backend.SetStreamer(ociEventsStreamer{b: h.Backend}) + } + + if h, found := byName["OCIFunctions"].(*ocifunctions.Handler); found { + evH.Backend.SetFunctionInvoker(ociEventsFunctions{b: h.Backend}) + h.Backend.SetEventSink(func(e ocifunctions.ResourceEvent) { evH.Backend.Emit(functionsEvent(e)) }) + } + + if h, found := byName["OCIObjectStorage"].(*ociobjectstorage.Handler); found { + h.Backend.SetObjectEventSink(func(e ociobjectstorage.ObjectEvent) { evH.Backend.Emit(objectEvent(e)) }) + } +} diff --git a/internal/app/ocievents_wiring_test.go b/internal/app/ocievents_wiring_test.go new file mode 100644 index 0000000000..260e433287 --- /dev/null +++ b/internal/app/ocievents_wiring_test.go @@ -0,0 +1,110 @@ +package app + +import ( + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocievents "github.com/blackbirdworks/gopherstack/services/ocievents" + ocifunctions "github.com/blackbirdworks/gopherstack/services/ocifunctions" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ocinotifications "github.com/blackbirdworks/gopherstack/services/ocinotifications" + ociobjectstorage "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" + ocistreaming "github.com/blackbirdworks/gopherstack/services/ocistreaming" +) + +func TestReserveFixedServicePorts_OCIEvents(t *testing.T) { + t.Parallel() + + assert.Equal(t, 8164, ocievents.DefaultPort) + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCIEvents: ocievents.Settings{Port: ocievents.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ocievents.DefaultPort)) + }) + } +} + +func TestWireOCIEvents(t *testing.T) { + t.Parallel() + + evH := ocievents.NewHandler(ocievents.NewInMemoryBackend()) + fnH := ocifunctions.NewHandler(ocifunctions.NewInMemoryBackend()) + osH := ociobjectstorage.NewHandler(ociobjectstorage.NewInMemoryBackend()) + byName := map[string]service.Registerable{ + "OCIEvents": evH, "OCIFunctions": fnH, "OCIObjectStorage": osH, + "OCIIdentity": ociidentitybackend.NewHandler(ociidentitybackend.NewInMemoryBackend()), + "OCINotifications": ocinotifications.NewHandler(ocinotifications.NewInMemoryBackend()), + "OCIStreaming": ocistreaming.NewHandler(ocistreaming.NewInMemoryBackend()), + } + + assert.NotPanics(t, func() { + wireOCIEvents(byName) + wireOCIEvents(map[string]service.Registerable{"OCIEvents": evH}) + wireOCIEvents(map[string]service.Registerable{}) + }) + + cli := CLI{OCIEvents: ocievents.Settings{Port: 8164}} + assert.Equal(t, 8164, cli.GetOCIEventsSettings().Port) +} + +func TestOCIEventsTargetsRejectUnknown(t *testing.T) { + t.Parallel() + + fn := ociEventsFunctions{b: ocifunctions.NewInMemoryBackend()} + assert.False(t, fn.FunctionActive("ocid1.fnfunc.oc1..nope")) + require.Error(t, fn.InvokeFunction(t.Context(), "ocid1.fnfunc.oc1..nope", []byte("{}"))) + + ons := ociEventsNotifier{b: ocinotifications.NewInMemoryBackend()} + assert.False(t, ons.TopicActive("ocid1.onstopic.oc1..nope")) + require.Error(t, ons.PublishEvent("ocid1.onstopic.oc1..nope", "{}")) + + oss := ociEventsStreamer{b: ocistreaming.NewInMemoryBackend()} + assert.False(t, oss.StreamActive("ocid1.stream.oc1..nope")) + require.Error(t, oss.AppendMessage("ocid1.stream.oc1..nope", nil, []byte("{}"))) +} + +func TestOCIEventsMapping(t *testing.T) { + t.Parallel() + + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + + obj := objectEvent(ociobjectstorage.ObjectEvent{ + Time: at, EventType: ociobjectstorage.EventCreateObject, CompartmentID: "c", Namespace: "ns", + BucketName: "b", BucketID: "bid", ObjectName: "o", ETag: "e", + }) + assert.Equal(t, "objectstorage", obj.Source) + assert.Equal(t, "o", obj.ResourceName) + assert.Equal(t, "b", obj.AdditionalDetails["bucketName"]) + assert.Nil(t, obj.AdditionalDetails["archivalState"]) + + fn := functionsEvent(ocifunctions.ResourceEvent{ + Time: at, EventType: ocifunctions.EventCreateFunction, CompartmentID: "c", ResourceName: "f", ResourceID: "fid", + }) + assert.Equal(t, "functions", fn.Source) + assert.Equal(t, "fid", fn.ResourceID) + assert.Equal(t, ocifunctions.EventCreateFunction, fn.EventType) +} diff --git a/internal/app/ociloadbalancer_wiring.go b/internal/app/ociloadbalancer_wiring.go new file mode 100644 index 0000000000..7b6bddf217 --- /dev/null +++ b/internal/app/ociloadbalancer_wiring.go @@ -0,0 +1,32 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ociloadbalancer "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" +) + +// GetOCILoadBalancerSettings returns OCI Load Balancer settings (ociloadbalancer.ConfigProvider). +func (c *CLI) GetOCILoadBalancerSettings() ociloadbalancer.Settings { return c.OCILoadBalancer } + +// wireOCILoadBalancer shares OCI Identity keys and compartments and resolves subnets through OCI Core. +func wireOCILoadBalancer(byName map[string]service.Registerable) { + lbH, ok := byName["OCILoadBalancer"].(*ociloadbalancer.Handler) + if !ok { + return + } + + if identityH, found := byName["OCIIdentity"].(*ociidentitybackend.Handler); found { + lbH.Backend.Auth.Link(identityH.Backend.Auth) + lbH.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + } + + if coreH, found := byName["OCICore"].(*ocicorebackend.Handler); found { + lbH.Backend.SetSubnetResolver(func(id string) (string, bool) { + ref, live := coreH.Backend.LookupSubnet(id) + + return ref.CidrBlock, live + }) + } +} diff --git a/internal/app/ociloadbalancer_wiring_test.go b/internal/app/ociloadbalancer_wiring_test.go new file mode 100644 index 0000000000..389abcb2b9 --- /dev/null +++ b/internal/app/ociloadbalancer_wiring_test.go @@ -0,0 +1,59 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ociloadbalancer "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" +) + +func TestReserveFixedServicePorts_OCILoadBalancer(t *testing.T) { + t.Parallel() + + assert.Equal(t, 8165, ociloadbalancer.DefaultPort) + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCILoadBalancer: ociloadbalancer.Settings{Port: ociloadbalancer.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ociloadbalancer.DefaultPort)) + }) + } +} + +func TestWireOCILoadBalancerSettingsAndLinking(t *testing.T) { + t.Parallel() + + idH := ociidentitybackend.NewHandler(ociidentitybackend.NewInMemoryBackend()) + lbH := ociloadbalancer.NewHandler(ociloadbalancer.NewInMemoryBackend()) + + assert.NotPanics(t, func() { + wireOCILoadBalancer(map[string]service.Registerable{"OCIIdentity": idH, "OCILoadBalancer": lbH}) + wireOCILoadBalancer(map[string]service.Registerable{"OCILoadBalancer": lbH}) + }) + + cli := CLI{OCILoadBalancer: ociloadbalancer.Settings{Port: 8165}} + assert.Equal(t, 8165, cli.GetOCILoadBalancerSettings().Port) +} diff --git a/internal/app/ocilogging_wiring.go b/internal/app/ocilogging_wiring.go new file mode 100644 index 0000000000..336def1121 --- /dev/null +++ b/internal/app/ocilogging_wiring.go @@ -0,0 +1,88 @@ +package app + +import ( + "context" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocifunctionsbackend "github.com/blackbirdworks/gopherstack/services/ocifunctions" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ocilogging "github.com/blackbirdworks/gopherstack/services/ocilogging" + ocimonitoring "github.com/blackbirdworks/gopherstack/services/ocimonitoring" + ocinotificationsbackend "github.com/blackbirdworks/gopherstack/services/ocinotifications" +) + +const ( + notificationsEndpoint = "http://localhost" + messageTypeJSON = "JSON" + functionsLogService = "functions" + functionsLogCategory = "invoke" + functionsEventType = "com.oraclecloud.functions.invoke" +) + +// GetOCILoggingSettings returns OCI Logging settings (ocilogging.ConfigProvider). +func (c *CLI) GetOCILoggingSettings() ocilogging.Settings { return c.OCILogging } + +// GetOCIMonitoringSettings returns OCI Monitoring settings (ocimonitoring.ConfigProvider). +func (c *CLI) GetOCIMonitoringSettings() ocimonitoring.Settings { return c.OCIMonitoring } + +// wireOCILoggingMonitoring links OCI Logging and Monitoring to the other OCI services: API keys from OCI +// Identity, function invocation logs into SERVICE logs, and alarm notifications into OCI Notifications. +func wireOCILoggingMonitoring(byName map[string]service.Registerable) { + logH, _ := byName["OCILogging"].(*ocilogging.Handler) + monH, _ := byName["OCIMonitoring"].(*ocimonitoring.Handler) + + if identityH, ok := byName["OCIIdentity"].(*ociidentitybackend.Handler); ok { + if logH != nil { + logH.Backend.Auth.Link(identityH.Backend.Auth) + } + + if monH != nil { + monH.Backend.Auth.Link(identityH.Backend.Auth) + } + } + + if fnH, ok := byName["OCIFunctions"].(*ocifunctionsbackend.Handler); ok && logH != nil { + fnH.Backend.SetInvokeObserver(functionLogObserver(logH.Backend)) + } + + if notifyH, ok := byName["OCINotifications"].(*ocinotificationsbackend.Handler); ok && monH != nil { + monH.Backend.SetPublisher(alarmPublisher(notifyH.Backend)) + monH.Backend.SetTopicChecker(func(id string) bool { + _, err := notifyH.Backend.GetTopic(id, notificationsEndpoint) + + return err == nil + }) + } +} + +// functionLogObserver writes each invocation to the SERVICE log enabled for the function's application. +func functionLogObserver(b *ocilogging.InMemoryBackend) ocifunctionsbackend.InvokeObserver { + return func(ev ocifunctionsbackend.InvokeEvent) { + data := map[string]any{ + keyMessageField: "function invocation finished", "applicationId": ev.AppID, "applicationName": ev.AppName, + "functionId": ev.FunctionID, "functionName": ev.FunctionName, "callId": ev.CallID, + lifecycleCmdStatus: ev.Status, "durationMs": ev.Duration.Milliseconds(), + } + if ev.ErrorCode != "" { + data["errorCode"], data["errorMessage"] = ev.ErrorCode, ev.ErrorMessage + } + + b.EmitServiceLog(functionsLogService, ev.AppID, functionsLogCategory, ocilogging.ServiceEvent{ + Time: ev.Time, Type: functionsEventType, Subject: ev.FunctionID, Data: data, + }) + } +} + +// alarmPublisher publishes alarm notifications to OCI Notifications topics. +func alarmPublisher(b *ocinotificationsbackend.InMemoryBackend) ocimonitoring.Publisher { + return func(_ context.Context, topicID, title, body string, asJSON bool) error { + msgType := "" + if asJSON { + msgType = messageTypeJSON + } + + _, err := b.PublishMessage(topicID, msgType, ocinotificationsbackend.PublishIn{Title: &title, Body: &body}) + + return err + } +} diff --git a/internal/app/ocilogging_wiring_test.go b/internal/app/ocilogging_wiring_test.go new file mode 100644 index 0000000000..9cf2fb222d --- /dev/null +++ b/internal/app/ocilogging_wiring_test.go @@ -0,0 +1,191 @@ +package app + +import ( + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocifunctionsbackend "github.com/blackbirdworks/gopherstack/services/ocifunctions" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ocilogging "github.com/blackbirdworks/gopherstack/services/ocilogging" + ocimonitoring "github.com/blackbirdworks/gopherstack/services/ocimonitoring" + ocinotificationsbackend "github.com/blackbirdworks/gopherstack/services/ocinotifications" +) + +const wireCompartment = "ocid1.compartment.oc1..c" + +func TestReserveFixedServicePorts_OCILoggingMonitoring(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + { + "logging inside pool", + CLI{OCILogging: ocilogging.Settings{Port: ocilogging.DefaultPort}}, + ocilogging.DefaultPort, + 8100, + 8200, + true, + }, + { + "logging outside pool", + CLI{OCILogging: ocilogging.Settings{Port: ocilogging.DefaultPort}}, + ocilogging.DefaultPort, + 10000, + 10100, + false, + }, + { + "monitoring inside pool", CLI{OCIMonitoring: ocimonitoring.Settings{Port: ocimonitoring.DefaultPort}}, + ocimonitoring.DefaultPort, 8100, 8200, true, + }, + { + "monitoring outside pool", CLI{OCIMonitoring: ocimonitoring.Settings{Port: ocimonitoring.DefaultPort}}, + ocimonitoring.DefaultPort, 10000, 10100, false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, tt.cli) + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} + +func TestOCILoggingMonitoringDefaultPorts(t *testing.T) { + t.Parallel() + + assert.Equal(t, 8160, ocilogging.DefaultPort) + assert.Equal(t, 8161, ocimonitoring.DefaultPort) +} + +func TestWireOCIFunctionInvocationLogs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource string + category string + wantHits int + }{ + {"log enabled for the application", "ocid1.fnapp.oc1..app", "invoke", 1}, + {"log for another application", "ocid1.fnapp.oc1..other", "invoke", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + logH := ocilogging.NewHandler(ocilogging.NewInMemoryBackend()) + fnH := ocifunctionsbackend.NewHandler(ocifunctionsbackend.NewInMemoryBackend()) + wireOCILoggingMonitoring(map[string]service.Registerable{"OCILogging": logH, "OCIFunctions": fnH}) + + b := logH.Backend + b.SetTransition(0) + + _, err := b.CreateLogGroup(ocilogging.CreateLogGroupIn{Name: "g", Compartment: wireCompartment}) + require.NoError(t, err) + + groups, err := b.ListLogGroups(wireCompartment, "", ocilogging.ListOpts{}) + require.NoError(t, err) + require.Len(t, groups.Data, 1) + + _, err = b.CreateLog(groups.Data[0].ID, ocilogging.CreateLogIn{ + Name: "fn", LogType: "SERVICE", + Configuration: &ocilogging.ConfigWire{Source: &ocilogging.SourceWire{ + Service: "functions", Resource: tt.resource, Category: tt.category, + }}, + }) + require.NoError(t, err) + + functionLogObserver(b)(ocifunctionsbackend.InvokeEvent{ + Time: time.Now(), + AppID: "ocid1.fnapp.oc1..app", + FunctionName: "hello", + FunctionID: "ocid1.fnfunc.oc1..f", + CallID: "call", + Status: 200, + Duration: 5 * time.Millisecond, + }) + + now := time.Now() + out, _, err := b.SearchLogs(ocilogging.SearchIn{ + TimeStart: new(now.Add(-time.Hour)), TimeEnd: new(now.Add(time.Hour)), + SearchQuery: `search "` + wireCompartment + `/g/fn" | where data.functionName = 'hello'`, + }, "", 0) + require.NoError(t, err) + assert.Equal(t, tt.wantHits, out.Summary.ResultCount) + }) + } +} + +func TestWireOCIMonitoringNotifications(t *testing.T) { + t.Parallel() + + monH := ocimonitoring.NewHandler(ocimonitoring.NewInMemoryBackend()) + notifyH := ocinotificationsbackend.NewHandler(ocinotificationsbackend.NewInMemoryBackend()) + identityH := ociidentitybackend.NewHandler(ociidentitybackend.NewInMemoryBackend()) + wireOCILoggingMonitoring(map[string]service.Registerable{ + "OCIMonitoring": monH, "OCINotifications": notifyH, "OCIIdentity": identityH, + }) + + nb := notifyH.Backend + nb.SetTransition(0) + + topic, err := nb.CreateTopic( + ocinotificationsbackend.CreateTopicIn{Name: "t", Compartment: wireCompartment}, + "http://x", + ) + require.NoError(t, err) + + _, err = nb.CreateSubscription(ocinotificationsbackend.CreateSubscriptionIn{ + TopicID: topic.TopicID, Compartment: wireCompartment, Protocol: "ORACLE_FUNCTIONS", + Endpoint: "ocid1.fnfunc.oc1..ghost", + }, "http://x") + require.NoError(t, err) + + mb := monH.Backend + + create := func(dest string) error { + _, _, cerr := mb.CreateAlarm(ocimonitoring.CreateAlarmIn{ + Name: "a", Compartment: wireCompartment, MetricComp: wireCompartment, Namespace: "ns", + Query: "m[1m].max() > 1", Severity: "CRITICAL", Destinations: []string{dest}, Enabled: new(true), + }) + + return cerr + } + + require.Error(t, create("ocid1.onstopic.oc1..ghost"), "unknown topics are rejected once Notifications is wired") + require.NoError(t, create(topic.TopicID)) + + now := time.Now() + _, err = mb.PostMetricData(ocimonitoring.PostMetricDataIn{MetricData: []ocimonitoring.MetricDataIn{{ + Namespace: "ns", CompartmentID: wireCompartment, Name: "m", Dimensions: map[string]string{"k": "v"}, + Datapoints: []ocimonitoring.DatapointIn{{Timestamp: &now, Value: new(5.0)}}, + }}}) + require.NoError(t, err) + + mb.Sweep(t.Context()) + + deliveries := nb.Deliveries(topic.TopicID, "") + require.Len(t, deliveries, 1) + assert.Contains(t, deliveries[0].Body, "OK_TO_FIRING") + assert.Equal(t, "JSON", deliveries[0].MessageType) +} diff --git a/internal/app/ocimysql_wire_test.go b/internal/app/ocimysql_wire_test.go new file mode 100644 index 0000000000..08187589be --- /dev/null +++ b/internal/app/ocimysql_wire_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ocimysqlbackend "github.com/blackbirdworks/gopherstack/services/ocimysql" +) + +const mysqlTestCompartment = "ocid1.compartment.oc1..c" + +func TestReserveFixedServicePorts_OCIMySQL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCIMySQL: ocimysqlbackend.Settings{Port: ocimysqlbackend.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ocimysqlbackend.DefaultPort)) + }) + } +} + +func TestWireOCIMySQLAgainstCore(t *testing.T) { + t.Parallel() + + coreH := ocicorebackend.NewHandler(ocicorebackend.NewInMemoryBackend()) + mysqlH := ocimysqlbackend.NewHandler(ocimysqlbackend.NewInMemoryBackend()) + + wireOCIMySQL(map[string]service.Registerable{"OCICore": coreH, "OCIMySQL": mysqlH}) + + vcn, _, aerr := coreH.Backend.CreateVcn(ocicorebackend.CreateVcnIn{ + CompartmentID: mysqlTestCompartment, CidrBlocks: []string{"10.0.0.0/16"}, + }) + require.Nil(t, aerr) + + vcnID := vcn.(*ocicorebackend.Vcn).ID //nolint:forcetypeassert // CreateVcn returns *Vcn + + sub, _, aerr := coreH.Backend.CreateSubnet(ocicorebackend.CreateSubnetIn{ + CompartmentID: mysqlTestCompartment, VcnID: vcnID, CidrBlock: "10.0.1.0/24", + }) + require.Nil(t, aerr) + + subID := sub.(*ocicorebackend.Subnet).ID //nolint:forcetypeassert // CreateSubnet returns *Subnet + + create := func(subnet string) error { + _, _, _, err := mysqlH.Backend.CreateDBSystem(ocimysqlbackend.CreateDBSystemIn{ + CompartmentID: mysqlTestCompartment, ShapeName: "MySQL.2", SubnetID: subnet, + AdminUsername: "dbadmin", AdminPassword: "Passw0rd!x", + }) + + return err + } + + require.Error(t, create("ocid1.subnet.oc1..ghost"), "subnets must exist in OCI Core") + require.NoError(t, create(subID)) + + list, err := mysqlH.Backend.ListDBSystems(ocimysqlbackend.DBSystemListIn{Compartment: mysqlTestCompartment}) + require.NoError(t, err) + require.Len(t, list.Data, 1) + + d, _, err := mysqlH.Backend.GetDBSystem(list.Data[0]["id"].(string)) //nolint:forcetypeassert // id is a string + require.NoError(t, err) + assert.Equal(t, "10.0.1.5", d["ipAddress"], "the endpoint address is allocated from the subnet CIDR") +} diff --git a/internal/app/ocimysql_wiring.go b/internal/app/ocimysql_wiring.go new file mode 100644 index 0000000000..6081fe5f6e --- /dev/null +++ b/internal/app/ocimysql_wiring.go @@ -0,0 +1,32 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocicorebackend "github.com/blackbirdworks/gopherstack/services/ocicore" + ocimysqlbackend "github.com/blackbirdworks/gopherstack/services/ocimysql" +) + +// ociMySQLNetwork adapts the OCI Core backend to the OCI MySQL Network interface. +type ociMySQLNetwork struct { + core *ocicorebackend.InMemoryBackend +} + +func (n ociMySQLNetwork) Subnet(id string) (ocimysqlbackend.SubnetInfo, bool) { + s, ok := n.core.LookupSubnet(id) + + return ocimysqlbackend.SubnetInfo{ + ID: s.ID, VcnID: s.VcnID, AvailabilityDomain: s.AvailabilityDomain, CIDR: s.CidrBlock, + }, ok +} + +// wireOCIMySQL lets OCI MySQL validate subnets and allocate endpoint addresses against OCI Core. +func wireOCIMySQL(byName map[string]service.Registerable) { + mysqlH, ok := byName["OCIMySQL"].(*ocimysqlbackend.Handler) + if !ok { + return + } + + if coreH, found := byName["OCICore"].(*ocicorebackend.Handler); found { + mysqlH.Backend.SetNetwork(ociMySQLNetwork{core: coreH.Backend}) + } +} diff --git a/internal/app/ocinosql_wiring.go b/internal/app/ocinosql_wiring.go new file mode 100644 index 0000000000..8c08e8d5f9 --- /dev/null +++ b/internal/app/ocinosql_wiring.go @@ -0,0 +1,23 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ocinosql "github.com/blackbirdworks/gopherstack/services/ocinosql" +) + +// GetOCINoSQLSettings returns OCI NoSQL settings (ocinosql.ConfigProvider). +func (c *CLI) GetOCINoSQLSettings() ocinosql.Settings { return c.OCINoSQL } + +// wireOCINoSQL shares OCI Identity API keys and compartment checks with OCI NoSQL. +func wireOCINoSQL(byName map[string]service.Registerable) { + identityH, ok := byName["OCIIdentity"].(*ociidentitybackend.Handler) + if !ok { + return + } + + if h, found := byName["OCINoSQL"].(*ocinosql.Handler); found { + h.Backend.Auth.Link(identityH.Backend.Auth) + h.Backend.SetCompartmentChecker(identityH.Backend.CompartmentActive) + } +} diff --git a/internal/app/ocinosql_wiring_test.go b/internal/app/ocinosql_wiring_test.go new file mode 100644 index 0000000000..e3db02324a --- /dev/null +++ b/internal/app/ocinosql_wiring_test.go @@ -0,0 +1,59 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + ociidentitybackend "github.com/blackbirdworks/gopherstack/services/ociidentity" + ocinosql "github.com/blackbirdworks/gopherstack/services/ocinosql" +) + +func TestReserveFixedServicePorts_OCINoSQL(t *testing.T) { + t.Parallel() + + assert.Equal(t, 8162, ocinosql.DefaultPort) + + tests := []struct { + name string + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", 8100, 8200, true}, + {"port outside pool range", 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCINoSQL: ocinosql.Settings{Port: ocinosql.DefaultPort}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(ocinosql.DefaultPort)) + }) + } +} + +func TestWireOCINoSQLSettingsAndLinking(t *testing.T) { + t.Parallel() + + idH := ociidentitybackend.NewHandler(ociidentitybackend.NewInMemoryBackend()) + nosqlH := ocinosql.NewHandler(ocinosql.NewInMemoryBackend()) + + assert.NotPanics(t, func() { + wireOCINoSQL(map[string]service.Registerable{"OCIIdentity": idH, "OCINoSQL": nosqlH}) + wireOCINoSQL(map[string]service.Registerable{"OCINoSQL": nosqlH}) + }) + + cli := CLI{OCINoSQL: ocinosql.Settings{Port: 8162}} + assert.Equal(t, 8162, cli.GetOCINoSQLSettings().Port) +} diff --git a/internal/app/ocinotifications_port_reservation_test.go b/internal/app/ocinotifications_port_reservation_test.go new file mode 100644 index 0000000000..86fbb286bd --- /dev/null +++ b/internal/app/ocinotifications_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + ocinotificationsbackend "github.com/blackbirdworks/gopherstack/services/ocinotifications" +) + +func TestReserveFixedServicePorts_OCINotifications(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", ocinotificationsbackend.DefaultPort, 8100, 8200, true}, + {"port outside pool range", ocinotificationsbackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCINotifications: ocinotificationsbackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/ociqueue_notifications_wire_test.go b/internal/app/ociqueue_notifications_wire_test.go new file mode 100644 index 0000000000..328f3fa91b --- /dev/null +++ b/internal/app/ociqueue_notifications_wire_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + ocifunctionsbackend "github.com/blackbirdworks/gopherstack/services/ocifunctions" + ocinotificationsbackend "github.com/blackbirdworks/gopherstack/services/ocinotifications" + ociqueuebackend "github.com/blackbirdworks/gopherstack/services/ociqueue" + ocivaultbackend "github.com/blackbirdworks/gopherstack/services/ocivault" +) + +func TestWireOCIQueueKeyChecker(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + withVault bool + wantErr bool + }{ + {"unknown key rejected when vault runs", true, true}, + {"any key OCID accepted standalone", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + queueH := ociqueuebackend.NewHandler(ociqueuebackend.NewInMemoryBackend()) + byName := map[string]service.Registerable{"OCIQueue": queueH} + + if tt.withVault { + byName["OCIVault"] = ocivaultbackend.NewHandler(ocivaultbackend.NewInMemoryBackend()) + } + + wireOCIQueueNotifications(byName) + + key := "ocid1.key.oc1..ghost" + _, err := queueH.Backend.CreateQueue(ociqueuebackend.CreateQueueIn{ + Name: "q", Compartment: "ocid1.compartment.oc1..c", KMSKeyID: &key, + }) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestWireOCINotificationsFunctions(t *testing.T) { + t.Parallel() + + notifyH := ocinotificationsbackend.NewHandler(ocinotificationsbackend.NewInMemoryBackend()) + fnH := ocifunctionsbackend.NewHandler(ocifunctionsbackend.NewInMemoryBackend()) + + wireOCIQueueNotifications(map[string]service.Registerable{"OCINotifications": notifyH, "OCIFunctions": fnH}) + + b := notifyH.Backend + b.SetTransition(0) + + topic, err := b.CreateTopic( + ocinotificationsbackend.CreateTopicIn{Name: "t", Compartment: "ocid1.compartment.oc1..c"}, + "http://x", + ) + require.NoError(t, err) + + _, err = b.CreateSubscription(ocinotificationsbackend.CreateSubscriptionIn{ + TopicID: topic.TopicID, Compartment: "ocid1.compartment.oc1..c", Protocol: "ORACLE_FUNCTIONS", + Endpoint: "ocid1.fnfunc.oc1..ghost", + }, "http://x") + require.NoError(t, err) + + body := "payload" + _, err = b.PublishMessage(topic.TopicID, "", ocinotificationsbackend.PublishIn{Body: &body}) + require.NoError(t, err) + + b.DeliverDue(t.Context(), func(f func()) { f() }) + + ds := b.Deliveries(topic.TopicID, "") + require.Len(t, ds, 1) + assert.Equal(t, "PENDING", ds[0].Status, "an unknown function is retried, not delivered") + assert.Equal(t, 1, ds[0].Attempts) + assert.NotEmpty(t, ds[0].LastError) + assert.NotEqual(t, http.StatusOK, ds[0].LastStatus) +} diff --git a/internal/app/ociqueue_port_reservation_test.go b/internal/app/ociqueue_port_reservation_test.go new file mode 100644 index 0000000000..7a830fb20a --- /dev/null +++ b/internal/app/ociqueue_port_reservation_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + ociqueuebackend "github.com/blackbirdworks/gopherstack/services/ociqueue" +) + +func TestReserveFixedServicePorts_OCIQueue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + rangeStart int + rangeEnd int + wantTaken bool + }{ + {"port inside pool range", ociqueuebackend.DefaultPort, 8100, 8200, true}, + {"port outside pool range", ociqueuebackend.DefaultPort, 10000, 10100, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(tt.rangeStart, tt.rangeEnd) + require.NoError(t, err) + + cli := CLI{OCIQueue: ociqueuebackend.Settings{Port: tt.port}} + reserveFixedServicePorts(t.Context(), slog.Default(), alloc, cli) + + assert.Equal(t, tt.wantTaken, alloc.IsAllocated(tt.port)) + }) + } +} diff --git a/internal/app/omics_s3_wiring_test.go b/internal/app/omics_s3_wiring_test.go index df5abad133..fc781246d6 100644 --- a/internal/app/omics_s3_wiring_test.go +++ b/internal/app/omics_s3_wiring_test.go @@ -156,7 +156,7 @@ func TestInitializeServices_OmicsS3Wiring(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - services, err := initializeServices(newTestAppContext(t, 19600, 19700)) + services, err := initializeServicesT(t, newTestAppContext(t, 19600, 19700)) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/organizations_rgtapi_tags_wiring_test.go b/internal/app/organizations_rgtapi_tags_wiring_test.go index 8a162dbfc6..d07e8fae25 100644 --- a/internal/app/organizations_rgtapi_tags_wiring_test.go +++ b/internal/app/organizations_rgtapi_tags_wiring_test.go @@ -30,7 +30,7 @@ func TestInitializeServices_OrganizationsTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/orphans.go b/internal/app/orphans.go new file mode 100644 index 0000000000..b4c8a20e02 --- /dev/null +++ b/internal/app/orphans.go @@ -0,0 +1,73 @@ +package app + +import ( + "context" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + orphanListTimeout = 15 * time.Second + orphanSweepDelay = 3 * time.Minute +) + +// initContainerIdentity pins the container owner id to the data dir when persistence is on, so a restart adopts +// its predecessor's containers; otherwise the id is fresh per run and nothing is adopted. +func initContainerIdentity(persist bool, dataDir string) { + if persist { + container.SetPersistentOwner(dataDir) + + return + } + + container.SetEphemeralOwner() +} + +// reconcileContainers removes provably dead leftovers of earlier runs and holds this owner's resource-labelled +// containers for the services to adopt while they restore; whatever is not claimed is removed after a grace period. +func reconcileContainers(ctx context.Context) { + log := logger.Load(ctx) + + rt, err := container.NewRuntime(container.Config{}) + if err != nil { + return + } + + defer rt.Close() + + lister, ok := rt.(container.ManagedLister) + if !ok { + return + } + + lctx, cancel := context.WithTimeout(ctx, orphanListTimeout) + defer cancel() + + if err = rt.Ping(lctx); err != nil { + return + } + + orphans, err := container.Reconcile(lctx, lister, container.CurrentIdentity(), log) + if err != nil { + log.WarnContext(ctx, "container reconcile failed", "error", err) + + return + } + + container.SetOrphans(orphans) + + go func() { + t := time.NewTimer(orphanSweepDelay) + defer t.Stop() + + select { + case <-ctx.Done(): + case <-t.C: + if n := container.SweepOrphans(ctx); n > 0 { + log.InfoContext(ctx, "removed unclaimed containers from an earlier run", "count", n) + } + } + }() +} diff --git a/internal/app/partition_middleware.go b/internal/app/partition_middleware.go index 403049fec8..4dd15bf5f2 100644 --- a/internal/app/partition_middleware.go +++ b/internal/app/partition_middleware.go @@ -109,7 +109,9 @@ func scannableBody(r *http.Request, service string) bool { } func scanBody(r *http.Request, partition string) (string, bool) { - body, err := io.ReadAll(io.LimitReader(r.Body, maxPartitionScanBody+1)) + buf := bytes.NewBuffer(make([]byte, 0, r.ContentLength+bytes.MinRead)) + _, err := buf.ReadFrom(io.LimitReader(r.Body, maxPartitionScanBody+1)) + body := buf.Bytes() _ = r.Body.Close() r.Body = io.NopCloser(bytes.NewReader(body)) @@ -117,8 +119,13 @@ func scanBody(r *http.Request, partition string) (string, bool) { return "", false } + form := strings.Contains(r.Header.Get("Content-Type"), "x-www-form-urlencoded") + if !form && !bytes.Contains(body, []byte("arn:")) { + return "", false + } + text := string(body) - if strings.Contains(r.Header.Get("Content-Type"), "x-www-form-urlencoded") { + if form { if vals, perr := url.ParseQuery(text); perr == nil { text = flattenValues(vals) } diff --git a/internal/app/partition_strict_test.go b/internal/app/partition_strict_test.go index b6023704a9..35cabefd3c 100644 --- a/internal/app/partition_strict_test.go +++ b/internal/app/partition_strict_test.go @@ -42,7 +42,7 @@ func partitionServer(t *testing.T) *echo.Echo { cli.portAlloc = setupPortAllocatorWithReservations(ctx, log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: ctx, PortAlloc: cli.portAlloc, }) if err != nil { diff --git a/internal/app/redshift_engine_test.go b/internal/app/redshift_engine_test.go index 7007b532b8..4118001e1b 100644 --- a/internal/app/redshift_engine_test.go +++ b/internal/app/redshift_engine_test.go @@ -69,7 +69,7 @@ func TestRedshiftEngineWiring(t *testing.T) { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/redshift_engine_wiring_test.go b/internal/app/redshift_engine_wiring_test.go index b6bf3f073f..d1dc65fd67 100644 --- a/internal/app/redshift_engine_wiring_test.go +++ b/internal/app/redshift_engine_wiring_test.go @@ -25,7 +25,7 @@ func TestRedshiftWorkgroupEngineAndS3Wiring(t *testing.T) { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/redshift_serverless_data_test.go b/internal/app/redshift_serverless_data_test.go index b63514cf61..4868557915 100644 --- a/internal/app/redshift_serverless_data_test.go +++ b/internal/app/redshift_serverless_data_test.go @@ -28,7 +28,7 @@ func TestRedshiftServerlessDataAPI(t *testing.T) { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/region_isolation_test.go b/internal/app/region_isolation_test.go index 039733651b..6ea54eb7c8 100644 --- a/internal/app/region_isolation_test.go +++ b/internal/app/region_isolation_test.go @@ -48,7 +48,7 @@ func newRegionServer(t *testing.T) string { cli := &CLI{AccountID: "000000000000", Region: regionA} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), }) require.NoError(t, err) @@ -64,7 +64,8 @@ func newRegionServer(t *testing.T) string { router := service.NewServiceRouter(registry). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) e.Use(router.RouteHandler()) srv := httptest.NewServer(e) diff --git a/internal/app/region_wiring_test.go b/internal/app/region_wiring_test.go index f9e5d4d05f..f6cd071af2 100644 --- a/internal/app/region_wiring_test.go +++ b/internal/app/region_wiring_test.go @@ -73,7 +73,7 @@ func TestInitializeServices_RegionAwareCrossServiceWiring(t *testing.T) { cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), PortAlloc: portAlloc, }) require.NoError(t, err) diff --git a/internal/app/regional_platform_wiring_test.go b/internal/app/regional_platform_wiring_test.go index 1c418b05db..22646b5770 100644 --- a/internal/app/regional_platform_wiring_test.go +++ b/internal/app/regional_platform_wiring_test.go @@ -118,7 +118,10 @@ func TestInitializeServices_CodePipelineActionsRunInPipelineRegion(t *testing.T) } const cfnPlatformTemplate = `{"Resources":{ -"P":{"Type":"AWS::CodeBuild::Project","Properties":{"Name":"cfn-proj"}}, +"P":{"Type":"AWS::CodeBuild::Project","Properties":{"Name":"cfn-proj", +"ServiceRole":"arn:aws:iam::000000000000:role/cb","Artifacts":{"Type":"NO_ARTIFACTS"}, +"Environment":{"Type":"LINUX_CONTAINER","ComputeType":"BUILD_GENERAL1_SMALL","Image":"aws/codebuild/standard:7.0"}, +"Source":{"Type":"NO_SOURCE","BuildSpec":"version: 0.2"}}}, "D":{"Type":"AWS::GuardDuty::Detector","Properties":{"Enable":true}}}}` func TestInitializeServices_CloudFormationProvisionsPlatformServicesInStackRegion(t *testing.T) { diff --git a/internal/app/rgtapi_extra_tags_wiring_test.go b/internal/app/rgtapi_extra_tags_wiring_test.go index ff52fba4b6..2eaec3bd80 100644 --- a/internal/app/rgtapi_extra_tags_wiring_test.go +++ b/internal/app/rgtapi_extra_tags_wiring_test.go @@ -130,7 +130,7 @@ func TestInitializeServices_RGTAPIExtraServicesWiring(t *testing.T) { appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/rgtapi_tagpolicy_wiring_test.go b/internal/app/rgtapi_tagpolicy_wiring_test.go index 9bffc7d730..9f754870bb 100644 --- a/internal/app/rgtapi_tagpolicy_wiring_test.go +++ b/internal/app/rgtapi_tagpolicy_wiring_test.go @@ -31,7 +31,7 @@ func TestInitializeServices_ResourceGroupsTaggingPolicyWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/rgtapi_wiring_helpers_test.go b/internal/app/rgtapi_wiring_helpers_test.go index 61c4f9ca68..0b95e7cdc3 100644 --- a/internal/app/rgtapi_wiring_helpers_test.go +++ b/internal/app/rgtapi_wiring_helpers_test.go @@ -27,7 +27,7 @@ func newRGTWiringEnv(t *testing.T) (map[string]service.Registerable, *rgtapiback appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/routing_alt_gates_test.go b/internal/app/routing_alt_gates_test.go new file mode 100644 index 0000000000..fed407c1e0 --- /dev/null +++ b/internal/app/routing_alt_gates_test.go @@ -0,0 +1,232 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/internal/routegates" + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const ( + altLambdaHost = "abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws" + altRowStride = 16 + altFormCT = "application/x-www-form-urlencoded" + altMaxASCII = 0x7f +) + +// altAdmits is an independent reference for service.AltGate semantics. +func altAdmits(g service.AltGate, req *http.Request, rc routingCase) bool { + path := req.URL.Path + collapsed := "/" + strings.TrimLeft(path, "/") + lowerUA := strings.ToLower(req.Header.Get("User-Agent")) + "\x00" + + strings.ToLower(req.Header.Get("X-Amz-User-Agent")) + nonASCII := strings.ContainsFunc(req.Host+lowerUA, func(r rune) bool { return r > altMaxASCII }) + svc := httputils.ExtractServiceFromRequest(req) + + return slices.ContainsFunc(g.Target, func(p string) bool { return strings.HasPrefix(rc.target, p) }) || + slices.ContainsFunc(g.Path, func(p string) bool { return strings.HasPrefix(collapsed, p) }) || + slices.ContainsFunc(g.PathSuffix, func(p string) bool { return strings.HasSuffix(path, p) }) || + slices.ContainsFunc(g.Host, func(p string) bool { + return nonASCII || strings.Contains(strings.ToLower(req.Host), p) + }) || + slices.ContainsFunc(g.UserAgent, func(p string) bool { return nonASCII || strings.Contains(lowerUA, p) }) || + slices.ContainsFunc(g.ContentType, func(p string) bool { + return strings.Contains(strings.ToLower(req.Header.Get("Content-Type")), p) + }) || + slices.ContainsFunc(g.Query, func(p string) bool { return strings.Contains(req.URL.RawQuery, p) }) || + slices.Contains(g.Scope, svc) +} + +func altVariants(rc routingCase, g service.AltGate) []routingCase { + _, query := splitURI(rc.uri) + + var out []routingCase + + mut := func(f func(*routingCase)) { + v := rc + f(&v) + out = append(out, v) + } + + mut(func(v *routingCase) { v.target = "" }) + mut(func(v *routingCase) { v.target = "Zz.Op" }) + mut(func(v *routingCase) { v.authSvc = "" }) + mut(func(v *routingCase) { v.authSvc = "s3" }) + + for _, p := range g.Target { + mut(func(v *routingCase) { v.target = p + "Op" }) + } + + for _, s := range append([]string{"s3"}, g.Scope...) { + mut(func(v *routingCase) { v.authSvc = s }) + mut(func(v *routingCase) { + sep := "?" + if query != "" { + sep = "&" + } + + v.authSvc = "" + v.uri += sep + "X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F" + s + "%2Faws4_request&X-Amz-Signature=00" + }) + } + + for _, h := range []string{ + "localhost:4566", altLambdaHost, "ABC.LAMBDA-URL.eu-west-1.LOCALHOST:4566", "x.lambda-url.y.on.aws:443", + } { + mut(func(v *routingCase) { v.host = h }) + } + + for _, ua := range []string{ + "", "User-Agent:MediaStoreData", "X-Amz-User-Agent:aws-sdk-js mediastore-data", "User-Agent:xİmediastoredata", + "User-Agent:aws-sdk-go-v2 api/mediastoredata#1", "User-Agent:aws-sdk-go-v2 api/mediastore#1", + } { + mut(func(v *routingCase) { v.headers = ua }) + } + + formBodies := []string{ + "Action=ListQueues&Version=2012-11-05", "Action=GetCallerIdentity&Version=2011-06-15", + "Action=ListTopics&Version=2010-03-31", + } + + for _, ct := range []string{"", altFormCT, "APPLICATION/X-WWW-FORM-URLENCODED; charset=utf-8"} { + mut(func(v *routingCase) { v.ctype = ct }) + mut(func(v *routingCase) { v.ctype, v.method = ct, "POST" }) + + for _, body := range formBodies { + mut(func(v *routingCase) { v.ctype, v.method, v.body = ct, "POST", body }) + } + } + + for _, q := range []string{ + "?Action=ConfirmSubscription&TopicArn=x", "?Act%69on=ConfirmSubscription&TopicArn=x", "?x=1", + } { + path, _ := splitURI(rc.uri) + + mut(func(v *routingCase) { v.method, v.uri = "GET", path+q }) + } + + for _, p := range g.Path { + mut(func(v *routingCase) { v.uri = p + "x" + query }) + mut(func(v *routingCase) { v.uri = "/" + p + query }) + } + + for _, sfx := range g.PathSuffix { + s := "/" + strings.TrimLeft(sfx, "/") + + mut(func(v *routingCase) { v.uri = s + query }) + mut(func(v *routingCase) { v.uri = "/pool" + s + query }) + mut(func(v *routingCase) { v.uri = "/a/b" + s + "x" + query }) + mut(func(v *routingCase) { v.uri = "/a/b" + strings.TrimLeft(sfx, "/") + query }) + } + + return out +} + +func TestRoutingAltGatedLookupMatchesScan(t *testing.T) { + t.Parallel() + + reg, entries := routingFixture(t) + router := service.NewServiceRouter(reg). + WithTargetGates(routegates.TargetGates()). + WithCBORGates(routegates.CBORGates()). + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) + + cases := loadRoutingCorpus(t) + if testing.Short() { + cases = strideSample(cases, altRowStride) + } + + gates := routegates.AltGates() + + // Lookup and the scan consider every service, so the per-service variant sets are + // unioned and deduplicated; each distinct request is checked exactly once. + chunk := (len(cases) + holdChunks - 1) / holdChunks + + var done sync.Map + + for i := range holdChunks { + part := cases[min(i*chunk, len(cases)):min((i+1)*chunk, len(cases))] + + t.Run("chunk", func(t *testing.T) { + t.Parallel() + + for _, rc := range part { + seen := map[routingCase]bool{rc: true} + all := []routingCase{rc} + + for _, g := range gates { + for _, v := range altVariants(rc, g) { + if !seen[v] { + seen[v] = true + all = append(all, v) + } + } + } + + for _, v := range all { + if _, dup := done.LoadOrStore(v, struct{}{}); dup { + continue + } + + scan := scanSelect(entries, echo.NewContext(v.request(t), httptest.NewRecorder())) + got := entryName(router.Lookup(echo.NewContext(v.request(t), httptest.NewRecorder()))) + require.Equal(t, scan, got, "%s %s%s target=%q auth=%q headers=%q", + v.method, v.host, v.uri, v.target, v.authSvc, v.headers) + } + } + }) + } +} + +func BenchmarkRouterLookupS3Object(b *testing.B) { + _, byName := benchServer(b) + reg := service.NewRegistry() + + for _, svc := range byName { + require.NoError(b, reg.Register(svc)) + } + + base := service.NewServiceRouter(reg). + WithTargetGates(routegates.TargetGates()). + WithCBORGates(routegates.CBORGates()). + WithPathGates(routegates.PathGates()) + withAlt := service.NewServiceRouter(reg). + WithTargetGates(routegates.TargetGates()). + WithCBORGates(routegates.CBORGates()). + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) + + routers := []struct { + r *service.Router + name string + }{{base, "no_alt"}, {withAlt, "alt"}} + + for _, method := range []string{"GET", "PUT"} { + for _, rt := range routers { + b.Run(method+"/"+rt.name, func(b *testing.B) { + b.ReportAllocs() + + req := httptest.NewRequest(method, "http://localhost:4566/bench-server-bucket/obj.txt", nil) + req.Header.Set("Authorization", strings.Replace(benchAuth, "%s", "s3", 1)) + + c := echo.NewContext(req, httptest.NewRecorder()) + + for range b.N { + if e := rt.r.Lookup(c); e == nil || e.Registerable.Name() != "S3" { + b.Fatal("not S3") + } + } + }) + } + } +} diff --git a/internal/app/routing_equivalence_test.go b/internal/app/routing_equivalence_test.go index ced413f034..d1637dc61b 100644 --- a/internal/app/routing_equivalence_test.go +++ b/internal/app/routing_equivalence_test.go @@ -191,7 +191,8 @@ func TestRoutingEquivalence(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) scan := selectAll(t, cases, func(c *echo.Context) string { return scanSelect(entries, c) }) lookup := selectAll(t, cases, func(c *echo.Context) string { return entryName(router.Lookup(c)) }) @@ -243,14 +244,17 @@ func TestRoutingCorpusCoversServices(t *testing.T) { neverMatch := []string{ "AzureAppConfig", "AzureARM", "AzureBlob", "AzureEventGrid", "AzureEventHubs", "AzureKeyVault", "AzureQueue", - "AzureFile", "AzureMonitor", "AzureServiceBus", "AzureStorageVHost", "AzureTable", "CosmosDB", + "AzureFile", "AzureGraph", "AzureMonitor", "AzureServiceBus", "AzureStorageVHost", "AzureTable", "CosmosDB", "GCPStorage", "GCPPubSub", "GCPSecretManager", "GCPKMS", "GCPCloudTasks", "GCPScheduler", "GCPFirestore", "GCPIAM", "GCPRun", "GCPFunctions", "GCPBigQuery", "GCPSQL", "GCPLogging", "GCPMonitoring", "GCPRedis", "AzureRedis", "AzureSQL", "GCPSpanner", "GCPBigtable", "GCPCloudBuild", "GCPEventarc", - "GCPWorkflows", "GCPDNS", "GCPCompute", + "GCPWorkflows", "GCPDNS", "GCPCompute", "GCPDatastore", "GCPContainer", + "GCPIdentityToolkit", "GCPAPIGateway", "GCPBatch", "GCPResourceManager", "AzureContainerRegistry", "AzureFunctions", "GCPArtifactRegistry", "OCIObjectStorage", - "OCIVault", "OCIStreaming", "OCIIdentity", "OCIFunctions", "OCICore", + "OCIVault", "OCIStreaming", "OCIIdentity", "OCIFunctions", "OCICore", "OCIQueue", "OCINotifications", + "OCIContainerEngine", "OCIMySQL", "OCILogging", "OCIMonitoring", "OCINoSQL", "OCIAPIGateway", "OCIEvents", + "OCILoadBalancer", "OCIArtifacts", "OCIContainerInstances", } for _, e := range entries { @@ -263,37 +267,11 @@ func TestRoutingCorpusCoversServices(t *testing.T) { assert.Empty(t, missing, "services never selected by the corpus") } -func TestRouteTargetGatesHold(t *testing.T) { - t.Parallel() - - _, entries := routingFixture(t) - cases := loadRoutingCorpus(t) - byName := map[string]*service.Entry{} +func cborCovered(name string, rc routingCase) bool { + shape := routegates.CBORGates()[name] + path, _ := splitURI(rc.uri) - for _, e := range entries { - byName[e.Registerable.Name()] = e - } - - for name, prefixes := range routegates.TargetGates() { - t.Run(name, func(t *testing.T) { - t.Parallel() - - entry := byName[name] - require.NotNil(t, entry, "gate names an unregistered service") - - for _, rc := range cases { - for _, target := range []string{"", "Zz.Op", "Zz" + strings.Join(prefixes, "")} { - rc.target = target - if hasTargetPrefix(target, prefixes) { - continue - } - - c := echo.NewContext(rc.request(t), httptest.NewRecorder()) - require.False(t, entry.Matcher(c), "%s matched %s %s, target %q", name, rc.method, rc.uri, target) - } - } - }) - } + return shape != "" && strings.HasPrefix(path, "/service/"+shape+"/operation/") } func hasTargetPrefix(target string, prefixes []string) bool { diff --git a/internal/app/routing_gates_hold_test.go b/internal/app/routing_gates_hold_test.go new file mode 100644 index 0000000000..49757e796c --- /dev/null +++ b/internal/app/routing_gates_hold_test.go @@ -0,0 +1,206 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/internal/routegates" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const ( + holdChunks = 8 + holdShortStride = 4 +) + +// holdGate is one gate under proof: covered reports whether the gate admits the request; +// entry's matcher must return false for every request it does not admit. +type holdGate struct { + entry *service.Entry + covered func(rc routingCase, req *http.Request) bool + name string +} + +// sweepHolds builds each variant request once and checks every gate against it, so the +// request set is shared across gates instead of rebuilt per gate. +func sweepHolds(t *testing.T, gates []holdGate, cases []routingCase, variants func(routingCase) []routingCase) { + t.Helper() + + chunk := (len(cases) + holdChunks - 1) / holdChunks + + var seen sync.Map + + for i := range holdChunks { + part := cases[min(i*chunk, len(cases)):min((i+1)*chunk, len(cases))] + + t.Run("chunk", func(t *testing.T) { + t.Parallel() + + for _, rc := range part { + for _, v := range variants(rc) { + if _, dup := seen.LoadOrStore(v, struct{}{}); dup { + continue + } + + req := v.request(t) + + for _, g := range gates { + if g.covered(v, req) { + continue + } + + require.False(t, g.entry.Matcher(echo.NewContext(req, httptest.NewRecorder())), + "%s matched %s %s%s outside its gate (target=%q auth=%q headers=%q)", + g.name, v.method, v.host, v.uri, v.target, v.authSvc, v.headers) + } + } + } + }) + } +} + +func holdEntries(t *testing.T) map[string]*service.Entry { + t.Helper() + + _, entries := routingFixture(t) + byName := map[string]*service.Entry{} + + for _, e := range entries { + byName[e.Registerable.Name()] = e + } + + return byName +} + +func TestRoutePathGatesHold(t *testing.T) { + t.Parallel() + + byName := holdEntries(t) + + pathGates := routegates.PathGates() + gates := make([]holdGate, 0, len(pathGates)) + + for name, prefixes := range pathGates { + require.NotNil(t, byName[name], "gate names an unregistered service") + + gates = append( + gates, + holdGate{name: name, entry: byName[name], covered: func(_ routingCase, req *http.Request) bool { + path := "/" + strings.TrimLeft(req.URL.Path, "/") + + return slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(path, p) }) + }}, + ) + } + + cases := loadRoutingCorpus(t) + arnRows := make(map[routingCase]bool, len(cases)) + + for _, rc := range cases { + arnRows[rc] = !testing.Short() + } + + if testing.Short() { + for _, rc := range strideSample(cases, holdShortStride) { + arnRows[rc] = true + } + } + + sweepHolds(t, gates, cases, func(rc routingCase) []routingCase { + if !arnRows[rc] { + return slices.Concat([]routingCase{rc}, pathVariants(rc)) + } + + return slices.Concat([]routingCase{rc}, pathVariants(rc), arnVariants(rc)) + }) +} + +func TestRouteTargetGatesHold(t *testing.T) { + t.Parallel() + + byName := holdEntries(t) + + targetGates := routegates.TargetGates() + gates := make([]holdGate, 0, len(targetGates)) + + var all []string + + for name, prefixes := range targetGates { + require.NotNil(t, byName[name], "gate names an unregistered service") + + all = append(all, prefixes...) + gates = append( + gates, + holdGate{name: name, entry: byName[name], covered: func(rc routingCase, _ *http.Request) bool { + return hasTargetPrefix(rc.target, prefixes) || cborCovered(name, rc) + }}, + ) + } + + targets := []string{"", "Zz.Op", "Zz" + strings.Join(all, "")} + + sweepHolds(t, gates, loadRoutingCorpus(t), func(rc routingCase) []routingCase { + out := make([]routingCase, 0, len(targets)) + + for _, target := range targets { + rc.target = target + out = append(out, rc) + } + + return out + }) +} + +func TestRouteAltGatesHold(t *testing.T) { + t.Parallel() + + byName := holdEntries(t) + altGates := routegates.AltGates() + + gates := make([]holdGate, 0, len(altGates)) + + for name, gate := range altGates { + require.NotNil(t, byName[name], "gate names an unregistered service") + + gates = append( + gates, + holdGate{name: name, entry: byName[name], covered: func(rc routingCase, req *http.Request) bool { + return altAdmits(gate, req, rc) + }}, + ) + } + + cases := loadRoutingCorpus(t) + sampled := make(map[routingCase]bool) + + for _, rc := range strideSample(cases, altRowStride) { + sampled[rc] = true + } + + sweepHolds(t, gates, cases, func(rc routingCase) []routingCase { + out := slices.Concat([]routingCase{rc}, pathVariants(rc)) + if !sampled[rc] { + return out + } + + seen := make(map[routingCase]bool) + + for _, g := range altGates { + for _, v := range altVariants(rc, g) { + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + } + + return out + }) +} diff --git a/internal/app/routing_index_test.go b/internal/app/routing_index_test.go index b68714d353..d6f9080d1b 100644 --- a/internal/app/routing_index_test.go +++ b/internal/app/routing_index_test.go @@ -12,7 +12,6 @@ import ( "github.com/labstack/echo/v5" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" ) @@ -162,7 +161,8 @@ func TestRoutingIndexMatchesLinearScan(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) all := make([]routingCase, 0, len(cases)*(1+indexVariantsPerCase)+indexCrossCases) @@ -204,44 +204,6 @@ func TestRoutingIndexMatchesLinearScan(t *testing.T) { } } -func TestRoutePathGatesHold(t *testing.T) { - t.Parallel() - - _, entries := routingFixture(t) - cases := loadRoutingCorpus(t) - byName := map[string]*service.Entry{} - - for _, e := range entries { - byName[e.Registerable.Name()] = e - } - - for name, prefixes := range routegates.PathGates() { - t.Run(name, func(t *testing.T) { - t.Parallel() - - entry := byName[name] - require.NotNil(t, entry, "gate names an unregistered service") - - covered := func(rc routingCase) bool { - path, _ := splitURI(rc.uri) - - return slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(path, p) }) - } - - for _, rc := range cases { - for _, v := range append(append([]routingCase{rc}, pathVariants(rc)...), arnVariants(rc)...) { - if covered(v) { - continue - } - - c := echo.NewContext(v.request(t), httptest.NewRecorder()) - require.False(t, entry.Matcher(c), "%s matched %s %s outside its path gate", name, v.method, v.uri) - } - } - }) - } -} - func TestRoutingPathGatedAlternatives(t *testing.T) { t.Parallel() @@ -249,7 +211,8 @@ func TestRoutingPathGatedAlternatives(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) tests := []struct { name, method, uri, target, want string @@ -300,7 +263,8 @@ func TestRoutingLambdaFunctionURLHost(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) tests := []struct { name, method, host, uri string @@ -331,7 +295,8 @@ func TestRoutingRPCv2CBORGates(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) tests := []struct { name, uri, want, notWant string @@ -367,3 +332,68 @@ func TestRoutingRPCv2CBORGates(t *testing.T) { }) } } + +func gatePrefixCases(rc routingCase, prefixes []string) []routingCase { + out := make([]routingCase, 0, 9*len(prefixes)) + + for _, p := range prefixes { + rest := strings.TrimLeft(p, "/") + + for _, lead := range []string{"/", "//", "///"} { + for _, tail := range []string{"", "/x", "x"} { + v := rc + v.uri = lead + rest + tail + out = append(out, v) + } + } + } + + return out +} + +func TestRoutingIndexLeadingSlashes(t *testing.T) { + t.Parallel() + + reg, entries := routingFixture(t) + router := service.NewServiceRouter(reg). + WithTargetGates(routegates.TargetGates()). + WithCBORGates(routegates.CBORGates()). + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) + + var cases []routingCase + + for _, rc := range loadRoutingCorpus(t) { + path, query := splitURI(rc.uri) + + for _, extra := range []string{"/", "//"} { + v := rc + v.uri = extra + path + query + cases = append(cases, v) + } + } + + for _, prefixes := range routegates.PathGates() { + for _, method := range []string{"GET", "POST", "PUT", "DELETE"} { + base := routingCase{method: method, host: "localhost:4566"} + cases = append(cases, gatePrefixCases(base, prefixes)...) + } + } + + if testing.Short() { + cases = strideSample(cases, indexShortStride) + } + + var diffs []string + + for _, rc := range cases { + scan := scanSelect(entries, echo.NewContext(rc.request(t), httptest.NewRecorder())) + got := entryName(router.Lookup(echo.NewContext(rc.request(t), httptest.NewRecorder()))) + + if got != scan && len(diffs) < routingMaxDiffs { + diffs = append(diffs, rc.method+" "+rc.host+rc.uri+": got "+got+" want "+scan) + } + } + + assert.Empty(t, diffs, "indexed Lookup diverged from the ungated scan on leading-slash paths") +} diff --git a/internal/app/routing_namespaced_target_test.go b/internal/app/routing_namespaced_target_test.go index 2996188350..af3d7d0392 100644 --- a/internal/app/routing_namespaced_target_test.go +++ b/internal/app/routing_namespaced_target_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestNamespacedTargetRouting(t *testing.T) { @@ -49,7 +51,7 @@ func TestNamespacedTargetRouting(t *testing.T) { "AWS4-HMAC-SHA256 Credential=test/20261001/us-east-1/"+tt.sigName+"/aws4_request, SignedHeaders=host, Signature=x", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/internal/app/routing_signing_scope_test.go b/internal/app/routing_signing_scope_test.go index 80d164099f..674b9c4712 100644 --- a/internal/app/routing_signing_scope_test.go +++ b/internal/app/routing_signing_scope_test.go @@ -215,7 +215,8 @@ func TestRoutingSigningScopeDispatch(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) var ( mu sync.Mutex @@ -263,7 +264,8 @@ func TestRoutingSigningScopeUnimplementedNotBatch(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) tests := []struct { name, authSvc, uri string diff --git a/internal/app/routing_target_ownership_test.go b/internal/app/routing_target_ownership_test.go index 90244106cd..115915aebf 100644 --- a/internal/app/routing_target_ownership_test.go +++ b/internal/app/routing_target_ownership_test.go @@ -19,7 +19,8 @@ func TestRoutingTargetOwnership(t *testing.T) { router := service.NewServiceRouter(reg). WithTargetGates(routegates.TargetGates()). WithCBORGates(routegates.CBORGates()). - WithPathGates(routegates.PathGates()) + WithPathGates(routegates.PathGates()). + WithAltGates(routegates.AltGates()) const ( local = "localhost:4566" diff --git a/internal/app/s3_rgtapi_tags_wiring_test.go b/internal/app/s3_rgtapi_tags_wiring_test.go index fa2ed8292b..8602d9ef4b 100644 --- a/internal/app/s3_rgtapi_tags_wiring_test.go +++ b/internal/app/s3_rgtapi_tags_wiring_test.go @@ -35,7 +35,7 @@ func TestInitializeServices_S3TagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/s3_snapshot_options_test.go b/internal/app/s3_snapshot_options_test.go index 33c26847c7..48d7885ac8 100644 --- a/internal/app/s3_snapshot_options_test.go +++ b/internal/app/s3_snapshot_options_test.go @@ -196,7 +196,7 @@ func newWrappedOptionsFixture( cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/s3control_object_lambda_wiring_test.go b/internal/app/s3control_object_lambda_wiring_test.go index 77f1d0cef6..feb8b76b74 100644 --- a/internal/app/s3control_object_lambda_wiring_test.go +++ b/internal/app/s3control_object_lambda_wiring_test.go @@ -47,7 +47,7 @@ func TestInitializeServices_S3ControlObjectLambdaWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/s3control_rgtapi_tags_wiring_test.go b/internal/app/s3control_rgtapi_tags_wiring_test.go index efb5092bb3..a530b6c183 100644 --- a/internal/app/s3control_rgtapi_tags_wiring_test.go +++ b/internal/app/s3control_rgtapi_tags_wiring_test.go @@ -29,7 +29,7 @@ func TestInitializeServices_S3ControlTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/sagemaker_engine_docker_test.go b/internal/app/sagemaker_engine_docker_test.go index 11d37b8f39..11e35733cf 100644 --- a/internal/app/sagemaker_engine_docker_test.go +++ b/internal/app/sagemaker_engine_docker_test.go @@ -146,7 +146,7 @@ func smStack(t *testing.T) smClients { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/sagemaker_s3_pipeline_wiring_test.go b/internal/app/sagemaker_s3_pipeline_wiring_test.go index 96d7eca9c1..185dccee58 100644 --- a/internal/app/sagemaker_s3_pipeline_wiring_test.go +++ b/internal/app/sagemaker_s3_pipeline_wiring_test.go @@ -39,7 +39,7 @@ func TestInitializeServices_SageMakerS3PipelineWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/scheduler_sagemaker_wiring_test.go b/internal/app/scheduler_sagemaker_wiring_test.go index a077f85568..ce28b7576d 100644 --- a/internal/app/scheduler_sagemaker_wiring_test.go +++ b/internal/app/scheduler_sagemaker_wiring_test.go @@ -31,7 +31,7 @@ func TestInitializeServices_SchedulerSageMakerWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/scheduler_sqs_fifo_wiring_test.go b/internal/app/scheduler_sqs_fifo_wiring_test.go index c16c140a97..f430bfa565 100644 --- a/internal/app/scheduler_sqs_fifo_wiring_test.go +++ b/internal/app/scheduler_sqs_fifo_wiring_test.go @@ -32,7 +32,7 @@ func TestInitializeServices_SchedulerSQSFIFOWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/server_path_mixed_bench_test.go b/internal/app/server_path_mixed_bench_test.go new file mode 100644 index 0000000000..6a9ec9f66c --- /dev/null +++ b/internal/app/server_path_mixed_bench_test.go @@ -0,0 +1,99 @@ +package app + +import ( + "net/http" + "strings" + "sync/atomic" + "testing" +) + +type mixedCase struct { + name, method, path, signSvc, ctype, target, body string +} + +func mixedCases() []mixedCase { + const ( + json10 = "application/x-amz-json-1.0" + form = "application/x-www-form-urlencoded" + ) + + return []mixedCase{ + {"ddb_getitem", "POST", "/", "dynamodb", json10, "DynamoDB_20120810.GetItem", + `{"TableName":"bench-server-table","Key":{"id":{"S":"k"}}}`}, + {"sts_query", "POST", "/", "sts", form, "", "Action=GetCallerIdentity&Version=2011-06-15"}, + {"sns_query", "POST", "/", "sns", form, "", "Action=ListTopics&Version=2010-03-31"}, + {"sqs_query", "POST", "/", "sqs", form, "", "Action=ListQueues&Version=2012-11-05"}, + {"ec2_describe_instances", "POST", "/", "ec2", form, "", "Action=DescribeInstances&Version=2016-11-15"}, + {"s3_getobject", "GET", "/bench-server-bucket/obj.txt", "s3", "", "", ""}, + {"apigw_restjson", "GET", "/restapis", "apigateway", "", "", ""}, + } +} + +func (tc mixedCase) newRequest(tb testing.TB) *http.Request { + tb.Helper() + + req, err := http.NewRequestWithContext( + tb.Context(), tc.method, benchEndpoint+tc.path, strings.NewReader(tc.body), + ) + if err != nil { + tb.Fatal(err) + } + + req.Header.Set("Authorization", strings.Replace(benchAuth, "%s", tc.signSvc, 1)) + req.Header.Set("X-Amz-Date", "20260101T000000Z") + + if tc.ctype != "" { + req.Header.Set("Content-Type", tc.ctype) + } + + if tc.target != "" { + req.Header.Set("X-Amz-Target", tc.target) + } + + return req +} + +// BenchmarkServerPathMixed round-robins requests across many services so +// routing cost across the whole matcher set is visible. +func BenchmarkServerPathMixed(b *testing.B) { + e, byName := benchServer(b) + serverFixtures(b, byName) + + cases := mixedCases() + + b.ReportAllocs() + + for i := range b.N { + w := &discardWriter{h: make(http.Header)} + e.ServeHTTP(w, cases[i%len(cases)].newRequest(b)) + + if w.code >= http.StatusInternalServerError { + b.Fatalf("status %d", w.code) + } + } +} + +// BenchmarkServerPathMixedParallel is the concurrent variant of BenchmarkServerPathMixed. +func BenchmarkServerPathMixedParallel(b *testing.B) { + e, byName := benchServer(b) + serverFixtures(b, byName) + + cases := mixedCases() + + var next atomic.Uint64 + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + i := next.Add(1) + w := &discardWriter{h: make(http.Header)} + e.ServeHTTP(w, cases[i%uint64(len(cases))].newRequest(b)) + + if w.code >= http.StatusInternalServerError { + b.Errorf("status %d", w.code) + + return + } + } + }) +} diff --git a/internal/app/server_soak_leak_test.go b/internal/app/server_soak_leak_test.go new file mode 100644 index 0000000000..cc4d3d1d46 --- /dev/null +++ b/internal/app/server_soak_leak_test.go @@ -0,0 +1,571 @@ +package app + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/cloudformation" + "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/cloudwatchlogs" + cwltypes "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" + "github.com/aws/aws-sdk-go-v2/service/eventbridge" + ebtypes "github.com/aws/aws-sdk-go-v2/service/eventbridge/types" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/sfn" + "github.com/aws/aws-sdk-go-v2/service/sns" + "github.com/aws/aws-sdk-go-v2/service/sqs" + "github.com/aws/aws-sdk-go-v2/service/ssm" + ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "net/http/httptest" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testleak" + cloudtrailbackend "github.com/blackbirdworks/gopherstack/services/cloudtrail" +) + +const ( + soakRestartCycles = 5 + // soakRestartHeapBudget bounds retained heap after the warm-up cycle; + // a service leaking its backend per restart retains tens of MB per cycle. + soakRestartHeapBudget = 24 << 20 + soakWarmupIters = 200 + soakMeasureIters = 600 + soakHeapBudget = 16 << 20 + soakObjBudget = 150_000 +) + +// listeningSockets returns "proto:port" for every TCP listener and bound UDP +// socket owned by this process, read from /proc. +func listeningSockets(t *testing.T) map[string]bool { + t.Helper() + + owned := map[string]bool{} + + fds, err := os.ReadDir("/proc/self/fd") + require.NoError(t, err) + + for _, fd := range fds { + target, linkErr := os.Readlink(filepath.Join("/proc/self/fd", fd.Name())) + if linkErr == nil && strings.HasPrefix(target, "socket:[") { + owned[strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")] = true + } + } + + out := map[string]bool{} + + for _, tbl := range []struct{ file, state string }{ + {"tcp", "0A"}, {"tcp6", "0A"}, {"udp", "07"}, {"udp6", "07"}, + } { + f, openErr := os.Open("/proc/net/" + tbl.file) + require.NoError(t, openErr) + + sc := bufio.NewScanner(f) + sc.Scan() + + for sc.Scan() { + cols := strings.Fields(sc.Text()) + if len(cols) < 10 || cols[3] != tbl.state || !owned[cols[9]] { + continue + } + + _, hexPort, _ := strings.Cut(cols[1], ":") + port, _ := strconv.ParseUint(hexPort, 16, 32) + out[strings.TrimSuffix(tbl.file, "6")+":"+strconv.FormatUint(port, 10)] = true + } + + require.NoError(t, f.Close()) + } + + return out +} + +func soakConfig(t *testing.T, endpoint string) aws.Config { + t.Helper() + + cfg, err := awscfg.LoadDefaultConfig( + t.Context(), + awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + awscfg.WithBaseEndpoint(endpoint), + awscfg.WithRetryMaxAttempts(1), + ) + require.NoError(t, err) + + return cfg +} + +type soakClients struct { + sts *sts.Client + sqs *sqs.Client + sns *sns.Client + kms *kms.Client + sm *secretsmanager.Client + ssm *ssm.Client + eb *eventbridge.Client + cw *cloudwatch.Client + cwl *cloudwatchlogs.Client + iam *iam.Client + sfn *sfn.Client + cfn *cloudformation.Client +} + +// newSoakClients builds one client per service; SDK clients own their +// transport, so building them per iteration would leak idle connections. +func newSoakClients(cfg aws.Config) *soakClients { + return &soakClients{ + sts: sts.NewFromConfig(cfg), sqs: sqs.NewFromConfig(cfg), sns: sns.NewFromConfig(cfg), + kms: kms.NewFromConfig(cfg), sm: secretsmanager.NewFromConfig(cfg), ssm: ssm.NewFromConfig(cfg), + eb: eventbridge.NewFromConfig(cfg), cw: cloudwatch.NewFromConfig(cfg), + cwl: cloudwatchlogs.NewFromConfig(cfg), iam: iam.NewFromConfig(cfg), + sfn: sfn.NewFromConfig(cfg), cfn: cloudformation.NewFromConfig(cfg), + } +} + +// soakTraffic drives one create/use/delete round across many unrelated +// services (S3 and DynamoDB are covered by the dedicated leak suites). +// Names carry "leak" so leakOwnedSeries counts the metric series they create. +func soakTraffic(ctx context.Context, c *soakClients, i int) error { + n := "leak" + strconv.Itoa(i) + + steps := []func() error{ + func() error { + _, err := c.sts.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + + return err + }, + func() error { return soakSQS(ctx, c.sqs, n) }, + func() error { return soakSNS(ctx, c.sns, n) }, + func() error { return soakKMS(ctx, c.kms) }, + func() error { return soakSecrets(ctx, c.sm, n) }, + func() error { return soakSSM(ctx, c.ssm, n) }, + func() error { return soakEventBridge(ctx, c.eb, n) }, + func() error { return soakCloudWatch(ctx, c.cw, n) }, + func() error { return soakLogs(ctx, c.cwl, n) }, + func() error { return soakIAM(ctx, c.iam, n) }, + func() error { return soakStepFunctions(ctx, c.sfn, n) }, + func() error { return soakCloudFormation(ctx, c.cfn, n) }, + } + + for _, step := range steps { + if err := step(); err != nil { + return err + } + } + + return nil +} + +func soakStepFunctions(ctx context.Context, c *sfn.Client, n string) error { + sm, err := c.CreateStateMachine(ctx, &sfn.CreateStateMachineInput{ + Name: aws.String(n), + RoleArn: aws.String("arn:aws:iam::000000000000:role/leak"), + Definition: aws.String(`{"StartAt":"P","States":{"P":{"Type":"Pass","End":true}}}`), + }) + if err != nil { + return err + } + + if _, err = c.StartExecution(ctx, &sfn.StartExecutionInput{StateMachineArn: sm.StateMachineArn}); err != nil { + return err + } + + _, err = c.DeleteStateMachine(ctx, &sfn.DeleteStateMachineInput{StateMachineArn: sm.StateMachineArn}) + + return err +} + +func soakCloudFormation(ctx context.Context, c *cloudformation.Client, n string) error { + const tpl = `{"Resources":{"Q":{"Type":"AWS::SQS::Queue"}}}` + + if _, err := c.CreateStack(ctx, &cloudformation.CreateStackInput{ + StackName: aws.String(n), TemplateBody: aws.String(tpl), + }); err != nil { + return err + } + + _, err := c.DeleteStack(ctx, &cloudformation.DeleteStackInput{StackName: aws.String(n)}) + + return err +} + +func soakSQS(ctx context.Context, c *sqs.Client, n string) error { + q, err := c.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String(n)}) + if err != nil { + return err + } + + if _, err = c.SendMessage(ctx, &sqs.SendMessageInput{ + QueueUrl: q.QueueUrl, MessageBody: aws.String("m"), + }); err != nil { + return err + } + + rcv, err := c.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{QueueUrl: q.QueueUrl, MaxNumberOfMessages: 1}) + if err != nil { + return err + } + + for _, m := range rcv.Messages { + if _, err = c.DeleteMessage(ctx, &sqs.DeleteMessageInput{ + QueueUrl: q.QueueUrl, ReceiptHandle: m.ReceiptHandle, + }); err != nil { + return err + } + } + + _, err = c.DeleteQueue(ctx, &sqs.DeleteQueueInput{QueueUrl: q.QueueUrl}) + + return err +} + +func soakSNS(ctx context.Context, c *sns.Client, n string) error { + topic, err := c.CreateTopic(ctx, &sns.CreateTopicInput{Name: aws.String(n)}) + if err != nil { + return err + } + + if _, err = c.Publish(ctx, &sns.PublishInput{TopicArn: topic.TopicArn, Message: aws.String("m")}); err != nil { + return err + } + + _, err = c.DeleteTopic(ctx, &sns.DeleteTopicInput{TopicArn: topic.TopicArn}) + + return err +} + +func soakKMS(ctx context.Context, c *kms.Client) error { + key, err := c.CreateKey(ctx, &kms.CreateKeyInput{}) + if err != nil { + return err + } + + enc, err := c.Encrypt(ctx, &kms.EncryptInput{KeyId: key.KeyMetadata.KeyId, Plaintext: []byte("p")}) + if err != nil { + return err + } + + if _, err = c.Decrypt(ctx, &kms.DecryptInput{CiphertextBlob: enc.CiphertextBlob}); err != nil { + return err + } + + _, err = c.ScheduleKeyDeletion(ctx, &kms.ScheduleKeyDeletionInput{ + KeyId: key.KeyMetadata.KeyId, PendingWindowInDays: aws.Int32(7), + }) + + return err +} + +func soakSecrets(ctx context.Context, c *secretsmanager.Client, n string) error { + if _, err := c.CreateSecret(ctx, &secretsmanager.CreateSecretInput{ + Name: aws.String(n), SecretString: aws.String("s"), + }); err != nil { + return err + } + + if _, err := c.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{SecretId: aws.String(n)}); err != nil { + return err + } + + _, err := c.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{ + SecretId: aws.String(n), ForceDeleteWithoutRecovery: aws.Bool(true), + }) + + return err +} + +func soakSSM(ctx context.Context, c *ssm.Client, n string) error { + name := aws.String("/" + n) + + if _, err := c.PutParameter(ctx, &ssm.PutParameterInput{ + Name: name, Value: aws.String("v"), Type: ssmtypes.ParameterTypeString, + }); err != nil { + return err + } + + if _, err := c.GetParameter(ctx, &ssm.GetParameterInput{Name: name}); err != nil { + return err + } + + _, err := c.DeleteParameter(ctx, &ssm.DeleteParameterInput{Name: name}) + + return err +} + +func soakEventBridge(ctx context.Context, c *eventbridge.Client, n string) error { + if _, err := c.PutRule(ctx, &eventbridge.PutRuleInput{ + Name: aws.String(n), EventPattern: aws.String(`{"source":["leak"]}`), + }); err != nil { + return err + } + + if _, err := c.PutEvents(ctx, &eventbridge.PutEventsInput{Entries: []ebtypes.PutEventsRequestEntry{{ + Source: aws.String("leak"), DetailType: aws.String("t"), Detail: aws.String("{}"), + }}}); err != nil { + return err + } + + _, err := c.DeleteRule(ctx, &eventbridge.DeleteRuleInput{Name: aws.String(n)}) + + return err +} + +func soakCloudWatch(ctx context.Context, c *cloudwatch.Client, n string) error { + _, err := c.PutMetricData(ctx, &cloudwatch.PutMetricDataInput{ + Namespace: aws.String("Leak/" + n), + MetricData: []cwtypes.MetricDatum{{ + MetricName: aws.String("m"), Value: aws.Float64(1), Unit: cwtypes.StandardUnitCount, + }}, + }) + + return err +} + +func soakLogs(ctx context.Context, c *cloudwatchlogs.Client, n string) error { + if _, err := c.CreateLogGroup(ctx, &cloudwatchlogs.CreateLogGroupInput{LogGroupName: aws.String(n)}); err != nil { + return err + } + + if _, err := c.CreateLogStream(ctx, &cloudwatchlogs.CreateLogStreamInput{ + LogGroupName: aws.String(n), LogStreamName: aws.String("s"), + }); err != nil { + return err + } + + if _, err := c.PutLogEvents(ctx, &cloudwatchlogs.PutLogEventsInput{ + LogGroupName: aws.String(n), LogStreamName: aws.String("s"), + LogEvents: []cwltypes.InputLogEvent{{Message: aws.String("e"), Timestamp: aws.Int64(time.Now().UnixMilli())}}, + }); err != nil { + return err + } + + _, err := c.DeleteLogGroup(ctx, &cloudwatchlogs.DeleteLogGroupInput{LogGroupName: aws.String(n)}) + + return err +} + +func soakIAM(ctx context.Context, c *iam.Client, n string) error { + if _, err := c.CreateUser(ctx, &iam.CreateUserInput{UserName: aws.String(n)}); err != nil { + return err + } + + _, err := c.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(n)}) + + return err +} + +// logTopGoroutines logs the largest goroutine groups, to attribute growth. +func logTopGoroutines(t *testing.T, top int) { + t.Helper() + + var buf strings.Builder + + require.NoError(t, pprof.Lookup("goroutine").WriteTo(&buf, 1)) + + groups := strings.Split(buf.String(), "\n\n") + count := func(g string) int { + n, _, _ := strings.Cut(strings.TrimSpace(g), " ") + v, _ := strconv.Atoi(n) + + return v + } + + slices.SortFunc(groups, func(a, b string) int { return count(b) - count(a) }) + + for _, g := range groups[:min(top, len(groups))] { + t.Logf("goroutine group:\n%s", g) + } +} + +func settledHeap() (uint64, int) { + for range 3 { + runtime.GC() + } + + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + return ms.HeapAlloc, runtime.NumGoroutine() +} + +// runServerCycle boots the full composition through run(), drives light +// traffic, shuts it down and checks the listeners it opened are released. +func runServerCycle(t *testing.T) { + t.Helper() + + before := listeningSockets(t) + + port := freeTCPPort(t) + cli := parseCLI(t, map[string]string{"PORT": strconv.Itoa(port)}) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + errCh := make(chan error, 1) + + go func() { errCh <- run(ctx, cli) }() + + waitForServerReady(t, port) + + assert.True(t, listeningSockets(t)["tcp:"+strconv.Itoa(port)], "main listener not bound") + + clients := newSoakClients(soakConfig(t, fmt.Sprintf("http://127.0.0.1:%d", port))) + for i := range 3 { + require.NoError(t, soakTraffic(ctx, clients, i)) + } + + cancel() + + select { + case err := <-errCh: + require.NoError(t, err, "server should shut down cleanly") + case <-time.After(shutdownWaitTimeout): + require.FailNow(t, "server did not shut down within timeout") + } + + after := listeningSockets(t) + for sock := range after { + assert.True(t, before[sock], "listener %s still bound after shutdown", sock) + } + + conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), time.Second) + if err == nil { + _ = conn.Close() + + assert.Fail(t, "main port still accepting connections after shutdown") + } +} + +// TestServerRestart_NoLeak starts and stops the full server (every service) +// repeatedly and asserts goroutines, heap and listeners return to baseline. +// +//nolint:paralleltest // measures process-wide goroutine and heap counts +func TestServerRestart_NoLeak(t *testing.T) { + skipLeakInShort(t) + + baseline := goleak.IgnoreCurrent() + + runServerCycle(t) + + heap0, gor0 := settledHeap() + + for range soakRestartCycles { + runServerCycle(t) + } + + var ( + heap1 uint64 + gor1 int + ) + + require.Eventually(t, func() bool { + heap1, gor1 = settledHeap() + + return gor1-gor0 <= leakMaxGoroutines + }, leakSettleTimeout, time.Second, "goroutines %d -> %d", gor0, gor1) + + t.Logf("after %d restarts: goroutines %d->%d heap %d->%d", soakRestartCycles, gor0, gor1, heap0, heap1) + require.LessOrEqual(t, int64(heap1)-int64(heap0), int64(soakRestartHeapBudget), "heap retained across restarts") + + goleak.VerifyNone(t, append(testleak.DefaultIgnores(), baseline)...) +} + +// TestServerSoak_BoundedGrowth drives a sustained create/use/delete mix across +// many services against the full composition with every background worker +// running, and asserts bounded heap, goroutine and metric-series growth. +// +//nolint:paralleltest // measures process-wide goroutine and heap counts +func TestServerSoak_BoundedGrowth(t *testing.T) { + skipLeakInShort(t) + + log := buildLogger("") + cli := CLI{AccountID: "000000000000", Region: "us-east-1"} + cli.S3.JanitorInterval = leakJanitorInterval + cli.DynamoDB.JanitorInterval = leakJanitorInterval + cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServicesT(t, &service.AppContext{ + Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, + }) + require.NoError(t, err) + + e := buildEchoServer(t.Context(), log, nil, services, cli) + require.NoError(t, setupChaosAndRegistry(e, log, &cli, services)) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + startBackgroundWorkers(t.Context(), services) + + ct, ok := serviceByName(services)["CloudTrail"].(*cloudtrailbackend.Handler) + require.True(t, ok) + + env := &leakEnv{cfg: soakConfig(t, srv.URL), resetBounded: ct.Reset} + + clients := newSoakClients(env.cfg) + + var runErr error + + op := func(i int) { + if opErr := soakTraffic(t.Context(), clients, i); opErr != nil && runErr == nil { + runErr = fmt.Errorf("iteration %d: %w", i, opErr) + } + } + + for i := range soakWarmupIters { + op(i) + } + + require.NoError(t, runErr) + + before := takeLeakSnapshot(t, env) + + for i := range soakMeasureIters { + op(soakWarmupIters + i) + } + + require.NoError(t, runErr) + + after := takeLeakSnapshot(t, env) + require.Eventually(t, func() bool { + after = takeLeakSnapshot(t, env) + + return after.series-before.series <= 0 + }, leakSettleTimeout, time.Second, "series never returned to baseline: %d -> %d", before.series, after.series) + + growth := int64(after.heapAlloc) - int64(before.heapAlloc) + objs := int64(after.heapObjs) - int64(before.heapObjs) + t.Logf("goroutines %d->%d heapAlloc %+d B heapObjs %+d series %d->%d", + before.goroutines, after.goroutines, growth, objs, before.series, after.series) + + if after.goroutines-before.goroutines > leakMaxGoroutines { + logTopGoroutines(t, 4) + } + + require.LessOrEqual(t, after.goroutines-before.goroutines, leakMaxGoroutines, "goroutine growth") + require.LessOrEqual(t, growth, int64(soakHeapBudget), "live heap growth") + require.LessOrEqual(t, objs, int64(soakObjBudget), "live heap objects growth") +} diff --git a/internal/app/service_filter.go b/internal/app/service_filter.go index 3f7cd6452e..63390ca045 100644 --- a/internal/app/service_filter.go +++ b/internal/app/service_filter.go @@ -18,6 +18,12 @@ import ( var errUnknownService = errors.New("unknown service") +const gcpResourceManagerName = "GCPResourceManager" + +var errEnforceNeedsResourceManager = errors.New( + "--gcp-enforce-projects and --gcp-enforce-service-enablement need the GCPResourceManager service, " + + "which --services / --disable-services excludes") + const ( errCodeServiceNotEnabled = "ServiceNotEnabled" alwaysOnDashboard = "dashboard" @@ -163,9 +169,16 @@ func parseServiceList(in []string, valid map[string]bool) (map[string]bool, []st // Validate is invoked by kong after parsing; it rejects unknown service names at startup. func (c *CLI) Validate() error { - _, err := newServiceFilter(c.ServiceFilter) + f, err := newServiceFilter(c.ServiceFilter) + if err != nil { + return err + } + + if c.GCPEnforce.Enabled() && !f.enabled(gcpResourceManagerName) { + return errEnforceNeedsResourceManager + } - return err + return nil } // serviceGateInterceptor answers requests for disabled services with a protocol-correct error. diff --git a/internal/app/sfn_sdk_integration_test.go b/internal/app/sfn_sdk_integration_test.go index 11fed86202..348dc98d78 100644 --- a/internal/app/sfn_sdk_integration_test.go +++ b/internal/app/sfn_sdk_integration_test.go @@ -55,7 +55,7 @@ func newSFNFixtureIAM(t *testing.T, enforceIAM bool) *sfnFixture { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) diff --git a/internal/app/sfn_test_state_http_test.go b/internal/app/sfn_test_state_http_test.go new file mode 100644 index 0000000000..2f01cfeba6 --- /dev/null +++ b/internal/app/sfn_test_state_http_test.go @@ -0,0 +1,136 @@ +package app + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sfn" + sfntypes "github.com/aws/aws-sdk-go-v2/service/sfn/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + sfnbackend "github.com/blackbirdworks/gopherstack/services/stepfunctions" +) + +func TestStepFunctionsTestStateHTTPTrace(t *testing.T) { + t.Parallel() + + fx := newSFNFixture(t) + tg := newHTTPTarget(t) + + fx.sfn.SetSDKIntegration(sfnbackend.NewSDKIntegrationWithHTTP(fx.handler, "us-east-1", nil, fakeConnections{ + httpTaskConnARN + "apikey": {AuthType: "API_KEY", APIKeyName: "X-Api-Key", APIKeyValue: "s3cret"}, + })) + + client := sfn.NewFromConfig(fx.cfg) + definition := `{"Type":"Task","Resource":"arn:aws:states:::http:invoke","Parameters":{` + + `"ApiEndpoint":"` + tg.srv.URL + `/echo","Method":"POST","QueryParameters":{"id":"trace"},` + + `"Headers":{"X-Custom":"h1"},"RequestBody":{"message":"hi"},` + + `"Authentication":{"ConnectionArn":"` + httpTaskConnARN + `apikey"}},"End":true}` + + tests := []struct { + name string + level sfntypes.InspectionLevel + reveal bool + wantSecret bool + wantRequest bool + }{ + {name: "trace_redacted", level: sfntypes.InspectionLevelTrace, wantRequest: true}, + { + name: "trace_revealed", + level: sfntypes.InspectionLevelTrace, + reveal: true, + wantSecret: true, + wantRequest: true, + }, + {name: "debug_has_no_http_data", level: sfntypes.InspectionLevelDebug}, + {name: "info_has_no_inspection", level: sfntypes.InspectionLevelInfo}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, err := client.TestState(t.Context(), &sfn.TestStateInput{ + Definition: aws.String(definition), + RoleArn: aws.String(sfnTestRole), + Input: aws.String(`{}`), + InspectionLevel: tc.level, + RevealSecrets: tc.reveal, + }) + require.NoError(t, err) + require.Equal(t, sfntypes.TestExecutionStatusSucceeded, out.Status) + assert.Contains(t, aws.ToString(out.Output), `"ResponseBody":{"ok":true}`) + + if tc.level == sfntypes.InspectionLevelInfo { + assert.Nil(t, out.InspectionData) + + return + } + + require.NotNil(t, out.InspectionData) + + if !tc.wantRequest { + assert.Nil(t, out.InspectionData.Request) + assert.Nil(t, out.InspectionData.Response) + + return + } + + req, resp := out.InspectionData.Request, out.InspectionData.Response + require.NotNil(t, req) + require.NotNil(t, resp) + + assert.Equal(t, "POST", aws.ToString(req.Method)) + assert.Equal(t, "HTTP/1.1", aws.ToString(req.Protocol)) + assert.Contains(t, aws.ToString(req.Url), "/echo?id=trace") + assert.JSONEq(t, `{"message":"hi"}`, aws.ToString(req.Body)) + assert.Contains(t, aws.ToString(req.Headers), `"X-Custom":"h1"`) + assert.Equal(t, tc.wantSecret, strings.Contains(aws.ToString(req.Headers), "s3cret")) + assert.Equal(t, "200", aws.ToString(resp.StatusCode)) + assert.JSONEq(t, `{"ok":true}`, aws.ToString(resp.Body)) + }) + } +} + +func TestStepFunctionsTestStateTraceValidation(t *testing.T) { + t.Parallel() + + fx := newSFNFixture(t) + client := sfn.NewFromConfig(fx.cfg) + + const httpDef = `{"Type":"Task","Resource":"arn:aws:states:::http:invoke","Parameters":{` + + `"ApiEndpoint":"https://example.invalid","Method":"GET",` + + `"Authentication":{"ConnectionArn":"arn:aws:events:us-east-1:000000000000:connection/c/x"}},"End":true}` + + tests := []struct { + input *sfn.TestStateInput + name string + }{ + { + name: "trace_on_non_http_state", + input: &sfn.TestStateInput{ + Definition: aws.String(`{"Type":"Pass","End":true}`), InspectionLevel: sfntypes.InspectionLevelTrace, + }, + }, + { + name: "reveal_secrets_with_mock", + input: &sfn.TestStateInput{ + Definition: aws.String(httpDef), InspectionLevel: sfntypes.InspectionLevelTrace, RevealSecrets: true, + Mock: &sfntypes.MockInput{Result: aws.String(`{}`)}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tc.input.RoleArn = aws.String(sfnTestRole) + _, err := client.TestState(t.Context(), tc.input) + require.Error(t, err) + assert.Contains(t, err.Error(), "ValidationException") + }) + } +} diff --git a/internal/app/shared_path_server_bench_test.go b/internal/app/shared_path_server_bench_test.go index c90adeb74b..1442266aba 100644 --- a/internal/app/shared_path_server_bench_test.go +++ b/internal/app/shared_path_server_bench_test.go @@ -106,6 +106,7 @@ func BenchmarkServerPath(b *testing.B) { {"sqs_query", "POST", "/", "sqs", form, "", "Action=ListQueues&Version=2012-11-05"}, {"ec2_describe_instances", "POST", "/", "ec2", form, "", "Action=DescribeInstances&Version=2016-11-15"}, {"s3_getobject", "GET", "/bench-server-bucket/obj.txt", "s3", "", "", ""}, + {"s3_putobject", "PUT", "/bench-server-bucket/put.txt", "s3", "application/octet-stream", "", "bench put body"}, {"lambda_list", "GET", "/2015-03-31/functions/", "lambda", "", "", ""}, {"apigw_restjson", "GET", "/restapis", "apigateway", "", "", ""}, } diff --git a/internal/app/sns_sqs_checker_wiring_test.go b/internal/app/sns_sqs_checker_wiring_test.go index bf1afcedca..b0ca8b8bdd 100644 --- a/internal/app/sns_sqs_checker_wiring_test.go +++ b/internal/app/sns_sqs_checker_wiring_test.go @@ -33,7 +33,7 @@ func TestInitializeServices_SNSSQSCheckerWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/sso_test.go b/internal/app/sso_test.go index 00fd22e83c..3cc196dbd4 100644 --- a/internal/app/sso_test.go +++ b/internal/app/sso_test.go @@ -22,6 +22,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/chaos" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" identitystorebackend "github.com/blackbirdworks/gopherstack/services/identitystore" ssoadminbackend "github.com/blackbirdworks/gopherstack/services/ssoadmin" ) @@ -55,7 +56,7 @@ func newSSOStack(t *testing.T) ssoStack { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) @@ -139,7 +140,7 @@ func (s ssoStack) approveURL(t *testing.T, verificationComplete, user string) { ) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -402,7 +403,7 @@ func TestSSOVerificationUnknownUser(t *testing.T) { ) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/internal/app/ssoadmin_rgtapi_tags_wiring_test.go b/internal/app/ssoadmin_rgtapi_tags_wiring_test.go index e4159cc468..78b140c8d0 100644 --- a/internal/app/ssoadmin_rgtapi_tags_wiring_test.go +++ b/internal/app/ssoadmin_rgtapi_tags_wiring_test.go @@ -30,7 +30,7 @@ func TestInitializeServices_SSOAdminTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/startup_flags_test.go b/internal/app/startup_flags_test.go index 071a15df17..b78fdc4a4f 100644 --- a/internal/app/startup_flags_test.go +++ b/internal/app/startup_flags_test.go @@ -23,6 +23,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/chaos" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func startupFlagsServer(t *testing.T, cli CLI) (*httptest.Server, aws.Config) { @@ -33,7 +34,7 @@ func startupFlagsServer(t *testing.T, cli CLI) (*httptest.Server, aws.Config) { cli.portAlloc = setupPortAllocatorWithReservations(t.Context(), log, cli) cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(&service.AppContext{ + services, err := initializeServicesT(t, &service.AppContext{ Logger: log, Config: &cli, JanitorCtx: t.Context(), PortAlloc: cli.portAlloc, }) require.NoError(t, err) @@ -263,7 +264,7 @@ func TestCORSMiddleware(t *testing.T) { req.Header.Set("Access-Control-Request-Method", tt.reqMethod) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/internal/app/sts_elbv2_wiring_test.go b/internal/app/sts_elbv2_wiring_test.go index 9d74412b16..5c12fec029 100644 --- a/internal/app/sts_elbv2_wiring_test.go +++ b/internal/app/sts_elbv2_wiring_test.go @@ -30,7 +30,7 @@ func wiredServices(t *testing.T) map[string]service.Registerable { cli.faultStore = chaos.NewFaultStore() appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) return serviceByName(services) diff --git a/internal/app/support_attachment_links_test.go b/internal/app/support_attachment_links_test.go index f4c25c69c9..c14035e744 100644 --- a/internal/app/support_attachment_links_test.go +++ b/internal/app/support_attachment_links_test.go @@ -11,6 +11,8 @@ import ( supporttypes "github.com/aws/aws-sdk-go-v2/service/support/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestSupportAttachmentLinks_FullRouter(t *testing.T) { @@ -45,7 +47,7 @@ func TestSupportAttachmentLinks_FullRouter(t *testing.T) { req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload)) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) _ = resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/internal/app/tagging_account_scope_test.go b/internal/app/tagging_account_scope_test.go new file mode 100644 index 0000000000..cae2f91993 --- /dev/null +++ b/internal/app/tagging_account_scope_test.go @@ -0,0 +1,146 @@ +package app + +import ( + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + configsdk "github.com/aws/aws-sdk-go-v2/service/configservice" + configtypes "github.com/aws/aws-sdk-go-v2/service/configservice/types" + tagsdk "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const taggingOtherAcct = "111111111111" + +func taggingAccountServer(t *testing.T) string { + t.Helper() + + byName := crossRegionServices(t) + registry := service.NewRegistry() + require.NoError(t, registry.Register(byName["AWSConfig"])) + require.NoError(t, registry.Register(byName["ResourceGroupsTaggingAPI"])) + + e := echo.New() + e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + r := c.Request() + c.SetRequest(r.WithContext(awsmeta.Set(r.Context(), awsmeta.FromRequest(r, crossHome)))) + + return next(c) + } + }) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + return srv.URL +} + +func taggingSDKConfig(t *testing.T, account, region string) aws.Config { + t.Helper() + + cfg, err := awscfg.LoadDefaultConfig( + t.Context(), + awscfg.WithRegion(region), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(account, "secret", "")), + ) + require.NoError(t, err) + + return cfg +} + +func TestTaggingBridge_AWSConfigIsAccountScoped(t *testing.T) { + t.Parallel() + + url := taggingAccountServer(t) + ctx := t.Context() + + cfgClient := func(account, region string) *configsdk.Client { + return configsdk.NewFromConfig(taggingSDKConfig(t, account, region), func(o *configsdk.Options) { + o.BaseEndpoint = aws.String(url) + }) + } + tagClient := func(account, region string) *tagsdk.Client { + return tagsdk.NewFromConfig(taggingSDKConfig(t, account, region), func(o *tagsdk.Options) { + o.BaseEndpoint = aws.String(url) + }) + } + + type scope struct{ account, region string } + + scopes := []scope{ + {crossAcct, crossHome}, {crossAcct, euRegion}, + {taggingOtherAcct, crossHome}, {taggingOtherAcct, euRegion}, + } + + for _, s := range scopes { + _, err := cfgClient(s.account, s.region).PutConfigRule(ctx, &configsdk.PutConfigRuleInput{ + ConfigRule: &configtypes.ConfigRule{ + ConfigRuleName: aws.String("rule-" + s.account), + Source: &configtypes.Source{ + Owner: configtypes.OwnerAws, SourceIdentifier: aws.String("S3_BUCKET_VERSIONING_ENABLED"), + }, + }, + Tags: []configtypes.Tag{{Key: aws.String("Scope"), Value: aws.String(s.account + s.region)}}, + }) + require.NoError(t, err) + } + + listARNs := func(s scope) []string { + out, err := tagClient(s.account, s.region).GetResources(ctx, &tagsdk.GetResourcesInput{}) + require.NoError(t, err) + + arns := make([]string, 0, len(out.ResourceTagMappingList)) + for _, m := range out.ResourceTagMappingList { + arns = append(arns, aws.ToString(m.ResourceARN)) + } + + return arns + } + + for _, s := range scopes { + arns := listARNs(s) + require.Len(t, arns, 1, "scope %v", s) + assert.Contains(t, arns[0], ":"+s.region+":"+s.account+":", "scope %v", s) + } + + other := scope{taggingOtherAcct, crossHome} + otherARN := listARNs(other)[0] + tc := tagClient(other.account, other.region) + + res, err := tc.TagResources(ctx, &tagsdk.TagResourcesInput{ + ResourceARNList: []string{otherARN}, Tags: map[string]string{"Team": "x"}, + }) + require.NoError(t, err) + assert.Empty(t, res.FailedResourcesMap) + + keys, err := tc.GetTagKeys(ctx, &tagsdk.GetTagKeysInput{}) + require.NoError(t, err) + assert.Contains(t, keys.TagKeys, "Team") + + homeKeys, err := tagClient(crossAcct, crossHome).GetTagKeys(ctx, &tagsdk.GetTagKeysInput{}) + require.NoError(t, err) + assert.NotContains(t, homeKeys.TagKeys, "Team") + + vals, err := tc.GetTagValues(ctx, &tagsdk.GetTagValuesInput{Key: aws.String("Team")}) + require.NoError(t, err) + assert.Equal(t, []string{"x"}, vals.TagValues) + + _, err = tc.UntagResources(ctx, &tagsdk.UntagResourcesInput{ + ResourceARNList: []string{otherARN}, TagKeys: []string{"Team"}, + }) + require.NoError(t, err) + + keys, err = tc.GetTagKeys(ctx, &tagsdk.GetTagKeysInput{}) + require.NoError(t, err) + assert.NotContains(t, keys.TagKeys, "Team") +} diff --git a/internal/app/textract_rekognition_s3_wiring_test.go b/internal/app/textract_rekognition_s3_wiring_test.go index 80e1f18f9f..c58743b500 100644 --- a/internal/app/textract_rekognition_s3_wiring_test.go +++ b/internal/app/textract_rekognition_s3_wiring_test.go @@ -77,7 +77,7 @@ func TestInitializeServices_TextractS3Wiring(t *testing.T) { appCtx := newTestAppContext(t, 19400, 19500) - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) @@ -131,7 +131,7 @@ func TestInitializeServices_RekognitionS3Wiring(t *testing.T) { appCtx := newTestAppContext(t, 19500, 19600) - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/timestream_query_sdk_test.go b/internal/app/timestream_query_sdk_test.go new file mode 100644 index 0000000000..90b7ad9fc8 --- /dev/null +++ b/internal/app/timestream_query_sdk_test.go @@ -0,0 +1,567 @@ +package app + +import ( + "log/slog" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + tqsdk "github.com/aws/aws-sdk-go-v2/service/timestreamquery" + tqtypes "github.com/aws/aws-sdk-go-v2/service/timestreamquery/types" + twsdk "github.com/aws/aws-sdk-go-v2/service/timestreamwrite" + twtypes "github.com/aws/aws-sdk-go-v2/service/timestreamwrite/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/service" + timestreamquerybackend "github.com/blackbirdworks/gopherstack/services/timestreamquery" + timestreamwritebackend "github.com/blackbirdworks/gopherstack/services/timestreamwrite" +) + +type tsEnv struct { + query *tqsdk.Client + write *twsdk.Client + now *atomic.Int64 +} + +func newTimestreamEnv(t *testing.T, start time.Time) *tsEnv { + t.Helper() + + cli := &CLI{AccountID: "000000000000", Region: "us-east-1"} + appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServicesT(t, appCtx) + require.NoError(t, err) + + byName := serviceByName(services) + qh, ok := byName["TimestreamQuery"].(*timestreamquerybackend.Handler) + require.True(t, ok) + + wh, ok := byName["TimestreamWrite"].(*timestreamwritebackend.Handler) + require.True(t, ok) + + now := &atomic.Int64{} + now.Store(start.UnixNano()) + clock := func() time.Time { return time.Unix(0, now.Load()).UTC() } + + qh.Backend.(*timestreamquerybackend.InMemoryBackend).SetClock(clock) + wh.Backend.SetClock(clock) + + e := echo.New() + registry := service.NewRegistry() + require.NoError(t, registry.Register(qh)) + require.NoError(t, registry.Register(wh)) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + cfg, err := awscfg.LoadDefaultConfig(t.Context(), awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", ""))) + require.NoError(t, err) + + return &tsEnv{ + now: now, + query: tqsdk.NewFromConfig(cfg, func(o *tqsdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }), + write: twsdk.NewFromConfig(cfg, func(o *twsdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }), + } +} + +func dim(name, value string) twtypes.Dimension { + return twtypes.Dimension{Name: aws.String(name), Value: aws.String(value)} +} + +func ms(t time.Time) *string { return aws.String(strconv.FormatInt(t.UnixMilli(), 10)) } + +func single(name, value string, typ twtypes.MeasureValueType, at time.Time, dims ...twtypes.Dimension) twtypes.Record { + return twtypes.Record{ + MeasureName: aws.String(name), MeasureValue: aws.String(value), MeasureValueType: typ, + Time: ms(at), TimeUnit: twtypes.TimeUnitMilliseconds, Dimensions: dims, + } +} + +func (e *tsEnv) seed(t *testing.T, db, table string, props *twtypes.RetentionProperties, recs ...twtypes.Record) { + t.Helper() + + _, err := e.write.CreateDatabase(t.Context(), &twsdk.CreateDatabaseInput{DatabaseName: aws.String(db)}) + if err != nil { + require.ErrorContains(t, err, "ConflictException") + } + + _, err = e.write.CreateTable(t.Context(), &twsdk.CreateTableInput{ + DatabaseName: aws.String(db), TableName: aws.String(table), RetentionProperties: props, + MagneticStoreWriteProperties: &twtypes.MagneticStoreWriteProperties{EnableMagneticStoreWrites: aws.Bool(true)}, + }) + require.NoError(t, err) + + if len(recs) == 0 { + return + } + + _, err = e.write.WriteRecords(t.Context(), &twsdk.WriteRecordsInput{ + DatabaseName: aws.String(db), TableName: aws.String(table), Records: recs, + }) + require.NoError(t, err) +} + +func cell(d tqtypes.Datum) string { + if d.NullValue != nil && *d.NullValue { + return "" + } + + return aws.ToString(d.ScalarValue) +} + +func rowsOf(out *tqsdk.QueryOutput) [][]string { + rows := make([][]string, 0, len(out.Rows)) + for _, r := range out.Rows { + row := make([]string, 0, len(r.Data)) + for _, d := range r.Data { + row = append(row, cell(d)) + } + + rows = append(rows, row) + } + + return rows +} + +func typesOf(out *tqsdk.QueryOutput) []string { + ts := make([]string, 0, len(out.ColumnInfo)) + for _, c := range out.ColumnInfo { + ts = append(ts, aws.ToString(c.Name)+":"+string(c.Type.ScalarType)) + } + + return ts +} + +func seedSensors(t *testing.T, e *tsEnv, t0 time.Time) { + t.Helper() + + retention := &twtypes.RetentionProperties{ + MemoryStoreRetentionPeriodInHours: aws.Int64(24), MagneticStoreRetentionPeriodInDays: aws.Int64(365), + } + ago := func(d time.Duration) time.Time { return t0.Add(-d) } + dbl := twtypes.MeasureValueTypeDouble + + e.seed(t, "sensors", "readings", retention, + single("cpu", "10", dbl, ago(30*time.Minute), dim("host", "a"), dim("region", "us")), + single("cpu", "20", dbl, ago(20*time.Minute), dim("host", "a"), dim("region", "us")), + single("cpu", "30", dbl, ago(5*time.Minute), dim("host", "a"), dim("region", "us")), + single("cpu", "50.5", dbl, ago(25*time.Minute), dim("host", "b"), dim("region", "eu")), + single("cpu", "70", dbl, ago(3*time.Minute), dim("host", "b"), dim("region", "eu")), + single("cpu", "1", dbl, ago(3*time.Hour), dim("host", "a"), dim("region", "us")), + single("reqs", "5", twtypes.MeasureValueTypeBigint, ago(10*time.Minute), dim("host", "a"), dim("region", "us")), + single("state", "up", twtypes.MeasureValueTypeVarchar, ago(time.Minute), dim("host", "a"), dim("region", "us")), + twtypes.Record{ + MeasureName: aws.String("metrics"), MeasureValueType: twtypes.MeasureValueTypeMulti, + Time: ms(ago(2 * time.Minute)), TimeUnit: twtypes.TimeUnitMilliseconds, + Dimensions: []twtypes.Dimension{dim("host", "c")}, + MeasureValues: []twtypes.MeasureValue{ + {Name: aws.String("cpu_pct"), Value: aws.String("73.5"), Type: twtypes.MeasureValueTypeDouble}, + {Name: aws.String("mem"), Value: aws.String("2048"), Type: twtypes.MeasureValueTypeBigint}, + {Name: aws.String("ok"), Value: aws.String("true"), Type: twtypes.MeasureValueTypeBoolean}, + }, + }, + ) +} + +func TestTimestreamQuery_SDK_QueriesWrittenRecords(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + env := newTimestreamEnv(t, t0) + seedSensors(t, env, t0) + + const tbl = `"sensors"."readings"` + + cases := []struct { + name string + query string + wantRows [][]string + wantTypes []string + }{ + { + name: "order by time desc limit with ago and double cast", + query: `SELECT host, measure_value::double FROM ` + tbl + + ` WHERE measure_name = 'cpu' AND time > ago(1h) ORDER BY time DESC LIMIT 2`, + wantRows: [][]string{{"b", "70.0"}, {"a", "30.0"}}, + wantTypes: []string{"host:VARCHAR", "measure_value::double:DOUBLE"}, + }, + { + name: "group by bin with avg and max", + query: `SELECT bin(time, 10m) AS t, avg(measure_value::double) AS a, max(measure_value::double) AS m FROM ` + + tbl + ` WHERE measure_name = 'cpu' AND host = 'a' AND time > ago(1h) GROUP BY bin(time, 10m) ORDER BY t`, + wantRows: [][]string{ + {"2026-10-31 11:30:00.000000000", "10.0", "10.0"}, + {"2026-10-31 11:40:00.000000000", "20.0", "20.0"}, + {"2026-10-31 11:50:00.000000000", "30.0", "30.0"}, + }, + wantTypes: []string{"t:TIMESTAMP", "a:DOUBLE", "m:DOUBLE"}, + }, + { + name: "count grouped by dimension", + query: `SELECT host, count(*) AS n FROM ` + tbl + + ` WHERE measure_name = 'cpu' GROUP BY host ORDER BY host`, + wantRows: [][]string{{"a", "4"}, {"b", "2"}}, + wantTypes: []string{"host:VARCHAR", "n:BIGINT"}, + }, + { + name: "time range with between and interval", + query: `SELECT count(*) FROM ` + tbl + + ` WHERE measure_name = 'cpu' AND time BETWEEN now() - INTERVAL '15' minute AND now()`, + wantRows: [][]string{{"2"}}, + wantTypes: []string{"_col0:BIGINT"}, + }, + { + name: "multi measure with null dimension", + query: `SELECT host, region, cpu_pct, mem, ok, measure_name FROM ` + tbl + + ` WHERE measure_name = 'metrics'`, + wantRows: [][]string{{"c", "", "73.5", "2048", "true", "metrics"}}, + wantTypes: []string{ + "host:VARCHAR", + "region:VARCHAR", + "cpu_pct:DOUBLE", + "mem:BIGINT", + "ok:BOOLEAN", + "measure_name:VARCHAR", + }, + }, + { + name: "bigint and varchar measures", + query: `SELECT measure_value::bigint, measure_value::varchar FROM ` + tbl + + ` WHERE measure_name IN ('reqs','state') ORDER BY time`, + wantRows: [][]string{{"5", ""}, {"", "up"}}, + wantTypes: []string{"measure_value::bigint:BIGINT", "measure_value::varchar:VARCHAR"}, + }, + { + name: "timestamp column formatting", + query: `SELECT time FROM ` + tbl + + ` WHERE measure_name = 'state'`, + wantRows: [][]string{{"2026-10-31 11:59:00.000000000"}}, + wantTypes: []string{"time:TIMESTAMP"}, + }, + { + name: "approx_percentile", + query: `SELECT approx_percentile(measure_value::double, 0.5) FROM ` + tbl + + ` WHERE measure_name = 'cpu' AND host = 'b'`, + wantRows: [][]string{{"50.5"}}, + wantTypes: []string{"_col0:DOUBLE"}, + }, + { + name: "describe", + query: `DESCRIBE ` + tbl, + wantRows: nil, + wantTypes: []string{"Column:VARCHAR", "Type:VARCHAR", "Timestream attribute type:VARCHAR"}, + }, + { + name: "show measures", + query: `SHOW MEASURES FROM ` + tbl, + wantRows: [][]string{ + { + "cpu", + "double", + `[{"data_type":"varchar","dimension_name":"host"},{"data_type":"varchar","dimension_name":"region"}]`, + }, + {"metrics", "multi", `[{"data_type":"varchar","dimension_name":"host"}]`}, + { + "reqs", + "bigint", + `[{"data_type":"varchar","dimension_name":"host"},{"data_type":"varchar","dimension_name":"region"}]`, + }, + { + "state", + "varchar", + `[{"data_type":"varchar","dimension_name":"host"},{"data_type":"varchar","dimension_name":"region"}]`, + }, + }, + wantTypes: []string{"measure_name:VARCHAR", "data_type:VARCHAR", "Dimensions:VARCHAR"}, + }, + { + name: "show tables", + query: `SHOW TABLES FROM sensors`, + wantRows: [][]string{{"readings"}}, + wantTypes: []string{"TableName:VARCHAR"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, err := env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(tc.query)}) + require.NoError(t, err) + assert.Equal(t, tc.wantTypes, typesOf(out)) + assert.NotEmpty(t, aws.ToString(out.QueryId)) + + if tc.wantRows != nil { + assert.Equal(t, tc.wantRows, rowsOf(out)) + } + + if tc.name == "describe" { + assert.Contains(t, rowsOf(out), []string{"host", "varchar", "DIMENSION"}) + assert.Contains(t, rowsOf(out), []string{"time", "timestamp", "TIMESTAMP"}) + assert.Contains(t, rowsOf(out), []string{"measure_value::double", "double", "MEASURE_VALUE"}) + assert.Contains(t, rowsOf(out), []string{"cpu_pct", "double", "MULTI"}) + } + }) + } +} + +func TestTimestreamQuery_SDK_Errors(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + env := newTimestreamEnv(t, t0) + seedSensors(t, env, t0) + + cases := []struct { + name string + query string + wantErr string + }{ + { + "unsupported function", + `SELECT create_time_series(time, measure_value::double) FROM sensors.readings`, + "create_time_series", + }, + {"unknown column", `SELECT nope FROM sensors.readings`, "Column 'nope' does not exist"}, + {"unknown table", `SELECT * FROM sensors.missing`, "Table 'sensors.missing' does not exist"}, + {"unknown database", `SELECT * FROM nodb.readings`, "Database 'nodb' does not exist"}, + {"unqualified table", `SELECT * FROM readings`, "Schema must be specified"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(tc.query)}) + require.Error(t, err) + assert.Contains(t, err.Error(), "ValidationException") + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestTimestreamQuery_SDK_PagingAndCancel(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + env := newTimestreamEnv(t, t0) + seedSensors(t, env, t0) + + q := `SELECT host, time FROM sensors.readings ORDER BY time` + first, err := env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(q), MaxRows: aws.Int32(4)}) + require.NoError(t, err) + require.Len(t, first.Rows, 4) + require.NotNil(t, first.NextToken) + + all := rowsOf(first) + token := first.NextToken + pages := 1 + + for token != nil { + next, pErr := env.query.Query( + t.Context(), + &tqsdk.QueryInput{QueryString: aws.String(q), NextToken: token, MaxRows: aws.Int32(4)}, + ) + require.NoError(t, pErr) + assert.Equal(t, aws.ToString(first.QueryId), aws.ToString(next.QueryId)) + assert.Len(t, next.ColumnInfo, 2) + + all = append(all, rowsOf(next)...) + token = next.NextToken + pages++ + } + + assert.Equal(t, 3, pages) + assert.Len(t, all, 9) + assert.True(t, sortedByTime(all)) + + // a token is good for five uses (one already spent paging above) + for range 4 { + _, rErr := env.query.Query( + t.Context(), + &tqsdk.QueryInput{QueryString: aws.String(q), NextToken: first.NextToken}, + ) + require.NoError(t, rErr) + } + + _, err = env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(q), NextToken: first.NextToken}) + require.ErrorContains(t, err, "Invalid pagination token") + + // cancelling a query with pending pages stops further paging + again, err := env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(q), MaxRows: aws.Int32(2)}) + require.NoError(t, err) + require.NotNil(t, again.NextToken) + + _, err = env.query.CancelQuery(t.Context(), &tqsdk.CancelQueryInput{QueryId: again.QueryId}) + require.NoError(t, err) + + _, err = env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(q), NextToken: again.NextToken}) + require.ErrorContains(t, err, "cancelled query") + + // tokens expire after an hour + third, err := env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(q), MaxRows: aws.Int32(2)}) + require.NoError(t, err) + + env.now.Add(int64(61 * time.Minute)) + + _, err = env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(q), NextToken: third.NextToken}) + require.ErrorContains(t, err, "Invalid pagination token") +} + +func sortedByTime(rows [][]string) bool { + for i := 1; i < len(rows); i++ { + if rows[i][1] < rows[i-1][1] { + return false + } + } + + return true +} + +func TestTimestreamQuery_SDK_StatusAndInsights(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + env := newTimestreamEnv(t, t0) + seedSensors(t, env, t0) + + out, err := env.query.Query(t.Context(), &tqsdk.QueryInput{ + QueryString: aws.String(`SELECT host FROM sensors.readings`), + QueryInsights: &tqtypes.QueryInsights{Mode: tqtypes.QueryInsightsModeEnabledWithRateControl}, + }) + require.NoError(t, err) + assert.InDelta(t, 100.0, out.QueryStatus.ProgressPercentage, 0.001) + assert.Positive(t, out.QueryStatus.CumulativeBytesScanned) + assert.Less(t, out.QueryStatus.CumulativeBytesScanned, int64(10*1024*1024)) + assert.EqualValues(t, 10*1024*1024, out.QueryStatus.CumulativeBytesMetered) + assert.EqualValues(t, 9, aws.ToInt64(out.QueryInsightsResponse.OutputRows)) + + none, err := env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(`SELECT 1`)}) + require.NoError(t, err) + assert.Zero(t, none.QueryStatus.CumulativeBytesMetered) + assert.Equal(t, [][]string{{"1"}}, rowsOf(none)) +} + +func TestTimestreamQuery_SDK_RetentionFollowsClock(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + env := newTimestreamEnv(t, t0) + + retention := &twtypes.RetentionProperties{ + MemoryStoreRetentionPeriodInHours: aws.Int64(24), MagneticStoreRetentionPeriodInDays: aws.Int64(2), + } + dbl := twtypes.MeasureValueTypeDouble + env.seed(t, "ret", "t", retention, + single("m", "1", dbl, t0.Add(-time.Hour), dim("d", "memory")), + single("m", "2", dbl, t0.Add(-36*time.Hour), dim("d", "magnetic")), + single("m", "3", dbl, t0.Add(-72*time.Hour), dim("d", "expired")), + ) + + count := func() string { + out, err := env.query.Query( + t.Context(), + &tqsdk.QueryInput{QueryString: aws.String(`SELECT d FROM ret.t ORDER BY d`)}, + ) + require.NoError(t, err) + + return fmtRows(rowsOf(out)) + } + + assert.Equal(t, "magnetic,memory", count()) + + env.now.Add(int64(30 * time.Hour)) + assert.Equal(t, "memory", count()) + + env.now.Add(int64(48 * time.Hour)) + + out, err := env.query.Query(t.Context(), &tqsdk.QueryInput{QueryString: aws.String(`SELECT count(*) FROM ret.t`)}) + require.NoError(t, err) + assert.Equal(t, [][]string{{"0"}}, rowsOf(out)) +} + +func fmtRows(rows [][]string) string { + firsts := make([]string, 0, len(rows)) + for _, r := range rows { + firsts = append(firsts, r[0]) + } + + return strings.Join(firsts, ",") +} + +func TestTimestreamQuery_SDK_ScheduledQueryWritesTarget(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + env := newTimestreamEnv(t, t0) + seedSensors(t, env, t0) + env.seed(t, "sensors", "rollup", &twtypes.RetentionProperties{ + MemoryStoreRetentionPeriodInHours: aws.Int64(24), MagneticStoreRetentionPeriodInDays: aws.Int64(365), + }) + + created, err := env.query.CreateScheduledQuery(t.Context(), &tqsdk.CreateScheduledQueryInput{ + Name: aws.String("hourly-cpu"), + QueryString: aws.String(`SELECT host, bin(time, 1h) AS t, avg(measure_value::double) AS avg_cpu + FROM sensors.readings WHERE measure_name = 'cpu' AND time BETWEEN @scheduled_runtime - 2h AND @scheduled_runtime + GROUP BY host, bin(time, 1h)`), + ScheduledQueryExecutionRoleArn: aws.String("arn:aws:iam::000000000000:role/tsq-role"), + ScheduleConfiguration: &tqtypes.ScheduleConfiguration{ScheduleExpression: aws.String("rate(1 hour)")}, + NotificationConfiguration: &tqtypes.NotificationConfiguration{ + SnsConfiguration: &tqtypes.SnsConfiguration{TopicArn: aws.String("arn:aws:sns:us-east-1:000000000000:t")}, + }, + ErrorReportConfiguration: &tqtypes.ErrorReportConfiguration{ + S3Configuration: &tqtypes.S3Configuration{BucketName: aws.String("errs")}, + }, + TargetConfiguration: &tqtypes.TargetConfiguration{TimestreamConfiguration: &tqtypes.TimestreamConfiguration{ + DatabaseName: aws.String("sensors"), + TableName: aws.String("rollup"), + TimeColumn: aws.String("t"), + DimensionMappings: []tqtypes.DimensionMapping{ + {Name: aws.String("host"), DimensionValueType: tqtypes.DimensionValueTypeVarchar}, + }, + MultiMeasureMappings: &tqtypes.MultiMeasureMappings{ + TargetMultiMeasureName: aws.String("cpu_stats"), + MultiMeasureAttributeMappings: []tqtypes.MultiMeasureAttributeMapping{{ + SourceColumn: aws.String("avg_cpu"), MeasureValueType: tqtypes.ScalarMeasureValueTypeDouble, + }}, + }, + }}, + }) + require.NoError(t, err) + + _, err = env.query.ExecuteScheduledQuery(t.Context(), &tqsdk.ExecuteScheduledQueryInput{ + ScheduledQueryArn: created.Arn, InvocationTime: aws.Time(t0), + }) + require.NoError(t, err) + + out, err := env.query.Query(t.Context(), &tqsdk.QueryInput{ + QueryString: aws.String(`SELECT host, measure_name, avg_cpu, time FROM sensors.rollup ORDER BY host, time`), + }) + require.NoError(t, err) + assert.Equal(t, [][]string{ + {"a", "cpu_stats", "20.0", "2026-10-31 11:00:00.000000000"}, + {"b", "cpu_stats", "60.25", "2026-10-31 11:00:00.000000000"}, + }, rowsOf(out)) + + desc, err := env.query.DescribeScheduledQuery( + t.Context(), + &tqsdk.DescribeScheduledQueryInput{ScheduledQueryArn: created.Arn}, + ) + require.NoError(t, err) + require.NotNil(t, desc.ScheduledQuery.LastRunSummary) + assert.Equal(t, tqtypes.ScheduledQueryRunStatusManualTriggerSuccess, desc.ScheduledQuery.LastRunSummary.RunStatus) + assert.EqualValues(t, 2, desc.ScheduledQuery.LastRunSummary.ExecutionStats.RecordsIngested) + assert.EqualValues(t, 2, desc.ScheduledQuery.LastRunSummary.ExecutionStats.QueryResultRows) +} diff --git a/internal/app/timestream_source.go b/internal/app/timestream_source.go new file mode 100644 index 0000000000..e7a735b2de --- /dev/null +++ b/internal/app/timestream_source.go @@ -0,0 +1,181 @@ +package app + +import ( + "sort" + "strconv" + "strings" + "time" + + timestreamquerybackend "github.com/blackbirdworks/gopherstack/services/timestreamquery" + timestreamwritebackend "github.com/blackbirdworks/gopherstack/services/timestreamwrite" +) + +const ( + tsNanoLayout = "2006-01-02 15:04:05.000000000" + tsMeasureTime = "TIMESTAMP" + tsNanosUnit = "NANOSECONDS" + tsVarchar = "VARCHAR" +) + +// timestreamRecordSource exposes Timestream Write's tables and records to Timestream Query. +type timestreamRecordSource struct { + timestreamCatalog +} + +var _ timestreamquerybackend.RecordSource = (*timestreamRecordSource)(nil) + +func newTimestreamRecordSource(h *timestreamwritebackend.Handler) *timestreamRecordSource { + return ×treamRecordSource{timestreamCatalog{handler: h}} +} + +func (s *timestreamRecordSource) TableRecords( + region, database, table string, now time.Time, +) ([]timestreamquerybackend.SourceRecord, error) { + data, err := s.handler.BackendFor(region).TableData(database, table, now) + if err != nil { + return nil, err + } + + out := make([]timestreamquerybackend.SourceRecord, 0, len(data.Records)) + for _, r := range data.Records { + out = append(out, toSourceRecord(r)) + } + + return out, nil +} + +func (s *timestreamRecordSource) ListDatabases(region string) []string { + dbs := s.handler.BackendFor(region).ListDatabases() + names := make([]string, 0, len(dbs)) + + for _, d := range dbs { + names = append(names, d.DatabaseName) + } + + sort.Strings(names) + + return names +} + +func (s *timestreamRecordSource) ListTables(region, database string) []string { + tbls, err := s.handler.BackendFor(region).ListTables(database) + if err != nil { + return nil + } + + names := make([]string, 0, len(tbls)) + for _, t := range tbls { + names = append(names, t.TableName) + } + + sort.Strings(names) + + return names +} + +func (s *timestreamRecordSource) AppendRecords( + region, database, table string, records []timestreamquerybackend.SourceRecord, +) error { + recs := make([]timestreamwritebackend.Record, 0, len(records)) + for _, r := range records { + recs = append(recs, fromSourceRecord(r)) + } + + _, err := s.handler.BackendFor(region).WriteRecords(database, table, recs) + + return err +} + +func toSourceRecord(r timestreamwritebackend.Record) timestreamquerybackend.SourceRecord { + out := timestreamquerybackend.SourceRecord{ + Time: r.InternalTimestamp, + MeasureName: r.MeasureName, + MeasureValue: r.MeasureValue, + MeasureValueType: r.MeasureValueType, + Version: r.Version, + } + + for _, d := range r.Dimensions { + out.Dimensions = append(out.Dimensions, timestreamquerybackend.SourceDimension{Name: d.Name, Value: d.Value}) + } + + if strings.EqualFold(r.MeasureValueType, tsMeasureTime) { + out.MeasureValue = epochToTimestamp(r.MeasureValue, r.TimeUnit) + } + + for _, m := range r.MeasureValues { + v := m.Value + if strings.EqualFold(m.Type, tsMeasureTime) { + v = epochToTimestamp(v, r.TimeUnit) + } + + out.Measures = append(out.Measures, timestreamquerybackend.SourceMeasure{Name: m.Name, Value: v, Type: m.Type}) + } + + return out +} + +func epochToTimestamp(v, unit string) string { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return v + } + + var t time.Time + + switch strings.ToUpper(unit) { + case "SECONDS": + t = time.Unix(n, 0) + case "MICROSECONDS": + t = time.UnixMicro(n) + case tsNanosUnit: + t = time.Unix(0, n) + default: + t = time.UnixMilli(n) + } + + return t.UTC().Format(tsNanoLayout) +} + +func timestampToNanos(v string) string { + t, err := time.Parse("2006-01-02 15:04:05.999999999", v) + if err != nil { + return v + } + + return strconv.FormatInt(t.UnixNano(), 10) +} + +func fromSourceRecord(r timestreamquerybackend.SourceRecord) timestreamwritebackend.Record { + out := timestreamwritebackend.Record{ + MeasureName: r.MeasureName, + MeasureValue: r.MeasureValue, + MeasureValueType: r.MeasureValueType, + Time: strconv.FormatInt(r.Time.UnixNano(), 10), + TimeUnit: tsNanosUnit, + Version: r.Version, + } + + if strings.EqualFold(r.MeasureValueType, tsMeasureTime) { + out.MeasureValue = timestampToNanos(r.MeasureValue) + } + + for _, d := range r.Dimensions { + out.Dimensions = append(out.Dimensions, timestreamwritebackend.Dimension{ + Name: d.Name, Value: d.Value, DimensionValueType: tsVarchar, + }) + } + + for _, m := range r.Measures { + v := m.Value + if strings.EqualFold(m.Type, tsMeasureTime) { + v = timestampToNanos(v) + } + + out.MeasureValues = append(out.MeasureValues, timestreamwritebackend.MeasureValue{ + Name: m.Name, Value: v, Type: m.Type, + }) + } + + return out +} diff --git a/internal/app/timestreamquery_catalog_wiring_test.go b/internal/app/timestreamquery_catalog_wiring_test.go index 4124063787..8987e90ed0 100644 --- a/internal/app/timestreamquery_catalog_wiring_test.go +++ b/internal/app/timestreamquery_catalog_wiring_test.go @@ -20,7 +20,7 @@ func TestInitializeServices_TimestreamQueryCatalogWiring(t *testing.T) { appCtx := &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()} cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/timestreamquery_tags_wiring_test.go b/internal/app/timestreamquery_tags_wiring_test.go index e18de24f50..2763568437 100644 --- a/internal/app/timestreamquery_tags_wiring_test.go +++ b/internal/app/timestreamquery_tags_wiring_test.go @@ -31,7 +31,7 @@ func TestInitializeServices_TimestreamQueryTagsWiring(t *testing.T) { } cli.faultStore = chaos.NewFaultStore() - services, err := initializeServices(appCtx) + services, err := initializeServicesT(t, appCtx) require.NoError(t, err) byName := serviceByName(services) diff --git a/internal/app/verifiedpermissions_jwt_test.go b/internal/app/verifiedpermissions_jwt_test.go new file mode 100644 index 0000000000..681d4dc078 --- /dev/null +++ b/internal/app/verifiedpermissions_jwt_test.go @@ -0,0 +1,477 @@ +package app + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + vpsdk "github.com/aws/aws-sdk-go-v2/service/verifiedpermissions" + vptypes "github.com/aws/aws-sdk-go-v2/service/verifiedpermissions/types" + "github.com/aws/smithy-go" + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/service" + cognitoidpbackend "github.com/blackbirdworks/gopherstack/services/cognitoidp" + verifiedpermissionsbackend "github.com/blackbirdworks/gopherstack/services/verifiedpermissions" +) + +type vpJWTEnv struct { + client *vpsdk.Client + vp *verifiedpermissionsbackend.Handler + idp *cognitoidpbackend.Handler + storeID string +} + +func newVPJWTEnv(t *testing.T, disableVerification bool) *vpJWTEnv { + t.Helper() + + cli := &CLI{AccountID: "000000000000", Region: "us-east-1"} + cli.VerifiedPermissions.DisableJWTVerification = disableVerification + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServicesT(t, + &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, + ) + require.NoError(t, err) + + byName := serviceByName(services) + vpH, ok := byName["VerifiedPermissions"].(*verifiedpermissionsbackend.Handler) + require.True(t, ok) + idpH, ok := byName["CognitoIDP"].(*cognitoidpbackend.Handler) + require.True(t, ok) + + registry := service.NewRegistry() + require.NoError(t, registry.Register(vpH)) + + e := echo.New() + e.Use(service.NewServiceRouter(registry).RouteHandler()) + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + client := vpsdk.NewFromConfig(wiredSDKConfig(t, "test", "test"), func(o *vpsdk.Options) { + o.BaseEndpoint = aws.String(srv.URL) + }) + + store, err := client.CreatePolicyStore(t.Context(), &vpsdk.CreatePolicyStoreInput{ + ValidationSettings: &vptypes.ValidationSettings{Mode: vptypes.ValidationModeOff}, + }) + require.NoError(t, err) + + return &vpJWTEnv{client: client, vp: vpH, idp: idpH, storeID: aws.ToString(store.PolicyStoreId)} +} + +func (e *vpJWTEnv) addPolicy(t *testing.T, statement string) { + t.Helper() + + _, err := e.client.CreatePolicy(t.Context(), &vpsdk.CreatePolicyInput{ + PolicyStoreId: aws.String(e.storeID), + Definition: &vptypes.PolicyDefinitionMemberStatic{ + Value: vptypes.StaticPolicyDefinition{Statement: aws.String(statement)}, + }, + }) + require.NoError(t, err) +} + +func (e *vpJWTEnv) isAuthorized( + t *testing.T, + action, idToken, accessToken string, +) (*vpsdk.IsAuthorizedWithTokenOutput, error) { + t.Helper() + + in := &vpsdk.IsAuthorizedWithTokenInput{ + PolicyStoreId: aws.String(e.storeID), + Action: &vptypes.ActionIdentifier{ActionType: aws.String("App::Action"), ActionId: aws.String(action)}, + Resource: &vptypes.EntityIdentifier{EntityType: aws.String("App::Doc"), EntityId: aws.String("d1")}, + } + if idToken != "" { + in.IdentityToken = aws.String(idToken) + } + + if accessToken != "" { + in.AccessToken = aws.String(accessToken) + } + + return e.client.IsAuthorizedWithToken(t.Context(), in) +} + +type cognitoFixture struct { + poolID, poolARN, clientID, idToken, accessToken string +} + +func (e *vpJWTEnv) cognitoSource(t *testing.T, configuredClient string) cognitoFixture { + t.Helper() + + pool, err := e.idp.Backend.CreateUserPool("vp-users") + require.NoError(t, err) + client, err := e.idp.Backend.CreateUserPoolClient(pool.ID, "app") + require.NoError(t, err) + _, err = e.idp.Backend.CreateGroup(pool.ID, "admins", "", 1) + require.NoError(t, err) + _, err = e.idp.Backend.AdminCreateUser(pool.ID, "alice", "Temp#Pass1", nil) + require.NoError(t, err) + require.NoError(t, e.idp.Backend.AdminSetUserPassword(pool.ID, "alice", "Perm#Pass1x", true)) + require.NoError(t, e.idp.Backend.AdminAddUserToGroup(pool.ID, "alice", "admins")) + + auth, err := e.idp.Backend.InitiateAuth(client.ClientID, "USER_PASSWORD_AUTH", "alice", "Perm#Pass1x") + require.NoError(t, err) + + if configuredClient == "" { + configuredClient = client.ClientID + } + + _, err = e.client.CreateIdentitySource(t.Context(), &vpsdk.CreateIdentitySourceInput{ + PolicyStoreId: aws.String(e.storeID), + PrincipalEntityType: aws.String("App::User"), + Configuration: &vptypes.ConfigurationMemberCognitoUserPoolConfiguration{ + Value: vptypes.CognitoUserPoolConfiguration{ + UserPoolArn: aws.String(pool.ARN), + ClientIds: []string{configuredClient}, + GroupConfiguration: &vptypes.CognitoGroupConfiguration{ + GroupEntityType: aws.String("App::Group"), + }, + }, + }, + }) + require.NoError(t, err) + + e.addPolicy(t, `permit(principal is App::User, action == App::Action::"read", resource);`) + e.addPolicy(t, `permit(principal in App::Group::"`+pool.ID+`|admins", action == App::Action::"write", resource);`) + e.addPolicy(t, `permit(principal in App::Group::"`+pool.ID+`|nobody", action == App::Action::"delete", resource);`) + + return cognitoFixture{ + poolID: pool.ID, poolARN: pool.ARN, clientID: client.ClientID, + idToken: auth.Tokens.IDToken, accessToken: auth.Tokens.AccessToken, + } +} + +func tamperSignature(tok string) string { + i := strings.LastIndex(tok, ".") + sig := []byte(tok[i+1:]) + mid := len(sig) / 2 + + if sig[mid] == 'A' { + sig[mid] = 'B' + } else { + sig[mid] = 'A' + } + + return tok[:i+1] + string(sig) +} + +func errorCode(err error) string { + if ae, ok := errors.AsType[smithy.APIError](err); ok { + return ae.ErrorCode() + } + + return "" +} + +func TestVerifiedPermissions_CognitoTokenVerification(t *testing.T) { + t.Parallel() + + tests := []struct { + clock func() time.Time + name string + action string + useAccess bool + tamper bool + wrongApp bool + disable bool + wantDeny bool + wantErrors bool + }{ + {name: "id_token_allow", action: "read"}, + {name: "access_token_allow", action: "read", useAccess: true}, + {name: "group_permit", action: "write"}, + {name: "group_not_member_deny", action: "delete", wantDeny: true}, + {name: "tampered_signature", action: "read", tamper: true, wantErrors: true}, + {name: "tampered_access_signature", action: "read", tamper: true, useAccess: true, wantErrors: true}, + { + name: "expired", action: "read", wantErrors: true, + clock: func() time.Time { return time.Now().Add(2 * time.Hour) }, + }, + {name: "wrong_client", action: "read", wrongApp: true, wantErrors: true}, + {name: "disable_flag_skips_tamper", action: "read", tamper: true, disable: true}, + { + name: "disable_flag_skips_expiry", action: "read", disable: true, + clock: func() time.Time { return time.Now().Add(2 * time.Hour) }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newVPJWTEnv(t, tt.disable) + + configured := "" + if tt.wrongApp { + configured = "someotherclient" + } + + fx := env.cognitoSource(t, configured) + + if tt.clock != nil { + env.vp.TokenVerifier().SetClock(tt.clock) + } + + idTok, accTok := fx.idToken, "" + if tt.useAccess { + idTok, accTok = "", fx.accessToken + } + + if tt.tamper { + idTok, accTok = tamperIf(idTok), tamperIf(accTok) + } + + out, aerr := env.isAuthorized(t, tt.action, idTok, accTok) + if tt.wantErrors { + require.Error(t, aerr) + assert.Equal(t, "ValidationException", errorCode(aerr)) + + return + } + + require.NoError(t, aerr) + + want := vptypes.DecisionAllow + if tt.wantDeny { + want = vptypes.DecisionDeny + } + + assert.Equal(t, want, out.Decision) + assert.Equal(t, "App::User", aws.ToString(out.Principal.EntityType)) + assert.True(t, strings.HasPrefix(aws.ToString(out.Principal.EntityId), fx.poolID+"|")) + }) + } +} + +func tamperIf(tok string) string { + if tok == "" { + return "" + } + + return tamperSignature(tok) +} + +func TestVerifiedPermissions_CognitoTokenUseMismatch(t *testing.T) { + t.Parallel() + + env := newVPJWTEnv(t, false) + fx := env.cognitoSource(t, "") + + _, err := env.isAuthorized(t, "read", "", fx.idToken) + require.Error(t, err) + assert.Equal(t, "ValidationException", errorCode(err)) + + _, err = env.isAuthorized(t, "read", fx.accessToken, "") + require.Error(t, err) +} + +type oidcServer struct { + srv *httptest.Server + key *rsa.PrivateKey + jwksHits atomic.Int32 +} + +func newOIDCServer(t *testing.T) *oidcServer { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + o := &oidcServer{key: key} + mux := http.NewServeMux() + o.srv = httptest.NewTLSServer(mux) + t.Cleanup(o.srv.Close) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"issuer": o.srv.URL, "jwks_uri": o.srv.URL + "/keys"}) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { + o.jwksHits.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ + "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "k1", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + }}}) + }) + + return o +} + +func (o *oidcServer) sign(t *testing.T, signer *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = "k1" + s, err := tok.SignedString(signer) + require.NoError(t, err) + + return s +} + +func TestVerifiedPermissions_OIDCTokenVerification(t *testing.T) { + t.Parallel() + + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + tests := []struct { + name string + action string + audience string + exp time.Duration + nbf time.Duration + useOther bool + asID bool + wantErr bool + }{ + {name: "allow", action: "read", audience: "api", exp: time.Hour}, + {name: "group_permit", action: "write", audience: "api", exp: time.Hour}, + {name: "wrong_signing_key", action: "read", audience: "api", exp: time.Hour, useOther: true, wantErr: true}, + {name: "wrong_audience", action: "read", audience: "other", exp: time.Hour, wantErr: true}, + {name: "expired", action: "read", audience: "api", exp: -time.Hour, wantErr: true}, + {name: "not_yet_valid", action: "read", audience: "api", exp: 2 * time.Hour, nbf: time.Hour, wantErr: true}, + { + name: "identity_token_for_access_source", action: "read", audience: "api", + exp: time.Hour, asID: true, wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newVPJWTEnv(t, false) + o := newOIDCServer(t) + trustOIDCServer(env, o) + + _, cerr := env.client.CreateIdentitySource(t.Context(), &vpsdk.CreateIdentitySourceInput{ + PolicyStoreId: aws.String(env.storeID), + PrincipalEntityType: aws.String("App::User"), + Configuration: &vptypes.ConfigurationMemberOpenIdConnectConfiguration{ + Value: vptypes.OpenIdConnectConfiguration{ + Issuer: aws.String(o.srv.URL), + EntityIdPrefix: aws.String("corp"), + GroupConfiguration: &vptypes.OpenIdConnectGroupConfiguration{ + GroupClaim: aws.String("roles"), + GroupEntityType: aws.String("App::Group"), + }, + TokenSelection: &vptypes.OpenIdConnectTokenSelectionMemberAccessTokenOnly{ + Value: vptypes.OpenIdConnectAccessTokenConfiguration{ + Audiences: []string{"api"}, + PrincipalIdClaim: aws.String("sub"), + }, + }, + }, + }, + }) + require.NoError(t, cerr) + + env.addPolicy(t, `permit(principal == App::User::"corp|bob", action == App::Action::"read", resource);`) + env.addPolicy(t, + `permit(principal in App::Group::"corp|editors", action == App::Action::"write", resource);`) + + signer := o.key + if tt.useOther { + signer = otherKey + } + + tok := o.sign(t, signer, jwt.MapClaims{ + "iss": o.srv.URL, "sub": "bob", "aud": tt.audience, "roles": []string{"editors"}, + "exp": time.Now().Add(tt.exp).Unix(), "nbf": time.Now().Add(tt.nbf).Unix(), + }) + + idTok, accTok := "", tok + if tt.asID { + idTok, accTok = tok, "" + } + + out, aerr := env.isAuthorized(t, tt.action, idTok, accTok) + if tt.wantErr { + require.Error(t, aerr) + assert.Equal(t, "ValidationException", errorCode(aerr)) + + return + } + + require.NoError(t, aerr) + assert.Equal(t, vptypes.DecisionAllow, out.Decision) + assert.Equal(t, "corp|bob", aws.ToString(out.Principal.EntityId)) + }) + } +} + +func trustOIDCServer(env *vpJWTEnv, o *oidcServer) { + pool := x509.NewCertPool() + pool.AddCert(o.srv.Certificate()) + + c := httpclient.New(5 * time.Second) + tr := httpclient.NewTransport() + tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + c.Transport = tr + env.vp.TokenVerifier().SetHTTPClient(c) +} + +func TestVerifiedPermissions_OIDCJWKSCacheTTL(t *testing.T) { + t.Parallel() + + env := newVPJWTEnv(t, false) + o := newOIDCServer(t) + trustOIDCServer(env, o) + + _, err := env.client.CreateIdentitySource(t.Context(), &vpsdk.CreateIdentitySourceInput{ + PolicyStoreId: aws.String(env.storeID), + PrincipalEntityType: aws.String("App::User"), + Configuration: &vptypes.ConfigurationMemberOpenIdConnectConfiguration{ + Value: vptypes.OpenIdConnectConfiguration{ + Issuer: aws.String(o.srv.URL), + TokenSelection: &vptypes.OpenIdConnectTokenSelectionMemberAccessTokenOnly{ + Value: vptypes.OpenIdConnectAccessTokenConfiguration{Audiences: []string{"api"}}, + }, + }, + }, + }) + require.NoError(t, err) + env.addPolicy(t, `permit(principal is App::User, action == App::Action::"read", resource);`) + + tok := o.sign(t, o.key, jwt.MapClaims{ + "iss": o.srv.URL, "sub": "bob", "aud": "api", "exp": time.Now().Add(48 * time.Hour).Unix(), + }) + + var offset atomic.Int64 + + env.vp.TokenVerifier().SetClock(func() time.Time { return time.Now().Add(time.Duration(offset.Load())) }) + + for range 3 { + _, err = env.isAuthorized(t, "read", "", tok) + require.NoError(t, err) + } + + assert.EqualValues(t, 1, o.jwksHits.Load(), "JWKS cached within TTL") + + offset.Store(int64(2 * time.Hour)) + + _, err = env.isAuthorized(t, "read", "", tok) + require.NoError(t, err) + assert.EqualValues(t, 2, o.jwksHits.Load(), "JWKS refetched after TTL") +} diff --git a/internal/app/verifiedpermissions_wiring.go b/internal/app/verifiedpermissions_wiring.go new file mode 100644 index 0000000000..18dbb9bca1 --- /dev/null +++ b/internal/app/verifiedpermissions_wiring.go @@ -0,0 +1,19 @@ +package app + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/service" + cognitoidpbackend "github.com/blackbirdworks/gopherstack/services/cognitoidp" + verifiedpermissionsbackend "github.com/blackbirdworks/gopherstack/services/verifiedpermissions" +) + +// wireVerifiedPermissionsCognito gives Verified Permissions in-process access to user pool signing keys. +func wireVerifiedPermissionsCognito(vpReg, idpReg service.Registerable) { + vpH, ok := vpReg.(*verifiedpermissionsbackend.Handler) + if !ok || vpH.TokenVerifier() == nil { + return + } + + if idpH, idpOk := idpReg.(*cognitoidpbackend.Handler); idpOk { + vpH.TokenVerifier().SetCognitoKeys(idpH) + } +} diff --git a/internal/app/wiring_sdk_helper_test.go b/internal/app/wiring_sdk_helper_test.go index 0f6be103f9..49617f4f11 100644 --- a/internal/app/wiring_sdk_helper_test.go +++ b/internal/app/wiring_sdk_helper_test.go @@ -1,6 +1,7 @@ package app import ( + "context" "net/http/httptest" "testing" @@ -42,3 +43,23 @@ func newWiredSDKConfig(t *testing.T, cli CLI) aws.Config { return cfg } + +// initializeServicesT is initializeServices plus a cleanup that stops every +// backend's background workers once the test ends. +func initializeServicesT(tb testing.TB, appCtx *service.AppContext) ([]service.Registerable, error) { + tb.Helper() + + services, err := initializeServices(appCtx) + if err != nil { + return nil, err + } + + tb.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + shutdownServices(ctx, services) + }) + + return services, nil +} diff --git a/internal/dockercompat/api/types/container/container.go b/internal/dockercompat/api/types/container/container.go index 187e154d4e..eb0a1cdf1d 100644 --- a/internal/dockercompat/api/types/container/container.go +++ b/internal/dockercompat/api/types/container/container.go @@ -74,3 +74,9 @@ type WaitResult struct { Result <-chan WaitResponse Error <-chan error } + +// InspectResponse aliases the Moby container inspect response type. +type InspectResponse = mobycontainer.InspectResponse + +// State aliases the Moby container runtime state type. +type State = mobycontainer.State diff --git a/internal/dockercompat/client/client.go b/internal/dockercompat/client/client.go index fd5f6bcf2e..7c6119f4ff 100644 --- a/internal/dockercompat/client/client.go +++ b/internal/dockercompat/client/client.go @@ -10,6 +10,7 @@ import ( "regexp" "time" + cerrdefs "github.com/containerd/errdefs" "github.com/moby/moby/api/pkg/stdcopy" mobyclient "github.com/moby/moby/client" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -354,10 +355,19 @@ func (c *Client) ContainerExec( ctx context.Context, containerID string, cmd []string, +) ([]byte, []byte, int, error) { + return c.ContainerExecEnv(ctx, containerID, cmd, nil) +} + +// ContainerExecEnv is ContainerExec with extra environment entries (KEY=VALUE) set on the exec process. +func (c *Client) ContainerExecEnv( + ctx context.Context, + containerID string, + cmd, env []string, ) ([]byte, []byte, int, error) { var stdout, stderr bytes.Buffer - code, err := c.ContainerExecStream(ctx, containerID, cmd, &stdout, &stderr) + code, err := c.execStream(ctx, containerID, cmd, env, &stdout, &stderr) if err != nil { return nil, nil, 0, err } @@ -371,11 +381,21 @@ func (c *Client) ContainerExecStream( containerID string, cmd []string, stdout, stderr io.Writer, +) (int, error) { + return c.execStream(ctx, containerID, cmd, nil, stdout, stderr) +} + +func (c *Client) execStream( + ctx context.Context, + containerID string, + cmd, env []string, + stdout, stderr io.Writer, ) (int, error) { created, err := c.inner.ExecCreate(ctx, containerID, mobyclient.ExecCreateOptions{ AttachStdout: true, AttachStderr: true, Cmd: cmd, + Env: env, }) if err != nil { return 0, fmt.Errorf("exec create: %w", err) @@ -518,3 +538,68 @@ func (e *InteractiveExec) Resize(ctx context.Context, cols, rows uint) error { return err } + +// ContainerInspect returns a container's full inspect response. +func (c *Client) ContainerInspect( + ctx context.Context, + containerID string, +) (compatcontainer.InspectResponse, error) { + res, err := c.inner.ContainerInspect(ctx, containerID, mobyclient.ContainerInspectOptions{}) + if err != nil { + return compatcontainer.InspectResponse{}, err + } + + return res.Container, nil +} + +// VolumeCreate creates a named Docker volume with labels; an existing volume of that name is reused. +func (c *Client) VolumeCreate(ctx context.Context, name string, labels map[string]string) error { + if _, err := c.inner.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: name, Labels: labels}); err != nil { + return fmt.Errorf("volume create: %w", err) + } + + return nil +} + +// VolumeListUnused maps the names of volumes carrying label that no container references to their creation time. +func (c *Client) VolumeListUnused(ctx context.Context, label string) (map[string]time.Time, error) { + res, err := c.inner.VolumeList(ctx, mobyclient.VolumeListOptions{ + Filters: make(mobyclient.Filters).Add("label", label).Add("dangling", "true"), + }) + if err != nil { + return nil, fmt.Errorf("volume list: %w", err) + } + + out := make(map[string]time.Time, len(res.Items)) + + for _, v := range res.Items { + created, _ := time.Parse(time.RFC3339Nano, v.CreatedAt) + out[v.Name] = created + } + + return out, nil +} + +// VolumeExists reports whether a Docker volume named name exists. +func (c *Client) VolumeExists(ctx context.Context, name string) (bool, error) { + _, err := c.inner.VolumeInspect(ctx, name, mobyclient.VolumeInspectOptions{}) + if err == nil { + return true, nil + } + + if cerrdefs.IsNotFound(err) { + return false, nil + } + + return false, err +} + +// VolumeRemove force-removes a Docker volume; a missing volume is not an error. +func (c *Client) VolumeRemove(ctx context.Context, name string) error { + _, err := c.inner.VolumeRemove(ctx, name, mobyclient.VolumeRemoveOptions{Force: true}) + if err != nil && !cerrdefs.IsNotFound(err) { + return err + } + + return nil +} diff --git a/internal/mcpserver/backend.go b/internal/mcpserver/backend.go new file mode 100644 index 0000000000..be0feaa9c2 --- /dev/null +++ b/internal/mcpserver/backend.go @@ -0,0 +1,89 @@ +// Package mcpserver exposes Gopherstack's admin APIs as Model Context Protocol tools. +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + maxResponseBytes = 8 << 20 + maxErrorBytes = 1 << 11 +) + +// ErrBackend is returned when the Gopherstack admin API rejects a call. +var ErrBackend = errors.New("gopherstack admin API error") + +// Doer executes an HTTP request against a Gopherstack instance. +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +type backend struct { + doer Doer + base string +} + +func (b *backend) call(ctx context.Context, method, path string, q url.Values, in, out any) error { + target := strings.TrimRight(b.base, "/") + path + if len(q) > 0 { + target += "?" + q.Encode() + } + + var body io.Reader = http.NoBody + + if in != nil { + raw, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + + body = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := b.doer.Do(req) + if err != nil { + return fmt.Errorf("%w: %w", ErrBackend, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= http.StatusBadRequest { + msg := strings.TrimSpace(string(raw)) + if len(msg) > maxErrorBytes { + msg = msg[:maxErrorBytes] + "..." + } + + return fmt.Errorf("%w: %s %s: HTTP %d: %s", ErrBackend, method, path, resp.StatusCode, msg) + } + + if out == nil || len(bytes.TrimSpace(raw)) == 0 { + return nil + } + + if err = json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + + return nil +} diff --git a/internal/mcpserver/http.go b/internal/mcpserver/http.go new file mode 100644 index 0000000000..c4497e533b --- /dev/null +++ b/internal/mcpserver/http.go @@ -0,0 +1,84 @@ +package mcpserver + +import ( + "crypto/subtle" + "net" + "net/http" + "net/http/httptest" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const inProcessBase = "http://gopherstack.internal" + +// HandlerDoer returns a Doer that dispatches requests straight into h. +func HandlerDoer(h http.Handler) Doer { return handlerDoer{h: h} } + +type handlerDoer struct{ h http.Handler } + +func (d handlerDoer) Do(req *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + req.RemoteAddr = "127.0.0.1:0" + d.h.ServeHTTP(rec, req) + + return rec.Result(), nil +} + +// InProcessOptions returns Options for a server mounted inside the Gopherstack process. +func InProcessOptions(admin http.Handler, version string) Options { + return Options{Doer: HandlerDoer(admin), BaseURL: inProcessBase, Version: version} +} + +// NewHTTPHandler serves the streamable-HTTP MCP transport behind Auth. +// +// With a non-empty token every request must carry it as a Bearer credential. +// Without one, only loopback clients are accepted. +func NewHTTPHandler(opts Options, token string) http.Handler { + srv := New(opts) + inner := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil) + + return Auth(token, inner) +} + +// Auth guards next with a bearer token, or with a loopback-only check when token is empty. +func Auth(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token == "" { + if !isLoopback(r.RemoteAddr) { + http.Error( + w, + "MCP endpoint is loopback-only; set a token to allow remote clients", + http.StatusForbidden, + ) + + return + } + + next.ServeHTTP(w, r) + + return + } + + got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer realm="gopherstack-mcp"`) + http.Error(w, "missing or invalid bearer token", http.StatusUnauthorized) + + return + } + + next.ServeHTTP(w, r) + }) +} + +func isLoopback(remote string) bool { + host, _, err := net.SplitHostPort(remote) + if err != nil { + host = remote + } + + ip := net.ParseIP(host) + + return ip != nil && ip.IsLoopback() +} diff --git a/internal/mcpserver/http_test.go b/internal/mcpserver/http_test.go new file mode 100644 index 0000000000..6db18c4a3c --- /dev/null +++ b/internal/mcpserver/http_test.go @@ -0,0 +1,72 @@ +package mcpserver_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/blackbirdworks/gopherstack/internal/mcpserver" +) + +func TestAuth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + remote string + header string + wantStatus int + }{ + {name: "loopback_v4_no_token", remote: "127.0.0.1:5000", wantStatus: http.StatusOK}, + {name: "loopback_v6_no_token", remote: "[::1]:5000", wantStatus: http.StatusOK}, + {name: "remote_no_token", remote: "10.1.2.3:5000", wantStatus: http.StatusForbidden}, + {name: "remote_token_missing", token: "tok", remote: "10.1.2.3:5000", wantStatus: http.StatusUnauthorized}, + { + name: "remote_token_wrong", + token: "tok", + remote: "10.1.2.3:5000", + header: "Bearer x", + wantStatus: http.StatusUnauthorized, + }, + { + name: "remote_basic_scheme", + token: "tok", + remote: "10.1.2.3:5000", + header: "Basic tok", + wantStatus: http.StatusUnauthorized, + }, + { + name: "remote_token_ok", + token: "tok", + remote: "10.1.2.3:5000", + header: "Bearer tok", + wantStatus: http.StatusOK, + }, + {name: "loopback_token_missing", token: "tok", remote: "127.0.0.1:5000", wantStatus: http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := mcpserver.Auth(tt.token, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", http.NoBody) + req.RemoteAddr = tt.remote + + if tt.header != "" { + req.Header.Set("Authorization", tt.header) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, tt.wantStatus, rec.Code) + }) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000000..4a748d7aec --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,67 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + serverName = "gopherstack" + infoURI = "gopherstack://info" +) + +// Options configures a Gopherstack MCP server. +type Options struct { + Doer Doer + BaseURL string + Version string + // ReadOnly omits every tool that can change state. + ReadOnly bool +} + +// New builds an MCP server whose tools call the admin API reachable through opts.Doer. +func New(opts Options) *mcp.Server { + s := mcp.NewServer( + &mcp.Implementation{Name: serverName, Title: "Gopherstack", Version: opts.Version}, + &mcp.ServerOptions{ + Instructions: "Inspect and control a local Gopherstack cloud emulator: health, state snapshots, " + + "resets, chaos faults, request traces, IAM denials and init hooks. Secrets are never exposed.", + }, + ) + b := &backend{doer: opts.Doer, base: opts.BaseURL} + + addTools(s, b, opts.ReadOnly) + s.AddResource(&mcp.Resource{ + URI: infoURI, + Name: "info", + Title: "Server info", + Description: "Version, uptime and enabled services of the running Gopherstack (no secrets).", + MIMEType: "application/json", + }, b.infoResource) + + return s +} + +func (b *backend) infoResource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + var h HealthOutput + if err := b.call(ctx, http.MethodGet, "/_gopherstack/health", nil, nil, &h); err != nil { + return nil, err + } + + raw, err := json.Marshal(h) + if err != nil { + return nil, err + } + + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{ + {URI: req.Params.URI, MIMEType: "application/json", Text: string(raw)}, + }}, nil +} + +// ServeStdio serves s over t until the client disconnects or ctx ends. +func ServeStdio(ctx context.Context, s *mcp.Server, t mcp.Transport) error { + return s.Run(ctx, t) +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go new file mode 100644 index 0000000000..3ae4c3957b --- /dev/null +++ b/internal/mcpserver/tools.go @@ -0,0 +1,482 @@ +package mcpserver + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + defaultTraceLimit = 20 + maxTraceLimit = 100 + defaultDenyLimit = 50 + maxDenyLimit = 500 + traceBufferFetch = 100 + redactedValue = "REDACTED" +) + +func readOnly(title string) *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: new(false)} +} + +func destructive(title string) *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{Title: title, DestructiveHint: new(true), OpenWorldHint: new(false)} +} + +func additive(title string) *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{Title: title, DestructiveHint: new(false), OpenWorldHint: new(false)} +} + +// HealthInput is the (empty) input of gopherstack_health. +type HealthInput struct{} + +// HealthOutput reports the running instance's identity and enabled services. +type HealthOutput struct { + Status string `json:"status"` + Version string `json:"version"` + Services []string `json:"services"` + Disabled []string `json:"disabled,omitempty"` + UptimeSeconds int64 `json:"uptime_seconds"` + Goroutines int `json:"goroutines"` +} + +// SnapshotSaveInput names a snapshot to save. +type SnapshotSaveInput struct { + Name string `json:"name" jsonschema:"snapshot name (a new version is created if it exists)"` + Message string `json:"message,omitempty" jsonschema:"optional version message"` + Services []string `json:"services,omitempty" jsonschema:"only save these services (default: all)"` +} + +// SnapshotLoadInput selects a snapshot version to restore. +type SnapshotLoadInput struct { + Name string `json:"name" jsonschema:"snapshot name"` + Version string `json:"version,omitempty" jsonschema:"version number or 'latest' (default latest)"` + Strategy string `json:"strategy,omitempty" jsonschema:"overwrite (default) or service-merge"` + Services []string `json:"services,omitempty" jsonschema:"only load these services (default: all in the snapshot)"` + Merge bool `json:"merge,omitempty" jsonschema:"merge into existing state instead of replacing it"` +} + +// SnapshotDeleteInput selects a snapshot (version) to delete. +type SnapshotDeleteInput struct { + Name string `json:"name" jsonschema:"snapshot name"` + Version string `json:"version,omitempty" jsonschema:"version to delete (default: all versions)"` +} + +// SnapshotListInput is the (empty) input of snapshot_list. +type SnapshotListInput struct{} + +// DataOutput wraps a JSON document returned by the admin API. +type DataOutput struct { + Data any `json:"data"` +} + +// ResetInput scopes a state reset. +type ResetInput struct { + Service string `json:"service,omitempty" jsonschema:"only reset this service (default: every service)"` +} + +// FaultsListInput is the (empty) input of chaos_list_faults. +type FaultsListInput struct{} + +// FaultError is the error a fault rule injects. +type FaultError struct { + Code string `json:"code" jsonschema:"AWS error code, e.g. ThrottlingException"` + StatusCode int `json:"statusCode" jsonschema:"HTTP status code, e.g. 429"` +} + +// FaultRule is one chaos fault; omitted match fields match everything. +type FaultRule struct { + Error *FaultError `json:"error,omitempty" jsonschema:"injected error (default 503 ServiceUnavailable)"` + Probability *float64 `json:"probability,omitempty" jsonschema:"firing probability 0..1 (default 1)"` + Service string `json:"service,omitempty" jsonschema:"service name to match, e.g. dynamodb"` + Region string `json:"region,omitempty" jsonschema:"region to match"` + Operation string `json:"operation,omitempty" jsonschema:"operation name to match, e.g. PutItem"` +} + +// FaultsSetInput sets chaos fault rules. +type FaultsSetInput struct { + Rules []FaultRule `json:"rules" jsonschema:"fault rules; evaluated in order, first match wins"` + Append bool `json:"append,omitempty" jsonschema:"append to existing rules instead of replacing them"` +} + +// FaultsOutput lists the active chaos fault rules. +type FaultsOutput struct { + Rules []FaultRule `json:"rules"` +} + +// FaultsClearInput is the (empty) input of chaos_clear_faults. +type FaultsClearInput struct{} + +// StatusOutput confirms a state-changing operation. +type StatusOutput struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// TracesInput filters recent request traces. +type TracesInput struct { + Service string `json:"service,omitempty" jsonschema:"only this service, e.g. s3"` + Operation string `json:"operation,omitempty" jsonschema:"only this operation name, e.g. PutObject (case-insensitive)"` + Status string `json:"status,omitempty" jsonschema:"status code like 500, class like 4xx, or 'failed' (>=400)"` + Limit int `json:"limit,omitempty" jsonschema:"max traces to return, 1-100 (default 20)"` +} + +// Trace is a redacted summary of one captured request (no headers or bodies). +type Trace struct { + Timestamp time.Time `json:"timestamp"` + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + Service string `json:"service,omitempty"` + Operation string `json:"operation,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Status int `json:"status"` + DurationMS int64 `json:"duration_ms"` +} + +// TracesOutput lists traces, newest first. +type TracesOutput struct { + Traces []Trace `json:"traces"` +} + +// IAMDenialsInput filters IAM policy-evaluation denials. +type IAMDenialsInput struct { + Service string `json:"service,omitempty" jsonschema:"only this service"` + Principal string `json:"principal,omitempty" jsonschema:"only this principal ARN"` + Since string `json:"since,omitempty" jsonschema:"RFC3339 time or a duration such as 5m"` + Limit int `json:"limit,omitempty" jsonschema:"max events, 1-500 (default 50)"` +} + +// IAMDenialsOutput lists denied evaluations from the IAM policy stream. +type IAMDenialsOutput struct { + Events []map[string]any `json:"events"` + Dropped int64 `json:"dropped"` +} + +// InitHooksInput is the (empty) input of init_hooks_list. +type InitHooksInput struct{} + +func (b *backend) health( + ctx context.Context, + _ *mcp.CallToolRequest, + _ HealthInput, +) (*mcp.CallToolResult, HealthOutput, error) { + var out HealthOutput + if err := b.call(ctx, http.MethodGet, "/_gopherstack/health", nil, nil, &out); err != nil { + return nil, out, err + } + + return nil, out, nil +} + +func (b *backend) snapshotSave( + ctx context.Context, + _ *mcp.CallToolRequest, + in SnapshotSaveInput, +) (*mcp.CallToolResult, DataOutput, error) { + body := map[string]any{"message": in.Message, "services": in.Services} + + return b.data(ctx, http.MethodPost, "/_gopherstack/pods/"+url.PathEscape(in.Name), nil, body) +} + +func (b *backend) snapshotList( + ctx context.Context, + _ *mcp.CallToolRequest, + _ SnapshotListInput, +) (*mcp.CallToolResult, DataOutput, error) { + return b.data(ctx, http.MethodGet, "/_gopherstack/pods", nil, nil) +} + +func (b *backend) snapshotLoad( + ctx context.Context, + _ *mcp.CallToolRequest, + in SnapshotLoadInput, +) (*mcp.CallToolResult, DataOutput, error) { + q := url.Values{} + setIf(q, "version", in.Version) + setIf(q, "strategy", in.Strategy) + setIf(q, "services", strings.Join(in.Services, ",")) + + if in.Merge { + q.Set("merge", "true") + } + + return b.data(ctx, http.MethodPost, "/_gopherstack/pods/"+url.PathEscape(in.Name)+"/load", q, nil) +} + +func (b *backend) snapshotDelete( + ctx context.Context, + _ *mcp.CallToolRequest, + in SnapshotDeleteInput, +) (*mcp.CallToolResult, DataOutput, error) { + q := url.Values{} + setIf(q, "version", in.Version) + + return b.data(ctx, http.MethodDelete, "/_gopherstack/pods/"+url.PathEscape(in.Name), q, nil) +} + +func (b *backend) reset( + ctx context.Context, + _ *mcp.CallToolRequest, + in ResetInput, +) (*mcp.CallToolResult, DataOutput, error) { + q := url.Values{} + setIf(q, "service", in.Service) + + return b.data(ctx, http.MethodPost, "/_gopherstack/reset", q, nil) +} + +func (b *backend) faultsList( + ctx context.Context, + _ *mcp.CallToolRequest, + _ FaultsListInput, +) (*mcp.CallToolResult, FaultsOutput, error) { + return b.faults(ctx, http.MethodGet, "/_gopherstack/chaos/faults", nil) +} + +func (b *backend) faultsSet( + ctx context.Context, + _ *mcp.CallToolRequest, + in FaultsSetInput, +) (*mcp.CallToolResult, FaultsOutput, error) { + method := http.MethodPost + if in.Append { + method = http.MethodPatch + } + + rules := in.Rules + if rules == nil { + rules = []FaultRule{} + } + + return b.faults(ctx, method, "/_gopherstack/chaos/faults", rules) +} + +func (b *backend) faultsClear( + ctx context.Context, + _ *mcp.CallToolRequest, + _ FaultsClearInput, +) (*mcp.CallToolResult, StatusOutput, error) { + if err := b.call(ctx, http.MethodPost, "/_gopherstack/chaos/faults/clear", nil, nil, nil); err != nil { + return nil, StatusOutput{}, err + } + + return nil, StatusOutput{Status: "ok", Message: "all fault rules cleared"}, nil +} + +func (b *backend) faults(ctx context.Context, method, path string, in any) (*mcp.CallToolResult, FaultsOutput, error) { + rules := []FaultRule{} + if err := b.call(ctx, method, path, nil, in, &rules); err != nil { + return nil, FaultsOutput{}, err + } + + return nil, FaultsOutput{Rules: rules}, nil +} + +func (b *backend) traces( + ctx context.Context, + _ *mcp.CallToolRequest, + in TracesInput, +) (*mcp.CallToolResult, TracesOutput, error) { + limit := clamp(in.Limit, defaultTraceLimit, maxTraceLimit) + + q := url.Values{"n": {strconv.Itoa(traceBufferFetch)}} + setIf(q, "service", in.Service) + setIf(q, "status", in.Status) + + var raw []capturedRequest + if err := b.call(ctx, http.MethodGet, "/_gopherstack/requests", q, nil, &raw); err != nil { + return nil, TracesOutput{}, err + } + + out := TracesOutput{Traces: make([]Trace, 0, min(limit, len(raw)))} + + for i := range raw { + t := raw[i].trace() + if in.Operation != "" && !strings.EqualFold(t.Operation, in.Operation) { + continue + } + + out.Traces = append(out.Traces, t) + if len(out.Traces) == limit { + break + } + } + + return nil, out, nil +} + +func (b *backend) iamDenials( + ctx context.Context, + _ *mcp.CallToolRequest, + in IAMDenialsInput, +) (*mcp.CallToolResult, IAMDenialsOutput, error) { + q := url.Values{"decision": {"deny"}, "n": {strconv.Itoa(clamp(in.Limit, defaultDenyLimit, maxDenyLimit))}} + setIf(q, "service", in.Service) + setIf(q, "principal", in.Principal) + setIf(q, "since", in.Since) + + out := IAMDenialsOutput{Events: []map[string]any{}} + if err := b.call(ctx, http.MethodGet, "/_gopherstack/iam/policy-stream", q, nil, &out); err != nil { + return nil, out, err + } + + if out.Events == nil { + out.Events = []map[string]any{} + } + + return nil, out, nil +} + +// InitHooksRunInput selects registered init hook scripts to run. +type InitHooksRunInput struct { + Stage string `json:"stage" jsonschema:"BOOT, START, READY or SHUTDOWN"` + Script string `json:"script,omitempty" jsonschema:"script file name from init_hooks_list (default: whole stage)"` +} + +func (b *backend) initHooksRun( + ctx context.Context, + _ *mcp.CallToolRequest, + in InitHooksRunInput, +) (*mcp.CallToolResult, DataOutput, error) { + if strings.ContainsAny(in.Script, `/\`) || in.Script == ".." || in.Script == "." { + return nil, DataOutput{}, fmt.Errorf("%w: script must be a registered file name", ErrBackend) + } + + return b.data(ctx, http.MethodPost, "/_gopherstack/init/run", nil, in) +} + +func (b *backend) initHooks( + ctx context.Context, + _ *mcp.CallToolRequest, + _ InitHooksInput, +) (*mcp.CallToolResult, DataOutput, error) { + return b.data(ctx, http.MethodGet, "/_localstack/init", nil, nil) +} + +func (b *backend) data( + ctx context.Context, + method, path string, + q url.Values, + in any, +) (*mcp.CallToolResult, DataOutput, error) { + var out any + if err := b.call(ctx, method, path, q, in, &out); err != nil { + return nil, DataOutput{}, err + } + + if out == nil { + out = map[string]any{"status": "ok"} + } + + return nil, DataOutput{Data: out}, nil +} + +func setIf(q url.Values, key, val string) { + if val != "" { + q.Set(key, val) + } +} + +func clamp(v, def, maxV int) int { + if v <= 0 { + return def + } + + return min(v, maxV) +} + +// addTool registers t unless the server is read-only and t can change state. +func addTool[In, Out any](s *mcp.Server, ro bool, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) { + if ro && !t.Annotations.ReadOnlyHint { + return + } + + mcp.AddTool(s, t, h) +} + +func addTools(s *mcp.Server, b *backend, ro bool) { + addTool(s, ro, &mcp.Tool{ + Name: "gopherstack_health", + Description: "Report the running Gopherstack's version, uptime and the list of enabled and disabled services.", + Annotations: readOnly("Health and enabled services"), + }, b.health) + addTool(s, ro, &mcp.Tool{ + Name: "snapshot_list", + Description: "List saved state snapshots (pods) and their versions.", + Annotations: readOnly("List state snapshots"), + }, b.snapshotList) + addTool(s, ro, &mcp.Tool{ + Name: "snapshot_save", + Description: "Save the current in-memory state of all (or selected) services as a new snapshot version.", + Annotations: additive("Save state snapshot"), + }, b.snapshotSave) + addTool(s, ro, &mcp.Tool{ + Name: "snapshot_load", + Description: "Restore a saved snapshot into the running server. " + + "Replaces current state of the loaded services unless merge is set.", + Annotations: destructive("Load state snapshot"), + }, b.snapshotLoad) + addTool(s, ro, &mcp.Tool{ + Name: "snapshot_delete", + Description: "Delete a saved snapshot version, or every version when no version is given.", + Annotations: destructive("Delete state snapshot"), + }, b.snapshotDelete) + addTool(s, ro, &mcp.Tool{ + Name: "reset_state", + Description: "Erase in-memory state of every service, or only the named service. " + + "Cannot be undone unless a snapshot was saved.", + Annotations: destructive("Reset state"), + }, b.reset) + addTool(s, ro, &mcp.Tool{ + Name: "chaos_list_faults", + Description: "List the active chaos fault-injection rules.", + Annotations: readOnly("List chaos faults"), + }, b.faultsList) + addTool(s, ro, &mcp.Tool{ + Name: "chaos_set_faults", + Description: "Replace (default) or append chaos fault rules that make matching AWS requests " + + "fail with a chosen error.", + Annotations: destructive("Set chaos faults"), + }, b.faultsSet) + addTool(s, ro, &mcp.Tool{ + Name: "chaos_clear_faults", + Description: "Remove every chaos fault rule.", + Annotations: destructive("Clear chaos faults"), + }, b.faultsClear) + addTool(s, ro, &mcp.Tool{ + Name: "query_traces", + Description: "Query recent request traces (newest first, at most 100 retained) " + + "filtered by service, operation and HTTP status. " + + "Headers and bodies are never returned.", + Annotations: readOnly("Query request traces"), + }, b.traces) + addTool(s, ro, &mcp.Tool{ + Name: "iam_denials", + Description: "Query IAM policy-evaluation denials (requires --enforce-iam or --iam-audit), " + + "including the deciding policy and a suggested fix.", + Annotations: readOnly("IAM policy denials"), + }, b.iamDenials) + addTool(s, ro, &mcp.Tool{ + Name: "init_hooks_list", + Description: "List init hook scripts per stage (BOOT, START, READY, SHUTDOWN) with completion state " + + "and results. Hooks run at startup only.", + Annotations: readOnly("List init hooks"), + }, b.initHooks) + + addTool(s, ro, &mcp.Tool{ + Name: "init_hooks_run", + Description: "Run init hook scripts already present in the configured stage directories, " + + "one script by name (from init_hooks_list) or a whole stage. " + + "Returns exit code and bounded output; arbitrary paths or inline scripts are rejected.", + Annotations: destructive("Run init hooks"), + }, b.initHooksRun) +} diff --git a/internal/mcpserver/tools_test.go b/internal/mcpserver/tools_test.go new file mode 100644 index 0000000000..1a51b52926 --- /dev/null +++ b/internal/mcpserver/tools_test.go @@ -0,0 +1,80 @@ +package mcpserver_test + +import ( + "net/http" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/internal/mcpserver" +) + +func TestQueryTracesRedactsAndFilters(t *testing.T) { + t.Parallel() + + admin := http.NewServeMux() + admin.HandleFunc("/_gopherstack/requests", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":"1","method":"POST","path":"/","service":"dynamodb","status":200,"duration_ms":2000000, + "headers":{"X-Amz-Target":"DynamoDB_20120810.PutItem","Authorization":"AWS4 Signature=hdrsecret"}}, + {"id":"2","method":"GET","path":"/b/k","query":"X-Amz-Signature=qsecret&versionId=3","service":"s3","status":403, + "headers":{}}, + {"id":"3","method":"POST","path":"/","query":"Action=ListQueues","service":"sqs","status":200,"headers":{}} + ]`)) + }) + + srv := mcpserver.New(mcpserver.InProcessOptions(admin, "test")) + ct, st := mcp.NewInMemoryTransports() + + ss, err := srv.Connect(t.Context(), st, nil) + require.NoError(t, err) + + t.Cleanup(func() { _ = ss.Close() }) + + cs, err := mcp.NewClient(&mcp.Implementation{Name: "t", Version: "0"}, nil).Connect(t.Context(), ct, nil) + require.NoError(t, err) + + t.Cleanup(func() { _ = cs.Close() }) + + tests := []struct { + args map[string]any + name string + wantIDs []string + }{ + {name: "all", args: map[string]any{}, wantIDs: []string{"1", "2", "3"}}, + {name: "op_header", args: map[string]any{"operation": "putitem"}, wantIDs: []string{"1"}}, + {name: "op_query_action", args: map[string]any{"operation": "ListQueues"}, wantIDs: []string{"3"}}, + {name: "limit", args: map[string]any{"limit": 2}, wantIDs: []string{"1", "2"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, callErr := cs.CallTool(t.Context(), &mcp.CallToolParams{Name: "query_traces", Arguments: tt.args}) + require.NoError(t, callErr) + require.False(t, res.IsError) + + out, _ := res.StructuredContent.(map[string]any) + traces, _ := out["traces"].([]any) + + var ids []string + for _, tr := range traces { + ids = append(ids, tr.(map[string]any)["id"].(string)) + } + + assert.Equal(t, tt.wantIDs, ids) + + assert.NotContains(t, out, "headers") + + for _, tr := range traces { + assert.NotContains(t, tr, "headers") + q, _ := tr.(map[string]any)["query"].(string) + assert.NotContains(t, q, "qsecret") + } + }) + } +} diff --git a/internal/mcpserver/trace.go b/internal/mcpserver/trace.go new file mode 100644 index 0000000000..710d40565a --- /dev/null +++ b/internal/mcpserver/trace.go @@ -0,0 +1,85 @@ +package mcpserver + +import ( + "net/url" + "strings" + "time" +) + +type capturedRequest struct { + Timestamp time.Time `json:"timestamp"` + Headers map[string]string `json:"headers"` + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Service string `json:"service"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` + Status int `json:"status"` + Duration time.Duration `json:"duration_ms"` +} + +func isSensitiveQueryKey(k string) bool { + switch strings.ToLower(k) { + case "x-amz-signature", "x-amz-credential", "x-amz-security-token", + "signature", "awsaccesskeyid", "token", "sig": + return true + } + + return false +} + +func (r *capturedRequest) trace() Trace { + return Trace{ + Timestamp: r.Timestamp, + ID: r.ID, + Method: r.Method, + Path: r.Path, + Query: redactQuery(r.Query), + Service: r.Service, + Operation: r.operation(), + ErrorCode: r.ErrorCode, + ErrorMessage: r.ErrorMessage, + Status: r.Status, + DurationMS: r.Duration.Milliseconds(), + } +} + +func (r *capturedRequest) operation() string { + for k, v := range r.Headers { + if strings.EqualFold(k, "X-Amz-Target") { + if _, op, ok := strings.Cut(v, "."); ok { + return op + } + + return v + } + } + + vals, err := url.ParseQuery(r.Query) + if err != nil { + return "" + } + + return vals.Get("Action") +} + +func redactQuery(raw string) string { + if raw == "" { + return "" + } + + vals, err := url.ParseQuery(raw) + if err != nil { + return redactedValue + } + + for k := range vals { + if isSensitiveQueryKey(k) { + vals.Set(k, redactedValue) + } + } + + return vals.Encode() +} diff --git a/internal/routegates/gates.go b/internal/routegates/gates.go index 0e2db0f36e..36eee80d3e 100644 --- a/internal/routegates/gates.go +++ b/internal/routegates/gates.go @@ -3,6 +3,8 @@ package routegates import ( _ "embed" // pathGatesData "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/service" ) // namespaced returns prefix plus the form botocore clients send, which prepends "com.amazonaws.". @@ -14,81 +16,84 @@ func namespaced(ns, prefix string) []string { // TestRouteTargetGatesHold proves the matchers return false outside them. func TargetGates() map[string][]string { return map[string][]string{ - "ACM": {"CertificateManager."}, - "ACMPCA": {"ACMPrivateCA."}, - "AWSConfig": {"StarlingDoveService."}, - "BCMDataExports": {"AWSBillingAndCostManagementDataExports."}, - "CUR": {"AWSOrigamiServiceGatewayService."}, - "AppRunner": {"AppRunner."}, - "ApplicationAutoscaling": {"AnyScaleFrontendService."}, - "Athena": {"AmazonAthena"}, - "Budgets": {"AWSBudgetServiceGateway."}, - "Ce": {"AWSInsightsIndexService."}, - "CloudControl": {"CloudApiService."}, - "CloudHSMV2": {"BaldrApiService."}, - "CloudTrail": namespaced("cloudtrail.v20131101.", "CloudTrail_20131101."), - "CloudWatchLogs": {"Logs_20140328."}, - "CodeBuild": {"CodeBuild_20161006."}, - "CodeCommit": {"CodeCommit_20150413."}, - "CodeConnections": namespaced("codeconnections.", "CodeConnections_20231201."), - "CodeDeploy": {"CodeDeploy_20141006."}, - "CodePipeline": {"CodePipeline_20150709."}, - "CodeStarConnections": namespaced("codestar.connections.", "CodeStar_connections_20191201."), - "CognitoIdentity": {"AWSCognitoIdentityService."}, - "Comprehend": {"Comprehend_20171127."}, - "DAX": {"AmazonDAXV3."}, - "DMS": {"AmazonDMSv20160101."}, - "DataSync": {"FmrsService."}, - "DirectConnect": {"OvertureService."}, - "DirectoryService": {"DirectoryService_20150416."}, - "DynamoDB": {"DynamoDB_"}, - "DynamoDBStreams": {"DynamoDBStreams_20120810."}, - "ECRPublic": {"SpencerFrontendService."}, - "ECS": {"AmazonEC2ContainerServiceV20141113."}, - "EMR": {"ElasticMapReduce."}, - "FSx": {"AWSSimbaAPIService_v20180301."}, - "Firehose": {"Firehose_20150804."}, - "Forecast": {"AmazonForecast."}, - "GlobalAccelerator": {"GlobalAccelerator_V20180706."}, - "Glue": {"AWSGlue."}, - "IdentityStore": {"AWSIdentityStore."}, - "KMS": {"TrentService"}, - "Kinesis": {"Kinesis_20131202."}, - "KinesisAnalytics": {"KinesisAnalytics_20150814."}, - "KinesisAnalyticsV2": {"KinesisAnalytics_20180523."}, - "Lightsail": {"Lightsail_20161128."}, - "MarketplaceMetering": {"AWSMPMeteringService."}, - "MediaStore": {"MediaStore_20170901."}, - "NetworkFirewall": {"NetworkFirewall_20201112."}, - "OpsWorks": {"OpsWorks_20130218."}, - "Organizations": {"AWSOrganizationsV20161128."}, - "Pricing": {"AWSPriceListService."}, - "RedshiftData": {"RedshiftData."}, - "RedshiftServerless": {"RedshiftServerless."}, - "Rekognition": {"RekognitionService."}, - "ResourceGroupsTaggingAPI": {"ResourceGroupsTaggingAPI_20170126."}, - "Route53Resolver": {"Route53Resolver."}, - "SWF": {"SimpleWorkflowService."}, - "TimestreamInfluxDB": {"AmazonTimestreamInfluxDB."}, - "SageMaker": {"SageMaker."}, - "SecretsManager": {"secretsmanager"}, - "ServiceCatalog": {"AWS242ServiceCatalogService."}, - "ServiceDiscovery": {"Route53AutoNaming_v20170314."}, - "ServiceQuotas": {"ServiceQuotasV20190624."}, - "Shield": {"AWSShield_20160616."}, - "SsoAdmin": {"SWBExternalService."}, - "StepFunctions": {"AmazonStates.", "AWSStepFunctions."}, - "Support": {"AWSSupport_20130415."}, - "Textract": {"Textract."}, - "TimestreamWrite": {"Timestream_20181101."}, - "Transcribe": {"Transcribe."}, - "Transfer": {"TransferService."}, - "Translate": {"AWSShineFrontendService_20170701."}, - "VerifiedPermissions": {"VerifiedPermissions."}, - "WAF": {"AWSWAF_20150824."}, - "Wafv2": {"AWSWAF_20190729."}, - "WorkMail": {"WorkMailService."}, - "WorkSpaces": {"WorkspacesService."}, + "ACM": {"CertificateManager."}, + "ACMPCA": {"ACMPrivateCA."}, + "AWSConfig": {"StarlingDoveService."}, + "BCMDataExports": {"AWSBillingAndCostManagementDataExports."}, + "CUR": {"AWSOrigamiServiceGatewayService."}, + "AppRunner": {"AppRunner."}, + "AppStream": {"PhotonAdminProxyService."}, + "ApplicationAutoscaling": {"AnyScaleFrontendService."}, + "Athena": {"AmazonAthena"}, + "Budgets": {"AWSBudgetServiceGateway."}, + "Ce": {"AWSInsightsIndexService."}, + "CloudControl": {"CloudApiService."}, + "CloudHSMV2": {"BaldrApiService."}, + "CloudTrail": namespaced("cloudtrail.v20131101.", "CloudTrail_20131101."), + "CloudWatchLogs": {"Logs_20140328."}, + "CodeBuild": {"CodeBuild_20161006."}, + "CodeCommit": {"CodeCommit_20150413."}, + "CodeConnections": namespaced("codeconnections.", "CodeConnections_20231201."), + "CodeDeploy": {"CodeDeploy_20141006."}, + "CodePipeline": {"CodePipeline_20150709."}, + "CodeStarConnections": namespaced("codestar.connections.", "CodeStar_connections_20191201."), + "CognitoIdentity": {"AWSCognitoIdentityService."}, + "Comprehend": {"Comprehend_20171127."}, + "DAX": {"AmazonDAXV3."}, + "DMS": {"AmazonDMSv20160101."}, + "DataSync": {"FmrsService."}, + "DirectConnect": {"OvertureService."}, + "DirectoryService": {"DirectoryService_20150416."}, + "DynamoDB": {"DynamoDB_"}, + "DynamoDBStreams": {"DynamoDBStreams_20120810."}, + "ECRPublic": {"SpencerFrontendService."}, + "ECS": {"AmazonEC2ContainerServiceV20141113."}, + "EMR": {"ElasticMapReduce."}, + "FSx": {"AWSSimbaAPIService_v20180301."}, + "Firehose": {"Firehose_20150804."}, + "Forecast": {"AmazonForecast."}, + "GlobalAccelerator": {"GlobalAccelerator_V20180706."}, + "Glue": {"AWSGlue."}, + "IdentityStore": {"AWSIdentityStore."}, + "KMS": {"TrentService"}, + "Kinesis": {"Kinesis_20131202."}, + "KinesisAnalytics": {"KinesisAnalytics_20150814."}, + "KinesisAnalyticsV2": {"KinesisAnalytics_20180523."}, + "Lightsail": {"Lightsail_20161128."}, + "MarketplaceEntitlementService": {"AWSMPEntitlementService."}, + "MarketplaceMetering": {"AWSMPMeteringService."}, + "MediaStore": {"MediaStore_20170901."}, + "NetworkFirewall": {"NetworkFirewall_20201112."}, + "OpsWorks": {"OpsWorks_20130218."}, + "Organizations": {"AWSOrganizationsV20161128."}, + "Pricing": {"AWSPriceListService."}, + "RedshiftData": {"RedshiftData."}, + "RedshiftServerless": {"RedshiftServerless."}, + "Rekognition": {"RekognitionService."}, + "ResourceGroupsTaggingAPI": {"ResourceGroupsTaggingAPI_20170126."}, + "Route53Resolver": {"Route53Resolver."}, + "SWF": {"SimpleWorkflowService."}, + "TimestreamInfluxDB": {"AmazonTimestreamInfluxDB."}, + "SageMaker": {"SageMaker."}, + "SecretsManager": {"secretsmanager"}, + "ServiceCatalog": {"AWS242ServiceCatalogService."}, + "ServiceDiscovery": {"Route53AutoNaming_v20170314."}, + "ServiceQuotas": {"ServiceQuotasV20190624."}, + "Shield": {"AWSShield_20160616."}, + "SsoAdmin": {"SWBExternalService."}, + "StepFunctions": {"AmazonStates.", "AWSStepFunctions."}, + "Support": {"AWSSupport_20130415."}, + "Textract": {"Textract."}, + "TimestreamQuery": {"Timestream_20181101."}, + "TimestreamWrite": {"Timestream_20181101."}, + "Transcribe": {"Transcribe."}, + "Transfer": {"TransferService."}, + "Translate": {"AWSShineFrontendService_20170701."}, + "VerifiedPermissions": {"VerifiedPermissions."}, + "WAF": {"AWSWAF_20150824."}, + "Wafv2": {"AWSWAF_20190729."}, + "WorkMail": {"WorkMailService."}, + "WorkSpaces": {"WorkspacesService."}, } } @@ -96,8 +101,10 @@ func TargetGates() map[string][]string { // shape name; /service//operation/ passes the service's target gate. func CBORGates() map[string]string { return map[string]string{ - "CodeConnections": "CodeConnections_20231201", - "Translate": "AWSShineFrontendService_20170701", + "AppStream": "PhotonAdminProxyService", + "CodeConnections": "CodeConnections_20231201", + "MarketplaceEntitlementService": "AWSMPEntitlementService", + "Translate": "AWSShineFrontendService_20170701", } } @@ -117,3 +124,53 @@ func PathGates() map[string][]string { return gates } + +const formContentType = "application/x-www-form-urlencoded" + +// AltGates maps services whose matchers accept via several independent signals to their +// OR-gate; TestRouteAltGatesHold proves each matcher returns false whenever its gate rejects. +func AltGates() map[string]service.AltGate { + return map[string]service.AltGate{ + "MemoryDB": {Target: []string{"AmazonMemoryDB."}, Scope: []string{"memorydb"}}, + "Route53RecoveryCluster": { + Target: []string{"ToggleCustomerAPI."}, Scope: []string{"route53-recovery-cluster"}, + }, + "MediaStoreData": {UserAgent: []string{"mediastore"}}, + "CognitoIDP": { + Target: []string{"AWSCognitoIdentityProviderService."}, + Path: []string{"/oauth2/", "/login", "/logout", "/saml2/", "/.well-known/"}, + PathSuffix: []string{ + "/.well-known/jwks.json", "/.well-known/openid-configuration", "/saml2/metadata", + }, + }, + "Lambda": {Target: []string{"AWSLambda"}, Path: []string{"/20"}, Host: []string{".lambda-url."}}, + "EventBridge": { + Target: []string{"AmazonEventBridge.", "AWSEvents.", "AWSSchemas."}, Path: []string{"/v1"}, + }, + "ResourceGroups": { + Target: []string{"ResourceGroups."}, + Path: []string{ + "/groups", "/get-", "/delete-group", "/update-", "/cancel-tag-sync-task", "/group-resources", + "/list-g", "/list-tag-sync-tasks", "/put-group-configuration", "/resources/", "/start-tag-sync-task", + "/ungroup-resources", + }, + }, + "Personalize": { + Target: []string{"AmazonPersonalize"}, + Path: []string{"/recommendations", "/personalize-ranking", "/action-recommendations"}, + }, + "SSM": { + Target: []string{"AmazonSSM", "EC2WindowsMessageDeliveryService."}, Path: []string{"/ssmmessages/"}, + }, + "ECR": {Target: []string{"AmazonEC2ContainerRegistry_V20150921."}, Path: []string{"/v2"}}, + "Scheduler": { + Target: []string{"AWSScheduler."}, Path: []string{"/schedules", "/schedule-groups", "/tags/"}, + }, + "STS": {ContentType: []string{formContentType}}, + "SQS": {Target: []string{"AmazonSQS."}, ContentType: []string{formContentType}}, + "SNS": { + ContentType: []string{formContentType}, PathSuffix: []string{"SimpleNotificationService.pem"}, + Query: []string{"Action=", "%"}, + }, + } +} diff --git a/internal/routegates/path_gates.txt b/internal/routegates/path_gates.txt index affb8a7b72..e550cc79d9 100644 --- a/internal/routegates/path_gates.txt +++ b/internal/routegates/path_gates.txt @@ -2,7 +2,7 @@ AMP /scraperconfiguration /scrapers /tags /workspaces APIGatewayManagementAPI /@connections /_gopherstack APIGatewayV2 /v2 AccessAnalyzer /access-preview /analyzed-resource /analyzer /archive-rule /finding /policy /recommendation /resource /service-linked-analyzer /tags -Account /acceptPrimaryEmailUpdate /deleteAlternateContact /disableRegion /enableRegion /getAccountInformation /getAlternateContact /getContactInformation /getGovCloudAccountInformation /getPrimaryEmail /getRegionOptStatus /listRegions /putAccountName /putAlternateContact /putContactInformation /startPrimaryEmailUpdate +Account /acceptPrimaryEmailUpdate /deleteAlternateContact /disableRegion /enableRegion /getAccountInformation /getAlternateContact /getContactInformation /getGovCloudAccountInformation /getPrimaryEmail /getRegionOptStatus /listRegions /putAccountName /putAlternateContact /putContactInformation /sendPhoneNumberVerification /startPrimaryEmailUpdate /verifyPhoneNumber Amplify /apps /artifacts /tags /webhooks AppConfig /applications /deployementstrategies /deploymentstrategies /experimentdefinitions /extensionassociations /extensions /settings /tags AppConfigData /configuration @@ -32,6 +32,7 @@ FIS /actions /experimentTemplates /experiments /safetyLevers /tags /targetResour Grafana /tags /versions /workspaces GuardDuty /admin /detector /invitation /malware-protection-plan /malware-scan /object-malware-scan /organization /tags Inspector2 /accountpermissions /cis /cluster /codesecurity /codesnippet /configuration /connector /coverage /delegatedadminaccounts /disable /ec2deepinspection /enable /encryptionkey /filters /findings /freetrialinfo /members /organizationconfiguration /reporting /sbomexport /status /tags /usage /vulnerabilities +IoT /accept-certificate-transfer/ /active-violations /api/things/shadow/ /attached-policies /audit/ /authorizer /behavior-model-training/summaries /billing-groups /cacertificate /cancel-certificate-transfer/ /certificate /command-executions /commands /confirmdestination /custom-metric /default-authorizer /destinations /detect/ /dimensions /domainConfigurations /dynamic-thing-groups /effective-policies /encryption-configuration /endpoint /event-configurations /fleet-metric /indexing/config /indices /job-templates /jobs /keys-and-certificate /loggingOptions /managed-job-templates /metric-values /mitigationactions/ /otaUpdates /package-configuration /packages /policies /policy-principals /policy-targets/ /principal-policies /principal-things /principals/things /provisioning-templates /registrationcode /reject-certificate-transfer/ /role-aliases /rule-destinations /rules /security-profile /streams /tags /target-policies /test-authorization /thing-groups /thing-registration-tasks /thing-types /things /transfer-certificate/ /untag /v2Logging /violation-events /violations/ IoTAnalytics /channels /datasets /datastores /logging /messages /pipelineactivities /pipelines /tags IoTDataPlane /_admin /api /connections /retainedMessage /things /topics IoTWireless /destinations /device-profiles /event-configurations /fuota-tasks /log-levels /metric-configuration /metrics /multicast-groups /network-analyzer-configurations /partner-accounts /position-configurations /position-estimate /positions /resource-positions /service-endpoint /service-profiles /tags /wireless-devices /wireless-gateway-task-definitions /wireless-gateways /wireless_device_import_task /wireless_single_device_import_task @@ -65,9 +66,16 @@ S3Vectors /CreateIndex /CreateVectorBucket /DeleteIndex /DeleteVectorBucket /Del S3tables /buckets /get-table /namespaces /replication-status /table-bucket-replication /table-record-expiration /table-replication /tables /tag SESv2 /v2 SageMakerRuntime /endpoints -SecurityHub /accounts /actionTargets /administrator /aggregatorv2 /associations /automationrules /configurationPolicy /connectors /findingAggregator /findingHistory /findings /hubv2 /insights /invitations /master /members /organization /productSubscriptions /products /recommendedPolicyV2 /resourcesTrendsv2 /resourcesv2 /securityControl /standards /tags /ticketsv2 +SecurityHub /accounts /actionTargets /administrator /aggregatorv2 /associations /automationrules /configurationPolicy /connectors /exportjobsv2 /findingAggregator /findingHistory /findings /hubv2 /insights /invitations /master /members /organization /productSubscriptions /products /recommendedPolicyV2 /resourcesTrendsv2 /resourcesv2 /securityControl /standards /tags /ticketsv2 ServerlessRepo /applications SSOOIDC /client /device_authorization /token SSOPortal /assignment /federation /logout VPCLattice /accesslogsubscriptions /authpolicy /domainverifications /resourceconfigurations /resourceendpointassociations /resourcegateways /resourcepolicy /servicenetworkresourceassociations /servicenetworks /servicenetworkvpcassociations /servicenetworkvpcendpointassociations /services /tags /targetgroups Xray /CancelTraceRetrieval /CreateGroup /CreateSamplingRule /DeleteGroup /DeleteResourcePolicy /DeleteSamplingRule /EncryptionConfig /GetGroup /GetIndexingRules /GetRetrievedTracesGraph /GetSamplingRules /GetTraceSegmentDestination /Groups /Insight /ListResourcePolicies /ListRetrievedTraces /ListTagsForResource /PutEncryptionConfig /PutResourcePolicy /SamplingStatisticSummaries /SamplingTargets /ServiceGraph /StartTraceRetrieval /TagResource /TelemetryRecords /TimeSeriesServiceStatistics /TraceGraph /TraceSegments /TraceSummaries /Traces /UntagResource /UpdateGroup /UpdateIndexingRule /UpdateSamplingRule /UpdateTraceSegmentDestination +ARCZonalShift /autoshift /configuration /managedresources /practiceruns /zonalshifts +BedrockAgentCore /browsers /code-interpreters /identities /memories /runtimes +Route53RecoveryControlConfig /cluster /controlpanel /resourcePolicy /routingcontrol /safetyrule /tags +Route53RecoveryReadiness /cellreadiness /cells /crossaccountauthorizations /readinesschecks /recoverygroup /resourcesets /rules /tags +MGN /A /C /D /F /G /I /L /M /P /R /S /T /U /network-migration /tags +OAM /Create /Delete /Get /List /Put /Update /tags +ResilienceHub /accept- /add- /batch- /create- /delete- /describe- /import- /list- /publish- /put- /reject- /remove- /resolve- /start- /tags /update- diff --git a/modules/gopherstack/gopherstack_test.go b/modules/gopherstack/gopherstack_test.go index 504f01bdd7..01b5ba0353 100644 --- a/modules/gopherstack/gopherstack_test.go +++ b/modules/gopherstack/gopherstack_test.go @@ -10,6 +10,7 @@ import ( "github.com/testcontainers/testcontainers-go" gopherstackmodule "github.com/blackbirdworks/gopherstack/modules/gopherstack" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // TestRun verifies that a Gopherstack container starts, exposes a valid @@ -50,7 +51,7 @@ func TestRun(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, url, "expected non-empty base URL") - resp, err := http.Get(url + "/_gopherstack/health") + resp, err := testclient.New(t).Get(url + "/_gopherstack/health") require.NoError(t, err) defer resp.Body.Close() diff --git a/pkgs/aadauth/issuer.go b/pkgs/aadauth/issuer.go index c5e88687e7..17494f6c5a 100644 --- a/pkgs/aadauth/issuer.go +++ b/pkgs/aadauth/issuer.go @@ -80,7 +80,8 @@ type ClientCredentialsClaims struct { Issuer string // https:////v2.0 Audience string // requested scope's resource, e.g. https://management.azure.com TenantID string - ClientID string // becomes both "appid" and "oid"/"sub" (a service principal has no separate user) + ClientID string // the "appid" claim; also "oid"/"sub" when ObjectID is empty + ObjectID string // optional "oid"/"sub": the service principal's or user's directory object ID } // IssueClientCredentialsToken signs and returns an AAD-shaped access token @@ -94,13 +95,18 @@ func (iss *Issuer) IssueClientCredentialsToken(claims ClientCredentialsClaims, e now := time.Now().Unix() expiry := int64(expirySeconds) + oid := claims.ObjectID + if oid == "" { + oid = claims.ClientID + } + jwtClaims := jwt.MapClaims{ "iss": claims.Issuer, "aud": claims.Audience, "tid": claims.TenantID, "appid": claims.ClientID, - "oid": claims.ClientID, - "sub": claims.ClientID, + "oid": oid, + "sub": oid, "ver": aadVersion, "iat": now, "nbf": now, diff --git a/pkgs/awsmeta/awsmeta.go b/pkgs/awsmeta/awsmeta.go index e243c197e9..d457b78dbb 100644 --- a/pkgs/awsmeta/awsmeta.go +++ b/pkgs/awsmeta/awsmeta.go @@ -194,6 +194,22 @@ func GetPrincipal(ctx context.Context) *Principal { return nil } +// CallerAccount returns the account the request is known to come from and +// whether that identity is explicit: a resolved principal, or any account +// other than DefaultAccount (header or 12-digit access key). Anonymous +// default-account requests report ok=false so callers can stay permissive. +func CallerAccount(ctx context.Context) (string, bool) { + if p := GetPrincipal(ctx); p != nil && p.AccountID != "" { + return p.AccountID, true + } + + if acct := Account(ctx); acct != DefaultAccount { + return acct, true + } + + return "", false +} + // CallerArn returns the ARN of the calling principal, or empty string. func CallerArn(ctx context.Context) string { if p := GetPrincipal(ctx); p != nil { diff --git a/pkgs/awsmeta/awsmeta_test.go b/pkgs/awsmeta/awsmeta_test.go index de5d4870ba..331ffbbe81 100644 --- a/pkgs/awsmeta/awsmeta_test.go +++ b/pkgs/awsmeta/awsmeta_test.go @@ -386,3 +386,41 @@ func TestAccountFromAccessKey(t *testing.T) { }) } } + +func TestCallerAccount(t *testing.T) { + t.Parallel() + + tests := []struct { + meta *awsmeta.Metadata + name string + want string + wantOK bool + }{ + {name: "no metadata", meta: nil}, + {name: "default account", meta: &awsmeta.Metadata{Account: awsmeta.DefaultAccount}}, + { + name: "explicit account", + meta: &awsmeta.Metadata{Account: "111111111111"}, + want: "111111111111", + wantOK: true, + }, + { + name: "principal wins", + meta: &awsmeta.Metadata{ + Account: awsmeta.DefaultAccount, + Principal: &awsmeta.Principal{AccountID: "222222222222"}, + }, + want: "222222222222", wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, ok := awsmeta.CallerAccount(awsmeta.Set(t.Context(), tt.meta)) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantOK, ok) + }) + } +} diff --git a/pkgs/bolt/server.go b/pkgs/bolt/server.go index a457db5db3..27a18122ec 100644 --- a/pkgs/bolt/server.go +++ b/pkgs/bolt/server.go @@ -37,6 +37,7 @@ const ( CodeSyntax = "Neo.ClientError.Statement.SyntaxError" CodeInvalid = "Neo.ClientError.Request.Invalid" CodeUnauthorized = "Neo.ClientError.Security.Unauthorized" + CodeForbidden = "Neo.ClientError.Security.Forbidden" CodeConstraint = "Neo.ClientError.Schema.ConstraintValidationFailed" CodeTransient = "Neo.TransientError.Transaction.Terminated" CodeDatabaseError = "Neo.DatabaseError.General.UnknownError" diff --git a/pkgs/chaos/effect.go b/pkgs/chaos/effect.go new file mode 100644 index 0000000000..266c29c2ae --- /dev/null +++ b/pkgs/chaos/effect.go @@ -0,0 +1,73 @@ +package chaos + +import ( + "errors" + "fmt" + "maps" +) + +// EffectDynamoDBTransactionConflict makes DynamoDB treat the targeted items as +// part of an in-flight TransactWriteItems, so the documented transaction +// conflict errors fire. +const EffectDynamoDBTransactionConflict = "dynamodb:transaction-conflict" + +// ErrInvalidFaultRule is returned for a rule that cannot be applied. +var ErrInvalidFaultRule = errors.New("invalid fault rule") + +// EffectTarget narrows an effect rule to a table and, optionally, one item. +// Key maps key attribute names to their scalar (S/N/B) string values. +type EffectTarget struct { + Key map[string]string `json:"key,omitempty"` + Table string `json:"table,omitempty"` +} + +func effectTargetsEqual(a, b *EffectTarget) bool { + if a == nil || b == nil { + return a == b + } + + return a.Table == b.Table && maps.Equal(a.Key, b.Key) +} + +// Validate reports whether the rule is well formed. +func (r FaultRule) Validate() error { + if r.Effect == "" { + if r.Target != nil || r.HoldOps != 0 || r.HoldMs != 0 { + return fmt.Errorf("%w: target/holdOps/holdMs require an effect", ErrInvalidFaultRule) + } + + return nil + } + + if r.Effect != EffectDynamoDBTransactionConflict { + return fmt.Errorf("%w: unknown effect %q", ErrInvalidFaultRule, r.Effect) + } + + if r.Service != "" && r.Service != "dynamodb" { + return fmt.Errorf("%w: effect %q applies to service dynamodb", ErrInvalidFaultRule, r.Effect) + } + + if r.Error != nil { + return fmt.Errorf("%w: an effect rule cannot carry an error", ErrInvalidFaultRule) + } + + if r.HoldOps < 0 || r.HoldMs < 0 { + return fmt.Errorf("%w: holdOps and holdMs must not be negative", ErrInvalidFaultRule) + } + + if r.Target != nil && len(r.Target.Key) > 0 && r.Target.Table == "" { + return fmt.Errorf("%w: a key target requires a table", ErrInvalidFaultRule) + } + + return nil +} + +func validateRules(rules []FaultRule) error { + for _, r := range rules { + if err := r.Validate(); err != nil { + return err + } + } + + return nil +} diff --git a/pkgs/chaos/effect_test.go b/pkgs/chaos/effect_test.go new file mode 100644 index 0000000000..92daffcbd0 --- /dev/null +++ b/pkgs/chaos/effect_test.go @@ -0,0 +1,122 @@ +package chaos_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" +) + +func TestFaultRule_Validate(t *testing.T) { + t.Parallel() + + one := 1.0 + tests := []struct { + name string + rule chaos.FaultRule + wantErr bool + }{ + {name: "plain rule", rule: chaos.FaultRule{Service: "s3"}}, + {name: "dynamodb conflict effect", rule: chaos.FaultRule{ + Effect: chaos.EffectDynamoDBTransactionConflict, Service: "dynamodb", Probability: &one, HoldOps: 2, + Target: &chaos.EffectTarget{Table: "t", Key: map[string]string{"pk": "a"}}, + }}, + {name: "unknown effect", rule: chaos.FaultRule{Effect: "dynamodb:nope"}, wantErr: true}, + {name: "target without effect", rule: chaos.FaultRule{Target: &chaos.EffectTarget{Table: "t"}}, wantErr: true}, + {name: "hold without effect", rule: chaos.FaultRule{HoldMs: 5}, wantErr: true}, + { + name: "wrong service", + rule: chaos.FaultRule{Effect: chaos.EffectDynamoDBTransactionConflict, Service: "s3"}, + wantErr: true, + }, + {name: "effect with error", rule: chaos.FaultRule{ + Effect: chaos.EffectDynamoDBTransactionConflict, Error: &chaos.FaultError{Code: "X", StatusCode: 500}, + }, wantErr: true}, + { + name: "negative hold", + rule: chaos.FaultRule{Effect: chaos.EffectDynamoDBTransactionConflict, HoldOps: -1}, + wantErr: true, + }, + {name: "key without table", rule: chaos.FaultRule{ + Effect: chaos.EffectDynamoDBTransactionConflict, + Target: &chaos.EffectTarget{Key: map[string]string{"pk": "a"}}, + }, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.rule.Validate() + if tt.wantErr { + require.ErrorIs(t, err, chaos.ErrInvalidFaultRule) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestFaultStore_EffectRulesAreNotHTTPFaults(t *testing.T) { + t.Parallel() + + effect := chaos.FaultRule{ + Effect: chaos.EffectDynamoDBTransactionConflict, + Service: "dynamodb", + Target: &chaos.EffectTarget{Table: "t"}, + } + plain := chaos.FaultRule{Service: "dynamodb"} + + store := chaos.NewFaultStore() + store.SetRules([]chaos.FaultRule{effect}) + + _, matched := store.Match("dynamodb", "PutItem", "us-east-1") + assert.False(t, matched, "Match must skip effect rules") + assert.Len(t, store.MatchEffects(chaos.EffectDynamoDBTransactionConflict, "dynamodb", "PutItem", "us-east-1"), 1) + assert.Empty(t, store.MatchEffects(chaos.EffectDynamoDBTransactionConflict, "s3", "PutItem", "us-east-1")) + + store.SetRules([]chaos.FaultRule{effect, plain}) + rule, matched := store.Match("dynamodb", "PutItem", "us-east-1") + assert.True(t, matched) + assert.Empty(t, rule.Effect) + + store.DeleteRules([]chaos.FaultRule{plain}) + assert.Equal(t, []chaos.FaultRule{effect}, store.GetRules(), "deleting a plain rule leaves the effect rule") + + store.DeleteExactRules([]chaos.FaultRule{{Effect: chaos.EffectDynamoDBTransactionConflict, Service: "dynamodb"}}) + assert.Len(t, store.GetRules(), 1, "exact delete compares the target too") + + store.DeleteExactRules([]chaos.FaultRule{effect}) + assert.Empty(t, store.GetRules()) +} + +func TestMiddleware_PassesThroughEffectRules(t *testing.T) { + t.Parallel() + + store := chaos.NewFaultStore() + store.SetRules([]chaos.FaultRule{{Effect: chaos.EffectDynamoDBTransactionConflict}}) + + called := false + wrapped := chaos.Middleware(store)(func(c *echo.Context) error { + called = true + + return c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil) + req.Header.Set("Authorization", buildSigV4Auth("dynamodb", "us-east-1")) + req.Header.Set("X-Amz-Target", "DynamoDB_20120810.PutItem") + + rec := httptest.NewRecorder() + require.NoError(t, wrapped(echo.New().NewContext(req, rec))) + + assert.True(t, called) + assert.Equal(t, http.StatusOK, rec.Code) +} diff --git a/pkgs/chaos/handler.go b/pkgs/chaos/handler.go index 22857f86b0..2f15e1ada6 100644 --- a/pkgs/chaos/handler.go +++ b/pkgs/chaos/handler.go @@ -87,6 +87,10 @@ func (h *apiHandler) postFaults(c *echo.Context) error { return c.String(http.StatusBadRequest, "invalid JSON: "+err.Error()) } + if err := validateRules(rules); err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + h.store.SetRules(rules) return c.JSON(http.StatusOK, rules) @@ -99,6 +103,10 @@ func (h *apiHandler) patchFaults(c *echo.Context) error { return c.String(http.StatusBadRequest, "invalid JSON: "+err.Error()) } + if err := validateRules(rules); err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + h.store.AppendRules(rules) return c.JSON(http.StatusOK, h.store.GetRules()) diff --git a/pkgs/chaos/middleware_test.go b/pkgs/chaos/middleware_test.go index b95d7b3508..1538966467 100644 --- a/pkgs/chaos/middleware_test.go +++ b/pkgs/chaos/middleware_test.go @@ -312,10 +312,10 @@ func TestMiddleware_ScopeParsing(t *testing.T) { t.Parallel() tests := []struct { - rule chaos.FaultRule name string auth string region string + rule chaos.FaultRule wantFault bool }{ { diff --git a/pkgs/chaos/protocol_sdk_test.go b/pkgs/chaos/protocol_sdk_test.go index e91d872e17..938be0996e 100644 --- a/pkgs/chaos/protocol_sdk_test.go +++ b/pkgs/chaos/protocol_sdk_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) const injectedCode = "ChaosInjectedError" @@ -212,7 +213,7 @@ func TestMiddleware_ProbabilitySemantics(t *testing.T) { req.Header.Set("Authorization", buildSigV4Auth("dynamodb", "us-east-1")) req.Header.Set("X-Amz-Target", "DynamoDB_20120810.ListTables") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) } diff --git a/pkgs/chaos/store.go b/pkgs/chaos/store.go index c1be2d5e5e..87e3d447a0 100644 --- a/pkgs/chaos/store.go +++ b/pkgs/chaos/store.go @@ -42,12 +42,19 @@ var defaultFaultError = FaultError{ // FaultRule defines a single fault injection rule. All fields are optional; // omitting a field means "match any". Rules are evaluated sequentially; // the first match wins. +// +// A rule with Effect set is not an HTTP error fault: the middleware ignores it +// and the owning service consumes it through [FaultStore.MatchEffects]. type FaultRule struct { - Error *FaultError `json:"error,omitempty"` - Probability *float64 `json:"probability,omitempty"` - Service string `json:"service,omitempty"` - Region string `json:"region,omitempty"` - Operation string `json:"operation,omitempty"` + Error *FaultError `json:"error,omitempty"` + Probability *float64 `json:"probability,omitempty"` + Target *EffectTarget `json:"target,omitempty"` + Service string `json:"service,omitempty"` + Region string `json:"region,omitempty"` + Operation string `json:"operation,omitempty"` + Effect string `json:"effect,omitempty"` + HoldOps int `json:"holdOps,omitempty"` + HoldMs int `json:"holdMs,omitempty"` } // ShouldTrigger reports whether this fault should fire based on its probability. @@ -239,6 +246,8 @@ func (s *FaultStore) DeleteExactRules(rules []FaultRule) { func faultRulesEqual(a, b FaultRule) bool { if a.Service != b.Service || a.Region != b.Region || a.Operation != b.Operation || + a.Effect != b.Effect || a.HoldOps != b.HoldOps || a.HoldMs != b.HoldMs || + !effectTargetsEqual(a.Target, b.Target) || (a.Probability == nil) != (b.Probability == nil) { return false } @@ -258,7 +267,9 @@ func rulesContainMatch(candidates []FaultRule, target FaultRule) bool { for _, c := range candidates { if c.Service == target.Service && c.Region == target.Region && - c.Operation == target.Operation { + c.Operation == target.Operation && + c.Effect == target.Effect && + effectTargetsEqual(c.Target, target.Target) { return true } } @@ -274,7 +285,7 @@ func (s *FaultStore) Match(svc, op, region string) (FaultRule, bool) { defer s.mu.RUnlock() for _, r := range s.rules { - if ruleMatches(r, svc, op, region) { + if r.Effect == "" && ruleMatches(r, svc, op, region) { return r, true } } @@ -282,6 +293,23 @@ func (s *FaultStore) Match(svc, op, region string) (FaultRule, bool) { return FaultRule{}, false } +// MatchEffects returns, in order, every rule carrying the given effect that +// matches the service, operation and region. +func (s *FaultStore) MatchEffects(effect, svc, op, region string) []FaultRule { + s.mu.RLock() + defer s.mu.RUnlock() + + var out []FaultRule + + for _, r := range s.rules { + if r.Effect == effect && ruleMatches(r, svc, op, region) { + out = append(out, r) + } + } + + return out +} + // GetEffects returns a copy of the current network effects. func (s *FaultStore) GetEffects() NetworkEffects { s.mu.RLock() diff --git a/pkgs/chaos/store_test.go b/pkgs/chaos/store_test.go index 4a3dbc8eb8..3afddef891 100644 --- a/pkgs/chaos/store_test.go +++ b/pkgs/chaos/store_test.go @@ -305,8 +305,8 @@ func TestFaultRule_ShouldTrigger(t *testing.T) { t.Parallel() tests := []struct { - rule chaos.FaultRule name string + rule chaos.FaultRule wantTrigger bool }{ { @@ -345,9 +345,9 @@ func TestFaultRule_EffectiveError(t *testing.T) { t.Parallel() tests := []struct { - rule chaos.FaultRule name string wantCode string + rule chaos.FaultRule wantStatusCode int }{ { diff --git a/pkgs/compress/handler_test.go b/pkgs/compress/handler_test.go index a2340689a1..455f068e68 100644 --- a/pkgs/compress/handler_test.go +++ b/pkgs/compress/handler_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/compress" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func bigJSON(n int) []byte { @@ -441,7 +442,7 @@ func TestStreamingFlush(t *testing.T) { require.NoError(t, err) req.Header.Set("Accept-Encoding", "gzip") - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := testclient.New(t).Transport.RoundTrip(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/pkgs/container/await_wait_test.go b/pkgs/container/await_wait_test.go new file mode 100644 index 0000000000..0e9ecc1419 --- /dev/null +++ b/pkgs/container/await_wait_test.go @@ -0,0 +1,92 @@ +package container_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dockercontainer "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +var errPost = errors.New("post failed") + +// mobyWait mimics moby's ContainerWait: unbuffered result, buffered error, +// and a sender goroutine that never selects on ctx. The sender runs only +// after release is closed. +func mobyWait(mode string, release <-chan struct{}) (dockercontainer.WaitResult, <-chan struct{}) { + resultC := make(chan dockercontainer.WaitResponse) + errC := make(chan error, 1) + done := make(chan struct{}) + + switch mode { + case "post_error": + errC <- errPost + close(done) + case "error_first": + errC <- errPost + close(done) + default: + go func() { + defer close(done) + <-release + resultC <- dockercontainer.WaitResponse{StatusCode: 7} + }() + } + + return dockercontainer.WaitResult{Result: resultC, Error: errC}, done +} + +func TestAwaitWait(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + name string + mode string + wantCode int64 + cancel bool + wantCtxEr bool + }{ + {name: "ctx_cancelled_before_result", mode: "result", cancel: true, wantCtxEr: true}, + {name: "result_first", mode: "result", wantCode: 7}, + {name: "error_first", mode: "error_first", wantErr: errPost}, + {name: "post_error", mode: "post_error", wantErr: errPost}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + release := make(chan struct{}) + res, done := mobyWait(tt.mode, release) + + if tt.cancel { + cancel() + } else if tt.mode == "result" { + close(release) + } + + resp, err := container.AwaitWait(ctx, res) + + switch { + case tt.wantCtxEr: + require.ErrorIs(t, err, context.Canceled) + close(release) + case tt.wantErr != nil: + require.ErrorIs(t, err, tt.wantErr) + default: + require.NoError(t, err) + assert.Equal(t, tt.wantCode, resp.StatusCode) + } + + <-done // sender goroutine exited: nothing leaked + }) + } +} diff --git a/pkgs/container/container.go b/pkgs/container/container.go index 85b4cdbc15..eef1410951 100644 --- a/pkgs/container/container.go +++ b/pkgs/container/container.go @@ -12,6 +12,7 @@ import ( "errors" "io" "log/slog" + "maps" "time" ) @@ -54,28 +55,27 @@ type Config struct { // Spec holds the specification for creating a container. type Spec struct { - Image string - Name string - Network string - // WorkingDir overrides the image's working directory; empty keeps it. - WorkingDir string - // User runs the container process as uid[:gid]; empty keeps the image's user. - User string - // GroupAdd lists supplementary group IDs for the container process. - GroupAdd []string - // OCIRuntime selects the daemon-registered OCI runtime (e.g. runsc); empty uses the daemon default. - OCIRuntime string - Env []string - Mounts []string - Cmd []string - Entrypoint []string - Ports []string - Tmpfs []string - Privileged bool - // MemoryBytes caps container memory; zero is unlimited. + Labels map[string]string + Image string + Name string + Network string + WorkingDir string + User string + Platform string + OCIRuntime string + Cmd []string + Mounts []string + Env []string + Entrypoint []string + Ports []string + Tmpfs []string + ExtraHosts []string + DNS []string + GroupAdd []string MemoryBytes int64 - // NanoCPUs caps CPU in billionths of a CPU; zero is unlimited. - NanoCPUs int64 + NanoCPUs int64 + Privileged bool + Unmanaged bool } // ExecResult is the outcome of a command run inside a container. @@ -87,6 +87,31 @@ type ExecResult struct { ExitCode int } +// Execer runs a command in a container. +type Execer interface { + Exec(ctx context.Context, containerID string, cmd []string) (ExecResult, error) +} + +// EnvExecer is implemented by runtimes that can set environment variables on an exec process. +type EnvExecer interface { + ExecEnv(ctx context.Context, containerID string, cmd, env []string) (ExecResult, error) +} + +// ExecWithEnv runs cmd with env (KEY=VALUE entries) set on the process, keeping secrets out of argv. +// It never falls back to argv: a runtime without EnvExecer yields ErrExecUnsupported. +func ExecWithEnv(ctx context.Context, x Execer, containerID string, cmd, env []string) (ExecResult, error) { + if len(env) == 0 { + return x.Exec(ctx, containerID, cmd) + } + + ex, ok := x.(EnvExecer) + if !ok { + return ExecResult{}, ErrExecUnsupported + } + + return ex.ExecEnv(ctx, containerID, cmd, env) +} + // StreamExecer is implemented by runtimes that can stream a command's output while it runs. type StreamExecer interface { // ExecStream runs cmd, writing combined stdout and stderr to out as produced; it returns the exit status. @@ -106,6 +131,20 @@ type NetworkRuntime interface { RemoveNetwork(ctx context.Context, networkID string) error } +// ErrVolumeUnsupported is returned when the underlying client cannot manage volumes. +var ErrVolumeUnsupported = errors.New("container volumes are not supported by this runtime client") + +// VolumeRuntime is implemented by runtimes that can manage named volumes. +type VolumeRuntime interface { + // CreateVolume creates a labelled named volume; an existing one is reused. + CreateVolume(ctx context.Context, name string, labels map[string]string) error + // RemoveVolume deletes the volume; a missing one is not an error. + RemoveVolume(ctx context.Context, name string) error + // ListUnusedVolumes lists the volumes carrying label (key or key=value) that no container references and that + // are older than olderThan. + ListUnusedVolumes(ctx context.Context, label string, olderThan time.Duration) ([]string, error) +} + // PooledContainer tracks a container managed by the warm pool. type PooledContainer struct { // LastUsed is the timestamp of the last time this container was used. @@ -132,6 +171,10 @@ type Runtime interface { CreateAndStart(ctx context.Context, spec Spec) (string, error) // StopAndRemove stops and removes a container. StopAndRemove(ctx context.Context, containerID string) error + // StopContainer stops a container without removing it, keeping its filesystem and published ports. + StopContainer(ctx context.Context, containerID string) error + // StartContainer starts a container previously stopped by StopContainer. + StartContainer(ctx context.Context, containerID string) error // Exec runs cmd inside a running container and waits for it to finish. // A non-zero exit status is reported in the result, not as an error. Exec(ctx context.Context, containerID string, cmd []string) (ExecResult, error) @@ -146,3 +189,15 @@ type Runtime interface { // Close releases resources held by the runtime client. Close() error } + +func (s Spec) launchLabels() map[string]string { + if s.Unmanaged { + return s.Labels + } + + out := CurrentIdentity().Labels() + maps.Copy(out, s.Labels) + out[LabelManaged] = labelTrue + + return out +} diff --git a/pkgs/container/docker_runtime.go b/pkgs/container/docker_runtime.go index 87f225b000..9a9e661144 100644 --- a/pkgs/container/docker_runtime.go +++ b/pkgs/container/docker_runtime.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "io" + "net/netip" + "slices" + "strings" "time" dockercontainer "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" @@ -12,6 +15,8 @@ import ( "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/network" "github.com/blackbirdworks/gopherstack/internal/dockercompat/client" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/blackbirdworks/gopherstack/pkgs/docker" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/registryauth" @@ -63,6 +68,15 @@ type ExecAPIClient interface { ) (stdout, stderr []byte, exitCode int, err error) } +// ExecEnvAPIClient is implemented by clients that can run a command with extra environment variables. +type ExecEnvAPIClient interface { + ContainerExecEnv( + ctx context.Context, + containerID string, + cmd, env []string, + ) (stdout, stderr []byte, exitCode int, err error) +} + // ExecStreamAPIClient is implemented by clients that can stream a command's output while it runs. type ExecStreamAPIClient interface { ContainerExecStream( @@ -80,6 +94,11 @@ type NetworkAPIClient interface { NetworkRemove(ctx context.Context, networkID string) error } +// ListAPIClient is implemented by clients that can list containers by label. +type ListAPIClient interface { + ContainerListByLabel(ctx context.Context, label string) ([]ManagedContainer, error) +} + // realDockerClient wraps the standard Docker SDK client to satisfy APIClient. type realDockerClient struct { c *client.Client @@ -110,10 +129,30 @@ func (r *realDockerClient) ContainerCreate( cfg *dockercontainer.Config, hostConfig *dockercontainer.HostConfig, _ any, - _ any, + platform any, containerName string, ) (dockercontainer.CreateResponse, error) { - return r.c.ContainerCreate(ctx, cfg, hostConfig, nil, nil, containerName) + p, _ := platform.(*ocispec.Platform) + + return r.c.ContainerCreate(ctx, cfg, hostConfig, nil, p, containerName) +} + +// platformOSArch is the number of mandatory "os/arch" platform segments. +const platformOSArch = 2 + +// parsePlatform turns "os/arch[/variant]" into an OCI platform; nil when empty or malformed. +func parsePlatform(s string) *ocispec.Platform { + parts := strings.Split(s, "/") + if len(parts) < platformOSArch || parts[0] == "" || parts[1] == "" { + return nil + } + + p := &ocispec.Platform{OS: parts[0], Architecture: parts[1]} + if len(parts) > platformOSArch { + p.Variant = parts[2] + } + + return p } func (r *realDockerClient) ContainerStart( @@ -148,6 +187,14 @@ func (r *realDockerClient) ContainerExec( return r.c.ContainerExec(ctx, containerID, cmd) } +func (r *realDockerClient) ContainerExecEnv( + ctx context.Context, + containerID string, + cmd, env []string, +) ([]byte, []byte, int, error) { + return r.c.ContainerExecEnv(ctx, containerID, cmd, env) +} + func (r *realDockerClient) ContainerExecStream( ctx context.Context, containerID string, @@ -157,6 +204,37 @@ func (r *realDockerClient) ContainerExecStream( return r.c.ContainerExecStream(ctx, containerID, cmd, stdout, stderr) } +func (r *realDockerClient) ContainerListByLabel(ctx context.Context, label string) ([]ManagedContainer, error) { + items, err := r.c.ContainerList(ctx, dockercontainer.ListOptions{ + All: true, + Filters: filters.NewArgs(filters.KeyValuePair{Key: "label", Value: label}), + }) + if err != nil { + return nil, err + } + + out := make([]ManagedContainer, 0, len(items)) + + for _, it := range items { + mc := ManagedContainer{ + ID: it.ID, Labels: it.Labels, Running: string(it.State) == "running", Ports: map[int]int{}, + } + if len(it.Names) > 0 { + mc.Name = strings.TrimPrefix(it.Names[0], "/") + } + + for _, p := range it.Ports { + if p.PublicPort != 0 { + mc.Ports[int(p.PrivatePort)] = int(p.PublicPort) + } + } + + out = append(out, mc) + } + + return out, nil +} + func (r *realDockerClient) NetworkCreate(ctx context.Context, name string) (string, error) { return r.c.NetworkCreate(ctx, name) } @@ -169,6 +247,18 @@ func (r *realDockerClient) NetworkRemove(ctx context.Context, networkID string) return r.c.NetworkRemove(ctx, networkID) } +func (r *realDockerClient) VolumeCreate(ctx context.Context, name string, labels map[string]string) error { + return r.c.VolumeCreate(ctx, name, labels) +} + +func (r *realDockerClient) VolumeRemove(ctx context.Context, name string) error { + return r.c.VolumeRemove(ctx, name) +} + +func (r *realDockerClient) VolumeListUnused(ctx context.Context, label string) (map[string]time.Time, error) { + return r.c.VolumeListUnused(ctx, label) +} + func (r *realDockerClient) Ping(ctx context.Context) (any, error) { return r.c.Ping(ctx) } @@ -323,6 +413,7 @@ func (r *DockerRuntime) CreateAndStart(ctx context.Context, spec Spec) (string, cfg.Entrypoint = spec.Entrypoint } + cfg.Labels = spec.launchLabels() cfg.WorkingDir = spec.WorkingDir cfg.User = spec.User @@ -334,6 +425,13 @@ func (r *DockerRuntime) CreateAndStart(ctx context.Context, spec Spec) (string, } hostCfg.Memory = spec.MemoryBytes hostCfg.NanoCPUs = spec.NanoCPUs + hostCfg.ExtraHosts = spec.ExtraHosts + + for _, d := range spec.DNS { + if addr, perr := netip.ParseAddr(d); perr == nil { + hostCfg.DNS = append(hostCfg.DNS, addr) + } + } if spec.Network != "" { hostCfg.NetworkMode = dockercontainer.NetworkMode(spec.Network) @@ -361,7 +459,7 @@ func (r *DockerRuntime) CreateAndStart(ctx context.Context, spec Spec) (string, } } - resp, err := r.docker.ContainerCreate(ctx, cfg, hostCfg, nil, nil, spec.Name) + resp, err := r.docker.ContainerCreate(ctx, cfg, hostCfg, nil, parsePlatform(spec.Platform), spec.Name) if err != nil { return "", fmt.Errorf("container create %q: %w", spec.Image, err) } @@ -475,6 +573,64 @@ func (r *DockerRuntime) RemoveNetwork(ctx context.Context, networkID string) err return api.NetworkRemove(ctx, networkID) } +// VolumeAPIClient is implemented by clients that can manage named volumes. +type VolumeAPIClient interface { + VolumeCreate(ctx context.Context, name string, labels map[string]string) error + VolumeRemove(ctx context.Context, name string) error + VolumeListUnused(ctx context.Context, label string) (map[string]time.Time, error) +} + +// CreateVolume creates a labelled named volume; an existing one is reused. +func (r *DockerRuntime) CreateVolume(ctx context.Context, name string, labels map[string]string) error { + api, ok := r.docker.(VolumeAPIClient) + if !ok { + return ErrVolumeUnsupported + } + + return api.VolumeCreate(ctx, name, labels) +} + +// RemoveVolume force-removes a named volume; a missing volume is not an error. +func (r *DockerRuntime) RemoveVolume(ctx context.Context, name string) error { + api, ok := r.docker.(VolumeAPIClient) + if !ok { + return ErrVolumeUnsupported + } + + return api.VolumeRemove(ctx, name) +} + +// ListUnusedVolumes lists the volumes carrying label that no container references and that are older than +// olderThan, so a volume a concurrent launch has just created is left alone. +func (r *DockerRuntime) ListUnusedVolumes( + ctx context.Context, + label string, + olderThan time.Duration, +) ([]string, error) { + api, ok := r.docker.(VolumeAPIClient) + if !ok { + return nil, ErrVolumeUnsupported + } + + all, err := api.VolumeListUnused(ctx, label) + if err != nil { + return nil, err + } + + cutoff := time.Now().Add(-olderThan) + out := make([]string, 0, len(all)) + + for name, created := range all { + if created.Before(cutoff) { + out = append(out, name) + } + } + + slices.Sort(out) + + return out, nil +} + // Exec runs cmd inside a running container and waits for it to finish. func (r *DockerRuntime) Exec(ctx context.Context, containerID string, cmd []string) (ExecResult, error) { api, ok := r.docker.(ExecAPIClient) @@ -490,6 +646,21 @@ func (r *DockerRuntime) Exec(ctx context.Context, containerID string, cmd []stri return ExecResult{Stdout: string(stdout), Stderr: string(stderr), ExitCode: code}, nil } +// ExecEnv runs cmd like Exec with env (KEY=VALUE entries) set on the process instead of in its argv. +func (r *DockerRuntime) ExecEnv(ctx context.Context, containerID string, cmd, env []string) (ExecResult, error) { + api, ok := r.docker.(ExecEnvAPIClient) + if !ok { + return ExecResult{}, ErrExecUnsupported + } + + stdout, stderr, code, err := api.ContainerExecEnv(ctx, containerID, cmd, env) + if err != nil { + return ExecResult{}, fmt.Errorf("container exec %q: %w", containerID, err) + } + + return ExecResult{Stdout: string(stdout), Stderr: string(stderr), ExitCode: code}, nil +} + // ExecStream runs cmd like Exec but writes stdout and stderr to out as the command produces them. func (r *DockerRuntime) ExecStream(ctx context.Context, containerID string, cmd []string, out io.Writer) (int, error) { api, ok := r.docker.(ExecStreamAPIClient) diff --git a/pkgs/container/exec_test.go b/pkgs/container/exec_test.go index a3c961da9c..72c43211e0 100644 --- a/pkgs/container/exec_test.go +++ b/pkgs/container/exec_test.go @@ -70,3 +70,49 @@ func TestDockerRuntime_Exec(t *testing.T) { }) } } + +type execEnvAPI struct { + gotEnv []string + execAPI +} + +func (e *execEnvAPI) ContainerExecEnv( + _ context.Context, id string, cmd, env []string, +) ([]byte, []byte, int, error) { + e.gotID, e.gotCmd, e.gotEnv = id, cmd, env + + return []byte("out"), nil, 0, nil +} + +type plainExecer struct{} + +func (plainExecer) Exec(context.Context, string, []string) (container.ExecResult, error) { + return container.ExecResult{Stdout: "plain"}, nil +} + +func TestDockerRuntime_ExecEnv(t *testing.T) { + t.Parallel() + + api := &execEnvAPI{} + rt := newRuntime(api) + + got, err := rt.ExecEnv(t.Context(), "ctr-1", []string{"mysql"}, []string{"MYSQL_PWD=pw"}) + require.NoError(t, err) + assert.Equal(t, "out", got.Stdout) + assert.Equal(t, []string{"mysql"}, api.gotCmd) + assert.Equal(t, []string{"MYSQL_PWD=pw"}, api.gotEnv) + + _, err = newRuntime(&mockAPI{}).ExecEnv(t.Context(), "ctr-1", nil, []string{"A=b"}) + require.ErrorIs(t, err, container.ErrExecUnsupported) +} + +func TestExecWithEnv(t *testing.T) { + t.Parallel() + + got, err := container.ExecWithEnv(t.Context(), plainExecer{}, "c", []string{"x"}, nil) + require.NoError(t, err) + assert.Equal(t, "plain", got.Stdout) + + _, err = container.ExecWithEnv(t.Context(), plainExecer{}, "c", []string{"x"}, []string{"A=b"}) + require.ErrorIs(t, err, container.ErrExecUnsupported) +} diff --git a/pkgs/container/identity.go b/pkgs/container/identity.go new file mode 100644 index 0000000000..96225d2a77 --- /dev/null +++ b/pkgs/container/identity.go @@ -0,0 +1,132 @@ +package container + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "maps" + "os" + "path/filepath" + "strconv" + "sync/atomic" +) + +// Labels stamped on every container gopherstack starts. +const labelTrue = "true" + +const ( + // LabelManaged marks a container as started by gopherstack. + LabelManaged = "gopherstack.managed" + // LabelService names the service that owns the container. + LabelService = "gopherstack.service" + // LabelOwner is the instance identity: stable per data dir when persistence is on, fresh per run otherwise. + LabelOwner = "gopherstack.owner" + // LabelResource is the owning service's key for the resource; only labelled containers can be adopted. + LabelResource = "gopherstack.resource" + // LabelPID is "pid:starttime" of the launching process. + LabelPID = "gopherstack.pid" + // LabelEphemeral is "true" when the owner id is per-run (persistence off). + LabelEphemeral = "gopherstack.ephemeral" +) + +// Identity is the per-process instance identity stamped onto launched containers. +type Identity struct { + Owner string + Start string + PID int + Ephemeral bool +} + +var current atomic.Pointer[Identity] //nolint:gochecknoglobals // process-wide identity + +// SetPersistentOwner derives a stable owner id from the persistence data dir. +func SetPersistentOwner(dataDir string) Identity { + if abs, err := filepath.Abs(dataDir); err == nil { + dataDir = abs + } + + sum := sha256.Sum256([]byte(dataDir)) + id := newIdentity("dir-"+hex.EncodeToString(sum[:6]), false) + current.Store(&id) + + return id +} + +// SetEphemeralOwner gives the process a fresh owner id, so no earlier run's containers are ever adopted. +func SetEphemeralOwner() Identity { + id := newIdentity("run-"+randomHex(), true) + current.Store(&id) + + return id +} + +// CurrentIdentity returns the process identity, creating an ephemeral one on first use. +func CurrentIdentity() Identity { + if id := current.Load(); id != nil { + return *id + } + + id := newIdentity("run-"+randomHex(), true) + if current.CompareAndSwap(nil, &id) { + return id + } + + return *current.Load() +} + +func newIdentity(owner string, ephemeral bool) Identity { + pid := os.Getpid() + start, _ := processStart(pid) + + return Identity{Owner: owner, PID: pid, Start: start, Ephemeral: ephemeral} +} + +const randomBytes = 8 + +func randomHex() string { + b := make([]byte, randomBytes) + _, _ = rand.Read(b) + + return hex.EncodeToString(b) +} + +// Labels are the identity labels stamped on every managed container. +func (i Identity) Labels() map[string]string { + l := map[string]string{ + LabelManaged: labelTrue, + LabelOwner: i.Owner, + LabelPID: strconv.Itoa(i.PID) + ":" + i.Start, + } + if i.Ephemeral { + l[LabelEphemeral] = labelTrue + } + + return l +} + +// ResourceLabels labels a container so a restarted gopherstack can match it to resource (the service's own key). +func ResourceLabels(service, resource string) map[string]string { + return map[string]string{LabelService: service, LabelResource: resource} +} + +// ServiceLabels names the owning service without a resource key: the container is never adopted, and a restarted +// gopherstack removes it at startup before the service relaunches. +func ServiceLabels(service string) map[string]string { + return map[string]string{LabelService: service} +} + +// ManagedLabels are the labels for a container started outside DockerRuntime: this process's identity plus the +// service labels, with the resource key when resource is non-empty (only then can a restart adopt it). +func ManagedLabels(service, resource string) map[string]string { + l := CurrentIdentity().Labels() + + if resource == "" { + maps.Copy(l, ServiceLabels(service)) + + return l + } + + maps.Copy(l, ResourceLabels(service, resource)) + + return l +} diff --git a/pkgs/container/managed_labels_test.go b/pkgs/container/managed_labels_test.go new file mode 100644 index 0000000000..9d1621a696 --- /dev/null +++ b/pkgs/container/managed_labels_test.go @@ -0,0 +1,40 @@ +package container_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +func TestManagedLabels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource string + wantRes string + }{ + {name: "with resource", resource: "i-1", wantRes: "i-1"}, + {name: "service only", resource: "", wantRes: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := container.ManagedLabels("ec2", tt.resource) + id := container.CurrentIdentity() + + assert.Equal(t, "true", got[container.LabelManaged]) + assert.Equal(t, id.Owner, got[container.LabelOwner]) + assert.Equal(t, "ec2", got[container.LabelService]) + assert.Equal(t, tt.wantRes, got[container.LabelResource]) + + if tt.resource == "" { + assert.NotContains(t, got, container.LabelResource) + } + }) + } +} diff --git a/pkgs/container/orphans.go b/pkgs/container/orphans.go new file mode 100644 index 0000000000..9a8ea9dcd7 --- /dev/null +++ b/pkgs/container/orphans.go @@ -0,0 +1,170 @@ +package container + +import ( + "context" + "errors" + "log/slog" + "strconv" + "sync/atomic" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +// ErrListUnsupported is returned when the runtime client cannot list containers. +var ErrListUnsupported = errors.New("container listing is not supported by this runtime client") + +// ManagedContainer is a container carrying LabelManaged. +type ManagedContainer struct { + Labels map[string]string + // Ports maps container port to published host port. + Ports map[int]int + ID string + Name string + Running bool +} + +// ManagedLister is implemented by runtimes that can list gopherstack-managed containers. +type ManagedLister interface { + ListManaged(ctx context.Context) ([]ManagedContainer, error) + StopAndRemove(ctx context.Context, containerID string) error +} + +// Orphans holds the previous run's resource-labelled containers until their services adopt them. +type Orphans struct { + rt ManagedLister + log *slog.Logger + mu *lockmetrics.RWMutex + held map[orphanKey][]ManagedContainer +} + +type orphanKey struct{ service, resource string } + +// Reconcile lists managed containers and removes the ones that are provably dead leftovers of this instance: +// - this owner's containers without a LabelResource, and ones whose launcher process is gone and has no +// resource to adopt for are removed now; +// - another owner's containers are removed only when they are ephemeral and their launcher process is dead; +// - this owner's resource-labelled containers (launcher dead or this very process) are held for Claim; +// - everything else, including this owner's containers whose launcher is still alive elsewhere, is untouched. +func Reconcile(ctx context.Context, rt ManagedLister, id Identity, log *slog.Logger) (*Orphans, error) { + all, err := rt.ListManaged(ctx) + if err != nil { + return nil, err + } + + o := &Orphans{rt: rt, log: log, mu: lockmetrics.New("container.orphans"), held: map[orphanKey][]ManagedContainer{}} + + for _, c := range all { + if c.Labels[LabelManaged] != labelTrue { + continue + } + + pidLabel := c.Labels[LabelPID] + ownPID := pidLabel == strconv.Itoa(id.PID)+":"+id.Start + + switch { + case c.Labels[LabelOwner] == id.Owner: + if !ownPID && processAlive(pidLabel) { + continue + } + + if c.Labels[LabelResource] == "" { + o.remove(ctx, c) + + continue + } + + k := orphanKey{c.Labels[LabelService], c.Labels[LabelResource]} + o.held[k] = append(o.held[k], c) + case c.Labels[LabelEphemeral] == labelTrue && !processAlive(pidLabel): + o.remove(ctx, c) + } + } + + return o, nil +} + +func (o *Orphans) remove(ctx context.Context, c ManagedContainer) { + if err := o.rt.StopAndRemove(ctx, c.ID); err != nil && o.log != nil { + o.log.WarnContext(ctx, "container: orphan removal failed", "id", c.ID, "name", c.Name, "error", err) + } +} + +// Claim hands the caller the held container for (service, resource), preferring a running one. The caller owns +// it from then on and must remove it itself if it decides not to adopt it. +func (o *Orphans) Claim(service, resource string) (ManagedContainer, bool) { + if o == nil { + return ManagedContainer{}, false + } + + o.mu.Lock("Claim") + defer o.mu.Unlock() + + k := orphanKey{service, resource} + list := o.held[k] + + pick := -1 + + for i, c := range list { + if c.Running { + pick = i + + break + } + } + + if pick < 0 { + return ManagedContainer{}, false + } + + c := list[pick] + o.held[k] = append(list[:pick:pick], list[pick+1:]...) + + return c, true +} + +// SweepUnclaimed removes every container still held and returns how many were removed. +func (o *Orphans) SweepUnclaimed(ctx context.Context) int { + if o == nil { + return 0 + } + + o.mu.Lock("SweepUnclaimed") + held := o.held + o.held = map[orphanKey][]ManagedContainer{} + o.mu.Unlock() + + n := 0 + + for _, list := range held { + for _, c := range list { + o.remove(ctx, c) + + n++ + } + } + + return n +} + +var defaultOrphans atomic.Pointer[Orphans] //nolint:gochecknoglobals // set once at startup + +// SetOrphans installs the process-wide registry services claim from. +func SetOrphans(o *Orphans) { defaultOrphans.Store(o) } + +// ClaimOrphan claims from the process-wide registry; false when nothing is held. +func ClaimOrphan(service, resource string) (ManagedContainer, bool) { + return defaultOrphans.Load().Claim(service, resource) +} + +// SweepOrphans sweeps the process-wide registry. +func SweepOrphans(ctx context.Context) int { return defaultOrphans.Load().SweepUnclaimed(ctx) } + +// ListManaged lists the containers carrying LabelManaged, running or not. +func (r *DockerRuntime) ListManaged(ctx context.Context) ([]ManagedContainer, error) { + api, ok := r.docker.(ListAPIClient) + if !ok { + return nil, ErrListUnsupported + } + + return api.ContainerListByLabel(ctx, LabelManaged+"=true") +} diff --git a/pkgs/container/orphans_test.go b/pkgs/container/orphans_test.go new file mode 100644 index 0000000000..e8ac09edab --- /dev/null +++ b/pkgs/container/orphans_test.go @@ -0,0 +1,222 @@ +package container_test + +import ( + "context" + "os" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/image" + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +type fakeLister struct { + all []container.ManagedContainer + removed []string +} + +func (f *fakeLister) ListManaged(context.Context) ([]container.ManagedContainer, error) { + return f.all, nil +} + +func (f *fakeLister) StopAndRemove(_ context.Context, id string) error { + f.removed = append(f.removed, id) + + return nil +} + +const deadPID = "999999999:1" + +func mc(running bool, kv ...string) container.ManagedContainer { + l := map[string]string{container.LabelManaged: "true"} + for i := 0; i+1 < len(kv); i += 2 { + l[kv[i]] = kv[i+1] + } + + return container.ManagedContainer{ID: "c1", Running: running, Labels: l} +} + +func TestReconcile(t *testing.T) { + t.Parallel() + + me := container.Identity{Owner: "dir-me", PID: os.Getpid()} + parent := strconv.Itoa(os.Getppid()) + + tests := []struct { + name string + all []container.ManagedContainer + wantRemoved []string + wantClaim string + wantSwept []string + }{ + { + name: "adopts same owner resource", + all: []container.ManagedContainer{mc(true, container.LabelOwner, "dir-me", + container.LabelPID, deadPID, container.LabelService, "gcpsql", container.LabelResource, "p/i")}, + wantClaim: "c1", + }, + { + name: "unclaimed same owner swept", + all: []container.ManagedContainer{mc(true, container.LabelOwner, "dir-me", + container.LabelPID, deadPID, container.LabelService, "gcpsql", container.LabelResource, "p/other")}, + wantSwept: []string{"c1"}, + }, + { + name: "same owner without resource removed", + all: []container.ManagedContainer{ + mc(true, container.LabelOwner, "dir-me", container.LabelPID, deadPID), + }, + wantRemoved: []string{"c1"}, + }, + { + name: "same owner live other process untouched", + all: []container.ManagedContainer{mc(true, container.LabelOwner, "dir-me", + container.LabelPID, parent, container.LabelService, "gcpsql", container.LabelResource, "p/i")}, + }, + { + name: "foreign owner untouched", + all: []container.ManagedContainer{ + mc(true, container.LabelOwner, "dir-other", container.LabelPID, deadPID), + }, + }, + { + name: "unlabelled untouched", + all: []container.ManagedContainer{{ID: "c1", Running: true, Labels: map[string]string{"x": "y"}}}, + }, + { + name: "dead ephemeral removed", + all: []container.ManagedContainer{mc(true, container.LabelOwner, "run-old", + container.LabelEphemeral, "true", container.LabelPID, deadPID)}, + wantRemoved: []string{"c1"}, + }, + { + name: "live ephemeral untouched", + all: []container.ManagedContainer{mc(true, container.LabelOwner, "run-old", + container.LabelEphemeral, "true", container.LabelPID, parent)}, + }, + { + name: "stopped same owner swept not claimed", + all: []container.ManagedContainer{mc(false, container.LabelOwner, "dir-me", + container.LabelPID, deadPID, container.LabelService, "gcpsql", container.LabelResource, "p/i")}, + wantSwept: []string{"c1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := &fakeLister{all: tt.all} + o, err := container.Reconcile(t.Context(), f, me, nil) + require.NoError(t, err) + assert.ElementsMatch(t, tt.wantRemoved, f.removed) + + got, ok := o.Claim("gcpsql", "p/i") + assert.Equal(t, tt.wantClaim != "", ok) + assert.Equal(t, tt.wantClaim, got.ID) + + f.removed = nil + assert.Len(t, tt.wantSwept, o.SweepUnclaimed(t.Context())) + assert.ElementsMatch(t, tt.wantSwept, f.removed) + }) + } +} + +func TestOwnProcessContainersAdopted(t *testing.T) { + t.Parallel() + + id := container.CurrentIdentity() + l := id.Labels() + l[container.LabelService], l[container.LabelResource] = "gcpsql", "p/i" + + f := &fakeLister{all: []container.ManagedContainer{{ID: "c1", Running: true, Labels: l}}} + o, err := container.Reconcile(t.Context(), f, id, nil) + require.NoError(t, err) + + got, ok := o.Claim("gcpsql", "p/i") + assert.True(t, ok) + assert.Equal(t, "c1", got.ID) +} + +func TestSetPersistentOwnerStable(t *testing.T) { + t.Parallel() + + a := container.SetPersistentOwner("/some/dir") + b := container.SetPersistentOwner("/some/dir") + c := container.SetPersistentOwner("/other/dir") + t.Cleanup(func() { container.SetEphemeralOwner() }) + + assert.Equal(t, a.Owner, b.Owner) + assert.NotEqual(t, a.Owner, c.Owner) + assert.False(t, a.Ephemeral) + assert.True(t, container.SetEphemeralOwner().Ephemeral) +} + +type listAPI struct { + *mockAPI + items []container.ManagedContainer +} + +func (l *listAPI) ContainerListByLabel(context.Context, string) ([]container.ManagedContainer, error) { + return l.items, nil +} + +func TestDockerRuntimeListManaged(t *testing.T) { + t.Parallel() + + rt := newRuntime(&listAPI{mockAPI: &mockAPI{}, items: []container.ManagedContainer{{ID: "a"}}}) + got, err := rt.ListManaged(t.Context()) + require.NoError(t, err) + assert.Len(t, got, 1) + + _, err = newRuntime(&mockAPI{}).ListManaged(t.Context()) + require.ErrorIs(t, err, container.ErrListUnsupported) +} + +func TestCreateAndStartLabels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantKeys []string + spec container.Spec + unmanaged bool + }{ + { + name: "identity and resource labels", spec: container.Spec{ + Image: "img", Labels: container.ResourceLabels("gcpsql", "p/i"), + }, + wantKeys: []string{ + container.LabelManaged, container.LabelOwner, container.LabelPID, + container.LabelService, container.LabelResource, + }, + }, + {name: "unmanaged", spec: container.Spec{Image: "img", Unmanaged: true}, unmanaged: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + api := &mockAPI{images: []image.Summary{{}}} + _, err := newRuntime(api).CreateAndStart(t.Context(), tt.spec) + require.NoError(t, err) + + cfg := api.lastCfg + if tt.unmanaged { + assert.NotContains(t, cfg.Labels, container.LabelManaged) + + return + } + + for _, k := range tt.wantKeys { + assert.Contains(t, cfg.Labels, k) + } + + assert.Equal(t, "true", cfg.Labels[container.LabelManaged]) + }) + } +} diff --git a/pkgs/container/proc.go b/pkgs/container/proc.go new file mode 100644 index 0000000000..ca4787acf6 --- /dev/null +++ b/pkgs/container/proc.go @@ -0,0 +1,57 @@ +package container + +import ( + "errors" + "os" + "strconv" + "strings" + "syscall" +) + +const ( + procStartField = 19 + pidLabelParts = 2 +) + +// processStart is the kernel start time of pid ("" where /proc is unavailable). +func processStart(pid int) (string, bool) { + raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return "", false + } + + s := string(raw) + + rest := strings.Fields(s[strings.LastIndexByte(s, ')')+1:]) + if len(rest) <= procStartField { + return "", false + } + + return rest[procStartField], true +} + +// processAlive reports whether the process in a LabelPID value ("pid:start") still runs. Anything unparseable or +// unverifiable counts as alive: a wrong "dead" would remove a live instance's containers. +func processAlive(label string) bool { + parts := strings.SplitN(label, ":", pidLabelParts) + + pid, err := strconv.Atoi(parts[0]) + if err != nil || pid <= 0 { + return true + } + + if start, ok := processStart(pid); ok { + return len(parts) < pidLabelParts || parts[1] == "" || start == parts[1] + } + + if _, statErr := os.Stat("/proc/self"); statErr == nil { + return false + } + + p, err := os.FindProcess(pid) + if err != nil { + return true + } + + return !errors.Is(p.Signal(syscall.Signal(0)), os.ErrProcessDone) +} diff --git a/pkgs/container/stop_start_docker_test.go b/pkgs/container/stop_start_docker_test.go new file mode 100644 index 0000000000..13ccb00411 --- /dev/null +++ b/pkgs/container/stop_start_docker_test.go @@ -0,0 +1,57 @@ +package container_test + +import ( + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +func TestDockerRuntime_StopStartKeepsFilesystem(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + + rt, err := container.NewRuntime(container.Config{}) + if err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + t.Cleanup(func() { _ = rt.Close() }) + + if err = rt.Ping(t.Context()); err != nil { + t.Skipf("container daemon unreachable: %v", err) + } + + const image = "busybox:1.36" + if ok, hasErr := rt.HasImage(t.Context(), image); hasErr != nil || !ok { + t.Skipf("image %s not present locally", image) + } + + id, err := rt.CreateAndStart(t.Context(), container.Spec{Image: image, Cmd: []string{"sleep", "300"}}) + require.NoError(t, err) + + t.Cleanup(func() { _ = exec.Command("docker", "rm", "-fv", id).Run() }) + + res, err := rt.Exec(t.Context(), id, []string{"sh", "-c", "echo kept > /state.txt"}) + require.NoError(t, err) + require.Zero(t, res.ExitCode) + + require.NoError(t, rt.StopContainer(t.Context(), id)) + + out, err := exec.CommandContext(t.Context(), "docker", "inspect", "-f", "{{.State.Running}}", id).Output() + require.NoError(t, err) + assert.Equal(t, "false", strings.TrimSpace(string(out))) + + require.NoError(t, rt.StartContainer(t.Context(), id)) + + res, err = rt.Exec(t.Context(), id, []string{"cat", "/state.txt"}) + require.NoError(t, err) + assert.Equal(t, "kept", strings.TrimSpace(res.Stdout)) +} diff --git a/pkgs/container/volume_runtime_test.go b/pkgs/container/volume_runtime_test.go new file mode 100644 index 0000000000..9da4ad77e3 --- /dev/null +++ b/pkgs/container/volume_runtime_test.go @@ -0,0 +1,114 @@ +package container_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +var errVolumeBoom = errors.New("volume api failed") + +type volumeAPI struct { + err error + *mockAPI + listed map[string]time.Time + created map[string]map[string]string + removed []string +} + +func (v *volumeAPI) VolumeCreate(_ context.Context, name string, labels map[string]string) error { + if v.err != nil { + return v.err + } + + v.created[name] = labels + + return nil +} + +func (v *volumeAPI) VolumeRemove(_ context.Context, name string) error { + v.removed = append(v.removed, name) + + return v.err +} + +func (v *volumeAPI) VolumeListUnused(context.Context, string) (map[string]time.Time, error) { + return v.listed, v.err +} + +func TestDockerRuntime_Volumes(t *testing.T) { + t.Parallel() + + now := time.Now() + + tests := []struct { + wantErr error + api func() container.APIClient + name string + wantList []string + }{ + { + name: "old unused volumes only", + api: func() container.APIClient { + return &volumeAPI{ + mockAPI: &mockAPI{}, created: map[string]map[string]string{}, + listed: map[string]time.Time{ + "old-b": now.Add(-time.Hour), + "old-a": now.Add(-time.Hour), + "new": now, + }, + } + }, + wantList: []string{"old-a", "old-b"}, + }, + { + name: "client without volumes", + api: func() container.APIClient { return &mockAPI{} }, + wantErr: container.ErrVolumeUnsupported, + }, + { + name: "api error", + api: func() container.APIClient { + return &volumeAPI{mockAPI: &mockAPI{}, created: map[string]map[string]string{}, err: errVolumeBoom} + }, + wantErr: errVolumeBoom, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + api := tt.api() + rt := newRuntime(api) + + var vr container.VolumeRuntime = rt + + got, err := vr.ListUnusedVolumes(t.Context(), "l", 10*time.Minute) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + require.ErrorIs(t, vr.CreateVolume(t.Context(), "v", nil), tt.wantErr) + require.ErrorIs(t, vr.RemoveVolume(t.Context(), "v"), tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantList, got) + + require.NoError(t, vr.CreateVolume(t.Context(), "v", map[string]string{"k": "1"})) + require.NoError(t, vr.RemoveVolume(t.Context(), "v")) + + va, ok := api.(*volumeAPI) + require.True(t, ok) + assert.Equal(t, map[string]string{"k": "1"}, va.created["v"]) + assert.Equal(t, []string{"v"}, va.removed) + }) + } +} diff --git a/pkgs/container/wait.go b/pkgs/container/wait.go index 5459d810b2..f7ad739a4a 100644 --- a/pkgs/container/wait.go +++ b/pkgs/container/wait.go @@ -46,16 +46,37 @@ func (r *DockerRuntime) WaitExit(ctx context.Context, containerID string) (int, res := api.ContainerWait(ctx, containerID, dockercontainer.WaitOptions{}) - select { - case <-ctx.Done(): - return 0, fmt.Errorf("container wait %q: %w", containerID, ctx.Err()) - case err := <-res.Error: + resp, err := AwaitWait(ctx, res) + if err != nil { return 0, fmt.Errorf("container wait %q: %w", containerID, err) + } + + if resp.Error != nil { + return int(resp.StatusCode), fmt.Errorf("%w %q: %s", ErrWaitFailed, containerID, resp.Error.Message) + } + + return int(resp.StatusCode), nil +} + +// AwaitWait blocks for the first of res.Result, res.Error or ctx cancellation. +// Moby's resultC is unbuffered and its sender never selects on ctx, so on early +// return a drainer receives from res to let that goroutine exit: it either sends +// the result or, once ctx is cancelled, fails the body read into buffered errC. +func AwaitWait(ctx context.Context, res dockercontainer.WaitResult) (dockercontainer.WaitResponse, error) { + select { case resp := <-res.Result: - if resp.Error != nil { - return int(resp.StatusCode), fmt.Errorf("%w %q: %s", ErrWaitFailed, containerID, resp.Error.Message) - } + return resp, nil + case err := <-res.Error: + return dockercontainer.WaitResponse{}, err + case <-ctx.Done(): + go func() { + select { + case <-res.Result: + case err := <-res.Error: + _ = err + } + }() - return int(resp.StatusCode), nil + return dockercontainer.WaitResponse{}, ctx.Err() } } diff --git a/pkgs/dbrepl/dbrepl.go b/pkgs/dbrepl/dbrepl.go new file mode 100644 index 0000000000..6132e011e9 --- /dev/null +++ b/pkgs/dbrepl/dbrepl.go @@ -0,0 +1,161 @@ +// Package dbrepl holds the engine-level pieces of Postgres streaming and MySQL binlog replication that the +// container-backed database emulators share. +package dbrepl + +import ( + "cmp" + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" +) + +// PasswordEnv is the container environment variable carrying the replication password into PGReplicaScript. +const PasswordEnv = "REPL_KEY" + +// ErrNotStreaming is returned when a MySQL replica has no replication channel configured. +var ErrNotStreaming = errors.New("replication is not running") + +// Status is a replica's replication health as reported by its engine. +type Status struct { + Message string + LagSeconds float64 + Replicating bool +} + +// PGReplicaScript is a container entrypoint that seeds an empty PGDATA with pg_basebackup from the source +// ($1 host, $2 port) before handing over to the image entrypoint. +func PGReplicaScript(user string) string { + return `set -e +host="$1"; port="$2"; shift 2 +if [ ! -s "$PGDATA/PG_VERSION" ]; then + gosu postgres pg_basebackup -d "host=$host port=$port user=` + user + ` password=$` + PasswordEnv + `" ` + + `-D "$PGDATA" -R -X stream -c fast +fi +exec docker-entrypoint.sh "$@"` +} + +// PGHBAScript appends a replication rule for user to the source's pg_hba.conf, once. +func PGHBAScript(user string) string { + return `f="$PGDATA/pg_hba.conf"; grep -q "^host replication ` + user + ` " "$f" || ` + + `echo "host replication ` + user + ` samenet scram-sha-256" >> "$f"` +} + +// PGReplicaRoleSQL creates or resets the replication login on a Postgres source. +func PGReplicaRoleSQL(user, pass string) string { + return fmt.Sprintf("SET password_encryption='scram-sha-256'; DO $$ BEGIN "+ + "CREATE ROLE %[1]s WITH LOGIN REPLICATION PASSWORD '%[2]s'; "+ + "EXCEPTION WHEN duplicate_object THEN ALTER ROLE %[1]s WITH LOGIN REPLICATION PASSWORD '%[2]s'; END $$", + user, pass) +} + +// MySQLReplicaUserSQL creates or resets the replication login on a MySQL source. +func MySQLReplicaUserSQL(user, pass string) string { + return fmt.Sprintf("CREATE USER IF NOT EXISTS '%[1]s'@'%%' IDENTIFIED BY '%[2]s'; "+ + "ALTER USER '%[1]s'@'%%' IDENTIFIED BY '%[2]s'; GRANT REPLICATION SLAVE ON *.* TO '%[1]s'@'%%'", + user, pass) +} + +// MySQLChangeSourceSQL points a MySQL 8 replica at host:port with GTID auto-positioning and starts it. +func MySQLChangeSourceSQL(host string, port int, user, pass string) string { + return fmt.Sprintf("CHANGE REPLICATION SOURCE TO SOURCE_HOST='%s', SOURCE_PORT=%d, SOURCE_USER='%s', "+ + "SOURCE_PASSWORD='%s', SOURCE_AUTO_POSITION=1, GET_SOURCE_PUBLIC_KEY=1; START REPLICA", + host, port, user, pass) +} + +// QueryStatus reads the replication health of a Postgres replica (pg) or a MySQL replica. +func QueryStatus(ctx context.Context, db *sql.DB, postgres bool) (Status, error) { + if postgres { + return pgStatus(ctx, db) + } + + return mysqlStatus(ctx, db) +} + +func pgStatus(ctx context.Context, db *sql.DB) (Status, error) { + var ( + status string + lag float64 + ) + + err := db.QueryRowContext(ctx, `SELECT COALESCE((SELECT status FROM pg_stat_wal_receiver LIMIT 1), ''), `+ + `CASE WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0 `+ + `ELSE COALESCE(EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp()), 0) END::float8`).Scan(&status, &lag) + if err != nil { + return Status{}, fmt.Errorf("replica status: %w", err) + } + + if status != "streaming" { + return Status{Message: "WAL receiver status: " + cmp.Or(status, "not running")}, nil + } + + return Status{Replicating: true, LagSeconds: lag}, nil +} + +func mysqlStatus(ctx context.Context, db *sql.DB) (Status, error) { + rows, err := db.QueryContext(ctx, "SHOW REPLICA STATUS") + if err != nil { + return Status{}, fmt.Errorf("replica status: %w", err) + } + + defer func() { _ = rows.Close() }() + + cols, err := rows.Columns() + if err != nil { + return Status{}, fmt.Errorf("replica status columns: %w", err) + } + + if !rows.Next() { + if err = rows.Err(); err != nil { + return Status{}, fmt.Errorf("replica status rows: %w", err) + } + + return Status{}, ErrNotStreaming + } + + vals := make([]sql.NullString, len(cols)) + ptrs := make([]any, len(cols)) + + for i := range vals { + ptrs[i] = &vals[i] + } + + if err = rows.Scan(ptrs...); err != nil { + return Status{}, fmt.Errorf("replica status scan: %w", err) + } + + row := make(map[string]string, len(cols)) + for i, c := range cols { + row[strings.ToLower(c)] = vals[i].String + } + + return InterpretMySQL(row), nil +} + +func firstOf(row map[string]string, keys ...string) string { + for _, k := range keys { + if v := row[k]; v != "" { + return v + } + } + + return "" +} + +// InterpretMySQL turns a lower-cased SHOW REPLICA STATUS row into a Status. +func InterpretMySQL(row map[string]string) Status { + ioOn := firstOf(row, "replica_io_running", "slave_io_running") == "Yes" + sqlOn := firstOf(row, "replica_sql_running", "slave_sql_running") == "Yes" + + if !ioOn || !sqlOn { + msg := firstOf(row, "last_io_error", "last_sql_error") + + return Status{Message: cmp.Or(msg, "replication threads are not running")} + } + + lag, _ := strconv.ParseFloat(firstOf(row, "seconds_behind_source", "seconds_behind_master"), 64) + + return Status{Replicating: true, LagSeconds: lag} +} diff --git a/pkgs/dbrepl/dbrepl_test.go b/pkgs/dbrepl/dbrepl_test.go new file mode 100644 index 0000000000..1644326c09 --- /dev/null +++ b/pkgs/dbrepl/dbrepl_test.go @@ -0,0 +1,63 @@ +package dbrepl_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" +) + +func TestInterpretMySQL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + row map[string]string + want dbrepl.Status + }{ + { + name: "running with lag", + row: map[string]string{ + "replica_io_running": "Yes", "replica_sql_running": "Yes", "seconds_behind_source": "3", + }, + want: dbrepl.Status{Replicating: true, LagSeconds: 3}, + }, + { + name: "legacy column names", + row: map[string]string{ + "slave_io_running": "Yes", + "slave_sql_running": "Yes", + "seconds_behind_master": "1", + }, + want: dbrepl.Status{Replicating: true, LagSeconds: 1}, + }, + { + name: "io thread error", + row: map[string]string{"replica_io_running": "No", "replica_sql_running": "Yes", "last_io_error": "boom"}, + want: dbrepl.Status{Message: "boom"}, + }, + { + name: "stopped", + row: map[string]string{"replica_io_running": "No", "replica_sql_running": "No"}, + want: dbrepl.Status{Message: "replication threads are not running"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, dbrepl.InterpretMySQL(tt.row)) + }) + } +} + +func TestStatements(t *testing.T) { + t.Parallel() + + assert.Contains(t, dbrepl.PGReplicaScript("u1"), "user=u1 password=$"+dbrepl.PasswordEnv) + assert.Contains(t, dbrepl.PGHBAScript("u1"), "host replication u1 samenet") + assert.Contains(t, dbrepl.PGReplicaRoleSQL("u1", "pw"), "CREATE ROLE u1 WITH LOGIN REPLICATION PASSWORD 'pw'") + assert.Contains(t, dbrepl.MySQLReplicaUserSQL("u1", "pw"), "GRANT REPLICATION SLAVE ON *.* TO 'u1'@'%'") + assert.Contains(t, dbrepl.MySQLChangeSourceSQL("h", 3306, "u1", "pw"), "SOURCE_HOST='h', SOURCE_PORT=3306") +} diff --git a/pkgs/deltalog/deltalog.go b/pkgs/deltalog/deltalog.go new file mode 100644 index 0000000000..c2f896ebf2 --- /dev/null +++ b/pkgs/deltalog/deltalog.go @@ -0,0 +1,476 @@ +// Package deltalog replays a Delta Lake transaction log (JSON commits and +// checkpoint Parquet files) into the table's active file list and schema +// (https://github.com/delta-io/delta/blob/master/PROTOCOL.md). +package deltalog + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "slices" + "sort" + "strconv" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +const ( + logDir = "_delta_log/" + lastCheckpoint = "_last_checkpoint" + maxReaderVersion = 3 + columnMappingModeKey = "delta.columnMapping.mode" + physicalNameKey = "delta.columnMapping.physicalName" + columnMappingIDKey = "delta.columnMapping.id" + jsonSuffix = ".json" + checkpointMarker = ".checkpoint." + parquetSuffix = ".parquet" + maxLogLineBytes = 64 << 20 + scanBufBytes = 64 << 10 +) + +var ( + // ErrNotDeltaTable is returned when the location has no transaction log. + ErrNotDeltaTable = errors.New("not a Delta Lake table") + // ErrBadLog is returned for a transaction log that cannot be replayed. + ErrBadLog = errors.New("invalid Delta Lake transaction log") + // ErrUnsupported is returned for protocol features this reader does not implement. + ErrUnsupported = errors.New("unsupported Delta Lake feature") +) + +// Storage reads objects under the table location; keys are relative to it. +type Storage interface { + List(ctx context.Context, prefix string) ([]string, error) + Get(ctx context.Context, key string) ([]byte, error) +} + +// Column is one logical table column. +type Column struct { + Name string + Type string + PhysicalName string + ID int +} + +// AddFile is an active data file. +type AddFile struct { + PartitionValues map[string]*string + Path string + Size int64 +} + +// Snapshot is the table state at a log version. +type Snapshot struct { + ColumnMapping string + Columns []Column + PartitionColumns []string + Files []AddFile + Version int64 +} + +type action struct { + Add *addAction `json:"add"` + Remove *removeAction `json:"remove"` + MetaData *metaDataAction `json:"metaData"` + Protocol *protocolAction `json:"protocol"` +} + +type addAction struct { + PartitionValues map[string]*string `json:"partitionValues"` + Path string `json:"path"` + DeletionVector json.RawMessage `json:"deletionVector"` + Size int64 `json:"size"` +} + +type removeAction struct { + Path string `json:"path"` +} + +type metaDataAction struct { + Configuration map[string]string `json:"configuration"` + SchemaString string `json:"schemaString"` + PartitionColumns []string `json:"partitionColumns"` +} + +type protocolAction struct { + ReaderFeatures []string `json:"readerFeatures"` + MinReaderVersion int `json:"minReaderVersion"` +} + +type state struct { + files map[string]AddFile + meta *metaDataAction + order []string + version int64 +} + +// Load replays the log to its newest version. +func Load(ctx context.Context, st Storage) (*Snapshot, error) { + keys, err := st.List(ctx, logDir) + if err != nil { + return nil, err + } + + commits := map[int64]string{} + checkpoints := map[int64][]string{} + + for _, k := range keys { + name := strings.TrimPrefix(k, logDir) + if strings.Contains(name, "/") { + continue + } + + v, kind := classify(name) + + switch kind { + case kindCommit: + commits[v] = k + case kindCheckpoint: + checkpoints[v] = append(checkpoints[v], k) + } + } + + if len(commits) == 0 && len(checkpoints) == 0 { + return nil, fmt.Errorf("%w: no _delta_log commits found", ErrNotDeltaTable) + } + + s := &state{files: map[string]AddFile{}, version: -1} + + if err = s.loadCheckpoint(ctx, st, checkpoints); err != nil { + return nil, err + } + + versions := make([]int64, 0, len(commits)) + + for v := range commits { + if v > s.version { + versions = append(versions, v) + } + } + + slices.Sort(versions) + + for _, v := range versions { + if v != s.version+1 { + return nil, fmt.Errorf("%w: missing commit %d", ErrBadLog, s.version+1) + } + + body, gerr := st.Get(ctx, commits[v]) + if gerr != nil { + return nil, gerr + } + + if err = s.applyCommit(body); err != nil { + return nil, fmt.Errorf("commit %d: %w", v, err) + } + + s.version = v + } + + return s.snapshot() +} + +const ( + kindOther = iota + kindCommit + kindCheckpoint +) + +func classify(name string) (int64, int) { + if rest, ok := strings.CutSuffix(name, jsonSuffix); ok { + if v, err := strconv.ParseInt(rest, 10, 64); err == nil { + return v, kindCommit + } + + return 0, kindOther + } + + head, _, ok := strings.Cut(name, checkpointMarker) + if !ok || !strings.HasSuffix(name, parquetSuffix) { + return 0, kindOther + } + + v, err := strconv.ParseInt(head, 10, 64) + if err != nil { + return 0, kindOther + } + + return v, kindCheckpoint +} + +func (s *state) loadCheckpoint(ctx context.Context, st Storage, cps map[int64][]string) error { + var best int64 = -1 + + if raw, err := st.Get(ctx, logDir+lastCheckpoint); err == nil { + var lc struct { + Version int64 `json:"version"` + } + + if json.Unmarshal(raw, &lc) == nil { + if _, ok := cps[lc.Version]; ok { + best = lc.Version + } + } + } + + if best < 0 { + for v := range cps { + best = max(best, v) + } + } + + if best < 0 { + return nil + } + + files := cps[best] + sort.Strings(files) + + for _, k := range files { + body, err := st.Get(ctx, k) + if err != nil { + return err + } + + if err = s.applyCheckpoint(body); err != nil { + return fmt.Errorf("checkpoint %d: %w", best, err) + } + } + + s.version = best + + return nil +} + +func (s *state) applyCheckpoint(body []byte) error { + pf, err := parquetio.Open(body) + if err != nil { + return fmt.Errorf("%w: %w", ErrBadLog, err) + } + + err = pf.ForEach([]string{"add", "metaData", "protocol", "remove"}, func(vals []any) error { + var a action + + for i, v := range vals { + if v == nil { + continue + } + + raw, merr := json.Marshal(v) + if merr != nil { + return merr + } + + part := map[int]string{0: "add", 1: "metaData", 2: "protocol", 3: "remove"}[i] + if uerr := json.Unmarshal([]byte(`{"`+part+`":`+string(raw)+`}`), &a); uerr != nil { + return fmt.Errorf("%w: %w", ErrBadLog, uerr) + } + } + + return s.apply(a) + }) + if err != nil { + return fmt.Errorf("%w: %w", ErrBadLog, err) + } + + return nil +} + +func (s *state) applyCommit(body []byte) error { + sc := bufio.NewScanner(bytes.NewReader(body)) + sc.Buffer(make([]byte, 0, scanBufBytes), maxLogLineBytes) + + for sc.Scan() { + line := bytes.TrimSpace(sc.Bytes()) + if len(line) == 0 { + continue + } + + var a action + if err := json.Unmarshal(line, &a); err != nil { + return fmt.Errorf("%w: %w", ErrBadLog, err) + } + + if err := s.apply(a); err != nil { + return err + } + } + + return sc.Err() +} + +func (s *state) apply(a action) error { + switch { + case a.Protocol != nil: + if a.Protocol.MinReaderVersion > maxReaderVersion { + return fmt.Errorf("%w: reader version %d", ErrUnsupported, a.Protocol.MinReaderVersion) + } + case a.MetaData != nil: + s.meta = a.MetaData + case a.Add != nil: + if len(a.Add.DeletionVector) > 0 && string(a.Add.DeletionVector) != "null" { + return fmt.Errorf("%w: deletion vectors", ErrUnsupported) + } + + p := decodePath(a.Add.Path) + if _, seen := s.files[p]; !seen { + s.order = append(s.order, p) + } + + s.files[p] = AddFile{Path: p, Size: a.Add.Size, PartitionValues: a.Add.PartitionValues} + case a.Remove != nil: + delete(s.files, decodePath(a.Remove.Path)) + } + + return nil +} + +func decodePath(p string) string { + if u, err := url.PathUnescape(p); err == nil { + return u + } + + return p +} + +func (s *state) snapshot() (*Snapshot, error) { + if s.meta == nil { + return nil, fmt.Errorf("%w: no metaData action", ErrBadLog) + } + + cols, err := parseSchema(s.meta.SchemaString, s.meta.Configuration[columnMappingModeKey]) + if err != nil { + return nil, err + } + + snap := &Snapshot{ + Version: s.version, Columns: cols, PartitionColumns: s.meta.PartitionColumns, + ColumnMapping: s.meta.Configuration[columnMappingModeKey], + } + + for _, p := range s.order { + if f, ok := s.files[p]; ok { + snap.Files = append(snap.Files, f) + } + } + + return snap, nil +} + +type schemaField struct { + Metadata map[string]any `json:"metadata"` + Name string `json:"name"` + Type json.RawMessage `json:"type"` +} + +func parseSchema(s, mode string) ([]Column, error) { + var st struct { + Fields []schemaField `json:"fields"` + } + + if err := json.Unmarshal([]byte(s), &st); err != nil { + return nil, fmt.Errorf("%w: schemaString: %w", ErrBadLog, err) + } + + cols := make([]Column, 0, len(st.Fields)) + + for _, f := range st.Fields { + t, err := hiveType(f.Type) + if err != nil { + return nil, err + } + + c := Column{Name: f.Name, Type: t, PhysicalName: f.Name} + + if mode == "name" || mode == "id" { + if pn, ok := f.Metadata[physicalNameKey].(string); ok { + c.PhysicalName = pn + } + + if id, ok := f.Metadata[columnMappingIDKey].(float64); ok { + c.ID = int(id) + } + } + + cols = append(cols, c) + } + + return cols, nil +} + +func hiveType(raw json.RawMessage) (string, error) { + var prim string + if err := json.Unmarshal(raw, &prim); err == nil { + return hivePrimitive(prim) + } + + var n struct { + Type string `json:"type"` + ElementType json.RawMessage `json:"elementType"` + KeyType json.RawMessage `json:"keyType"` + ValueType json.RawMessage `json:"valueType"` + Fields []schemaField `json:"fields"` + } + + if err := json.Unmarshal(raw, &n); err != nil { + return "", fmt.Errorf("%w: type %s", ErrBadLog, raw) + } + + switch n.Type { + case "array": + e, err := hiveType(n.ElementType) + + return "array<" + e + ">", err + case "map": + k, err := hiveType(n.KeyType) + if err != nil { + return "", err + } + + v, err := hiveType(n.ValueType) + + return "map<" + k + "," + v + ">", err + case "struct": + parts := make([]string, 0, len(n.Fields)) + + for _, f := range n.Fields { + t, err := hiveType(f.Type) + if err != nil { + return "", err + } + + parts = append(parts, f.Name+":"+t) + } + + return "struct<" + strings.Join(parts, ",") + ">", nil + default: + return "", fmt.Errorf("%w: type %s", ErrUnsupported, n.Type) + } +} + +func hivePrimitive(prim string) (string, error) { + switch prim { + case "long": + return "bigint", nil + case "integer": + return "int", nil + case "short": + return "smallint", nil + case "byte": + return "tinyint", nil + case "timestamp_ntz": + return "timestamp", nil + case "string", "float", "double", "boolean", "binary", "date", "timestamp": + return prim, nil + default: + if strings.HasPrefix(prim, "decimal") { + return prim, nil + } + + return "", fmt.Errorf("%w: type %s", ErrUnsupported, prim) + } +} diff --git a/pkgs/deltalog/deltalog_test.go b/pkgs/deltalog/deltalog_test.go new file mode 100644 index 0000000000..c22ab145ed --- /dev/null +++ b/pkgs/deltalog/deltalog_test.go @@ -0,0 +1,189 @@ +package deltalog_test + +import ( + "bytes" + "context" + "sort" + "strings" + "testing" + + "github.com/parquet-go/parquet-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/deltalog" +) + +type memStorage map[string][]byte + +func (m memStorage) List(_ context.Context, prefix string) ([]string, error) { + var keys []string + + for k := range m { + if strings.HasPrefix(k, prefix) { + keys = append(keys, k) + } + } + + sort.Strings(keys) + + return keys, nil +} + +func (m memStorage) Get(_ context.Context, key string) ([]byte, error) { + b, ok := m[key] + if !ok { + return nil, assert.AnError + } + + return b, nil +} + +const schema = `{"type":"struct","fields":[{"name":"id","type":"long","nullable":true,"metadata":{}},` + + `{"name":"tags","type":{"type":"array","elementType":"string","containsNull":true},"nullable":true,"metadata":{}}]}` + +type cpAdd struct { + PartitionValues map[string]string `parquet:"partitionValues"` + Path string `parquet:"path"` + Size int64 `parquet:"size"` +} + +type cpMeta struct { + Configuration map[string]string `parquet:"configuration"` + SchemaString string `parquet:"schemaString"` + PartitionColumns []string `parquet:"partitionColumns"` +} + +type cpProto struct { + MinReaderVersion int32 `parquet:"minReaderVersion"` +} + +type cpRow struct { + Add *cpAdd `parquet:"add,optional"` + MetaData *cpMeta `parquet:"metaData,optional"` + Protocol *cpProto `parquet:"protocol,optional"` +} + +func checkpoint(t *testing.T) []byte { + t.Helper() + + var buf bytes.Buffer + + w := parquet.NewGenericWriter[cpRow](&buf) + _, err := w.Write([]cpRow{ + {Protocol: &cpProto{MinReaderVersion: 1}}, + {MetaData: &cpMeta{SchemaString: schema, PartitionColumns: []string{}, Configuration: map[string]string{}}}, + {Add: &cpAdd{Path: "a.parquet", Size: 5, PartitionValues: map[string]string{}}}, + {Add: &cpAdd{Path: "b%20c.parquet", Size: 6, PartitionValues: map[string]string{}}}, + }) + require.NoError(t, err) + require.NoError(t, w.Close()) + + return buf.Bytes() +} + +func TestLoad(t *testing.T) { + t.Parallel() + + meta := `{"metaData":{"schemaString":` + quote(schema) + `,"partitionColumns":[],"configuration":{}}}` + + cases := []struct { + wantErr error + files memStorage + name string + wantPaths []string + wantCols []string + wantVer int64 + }{ + { + name: "commits replay in order", + files: memStorage{ + "_delta_log/00000000000000000000.json": []byte( + meta + "\n" + `{"add":{"path":"a.parquet","size":1}}` + "\n", + ), + "_delta_log/00000000000000000001.json": []byte(`{"add":{"path":"b%20c.parquet","size":1}}` + "\n" + + `{"remove":{"path":"a.parquet"}}` + "\n"), + }, + wantPaths: []string{"b c.parquet"}, wantVer: 1, wantCols: []string{"bigint", "array"}, + }, + { + name: "checkpoint then later commit", + files: memStorage{ + "_delta_log/00000000000000000010.checkpoint.parquet": checkpoint(t), + "_delta_log/_last_checkpoint": []byte(`{"version":10,"size":4}`), + "_delta_log/00000000000000000011.json": []byte(`{"remove":{"path":"a.parquet"}}` + "\n"), + }, + wantPaths: []string{"b c.parquet"}, wantVer: 11, wantCols: []string{"bigint", "array"}, + }, + { + name: "checkpoint without pointer", + files: memStorage{ + "_delta_log/00000000000000000010.checkpoint.parquet": checkpoint(t), + }, + wantPaths: []string{"a.parquet", "b c.parquet"}, wantVer: 10, wantCols: []string{"bigint", "array"}, + }, + {name: "empty log", files: memStorage{}, wantErr: deltalog.ErrNotDeltaTable}, + { + name: "deletion vectors", + files: memStorage{ + "_delta_log/00000000000000000000.json": []byte(meta + "\n" + + `{"add":{"path":"a.parquet","deletionVector":{"storageType":"u","pathOrInlineDv":"x"}}}` + "\n"), + }, + wantErr: deltalog.ErrUnsupported, + }, + { + name: "gap", + files: memStorage{ + "_delta_log/00000000000000000000.json": []byte(meta + "\n"), + "_delta_log/00000000000000000002.json": []byte(`{}` + "\n"), + }, + wantErr: deltalog.ErrBadLog, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + snap, err := deltalog.Load(t.Context(), tc.files) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantVer, snap.Version) + + var paths, cols []string + for _, f := range snap.Files { + paths = append(paths, f.Path) + } + + for _, c := range snap.Columns { + cols = append(cols, c.Type) + } + + assert.Equal(t, tc.wantPaths, paths) + assert.Equal(t, tc.wantCols, cols) + }) + } +} + +func quote(s string) string { + var sb strings.Builder + + sb.WriteByte('"') + + for _, r := range s { + if r == '"' || r == '\\' { + sb.WriteByte('\\') + } + + sb.WriteRune(r) + } + + sb.WriteByte('"') + + return sb.String() +} diff --git a/pkgs/dmsengine/dynamo_target.go b/pkgs/dmsengine/dynamo_target.go new file mode 100644 index 0000000000..7590a76cb3 --- /dev/null +++ b/pkgs/dmsengine/dynamo_target.go @@ -0,0 +1,124 @@ +package dmsengine + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var errNoDynamoKey = errors.New("DynamoDB target requires a source primary key") + +type dynamoTarget struct{ store DynamoStore } + +func newDynamoTarget(store DynamoStore) (Target, error) { + if store == nil { + return nil, fmt.Errorf("%w: DynamoDB", ErrNotConfigured) + } + + return &dynamoTarget{store: store}, nil +} + +func (t *dynamoTarget) Ping(context.Context) error { return nil } + +func (t *dynamoTarget) Close() error { return nil } + +func (t *dynamoTarget) EndLoad(context.Context, TargetTable) error { return nil } + +func (t *dynamoTarget) Commit(context.Context) error { return nil } + +func isNumeric(k Kind) bool { return k == KindInt || k == KindDecimal || k == KindFloat } + +func (t *dynamoTarget) Prepare(ctx context.Context, tt TargetTable, mode PrepMode) error { + if len(tt.PK) == 0 || len(tt.PK) > 2 { + return fmt.Errorf("%w: %s", errNoDynamoKey, tt.Key()) + } + + keys := make([]DynamoKey, len(tt.PK)) + + for i, name := range tt.PK { + for _, c := range tt.Columns { + if c.Name == name { + keys[i] = DynamoKey{Name: name, Number: isNumeric(c.Kind)} + } + } + } + + if mode != PrepDoNothing { + _ = t.store.DropTable(ctx, tt.Name) + } + + err := t.store.CreateTable(ctx, tt.Name, keys) + if err != nil && mode == PrepDoNothing && strings.Contains(strings.ToLower(err.Error()), "exist") { + return nil + } + + return err +} + +func dynamoValue(v any, k Kind) (DynamoValue, bool) { + switch x := v.(type) { + case nil: + return DynamoValue{}, false + case []byte: + return DynamoValue{Type: "B", B: x}, true + case string: + if isNumeric(k) { + return DynamoValue{Type: "N", S: x}, true + } + + return DynamoValue{Type: "S", S: x}, true + } + + return DynamoValue{Type: "S", S: fmt.Sprint(v)}, true +} + +func (t *dynamoTarget) item(tt TargetTable, row Row, keyOnly bool) map[string]DynamoValue { + out := map[string]DynamoValue{} + + for i, c := range tt.Columns { + if keyOnly && !slicesContain(tt.PK, c.Name) { + continue + } + + if v, ok := dynamoValue(row[i], c.Kind); ok { + out[c.Name] = v + } + } + + return out +} + +func (t *dynamoTarget) Load(ctx context.Context, tt TargetTable, rows []Row) error { + for _, r := range rows { + if err := t.store.PutItem(ctx, tt.Name, t.item(tt, r, false)); err != nil { + return err + } + } + + return nil +} + +func (t *dynamoTarget) Apply(ctx context.Context, tt TargetTable, c Change) error { + if c.Op == OpDelete { + return t.store.DeleteItem(ctx, tt.Name, t.item(tt, c.Before, true)) + } + + if c.Op == OpUpdate && c.Before != nil && !sameKey(tt, c.Before, c.After) { + if err := t.store.DeleteItem(ctx, tt.Name, t.item(tt, c.Before, true)); err != nil { + return err + } + } + + return t.store.PutItem(ctx, tt.Name, t.item(tt, c.After, false)) +} + +func sameKey(tt TargetTable, a, b Row) bool { + for i, c := range tt.Columns { + if slicesContain(tt.PK, c.Name) && fmt.Sprint(a[i]) != fmt.Sprint(b[i]) { + return false + } + } + + return true +} diff --git a/pkgs/dmsengine/endpoint.go b/pkgs/dmsengine/endpoint.go new file mode 100644 index 0000000000..d4591ed036 --- /dev/null +++ b/pkgs/dmsengine/endpoint.go @@ -0,0 +1,227 @@ +package dmsengine + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "strings" + "time" +) + +// Errors reported while opening endpoints. +var ( + ErrUnsupportedEngine = errors.New("unsupported endpoint engine") + ErrNotConfigured = errors.New("in-process target not available") +) + +// DefaultConnectTimeout bounds each new database connection. +const DefaultConnectTimeout = 10 * time.Second + +// EndpointConfig is the engine's view of a DMS endpoint. +type EndpointConfig struct { + Engine string + Server string + Database string + User string + Password string + SSLMode string + // CertificatePEM is the endpoint's CertificateArn contents, trusted when SslMode asks for TLS. + CertificatePEM string + // SlotName names the PostgreSQL logical replication slot for source endpoints. + SlotName string + // S3Settings, KinesisSettings and DynamoDBSettings carry the raw settings JSON objects. + S3Settings string + KinesisSettings string + DynamoDBSettings string + Port int + // PollInterval spaces PostgreSQL change polls; zero picks a default. + PollInterval time.Duration +} + +func (c EndpointConfig) addr() string { + if c.Port == 0 { + return c.Server + } + + return fmt.Sprintf("%s:%d", c.Server, c.Port) +} + +// ObjectStore writes objects into the emulator's S3. +type ObjectStore interface { + PutObject(ctx context.Context, bucket, key string, body []byte) error + CheckBucket(ctx context.Context, bucket string) error +} + +// StreamStore writes records into the emulator's Kinesis. +type StreamStore interface { + PutRecord(ctx context.Context, stream, partitionKey string, data []byte) error + CheckStream(ctx context.Context, stream string) error +} + +// DynamoKey is one key attribute of a target table; Number selects type N over S. +type DynamoKey struct { + Name string + Number bool +} + +// DynamoValue is a typed attribute value: N, S or B. +type DynamoValue struct { + Type string + S string + B []byte +} + +// DynamoStore writes items into the emulator's DynamoDB. +type DynamoStore interface { + CreateTable(ctx context.Context, name string, keys []DynamoKey) error + DropTable(ctx context.Context, name string) error + PutItem(ctx context.Context, table string, item map[string]DynamoValue) error + DeleteItem(ctx context.Context, table string, key map[string]DynamoValue) error +} + +// Stores bundles the in-process services DMS targets can write to. +type Stores struct { + Objects ObjectStore + Streams StreamStore + Dynamo DynamoStore +} + +// Opener builds sources and targets for endpoints. +type Opener interface { + OpenSource(ctx context.Context, ep EndpointConfig) (Source, error) + OpenTarget(ctx context.Context, ep EndpointConfig, src Family) (Target, error) + // ReleaseSlot drops the replication slot a source endpoint created, if any. + ReleaseSlot(ctx context.Context, ep EndpointConfig) error +} + +// SupportedSource reports whether the engine can read from the endpoint engine. +func SupportedSource(engine string) bool { + f := FamilyOf(engine) + + return f == FamilyMySQL || f == FamilyPostgres +} + +// SupportedTarget reports whether the engine can write to the endpoint engine. +func SupportedTarget(engine string) bool { + return FamilyOf(engine) != FamilyOther +} + +type defaultOpener struct{ stores Stores } + +// NewOpener returns the production Opener, writing in-process targets through stores. +func NewOpener(stores Stores) Opener { return &defaultOpener{stores: stores} } + +func (o *defaultOpener) OpenSource(ctx context.Context, ep EndpointConfig) (Source, error) { + switch FamilyOf(ep.Engine) { + case FamilyMySQL: + return openMySQLSource(ctx, ep) + case FamilyPostgres: + return openPGSource(ctx, ep) + default: + return nil, fmt.Errorf("%w: %s as a source", ErrUnsupportedEngine, ep.Engine) + } +} + +func (o *defaultOpener) OpenTarget(ctx context.Context, ep EndpointConfig, src Family) (Target, error) { + switch FamilyOf(ep.Engine) { + case FamilyMySQL: + return openSQLTarget(ctx, ep, FamilyMySQL, src) + case FamilyPostgres: + return openSQLTarget(ctx, ep, FamilyPostgres, src) + case FamilyS3: + return newS3Target(ep, o.stores.Objects) + case FamilyKinesis: + return newKinesisTarget(ep, o.stores.Streams) + case FamilyDynamoDB: + return newDynamoTarget(o.stores.Dynamo) + default: + return nil, fmt.Errorf("%w: %s as a target", ErrUnsupportedEngine, ep.Engine) + } +} + +func (o *defaultOpener) ReleaseSlot(ctx context.Context, ep EndpointConfig) error { + if FamilyOf(ep.Engine) != FamilyPostgres || ep.SlotName == "" { + return nil + } + + src, err := openPGSource(ctx, ep) + if err != nil { + return err + } + defer src.Close() + + return src.Release(ctx) +} + +// TestEndpoint opens the endpoint and verifies it is reachable, returning the driver's own error on failure. +func TestEndpoint(ctx context.Context, o Opener, ep EndpointConfig, source bool) error { + if source { + s, err := o.OpenSource(ctx, ep) + if err != nil { + return err + } + defer s.Close() + + return s.Ping(ctx) + } + + t, err := o.OpenTarget(ctx, ep, FamilyOther) + if err != nil { + return err + } + defer t.Close() + + return t.Ping(ctx) +} + +func tlsRequired(mode string) bool { + switch strings.ToLower(mode) { + case sslRequire, sslVerifyCA, sslVerifyFull: + return true + } + + return false +} + +const ( + sslRequire = "require" + sslVerifyCA = "verify-ca" + sslVerifyFull = "verify-full" +) + +// ErrCertificate reports an endpoint CertificateArn whose PEM holds no usable certificate. +var ErrCertificate = errors.New("endpoint certificate is not a valid PEM CA bundle") + +// tlsConfigFor builds the client TLS settings for the endpoint's SslMode: nil for none, otherwise a verified +// connection trusting the endpoint's CertificatePEM, or the system roots when none was supplied. The server +// certificate must match the endpoint's server name in every TLS mode. +func tlsConfigFor(ep EndpointConfig) (*tls.Config, error) { + if !tlsRequired(ep.SSLMode) { + return nil, nil //nolint:nilnil // nil config means plaintext + } + + cfg := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: ep.Server} + + if strings.TrimSpace(ep.CertificatePEM) != "" { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(ep.CertificatePEM)) { + return nil, ErrCertificate + } + + cfg.RootCAs = pool + } + + return cfg, nil +} + +// explainTLS adds guidance to a certificate-verification failure. +func explainTLS(ep EndpointConfig, err error) error { + if err == nil || !tlsRequired(ep.SSLMode) || !strings.Contains(err.Error(), "x509:") { + return err + } + + return fmt.Errorf("SslMode %s needs a trusted server certificate: attach a certificate (CertificateArn) "+ + "that signed the server's certificate, or use a publicly trusted one: %w", ep.SSLMode, err) +} diff --git a/pkgs/dmsengine/kinesis_target.go b/pkgs/dmsengine/kinesis_target.go new file mode 100644 index 0000000000..377ca82500 --- /dev/null +++ b/pkgs/dmsengine/kinesis_target.go @@ -0,0 +1,178 @@ +package dmsengine + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +var errNoStreamARN = errors.New("KinesisSettings.StreamArn is required") + +type kinesisOptions struct { + StreamArn string `json:"StreamArn"` + MessageFormat string `json:"MessageFormat"` + IncludeControlDetails bool `json:"IncludeControlDetails"` + IncludeNullAndEmpty bool `json:"IncludeNullAndEmpty"` + IncludePartitionValue bool `json:"IncludePartitionValue"` + PartitionIncludeSchemaTable bool `json:"PartitionIncludeSchemaTable"` +} + +type kinesisTarget struct { + store StreamStore + now func() time.Time + stream string + opts kinesisOptions +} + +func newKinesisTarget(ep EndpointConfig, store StreamStore) (Target, error) { + if store == nil { + return nil, fmt.Errorf("%w: Kinesis", ErrNotConfigured) + } + + var o kinesisOptions + if ep.KinesisSettings != "" { + if err := json.Unmarshal([]byte(ep.KinesisSettings), &o); err != nil { + return nil, fmt.Errorf("invalid KinesisSettings: %w", err) + } + } + + if o.StreamArn == "" { + return nil, errNoStreamARN + } + + name := o.StreamArn[strings.LastIndex(o.StreamArn, "/")+1:] + + return &kinesisTarget{store: store, opts: o, stream: name, now: time.Now}, nil +} + +func (t *kinesisTarget) Ping(ctx context.Context) error { return t.store.CheckStream(ctx, t.stream) } + +func (t *kinesisTarget) Close() error { return nil } + +func (t *kinesisTarget) EndLoad(context.Context, TargetTable) error { return nil } + +func (t *kinesisTarget) Commit(context.Context) error { return nil } + +func (t *kinesisTarget) Prepare(ctx context.Context, tt TargetTable, _ PrepMode) error { + if !t.opts.IncludeControlDetails { + return nil + } + + cols := map[string]any{} + for i, c := range tt.Columns { + cols[c.Name] = map[string]any{"type": strings.ToUpper(c.Native), "nullable": c.Nullable, "position": i + 1} + } + + def := map[string]any{"columns": cols, "primary-key": tt.PK} + + return t.send(ctx, tt, "control", "create-table", map[string]any{"table-def": def}, t.now()) +} + +func (t *kinesisTarget) value(v any, k Kind) any { + if b, ok := v.([]byte); ok { + return fmt.Sprintf("%X", b) + } + + x, ok := v.(string) + if !ok { + return v + } + + if isNumeric(k) { + if _, err := strconv.ParseFloat(x, 64); err == nil { + return json.Number(x) + } + } + + if k == KindBool { + return x == "1" || x == "t" || strings.EqualFold(x, "true") + } + + return x +} + +func (t *kinesisTarget) dataOf(tt TargetTable, row Row) map[string]any { + out := make(map[string]any, len(row)) + + for i, c := range tt.Columns { + v := row[i] + if !t.opts.IncludeNullAndEmpty && (v == nil || v == "") { + continue + } + + out[c.Name] = t.value(v, c.Kind) + } + + return out +} + +func (t *kinesisTarget) Load(ctx context.Context, tt TargetTable, rows []Row) error { + for _, r := range rows { + if err := t.send(ctx, tt, "data", "load", t.dataOf(tt, r), t.now()); err != nil { + return err + } + } + + return nil +} + +func (t *kinesisTarget) Apply(ctx context.Context, tt TargetTable, c Change) error { + row := c.After + if c.Op == OpDelete { + row = c.Before + } + + return t.send(ctx, tt, "data", string(c.Op), t.dataOf(tt, row), c.Time) +} + +func (t *kinesisTarget) send( + ctx context.Context, tt TargetTable, recType, op string, data map[string]any, ts time.Time, +) error { + if ts.IsZero() { + ts = t.now() + } + + pk := tt.Schema + "." + tt.Name + meta := map[string]any{ + tTimestamp: ts.UTC().Format("2006-01-02T15:04:05.000000Z"), + "record-type": recType, + "operation": op, + "partition-key-type": "schema-table", + "schema-name": tt.Schema, + "table-name": tt.Name, + } + + body, err := t.encode(data, meta) + if err != nil { + return err + } + + return t.store.PutRecord(ctx, t.stream, pk, body) +} + +type recordOut struct { + Data map[string]any `json:"data"` + Metadata map[string]any `json:"metadata"` +} + +func (t *kinesisTarget) encode(data, meta map[string]any) ([]byte, error) { + var buf bytes.Buffer + + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if !strings.EqualFold(t.opts.MessageFormat, "json-unformatted") { + enc.SetIndent("", " ") + } + + if err := enc.Encode(recordOut{Data: data, Metadata: meta}); err != nil { + return nil, err + } + + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} diff --git a/pkgs/dmsengine/mapping.go b/pkgs/dmsengine/mapping.go new file mode 100644 index 0000000000..ee83aa71d7 --- /dev/null +++ b/pkgs/dmsengine/mapping.go @@ -0,0 +1,293 @@ +package dmsengine + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +// ErrMapping reports an invalid table-mappings document. +var ErrMapping = errors.New("invalid table mappings") + +// Rule actions and targets used by table mappings. +const ( + ruleSelection = "selection" + ruleTransformation = "transformation" + actInclude = "include" + actExplicit = "explicit" + actExclude = "exclude" + actRename = "rename" + actRemoveColumn = "remove-column" + targetSchema = "schema" + targetTable = "table" + targetColumn = "column" +) + +type flexString string + +func (f *flexString) UnmarshalJSON(b []byte) error { + var n json.Number + if err := json.Unmarshal(b, &n); err == nil { + *f = flexString(n.String()) + + return nil + } + + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + *f = flexString(s) + + return nil +} + +// Rule is one entry of a table-mappings document. +type Rule struct { + Locator struct { + Schema string `json:"schema-name"` + Table string `json:"table-name"` + Column string `json:"column-name"` + } `json:"object-locator"` + ID flexString `json:"rule-id"` + Name flexString `json:"rule-name"` + Type string `json:"rule-type"` + Action string `json:"rule-action"` + Target string `json:"rule-target"` + Value string `json:"value"` + Old string `json:"old-value"` +} + +// Mappings is a parsed table-mappings document. +type Mappings struct { + Rules []Rule `json:"rules"` +} + +// ParseMappings parses and validates a TableMappings JSON document. +func ParseMappings(doc string) (Mappings, error) { + var m Mappings + if strings.TrimSpace(doc) == "" { + return m, nil + } + + if err := json.Unmarshal([]byte(doc), &m); err != nil { + return m, fmt.Errorf("%w: %w", ErrMapping, err) + } + + for _, r := range m.Rules { + if err := validateRule(r); err != nil { + return m, err + } + } + + sort.SliceStable(m.Rules, func(i, j int) bool { return ruleOrder(m.Rules[i]) < ruleOrder(m.Rules[j]) }) + + return m, nil +} + +const unorderedRule = 1 << 30 + +func ruleOrder(r Rule) int { + n, err := strconv.Atoi(string(r.ID)) + if err != nil { + return unorderedRule + } + + return n +} + +func validateRule(r Rule) error { + switch r.Type { + case ruleSelection: + switch r.Action { + case actInclude, actExclude, actExplicit: + return nil + } + case ruleTransformation: + return validateTransformation(r) + default: + return fmt.Errorf("%w: unsupported rule-type %q", ErrMapping, r.Type) + } + + return fmt.Errorf("%w: unsupported selection rule-action %q", ErrMapping, r.Action) +} + +func validateTransformation(r Rule) error { + switch r.Target { + case targetSchema, targetTable, targetColumn: + default: + return fmt.Errorf("%w: unsupported rule-target %q", ErrMapping, r.Target) + } + + switch r.Action { + case actRename, "add-prefix", "remove-prefix", "replace-prefix", "add-suffix", "remove-suffix", + "replace-suffix", "convert-lowercase", "convert-uppercase": + return nil + case actRemoveColumn: + if r.Target == targetColumn { + return nil + } + } + + return fmt.Errorf("%w: unsupported transformation rule-action %q for %s", ErrMapping, r.Action, r.Target) +} + +// MatchPattern matches s against a DMS locator pattern where % matches any run of characters. +func MatchPattern(pattern, s string) bool { + if pattern == "" { + pattern = "%" + } + + if !strings.Contains(pattern, "%") { + return pattern == s + } + + parts := strings.Split(pattern, "%") + for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) + } + + return regexp.MustCompile("^" + strings.Join(parts, ".*") + "$").MatchString(s) +} + +func applyNameAction(action, name, value, old string) string { + switch action { + case actRename: + return value + case "add-prefix": + return value + name + case "remove-prefix": + return strings.TrimPrefix(name, value) + case "replace-prefix": + if rest, ok := strings.CutPrefix(name, old); ok { + return value + rest + } + case "add-suffix": + return name + value + case "remove-suffix": + return strings.TrimSuffix(name, value) + case "replace-suffix": + if rest, ok := strings.CutSuffix(name, old); ok { + return rest + value + } + case "convert-lowercase": + return strings.ToLower(name) + case "convert-uppercase": + return strings.ToUpper(name) + } + + return name +} + +// Select returns the source tables chosen by the selection rules, in source order. +func (m Mappings) Select(tables []Table) []Table { + chosen := make(map[string]bool, len(tables)) + + for _, r := range m.Rules { + if r.Type != ruleSelection { + continue + } + + for _, t := range tables { + if !MatchPattern(r.Locator.Schema, t.Schema) || !MatchPattern(r.Locator.Table, t.Name) { + continue + } + + chosen[t.Key()] = r.Action != actExclude + } + } + + out := make([]Table, 0, len(chosen)) + + for _, t := range tables { + if chosen[t.Key()] { + out = append(out, t) + } + } + + return out +} + +// Plan maps a selected source table to its target shape. overrideSchema (TargetMetadata.TargetSchema) wins +// over schema renames when set. +func (m Mappings) Plan(t Table, src Family, overrideSchema string) TargetTable { + out := TargetTable{ + SourceFamily: src, Schema: t.Schema, Name: t.Name, SourceSchema: t.Schema, SourceName: t.Name, + } + + for _, r := range m.Rules { + if r.Type != ruleTransformation || !MatchPattern(r.Locator.Schema, t.Schema) { + continue + } + + if r.Target != targetSchema && !MatchPattern(r.Locator.Table, t.Name) { + continue + } + + switch r.Target { + case targetSchema: + out.Schema = applyNameAction(r.Action, out.Schema, r.Value, r.Old) + case targetTable: + out.Name = applyNameAction(r.Action, out.Name, r.Value, r.Old) + } + } + + if overrideSchema != "" { + out.Schema = overrideSchema + } + + out.Columns, out.PK = m.planColumns(t) + + return out +} + +func (m Mappings) planColumns(t Table) ([]TargetColumn, []string) { + cols := make([]TargetColumn, 0, len(t.Columns)) + rename := make(map[string]string, len(t.Columns)) + + for i, c := range t.Columns { + name, removed := c.Name, false + + for _, r := range m.Rules { + if r.Type != ruleTransformation || r.Target != targetColumn || !m.columnRuleApplies(r, t, c.Name) { + continue + } + + if r.Action == actRemoveColumn { + removed = true + + break + } + + name = applyNameAction(r.Action, name, r.Value, r.Old) + } + + if removed { + continue + } + + rename[c.Name] = name + c.Name = name + cols = append(cols, TargetColumn{Column: c, Source: i}) + } + + pk := make([]string, 0, len(t.PK)) + + for _, p := range t.PK { + if n, ok := rename[p]; ok { + pk = append(pk, n) + } + } + + return cols, pk +} + +func (m Mappings) columnRuleApplies(r Rule, t Table, col string) bool { + return MatchPattern(r.Locator.Schema, t.Schema) && MatchPattern(r.Locator.Table, t.Name) && + MatchPattern(r.Locator.Column, col) +} diff --git a/pkgs/dmsengine/mapping_test.go b/pkgs/dmsengine/mapping_test.go new file mode 100644 index 0000000000..83925277c3 --- /dev/null +++ b/pkgs/dmsengine/mapping_test.go @@ -0,0 +1,361 @@ +package dmsengine_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" +) + +func sampleTables() []dmsengine.Table { + return []dmsengine.Table{ + {Schema: "app", Name: "items", PK: []string{"id"}, Columns: []dmsengine.Column{ + {Name: "id", Native: "int"}, {Name: "Name", Native: "varchar(20)"}, {Name: "secret", Native: "text"}, + }}, + {Schema: "app", Name: "orders", PK: []string{"id"}, Columns: []dmsengine.Column{{Name: "id", Native: "int"}}}, + {Schema: "app", Name: "tmp_scratch", Columns: []dmsengine.Column{{Name: "x", Native: "int"}}}, + {Schema: "other", Name: "items", Columns: []dmsengine.Column{{Name: "id", Native: "int"}}}, + } +} + +func TestMappingsSelect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + doc string + wants []string + }{ + { + name: "include all in schema", + doc: `{"rules":[{"rule-type":"selection","rule-id":"1","rule-name":"1", + "object-locator":{"schema-name":"app","table-name":"%"},"rule-action":"include"}]}`, + wants: []string{"app.items", "app.orders", "app.tmp_scratch"}, + }, + { + name: "exclude by wildcard after include", + doc: `{"rules":[ + {"rule-type":"selection","rule-id":"1","rule-name":"1", + "object-locator":{"schema-name":"app","table-name":"%"},"rule-action":"include"}, + {"rule-type":"selection","rule-id":"2","rule-name":"2", + "object-locator":{"schema-name":"app","table-name":"tmp%"},"rule-action":"exclude"}]}`, + wants: []string{"app.items", "app.orders"}, + }, + { + name: "rules apply in rule-id order not document order", + doc: `{"rules":[ + {"rule-type":"selection","rule-id":"2","rule-name":"2", + "object-locator":{"schema-name":"app","table-name":"tmp%"},"rule-action":"exclude"}, + {"rule-type":"selection","rule-id":"1","rule-name":"1", + "object-locator":{"schema-name":"app","table-name":"%"},"rule-action":"include"}]}`, + wants: []string{"app.items", "app.orders"}, + }, + { + name: "explicit table across any schema", + doc: `{"rules":[{"rule-type":"selection","rule-id":1,"rule-name":"1", + "object-locator":{"schema-name":"%","table-name":"items"},"rule-action":"explicit"}]}`, + wants: []string{"app.items", "other.items"}, + }, + {name: "no rules selects nothing", doc: `{"rules":[]}`, wants: []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + m, err := dmsengine.ParseMappings(tt.doc) + require.NoError(t, err) + + got := []string{} + for _, tb := range m.Select(sampleTables()) { + got = append(got, tb.Key()) + } + + assert.Equal(t, tt.wants, got) + }) + } +} + +func TestMappingsPlan(t *testing.T) { + t.Parallel() + + rule := func(id, target, action, schema, table, column, value, old string) string { + return `{"rule-type":"transformation","rule-id":"` + id + `","rule-name":"` + id + `","rule-target":"` + target + + `","rule-action":"` + action + `","object-locator":{"schema-name":"` + schema + `","table-name":"` + table + + `","column-name":"` + column + `"},"value":"` + value + `","old-value":"` + old + `"}` + } + + tests := []struct { + name string + rules []string + override string + wantSchema string + wantTable string + wantCols []string + wantPK []string + }{ + { + name: "no rules", + wantSchema: "app", + wantTable: "items", + wantCols: []string{"id", "Name", "secret"}, + wantPK: []string{"id"}, + }, + { + name: "rename schema and table", rules: []string{ + rule("1", "schema", "rename", "app", "%", "", "dst", ""), + rule("2", "table", "rename", "app", "items", "", "things", ""), + }, + wantSchema: "dst", wantTable: "things", wantCols: []string{"id", "Name", "secret"}, wantPK: []string{"id"}, + }, + { + name: "table prefix and suffix then lowercase", + rules: []string{ + rule("1", "table", "add-prefix", "app", "items", "", "pre_", ""), + rule("2", "table", "add-suffix", "app", "items", "", "_SUF", ""), + rule("3", "table", "convert-lowercase", "app", "items", "", "", ""), + }, + wantSchema: "app", + wantTable: "pre_items_suf", + wantCols: []string{"id", "Name", "secret"}, + wantPK: []string{"id"}, + }, + { + name: "replace and remove prefix", rules: []string{ + rule("1", "table", "replace-prefix", "app", "items", "", "xx", "it"), + }, + wantSchema: "app", wantTable: "xxems", wantCols: []string{"id", "Name", "secret"}, wantPK: []string{"id"}, + }, + { + name: "column rename remove and uppercase", rules: []string{ + rule("1", "column", "rename", "app", "items", "Name", "label", ""), + rule("2", "column", "remove-column", "app", "items", "secret", "", ""), + rule("3", "column", "convert-uppercase", "app", "items", "id", "", ""), + }, + wantSchema: "app", wantTable: "items", wantCols: []string{"ID", "label"}, wantPK: []string{"ID"}, + }, + { + name: "rules for other tables are ignored", rules: []string{ + rule("1", "table", "rename", "app", "orders", "", "o2", ""), + }, + wantSchema: "app", wantTable: "items", wantCols: []string{"id", "Name", "secret"}, wantPK: []string{"id"}, + }, + { + name: "TargetSchema override wins", + rules: []string{rule("1", "schema", "rename", "app", "%", "", "dst", "")}, + override: "forced", + wantSchema: "forced", + wantTable: "items", + wantCols: []string{"id", "Name", "secret"}, + wantPK: []string{"id"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + doc := `{"rules":[` + join(tt.rules) + `]}` + m, err := dmsengine.ParseMappings(doc) + require.NoError(t, err) + + got := m.Plan(sampleTables()[0], dmsengine.FamilyMySQL, tt.override) + assert.Equal(t, tt.wantSchema, got.Schema) + assert.Equal(t, tt.wantTable, got.Name) + + cols := []string{} + for _, c := range got.Columns { + cols = append(cols, c.Name) + } + + assert.Equal(t, tt.wantCols, cols) + assert.Equal(t, tt.wantPK, got.PK) + }) + } +} + +func join(parts []string) string { return strings.Join(parts, ",") } + +func TestParseMappingsRejects(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + doc string + }{ + {name: "bad json", doc: `{`}, + {name: "unknown rule type", doc: `{"rules":[{"rule-type":"nope","rule-id":"1"}]}`}, + { + name: "unknown selection action", + doc: `{"rules":[{"rule-type":"selection","rule-id":"1","rule-action":"drop"}]}`, + }, + { + name: "remove-column on table", + doc: `{"rules":[{"rule-type":"transformation","rule-id":"1","rule-target":"table","rule-action":"remove-column"}]}`, + }, + { + name: "bad target", + doc: `{"rules":[{"rule-type":"transformation","rule-id":"1","rule-target":"db","rule-action":"rename"}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := dmsengine.ParseMappings(tt.doc) + require.Error(t, err) + }) + } +} + +func TestParseTaskSettings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + doc string + mode dmsengine.PrepMode + schema string + wantErr bool + }{ + {name: "empty defaults", doc: "", mode: dmsengine.PrepDropAndCreate}, + { + name: "truncate", doc: `{"FullLoadSettings":{"TargetTablePrepMode":"TRUNCATE_BEFORE_LOAD"}}`, + mode: dmsengine.PrepTruncateBefore, + }, + { + name: "do nothing with schema", + doc: `{"TargetMetadata":{"TargetSchema":"x"},"FullLoadSettings":{"TargetTablePrepMode":"DO_NOTHING"}}`, + mode: dmsengine.PrepDoNothing, + schema: "x", + }, + {name: "bad mode", doc: `{"FullLoadSettings":{"TargetTablePrepMode":"NOPE"}}`, wantErr: true}, + {name: "bad json", doc: `{`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := dmsengine.ParseTaskSettings(tt.doc) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.mode, got.PrepMode) + assert.Equal(t, tt.schema, got.TargetSchema) + }) + } +} + +func TestTargetType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + native string + want string + src dmsengine.Family + dst dmsengine.Family + }{ + { + name: "same family keeps native", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyMySQL, + native: "int unsigned", + want: "int unsigned", + }, + { + name: "mysql int to pg", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyPostgres, + native: "int", + want: "integer", + }, + { + name: "mysql unsigned int to pg", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyPostgres, + native: "int unsigned", + want: "bigint", + }, + { + name: "mysql varchar to pg", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyPostgres, + native: "varchar(20)", + want: "varchar(20)", + }, + { + name: "mysql decimal to pg", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyPostgres, + native: "decimal(10,2)", + want: "numeric(10,2)", + }, + { + name: "mysql datetime to pg", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyPostgres, + native: "datetime", + want: "timestamp", + }, + { + name: "mysql longtext to pg", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyPostgres, + native: "longtext", + want: "text", + }, + { + name: "mysql blob to pg", + src: dmsengine.FamilyMySQL, + dst: dmsengine.FamilyPostgres, + native: "blob", + want: "bytea", + }, + { + name: "pg text to mysql", + src: dmsengine.FamilyPostgres, + dst: dmsengine.FamilyMySQL, + native: "text", + want: "longtext", + }, + { + name: "pg boolean to mysql", + src: dmsengine.FamilyPostgres, + dst: dmsengine.FamilyMySQL, + native: "boolean", + want: "tinyint(1)", + }, + { + name: "pg varchar to mysql", + src: dmsengine.FamilyPostgres, + dst: dmsengine.FamilyMySQL, + native: "character varying(40)", + want: "varchar(40)", + }, + { + name: "pg timestamptz to mysql", + src: dmsengine.FamilyPostgres, + dst: dmsengine.FamilyMySQL, + native: "timestamp with time zone", + want: "datetime(6)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, dmsengine.TargetType(tt.src, tt.dst, tt.native)) + }) + } +} diff --git a/pkgs/dmsengine/model.go b/pkgs/dmsengine/model.go new file mode 100644 index 0000000000..701c8a5ca8 --- /dev/null +++ b/pkgs/dmsengine/model.go @@ -0,0 +1,182 @@ +// Package dmsengine moves rows between databases and in-process stores for the DMS emulator: table-mapping +// evaluation, full load, and change data capture from MySQL binlogs and PostgreSQL logical decoding. +package dmsengine + +import ( + "context" + "strings" + "time" +) + +// Kind is the value class a column's text form belongs to. +type Kind int + +// Column value classes. +const ( + KindString Kind = iota + KindInt + KindDecimal + KindFloat + KindBool + KindTime + KindBytes + KindJSON +) + +// Family groups engine names that share a SQL dialect. +type Family int + +// Engine families. +const ( + FamilyOther Family = iota + FamilyMySQL + FamilyPostgres + FamilyS3 + FamilyDynamoDB + FamilyKinesis +) + +// FamilyOf maps a DMS engine name to its family. +func FamilyOf(engine string) Family { + switch strings.ToLower(engine) { + case "mysql", "mariadb", "aurora", "aurora-mysql": + return FamilyMySQL + case "postgres", "aurora-postgresql": + return FamilyPostgres + case "s3": + return FamilyS3 + case "dynamodb": + return FamilyDynamoDB + case "kinesis": + return FamilyKinesis + default: + return FamilyOther + } +} + +// Column describes one source column. Native is the engine's own type text (e.g. "varchar(20)"). +type Column struct { + Name string + Native string + Kind Kind + Nullable bool +} + +// Table is a source table; PK lists primary-key column names in key order. +type Table struct { + Schema string + Name string + Columns []Column + PK []string +} + +// Key is "schema.table". +func (t Table) Key() string { return t.Schema + "." + t.Name } + +// Row holds a column's text form: nil (NULL), string, or []byte for binary columns. Values line up with a +// table's Columns. +type Row []any + +// Op is a change operation. +type Op string + +// Change operations. +const ( + OpInsert Op = "insert" + OpUpdate Op = "update" + OpDelete Op = "delete" +) + +// Change is one captured row change; Before is set for updates and deletes when the source logs it. +type Change struct { + Time time.Time + Schema string + Table string + Op Op + Position string + Before Row + After Row + Commit bool +} + +// StreamStart says where change capture begins. +type StreamStart struct { + Time time.Time + Position string +} + +// Source reads tables and change streams. +type Source interface { + Ping(ctx context.Context) error + Tables(ctx context.Context) ([]Table, error) + Scan(ctx context.Context, t Table, fn func(Row) error) error + // Position captures the current change position; for PostgreSQL it also creates the replication slot. + Position(ctx context.Context) (string, error) + // Stream delivers changes from start until ctx is done or fn fails. Rows are in the table's column order. + Stream( + ctx context.Context, + start StreamStart, + tables func(schema, table string) (Table, bool), + fn func(Change) error, + ) error + // Release drops source-side resources such as replication slots. + Release(ctx context.Context) error + Close() error +} + +// PrepMode is FullLoadSettings.TargetTablePrepMode. +type PrepMode string + +// Target table preparation modes. +const ( + PrepDropAndCreate PrepMode = "DROP_AND_CREATE" + PrepTruncateBefore PrepMode = "TRUNCATE_BEFORE_LOAD" + PrepDoNothing PrepMode = "DO_NOTHING" +) + +// TargetColumn is a column on the target; Source indexes the originating source column. +type TargetColumn struct { + Column + Source int +} + +// TargetTable is a source table after mapping rules. +type TargetTable struct { + Schema string + Name string + SourceSchema string + SourceName string + Columns []TargetColumn + PK []string + SourceFamily Family +} + +// Key is "schema.table" on the target. +func (t TargetTable) Key() string { return t.Schema + "." + t.Name } + +// Project selects and orders a source row's values for the target. +func (t TargetTable) Project(r Row) Row { + if r == nil { + return nil + } + + out := make(Row, len(t.Columns)) + for i, c := range t.Columns { + if c.Source < len(r) { + out[i] = r[c.Source] + } + } + + return out +} + +// Target receives rows and changes. +type Target interface { + Ping(ctx context.Context) error + Prepare(ctx context.Context, t TargetTable, mode PrepMode) error + Load(ctx context.Context, t TargetTable, rows []Row) error + EndLoad(ctx context.Context, t TargetTable) error + Apply(ctx context.Context, t TargetTable, c Change) error + Commit(ctx context.Context) error + Close() error +} diff --git a/pkgs/dmsengine/mysql_source.go b/pkgs/dmsengine/mysql_source.go new file mode 100644 index 0000000000..44150fd4c2 --- /dev/null +++ b/pkgs/dmsengine/mysql_source.go @@ -0,0 +1,600 @@ +package dmsengine + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math/rand/v2" + "net" + "slices" + "strconv" + "strings" + "time" + + "github.com/go-mysql-org/go-mysql/mysql" + "github.com/go-mysql-org/go-mysql/replication" + sqldriver "github.com/go-sql-driver/mysql" +) + +var errBinlog = errors.New("binary log capture unavailable") + +const ( + positionColumns = 2 + binlogStartPos = 4 + serverIDBase = 2_000_000 + serverIDSpan = 1_000_000 +) + +type mysqlSource struct { + db *sql.DB + cfg EndpointConfig +} + +func mysqlDB(ep EndpointConfig, clientFound bool) (*sql.DB, error) { + c := sqldriver.NewConfig() + c.User, c.Passwd, c.Net, c.Addr, c.DBName = ep.User, ep.Password, "tcp", ep.addr(), ep.Database + c.Timeout = DefaultConnectTimeout + c.ClientFoundRows = clientFound + c.Loc = time.UTC + + tc, err := tlsConfigFor(ep) + if err != nil { + return nil, err + } + + c.TLS = tc + + conn, err := sqldriver.NewConnector(c) + if err != nil { + return nil, err + } + + return sql.OpenDB(conn), nil +} + +func openMySQLSource(_ context.Context, ep EndpointConfig) (Source, error) { + db, err := mysqlDB(ep, false) + if err != nil { + return nil, err + } + + return &mysqlSource{db: db, cfg: ep}, nil +} + +func (s *mysqlSource) Ping(ctx context.Context) error { + return explainTLS(s.cfg, s.db.PingContext(ctx)) +} + +func (s *mysqlSource) Close() error { return s.db.Close() } + +func (s *mysqlSource) Release(context.Context) error { return nil } + +const mysqlColumnsSQL = "SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE " + + "FROM information_schema.COLUMNS c JOIN information_schema.TABLES t " + + "ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_TYPE = 'BASE TABLE' " + + "WHERE c.TABLE_SCHEMA NOT IN ('mysql','information_schema','performance_schema','sys') " + + "ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION" + +const mysqlKeysSQL = "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE " + + "WHERE CONSTRAINT_NAME = 'PRIMARY' " + + "AND TABLE_SCHEMA NOT IN ('mysql','information_schema','performance_schema','sys') " + + "ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION" + +func (s *mysqlSource) Tables(ctx context.Context) ([]Table, error) { + rows, err := s.db.QueryContext(ctx, mysqlColumnsSQL) + if err != nil { + return nil, err + } + defer rows.Close() + + var ( + out []Table + index = map[string]int{} + ) + + for rows.Next() { + var schema, table, col, typ, nullable string + if err = rows.Scan(&schema, &table, &col, &typ, &nullable); err != nil { + return nil, err + } + + if s.cfg.Database != "" && schema != s.cfg.Database { + continue + } + + k := schema + "." + table + i, ok := index[k] + + if !ok { + i = len(out) + index[k] = i + out = append(out, Table{Schema: schema, Name: table}) + } + + out[i].Columns = append( + out[i].Columns, + Column{Name: col, Native: typ, Kind: KindOf(typ), Nullable: nullable == "YES"}, + ) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return out, s.attachKeys(ctx, out, index) +} + +func (s *mysqlSource) attachKeys(ctx context.Context, tables []Table, index map[string]int) error { + rows, err := s.db.QueryContext(ctx, mysqlKeysSQL) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var schema, table, col string + if err = rows.Scan(&schema, &table, &col); err != nil { + return err + } + + if i, ok := index[schema+"."+table]; ok { + tables[i].PK = append(tables[i].PK, col) + } + } + + return rows.Err() +} + +func (s *mysqlSource) Scan(ctx context.Context, t Table, fn func(Row) error) error { + names := make([]string, len(t.Columns)) + for i, c := range t.Columns { + names[i] = QuoteIdent(FamilyMySQL, c.Name) + } + + q := stmt("SELECT ", strings.Join(names, ", "), " FROM ", QuoteIdent(FamilyMySQL, t.Schema), ".", + QuoteIdent(FamilyMySQL, t.Name)) + + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return err + } + defer rows.Close() + + return scanRows(rows, t, fn) +} + +func scanRows(rows *sql.Rows, t Table, fn func(Row) error) error { + vals := make([]any, len(t.Columns)) + ptrs := make([]any, len(vals)) + + for i := range vals { + ptrs[i] = &vals[i] + } + + for rows.Next() { + if err := rows.Scan(ptrs...); err != nil { + return err + } + + row := make(Row, len(vals)) + for i, v := range vals { + row[i] = textForm(v, t.Columns[i].Kind) + } + + if err := fn(row); err != nil { + return err + } + } + + return rows.Err() +} + +func textForm(v any, k Kind) any { + switch x := v.(type) { + case nil: + return nil + case []byte: + if k == KindBytes { + return append([]byte(nil), x...) + } + + return string(x) + case string: + if k == KindBytes { + return []byte(x) + } + + return x + case time.Time: + return x.UTC().Format("2006-01-02 15:04:05.999999") + case bool: + return strconv.FormatBool(x) + default: + return fmt.Sprint(x) + } +} + +func (s *mysqlSource) firstRow(ctx context.Context, q string) ([]any, error) { + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + cols, err := rows.Columns() + if err != nil { + return nil, err + } + + if !rows.Next() { + return nil, rows.Err() + } + + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + + for i := range vals { + ptrs[i] = &vals[i] + } + + return vals, rows.Scan(ptrs...) +} + +func (s *mysqlSource) Position(ctx context.Context) (string, error) { + var format string + if err := s.db.QueryRowContext(ctx, "SELECT @@GLOBAL.binlog_format").Scan(&format); err != nil { + return "", err + } + + if !strings.EqualFold(format, "ROW") { + return "", fmt.Errorf("%w: binlog_format is %s, ROW is required for CDC", errBinlog, format) + } + + row, err := s.firstRow(ctx, "SHOW BINARY LOG STATUS") + if err != nil { + row, err = s.firstRow(ctx, "SHOW MASTER STATUS") + } + + if err != nil { + return "", err + } + + if len(row) < positionColumns { + return "", fmt.Errorf("%w: binary logging is not enabled on the source", errBinlog) + } + + return fmt.Sprintf("%s:%s", anyText(row[0]), anyText(row[1])), nil +} + +func anyText(v any) string { + if b, ok := v.([]byte); ok { + return string(b) + } + + return fmt.Sprint(v) +} + +func parseBinlogPosition(p string) (mysql.Position, bool) { + i := strings.LastIndexByte(p, ':') + if i <= 0 { + return mysql.Position{}, false + } + + pos, err := strconv.ParseUint(p[i+1:], 10, 32) + if err != nil { + return mysql.Position{}, false + } + + return mysql.Position{Name: p[:i], Pos: uint32(pos)}, true +} + +func (s *mysqlSource) startPosition(ctx context.Context, start StreamStart) (mysql.Position, error) { + if p, ok := parseBinlogPosition(start.Position); ok { + return p, nil + } + + if !start.Time.IsZero() { + row, err := s.firstRow(ctx, "SHOW BINARY LOGS") + if err != nil || len(row) == 0 { + return mysql.Position{}, fmt.Errorf("%w: cannot list binary logs", errBinlog) + } + + return mysql.Position{Name: anyText(row[0]), Pos: binlogStartPos}, nil + } + + cur, err := s.Position(ctx) + if err != nil { + return mysql.Position{}, err + } + + p, _ := parseBinlogPosition(cur) + + return p, nil +} + +func (s *mysqlSource) isMariaDB(ctx context.Context) bool { + var v string + + return s.db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&v) == nil && + strings.Contains(strings.ToLower(v), "mariadb") +} + +func (s *mysqlSource) syncerConfig(ctx context.Context) (replication.BinlogSyncerConfig, error) { + host, portStr, err := net.SplitHostPort(s.cfg.addr()) + if err != nil { + return replication.BinlogSyncerConfig{}, err + } + + port, _ := strconv.ParseUint(portStr, 10, 16) + flavor := mysql.MySQLFlavor + + if s.isMariaDB(ctx) { + flavor = mysql.MariaDBFlavor + } + + cfg := replication.BinlogSyncerConfig{ + ServerID: serverIDBase + rand.Uint32N(serverIDSpan), //nolint:gosec // replica ids need uniqueness, not secrecy + Flavor: flavor, Host: host, Port: uint16(port), User: s.cfg.User, Password: s.cfg.Password, + UseDecimal: true, TimestampStringLocation: time.UTC, + } + + tc, err := tlsConfigFor(s.cfg) + if err != nil { + return cfg, err + } + + cfg.TLSConfig = tc + + return cfg, nil +} + +func (s *mysqlSource) Stream( + ctx context.Context, start StreamStart, tables func(string, string) (Table, bool), fn func(Change) error, +) error { + pos, err := s.startPosition(ctx, start) + if err != nil { + return err + } + + cfg, err := s.syncerConfig(ctx) + if err != nil { + return err + } + + syncer := replication.NewBinlogSyncer(cfg) + defer syncer.Close() + + streamer, err := syncer.StartSync(pos) + if err != nil { + return fmt.Errorf("start binlog sync: %w", err) + } + + file := pos.Name + + for { + ev, gerr := streamer.GetEvent(ctx) + if gerr != nil { + if ctx.Err() != nil { + return nil + } + + return gerr + } + + if err = s.handleEvent(ev, &file, start.Time, tables, fn); err != nil { + return err + } + } +} + +func (s *mysqlSource) handleEvent( + ev *replication.BinlogEvent, file *string, since time.Time, tables func(string, string) (Table, bool), + fn func(Change) error, +) error { + ts := time.Unix(int64(ev.Header.Timestamp), 0).UTC() + + switch e := ev.Event.(type) { + case *replication.RotateEvent: + *file = string(e.NextLogName) + case *replication.XIDEvent: + if !ts.Before(since) { + return fn(Change{Time: ts, Commit: true, Position: binlogPos(*file, ev.Header.LogPos)}) + } + case *replication.RowsEvent: + if !ts.Before(since) { + return s.emitRows(ev, e, *file, ts, tables, fn) + } + } + + return nil +} + +func binlogPos(file string, pos uint32) string { return fmt.Sprintf("%s:%d", file, pos) } + +func (s *mysqlSource) emitRows( + ev *replication.BinlogEvent, e *replication.RowsEvent, file string, ts time.Time, + tables func(string, string) (Table, bool), fn func(Change) error, +) error { + t, ok := tables(string(e.Table.Schema), string(e.Table.Table)) + if !ok { + return nil + } + + base := Change{Time: ts, Schema: t.Schema, Table: t.Name, Position: binlogPos(file, ev.Header.LogPos)} + conv := func(r []any) Row { return binlogRow(r, t) } + + switch rowsKind(ev.Header.EventType) { + case OpInsert: + for _, r := range e.Rows { + c := base + c.Op, c.After = OpInsert, conv(r) + + if err := fn(c); err != nil { + return err + } + } + case OpDelete: + for _, r := range e.Rows { + c := base + c.Op, c.Before = OpDelete, conv(r) + + if err := fn(c); err != nil { + return err + } + } + case OpUpdate: + for i := 0; i+1 < len(e.Rows); i += 2 { + c := base + c.Op, c.Before, c.After = OpUpdate, conv(e.Rows[i]), conv(e.Rows[i+1]) + + if err := fn(c); err != nil { + return err + } + } + } + + return nil +} + +// rowsKind maps a rows-event type to the change operation it carries, or "" for other events. +func rowsKind(t replication.EventType) Op { + writes := []replication.EventType{ + replication.WRITE_ROWS_EVENTv0, replication.WRITE_ROWS_EVENTv1, replication.WRITE_ROWS_EVENTv2, + replication.MARIADB_WRITE_ROWS_COMPRESSED_EVENT_V1, + } + deletes := []replication.EventType{ + replication.DELETE_ROWS_EVENTv0, replication.DELETE_ROWS_EVENTv1, replication.DELETE_ROWS_EVENTv2, + replication.MARIADB_DELETE_ROWS_COMPRESSED_EVENT_V1, + } + updates := []replication.EventType{ + replication.UPDATE_ROWS_EVENTv0, replication.UPDATE_ROWS_EVENTv1, replication.UPDATE_ROWS_EVENTv2, + replication.MARIADB_UPDATE_ROWS_COMPRESSED_EVENT_V1, + } + + switch { + case slices.Contains(writes, t): + return OpInsert + case slices.Contains(deletes, t): + return OpDelete + case slices.Contains(updates, t): + return OpUpdate + } + + return "" +} + +func binlogRow(vals []any, t Table) Row { + out := make(Row, len(vals)) + + for i, v := range vals { + var col Column + if i < len(t.Columns) { + col = t.Columns[i] + } + + out[i] = binlogValue(v, col) + } + + return out +} + +func binlogValue(v any, col Column) any { + switch x := v.(type) { + case nil: + return nil + case string: + return textForm(x, col.Kind) + case []byte: + return textForm(x, col.Kind) + case int8: + return intText(int64(x), col) + case int16: + return intText(int64(x), col) + case int32: + return intText(int64(x), col) + case int: + return intText(int64(x), col) + case int64: + return intText(x, col) + case uint64: + return strconv.FormatUint(x, 10) + case float32: + return strconv.FormatFloat(float64(x), 'g', -1, 32) + case float64: + return strconv.FormatFloat(x, 'g', -1, 64) + case fmt.Stringer: + return x.String() + default: + return textForm(v, col.Kind) + } +} + +const ( + bitsTiny = 8 + bitsSmall = 16 + bitsMedium = 24 + bitsInt = 32 + bitsBig = 64 +) + +func unsignedBits(base string) uint { + return map[string]uint{ + tTinyint: bitsTiny, tSmallint: bitsSmall, tMediumint: bitsMedium, tInt: bitsInt, tInteger: bitsInt, + tBigint: bitsBig, + }[base] +} + +func intText(v int64, col Column) string { + p := parseNative(col.Native) + + switch p.base { + case "enum": + return enumName(p.args, v) + case "set": + return setNames(p.args, v) + } + + if p.unsigned && v < 0 { + bits := unsignedBits(p.base) + if bits == 0 || bits >= bitsBig { + return strconv.FormatUint(uint64(v), 10) //nolint:gosec // reinterpreting a signed binlog value as unsigned + } + + return strconv.FormatInt(v+(int64(1)< len(m) { + return "" + } + + return m[idx-1] +} + +func setNames(args string, mask int64) string { + var names []string + + for i, m := range enumMembers(args) { + if mask&(1< 0 AND NOT a.attisdropped " + + "WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema') " + + "AND n.nspname NOT LIKE 'pg_toast%' ORDER BY n.nspname, c.relname, a.attnum" + +const pgKeysSQL = "SELECT n.nspname, c.relname, a.attname FROM pg_index i " + + "JOIN pg_class c ON c.oid = i.indrelid JOIN pg_namespace n ON n.oid = c.relnamespace " + + "JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true " + + "JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum " + + "WHERE i.indisprimary AND n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY n.nspname, c.relname, k.ord" + +func (s *pgSource) Tables(ctx context.Context) ([]Table, error) { + rows, err := s.db.QueryContext(ctx, pgColumnsSQL) + if err != nil { + return nil, err + } + defer rows.Close() + + var ( + out []Table + index = map[string]int{} + ) + + for rows.Next() { + var schema, table, col, typ string + + var nullable bool + if err = rows.Scan(&schema, &table, &col, &typ, &nullable); err != nil { + return nil, err + } + + k := schema + "." + table + i, ok := index[k] + + if !ok { + i = len(out) + index[k] = i + out = append(out, Table{Schema: schema, Name: table}) + } + + out[i].Columns = append(out[i].Columns, Column{Name: col, Native: typ, Kind: KindOf(typ), Nullable: nullable}) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + keys, err := s.db.QueryContext(ctx, pgKeysSQL) + if err != nil { + return nil, err + } + defer keys.Close() + + for keys.Next() { + var schema, table, col string + if err = keys.Scan(&schema, &table, &col); err != nil { + return nil, err + } + + if i, ok := index[schema+"."+table]; ok { + out[i].PK = append(out[i].PK, col) + } + } + + return out, keys.Err() +} + +func (s *pgSource) Scan(ctx context.Context, t Table, fn func(Row) error) error { + names := make([]string, len(t.Columns)) + + for i, c := range t.Columns { + q := QuoteIdent(FamilyPostgres, c.Name) + if c.Kind != KindBytes { + q += "::text" + } + + names[i] = q + } + + q := stmt("SELECT ", strings.Join(names, ", "), " FROM ", QuoteIdent(FamilyPostgres, t.Schema), ".", + QuoteIdent(FamilyPostgres, t.Name)) + + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return err + } + defer rows.Close() + + return scanRows(rows, t, fn) +} + +func (s *pgSource) slot() string { return s.cfg.SlotName } + +func (s *pgSource) Position(ctx context.Context) (string, error) { + var level string + if err := s.db.QueryRowContext(ctx, "SHOW wal_level").Scan(&level); err != nil { + return "", err + } + + if level != "logical" { + return "", fmt.Errorf("%w: wal_level is %s, logical is required for CDC", errLogical, level) + } + + var exists bool + + err := s.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = $1)", s.slot()). + Scan(&exists) + if err != nil { + return "", err + } + + if !exists { + if _, err = s.db.ExecContext( + ctx, + "SELECT pg_create_logical_replication_slot($1, 'test_decoding')", + s.slot(), + ); err != nil { + return "", err + } + } + + var lsn string + if err = s.db.QueryRowContext(ctx, "SELECT pg_current_wal_lsn()::text").Scan(&lsn); err != nil { + return "", err + } + + return lsn, nil +} + +func (s *pgSource) Release(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, + "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1", s.slot()) + + return err +} + +func (s *pgSource) Stream( + ctx context.Context, start StreamStart, tables func(string, string) (Table, bool), fn func(Change) error, +) error { + if start.Position == "" && start.Time.IsZero() { + if _, err := s.Position(ctx); err != nil { + return err + } + } + + poll := s.cfg.PollInterval + if poll <= 0 { + poll = defaultPGPoll + } + + tick := time.NewTicker(poll) + defer tick.Stop() + + for { + if err := s.drain(ctx, start, tables, fn); err != nil { + if ctx.Err() != nil { + return nil + } + + return err + } + + select { + case <-ctx.Done(): + return nil + case <-tick.C: + } + } +} + +func (s *pgSource) drain( + ctx context.Context, start StreamStart, tables func(string, string) (Table, bool), fn func(Change) error, +) error { + rows, err := s.db.QueryContext(ctx, pgChangesSQL, s.slot()) + if err != nil { + return err + } + defer rows.Close() + + var ( + batch []decoded + stamp time.Time + failed error + ) + + for rows.Next() { + var lsn, data string + if err = rows.Scan(&lsn, &data); err != nil { + return err + } + + d, ok := parseDecoded(lsn, data, tables) + if !ok { + continue + } + + if d.commit { + stamp = d.when + failed = emitBatch(batch, stamp, start, fn, d.lsn) + batch = batch[:0] + + if failed != nil { + return failed + } + + continue + } + + batch = append(batch, d) + } + + return rows.Err() +} + +type decoded struct { + when time.Time + lsn string + change Change + commit bool +} + +func emitBatch(batch []decoded, stamp time.Time, start StreamStart, fn func(Change) error, commitLSN string) error { + if stamp.Before(start.Time) { + return nil + } + + for _, d := range batch { + d.change.Time, d.change.Position = stamp, d.lsn + if err := fn(d.change); err != nil { + return err + } + } + + return fn(Change{Time: stamp, Commit: true, Position: commitLSN}) +} + +var commitRe = regexp.MustCompile(`^COMMIT \d+(?: \(at (.+)\))?$`) + +func parseDecoded(lsn, data string, tables func(string, string) (Table, bool)) (decoded, bool) { + if m := commitRe.FindStringSubmatch(data); m != nil { + when := time.Now().UTC() + if t, err := time.Parse("2006-01-02 15:04:05.999999-07", m[1]); err == nil { + when = t.UTC() + } + + return decoded{commit: true, when: when, lsn: lsn}, true + } + + rest, ok := strings.CutPrefix(data, "table ") + if !ok { + return decoded{}, false + } + + schema, table, rest, ok := splitQualified(rest) + if !ok { + return decoded{}, false + } + + t, ok := tables(schema, table) + if !ok { + return decoded{}, false + } + + opName, body, _ := strings.Cut(strings.TrimPrefix(rest, " "), ": ") + c := Change{Schema: t.Schema, Table: t.Name} + + switch opName { + case "INSERT": + c.Op, c.After = OpInsert, tupleRow(parseTuple(body), t) + case "DELETE": + c.Op, c.Before = OpDelete, tupleRow(parseTuple(body), t) + case "UPDATE": + c.Op = OpUpdate + + if oldPart, newPart, hasOld := strings.Cut(body, " new-tuple: "); hasOld { + c.Before = tupleRow(parseTuple(strings.TrimPrefix(oldPart, "old-key: ")), t) + c.After = tupleRow(parseTuple(newPart), t) + } else { + c.After = tupleRow(parseTuple(body), t) + } + default: + return decoded{}, false + } + + return decoded{change: c, lsn: lsn}, true +} + +func splitQualified(s string) (string, string, string, bool) { + schema, rest, ok := readIdent(s) + if !ok || !strings.HasPrefix(rest, ".") { + return "", "", "", false + } + + table, rest, ok := readIdent(rest[1:]) + if !ok || !strings.HasPrefix(rest, ":") { + return "", "", "", false + } + + return schema, table, rest[1:], true +} + +func readIdent(s string) (string, string, bool) { + if strings.HasPrefix(s, `"`) { + var b strings.Builder + + for i := 1; i < len(s); i++ { + if s[i] == '"' { + if i+1 < len(s) && s[i+1] == '"' { + b.WriteByte('"') + i++ + + continue + } + + return b.String(), s[i+1:], true + } + + b.WriteByte(s[i]) + } + + return "", "", false + } + + end := strings.IndexAny(s, ".:[ ") + if end < 0 { + return "", "", false + } + + return s[:end], s[end:], true +} + +// parseTuple reads `name[type]:value ...` pairs; values are quoted (” escapes) or bare tokens. +func parseTuple(s string) map[string]any { + out := map[string]any{} + s = strings.TrimSpace(s) + + for s != "" { + name, rest, ok := readIdent(s) + if !ok || !strings.HasPrefix(rest, "[") { + break + } + + depth, i := 0, 0 + + for ; i < len(rest); i++ { + if rest[i] == '[' { + depth++ + } else if rest[i] == ']' { + depth-- + if depth == 0 { + break + } + } + } + + if i >= len(rest) || i+1 >= len(rest) || rest[i+1] != ':' { + break + } + + val, tail := readValue(rest[i+2:]) + out[name] = val + s = strings.TrimLeft(tail, " ") + } + + return out +} + +func readValue(s string) (any, string) { + if strings.HasPrefix(s, "'") { + var b strings.Builder + + for i := 1; i < len(s); i++ { + if s[i] == '\'' { + if i+1 < len(s) && s[i+1] == '\'' { + b.WriteByte('\'') + i++ + + continue + } + + return b.String(), s[i+1:] + } + + b.WriteByte(s[i]) + } + + return b.String(), "" + } + + tok, tail, _ := strings.Cut(s, " ") + if tok == "null" { + return nil, tail + } + + return tok, tail +} + +func tupleRow(m map[string]any, t Table) Row { + r := make(Row, len(t.Columns)) + + for i, c := range t.Columns { + v := m[c.Name] + if c.Kind == KindBytes { + if str, ok := v.(string); ok { + v = decodeBytea(str) + } + } + + r[i] = v + } + + return r +} + +func decodeBytea(s string) any { + h, ok := strings.CutPrefix(s, `\x`) + if !ok { + return []byte(s) + } + + out := make([]byte, 0, len(h)/hexPairWidth) + + for i := 0; i+1 < len(h); i += hexPairWidth { + n, err := strconv.ParseUint(h[i:i+hexPairWidth], 16, 8) + if err != nil { + return []byte(s) + } + + out = append(out, byte(n)) + } + + return out +} diff --git a/pkgs/dmsengine/s3_parquet.go b/pkgs/dmsengine/s3_parquet.go new file mode 100644 index 0000000000..82b8a22ded --- /dev/null +++ b/pkgs/dmsengine/s3_parquet.go @@ -0,0 +1,143 @@ +package dmsengine + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +func hiveType(c Column) string { + if c.Kind == KindInt { + return tBigint + } + + if c.Kind == KindFloat { + return tDouble + } + + if c.Kind == KindBool { + return tBoolean + } + + p := parseNative(c.Native) + + if c.Kind == KindDecimal && p.args != "" && (p.base == tDecimal || p.base == tNumeric) { + return "decimal(" + strings.ReplaceAll(p.args, " ", "") + ")" + } + + if c.Kind == KindTime { + switch p.base { + case tDate: + return tDate + case tDatetime, tTimestamp, tTimestampN, tTimestampZ: + return tTimestamp + } + } + + return tString +} + +func parquetColumns(tt TargetTable, withOp bool, stampCol string) []parquetColumn { + cols := make([]parquetColumn, 0, len(tt.Columns)+extraColumns) + if withOp { + cols = append(cols, parquetColumn{Name: "Op", Type: tString}) + } + + if stampCol != "" { + cols = append(cols, parquetColumn{Name: stampCol, Type: tString}) + } + + for _, c := range tt.Columns { + cols = append(cols, parquetColumn{Name: c.Name, Type: hiveType(c.Column)}) + } + + return cols +} + +func parquetCell(v any, hive string) (any, error) { + if v == nil { + return v, nil + } + + if b, ok := v.([]byte); ok { + return hex.EncodeToString(b), nil + } + + x, ok := v.(string) + if !ok { + return fmt.Sprint(v), nil + } + + switch hive { + case tBigint: + n, err := strconv.ParseInt(x, 10, 64) + if err != nil { + return nil, fmt.Errorf("value %q is not a bigint: %w", x, err) + } + + return n, nil + case tDouble: + f, err := strconv.ParseFloat(x, 64) + if err != nil { + return nil, fmt.Errorf("value %q is not a double: %w", x, err) + } + + return f, nil + case tBoolean: + return x == "1" || x == "t" || strings.EqualFold(x, "true"), nil + } + + return x, nil +} + +type pqBuf struct { + cols []parquetColumn + rows [][]any + table TargetTable +} + +func newPQBuf(tt TargetTable, withOp bool, stampCol string) *pqBuf { + return &pqBuf{table: tt, cols: parquetColumns(tt, withOp, stampCol)} +} + +func (b *pqBuf) add(op, stamp string, row Row, withOp bool, stampCol string) error { + cells := make([]any, 0, len(b.cols)) + if withOp { + cells = append(cells, op) + } + + if stampCol != "" { + cells = append(cells, stamp) + } + + off := len(cells) + + for i, v := range row { + cell, err := parquetCell(v, b.cols[off+i].Type) + if err != nil { + return err + } + + cells = append(cells, cell) + } + + b.rows = append(b.rows, cells) + + return nil +} + +func (t *s3Target) writeParquet(ctx context.Context, b *pqBuf, file string) error { + if len(b.rows) == 0 { + return nil + } + + var out bytes.Buffer + if err := writeParquetFile(&out, b.cols, b.rows); err != nil { + return err + } + + return t.store.PutObject(ctx, t.opts.BucketName, t.keyFor(b.table, file), out.Bytes()) +} diff --git a/pkgs/dmsengine/s3_parquet_write.go b/pkgs/dmsengine/s3_parquet_write.go new file mode 100644 index 0000000000..fd0b4cdd02 --- /dev/null +++ b/pkgs/dmsengine/s3_parquet_write.go @@ -0,0 +1,13 @@ +package dmsengine + +import ( + "io" + + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +type parquetColumn = parquetio.Column + +func writeParquetFile(w io.Writer, cols []parquetColumn, rows [][]any) error { + return parquetio.Write(w, cols, rows, "") +} diff --git a/pkgs/dmsengine/s3_target.go b/pkgs/dmsengine/s3_target.go new file mode 100644 index 0000000000..dd650cf20a --- /dev/null +++ b/pkgs/dmsengine/s3_target.go @@ -0,0 +1,388 @@ +package dmsengine + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +var ( + errBucketRequired = errors.New("S3Settings.BucketName is required") + errNotPrepared = errors.New("table was not prepared") +) + +const ( + extraColumns = 2 + defaultMaxFileKB = 1048576 + bytesPerKB = 1024 + seqModulo = 1000 +) + +// s3Options mirrors the S3Settings members the writer honours (types.S3Settings). +type s3Options struct { + Rfc4180 *bool `json:"Rfc4180"` + CsvNullValue string `json:"CsvNullValue"` + delimiter string + CsvRowDelimiter string `json:"CsvRowDelimiter"` + BucketName string `json:"BucketName"` + DataFormat string `json:"DataFormat"` + nullValue string + rowDelim string + BucketFolder string `json:"BucketFolder"` + CsvDelimiter string `json:"CsvDelimiter"` + TimestampColumnName string `json:"TimestampColumnName"` + MaxFileSize int `json:"MaxFileSize"` + maxBytes int + CdcInsertsAndUpdates bool `json:"CdcInsertsAndUpdates"` + CdcInsertsOnly bool `json:"CdcInsertsOnly"` + quote bool + IncludeOpForFullLoad bool `json:"IncludeOpForFullLoad"` + AddColumnName bool `json:"AddColumnName"` +} + +type s3Target struct { + store ObjectStore + now func() time.Time + load map[string]*loadFile + cdc map[string]*bytes.Buffer + pqLoad map[string]*pqBuf + pqCDC map[string]*pqBuf + opts s3Options + seq int + mu sync.Mutex + parquet bool +} + +type loadFile struct { + buf bytes.Buffer + next int +} + +func newS3Target(ep EndpointConfig, store ObjectStore) (Target, error) { + if store == nil { + return nil, fmt.Errorf("%w: S3", ErrNotConfigured) + } + + var o s3Options + if ep.S3Settings != "" { + if err := json.Unmarshal([]byte(ep.S3Settings), &o); err != nil { + return nil, fmt.Errorf("invalid S3Settings: %w", err) + } + } + + if o.BucketName == "" { + return nil, errBucketRequired + } + + o.delimiter, o.rowDelim, o.nullValue = o.CsvDelimiter, o.CsvRowDelimiter, o.CsvNullValue + if o.delimiter == "" { + o.delimiter = "," + } + + if o.rowDelim == "" { + o.rowDelim = "\n" + } + + if o.nullValue == "" { + o.nullValue = "NULL" + } + + o.quote = o.Rfc4180 == nil || *o.Rfc4180 + o.maxBytes = defaultMaxFileKB * bytesPerKB + + if o.MaxFileSize > 0 { + o.maxBytes = o.MaxFileSize * bytesPerKB + } + + return &s3Target{ + store: store, opts: o, now: time.Now, load: map[string]*loadFile{}, cdc: map[string]*bytes.Buffer{}, + pqLoad: map[string]*pqBuf{}, pqCDC: map[string]*pqBuf{}, parquet: strings.EqualFold(o.DataFormat, "parquet"), + }, nil +} + +func (t *s3Target) Ping(ctx context.Context) error { + return t.store.CheckBucket(ctx, t.opts.BucketName) +} + +func (t *s3Target) Close() error { return nil } + +func (t *s3Target) keyFor(tt TargetTable, file string) string { + parts := []string{} + if f := strings.Trim(t.opts.BucketFolder, "/"); f != "" { + parts = append(parts, f) + } + + return strings.Join(append(parts, tt.Schema, tt.Name, file), "/") +} + +func (t *s3Target) Prepare(_ context.Context, tt TargetTable, _ PrepMode) error { + t.mu.Lock() + defer t.mu.Unlock() + + t.load[tt.Key()] = &loadFile{next: 1} + if t.parquet { + t.pqLoad[tt.Key()] = newPQBuf(tt, t.opts.IncludeOpForFullLoad, t.opts.TimestampColumnName) + } + + return nil +} + +func (t *s3Target) field(v any) string { + var s string + + switch x := v.(type) { + case nil: + return t.opts.nullValue + case []byte: + s = hex.EncodeToString(x) + default: + s = fmt.Sprint(x) + } + + if t.opts.quote && (strings.Contains(s, t.opts.delimiter) || strings.ContainsAny(s, "\"\r\n")) { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + + return s +} + +func (t *s3Target) line(opCode string, row Row, stamp string) string { + fields := make([]string, 0, len(row)+extraColumns) + if opCode != "" { + fields = append(fields, opCode) + } + + if stamp != "" { + fields = append(fields, stamp) + } + + for _, v := range row { + fields = append(fields, t.field(v)) + } + + return strings.Join(fields, t.opts.delimiter) + t.opts.rowDelim +} + +func (t *s3Target) header(tt TargetTable, withOp bool) string { + if !t.opts.AddColumnName { + return "" + } + + names := make([]string, 0, len(tt.Columns)+extraColumns) + if withOp { + names = append(names, "Op") + } + + if t.opts.TimestampColumnName != "" { + names = append(names, t.opts.TimestampColumnName) + } + + names = append(names, columnNames(tt)...) + + return strings.Join(names, t.opts.delimiter) + t.opts.rowDelim +} + +func (t *s3Target) Load(ctx context.Context, tt TargetTable, rows []Row) error { + t.mu.Lock() + lf := t.load[tt.Key()] + pq := t.pqLoad[tt.Key()] + t.mu.Unlock() + + if t.parquet && pq != nil { + return t.loadParquet(pq, rows) + } + + if lf == nil { + return fmt.Errorf("%w: %s", errNotPrepared, tt.Key()) + } + + stamp := "" + if t.opts.TimestampColumnName != "" { + stamp = t.now().UTC().Format("2006-01-02 15:04:05.000000") + } + + for _, r := range rows { + if lf.buf.Len() == 0 { + lf.buf.WriteString(t.header(tt, t.opts.IncludeOpForFullLoad)) + } + + op := "" + if t.opts.IncludeOpForFullLoad { + op = "I" + } + + lf.buf.WriteString(t.line(op, r, stamp)) + + if lf.buf.Len() >= t.opts.maxBytes { + if err := t.flushLoad(ctx, tt, lf); err != nil { + return err + } + } + } + + return nil +} + +func (t *s3Target) flushLoad(ctx context.Context, tt TargetTable, lf *loadFile) error { + if lf.buf.Len() == 0 { + return nil + } + + key := t.keyFor(tt, fmt.Sprintf("LOAD%08d.csv", lf.next)) + if err := t.store.PutObject(ctx, t.opts.BucketName, key, lf.buf.Bytes()); err != nil { + return err + } + + lf.next++ + lf.buf.Reset() + + return nil +} + +func (t *s3Target) EndLoad(ctx context.Context, tt TargetTable) error { + t.mu.Lock() + lf := t.load[tt.Key()] + pq := t.pqLoad[tt.Key()] + t.mu.Unlock() + + if t.parquet { + if pq == nil { + return nil + } + + return t.writeParquet(ctx, pq, "LOAD00000001.parquet") + } + + if lf == nil { + return nil + } + + if lf.next == 1 && lf.buf.Len() == 0 { + lf.buf.WriteString(t.header(tt, t.opts.IncludeOpForFullLoad)) + } + + return t.flushLoad(ctx, tt, lf) +} + +func (t *s3Target) Apply(_ context.Context, tt TargetTable, c Change) error { + if t.opts.CdcInsertsOnly && c.Op != OpInsert { + return nil + } + + if t.opts.CdcInsertsAndUpdates && c.Op == OpDelete { + return nil + } + + row, code := c.After, "I" + + switch c.Op { + case OpInsert: + case OpUpdate: + code = "U" + case OpDelete: + row, code = c.Before, "D" + } + + if t.opts.CdcInsertsOnly { + code = "" + } + + stamp := "" + if t.opts.TimestampColumnName != "" { + stamp = c.Time.UTC().Format("2006-01-02 15:04:05.000000") + } + + t.mu.Lock() + defer t.mu.Unlock() + + if t.parquet { + return t.applyParquet(tt, c, row, code, stamp) + } + + buf := t.cdc[tt.Key()] + if buf == nil { + buf = &bytes.Buffer{} + t.cdc[tt.Key()] = buf + buf.WriteString(t.header(tt, code != "")) + } + + buf.WriteString(t.line(code, row, stamp)) + + return nil +} + +func (t *s3Target) tableOf(key string) TargetTable { + schema, name, _ := strings.Cut(key, ".") + + return TargetTable{Schema: schema, Name: name} +} + +func (t *s3Target) Commit(ctx context.Context) error { + t.mu.Lock() + pending := t.cdc + t.cdc = map[string]*bytes.Buffer{} + pendingPQ := t.pqCDC + t.pqCDC = map[string]*pqBuf{} + t.seq++ + seq := t.seq + t.mu.Unlock() + + if t.parquet { + name := t.now().UTC().Format("20060102-150405") + fmt.Sprintf("%03d", seq%seqModulo) + ".parquet" + + for _, b := range pendingPQ { + if err := t.writeParquet(ctx, b, name); err != nil { + return err + } + } + + return nil + } + + name := t.now().UTC().Format("20060102-150405") + fmt.Sprintf("%03d", seq%seqModulo) + ".csv" + + for key, buf := range pending { + if buf.Len() == 0 { + continue + } + + if err := t.store.PutObject(ctx, t.opts.BucketName, t.keyFor(t.tableOf(key), name), buf.Bytes()); err != nil { + return err + } + } + + return nil +} + +func (t *s3Target) loadParquet(pq *pqBuf, rows []Row) error { + stamp := "" + if t.opts.TimestampColumnName != "" { + stamp = t.now().UTC().Format("2006-01-02 15:04:05.000000") + } + + for _, r := range rows { + if err := pq.add("I", stamp, r, t.opts.IncludeOpForFullLoad, t.opts.TimestampColumnName); err != nil { + return err + } + } + + return nil +} + +func (t *s3Target) applyParquet(tt TargetTable, _ Change, row Row, code, stamp string) error { + withOp := code != "" + + b := t.pqCDC[tt.Key()] + if b == nil { + b = newPQBuf(tt, withOp, t.opts.TimestampColumnName) + t.pqCDC[tt.Key()] = b + } + + return b.add(code, stamp, row, withOp, t.opts.TimestampColumnName) +} diff --git a/pkgs/dmsengine/settings.go b/pkgs/dmsengine/settings.go new file mode 100644 index 0000000000..62a678aa5b --- /dev/null +++ b/pkgs/dmsengine/settings.go @@ -0,0 +1,58 @@ +package dmsengine + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// ErrInvalidSettings reports an unusable ReplicationTaskSettings document. +var ErrInvalidSettings = errors.New("invalid ReplicationTaskSettings") + +// TaskSettings is the subset of ReplicationTaskSettings the engine honours. +type TaskSettings struct { + PrepMode PrepMode + TargetSchema string + CommitRate int +} + +// ParseTaskSettings reads ReplicationTaskSettings JSON; empty input yields the defaults. +func ParseTaskSettings(doc string) (TaskSettings, error) { + ts := TaskSettings{PrepMode: PrepDropAndCreate, CommitRate: defaultCommitRate} + if strings.TrimSpace(doc) == "" { + return ts, nil + } + + var raw struct { + TargetMetadata struct { + TargetSchema string `json:"TargetSchema"` + } `json:"TargetMetadata"` + FullLoadSettings struct { + TargetTablePrepMode string `json:"TargetTablePrepMode"` + CommitRate int `json:"CommitRate"` + } `json:"FullLoadSettings"` + } + + if err := json.Unmarshal([]byte(doc), &raw); err != nil { + return ts, fmt.Errorf("%w: %w", ErrInvalidSettings, err) + } + + ts.TargetSchema = raw.TargetMetadata.TargetSchema + + if raw.FullLoadSettings.CommitRate > 0 { + ts.CommitRate = raw.FullLoadSettings.CommitRate + } + + switch m := PrepMode(raw.FullLoadSettings.TargetTablePrepMode); m { + case "": + case PrepDropAndCreate, PrepTruncateBefore, PrepDoNothing: + ts.PrepMode = m + default: + return ts, fmt.Errorf("%w: FullLoadSettings.TargetTablePrepMode %q", ErrInvalidSettings, m) + } + + return ts, nil +} + +const defaultCommitRate = 10000 diff --git a/pkgs/dmsengine/sql_target.go b/pkgs/dmsengine/sql_target.go new file mode 100644 index 0000000000..5d426d1db2 --- /dev/null +++ b/pkgs/dmsengine/sql_target.go @@ -0,0 +1,325 @@ +package dmsengine + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strconv" + "strings" +) + +const ( + maxBindParams = 30000 + updateArgsFactor = 2 + varcharKey = "varchar(255)" +) + +var errNoRowImage = errors.New("delete has no row image") + +type sqlTarget struct { + db *sql.DB + ep EndpointConfig + fam Family + src Family +} + +func openSQLTarget(_ context.Context, ep EndpointConfig, fam, src Family) (Target, error) { + var ( + db *sql.DB + err error + ) + + if fam == FamilyPostgres { + db, err = pgDB(ep) + } else { + db, err = mysqlDB(ep, true) + } + + if err != nil { + return nil, err + } + + return &sqlTarget{db: db, fam: fam, src: src, ep: ep}, nil +} + +func (t *sqlTarget) Ping(ctx context.Context) error { + return explainTLS(t.ep, t.db.PingContext(ctx)) +} + +func (t *sqlTarget) Close() error { return t.db.Close() } + +func (t *sqlTarget) Commit(context.Context) error { return nil } + +func (t *sqlTarget) q(name string) string { return QuoteIdent(t.fam, name) } + +func (t *sqlTarget) qualified(tt TargetTable) string { return t.q(tt.Schema) + "." + t.q(tt.Name) } + +func (t *sqlTarget) ph(n int) string { + if t.fam == FamilyPostgres { + return "$" + strconv.Itoa(n) + } + + return "?" +} + +func (t *sqlTarget) columnType(tt TargetTable, c TargetColumn) string { + typ := TargetType(tt.SourceFamily, t.fam, c.Native) + + if t.fam == FamilyMySQL && (typ == tLongtext || typ == tText) && slicesContain(tt.PK, c.Name) { + return varcharKey + } + + return typ +} + +func slicesContain(list []string, s string) bool { return slices.Contains(list, s) } + +func (t *sqlTarget) createSQL(tt TargetTable) string { + defs := make([]string, 0, len(tt.Columns)+1) + + for _, c := range tt.Columns { + def := t.q(c.Name) + " " + t.columnType(tt, c) + if !c.Nullable || slicesContain(tt.PK, c.Name) { + def += " NOT NULL" + } + + defs = append(defs, def) + } + + if len(tt.PK) > 0 { + defs = append(defs, "PRIMARY KEY ("+t.quoteAll(tt.PK)+")") + } + + return "CREATE TABLE IF NOT EXISTS " + t.qualified(tt) + " (" + strings.Join(defs, ", ") + ")" +} + +func (t *sqlTarget) quoteAll(names []string) string { + q := make([]string, len(names)) + for i, n := range names { + q[i] = t.q(n) + } + + return strings.Join(q, ", ") +} + +func (t *sqlTarget) Prepare(ctx context.Context, tt TargetTable, mode PrepMode) error { + create := stmt("CREATE SCHEMA IF NOT EXISTS ", t.q(tt.Schema)) + if t.fam == FamilyMySQL { + create = stmt("CREATE DATABASE IF NOT EXISTS ", t.q(tt.Schema)) + } + + if _, err := t.db.ExecContext(ctx, create); err != nil { + return err + } + + if mode == PrepDropAndCreate { + if _, err := t.db.ExecContext(ctx, stmt("DROP TABLE IF EXISTS ", t.qualified(tt))); err != nil { + return err + } + } + + if _, err := t.db.ExecContext(ctx, t.createSQL(tt)); err != nil { + return err + } + + if mode == PrepTruncateBefore { + _, err := t.db.ExecContext(ctx, stmt("TRUNCATE TABLE ", t.qualified(tt))) + + return err + } + + return nil +} + +func (t *sqlTarget) EndLoad(context.Context, TargetTable) error { return nil } + +func (t *sqlTarget) Load(ctx context.Context, tt TargetTable, rows []Row) error { + if len(rows) == 0 || len(tt.Columns) == 0 { + return nil + } + + per := max(1, maxBindParams/len(tt.Columns)) + cols := t.quoteAll(columnNames(tt)) + + for lo := 0; lo < len(rows); lo += per { + chunk := rows[lo:min(lo+per, len(rows))] + args := make([]any, 0, len(chunk)*len(tt.Columns)) + groups := make([]string, len(chunk)) + n := 0 + + for i, r := range chunk { + ph := make([]string, len(tt.Columns)) + + for j := range tt.Columns { + n++ + ph[j] = t.ph(n) + args = append(args, r[j]) + } + + groups[i] = "(" + strings.Join(ph, ", ") + ")" + } + + q := stmt("INSERT INTO ", t.qualified(tt), " (", cols, ") VALUES ", strings.Join(groups, ", ")) + if _, err := t.db.ExecContext(ctx, q, args...); err != nil { + return err + } + } + + return nil +} + +func columnNames(tt TargetTable) []string { + out := make([]string, len(tt.Columns)) + for i, c := range tt.Columns { + out[i] = c.Name + } + + return out +} + +func (t *sqlTarget) Apply(ctx context.Context, tt TargetTable, c Change) error { + switch c.Op { + case OpInsert: + return t.upsert(ctx, tt, c.After) + case OpDelete: + return t.deleteRow(ctx, tt, c.Before) + case OpUpdate: + return t.update(ctx, tt, c) + } + + return nil +} + +func (t *sqlTarget) upsert(ctx context.Context, tt TargetTable, row Row) error { + cols := t.quoteAll(columnNames(tt)) + ph := make([]string, len(tt.Columns)) + + for i := range ph { + ph[i] = t.ph(i + 1) + } + + q := stmt("INSERT INTO ", t.qualified(tt), " (", cols, ") VALUES (", strings.Join(ph, ", "), ")") + + var sets []string + + for _, c := range tt.Columns { + if !slicesContain(tt.PK, c.Name) { + if t.fam == FamilyPostgres { + sets = append(sets, t.q(c.Name)+" = EXCLUDED."+t.q(c.Name)) + } else { + sets = append(sets, t.q(c.Name)+" = VALUES("+t.q(c.Name)+")") + } + } + } + + switch { + case len(tt.PK) == 0: + case t.fam == FamilyPostgres && len(sets) == 0: + q += " ON CONFLICT (" + t.quoteAll(tt.PK) + ") DO NOTHING" + case t.fam == FamilyPostgres: + q += " ON CONFLICT (" + t.quoteAll(tt.PK) + ") DO UPDATE SET " + strings.Join(sets, ", ") + case len(sets) > 0: + q += " ON DUPLICATE KEY UPDATE " + strings.Join(sets, ", ") + default: + q = strings.Replace(q, "INSERT INTO", "INSERT IGNORE INTO", 1) + } + + _, err := t.db.ExecContext(ctx, q, rowArgs(row)...) + + return err +} + +func rowArgs(r Row) []any { + out := make([]any, len(r)) + copy(out, r) + + return out +} + +// where builds a key predicate from the image: the primary key when present, otherwise every column. +func (t *sqlTarget) where(tt TargetTable, image Row, start int) (string, []any) { + var ( + parts []string + args []any + ) + + for i, c := range tt.Columns { + if len(tt.PK) > 0 && !slicesContain(tt.PK, c.Name) { + continue + } + + if image[i] == nil { + parts = append(parts, t.q(c.Name)+" IS NULL") + + continue + } + + parts = append(parts, t.q(c.Name)+" = "+t.ph(start+len(args)+1)) + args = append(args, image[i]) + } + + return strings.Join(parts, " AND "), args +} + +func (t *sqlTarget) deleteRow(ctx context.Context, tt TargetTable, before Row) error { + if before == nil { + return fmt.Errorf("%w: %s", errNoRowImage, tt.Key()) + } + + w, args := t.where(tt, before, 0) + _, err := t.db.ExecContext(ctx, stmt("DELETE FROM ", t.qualified(tt), " WHERE ", w), args...) + + return err +} + +func (t *sqlTarget) update(ctx context.Context, tt TargetTable, c Change) error { + image := c.Before + if image == nil || !keyComplete(tt, image) { + image = c.After + } + + sets := make([]string, len(tt.Columns)) + args := make([]any, 0, len(tt.Columns)*updateArgsFactor) + + for i, col := range tt.Columns { + sets[i] = t.q(col.Name) + " = " + t.ph(i+1) + args = append(args, c.After[i]) + } + + w, wargs := t.where(tt, image, len(tt.Columns)) + args = append(args, wargs...) + + update := stmt("UPDATE ", t.qualified(tt), " SET ", strings.Join(sets, ", "), " WHERE ", w) + res, err := t.db.ExecContext( + ctx, + update, + args...) + if err != nil { + return err + } + + if n, nerr := res.RowsAffected(); nerr == nil && n == 0 { + return t.upsert(ctx, tt, c.After) + } + + return nil +} + +func keyComplete(tt TargetTable, image Row) bool { + if len(tt.PK) == 0 { + return true + } + + for i, c := range tt.Columns { + if slicesContain(tt.PK, c.Name) && image[i] == nil { + return false + } + } + + return true +} + +// stmt joins SQL fragments whose identifiers were already quoted with QuoteIdent. +func stmt(parts ...string) string { return strings.Join(parts, "") } diff --git a/pkgs/dmsengine/sql_target_internal_test.go b/pkgs/dmsengine/sql_target_internal_test.go new file mode 100644 index 0000000000..5d7b6a3162 --- /dev/null +++ b/pkgs/dmsengine/sql_target_internal_test.go @@ -0,0 +1,86 @@ +package dmsengine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSQLTargetCreate(t *testing.T) { + t.Parallel() + + tab := func(src Family, pk []string, cols ...TargetColumn) TargetTable { + return TargetTable{SourceFamily: src, Schema: "s", Name: "t", PK: pk, Columns: cols} + } + col := func(name, native string, nullable bool) TargetColumn { + return TargetColumn{Name: name, Native: native, Nullable: nullable} + } + + tests := []struct { + name string + want string + tab TargetTable + fam Family + }{ + { + name: "mysql to mysql keeps native types", + fam: FamilyMySQL, + tab: tab(FamilyMySQL, []string{"id"}, col("id", "int unsigned", false), col("n", "varchar(5)", true)), + want: "CREATE TABLE IF NOT EXISTS `s`.`t` (`id` int unsigned NOT NULL, `n` varchar(5), PRIMARY KEY (`id`))", + }, + { + name: "mysql to postgres maps types and quotes", + fam: FamilyPostgres, + tab: tab( + FamilyMySQL, + []string{"id"}, + col("id", "int", false), + col("When", "datetime", true), + col("p", "decimal(8,2)", true), + ), + want: `CREATE TABLE IF NOT EXISTS "s"."t" ("id" integer NOT NULL, "When" timestamp, "p" numeric(8,2), ` + + `PRIMARY KEY ("id"))`, + }, + { + name: "postgres text pk becomes varchar on mysql", + fam: FamilyMySQL, + tab: tab(FamilyPostgres, []string{"k"}, col("k", "text", false), col("v", "text", true)), + want: "CREATE TABLE IF NOT EXISTS `s`.`t` (`k` varchar(255) NOT NULL, `v` longtext, PRIMARY KEY (`k`))", + }, + { + name: "no primary key", + fam: FamilyPostgres, + tab: tab(FamilyPostgres, nil, col("a", "integer", true)), + want: `CREATE TABLE IF NOT EXISTS "s"."t" ("a" integer)`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tg := &sqlTarget{fam: tt.fam, src: tt.tab.SourceFamily} + assert.Equal(t, tt.want, tg.createSQL(tt.tab)) + }) + } +} + +func TestSQLTargetWhere(t *testing.T) { + t.Parallel() + + tab := TargetTable{PK: []string{"id"}, Columns: []TargetColumn{ + {Name: "id"}, {Name: "n"}, + }} + keyless := TargetTable{Columns: tab.Columns} + + pg := &sqlTarget{fam: FamilyPostgres} + my := &sqlTarget{fam: FamilyMySQL} + + w, args := pg.where(tab, Row{"1", "x"}, 2) + assert.Equal(t, `"id" = $3`, w) + assert.Equal(t, []any{"1"}, args) + + w, args = my.where(keyless, Row{"1", nil}, 0) + assert.Equal(t, "`id` = ? AND `n` IS NULL", w) + assert.Equal(t, []any{"1"}, args) +} diff --git a/pkgs/dmsengine/targets_test.go b/pkgs/dmsengine/targets_test.go new file mode 100644 index 0000000000..0e17e83597 --- /dev/null +++ b/pkgs/dmsengine/targets_test.go @@ -0,0 +1,385 @@ +package dmsengine_test + +import ( + "context" + "encoding/json" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +type memObjects struct { + objs map[string]string + mu sync.Mutex +} + +func (m *memObjects) PutObject(_ context.Context, bucket, key string, body []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.objs == nil { + m.objs = map[string]string{} + } + + m.objs[bucket+"/"+key] = string(body) + + return nil +} + +func (m *memObjects) CheckBucket(_ context.Context, bucket string) error { + if bucket == "missing" { + return errBoom + } + + return nil +} + +type memStream struct { + recs []string + keys []string +} + +func (m *memStream) PutRecord(_ context.Context, _, key string, data []byte) error { + m.recs, m.keys = append(m.recs, string(data)), append(m.keys, key) + + return nil +} + +func (m *memStream) CheckStream(context.Context, string) error { return nil } + +type memDynamo struct { + keys map[string][]dmsengine.DynamoKey + items map[string]map[string]dmsengine.DynamoValue + dropped []string +} + +func newMemDynamo() *memDynamo { + return &memDynamo{keys: map[string][]dmsengine.DynamoKey{}, items: map[string]map[string]dmsengine.DynamoValue{}} +} + +func (m *memDynamo) CreateTable(_ context.Context, name string, keys []dmsengine.DynamoKey) error { + m.keys[name] = keys + + return nil +} + +func (m *memDynamo) DropTable(_ context.Context, name string) error { + m.dropped = append(m.dropped, name) + + return nil +} + +func (m *memDynamo) PutItem(_ context.Context, table string, item map[string]dmsengine.DynamoValue) error { + m.items[table+"|"+item["id"].S] = item + + return nil +} + +func (m *memDynamo) DeleteItem(_ context.Context, table string, key map[string]dmsengine.DynamoValue) error { + delete(m.items, table+"|"+key["id"].S) + + return nil +} + +func itemsTarget() dmsengine.TargetTable { + return dmsengine.TargetTable{ + SourceFamily: dmsengine.FamilyMySQL, Schema: "app", Name: "items", PK: []string{"id"}, + Columns: []dmsengine.TargetColumn{ + {Name: "id", Native: "int", Kind: dmsengine.KindInt, Source: 0}, + {Name: "name", Native: "varchar(10)", Nullable: true, Source: 1}, + }, + } +} + +func TestS3TargetCSV(t *testing.T) { + t.Parallel() + + tests := []struct { + want map[string]string + name string + settings string + }{ + { + name: "defaults", + settings: `{"BucketName":"b","BucketFolder":"out"}`, + want: map[string]string{ + "b/out/app/items/LOAD00000001.csv": "1,alpha\n2,NULL\n3,\"x,y\"\n", + "b/out/app/items/cdc": "I,4,new\nU,1,ALPHA\nD,2,NULL\n", + }, + }, + { + name: "custom delimiters header op and null", + settings: `{"BucketName":"b","CsvDelimiter":"|","CsvRowDelimiter":"\r\n","CsvNullValue":"\\N",` + + `"AddColumnName":true,"IncludeOpForFullLoad":true}`, + want: map[string]string{ + "b/app/items/LOAD00000001.csv": "Op|id|name\r\nI|1|alpha\r\nI|2|\\N\r\nI|3|x,y\r\n", + "b/app/items/cdc": "Op|id|name\r\nI|4|new\r\nU|1|ALPHA\r\nD|2|\\N\r\n", + }, + }, + { + name: "inserts only drops the op column and other ops", + settings: `{"BucketName":"b","CdcInsertsOnly":true}`, + want: map[string]string{ + "b/app/items/LOAD00000001.csv": "1,alpha\n2,NULL\n3,\"x,y\"\n", + "b/app/items/cdc": "4,new\n", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := &memObjects{} + tgt, err := dmsengine.NewOpener(dmsengine.Stores{Objects: store}).OpenTarget( + t.Context(), dmsengine.EndpointConfig{Engine: "s3", S3Settings: tt.settings}, dmsengine.FamilyMySQL) + require.NoError(t, err) + + tab := itemsTarget() + require.NoError(t, tgt.Prepare(t.Context(), tab, dmsengine.PrepDropAndCreate)) + require.NoError(t, tgt.Load(t.Context(), tab, []dmsengine.Row{{"1", "alpha"}, {"2", nil}, {"3", "x,y"}})) + require.NoError(t, tgt.EndLoad(t.Context(), tab)) + require.NoError( + t, + tgt.Apply(t.Context(), tab, dmsengine.Change{Op: dmsengine.OpInsert, After: dmsengine.Row{"4", "new"}}), + ) + require.NoError(t, tgt.Apply(t.Context(), tab, dmsengine.Change{ + Op: dmsengine.OpUpdate, Before: dmsengine.Row{"1", "alpha"}, After: dmsengine.Row{"1", "ALPHA"}, + })) + require.NoError( + t, + tgt.Apply(t.Context(), tab, dmsengine.Change{Op: dmsengine.OpDelete, Before: dmsengine.Row{"2", nil}}), + ) + require.NoError(t, tgt.Commit(t.Context())) + + got := map[string]string{} + + for k, v := range store.objs { + if strings.Contains(k, "LOAD") { + got[k] = v + } else { + got[k[:strings.LastIndex(k, "/")]+"/cdc"] = v + assert.Regexp(t, `/\d{8}-\d{6}\d{3}\.csv$`, k) + } + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestS3TargetRejects(t *testing.T) { + t.Parallel() + + tests := []struct { + stores dmsengine.Stores + name string + settings string + }{ + {name: "no bucket", settings: `{}`, stores: dmsengine.Stores{Objects: &memObjects{}}}, + {name: "bad json", settings: `{`, stores: dmsengine.Stores{Objects: &memObjects{}}}, + {name: "no store", settings: `{"BucketName":"b"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := dmsengine.NewOpener(tt.stores).OpenTarget( + t.Context(), dmsengine.EndpointConfig{Engine: "s3", S3Settings: tt.settings}, dmsengine.FamilyMySQL) + require.Error(t, err) + }) + } +} + +func TestEndpointPing(t *testing.T) { + t.Parallel() + + o := dmsengine.NewOpener(dmsengine.Stores{Objects: &memObjects{}}) + + require.NoError(t, dmsengine.TestEndpoint(t.Context(), o, dmsengine.EndpointConfig{ + Engine: "s3", S3Settings: `{"BucketName":"b"}`, + }, false)) + require.Error(t, dmsengine.TestEndpoint(t.Context(), o, dmsengine.EndpointConfig{ + Engine: "s3", S3Settings: `{"BucketName":"missing"}`, + }, false)) + require.ErrorIs(t, dmsengine.TestEndpoint(t.Context(), o, dmsengine.EndpointConfig{Engine: "oracle"}, true), + dmsengine.ErrUnsupportedEngine) +} + +func TestKinesisTargetRecords(t *testing.T) { + t.Parallel() + + store := &memStream{} + tgt, err := dmsengine.NewOpener(dmsengine.Stores{Streams: store}).OpenTarget(t.Context(), dmsengine.EndpointConfig{ + Engine: "kinesis", + KinesisSettings: `{"StreamArn":"arn:aws:kinesis:us-east-1:000000000000:stream/s","MessageFormat":"json-unformatted"}`, + }, dmsengine.FamilyMySQL) + require.NoError(t, err) + + tab := itemsTarget() + require.NoError(t, tgt.Load(t.Context(), tab, []dmsengine.Row{{"1", "alpha"}, {"2", nil}})) + require.NoError(t, tgt.Apply(t.Context(), tab, dmsengine.Change{ + Op: dmsengine.OpDelete, + Before: dmsengine.Row{"1", "alpha"}, + Time: time.Date(2024, 1, 2, 3, 4, 5, 123456000, time.UTC), + })) + + require.Len(t, store.recs, 3) + assert.Equal(t, []string{"app.items", "app.items", "app.items"}, store.keys) + assert.NotContains(t, store.recs[0], "\n") + + var first, second, del map[string]map[string]any + + require.NoError(t, json.Unmarshal([]byte(store.recs[0]), &first)) + require.NoError(t, json.Unmarshal([]byte(store.recs[1]), &second)) + require.NoError(t, json.Unmarshal([]byte(store.recs[2]), &del)) + + assert.Equal(t, map[string]any{"id": float64(1), "name": "alpha"}, first["data"]) + assert.Equal(t, map[string]any{"id": float64(2)}, second["data"], "nulls are omitted by default") + assert.Equal(t, "load", first["metadata"]["operation"]) + assert.Equal(t, "delete", del["metadata"]["operation"]) + assert.Equal(t, "2024-01-02T03:04:05.123456Z", del["metadata"]["timestamp"]) + assert.Equal(t, "schema-table", del["metadata"]["partition-key-type"]) +} + +func TestKinesisTargetControlRecordAndNulls(t *testing.T) { + t.Parallel() + + store := &memStream{} + tgt, err := dmsengine.NewOpener(dmsengine.Stores{Streams: store}).OpenTarget(t.Context(), dmsengine.EndpointConfig{ + Engine: "kinesis", + KinesisSettings: `{"StreamArn":"arn:aws:kinesis:us-east-1:000000000000:stream/s",` + + `"IncludeControlDetails":true,"IncludeNullAndEmpty":true}`, + }, dmsengine.FamilyMySQL) + require.NoError(t, err) + + tab := itemsTarget() + require.NoError(t, tgt.Prepare(t.Context(), tab, dmsengine.PrepDropAndCreate)) + require.NoError(t, tgt.Load(t.Context(), tab, []dmsengine.Row{{"2", nil}})) + + require.Len(t, store.recs, 2) + assert.Contains(t, store.recs[0], `"record-type": "control"`) + assert.Contains(t, store.recs[0], `"operation": "create-table"`) + assert.Contains(t, store.recs[1], `"name": null`) +} + +func TestDynamoTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode dmsengine.PrepMode + pk []string + wantErr bool + wantDropped bool + }{ + {name: "creates table keyed by pk", pk: []string{"id"}, mode: dmsengine.PrepDropAndCreate, wantDropped: true}, + {name: "do nothing keeps table", pk: []string{"id"}, mode: dmsengine.PrepDoNothing}, + {name: "no key fails", pk: nil, mode: dmsengine.PrepDropAndCreate, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := newMemDynamo() + tgt, err := dmsengine.NewOpener(dmsengine.Stores{Dynamo: store}).OpenTarget( + t.Context(), dmsengine.EndpointConfig{Engine: "dynamodb"}, dmsengine.FamilyMySQL) + require.NoError(t, err) + + tab := itemsTarget() + tab.PK = tt.pk + + err = tgt.Prepare(t.Context(), tab, tt.mode) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.Equal(t, []dmsengine.DynamoKey{{Name: "id", Number: true}}, store.keys["items"]) + assert.Equal(t, tt.wantDropped, len(store.dropped) == 1) + + require.NoError(t, tgt.Load(t.Context(), tab, []dmsengine.Row{{"1", "alpha"}, {"2", nil}})) + assert.Equal(t, dmsengine.DynamoValue{Type: "N", S: "1"}, store.items["items|1"]["id"]) + assert.Equal(t, dmsengine.DynamoValue{Type: "S", S: "alpha"}, store.items["items|1"]["name"]) + assert.NotContains(t, store.items["items|2"], "name") + + require.NoError(t, tgt.Apply(t.Context(), tab, dmsengine.Change{ + Op: dmsengine.OpUpdate, Before: dmsengine.Row{"1", "alpha"}, After: dmsengine.Row{"1", "beta"}, + })) + assert.Equal(t, "beta", store.items["items|1"]["name"].S) + + require.NoError( + t, + tgt.Apply(t.Context(), tab, dmsengine.Change{Op: dmsengine.OpDelete, Before: dmsengine.Row{"2", nil}}), + ) + + keys := make([]string, 0, len(store.items)) + for k := range store.items { + keys = append(keys, k) + } + + sort.Strings(keys) + assert.Equal(t, []string{"items|1"}, keys) + }) + } +} + +func TestS3TargetParquet(t *testing.T) { + t.Parallel() + + store := &memObjects{} + tgt, err := dmsengine.NewOpener(dmsengine.Stores{Objects: store}).OpenTarget( + t.Context(), dmsengine.EndpointConfig{Engine: "s3", S3Settings: `{"BucketName":"b","DataFormat":"parquet"}`}, + dmsengine.FamilyMySQL) + require.NoError(t, err) + + tab := itemsTarget() + require.NoError(t, tgt.Prepare(t.Context(), tab, dmsengine.PrepDropAndCreate)) + require.NoError(t, tgt.Load(t.Context(), tab, []dmsengine.Row{{"1", "alpha"}, {"2", nil}})) + require.NoError(t, tgt.EndLoad(t.Context(), tab)) + require.NoError( + t, + tgt.Apply(t.Context(), tab, dmsengine.Change{Op: dmsengine.OpInsert, After: dmsengine.Row{"3", "new"}}), + ) + require.NoError(t, tgt.Commit(t.Context())) + + var load, cdc string + + for k, v := range store.objs { + if strings.Contains(k, "LOAD00000001.parquet") { + load = v + } else if strings.HasSuffix(k, ".parquet") { + cdc = v + } + } + + read := func(data string) [][]any { + f, oerr := parquetio.Open([]byte(data)) + require.NoError(t, oerr) + + var rows [][]any + + require.NoError(t, f.ForEach(f.Columns(), func(vals []any) error { + rows = append(rows, append([]any(nil), vals...)) + + return nil + })) + + return rows + } + + assert.Equal(t, [][]any{{int64(1), "alpha"}, {int64(2), nil}}, read(load)) + assert.Equal(t, [][]any{{"I", int64(3), "new"}}, read(cdc)) +} diff --git a/pkgs/dmsengine/task.go b/pkgs/dmsengine/task.go new file mode 100644 index 0000000000..0686e10cc7 --- /dev/null +++ b/pkgs/dmsengine/task.go @@ -0,0 +1,546 @@ +package dmsengine + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +// Migration types. +const ( + MigrationFullLoad = "full-load" + MigrationCDC = "cdc" + MigrationFullLoadAndCDC = "full-load-and-cdc" +) + +// Stop reasons as DescribeReplicationTasks reports them. +const ( + StopNormal = "Stop Reason NORMAL" + StopFatalError = "Stop Reason FATAL_ERROR" + StopFullLoadOnly = "Stop Reason FULL_LOAD_ONLY_FINISHED" + StopAtServerTime = "Stop Reason STOPPED_AT_SERVER_TIME" + StopAtCommitTime = "Stop Reason STOPPED_AT_COMMIT_TIME" + tableBeforeLoad = "Before load" + tableFullLoad = "Full load" + tableCompleted = "Table completed" + tableError = "Table error" + tableBeingReloaded = "Table is being reloaded" + loadBatchRows = 1000 + cdcStopServerPrefix = "server_time:" + cdcStopCommitPrefix = "commit_time:" +) + +// ErrTableNotInTask is returned when a reload names a table the task does not replicate. +var ErrTableNotInTask = errors.New("table is not part of the replication task") + +// TableStat is one row of DescribeTableStatistics; names are the source's. +type TableStat struct { + FullLoadStart time.Time + FullLoadEnd time.Time + LastUpdate time.Time + Schema string + Name string + State string + Error string + FullLoadRows int64 + FullLoadErrorRows int64 + Inserts int64 + Updates int64 + Deletes int64 + FullLoadReloaded bool +} + +// Progress is a point-in-time view of a running task. +type Progress struct { + FullLoadStart time.Time + FullLoadFinish time.Time + Checkpoint string + Tables []TableStat + Running bool +} + +// Result is how a run ended: Reason is a StopReason string and Err is set when the task failed. +type Result struct { + Err error + Reason string +} + +// TableRef names a source table. +type TableRef struct{ Schema, Name string } + +// TaskConfig wires a task to its endpoints. +type TaskConfig struct { + Source Source + Target Target + Now func() time.Time + OnProgress func(Progress) + MigrationType string + CDCStart string + CDCStop string + Resume string + Settings TaskSettings + Mappings Mappings + PingBackoff time.Duration + SourceFamily Family +} + +type planned struct { + src Table + target TargetTable + stat TableStat + active bool +} + +// Task runs one replication from start to finish. +type Task struct { + plans map[string]*planned + order []*planned + prog Progress + cfg TaskConfig + applyMu sync.Mutex + pmu sync.Mutex +} + +// NewTask builds a task; call Run once. +func NewTask(cfg TaskConfig) *Task { + if cfg.Now == nil { + cfg.Now = time.Now + } + + return &Task{cfg: cfg, plans: map[string]*planned{}} +} + +func (t *Task) includesLoad() bool { return t.cfg.MigrationType != MigrationCDC && t.cfg.Resume == "" } + +func (t *Task) includesCDC() bool { return t.cfg.MigrationType != MigrationFullLoad } + +func (t *Task) notify() { + if t.cfg.OnProgress == nil { + return + } + + t.pmu.Lock() + p := t.prog + p.Tables = make([]TableStat, len(t.order)) + + for i, pl := range t.order { + p.Tables[i] = pl.stat + } + t.pmu.Unlock() + + t.cfg.OnProgress(p) +} + +func (t *Task) update(pl *planned, fn func(*TableStat)) { + t.pmu.Lock() + fn(&pl.stat) + pl.stat.LastUpdate = t.cfg.Now() + t.pmu.Unlock() +} + +func (t *Task) fail(err error) Result { return Result{Reason: StopFatalError, Err: err} } + +// Run executes the task until it finishes, fails or ctx is cancelled. +func (t *Task) Run(ctx context.Context) Result { + if err := t.setup(ctx); err != nil { + return t.fail(err) + } + + t.pmu.Lock() + t.prog.Running = true + t.pmu.Unlock() + t.notify() + + var position string + + if t.includesCDC() && t.cfg.Resume == "" && t.cfg.MigrationType == MigrationFullLoadAndCDC { + p, err := t.cfg.Source.Position(ctx) + if err != nil { + return t.fail(fmt.Errorf("capture change position: %w", err)) + } + + position = p + } + + if t.includesLoad() { + if err := t.fullLoad(ctx); err != nil { + return t.finish(ctx, err) + } + } + + if !t.includesCDC() { + return Result{Reason: StopFullLoadOnly} + } + + return t.finish(ctx, t.cdc(ctx, position)) +} + +func (t *Task) finish(ctx context.Context, err error) Result { + var stop *stopError + + switch { + case errors.As(err, &stop): + return Result{Reason: stop.reason} + case ctx.Err() != nil: + return Result{Reason: StopNormal} + case err != nil: + return t.fail(err) + default: + return Result{Reason: StopNormal} + } +} + +func (t *Task) setup(ctx context.Context) error { + if err := pingWithRetry(ctx, t.cfg.Source.Ping, t.cfg.PingBackoff); err != nil { + return fmt.Errorf("source endpoint: %w", err) + } + + if err := pingWithRetry(ctx, t.cfg.Target.Ping, t.cfg.PingBackoff); err != nil { + return fmt.Errorf("target endpoint: %w", err) + } + + all, err := t.cfg.Source.Tables(ctx) + if err != nil { + return fmt.Errorf("read source metadata: %w", err) + } + + state := tableCompleted + if t.includesLoad() { + state = tableBeforeLoad + } + + for _, st := range t.cfg.Mappings.Select(all) { + pl := &planned{ + src: st, + target: t.cfg.Mappings.Plan(st, t.cfg.SourceFamily, t.cfg.Settings.TargetSchema), + active: true, + stat: TableStat{Schema: st.Schema, Name: st.Name, State: state}, + } + t.plans[st.Key()] = pl + t.order = append(t.order, pl) + } + + return nil +} + +func (t *Task) fullLoad(ctx context.Context) error { + t.pmu.Lock() + t.prog.FullLoadStart = t.cfg.Now() + t.pmu.Unlock() + + for _, pl := range t.order { + if err := ctx.Err(); err != nil { + return err + } + + t.loadTable(ctx, pl, t.cfg.Settings.PrepMode, false) + } + + t.pmu.Lock() + t.prog.FullLoadFinish = t.cfg.Now() + t.pmu.Unlock() + t.notify() + + return ctx.Err() +} + +func (t *Task) loadTable(ctx context.Context, pl *planned, mode PrepMode, reload bool) { + err := t.copyTable(ctx, pl, mode, reload) + if err == nil { + return + } + + if ctx.Err() != nil { + return + } + + t.update(pl, func(s *TableStat) { + s.State, s.Error = tableError, err.Error() + }) + t.pmu.Lock() + pl.active = false + t.pmu.Unlock() + t.notify() +} + +func (t *Task) copyTable(ctx context.Context, pl *planned, mode PrepMode, reload bool) error { + t.update(pl, func(s *TableStat) { + s.State, s.FullLoadStart, s.FullLoadReloaded, s.FullLoadRows, s.Error = tableFullLoad, t.cfg.Now(), reload, 0, "" + }) + t.notify() + + if err := t.cfg.Target.Prepare(ctx, pl.target, mode); err != nil { + return fmt.Errorf("prepare target table %s: %w", pl.target.Key(), err) + } + + batch := make([]Row, 0, min(t.cfg.Settings.CommitRate, loadBatchRows)) + flush := func() error { + if len(batch) == 0 { + return nil + } + + if err := t.cfg.Target.Load(ctx, pl.target, batch); err != nil { + return err + } + + n := int64(len(batch)) + batch = batch[:0] + t.update(pl, func(s *TableStat) { s.FullLoadRows += n }) + t.notify() + + return nil + } + + err := t.cfg.Source.Scan(ctx, pl.src, func(r Row) error { + batch = append(batch, pl.target.Project(r)) + if len(batch) >= cap(batch) { + return flush() + } + + return nil + }) + if err == nil { + err = flush() + } + + if err == nil { + err = t.cfg.Target.EndLoad(ctx, pl.target) + } + + if err != nil { + return err + } + + t.update(pl, func(s *TableStat) { s.State, s.FullLoadEnd = tableCompleted, t.cfg.Now() }) + t.notify() + + return nil +} + +// Reload re-copies tables while change capture keeps running. +func (t *Task) Reload(ctx context.Context, refs []TableRef) error { + t.applyMu.Lock() + defer t.applyMu.Unlock() + + plans := make([]*planned, 0, len(refs)) + + for _, r := range refs { + pl, ok := t.plans[r.Schema+"."+r.Name] + if !ok { + return fmt.Errorf("%w: %s.%s", ErrTableNotInTask, r.Schema, r.Name) + } + + plans = append(plans, pl) + } + + for _, pl := range plans { + t.update(pl, func(s *TableStat) { s.State = tableBeingReloaded }) + t.pmu.Lock() + pl.active = true + t.pmu.Unlock() + t.loadTable(ctx, pl, t.cfg.Settings.PrepMode, true) + } + + return nil +} + +type stopError struct{ reason string } + +func (s *stopError) Error() string { return s.reason } + +func parseStopTime(spec string) (time.Time, string, bool) { + var reason string + + switch { + case strings.HasPrefix(spec, cdcStopServerPrefix): + spec, reason = strings.TrimPrefix(spec, cdcStopServerPrefix), StopAtServerTime + case strings.HasPrefix(spec, cdcStopCommitPrefix): + spec, reason = strings.TrimPrefix(spec, cdcStopCommitPrefix), StopAtCommitTime + default: + return time.Time{}, "", false + } + + ts, ok := ParseCDCTime(spec) + + return ts, reason, ok +} + +func cdcTimeLayouts() []string { + return []string{time.RFC3339Nano, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02T15:04:05Z0700"} +} + +// ParseCDCTime reads the timestamp forms CdcStartPosition and CdcStopPosition accept. +func ParseCDCTime(s string) (time.Time, bool) { + for _, layout := range cdcTimeLayouts() { + if ts, err := time.ParseInLocation(layout, strings.TrimSpace(s), time.UTC); err == nil { + return ts.UTC(), true + } + } + + return time.Time{}, false +} + +func (t *Task) streamStart(captured string) StreamStart { + switch { + case t.cfg.Resume != "": + return StreamStart{Position: t.cfg.Resume} + case t.cfg.CDCStart != "" && !strings.EqualFold(t.cfg.CDCStart, "now"): + if ts, ok := ParseCDCTime(t.cfg.CDCStart); ok { + return StreamStart{Time: ts} + } + + return StreamStart{Position: strings.TrimPrefix(t.cfg.CDCStart, "checkpoint:")} + default: + return StreamStart{Position: captured} + } +} + +func (t *Task) lookup(schema, table string) (Table, bool) { + pl, ok := t.plans[schema+"."+table] + if !ok { + return Table{}, false + } + + return pl.src, true +} + +func (t *Task) cdc(ctx context.Context, captured string) error { + stopAt, stopReason, hasStop := parseStopTime(t.cfg.CDCStop) + + return t.cfg.Source.Stream(ctx, t.streamStart(captured), t.lookup, func(c Change) error { + if c.Commit { + if err := t.cfg.Target.Commit(ctx); err != nil { + return fmt.Errorf("commit target: %w", err) + } + + t.pmu.Lock() + t.prog.Checkpoint = c.Position + t.pmu.Unlock() + t.notify() + + return nil + } + + if hasStop && stopReason == StopAtCommitTime && !c.Time.Before(stopAt) { + return &stopError{reason: stopReason} + } + + if hasStop && stopReason == StopAtServerTime && !t.cfg.Now().Before(stopAt) { + return &stopError{reason: stopReason} + } + + return t.apply(ctx, c) + }) +} + +func (t *Task) apply(ctx context.Context, c Change) error { + t.applyMu.Lock() + defer t.applyMu.Unlock() + + t.pmu.Lock() + pl := t.plans[c.Schema+"."+c.Table] + active := pl != nil && pl.active + t.pmu.Unlock() + + if !active { + return nil + } + + if bad := rowWidth(pl.src, c); bad != "" { + t.suspend(pl, bad) + + return nil + } + + tc := c + tc.Before, tc.After = pl.target.Project(c.Before), pl.target.Project(c.After) + + if err := t.cfg.Target.Apply(ctx, pl.target, tc); err != nil { + if ctx.Err() != nil { + return nil + } + + t.suspend(pl, err.Error()) + + return nil + } + + t.update(pl, func(s *TableStat) { + switch c.Op { + case OpInsert: + s.Inserts++ + case OpUpdate: + s.Updates++ + case OpDelete: + s.Deletes++ + } + }) + + return nil +} + +func rowWidth(src Table, c Change) string { + for _, r := range []Row{c.Before, c.After} { + if r != nil && len(r) != len(src.Columns) { + return fmt.Sprintf( + "source table definition changed: row has %d columns, expected %d", + len(r), + len(src.Columns), + ) + } + } + + return "" +} + +func (t *Task) suspend(pl *planned, msg string) { + t.update(pl, func(s *TableStat) { + s.State, s.Error = tableError, msg + }) + t.pmu.Lock() + pl.active = false + t.pmu.Unlock() + t.notify() +} + +// Has reports whether the task replicates the table. +func (t *Task) Has(r TableRef) bool { + _, ok := t.plans[r.Schema+"."+r.Name] + + return ok +} + +const ( + pingAttempts = 5 + pingBackoff = 500 * time.Millisecond +) + +// pingWithRetry rides out a database that is still restarting after it reported ready. +func pingWithRetry(ctx context.Context, ping func(context.Context) error, backoff time.Duration) error { + var err error + + if backoff <= 0 { + backoff = pingBackoff + } + + for attempt := range pingAttempts { + if err = ping(ctx); err == nil { + return nil + } + + if attempt == pingAttempts-1 { + break + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + } + + return err +} diff --git a/pkgs/dmsengine/task_test.go b/pkgs/dmsengine/task_test.go new file mode 100644 index 0000000000..f5a56e7e93 --- /dev/null +++ b/pkgs/dmsengine/task_test.go @@ -0,0 +1,497 @@ +package dmsengine_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" +) + +var errBoom = errors.New("boom") + +type fakeSource struct { + rows map[string][]dmsengine.Row + changes chan dmsengine.Change + scanned map[string]int + startAt dmsengine.StreamStart + tables []dmsengine.Table + mu sync.Mutex +} + +func (s *fakeSource) Ping(context.Context) error { return nil } +func (s *fakeSource) Tables(context.Context) ([]dmsengine.Table, error) { return s.tables, nil } +func (s *fakeSource) Position(context.Context) (string, error) { return "bin.000001:4", nil } +func (s *fakeSource) Release(context.Context) error { return nil } +func (s *fakeSource) Close() error { return nil } + +func (s *fakeSource) Scan(_ context.Context, t dmsengine.Table, fn func(dmsengine.Row) error) error { + s.mu.Lock() + if s.scanned == nil { + s.scanned = map[string]int{} + } + + s.scanned[t.Key()]++ + rows := s.rows[t.Key()] + s.mu.Unlock() + + for _, r := range rows { + if err := fn(r); err != nil { + return err + } + } + + return nil +} + +func (s *fakeSource) Stream( + ctx context.Context, start dmsengine.StreamStart, tables func(string, string) (dmsengine.Table, bool), + fn func(dmsengine.Change) error, +) error { + s.mu.Lock() + s.startAt = start + s.mu.Unlock() + + for { + select { + case <-ctx.Done(): + return nil + case c := <-s.changes: + if !c.Commit { + if _, ok := tables(c.Schema, c.Table); !ok { + continue + } + } + + if err := fn(c); err != nil { + return err + } + } + } +} + +type fakeTarget struct { + prepared map[string]dmsengine.PrepMode + loaded map[string][]dmsengine.Row + failPrep map[string]error + applied []dmsengine.Change + commits int + mu sync.Mutex +} + +func newFakeTarget() *fakeTarget { + return &fakeTarget{ + prepared: map[string]dmsengine.PrepMode{}, loaded: map[string][]dmsengine.Row{}, failPrep: map[string]error{}, + } +} + +func (f *fakeTarget) Ping(context.Context) error { return nil } +func (f *fakeTarget) Close() error { return nil } + +func (f *fakeTarget) Prepare(_ context.Context, t dmsengine.TargetTable, m dmsengine.PrepMode) error { + f.mu.Lock() + defer f.mu.Unlock() + + if err := f.failPrep[t.Key()]; err != nil { + return err + } + + f.prepared[t.Key()] = m + f.loaded[t.Key()] = nil + + return nil +} + +func (f *fakeTarget) Load(_ context.Context, t dmsengine.TargetTable, rows []dmsengine.Row) error { + f.mu.Lock() + defer f.mu.Unlock() + + for _, r := range rows { + f.loaded[t.Key()] = append(f.loaded[t.Key()], append(dmsengine.Row(nil), r...)) + } + + return nil +} + +func (f *fakeTarget) EndLoad(context.Context, dmsengine.TargetTable) error { return nil } + +func (f *fakeTarget) Apply(_ context.Context, _ dmsengine.TargetTable, c dmsengine.Change) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.applied = append(f.applied, c) + + return nil +} + +func (f *fakeTarget) Commit(context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.commits++ + + return nil +} + +type fakeClock struct { + t time.Time + mu sync.Mutex +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + c.t = c.t.Add(time.Second) + + return c.t +} + +func twoTables() *fakeSource { + items := dmsengine.Table{Schema: "app", Name: "items", PK: []string{"id"}, Columns: []dmsengine.Column{ + {Name: "id", Native: "int"}, {Name: "name", Native: "varchar(20)"}, + }} + orders := dmsengine.Table{Schema: "app", Name: "orders", PK: []string{"id"}, Columns: []dmsengine.Column{ + {Name: "id", Native: "int"}, + }} + + return &fakeSource{ + tables: []dmsengine.Table{items, orders}, + rows: map[string][]dmsengine.Row{ + "app.items": {{"1", "alpha"}, {"2", "beta"}}, + "app.orders": {{"10"}}, + }, + changes: make(chan dmsengine.Change, 16), + } +} + +const selectAll = `{"rules":[{"rule-type":"selection","rule-id":"1","rule-name":"1", + "object-locator":{"schema-name":"app","table-name":"%"},"rule-action":"include"}]}` + +type progressLog struct { + ch chan dmsengine.Progress +} + +func newProgressLog() *progressLog { return &progressLog{ch: make(chan dmsengine.Progress, 1024)} } + +func (p *progressLog) push(pr dmsengine.Progress) { + select { + case p.ch <- pr: + default: + } +} + +// waitFor drains progress until pred holds. +func (p *progressLog) waitFor(t *testing.T, pred func(dmsengine.Progress) bool) dmsengine.Progress { + t.Helper() + + timeout := time.After(10 * time.Second) + + for { + select { + case pr := <-p.ch: + if pred(pr) { + return pr + } + case <-timeout: + require.FailNow(t, "progress condition not reached") + } + } +} + +func stat(pr dmsengine.Progress, key string) dmsengine.TableStat { + for _, s := range pr.Tables { + if s.Schema+"."+s.Name == key { + return s + } + } + + return dmsengine.TableStat{} +} + +func TestTaskFullLoadOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + settings string + wantMode dmsengine.PrepMode + }{ + {name: "default drops and creates", settings: "", wantMode: dmsengine.PrepDropAndCreate}, + { + name: "truncate before load", + settings: `{"FullLoadSettings":{"TargetTablePrepMode":"TRUNCATE_BEFORE_LOAD"}}`, + wantMode: dmsengine.PrepTruncateBefore, + }, + { + name: "do nothing", + settings: `{"FullLoadSettings":{"TargetTablePrepMode":"DO_NOTHING"}}`, + wantMode: dmsengine.PrepDoNothing, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + src, tgt, clk, log := twoTables(), newFakeTarget(), &fakeClock{ + t: time.Unix(1_700_000_000, 0), + }, newProgressLog() + mappings, err := dmsengine.ParseMappings(selectAll) + require.NoError(t, err) + + settings, err := dmsengine.ParseTaskSettings(tt.settings) + require.NoError(t, err) + + var last dmsengine.Progress + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, Now: clk.Now, MigrationType: dmsengine.MigrationFullLoad, + Mappings: mappings, Settings: settings, SourceFamily: dmsengine.FamilyMySQL, + OnProgress: func(p dmsengine.Progress) { last = p; log.push(p) }, + }) + + res := task.Run(t.Context()) + + require.NoError(t, res.Err) + assert.Equal(t, dmsengine.StopFullLoadOnly, res.Reason) + assert.Equal(t, tt.wantMode, tgt.prepared["app.items"]) + assert.Equal(t, []dmsengine.Row{{"1", "alpha"}, {"2", "beta"}}, tgt.loaded["app.items"]) + + items := stat(last, "app.items") + assert.Equal(t, "Table completed", items.State) + assert.EqualValues(t, 2, items.FullLoadRows) + assert.True(t, items.FullLoadEnd.After(items.FullLoadStart)) + assert.EqualValues(t, 1, stat(last, "app.orders").FullLoadRows) + assert.True(t, last.FullLoadFinish.After(last.FullLoadStart)) + }) + } +} + +func TestTaskTransformsReachTarget(t *testing.T) { + t.Parallel() + + src, tgt := twoTables(), newFakeTarget() + mappings, err := dmsengine.ParseMappings(`{"rules":[ + {"rule-type":"selection","rule-id":"1","rule-name":"1", + "object-locator":{"schema-name":"app","table-name":"items"},"rule-action":"include"}, + {"rule-type":"transformation","rule-id":"2","rule-name":"2","rule-target":"table","rule-action":"rename", + "object-locator":{"schema-name":"app","table-name":"items"},"value":"items_copy"}, + {"rule-type":"transformation","rule-id":"3","rule-name":"3","rule-target":"column","rule-action":"remove-column", + "object-locator":{"schema-name":"app","table-name":"items","column-name":"name"}}]}`) + require.NoError(t, err) + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, MigrationType: dmsengine.MigrationFullLoad, Mappings: mappings, + Settings: defaultSettings(t), SourceFamily: dmsengine.FamilyMySQL, + }) + + require.NoError(t, task.Run(t.Context()).Err) + assert.Equal(t, []dmsengine.Row{{"1"}, {"2"}}, tgt.loaded["app.items_copy"]) + assert.NotContains(t, tgt.prepared, "app.orders") +} + +func defaultSettings(t *testing.T) dmsengine.TaskSettings { + t.Helper() + + s, err := dmsengine.ParseTaskSettings("") + require.NoError(t, err) + + return s +} + +func TestTaskTableErrorDoesNotStopOthers(t *testing.T) { + t.Parallel() + + src, tgt, log := twoTables(), newFakeTarget(), newProgressLog() + tgt.failPrep["app.items"] = errBoom + mappings, err := dmsengine.ParseMappings(selectAll) + require.NoError(t, err) + + var last dmsengine.Progress + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, MigrationType: dmsengine.MigrationFullLoad, Mappings: mappings, + Settings: defaultSettings(t), SourceFamily: dmsengine.FamilyMySQL, + OnProgress: func(p dmsengine.Progress) { last = p; log.push(p) }, + }) + + res := task.Run(t.Context()) + + require.NoError(t, res.Err) + assert.Equal(t, "Table error", stat(last, "app.items").State) + assert.Contains(t, stat(last, "app.items").Error, "boom") + assert.Equal(t, "Table completed", stat(last, "app.orders").State) +} + +func TestTaskFullLoadAndCDC(t *testing.T) { + t.Parallel() + + src, tgt, log := twoTables(), newFakeTarget(), newProgressLog() + mappings, err := dmsengine.ParseMappings(selectAll) + require.NoError(t, err) + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, MigrationType: dmsengine.MigrationFullLoadAndCDC, Mappings: mappings, + Settings: defaultSettings(t), SourceFamily: dmsengine.FamilyMySQL, OnProgress: log.push, + }) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan dmsengine.Result, 1) + + go func() { done <- task.Run(ctx) }() + + log.waitFor(t, func(p dmsengine.Progress) bool { return stat(p, "app.orders").State == "Table completed" }) + + src.changes <- dmsengine.Change{ + Schema: "app", Table: "items", Op: dmsengine.OpInsert, After: dmsengine.Row{"3", "gamma"}, + } + src.changes <- dmsengine.Change{ + Schema: "app", Table: "items", Op: dmsengine.OpUpdate, + Before: dmsengine.Row{"3", "gamma"}, After: dmsengine.Row{"3", "delta"}, + } + src.changes <- dmsengine.Change{ + Schema: "app", Table: "items", Op: dmsengine.OpDelete, Before: dmsengine.Row{"1", "alpha"}, + } + src.changes <- dmsengine.Change{Schema: "app", Table: "ignored", Op: dmsengine.OpInsert, After: dmsengine.Row{"1"}} + src.changes <- dmsengine.Change{Commit: true, Position: "bin.000001:900"} + + final := log.waitFor(t, func(p dmsengine.Progress) bool { return p.Checkpoint == "bin.000001:900" }) + + cancel() + + res := <-done + + require.NoError(t, res.Err) + assert.Equal(t, dmsengine.StopNormal, res.Reason) + assert.Equal(t, dmsengine.StreamStart{Position: "bin.000001:4"}, src.startAt) + + items := stat(final, "app.items") + assert.EqualValues(t, 1, items.Inserts) + assert.EqualValues(t, 1, items.Updates) + assert.EqualValues(t, 1, items.Deletes) + assert.EqualValues(t, 2, items.FullLoadRows) + assert.Len(t, tgt.applied, 3) + assert.Equal(t, 1, tgt.commits) +} + +func TestTaskCDCOnlyDoesNotLoad(t *testing.T) { + t.Parallel() + + src, tgt, log := twoTables(), newFakeTarget(), newProgressLog() + mappings, err := dmsengine.ParseMappings(selectAll) + require.NoError(t, err) + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, MigrationType: dmsengine.MigrationCDC, Mappings: mappings, + CDCStart: "2024-03-08T12:12:12", Settings: defaultSettings(t), SourceFamily: dmsengine.FamilyMySQL, + OnProgress: log.push, + }) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan dmsengine.Result, 1) + + go func() { done <- task.Run(ctx) }() + + src.changes <- dmsengine.Change{Schema: "app", Table: "orders", Op: dmsengine.OpInsert, After: dmsengine.Row{"11"}} + src.changes <- dmsengine.Change{Commit: true, Position: "p"} + log.waitFor(t, func(p dmsengine.Progress) bool { return stat(p, "app.orders").Inserts == 1 }) + cancel() + + assert.Equal(t, dmsengine.StopNormal, (<-done).Reason) + assert.Empty(t, src.scanned) + assert.Empty(t, tgt.prepared) + assert.Equal(t, time.Date(2024, 3, 8, 12, 12, 12, 0, time.UTC), src.startAt.Time) +} + +func TestTaskStopsAtCommitTime(t *testing.T) { + t.Parallel() + + src, tgt, log := twoTables(), newFakeTarget(), newProgressLog() + mappings, err := dmsengine.ParseMappings(selectAll) + require.NoError(t, err) + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, MigrationType: dmsengine.MigrationCDC, Mappings: mappings, + CDCStop: "commit_time:2024-01-01T00:00:10", Settings: defaultSettings(t), + SourceFamily: dmsengine.FamilyMySQL, OnProgress: log.push, + }) + + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + src.changes <- dmsengine.Change{ + Time: base, Schema: "app", Table: "orders", Op: dmsengine.OpInsert, After: dmsengine.Row{"1"}, + } + src.changes <- dmsengine.Change{ + Time: base.Add(10 * time.Second), Schema: "app", Table: "orders", Op: dmsengine.OpInsert, + After: dmsengine.Row{"2"}, + } + + res := task.Run(t.Context()) + + require.NoError(t, res.Err) + assert.Equal(t, dmsengine.StopAtCommitTime, res.Reason) + assert.Len(t, tgt.applied, 1) +} + +func TestTaskReload(t *testing.T) { + t.Parallel() + + src, tgt, log := twoTables(), newFakeTarget(), newProgressLog() + mappings, err := dmsengine.ParseMappings(selectAll) + require.NoError(t, err) + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, MigrationType: dmsengine.MigrationFullLoadAndCDC, Mappings: mappings, + Settings: defaultSettings(t), SourceFamily: dmsengine.FamilyMySQL, OnProgress: log.push, + }) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan dmsengine.Result, 1) + + go func() { done <- task.Run(ctx) }() + + log.waitFor(t, func(p dmsengine.Progress) bool { return stat(p, "app.items").State == "Table completed" }) + require.ErrorIs( + t, + task.Reload(ctx, []dmsengine.TableRef{{Schema: "app", Name: "missing"}}), + dmsengine.ErrTableNotInTask, + ) + require.NoError(t, task.Reload(ctx, []dmsengine.TableRef{{Schema: "app", Name: "items"}})) + + got := log.waitFor(t, func(p dmsengine.Progress) bool { + s := stat(p, "app.items") + + return s.FullLoadReloaded && s.State == "Table completed" + }) + cancel() + <-done + + assert.Equal(t, "Table completed", stat(got, "app.items").State) + assert.Equal(t, 2, src.scanned["app.items"]) + assert.True(t, task.Has(dmsengine.TableRef{Schema: "app", Name: "items"})) +} + +func TestTaskSourceFailure(t *testing.T) { + t.Parallel() + + src := &failingSource{fakeSource: *twoTables()} + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: newFakeTarget(), MigrationType: dmsengine.MigrationFullLoad, + Settings: defaultSettings(t), SourceFamily: dmsengine.FamilyMySQL, PingBackoff: time.Millisecond, + }) + + res := task.Run(t.Context()) + + require.Error(t, res.Err) + assert.Equal(t, dmsengine.StopFatalError, res.Reason) + assert.Contains(t, res.Err.Error(), "boom") +} + +type failingSource struct{ fakeSource } + +func (f *failingSource) Ping(context.Context) error { return errBoom } diff --git a/pkgs/dmsengine/tls_internal_test.go b/pkgs/dmsengine/tls_internal_test.go new file mode 100644 index 0000000000..76165ccec9 --- /dev/null +++ b/pkgs/dmsengine/tls_internal_test.go @@ -0,0 +1,201 @@ +package dmsengine + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testPKI struct { + server tls.Certificate + caPEM string +} + +func newTestPKI(t *testing.T) testPKI { + t.Helper() + + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test ca"}, IsCA: true, + BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + require.NoError(t, err) + + caCert, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + + srvKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + srvTmpl := &x509.Certificate{ + SerialNumber: big.NewInt( + 2, + ), + Subject: pkix.Name{CommonName: "db"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + srvDER, err := x509.CreateCertificate(rand.Reader, srvTmpl, caCert, &srvKey.PublicKey, caKey) + require.NoError(t, err) + + return testPKI{ + caPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})), + server: tls.Certificate{Certificate: [][]byte{srvDER}, PrivateKey: srvKey}, + } +} + +// serveTLSAfterSSLRequest answers PostgreSQL's SSLRequest and completes a TLS handshake, then hangs up. +func serveTLSAfterSSLRequest(t *testing.T, cert tls.Certificate) int { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + c, aerr := ln.Accept() + if aerr != nil { + return + } + + go func() { + defer c.Close() + + buf := make([]byte, 8) + if _, rerr := c.Read(buf); rerr != nil { + return + } + + _, _ = c.Write([]byte("S")) + _ = tls.Server(c, &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}). + Handshake() + }() + } + }() + + port, err := strconv.Atoi(ln.Addr().(*net.TCPAddr).AddrPort().String()[len("127.0.0.1:"):]) + require.NoError(t, err) + + return port +} + +func TestTLSConfigFor(t *testing.T) { + t.Parallel() + + pki := newTestPKI(t) + + tests := []struct { + wantErr error + name string + ep EndpointConfig + wantNil bool + ownRoot bool + }{ + {name: "none is plaintext", ep: EndpointConfig{SSLMode: "none", Server: "db"}, wantNil: true}, + {name: "unset is plaintext", ep: EndpointConfig{Server: "db"}, wantNil: true}, + { + name: "require with certificate trusts it", + ep: EndpointConfig{SSLMode: "require", Server: "db", CertificatePEM: pki.caPEM}, + ownRoot: true, + }, + { + name: "verify-full with certificate trusts it", + ep: EndpointConfig{SSLMode: "verify-full", Server: "db", CertificatePEM: pki.caPEM}, + ownRoot: true, + }, + {name: "require without certificate uses system roots", ep: EndpointConfig{SSLMode: "require", Server: "db"}}, + { + name: "garbage certificate", + ep: EndpointConfig{SSLMode: "require", Server: "db", CertificatePEM: "nope"}, + wantErr: ErrCertificate, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, err := tlsConfigFor(tt.ep) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + + if tt.wantNil { + assert.Nil(t, cfg) + + return + } + + require.NotNil(t, cfg) + assert.False(t, cfg.InsecureSkipVerify) + assert.Equal(t, "db", cfg.ServerName) + assert.Equal(t, tt.ownRoot, cfg.RootCAs != nil) + }) + } +} + +func TestPostgresTLSVerification(t *testing.T) { + t.Parallel() + + pki := newTestPKI(t) + + tests := []struct { + name string + pem string + wantVerify bool + }{ + {name: "require with the signing certificate verifies", pem: pki.caPEM, wantVerify: true}, + {name: "require without a certificate fails verification clearly", pem: "", wantVerify: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + port := serveTLSAfterSSLRequest(t, pki.server) + ep := EndpointConfig{ + Engine: "postgres", Server: "127.0.0.1", Port: port, Database: "d", User: "u", Password: "p", + SSLMode: "require", CertificatePEM: tt.pem, + } + + src, err := openPGSource(t.Context(), ep) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + err = src.Ping(t.Context()) + require.Error(t, err) + + if tt.wantVerify { + assert.NotContains(t, err.Error(), "x509:") + + return + } + + assert.Contains(t, err.Error(), "SslMode require needs a trusted server certificate") + assert.Contains(t, err.Error(), "CertificateArn") + }) + } +} diff --git a/pkgs/dmsengine/typemap.go b/pkgs/dmsengine/typemap.go new file mode 100644 index 0000000000..24c879ca9f --- /dev/null +++ b/pkgs/dmsengine/typemap.go @@ -0,0 +1,238 @@ +package dmsengine + +import ( + "regexp" + "strings" +) + +var typeRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_ ]*?)\s*(?:\(([^)]*)\))?\s*(unsigned)?\s*(zerofill)?\s*$`) + +type parsedType struct { + base string + args string + unsigned bool +} + +func parseNative(native string) parsedType { + n := strings.ToLower(strings.TrimSpace(native)) + if strings.HasPrefix(n, "enum(") || strings.HasPrefix(n, "set(") { + return parsedType{base: n[:strings.IndexByte(n, '(')], args: n[strings.IndexByte(n, '(')+1 : len(n)-1]} + } + + m := typeRe.FindStringSubmatch(n) + if m == nil { + return parsedType{base: n} + } + + return parsedType{base: strings.TrimSpace(m[1]), args: m[2], unsigned: m[3] != ""} +} + +const ( + tBigint = "bigint" + tInt = "int" + tInteger = "integer" + tSmallint = "smallint" + tTinyint = "tinyint" + tDecimal = "decimal" + tNumeric = "numeric" + tDouble = "double" + tDoublePrec = "double precision" + tBoolean = "boolean" + tDate = "date" + tDatetime = "datetime" + tTimestamp = "timestamp" + tTime = "time" + tBytea = "bytea" + tJSON = "json" + tLongtext = "longtext" + tText = "text" + tChar = "char" + tVarchar = "varchar" + tString = "string" + tReal = "real" + tFloat = "float" + tTimestampN = "timestamp without time zone" + tTimestampZ = "timestamp with time zone" + tMediumint = "mediumint" + tLongblob = "longblob" + tJSONB = "jsonb" + tDatetime6 = "datetime(6)" +) + +func kindByBase() map[string]Kind { + return map[string]Kind{ + tTinyint: KindInt, + tSmallint: KindInt, + tMediumint: KindInt, + tInt: KindInt, + tInteger: KindInt, + tBigint: KindInt, + "year": KindInt, + "serial": KindInt, + "bigserial": KindInt, + "smallserial": KindInt, + "int2": KindInt, + "int4": KindInt, + "int8": KindInt, + tDecimal: KindDecimal, + tNumeric: KindDecimal, + "dec": KindDecimal, + "fixed": KindDecimal, + "money": KindDecimal, + tFloat: KindFloat, + tDouble: KindFloat, + tDoublePrec: KindFloat, + tReal: KindFloat, + "float4": KindFloat, + "float8": KindFloat, + tBoolean: KindBool, + "bool": KindBool, + tDate: KindTime, + tDatetime: KindTime, + tTimestamp: KindTime, + tTime: KindTime, + tTimestampN: KindTime, + tTimestampZ: KindTime, + "time without time zone": KindTime, + "time with time zone": KindTime, + "timestamptz": KindTime, + "timetz": KindTime, + "binary": KindBytes, + "varbinary": KindBytes, + "tinyblob": KindBytes, + "blob": KindBytes, + "mediumblob": KindBytes, + tLongblob: KindBytes, + tBytea: KindBytes, + "bit": KindBytes, + "geometry": KindBytes, + tJSON: KindJSON, + tJSONB: KindJSON, + } +} + +// KindOf classifies an engine-native type. +func KindOf(native string) Kind { + return kindByBase()[parseNative(native).base] +} + +func mysqlToPGBase() map[string]string { + return map[string]string{ + tTinyint: tSmallint, tMediumint: tInteger, "year": tSmallint, tFloat: tReal, tDouble: tDoublePrec, + tDoublePrec: tDoublePrec, tDatetime: tTimestamp, tTimestamp: tTimestamp, tDate: tDate, tTime: tTime, + "binary": tBytea, "varbinary": tBytea, "tinyblob": tBytea, "blob": tBytea, "mediumblob": tBytea, + tLongblob: tBytea, "bit": tBytea, "geometry": tBytea, tJSON: tJSONB, + } +} + +func mysqlToPG(p parsedType) string { + switch p.base { + case tSmallint: + if p.unsigned { + return tInteger + } + + return tSmallint + case tInt, tInteger: + if p.unsigned { + return tBigint + } + + return tInteger + case tBigint: + if p.unsigned { + return "numeric(20)" + } + + return tBigint + case tVarchar, tChar: + if p.args == "" { + return tText + } + + return p.base + "(" + p.args + ")" + case tDecimal, tNumeric, "dec", "fixed": + return numericWithArgs(p.args) + } + + if v, ok := mysqlToPGBase()[p.base]; ok { + return v + } + + return tText +} + +func numericWithArgs(args string) string { + if args == "" { + return tNumeric + } + + return "numeric(" + args + ")" +} + +func pgToMySQLBase() map[string]string { + return map[string]string{ + tSmallint: tSmallint, "int2": tSmallint, "smallserial": tSmallint, tInteger: tInt, tInt: tInt, "int4": tInt, + "serial": tInt, tBigint: tBigint, "int8": tBigint, "bigserial": tBigint, tBoolean: "tinyint(1)", + "bool": "tinyint(1)", "money": "decimal(19,2)", tReal: tFloat, "float4": tFloat, tDoublePrec: tDouble, + "float8": tDouble, tTimestampN: tDatetime6, tTimestampZ: tDatetime6, tTimestamp: tDatetime6, + "timestamptz": tDatetime6, tDate: tDate, "time without time zone": tTime, "time with time zone": tTime, + tTime: tTime, "timetz": tTime, tBytea: tLongblob, tJSON: tJSON, tJSONB: tJSON, "uuid": "char(36)", + } +} + +func pgToMySQL(p parsedType) string { + switch p.base { + case "character varying", tVarchar: + if p.args == "" { + return tLongtext + } + + return "varchar(" + p.args + ")" + case "character", tChar, "bpchar": + if p.args == "" { + return "char(1)" + } + + return "char(" + p.args + ")" + case tNumeric, tDecimal: + if p.args == "" { + return "decimal(65,30)" + } + + return "decimal(" + p.args + ")" + } + + if v, ok := pgToMySQLBase()[p.base]; ok { + return v + } + + return tLongtext +} + +// TargetType renders the column type to use on a target of family dst when the source is src. +func TargetType(src, dst Family, native string) string { + if src == dst { + return native + } + + p := parseNative(native) + + switch { + case src == FamilyMySQL && dst == FamilyPostgres: + return mysqlToPG(p) + case src == FamilyPostgres && dst == FamilyMySQL: + return pgToMySQL(p) + default: + return native + } +} + +// QuoteIdent quotes an identifier for the family's dialect. +func QuoteIdent(f Family, name string) string { + if f == FamilyPostgres { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` + } + + return "`" + strings.ReplaceAll(name, "`", "``") + "`" +} diff --git a/pkgs/dns/dns.go b/pkgs/dns/dns.go index b61f8d5d34..a5fa26b878 100644 --- a/pkgs/dns/dns.go +++ b/pkgs/dns/dns.go @@ -18,8 +18,10 @@ import ( "fmt" "log/slog" "net" + "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/miekg/dns" @@ -45,6 +47,12 @@ const defaultTTL = 60 // ErrInvalidResolveIP is returned when the configured resolve IP cannot be parsed. var ErrInvalidResolveIP = errors.New("invalid resolve IP") +// ErrUnexpectedAddr is returned when a listener reports a non-TCP address. +var ErrUnexpectedAddr = errors.New("dns: unexpected listener address type") + +// ErrEphemeralBind is returned when no port free on both TCP and UDP was found. +var ErrEphemeralBind = errors.New("dns: no ephemeral port free on both tcp and udp") + // ErrIPv4Required is returned when the configured resolve IP is not an IPv4 address. var ErrIPv4Required = errors.New("resolve IP must be an IPv4 address") @@ -85,7 +93,8 @@ type Server struct { listenAddr string resolveIP net.IP - stopOnce sync.Once + boundPort atomic.Int64 + stopOnce sync.Once } // New creates a new Server with the given config. @@ -205,6 +214,15 @@ func (s *Server) Start(ctx context.Context) error { mux := dns.NewServeMux() mux.HandleFunc(".", s.handleQuery) + tcpLn, udpPc, err := bindEphemeral(ctx, s.listenAddr) + if err != nil { + return err + } + + if tcpLn != nil { + s.setBoundPort(tcpLn.Addr()) + } + udpReady := make(chan struct{}) tcpReady := make(chan struct{}) udpErrCh := make(chan error, 1) @@ -213,6 +231,7 @@ func (s *Server) Start(ctx context.Context) error { s.udpServer = &dns.Server{ Addr: s.listenAddr, Net: "udp", + PacketConn: udpPc, Handler: mux, ReadTimeout: DefaultReadTimeout, WriteTimeout: DefaultWriteTimeout, @@ -222,6 +241,7 @@ func (s *Server) Start(ctx context.Context) error { s.tcpServer = &dns.Server{ Addr: s.listenAddr, Net: "tcp", + Listener: tcpLn, Handler: mux, ReadTimeout: DefaultReadTimeout, WriteTimeout: DefaultWriteTimeout, @@ -235,7 +255,7 @@ func (s *Server) Start(ctx context.Context) error { go udpRun.serve(udpErrCh) go tcpRun.serve(tcpErrCh) - err := waitForReady(ctx, udpReady, udpErrCh, tcpErrCh) + err = waitForReady(ctx, udpReady, udpErrCh, tcpErrCh) if err == nil { err = waitForReady(ctx, tcpReady, udpErrCh, tcpErrCh) } @@ -274,7 +294,12 @@ type serveRun struct { func (r *serveRun) serve(errCh chan<- error) { defer close(r.done) - if err := r.srv.ListenAndServe(); err != nil { + serve := r.srv.ListenAndServe + if r.srv.Listener != nil || r.srv.PacketConn != nil { + serve = r.srv.ActivateAndServe // pre-bound sockets: ListenAndServe would rebind Addr + } + + if err := serve(); err != nil { if !errors.Is(err, net.ErrClosed) && !isServerClosed(err) { errCh <- err } @@ -513,3 +538,53 @@ func SyntheticHostname(resourceID, randomSuffix, region, serviceType string) str func isServerClosed(err error) bool { return err != nil && strings.Contains(err.Error(), "server closed") } + +// ephemeralBindAttempts bounds the retries when the UDP twin of an OS-assigned TCP port is taken. +const ephemeralBindAttempts = 32 + +// bindEphemeral pre-binds a TCP listener and UDP socket on one OS-assigned port when addr's port +// is 0, keeping both open so nothing can take them before the servers start. Other addrs return nils. +func bindEphemeral(ctx context.Context, addr string) (net.Listener, net.PacketConn, error) { + host, port, err := net.SplitHostPort(addr) + if err == nil && port == "0" { + return bindPair(ctx, host, addr) + } + + return nil, nil, nil +} + +func bindPair(ctx context.Context, host, addr string) (net.Listener, net.PacketConn, error) { + var lc net.ListenConfig + + for range ephemeralBindAttempts { + ln, lerr := lc.Listen(ctx, "tcp", addr) + if lerr != nil { + return nil, nil, fmt.Errorf("dns ephemeral tcp: %w", lerr) + } + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + _ = ln.Close() + + return nil, nil, fmt.Errorf("%w: %T", ErrUnexpectedAddr, ln.Addr()) + } + + pc, perr := lc.ListenPacket(ctx, "udp", net.JoinHostPort(host, strconv.Itoa(tcpAddr.Port))) + if perr == nil { + return ln, pc, nil + } + + _ = ln.Close() + } + + return nil, nil, ErrEphemeralBind +} + +func (s *Server) setBoundPort(a net.Addr) { + if t, ok := a.(*net.TCPAddr); ok { + s.boundPort.Store(int64(t.Port)) + } +} + +// Port returns the TCP/UDP port the server is bound to, or 0 before Start binds an ephemeral port. +func (s *Server) Port() int { return int(s.boundPort.Load()) } diff --git a/pkgs/dns/dnstest/dnstest_test.go b/pkgs/dns/dnstest/dnstest_test.go index c5e233f543..c348088904 100644 --- a/pkgs/dns/dnstest/dnstest_test.go +++ b/pkgs/dns/dnstest/dnstest_test.go @@ -17,12 +17,12 @@ func TestStart(t *testing.T) { t.Parallel() tests := []struct { - name string - holdFirst bool - wantPickups int + name string + holdFirst bool + minPickups int }{ - {name: "free_port", wantPickups: 1}, - {name: "first_tcp_port_taken", holdFirst: true, wantPickups: 2}, + {name: "free_port", minPickups: 1}, + {name: "first_tcp_port_taken", holdFirst: true, minPickups: 2}, } for _, tt := range tests { @@ -53,7 +53,9 @@ func TestStart(t *testing.T) { } srv, addr, _ := dnstest.StartWith(t, gopherDNS.Config{ResolveIP: "127.0.0.1"}, pick) - assert.Equal(t, tt.wantPickups, pickups) + // Another process can steal a later free port, so only the injected + // collision is exact: at least one retry per held port. + assert.GreaterOrEqual(t, pickups, tt.minPickups) srv.RegisterRecord("x.test", "A", []string{"1.2.3.4"}) diff --git a/pkgs/dns/stop_test.go b/pkgs/dns/stop_test.go index 17f4b40469..406f294f15 100644 --- a/pkgs/dns/stop_test.go +++ b/pkgs/dns/stop_test.go @@ -3,8 +3,10 @@ package dns_test import ( "context" "net" + "strconv" "testing" + mdns "github.com/miekg/dns" "github.com/stretchr/testify/require" gopherDNS "github.com/blackbirdworks/gopherstack/pkgs/dns" @@ -88,3 +90,23 @@ func TestServer_StartFailureStopsOtherServer(t *testing.T) { require.Error(t, s.Start(t.Context())) require.NoError(t, s.Stop()) } + +func TestServer_EphemeralPort(t *testing.T) { + t.Parallel() + + s, err := gopherDNS.New(gopherDNS.Config{ListenAddr: "127.0.0.1:0"}) + require.NoError(t, err) + require.NoError(t, s.Start(t.Context())) + t.Cleanup(func() { _ = s.Stop() }) + require.NotZero(t, s.Port()) + + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(s.Port())) + + for _, network := range []string{"tcp", "udp"} { + m := new(mdns.Msg) + m.SetQuestion("example.test.", mdns.TypeA) + + _, _, err = (&mdns.Client{Net: network}).ExchangeContext(t.Context(), m, addr) + require.NoError(t, err, network) + } +} diff --git a/pkgs/dynamoattr/attr.go b/pkgs/dynamoattr/attr.go index 5d567ad6a8..39e782c0c1 100644 --- a/pkgs/dynamoattr/attr.go +++ b/pkgs/dynamoattr/attr.go @@ -5,6 +5,8 @@ import ( "fmt" "strconv" "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" ) // UnwrapAttributeValue converts a DynamoDB wire attribute map into a bare value when possible. @@ -56,23 +58,23 @@ func unwrapAttributeValueRare(m map[string]any, v any) any { return v } -// ParseNumeric tries to read a value as a float64 number. -func ParseNumeric(v any) (float64, bool) { +// ParseNumeric tries to read a value as an exact DynamoDB number. +func ParseNumeric(v any) (dynamonum.Num, bool) { unwrapped := UnwrapAttributeValue(v) switch val := unwrapped.(type) { case float64: - return val, true + return dynamonum.FromFloat(val) case int: - return float64(val), true + return dynamonum.FromInt64(int64(val)), true case int64: - return float64(val), true + return dynamonum.FromInt64(val), true case string: - if f, err := strconv.ParseFloat(val, 64); err == nil { - return f, true + if x, err := dynamonum.Parse(val); err == nil { + return x, true } } - return 0, false + return dynamonum.Num{}, false } // ResolveValue maps expression tokens to attribute values when prefixed with ':'. @@ -87,64 +89,38 @@ func ResolveValue(token string, attrValues map[string]any) any { } // CompareValues compares DynamoDB attribute values against an operator. +// Two numeric operands compare exactly; anything else compares as strings. func CompareValues(lhs any, op string, rhs any) bool { lhs = UnwrapAttributeValue(lhs) rhs = UnwrapAttributeValue(rhs) - lhsStr := ToString(lhs) - rhsStr := ToString(rhs) - lNum, lIsNum := ParseNumeric(lhs) - rNum, rIsNum := ParseNumeric(rhs) + var c int + + if lNum, ok := ParseNumeric(lhs); ok { + if rNum, ok2 := ParseNumeric(rhs); ok2 { + c = lNum.Cmp(rNum) + + return cmpResult(op, c) + } + } + return cmpResult(op, strings.Compare(ToString(lhs), ToString(rhs))) +} + +func cmpResult(op string, c int) bool { switch op { case "=": - return lhsStr == rhsStr + return c == 0 case "<>": - return lhsStr != rhsStr + return c != 0 case "<": - return compareOrdered( - lNum, - rNum, - lIsNum, - rIsNum, - lhsStr, - rhsStr, - func(a, b float64) bool { return a < b }, - func(a, b string) bool { return a < b }, - ) + return c < 0 case ">": - return compareOrdered( - lNum, - rNum, - lIsNum, - rIsNum, - lhsStr, - rhsStr, - func(a, b float64) bool { return a > b }, - func(a, b string) bool { return a > b }, - ) + return c > 0 case "<=": - return compareOrdered( - lNum, - rNum, - lIsNum, - rIsNum, - lhsStr, - rhsStr, - func(a, b float64) bool { return a <= b }, - func(a, b string) bool { return a <= b }, - ) + return c <= 0 case ">=": - return compareOrdered( - lNum, - rNum, - lIsNum, - rIsNum, - lhsStr, - rhsStr, - func(a, b float64) bool { return a >= b }, - func(a, b string) bool { return a >= b }, - ) + return c >= 0 } return false @@ -195,17 +171,3 @@ func ToString(v any) string { return string(b) } } - -func compareOrdered( - lNum, rNum float64, - lIsNum, rIsNum bool, - lStr, rStr string, - numCmp func(float64, float64) bool, - strCmp func(string, string) bool, -) bool { - if lIsNum && rIsNum { - return numCmp(lNum, rNum) - } - - return strCmp(lStr, rStr) -} diff --git a/pkgs/dynamoattr/attr_test.go b/pkgs/dynamoattr/attr_test.go index 1b2f177e59..5fde09187b 100644 --- a/pkgs/dynamoattr/attr_test.go +++ b/pkgs/dynamoattr/attr_test.go @@ -60,15 +60,15 @@ func TestParseNumeric(t *testing.T) { cases := []struct { in any name string - want float64 + want string okWant bool }{ - {in: float64(1.5), name: "float64", want: 1.5, okWant: true}, - {in: int(2), name: "int", want: 2, okWant: true}, - {in: int64(3), name: "int64", want: 3, okWant: true}, - {in: "4.5", name: "string", want: 4.5, okWant: true}, - {in: map[string]any{"N": "6"}, name: "wrapped", want: 6, okWant: true}, - {in: "nope", name: "bad", want: 0, okWant: false}, + {in: float64(1.5), name: "float64", want: "1.5", okWant: true}, + {in: int(2), name: "int", want: "2", okWant: true}, + {in: int64(3), name: "int64", want: "3", okWant: true}, + {in: "4.5", name: "string", want: "4.5", okWant: true}, + {in: map[string]any{"N": "6"}, name: "wrapped", want: "6", okWant: true}, + {in: "nope", name: "bad", want: "", okWant: false}, } for _, tc := range cases { @@ -77,7 +77,7 @@ func TestParseNumeric(t *testing.T) { got, ok := dynamoattr.ParseNumeric(tc.in) assert.Equal(t, tc.okWant, ok) if ok { - assert.InDelta(t, tc.want, got, 1e-9) + assert.Equal(t, tc.want, got.String()) } }) } diff --git a/pkgs/dynamonum/num.go b/pkgs/dynamonum/num.go new file mode 100644 index 0000000000..d99ff33ae4 --- /dev/null +++ b/pkgs/dynamonum/num.go @@ -0,0 +1,642 @@ +// Package dynamonum implements DynamoDB's exact decimal Number type: up to 38 +// significant digits, magnitude 1E-130..9.9999999999999999999999999999999999999E+125. +package dynamonum + +import ( + "errors" + "math/big" + "strconv" +) + +const ( + // MaxDigits is the maximum number of significant digits. + MaxDigits = 38 + maxExp = 125 + minExp = -130 + fastDigit = 18 + base10 = 10 +) + +var ( + // ErrInvalid is returned for text that is not a decimal number. + ErrInvalid = errors.New("not a valid number") + // ErrOverflow is returned when the magnitude exceeds the supported range. + ErrOverflow = errors.New( //nolint:staticcheck // verbatim AWS message + "Number overflow. Attempting to store a number with magnitude larger than supported range") + // ErrUnderflow is returned when a non-zero magnitude is below the supported range. + ErrUnderflow = errors.New( //nolint:staticcheck // verbatim AWS message + "Number underflow. Attempting to store a number with magnitude smaller than supported range") + // ErrTooManyDigits is returned when a value needs more than 38 significant digits. + ErrTooManyDigits = errors.New( //nolint:staticcheck // verbatim AWS message + "Attempting to store more than 38 significant digits in a Number") +) + +// Num is a normalised decimal: value = (+/-) d[:n] * 10^scale, with no leading or +// trailing zero digit. Non-zero integers of at most 18 digits are held only in +// small (the fast path); the digit fields are then unset. The zero value is zero. +// Equal values have equal representations, so Num is comparable with ==. +type Num struct { + small int64 + d [MaxDigits]byte + scale int16 + n uint8 + neg bool +} + +// IsZero reports whether the value is zero. +func (x Num) IsZero() bool { return x.n == 0 && x.small == 0 } + +// wide returns the digit form of x. +func (x Num) wide() Num { + if x.small == 0 { + return x + } + + return fromInt64Wide(x.small) +} + +// compact converts an integer of at most fastDigit digits to the small form. +func (x Num) compact() Num { + if x.n == 0 { + return Num{} + } + + if v, ok := x.int64Fast(); ok { + return Num{small: v} + } + + return x +} + +// mag is the decimal exponent of the most significant digit. +func (x Num) mag() int { return int(x.scale) + int(x.n) - 1 } + +// Parse parses and range-checks a DynamoDB number string. +func Parse(s string) (Num, error) { + if plainInt(s) { + return parsePlainInt(s), nil + } + + return parseGeneral(s) +} + +// mantissa accumulates significant digits; trailing zeros stay pending (zeros). +type mantissa struct { + cnt int + zeros int + frac int + buf [MaxDigits]byte + leading bool + extra bool +} + +func (m *mantissa) add(c byte) { + if m.leading && c == '0' { + return + } + + m.leading = false + + if c == '0' { + m.zeros++ + + return + } + + if m.cnt+m.zeros >= MaxDigits { + m.extra = true + + return + } + + for ; m.zeros > 0; m.zeros-- { + m.buf[m.cnt] = '0' + m.cnt++ + } + + m.buf[m.cnt] = c + m.cnt++ +} + +// scan consumes [digits][.digits] from s and returns the index where it stopped. +func (m *mantissa) scan(s string, i int) (int, bool) { + pointSeen, anyDigit := false, false + + for ; i < len(s); i++ { + c := s[i] + + switch { + case c == '.': + if pointSeen { + return i, false + } + + pointSeen = true + case c >= '0' && c <= '9': + anyDigit = true + + if pointSeen { + m.frac++ + } + + m.add(c) + default: + return i, anyDigit + } + } + + return i, anyDigit +} + +func parseGeneral(s string) (Num, error) { + var x Num + + i := 0 + if i < len(s) && (s[i] == '-' || s[i] == '+') { + x.neg = s[i] == '-' + i++ + } + + m := mantissa{leading: true} + + i, ok := m.scan(s, i) + if !ok { + return Num{}, ErrInvalid + } + + exp := 0 + + if i < len(s) { + if s[i] != 'e' && s[i] != 'E' { + return Num{}, ErrInvalid + } + + e, err := parseExp(s[i+1:]) + if err != nil { + return Num{}, err + } + + exp = e + } + + if m.extra { + return Num{}, ErrTooManyDigits + } + + if m.cnt == 0 { + return Num{}, nil + } + + copy(x.d[:], m.buf[:m.cnt]) + x.n = uint8(m.cnt) //nolint:gosec // cnt <= MaxDigits + x.scale = clampScale(exp - m.frac + m.zeros) + + if err := x.check(); err != nil { + return Num{}, err + } + + return x.compact(), nil +} + +// parsePlainInt handles plainInt text (<= 38 digits, within range by construction). +func parsePlainInt(s string) Num { + var x Num + + if s == "0" { + return x + } + + neg := s[0] == '-' + if neg { + s = s[1:] + } + + if len(s) <= fastDigit { + var v int64 + + for i := range len(s) { + v = v*base10 + int64(s[i]-'0') + } + + if neg { + v = -v + } + + return Num{small: v} + } + + x.neg = neg + end := len(s) + for s[end-1] == '0' { + end-- + } + + copy(x.d[:], s[:end]) + x.n = uint8(end) //nolint:gosec // end <= MaxDigits + x.scale = int16(len(s) - end) //nolint:gosec // < MaxDigits + + return x +} + +func clampScale(v int) int16 { + const lim = 1 << 14 + if v > lim { + v = lim + } + + if v < -lim { + v = -lim + } + + return int16(v) +} + +func parseExp(s string) (int, error) { + if s == "" { + return 0, ErrInvalid + } + + neg := false + if s[0] == '-' || s[0] == '+' { + neg = s[0] == '-' + s = s[1:] + } + + if s == "" { + return 0, ErrInvalid + } + + v := 0 + + for i := range len(s) { + c := s[i] + if c < '0' || c > '9' { + return 0, ErrInvalid + } + + if v < 1<<20 { + v = v*base10 + int(c-'0') + } + } + + if neg { + v = -v + } + + return v, nil +} + +func (x Num) check() error { + if x.n == 0 { + return nil + } + + if m := x.mag(); m > maxExp { + return ErrOverflow + } else if m < minExp { + return ErrUnderflow + } + + return nil +} + +// FromInt64 converts an integer. +func FromInt64(v int64) Num { + if v > -smallLimit && v < smallLimit { + return Num{small: v} + } + + return fromInt64Wide(v) +} + +const smallLimit = 1_000_000_000_000_000_000 + +func fromInt64Wide(v int64) Num { + var x Num + + if v == 0 { + return x + } + + u := uint64(v) //nolint:gosec // sign handled below + if v < 0 { + x.neg = true + u = -u + } + + var tmp [20]byte + + n := 0 + + for u > 0 { + tmp[n] = byte(u%base10) + '0' + u /= base10 + n++ + } + + scale := 0 + + for k := 0; k < n && tmp[k] == '0'; k++ { + scale++ + } + + x.n = uint8(n - scale) //nolint:gosec // n <= 19 + for k := range int(x.n) { + x.d[k] = tmp[n-1-k] + } + + x.scale = int16(scale) + + return x +} + +// FromFloat converts a float64 via its shortest decimal form; false if not finite or out of range. +func FromFloat(f float64) (Num, bool) { + x, err := Parse(strconv.FormatFloat(f, 'e', -1, 64)) + + return x, err == nil +} + +// Cmp returns -1, 0 or +1. +func (x Num) Cmp(y Num) int { + if x.small != 0 && y.small != 0 { + switch { + case x.small < y.small: + return -1 + case x.small > y.small: + return 1 + } + + return 0 + } + + x, y = x.wide(), y.wide() + + switch { + case x.n == 0 && y.n == 0: + return 0 + case x.n == 0: + if y.neg { + return 1 + } + + return -1 + case y.n == 0: + if x.neg { + return -1 + } + + return 1 + case x.neg != y.neg: + if x.neg { + return -1 + } + + return 1 + } + + c := x.cmpMag(y) + if x.neg { + return -c + } + + return c +} + +func (x Num) cmpMag(y Num) int { + if mx, my := x.mag(), y.mag(); mx != my { + if mx < my { + return -1 + } + + return 1 + } + + m := min(x.n, y.n) + for i := range m { + if x.d[i] != y.d[i] { + if x.d[i] < y.d[i] { + return -1 + } + + return 1 + } + } + + switch { + case x.n < y.n: + return -1 + case x.n > y.n: + return 1 + } + + return 0 +} + +// String renders canonical plain decimal text (no exponent, no redundant zeros). +func (x Num) String() string { + if x.small != 0 { + return strconv.FormatInt(x.small, base10) + } + + if x.n == 0 { + return "0" + } + + var b []byte + + if x.neg { + b = append(b, '-') + } + + n, sc := int(x.n), int(x.scale) + + switch { + case sc >= 0: + b = append(b, x.d[:n]...) + for range sc { + b = append(b, '0') + } + case -sc >= n: + b = append(b, "0."...) + for range -sc - n { + b = append(b, '0') + } + + b = append(b, x.d[:n]...) + default: + b = append(b, x.d[:n+sc]...) + b = append(b, '.') + b = append(b, x.d[n+sc:n]...) + } + + return string(b) +} + +// Canonical returns s with redundant zeros, exponent and sign normalised, as DynamoDB +// stores numbers. Invalid text and text with meaningless leading zeros ("007") is +// returned unchanged so validation can reject it. +func Canonical(s string) string { + if plainInt(s) { + return s + } + + body := s + if body != "" && (body[0] == '-' || body[0] == '+') { + body = body[1:] + } + + if len(body) > 1 && body[0] == '0' && body[1] >= '0' && body[1] <= '9' { + return s + } + + x, err := Parse(s) + if err != nil { + return s + } + + if c := x.String(); c != s { + return c + } + + return s +} + +// plainInt reports whether s is already a canonical integer ("0", "-12", "340"). +func plainInt(s string) bool { + if s == "0" { + return true + } + + i := 0 + if s != "" && s[0] == '-' { + i = 1 + } + + if i >= len(s) || s[i] < '1' || s[i] > '9' || len(s)-i > MaxDigits { + return false + } + + for ; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + + return true +} + +func (x Num) int64Fast() (int64, bool) { + if x.small != 0 { + return x.small, true + } + + if x.scale < 0 || int(x.n)+int(x.scale) > fastDigit { + return 0, false + } + + var v int64 + + for i := range int(x.n) { + v = v*base10 + int64(x.d[i]-'0') + } + + for range x.scale { + v *= base10 + } + + if x.neg { + v = -v + } + + return v, true +} + +func (x Num) toBig() (*big.Int, int) { + x = x.wide() + z := new(big.Int) + z.SetString(string(x.d[:x.n]), base10) + + if x.neg { + z.Neg(z) + } + + return z, int(x.scale) +} + +// Add returns x+y, or an error when the result leaves the supported range or precision. +func Add(x, y Num) (Num, error) { + if a, ok := x.int64Fast(); ok { + if b, ok2 := y.int64Fast(); ok2 { + return FromInt64(a + b), nil + } + } + + return addBig(x, y) +} + +// Sub returns x-y. +func Sub(x, y Num) (Num, error) { + if y.small != 0 { + y.small = -y.small + } else if y.n != 0 { + y.neg = !y.neg + } + + return Add(x, y) +} + +func addBig(x, y Num) (Num, error) { + if x.IsZero() { + return y, nil + } + + if y.IsZero() { + return x, nil + } + + a, sa := x.toBig() + b, sb := y.toBig() + s := min(sa, sb) + + a.Mul(a, pow10(sa-s)) + b.Mul(b, pow10(sb-s)) + a.Add(a, b) + + return fromBig(a, s) +} + +func pow10(n int) *big.Int { + return new(big.Int).Exp(big.NewInt(base10), big.NewInt(int64(n)), nil) +} + +func fromBig(z *big.Int, scale int) (Num, error) { + var x Num + + if z.Sign() == 0 { + return x, nil + } + + x.neg = z.Sign() < 0 + str := new(big.Int).Abs(z).String() + + end := len(str) + for end > 1 && str[end-1] == '0' { + end-- + scale++ + } + + if end > MaxDigits { + return Num{}, ErrTooManyDigits + } + + copy(x.d[:], str[:end]) + x.n = uint8(end) //nolint:gosec // end <= MaxDigits + x.scale = clampScale(scale) + + if err := x.check(); err != nil { + return Num{}, err + } + + return x.compact(), nil +} diff --git a/pkgs/dynamonum/num_test.go b/pkgs/dynamonum/num_test.go new file mode 100644 index 0000000000..d4f16a506c --- /dev/null +++ b/pkgs/dynamonum/num_test.go @@ -0,0 +1,220 @@ +package dynamonum_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" +) + +func TestParseCanonical(t *testing.T) { + t.Parallel() + + tests := []struct{ in, want string }{ + {"1", "1"}, + {"1.0", "1"}, + {"1.10", "1.1"}, + {"-0", "0"}, + {"0.000", "0"}, + {"1E+3", "1000"}, + {"1e3", "1000"}, + {"12.5e-2", "0.125"}, + {"007", "7"}, + {"-1.50", "-1.5"}, + {".5", "0.5"}, + {"100", "100"}, + {"1E-130", "0." + zeros(129) + "1"}, + {"99999999999999999999999999999999999999", "99999999999999999999999999999999999999"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + + x, err := dynamonum.Parse(tt.in) + require.NoError(t, err) + assert.Equal(t, tt.want, x.String()) + }) + } +} + +func zeros(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = '0' + } + + return string(b) +} + +func TestParseErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + err error + in string + }{ + {dynamonum.ErrInvalid, ""}, + {dynamonum.ErrInvalid, "abc"}, + {dynamonum.ErrInvalid, "1e"}, + {dynamonum.ErrInvalid, "1.2.3"}, + {dynamonum.ErrInvalid, "NaN"}, + {dynamonum.ErrInvalid, "Inf"}, + {dynamonum.ErrTooManyDigits, "123456789012345678901234567890123456789"}, + {dynamonum.ErrOverflow, "1E+126"}, + {dynamonum.ErrUnderflow, "1E-131"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + + _, err := dynamonum.Parse(tt.in) + require.ErrorIs(t, err, tt.err) + }) + } + + _, err := dynamonum.Parse("9.9999999999999999999999999999999999999E+125") + require.NoError(t, err) +} + +func TestCmp(t *testing.T) { + t.Parallel() + + tests := []struct { + a, b string + want int + }{ + {"1", "1.0", 0}, + {"1E+3", "1000", 0}, + {"-0", "0", 0}, + {"12345678901234567890123456789012345678", "12345678901234567890123456789012345679", -1}, + {"-12345678901234567890123456789012345678", "-12345678901234567890123456789012345679", 1}, + {"2", "10", -1}, + {"-2", "-10", 1}, + {"-1", "1", -1}, + {"0.5", "0", 1}, + {"-0.5", "0", -1}, + {"1.5", "1.25", 1}, + {"1", "12", -1}, + } + + for _, tt := range tests { + t.Run(tt.a+"_"+tt.b, func(t *testing.T) { + t.Parallel() + + a, err := dynamonum.Parse(tt.a) + require.NoError(t, err) + + b, err := dynamonum.Parse(tt.b) + require.NoError(t, err) + assert.Equal(t, tt.want, a.Cmp(b)) + assert.Equal(t, -tt.want, b.Cmp(a)) + }) + } +} + +func TestAddSub(t *testing.T) { + t.Parallel() + + maxNum := "99999999999999999999999999999999999999" + tests := []struct { + err error + a, b, add, sub string + }{ + {a: "1", b: "2", add: "3", sub: "-1"}, + {a: "0.1", b: "0.2", add: "0.3", sub: "-0.1"}, + { + a: "12345678901234567890123456789012345678", + b: "1", + add: "12345678901234567890123456789012345679", + sub: "12345678901234567890123456789012345677", + }, + {a: "1E+10", b: "1", add: "10000000001", sub: "9999999999"}, + {a: "1.10", b: "0", add: "1.1", sub: "1.1"}, + {a: maxNum, b: "0.5", err: dynamonum.ErrTooManyDigits, sub: "99999999999999999999999999999999999998.5"}, + {a: "5E+125", b: "5E+125", err: dynamonum.ErrOverflow, sub: "0"}, + {a: "1E-130", b: "1E-130", add: "0." + zeros(129) + "2", sub: "0"}, + {a: "1E-130", b: "-1E-130", add: "0", sub: "0." + zeros(129) + "2"}, + } + + for _, tt := range tests { + t.Run(tt.a+"_"+tt.b, func(t *testing.T) { + t.Parallel() + + a, err := dynamonum.Parse(tt.a) + require.NoError(t, err) + + b, err := dynamonum.Parse(tt.b) + require.NoError(t, err) + + got, err := dynamonum.Add(a, b) + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.add, got.String()) + } + + got, err = dynamonum.Sub(a, b) + if tt.sub == "" { + return + } + + if tt.err != nil && tt.sub == "0" { + return + } + + if tt.a == maxNum { + require.ErrorIs(t, err, dynamonum.ErrTooManyDigits) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.sub, got.String()) + }) + } +} + +func BenchmarkParseCmp(b *testing.B) { + lo, _ := dynamonum.Parse("100") + b.ReportAllocs() + + for b.Loop() { + x, _ := dynamonum.Parse("12345") + _ = x.Cmp(lo) + } +} + +func TestEqualValuesAreComparable(t *testing.T) { + t.Parallel() + + tests := []struct{ a, b string }{ + {"1", "1.0"}, + {"1000", "1E+3"}, + {"-5", "-5.00"}, + {"0", "-0.0"}, + {"1E+20", "100000000000000000000"}, + {"0.25", "25E-2"}, + } + + for _, tt := range tests { + t.Run(tt.a+"_"+tt.b, func(t *testing.T) { + t.Parallel() + + a, err := dynamonum.Parse(tt.a) + require.NoError(t, err) + + b, err := dynamonum.Parse(tt.b) + require.NoError(t, err) + assert.Equal(t, a, b) + }) + } + + got, err := dynamonum.Add(dynamonum.FromInt64(999999999999999999), dynamonum.FromInt64(1)) + require.NoError(t, err) + assert.Equal(t, "1000000000000000000", got.String()) +} diff --git a/pkgs/extensions/main_internal_test.go b/pkgs/extensions/main_internal_test.go index 5633cc3136..6daa0cd011 100644 --- a/pkgs/extensions/main_internal_test.go +++ b/pkgs/extensions/main_internal_test.go @@ -11,6 +11,7 @@ import ( "syscall" "testing" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" "github.com/blackbirdworks/gopherstack/pkgs/testleak" ) @@ -71,7 +72,7 @@ func runHelper() { ) req.Header.Set("Authorization", "Bearer "+os.Getenv("GOPHERSTACK_EXTENSION_TOKEN")) - resp, err := http.DefaultClient.Do(req) + resp, err := (&http.Client{Transport: httpclient.NewTransport()}).Do(req) if err != nil || resp.StatusCode != http.StatusOK { os.Exit(3) } diff --git a/pkgs/gcpenforce/enforce_test.go b/pkgs/gcpenforce/enforce_test.go new file mode 100644 index 0000000000..d58fa245ff --- /dev/null +++ b/pkgs/gcpenforce/enforce_test.go @@ -0,0 +1,195 @@ +package gcpenforce_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" +) + +type fakeEnforcer struct { + projectErr, serviceErr error + calls []string +} + +func (f *fakeEnforcer) CheckProject(project, service string) error { + f.calls = append(f.calls, "project:"+project+":"+service) + + return f.projectErr +} + +func (f *fakeEnforcer) CheckServiceEnabled(project, service string) error { + f.calls = append(f.calls, "service:"+project+":"+service) + + return f.serviceErr +} + +func TestGateCheck(t *testing.T) { + t.Parallel() + + errDeny := status.Error(codes.PermissionDenied, "no") + + tests := []struct { + name string + gate func(f *fakeEnforcer) *gcpenforce.Gate + project string + calls []string + wantErr bool + }{ + {"nil gate", func(*fakeEnforcer) *gcpenforce.Gate { return nil }, "p", nil, false}, + {"all off", func(f *fakeEnforcer) *gcpenforce.Gate { return &gcpenforce.Gate{Enforcer: f} }, "p", nil, false}, + {"no project named", func(f *fakeEnforcer) *gcpenforce.Gate { + return &gcpenforce.Gate{Enforcer: f, Settings: gcpenforce.Settings{Projects: true}} + }, "", nil, false}, + {"projects only", func(f *fakeEnforcer) *gcpenforce.Gate { + f.projectErr = errDeny + + return &gcpenforce.Gate{Enforcer: f, Settings: gcpenforce.Settings{Projects: true}} + }, "p", []string{"project:p:s.googleapis.com"}, true}, + {"service enablement implies project check", func(f *fakeEnforcer) *gcpenforce.Gate { + f.serviceErr = errDeny + + return &gcpenforce.Gate{Enforcer: f, Settings: gcpenforce.Settings{Projects: true, ServiceEnablement: true}} + }, "p", []string{"service:p:s.googleapis.com"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := &fakeEnforcer{} + err := tt.gate(f).Check(tt.project, "s.googleapis.com") + + assert.Equal(t, tt.wantErr, err != nil) + assert.Equal(t, tt.calls, f.calls) + }) + } +} + +func TestNoGateInContextAddsNothing(t *testing.T) { + t.Parallel() + + assert.Nil(t, gcpenforce.ServerOptions(t.Context(), gcpenforce.Target{Host: "x"})) + assert.Nil(t, gcpenforce.GateFrom(t.Context())) + assert.Equal(t, t.Context(), gcpenforce.WithGate(t.Context(), nil)) + + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + assert.NotNil(t, gcpenforce.Middleware(t.Context(), gcpenforce.Target{Host: "x"}, next)) +} + +type publisher struct { + pubsubpb.UnimplementedPublisherServer +} + +func (publisher) CreateTopic(_ context.Context, in *pubsubpb.Topic) (*pubsubpb.Topic, error) { + return in, nil +} + +func (publisher) DeleteTopic(context.Context, *pubsubpb.DeleteTopicRequest) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func TestInterceptorAnswersWithTheEnforcerError(t *testing.T) { + t.Parallel() + + errDenied := status.Error(codes.PermissionDenied, "denied") + f := &fakeEnforcer{serviceErr: errDenied} + ctx := gcpenforce.WithGate(t.Context(), + &gcpenforce.Gate{Enforcer: f, Settings: gcpenforce.Settings{ServiceEnablement: true}}) + + srv := gcpenforce.NewServer(ctx, "pubsub.googleapis.com") + pubsubpb.RegisterPublisherServer(srv, publisher{}) + + var lc net.ListenConfig + + l, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { _ = srv.Serve(l) }() + + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient(l.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + pub := pubsubpb.NewPublisherClient(conn) + + _, err = pub.CreateTopic(t.Context(), &pubsubpb.Topic{Name: "projects/p/topics/t"}) + require.Error(t, err) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + assert.Equal(t, []string{"service:p:pubsub.googleapis.com"}, f.calls) + + f.serviceErr = nil + + _, err = pub.CreateTopic(t.Context(), &pubsubpb.Topic{Name: "projects/p/topics/t"}) + require.NoError(t, err) +} + +func TestMiddlewareWritesJSONEnvelope(t *testing.T) { + t.Parallel() + + f := &fakeEnforcer{serviceErr: status.Error(codes.PermissionDenied, "disabled")} + ctx := gcpenforce.WithGate(t.Context(), + &gcpenforce.Gate{Enforcer: f, Settings: gcpenforce.Settings{ServiceEnablement: true}}) + + reached := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + + w.WriteHeader(http.StatusOK) + }) + h := gcpenforce.Middleware(ctx, gcpenforce.Target{Host: "compute.googleapis.com"}, next) + + tests := []struct { + name string + path string + wantCode int + wantReach bool + }{ + {"blocked", "/compute/v1/projects/p/zones/z/instances", http.StatusForbidden, false}, + {"operations skipped", "/compute/v1/projects/p/global/operations/op1", http.StatusOK, true}, + {"no project", "/compute/v1/other", http.StatusOK, true}, + } + + for _, tt := range tests { + reached = false + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, tt.path, nil)) + + assert.Equal(t, tt.wantCode, rec.Code, tt.name) + assert.Equal(t, tt.wantReach, reached, tt.name) + } + + assert.Contains(t, func() string { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/compute/v1/projects/p/x", nil)) + + return rec.Body.String() + }(), `"status":"PERMISSION_DENIED"`) + + e := echo.New() + e.Use(gcpenforce.EchoMiddleware(ctx, gcpenforce.Target{Host: "compute.googleapis.com"})) + e.Any("/*", func(c *echo.Context) error { return c.NoContent(http.StatusNoContent) }) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/compute/v1/projects/p/x", nil)) + assert.Equal(t, http.StatusForbidden, rec.Code) + + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/compute/v1/none", nil)) + assert.Equal(t, http.StatusNoContent, rec.Code) +} diff --git a/pkgs/gcpenforce/extract.go b/pkgs/gcpenforce/extract.go new file mode 100644 index 0000000000..52548b5aa6 --- /dev/null +++ b/pkgs/gcpenforce/extract.go @@ -0,0 +1,198 @@ +package gcpenforce + +import ( + "net/http" + "regexp" + "strings" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +const ( + fieldProject = "project" + fieldProjectID = "project_id" + projectsSegment = "projects/" + wildcardProject = "-" + maxDepth = 2 +) + +var projectRE = regexp.MustCompile(`(?:^|/)projects/([^/\s:?]+)`) + +// priority lists the request field names that carry a resource name, most specific first. A field outside the +// list is tried last, in declaration order. +// +//nolint:gochecknoglobals // immutable lookup table +var priority = []string{ + fieldProject, fieldProjectID, "project_number", "parent", "name", "resource", "database", "instance", "session", + "table_name", "topic", "subscription", "key_ring", "crypto_key", "secret", "location", +} + +//nolint:gochecknoglobals // immutable lookup table +var bareProjectFields = map[string]bool{fieldProject: true, fieldProjectID: true, "project_number": true} + +// FromMessage extracts the project a request targets. paths are per-method override field paths (dotted proto +// field names, such as "topic.name") tried first. It returns "" when the request names no project. +func FromMessage(msg proto.Message, paths ...string) string { + if msg == nil { + return "" + } + + m := msg.ProtoReflect() + + for _, p := range paths { + if v := stringAtPath(m, strings.Split(p, ".")); v != "" { + if proj := fromValue(lastSegment(p), v); proj != "" { + return proj + } + } + } + + var cands []candidate + + collect(m, 0, &cands) + + best := -1 + bestRank := len(priority) + 1 + + for i, c := range cands { + if r := rank(c.field); r < bestRank { + best, bestRank = i, r + } + } + + if best < 0 { + return "" + } + + return cands[best].project +} + +type candidate struct { + field string + project string +} + +func rank(field string) int { + for i, f := range priority { + if f == field { + return i + } + } + + return len(priority) +} + +func collect(m protoreflect.Message, depth int, out *[]candidate) { + fields := m.Descriptor().Fields() + + for i := range fields.Len() { + fd := fields.Get(i) + + if !m.Has(fd) { + continue + } + + name := string(fd.Name()) + + switch { + case fd.Kind() == protoreflect.StringKind && !fd.IsMap(): + collectString(m, fd, name, out) + case fd.Kind() == protoreflect.MessageKind && !fd.IsList() && !fd.IsMap() && depth < maxDepth: + collect(m.Get(fd).Message(), depth+1, out) + } + } +} + +func collectString(m protoreflect.Message, fd protoreflect.FieldDescriptor, name string, out *[]candidate) { + v := m.Get(fd) + if fd.IsList() { + if v.List().Len() == 0 { + return + } + + v = v.List().Get(0) + } + + if proj := fromValue(name, v.String()); proj != "" { + *out = append(*out, candidate{field: name, project: proj}) + } +} + +func fromValue(field, v string) string { + if v == "" { + return "" + } + + if mm := projectRE.FindStringSubmatch(v); mm != nil { + return clean(mm[1]) + } + + if bareProjectFields[field] && !strings.Contains(v, "/") { + return clean(v) + } + + return "" +} + +func clean(p string) string { + if p == wildcardProject { + return "" + } + + return p +} + +func lastSegment(path string) string { + _, last, found := strings.Cut(path, ".") + for found { + path = last + _, last, found = strings.Cut(path, ".") + } + + return path +} + +func stringAtPath(m protoreflect.Message, path []string) string { + for i, seg := range path { + fd := m.Descriptor().Fields().ByName(protoreflect.Name(seg)) + if fd == nil || fd.IsList() || fd.IsMap() { + return "" + } + + if i == len(path)-1 { + if fd.Kind() != protoreflect.StringKind { + return "" + } + + return m.Get(fd).String() + } + + if fd.Kind() != protoreflect.MessageKind { + return "" + } + + m = m.Get(fd).Message() + } + + return "" +} + +// FromRequest extracts the project of a REST request: a projects/{id} path segment, else the project query +// parameter. With queryOnly the path is ignored (APIs whose object names may contain "projects/"). +func FromRequest(r *http.Request, queryOnly bool) string { + if !queryOnly { + if mm := projectRE.FindStringSubmatch(r.URL.EscapedPath()); mm != nil { + return clean(mm[1]) + } + } + + q := r.URL.Query() + for _, k := range []string{fieldProject, "projectId", fieldProjectID} { + if v := q.Get(k); v != "" { + return clean(strings.TrimPrefix(v, projectsSegment)) + } + } + + return "" +} diff --git a/pkgs/gcpenforce/extract_test.go b/pkgs/gcpenforce/extract_test.go new file mode 100644 index 0000000000..3c77927ca8 --- /dev/null +++ b/pkgs/gcpenforce/extract_test.go @@ -0,0 +1,187 @@ +package gcpenforce_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + btpb "cloud.google.com/go/bigtable/apiv2/bigtablepb" + "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" + "cloud.google.com/go/firestore/apiv1/firestorepb" + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/kms/apiv1/kmspb" + "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb" + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" + "cloud.google.com/go/spanner/apiv1/spannerpb" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" +) + +func TestFromMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + msg proto.Message + name string + want string + paths []string + }{ + {name: "pubsub topic name", msg: &pubsubpb.Topic{Name: "projects/p1/topics/t"}, want: "p1"}, + {name: "pubsub publish topic", msg: &pubsubpb.PublishRequest{Topic: "projects/p2/topics/t"}, want: "p2"}, + { + name: "pubsub subscription beats its topic", + msg: &pubsubpb.Subscription{Name: "projects/a/subscriptions/s", Topic: "projects/b/topics/t"}, want: "a", + }, + { + name: "pubsub update nests the topic", + msg: &pubsubpb.UpdateTopicRequest{Topic: &pubsubpb.Topic{Name: "projects/p3/topics/t"}}, want: "p3", + }, + {name: "secretmanager parent", msg: &secretmanagerpb.CreateSecretRequest{Parent: "projects/p4"}, want: "p4"}, + { + name: "regional parent", want: "p5", + msg: &secretmanagerpb.ListSecretsRequest{Parent: "projects/p5/locations/us-east1"}, + }, + { + name: "kms name", want: "p6", + msg: &kmspb.GetCryptoKeyRequest{Name: "projects/p6/locations/g/keyRings/k/cryptoKeys/c"}, + }, + {name: "iam resource", msg: &iampb.GetIamPolicyRequest{Resource: "projects/p7/topics/t"}, want: "p7"}, + { + name: "full resource name", want: "p8", + msg: &iampb.GetIamPolicyRequest{Resource: "//pubsub.googleapis.com/projects/p8/topics/t"}, + }, + { + name: "spanner database", want: "p9", + msg: &spannerpb.CreateSessionRequest{Database: "projects/p9/instances/i/databases/d"}, + }, + { + name: "bigtable table", want: "p10", + msg: &btpb.ReadRowsRequest{TableName: "projects/p10/instances/i/tables/t"}, + }, + { + name: "firestore database", want: "p11", + msg: &firestorepb.GetDocumentRequest{Name: "projects/p11/databases/(default)/documents/c/d"}, + }, + { + name: "tasks parent", want: "p12", + msg: &cloudtaskspb.ListTasksRequest{Parent: "projects/p12/locations/l/queues/q"}, + }, + {name: "wildcard is no project", msg: &secretmanagerpb.ListSecretsRequest{Parent: "projects/-"}, want: ""}, + {name: "no project field", msg: &secretmanagerpb.ListSecretsRequest{}, want: ""}, + {name: "non project parent", msg: &secretmanagerpb.ListSecretsRequest{Parent: "folders/1"}, want: ""}, + { + name: "override path wins", want: "viaOverride", paths: []string{"topic.name"}, + msg: &pubsubpb.UpdateTopicRequest{ + Topic: &pubsubpb.Topic{Name: "projects/viaOverride/topics/t"}, + UpdateMask: nil, + }, + }, + { + name: "override missing falls back", want: "p13", paths: []string{"nope"}, + msg: &pubsubpb.Topic{Name: "projects/p13/topics/t"}, + }, + {name: "nil message", msg: nil, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, gcpenforce.FromMessage(tt.msg, tt.paths...)) + }) + } +} + +func TestFromRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + want string + queryOnly bool + }{ + {name: "compute path", url: "/compute/v1/projects/p1/zones/z/instances", want: "p1"}, + {name: "bigquery path", url: "/bigquery/v2/projects/p2/datasets", want: "p2"}, + {name: "dns path with verb", url: "/dns/v1/projects/p3/managedZones", want: "p3"}, + {name: "storage query", url: "/storage/v1/b?project=p4", want: "p4", queryOnly: true}, + {name: "storage ignores object path", url: "/storage/v1/b/bkt/o/projects/x", want: "", queryOnly: true}, + {name: "encoded object name is no project", url: "/storage/v1/b/bkt/o/projects%2Fx", want: ""}, + {name: "no project", url: "/v1/accounts:signUp", want: ""}, + {name: "wildcard", url: "/v1/projects/-/instances", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tt.url, nil) + assert.Equal(t, tt.want, gcpenforce.FromRequest(r, tt.queryOnly)) + }) + } +} + +func TestServiceForMethod(t *testing.T) { + t.Parallel() + + tests := []struct { + name, method, fallback, want string + }{ + {"pubsub", "/google.pubsub.v1.Publisher/Publish", "", "pubsub.googleapis.com"}, + {"subscriber", "/google.pubsub.v1.Subscriber/Pull", "", "pubsub.googleapis.com"}, + { + "secretmanager", "/google.cloud.secretmanager.v1.SecretManagerService/CreateSecret", "", + "secretmanager.googleapis.com", + }, + {"kms", "/google.cloud.kms.v1.KeyManagementService/GetCryptoKey", "", "cloudkms.googleapis.com"}, + {"spanner", "/google.spanner.v1.Spanner/CreateSession", "", "spanner.googleapis.com"}, + {"firestore", "/google.firestore.v1.Firestore/GetDocument", "", "firestore.googleapis.com"}, + {"tasks", "/google.cloud.tasks.v2.CloudTasks/ListTasks", "", "cloudtasks.googleapis.com"}, + { + "iam policy falls back", "/google.iam.v1.IAMPolicy/GetIamPolicy", + "pubsub.googleapis.com", "pubsub.googleapis.com", + }, + {"unknown falls back", "/nope.Service/Do", "x.googleapis.com", "x.googleapis.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, gcpenforce.ServiceForMethod(tt.method, tt.fallback)) + }) + } +} + +func TestExempt(t *testing.T) { + t.Parallel() + + tests := []struct { + method string + want bool + }{ + {"/google.longrunning.Operations/GetOperation", true}, + {"/google.cloud.resourcemanager.v3.Projects/GetProject", true}, + {"/google.api.serviceusage.v1.ServiceUsage/EnableService", true}, + {"/grpc.health.v1.Health/Check", true}, + {"/google.pubsub.v1.Publisher/Publish", false}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, gcpenforce.Exempt(tt.method)) + }) + } +} + +func TestOperationsPath(t *testing.T) { + t.Parallel() + + assert.True(t, gcpenforce.OperationsPath("/v1/projects/p/locations/l/operations/op")) + assert.True(t, gcpenforce.OperationsPath("/v1/operations")) + assert.False(t, gcpenforce.OperationsPath("/v1/projects/p/topics")) +} diff --git a/pkgs/gcpenforce/gate.go b/pkgs/gcpenforce/gate.go new file mode 100644 index 0000000000..3d32f93936 --- /dev/null +++ b/pkgs/gcpenforce/gate.go @@ -0,0 +1,79 @@ +// Package gcpenforce applies the opt-in project-existence and API-enablement checks to every GCP emulator: one +// gRPC interceptor and one HTTP middleware, fed by an Enforcer that Resource Manager implements. +package gcpenforce + +import "context" + +// Settings are the opt-in switches, embedded in the root CLI command as CLI.GCPEnforce. +type Settings struct { + Projects bool `json:"projects" env:"GCP_ENFORCE_PROJECTS" default:"false" name:"projects" help:"Reject GCP requests whose project does not exist or is DELETE_REQUESTED (needs the gcpresourcemanager service). Default off: any project id is accepted."` //nolint:lll // config struct tags are intentionally verbose + ServiceEnablement bool `json:"serviceEnablement" env:"GCP_ENFORCE_SERVICE_ENABLEMENT" default:"false" name:"service-enablement" help:"Reject GCP requests to a service whose API is not enabled for the project (SERVICE_DISABLED); implies the project check (needs the gcpresourcemanager service). Default off."` //nolint:lll // config struct tags are intentionally verbose +} + +// Enabled reports whether any check is on. +func (s Settings) Enabled() bool { return s.Projects || s.ServiceEnablement } + +// Enforcer answers the two checks. project is a project id or number, with or without the "projects/" prefix. +type Enforcer interface { + CheckProject(project, service string) error + CheckServiceEnabled(project, service string) error +} + +// Gate pairs an Enforcer with the switches that are on. +type Gate struct { + Enforcer Enforcer + Settings Settings +} + +// Check applies the enabled checks for a call to service (an API host such as pubsub.googleapis.com) on project. +func (g *Gate) Check(project, service string) error { + if g == nil || g.Enforcer == nil || project == "" { + return nil + } + + if g.Settings.ServiceEnablement { + return g.Enforcer.CheckServiceEnabled(project, service) + } + + if g.Settings.Projects { + return g.Enforcer.CheckProject(project, service) + } + + return nil +} + +// GateProvider is implemented by the service that owns the Enforcer. +type GateProvider interface { + EnforcementGate() *Gate +} + +type gateKey struct{} + +// WithGate returns ctx carrying g; services read it in StartWorker. A nil g returns ctx unchanged. +func WithGate(ctx context.Context, g *Gate) context.Context { + if g == nil { + return ctx + } + + return context.WithValue(ctx, gateKey{}, g) +} + +// GateFrom returns the Gate carried by ctx, or nil. +func GateFrom(ctx context.Context) *Gate { + g, _ := ctx.Value(gateKey{}).(*Gate) + + return g +} + +// WithGateFrom installs the Gate of the first candidate that is a GateProvider with enforcement on. +func WithGateFrom[T any](ctx context.Context, candidates []T) context.Context { + for _, c := range candidates { + if gp, ok := any(c).(GateProvider); ok { + if g := gp.EnforcementGate(); g != nil { + return WithGate(ctx, g) + } + } + } + + return ctx +} diff --git a/pkgs/gcpenforce/grpc.go b/pkgs/gcpenforce/grpc.go new file mode 100644 index 0000000000..36211ab5f3 --- /dev/null +++ b/pkgs/gcpenforce/grpc.go @@ -0,0 +1,99 @@ +package gcpenforce + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// Target describes the API one gRPC server or HTTP listener serves. +type Target struct { + // ProjectFields maps a gRPC full method to field paths holding the project when the generic scan is wrong. + ProjectFields map[string][]string + // Host is the API host used for services without a default_host option, and for REST. + Host string + // QueryOnly takes the REST project from the query string only. + QueryOnly bool +} + +// ServerOptions returns the interceptors for a gRPC server serving t, or nil when enforcement is off in ctx. +func ServerOptions(ctx context.Context, t Target) []grpc.ServerOption { + g := GateFrom(ctx) + if g == nil { + return nil + } + + return []grpc.ServerOption{ + grpc.ChainUnaryInterceptor(unary(g, t)), + grpc.ChainStreamInterceptor(stream(g, t)), + } +} + +// NewServer is grpc.NewServer with the enforcement interceptors added when ctx carries a Gate. +func NewServer(ctx context.Context, host string, opts ...grpc.ServerOption) *grpc.Server { + return NewServerFor(ctx, Target{Host: host}, opts...) +} + +// NewServerFor is NewServer with per-method project overrides. +func NewServerFor(ctx context.Context, t Target, opts ...grpc.ServerOption) *grpc.Server { + return grpc.NewServer(append(opts, ServerOptions(ctx, t)...)...) +} + +func check(g *Gate, t Target, fullMethod string, req any) error { + if Exempt(fullMethod) { + return nil + } + + host := ServiceForMethod(fullMethod, t.Host) + if host == "" || exemptHost(host) { + return nil + } + + msg, ok := req.(proto.Message) + if !ok { + return nil + } + + return g.Check(FromMessage(msg, t.ProjectFields[fullMethod]...), host) +} + +func unary(g *Gate, t Target) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { + if err := check(g, t, info.FullMethod, req); err != nil { + return nil, err + } + + return next(ctx, req) + } +} + +func stream(g *Gate, t Target) grpc.StreamServerInterceptor { + return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, next grpc.StreamHandler) error { + return next(srv, &checkedStream{ServerStream: ss, g: g, t: t, method: info.FullMethod}) + } +} + +// checkedStream checks the first received message, which carries the resource name of every GCP streaming RPC. +type checkedStream struct { + grpc.ServerStream + + g *Gate + method string + t Target + checked bool +} + +func (s *checkedStream) RecvMsg(m any) error { + if err := s.ServerStream.RecvMsg(m); err != nil { + return err + } + + if !s.checked { + s.checked = true + + return check(s.g, s.t, s.method, m) + } + + return nil +} diff --git a/pkgs/gcpenforce/http.go b/pkgs/gcpenforce/http.go new file mode 100644 index 0000000000..769fc1c06a --- /dev/null +++ b/pkgs/gcpenforce/http.go @@ -0,0 +1,57 @@ +package gcpenforce + +import ( + "context" + "net/http" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" +) + +// Middleware wraps next, answering requests that fail the Gate with the Google JSON error envelope. It returns +// next unchanged when ctx carries no Gate. +func Middleware(ctx context.Context, t Target, next http.Handler) http.Handler { + g := GateFrom(ctx) + if g == nil || exemptHost(t.Host) { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := checkRequest(g, t, r); err != nil { + gcprest.WriteError(w, err) + + return + } + + next.ServeHTTP(w, r) + }) +} + +// EchoMiddleware is Middleware for an echo router; it is a pass-through when ctx carries no Gate. +func EchoMiddleware(ctx context.Context, t Target) echo.MiddlewareFunc { + g := GateFrom(ctx) + if g == nil || exemptHost(t.Host) { + return func(next echo.HandlerFunc) echo.HandlerFunc { return next } + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + if err := checkRequest(g, t, c.Request()); err != nil { + gcprest.WriteError(c.Response(), err) + + return nil + } + + return next(c) + } + } +} + +func checkRequest(g *Gate, t Target, r *http.Request) error { + if OperationsPath(r.URL.Path) { + return nil + } + + return g.Check(FromRequest(r, t.QueryOnly), t.Host) +} diff --git a/pkgs/gcpenforce/service.go b/pkgs/gcpenforce/service.go new file mode 100644 index 0000000000..ca2316a9f8 --- /dev/null +++ b/pkgs/gcpenforce/service.go @@ -0,0 +1,95 @@ +package gcpenforce + +import ( + "strings" + + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" +) + +const ( + // metaHost is the default_host of google.iam.v1.IAMPolicy, which every API embeds rather than owns. + metaHost = "iam-meta-api.googleapis.com" + hostResourceManager = "cloudresourcemanager.googleapis.com" + hostServiceUsage = "serviceusage.googleapis.com" +) + +// exemptPrefixes are proto packages never enforced: operations, the control plane itself, and gRPC internals. +// +//nolint:gochecknoglobals // immutable lookup table +var exemptPrefixes = []string{ + "google.longrunning.", "google.cloud.resourcemanager.", "google.api.serviceusage.", "grpc.", +} + +// splitMethod splits "/pkg.Service/Method" into the service full name and the method. +func splitMethod(full string) (string, string) { + svc, method, _ := strings.Cut(strings.TrimPrefix(full, "/"), "/") + + return svc, method +} + +// Exempt reports whether a gRPC method is outside enforcement. +func Exempt(fullMethod string) bool { + svc, _ := splitMethod(fullMethod) + + for _, p := range exemptPrefixes { + if strings.HasPrefix(svc, p) { + return true + } + } + + return false +} + +// ServiceForMethod resolves "/google.pubsub.v1.Publisher/Publish" to "pubsub.googleapis.com" from the service's +// google.api.default_host option, falling back to fallback for services without one (google.iam.v1.IAMPolicy). +func ServiceForMethod(fullMethod, fallback string) string { + svc, _ := splitMethod(fullMethod) + + if host := defaultHost(svc); host != "" { + return host + } + + return fallback +} + +func trimPort(host string) string { + h, _, _ := strings.Cut(host, ":") + + return h +} + +// exemptHost reports whether an API host is the control plane. +func exemptHost(host string) bool { return host == hostResourceManager || host == hostServiceUsage } + +// OperationsPath reports whether an HTTP path addresses long-running operations. +func OperationsPath(path string) bool { + return strings.HasSuffix(path, "/operations") || strings.Contains(path, "/operations/") +} + +func defaultHost(service string) string { + d, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(service)) + if err != nil { + return "" + } + + sd, isService := d.(protoreflect.ServiceDescriptor) + if !isService { + return "" + } + + opts, hasOpts := sd.Options().(*descriptorpb.ServiceOptions) + if !hasOpts || opts == nil { + return "" + } + + host, _ := proto.GetExtension(opts, annotations.E_DefaultHost).(string) + if host == metaHost { + return "" + } + + return trimPort(host) +} diff --git a/pkgs/gcpevents/cloudevent.go b/pkgs/gcpevents/cloudevent.go new file mode 100644 index 0000000000..3c3cbb3590 --- /dev/null +++ b/pkgs/gcpevents/cloudevent.go @@ -0,0 +1,142 @@ +package gcpevents + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "cloud.google.com/go/firestore/apiv1/firestorepb" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" +) + +const ( + // StorageTypePrefix prefixes Cloud Storage event types. + StorageTypePrefix = "google.cloud.storage.object.v1." + // FirestoreTypePrefix prefixes Firestore event types. + FirestoreTypePrefix = "google.cloud.firestore.document.v1." + // FirestoreWritten is the suffix matching created, updated and deleted. + FirestoreWritten = "written" + + contentTypeJSON = "application/json" + contentTypeProto = "application/protobuf" + docSegment = "/documents/" + maskField = protowire.Number(3) +) + +// CloudEvent is one event in binary content mode: attributes travel as ce-* headers, the payload as the body. +type CloudEvent struct { + Extensions map[string]string + ID string + Source string + Type string + Subject string + Time string + ContentType string + Data []byte +} + +// NewRequest builds the POST that delivers e to url. +func (e CloudEvent) NewRequest(ctx context.Context, url string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(e.Data)) + if err != nil { + return nil, fmt.Errorf("build event request: %w", err) + } + + req.Header.Set("Content-Type", e.ContentType) + req.Header.Set("Ce-Specversion", "1.0") + req.Header.Set("Ce-Id", e.ID) + req.Header.Set("Ce-Type", e.Type) + req.Header.Set("Ce-Source", e.Source) + req.Header.Set("Ce-Time", e.Time) + + if e.Subject != "" { + req.Header.Set("Ce-Subject", e.Subject) + } + + for k, v := range e.Extensions { + req.Header.Set("Ce-"+k, v) + } + + return req, nil +} + +func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +// StorageEvent wraps a Cloud Storage object resource (StorageObjectData JSON) in a CloudEvent of type +// google.cloud.storage.object.v1.. +func StorageEvent(id, kind, bucket, object string, data []byte, t time.Time) CloudEvent { + return CloudEvent{ + ID: id, + Type: StorageTypePrefix + kind, + Source: "//storage.googleapis.com/projects/_/buckets/" + bucket, + Subject: "objects/" + object, + Time: stamp(t), + ContentType: contentTypeJSON, + Extensions: map[string]string{"bucket": bucket}, + Data: data, + } +} + +// FirestoreEvent wraps a document change in a CloudEvent of type google.cloud.firestore.document.v1.. The +// payload is a google.events.cloud.firestore.v1.DocumentEventData message (value=1, old_value=2, update_mask=3), +// which Eventarc delivers as application/protobuf. The document name is +// projects/{p}/databases/{d}/documents/{path}. +func FirestoreEvent(id, kind string, doc, old *firestorepb.Document, mask []string, t time.Time) (CloudEvent, error) { + name := doc.GetName() + if name == "" { + name = old.GetName() + } + + db, path, ok := strings.Cut(name, docSegment) + if !ok { + return CloudEvent{}, fmt.Errorf("%w: document name %q", ErrPattern, name) + } + + var body []byte + + for _, f := range []struct { + d *firestorepb.Document + n protowire.Number + }{{doc, 1}, {old, 2}} { + if f.d == nil { + continue + } + + raw, err := proto.Marshal(f.d) + if err != nil { + return CloudEvent{}, fmt.Errorf("marshal document: %w", err) + } + + body = protowire.AppendTag(body, f.n, protowire.BytesType) + body = protowire.AppendBytes(body, raw) + } + + if len(mask) > 0 { + var m []byte + for _, p := range mask { + m = protowire.AppendTag(m, 1, protowire.BytesType) + m = protowire.AppendString(m, p) + } + + body = protowire.AppendTag(body, maskField, protowire.BytesType) + body = protowire.AppendBytes(body, m) + } + + dbName := strings.TrimPrefix(db, "projects/") + _, database, _ := strings.Cut(dbName, "/databases/") + + return CloudEvent{ + ID: id, + Type: FirestoreTypePrefix + kind, + Source: "//firestore.googleapis.com/" + db, + Subject: "documents/" + path, + Time: stamp(t), + ContentType: contentTypeProto, + Extensions: map[string]string{"document": path, "database": database, "namespace": "(default)"}, + Data: body, + }, nil +} diff --git a/pkgs/gcpevents/gcpevents_test.go b/pkgs/gcpevents/gcpevents_test.go new file mode 100644 index 0000000000..fa5eb39481 --- /dev/null +++ b/pkgs/gcpevents/gcpevents_test.go @@ -0,0 +1,115 @@ +package gcpevents_test + +import ( + "io" + "net/http" + "testing" + "time" + + "cloud.google.com/go/firestore/apiv1/firestorepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpevents" +) + +func TestMatchFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name, op, pattern, value string + want bool + }{ + {"exact", "", "users/alice", "users/alice", true}, + {"exact differs", "", "users/alice", "users/bob", false}, + {"capture one segment", gcpevents.OperatorPathPattern, "users/{id}", "users/alice", true}, + {"capture not two segments", gcpevents.OperatorPathPattern, "users/{id}", "users/alice/orders", false}, + {"capture rest", gcpevents.OperatorPathPattern, "users/{path=**}", "users/alice/orders/1", true}, + {"star within segment", gcpevents.OperatorPathPattern, "logs-*", "logs-prod", true}, + {"star not across segments", gcpevents.OperatorPathPattern, "a/*", "a/b/c", false}, + {"double star", gcpevents.OperatorPathPattern, "a/**", "a/b/c", true}, + {"literal dots are literal", gcpevents.OperatorPathPattern, "a.b", "axb", false}, + {"bad pattern never matches", gcpevents.OperatorPathPattern, "{bad id}", "x", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, gcpevents.MatchFilter(tt.op, tt.pattern, tt.value)) + }) + } +} + +func TestCloudEventRequestIsBinaryMode(t *testing.T) { + t.Parallel() + + at := time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC) + ev := gcpevents.StorageEvent("id-1", "finalized", "b", "dir/o.txt", []byte(`{"name":"dir/o.txt"}`), at) + + req, err := ev.NewRequest(t.Context(), "http://example.test/") + require.NoError(t, err) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, req.Method) + assert.JSONEq(t, `{"name":"dir/o.txt"}`, string(body)) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + + want := map[string]string{ + "Ce-Specversion": "1.0", "Ce-Id": "id-1", "Ce-Type": "google.cloud.storage.object.v1.finalized", + "Ce-Source": "//storage.googleapis.com/projects/_/buckets/b", "Ce-Subject": "objects/dir/o.txt", + "Ce-Time": "2026-05-06T07:08:09Z", "Ce-Bucket": "b", + } + for k, v := range want { + assert.Equal(t, v, req.Header.Get(k), k) + } +} + +func TestFirestoreEventPayload(t *testing.T) { + t.Parallel() + + doc := &firestorepb.Document{ + Name: "projects/p/databases/(default)/documents/users/alice", + Fields: map[string]*firestorepb.Value{"n": {ValueType: &firestorepb.Value_IntegerValue{IntegerValue: 2}}}, + } + old := &firestorepb.Document{Name: doc.GetName()} + + ev, err := gcpevents.FirestoreEvent("i", "updated", doc, old, []string{"n"}, time.Unix(0, 0)) + require.NoError(t, err) + + assert.Equal(t, "google.cloud.firestore.document.v1.updated", ev.Type) + assert.Equal(t, "//firestore.googleapis.com/projects/p/databases/(default)", ev.Source) + assert.Equal(t, "documents/users/alice", ev.Subject) + assert.Equal(t, "application/protobuf", ev.ContentType) + assert.Equal(t, map[string]string{"document": "users/alice", "database": "(default)", "namespace": "(default)"}, + ev.Extensions) + + // DocumentEventData: value=1, old_value=2, update_mask=3. + fields := map[int32][]byte{} + + for raw := ev.Data; len(raw) > 0; { + num, _, l := protowire.ConsumeTag(raw) + v, l2 := protowire.ConsumeBytes(raw[l:]) + fields[int32(num)] = v + raw = raw[l+l2:] + } + + got := &firestorepb.Document{} + require.NoError(t, proto.Unmarshal(fields[1], got)) + assert.True(t, proto.Equal(doc, got)) + assert.Contains(t, fields, int32(2)) + + path, _, _ := protowire.ConsumeTag(fields[3]) + assert.Equal(t, protowire.Number(1), path, "update_mask.field_paths is field 1") + assert.Contains(t, string(fields[3]), "n") +} + +func TestFirestoreEventRejectsForeignName(t *testing.T) { + t.Parallel() + + _, err := gcpevents.FirestoreEvent("i", "created", &firestorepb.Document{Name: "nope"}, nil, nil, time.Now()) + require.Error(t, err) +} diff --git a/pkgs/gcpevents/pattern.go b/pkgs/gcpevents/pattern.go new file mode 100644 index 0000000000..cdd9c145d0 --- /dev/null +++ b/pkgs/gcpevents/pattern.go @@ -0,0 +1,76 @@ +// Package gcpevents holds the Eventarc pieces shared by GCP emulators: path patterns and CloudEvents in binary +// content mode. +package gcpevents + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +// OperatorPathPattern is the EventFilter operator that selects path-pattern matching. +const OperatorPathPattern = "match-path-pattern" + +// ErrPattern means a path pattern is malformed. +var ErrPattern = errors.New("invalid path pattern") + +var captureRe = regexp.MustCompile(`^\{[A-Za-z_][A-Za-z0-9_]*(=\*\*)?\}$`) + +// CompilePathPattern turns an Eventarc path pattern into an anchored expression: "*" matches within one +// segment, "**" spans segments, "{name}" captures one segment and "{name=**}" several. +func CompilePathPattern(p string) (*regexp.Regexp, error) { + var sb strings.Builder + + sb.WriteString("^") + + for i, seg := range strings.Split(p, "/") { + if i > 0 { + sb.WriteString("/") + } + + switch { + case seg == "**": + sb.WriteString(".*") + case strings.HasPrefix(seg, "{"): + if !captureRe.MatchString(seg) { + return nil, fmt.Errorf("%w: bad capture %q", ErrPattern, seg) + } + + if strings.HasSuffix(seg, "=**}") { + sb.WriteString(".+") + } else { + sb.WriteString("[^/]+") + } + default: + for _, part := range strings.SplitAfter(seg, "*") { + sb.WriteString(regexp.QuoteMeta(strings.TrimSuffix(part, "*"))) + + if strings.HasSuffix(part, "*") { + sb.WriteString("[^/]*") + } + } + } + } + + sb.WriteString("$") + + re, err := regexp.Compile(sb.String()) + if err != nil { + return nil, fmt.Errorf("%w: %q", ErrPattern, p) + } + + return re, nil +} + +// MatchFilter reports whether got satisfies an event filter: exact equality, or a path pattern when operator is +// OperatorPathPattern. +func MatchFilter(operator, want, got string) bool { + if operator != OperatorPathPattern { + return got == want + } + + re, err := CompilePathPattern(want) + + return err == nil && re.MatchString(got) +} diff --git a/pkgs/gcprest/errors.go b/pkgs/gcprest/errors.go index 08cecbd3e3..1a87408b98 100644 --- a/pkgs/gcprest/errors.go +++ b/pkgs/gcprest/errors.go @@ -92,3 +92,6 @@ func writeRaw(w http.ResponseWriter, code int, b []byte) { w.WriteHeader(code) _, _ = w.Write(b) //nolint:gosec // b is JSON from encoding/json or protojson, not HTML } + +// WriteError writes err as the Google JSON error envelope, with its status details. +func WriteError(w http.ResponseWriter, err error) { writeError(w, err) } diff --git a/pkgs/gcprest/routes.go b/pkgs/gcprest/routes.go index 410d37c408..c9e5be0952 100644 --- a/pkgs/gcprest/routes.go +++ b/pkgs/gcprest/routes.go @@ -107,11 +107,20 @@ var operationScopes = []string{ //nolint:gochecknoglobals // immutable catalog // of operationScopes (any API version). A rule with no such form yields none. func scopedOperationRules(r *annotations.HttpRule) []*annotations.HttpRule { method, path := ruleVerb(r) - out := make([]*annotations.HttpRule, 0, len(operationScopes)) + out := make([]*annotations.HttpRule, 0, len(operationScopes)+1) - for _, scope := range operationScopes { + for _, scope := range append([]string{""}, operationScopes...) { scoped := strings.Replace(path, "/v1/{name=operations/**}", "/*/{name="+scope+"/operations/*}", 1) - if scoped == path { + if scope == "" { + if scoped = strings.Replace( + path, + "/v1/{name=operations/**}", + "/*/{name=operations/**}", + 1, + ); scoped == path { + continue + } + } else if scoped == path { scoped = strings.Replace(path, "/v1/{name=operations}", "/*/{name="+scope+"}/operations", 1) } @@ -138,10 +147,17 @@ func scopedOperationRules(r *annotations.HttpRule) []*annotations.HttpRule { return out } -// rank puts verb-suffixed templates first so ".../x:verb" is never swallowed by ".../{name}"; the rest -// keep proto declaration order, which is how Google frontends resolve overlapping templates. +// rank puts verb-suffixed templates first so ".../x:verb" is never swallowed by ".../{name}", then templates that +// end in a literal segment (".../tagKeys/namespaced") ahead of the variable ones that would also match them; the +// rest keep proto declaration order, which is how Google frontends resolve overlapping templates. func rank(r *route) int { if r.tmpl.verb != "" { + return 2 //nolint:mnd // verb templates sort first + } + + last := len(r.tmpl.elems) - 1 + if last >= 0 && r.tmpl.elems[last].kind == elemLiteral && + !slices.ContainsFunc(r.tmpl.vars, func(v variable) bool { return v.start <= last && last < v.end }) { return 1 } diff --git a/pkgs/gcprest/split.go b/pkgs/gcprest/split.go index e99e301897..639bfe752a 100644 --- a/pkgs/gcprest/split.go +++ b/pkgs/gcprest/split.go @@ -2,6 +2,7 @@ package gcprest import ( "bufio" + "context" "errors" "net" "net/http" @@ -24,11 +25,17 @@ type splitter struct { grpcConns chan net.Conn restConns chan net.Conn done chan struct{} + ctx context.Context + cancel context.CancelFunc once sync.Once } func newSplitter(l net.Listener, rest *http.Server) *splitter { + ctx, cancel := context.WithCancel(context.Background()) + return &splitter{ + ctx: ctx, + cancel: cancel, Listener: l, rest: rest, grpcConns: make(chan net.Conn), @@ -56,16 +63,22 @@ func (sp *splitter) fail(err error) { sp.once.Do(func() { sp.err = err close(sp.done) + sp.cancel() }) } func (sp *splitter) route(c net.Conn) { br := bufio.NewReader(c) + // Close must not strand conns still waiting for their first bytes. + stop := context.AfterFunc(sp.ctx, func() { _ = c.Close() }) + _ = c.SetReadDeadline(time.Now().Add(sniffWindow)) head, err := br.Peek(prefaceLen) _ = c.SetReadDeadline(time.Time{}) + stop() + if err != nil { _ = c.Close() diff --git a/pkgs/gcprest/split_close_test.go b/pkgs/gcprest/split_close_test.go new file mode 100644 index 0000000000..3e1005d727 --- /dev/null +++ b/pkgs/gcprest/split_close_test.go @@ -0,0 +1,58 @@ +package gcprest_test + +import ( + "errors" + "net" + "net/http" + "testing" + "time" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" +) + +func TestSplitterCloseReleasesUnsniffedConns(t *testing.T) { + t.Parallel() + + srv := grpc.NewServer() + longrunningpb.RegisterOperationsServer(srv, fakeOps{}) + + var lc net.ListenConfig + + l, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + done := make(chan struct{}) + + go func() { + defer close(done) + + _ = gcprest.ServeWithFallback(srv, l, nil) + }() + + var d net.Dialer + + idle, err := d.DialContext(t.Context(), "tcp", l.Addr().String()) + require.NoError(t, err) + + t.Cleanup(func() { _ = idle.Close() }) + + status, _ := do(t, http.MethodGet, "http://"+l.Addr().String()+"/v1/projects/p/locations/l/operations/op-1", "") + require.Equal(t, http.StatusOK, status) + + srv.Stop() + <-done + + require.NoError(t, idle.SetReadDeadline(time.Now().Add(5*time.Second))) + + _, readErr := idle.Read(make([]byte, 1)) + require.Error(t, readErr) + + if nerr, ok := errors.AsType[net.Error](readErr); ok { + assert.False(t, nerr.Timeout(), "server kept the idle connection open after Close") + } +} diff --git a/pkgs/gcpscale/clock.go b/pkgs/gcpscale/clock.go new file mode 100644 index 0000000000..a5a7e48989 --- /dev/null +++ b/pkgs/gcpscale/clock.go @@ -0,0 +1,37 @@ +// Package gcpscale holds the request-driven instance pool and injectable clock that Cloud Run style emulators share. +package gcpscale + +import "time" + +// Timer is the slice of *time.Timer that scaling uses. +type Timer interface { + C() <-chan time.Time + Stop() bool +} + +// Clock abstracts time so idle scale-down and request queueing can be driven by tests. +type Clock interface { + Now() time.Time + NewTimer(d time.Duration) Timer + AfterFunc(d time.Duration, f func()) Timer +} + +// RealClock is the wall clock. +type RealClock struct{} + +type realTimer struct{ t *time.Timer } + +func (r realTimer) C() <-chan time.Time { return r.t.C } + +func (r realTimer) Stop() bool { return r.t.Stop() } + +// Now returns the wall time. +func (RealClock) Now() time.Time { return time.Now() } + +// NewTimer starts a wall-clock timer. +func (RealClock) NewTimer(d time.Duration) Timer { return realTimer{t: time.NewTimer(d)} } + +// AfterFunc runs f after d on the wall clock. +func (RealClock) AfterFunc(d time.Duration, f func()) Timer { + return realTimer{t: time.AfterFunc(d, f)} +} diff --git a/pkgs/gcpscale/pool.go b/pkgs/gcpscale/pool.go new file mode 100644 index 0000000000..e2620ffe3c --- /dev/null +++ b/pkgs/gcpscale/pool.go @@ -0,0 +1,491 @@ +package gcpscale + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + // DefaultIdleTimeout is how long an idle instance above the minimum is kept; Cloud Run documents "up to 15 minutes". + DefaultIdleTimeout = 15 * time.Minute + // DefaultMaxInstances is Cloud Run's default instance cap. + DefaultMaxInstances = 100 + minQueueTimeout = 10 * time.Second + queueStartupFactor = 3.5 + avgDivisor = 2 +) + +// ErrSaturated means every instance slot stayed busy until the pending-queue timeout (HTTP 429 on Cloud Run). +var ErrSaturated = errors.New("no instance became available: the request timed out in the pending queue") + +// ErrClosed means the pool was shut down. +var ErrClosed = errors.New("instance pool is closed") + +// ErrRetired means the instance was drained or removed before it finished starting. +var ErrRetired = errors.New("instance was retired while starting") + +// Limits are the scaling settings read afresh on every decision. Zero Max means DefaultMaxInstances, zero +// Concurrency means 1. +type Limits struct { + Min int + Max int + Concurrency int +} + +// Config wires a Pool to the thing it scales. Start gets a context cancelled once its instance is retired (after +// Stop for a started one). Limits and Idle run under the pool lock and must not call back into the pool. +type Config[T any] struct { + Start func(ctx context.Context) (T, error) + Stop func(T) + Limits func() Limits + Idle func() time.Duration + Clock Clock + Base context.Context //nolint:containedctx // lifetime of the instances, set by the worker + IdleTimeout time.Duration +} + +type slot[T any] struct { + err error + idle Timer + cancel context.CancelFunc + ready chan struct{} + val T + inflight int + dead bool +} + +// Pool keeps instances of one revision: requests take a concurrency slot, new instances start while below the +// maximum, and idle ones above the minimum are removed. +type Pool[T any] struct { + cfg Config[T] + mu *lockmetrics.RWMutex + changed chan struct{} + slots []*slot[T] + wg sync.WaitGroup + avgStart time.Duration + closed bool +} + +// New creates an empty pool. +func New[T any](name string, cfg Config[T]) *Pool[T] { + if cfg.Clock == nil { + cfg.Clock = RealClock{} + } + + if cfg.Base == nil { + cfg.Base = context.Background() + } + + if cfg.IdleTimeout <= 0 { + cfg.IdleTimeout = DefaultIdleTimeout + } + + return &Pool[T]{cfg: cfg, mu: lockmetrics.New("gcpscale." + name), changed: make(chan struct{})} +} + +func (p *Pool[T]) limits() (int, int, int) { + l := p.cfg.Limits() + if l.Max <= 0 { + l.Max = DefaultMaxInstances + } + + return min(l.Min, l.Max), l.Max, max(l.Concurrency, 1) +} + +func (p *Pool[T]) notifyLocked() { + close(p.changed) + p.changed = make(chan struct{}) +} + +func (p *Pool[T]) liveLocked() int { + n := 0 + + for _, s := range p.slots { + if !s.dead { + n++ + } + } + + return n +} + +// Live returns the number of instances started or starting. +func (p *Pool[T]) Live() int { + p.mu.RLock("Live") + defer p.mu.RUnlock() + + return p.liveLocked() +} + +func (p *Pool[T]) growLocked() *slot[T] { + ctx, cancel := context.WithCancel(p.cfg.Base) + s := &slot[T]{ready: make(chan struct{}), cancel: cancel} + p.slots = append(p.slots, s) + + p.wg.Go(func() { + began := p.cfg.Clock.Now() + val, err := p.cfg.Start(ctx) + + p.mu.Lock("started") + + discard := err == nil && s.dead + + switch { + case s.dead && p.closed: + err = errors.Join(ErrClosed, err) + case s.dead: + err = errors.Join(ErrRetired, err) + } + + s.val, s.err = val, err + + if err != nil { + s.dead = true + p.removeLocked(s) + } else { + d := p.cfg.Clock.Now().Sub(began) + if p.avgStart == 0 { + p.avgStart = d + } else { + p.avgStart = (p.avgStart + d) / avgDivisor + } + } + + close(s.ready) + p.notifyLocked() + p.mu.Unlock() + + if discard { + p.cfg.Stop(val) + } + + if err != nil { + cancel() + } + }) + + return s +} + +func (p *Pool[T]) removeLocked(s *slot[T]) { + for i, x := range p.slots { + if x == s { + p.slots = append(p.slots[:i], p.slots[i+1:]...) + + return + } + } +} + +func (p *Pool[T]) pickLocked(conc int) *slot[T] { + var starting *slot[T] + + for _, s := range p.slots { + if s.dead || s.inflight >= conc { + continue + } + + select { + case <-s.ready: + return s + default: + if starting == nil { + starting = s + } + } + } + + return starting +} + +func (p *Pool[T]) idleFor() time.Duration { + if p.cfg.Idle != nil { + if d := p.cfg.Idle(); d > 0 { + return d + } + } + + return p.cfg.IdleTimeout +} + +func (p *Pool[T]) queueTimeout() time.Duration { + return max(minQueueTimeout, time.Duration(queueStartupFactor*float64(p.avgStart))) +} + +func stopIdle[T any](s *slot[T]) { + if s.idle != nil { + s.idle.Stop() + s.idle = nil + } +} + +// Acquire reserves a request slot, starting an instance when all slots are taken and the pool is below its +// maximum, and queueing for max(10s, 3.5 x observed start time) otherwise (then ErrSaturated). +func (p *Pool[T]) Acquire(ctx context.Context) (T, func(), error) { + var ( + zero T + deadline Timer + ) + + defer func() { + if deadline != nil { + deadline.Stop() + } + }() + + for { + p.mu.Lock("Acquire") + + if p.closed { + p.mu.Unlock() + + return zero, nil, ErrClosed + } + + _, maxN, conc := p.limits() + + s := p.pickLocked(conc) + if s == nil && p.liveLocked() < maxN { + s = p.growLocked() + } + + if s != nil { + s.inflight++ + stopIdle(s) + p.mu.Unlock() + + return p.await(ctx, s) + } + + changed, wait := p.changed, p.queueTimeout() + p.mu.Unlock() + + if deadline == nil { + deadline = p.cfg.Clock.NewTimer(wait) + } + + select { + case <-changed: + case <-deadline.C(): + return zero, nil, ErrSaturated + case <-ctx.Done(): + return zero, nil, fmt.Errorf("request cancelled while queued: %w", ctx.Err()) + } + } +} + +func (p *Pool[T]) await(ctx context.Context, s *slot[T]) (T, func(), error) { + var zero T + + select { + case <-s.ready: + case <-ctx.Done(): + p.release(s) + + return zero, nil, fmt.Errorf("request cancelled while the instance started: %w", ctx.Err()) + } + + if s.err != nil { + p.release(s) + + return zero, nil, s.err + } + + return s.val, func() { p.release(s) }, nil +} + +func (p *Pool[T]) release(s *slot[T]) { + p.mu.Lock("release") + defer p.mu.Unlock() + + s.inflight-- + p.notifyLocked() + p.armIdleLocked(s) +} + +func (p *Pool[T]) armIdleLocked(s *slot[T]) { + if s.dead || s.inflight > 0 || s.idle != nil || s.err != nil || p.closed { + return + } + + select { + case <-s.ready: + default: + return + } + + s.idle = p.cfg.Clock.AfterFunc(p.idleFor(), func() { p.idleExpired(s) }) +} + +func (p *Pool[T]) idleExpired(s *slot[T]) { + p.mu.Lock("idleExpired") + + minN, _, _ := p.limits() + if s.dead || s.inflight > 0 || p.liveLocked() <= minN { + s.idle = nil + p.mu.Unlock() + + return + } + + s.dead = true + p.removeLocked(s) + p.notifyLocked() + p.mu.Unlock() + + p.stop(s) +} + +func (p *Pool[T]) stop(s *slot[T]) { + p.cfg.Stop(s.val) + s.cancel() +} + +// retireLocked marks s dead and unlinks it; a slot still starting has its start cancelled. It returns whether s +// is a started instance the caller must stop. +func (p *Pool[T]) retireLocked(s *slot[T]) bool { + started := false + + select { + case <-s.ready: + started = !s.dead && s.err == nil + default: + s.cancel() + } + + s.dead = true + stopIdle(s) + + return started +} + +// Drain retires every instance but keeps the pool open: later requests start fresh ones. +func (p *Pool[T]) Drain() { + p.mu.Lock("Drain") + + var live []*slot[T] + + for _, s := range p.slots { + if p.retireLocked(s) { + live = append(live, s) + } + } + + p.slots = nil + p.notifyLocked() + p.mu.Unlock() + + for _, s := range live { + p.stop(s) + } +} + +// Remove retires and stops the started instances match selects (a failed liveness probe, say); the pool starts a +// replacement on demand. It reports whether any instance was removed. +func (p *Pool[T]) Remove(match func(T) bool) bool { + p.mu.Lock("Remove") + + var gone []*slot[T] + + for _, s := range slices.Clone(p.slots) { + select { + case <-s.ready: + default: + continue + } + + if s.dead || s.err != nil || !match(s.val) { + continue + } + + p.retireLocked(s) + p.removeLocked(s) + gone = append(gone, s) + } + + if len(gone) > 0 { + p.notifyLocked() + } + p.mu.Unlock() + + for _, s := range gone { + p.stop(s) + } + + return len(gone) > 0 +} + +// Ensure brings the pool to at least n instances (capped at the maximum) and waits until they are ready. +func (p *Pool[T]) Ensure(ctx context.Context, n int) error { + p.mu.Lock("Ensure") + + if p.closed { + p.mu.Unlock() + + return ErrClosed + } + + _, maxN, _ := p.limits() + for p.liveLocked() < min(n, maxN) { + p.growLocked() + } + + waits := make([]*slot[T], 0, n) + + for _, s := range p.slots { + if !s.dead && len(waits) < n { + waits = append(waits, s) + } + } + p.mu.Unlock() + + for _, s := range waits { + select { + case <-s.ready: + case <-ctx.Done(): + return fmt.Errorf("waiting for instances: %w", ctx.Err()) + } + + if s.err != nil { + return s.err + } + } + + p.mu.Lock("Ensure.idle") + for _, s := range waits { + p.armIdleLocked(s) + } + p.mu.Unlock() + + return nil +} + +// Close stops every instance and rejects further requests. Starts in flight are cancelled and stopped as they finish. +func (p *Pool[T]) Close() { + p.mu.Lock("Close") + p.closed = true + + var live []*slot[T] + + for _, s := range p.slots { + if p.retireLocked(s) { + live = append(live, s) + } + } + + p.slots = nil + p.notifyLocked() + p.mu.Unlock() + + for _, s := range live { + p.stop(s) + } + + p.wg.Wait() +} diff --git a/pkgs/gcpscale/pool_test.go b/pkgs/gcpscale/pool_test.go new file mode 100644 index 0000000000..ab1f7eab6a --- /dev/null +++ b/pkgs/gcpscale/pool_test.go @@ -0,0 +1,341 @@ +package gcpscale_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +var errStart = errors.New("boom") + +func TestMain(m *testing.M) { testleak.VerifyTestMain(m) } + +type timer struct { + c chan time.Time + fn func() + at time.Duration +} + +func (t *timer) C() <-chan time.Time { return t.c } +func (t *timer) Stop() bool { + t.fn, t.at = nil, -1 + + return true +} + +// clock hands out timers that only fire when the test calls fire. +type clock struct { + timers []*timer + mu sync.Mutex +} + +func (c *clock) Now() time.Time { return time.Unix(0, 0) } + +func (c *clock) NewTimer(d time.Duration) gcpscale.Timer { + c.mu.Lock() + defer c.mu.Unlock() + + t := &timer{c: make(chan time.Time, 1), at: d} + c.timers = append(c.timers, t) + + return t +} + +func (c *clock) AfterFunc(d time.Duration, f func()) gcpscale.Timer { + c.mu.Lock() + defer c.mu.Unlock() + + t := &timer{c: make(chan time.Time), fn: f, at: d} + c.timers = append(c.timers, t) + + return t +} + +func (c *clock) fireAll() { + c.mu.Lock() + ts := append([]*timer(nil), c.timers...) + c.timers = nil + c.mu.Unlock() + + for _, t := range ts { + switch { + case t.fn != nil: + t.fn() + case t.at >= 0: + t.c <- time.Time{} + } + } +} + +func (c *clock) count() int { + c.mu.Lock() + defer c.mu.Unlock() + + return len(c.timers) +} + +type fixture struct { + started atomic.Int32 + stopped atomic.Int32 + failNow atomic.Bool +} + +func (f *fixture) pool(l gcpscale.Limits, clk gcpscale.Clock) *gcpscale.Pool[int] { + return gcpscale.New("test", gcpscale.Config[int]{ + Start: func(context.Context) (int, error) { + if f.failNow.Load() { + return 0, errStart + } + + return int(f.started.Add(1)), nil + }, + Stop: func(int) { f.stopped.Add(1) }, + Limits: func() gcpscale.Limits { return l }, + Clock: clk, + IdleTimeout: time.Minute, + }) +} + +func TestPool_ConcurrencyAndMax(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + limits gcpscale.Limits + acquires int + wantStarted int32 + }{ + {"default concurrency is one", gcpscale.Limits{}, 3, 3}, + {"concurrency shares", gcpscale.Limits{Concurrency: 3}, 3, 1}, + {"concurrency spills", gcpscale.Limits{Concurrency: 2}, 3, 2}, + {"max caps instances", gcpscale.Limits{Max: 2, Concurrency: 5}, 5, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := &fixture{} + p := f.pool(tt.limits, &clock{}) + t.Cleanup(p.Close) + + for range tt.acquires { + _, release, err := p.Acquire(t.Context()) + require.NoError(t, err) + t.Cleanup(release) + } + + assert.Equal(t, tt.wantStarted, f.started.Load()) + }) + } +} + +func TestPool_SaturationQueuesThenFails(t *testing.T) { + t.Parallel() + + f := &fixture{} + clk := &clock{} + p := f.pool(gcpscale.Limits{Max: 1, Concurrency: 1}, clk) + t.Cleanup(p.Close) + + _, release, err := p.Acquire(t.Context()) + require.NoError(t, err) + + errs := make(chan error, 1) + + go func() { + _, _, aerr := p.Acquire(t.Context()) + errs <- aerr + }() + + require.Eventually(t, func() bool { return clk.count() == 1 }, 5*time.Second, time.Millisecond) + clk.mu.Lock() + assert.Equal(t, 10*time.Second, clk.timers[0].at, "queue waits at least 10s") + clk.mu.Unlock() + clk.fireAll() + require.ErrorIs(t, <-errs, gcpscale.ErrSaturated) + + release() + _, release2, err := p.Acquire(t.Context()) + require.NoError(t, err) + release2() + assert.Equal(t, int32(1), f.started.Load(), "the freed slot is reused") +} + +func TestPool_QueuedRequestTakesFreedSlot(t *testing.T) { + t.Parallel() + + f := &fixture{} + p := f.pool(gcpscale.Limits{Max: 1, Concurrency: 1}, &clock{}) + t.Cleanup(p.Close) + + _, release, err := p.Acquire(t.Context()) + require.NoError(t, err) + + got := make(chan int, 1) + + go func() { + v, rel, aerr := p.Acquire(t.Context()) + if aerr == nil { + rel() + got <- v + } + }() + + release() + assert.Equal(t, 1, <-got) +} + +func TestPool_IdleScaleDownKeepsMinimum(t *testing.T) { + t.Parallel() + + f := &fixture{} + clk := &clock{} + p := f.pool(gcpscale.Limits{Min: 1, Max: 5, Concurrency: 1}, clk) + t.Cleanup(p.Close) + + require.NoError(t, p.Ensure(t.Context(), 1)) + + releases := make([]func(), 0, 3) + + for range 3 { + _, rel, err := p.Acquire(t.Context()) + require.NoError(t, err) + + releases = append(releases, rel) + } + + assert.Equal(t, 3, p.Live()) + + for _, rel := range releases { + rel() + } + + clk.fireAll() + assert.Equal(t, 1, p.Live(), "idle instances above the minimum go away") + assert.Equal(t, int32(2), f.stopped.Load()) +} + +func TestPool_StartFailureIsReportedAndRetried(t *testing.T) { + t.Parallel() + + f := &fixture{} + p := f.pool(gcpscale.Limits{}, &clock{}) + t.Cleanup(p.Close) + + f.failNow.Store(true) + _, _, err := p.Acquire(t.Context()) + require.ErrorContains(t, err, "boom") + assert.Zero(t, p.Live()) + + f.failNow.Store(false) + _, release, err := p.Acquire(t.Context()) + require.NoError(t, err) + release() +} + +func TestPool_CloseStopsInstancesAndRejects(t *testing.T) { + t.Parallel() + + f := &fixture{} + p := f.pool(gcpscale.Limits{Min: 2}, &clock{}) + require.NoError(t, p.Ensure(t.Context(), 2)) + + p.Close() + assert.Equal(t, int32(2), f.stopped.Load()) + + _, _, err := p.Acquire(t.Context()) + require.ErrorIs(t, err, gcpscale.ErrClosed) + require.ErrorIs(t, p.Ensure(t.Context(), 1), gcpscale.ErrClosed) +} + +func TestPool_CancelledWhileQueued(t *testing.T) { + t.Parallel() + + f := &fixture{} + p := f.pool(gcpscale.Limits{Max: 1}, &clock{}) + t.Cleanup(p.Close) + + _, release, err := p.Acquire(t.Context()) + require.NoError(t, err) + t.Cleanup(release) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, _, err = p.Acquire(ctx) + require.ErrorIs(t, err, context.Canceled) +} + +func TestPool_DrainKeepsPoolOpen(t *testing.T) { + t.Parallel() + + f := &fixture{} + p := f.pool(gcpscale.Limits{}, &clock{}) + + require.NoError(t, p.Ensure(t.Context(), 2)) + p.Drain() + assert.Equal(t, int32(2), f.stopped.Load()) + assert.Zero(t, p.Live()) + + _, release, err := p.Acquire(t.Context()) + require.NoError(t, err) + release() + assert.Equal(t, 1, p.Live(), "a request after a drain starts a fresh instance") + p.Close() +} + +func TestPool_RemoveStopsMatchingInstance(t *testing.T) { + t.Parallel() + + f := &fixture{} + p := f.pool(gcpscale.Limits{}, &clock{}) + + require.NoError(t, p.Ensure(t.Context(), 2)) + assert.True(t, p.Remove(func(v int) bool { return v == 1 })) + assert.False(t, p.Remove(func(v int) bool { return v == 1 }), "already removed") + assert.Equal(t, int32(1), f.stopped.Load()) + assert.Equal(t, 1, p.Live()) + p.Close() +} + +func TestPool_RetiredWhileStarting(t *testing.T) { + t.Parallel() + + var stopped atomic.Int32 + + entered := make(chan struct{}) + p := gcpscale.New("test", gcpscale.Config[int]{ + Start: func(ctx context.Context) (int, error) { + close(entered) + <-ctx.Done() + + return 1, nil + }, + Stop: func(int) { stopped.Add(1) }, + Limits: func() gcpscale.Limits { return gcpscale.Limits{} }, + }) + + errc := make(chan error, 1) + + go func() { + _, _, err := p.Acquire(t.Context()) + errc <- err + }() + + <-entered + p.Drain() + + require.ErrorIs(t, <-errc, gcpscale.ErrRetired) + p.Close() + assert.Equal(t, int32(1), stopped.Load(), "an instance that finished starting after the drain is stopped") +} diff --git a/pkgs/gcpserve/gcpserve_test.go b/pkgs/gcpserve/gcpserve_test.go index 29fba0a73e..1c72dbae39 100644 --- a/pkgs/gcpserve/gcpserve_test.go +++ b/pkgs/gcpserve/gcpserve_test.go @@ -8,10 +8,13 @@ import ( "testing" "time" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) type pool struct{ released []int } @@ -76,7 +79,7 @@ func TestWaitReadyAndProxy(t *testing.T) { port := srv.Listener.Addr().(*net.TCPAddr).Port require.NoError(t, gcpserve.WaitReady(t.Context(), port)) - front := httptest.NewServer(gcpserve.Proxy(port, http.DefaultTransport)) + front := httptest.NewServer(gcpserve.Proxy(port, httpclient.NewTransport())) t.Cleanup(front.Close) req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, front.URL, nil) @@ -84,7 +87,7 @@ func TestWaitReadyAndProxy(t *testing.T) { req.Host = "fn.localhost" - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -141,8 +144,15 @@ func TestWaitReadyRequiresHTTP(t *testing.T) { }() } - ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) - defer cancel() + // Only the failing case needs a deadline; the success case must not race one. + ctx := t.Context() + + if tt.wantErr { + var cancel context.CancelFunc + + ctx, cancel = context.WithTimeout(ctx, 300*time.Millisecond) + defer cancel() + } err = gcpserve.WaitReady(ctx, l.Addr().(*net.TCPAddr).Port) if tt.wantErr { diff --git a/pkgs/gcpserve/network.go b/pkgs/gcpserve/network.go new file mode 100644 index 0000000000..e79c1d3799 --- /dev/null +++ b/pkgs/gcpserve/network.go @@ -0,0 +1,18 @@ +package gcpserve + +import "net/http" + +const ( + // InternalNetworkHeader marks a request as coming from inside the emulated network (VPC, Pub/Sub, Eventarc, + // Cloud Tasks, Cloud Scheduler). Every caller of the emulator is on the loopback interface, so the source + // network cannot be told apart otherwise. + InternalNetworkHeader = "X-Gopherstack-Network" + // InternalNetwork is the header value that marks a request internal. + InternalNetwork = "internal" +) + +// MarkInternal flags req as originating inside the emulated network. +func MarkInternal(req *http.Request) { req.Header.Set(InternalNetworkHeader, InternalNetwork) } + +// IsInternal reports whether r carries the internal-network marker. +func IsInternal(r *http.Request) bool { return r.Header.Get(InternalNetworkHeader) == InternalNetwork } diff --git a/pkgs/gcpserve/secrets.go b/pkgs/gcpserve/secrets.go new file mode 100644 index 0000000000..b9992956e7 --- /dev/null +++ b/pkgs/gcpserve/secrets.go @@ -0,0 +1,52 @@ +package gcpserve + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const secretDirMode = 0o755 + +// ErrSecretPath means a secret volume file path escapes its volume. +var ErrSecretPath = errors.New("secret volume path must be relative and stay inside the volume") + +// SecretResolver returns the payload of a Secret Manager version resource name +// (projects/{p}/secrets/{s}/versions/{v}). +type SecretResolver func(versionName string) ([]byte, error) + +// SecretVersionName builds the version resource name for a secret given by short name or full resource name. +// An empty version means "latest". +func SecretVersionName(project, secret, version string) string { + if version == "" { + version = "latest" + } + + name := secret + if !strings.HasPrefix(secret, "projects/") { + name = "projects/" + project + "/secrets/" + secret + } + + return name + "/versions/" + version +} + +// WriteSecretFile writes data to rel inside dir with mode, rejecting paths that leave dir. +func WriteSecretFile(dir, rel string, data []byte, mode os.FileMode) error { + clean := filepath.Clean(rel) + if clean == "." || filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { + return fmt.Errorf("%w: %q", ErrSecretPath, rel) + } + + full := filepath.Join(dir, clean) + if err := os.MkdirAll(filepath.Dir(full), secretDirMode); err != nil { + return fmt.Errorf("create secret volume path: %w", err) + } + + if err := os.WriteFile(full, data, mode); err != nil { + return fmt.Errorf("write secret volume file: %w", err) + } + + return os.Chmod(full, mode) //nolint:wrapcheck // a plain chmod failure message is clear +} diff --git a/pkgs/httpclient/guard.go b/pkgs/httpclient/guard.go new file mode 100644 index 0000000000..6744321c81 --- /dev/null +++ b/pkgs/httpclient/guard.go @@ -0,0 +1,33 @@ +package httpclient + +import ( + "net" + "syscall" + "time" +) + +const dialerKeepAlive = 30 * time.Second + +// LoopbackDialer returns a dialer that refuses non-loopback peers unless allow reports true. The check +// runs on the resolved peer address, so a hostname that resolves elsewhere (or later rebinds) is refused. +func LoopbackDialer(allow func() bool, denied error) *net.Dialer { + return &net.Dialer{ + KeepAlive: dialerKeepAlive, + Control: func(_, address string, _ syscall.RawConn) error { + if allow() { + return nil + } + + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + + if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() { + return denied + } + + return nil + }, + } +} diff --git a/pkgs/httpclient/guard_test.go b/pkgs/httpclient/guard_test.go new file mode 100644 index 0000000000..d0bbf87bfb --- /dev/null +++ b/pkgs/httpclient/guard_test.go @@ -0,0 +1,51 @@ +package httpclient_test + +import ( + "errors" + "net" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" +) + +var errDenied = errors.New("denied") + +func TestLoopbackDialer(t *testing.T) { + t.Parallel() + + ln, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + t.Cleanup(func() { _ = ln.Close() }) + + tests := []struct { + wantErr error + name string + addr string + allow bool + }{ + {name: "loopback permitted", addr: ln.Addr().String()}, + {name: "loopback permitted when external allowed", addr: ln.Addr().String(), allow: true}, + {name: "external refused", addr: "192.0.2.1:80", wantErr: errDenied}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + d := httpclient.LoopbackDialer(func() bool { return tt.allow }, errDenied) + c, dialErr := d.DialContext(t.Context(), "tcp", tt.addr) + + if tt.wantErr != nil { + require.ErrorIs(t, dialErr, tt.wantErr) + + return + } + + require.NoError(t, dialErr) + _ = c.Close() + }) + } +} diff --git a/pkgs/httputils/httputils.go b/pkgs/httputils/httputils.go index df1dee8686..cd7f4741e8 100644 --- a/pkgs/httputils/httputils.go +++ b/pkgs/httputils/httputils.go @@ -624,3 +624,23 @@ func isAllAllowedASCII(s string) bool { return true } + +// ReadBodyLimit reads r.Body up to limit bytes and returns *http.MaxBytesError +// when the body is larger. Unlike ReadBody it does not cache the body, so it +// suits per-operation caps below or above MaxRequestBodyBytes. +func ReadBodyLimit(r *http.Request, limit int64) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + + body, err := io.ReadAll(io.LimitReader(r.Body, limit+1)) + if err != nil { + return nil, err + } + + if int64(len(body)) > limit { + return nil, &http.MaxBytesError{Limit: limit} + } + + return body, nil +} diff --git a/pkgs/httputils/queryspace.go b/pkgs/httputils/queryspace.go new file mode 100644 index 0000000000..fdcd8197fd --- /dev/null +++ b/pkgs/httputils/queryspace.go @@ -0,0 +1,354 @@ +package httputils + +import ( + "bytes" + "context" + "crypto/tls" + "net" + "net/http" + "strconv" +) + +const ( + maxRequestLine = 8 << 10 + maxHeaderLine = 64 << 10 + maxContentLenDg = 18 + encodedSpace = "%20" + versionLen = len("HTTP/1.1") + maxMethodLen = 16 + maxRequestLineSpaces = 2 +) + +type qsState uint8 + +const ( + qsRequestLine qsState = iota + qsHeaders + qsBody + qsPassthrough +) + +// NewQuerySpaceListener wraps ln so request lines whose query component holds +// raw ASCII spaces are rewritten to %20 before net/http parses them. Clients +// such as armauthorization send `$filter=a eq 'b'` unencoded, which net/http +// answers with 400; Azure's front door accepts it. Only the query part of an +// origin-form target of a well-formed line is touched. Anything unusual +// (control bytes, spaces before '?', odd framing) is passed through +// byte-identical, so net/http still rejects it exactly as before. +func NewQuerySpaceListener(ln net.Listener) net.Listener { + return &qsListener{Listener: ln} +} + +type qsListener struct{ net.Listener } + +func (l *qsListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + + return &qsConn{Conn: c}, nil +} + +type connKey struct{} + +// QuerySpaceConnContext is an http.Server.ConnContext that keeps the wrapped +// connection reachable so QuerySpaceTLS can restore Request.TLS. +func QuerySpaceConnContext(ctx context.Context, c net.Conn) context.Context { + return context.WithValue(ctx, connKey{}, c) +} + +// QuerySpaceTLS sets Request.TLS from the underlying *tls.Conn, which the +// listener wrapper hides from net/http. +func QuerySpaceTLS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + if qc, ok := r.Context().Value(connKey{}).(*qsConn); ok { + if tc, isTLS := qc.Conn.(*tls.Conn); isTLS { + st := tc.ConnectionState() + r.TLS = &st + } + } + } + + next.ServeHTTP(w, r) + }) +} + +type qsConn struct { + net.Conn + + readErr error + line []byte + buf []byte + out []byte + bodyLeft int64 + contentLen int64 + state qsState + clSeen bool + connect bool + special bool +} + +func (c *qsConn) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + if c.state == qsPassthrough && len(c.out) == 0 && c.readErr == nil { + return c.Conn.Read(p) + } + + if len(c.buf) < len(p) { + c.buf = make([]byte, len(p)) + } + + for len(c.out) == 0 && c.readErr == nil { + n, err := c.Conn.Read(c.buf[:len(p)]) + if n > 0 { + c.feed(c.buf[:n]) + } + + if err != nil { + if len(c.line) > 0 { + c.passthrough() + } + + c.readErr = err + } + } + + n := copy(p, c.out) + c.out = c.out[n:] + + if len(c.out) == 0 && c.readErr != nil { + err := c.readErr + c.readErr = nil + + return n, err + } + + return n, nil +} + +// CloseWrite lets net/http half-close before Close so a pending response is +// not lost to an RST; the embedded net.Conn interface would hide it. +func (c *qsConn) CloseWrite() error { + if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + + return nil +} + +func (c *qsConn) feed(data []byte) { + for len(data) > 0 { + switch c.state { + case qsPassthrough: + c.out = append(c.out, data...) + + return + case qsBody: + n := min(int64(len(data)), c.bodyLeft) + + c.out = append(c.out, data[:n]...) + data = data[n:] + c.bodyLeft -= n + + if c.bodyLeft == 0 { + c.state = qsRequestLine + } + default: + data = c.feedLine(data) + } + } +} + +func (c *qsConn) limit() int { + if c.state == qsRequestLine { + return maxRequestLine + } + + return maxHeaderLine +} + +func (c *qsConn) feedLine(data []byte) []byte { + i := bytes.IndexByte(data, '\n') + if i < 0 { + c.line = append(c.line, data...) + if len(c.line) > c.limit() { + c.passthrough() + } + + return nil + } + + c.line = append(c.line, data[:i+1]...) + rest := data[i+1:] + + if len(c.line) > c.limit() { + c.passthrough() + + return rest + } + + line := c.line + c.line = nil + + if c.state == qsRequestLine { + c.requestLine(line) + } else { + c.headerLine(line) + } + + return rest +} + +func (c *qsConn) passthrough() { + c.out = append(c.out, c.line...) + c.line = nil + c.state = qsPassthrough +} + +func (c *qsConn) requestLine(line []byte) { + if isBlankLine(line) { + c.out = append(c.out, line...) + + return + } + + c.connect = bytes.HasPrefix(line, []byte("CONNECT ")) + c.clSeen, c.special, c.contentLen = false, false, 0 + c.state = qsHeaders + c.out = append(c.out, EncodeQuerySpaces(line)...) +} + +func (c *qsConn) headerLine(line []byte) { + c.out = append(c.out, line...) + + if isBlankLine(line) { + c.endHeaders() + + return + } + + if line[0] == ' ' || line[0] == '\t' { + c.state = qsPassthrough + + return + } + + name, val, ok := bytes.Cut(line, []byte(":")) + if !ok { + c.state = qsPassthrough + + return + } + + switch string(bytes.ToLower(bytes.TrimSpace(name))) { + case "transfer-encoding", "upgrade": + c.special = true + case "content-length": + v := bytes.TrimSpace(val) + if c.clSeen || !allDigits(v) { + c.state = qsPassthrough + + return + } + + n, err := strconv.ParseInt(string(v), 10, 64) + if err != nil { + c.state = qsPassthrough + + return + } + + c.clSeen, c.contentLen = true, n + } +} + +func (c *qsConn) endHeaders() { + switch { + case c.special || c.connect: + c.state = qsPassthrough + case c.contentLen > 0: + c.state, c.bodyLeft = qsBody, c.contentLen + default: + c.state = qsRequestLine + } +} + +func isBlankLine(l []byte) bool { + return len(l) == 1 || (len(l) == 2 && l[0] == '\r') +} + +func allDigits(v []byte) bool { + if len(v) == 0 || len(v) > maxContentLenDg { + return false + } + + for _, b := range v { + if b < '0' || b > '9' { + return false + } + } + + return true +} + +// EncodeQuerySpaces returns the request line (terminator included) with ASCII +// spaces in the origin-form query encoded as %20. A line that is not exactly +// `METHOD SP /path?query SP HTTP/1.x` with a clean target is returned as is. +func EncodeQuerySpaces(line []byte) []byte { + body := bytes.TrimSuffix(line, []byte("\n")) + body = bytes.TrimSuffix(body, []byte("\r")) + + if bytes.Count(body, []byte(" ")) <= maxRequestLineSpaces { + return line + } + + sp := bytes.IndexByte(body, ' ') + last := bytes.LastIndexByte(body, ' ') + + method, target, version := body[:sp], body[sp+1:last], body[last+1:] + if !validMethod(method) || len(version) != versionLen || + (string(version) != "HTTP/1.1" && string(version) != "HTTP/1.0") { + return line + } + + q := bytes.IndexByte(target, '?') + if q < 0 || target[0] != '/' || bytes.IndexByte(target[:q], ' ') >= 0 { + return line + } + + for _, b := range target { + if b < ' ' || b == 0x7f { + return line + } + } + + out := make([]byte, 0, len(line)+2*bytes.Count(target, []byte(" "))) + out = append(out, method...) + out = append(out, ' ') + out = append(out, target[:q]...) + out = append(out, bytes.ReplaceAll(target[q:], []byte(" "), []byte(encodedSpace))...) + out = append(out, ' ') + out = append(out, version...) + out = append(out, line[len(body):]...) + + return out +} + +func validMethod(m []byte) bool { + if len(m) == 0 || len(m) > maxMethodLen { + return false + } + + for _, b := range m { + if b < 'A' || b > 'Z' { + return false + } + } + + return true +} diff --git a/pkgs/httputils/queryspace_test.go b/pkgs/httputils/queryspace_test.go new file mode 100644 index 0000000000..5caf36e9f6 --- /dev/null +++ b/pkgs/httputils/queryspace_test.go @@ -0,0 +1,422 @@ +package httputils_test + +import ( + "bufio" + "crypto/tls" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" +) + +type pipeListener struct{ ch chan net.Conn } + +func (l *pipeListener) Accept() (net.Conn, error) { + c, ok := <-l.ch + if !ok { + return nil, net.ErrClosed + } + + return c, nil +} +func (l *pipeListener) Close() error { return nil } +func (l *pipeListener) Addr() net.Addr { return &net.TCPAddr{} } + +// wire feeds chunks through the wrapper and returns what the server side reads. +func wire(t *testing.T, chunks ...string) string { + t.Helper() + + client, server := net.Pipe() + pln := &pipeListener{ch: make(chan net.Conn, 1)} + wrapped := httputils.NewQuerySpaceListener(pln) + pln.ch <- server + + done := make(chan string, 1) + + go func() { + c, err := wrapped.Accept() + if err != nil { + done <- "" + + return + } + + b, _ := io.ReadAll(c) + done <- string(b) + }() + + for _, ch := range chunks { + _, err := client.Write([]byte(ch)) + require.NoError(t, err) + } + + require.NoError(t, client.Close()) + + select { + case got := <-done: + return got + case <-time.After(5 * time.Second): + t.Fatal("timeout") + + return "" + } +} + +type connCase struct { + name string + want string + chunks []string +} + +func same(name, in string) connCase { return connCase{name: name, chunks: []string{in}, want: in} } + +func TestQuerySpaceConn(t *testing.T) { + t.Parallel() + + long := strings.Repeat("a", 9<<10) + req := "GET /p?$filter=a eq 'b' HTTP/1.1\r\nHost: x\r\n\r\n" + enc := "GET /p?$filter=a%20eq%20'b' HTTP/1.1\r\nHost: x\r\n\r\n" + + inner := "GET /body?x y HTTP/1.1\r\nHost: z\r\n\r\n" + cl := strconv.Itoa(len(inner)) + + tests := []connCase{ + {name: "query spaces", chunks: []string{req}, want: enc}, + same("no spaces untouched", "GET /p?a=1 HTTP/1.1\r\n\r\n"), + {name: "bare lf", chunks: []string{"GET /p?a b HTTP/1.1\n\n"}, want: "GET /p?a%20b HTTP/1.1\n\n"}, + { + name: "split writes", + chunks: []string{"GET /p?$fil", "ter=a eq", " 'b' HTTP/1.1\r\nHo", "st: x\r\n\r\n"}, + want: enc, + }, + { + name: "space in path untouched", + chunks: []string{"GET /a b?c d HTTP/1.1\r\n\r\n"}, + want: "GET /a b?c d HTTP/1.1\r\n\r\n", + }, + same("no query untouched", "GET /a b HTTP/1.1\r\n\r\n"), + same("bad version untouched", "GET /p?a b HTTP/2.0\r\n\r\n"), + same("bad method untouched", "get /p?a b HTTP/1.1\r\n\r\n"), + same("cr injection untouched", "GET /p?a\rb c HTTP/1.1\r\n\r\n"), + same("tab untouched", "GET /p?a\tb c HTTP/1.1\r\n\r\n"), + same("nul untouched", "GET /p?a\x00b c HTTP/1.1\r\n\r\n"), + same("absolute form untouched", "GET http://h/p?a b HTTP/1.1\r\n\r\n"), + same("trailing space untouched", "GET /p?a b HTTP/1.1 \r\n\r\n"), + { + name: "path not altered by header injection attempt", + chunks: []string{"GET /p?a HTTP/1.1\r\nX: GET /q?a b HTTP/1.1\r\n\r\n"}, + want: "GET /p?a HTTP/1.1\r\nX: GET /q?a b HTTP/1.1\r\n\r\n", + }, + { + name: "pipelined", + chunks: []string{req + req}, + want: enc + enc, + }, + { + name: "body is opaque then next request encoded", + chunks: []string{ + "POST /p?a b HTTP/1.1\r\nContent-Length: " + cl + "\r\n\r\n" + inner + req, + }, + want: "POST /p?a%20b HTTP/1.1\r\nContent-Length: " + cl + "\r\n\r\n" + inner + enc, + }, + { + name: "chunked disables rewriting", + chunks: []string{"POST /p?a b HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n" + req}, + want: "POST /p?a%20b HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n" + req, + }, + { + name: "duplicate content-length disables rewriting", + chunks: []string{"POST /p?a b HTTP/1.1\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\nx" + req}, + want: "POST /p?a%20b HTTP/1.1\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\nx" + req, + }, + { + name: "oversized request line passes through bounded", + chunks: []string{"GET /p?" + long + " b HTTP/1.1\r\n\r\n" + req}, + want: "GET /p?" + long + " b HTTP/1.1\r\n\r\n" + req, + }, + { + name: "garbage", + chunks: []string{"\x16\x03\x01 not http \xff\xfe"}, + want: "\x16\x03\x01 not http \xff\xfe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, wire(t, tt.chunks...)) + }) + } +} + +func TestQuerySpaceServer(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + srv := &http.Server{ + ReadHeaderTimeout: 5 * time.Second, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.URL.Query().Get("$filter")+"|"+r.URL.Path) + }), + } + + go func() { _ = srv.Serve(httputils.NewQuerySpaceListener(ln)) }() + + t.Cleanup(func() { _ = srv.Close() }) + + tests := []struct { + name string + send string + wantBody []string + wantCode []int + }{ + { + name: "filter decoded, keep-alive", + send: "GET /s?$filter=principalId eq 'x' HTTP/1.1\r\nHost: h\r\n\r\n" + + "GET /t?$filter=a b HTTP/1.1\r\nHost: h\r\n\r\n", + wantCode: []int{200, 200}, + wantBody: []string{"principalId eq 'x'|/s", "a b|/t"}, + }, + {name: "space in path rejected", send: "GET /a b?c=d HTTP/1.1\r\nHost: h\r\n\r\n", wantCode: []int{400}}, + {name: "control byte rejected", send: "GET /a?b\x01 c HTTP/1.1\r\nHost: h\r\n\r\n", wantCode: []int{400}}, + {name: "cr in target rejected", send: "GET /a?b\rc d HTTP/1.1\r\nHost: h\r\n\r\n", wantCode: []int{400}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c, derr := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, derr) + + defer c.Close() + + _, werr := io.WriteString(c, tt.send) + require.NoError(t, werr) + + br := bufio.NewReader(c) + + for i, code := range tt.wantCode { + resp, rerr := http.ReadResponse(br, nil) + require.NoError(t, rerr) + + assert.Equal(t, code, resp.StatusCode) + + if tt.wantBody != nil { + b, _ := io.ReadAll(resp.Body) + assert.Equal(t, tt.wantBody[i], string(b)) + } + + _ = resp.Body.Close() + } + }) + } +} + +func TestEncodeQuerySpaces(t *testing.T) { + t.Parallel() + + got := httputils.EncodeQuerySpaces([]byte("GET /p?a b HTTP/1.0\r\n")) + assert.Equal(t, []byte("GET /p?a%20%20b HTTP/1.0\r\n"), got) + assert.Equal(t, []byte("GET /p?a HTTP/1.1\r\n"), httputils.EncodeQuerySpaces([]byte("GET /p?a HTTP/1.1\r\n"))) +} + +type errConn struct { + net.Conn + + data []byte + errs []error +} + +func (c *errConn) Read(p []byte) (int, error) { + if len(c.errs) == 0 { + return 0, io.EOF + } + + err := c.errs[0] + c.errs = c.errs[1:] + n := copy(p, c.data) + c.data = nil + + return n, err +} + +func TestQuerySpaceConnReadSemantics(t *testing.T) { + t.Parallel() + + errBoom := io.ErrUnexpectedEOF + + tests := []struct { + wantErr error + name string + data string + zeroBuf bool + }{ + {name: "error with data is surfaced", data: "GET /p HTTP/1.1\r\n\r\n", wantErr: errBoom}, + {name: "error with partial line is surfaced", data: "GET /p?a b", wantErr: errBoom}, + {name: "zero length read returns", zeroBuf: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close(); _ = server.Close() }) + + pln := &pipeListener{ch: make(chan net.Conn, 1)} + pln.ch <- &errConn{Conn: server, data: []byte(tt.data), errs: []error{errBoom}} + + c, err := httputils.NewQuerySpaceListener(pln).Accept() + require.NoError(t, err) + + done := make(chan error, 1) + + go func() { + if tt.zeroBuf { + _, rerr := c.Read(nil) + done <- rerr + + return + } + + var got []byte + + buf := make([]byte, 64) + + for { + n, rerr := c.Read(buf) + got = append(got, buf[:n]...) + + if rerr != nil { + assert.Equal(t, tt.data, string(got)) + done <- rerr + + return + } + } + }() + + select { + case rerr := <-done: + if tt.wantErr == nil { + require.NoError(t, rerr) + } else { + require.ErrorIs(t, rerr, tt.wantErr) + } + case <-time.After(3 * time.Second): + t.Fatal("read did not return") + } + }) + } +} + +func TestQuerySpaceConnCloseWrite(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + c, derr := net.Dial("tcp", ln.Addr().String()) + if derr == nil { + defer c.Close() + _, _ = io.Copy(io.Discard, c) + } + }() + + c, err := httputils.NewQuerySpaceListener(ln).Accept() + require.NoError(t, err) + + defer c.Close() + + cw, ok := c.(interface{ CloseWrite() error }) + require.True(t, ok, "wrapped conn must expose CloseWrite") + require.NoError(t, cw.CloseWrite()) +} + +func TestQuerySpaceTLS(t *testing.T) { + t.Parallel() + + ts := httptest.NewTLSServer(http.NotFoundHandler()) + t.Cleanup(ts.Close) + + rawLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + tlsLn := tls.NewListener(rawLn, &tls.Config{Certificates: ts.TLS.Certificates, MinVersion: tls.VersionTLS12}) + + srv := &http.Server{ + ReadHeaderTimeout: 5 * time.Second, + ConnContext: httputils.QuerySpaceConnContext, + Handler: httputils.QuerySpaceTLS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, strconv.FormatBool(r.TLS != nil && r.TLS.HandshakeComplete)+"|"+ + r.URL.Query().Get("f")+"|"+r.Method) + })), + } + + go func() { _ = srv.Serve(httputils.NewQuerySpaceListener(tlsLn)) }() + + t.Cleanup(func() { _ = srv.Close() }) + + chunkedThenGet := "POST /a?f=1 HTTP/1.1\r\nHost: h\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n" + + "GET /b?f=2 HTTP/1.1\r\nHost: h\r\n\r\n" + + tests := []struct { + name string + send string + want []string + }{ + { + name: "keep-alive requests all see tls", + send: "GET /a?f=x y HTTP/1.1\r\nHost: h\r\n\r\nGET /a?f=p q HTTP/1.1\r\nHost: h\r\n\r\n", + want: []string{"true|x y|GET", "true|p q|GET"}, + }, + {name: "after passthrough still tls", send: chunkedThenGet, want: []string{"true|1|POST", "true|2|GET"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c, derr := tls.Dial("tcp", rawLn.Addr().String(), &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{"h2", "http/1.1"}, + }) + require.NoError(t, derr) + + defer c.Close() + + assert.NotEqual(t, "h2", c.ConnectionState().NegotiatedProtocol) + + _, werr := io.WriteString(c, tt.send) + require.NoError(t, werr) + + br := bufio.NewReader(c) + + for _, want := range tt.want { + resp, rerr := http.ReadResponse(br, nil) + require.NoError(t, rerr) + + b, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + assert.Equal(t, want, string(b)) + } + }) + } +} diff --git a/pkgs/httputils/read_body_limit_test.go b/pkgs/httputils/read_body_limit_test.go new file mode 100644 index 0000000000..4c1b38d4a1 --- /dev/null +++ b/pkgs/httputils/read_body_limit_test.go @@ -0,0 +1,61 @@ +package httputils_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" +) + +func TestReadBodyLimit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + size int + limit int64 + tooLong bool + }{ + {name: "empty", size: 0, limit: 8}, + {name: "under", size: 7, limit: 8}, + {name: "at limit", size: 8, limit: 8}, + {name: "over limit", size: 9, limit: 8, tooLong: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + payload := strings.Repeat("a", tc.size) + req := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(strings.NewReader(payload))) + got, err := httputils.ReadBodyLimit(req, tc.limit) + + if tc.tooLong { + var mbe *http.MaxBytesError + require.ErrorAs(t, err, &mbe) + assert.Equal(t, tc.limit, mbe.Limit) + + return + } + + require.NoError(t, err) + assert.Len(t, got, tc.size) + }) + } + + t.Run("nil body", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Body = nil + got, err := httputils.ReadBodyLimit(req, 4) + require.NoError(t, err) + assert.Nil(t, got) + }) +} diff --git a/pkgs/iampolicystream/stream.go b/pkgs/iampolicystream/stream.go index 6c820bdb65..8d56505c98 100644 --- a/pkgs/iampolicystream/stream.go +++ b/pkgs/iampolicystream/stream.go @@ -30,6 +30,7 @@ const ( ReasonPermissionBound = "permission-boundary" ReasonImplicitDeny = "implicit-deny" ReasonUnresolvedCaller = "unresolved-principal" + ReasonServiceControl = "service-control-policy" ) // Deny-kind values recorded on a denied Event. @@ -45,6 +46,7 @@ const ( PolicyResource = "resource" PolicyBoundary = "permission-boundary" PolicyIdentity = "identity" + PolicySCP = "service-control-policy" ) // PolicyRef names the policy that produced the deciding statement. Owner is the diff --git a/pkgs/iceberg/append.go b/pkgs/iceberg/append.go new file mode 100644 index 0000000000..2107ba1db9 --- /dev/null +++ b/pkgs/iceberg/append.go @@ -0,0 +1,148 @@ +package iceberg + +import ( + "encoding/binary" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +// Object is a file to store alongside a table commit. +type Object struct { + Location string + Body []byte +} + +// AppendPlan is a prepared append commit: the metadata, manifest and manifest +// list objects to store, and the metadata location to commit to the catalog. +type AppendPlan struct { + Metadata *TableMetadata + MetadataLocation string + Objects []Object +} + +const ( + maxInt63 = 0x7fffffffffffffff + summaryOp = "operation" + summaryAdded = "added-data-files" + summaryRecords = "added-records" + summarySize = "added-files-size" + totalRecords = "total-records" + totalFiles = "total-data-files" + totalSize = "total-files-size" + totalDeletes = "total-delete-files" + totalPosDeletes = "total-position-deletes" + totalEqDeletes = "total-equality-deletes" +) + +func summaryInt(s map[string]string, k string) int64 { + n, _ := strconv.ParseInt(s[k], 10, 64) + + return n +} + +// NewSnapshotID returns a random positive snapshot id. +func NewSnapshotID() int64 { + u := uuid.New() + + return int64(binary.BigEndian.Uint64(u[:8]) & maxInt63) +} + +// PlanAppend prepares a commit appending files to the table. currentLocation +// is the metadata file m was read from; prev are the current snapshot's manifests. +func (m *TableMetadata) PlanAppend( + currentLocation string, prev []ManifestFile, files []DataFile, now time.Time, +) (*AppendPlan, error) { + spec := m.DefaultSpec() + snapID := NewSnapshotID() + seq := m.LastSequenceNumber + 1 + tsMS := now.UnixMilli() + + var parent *int64 + + var prevSummary map[string]string + + if cur, ok := m.CurrentSnapshot(); ok { + id := cur.SnapshotID + parent, prevSummary = &id, cur.Summary + tsMS = max(tsMS, cur.TimestampMS) + } + + base := strings.TrimSuffix(m.Location, "/") + "/metadata/" + + entries := make([]ManifestEntry, 0, len(files)) + + var rows, size int64 + + for _, f := range files { + f.Content = ContentData + entries = append(entries, ManifestEntry{ + Status: StatusAdded, SnapshotID: &snapID, SequenceNumber: &seq, DataFile: f, + }) + rows += f.RecordCount + size += f.FileSize + } + + manifestBody, err := EncodeManifest(m, spec, ContentData, entries) + if err != nil { + return nil, err + } + + manifestLoc := base + uuid.NewString() + "-m0.avro" + list := make([]ManifestFile, 0, 1+len(prev)) + list = append(list, ManifestFile{ + Path: manifestLoc, Length: int64(len(manifestBody)), SpecID: spec.SpecID, Content: ContentData, + SequenceNumber: seq, MinSequenceNumber: seq, AddedSnapshotID: snapID, + AddedFiles: len(files), AddedRows: rows, + }) + list = append(list, prev...) + + listBody, err := EncodeManifestList(snapID, parent, seq, list) + if err != nil { + return nil, err + } + + listLoc := fmt.Sprintf("%ssnap-%d-1-%s.avro", base, snapID, uuid.NewString()) + schemaID := m.CurrentSchemaID + snap := Snapshot{ + SnapshotID: snapID, ParentSnapshotID: parent, SequenceNumber: seq, TimestampMS: tsMS, + ManifestList: listLoc, SchemaID: &schemaID, + Summary: map[string]string{ + summaryOp: "append", summaryAdded: strconv.Itoa(len(files)), + summaryRecords: strconv.FormatInt(rows, 10), summarySize: strconv.FormatInt(size, 10), + totalRecords: strconv.FormatInt(summaryInt(prevSummary, totalRecords)+rows, 10), + totalFiles: strconv.FormatInt(summaryInt(prevSummary, totalFiles)+int64(len(files)), 10), + totalSize: strconv.FormatInt(summaryInt(prevSummary, totalSize)+size, 10), + totalDeletes: strconv.FormatInt(summaryInt(prevSummary, totalDeletes), 10), + totalPosDeletes: strconv.FormatInt(summaryInt(prevSummary, totalPosDeletes), 10), + totalEqDeletes: strconv.FormatInt(summaryInt(prevSummary, totalEqDeletes), 10), + }, + } + + next := m.AppendSnapshot(snap, currentLocation, now) + metaLoc := next.NextMetadataLocation() + + body, err := marshalMetadata(next) + if err != nil { + return nil, err + } + + return &AppendPlan{ + Metadata: next, MetadataLocation: metaLoc, + Objects: []Object{ + {Location: manifestLoc, Body: manifestBody}, + {Location: listLoc, Body: listBody}, + {Location: metaLoc, Body: body}, + }, + }, nil +} + +func marshalMetadata(m *TableMetadata) ([]byte, error) { + return m.MarshalJSON() +} + +// EncodeMetadata renders the metadata.json document. +func EncodeMetadata(m *TableMetadata) ([]byte, error) { return marshalMetadata(m) } diff --git a/pkgs/iceberg/consts.go b/pkgs/iceberg/consts.go new file mode 100644 index 0000000000..ef89eed783 --- /dev/null +++ b/pkgs/iceberg/consts.go @@ -0,0 +1,57 @@ +package iceberg + +// Iceberg primitive type names and document keys used across the package. +const ( + typeBoolean = "boolean" + typeInt = "int" + typeLong = "long" + typeFloat = "float" + typeDouble = "double" + typeDate = "date" + typeTime = "time" + typeTimestamp = "timestamp" + typeTimestamptz = "timestamptz" + typeString = "string" + typeUUID = "uuid" + typeBinary = "binary" + typeStruct = "struct" + typeMap = "map" + + transformYear = "year" + transformMonth = "month" + transformHour = "hour" + transformDay = "day" + transformBucket = "bucket" + transformTruncate = "truncate" + + keyFormatVersion = "format-version" + keyContent = "content" + keyFields = "fields" + keyDefault = "default" + avroRecord = "record" + keyName = "name" + keyType = "type" + keyKey = "key" + keyValue = "value" +) + +// Field ids fixed by the manifest and manifest-list schemas of the table spec. +const ( + idStatus = 0 + idSnapshotID = 1 + idDataFile = 2 + idSequenceNumber = 3 + idFileSequence = 4 + idFilePath = 100 + idFileFormat = 101 + idPartition = 102 + idRecordCount = 103 + idFileSize = 104 + idValueCounts = 109 + idNullCounts = 110 + idNullCountsKey = 121 + idNullCountsValue = 122 + idContent = 134 + idValueCountsKey = 119 + idValueCountsValue = 120 +) diff --git a/pkgs/iceberg/iceberg_test.go b/pkgs/iceberg/iceberg_test.go new file mode 100644 index 0000000000..92f85282af --- /dev/null +++ b/pkgs/iceberg/iceberg_test.go @@ -0,0 +1,245 @@ +package iceberg_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" +) + +func TestTransformSpecVectors(t *testing.T) { + t.Parallel() + + tests := []struct { + want any + name string + transform string + source string + in string + }{ + { + name: "bucket long 34", + transform: "bucket[100]", + source: "long", + in: "34", + want: int32(2017239379 & 0x7fffffff % 100), + }, + { + name: "bucket string", + transform: "bucket[100]", + source: "string", + in: "iceberg", + want: int32(1210000089 % 100), + }, + {name: "truncate int", transform: "truncate[10]", source: "int", in: "-1", want: int32(-10)}, + {name: "truncate string", transform: "truncate[3]", source: "string", in: "iceberg", want: "ice"}, + {name: "year", transform: "year", source: "date", in: "2017-11-16", want: int32(47)}, + {name: "month", transform: "month", source: "date", in: "2017-11-16", want: int32(574)}, + {name: "day", transform: "day", source: "date", in: "2017-11-16", want: int32(17486)}, + {name: "hour", transform: "hour", source: "timestamp", in: "2017-11-16 22:31:08", want: int32(419686)}, + {name: "identity date", transform: "identity", source: "date", in: "1970-01-02", want: int32(1)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tr, err := iceberg.ParseTransform(tc.transform) + require.NoError(t, err) + + got, err := tr.Apply(tc.source, new(tc.in)) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func testSchema(t *testing.T) iceberg.Schema { + t.Helper() + + s, err := iceberg.SchemaFromColumns([]iceberg.ColumnSpec{ + {Name: "id", Type: "bigint"}, {Name: "category", Type: "string"}, {Name: "ts", Type: "timestamp"}, + }) + require.NoError(t, err) + + return s +} + +func TestAppendRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + part map[string]any + name string + spec iceberg.PartitionSpec + }{ + { + name: "unpartitioned", + spec: iceberg.PartitionSpec{Fields: []iceberg.PartitionField{}}, + part: map[string]any{}, + }, + { + name: "partitioned", + spec: iceberg.PartitionSpec{Fields: []iceberg.PartitionField{ + {Name: "category", Transform: "identity", SourceID: 2, FieldID: 1000}, + {Name: "id_bucket", Transform: "bucket[16]", SourceID: 1, FieldID: 1001}, + {Name: "ts_day", Transform: "day", SourceID: 3, FieldID: 1002}, + }}, + part: map[string]any{"category": "a", "id_bucket": int32(3), "ts_day": int32(19000)}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + now := time.UnixMilli(1_700_000_000_000) + meta := iceberg.NewTableMetadata(iceberg.NewParams{ + Location: "s3://b/tbl", Schema: testSchema(t), Spec: tc.spec, Now: now, + }) + + body, err := iceberg.EncodeMetadata(meta) + require.NoError(t, err) + + parsed, err := iceberg.ParseMetadata(body) + require.NoError(t, err) + assert.Equal(t, iceberg.NoSnapshot, parsed.CurrentSnapshotID) + + files := []iceberg.DataFile{ + { + Path: "s3://b/tbl/data/f1.parquet", + Format: "PARQUET", + Partition: tc.part, + RecordCount: 5, + FileSize: 100, + ValueCounts: map[int]int64{1: 5}, + NullCounts: map[int]int64{1: 0}, + }, + } + + plan, err := parsed.PlanAppend("s3://b/tbl/metadata/00000.json", nil, files, now.Add(time.Second)) + require.NoError(t, err) + require.Len(t, plan.Objects, 3) + + again, err := iceberg.ParseMetadata(plan.Objects[2].Body) + require.NoError(t, err) + + snap, ok := again.CurrentSnapshot() + require.True(t, ok) + assert.Equal(t, "5", snap.Summary["total-records"]) + assert.Equal(t, int64(1), snap.SequenceNumber) + + list, err := iceberg.ReadManifestList(plan.Objects[1].Body) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, plan.Objects[0].Location, list[0].Path) + + entries, err := iceberg.ReadManifest(plan.Objects[0].Body) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, files[0].Path, entries[0].DataFile.Path) + assert.Equal(t, int64(5), entries[0].DataFile.RecordCount) + assert.Equal(t, int64(5), entries[0].DataFile.ValueCounts[1]) + + for k, v := range tc.part { + assert.EqualValues(t, v, entries[0].DataFile.Partition[k]) + } + + p2, err := again.PlanAppend(plan.MetadataLocation, list, files, now.Add(2*time.Second)) + require.NoError(t, err) + + final, err := iceberg.ParseMetadata(p2.Objects[2].Body) + require.NoError(t, err) + assert.Len(t, final.Snapshots, 2) + assert.Len(t, final.MetadataLog, 2) + assert.Equal(t, "10", final.Snapshots[1].Summary["total-records"]) + + old, err := final.SnapshotAsOf(now.Add(1500 * time.Millisecond)) + require.NoError(t, err) + assert.Equal(t, final.Snapshots[0].SnapshotID, old.SnapshotID) + + _, err = final.SnapshotAsOf(now.Add(-time.Hour)) + require.ErrorIs(t, err, iceberg.ErrNoSnapshot) + }) + } +} + +func TestMetadataPreservesUnknownFields(t *testing.T) { + t.Parallel() + + src := `{"format-version":2,"table-uuid":"u","location":"s3://b/t","last-sequence-number":0, + "last-updated-ms":1,"last-column-id":1,"current-schema-id":0, + "schemas":[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"a","required":false,"type":"int"}]}], + "default-spec-id":0,"partition-specs":[{"spec-id":0,"fields":[]}],"last-partition-id":999, + "default-sort-order-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"properties":{}, + "current-snapshot-id":-1,"statistics":[{"snapshot-id":1}]}` + + m, err := iceberg.ParseMetadata([]byte(src)) + require.NoError(t, err) + + out, err := iceberg.EncodeMetadata(m) + require.NoError(t, err) + + var generic map[string]json.RawMessage + require.NoError(t, json.Unmarshal(out, &generic)) + assert.Contains(t, generic, "statistics") + + _, err = iceberg.ParseMetadata([]byte(`{"format-version":9}`)) + require.ErrorIs(t, err, iceberg.ErrInvalidMetadata) +} + +func TestHiveType(t *testing.T) { + t.Parallel() + + tests := []struct{ in, want string }{ + {`"long"`, "bigint"}, + {`"decimal(10, 2)"`, "decimal(10,2)"}, + {`{"type":"list","element-id":3,"element":"string","element-required":false}`, "array"}, + {`{"type":"struct","fields":[{"id":1,"name":"a","required":false,"type":"int"}]}`, "struct"}, + } + + for _, tc := range tests { + t.Run(tc.want, func(t *testing.T) { + t.Parallel() + + got, err := iceberg.HiveType(json.RawMessage(tc.in)) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestParseTransformWidthBounds(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + wantErr bool + }{ + {name: "max int32", in: "bucket[2147483647]"}, + {name: "over int32", in: "bucket[2147483648]", wantErr: true}, + {name: "huge", in: "truncate[99999999999999999999]", wantErr: true}, + {name: "zero", in: "bucket[0]", wantErr: true}, + {name: "negative", in: "bucket[-1]", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := iceberg.ParseTransform(tc.in) + if tc.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/pkgs/iceberg/manifest.go b/pkgs/iceberg/manifest.go new file mode 100644 index 0000000000..867264f0aa --- /dev/null +++ b/pkgs/iceberg/manifest.go @@ -0,0 +1,489 @@ +package iceberg + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + + "github.com/iskorotkov/avro/v2" + "github.com/iskorotkov/avro/v2/ocf" +) + +// Manifest entry statuses and content types from the table spec. +const ( + StatusExisting = 0 + StatusAdded = 1 + StatusDeleted = 2 + + ContentData = 0 + ContentPositionDeletes = 1 + ContentEqualityDeletes = 2 +) + +// ErrInvalidManifest is returned for an Avro manifest or manifest list that cannot be decoded. +var ErrInvalidManifest = errors.New("invalid iceberg manifest") + +// DataFile is one data or delete file of a manifest entry. +type DataFile struct { + Partition map[string]any + ValueCounts map[int]int64 + NullCounts map[int]int64 + Path string + Format string + EqualityIDs []int + Content int + RecordCount int64 + FileSize int64 +} + +// ManifestEntry is a manifest row. +type ManifestEntry struct { + SnapshotID *int64 + SequenceNumber *int64 + DataFile DataFile + Status int +} + +// ManifestFile is a manifest list row. +type ManifestFile struct { + Path string + Length int64 + SequenceNumber int64 + MinSequenceNumber int64 + AddedSnapshotID int64 + AddedRows int64 + ExistingRows int64 + DeletedRows int64 + SpecID int + Content int + AddedFiles int + ExistingFiles int + DeletedFiles int +} + +const manifestListSchema = `{"type":"record","name":"manifest_file","fields":[ +{"name":"manifest_path","type":"string","field-id":500}, +{"name":"manifest_length","type":"long","field-id":501}, +{"name":"partition_spec_id","type":"int","field-id":502}, +{"name":"content","type":"int","field-id":517}, +{"name":"sequence_number","type":"long","field-id":515}, +{"name":"min_sequence_number","type":"long","field-id":516}, +{"name":"added_snapshot_id","type":"long","field-id":503}, +{"name":"added_files_count","type":"int","field-id":504}, +{"name":"existing_files_count","type":"int","field-id":505}, +{"name":"deleted_files_count","type":"int","field-id":506}, +{"name":"added_rows_count","type":"long","field-id":512}, +{"name":"existing_rows_count","type":"long","field-id":513}, +{"name":"deleted_rows_count","type":"long","field-id":514}]}` + +// EncodeManifestList writes a manifest list for a snapshot. +func EncodeManifestList(snapshotID int64, parent *int64, seq int64, files []ManifestFile) ([]byte, error) { + meta := map[string][]byte{ + "snapshot-id": []byte(strconv.FormatInt(snapshotID, 10)), + "sequence-number": []byte(strconv.FormatInt(seq, 10)), + keyFormatVersion: []byte("2"), + } + if parent != nil { + meta["parent-snapshot-id"] = []byte(strconv.FormatInt(*parent, 10)) + } + + var buf bytes.Buffer + + enc, err := ocf.NewEncoder(manifestListSchema, &buf, ocf.WithMetadata(meta)) + if err != nil { + return nil, err + } + + for _, f := range files { + row := map[string]any{ + "manifest_path": f.Path, "manifest_length": f.Length, "partition_spec_id": i32(f.SpecID), + keyContent: i32(f.Content), "sequence_number": f.SequenceNumber, + "min_sequence_number": f.MinSequenceNumber, "added_snapshot_id": f.AddedSnapshotID, + "added_files_count": i32(f.AddedFiles), "existing_files_count": i32(f.ExistingFiles), + "deleted_files_count": i32(f.DeletedFiles), "added_rows_count": f.AddedRows, + "existing_rows_count": f.ExistingRows, "deleted_rows_count": f.DeletedRows, + } + if err = enc.Encode(row); err != nil { + return nil, err + } + } + + if err = enc.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// ReadManifestList decodes a manifest list (v1 and v2 column names). +func ReadManifestList(data []byte) ([]ManifestFile, error) { + rows, err := readOCF(data) + if err != nil { + return nil, err + } + + out := make([]ManifestFile, 0, len(rows)) + + for _, r := range rows { + path, _ := r["manifest_path"].(string) + if path == "" { + return nil, fmt.Errorf("%w: manifest list entry without manifest_path", ErrInvalidManifest) + } + + out = append(out, ManifestFile{ + Path: path, + Length: asInt64(r["manifest_length"]), + SpecID: int(asInt64(r["partition_spec_id"])), + Content: int(asInt64(r["content"])), + SequenceNumber: asInt64(r["sequence_number"]), + MinSequenceNumber: asInt64(r["min_sequence_number"]), + AddedSnapshotID: asInt64(r["added_snapshot_id"]), + AddedFiles: int(firstInt(r, "added_files_count", "added_data_files_count")), + ExistingFiles: int(firstInt(r, "existing_files_count", "existing_data_files_count")), + DeletedFiles: int(firstInt(r, "deleted_files_count", "deleted_data_files_count")), + AddedRows: asInt64(r["added_rows_count"]), + ExistingRows: asInt64(r["existing_rows_count"]), + DeletedRows: asInt64(r["deleted_rows_count"]), + }) + } + + return out, nil +} + +func firstInt(r map[string]any, keys ...string) int64 { + for _, k := range keys { + if v, ok := r[k]; ok && v != nil { + return asInt64(v) + } + } + + return 0 +} + +// ReadManifest decodes a manifest. +func ReadManifest(data []byte) ([]ManifestEntry, error) { + rows, err := readOCF(data) + if err != nil { + return nil, err + } + + out := make([]ManifestEntry, 0, len(rows)) + + for _, r := range rows { + df, ok := unwrap(r["data_file"]).(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: manifest entry without data_file", ErrInvalidManifest) + } + + e := ManifestEntry{Status: int(asInt64(r["status"]))} + e.SnapshotID = optInt64(r["snapshot_id"]) + e.SequenceNumber = optInt64(r["sequence_number"]) + part, _ := unwrap(df["partition"]).(map[string]any) + + path, _ := df["file_path"].(string) + format, _ := df["file_format"].(string) + + e.DataFile = DataFile{ + Content: int(asInt64(df["content"])), Path: path, Format: format, + Partition: unwrapMap(part), RecordCount: asInt64(df["record_count"]), + FileSize: asInt64(df["file_size_in_bytes"]), ValueCounts: intLongMap(df["value_counts"]), + NullCounts: intLongMap(df["null_value_counts"]), EqualityIDs: intList(df["equality_ids"]), + } + out = append(out, e) + } + + return out, nil +} + +func readOCF(data []byte) ([]map[string]any, error) { + dec, err := ocf.NewDecoder(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidManifest, err) + } + + var rows []map[string]any + + for dec.HasNext() { + var row map[string]any + if err = dec.Decode(&row); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidManifest, err) + } + + rows = append(rows, row) + } + + if err = dec.Error(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidManifest, err) + } + + return rows, nil +} + +// unwrap strips the single-key map hamba wraps around a decoded non-null union member. +func unwrap(v any) any { + if m, ok := v.(map[string]any); ok && len(m) == 1 { + for k, inner := range m { + switch k { + case typeInt, typeLong, typeString, typeBoolean, typeFloat, typeDouble, "bytes", "array", typeMap, "record": + return inner + } + } + } + + return v +} + +func unwrapMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = unwrap(v) + } + + return out +} + +func asInt64(v any) int64 { + switch n := unwrap(v).(type) { + case int: + return int64(n) + case int32: + return int64(n) + case int64: + return n + case float64: + return int64(n) + default: + return 0 + } +} + +func optInt64(v any) *int64 { + if unwrap(v) == nil { + return nil + } + + n := asInt64(v) + + return &n +} + +func intLongMap(v any) map[int]int64 { + arr, ok := unwrap(v).([]any) + if !ok { + return nil + } + + out := make(map[int]int64, len(arr)) + + for _, e := range arr { + if m, isMap := e.(map[string]any); isMap { + out[int(asInt64(m["key"]))] = asInt64(m["value"]) + } + } + + return out +} + +func intList(v any) []int { + arr, ok := unwrap(v).([]any) + if !ok { + return nil + } + + out := make([]int, 0, len(arr)) + for _, e := range arr { + out = append(out, int(asInt64(e))) + } + + return out +} + +func avroPartitionField(meta *TableMetadata, pf PartitionField) (map[string]any, error) { + var source string + + for _, s := range meta.Schemas { + for _, f := range s.Fields { + if f.ID == pf.SourceID { + source = PrimitiveName(f.Type) + } + } + } + + tr, err := ParseTransform(pf.Transform) + if err != nil { + return nil, err + } + + at, err := AvroTypeOf(tr.ResultType(source)) + if err != nil { + return nil, err + } + + return map[string]any{ + keyName: pf.Name, keyType: []any{"null", at}, keyDefault: nil, "field-id": pf.FieldID, + }, nil +} + +func avroField(name string, typ any, id int) map[string]any { + return map[string]any{keyName: name, keyType: typ, "field-id": id} +} + +func avroOptional(name string, typ any, id int) map[string]any { + f := avroField(name, []any{"null", typ}, id) + f[keyDefault] = nil + + return f +} + +func kvArray(name string, keyID, valID int, valType string) map[string]any { + return map[string]any{ + keyType: "array", "logicalType": typeMap, + "items": map[string]any{ + keyType: avroRecord, keyName: name, + keyFields: []any{ + avroField(keyKey, typeInt, keyID), + avroField(keyValue, valType, valID), + }, + }, + } +} + +func manifestSchema(meta *TableMetadata, spec PartitionSpec) (string, error) { + pfields := make([]any, 0, len(spec.Fields)) + + for _, pf := range spec.Fields { + f, err := avroPartitionField(meta, pf) + if err != nil { + return "", err + } + + pfields = append(pfields, f) + } + + dataFile := map[string]any{ + keyType: avroRecord, keyName: "r2", + keyFields: []any{ + avroField(keyContent, typeInt, idContent), + avroField("file_path", typeString, idFilePath), + avroField("file_format", typeString, idFileFormat), + avroField( + "partition", + map[string]any{keyType: avroRecord, keyName: "r102", keyFields: pfields}, + idPartition, + ), + avroField("record_count", typeLong, idRecordCount), + avroField("file_size_in_bytes", typeLong, idFileSize), + avroOptional( + "value_counts", + kvArray("k119_v120", idValueCountsKey, idValueCountsValue, typeLong), + idValueCounts, + ), + avroOptional( + "null_value_counts", + kvArray("k121_v122", idNullCountsKey, idNullCountsValue, typeLong), + idNullCounts, + ), + }, + } + + schema := map[string]any{ + keyType: avroRecord, keyName: "manifest_entry", + keyFields: []any{ + avroField("status", typeInt, idStatus), + avroOptional("snapshot_id", typeLong, idSnapshotID), + avroOptional("sequence_number", typeLong, idSequenceNumber), + avroOptional("file_sequence_number", typeLong, idFileSequence), + avroField("data_file", dataFile, idDataFile), + }, + } + + b, err := json.Marshal(schema) + + return string(b), err +} + +// EncodeManifest writes a manifest of entries written under spec. +func EncodeManifest(meta *TableMetadata, spec PartitionSpec, content int, entries []ManifestEntry) ([]byte, error) { + schemaJSON, err := manifestSchema(meta, spec) + if err != nil { + return nil, err + } + + if _, err = avro.Parse(schemaJSON); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidManifest, err) + } + + cur, _ := json.Marshal(meta.CurrentSchema()) + specJSON, _ := json.Marshal(spec.Fields) + contentName := "data" + + if content != ContentData { + contentName = "deletes" + } + + var buf bytes.Buffer + + enc, err := ocf.NewEncoder(schemaJSON, &buf, ocf.WithMetadata(map[string][]byte{ + "schema": cur, + "schema-id": []byte(strconv.Itoa(meta.CurrentSchemaID)), + "partition-spec": specJSON, + "partition-spec-id": []byte(strconv.Itoa(spec.SpecID)), + keyFormatVersion: []byte("2"), + keyContent: []byte(contentName), + })) + if err != nil { + return nil, err + } + + for _, e := range entries { + if err = enc.Encode(entryRow(e, spec)); err != nil { + return nil, err + } + } + + if err = enc.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func entryRow(e ManifestEntry, spec PartitionSpec) map[string]any { + part := map[string]any{} + for _, pf := range spec.Fields { + part[pf.Name] = e.DataFile.Partition[pf.Name] + } + + df := map[string]any{ + keyContent: i32(e.DataFile.Content), "file_path": e.DataFile.Path, "file_format": e.DataFile.Format, + "partition": part, "record_count": e.DataFile.RecordCount, "file_size_in_bytes": e.DataFile.FileSize, + "value_counts": kvRows(e.DataFile.ValueCounts), "null_value_counts": kvRows(e.DataFile.NullCounts), + } + + row := map[string]any{"status": i32(e.Status), "data_file": df} + row["snapshot_id"] = e.SnapshotID + row["sequence_number"] = e.SequenceNumber + row["file_sequence_number"] = e.SequenceNumber + + return row +} + +func kvRows(m map[int]int64) any { + if m == nil { + return nil + } + + rows := make([]map[string]any, 0, len(m)) + for k, v := range m { + rows = append(rows, map[string]any{keyKey: i32(k), keyValue: v}) + } + + return rows +} + +// i32 narrows a small count or id for an Avro int; values here are bounded by file and field counts. +func i32(n int) int32 { + return int32(n) //nolint:gosec // counts and ids are far below MaxInt32 +} diff --git a/pkgs/iceberg/metadata.go b/pkgs/iceberg/metadata.go new file mode 100644 index 0000000000..b977bf3ee4 --- /dev/null +++ b/pkgs/iceberg/metadata.go @@ -0,0 +1,409 @@ +// Package iceberg reads and writes Apache Iceberg v2 table metadata, manifest +// lists and manifests (the table spec, https://iceberg.apache.org/spec/). +package iceberg + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + // FormatVersion is the only table format version this package writes. + FormatVersion = 2 + // NoSnapshot is the current-snapshot-id of a table without snapshots. + NoSnapshot = int64(-1) + // MainBranch is the default branch ref name. + MainBranch = "main" + + firstPartitionFieldID = 1000 + supportedMaxVersion = 2 +) + +var ( + // ErrInvalidMetadata is returned for a metadata document that is not valid Iceberg table metadata. + ErrInvalidMetadata = errors.New("invalid iceberg table metadata") + // ErrNoSnapshot is returned when a snapshot id or timestamp matches no snapshot. + ErrNoSnapshot = errors.New("iceberg snapshot not found") +) + +// Field is one schema field; Type is a primitive name or a nested type object. +type Field struct { + Name string `json:"name"` + Doc string `json:"doc,omitempty"` + Type json.RawMessage `json:"type"` + ID int `json:"id"` + Required bool `json:"required"` +} + +// Schema is an Iceberg struct schema. +type Schema struct { + Type string `json:"type"` + Fields []Field `json:"fields"` + IdentifierFieldIDs []int `json:"identifier-field-ids,omitempty"` + SchemaID int `json:"schema-id"` +} + +// PartitionField is one partition spec field. +type PartitionField struct { + Name string `json:"name"` + Transform string `json:"transform"` + SourceID int `json:"source-id"` + FieldID int `json:"field-id"` +} + +// PartitionSpec is a partitioning of a table. +type PartitionSpec struct { + Fields []PartitionField `json:"fields"` + SpecID int `json:"spec-id"` +} + +// Snapshot is one table snapshot. +type Snapshot struct { + Summary map[string]string `json:"summary,omitempty"` + ParentSnapshotID *int64 `json:"parent-snapshot-id,omitempty"` + SchemaID *int `json:"schema-id,omitempty"` + ManifestList string `json:"manifest-list,omitempty"` + SnapshotID int64 `json:"snapshot-id"` + SequenceNumber int64 `json:"sequence-number"` + TimestampMS int64 `json:"timestamp-ms"` +} + +// SnapshotLogEntry records when a snapshot became current. +type SnapshotLogEntry struct { + TimestampMS int64 `json:"timestamp-ms"` + SnapshotID int64 `json:"snapshot-id"` +} + +// MetadataLogEntry records a previous metadata file. +type MetadataLogEntry struct { + MetadataFile string `json:"metadata-file"` + TimestampMS int64 `json:"timestamp-ms"` +} + +// Ref is a named snapshot reference. +type Ref struct { + Type string `json:"type"` + SnapshotID int64 `json:"snapshot-id"` +} + +// TableMetadata is a table's metadata.json document. Fields this package does +// not model survive a decode/encode round trip. +type TableMetadata struct { + Properties map[string]string `json:"properties"` + Refs map[string]Ref `json:"refs,omitempty"` + extra map[string]json.RawMessage + TableUUID string `json:"table-uuid"` + Location string `json:"location"` + Schemas []Schema `json:"schemas"` + PartitionSpecs []PartitionSpec `json:"partition-specs"` + SortOrders []json.RawMessage `json:"sort-orders"` + Snapshots []Snapshot `json:"snapshots"` + SnapshotLog []SnapshotLogEntry `json:"snapshot-log"` + MetadataLog []MetadataLogEntry `json:"metadata-log"` + LastSequenceNumber int64 `json:"last-sequence-number"` + LastUpdatedMS int64 `json:"last-updated-ms"` + CurrentSnapshotID int64 `json:"current-snapshot-id"` + FormatVersion int `json:"format-version"` + LastColumnID int `json:"last-column-id"` + CurrentSchemaID int `json:"current-schema-id"` + DefaultSpecID int `json:"default-spec-id"` + LastPartitionID int `json:"last-partition-id"` + DefaultSortOrderID int `json:"default-sort-order-id"` +} + +func modeledKeys() []string { + return []string{ + keyFormatVersion, "table-uuid", "location", "last-sequence-number", "last-updated-ms", "last-column-id", + "current-schema-id", "schemas", "default-spec-id", "partition-specs", "last-partition-id", + "default-sort-order-id", "sort-orders", "properties", "current-snapshot-id", "snapshots", + "snapshot-log", "metadata-log", "refs", + } +} + +type metadataAlias TableMetadata + +// UnmarshalJSON decodes a v1 or v2 document and keeps unmodeled members. +func (m *TableMetadata) UnmarshalJSON(b []byte) error { + var v1 struct { + Schema *Schema `json:"schema"` + PartitionSpecV1 []PartitionField `json:"partition-spec"` + } + + var a metadataAlias + + a.CurrentSnapshotID = NoSnapshot + + if err := json.Unmarshal(b, &a); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidMetadata, err) + } + + if err := json.Unmarshal(b, &v1); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidMetadata, err) + } + + if len(a.Schemas) == 0 && v1.Schema != nil { + a.Schemas = []Schema{*v1.Schema} + a.CurrentSchemaID = v1.Schema.SchemaID + } + + if len(a.PartitionSpecs) == 0 && v1.PartitionSpecV1 != nil { + a.PartitionSpecs = []PartitionSpec{{SpecID: 0, Fields: v1.PartitionSpecV1}} + } + + raw := map[string]json.RawMessage{} + _ = json.Unmarshal(b, &raw) + + for _, k := range modeledKeys() { + delete(raw, k) + } + + *m = TableMetadata(a) + m.extra = raw + + return nil +} + +// MarshalJSON encodes the modeled members over the preserved extras. +func (m *TableMetadata) MarshalJSON() ([]byte, error) { + out := maps.Clone(m.extra) + if out == nil { + out = map[string]json.RawMessage{} + } + + typed, err := json.Marshal(metadataAlias(*m)) + if err != nil { + return nil, err + } + + var fields map[string]json.RawMessage + if err = json.Unmarshal(typed, &fields); err != nil { + return nil, err + } + + maps.Copy(out, fields) + + return json.Marshal(out) +} + +// ParseMetadata decodes and validates a metadata.json document. +func ParseMetadata(data []byte) (*TableMetadata, error) { + var m TableMetadata + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + + switch { + case m.FormatVersion < 1 || m.FormatVersion > supportedMaxVersion: + return nil, fmt.Errorf("%w: unsupported format-version %d", ErrInvalidMetadata, m.FormatVersion) + case len(m.Schemas) == 0: + return nil, fmt.Errorf("%w: no schema", ErrInvalidMetadata) + case m.Location == "": + return nil, fmt.Errorf("%w: no location", ErrInvalidMetadata) + } + + if _, ok := m.SchemaByID(m.CurrentSchemaID); !ok { + return nil, fmt.Errorf("%w: current-schema-id %d not in schemas", ErrInvalidMetadata, m.CurrentSchemaID) + } + + return &m, nil +} + +// SchemaByID returns the schema with the given id. +func (m *TableMetadata) SchemaByID(id int) (Schema, bool) { + for _, s := range m.Schemas { + if s.SchemaID == id { + return s, true + } + } + + return Schema{}, false +} + +// CurrentSchema is the table's current schema. +func (m *TableMetadata) CurrentSchema() Schema { + s, _ := m.SchemaByID(m.CurrentSchemaID) + + return s +} + +// SpecByID returns the partition spec with the given id. +func (m *TableMetadata) SpecByID(id int) (PartitionSpec, bool) { + for _, s := range m.PartitionSpecs { + if s.SpecID == id { + return s, true + } + } + + return PartitionSpec{}, false +} + +// DefaultSpec is the spec new data is written with. +func (m *TableMetadata) DefaultSpec() PartitionSpec { + s, _ := m.SpecByID(m.DefaultSpecID) + + return s +} + +// SnapshotByID returns the snapshot with the given id. +func (m *TableMetadata) SnapshotByID(id int64) (Snapshot, bool) { + for _, s := range m.Snapshots { + if s.SnapshotID == id { + return s, true + } + } + + return Snapshot{}, false +} + +// CurrentSnapshot returns the current snapshot, if the table has one. +func (m *TableMetadata) CurrentSnapshot() (Snapshot, bool) { + if m.CurrentSnapshotID == NoSnapshot { + return Snapshot{}, false + } + + return m.SnapshotByID(m.CurrentSnapshotID) +} + +// SnapshotAsOf returns the snapshot that was current at t: the last +// snapshot-log entry at or before t. +func (m *TableMetadata) SnapshotAsOf(t time.Time) (Snapshot, error) { + ms := t.UnixMilli() + + var best *SnapshotLogEntry + + for i := range m.SnapshotLog { + e := &m.SnapshotLog[i] + if e.TimestampMS <= ms && (best == nil || e.TimestampMS >= best.TimestampMS) { + best = e + } + } + + if best == nil { + for _, s := range m.Snapshots { + if s.TimestampMS <= ms && (best == nil || s.TimestampMS >= best.TimestampMS) { + best = &SnapshotLogEntry{TimestampMS: s.TimestampMS, SnapshotID: s.SnapshotID} + } + } + } + + if best == nil { + return Snapshot{}, fmt.Errorf("%w: no version at or before %s", ErrNoSnapshot, t.UTC().Format(time.RFC3339)) + } + + s, ok := m.SnapshotByID(best.SnapshotID) + if !ok { + return Snapshot{}, fmt.Errorf("%w: snapshot %d", ErrNoSnapshot, best.SnapshotID) + } + + return s, nil +} + +// NewParams describes a table to create. +type NewParams struct { + Now time.Time + Properties map[string]string + Location string + SortOrder json.RawMessage + Schema Schema + Spec PartitionSpec +} + +// NewTableMetadata builds the v2 metadata of a table with no snapshots. +func NewTableMetadata(p NewParams) *TableMetadata { + schema := p.Schema + schema.Type = typeStruct + schema.SchemaID = 0 + + lastCol := 0 + for _, f := range schema.Fields { + lastCol = max(lastCol, f.ID) + } + + lastPart := firstPartitionFieldID - 1 + for _, f := range p.Spec.Fields { + lastPart = max(lastPart, f.FieldID) + } + + order := p.SortOrder + if order == nil { + order = json.RawMessage(`{"order-id":0,"fields":[]}`) + } + + props := map[string]string{} + maps.Copy(props, p.Properties) + + return &TableMetadata{ + FormatVersion: FormatVersion, + TableUUID: uuid.NewString(), + Location: p.Location, + LastUpdatedMS: p.Now.UnixMilli(), + LastColumnID: lastCol, + Schemas: []Schema{schema}, + DefaultSpecID: p.Spec.SpecID, + PartitionSpecs: []PartitionSpec{p.Spec}, + LastPartitionID: lastPart, + SortOrders: []json.RawMessage{order}, + Properties: props, + CurrentSnapshotID: NoSnapshot, + Snapshots: []Snapshot{}, + SnapshotLog: []SnapshotLogEntry{}, + MetadataLog: []MetadataLogEntry{}, + DefaultSortOrderID: sortOrderID(order), + } +} + +func sortOrderID(order json.RawMessage) int { + var o struct { + ID int `json:"order-id"` + } + + _ = json.Unmarshal(order, &o) + + return o.ID +} + +// AppendSnapshot returns a copy of m with snap added as the current snapshot of main. +// previousMetadataFile is recorded in the metadata log. +func (m *TableMetadata) AppendSnapshot(snap Snapshot, previousMetadataFile string, now time.Time) *TableMetadata { + next := *m + next.Properties = maps.Clone(m.Properties) + next.Snapshots = append(slices.Clone(m.Snapshots), snap) + next.SnapshotLog = append(slices.Clone(m.SnapshotLog), + SnapshotLogEntry{TimestampMS: snap.TimestampMS, SnapshotID: snap.SnapshotID}) + next.MetadataLog = slices.Clone(m.MetadataLog) + + if previousMetadataFile != "" { + next.MetadataLog = append(next.MetadataLog, + MetadataLogEntry{MetadataFile: previousMetadataFile, TimestampMS: m.LastUpdatedMS}) + } + + next.Refs = maps.Clone(m.Refs) + if next.Refs == nil { + next.Refs = map[string]Ref{} + } + + next.Refs[MainBranch] = Ref{Type: "branch", SnapshotID: snap.SnapshotID} + next.CurrentSnapshotID = snap.SnapshotID + next.LastSequenceNumber = snap.SequenceNumber + next.LastUpdatedMS = max(now.UnixMilli(), snap.TimestampMS) + + return &next +} + +// NextMetadataLocation names this metadata's own file, numbered by its metadata-log length. +func (m *TableMetadata) NextMetadataLocation() string { + return fmt.Sprintf("%s/metadata/%05d-%s.metadata.json", + strings.TrimSuffix(m.Location, "/"), len(m.MetadataLog), uuid.NewString()) +} + +// InitialMetadataLocation names the first metadata file of a new table. +func InitialMetadataLocation(location string) string { + return fmt.Sprintf("%s/metadata/00000-%s.metadata.json", strings.TrimSuffix(location, "/"), uuid.NewString()) +} diff --git a/pkgs/iceberg/transform.go b/pkgs/iceberg/transform.go new file mode 100644 index 0000000000..1d149332ba --- /dev/null +++ b/pkgs/iceberg/transform.go @@ -0,0 +1,309 @@ +package iceberg + +import ( + "encoding/binary" + "fmt" + "math" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ( + murmurC1 = 0xcc9e2d51 + murmurC2 = 0x1b873593 + murmurN = 0xe6546b64 + murmurMix1 = 0x85ebca6b + murmurMix2 = 0xc2b2ae35 + rotl1 = 15 + rotl2 = 13 + secondsPerDay = 86400 + microsPerSec = 1_000_000 + microsPerHour = 3_600_000_000 + epochYear = 1970 + maxInt32Bucket = 0x7fffffff +) + +// Transform is a parsed partition transform. +type Transform struct { + Name string + Width int +} + +// ParseTransform parses identity, year, month, day, hour, void, bucket[N] and truncate[W]. +func ParseTransform(s string) (Transform, error) { + name, arg, hasArg := strings.Cut(strings.ToLower(strings.TrimSpace(s)), "[") + if !hasArg { + switch name { + case "identity", transformYear, transformMonth, transformDay, transformHour, "void": + return Transform{Name: name}, nil + } + + return Transform{}, fmt.Errorf("%w: transform %q", ErrUnsupportedType, s) + } + + n64, err := strconv.ParseInt(strings.TrimSuffix(arg, "]"), 10, 32) + if err != nil || n64 <= 0 || (name != "bucket" && name != "truncate") { + return Transform{}, fmt.Errorf("%w: transform %q", ErrUnsupportedType, s) + } + + return Transform{Name: name, Width: int(n64)}, nil +} + +// String renders the transform as it appears in a partition spec. +func (t Transform) String() string { + if t.Width > 0 { + return fmt.Sprintf("%s[%d]", t.Name, t.Width) + } + + return t.Name +} + +// ResultType is the Iceberg type of the transform's output for a source type. +func (t Transform) ResultType(source string) string { + switch t.Name { + case transformYear, transformMonth, transformDay, transformHour, transformBucket: + return typeInt + default: + return source + } +} + +// Apply transforms the textual value of a source column. A nil result is null. +// Results are bool, int32, int64, float32, float64 or string. +func (t Transform) Apply(source string, raw *string) (any, error) { + if raw == nil || t.Name == "void" { + return nil, nil //nolint:nilnil // a null partition value is not an error + } + + switch t.Name { + case "identity": + return identityValue(source, *raw) + case transformYear, transformMonth, transformDay, transformHour: + return t.temporal(source, *raw) + case transformBucket: + return t.bucket(source, *raw) + default: + return t.truncate(source, *raw) + } +} + +func parseInstant(source, raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + + for _, l := range []string{ + "2006-01-02 15:04:05.999999999", "2006-01-02T15:04:05.999999999", time.RFC3339Nano, + "2006-01-02 15:04:05.999999999 MST", "2006-01-02", + } { + if v, err := time.Parse(l, raw); err == nil { + return v.UTC(), nil + } + } + + return time.Time{}, fmt.Errorf("%w: %q as %s", ErrUnsupportedType, raw, source) +} + +func identityValue(source, raw string) (any, error) { + switch source { + case typeBoolean: + return strings.EqualFold(raw, "true"), nil + case typeInt: + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32) + + return narrow(n), err + case typeLong: + return strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + case typeFloat: + f, err := strconv.ParseFloat(strings.TrimSpace(raw), 32) + + return float32(f), err + case typeDouble: + return strconv.ParseFloat(strings.TrimSpace(raw), 64) + case typeDate: + d, err := parseInstant(source, raw) + + return narrow(d.Unix() / secondsPerDay), err + case typeTimestamp, typeTimestamptz: + d, err := parseInstant(source, raw) + + return d.UnixMicro(), err + case typeString, typeUUID: + return raw, nil + default: + return nil, fmt.Errorf("%w: identity partition on %s", ErrUnsupportedType, source) + } +} + +func (t Transform) temporal(source, raw string) (any, error) { + if source != "date" && source != "timestamp" && source != "timestamptz" { + return nil, fmt.Errorf("%w: %s partition on %s", ErrUnsupportedType, t.Name, source) + } + + if t.Name == transformHour && source == "date" { + return nil, fmt.Errorf("%w: hour partition on date", ErrUnsupportedType) + } + + d, err := parseInstant(source, raw) + if err != nil { + return nil, err + } + + switch t.Name { + case transformYear: + return narrow(int64(d.Year() - epochYear)), nil + case transformMonth: + return narrow(int64((d.Year()-epochYear)*12 + int(d.Month()) - 1)), nil + case transformDay: + return narrow(floorDiv(d.Unix(), secondsPerDay)), nil + default: + return narrow(floorDiv(d.UnixMicro(), microsPerHour)), nil + } +} + +func floorDiv(a, b int64) int64 { + q := a / b + if a%b != 0 && (a < 0) != (b < 0) { + q-- + } + + return q +} + +func (t Transform) bucket(source, raw string) (any, error) { + var in []byte + + switch source { + case typeInt, typeLong, typeDate, typeTimestamp, typeTimestamptz: + v, err := identityValue(source, raw) + if err != nil { + return nil, err + } + + var n int64 + + switch x := v.(type) { + case int32: + n = int64(x) + case int64: + n = x + } + + in = binary.LittleEndian.AppendUint64(nil, uint64(n)) + case typeString, typeUUID: + in = []byte(raw) + case typeBinary: + in = []byte(raw) + default: + return nil, fmt.Errorf("%w: bucket partition on %s", ErrUnsupportedType, source) + } + + if t.Width <= 0 || t.Width > math.MaxInt32 { + return nil, fmt.Errorf("%w: bucket width %d", ErrUnsupportedType, t.Width) + } + + width := uint32(t.Width) + + return narrow(int64((murmur3(in) & maxInt32Bucket) % width)), nil +} + +func (t Transform) truncate(source, raw string) (any, error) { + switch source { + case typeInt, typeLong: + v, err := identityValue(source, raw) + if err != nil { + return nil, err + } + + w := int64(t.Width) + + if x, ok := v.(int32); ok { + n := int64(x) + + return narrow(n - ((n%w)+w)%w), nil + } + + n, _ := v.(int64) + + return n - ((n%w)+w)%w, nil + case typeString: + if utf8.RuneCountInString(raw) <= t.Width { + return raw, nil + } + + return string([]rune(raw)[:t.Width]), nil + case "decimal": + return nil, fmt.Errorf("%w: truncate partition on decimal", ErrUnsupportedType) + default: + return nil, fmt.Errorf("%w: truncate partition on %s", ErrUnsupportedType, source) + } +} + +//nolint:mnd // MurmurHash3 x86_32 mixing constants +func murmur3(data []byte) uint32 { + var h uint32 + + n := len(data) / 4 * 4 + for i := 0; i < n; i += 4 { + k := binary.LittleEndian.Uint32(data[i:]) + k *= murmurC1 + k = k<>(32-rotl1) + k *= murmurC2 + h ^= k + h = h<>(32-rotl2) + h = h*5 + murmurN + } + + var k uint32 + + switch len(data) & 3 { + case 3: + k ^= uint32(data[n+2]) << 16 + + fallthrough + case 2: + k ^= uint32(data[n+1]) << 8 + + fallthrough + case 1: + k ^= uint32(data[n]) + k *= murmurC1 + k = k<>(32-rotl1) + k *= murmurC2 + h ^= k + } + + h ^= uint32(len(data)) //nolint:gosec // length fits uint32 for hashed values + h ^= h >> 16 + h *= murmurMix1 + h ^= h >> 13 + h *= murmurMix2 + h ^= h >> 16 + + return h +} + +// AvroTypeOf is the Avro primitive a value of the given Iceberg primitive is stored as in a manifest. +func AvroTypeOf(icebergType string) (string, error) { + switch icebergType { + case typeBoolean: + return typeBoolean, nil + case typeInt, typeDate: + return typeInt, nil + case typeLong, typeTime, typeTimestamp, typeTimestamptz: + return typeLong, nil + case typeFloat: + return typeFloat, nil + case typeDouble: + return typeDouble, nil + case typeString, typeUUID: + return typeString, nil + default: + return "", fmt.Errorf("%w: partition value of type %s", ErrUnsupportedType, icebergType) + } +} + +// narrow converts a transform result that is bounded by calendar or width arithmetic to an Avro int. +func narrow(n int64) int32 { + return int32(n) //nolint:gosec // years, days, hours and truncated ints fit 32 bits +} diff --git a/pkgs/iceberg/types.go b/pkgs/iceberg/types.go new file mode 100644 index 0000000000..0cb5bdded9 --- /dev/null +++ b/pkgs/iceberg/types.go @@ -0,0 +1,184 @@ +package iceberg + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +// ErrUnsupportedType is returned for a type that cannot be converted or written. +var ErrUnsupportedType = errors.New("unsupported iceberg type") + +type nestedType struct { + Element json.RawMessage `json:"element"` + Key json.RawMessage `json:"key"` + Value json.RawMessage `json:"value"` + Type string `json:"type"` + Fields []Field `json:"fields"` + ElementID int `json:"element-id"` + KeyID int `json:"key-id"` + ValueID int `json:"value-id"` + ElementNeeded bool `json:"element-required"` + ValueNeeded bool `json:"value-required"` +} + +// HiveType renders an Iceberg type as the Athena/Hive type name used in the +// Glue catalog (long -> bigint, list -> array<..>, struct -> struct). +func HiveType(raw json.RawMessage) (string, error) { + var prim string + if err := json.Unmarshal(raw, &prim); err == nil { + return hivePrimitive(prim) + } + + var n nestedType + if err := json.Unmarshal(raw, &n); err != nil { + return "", fmt.Errorf("%w: %s", ErrUnsupportedType, raw) + } + + switch n.Type { + case typeStruct: + parts := make([]string, 0, len(n.Fields)) + + for _, f := range n.Fields { + t, err := HiveType(f.Type) + if err != nil { + return "", err + } + + parts = append(parts, f.Name+":"+t) + } + + return "struct<" + strings.Join(parts, ",") + ">", nil + case "list": + t, err := HiveType(n.Element) + if err != nil { + return "", err + } + + return "array<" + t + ">", nil + case typeMap: + k, err := HiveType(n.Key) + if err != nil { + return "", err + } + + v, err := HiveType(n.Value) + if err != nil { + return "", err + } + + return "map<" + k + "," + v + ">", nil + default: + return "", fmt.Errorf("%w: %s", ErrUnsupportedType, n.Type) + } +} + +func hivePrimitive(p string) (string, error) { + switch { + case p == typeBoolean, + p == typeInt, + p == typeFloat, + p == typeDouble, + p == typeDate, + p == typeString, + p == typeBinary: + return p, nil + case p == typeLong: + return "bigint", nil + case p == typeTime: + return typeTime, nil + case p == typeTimestamp: + return typeTimestamp, nil + case p == typeTimestamptz, p == "timestamp_ns", p == "timestamptz_ns": + return "timestamp with time zone", nil + case p == typeUUID: + return typeString, nil + case strings.HasPrefix(p, "decimal("): + return strings.ReplaceAll(p, " ", ""), nil + case strings.HasPrefix(p, "fixed["): + return typeBinary, nil + default: + return "", fmt.Errorf("%w: %s", ErrUnsupportedType, p) + } +} + +// PrimitiveName returns the primitive type name of a field type ("" for nested types). +func PrimitiveName(raw json.RawMessage) string { + var prim string + if err := json.Unmarshal(raw, &prim); err != nil { + return "" + } + + return prim +} + +// FromHive maps an Athena/Hive column type to an Iceberg primitive type name. +// Nested types are not writable and return ErrUnsupportedType. +func FromHive(hive string) (string, error) { + t := strings.ToLower(strings.TrimSpace(hive)) + base, _, _ := strings.Cut(t, "(") + base = strings.TrimSpace(base) + + switch base { + case typeBoolean, typeFloat, typeDouble, typeDate, typeBinary: + return base, nil + case "tinyint", "smallint", typeInt, "integer": + return typeInt, nil + case "bigint": + return typeLong, nil + case "real": + return typeFloat, nil + case typeString, "varchar", "char": + return typeString, nil + case typeTimestamp: + if strings.Contains(t, "with time zone") { + return typeTimestamptz, nil + } + + return typeTimestamp, nil + case "decimal", "numeric": + if !strings.Contains(t, "(") { + return "decimal(10, 0)", nil + } + + _, rest, _ := strings.Cut(t, "(") + p, s, _ := strings.Cut(strings.TrimSuffix(rest, ")"), ",") + + pi, err1 := strconv.Atoi(strings.TrimSpace(p)) + si, err2 := strconv.Atoi(strings.TrimSpace(s)) + + if err1 != nil || (err2 != nil && s != "") { + return "", fmt.Errorf("%w: %s", ErrUnsupportedType, hive) + } + + return fmt.Sprintf("decimal(%d, %d)", pi, si), nil + default: + return "", fmt.Errorf("%w: %s", ErrUnsupportedType, hive) + } +} + +// ColumnSpec is a column of a table about to be created. +type ColumnSpec struct { + Name string + Type string + Comment string +} + +// SchemaFromColumns builds a schema with sequential field ids; columns are optional (nullable). +func SchemaFromColumns(cols []ColumnSpec) (Schema, error) { + fields := make([]Field, 0, len(cols)) + + for i, c := range cols { + t, err := FromHive(c.Type) + if err != nil { + return Schema{}, err + } + + raw, _ := json.Marshal(t) + fields = append(fields, Field{ID: i + 1, Name: c.Name, Type: raw, Doc: c.Comment}) + } + + return Schema{Type: typeStruct, Fields: fields}, nil +} diff --git a/pkgs/inithooks/ondemand.go b/pkgs/inithooks/ondemand.go new file mode 100644 index 0000000000..892c93a9ce --- /dev/null +++ b/pkgs/inithooks/ondemand.go @@ -0,0 +1,111 @@ +package inithooks + +import ( + "context" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "sync" +) + +// MaxCapturedOutput bounds the output retained per on-demand script run. +const MaxCapturedOutput = 16 << 10 + +var ( + // ErrUnknownStage means the stage is not one of [Stages]. + ErrUnknownStage = errors.New("unknown init stage") + // ErrUnknownScript means no script of that name is registered in the stage. + ErrUnknownScript = errors.New("init script not registered in stage") +) + +// ScriptRun is the outcome of one on-demand script execution. +type ScriptRun struct { + Stage Stage + Name string + Output string + Error string + ExitCode int + Truncated bool +} + +type boundedBuffer struct { + buf []byte + mu sync.Mutex + truncated bool +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + room := MaxCapturedOutput - len(b.buf) + if room > 0 { + b.buf = append(b.buf, p[:min(room, len(p))]...) + } + + if len(p) > room { + b.truncated = true + } + + return len(p), nil +} + +// RunNamed runs the discovered scripts of stage, or only the one whose file +// name equals name, and returns their exit status and bounded output. Only +// scripts found in the configured roots can run; name is matched against +// discovered base names, never used as a path. +func (m *Manager) RunNamed(ctx context.Context, stage Stage, name string) ([]ScriptRun, error) { + want := Stage(strings.ToUpper(string(stage))) + stage = "" + + for _, s := range Stages() { + if s == want { + stage = s + } + } + + if stage == "" { + return nil, fmt.Errorf("%w: %q", ErrUnknownStage, want) + } + + paths := m.discover(stage) + + if name != "" { + var sel []string + + for _, p := range paths { + if filepath.Base(p) == name { + sel = append(sel, p) + } + } + + if len(sel) == 0 { + return nil, fmt.Errorf("%w: %q in %s", ErrUnknownScript, name, stage) + } + + paths = sel + } + + runs := make([]ScriptRun, 0, len(paths)) + + for _, p := range paths { + out := &boundedBuffer{} + err := m.runScript(ctx, stage, p, out) + run := ScriptRun{Stage: stage, Name: filepath.Base(p), Output: string(out.buf), Truncated: out.truncated} + + if err != nil { + run.Error = err.Error() + run.ExitCode = -1 + + if ee := (*exec.ExitError)(nil); errors.As(err, &ee) { + run.ExitCode = ee.ExitCode() + } + } + + runs = append(runs, run) + } + + return runs, nil +} diff --git a/pkgs/inithooks/runnamed_stage_test.go b/pkgs/inithooks/runnamed_stage_test.go new file mode 100644 index 0000000000..63ded15ab8 --- /dev/null +++ b/pkgs/inithooks/runnamed_stage_test.go @@ -0,0 +1,25 @@ +package inithooks_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/inithooks" +) + +func TestRunNamedRejectsTraversalStage(t *testing.T) { + t.Parallel() + + for _, stage := range []inithooks.Stage{"../../etc", "ready/../boot", "", "nope"} { + t.Run(string(stage), func(t *testing.T) { + t.Parallel() + + m := inithooks.NewManager(inithooks.ManagerConfig{Roots: []string{t.TempDir()}}) + _, err := m.RunNamed(t.Context(), stage, "") + require.ErrorIs(t, err, inithooks.ErrUnknownStage) + assert.Empty(t, m.Status()) + }) + } +} diff --git a/pkgs/inithooks/stages.go b/pkgs/inithooks/stages.go index c2cffda613..2868bc637d 100644 --- a/pkgs/inithooks/stages.go +++ b/pkgs/inithooks/stages.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "log/slog" "maps" "os" @@ -142,8 +143,16 @@ func (m *Manager) setScript(stage Stage, idx int, st State, errMsg string) { }) } -// listScripts returns the sorted .sh/.py script paths directly inside dir. -func listScripts(dir string) []string { +// listScripts returns the sorted .sh/.py script paths directly inside root/sub. +func listScripts(root, sub string) []string { + base := filepath.Clean(root) + dir := filepath.Join(base, sub) + prefix := strings.TrimSuffix(base, string(filepath.Separator)) + string(filepath.Separator) + + if dir != base && !strings.HasPrefix(dir, prefix) { + return nil + } + entries, err := os.ReadDir(dir) if err != nil { return nil @@ -173,7 +182,7 @@ func (m *Manager) discover(stage Stage) []string { var out []string for _, root := range m.roots { - out = append(out, listScripts(filepath.Join(root, strings.ToLower(string(stage))+".d"))...) + out = append(out, listScripts(root, strings.ToLower(string(stage))+".d")...) } if stage != StageReady { @@ -181,7 +190,7 @@ func (m *Manager) discover(stage Stage) []string { } for _, dir := range m.legacy { - if legacy := listScripts(dir); len(legacy) > 0 { + if legacy := listScripts(dir, ""); len(legacy) > 0 { m.log.Warn("init hook: legacy directory is deprecated; move scripts to /ready.d", "dir", dir) out = append(out, legacy...) @@ -205,7 +214,7 @@ func (m *Manager) Run(ctx context.Context, stage Stage) { for i, p := range paths { m.setScript(stage, i, StateRunning, "") - if err := m.runScript(ctx, stage, p); err != nil { + if err := m.runScript(ctx, stage, p, nil); err != nil { m.log.ErrorContext(ctx, "init hook: script failed", "stage", stage, "script", p, "error", err) m.setScript(stage, i, StateError, err.Error()) @@ -234,7 +243,7 @@ func interpreter(path string) (string, error) { return "", errNoPython } -func (m *Manager) runScript(ctx context.Context, stage Stage, path string) error { +func (m *Manager) runScript(ctx context.Context, stage Stage, path string, capture io.Writer) error { interp, err := interpreter(path) if err != nil { return err @@ -251,6 +260,10 @@ func (m *Manager) runScript(ctx context.Context, stage Stage, path string) error stderr := &lineLogger{ctx: ctx, log: m.log, stage: stage, script: path, stream: "stderr"} cmd.Stdout, cmd.Stderr = stdout, stderr + if capture != nil { + cmd.Stdout, cmd.Stderr = io.MultiWriter(stdout, capture), io.MultiWriter(stderr, capture) + } + m.log.InfoContext(ctx, "init hook: running", "stage", stage, "script", path) err = cmd.Run() diff --git a/pkgs/inithooks/stages_test.go b/pkgs/inithooks/stages_test.go index 19f0fd58ef..e408aad606 100644 --- a/pkgs/inithooks/stages_test.go +++ b/pkgs/inithooks/stages_test.go @@ -152,3 +152,72 @@ func TestManager_LegacyReadyDirs(t *testing.T) { }) } } + +func TestManager_RunNamed(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + name string + stage inithooks.Stage + script string + wantOut string + wantNames []string + wantExit int + }{ + {name: "one", stage: inithooks.StageReady, script: "a.sh", wantNames: []string{"a.sh"}, wantOut: "hi"}, + {name: "lower_stage", stage: "ready", script: "b.sh", wantNames: []string{"b.sh"}, wantExit: 4, wantOut: "bad"}, + {name: "all", stage: inithooks.StageReady, wantNames: []string{"a.sh", "b.sh"}, wantExit: 4, wantOut: "hi"}, + {name: "missing", stage: inithooks.StageReady, script: "c.sh", wantErr: inithooks.ErrUnknownScript}, + { + name: "traversal", stage: inithooks.StageReady, script: "../ready.d/a.sh", + wantErr: inithooks.ErrUnknownScript, + }, + {name: "wrong_stage", stage: inithooks.StageBoot, script: "a.sh", wantErr: inithooks.ErrUnknownScript}, + {name: "bad_stage", stage: "nope", wantErr: inithooks.ErrUnknownStage}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeScript(t, root, "ready", "a.sh", "echo hi\n") + writeScript(t, root, "ready", "b.sh", "echo bad\nexit 4\n") + + m := inithooks.NewManager(inithooks.ManagerConfig{Roots: []string{root}}) + runs, err := m.RunNamed(t.Context(), tt.stage, tt.script) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + + var names []string + for _, r := range runs { + names = append(names, r.Name) + } + + assert.Equal(t, tt.wantNames, names) + assert.Contains(t, runs[len(runs)-1].Output+runs[0].Output, tt.wantOut) + assert.Equal(t, tt.wantExit, runs[len(runs)-1].ExitCode) + }) + } +} + +func TestManager_RunNamedBoundsOutput(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeScript(t, root, "ready", "big.sh", "yes x | head -c 100000\n") + + m := inithooks.NewManager(inithooks.ManagerConfig{Roots: []string{root}}) + runs, err := m.RunNamed(t.Context(), inithooks.StageReady, "big.sh") + require.NoError(t, err) + require.Len(t, runs, 1) + assert.True(t, runs[0].Truncated) + assert.Len(t, runs[0].Output, inithooks.MaxCapturedOutput) +} diff --git a/pkgs/k3sengine/ca.go b/pkgs/k3sengine/ca.go new file mode 100644 index 0000000000..b0d3807b93 --- /dev/null +++ b/pkgs/k3sengine/ca.go @@ -0,0 +1,63 @@ +package k3sengine + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "time" +) + +const ( + caValidity = 10 * 365 * 24 * time.Hour + caSerialBits = 127 +) + +// CA is the server CA pre-provisioned into k3s so the readiness probe never has to trust-on-first-use. +type CA struct { + CertPEM []byte + KeyPEM []byte +} + +// NewCA generates a fresh P-256 CA with the given common name. +func NewCA(commonName string) (*CA, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate cluster CA key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), caSerialBits)) + if err != nil { + return nil, fmt.Errorf("generate cluster CA serial: %w", err) + } + + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: commonName}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(caValidity), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, fmt.Errorf("create cluster CA: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, fmt.Errorf("marshal cluster CA key: %w", err) + } + + return &CA{ + CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + KeyPEM: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), + }, nil +} diff --git a/pkgs/k3sengine/engine.go b/pkgs/k3sengine/engine.go new file mode 100644 index 0000000000..862b013851 --- /dev/null +++ b/pkgs/k3sengine/engine.go @@ -0,0 +1,574 @@ +package k3sengine + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const ( + defaultHost = "127.0.0.1" + startTimeout = 5 * time.Minute + probeInterval = time.Second + probeTimeout = 5 * time.Second + stopTimeout = 30 * time.Second + minTokenLen = 16 + caPollInterval = 500 * time.Millisecond + logTailTimeout = 5 * time.Second + logTailLines = "40" +) + +var ( + // ErrInvalidConfig is returned when the engine host or token is unusable. + ErrInvalidConfig = errors.New("invalid k3s cluster engine configuration") + // ErrDefaultTokenExposed is returned when the public default token would be exposed off-host. + ErrDefaultTokenExposed = errors.New("a cluster token must be set when the cluster host is not loopback") + // ErrSuperseded is returned by Launch when the caller's record callback declined the new container. + ErrSuperseded = errors.New("cluster was superseded while starting") + // ErrNotAdoptable is returned when a leftover container cannot be taken over. + ErrNotAdoptable = errors.New("container cannot be adopted") + // ErrNodeExited is returned when the node container stops while the engine waits for its API server. + ErrNodeExited = errors.New("node container exited") + // ErrRotateFailed is returned when the node could not drop its certificates. + ErrRotateFailed = errors.New("certificate rotation failed") + // ErrStopUnsupported is returned by Stop and Start when the runtime cannot keep a stopped container. + ErrStopUnsupported = errors.New("container runtime cannot stop and restart a container in place") + + tokenPattern = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) + hostPattern = regexp.MustCompile(`^[A-Za-z0-9.:-]+$`) +) + +// Runtime is the slice of container.Runtime the engine needs. +type Runtime interface { + CreateAndStart(ctx context.Context, spec container.Spec) (string, error) + StopAndRemove(ctx context.Context, containerID string) error +} + +// Stoppable is implemented by runtimes that can stop a container and start it again with its data and ports. +type Stoppable interface { + StopContainer(ctx context.Context, containerID string) error + StartContainer(ctx context.Context, containerID string) error +} + +// Config configures an Engine. Prefix names containers and port labels ("eks", "gke"). +type Config struct { + Runtime Runtime + Ports *portalloc.Allocator + Logger *slog.Logger + Probe func(ctx context.Context, addr, token string) ([]byte, error) + Prefix string + Host string + Token string + StartTimeout time.Duration +} + +// Engine starts and stops single-node k3s containers. +type Engine struct { + cfg Config +} + +// NormalizeAccess validates host and token and fills their defaults. +func NormalizeAccess(host, token string) (string, string, error) { + if host == "" { + host = defaultHost + } + + defaulted := token == "" + if defaulted { + token = DefaultToken + } + + if !hostPattern.MatchString(host) || !tokenPattern.MatchString(token) || len(token) < minTokenLen { + return "", "", fmt.Errorf("%w: host must match %s and token %s (min %d chars)", + ErrInvalidConfig, hostPattern, tokenPattern, minTokenLen) + } + + if defaulted && container.BindHostFor(host) != container.LoopbackHost { + return "", "", ErrDefaultTokenExposed + } + + return host, token, nil +} + +// New validates cfg and returns an Engine with defaults applied. +func New(cfg Config) (*Engine, error) { + var err error + + if cfg.Host, cfg.Token, err = NormalizeAccess(cfg.Host, cfg.Token); err != nil { + return nil, err + } + + if cfg.StartTimeout <= 0 { + cfg.StartTimeout = startTimeout + } + + return &Engine{cfg: cfg}, nil +} + +// Host is the host clients reach the API server on. +func (e *Engine) Host() string { return e.cfg.Host } + +// Token is the static cluster-admin bearer token. +func (e *Engine) Token() string { return e.cfg.Token } + +// Runtime is the container runtime the engine starts nodes on. +func (e *Engine) Runtime() Runtime { return e.cfg.Runtime } + +// Addr is the host:port of an API server published on port. +func (e *Engine) Addr(port int) string { return net.JoinHostPort(e.cfg.Host, strconv.Itoa(port)) } + +// Log returns the configured logger, or the context's. +func (e *Engine) Log(ctx context.Context) *slog.Logger { + if e.cfg.Logger != nil { + return e.cfg.Logger + } + + return logger.Load(ctx) +} + +// AcquirePort takes a host port labelled for name. +func (e *Engine) AcquirePort(name string) (int, error) { + return portalloc.AcquireFrom(e.cfg.Ports, e.cfg.Prefix+"-"+name) +} + +// ReleasePort returns a port taken by AcquirePort. +func (e *Engine) ReleasePort(port int) { portalloc.ReleaseTo(e.cfg.Ports, port) } + +// ReleaseAll returns an API port and its load-balancer host ports. +func (e *Engine) ReleaseAll(port int, lbHost []int) { + e.ReleasePort(port) + + for _, p := range lbHost { + e.ReleasePort(p) + } +} + +// Remove stops and removes a container, then releases ports. +func (e *Engine) Remove(containerID string, ports ...int) { + ctx, cancel := context.WithTimeout(context.Background(), stopTimeout) + defer cancel() + + if err := e.cfg.Runtime.StopAndRemove(ctx, containerID); err != nil { + e.Log(ctx).WarnContext(ctx, e.cfg.Prefix+": cluster container removal failed", + "container", containerID, "error", err) + } + + for _, p := range ports { + e.ReleasePort(p) + } +} + +// Stop stops the container but keeps it, so its filesystem (the cluster state) and published ports survive. +func (e *Engine) Stop(ctx context.Context, containerID string) error { + rt, ok := e.cfg.Runtime.(Stoppable) + if !ok { + return ErrStopUnsupported + } + + return rt.StopContainer(ctx, containerID) +} + +// Start restarts a container kept by Stop and waits for its API server on addr; it returns the serving CA. +func (e *Engine) Start(ctx context.Context, containerID, addr string, ca []byte) ([]byte, error) { + rt, ok := e.cfg.Runtime.(Stoppable) + if !ok { + return nil, ErrStopUnsupported + } + + if err := rt.StartContainer(ctx, containerID); err != nil { + return nil, fmt.Errorf("start cluster container: %w", err) + } + + return e.awaitStarted(ctx, containerID, addr, ca) +} + +// awaitStarted waits for the restarted node's API server; it gives up as soon as the container exits and attaches +// the node's log tail to any failure. +func (e *Engine) awaitStarted(ctx context.Context, containerID, addr string, ca []byte) ([]byte, error) { + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + if w, ok := e.cfg.Runtime.(container.ExitWaiter); ok { + go func() { + if code, err := w.WaitExit(ctx, containerID); err == nil { + cancel(fmt.Errorf("%w with code %d", ErrNodeExited, code)) + } + }() + } + + got, err := e.AwaitReady(ctx, addr, ca) + if err == nil { + return got, nil + } + + if cause := context.Cause(ctx); cause != nil && !errors.Is(cause, context.DeadlineExceeded) { + err = fmt.Errorf("%w: %w", cause, err) + } + + return nil, fmt.Errorf("%w; node log tail:\n%s", err, e.logTail(containerID)) +} + +func (e *Engine) logTail(containerID string) string { + lr, ok := e.cfg.Runtime.(container.LogReader) + if !ok { + return "(runtime cannot read logs)" + } + + ctx, cancel := context.WithTimeout(context.Background(), logTailTimeout) + defer cancel() + + var out bytes.Buffer + + if err := lr.ReadLogs(ctx, containerID, logTailLines, false, &out, &out); err != nil { + return "(log read failed: " + err.Error() + ")" + } + + return out.String() +} + +// Close closes the runtime when it supports it. +func (e *Engine) Close() { + if c, ok := e.cfg.Runtime.(io.Closer); ok { + _ = c.Close() + } +} + +// Node describes one k3s server container. +type Node struct { + Name string + Image string + JoinToken string + // Resource is the owning service's key for the cluster: a restarted gopherstack can adopt the container by it. + Resource string + // Volume names a data volume mounted on DataDir, so cluster state outlives the container. + Volume string + // NodeName pins the Kubernetes node name; empty keeps the container hostname. + NodeName string + Mounts []string + LBPorts []int +} + +// Instance is a started k3s container. +type Instance struct { + CA *CA + ContainerID string + LBHost []int + Port int +} + +func (e *Engine) spec(n Node, port int, lbHost []int, ca *CA, vol bool) container.Spec { + bind := container.BindHostFor(e.cfg.Host) + ports := make([]string, 0, 1+len(n.LBPorts)) + ports = append(ports, container.PortSpec(bind, strconv.Itoa(port), strconv.Itoa(APIPort))) + + for i, p := range n.LBPorts { + ports = append(ports, container.PortSpec(bind, strconv.Itoa(lbHost[i]), strconv.Itoa(p))) + } + + labels := container.ServiceLabels("k3s-" + e.cfg.Prefix) + if n.Resource != "" { + labels = container.ResourceLabels("k3s-"+e.cfg.Prefix, n.Resource) + } + + env := []string{ + envAdminAuth + "=" + e.cfg.Token, envHost + "=" + e.cfg.Host, "K3S_TOKEN=" + n.JoinToken, + envCACert + "=" + string(ca.CertPEM), envCAKey + "=" + string(ca.KeyPEM), + } + if n.NodeName != "" { + env = append(env, envNodeName+"="+n.NodeName) + } + + mounts := n.Mounts + if vol { + mounts = append(slices.Clone(mounts), n.Volume+":"+DataDir) + } + + return container.Spec{ + Name: "gopherstack-" + e.cfg.Prefix + "-" + n.Name + "-" + uuid.NewString()[:8], + Labels: labels, + Image: n.Image, + Entrypoint: []string{"/bin/sh", "-c", Script(len(n.LBPorts) > 0)}, + Env: env, + Ports: ports, + Mounts: mounts, + Tmpfs: []string{"/run", "/var/run"}, + Privileged: true, + } +} + +// Launch starts the container for n. record runs once the container exists and returns false when the caller no +// longer wants it, in which case the container is removed and ErrSuperseded returned. +func (e *Engine) Launch(ctx context.Context, n Node, record func(*Instance) bool) (*Instance, error) { + ca, err := NewCA("gopherstack-" + e.cfg.Prefix + "-" + n.Name + "-ca") + if err != nil { + return nil, err + } + + vol, err := e.ensureVolume(ctx, n.Volume) + if err != nil { + return nil, err + } + + var ( + port int + lbHost []int + ) + + id, err := container.CreateAndStartRetry(ctx, e.cfg.Runtime, + func() (container.Spec, error) { + p, perr := e.AcquirePort(n.Name) + if perr != nil { + return container.Spec{}, perr + } + + port = p + lbHost = nil + + for range n.LBPorts { + hp, lerr := e.AcquirePort(n.Name) + if lerr != nil { + e.ReleaseAll(port, lbHost) + + return container.Spec{}, lerr + } + + lbHost = append(lbHost, hp) + } + + return e.spec(n, p, lbHost, ca, vol), nil + }, + func() { e.ReleaseAll(port, lbHost) }) + if err != nil { + return nil, err + } + + inst := &Instance{CA: ca, ContainerID: id, Port: port, LBHost: lbHost} + if record != nil && !record(inst) { + e.Remove(id, append([]int{port}, lbHost...)...) + + return nil, ErrSuperseded + } + + return inst, nil +} + +func (e *Engine) probe(ctx context.Context, addr string, ca []byte) ([]byte, error) { + if e.cfg.Probe != nil { + return e.cfg.Probe(ctx, addr, e.cfg.Token) + } + + return ca, Probe(ctx, addr, e.cfg.Token, ca) +} + +// AwaitReady polls the API server until it answers or the start timeout passes; it returns the serving CA. +func (e *Engine) AwaitReady(ctx context.Context, addr string, ca []byte) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, e.cfg.StartTimeout) + defer cancel() + + ticker := time.NewTicker(probeInterval) + defer ticker.Stop() + + for { + pctx, pcancel := context.WithTimeout(ctx, probeTimeout) + got, err := e.probe(pctx, addr, ca) + + pcancel() + + if err == nil { + return got, nil + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("API server not ready: %w", err) + case <-ticker.C: + } + } +} + +// VolumeLabel marks the data volumes the engine creates. +const VolumeLabel = "gopherstack.k3s-volume" + +func (e *Engine) volumes() (container.VolumeRuntime, bool) { + vr, ok := e.cfg.Runtime.(container.VolumeRuntime) + + return vr, ok +} + +// ensureVolume creates the named data volume; it reports false when there is no volume to mount (no name, or a +// runtime without volume support, which keeps state in the container only). +func (e *Engine) ensureVolume(ctx context.Context, name string) (bool, error) { + if name == "" { + return false, nil + } + + vr, ok := e.volumes() + if !ok { + return false, nil + } + + if err := vr.CreateVolume(ctx, name, map[string]string{VolumeLabel: e.cfg.Prefix}); err != nil { + return false, fmt.Errorf("create data volume %s: %w", name, err) + } + + return true, nil +} + +// RemoveVolume deletes a data volume; runtimes without volume support have nothing to delete. +func (e *Engine) RemoveVolume(ctx context.Context, name string) error { + vr, ok := e.volumes() + if !ok || name == "" { + return nil + } + + if err := vr.RemoveVolume(ctx, name); err != nil { + return fmt.Errorf("remove data volume %s: %w", name, err) + } + + return nil +} + +// UnusedVolumes lists the engine's data volumes no container references. +func (e *Engine) UnusedVolumes(ctx context.Context) ([]string, error) { + vr, ok := e.volumes() + if !ok { + return nil, nil + } + + names, err := vr.ListUnusedVolumes(ctx, VolumeLabel+"="+e.cfg.Prefix, 0) + if err != nil { + return nil, fmt.Errorf("list data volumes: %w", err) + } + + return names, nil +} + +// Exec runs cmd in a running node; env entries are set on the process, never in argv. +func (e *Engine) Exec(ctx context.Context, containerID string, cmd, env []string) (container.ExecResult, error) { + x, ok := e.cfg.Runtime.(container.Execer) + if !ok { + return container.ExecResult{}, container.ErrExecUnsupported + } + + res, err := container.ExecWithEnv(ctx, x, containerID, cmd, env) + if err != nil { + return container.ExecResult{}, fmt.Errorf("exec in cluster node: %w", err) + } + + return res, nil +} + +// ServingCA reads the node's serving CA certificate, which differs from the one generated for a launch when a +// reused data volume already held a CA. It waits for the node to write it. +func (e *Engine) ServingCA(ctx context.Context, containerID string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, e.cfg.StartTimeout) + defer cancel() + + ticker := time.NewTicker(caPollInterval) + defer ticker.Stop() + + for { + res, err := e.Exec(ctx, containerID, []string{"cat", CACertFile}, nil) + if errors.Is(err, container.ErrExecUnsupported) { + return nil, err + } + + if err == nil && res.ExitCode == 0 && res.Stdout != "" { + return []byte(res.Stdout), nil + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("read cluster CA: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +// Adopt takes over a running node container an earlier gopherstack left behind. The caller owns it afterwards. +func (e *Engine) Adopt(oc container.ManagedContainer) (*Instance, error) { + port := oc.Ports[APIPort] + if port == 0 { + return nil, fmt.Errorf("%w: container %s publishes no API port", ErrNotAdoptable, oc.ID) + } + + if e.cfg.Ports != nil { + if err := e.cfg.Ports.Reserve(port, e.cfg.Prefix+"-adopted"); err != nil { + return nil, fmt.Errorf("%w: %w", ErrNotAdoptable, err) + } + } + + return &Instance{ContainerID: oc.ID, Port: port}, nil +} + +const rotateCAEnvPerCA = 2 + +// rotateCANames are the CAs `k3s certificate rotate-ca` replaces, as the file names it stages. +func rotateCANames() []string { + return []string{"server-ca", "client-ca", "request-header-ca", "etcd/peer-ca", "etcd/server-ca"} +} + +func rotateCAScript() string { + return rotateCAScriptHead + strings.Join(rotateCANames(), " ") + rotateCAScriptTail +} + +const rotateCAScriptHead = `d=$(mktemp -d) && umask 077 && mkdir -p "$d/tls/etcd" && ` + + `cp ` + tlsDir + `/service.key "$d/tls/" && i=0 && for n in ` + +const rotateCAScriptTail = `; do ` + + `eval "c=\$GOPHERSTACK_ROTATE_CERT_$i k=\$GOPHERSTACK_ROTATE_KEY_$i" && ` + + `printf '%s\n' "$c" > "$d/tls/$n.crt" && printf '%s\n' "$k" > "$d/tls/$n.key" && i=$((i+1)); done && ` + + `/bin/k3s certificate rotate-ca --path="$d" --force && touch ` + rotatedMarker + `; rc=$?; rm -rf "$d"; exit $rc` + +// RotateCertificates replaces the node's cluster CAs with freshly generated ones (k3s certificate rotate-ca) and +// restarts it, so k3s reissues every certificate under them. It returns the new serving CA. +func (e *Engine) RotateCertificates(ctx context.Context, containerID, addr string) ([]byte, error) { + env := make([]string, 0, len(rotateCANames())*rotateCAEnvPerCA) + + var serving []byte + + for i, name := range rotateCANames() { + ca, err := NewCA("gopherstack-" + e.cfg.Prefix + "-" + name) + if err != nil { + return nil, err + } + + if i == 0 { + serving = ca.CertPEM + } + + env = append(env, + fmt.Sprintf("GOPHERSTACK_ROTATE_CERT_%d=%s", i, ca.CertPEM), + fmt.Sprintf("GOPHERSTACK_ROTATE_KEY_%d=%s", i, ca.KeyPEM)) + } + + res, err := e.Exec(ctx, containerID, []string{"/bin/sh", "-c", rotateCAScript()}, env) + if err != nil { + return nil, err + } + + if res.ExitCode != 0 { + return nil, fmt.Errorf("%w: k3s certificate rotate-ca exited %d: %s", ErrRotateFailed, res.ExitCode, res.Stderr) + } + + if err = e.Stop(ctx, containerID); err != nil { + return nil, err + } + + return e.Start(ctx, containerID, addr, serving) +} diff --git a/pkgs/k3sengine/image.go b/pkgs/k3sengine/image.go new file mode 100644 index 0000000000..583f24f5b2 --- /dev/null +++ b/pkgs/k3sengine/image.go @@ -0,0 +1,103 @@ +// Package k3sengine runs single-node k3s clusters in containers for the EKS and GKE emulators. +package k3sengine + +import ( + "strconv" + "strings" +) + +const ( + // ImageRepo is the image repository the engine runs. + ImageRepo = "rancher/k3s" + // DefaultToken is the static cluster-admin bearer token used when none is configured. + DefaultToken = "gopherstack-eks-cluster-token" //nolint:gosec // documented default for a local emulator + // APIPort is the k3s API server port inside the container. + APIPort = 6443 + + majorMinorParts = 2 + firstMinor = 29 + tokenFileName = "/etc/gopherstack-eks-tokens.csv" + envAdminAuth = "GOPHERSTACK_EKS_ADMIN_AUTH" + envHost = "GOPHERSTACK_EKS_HOST" + envCACert = "GOPHERSTACK_EKS_CA_CERT" + envCAKey = "GOPHERSTACK_EKS_CA_KEY" + envNodeName = "GOPHERSTACK_NODE_NAME" + tlsDir = "/var/lib/rancher/k3s/server/tls" + // DataDir is the k3s state directory a persistent volume is mounted on. + DataDir = "/var/lib/rancher/k3s" + // CACertFile is where the node keeps the serving CA certificate. + CACertFile = tlsDir + "/server-ca.crt" + + // rotatedMarker is left by a CA rotation so the next start drops the cached serving certificate. + rotatedMarker = tlsDir + "/.gopherstack-rotated" +) + +const scriptHead = `printf '%s,admin,admin,"system:masters"\n' "$` + envAdminAuth + `" > ` + tokenFileName + + ` && umask 077 && mkdir -p ` + tlsDir + + ` && { [ ! -e ` + rotatedMarker + ` ] || rm -f ` + rotatedMarker + ` ` + tlsDir + `/dynamic-cert.json; }` + + ` && { [ -s ` + tlsDir + `/server-ca.crt ] && [ -s ` + tlsDir + `/server-ca.key ] || {` + + ` printf '%s\n' "$` + envCACert + `" > ` + tlsDir + `/server-ca.crt` + + ` && printf '%s\n' "$` + envCAKey + `" > ` + tlsDir + `/server-ca.key; }; }` + + ` && unset ` + envCAKey + ` && exec /bin/k3s server --tls-san "$` + envHost + `"` + + ` --disable traefik${` + envNodeName + `:+ --node-name "$` + envNodeName + `"}` + +const scriptTail = ` --disable metrics-server --kube-apiserver-arg=token-auth-file=` + tokenFileName + +// Script builds the node command; servicelb stays on only when load-balancer ports are published. +func Script(servicelb bool) string { + if servicelb { + return scriptHead + scriptTail + } + + return scriptHead + ` --disable servicelb` + scriptTail +} + +// Tags are the newest published rancher/k3s tags for Kubernetes 1.29 through 1.36, in minor order. +func Tags() [8]string { + return [...]string{ + "v1.29.15-k3s1", "v1.30.14-k3s2", "v1.31.14-k3s1", "v1.32.13-k3s1", + "v1.33.13-k3s1", "v1.34.12-k3s1", "v1.35.9-k3s1", "v1.36.5-k3s1", + } +} + +// Tag returns the pinned k3s tag for a Kubernetes version's minor, or false when the minor is unsupported. +func Tag(version string) (string, bool) { + parts := strings.Split(version, ".") + if len(parts) < majorMinorParts || parts[0] != "1" { + return "", false + } + + tags := Tags() + + minor, err := strconv.Atoi(parts[1]) + if err != nil || minor < firstMinor || minor >= firstMinor+len(tags) { + return "", false + } + + return tags[minor-firstMinor], true +} + +// Image maps a Kubernetes version to its pinned k3s image; exact is false when it fell back to fallbackVersion. +func Image(version, fallbackVersion string) (string, bool) { + if tag, ok := Tag(version); ok { + return ImageRepo + ":" + tag, true + } + + tag, _ := Tag(fallbackVersion) + + return ImageRepo + ":" + tag, false +} + +// MinorKey returns "1.N" for a "1.N[.x]" version. +func MinorKey(version string) (string, bool) { + parts := strings.Split(version, ".") + if len(parts) < majorMinorParts || parts[0] != "1" { + return "", false + } + + if _, err := strconv.Atoi(parts[1]); err != nil { + return "", false + } + + return parts[0] + "." + parts[1], true +} diff --git a/pkgs/k3sengine/k3sengine_test.go b/pkgs/k3sengine/k3sengine_test.go new file mode 100644 index 0000000000..cb238fd772 --- /dev/null +++ b/pkgs/k3sengine/k3sengine_test.go @@ -0,0 +1,439 @@ +package k3sengine_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const token = "unit-test-cluster-token-0123" + +func TestTagAndImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + wantTag string + wantImage string + wantOK bool + }{ + {"minor only", "1.32", "v1.32.13-k3s1", k3sengine.ImageRepo + ":v1.32.13-k3s1", true}, + {"patch", "1.31.4", "v1.31.14-k3s1", k3sengine.ImageRepo + ":v1.31.14-k3s1", true}, + {"gke version", "1.34.2-gke.1008000", "v1.34.12-k3s1", k3sengine.ImageRepo + ":v1.34.12-k3s1", true}, + {"oldest", "1.29", "v1.29.15-k3s1", k3sengine.ImageRepo + ":v1.29.15-k3s1", true}, + {"newest", "1.36.0", "v1.36.5-k3s1", k3sengine.ImageRepo + ":v1.36.5-k3s1", true}, + {"too old", "1.28", "", k3sengine.ImageRepo + ":v1.34.12-k3s1", false}, + {"too new", "1.37", "", k3sengine.ImageRepo + ":v1.34.12-k3s1", false}, + {"not one", "2.0", "", k3sengine.ImageRepo + ":v1.34.12-k3s1", false}, + {"garbage", "latest", "", k3sengine.ImageRepo + ":v1.34.12-k3s1", false}, + {"empty", "", "", k3sengine.ImageRepo + ":v1.34.12-k3s1", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tag, ok := k3sengine.Tag(tt.version) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantTag, tag) + + image, exact := k3sengine.Image(tt.version, "1.34") + assert.Equal(t, tt.wantImage, image) + assert.Equal(t, tt.wantOK, exact) + }) + } +} + +func TestMinorKey(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + ok bool + }{ + {"1.32", "1.32", true}, {"1.32.5", "1.32", true}, {"1.x", "", false}, {"2.1", "", false}, {"1", "", false}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + + got, ok := k3sengine.MinorKey(tt.in) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestScript(t *testing.T) { + t.Parallel() + + assert.Contains(t, k3sengine.Script(false), "--disable servicelb") + assert.NotContains(t, k3sengine.Script(true), "servicelb") + + for _, lb := range []bool{false, true} { + s := k3sengine.Script(lb) + assert.Contains(t, s, "token-auth-file=") + assert.Contains(t, s, "--disable traefik") + assert.Contains(t, s, "server-ca.key") + } +} + +func TestNormalizeAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + name string + host string + token string + wantHost string + wantToken string + }{ + {name: "defaults", wantHost: "127.0.0.1", wantToken: k3sengine.DefaultToken}, + {name: "explicit", host: "k3s.test", token: token, wantHost: "k3s.test", wantToken: token}, + {name: "bad host", host: "bad host", token: token, wantErr: k3sengine.ErrInvalidConfig}, + {name: "short token", token: "short", wantErr: k3sengine.ErrInvalidConfig}, + {name: "bad token chars", token: "a token with spaces!!", wantErr: k3sengine.ErrInvalidConfig}, + {name: "public default token", host: "192.0.2.10", wantErr: k3sengine.ErrDefaultTokenExposed}, + { + name: "public explicit token", host: "192.0.2.10", token: token, + wantHost: "192.0.2.10", wantToken: token, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + host, tok, err := k3sengine.NormalizeAccess(tt.host, tt.token) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantHost, host) + assert.Equal(t, tt.wantToken, tok) + }) + } +} + +func TestNewCA(t *testing.T) { + t.Parallel() + + ca, err := k3sengine.NewCA("test-ca") + require.NoError(t, err) + + block, _ := pem.Decode(ca.CertPEM) + require.NotNil(t, block) + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.True(t, cert.IsCA) + assert.Equal(t, "test-ca", cert.Subject.CommonName) + + keyBlock, _ := pem.Decode(ca.KeyPEM) + require.NotNil(t, keyBlock) + + other, err := k3sengine.NewCA("test-ca") + require.NoError(t, err) + assert.NotEqual(t, ca.CertPEM, other.CertPEM) +} + +type runtime struct { + failErr error + specs []container.Spec + removed []string + mu sync.Mutex +} + +func (r *runtime) CreateAndStart(_ context.Context, spec container.Spec) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.failErr != nil { + return "", r.failErr + } + + r.specs = append(r.specs, spec) + + return "ctr-" + strconv.Itoa(len(r.specs)), nil +} + +func (r *runtime) StopAndRemove(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.removed = append(r.removed, id) + + return nil +} + +func newEngine(t *testing.T, rt *runtime, mod func(*k3sengine.Config)) (*k3sengine.Engine, *portalloc.Allocator) { + t.Helper() + + ports, err := portalloc.New(42000, 42050) + require.NoError(t, err) + + cfg := k3sengine.Config{Runtime: rt, Ports: ports, Prefix: "test", Token: token} + if mod != nil { + mod(&cfg) + } + + e, err := k3sengine.New(cfg) + require.NoError(t, err) + + return e, ports +} + +func TestLaunch(t *testing.T) { + t.Parallel() + + tests := []struct { + failErr error + wantErr error + name string + node k3sengine.Node + wantRemoved int + wantPortSpec int + decline bool + }{ + {name: "single node", node: k3sengine.Node{Name: "c1", Image: "img", JoinToken: "jt"}, wantPortSpec: 1}, + { + name: "with load balancer ports", + wantPortSpec: 3, + node: k3sengine.Node{ + Name: "c2", + Image: "img", + JoinToken: "jt", + LBPorts: []int{8080, 8443}, + Mounts: []string{"/a:/b:ro"}, + }, + }, + { + name: "declined by caller", + node: k3sengine.Node{Name: "c3", Image: "img"}, + decline: true, + wantErr: k3sengine.ErrSuperseded, + wantRemoved: 1, + }, + { + name: "runtime failure", + node: k3sengine.Node{Name: "c4", Image: "img"}, + failErr: errBoom, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := &runtime{failErr: tt.failErr} + e, ports := newEngine(t, rt, nil) + + var seen *k3sengine.Instance + + inst, err := e.Launch(t.Context(), tt.node, func(i *k3sengine.Instance) bool { + seen = i + + return !tt.decline + }) + + switch { + case tt.failErr != nil: + require.ErrorIs(t, err, tt.failErr) + + return + case tt.wantErr != nil: + require.ErrorIs(t, err, tt.wantErr) + require.Len(t, rt.removed, tt.wantRemoved) + assert.False(t, ports.IsAllocated(seen.Port), "ports of a declined container are released") + + return + } + + require.NoError(t, err) + assert.Same(t, seen, inst) + assert.Equal(t, "ctr-1", inst.ContainerID) + assert.True(t, ports.IsAllocated(inst.Port)) + assert.Len(t, inst.LBHost, len(tt.node.LBPorts)) + assert.NotEmpty(t, inst.CA.CertPEM) + + spec := rt.specs[0] + assert.Equal(t, tt.node.Image, spec.Image) + assert.Len(t, spec.Ports, tt.wantPortSpec) + assert.True(t, strings.HasPrefix(spec.Name, "gopherstack-test-"+tt.node.Name+"-")) + assert.Equal(t, tt.node.Mounts, spec.Mounts) + assert.Contains(t, spec.Env, "K3S_TOKEN="+tt.node.JoinToken) + assert.Contains(t, spec.Env, "GOPHERSTACK_EKS_ADMIN_AUTH="+token) + assert.Equal(t, strings.Contains(spec.Entrypoint[2], "--disable servicelb"), len(tt.node.LBPorts) == 0) + + e.Remove(inst.ContainerID, append([]int{inst.Port}, inst.LBHost...)...) + assert.Equal(t, []string{"ctr-1"}, rt.removed) + assert.False(t, ports.IsAllocated(inst.Port)) + }) + } +} + +var ( + errBoom = errors.New("boom") + errNotYet = errors.New("not yet") +) + +func TestAwaitReady(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + probe func(ctx context.Context, addr, token string) ([]byte, error) + name string + wantCA string + timeout time.Duration + }{ + { + name: "ready", timeout: time.Minute, wantCA: "served-ca", + probe: func(context.Context, string, string) ([]byte, error) { return []byte("served-ca"), nil }, + }, + { + name: "never ready", timeout: 20 * time.Millisecond, wantErr: errNotYet, + probe: func(context.Context, string, string) ([]byte, error) { return nil, errNotYet }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e, _ := newEngine( + t, + &runtime{}, + func(c *k3sengine.Config) { c.Probe, c.StartTimeout = tt.probe, tt.timeout }, + ) + + got, err := e.AwaitReady(t.Context(), "127.0.0.1:1", []byte("ca")) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + assert.Contains(t, err.Error(), "API server not ready") + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantCA, string(got)) + }) + } +} + +func TestProbeAgainstTLSServer(t *testing.T) { + t.Parallel() + + ca, err := k3sengine.NewCA("probe-ca") + require.NoError(t, err) + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+token || r.URL.Path != "/readyz" { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + _, _ = w.Write([]byte("ok")) + })) + srv.TLS = &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{leaf(t, ca)}} + srv.StartTLS() + t.Cleanup(srv.Close) + + addr := srv.Listener.Addr().String() + other, err := k3sengine.NewCA("other-ca") + require.NoError(t, err) + + tests := []struct { + name string + token string + ca []byte + wantErr bool + }{ + {"valid", token, ca.CertPEM, false}, + {"wrong token", "wrong", ca.CertPEM, true}, + {"untrusted ca", token, other.CertPEM, true}, + {"not a certificate", token, []byte("junk"), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + perr := k3sengine.Probe(t.Context(), addr, tt.token, tt.ca) + if tt.wantErr { + require.Error(t, perr) + + return + } + + require.NoError(t, perr) + }) + } + + _, err = k3sengine.HTTPGet( + t.Context(), + addr, + "/other", + token, + &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}, + ) + require.ErrorIs(t, err, k3sengine.ErrNotReady) +} + +func leaf(t *testing.T, ca *k3sengine.CA) tls.Certificate { + t.Helper() + + caBlk, _ := pem.Decode(ca.CertPEM) + keyBlk, _ := pem.Decode(ca.KeyPEM) + caCert, err := x509.ParseCertificate(caBlk.Bytes) + require.NoError(t, err) + + caKey, err := x509.ParseECPrivateKey(keyBlk.Bytes) + require.NoError(t, err) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "k3s"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey) + require.NoError(t, err) + + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} diff --git a/pkgs/k3sengine/node_state_test.go b/pkgs/k3sengine/node_state_test.go new file mode 100644 index 0000000000..7c618f7c79 --- /dev/null +++ b/pkgs/k3sengine/node_state_test.go @@ -0,0 +1,307 @@ +package k3sengine_test + +import ( + "context" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" +) + +type stateRuntime struct { + *stoppableRuntime + execFn func(cmd []string) (container.ExecResult, error) + volumes []string + removed []string + unused []string + execCmds [][]string + execEnvs [][]string + vmu sync.Mutex +} + +func (r *stateRuntime) CreateVolume(_ context.Context, name string, labels map[string]string) error { + r.vmu.Lock() + defer r.vmu.Unlock() + + r.volumes = append(r.volumes, name+"|"+labels[k3sengine.VolumeLabel]) + + return nil +} + +func (r *stateRuntime) RemoveVolume(_ context.Context, name string) error { + r.vmu.Lock() + defer r.vmu.Unlock() + + r.removed = append(r.removed, name) + + return nil +} + +func (r *stateRuntime) ListUnusedVolumes(context.Context, string, time.Duration) ([]string, error) { + return r.unused, nil +} + +func (r *stateRuntime) Exec(_ context.Context, _ string, cmd []string) (container.ExecResult, error) { + r.vmu.Lock() + r.execCmds = append(r.execCmds, cmd) + fn := r.execFn + r.vmu.Unlock() + + return fn(cmd) +} + +func (r *stateRuntime) ExecEnv(_ context.Context, _ string, cmd, env []string) (container.ExecResult, error) { + r.vmu.Lock() + r.execCmds = append(r.execCmds, cmd) + r.execEnvs = append(r.execEnvs, env) + fn := r.execFn + r.vmu.Unlock() + + return fn(cmd) +} + +func TestScriptKeepsExistingCA(t *testing.T) { + t.Parallel() + + const tls = "/var/lib/rancher/k3s/server/tls" + + for _, lb := range []bool{false, true} { + s := k3sengine.Script(lb) + assert.Contains(t, s, "[ -s /var/lib/rancher/k3s/server/tls/server-ca.crt ]", "a CA already on the volume wins") + assert.Contains(t, s, "rm -f "+tls+"/.gopherstack-rotated "+tls+"/dynamic-cert.json", + "a rotated node starts without its cached serving certificate") + assert.Contains(t, s, `${GOPHERSTACK_NODE_NAME:+ --node-name "$GOPHERSTACK_NODE_NAME"}`) + } +} + +func TestLaunchPersistentState(t *testing.T) { + t.Parallel() + + tests := []struct { + wantLabels map[string]string + name string + wantEnv string + node k3sengine.Node + wantMounts []string + noVolumes bool + }{ + { + name: "volume and resource", + node: k3sengine.Node{Name: "c1", Image: "img", Volume: "vol-1", Resource: "rg/c1", NodeName: "n-0"}, + wantMounts: []string{"vol-1:" + k3sengine.DataDir}, + wantLabels: map[string]string{container.LabelService: "k3s-test", container.LabelResource: "rg/c1"}, + wantEnv: "GOPHERSTACK_NODE_NAME=n-0", + }, + { + name: "plain node keeps the old labels", + node: k3sengine.Node{Name: "c1", Image: "img"}, + wantLabels: map[string]string{container.LabelService: "k3s-test"}, + }, + { + name: "runtime without volumes keeps state in the container", + node: k3sengine.Node{Name: "c1", Image: "img", Volume: "vol-1"}, + wantLabels: map[string]string{container.LabelService: "k3s-test"}, + noVolumes: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + base := &runtime{} + + var ( + e *k3sengine.Engine + sr *stateRuntime + ) + + if tt.noVolumes { + e, _ = newEngine(t, base, nil) + } else { + sr = &stateRuntime{stoppableRuntime: &stoppableRuntime{runtime: base}} + e, _ = newEngine(t, nil, func(c *k3sengine.Config) { c.Runtime = sr }) + } + + _, err := e.Launch(t.Context(), tt.node, nil) + require.NoError(t, err) + require.Len(t, base.specs, 1) + + spec := base.specs[0] + assert.Equal(t, tt.wantMounts, spec.Mounts) + + for k, v := range tt.wantLabels { + assert.Equal(t, v, spec.Labels[k]) + } + + if tt.wantEnv != "" { + assert.Contains(t, spec.Env, tt.wantEnv) + } + + if sr != nil && tt.node.Volume != "" { + assert.Equal(t, []string{"vol-1|test"}, sr.volumes) + } + }) + } +} + +func TestEngineVolumes(t *testing.T) { + t.Parallel() + + sr := &stateRuntime{stoppableRuntime: &stoppableRuntime{runtime: &runtime{}}, unused: []string{"a", "b"}} + e, _ := newEngine(t, nil, func(c *k3sengine.Config) { c.Runtime = sr }) + + require.NoError(t, e.RemoveVolume(t.Context(), "vol-1")) + require.NoError(t, e.RemoveVolume(t.Context(), "")) + assert.Equal(t, []string{"vol-1"}, sr.removed) + + names, err := e.UnusedVolumes(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"a", "b"}, names) + + plain, _ := newEngine(t, &runtime{}, nil) + require.NoError(t, plain.RemoveVolume(t.Context(), "vol-1")) + + names, err = plain.UnusedVolumes(t.Context()) + require.NoError(t, err) + assert.Empty(t, names) +} + +func TestAdopt(t *testing.T) { + t.Parallel() + + tests := []struct { + ports map[int]int + name string + wantPort int + wantErr bool + }{ + {name: "running node", ports: map[int]int{k3sengine.APIPort: 42010}, wantPort: 42010}, + {name: "no api port published", ports: map[int]int{80: 42011}, wantErr: true}, + {name: "port outside the pool", ports: map[int]int{k3sengine.APIPort: 50000}, wantPort: 50000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e, ports := newEngine(t, &runtime{}, nil) + + inst, err := e.Adopt(container.ManagedContainer{ID: "old", Ports: tt.ports}) + if tt.wantErr { + require.ErrorIs(t, err, k3sengine.ErrNotAdoptable) + + return + } + + require.NoError(t, err) + assert.Equal(t, "old", inst.ContainerID) + assert.Equal(t, tt.wantPort, inst.Port) + assert.Contains(t, ports.Reserved(), tt.wantPort, "the adopted port must not be handed out again") + }) + } + + e, _ := newEngine(t, &runtime{}, nil) + _, err := e.Adopt(container.ManagedContainer{ID: "a", Ports: map[int]int{k3sengine.APIPort: 42020}}) + require.NoError(t, err) + + _, err = e.Adopt(container.ManagedContainer{ID: "b", Ports: map[int]int{k3sengine.APIPort: 42020}}) + require.ErrorIs(t, err, k3sengine.ErrNotAdoptable, "a port already in use cannot be adopted twice") +} + +func TestServingCAAndExec(t *testing.T) { + t.Parallel() + + t.Run("reads the CA once the node wrote it", func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + calls := 0 + sr := &stateRuntime{stoppableRuntime: &stoppableRuntime{runtime: &runtime{}}} + sr.execFn = func([]string) (container.ExecResult, error) { + calls++ + if calls < 3 { + return container.ExecResult{ExitCode: 1}, nil + } + + return container.ExecResult{Stdout: "ca-pem"}, nil + } + + e, _ := newEngine(t, nil, func(c *k3sengine.Config) { c.Runtime = sr }) + ca, err := e.ServingCA(t.Context(), "c1") + require.NoError(t, err) + assert.Equal(t, "ca-pem", string(ca)) + assert.Equal(t, 3, calls) + assert.Equal(t, []string{"cat", k3sengine.CACertFile}, sr.execCmds[0]) + }) + }) + + t.Run("runtime without exec", func(t *testing.T) { + t.Parallel() + + e, _ := newEngine(t, &runtime{}, nil) + + _, err := e.ServingCA(t.Context(), "c1") + require.ErrorIs(t, err, container.ErrExecUnsupported) + + _, err = e.Exec(t.Context(), "c1", []string{"ls"}, nil) + require.ErrorIs(t, err, container.ErrExecUnsupported) + }) +} + +func TestRotateCertificates(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + name string + wantCalls []string + exit int + }{ + {name: "rotated and restarted", wantCalls: []string{"stop:c1", "start:c1"}}, + {name: "rotate refused leaves the node running", exit: 1, wantErr: k3sengine.ErrRotateFailed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + sr := &stateRuntime{stoppableRuntime: &stoppableRuntime{runtime: &runtime{}}} + sr.execFn = func([]string) (container.ExecResult, error) { + return container.ExecResult{ExitCode: tt.exit, Stderr: "no"}, nil + } + + e, _ := newEngine(t, nil, func(c *k3sengine.Config) { + c.Runtime = sr + c.StartTimeout = time.Minute + c.Probe = func(context.Context, string, string) ([]byte, error) { return []byte("served-ca"), nil } + }) + + ca, err := e.RotateCertificates(t.Context(), "c1", "127.0.0.1:1") + require.Len(t, sr.execCmds[0], 3) + assert.Equal(t, "/bin/sh", sr.execCmds[0][0]) + assert.Contains(t, sr.execCmds[0][2], "/bin/k3s certificate rotate-ca --path=") + assert.NotContains(t, sr.execCmds[0][2], "PRIVATE KEY", "key material must stay out of argv") + assert.Contains(t, sr.execCmds[0][2], "touch /var/lib/rancher/k3s/server/tls/.gopherstack-rotated", + "the next start must drop the cached serving certificate") + require.Len(t, sr.execEnvs, 1) + assert.Len(t, sr.execEnvs[0], 10, "a cert and key for each of the five CAs travel in the environment") + assert.Equal(t, tt.wantCalls, sr.calls) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, "served-ca", string(ca)) + }) + } +} diff --git a/services/eks/cluster_probe.go b/pkgs/k3sengine/probe.go similarity index 60% rename from services/eks/cluster_probe.go rename to pkgs/k3sengine/probe.go index 7053348847..8dde0ec13b 100644 --- a/services/eks/cluster_probe.go +++ b/pkgs/k3sengine/probe.go @@ -1,4 +1,4 @@ -package eks +package k3sengine import ( "context" @@ -12,21 +12,23 @@ import ( const maxProbeBody = 1 << 20 -var errNotReady = errors.New("readyz not ok") +// ErrNotReady is returned while the API server has not answered /readyz with 200. +var ErrNotReady = errors.New("readyz not ok") -// probeK3s requires /readyz to answer 200 for the bearer token over TLS verified against the pre-provisioned CA. -func probeK3s(ctx context.Context, addr, token string, caPEM []byte) error { +// Probe requires /readyz to answer 200 for the bearer token over TLS verified against the pre-provisioned CA. +func Probe(ctx context.Context, addr, token string, caPEM []byte) error { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caPEM) { - return fmt.Errorf("%w: cluster CA is not a certificate", errNotReady) + return fmt.Errorf("%w: cluster CA is not a certificate", ErrNotReady) } - _, err := httpGet(ctx, addr, "/readyz", token, &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}) + _, err := HTTPGet(ctx, addr, "/readyz", token, &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}) return err } -func httpGet(ctx context.Context, addr, path, token string, tlsCfg *tls.Config) ([]byte, error) { +// HTTPGet GETs https://addr/path with an optional bearer token and requires a 200. +func HTTPGet(ctx context.Context, addr, path, token string, tlsCfg *tls.Config) ([]byte, error) { tr := &http.Transport{TLSClientConfig: tlsCfg, DisableKeepAlives: true} defer tr.CloseIdleConnections() @@ -52,7 +54,7 @@ func httpGet(ctx context.Context, addr, path, token string, tlsCfg *tls.Config) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%w: %s returned %d", errNotReady, path, resp.StatusCode) + return nil, fmt.Errorf("%w: %s returned %d", ErrNotReady, path, resp.StatusCode) } return body, nil diff --git a/pkgs/k3sengine/stop_start_test.go b/pkgs/k3sengine/stop_start_test.go new file mode 100644 index 0000000000..ad9e8ff2f0 --- /dev/null +++ b/pkgs/k3sengine/stop_start_test.go @@ -0,0 +1,136 @@ +package k3sengine_test + +import ( + "context" + "io" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" +) + +type stoppableRuntime struct { + *runtime + startErr error + calls []string + mu sync.Mutex +} + +func (r *stoppableRuntime) StopContainer(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.calls = append(r.calls, "stop:"+id) + + return nil +} + +func (r *stoppableRuntime) StartContainer(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.calls = append(r.calls, "start:"+id) + + return r.startErr +} + +func TestEngineStopStart(t *testing.T) { + t.Parallel() + + tests := []struct { + startErr error + wantErr error + name string + wantCalls []string + stoppable bool + }{ + {name: "kept and restarted", stoppable: true, wantCalls: []string{"stop:c1", "start:c1"}}, + {name: "start failure", stoppable: true, startErr: errBoom, wantErr: errBoom, + wantCalls: []string{"stop:c1", "start:c1"}}, + {name: "runtime cannot keep containers", wantErr: k3sengine.ErrStopUnsupported}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + base := &runtime{} + sr := &stoppableRuntime{runtime: base, startErr: tt.startErr} + + cfg := func(c *k3sengine.Config) { + c.StartTimeout = time.Minute + c.Probe = func(context.Context, string, string) ([]byte, error) { return []byte("served-ca"), nil } + } + + var e *k3sengine.Engine + if tt.stoppable { + e, _ = newEngine(t, nil, func(c *k3sengine.Config) { cfg(c); c.Runtime = sr }) + } else { + e, _ = newEngine(t, base, cfg) + } + + err := e.Stop(t.Context(), "c1") + if !tt.stoppable { + require.ErrorIs(t, err, tt.wantErr) + + _, err = e.Start(t.Context(), "c1", "127.0.0.1:1", []byte("ca")) + require.ErrorIs(t, err, tt.wantErr) + assert.Empty(t, base.removed) + + return + } + + require.NoError(t, err) + + ca, err := e.Start(t.Context(), "c1", "127.0.0.1:1", []byte("ca")) + assert.Equal(t, tt.wantCalls, sr.calls) + assert.Empty(t, base.removed, "stop must not remove the container") + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, "served-ca", string(ca)) + }) + } +} + +type exitedRuntime struct { + *stoppableRuntime +} + +func (r *exitedRuntime) WaitExit(context.Context, string) (int, error) { return 7, nil } + +func (r *exitedRuntime) ReadLogs(_ context.Context, _, tail string, _ bool, stdout, _ io.Writer) error { + _, err := io.WriteString(stdout, "boom from k3s tail="+tail) + + return err +} + +func TestEngineStartFailsFastWhenNodeExits(t *testing.T) { + t.Parallel() + + rt := &exitedRuntime{stoppableRuntime: &stoppableRuntime{runtime: &runtime{}}} + + e, _ := newEngine(t, nil, func(c *k3sengine.Config) { + c.Runtime = rt + c.StartTimeout = time.Hour + c.Probe = func(ctx context.Context, _, _ string) ([]byte, error) { + <-ctx.Done() + + return nil, ctx.Err() + } + }) + + _, err := e.Start(t.Context(), "c1", "127.0.0.1:1", []byte("ca")) + require.ErrorIs(t, err, k3sengine.ErrNodeExited) + assert.Contains(t, err.Error(), "code 7") + assert.Contains(t, err.Error(), "boom from k3s") +} diff --git a/pkgs/mysqlengine/adopt.go b/pkgs/mysqlengine/adopt.go new file mode 100644 index 0000000000..ee03739dd7 --- /dev/null +++ b/pkgs/mysqlengine/adopt.go @@ -0,0 +1,113 @@ +package mysqlengine + +import ( + "context" + "maps" + "os" + "strconv" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const adoptReadyWait = 30 * time.Second + +func (e *Engine) dumpDirLabel() string { return "gopherstack." + e.cfg.Service + ".dumpdir" } +func (e *Engine) shapeLabel() string { return "gopherstack." + e.cfg.Service + ".shape" } + +func (s *Server) shape() string { + return s.spec.Image + "|" + strconv.FormatInt(s.spec.MemoryBytes, 10) + "|" + strconv.FormatInt(s.spec.NanoCPUs, 10) +} + +func (s *Server) ownerLabels() map[string]string { + l := map[string]string{} + + if s.spec.Resource != "" { + l = container.ResourceLabels(s.e.cfg.Service, s.spec.Resource) + l[s.e.dumpDirLabel()] = s.dir + l[s.e.shapeLabel()] = s.shape() + } + + maps.Copy(l, s.spec.Labels) + + return l +} + +func (s *Server) adopt(ctx context.Context) bool { + return s.adoptFrom(ctx, container.ClaimOrphan) +} + +// adoptFrom takes over the running container a crashed gopherstack left for this server; false means launch fresh. +func (s *Server) adoptFrom( + ctx context.Context, + claim func(service, resource string) (container.ManagedContainer, bool), +) bool { + if !s.spec.Adopt || s.spec.Resource == "" || s.spec.Fresh { + return false + } + + oc, ok := claim(s.e.cfg.Service, s.spec.Resource) + if !ok { + return false + } + + dir, port := oc.Labels[s.e.dumpDirLabel()], oc.Ports[mysqlPort] + if !s.adoptable(oc, dir, port) { + s.discard(ctx, oc.ID) + + return false + } + + if pool := s.e.cfg.Ports; pool != nil && pool.Reserve(port, s.e.cfg.Service) != nil { + s.discard(ctx, oc.ID) + + return false + } + + old := s.dir + + s.mu.Lock() + s.dir, s.port, s.ctrNm, s.ctrID = dir, port, oc.Name, oc.ID + s.mu.Unlock() + + wctx, cancel := context.WithTimeout(ctx, adoptReadyWait) + defer cancel() + + if err := s.waitReady(wctx); err != nil { + s.e.cfg.Logger.WarnContext(ctx, "mysqlengine: orphaned container unusable, launching fresh", + "server", s.spec.ID, "container", oc.ID, "error", err) + s.discard(ctx, oc.ID) + + s.mu.Lock() + s.dir, s.ctrID, s.port = old, "", 0 + s.mu.Unlock() + + portalloc.ReleaseTo(s.e.cfg.Ports, port) + + return false + } + + _ = os.RemoveAll(old) + + s.e.cfg.Logger.InfoContext(ctx, "mysqlengine: adopted container from an earlier run", + "server", s.spec.ID, "container", oc.ID) + + return true +} + +func (s *Server) adoptable(oc container.ManagedContainer, dir string, port int) bool { + if oc.Labels[s.e.shapeLabel()] != s.shape() || port == 0 || dir == "" { + return false + } + + st, err := os.Stat(dir) + + return err == nil && st.IsDir() +} + +func (s *Server) discard(ctx context.Context, id string) { + if err := s.e.cfg.Runtime.StopAndRemove(ctx, id); err != nil { + s.e.cfg.Logger.WarnContext(ctx, "mysqlengine: orphan removal failed", "container", id, "error", err) + } +} diff --git a/pkgs/mysqlengine/binlog.go b/pkgs/mysqlengine/binlog.go new file mode 100644 index 0000000000..fa8905cf42 --- /dev/null +++ b/pkgs/mysqlengine/binlog.go @@ -0,0 +1,287 @@ +package mysqlengine + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + pitrReplayWait = 6 * time.Minute + pitrPoll = 250 * time.Millisecond + pitrDir = dataDir + "/gopherstack-pitr" + baseFile = pitrDir + "/base.sql" + baseTimeFile = pitrDir + "/base-time" +) + +var ( + // ErrNoPITR is returned when point-in-time recovery has no persistent volume to keep its base in. + ErrNoPITR = errors.New("mysqlengine: point-in-time recovery needs a persistent volume") + // ErrPITRNotOn is returned when recovery is prepared on a server that never enabled it. + ErrPITRNotOn = errors.New("mysqlengine: point-in-time recovery is not enabled on the source engine") + + binlogCoordsRe = regexp.MustCompile(`SOURCE_LOG_FILE='([^']+)', SOURCE_LOG_POS=(\d+)`) +) + +// Recovery describes a binary log replay: a clone loads Script, then follows Source from FromFile/FromPos up to +// UntilFile/UntilPos. +type Recovery struct { + At time.Time + Source *ReplSource + FromFile string + UntilFile string + Script []byte + FromPos uint64 + UntilPos uint64 +} + +func (s *Server) storedBase(ctx context.Context) (time.Time, bool) { + out, err := s.ReadFile(ctx, baseTimeFile) + if err != nil { + return time.Time{}, false + } + + t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(out)) + + return t, err == nil +} + +// EnablePITR takes a base dump of dbs (or just the log coordinates) and returns the earliest recoverable time. +func (s *Server) EnablePITR(ctx context.Context, dbs []string) (time.Time, error) { + if s.volume == "" { + return time.Time{}, ErrNoPITR + } + + if t, ok := s.storedBase(ctx); ok { + return t, nil + } + + if err := s.writeBase(ctx, dbs); err != nil { + return time.Time{}, err + } + + now := time.Now().UTC() + + return now, s.WriteFile(ctx, baseTimeFile, now.Format(time.RFC3339Nano)) +} + +func (s *Server) writeBase(ctx context.Context, dbs []string) error { + if _, err := s.Run(ctx, []string{"mkdir", "-p", pitrDir}); err != nil { + return err + } + + if len(dbs) == 0 { + out, err := s.Statement(ctx, "FLUSH BINARY LOGS; SHOW BINARY LOGS") + if err != nil { + return err + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + file := strings.Fields(lines[len(lines)-1])[0] + + return s.WriteFile(ctx, baseFile, "-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='"+file+ + "', SOURCE_LOG_POS=4;\n") + } + + cmd := append([]string{ + mysqldumpBin, "-u" + RootUser, argSingleTx, argRoutines, + "--events", "--triggers", "--skip-dump-date", argAddDrop, "--source-data=2", "--flush-logs", + "--set-gtid-purged=OFF", "-r", baseFile, argDatabases, + }, dbs...) + + _, err := s.RunAuth(ctx, cmd) + + return err +} + +// DisablePITR discards the base so no recovery can target this server. +func (s *Server) DisablePITR(ctx context.Context) error { + if s.volume == "" { + return nil + } + + _, err := s.Run(ctx, []string{"rm", "-rf", pitrDir}) + + return err +} + +// PrepareRecovery locates the binary log range that reaches at, and readies the server as a replication source. +func (s *Server) PrepareRecovery(ctx context.Context, at time.Time) (Recovery, error) { + if s.volume == "" { + return Recovery{}, ErrNoPITR + } + + if _, ok := s.storedBase(ctx); !ok { + return Recovery{}, ErrPITRNotOn + } + + rec, err := s.binlogPlan(ctx, at) + if err != nil { + return Recovery{}, err + } + + src, err := s.ReplicationSource(ctx) + if err != nil { + return Recovery{}, err + } + + rec.Source = &src + + return rec, nil +} + +// binlogPlan locates the binary log range a clone replays: from the base dump's coordinates up to the first +// transaction at or after at (second resolution). +func (s *Server) binlogPlan(ctx context.Context, at time.Time) (Recovery, error) { + base, err := s.ReadFile(ctx, baseFile) + if err != nil { + return Recovery{}, err + } + + m := binlogCoordsRe.FindStringSubmatch(base) + if m == nil { + return Recovery{}, fmt.Errorf("%w: base dump has no binary log coordinates", errExec) + } + + from, _ := strconv.ParseUint(m[2], 10, 64) + + if _, err = s.Statement(ctx, "FLUSH BINARY LOGS"); err != nil { + return Recovery{}, err + } + + out, err := s.Statement(ctx, "SHOW BINARY LOGS") + if err != nil { + return Recovery{}, err + } + + rec := Recovery{At: at, Script: []byte(base), FromFile: m[1], FromPos: from} + stop := uint32(at.UTC().Truncate(time.Second).Add(time.Second).Unix()) //nolint:gosec // bounded unix seconds + + for ln := range strings.SplitSeq(strings.TrimSpace(out), "\n") { + f := strings.Fields(ln) + if len(f) == 0 || f[0] < m[1] { + continue + } + + data, rerr := s.CopyOut(ctx, dataDir+"/"+f[0]) + if rerr != nil { + return Recovery{}, rerr + } + + lo := uint64(binlogHeaderLen) + if f[0] == m[1] { + lo = from + } + + pos, found := binlogCutoff(data, lo, stop) + rec.UntilFile, rec.UntilPos = f[0], pos + + if found { + break + } + } + + return rec, nil +} + +const ( + binlogHeaderLen = 4 + eventHeaderLen = 19 + eventGTID = 33 + eventAnonGTID = 34 + eventSizeOffset = 9 + eventTypeOffset = 4 +) + +// binlogCutoff walks a binary log from lo and returns the start of the first transaction stamped at or after stop; +// without one it returns the end of the data. +func binlogCutoff(data []byte, lo uint64, stop uint32) (uint64, bool) { + pos := lo + + for pos+eventHeaderLen <= uint64(len(data)) { + hdr := data[pos : pos+eventHeaderLen] + ts := binary.LittleEndian.Uint32(hdr[0:4]) + size := uint64(binary.LittleEndian.Uint32(hdr[eventSizeOffset : eventSizeOffset+4])) + + if t := hdr[eventTypeOffset]; (t == eventGTID || t == eventAnonGTID) && ts >= stop { + return pos, true + } + + if size < eventHeaderLen { + break + } + + pos += size + } + + return uint64(len(data)), false +} + +// ApplyRecovery replays rec on a freshly launched server and detaches it from the source. +func (s *Server) ApplyRecovery(ctx context.Context, rec Recovery) error { + if rec.Source == nil { + return nil + } + + if len(rec.Script) > 0 { + if err := s.RestoreInto(ctx, "", rec.Script); err != nil { + return err + } + } + + stmt := fmt.Sprintf("CHANGE REPLICATION SOURCE TO SOURCE_HOST='%s', SOURCE_PORT=%d, SOURCE_USER='%s', "+ + "SOURCE_PASSWORD='%s', SOURCE_AUTO_POSITION=0, SOURCE_LOG_FILE='%s', SOURCE_LOG_POS=%d, "+ + "GET_SOURCE_PUBLIC_KEY=1; START REPLICA UNTIL SOURCE_LOG_FILE='%s', SOURCE_LOG_POS=%d", + rec.Source.Host, rec.Source.Port, s.e.cfg.ReplUser, rec.Source.Password, rec.FromFile, rec.FromPos, + rec.UntilFile, rec.UntilPos) + + if _, err := s.Statement(ctx, stmt); err != nil { + return err + } + + if err := s.waitReplayed(ctx); err != nil { + return err + } + + _, err := s.Statement(ctx, "STOP REPLICA; RESET REPLICA ALL") + + return err +} + +// waitReplayed blocks until the SQL thread has reached the UNTIL position and stopped. +func (s *Server) waitReplayed(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, pitrReplayWait) + defer cancel() + + tick := time.NewTicker(pitrPoll) + defer tick.Stop() + + for { + rows, err := s.Query(ctx, "", "SHOW REPLICA STATUS") + if err == nil && len(rows) == 2 { + row := map[string]string{} + for i, c := range rows[0] { + row[strings.ToLower(c)] = rows[1][i] + } + + if msg := row["last_sql_error"]; msg != "" { + return fmt.Errorf("%w: binary log replay failed: %s", errExec, msg) + } + + if row["replica_sql_running"] == "No" { + return nil + } + } + + select { + case <-ctx.Done(): + return fmt.Errorf("%w: binary log replay did not finish", errExec) + case <-tick.C: + } + } +} diff --git a/pkgs/mysqlengine/binlog_internal_test.go b/pkgs/mysqlengine/binlog_internal_test.go new file mode 100644 index 0000000000..a9754b4ad6 --- /dev/null +++ b/pkgs/mysqlengine/binlog_internal_test.go @@ -0,0 +1,64 @@ +package mysqlengine + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMySQLServerFlags(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"--server-id=7", "--gtid-mode=ON", "--enforce-gtid-consistency=ON"}, replicationFlags(7)) + assert.Contains(t, replicationFlags(0)[0], "--server-id=1", "a zero id is replaced") +} + +func binlogEvent(ts uint32, typ byte, size uint32) []byte { + ev := make([]byte, size) + binary.LittleEndian.PutUint32(ev[0:4], ts) + ev[4] = typ + binary.LittleEndian.PutUint32(ev[9:13], size) + + return ev +} + +func TestBinlogCutoff(t *testing.T) { + t.Parallel() + + log := make([]byte, 0, 256) + log = append(log, 0xfe, 'b', 'i', 'n') + log = append(log, binlogEvent(100, 15, 40)...) + + first := uint64(len(log)) + log = append(log, binlogEvent(100, eventGTID, 30)...) + log = append(log, binlogEvent(100, 2, 50)...) + + second := uint64(len(log)) + log = append(log, binlogEvent(105, eventGTID, 30)...) + log = append(log, binlogEvent(105, 2, 50)...) + + tests := []struct { + name string + lo uint64 + want uint64 + stop uint32 + wantFound bool + }{ + {name: "stops before the later transaction", lo: 4, stop: 102, want: second, wantFound: true}, + {name: "stop before everything", lo: 4, stop: 50, want: first, wantFound: true}, + {name: "stop after everything", lo: 4, stop: 500, want: uint64(len(log))}, + {name: "start past the early one", lo: first, stop: 106, want: uint64(len(log))}, + {name: "start at a boundary", lo: second, stop: 105, want: second, wantFound: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, found := binlogCutoff(log, tt.lo, tt.stop) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantFound, found) + }) + } +} diff --git a/pkgs/mysqlengine/exec.go b/pkgs/mysqlengine/exec.go new file mode 100644 index 0000000000..523bce6398 --- /dev/null +++ b/pkgs/mysqlengine/exec.go @@ -0,0 +1,105 @@ +package mysqlengine + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" +) + +const ( + mysqldumpBin = "mysqldump" + catBin = "cat" +) + +// DB opens a root connection to db ("" for none); the caller closes it. +func (s *Server) DB(db string) (*sql.DB, error) { return s.open(db) } + +// Persistent reports whether the data lives in a volume that survives the container. +func (s *Server) Persistent() bool { return s.volume != "" } + +// ContainerName is the name replicas use to reach this server on a shared network. +func (s *Server) ContainerName() string { + s.mu.Lock() + defer s.mu.Unlock() + + return s.ctrNm +} + +// Run executes cmd in the container and returns its stdout. +func (s *Server) Run(ctx context.Context, cmd []string) (string, error) { + return s.runOut(ctx, cmdLabel(cmd), cmd, nil) +} + +// RunAuth is Run with the root password in the process environment, for the mysql client tools. +func (s *Server) RunAuth(ctx context.Context, cmd []string) (string, error) { + return s.runOut(ctx, cmdLabel(cmd), cmd, s.pwdEnv()) +} + +// Statement runs SQL through the mysql client and returns its tab-separated output. +func (s *Server) Statement(ctx context.Context, stmt string) (string, error) { + return s.RunAuth(ctx, []string{mysqlBin, "-u" + RootUser, "-N", "-B", "-e", stmt}) +} + +// cmdLabel maps argv to a fixed label so credentials in argv never reach errors or logs. +func cmdLabel(cmd []string) string { + for _, a := range cmd { + if a == mysqldumpBin || a == mysqlBin { + return a + } + } + + return "command" +} + +// ReadFile returns a file inside the container. +func (s *Server) ReadFile(ctx context.Context, path string) (string, error) { + return s.Run(ctx, []string{catBin, path}) +} + +// WriteFile replaces a file inside the container. +func (s *Server) WriteFile(ctx context.Context, path, content string) error { + _, err := s.Run(ctx, []string{"sh", "-c", `printf %s "$1" > "$0"`, path, content}) + + return err +} + +// CopyOut returns a file inside the container through the dump mount. +func (s *Server) CopyOut(ctx context.Context, path string) ([]byte, error) { + if _, err := s.Run(ctx, []string{ + "sh", "-c", `cp "$0" "$1" && chmod 644 "$1"`, path, dumpMount + "/copy.out", + }); err != nil { + return nil, err + } + + return s.readDump("copy.out") +} + +func (s *Server) readDump(name string) ([]byte, error) { + path := filepath.Join(s.dir, name) + defer os.Remove(path) + + st, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("dump file: %w", err) + } + + if st.Size() > maxDumpBytes { + return nil, ErrTooLarge + } + + return os.ReadFile(path) +} + +// RemoveVolume deletes this server's data volume; the server must be closed first. +func (s *Server) RemoveVolume(ctx context.Context) error { + if s.volume == "" { + return nil + } + + return s.e.cfg.Volumes.RemoveVolume(ctx, s.volume) //nolint:wrapcheck // runtime errors carry the volume +} + +// Volume is the data volume name, empty without persistence. +func (s *Server) Volume() string { return s.volume } diff --git a/pkgs/mysqlengine/mysqlengine.go b/pkgs/mysqlengine/mysqlengine.go new file mode 100644 index 0000000000..10762bd5d2 --- /dev/null +++ b/pkgs/mysqlengine/mysqlengine.go @@ -0,0 +1,717 @@ +// Package mysqlengine runs real MySQL servers in containers for emulators of managed MySQL services: a named data +// volume, a published loopback port, SQL access as root, logical dump and restore, and stop/start that keep data. +package mysqlengine + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "maps" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-sql-driver/mysql" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const ( + // RootUser is the container's superuser; emulators keep its password to themselves. + RootUser = "root" + + dataDir = "/var/lib/mysql" + dumpMount = "/gopherstack-dump" + servicePort = "3306" + mysqlPort = 3306 + readyInterval = 500 * time.Millisecond + connectTimeout = 5 * time.Second + teardownLimit = 30 * time.Second + stderrLimit = 512 + maxDumpBytes = 256 << 20 + fileMode = 0o600 + volumeLabel = "gopherstack.mysqlengine" + mysqlBin = "mysql" + defaultStart = 3 * time.Minute + + argRoutines = "--routines" + argAddDrop = "--add-drop-database" + argDatabases = "--databases" + argSingleTx = "--single-transaction" +) + +var ( + // ErrRuntimeUnavailable is returned when no container runtime can exec in and stop containers. + ErrRuntimeUnavailable = errors.New("mysqlengine: container runtime unavailable") + errExec = errors.New("mysqlengine: command failed") + // ErrTooLarge is returned when a dump or query result exceeds its size limit. + ErrTooLarge = errors.New("mysqlengine: result is too large") +) + +// Runtime is the slice of the container runtime the engine uses. +type Runtime interface { + CreateAndStart(ctx context.Context, spec container.Spec) (string, error) + StopAndRemove(ctx context.Context, containerID string) error + StopContainer(ctx context.Context, containerID string) error + StartContainer(ctx context.Context, containerID string) error + Exec(ctx context.Context, containerID string, cmd []string) (container.ExecResult, error) +} + +// Config configures an Engine. Prefix names containers and volumes ("-..."); Service owns ports and +// orphan labels (default Prefix); ReplUser is the replication account created on a source. +type Config struct { + Runtime Runtime + Volumes container.VolumeRuntime + Ports *portalloc.Allocator + Logger *slog.Logger + Prefix string + Service string + ReplUser string + StartTimeout time.Duration +} + +// Engine launches MySQL servers. +type Engine struct{ cfg Config } + +// New validates cfg and returns an Engine. +func New(cfg Config) (*Engine, error) { + if cfg.Runtime == nil { + return nil, ErrRuntimeUnavailable + } + + if cfg.Prefix == "" { + cfg.Prefix = mysqlBin + } + + if cfg.Service == "" { + cfg.Service = cfg.Prefix + } + + if cfg.ReplUser == "" { + cfg.ReplUser = "replica" + } + + if cfg.StartTimeout <= 0 { + cfg.StartTimeout = defaultStart + } + + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + + return &Engine{cfg: cfg}, nil +} + +// Spec describes one server. +type Spec struct { + VolumeLabels map[string]string + // Labels are added to the container. Resource, when set, labels the container as owned by that key so a + // restarted gopherstack can find it; Adopt then lets Launch take over such an orphan of an earlier run. + Labels map[string]string + // ID names the container and, unless Volume is set, the data volume. + ID string + // Image is the container image, e.g. mysql:8.4. + Image string + // RootPassword is applied on first initialisation and must match an existing volume. + RootPassword string + // Volume overrides the data volume name; VolumeLabels are added to the volume's labels. + Volume string + Resource string + // Network and NetworkID place the container on an existing replication network. + Network string + NetworkID string + // MemoryBytes and NanoCPUs limit the container. + MemoryBytes int64 + NanoCPUs int64 + // ServerID, when non-zero, starts the server replication-ready (GTID mode). + ServerID uint32 + // Persist keeps data in a named volume; Fresh discards an existing one first. + Persist bool + Fresh bool + Adopt bool +} + +// VolumeName is the data volume of server id. +func (e *Engine) VolumeName(id string) string { return e.cfg.Prefix + "-data-" + sanitize(id) } + +// Launch starts a server and waits until it accepts root connections. +func (e *Engine) Launch(ctx context.Context, spec Spec) (*Server, error) { + dir, err := os.MkdirTemp("", e.cfg.Prefix+"-") + if err != nil { + return nil, fmt.Errorf("dump dir: %w", err) + } + + s := &Server{e: e, spec: spec, dir: dir} + + if vr := e.cfg.Volumes; vr != nil && spec.Persist { + s.volume = e.VolumeName(spec.ID) + if spec.Volume != "" { + s.volume = spec.Volume + } + } + + s.netID = spec.NetworkID + + lctx, cancel := context.WithTimeout(ctx, e.cfg.StartTimeout) + defer cancel() + + if err = s.boot(lctx); err != nil { + s.Close(context.WithoutCancel(ctx), false) + + return nil, err + } + + return s, nil +} + +// SweepVolumes deletes unused volumes of this engine that keep does not list. +func (e *Engine) SweepVolumes(ctx context.Context, keep map[string]bool, olderThan time.Duration) { + vr := e.cfg.Volumes + if vr == nil { + return + } + + names, err := vr.ListUnusedVolumes(ctx, volumeLabel+"."+e.cfg.Prefix, olderThan) + if err != nil { + return + } + + for _, n := range names { + if !keep[n] && strings.HasPrefix(n, e.cfg.Prefix+"-data-") { + _ = vr.RemoveVolume(ctx, n) + } + } +} + +// RemoveVolume deletes the data volume of server id. +func (e *Engine) RemoveVolume(ctx context.Context, id string) { + if e.cfg.Volumes != nil { + _ = e.cfg.Volumes.RemoveVolume(ctx, e.VolumeName(id)) + } +} + +// Server is one running MySQL container. +type Server struct { + e *Engine + net *sourceNet + dir string + volume string + ctrID string + ctrNm string + netID string + + replPass string + spec Spec + port int + mu sync.Mutex + + promoted bool + attached bool +} + +func (s *Server) boot(ctx context.Context) error { + if s.volume != "" { + vr := s.e.cfg.Volumes + + if s.spec.Fresh { + if err := vr.RemoveVolume(ctx, s.volume); err != nil { + return fmt.Errorf("discard stale volume %s: %w", s.volume, err) + } + } + + labels := map[string]string{volumeLabel: "true", volumeLabel + "." + s.e.cfg.Prefix: s.spec.ID} + maps.Copy(labels, s.spec.VolumeLabels) + + if err := vr.CreateVolume(ctx, s.volume, labels); err != nil { + return fmt.Errorf("create volume %s: %w", s.volume, err) + } + } + + if !s.adopt(ctx) { + id, err := container.CreateAndStartRetry(ctx, s.e.cfg.Runtime, s.prepare, s.releasePort) + if err != nil { + return fmt.Errorf("start %s: %w", s.spec.Image, err) + } + + s.mu.Lock() + s.ctrID = id + s.mu.Unlock() + } + + return s.waitReady(ctx) +} + +func (s *Server) prepare() (container.Spec, error) { + port, err := portalloc.AcquireFrom(s.e.cfg.Ports, s.e.cfg.Service) + if err != nil { + return container.Spec{}, fmt.Errorf("host port: %w", err) + } + + name := s.e.cfg.Prefix + "-" + sanitize(s.spec.ID) + "-" + strconv.Itoa(port) + + s.mu.Lock() + s.port, s.ctrNm = port, name + s.mu.Unlock() + + spec := container.Spec{ + Name: name, + Image: s.spec.Image, + Labels: s.ownerLabels(), + Env: []string{"MYSQL_ROOT_PASSWORD=" + s.pass()}, + Mounts: []string{s.dir + ":" + dumpMount}, + Ports: []string{container.PortSpec(container.LoopbackHost, strconv.Itoa(port), servicePort)}, + Network: s.spec.Network, + MemoryBytes: s.spec.MemoryBytes, + NanoCPUs: s.spec.NanoCPUs, + } + + if s.spec.ServerID > 0 { + spec.Cmd = replicationFlags(s.spec.ServerID) + } + + if s.volume != "" { + spec.Mounts = append(spec.Mounts, s.volume+":"+dataDir) + } + + return spec, nil +} + +func (s *Server) releasePort() { + s.mu.Lock() + port := s.port + s.port = 0 + s.mu.Unlock() + + portalloc.ReleaseTo(s.e.cfg.Ports, port) +} + +func (s *Server) container() string { + s.mu.Lock() + defer s.mu.Unlock() + + return s.ctrID +} + +// Endpoint is the host and published port clients connect to. +func (s *Server) Endpoint() (string, int) { + s.mu.Lock() + defer s.mu.Unlock() + + return container.LoopbackHost, s.port +} + +func (s *Server) open(db string) (*sql.DB, error) { + h, p := s.Endpoint() + + cfg := mysql.NewConfig() + cfg.User, cfg.Passwd, cfg.Net = RootUser, s.pass(), "tcp" + cfg.Addr, cfg.DBName = net.JoinHostPort(h, strconv.Itoa(p)), db + cfg.InterpolateParams = true + cfg.Timeout = connectTimeout + + conn, err := mysql.NewConnector(cfg) + if err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + + return sql.OpenDB(conn), nil +} + +func (s *Server) waitReady(ctx context.Context) error { + tick := time.NewTicker(readyInterval) + defer tick.Stop() + + for { + if s.ping(ctx) == nil { + return nil + } + + select { + case <-ctx.Done(): + return fmt.Errorf("mysql did not accept connections: %w", ctx.Err()) + case <-tick.C: + } + } +} + +func (s *Server) ping(ctx context.Context) error { + db, err := s.open("") + if err != nil { + return err + } + defer db.Close() + + pctx, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + + return db.PingContext(pctx) +} + +// exec runs one statement as root. Placeholders are interpolated client-side, so account DDL accepts them. +func (s *Server) exec(ctx context.Context, stmt string, args ...any) error { + db, err := s.open("") + if err != nil { + return err + } + defer db.Close() + + if _, err = db.ExecContext(ctx, stmt, args...); err != nil { + return fmt.Errorf("sql: %w", err) + } + + return nil +} + +// Query runs a query as root and returns the rows as strings; the first row holds the column names. +func (s *Server) Query(ctx context.Context, db, query string) ([][]string, error) { + return s.QueryMax(ctx, db, query, 0) +} + +// QueryMax is Query that fails with ErrTooLarge once the result exceeds maxCells values (zero is unlimited). +func (s *Server) QueryMax(ctx context.Context, db, query string, maxCells int) ([][]string, error) { + conn, err := s.open(db) + if err != nil { + return nil, err + } + defer conn.Close() + + rows, err := conn.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("sql: %w", err) + } + defer rows.Close() + + cols, err := rows.Columns() + if err != nil { + return nil, fmt.Errorf("sql: %w", err) + } + + out := [][]string{cols} + + for rows.Next() { + vals := make([]sql.NullString, len(cols)) + ptrs := make([]any, len(cols)) + + for i := range vals { + ptrs[i] = &vals[i] + } + + if err = rows.Scan(ptrs...); err != nil { + return nil, fmt.Errorf("sql: %w", err) + } + + row := make([]string, len(cols)) + for i, v := range vals { + row[i] = v.String + } + + if out = append(out, row); maxCells > 0 && len(out)*len(cols) > maxCells { + return nil, ErrTooLarge + } + } + + return out, rows.Err() //nolint:wrapcheck // driver error +} + +// EnsureAdmin creates or updates a full-privilege account reachable from any host. +func (s *Server) EnsureAdmin(ctx context.Context, user, password string) error { + for _, stmt := range []string{ + "CREATE USER IF NOT EXISTS ?@'%' IDENTIFIED BY ?", + "ALTER USER ?@'%' IDENTIFIED BY ?", + } { + if err := s.exec(ctx, stmt, user, password); err != nil { + return err + } + } + + return s.exec(ctx, "GRANT ALL PRIVILEGES ON *.* TO ?@'%' WITH GRANT OPTION", user) +} + +const ( + identMaxLen = 64 + charsetChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" + dbNameChars = charsetChars + "$-" +) + +// ErrInvalidIdentifier reports a database name, charset or collation outside the allowed character set. +var ErrInvalidIdentifier = errors.New("mysqlengine: invalid identifier") + +// rebuildIdent copies s byte by byte from the constant alphabet, so the result shares no data with the input. +func rebuildIdent(s, alphabet string) (string, error) { + if s == "" || len(s) > identMaxLen { + return "", ErrInvalidIdentifier + } + + var b strings.Builder + + b.Grow(len(s)) + + for i := range len(s) { + idx := strings.IndexByte(alphabet, s[i]) + if idx < 0 { + return "", ErrInvalidIdentifier + } + + b.WriteByte(alphabet[idx]) + } + + return b.String(), nil +} + +// CreateDatabaseSQL builds CREATE DATABASE from identifiers rebuilt out of a fixed alphabet. +func CreateDatabaseSQL(name, charset, collation string) (string, error) { + n, err := rebuildIdent(name, dbNameChars) + if err != nil { + return "", err + } + + cs, err := rebuildIdent(charset, charsetChars) + if err != nil { + return "", err + } + + co, err := rebuildIdent(collation, charsetChars) + if err != nil { + return "", err + } + + return "CREATE DATABASE IF NOT EXISTS `" + n + "` CHARACTER SET " + cs + " COLLATE " + co, nil +} + +// DropDatabaseSQL builds DROP DATABASE from a name rebuilt out of a fixed alphabet. +func DropDatabaseSQL(name string) (string, error) { + n, err := rebuildIdent(name, dbNameChars) + if err != nil { + return "", err + } + + return "DROP DATABASE IF EXISTS `" + n + "`", nil +} + +// CreateDatabase creates a database with the given charset and collation. +func (s *Server) CreateDatabase(ctx context.Context, name, charset, collation string) error { + stmt, err := CreateDatabaseSQL(name, charset, collation) + if err != nil { + return err + } + + return s.exec(ctx, stmt) +} + +// DropDatabase removes a database; a missing one is not an error. +func (s *Server) DropDatabase(ctx context.Context, name string) error { + stmt, err := DropDatabaseSQL(name) + if err != nil { + return err + } + + return s.exec(ctx, stmt) +} + +// DropUser removes an account; a missing one is not an error. +func (s *Server) DropUser(ctx context.Context, user string) error { + return s.exec(ctx, "DROP USER IF EXISTS ?@'%'", user) +} + +// Stop stops the container, keeping its data and published port. +func (s *Server) Stop(ctx context.Context) error { + return s.e.cfg.Runtime.StopContainer(ctx, s.container()) //nolint:wrapcheck // runtime errors carry the id +} + +// Start starts a stopped server and waits until it accepts connections. +func (s *Server) Start(ctx context.Context) error { + if err := s.e.cfg.Runtime.StartContainer(ctx, s.container()); err != nil { + return err //nolint:wrapcheck // runtime errors carry the id + } + + if err := s.waitReady(ctx); err != nil { + return err + } + + s.reguard(ctx) + + return nil +} + +func (s *Server) pwdEnv() []string { return []string{"MYSQL_PWD=" + s.pass()} } + +func (s *Server) run(ctx context.Context, op string, cmd, env []string) error { + _, err := s.runOut(ctx, op, cmd, env) + + return err +} + +func (s *Server) runOut(ctx context.Context, op string, cmd, env []string) (string, error) { + res, err := container.ExecWithEnv(ctx, s.e.cfg.Runtime, s.container(), cmd, env) + if err != nil { + return "", fmt.Errorf("%w: exec %s: %s", errExec, op, s.scrub(err.Error())) + } + + if res.ExitCode != 0 { + msg := s.scrub(strings.TrimSpace(res.Stderr + res.Stdout)) + if len(msg) > stderrLimit { + msg = msg[:stderrLimit] + } + + return "", fmt.Errorf("%w: %s exited %d: %s", errExec, op, res.ExitCode, msg) + } + + return res.Stdout, nil +} + +// scrub redacts the server's credentials from command output. +func (s *Server) scrub(msg string) string { + s.mu.Lock() + secrets := []string{s.spec.RootPassword, s.replPass} + s.mu.Unlock() + + for _, sec := range secrets { + if sec != "" { + msg = strings.ReplaceAll(msg, sec, "***") + } + } + + return msg +} + +func (s *Server) pass() string { + s.mu.Lock() + defer s.mu.Unlock() + + return s.spec.RootPassword +} + +// SetRootPassword records a root password changed through SQL so later connections use it. +func (s *Server) SetRootPassword(pw string) { + s.mu.Lock() + s.spec.RootPassword = pw + s.mu.Unlock() +} + +// UserDatabases lists the non-system schemas. +func (s *Server) UserDatabases(ctx context.Context) ([]string, error) { + rows, err := s.Query(ctx, "", "SHOW DATABASES") + if err != nil { + return nil, err + } + + var out []string + + for _, r := range rows[1:] { + if !isSystem(r[0]) { + out = append(out, r[0]) + } + } + + return out, nil +} + +func isSystem(db string) bool { + switch strings.ToLower(db) { + case mysqlBin, "sys", "information_schema", "performance_schema": + return true + } + + return false +} + +// Dump returns a logical dump of every user schema; accounts are not included. +func (s *Server) Dump(ctx context.Context) ([]byte, error) { + dbs, err := s.UserDatabases(ctx) + if err != nil { + return nil, err + } + + return s.DumpDatabases(ctx, dbs) +} + +// DumpDatabases returns a logical dump of dbs; accounts are not included. +func (s *Server) DumpDatabases(ctx context.Context, dbs []string) ([]byte, error) { + if len(dbs) == 0 { + return nil, nil + } + + path := filepath.Join(s.dir, "dump.sql") + defer os.Remove(path) + + cmd := append([]string{ + mysqldumpBin, "-u" + RootUser, argSingleTx, argRoutines, + argAddDrop, "--set-gtid-purged=OFF", "-r", dumpMount + "/dump.sql", argDatabases, + }, dbs...) + + if err := s.run(ctx, "mysqldump", cmd, s.pwdEnv()); err != nil { + return nil, err + } + + st, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("dump file: %w", err) + } + + if st.Size() > maxDumpBytes { + return nil, ErrTooLarge + } + + return os.ReadFile(path) +} + +// Restore runs a dump produced by Dump. +func (s *Server) Restore(ctx context.Context, data []byte) error { + return s.RestoreInto(ctx, "", data) +} + +// RestoreInto runs a SQL file with db as the default database when db is not empty. +func (s *Server) RestoreInto(ctx context.Context, db string, data []byte) error { + path := filepath.Join(s.dir, "in.sql") + defer os.Remove(path) + + if err := os.WriteFile(path, data, fileMode); err != nil { + return fmt.Errorf("write dump: %w", err) + } + + cmd := []string{mysqlBin, "-u" + RootUser} + if db != "" { + cmd = append(cmd, "--database="+db) + } + + return s.run(ctx, mysqlBin, append(cmd, "-e", "source "+dumpMount+"/in.sql"), s.pwdEnv()) +} + +// Close removes the container and releases its port; purge also deletes the data volume. +func (s *Server) Close(ctx context.Context, purge bool) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teardownLimit) + defer cancel() + + if id := s.container(); id != "" { + if err := s.e.cfg.Runtime.StopAndRemove(ctx, id); err != nil { + s.e.cfg.Logger.WarnContext(ctx, "mysqlengine: container removal failed", "container", id, "error", err) + } + } + + s.releaseNetwork(ctx) + s.releasePort() + + if purge && s.volume != "" { + _ = s.e.cfg.Volumes.RemoveVolume(ctx, s.volume) + } + + _ = os.RemoveAll(s.dir) +} + +func sanitize(v string) string { + b := []byte(strings.ToLower(v)) + for i, c := range b { + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { + b[i] = '-' + } + } + + return string(b) +} diff --git a/pkgs/mysqlengine/mysqlengine_test.go b/pkgs/mysqlengine/mysqlengine_test.go new file mode 100644 index 0000000000..e0fac3ab42 --- /dev/null +++ b/pkgs/mysqlengine/mysqlengine_test.go @@ -0,0 +1,166 @@ +package mysqlengine_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/mysqlengine" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +var errBoom = errors.New("boom") + +type failingRuntime struct{ created []container.Spec } + +func (r *failingRuntime) CreateAndStart(_ context.Context, spec container.Spec) (string, error) { + r.created = append(r.created, spec) + + return "", errBoom +} + +func (*failingRuntime) StopAndRemove(context.Context, string) error { return nil } +func (*failingRuntime) StopContainer(context.Context, string) error { return nil } +func (*failingRuntime) StartContainer(context.Context, string) error { return nil } + +func (*failingRuntime) Exec(context.Context, string, []string) (container.ExecResult, error) { + return container.ExecResult{}, nil +} + +func TestNewRequiresRuntime(t *testing.T) { + t.Parallel() + + _, err := mysqlengine.New(mysqlengine.Config{}) + require.ErrorIs(t, err, mysqlengine.ErrRuntimeUnavailable) +} + +func TestVolumeName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + id string + want string + }{ + {"ocid is sanitised", "p", "ocid1.mysqldbsystem.oc1.iad.AbC", "p-data-ocid1-mysqldbsystem-oc1-iad-abc"}, + {"default prefix", "", "x_y", "mysql-data-x-y"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e, err := mysqlengine.New(mysqlengine.Config{Runtime: &failingRuntime{}, Prefix: tt.prefix}) + require.NoError(t, err) + assert.Equal(t, tt.want, e.VolumeName(tt.id)) + }) + } +} + +func TestLaunchFailureReleasesPorts(t *testing.T) { + t.Parallel() + + alloc, err := portalloc.New(40100, 40110) + require.NoError(t, err) + + rt := &failingRuntime{} + e, err := mysqlengine.New(mysqlengine.Config{Runtime: rt, Ports: alloc, Prefix: "t"}) + require.NoError(t, err) + + _, err = e.Launch(t.Context(), mysqlengine.Spec{ID: "a", Image: "mysql:8.4", RootPassword: "pw"}) + require.ErrorIs(t, err, errBoom) + + require.NotEmpty(t, rt.created) + assert.Contains(t, rt.created[0].Env, "MYSQL_ROOT_PASSWORD=pw") + assert.Equal(t, "mysql:8.4", rt.created[0].Image) + assert.Empty(t, alloc.Allocated(), "a failed launch gives its ports back") +} + +func TestDatabaseSQL(t *testing.T) { + t.Parallel() + + tests := []struct { + name, db, charset, collation string + want string + bad bool + }{ + {name: "ok", db: "app_1", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", + want: "CREATE DATABASE IF NOT EXISTS `app_1` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"}, + {name: "backtick name", db: "a`; DROP DATABASE x; --", charset: "utf8mb4", collation: "utf8mb4_bin", bad: true}, + {name: "empty name", db: "", charset: "utf8mb4", collation: "utf8mb4_bin", bad: true}, + {name: "long name", db: strings.Repeat("a", 65), charset: "utf8mb4", collation: "utf8mb4_bin", bad: true}, + {name: "charset injection", db: "a", charset: "utf8; DROP DATABASE x", collation: "utf8mb4_bin", bad: true}, + {name: "collation injection", db: "a", charset: "utf8mb4", collation: "x; DROP DATABASE y", bad: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := mysqlengine.CreateDatabaseSQL(tt.db, tt.charset, tt.collation) + if tt.bad { + require.ErrorIs(t, err, mysqlengine.ErrInvalidIdentifier) + + if tt.db != "a" { + _, err = mysqlengine.DropDatabaseSQL(tt.db) + require.ErrorIs(t, err, mysqlengine.ErrInvalidIdentifier) + } + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + + drop, err := mysqlengine.DropDatabaseSQL(tt.db) + require.NoError(t, err) + assert.Equal(t, "DROP DATABASE IF EXISTS `"+tt.db+"`", drop) + }) + } +} + +func TestDatabaseSQLRoundTrip(t *testing.T) { + t.Parallel() + + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678_$-" + + tests := []struct { + name string + input string + bad bool + }{ + {name: "alphabet", input: alphabet}, + {name: "single", input: "a"}, + {name: "max len", input: strings.Repeat("Z", 64)}, + {name: "dollar dash", input: "my-db$1"}, + {name: "space", input: "a b", bad: true}, + {name: "backtick", input: "a`b", bad: true}, + {name: "quote", input: "a'b", bad: true}, + {name: "unicode", input: "café", bad: true}, + {name: "nul", input: "a\x00b", bad: true}, + {name: "too long", input: strings.Repeat("a", 65), bad: true}, + {name: "empty", input: "", bad: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + drop, err := mysqlengine.DropDatabaseSQL(tt.input) + if tt.bad { + require.ErrorIs(t, err, mysqlengine.ErrInvalidIdentifier) + + return + } + + require.NoError(t, err) + assert.Equal(t, "DROP DATABASE IF EXISTS `"+tt.input+"`", drop) + }) + } +} diff --git a/pkgs/mysqlengine/replication.go b/pkgs/mysqlengine/replication.go new file mode 100644 index 0000000000..acc601dcae --- /dev/null +++ b/pkgs/mysqlengine/replication.go @@ -0,0 +1,263 @@ +package mysqlengine + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" +) + +const ( + replPassLen = 30 + netSuffix = 8 + superRO = "SET GLOBAL super_read_only=ON" +) + +// ErrNoReplication is returned when the container runtime cannot manage the network replication needs. +var ErrNoReplication = errors.New("mysqlengine: the container runtime cannot run replication") + +// ReplSource tells a replica how to reach its source. +type ReplSource struct { + Network string + NetworkID string + Host string + Password string + Port int +} + +type sourceNet struct { + id string + name string + connected atomic.Bool +} + +func replicationFlags(serverID uint32) []string { + return []string{ + "--server-id=" + strconv.FormatUint(uint64(max(serverID, 1)), 10), + "--gtid-mode=ON", "--enforce-gtid-consistency=ON", + } +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + + return hex.EncodeToString(b) +} + +func (s *Server) replicaPass() string { + s.mu.Lock() + defer s.mu.Unlock() + + if s.replPass == "" { + s.replPass = randomHex(replPassLen)[:replPassLen] + } + + return s.replPass +} + +// ReplicationSource creates the replication account and puts the server on a network its replicas can join. +func (s *Server) ReplicationSource(ctx context.Context) (ReplSource, error) { + nr, ok := s.e.cfg.Runtime.(container.NetworkRuntime) + if !ok { + return ReplSource{}, ErrNoReplication + } + + pass := s.replicaPass() + + if _, err := s.Statement(ctx, dbrepl.MySQLReplicaUserSQL(s.e.cfg.ReplUser, pass)); err != nil { + return ReplSource{}, err + } + + n, err := s.sourceNetwork(ctx, nr) + if err != nil { + return ReplSource{}, err + } + + return ReplSource{Network: n.name, NetworkID: n.id, Host: s.ContainerName(), Port: mysqlPort, Password: pass}, nil +} + +func (s *Server) sourceNetwork(ctx context.Context, nr container.NetworkRuntime) (*sourceNet, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.net == nil { + name := s.e.cfg.Prefix + "-net-" + sanitize(s.spec.ID) + "-" + randomHex(netSuffix)[:netSuffix] + + id, err := nr.CreateNetwork(ctx, name) + if err != nil { + return nil, fmt.Errorf("create replication network: %w", err) + } + + s.net = &sourceNet{id: id, name: name} + s.netID = id + } + + if !s.net.connected.Load() { + if err := nr.ConnectNetwork(ctx, s.net.id, s.ctrID); err != nil { + return nil, fmt.Errorf("connect source to replication network: %w", err) + } + + s.net.connected.Store(true) + } + + return s.net, nil +} + +// releaseNetwork removes the replication network; one still in use by another server is removed by whichever +// closes last. +func (s *Server) releaseNetwork(ctx context.Context) { + nr, ok := s.e.cfg.Runtime.(container.NetworkRuntime) + if !ok { + return + } + + s.mu.Lock() + id := s.netID + s.mu.Unlock() + + if id != "" { + _ = nr.RemoveNetwork(ctx, id) + } +} + +// ReplicaSeed returns what a replica of this server starts from: a dump of dbs with the GTID set, or just the +// executed GTID set when dbs is empty. +func (s *Server) ReplicaSeed(ctx context.Context, dbs []string) ([]byte, error) { + if len(dbs) == 0 { + out, err := s.Statement(ctx, "SELECT @@GLOBAL.gtid_executed") + if err != nil { + return nil, err + } + + gtid := strings.ReplaceAll(strings.Join(strings.Fields(out), ""), `\n`, "") + if gtid == "" || gtid == "NULL" { + return nil, nil + } + + return []byte("SET @@GLOBAL.GTID_PURGED='" + gtid + "';\n"), nil + } + + cmd := append([]string{ + mysqldumpBin, "-u" + RootUser, argSingleTx, "--skip-dump-date", + argRoutines, "--events", "--triggers", "--set-gtid-purged=ON", argAddDrop, + "-r", dumpMount + "/seed.sql", argDatabases, + }, dbs...) + + if _, err := s.RunAuth(ctx, cmd); err != nil { + return nil, err + } + + return s.readDump("seed.sql") +} + +func (s *Server) resetStmt() string { + if strings.HasSuffix(s.spec.Image, ":8.4") { + return "RESET BINARY LOGS AND GTIDS" + } + + return "RESET MASTER" +} + +// AttachReplica makes the server follow src; seed, when present, is loaded first. +func (s *Server) AttachReplica(ctx context.Context, src ReplSource, seed []byte) error { + s.mu.Lock() + s.netID = src.NetworkID + s.mu.Unlock() + + change := dbrepl.MySQLChangeSourceSQL(src.Host, src.Port, s.e.cfg.ReplUser, src.Password) + + if out, err := s.Statement(ctx, "SHOW REPLICA STATUS"); err == nil && strings.TrimSpace(out) != "" { + _, err = s.Statement(ctx, "STOP REPLICA; "+change) + + return s.guardReplica(ctx, err) + } + + if _, err := s.Statement(ctx, s.resetStmt()); err != nil { + return err + } + + if len(seed) > 0 { + if err := s.RestoreInto(ctx, "", seed); err != nil { + return err + } + } + + _, err := s.Statement(ctx, change) + + return s.guardReplica(ctx, err) +} + +func (s *Server) guardReplica(ctx context.Context, prev error) error { + if prev != nil { + return prev + } + + if _, err := s.Statement(ctx, superRO); err != nil { + return err + } + + s.mu.Lock() + s.attached = true + s.mu.Unlock() + + return nil +} + +// Promote detaches a replica from its source and makes it writable. +func (s *Server) Promote(ctx context.Context) error { + if _, err := s.Statement(ctx, + "STOP REPLICA; RESET REPLICA ALL; SET GLOBAL super_read_only=OFF; SET GLOBAL read_only=OFF"); err != nil { + return err + } + + s.mu.Lock() + s.promoted = true + s.mu.Unlock() + + return nil +} + +// SetReplication pauses (false) or resumes (true) replication. +func (s *Server) SetReplication(ctx context.Context, on bool) error { + stmt := "STOP REPLICA" + if on { + stmt = "START REPLICA" + } + + _, err := s.Statement(ctx, stmt) + + return err +} + +// ReplicationStatus reports the replica's lag and thread state. +func (s *Server) ReplicationStatus(ctx context.Context) (dbrepl.Status, error) { + db, err := s.open("") + if err != nil { + return dbrepl.Status{}, err + } + defer db.Close() + + return dbrepl.QueryStatus(ctx, db, false) +} + +// reguard re-applies the read-only guard a replica loses on container restart. +func (s *Server) reguard(ctx context.Context) { + s.mu.Lock() + replica := s.attached && !s.promoted + s.mu.Unlock() + + if replica { + if _, err := s.Statement(ctx, superRO); err != nil { + s.e.cfg.Logger.WarnContext(ctx, "mysqlengine: replica read-only guard failed", + "server", s.spec.ID, "error", err) + } + } +} diff --git a/pkgs/mysqlengine/run_internal_test.go b/pkgs/mysqlengine/run_internal_test.go new file mode 100644 index 0000000000..9d85457e2f --- /dev/null +++ b/pkgs/mysqlengine/run_internal_test.go @@ -0,0 +1,152 @@ +package mysqlengine + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +var errDockerDown = errors.New("docker down") + +type execRuntime struct { + err error + gotCmd []string + gotEnv []string + res container.ExecResult +} + +func (*execRuntime) CreateAndStart(context.Context, container.Spec) (string, error) { return "", nil } +func (*execRuntime) StopAndRemove(context.Context, string) error { return nil } +func (*execRuntime) StopContainer(context.Context, string) error { return nil } +func (*execRuntime) StartContainer(context.Context, string) error { return nil } + +func (r *execRuntime) Exec(_ context.Context, _ string, cmd []string) (container.ExecResult, error) { + r.gotCmd = cmd + + return r.res, r.err +} + +func (r *execRuntime) ExecEnv(ctx context.Context, id string, cmd, env []string) (container.ExecResult, error) { + r.gotEnv = env + + return r.Exec(ctx, id, cmd) +} + +func TestPasswordGoesInEnvNotArgv(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t-root-pw" + + rt := &execRuntime{} + s := &Server{e: &Engine{cfg: Config{Runtime: rt}}, spec: Spec{RootPassword: secret}, dir: t.TempDir()} + + require.NoError(t, s.Restore(t.Context(), []byte("select 1"))) + assert.Equal(t, []string{"MYSQL_PWD=" + secret}, rt.gotEnv) + assert.NotEmpty(t, rt.gotCmd) + + for _, a := range rt.gotCmd { + assert.NotContains(t, a, secret) + } +} + +func TestRestoreErrorOmitsPassword(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t-root-pw" + + tests := []struct { + rt *execRuntime + name string + }{ + {name: "exec error", rt: &execRuntime{err: errDockerDown}}, + {name: "nonzero exit", rt: &execRuntime{res: container.ExecResult{ExitCode: 1, Stderr: "access denied"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := &Engine{cfg: Config{Runtime: tt.rt}} + s := &Server{e: e, spec: Spec{RootPassword: secret}, dir: t.TempDir()} + + err := s.Restore(t.Context(), []byte("select 1")) + require.Error(t, err) + assert.NotContains(t, err.Error(), secret) + assert.Contains(t, err.Error(), mysqlBin) + }) + } +} + +func TestStatementPasswordGoesInEnvNotArgv(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t-root-pw" + + rt := &execRuntime{} + s := &Server{e: &Engine{cfg: Config{Runtime: rt}}, spec: Spec{RootPassword: secret}, ctrID: "c1"} + + _, err := s.Statement(t.Context(), "SELECT 1") + require.NoError(t, err) + assert.Equal(t, []string{"MYSQL_PWD=" + secret}, rt.gotEnv) + assert.Equal(t, mysqlBin, rt.gotCmd[0]) + + for _, a := range rt.gotCmd { + assert.NotContains(t, a, secret) + } +} + +func TestStatementNeverLeaksSecrets(t *testing.T) { + t.Parallel() + + const ( + rootPW = "s3cr3t-root-pw" + replPW = "s3cr3t-repl-pw" + ) + + tests := []struct { + rt *execRuntime + name string + }{ + { + name: "stderr echoes root", + rt: &execRuntime{res: container.ExecResult{ExitCode: 1, Stderr: "near '" + rootPW + "'"}}, + }, + {name: "stdout echoes repl", rt: &execRuntime{res: container.ExecResult{ExitCode: 1, Stdout: replPW}}}, + { + name: "exec error echoes root", + rt: &execRuntime{err: fmt.Errorf("%w: create exec env MYSQL_PWD=%s", errDockerDown, rootPW)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + log := slog.New(slog.NewTextHandler(&buf, nil)) + s := &Server{ + e: &Engine{cfg: Config{Runtime: tt.rt, Logger: log}}, + spec: Spec{RootPassword: rootPW, ID: "i"}, replPass: replPW, ctrID: "c1", attached: true, + } + + _, err := s.Statement(t.Context(), superRO) + require.Error(t, err) + assert.NotContains(t, err.Error(), rootPW) + assert.NotContains(t, err.Error(), replPW) + + s.reguard(t.Context()) + assert.NotContains(t, buf.String(), rootPW) + assert.NotContains(t, buf.String(), replPW) + assert.Contains(t, buf.String(), "read-only guard failed") + }) + } +} diff --git a/pkgs/parquetio/fieldid_test.go b/pkgs/parquetio/fieldid_test.go new file mode 100644 index 0000000000..2a76377978 --- /dev/null +++ b/pkgs/parquetio/fieldid_test.go @@ -0,0 +1,46 @@ +package parquetio_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +func TestColumnIDs(t *testing.T) { + t.Parallel() + + cases := []struct { + want map[int]string + name string + cols []parquetio.Column + }{ + { + name: "ids written", + cols: []parquetio.Column{{Name: "a", Type: "bigint", FieldID: 7}, {Name: "b", Type: "string", FieldID: 9}}, + want: map[int]string{7: "a", 9: "b"}, + }, + {name: "no ids", cols: []parquetio.Column{{Name: "a", Type: "bigint"}}, want: map[int]string{}}, + { + name: "mixed", + cols: []parquetio.Column{{Name: "a", Type: "bigint"}, {Name: "b", Type: "string", FieldID: 2}}, + want: map[int]string{2: "b"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, parquetio.Write(&buf, tc.cols, [][]any{make([]any, len(tc.cols))}, "SNAPPY")) + + f, err := parquetio.Open(buf.Bytes()) + require.NoError(t, err) + assert.Equal(t, tc.want, f.ColumnIDs()) + }) + } +} diff --git a/pkgs/parquetio/reader.go b/pkgs/parquetio/reader.go index f2fb3d1a9e..973d490034 100644 --- a/pkgs/parquetio/reader.go +++ b/pkgs/parquetio/reader.go @@ -52,6 +52,17 @@ func Open(data []byte) (*File, error) { // NumRows is the total row count from the footer. func (f *File) NumRows() int64 { return f.pf.NumRows() } +// MaxRowGroupBytes is the largest uncompressed row-group size in the footer. +func (f *File) MaxRowGroupBytes() int64 { + var most int64 + + for _, rg := range f.pf.Metadata().RowGroups { + most = max(most, rg.TotalByteSize) + } + + return most +} + // Columns lists the top-level column names in file order. func (f *File) Columns() []string { fields := f.pf.Schema().Fields() @@ -64,6 +75,19 @@ func (f *File) Columns() []string { return out } +// ColumnIDs maps each top-level Parquet field id to its column name. +func (f *File) ColumnIDs() map[int]string { + out := map[int]string{} + + for _, fld := range f.pf.Schema().Fields() { + if id := fld.ID(); id > 0 { + out[id] = fld.Name() + } + } + + return out +} + // ScanBytes is the footer plus the compressed size of every column chunk // belonging to the named top-level columns (case-insensitive). func (f *File) ScanBytes(cols []string) int64 { diff --git a/pkgs/parquetio/writer.go b/pkgs/parquetio/writer.go index 5113b02178..af82a99fe2 100644 --- a/pkgs/parquetio/writer.go +++ b/pkgs/parquetio/writer.go @@ -28,6 +28,8 @@ const ( type Column struct { Name string Type string + // FieldID is written as the Parquet field id when positive (Iceberg data files). + FieldID int } type cellEnc func(v any) (parquet.Value, error) @@ -71,7 +73,12 @@ func Write(w io.Writer, cols []Column, rows [][]any, compression string) error { return fmt.Errorf("column %q: %w", c.Name, err) } - group.add(c.Name, parquet.Optional(encs[i].node)) + node := parquet.Optional(encs[i].node) + if c.FieldID > 0 { + node = parquet.FieldID(node, c.FieldID) + } + + group.add(c.Name, node) } schema := parquet.NewSchema("hive_schema", group) diff --git a/pkgs/persistence/testdata/snapshot_inventory.json b/pkgs/persistence/testdata/snapshot_inventory.json index be9994f90a..298ca89f5d 100644 --- a/pkgs/persistence/testdata/snapshot_inventory.json +++ b/pkgs/persistence/testdata/snapshot_inventory.json @@ -3118,11 +3118,14 @@ "aciAzureFileWire.ShareName string `json:\"shareName\"`", "aciAzureFileWire.StorageAccountKey string `json:\"storageAccountKey,omitempty\"`", "aciAzureFileWire.StorageAccountName string `json:\"storageAccountName\"`", + "aciContainerPropsWire.Args []string `json:\"-\"`", "aciContainerPropsWire.Command []string `json:\"command,omitempty\"`", "aciContainerPropsWire.EnvironmentVariables []aciEnvWire `json:\"environmentVariables,omitempty\"`", "aciContainerPropsWire.Image string `json:\"image\"`", "aciContainerPropsWire.InstanceView json.RawMessage `json:\"instanceView,omitempty\"`", + "aciContainerPropsWire.LivenessProbe *aciProbeWire `json:\"livenessProbe,omitempty\"`", "aciContainerPropsWire.Ports []aciPortWire `json:\"ports,omitempty\"`", + "aciContainerPropsWire.ReadinessProbe *aciProbeWire `json:\"readinessProbe,omitempty\"`", "aciContainerPropsWire.Resources *aciResourcesWire `json:\"resources,omitempty\"`", "aciContainerPropsWire.VolumeMounts []aciMountWire `json:\"volumeMounts,omitempty\"`", "aciContainerWire.Name string `json:\"name\"`", @@ -3130,36 +3133,75 @@ "aciCredWire.Password string `json:\"password,omitempty\"`", "aciCredWire.Server string `json:\"server\"`", "aciCredWire.Username string `json:\"username\"`", + "aciDNSWire.NameServers []string `json:\"nameServers\"`", + "aciDNSWire.Options string `json:\"options,omitempty\"`", + "aciDNSWire.SearchDomains string `json:\"searchDomains,omitempty\"`", + "aciDiagnosticsWire.LogAnalytics *aciLogAnalyticsWire `json:\"logAnalytics,omitempty\"`", "aciEnvWire.Name string `json:\"name\"`", "aciEnvWire.SecureValue *string `json:\"secureValue,omitempty\"`", "aciEnvWire.Value *string `json:\"value,omitempty\"`", + "aciExecWire.Command []string `json:\"command,omitempty\"`", + "aciGitRepoWire.Directory string `json:\"directory,omitempty\"`", + "aciGitRepoWire.Repository string `json:\"repository\"`", + "aciGitRepoWire.Revision string `json:\"revision,omitempty\"`", + "aciHTTPGetWire.HTTPHeaders []aciHeaderWire `json:\"httpHeaders,omitempty\"`", + "aciHTTPGetWire.Path string `json:\"path,omitempty\"`", + "aciHTTPGetWire.Port int `json:\"port\"`", + "aciHTTPGetWire.Scheme string `json:\"scheme,omitempty\"`", + "aciHeaderWire.Name string `json:\"name\"`", + "aciHeaderWire.Value string `json:\"value\"`", "aciIPWire.DNSNameLabel string `json:\"dnsNameLabel,omitempty\"`", "aciIPWire.Fqdn string `json:\"fqdn,omitempty\"`", "aciIPWire.IP string `json:\"ip,omitempty\"`", "aciIPWire.Ports []aciPortWire `json:\"ports,omitempty\"`", "aciIPWire.Type string `json:\"type,omitempty\"`", + "aciInitPropsWire.Command []string `json:\"command,omitempty\"`", + "aciInitPropsWire.EnvironmentVariables []aciEnvWire `json:\"environmentVariables,omitempty\"`", + "aciInitPropsWire.Image string `json:\"image\"`", + "aciInitPropsWire.InstanceView json.RawMessage `json:\"instanceView,omitempty\"`", + "aciInitPropsWire.VolumeMounts []aciMountWire `json:\"volumeMounts,omitempty\"`", + "aciInitWire.Name string `json:\"name\"`", + "aciInitWire.Properties aciInitPropsWire `json:\"properties\"`", + "aciLogAnalyticsWire.LogType string `json:\"logType,omitempty\"`", + "aciLogAnalyticsWire.Metadata map[string]string `json:\"metadata,omitempty\"`", + "aciLogAnalyticsWire.WorkspaceID string `json:\"workspaceId\"`", + "aciLogAnalyticsWire.WorkspaceKey string `json:\"workspaceKey,omitempty\"`", + "aciLogAnalyticsWire.WorkspaceResourceID string `json:\"workspaceResourceId,omitempty\"`", "aciMountWire.MountPath string `json:\"mountPath\"`", "aciMountWire.Name string `json:\"name\"`", "aciMountWire.ReadOnly bool `json:\"readOnly,omitempty\"`", "aciPortWire.Port int `json:\"port\"`", "aciPortWire.Protocol string `json:\"protocol,omitempty\"`", + "aciProbeWire.Exec *aciExecWire `json:\"exec,omitempty\"`", + "aciProbeWire.FailureThreshold int `json:\"failureThreshold,omitempty\"`", + "aciProbeWire.HTTPGet *aciHTTPGetWire `json:\"httpGet,omitempty\"`", + "aciProbeWire.InitialDelaySeconds int `json:\"initialDelaySeconds,omitempty\"`", + "aciProbeWire.PeriodSeconds int `json:\"periodSeconds,omitempty\"`", + "aciProbeWire.SuccessThreshold int `json:\"successThreshold,omitempty\"`", + "aciProbeWire.TimeoutSeconds int `json:\"timeoutSeconds,omitempty\"`", "aciPropsWire.Containers []aciContainerWire `json:\"containers\"`", + "aciPropsWire.DNSConfig *aciDNSWire `json:\"dnsConfig,omitempty\"`", + "aciPropsWire.Diagnostics *aciDiagnosticsWire `json:\"diagnostics,omitempty\"`", "aciPropsWire.IPAddress *aciIPWire `json:\"ipAddress,omitempty\"`", "aciPropsWire.ImageRegistryCredentials []aciCredWire `json:\"imageRegistryCredentials,omitempty\"`", + "aciPropsWire.InitContainers []aciInitWire `json:\"initContainers,omitempty\"`", "aciPropsWire.InstanceView json.RawMessage `json:\"instanceView,omitempty\"`", "aciPropsWire.OSType string `json:\"osType,omitempty\"`", "aciPropsWire.Priority string `json:\"priority,omitempty\"`", "aciPropsWire.ProvisioningState string `json:\"provisioningState,omitempty\"`", "aciPropsWire.RestartPolicy string `json:\"restartPolicy,omitempty\"`", "aciPropsWire.SKU string `json:\"sku,omitempty\"`", + "aciPropsWire.SubnetIDs []aciSubnetWire `json:\"subnetIds,omitempty\"`", "aciPropsWire.Volumes []aciVolumeWire `json:\"volumes,omitempty\"`", "aciResourceWire.CPU float64 `json:\"cpu,omitempty\"`", "aciResourceWire.MemoryInGB float64 `json:\"memoryInGB,omitempty\"`", "aciResourcesWire.Limits *aciResourceWire `json:\"limits,omitempty\"`", "aciResourcesWire.Requests *aciResourceWire `json:\"requests,omitempty\"`", + "aciSubnetWire.ID string `json:\"id\"`", + "aciSubnetWire.Name string `json:\"name,omitempty\"`", "aciVolumeWire.AzureFile *aciAzureFileWire `json:\"azureFile,omitempty\"`", "aciVolumeWire.EmptyDir *json.RawMessage `json:\"emptyDir,omitempty\"`", - "aciVolumeWire.GitRepo *json.RawMessage `json:\"gitRepo,omitempty\"`", + "aciVolumeWire.GitRepo *aciGitRepoWire `json:\"gitRepo,omitempty\"`", "aciVolumeWire.Name string `json:\"name\"`", "aciVolumeWire.Secret map[string]string `json:\"secret,omitempty\"`", "acrRecord.CreatedAt time.Time `json:\"createdAt\"`", @@ -3174,6 +3216,8 @@ "acrRecord.ResourceGroup string `json:\"resourceGroup\"`", "acrRecord.SKU string `json:\"sku,omitempty\"`", "acrRecord.Tags map[string]string `json:\"tags,omitempty\"`", + "apimSnapshot.Operations json.RawMessage `json:\"operations,omitempty\"`", + "apimSnapshot.Services json.RawMessage `json:\"services,omitempty\"`", "appConfigDeletedRecord.DeletedAt time.Time `json:\"deletedAt\"`", "appConfigDeletedRecord.Location string `json:\"location\"`", "appConfigDeletedRecord.Name string `json:\"name\"`", @@ -3194,21 +3238,73 @@ "appConfigRecord.Tags map[string]string `json:\"tags,omitempty\"`", "appConfigSnapshot.Deleted map[string]appConfigDeletedRecord `json:\"deleted,omitempty\"`", "appConfigSnapshot.Resources map[string]appConfigRecord `json:\"resources,omitempty\"`", + "armSnapshot.APIManagement *apimSnapshot `json:\"apiManagement,omitempty\"`", "armSnapshot.AppConfig *appConfigSnapshot `json:\"appConfig,omitempty\"`", + "armSnapshot.Authorization *authorizationSnapshot `json:\"authorization,omitempty\"`", + "armSnapshot.Compute *computeSnapshot `json:\"compute,omitempty\"`", + "armSnapshot.ContainerApps *containerAppsSnapshot `json:\"containerApps,omitempty\"`", "armSnapshot.ContainerInstance *containerInstanceSnapshot `json:\"containerInstance,omitempty\"`", "armSnapshot.ContainerRegistry *containerRegistrySnapshot `json:\"containerRegistry,omitempty\"`", + "armSnapshot.ContainerService *containerServiceSnapshot `json:\"containerService,omitempty\"`", "armSnapshot.Cosmos *cosmosSnapshot `json:\"cosmos,omitempty\"`", "armSnapshot.EventGrid *eventGridSnapshot `json:\"eventGrid,omitempty\"`", "armSnapshot.EventHub *eventHubSnapshot `json:\"eventHub,omitempty\"`", "armSnapshot.KeyVault *keyVaultSnapshot `json:\"keyVault,omitempty\"`", + "armSnapshot.ManagedIdentity *managedIdentitySnapshot `json:\"managedIdentity,omitempty\"`", + "armSnapshot.MySQLFlex *flexSnapshot `json:\"mysqlFlex,omitempty\"`", + "armSnapshot.Network *networkSnapshot `json:\"network,omitempty\"`", + "armSnapshot.PostgresFlex *flexSnapshot `json:\"postgresFlex,omitempty\"`", "armSnapshot.Redis *redisSnapshot `json:\"redis,omitempty\"`", "armSnapshot.RegisteredProviders map[string]map[string]bool `json:\"registeredProviders\"`", "armSnapshot.ResourceGroups map[string]*ResourceGroup `json:\"resourceGroups\"`", "armSnapshot.Resources map[string]*Resource `json:\"resources\"`", "armSnapshot.SQL *sqlSnapshot `json:\"sql,omitempty\"`", "armSnapshot.Web *webSnapshot `json:\"web,omitempty\"`", + "authPermission.Actions []string `json:\"actions\"`", + "authPermission.DataActions []string `json:\"dataActions\"`", + "authPermission.NotActions []string `json:\"notActions\"`", + "authPermission.NotDataActions []string `json:\"notDataActions\"`", + "authorizationSnapshot.Locks map[string]*storedLock `json:\"locks,omitempty\"`", + "authorizationSnapshot.PolicyAssigns map[string]*storedPolicyAssign `json:\"policyAssignments,omitempty\"`", + "authorizationSnapshot.PolicyDefs map[string]*storedPolicy `json:\"policyDefinitions,omitempty\"`", + "authorizationSnapshot.PolicySets map[string]*storedPolicy `json:\"policySetDefinitions,omitempty\"`", + "authorizationSnapshot.RoleAssigns map[string]*storedRoleAssign `json:\"roleAssignments,omitempty\"`", + "authorizationSnapshot.RoleDefs map[string]*storedRoleDef `json:\"roleDefinitions,omitempty\"`", + "cmpInstance.ID string `json:\"id\"`", + "cmpInstance.Model int `json:\"model\"`", + "cmpInstance.Power string `json:\"power\"`", + "cmpInstance.VMID string `json:\"vmId\"`", + "cmpKid.Created time.Time `json:\"created\"`", + "cmpKid.Etag string `json:\"etag\"`", + "cmpKid.Location string `json:\"location,omitempty\"`", + "cmpKid.Names []string `json:\"names\"`", + "cmpKid.Props map[string]any `json:\"props,omitempty\"`", + "cmpKid.Tags map[string]string `json:\"tags,omitempty\"`", + "cmpKid.Type string `json:\"type\"`", + "cmpRes.Aux map[string]string `json:\"aux,omitempty\"`", + "cmpRes.Created time.Time `json:\"created\"`", + "cmpRes.Etag string `json:\"etag\"`", + "cmpRes.Extra map[string]any `json:\"extra,omitempty\"`", + "cmpRes.Identity resourceIdentity `json:\"identity\"`", + "cmpRes.Instances []*cmpInstance `json:\"instances,omitempty\"`", + "cmpRes.Kids map[string]*cmpKid `json:\"kids,omitempty\"`", + "cmpRes.Location string `json:\"location\"`", + "cmpRes.Name string `json:\"name\"`", + "cmpRes.Props map[string]any `json:\"props,omitempty\"`", + "cmpRes.RG string `json:\"rg\"`", + "cmpRes.SKU map[string]any `json:\"sku,omitempty\"`", + "cmpRes.Sub string `json:\"sub\"`", + "cmpRes.Tags map[string]string `json:\"tags,omitempty\"`", + "cmpRes.Type string `json:\"type\"`", + "cmpRes.UID string `json:\"uid\"`", + "cmpRes.Zones []string `json:\"zones,omitempty\"`", + "computeSnapshot.Items map[string]*cmpRes `json:\"items\"`", + "containerAppsSnapshot.Apps map[string]*storedACAApp `json:\"apps\"`", + "containerAppsSnapshot.Envs map[string]*storedACAEnv `json:\"envs\"`", + "containerAppsSnapshot.Jobs map[string]*storedACAJob `json:\"jobs\"`", "containerInstanceSnapshot.Groups map[string]*storedACIGroup `json:\"groups\"`", "containerRegistrySnapshot.Resources map[string]acrRecord `json:\"resources,omitempty\"`", + "containerServiceSnapshot.Clusters map[string]*storedAKSCluster `json:\"clusters\"`", "cosmosAccountRecord.Host string `json:\"host,omitempty\"`", "cosmosAccountRecord.Identity any `json:\"identity,omitempty\"`", "cosmosAccountRecord.InstanceID string `json:\"instanceId\"`", @@ -3267,6 +3363,46 @@ "eventHubSnapshot.Hubs map[string]eventHubRecord `json:\"hubs,omitempty\"`", "eventHubSnapshot.Namespaces map[string]eventHubNamespaceRecord `json:\"namespaces,omitempty\"`", "eventHubSnapshot.NetworkRuleSets map[string]map[string]any `json:\"networkRuleSets,omitempty\"`", + "flexDatabase.Charset string `json:\"charset\"`", + "flexDatabase.Collation string `json:\"collation\"`", + "flexDatabase.Name string `json:\"name\"`", + "flexFirewall.End string `json:\"end\"`", + "flexFirewall.Name string `json:\"name\"`", + "flexFirewall.Start string `json:\"start\"`", + "flexServer.AuthConfig map[string]any `json:\"authConfig,omitempty\"`", + "flexServer.AutoGrow string `json:\"autoGrow\"`", + "flexServer.Config map[string]string `json:\"config,omitempty\"`", + "flexServer.CreateMode string `json:\"createMode,omitempty\"`", + "flexServer.CreatedAt time.Time `json:\"createdAt\"`", + "flexServer.DNSZone string `json:\"dnsZone,omitempty\"`", + "flexServer.Databases map[string]flexDatabase `json:\"databases,omitempty\"`", + "flexServer.Encryption map[string]any `json:\"encryption,omitempty\"`", + "flexServer.Firewall map[string]flexFirewall `json:\"firewall,omitempty\"`", + "flexServer.GeoBackup string `json:\"geoBackup\"`", + "flexServer.HAMode string `json:\"haMode\"`", + "flexServer.Identity map[string]any `json:\"identity,omitempty\"`", + "flexServer.Iops int `json:\"iops,omitempty\"`", + "flexServer.Location string `json:\"location\"`", + "flexServer.Login string `json:\"login\"`", + "flexServer.Maintenance map[string]any `json:\"maintenance,omitempty\"`", + "flexServer.Name string `json:\"name\"`", + "flexServer.OpID string `json:\"-\"`", + "flexServer.Pending map[string]bool `json:\"pendingRestart,omitempty\"`", + "flexServer.PointInTime time.Time `json:\"pointInTime,omitzero\"`", + "flexServer.PublicAcc string `json:\"publicAccess\"`", + "flexServer.ResourceGrp string `json:\"resourceGroup\"`", + "flexServer.Retention int `json:\"retentionDays\"`", + "flexServer.SKUName string `json:\"skuName\"`", + "flexServer.SKUTier string `json:\"skuTier\"`", + "flexServer.SourceID string `json:\"sourceId,omitempty\"`", + "flexServer.StandbyZone string `json:\"standbyZone,omitempty\"`", + "flexServer.State string `json:\"state\"`", + "flexServer.StorageGB int `json:\"storageGb\"`", + "flexServer.Subnet string `json:\"subnet,omitempty\"`", + "flexServer.Tags map[string]string `json:\"tags,omitempty\"`", + "flexServer.Transition string `json:\"transition,omitempty\"`", + "flexServer.Zone string `json:\"zone,omitempty\"`", + "flexSnapshot.Servers map[string]*flexServer `json:\"servers,omitempty\"`", "keyVaultRecord.Host string `json:\"host,omitempty\"`", "keyVaultRecord.Location string `json:\"location\"`", "keyVaultRecord.Name string `json:\"name\"`", @@ -3276,6 +3412,24 @@ "keyVaultRecord.Tags map[string]string `json:\"tags,omitempty\"`", "keyVaultSnapshot.Deleted map[string]deletedKeyVaultRecord `json:\"deleted,omitempty\"`", "keyVaultSnapshot.Vaults map[string]keyVaultRecord `json:\"vaults,omitempty\"`", + "managedIdentitySnapshot.Identities map[string]*storedIdentity `json:\"identities\"`", + "netObj.Aux map[string]string `json:\"aux,omitempty\"`", + "netObj.Etag string `json:\"etag\"`", + "netObj.Extra map[string]any `json:\"extra,omitempty\"`", + "netObj.GUID string `json:\"guid\"`", + "netObj.Location string `json:\"location,omitempty\"`", + "netObj.Name string `json:\"name\"`", + "netObj.Props map[string]any `json:\"props,omitempty\"`", + "netObj.Tags map[string]string `json:\"tags,omitempty\"`", + "netParent.Kids map[string][]*netObj `json:\"kids,omitempty\"`", + "netParent.Obj netObj `json:\"obj\"`", + "netParent.RG string `json:\"rg\"`", + "netParent.Sub string `json:\"sub\"`", + "netParent.Type string `json:\"type\"`", + "networkSnapshot.Items map[string]*netParent `json:\"items\"`", + "pendingSwap.ConnStrings map[string]any `json:\"connStrings,omitempty\"`", + "pendingSwap.Settings map[string]string `json:\"settings\"`", + "pendingSwap.Target string `json:\"target\"`", "redisFirewall.End string `json:\"end\"`", "redisFirewall.Name string `json:\"name\"`", "redisFirewall.Start string `json:\"start\"`", @@ -3303,6 +3457,10 @@ "redisRecord.Tags map[string]string `json:\"tags,omitempty\"`", "redisRecord.Zones []any `json:\"zones,omitempty\"`", "redisSnapshot.Caches map[string]*redisRecord `json:\"caches,omitempty\"`", + "resourceIdentity.PrincipalID string `json:\"principalId,omitempty\"`", + "resourceIdentity.TenantID string `json:\"tenantId,omitempty\"`", + "resourceIdentity.Type string `json:\"type\"`", + "resourceIdentity.UserAssigned []string `json:\"userAssigned,omitempty\"`", "sqlAdmin.Login string `json:\"login\"`", "sqlAdmin.SID string `json:\"sid\"`", "sqlAdmin.TenantID string `json:\"tenantId\"`", @@ -3343,12 +3501,168 @@ "sqlServer.Settings map[string]map[string]any `json:\"settings,omitempty\"`", "sqlServer.Tags map[string]string `json:\"tags,omitempty\"`", "sqlSnapshot.Servers map[string]*sqlServer `json:\"servers,omitempty\"`", + "storedACAApp.Config map[string]any `json:\"config\"`", + "storedACAApp.Created time.Time `json:\"created\"`", + "storedACAApp.EnvironmentID string `json:\"environmentId\"`", + "storedACAApp.Identity resourceIdentity `json:\"identity\"`", + "storedACAApp.Kind string `json:\"kind,omitempty\"`", + "storedACAApp.Location string `json:\"location\"`", + "storedACAApp.ManagedBy string `json:\"managedBy,omitempty\"`", + "storedACAApp.Modified time.Time `json:\"modified\"`", + "storedACAApp.Name string `json:\"name\"`", + "storedACAApp.ResourceGroup string `json:\"resourceGroup\"`", + "storedACAApp.Revisions []*storedACARevision `json:\"revisions\"`", + "storedACAApp.Stopped bool `json:\"stopped,omitempty\"`", + "storedACAApp.Sub string `json:\"sub\"`", + "storedACAApp.Tags map[string]string `json:\"tags,omitempty\"`", + "storedACAApp.WorkloadProfile string `json:\"workloadProfile,omitempty\"`", + "storedACAEnv.Created time.Time `json:\"created\"`", + "storedACAEnv.DefaultDomain string `json:\"defaultDomain\"`", + "storedACAEnv.Identity resourceIdentity `json:\"identity\"`", + "storedACAEnv.Kind string `json:\"kind,omitempty\"`", + "storedACAEnv.Location string `json:\"location\"`", + "storedACAEnv.Modified time.Time `json:\"modified\"`", + "storedACAEnv.Name string `json:\"name\"`", + "storedACAEnv.Props map[string]any `json:\"props\"`", + "storedACAEnv.ResourceGroup string `json:\"resourceGroup\"`", + "storedACAEnv.Storages map[string]*storedACAStorage `json:\"storages,omitempty\"`", + "storedACAEnv.Sub string `json:\"sub\"`", + "storedACAEnv.Tags map[string]string `json:\"tags,omitempty\"`", + "storedACAExecution.End *time.Time `json:\"end,omitempty\"`", + "storedACAExecution.Name string `json:\"name\"`", + "storedACAExecution.Replicas int `json:\"replicas\"`", + "storedACAExecution.Start time.Time `json:\"start\"`", + "storedACAExecution.Status string `json:\"status,omitempty\"`", + "storedACAExecution.Stopped bool `json:\"stopped,omitempty\"`", + "storedACAExecution.Template map[string]any `json:\"template\"`", + "storedACAJob.Config map[string]any `json:\"config\"`", + "storedACAJob.Created time.Time `json:\"created\"`", + "storedACAJob.EnvironmentID string `json:\"environmentId\"`", + "storedACAJob.Executions []*storedACAExecution `json:\"executions,omitempty\"`", + "storedACAJob.Identity resourceIdentity `json:\"identity\"`", + "storedACAJob.Location string `json:\"location\"`", + "storedACAJob.Modified time.Time `json:\"modified\"`", + "storedACAJob.Name string `json:\"name\"`", + "storedACAJob.ResourceGroup string `json:\"resourceGroup\"`", + "storedACAJob.Sub string `json:\"sub\"`", + "storedACAJob.Tags map[string]string `json:\"tags,omitempty\"`", + "storedACAJob.Template map[string]any `json:\"template\"`", + "storedACAJob.WorkloadProfile string `json:\"workloadProfile,omitempty\"`", + "storedACARevision.Active bool `json:\"active\"`", + "storedACARevision.Created time.Time `json:\"created\"`", + "storedACARevision.LastActive time.Time `json:\"lastActive\"`", + "storedACARevision.Name string `json:\"name\"`", + "storedACARevision.Suffix string `json:\"suffix\"`", + "storedACARevision.Template map[string]any `json:\"template\"`", + "storedACAStorage.Name string `json:\"name\"`", + "storedACAStorage.Props map[string]any `json:\"props\"`", "storedACIGroup.Created time.Time `json:\"created\"`", + "storedACIGroup.Identity resourceIdentity `json:\"identity\"`", "storedACIGroup.Location string `json:\"location\"`", "storedACIGroup.Name string `json:\"name\"`", "storedACIGroup.Props aciPropsWire `json:\"props\"`", "storedACIGroup.ResourceGroup string `json:\"resourceGroup\"`", "storedACIGroup.Tags map[string]string `json:\"tags,omitempty\"`", + "storedACIGroup.Zones []string `json:\"zones,omitempty\"`", + "storedAKSBinding.Created time.Time `json:\"created\"`", + "storedAKSBinding.Modified time.Time `json:\"modified\"`", + "storedAKSBinding.Name string `json:\"name\"`", + "storedAKSBinding.Roles []string `json:\"roles\"`", + "storedAKSBinding.Source string `json:\"source\"`", + "storedAKSCluster.Bindings map[string]*storedAKSBinding `json:\"bindings,omitempty\"`", + "storedAKSCluster.CACert string `json:\"caCert\"`", + "storedAKSCluster.Commands map[string]*storedAKSCommand `json:\"commands,omitempty\"`", + "storedAKSCluster.Created time.Time `json:\"created\"`", + "storedAKSCluster.Current string `json:\"current\"`", + "storedAKSCluster.Fqdn string `json:\"fqdn\"`", + "storedAKSCluster.Identity resourceIdentity `json:\"identity\"`", + "storedAKSCluster.Kind string `json:\"kind,omitempty\"`", + "storedAKSCluster.Location string `json:\"location\"`", + "storedAKSCluster.Maint map[string]*storedAKSMaint `json:\"maint,omitempty\"`", + "storedAKSCluster.Modified time.Time `json:\"modified\"`", + "storedAKSCluster.Name string `json:\"name\"`", + "storedAKSCluster.NextSeq int `json:\"nextSeq\"`", + "storedAKSCluster.NodeRG string `json:\"nodeRG\"`", + "storedAKSCluster.Pools map[string]*storedAKSPool `json:\"pools\"`", + "storedAKSCluster.Props map[string]any `json:\"props\"`", + "storedAKSCluster.ProvState string `json:\"provState,omitempty\"`", + "storedAKSCluster.Requested string `json:\"requested\"`", + "storedAKSCluster.ResourceGroup string `json:\"resourceGroup\"`", + "storedAKSCluster.SkuName string `json:\"skuName\"`", + "storedAKSCluster.SkuTier string `json:\"skuTier\"`", + "storedAKSCluster.Stopped bool `json:\"stopped,omitempty\"`", + "storedAKSCluster.Sub string `json:\"sub\"`", + "storedAKSCluster.Tags map[string]string `json:\"tags,omitempty\"`", + "storedAKSCluster.Token string `json:\"token\"`", + "storedAKSCommand.ExitCode *int `json:\"exitCode,omitempty\"`", + "storedAKSCommand.Finished time.Time `json:\"finished\"`", + "storedAKSCommand.ID string `json:\"id\"`", + "storedAKSCommand.Logs string `json:\"logs,omitempty\"`", + "storedAKSCommand.Reason string `json:\"reason,omitempty\"`", + "storedAKSCommand.Started time.Time `json:\"started\"`", + "storedAKSCommand.State string `json:\"state\"`", + "storedAKSMaint.Created time.Time `json:\"created\"`", + "storedAKSMaint.Modified time.Time `json:\"modified\"`", + "storedAKSMaint.Name string `json:\"name\"`", + "storedAKSMaint.Props map[string]any `json:\"props\"`", + "storedAKSPool.Created time.Time `json:\"created\"`", + "storedAKSPool.Current string `json:\"current\"`", + "storedAKSPool.Modified time.Time `json:\"modified\"`", + "storedAKSPool.Name string `json:\"name\"`", + "storedAKSPool.NodeImage string `json:\"nodeImage\"`", + "storedAKSPool.Props map[string]any `json:\"props\"`", + "storedAKSPool.Seq int `json:\"seq\"`", + "storedAKSPool.SpecVersion string `json:\"specVersion,omitempty\"`", + "storedFederated.Audiences []string `json:\"audiences\"`", + "storedFederated.Issuer string `json:\"issuer\"`", + "storedFederated.Name string `json:\"name\"`", + "storedFederated.Subject string `json:\"subject\"`", + "storedIdentity.ClientID string `json:\"clientId\"`", + "storedIdentity.Federated []*storedFederated `json:\"federated,omitempty\"`", + "storedIdentity.Location string `json:\"location\"`", + "storedIdentity.Name string `json:\"name\"`", + "storedIdentity.PrincipalID string `json:\"principalId\"`", + "storedIdentity.ResourceGrp string `json:\"resourceGroup\"`", + "storedIdentity.Tags map[string]string `json:\"tags,omitempty\"`", + "storedLock.Level string `json:\"level\"`", + "storedLock.Name string `json:\"name\"`", + "storedLock.Notes string `json:\"notes,omitempty\"`", + "storedLock.Owners []string `json:\"owners,omitempty\"`", + "storedLock.Scope string `json:\"scope\"`", + "storedPolicy.CreatedOn time.Time `json:\"createdOn\"`", + "storedPolicy.Name string `json:\"name\"`", + "storedPolicy.Props map[string]any `json:\"props\"`", + "storedPolicy.Scope string `json:\"scope\"`", + "storedPolicy.UpdatedOn time.Time `json:\"updatedOn\"`", + "storedPolicyAssign.CreatedOn time.Time `json:\"createdOn\"`", + "storedPolicyAssign.Extra map[string]any `json:\"extra,omitempty\"`", + "storedPolicyAssign.IdentityTyp string `json:\"identityType,omitempty\"`", + "storedPolicyAssign.Location string `json:\"location,omitempty\"`", + "storedPolicyAssign.Name string `json:\"name\"`", + "storedPolicyAssign.PrincipalID string `json:\"principalId,omitempty\"`", + "storedPolicyAssign.Props map[string]any `json:\"props\"`", + "storedPolicyAssign.Scope string `json:\"scope\"`", + "storedPolicyAssign.UpdatedOn time.Time `json:\"updatedOn\"`", + "storedRoleAssign.Condition string `json:\"condition,omitempty\"`", + "storedRoleAssign.ConditionVersion string `json:\"conditionVersion,omitempty\"`", + "storedRoleAssign.CreatedOn time.Time `json:\"createdOn\"`", + "storedRoleAssign.DelegatedMI string `json:\"delegatedManagedIdentityResourceId,omitempty\"`", + "storedRoleAssign.Description string `json:\"description,omitempty\"`", + "storedRoleAssign.Name string `json:\"name\"`", + "storedRoleAssign.PrincipalID string `json:\"principalId\"`", + "storedRoleAssign.PrincipalType string `json:\"principalType,omitempty\"`", + "storedRoleAssign.RoleDefinitionID string `json:\"roleDefinitionId\"`", + "storedRoleAssign.RoleGUID string `json:\"roleGuid\"`", + "storedRoleAssign.Scope string `json:\"scope\"`", + "storedRoleAssign.UpdatedOn time.Time `json:\"updatedOn\"`", + "storedRoleDef.AssignableScopes []string `json:\"assignableScopes\"`", + "storedRoleDef.CreatedOn time.Time `json:\"createdOn\"`", + "storedRoleDef.Description string `json:\"description\"`", + "storedRoleDef.Name string `json:\"name\"`", + "storedRoleDef.Permissions []authPermission `json:\"permissions\"`", + "storedRoleDef.RoleName string `json:\"roleName\"`", + "storedRoleDef.Scope string `json:\"scope\"`", + "storedRoleDef.UpdatedOn time.Time `json:\"updatedOn\"`", "storedWebPlan.Created time.Time `json:\"created\"`", "storedWebPlan.Extra map[string]any `json:\"extra,omitempty\"`", "storedWebPlan.Kind string `json:\"kind\"`", @@ -3362,10 +3676,18 @@ "storedWebSite.Extra map[string]any `json:\"extra,omitempty\"`", "storedWebSite.Identity map[string]any `json:\"identity,omitempty\"`", "storedWebSite.Kind string `json:\"kind\"`", + "storedWebSite.LastSwap *swapStatus `json:\"lastSwap,omitempty\"`", "storedWebSite.Location string `json:\"location\"`", + "storedWebSite.Managed resourceIdentity `json:\"managedIdentity\"`", "storedWebSite.Name string `json:\"name\"`", + "storedWebSite.Pending *pendingSwap `json:\"pendingSwap,omitempty\"`", "storedWebSite.ResourceGroup string `json:\"resourceGroup\"`", + "storedWebSite.Slot string `json:\"slot,omitempty\"`", + "storedWebSite.Slots map[string]*storedWebSite `json:\"slots,omitempty\"`", "storedWebSite.Tags map[string]string `json:\"tags,omitempty\"`", + "swapStatus.Destination string `json:\"destination\"`", + "swapStatus.Source string `json:\"source\"`", + "swapStatus.Time time.Time `json:\"time\"`", "webSnapshot.Plans map[string]*storedWebPlan `json:\"plans,omitempty\"`", "webSnapshot.Sites map[string]*storedWebSite `json:\"sites,omitempty\"`" ], @@ -3706,6 +4028,7 @@ "appState.Name string `json:\"name\"`", "appState.PublishPassword string `json:\"publishPassword\"`", "appState.Settings map[string]string `json:\"settings\"`", + "appState.Slot string `json:\"slot,omitempty\"`", "appState.Stopped bool `json:\"stopped\"`", "appState.SystemKeys map[string]string `json:\"systemKeys\"`", "appState.Zip []byte `json:\"zip\"`", @@ -3713,6 +4036,205 @@ ], "version": 1 }, + "azuregraph": { + "fields": [ + "APIApplication.AcceptMappedClaims *bool `json:\"acceptMappedClaims\"`", + "APIApplication.KnownClientApplications []string `json:\"knownClientApplications\"`", + "APIApplication.OAuth2PermissionScopes []PermissionScope `json:\"oauth2PermissionScopes\"`", + "APIApplication.PreAuthorizedApplications []PreAuthorizedApplication `json:\"preAuthorizedApplications\"`", + "APIApplication.RequestedAccessTokenVersion *int `json:\"requestedAccessTokenVersion\"`", + "AppRole.AllowedMemberTypes []string `json:\"allowedMemberTypes\"`", + "AppRole.Description string `json:\"description,omitempty\"`", + "AppRole.DisplayName string `json:\"displayName,omitempty\"`", + "AppRole.ID string `json:\"id\"`", + "AppRole.IsEnabled bool `json:\"isEnabled\"`", + "AppRole.Origin string `json:\"origin,omitempty\"`", + "AppRole.Value string `json:\"value,omitempty\"`", + "AppRoleAssignment.AppRoleID string `json:\"appRoleId\"`", + "AppRoleAssignment.CreatedDateTime time.Time `json:\"createdDateTime\"`", + "AppRoleAssignment.ID string `json:\"id\"`", + "AppRoleAssignment.PrincipalDisplayName string `json:\"principalDisplayName\"`", + "AppRoleAssignment.PrincipalID string `json:\"principalId\"`", + "AppRoleAssignment.PrincipalType string `json:\"principalType\"`", + "AppRoleAssignment.ResourceDisplayName string `json:\"resourceDisplayName\"`", + "AppRoleAssignment.ResourceID string `json:\"resourceId\"`", + "Application.API APIApplication `json:\"api\"`", + "Application.AddIns json.RawMessage `json:\"addIns,omitempty\"`", + "Application.AppID string `json:\"appId\"`", + "Application.AppRoles []AppRole `json:\"appRoles\"`", + "Application.ApplicationTemplateID string `json:\"applicationTemplateId,omitempty\"`", + "Application.AuthenticationBehaviors json.RawMessage `json:\"authenticationBehaviors,omitempty\"`", + "Application.Certification json.RawMessage `json:\"certification,omitempty\"`", + "Application.CreatedDateTime time.Time `json:\"createdDateTime\"`", + "Application.DefaultRedirectURI string `json:\"defaultRedirectUri,omitempty\"`", + "Application.Description string `json:\"description,omitempty\"`", + "Application.DisabledByMicrosoftStatus json.RawMessage `json:\"disabledByMicrosoftStatus,omitempty\"`", + "Application.DisplayName string `json:\"displayName\"`", + "Application.GroupMembershipClaims string `json:\"groupMembershipClaims,omitempty\"`", + "Application.ID string `json:\"id\"`", + "Application.IdentifierURIs []string `json:\"identifierUris\"`", + "Application.Info json.RawMessage `json:\"info,omitempty\"`", + "Application.IsDeviceOnlyAuthSupported *bool `json:\"isDeviceOnlyAuthSupported\"`", + "Application.IsFallbackPublicClient bool `json:\"isFallbackPublicClient\"`", + "Application.KeyCredentials []KeyCredential `json:\"keyCredentials\"`", + "Application.Notes string `json:\"notes,omitempty\"`", + "Application.OAuth2RequirePostResponse bool `json:\"oauth2RequirePostResponse\"`", + "Application.OptionalClaims json.RawMessage `json:\"optionalClaims,omitempty\"`", + "Application.ParentalControlSettings json.RawMessage `json:\"parentalControlSettings,omitempty\"`", + "Application.PasswordCredentials []PasswordCredential `json:\"passwordCredentials\"`", + "Application.PublicClient RedirectFacet `json:\"publicClient\"`", + "Application.PublisherDomain string `json:\"publisherDomain\"`", + "Application.RequiredResourceAccess []RequiredResourceAccess `json:\"requiredResourceAccess\"`", + "Application.SAMLMetadataURL string `json:\"samlMetadataUrl,omitempty\"`", + "Application.SPA RedirectFacet `json:\"spa\"`", + "Application.ServiceManagementReference string `json:\"serviceManagementReference,omitempty\"`", + "Application.SignInAudience string `json:\"signInAudience\"`", + "Application.Tags []string `json:\"tags\"`", + "Application.TokenEncryptionKeyID string `json:\"tokenEncryptionKeyId,omitempty\"`", + "Application.UniqueName string `json:\"uniqueName,omitempty\"`", + "Application.VerifiedPublisher json.RawMessage `json:\"verifiedPublisher,omitempty\"`", + "Application.Web WebApplication `json:\"web\"`", + "Group.Classification string `json:\"classification,omitempty\"`", + "Group.CreatedDateTime time.Time `json:\"createdDateTime\"`", + "Group.Description string `json:\"description,omitempty\"`", + "Group.DisplayName string `json:\"displayName\"`", + "Group.GroupTypes []string `json:\"groupTypes\"`", + "Group.ID string `json:\"id\"`", + "Group.IsAssignableToRole *bool `json:\"isAssignableToRole\"`", + "Group.Mail string `json:\"mail,omitempty\"`", + "Group.MailEnabled bool `json:\"mailEnabled\"`", + "Group.MailNickname string `json:\"mailNickname\"`", + "Group.MembershipRule string `json:\"membershipRule,omitempty\"`", + "Group.MembershipRuleProcessingState string `json:\"membershipRuleProcessingState,omitempty\"`", + "Group.PreferredDataLocation string `json:\"preferredDataLocation,omitempty\"`", + "Group.ResourceBehaviorOptions []string `json:\"resourceBehaviorOptions\"`", + "Group.ResourceProvisioningOptions []string `json:\"resourceProvisioningOptions\"`", + "Group.SecurityEnabled bool `json:\"securityEnabled\"`", + "Group.Visibility string `json:\"visibility,omitempty\"`", + "ImplicitGrantSettings.EnableAccessTokenIssuance bool `json:\"enableAccessTokenIssuance\"`", + "ImplicitGrantSettings.EnableIDTokenIssuance bool `json:\"enableIdTokenIssuance\"`", + "KeyCredential.CustomKeyIdentifier *string `json:\"customKeyIdentifier\"`", + "KeyCredential.DisplayName *string `json:\"displayName\"`", + "KeyCredential.EndDateTime time.Time `json:\"endDateTime\"`", + "KeyCredential.Key *string `json:\"key\"`", + "KeyCredential.KeyID string `json:\"keyId\"`", + "KeyCredential.StartDateTime time.Time `json:\"startDateTime\"`", + "KeyCredential.Type string `json:\"type\"`", + "KeyCredential.Usage string `json:\"usage\"`", + "OAuth2PermissionGrant.ClientID string `json:\"clientId\"`", + "OAuth2PermissionGrant.ConsentType string `json:\"consentType\"`", + "OAuth2PermissionGrant.ID string `json:\"id\"`", + "OAuth2PermissionGrant.PrincipalID string `json:\"principalId,omitempty\"`", + "OAuth2PermissionGrant.ResourceID string `json:\"resourceId\"`", + "OAuth2PermissionGrant.Scope string `json:\"scope\"`", + "PasswordCredential.CustomKeyIdentifier *string `json:\"customKeyIdentifier\"`", + "PasswordCredential.DisplayName *string `json:\"displayName\"`", + "PasswordCredential.EndDateTime time.Time `json:\"endDateTime\"`", + "PasswordCredential.Hint *string `json:\"hint\"`", + "PasswordCredential.KeyID string `json:\"keyId\"`", + "PasswordCredential.SecretText *string `json:\"secretText\"`", + "PasswordCredential.StartDateTime time.Time `json:\"startDateTime\"`", + "PasswordProfile.ForceChangePasswordNextMFA bool `json:\"forceChangePasswordNextSignInWithMfa\"`", + "PasswordProfile.ForceChangePasswordNextSignIn bool `json:\"forceChangePasswordNextSignIn\"`", + "PasswordProfile.Password string `json:\"password\"`", + "PermissionScope.AdminConsentDescription string `json:\"adminConsentDescription,omitempty\"`", + "PermissionScope.AdminConsentDisplayName string `json:\"adminConsentDisplayName,omitempty\"`", + "PermissionScope.ID string `json:\"id\"`", + "PermissionScope.IsEnabled bool `json:\"isEnabled\"`", + "PermissionScope.Type string `json:\"type,omitempty\"`", + "PermissionScope.UserConsentDescription string `json:\"userConsentDescription,omitempty\"`", + "PermissionScope.UserConsentDisplayName string `json:\"userConsentDisplayName,omitempty\"`", + "PermissionScope.Value string `json:\"value,omitempty\"`", + "PreAuthorizedApplication.AppID string `json:\"appId\"`", + "PreAuthorizedApplication.DelegatedPermissionIDs []string `json:\"delegatedPermissionIds\"`", + "RedirectFacet.RedirectURIs []string `json:\"redirectUris\"`", + "RequiredResourceAccess.ResourceAccess []ResourceAccess `json:\"resourceAccess\"`", + "RequiredResourceAccess.ResourceAppID string `json:\"resourceAppId\"`", + "ResourceAccess.ID string `json:\"id\"`", + "ResourceAccess.Type string `json:\"type\"`", + "ServicePrincipal.AccountEnabled bool `json:\"accountEnabled\"`", + "ServicePrincipal.AddIns json.RawMessage `json:\"addIns,omitempty\"`", + "ServicePrincipal.AlternativeNames []string `json:\"alternativeNames\"`", + "ServicePrincipal.AppDescription string `json:\"appDescription,omitempty\"`", + "ServicePrincipal.AppDisplayName string `json:\"appDisplayName\"`", + "ServicePrincipal.AppID string `json:\"appId\"`", + "ServicePrincipal.AppOwnerOrganizationID string `json:\"appOwnerOrganizationId,omitempty\"`", + "ServicePrincipal.AppRoleAssignmentRequired bool `json:\"appRoleAssignmentRequired\"`", + "ServicePrincipal.AppRoles []AppRole `json:\"appRoles\"`", + "ServicePrincipal.ApplicationTemplateID string `json:\"applicationTemplateId,omitempty\"`", + "ServicePrincipal.CreatedDateTime time.Time `json:\"createdDateTime\"`", + "ServicePrincipal.CustomSecurityAttributes json.RawMessage `json:\"customSecurityAttributes,omitempty\"`", + "ServicePrincipal.Description string `json:\"description,omitempty\"`", + "ServicePrincipal.DisabledByMicrosoftStatus json.RawMessage `json:\"disabledByMicrosoftStatus,omitempty\"`", + "ServicePrincipal.DisplayName string `json:\"displayName\"`", + "ServicePrincipal.Homepage string `json:\"homepage,omitempty\"`", + "ServicePrincipal.ID string `json:\"id\"`", + "ServicePrincipal.Info json.RawMessage `json:\"info,omitempty\"`", + "ServicePrincipal.KeyCredentials []KeyCredential `json:\"keyCredentials\"`", + "ServicePrincipal.LoginURL string `json:\"loginUrl,omitempty\"`", + "ServicePrincipal.LogoutURL string `json:\"logoutUrl,omitempty\"`", + "ServicePrincipal.Notes string `json:\"notes,omitempty\"`", + "ServicePrincipal.NotificationEmailAddresses []string `json:\"notificationEmailAddresses\"`", + "ServicePrincipal.OAuth2PermissionScopes []PermissionScope `json:\"oauth2PermissionScopes\"`", + "ServicePrincipal.PasswordCredentials []PasswordCredential `json:\"passwordCredentials\"`", + "ServicePrincipal.PasswordSSOSettings json.RawMessage `json:\"passwordSingleSignOnSettings,omitempty\"`", + "ServicePrincipal.PreferredKeyEndDateTime json.RawMessage `json:\"preferredTokenSigningKeyEndDateTime,omitempty\"`", + "ServicePrincipal.PreferredSingleSignOnMode string `json:\"preferredSingleSignOnMode,omitempty\"`", + "ServicePrincipal.PreferredTokenSigningKeyThumb string `json:\"preferredTokenSigningKeyThumbprint,omitempty\"`", + "ServicePrincipal.ReplyURLs []string `json:\"replyUrls\"`", + "ServicePrincipal.ResourceSpecificPermissions json.RawMessage `json:\"resourceSpecificApplicationPermissions,omitempty\"`", + "ServicePrincipal.SAMLMetadataURL string `json:\"samlMetadataUrl,omitempty\"`", + "ServicePrincipal.SAMLSingleSignOnSettings json.RawMessage `json:\"samlSingleSignOnSettings,omitempty\"`", + "ServicePrincipal.ServicePrincipalNames []string `json:\"servicePrincipalNames\"`", + "ServicePrincipal.ServicePrincipalType string `json:\"servicePrincipalType\"`", + "ServicePrincipal.SignInAudience string `json:\"signInAudience\"`", + "ServicePrincipal.Tags []string `json:\"tags\"`", + "ServicePrincipal.TokenEncryptionKeyID string `json:\"tokenEncryptionKeyId,omitempty\"`", + "ServicePrincipal.VerifiedPublisher json.RawMessage `json:\"verifiedPublisher,omitempty\"`", + "User.AccountEnabled bool `json:\"accountEnabled\"`", + "User.BusinessPhones []string `json:\"businessPhones\"`", + "User.City string `json:\"city,omitempty\"`", + "User.CompanyName string `json:\"companyName,omitempty\"`", + "User.Country string `json:\"country,omitempty\"`", + "User.CreatedDateTime time.Time `json:\"createdDateTime\"`", + "User.Department string `json:\"department,omitempty\"`", + "User.DisplayName string `json:\"displayName\"`", + "User.EmployeeID string `json:\"employeeId,omitempty\"`", + "User.GivenName string `json:\"givenName,omitempty\"`", + "User.ID string `json:\"id\"`", + "User.JobTitle string `json:\"jobTitle,omitempty\"`", + "User.Mail string `json:\"mail,omitempty\"`", + "User.MailNickname string `json:\"mailNickname\"`", + "User.MobilePhone string `json:\"mobilePhone,omitempty\"`", + "User.OfficeLocation string `json:\"officeLocation,omitempty\"`", + "User.OnPremImmutableID string `json:\"onPremisesImmutableId,omitempty\"`", + "User.OtherMails []string `json:\"otherMails\"`", + "User.PasswordPolicies string `json:\"passwordPolicies,omitempty\"`", + "User.PasswordProfile *PasswordProfile `json:\"passwordProfile,omitempty\"`", + "User.PostalCode string `json:\"postalCode,omitempty\"`", + "User.PreferredLanguage string `json:\"preferredLanguage,omitempty\"`", + "User.State string `json:\"state,omitempty\"`", + "User.StreetAddress string `json:\"streetAddress,omitempty\"`", + "User.Surname string `json:\"surname,omitempty\"`", + "User.UsageLocation string `json:\"usageLocation,omitempty\"`", + "User.UserPrincipalName string `json:\"userPrincipalName\"`", + "User.UserType string `json:\"userType\"`", + "WebApplication.HomePageURL string `json:\"homePageUrl\"`", + "WebApplication.ImplicitGrantSettings ImplicitGrantSettings `json:\"implicitGrantSettings\"`", + "WebApplication.LogoutURL string `json:\"logoutUrl\"`", + "WebApplication.RedirectURIs []string `json:\"redirectUris\"`", + "backendSnapshot.Applications []Application `json:\"applications\"`", + "backendSnapshot.Assignments []AppRoleAssignment `json:\"appRoleAssignments\"`", + "backendSnapshot.Grants []OAuth2PermissionGrant `json:\"oauth2PermissionGrants\"`", + "backendSnapshot.Groups []Group `json:\"groups\"`", + "backendSnapshot.Members map[string][]string `json:\"members\"`", + "backendSnapshot.Owners map[string][]string `json:\"owners\"`", + "backendSnapshot.Principals []ServicePrincipal `json:\"servicePrincipals\"`", + "backendSnapshot.Secrets map[string]string `json:\"secrets\"`", + "backendSnapshot.Users []User `json:\"users\"`" + ], + "version": 1 + }, "azurekeyvault": { "fields": [ "AdminDetails.Email string `json:\"email_address,omitempty\"`", @@ -4534,10 +5056,10 @@ "Job.StoppedAt *int64 `json:\"stoppedAt,omitempty\"`", "Job.Tags map[string]string `json:\"tags\"`", "Job.Timeout *JobTimeout `json:\"timeout,omitempty\"`", - "Job.attemptCount int32", "Job.isMainNode bool", "Job.nodeCount int32", "Job.nodeIndex *int32", + "Job.nodeRuns []nodeRun", "Job.region string", "JobAttempt.Container *JobAttemptContainer `json:\"container,omitempty\"`", "JobAttempt.StartedAt *int64 `json:\"startedAt,omitempty\"`", @@ -4715,7 +5237,12 @@ "backendSnapshot.AccountID string `json:\"accountID\"`", "backendSnapshot.JobDefRevisions map[string]map[string]int32 `json:\"jobDefRevisions\"`", "backendSnapshot.Region string `json:\"region\"`", - "backendSnapshot.Tables map[string]json.RawMessage `json:\"tables\"`" + "backendSnapshot.Tables map[string]json.RawMessage `json:\"tables\"`", + "nodeRun.attempts []JobAttempt", + "nodeRun.startedAt *int64", + "nodeRun.status string", + "nodeRun.statusReason string", + "nodeRun.stoppedAt *int64" ], "version": 1 }, @@ -8384,6 +8911,9 @@ "PipelineVariable.DefaultValue string `json:\"defaultValue,omitempty\"`", "PipelineVariable.Description string `json:\"description,omitempty\"`", "PipelineVariable.Name string `json:\"name\"`", + "RepositorySnapshot.CommitID string", + "RepositorySnapshot.CommitMessage string", + "RepositorySnapshot.Files map[string][]byte", "ResolvedPipelineVariable.Name string `json:\"name\"`", "ResolvedPipelineVariable.ResolvedValue string `json:\"resolvedValue\"`", "RetryConfiguration.RetryMode string `json:\"retryMode,omitempty\"`", @@ -9060,8 +9590,17 @@ "fields": [ "AccountRegion.FailoverPriority int `json:\"failoverPriority\"`", "AccountRegion.Name string `json:\"name\"`", + "AccountRegion.Offline bool `json:\"offline,omitempty\"`", + "AccountRegion.ReplicationLagMs int `json:\"replicationLagMs,omitempty\"`", + "AccountRegions.Backup string `json:\"backup,omitempty\"`", + "AccountRegions.Consistency *ConsistencyPolicy `json:\"consistency,omitempty\"`", "AccountRegions.MultiWrite bool `json:\"multiWrite,omitempty\"`", + "AccountRegions.NoAutoFailover bool `json:\"noAutoFailover,omitempty\"`", "AccountRegions.Regions []AccountRegion `json:\"regions,omitempty\"`", + "AccountRegions.Write string `json:\"write,omitempty\"`", + "ConsistencyPolicy.Level string `json:\"defaultConsistencyLevel\"`", + "ConsistencyPolicy.MaxIntervalInSeconds int `json:\"maxIntervalInSeconds,omitempty\"`", + "ConsistencyPolicy.MaxStalenessPrefix int64 `json:\"maxStalenessPrefix,omitempty\"`", "ThroughputSpec.AutoscaleMax int `json:\"AutoscaleMax,omitempty\"`", "ThroughputSpec.IncrementPercent int `json:\"IncrementPercent,omitempty\"`", "ThroughputSpec.Manual int `json:\"Throughput,omitempty\"`", @@ -9077,12 +9616,45 @@ "changeRecord.Seq int64 `json:\"Seq\"`", "changeRecord.TTLExpired bool `json:\"TTLExpired,omitempty\"`", "changeRecord.Time time.Time `json:\"Time\"`", + "conflictRecord.Doc *storedDocument `json:\"Doc,omitempty\"`", + "conflictRecord.DocID string `json:\"DocID\"`", + "conflictRecord.ID string `json:\"ID\"`", + "conflictRecord.LSN int64 `json:\"LSN\"`", + "conflictRecord.Op string `json:\"Op\"`", + "conflictRecord.PartitionKeyJSON string `json:\"PartitionKeyJSON\"`", + "conflictRecord.Region string `json:\"Region\"`", + "conflictRecord.Time time.Time `json:\"Time\"`", "documentCompositeKey.ID string", "documentCompositeKey.PartitionKeyJSON string", + "pkLayout.NextID int `json:\"nextId\"`", + "pkLayout.PendingDue time.Time `json:\"pendingDue,omitzero\"`", + "pkLayout.PendingTarget int `json:\"pendingTarget,omitempty\"`", + "pkLayout.Spans []pkSpan `json:\"spans\"`", + "pkSpan.ID int `json:\"id\"`", + "pkSpan.Lo u128 `json:\"lo\"`", + "pkSpan.Parents []int `json:\"parents,omitempty\"`", + "replEvent.At time.Time", + "replEvent.Base int64", + "replEvent.Dest string", + "replEvent.Doc *storedDocument", + "replEvent.Due time.Time", + "replEvent.Key documentCompositeKey", + "replEvent.LSN int64", + "replEvent.Op string", + "replEvent.Origin string", + "replEvent.Stale bool", + "replState.applied map[string]map[string]int64", + "replState.events []*replEvent", + "replState.home string", + "replState.lsn int64", + "replState.stores map[string]*storedContainer", + "replState.tombs map[string]map[documentCompositeKey]tombstone", + "storedContainer.ChangeFloor int64 `json:\"ChangeFloor,omitempty\"`", "storedContainer.ChangeLog []*changeRecord `json:\"ChangeLog,omitempty\"`", "storedContainer.ChangeSeq int64 `json:\"ChangeSeq,omitempty\"`", "storedContainer.ComputedProperties json.RawMessage `json:\"ComputedProperties,omitempty\"`", "storedContainer.ConflictResolutionPolicy json.RawMessage `json:\"ConflictResolutionPolicy,omitempty\"`", + "storedContainer.Conflicts []*conflictRecord `json:\"Conflicts,omitempty\"`", "storedContainer.DefaultTTL *int `json:\"DefaultTTL,omitempty\"`", "storedContainer.Documents map[documentCompositeKey]*storedDocument", "storedContainer.FullTextPolicy json.RawMessage `json:\"FullTextPolicy,omitempty\"`", @@ -9091,6 +9663,7 @@ "storedContainer.IndexingPolicy json.RawMessage `json:\"IndexingPolicy,omitempty\"`", "storedContainer.Modified time.Time `json:\"Modified\"`", "storedContainer.Offer *storedOffer `json:\"Offer,omitempty\"`", + "storedContainer.PKLayout *pkLayout `json:\"PKLayout,omitempty\"`", "storedContainer.PartitionKeyPath string", "storedContainer.PartitionKeyPaths []string `json:\"PartitionKeyPaths,omitempty\"`", "storedContainer.Sprocs map[string]*storedScript `json:\"Sprocs,omitempty\"`", @@ -9098,6 +9671,8 @@ "storedContainer.UDFs map[string]*storedScript `json:\"UDFs,omitempty\"`", "storedContainer.UniqueKeyPolicy json.RawMessage `json:\"UniqueKeyPolicy,omitempty\"`", "storedContainer.VectorEmbeddingPolicy json.RawMessage `json:\"VectorEmbeddingPolicy,omitempty\"`", + "storedContainer.keep time.Duration", + "storedContainer.repl *replState", "storedContainer.sorted atomic.Pointer[[]*storedDocument]", "storedDatabase.Containers map[string]*storedContainer", "storedDatabase.ID string", @@ -9105,12 +9680,15 @@ "storedDatabase.Offer *storedOffer `json:\"Offer,omitempty\"`", "storedDatabase.Users map[string]*storedUser `json:\"Users,omitempty\"`", "storedDocument.Body map[string]any", + "storedDocument.GLSN int64", "storedDocument.ID string", + "storedDocument.Origin string", "storedDocument.PartitionKeyJSON string", "storedDocument.Seq int64", "storedDocument.Timestamp time.Time", "storedDocument.{MarshalJSON}", "storedDocument.{UnmarshalJSON}", + "storedOffer.HighestSeen int `json:\"HighestSeen,omitempty\"`", "storedOffer.Modified time.Time `json:\"Modified\"`", "storedPermission.ID string", "storedPermission.Link string", @@ -9126,7 +9704,10 @@ "storedScript.TriggerType string `json:\",omitempty\"`", "storedUser.ID string", "storedUser.Modified time.Time `json:\"Modified\"`", - "storedUser.Permissions map[string]*storedPermission" + "storedUser.Permissions map[string]*storedPermission", + "tombstone.lsn int64", + "u128.hi uint64", + "u128.lo uint64" ], "version": 1 }, @@ -10259,15 +10840,24 @@ "ReplicationTask.CdcStartPosition string `json:\"cdcStartPosition,omitempty\"`", "ReplicationTask.CdcStopPosition string `json:\"cdcStopPosition,omitempty\"`", "ReplicationTask.CreationTime time.Time `json:\"creationTime\"`", + "ReplicationTask.ElapsedTimeMillis int64 `json:\"-\"`", + "ReplicationTask.FullLoadFinishDate time.Time `json:\"fullLoadFinishDate,omitzero\"`", + "ReplicationTask.FullLoadStartDate time.Time `json:\"fullLoadStartDate,omitzero\"`", + "ReplicationTask.LastFailureMessage string `json:\"lastFailureMessage,omitempty\"`", "ReplicationTask.MigrationType string `json:\"migrationType\"`", + "ReplicationTask.RecoveryCheckpoint string `json:\"recoveryCheckpoint,omitempty\"`", "ReplicationTask.Region string `json:\"region\"`", "ReplicationTask.ReplicationInstanceArn string `json:\"replicationInstanceArn\"`", "ReplicationTask.ReplicationTaskArn string `json:\"replicationTaskArn\"`", "ReplicationTask.ReplicationTaskIdentifier string `json:\"replicationTaskIdentifier\"`", "ReplicationTask.ReplicationTaskSettings string `json:\"replicationTaskSettings,omitempty\"`", "ReplicationTask.SourceEndpointArn string `json:\"sourceEndpointArn\"`", + "ReplicationTask.StartDate time.Time `json:\"startDate,omitzero\"`", "ReplicationTask.Status string `json:\"status\"`", + "ReplicationTask.StopDate time.Time `json:\"stopDate,omitzero\"`", + "ReplicationTask.StopReason string `json:\"stopReason,omitempty\"`", "ReplicationTask.TableMappings string `json:\"tableMappings,omitempty\"`", + "ReplicationTask.TableStats []TableStatistic `json:\"tableStats,omitempty\"`", "ReplicationTask.Tags *tags.Tags `json:\"-\"`", "ReplicationTask.TargetEndpointArn string `json:\"targetEndpointArn\"`", "ReplicationTask.TaskData string `json:\"taskData,omitempty\"`", @@ -10277,6 +10867,18 @@ "SourceDataSetting.CDCStartTime string `json:\"cdcStartTime,omitempty\"`", "SourceDataSetting.CDCStopTime string `json:\"cdcStopTime,omitempty\"`", "SourceDataSetting.SlotName string `json:\"slotName,omitempty\"`", + "TableStatistic.Deletes int64 `json:\"deletes\"`", + "TableStatistic.FullLoadEndTime time.Time `json:\"fullLoadEndTime,omitzero\"`", + "TableStatistic.FullLoadErrorRows int64 `json:\"fullLoadErrorRows\"`", + "TableStatistic.FullLoadReloaded bool `json:\"fullLoadReloaded,omitempty\"`", + "TableStatistic.FullLoadRows int64 `json:\"fullLoadRows\"`", + "TableStatistic.FullLoadStartTime time.Time `json:\"fullLoadStartTime,omitzero\"`", + "TableStatistic.Inserts int64 `json:\"inserts\"`", + "TableStatistic.LastUpdateTime time.Time `json:\"lastUpdateTime,omitzero\"`", + "TableStatistic.SchemaName string `json:\"schemaName\"`", + "TableStatistic.TableName string `json:\"tableName\"`", + "TableStatistic.TableState string `json:\"tableState\"`", + "TableStatistic.Updates int64 `json:\"updates\"`", "TargetDataSetting.TablePreparationMode string `json:\"tablePreparationMode,omitempty\"`", "backendSnapshot.AccountID string `json:\"accountID\"`", "backendSnapshot.Region string `json:\"region\"`", @@ -10742,6 +11344,7 @@ "AMIStub.BlockDeviceMappings []ImageBlockDeviceMapping `json:\"blockDeviceMappings,omitempty\"`", "AMIStub.CreationTime time.Time `json:\"creationTime,omitzero\"`", "AMIStub.Description string `json:\"description,omitempty\"`", + "AMIStub.DockerImage string `json:\"dockerImage,omitempty\"`", "AMIStub.EnaSupport bool `json:\"enaSupport,omitempty\"`", "AMIStub.EnaSupportSet bool `json:\"enaSupportSet,omitempty\"`", "AMIStub.ImageID string `json:\"imageID,omitempty\"`", @@ -12740,15 +13343,23 @@ "Container.TaskArn string `json:\"taskArn,omitempty\"`", "ContainerDefinition.CPU int `json:\"cpu,omitempty\"`", "ContainerDefinition.Command []string `json:\"command,omitempty\"`", + "ContainerDefinition.DNSSearchDomains []string `json:\"dnsSearchDomains,omitempty\"`", + "ContainerDefinition.DNSServers []string `json:\"dnsServers,omitempty\"`", "ContainerDefinition.DependsOn []ContainerDependency `json:\"dependsOn,omitempty\"`", "ContainerDefinition.DisableNetworking bool `json:\"disableNetworking,omitempty\"`", + "ContainerDefinition.DockerLabels map[string]string `json:\"dockerLabels,omitempty\"`", + "ContainerDefinition.DockerSecurityOptions []string `json:\"dockerSecurityOptions,omitempty\"`", "ContainerDefinition.EntryPoint []string `json:\"entryPoint,omitempty\"`", "ContainerDefinition.Environment []KeyValuePair `json:\"environment,omitempty\"`", "ContainerDefinition.EnvironmentFiles []EnvironmentFile `json:\"environmentFiles,omitempty\"`", "ContainerDefinition.Essential bool `json:\"essential\"`", + "ContainerDefinition.ExtraHosts []HostEntry `json:\"extraHosts,omitempty\"`", "ContainerDefinition.FirelensConfiguration *FirelensConfiguration `json:\"firelensConfiguration,omitempty\"`", "ContainerDefinition.HealthCheck *HealthCheck `json:\"healthCheck,omitempty\"`", + "ContainerDefinition.Hostname string `json:\"hostname,omitempty\"`", "ContainerDefinition.Image string `json:\"image\"`", + "ContainerDefinition.Interactive bool `json:\"interactive,omitempty\"`", + "ContainerDefinition.LinuxParameters *LinuxParameters `json:\"linuxParameters,omitempty\"`", "ContainerDefinition.LogConfiguration *LogConfiguration `json:\"logConfiguration,omitempty\"`", "ContainerDefinition.Memory int `json:\"memory,omitempty\"`", "ContainerDefinition.MemoryReservation int `json:\"memoryReservation,omitempty\"`", @@ -12756,11 +13367,19 @@ "ContainerDefinition.Name string `json:\"name\"`", "ContainerDefinition.PortMappings []PortMapping `json:\"portMappings,omitempty\"`", "ContainerDefinition.Privileged bool `json:\"privileged,omitempty\"`", + "ContainerDefinition.PseudoTerminal bool `json:\"pseudoTerminal,omitempty\"`", "ContainerDefinition.ReadonlyRootFilesystem bool `json:\"readonlyRootFilesystem,omitempty\"`", "ContainerDefinition.RepositoryCredentials *RepositoryCredentials `json:\"repositoryCredentials,omitempty\"`", "ContainerDefinition.ResourceRequirements []ResourceRequirement `json:\"resourceRequirements,omitempty\"`", + "ContainerDefinition.RestartPolicy *ContainerRestartPolicy `json:\"restartPolicy,omitempty\"`", "ContainerDefinition.Secrets []SecretReference `json:\"secrets,omitempty\"`", + "ContainerDefinition.StartTimeout *int `json:\"startTimeout,omitempty\"`", + "ContainerDefinition.StopTimeout *int `json:\"stopTimeout,omitempty\"`", + "ContainerDefinition.SystemControls []SystemControl `json:\"systemControls,omitempty\"`", + "ContainerDefinition.Ulimits []Ulimit `json:\"ulimits,omitempty\"`", + "ContainerDefinition.User string `json:\"user,omitempty\"`", "ContainerDefinition.VolumesFrom []VolumeFrom `json:\"volumesFrom,omitempty\"`", + "ContainerDefinition.WorkingDirectory string `json:\"workingDirectory,omitempty\"`", "ContainerDefinition.{UnmarshalJSON}", "ContainerDependency.Condition string `json:\"condition\"`", "ContainerDependency.ContainerName string `json:\"containerName\"`", @@ -12786,6 +13405,9 @@ "ContainerOverride.Memory *int `json:\"memory,omitempty\"`", "ContainerOverride.Name string `json:\"name\"`", "ContainerOverride.Secrets []SecretReference `json:\"secrets,omitempty\"`", + "ContainerRestartPolicy.Enabled bool `json:\"enabled\"`", + "ContainerRestartPolicy.IgnoredExitCodes []int `json:\"ignoredExitCodes,omitempty\"`", + "ContainerRestartPolicy.RestartAttemptPeriod int `json:\"restartAttemptPeriod,omitempty\"`", "Daemon.CapacityProviderArns []string `json:\"capacityProviderArns,omitempty\"`", "Daemon.ClusterArn string `json:\"clusterArn\"`", "Daemon.CreatedAt time.Time `json:\"createdAt\"`", @@ -12906,6 +13528,14 @@ "DeploymentLifecycleHook.TimeoutConfiguration *DeploymentLifecycleHookTimeout `json:\"timeoutConfiguration,omitempty\"`", "DeploymentLifecycleHookTimeout.Action string `json:\"action,omitempty\"`", "DeploymentLifecycleHookTimeout.TimeoutInMinutes *int `json:\"timeoutInMinutes,omitempty\"`", + "Device.ContainerPath string `json:\"containerPath,omitempty\"`", + "Device.HostPath string `json:\"hostPath\"`", + "Device.Permissions []string `json:\"permissions,omitempty\"`", + "DockerVolumeConfig.Autoprovision *bool `json:\"autoprovision,omitempty\"`", + "DockerVolumeConfig.Driver string `json:\"driver,omitempty\"`", + "DockerVolumeConfig.DriverOpts map[string]string `json:\"driverOpts,omitempty\"`", + "DockerVolumeConfig.Labels map[string]string `json:\"labels,omitempty\"`", + "DockerVolumeConfig.Scope string `json:\"scope,omitempty\"`", "EFSAuthorizationConfig.AccessPointID string `json:\"accessPointId,omitempty\"`", "EFSAuthorizationConfig.IAM string `json:\"iam,omitempty\"`", "EFSVolumeConfiguration.AuthorizationConfig *EFSAuthorizationConfig `json:\"authorizationConfig,omitempty\"`", @@ -12969,11 +13599,15 @@ "HealthCheck.Retries int `json:\"retries,omitempty\"`", "HealthCheck.StartPeriod int `json:\"startPeriod,omitempty\"`", "HealthCheck.Timeout int `json:\"timeout,omitempty\"`", + "HostEntry.Hostname string `json:\"hostname\"`", + "HostEntry.IPAddress string `json:\"ipAddress\"`", "HostVolumeProperties.SourcePath string `json:\"sourcePath,omitempty\"`", "InferenceAccelerator.DeviceName string `json:\"deviceName\"`", "InferenceAccelerator.DeviceType string `json:\"deviceType\"`", "IngressPathSummary.AccessType string `json:\"accessType,omitempty\"`", "IngressPathSummary.Endpoint string `json:\"endpoint,omitempty\"`", + "KernelCapabilities.Add []string `json:\"add,omitempty\"`", + "KernelCapabilities.Drop []string `json:\"drop,omitempty\"`", "KeyValuePair.Name string `json:\"name\"`", "KeyValuePair.Value string `json:\"value\"`", "LifecycleHookDetail.ExpiresAt *time.Time `json:\"expiresAt,omitempty\"`", @@ -12985,6 +13619,13 @@ "LifecycleHookDetail.TimeoutAction string `json:\"timeoutAction,omitempty\"`", "LinearConfiguration.StepBakeTimeInMinutes *int `json:\"stepBakeTimeInMinutes,omitempty\"`", "LinearConfiguration.StepPercent *float64 `json:\"stepPercent,omitempty\"`", + "LinuxParameters.Capabilities *KernelCapabilities `json:\"capabilities,omitempty\"`", + "LinuxParameters.Devices []Device `json:\"devices,omitempty\"`", + "LinuxParameters.InitProcessEnabled *bool `json:\"initProcessEnabled,omitempty\"`", + "LinuxParameters.MaxSwap *int `json:\"maxSwap,omitempty\"`", + "LinuxParameters.SharedMemorySize *int `json:\"sharedMemorySize,omitempty\"`", + "LinuxParameters.Swappiness *int `json:\"swappiness,omitempty\"`", + "LinuxParameters.Tmpfs []Tmpfs `json:\"tmpfs,omitempty\"`", "LoadBalancer.ContainerName string `json:\"containerName,omitempty\"`", "LoadBalancer.ContainerPort int `json:\"containerPort,omitempty\"`", "LoadBalancer.LoadBalancerName string `json:\"loadBalancerName,omitempty\"`", @@ -13129,6 +13770,8 @@ "ServiceRevisionSummary.PendingTaskCount int", "ServiceRevisionSummary.RequestedTaskCount int", "ServiceRevisionSummary.RunningTaskCount int", + "SystemControl.Namespace string `json:\"namespace\"`", + "SystemControl.Value string `json:\"value\"`", "Tag.Key string `json:\"key\"`", "Tag.Value string `json:\"value\"`", "Task.Attachments []TaskAttachment `json:\"attachments,omitempty\"`", @@ -13139,9 +13782,11 @@ "Task.ConnectivityAt *time.Time `json:\"connectivityAt,omitempty\"`", "Task.ContainerInstanceArn string `json:\"containerInstanceArn,omitempty\"`", "Task.Containers []Container `json:\"containers,omitempty\"`", + "Task.CreatedAt *time.Time `json:\"createdAt,omitempty\"`", "Task.DesiredStatus string `json:\"desiredStatus\"`", "Task.EnableExecuteCommand bool `json:\"enableExecuteCommand,omitempty\"`", "Task.Group string `json:\"group,omitempty\"`", + "Task.HealthStatus string `json:\"healthStatus,omitempty\"`", "Task.LastStatus string `json:\"lastStatus\"`", "Task.LaunchType string `json:\"launchType,omitempty\"`", "Task.NetworkConfiguration *NetworkConfiguration `json:\"networkConfiguration,omitempty\"`", @@ -13149,15 +13794,19 @@ "Task.PlatformFamily string `json:\"platformFamily,omitempty\"`", "Task.PlatformVersion string `json:\"platformVersion,omitempty\"`", "Task.PropagateTags string `json:\"propagateTags,omitempty\"`", + "Task.PullStartedAt *time.Time `json:\"pullStartedAt,omitempty\"`", + "Task.PullStoppedAt *time.Time `json:\"pullStoppedAt,omitempty\"`", "Task.RuntimeID string `json:\"runtimeId,omitempty\"`", "Task.StartedAt *time.Time `json:\"startedAt,omitempty\"`", "Task.StartedBy string `json:\"startedBy,omitempty\"`", + "Task.StopCode string `json:\"stopCode,omitempty\"`", "Task.StoppedAt *time.Time `json:\"stoppedAt,omitempty\"`", "Task.StoppedReason string `json:\"stoppedReason,omitempty\"`", "Task.Tags []Tag `json:\"tags,omitempty\"`", "Task.TaskArn string `json:\"taskArn\"`", "Task.TaskDefinitionArn string `json:\"taskDefinitionArn\"`", "Task.TaskRoleArn string `json:\"taskRoleArn,omitempty\"`", + "Task.runtimeHealth bool", "Task.version int64", "TaskAttachment.Details []KeyValuePair `json:\"details,omitempty\"`", "TaskAttachment.ID string `json:\"id\"`", @@ -13213,10 +13862,17 @@ "TaskSet.UpdatedAt time.Time `json:\"updatedAt\"`", "TaskSetScale.Unit string `json:\"unit\"`", "TaskSetScale.Value float64 `json:\"value\"`", + "Tmpfs.ContainerPath string `json:\"containerPath\"`", + "Tmpfs.MountOptions []string `json:\"mountOptions,omitempty\"`", + "Tmpfs.Size int `json:\"size\"`", + "Ulimit.HardLimit int `json:\"hardLimit\"`", + "Ulimit.Name string `json:\"name\"`", + "Ulimit.SoftLimit int `json:\"softLimit\"`", "VersionInfo.AgentHash string `json:\"agentHash,omitempty\"`", "VersionInfo.AgentVersion string `json:\"agentVersion,omitempty\"`", "VersionInfo.DockerVersion string `json:\"dockerVersion,omitempty\"`", "Volume.ConfiguredAtLaunch bool `json:\"configuredAtLaunch,omitempty\"`", + "Volume.DockerVolumeConfig *DockerVolumeConfig `json:\"dockerVolumeConfiguration,omitempty\"`", "Volume.EFSVolumeConfiguration *EFSVolumeConfiguration `json:\"efsVolumeConfiguration,omitempty\"`", "Volume.Host *HostVolumeProperties `json:\"host,omitempty\"`", "Volume.Name string `json:\"name\"`", @@ -15438,6 +16094,36 @@ ], "version": 2 }, + "gcpapigateway": { + "fields": [ + "backendSnapshot.Apis map[string]recSnap `json:\"apis\"`", + "backendSnapshot.Configs map[string]recSnap `json:\"configs\"`", + "backendSnapshot.Gateways map[string]recSnap `json:\"gateways\"`", + "backendSnapshot.Ops map[string]*opRec `json:\"operations\"`", + "backendSnapshot.Seq uint64 `json:\"seq\"`", + "diagnostic.Location string `json:\"location\"`", + "diagnostic.Message string `json:\"message\"`", + "life.DeleteAt time.Time `json:\"deleteAt\"`", + "life.DeleteOp string `json:\"deleteOp,omitempty\"`", + "life.Transient int32 `json:\"transient\"`", + "life.Until time.Time `json:\"until\"`", + "opRec.CancelRequested bool `json:\"cancelRequested,omitempty\"`", + "opRec.CancelledAt time.Time `json:\"cancelledAt\"`", + "opRec.Created time.Time `json:\"created\"`", + "opRec.Diags []diagnostic `json:\"diagnostics,omitempty\"`", + "opRec.EndAt time.Time `json:\"endAt\"`", + "opRec.FailCode int32 `json:\"failCode,omitempty\"`", + "opRec.FailMsg string `json:\"failMsg,omitempty\"`", + "opRec.Name string `json:\"name\"`", + "opRec.Resp []byte `json:\"resp,omitempty\"`", + "opRec.Target string `json:\"target\"`", + "opRec.Verb string `json:\"verb\"`", + "recSnap.Life life `json:\"life\"`", + "recSnap.Policy json.RawMessage `json:\"policy,omitempty\"`", + "recSnap.Resource json.RawMessage `json:\"resource\"`" + ], + "version": 1 + }, "gcpartifactregistry": { "fields": [ "backendSnapshot.GenericFiles map[string]*genFile `json:\"genericFiles,omitempty\"`", @@ -15467,6 +16153,44 @@ ], "version": 1 }, + "gcpbatch": { + "fields": [ + "backendSnapshot.Jobs map[string]jobSnap `json:\"jobs\"`", + "backendSnapshot.Ops map[string]*opRec `json:\"operations\"`", + "backendSnapshot.Requests map[string]string `json:\"requests\"`", + "eventRec.Desc string `json:\"desc\"`", + "eventRec.Exit *int32 `json:\"exit,omitempty\"`", + "eventRec.Time time.Time `json:\"time\"`", + "eventRec.Type string `json:\"type\"`", + "jobRec.CancelDone time.Time `json:\"cancelDone\"`", + "jobRec.Cancelled bool `json:\"cancelled\"`", + "jobRec.DeleteDone time.Time `json:\"deleteDone\"`", + "jobRec.Deleting bool `json:\"deleting\"`", + "jobRec.EndedAt time.Time `json:\"endedAt\"`", + "jobRec.Engine bool `json:\"engine\"`", + "jobRec.Events []eventRec `json:\"events\"`", + "jobRec.Parallel int64 `json:\"parallel\"`", + "jobRec.Phase int32 `json:\"phase\"`", + "jobRec.RunningAt time.Time `json:\"runningAt\"`", + "jobRec.ScheduledAt time.Time `json:\"scheduledAt\"`", + "jobRec.StartedAt time.Time `json:\"startedAt\"`", + "jobRec.TaskRun int64 `json:\"taskRun\"`", + "jobRec.Tasks []*taskRec `json:\"tasks\"`", + "jobRec.pb *batchpb.Job", + "jobSnap.Job json.RawMessage `json:\"job\"`", + "jobSnap.Rec *jobRec `json:\"rec\"`", + "opRec.Created time.Time `json:\"created\"`", + "opRec.EndAt time.Time `json:\"endAt\"`", + "opRec.Name string `json:\"name\"`", + "opRec.Resp []byte `json:\"resp,omitempty\"`", + "opRec.Target string `json:\"target\"`", + "opRec.Verb string `json:\"verb\"`", + "taskRec.Attempts int32 `json:\"attempts,omitempty\"`", + "taskRec.Events []eventRec `json:\"events,omitempty\"`", + "taskRec.State int32 `json:\"state\"`" + ], + "version": 1 + }, "gcpbigquery": { "fields": [ "Dataset.Created int64 `json:\"created\"`", @@ -15587,12 +16311,14 @@ "backendSnapshot.Networks map[string]*computeapi.Network `json:\"networks\"`", "backendSnapshot.Ops map[string]*opRec `json:\"ops\"`", "backendSnapshot.Projects map[string]*computeapi.Metadata `json:\"projects\"`", + "backendSnapshot.ResPolicies map[string]*computeapi.ResourcePolicy `json:\"resourcePolicies\"`", "backendSnapshot.Routers map[string]*routerRec `json:\"routers\"`", "backendSnapshot.Routes map[string]*computeapi.Route `json:\"routes\"`", "backendSnapshot.SSLCerts map[string]*computeapi.SslCertificate `json:\"sslCertificates\"`", "backendSnapshot.SSLPolicies map[string]*computeapi.SslPolicy `json:\"sslPolicies\"`", "backendSnapshot.SSLProxies map[string]*computeapi.TargetSslProxy `json:\"targetSslProxies\"`", "backendSnapshot.Scalers map[string]*computeapi.Autoscaler `json:\"autoscalers\"`", + "backendSnapshot.SecPolicies map[string]*computeapi.SecurityPolicy `json:\"securityPolicies\"`", "backendSnapshot.Snapshots map[string]*computeapi.Snapshot `json:\"snapshots\"`", "backendSnapshot.Subnets map[string]*computeapi.Subnetwork `json:\"subnets\"`", "backendSnapshot.TCPProxies map[string]*computeapi.TargetTcpProxy `json:\"targetTcpProxies\"`", @@ -15622,6 +16348,50 @@ ], "version": 1 }, + "gcpcontainer": { + "fields": [ + "backendSnapshot.Clusters map[string]clusterSnap `json:\"clusters\"`", + "backendSnapshot.Ops map[string]opSnap `json:\"operations\"`", + "backendSnapshot.Seq uint64 `json:\"seq\"`", + "clusterSnap.Cluster json.RawMessage `json:\"cluster\"`", + "clusterSnap.DeleteOp string `json:\"deleteOp\"`", + "clusterSnap.Failure string `json:\"failure\"`", + "clusterSnap.Pools []poolSnap `json:\"pools\"`", + "clusterSnap.Project string `json:\"project\"`", + "clusterSnap.Rotation *rotationRec `json:\"rotation,omitempty\"`", + "opSnap.Cluster string `json:\"cluster\"`", + "opSnap.EndAt time.Time `json:\"endAt\"`", + "opSnap.Failure string `json:\"failure\"`", + "opSnap.Finished bool `json:\"finished\"`", + "opSnap.Kind opKind `json:\"kind\"`", + "opSnap.Location string `json:\"location\"`", + "opSnap.Manual bool `json:\"manual\"`", + "opSnap.Op json.RawMessage `json:\"op\"`", + "opSnap.Pool string `json:\"pool\"`", + "opSnap.Project string `json:\"project\"`", + "poolSnap.DeleteOp string `json:\"deleteOp\"`", + "poolSnap.Pool json.RawMessage `json:\"pool\"`", + "poolSnap.PrevVersion string `json:\"prevVersion\"`", + "poolSnap.Size int32 `json:\"size\"`", + "rotationRec.Credentials bool", + "rotationRec.OldEndpoint string" + ], + "version": 1 + }, + "gcpdatastore": { + "fields": [ + "backendSnapshot.Entities map[string]entitySnap `json:\"entities\"`", + "backendSnapshot.Indexes map[string]json.RawMessage `json:\"indexes\"`", + "backendSnapshot.LastCommit time.Time `json:\"lastCommit\"`", + "backendSnapshot.NextIndex int64 `json:\"nextIndex\"`", + "backendSnapshot.Operations map[string]json.RawMessage `json:\"operations\"`", + "backendSnapshot.UsedIDs map[string][]int64 `json:\"usedIds\"`", + "entitySnap.Created time.Time `json:\"created\"`", + "entitySnap.Entity json.RawMessage `json:\"entity\"`", + "entitySnap.Updated time.Time `json:\"updated\"`" + ], + "version": 1 + }, "gcpdns": { "fields": [ "backendSnapshot.Policies map[string]*dnsapi.Policy `json:\"policies\"`", @@ -15695,6 +16465,65 @@ ], "version": 1 }, + "gcpidentitytoolkit": { + "fields": [ + "backendSnapshot.Keys *keyRec `json:\"keys\"`", + "backendSnapshot.Projects map[string]*projectRec `json:\"projects\"`", + "hashSpec.Algorithm string `json:\"algorithm\"`", + "hashSpec.BlockSize int64 `json:\"blockSize,omitempty\"`", + "hashSpec.CPUMemCost int64 `json:\"cpuMemCost,omitempty\"`", + "hashSpec.DKLen int64 `json:\"dkLen,omitempty\"`", + "hashSpec.MemoryCost int64 `json:\"memoryCost,omitempty\"`", + "hashSpec.Parallelization int64 `json:\"parallelization,omitempty\"`", + "hashSpec.PasswordHashOrder string `json:\"passwordHashOrder,omitempty\"`", + "hashSpec.Rounds int64 `json:\"rounds,omitempty\"`", + "hashSpec.SaltSeparator []byte `json:\"saltSeparator,omitempty\"`", + "hashSpec.SignerKey []byte `json:\"signerKey,omitempty\"`", + "keyRec.Cert string `json:\"cert\"`", + "keyRec.KID string `json:\"kid\"`", + "keyRec.Priv []byte `json:\"priv\"`", + "keyRec.Secret []byte `json:\"secret\"`", + "oobRec.ContinueURL string `json:\"continueUrl,omitempty\"`", + "oobRec.Created int64 `json:\"created\"`", + "oobRec.Email string `json:\"email\"`", + "oobRec.Expires int64 `json:\"expires\"`", + "oobRec.Link string `json:\"link\"`", + "oobRec.NewEmail string `json:\"newEmail,omitempty\"`", + "oobRec.RequestType string `json:\"requestType\"`", + "oobRec.TenantID string `json:\"tenantId,omitempty\"`", + "projectRec.Codes map[string]*oobRec `json:\"codes\"`", + "projectRec.Config *idtkv2.GoogleCloudIdentitytoolkitAdminV2Config `json:\"config\"`", + "projectRec.Hash hashSpec `json:\"hash\"`", + "projectRec.Tenants map[string]*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant `json:\"tenants\"`", + "projectRec.Users map[string]map[string]*userRec `json:\"users\"`", + "providerRec.DisplayName string `json:\"displayName,omitempty\"`", + "providerRec.Email string `json:\"email,omitempty\"`", + "providerRec.FederatedID string `json:\"federatedId,omitempty\"`", + "providerRec.Phone string `json:\"phoneNumber,omitempty\"`", + "providerRec.PhotoURL string `json:\"photoUrl,omitempty\"`", + "providerRec.ProviderID string `json:\"providerId\"`", + "providerRec.RawID string `json:\"rawId\"`", + "userRec.Anonymous bool `json:\"anonymous,omitempty\"`", + "userRec.CreatedAt int64 `json:\"createdAt\"`", + "userRec.CustomAttrs string `json:\"customAttributes,omitempty\"`", + "userRec.Disabled bool `json:\"disabled,omitempty\"`", + "userRec.DisplayName string `json:\"displayName,omitempty\"`", + "userRec.Email string `json:\"email,omitempty\"`", + "userRec.EmailVerified bool `json:\"emailVerified,omitempty\"`", + "userRec.Hash *hashSpec `json:\"hash,omitempty\"`", + "userRec.LastLoginAt int64 `json:\"lastLoginAt,omitempty\"`", + "userRec.LastRefreshAt int64 `json:\"lastRefreshAt,omitempty\"`", + "userRec.PasswordAt int64 `json:\"passwordUpdatedAt,omitempty\"`", + "userRec.PasswordHash []byte `json:\"passwordHash,omitempty\"`", + "userRec.Phone string `json:\"phoneNumber,omitempty\"`", + "userRec.PhotoURL string `json:\"photoUrl,omitempty\"`", + "userRec.Providers []providerRec `json:\"providers,omitempty\"`", + "userRec.Salt []byte `json:\"salt,omitempty\"`", + "userRec.UID string `json:\"uid\"`", + "userRec.ValidSince int64 `json:\"validSince,omitempty\"`" + ], + "version": 1 + }, "gcpkms": { "fields": [ "backendSnapshot.EtagSeq uint64 `json:\"etagSeq\"`", @@ -15832,6 +16661,45 @@ ], "version": 1 }, + "gcpresourcemanager": { + "fields": [ + "backendSnapshot.Bindings map[string]*bindingRec `json:\"tagBindings\"`", + "backendSnapshot.Enabled map[string]map[string]time.Time `json:\"enabledServices\"`", + "backendSnapshot.Folders map[string]*folderRec `json:\"folders\"`", + "backendSnapshot.Ops map[string]json.RawMessage `json:\"operations\"`", + "backendSnapshot.Policies map[string]json.RawMessage `json:\"policies\"`", + "backendSnapshot.PolicySeq uint64 `json:\"policySeq\"`", + "backendSnapshot.Projects map[string]*projectRec `json:\"projects\"`", + "backendSnapshot.Seed string `json:\"seed\"`", + "backendSnapshot.TagKeys map[string]*tagKeyRec `json:\"tagKeys\"`", + "backendSnapshot.TagValues map[string]*tagValueRec `json:\"tagValues\"`", + "bindingRec.Parent string `json:\"parent\"`", + "bindingRec.TagValue string `json:\"tagValue\"`", + "folderRec.DisplayName string `json:\"displayName\"`", + "folderRec.ID string `json:\"id\"`", + "folderRec.Parent string `json:\"parent\"`", + "lifecycle.Created time.Time `json:\"created\"`", + "lifecycle.Deleted time.Time `json:\"deleted,omitzero\"`", + "lifecycle.Updated time.Time `json:\"updated\"`", + "projectRec.Billing string `json:\"billing,omitempty\"`", + "projectRec.DisplayName string `json:\"displayName\"`", + "projectRec.ID string `json:\"id\"`", + "projectRec.Labels map[string]string `json:\"labels,omitempty\"`", + "projectRec.Number string `json:\"number\"`", + "projectRec.Parent string `json:\"parent,omitempty\"`", + "tagKeyRec.Description string `json:\"description,omitempty\"`", + "tagKeyRec.ID string `json:\"id\"`", + "tagKeyRec.Parent string `json:\"parent\"`", + "tagKeyRec.Purpose int32 `json:\"purpose,omitempty\"`", + "tagKeyRec.PurposeData map[string]string `json:\"purposeData,omitempty\"`", + "tagKeyRec.ShortName string `json:\"shortName\"`", + "tagValueRec.Description string `json:\"description,omitempty\"`", + "tagValueRec.ID string `json:\"id\"`", + "tagValueRec.Parent string `json:\"parent\"`", + "tagValueRec.ShortName string `json:\"shortName\"`" + ], + "version": 1 + }, "gcprun": { "fields": [ "backendSnapshot.DeletedExe map[string]json.RawMessage `json:\"deletedExecutions\"`", @@ -15933,9 +16801,12 @@ "Instance.InstanceType string `json:\"instanceType\"`", "Instance.Kind string `json:\"kind\"`", "Instance.MaintenanceVersion string `json:\"maintenanceVersion,omitempty\"`", + "Instance.MasterInstanceName string `json:\"masterInstanceName,omitempty\"`", "Instance.Name string `json:\"name\"`", "Instance.Project string `json:\"project\"`", "Instance.Region string `json:\"region\"`", + "Instance.ReplicaConfiguration map[string]any `json:\"replicaConfiguration,omitempty\"`", + "Instance.ReplicaNames []string `json:\"replicaNames,omitempty\"`", "Instance.SQLNetworkArchitecture string `json:\"sqlNetworkArchitecture,omitempty\"`", "Instance.SelfLink string `json:\"selfLink\"`", "Instance.ServerCaCert *SslCert `json:\"serverCaCert,omitempty\"`", @@ -15964,6 +16835,19 @@ "OperationError.Message string `json:\"message\"`", "OperationErrors.Errors []OperationError `json:\"errors\"`", "OperationErrors.Kind string `json:\"kind\"`", + "Recovery.At time.Time", + "Recovery.FromFile string", + "Recovery.FromPos uint64", + "Recovery.Script []byte", + "Recovery.Source *ReplicaSource", + "Recovery.UntilFile string", + "Recovery.UntilPos uint64", + "Recovery.Volume string", + "ReplicaSource.Host string", + "ReplicaSource.Network string", + "ReplicaSource.NetworkID string", + "ReplicaSource.Password string", + "ReplicaSource.Port int", "SslCert.Cert string `json:\"cert,omitempty\"`", "SslCert.CertSerialNumber string `json:\"certSerialNumber,omitempty\"`", "SslCert.CommonName string `json:\"commonName,omitempty\"`", @@ -15994,12 +16878,17 @@ "instRec.Certs map[string]*certRec `json:\"certs\"`", "instRec.Databases map[string]*Database `json:\"databases\"`", "instRec.Inst Instance `json:\"inst\"`", + "instRec.PitrBase string `json:\"pitrBase,omitempty\"`", + "instRec.ReplicaPaused bool `json:\"replicaPaused,omitempty\"`", "instRec.RootPassword string `json:\"rootPassword\"`", + "instRec.ServerID uint32 `json:\"serverID,omitempty\"`", "instRec.Users map[string]*userRec `json:\"users\"`", "instRec.draining bool", "instRec.eng Engine", + "instRec.fresh bool", "instRec.gone bool", "instRec.queue []task", + "instRec.recovery *Recovery", "task.fn func(ctx context.Context) error", "task.op string", "userRec.Password string `json:\"password,omitempty\"`", @@ -16316,7 +17205,10 @@ "CatalogImportStatus.ImportCompleted bool `json:\"ImportCompleted\"`", "CatalogImportStatus.ImportTime float64 `json:\"ImportTime\"`", "CatalogImportStatus.ImportedBy string `json:\"ImportedBy\"`", + "CatalogTarget.ConnectionName string `json:\"ConnectionName,omitempty\"`", "CatalogTarget.DatabaseName string `json:\"DatabaseName,omitempty\"`", + "CatalogTarget.DlqEventQueueArn string `json:\"DlqEventQueueArn,omitempty\"`", + "CatalogTarget.EventQueueArn string `json:\"EventQueueArn,omitempty\"`", "CatalogTarget.Tables []string `json:\"Tables,omitempty\"`", "Classifier.CsvClassifier *CsvClassifier `json:\"CsvClassifier,omitempty\"`", "Classifier.GrokClassifier *GrokClassifier `json:\"GrokClassifier,omitempty\"`", @@ -16844,10 +17736,12 @@ "SerDeInfo.Parameters map[string]string `json:\"Parameters,omitempty\"`", "SerDeInfo.SerializationLibrary string `json:\"SerializationLibrary,omitempty\"`", "Session.Command SessionCommand `json:\"Command,omitzero\"`", + "Session.CompletedOn float64 `json:\"CompletedOn,omitempty\"`", "Session.Connections ConnectionsList `json:\"Connections,omitzero\"`", "Session.CreatedOn float64 `json:\"CreatedOn,omitempty\"`", "Session.DefaultArguments map[string]string `json:\"DefaultArguments,omitempty\"`", "Session.Description string `json:\"Description,omitempty\"`", + "Session.ErrorMessage string `json:\"ErrorMessage,omitempty\"`", "Session.GlueVersion string `json:\"GlueVersion,omitempty\"`", "Session.IdleTimeout int32 `json:\"IdleTimeout,omitempty\"`", "Session.MaxCapacity float64 `json:\"MaxCapacity,omitempty\"`", @@ -20344,6 +21238,9 @@ "FunctionVersion.RevisionID string `json:\"RevisionId\"`", "FunctionVersion.Role string `json:\"Role\"`", "FunctionVersion.Runtime string `json:\"Runtime,omitempty\"`", + "FunctionVersion.S3BucketCode string `json:\"-\"`", + "FunctionVersion.S3KeyCode string `json:\"-\"`", + "FunctionVersion.S3ObjectVersionCode string `json:\"-\"`", "FunctionVersion.SnapStart *SnapStartResponse `json:\"SnapStart,omitempty\"`", "FunctionVersion.State FunctionState `json:\"State\"`", "FunctionVersion.StateReason string `json:\"StateReason,omitempty\"`", @@ -20352,6 +21249,7 @@ "FunctionVersion.Timeout int `json:\"Timeout\"`", "FunctionVersion.TracingConfig *TracingConfig `json:\"TracingConfig,omitempty\"`", "FunctionVersion.VpcConfig *VpcConfig `json:\"VpcConfig,omitempty\"`", + "FunctionVersion.ZipData []byte `json:\"-\"`", "ImageConfig.Command []string `json:\"Command,omitempty\"`", "ImageConfig.EntryPoint []string `json:\"EntryPoint,omitempty\"`", "ImageConfig.WorkingDirectory string `json:\"WorkingDirectory,omitempty\"`", @@ -24273,6 +25171,397 @@ ], "version": 1 }, + "ociapigateway": { + "fields": [ + "Certificate.CertPEM string `json:\"certificate\"`", + "Certificate.CompartmentID string `json:\"compartmentId\"`", + "Certificate.DisplayName string `json:\"displayName\"`", + "Certificate.Etag string `json:\"etag\"`", + "Certificate.ID string `json:\"id\"`", + "Certificate.Intermediate string `json:\"intermediateCertificates,omitempty\"`", + "Certificate.Locks json.RawMessage `json:\"locks,omitempty\"`", + "Certificate.State string `json:\"state\"`", + "Certificate.SubjectNames []string `json:\"subjectNames\"`", + "Certificate.Tags Tags `json:\"tags\"`", + "Certificate.TimeCreated time.Time `json:\"timeCreated\"`", + "Certificate.TimeNotValidAfter time.Time `json:\"timeNotValidAfter\"`", + "Certificate.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Certificate.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Deployment.CompartmentID string `json:\"compartmentId\"`", + "Deployment.DisplayName string `json:\"displayName\"`", + "Deployment.Etag string `json:\"etag\"`", + "Deployment.GatewayID string `json:\"gatewayId\"`", + "Deployment.ID string `json:\"id\"`", + "Deployment.Locks json.RawMessage `json:\"locks,omitempty\"`", + "Deployment.PathPrefix string `json:\"pathPrefix\"`", + "Deployment.Spec json.RawMessage `json:\"specification\"`", + "Deployment.State string `json:\"state\"`", + "Deployment.Tags Tags `json:\"tags\"`", + "Deployment.TimeCreated time.Time `json:\"timeCreated\"`", + "Deployment.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Deployment.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Gateway.CaBundles json.RawMessage `json:\"caBundles,omitempty\"`", + "Gateway.CertificateID string `json:\"certificateId,omitempty\"`", + "Gateway.CompartmentID string `json:\"compartmentId\"`", + "Gateway.DisplayName string `json:\"displayName\"`", + "Gateway.EndpointType string `json:\"endpointType\"`", + "Gateway.Etag string `json:\"etag\"`", + "Gateway.Hostname string `json:\"hostname\"`", + "Gateway.ID string `json:\"id\"`", + "Gateway.IPMode string `json:\"ipMode,omitempty\"`", + "Gateway.Locks json.RawMessage `json:\"locks,omitempty\"`", + "Gateway.NSGs []string `json:\"networkSecurityGroupIds,omitempty\"`", + "Gateway.ResponseCache json.RawMessage `json:\"responseCache,omitempty\"`", + "Gateway.State string `json:\"state\"`", + "Gateway.SubnetID string `json:\"subnetId\"`", + "Gateway.Tags Tags `json:\"tags\"`", + "Gateway.TimeCreated time.Time `json:\"timeCreated\"`", + "Gateway.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Gateway.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "WorkRequest.Accepted time.Time `json:\"accepted\"`", + "WorkRequest.Action string `json:\"action\"`", + "WorkRequest.CompartmentID string `json:\"compartmentId\"`", + "WorkRequest.EntityID string `json:\"entityId\"`", + "WorkRequest.EntityType string `json:\"entityType\"`", + "WorkRequest.FinishAt time.Time `json:\"finishAt\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.Operation string `json:\"operation\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Certificates map[string]*Certificate `json:\"certificates\"`", + "backendSnapshot.Deployments map[string]*Deployment `json:\"deployments\"`", + "backendSnapshot.Gateways map[string]*Gateway `json:\"gateways\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, + "ociartifacts": { + "fields": [ + "GenericArtifact.Content []byte `json:\"content\"`", + "GenericArtifact.Etag string `json:\"etag\"`", + "GenericArtifact.ID string `json:\"id\"`", + "GenericArtifact.Path string `json:\"path\"`", + "GenericArtifact.RepoID string `json:\"repoId\"`", + "GenericArtifact.Seq int64 `json:\"seq\"`", + "GenericArtifact.Sha256 string `json:\"sha256\"`", + "GenericArtifact.Tags Tags `json:\"tags\"`", + "GenericArtifact.TimeCreated time.Time `json:\"timeCreated\"`", + "GenericRepo.CompartmentID string `json:\"compartmentId\"`", + "GenericRepo.Description string `json:\"description\"`", + "GenericRepo.Etag string `json:\"etag\"`", + "GenericRepo.ID string `json:\"id\"`", + "GenericRepo.IsImmutable bool `json:\"isImmutable\"`", + "GenericRepo.Name string `json:\"name\"`", + "GenericRepo.Seq int64 `json:\"seq\"`", + "GenericRepo.Tags Tags `json:\"tags\"`", + "GenericRepo.TimeCreated time.Time `json:\"timeCreated\"`", + "Image.CreatedBy string `json:\"createdBy\"`", + "Image.Digest string `json:\"digest\"`", + "Image.Etag string `json:\"etag\"`", + "Image.ID string `json:\"id\"`", + "Image.LastVersion string `json:\"lastVersion,omitempty\"`", + "Image.Layers []Layer `json:\"layers\"`", + "Image.LayersSize int64 `json:\"layersSize\"`", + "Image.ManifestSize int `json:\"manifestSize\"`", + "Image.PullCount int64 `json:\"pullCount\"`", + "Image.RepoID string `json:\"repoId\"`", + "Image.Seq int64 `json:\"seq\"`", + "Image.State string `json:\"state\"`", + "Image.Tags Tags `json:\"tags\"`", + "Image.TimeCreated time.Time `json:\"timeCreated\"`", + "Image.TimeLastPulled *time.Time `json:\"timeLastPulled,omitempty\"`", + "Image.Versions []Version `json:\"versions\"`", + "Layer.Digest string `json:\"digest\"`", + "Layer.Size int64 `json:\"size\"`", + "Layer.TimeCreated time.Time `json:\"timeCreated\"`", + "Readme.Content string `json:\"content\"`", + "Readme.Format string `json:\"format\"`", + "Repo.CompartmentID string `json:\"compartmentId\"`", + "Repo.CreatedBy string `json:\"createdBy\"`", + "Repo.Etag string `json:\"etag\"`", + "Repo.ID string `json:\"id\"`", + "Repo.IsImmutable bool `json:\"isImmutable\"`", + "Repo.IsPublic bool `json:\"isPublic\"`", + "Repo.Name string `json:\"name\"`", + "Repo.Readme *Readme `json:\"readme,omitempty\"`", + "Repo.Seq int64 `json:\"seq\"`", + "Repo.Tags Tags `json:\"tags\"`", + "Repo.TimeCreated time.Time `json:\"timeCreated\"`", + "Repo.TimeLastPushed *time.Time `json:\"timeLastPushed,omitempty\"`", + "Signature.Algorithm string `json:\"signingAlgorithm\"`", + "Signature.CompartmentID string `json:\"compartmentId\"`", + "Signature.CreatedBy string `json:\"createdBy\"`", + "Signature.Etag string `json:\"etag\"`", + "Signature.ID string `json:\"id\"`", + "Signature.ImageID string `json:\"imageId\"`", + "Signature.KmsKeyID string `json:\"kmsKeyId\"`", + "Signature.KmsKeyVersionID string `json:\"kmsKeyVersionId\"`", + "Signature.Message string `json:\"message\"`", + "Signature.Seq int64 `json:\"seq\"`", + "Signature.Signature string `json:\"signature\"`", + "Signature.Tags Tags `json:\"tags\"`", + "Signature.TimeCreated time.Time `json:\"timeCreated\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "Version.CreatedBy string `json:\"createdBy\"`", + "Version.TimeCreated time.Time `json:\"timeCreated\"`", + "backendSnapshot.Artifacts map[string]*GenericArtifact `json:\"artifacts\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.ConfigEtag string `json:\"configEtag\"`", + "backendSnapshot.CreateOnPush bool `json:\"createOnPush\"`", + "backendSnapshot.GenericRepos map[string]*GenericRepo `json:\"genericRepos\"`", + "backendSnapshot.Images map[string]*Image `json:\"images\"`", + "backendSnapshot.Repos map[string]*Repo `json:\"repos\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.Signatures map[string]*Signature `json:\"signatures\"`" + ], + "version": 1 + }, + "ocicontainerengine": { + "fields": [ + "AddOns.IsKubernetesDashboardEnabled *bool `json:\"isKubernetesDashboardEnabled,omitempty\"`", + "AddOns.IsTillerEnabled *bool `json:\"isTillerEnabled,omitempty\"`", + "Admission.IsPodSecurityPolicyEnabled *bool `json:\"isPodSecurityPolicyEnabled,omitempty\"`", + "Cluster.CAPEM string `json:\"caPem\"`", + "Cluster.CompartmentID string `json:\"compartmentId\"`", + "Cluster.CreatedByWR string `json:\"createdByWr\"`", + "Cluster.DeletedByWR string `json:\"deletedByWr,omitempty\"`", + "Cluster.Details string `json:\"details,omitempty\"`", + "Cluster.EndpointConfig *EndpointConfig `json:\"endpointConfig,omitempty\"`", + "Cluster.Endpoints Endpoints `json:\"endpoints\"`", + "Cluster.Etag string `json:\"etag\"`", + "Cluster.ID string `json:\"id\"`", + "Cluster.ImagePolicy ImagePolicy `json:\"imagePolicy\"`", + "Cluster.KmsKeyID string `json:\"kmsKeyId,omitempty\"`", + "Cluster.MigrateScheduled *time.Time `json:\"migrateScheduled,omitempty\"`", + "Cluster.MigrateState string `json:\"migrateState,omitempty\"`", + "Cluster.Name string `json:\"name\"`", + "Cluster.Options ClusterOptions `json:\"options\"`", + "Cluster.PodNetworks []PodNet `json:\"podNetworks\"`", + "Cluster.Seq int64 `json:\"seq\"`", + "Cluster.State string `json:\"state\"`", + "Cluster.Tags Tags `json:\"tags\"`", + "Cluster.TimeCreated time.Time `json:\"timeCreated\"`", + "Cluster.TimeDeleted *time.Time `json:\"timeDeleted,omitempty\"`", + "Cluster.TimeUpdated *time.Time `json:\"timeUpdated,omitempty\"`", + "Cluster.Token string `json:\"token\"`", + "Cluster.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Cluster.Type string `json:\"type\"`", + "Cluster.UpdatedByWR string `json:\"updatedByWr,omitempty\"`", + "Cluster.VcnID string `json:\"vcnId,omitempty\"`", + "ClusterOptions.AddOns *AddOns `json:\"addOns,omitempty\"`", + "ClusterOptions.Admission *Admission `json:\"admissionControllerOptions,omitempty\"`", + "ClusterOptions.IPFamilies []string `json:\"ipFamilies,omitempty\"`", + "ClusterOptions.K8sNetwork *NetworkConfig `json:\"kubernetesNetworkConfig,omitempty\"`", + "ClusterOptions.OIDCAuth json.RawMessage `json:\"openIdConnectTokenAuthenticationConfig,omitempty\"`", + "ClusterOptions.OIDCDiscovery json.RawMessage `json:\"openIdConnectDiscovery,omitempty\"`", + "ClusterOptions.PersistentVolume *TagCfg `json:\"persistentVolumeConfig,omitempty\"`", + "ClusterOptions.ServiceLb *TagCfg `json:\"serviceLbConfig,omitempty\"`", + "ClusterOptions.ServiceLbSubnetID []string `json:\"serviceLbSubnetIds,omitempty\"`", + "EndpointConfig.IsPublicIPEnabled *bool `json:\"isPublicIpEnabled,omitempty\"`", + "EndpointConfig.NsgIDs []string `json:\"nsgIds,omitempty\"`", + "EndpointConfig.SecurityAttributes map[string]map[string]any `json:\"securityAttributes,omitempty\"`", + "EndpointConfig.SubnetID string `json:\"subnetId,omitempty\"`", + "Endpoints.Kubernetes string `json:\"kubernetes,omitempty\"`", + "Endpoints.PrivateEndpoint string `json:\"privateEndpoint,omitempty\"`", + "Endpoints.PublicEndpoint string `json:\"publicEndpoint,omitempty\"`", + "Endpoints.VcnHostnameEndpoint string `json:\"vcnHostnameEndpoint,omitempty\"`", + "ImagePolicy.IsPolicyEnabled *bool `json:\"isPolicyEnabled,omitempty\"`", + "ImagePolicy.KeyDetails []KeyDetail `json:\"keyDetails,omitempty\"`", + "KeyDetail.KmsKeyID string `json:\"kmsKeyId\"`", + "KeyValue.Key string `json:\"key\"`", + "KeyValue.Value string `json:\"value\"`", + "NetworkConfig.PodsCidr string `json:\"podsCidr,omitempty\"`", + "NetworkConfig.ServicesCidr string `json:\"servicesCidr,omitempty\"`", + "Node.AD string `json:\"availabilityDomain\"`", + "Node.FaultDomain string `json:\"faultDomain\"`", + "Node.ID string `json:\"id\"`", + "Node.Name string `json:\"name\"`", + "Node.PrivateIP string `json:\"privateIp,omitempty\"`", + "Node.State string `json:\"state\"`", + "Node.SubnetID string `json:\"subnetId\"`", + "Node.Tags Tags `json:\"tags\"`", + "Node.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "NodeConfig.IsPvEncryption *bool `json:\"isPvEncryptionInTransitEnabled,omitempty\"`", + "NodeConfig.KmsKeyID string `json:\"kmsKeyId,omitempty\"`", + "NodeConfig.NsgIDs []string `json:\"nsgIds,omitempty\"`", + "NodeConfig.Placements []Placement `json:\"placementConfigs\"`", + "NodeConfig.PodNet *PodNet `json:\"nodePoolPodNetworkOptionDetails,omitempty\"`", + "NodeConfig.Size int `json:\"size\"`", + "NodePool.ClusterID string `json:\"clusterId\"`", + "NodePool.CompartmentID string `json:\"compartmentId\"`", + "NodePool.Cycling json.RawMessage `json:\"cycling,omitempty\"`", + "NodePool.Etag string `json:\"etag\"`", + "NodePool.Eviction json.RawMessage `json:\"eviction,omitempty\"`", + "NodePool.ID string `json:\"id\"`", + "NodePool.ImageName string `json:\"imageName,omitempty\"`", + "NodePool.Labels []KeyValue `json:\"labels,omitempty\"`", + "NodePool.Name string `json:\"name\"`", + "NodePool.NodeConfig *NodeConfig `json:\"nodeConfig,omitempty\"`", + "NodePool.NodeMetadata map[string]string `json:\"nodeMetadata,omitempty\"`", + "NodePool.NodeSource *NodeSource `json:\"nodeSource,omitempty\"`", + "NodePool.Nodes []*Node `json:\"nodes\"`", + "NodePool.QuantityPerSub *int `json:\"quantityPerSubnet,omitempty\"`", + "NodePool.SSHKey string `json:\"sshKey,omitempty\"`", + "NodePool.Seq int64 `json:\"seq\"`", + "NodePool.Shape string `json:\"shape\"`", + "NodePool.ShapeConfig *ShapeConfig `json:\"shapeConfig,omitempty\"`", + "NodePool.State string `json:\"state\"`", + "NodePool.SubnetIDs []string `json:\"subnetIds,omitempty\"`", + "NodePool.Tags Tags `json:\"tags\"`", + "NodePool.TimeCreated time.Time `json:\"timeCreated\"`", + "NodePool.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "NodeSource.BootVolumeGBs *int64 `json:\"bootVolumeSizeInGBs,omitempty\"`", + "NodeSource.ImageID string `json:\"imageId\"`", + "NodeSource.SourceType string `json:\"sourceType\"`", + "Placement.AvailabilityDom string `json:\"availabilityDomain\"`", + "Placement.CapacityReserveID string `json:\"capacityReservationId,omitempty\"`", + "Placement.FaultDomains []string `json:\"faultDomains,omitempty\"`", + "Placement.PreemptibleConfig json.RawMessage `json:\"preemptibleNodeConfig,omitempty\"`", + "Placement.SubnetID string `json:\"subnetId\"`", + "PodNet.CniType string `json:\"cniType\"`", + "PodNet.MaxPodsPerNode *int `json:\"maxPodsPerNode,omitempty\"`", + "PodNet.PodNsgIDs []string `json:\"podNsgIds,omitempty\"`", + "PodNet.PodSubnetIDs []string `json:\"podSubnetIds,omitempty\"`", + "ShapeConfig.MemoryInGBs *float64 `json:\"memoryInGBs,omitempty\"`", + "ShapeConfig.Ocpus *float64 `json:\"ocpus,omitempty\"`", + "TagCfg.BackendNsgIDs []string `json:\"backendNsgIds,omitempty\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "WorkRequest.Accepted time.Time `json:\"accepted\"`", + "WorkRequest.Action string `json:\"action\"`", + "WorkRequest.CompartmentID string `json:\"compartmentId\"`", + "WorkRequest.EntityID string `json:\"entityId\"`", + "WorkRequest.EntityType string `json:\"entityType\"`", + "WorkRequest.EntityURI string `json:\"entityUri\"`", + "WorkRequest.Failure string `json:\"failure,omitempty\"`", + "WorkRequest.FinishAt *time.Time `json:\"finishAt,omitempty\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.Operation string `json:\"operation\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Clusters map[string]*Cluster `json:\"clusters\"`", + "backendSnapshot.Pools map[string]*NodePool `json:\"nodePools\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, + "ocicontainerinstances": { + "fields": [ + "ConfigFile.Data string `json:\"data\"`", + "ConfigFile.FileName string `json:\"fileName\"`", + "ConfigFile.Path string `json:\"path,omitempty\"`", + "Container.Args []string `json:\"arguments,omitempty\"`", + "Container.Command []string `json:\"command,omitempty\"`", + "Container.Created time.Time `json:\"timeCreated\"`", + "Container.Defined map[string]any `json:\"definedTags,omitempty\"`", + "Container.Details string `json:\"lifecycleDetails,omitempty\"`", + "Container.DisplayName string `json:\"displayName\"`", + "Container.Env map[string]string `json:\"environmentVariables,omitempty\"`", + "Container.ExitCode *int `json:\"exitCode,omitempty\"`", + "Container.Freeform map[string]string `json:\"freeformTags,omitempty\"`", + "Container.Health []*HealthCheck `json:\"healthChecks,omitempty\"`", + "Container.ID string `json:\"id\"`", + "Container.ImageURL string `json:\"imageUrl\"`", + "Container.Mounts []VolumeMount `json:\"volumeMounts,omitempty\"`", + "Container.NoResourcePrn bool `json:\"isResourcePrincipalDisabled\"`", + "Container.Resource *ResourceConfig `json:\"resourceConfig,omitempty\"`", + "Container.RestartCount int `json:\"restartCount\"`", + "Container.Security json.RawMessage `json:\"securityContext,omitempty\"`", + "Container.State string `json:\"lifecycleState\"`", + "Container.Terminated *time.Time `json:\"timeTerminated,omitempty\"`", + "Container.Updated *time.Time `json:\"timeUpdated,omitempty\"`", + "Container.WorkingDir string `json:\"workingDirectory,omitempty\"`", + "DNSConfig.Nameservers []string `json:\"nameservers,omitempty\"`", + "DNSConfig.Options []string `json:\"options,omitempty\"`", + "DNSConfig.Searches []string `json:\"searches,omitempty\"`", + "HealthCheck.FailureAction string `json:\"failureAction\"`", + "HealthCheck.FailureThresh int `json:\"failureThreshold\"`", + "HealthCheck.Headers []HealthHeader `json:\"headers,omitempty\"`", + "HealthCheck.InitialDelay int `json:\"initialDelayInSeconds\"`", + "HealthCheck.Interval int `json:\"intervalInSeconds\"`", + "HealthCheck.LastChecked *time.Time `json:\"lastChecked,omitempty\"`", + "HealthCheck.Name string `json:\"name,omitempty\"`", + "HealthCheck.Path string `json:\"path,omitempty\"`", + "HealthCheck.Port int `json:\"port\"`", + "HealthCheck.Status string `json:\"status\"`", + "HealthCheck.StatusDetails string `json:\"statusDetails,omitempty\"`", + "HealthCheck.SuccessThresh int `json:\"successThreshold\"`", + "HealthCheck.Timeout int `json:\"timeoutInSeconds\"`", + "HealthCheck.Type string `json:\"type\"`", + "HealthHeader.Name string `json:\"name\"`", + "HealthHeader.Value string `json:\"value\"`", + "Instance.AD string `json:\"availabilityDomain\"`", + "Instance.Compartment string `json:\"compartmentId\"`", + "Instance.Containers []*Container `json:\"containers\"`", + "Instance.Created time.Time `json:\"timeCreated\"`", + "Instance.DNS *DNSConfig `json:\"dnsConfig,omitempty\"`", + "Instance.Defined map[string]any `json:\"definedTags,omitempty\"`", + "Instance.Details string `json:\"lifecycleDetails,omitempty\"`", + "Instance.DisplayName string `json:\"displayName\"`", + "Instance.Etag string `json:\"etag\"`", + "Instance.FaultDomain string `json:\"faultDomain,omitempty\"`", + "Instance.Freeform map[string]string `json:\"freeformTags,omitempty\"`", + "Instance.Graceful *int64 `json:\"gracefulShutdownTimeoutInSeconds,omitempty\"`", + "Instance.ID string `json:\"id\"`", + "Instance.PullSecrets []PullSecret `json:\"imagePullSecrets,omitempty\"`", + "Instance.Restart string `json:\"containerRestartPolicy\"`", + "Instance.RetryToken string `json:\"retryToken,omitempty\"`", + "Instance.Security json.RawMessage `json:\"securityContext,omitempty\"`", + "Instance.Shape string `json:\"shape\"`", + "Instance.ShapeConfig ShapeConfig `json:\"shapeConfig\"`", + "Instance.State string `json:\"lifecycleState\"`", + "Instance.Target string `json:\"target,omitempty\"`", + "Instance.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Instance.Updated *time.Time `json:\"timeUpdated,omitempty\"`", + "Instance.Vnics []Vnic `json:\"vnics\"`", + "Instance.Volumes []Volume `json:\"volumes,omitempty\"`", + "PullSecret.Endpoint string `json:\"registryEndpoint\"`", + "PullSecret.Password string `json:\"password,omitempty\"`", + "PullSecret.SecretID string `json:\"secretId,omitempty\"`", + "PullSecret.Type string `json:\"type\"`", + "PullSecret.Username string `json:\"username,omitempty\"`", + "ResourceConfig.MemoryLimitInGBs *float32 `json:\"memoryLimitInGBs,omitempty\"`", + "ResourceConfig.VcpusLimit *float32 `json:\"vcpusLimit,omitempty\"`", + "ShapeConfig.Bandwidth float32 `json:\"networkingBandwidthInGbps\"`", + "ShapeConfig.Memory float32 `json:\"memoryInGBs\"`", + "ShapeConfig.Ocpus float32 `json:\"ocpus\"`", + "ShapeConfig.Processor string `json:\"processorDescription\"`", + "Vnic.SubnetID string `json:\"subnetId\"`", + "Vnic.VnicID string `json:\"vnicId\"`", + "Volume.BackingStore string `json:\"backingStore,omitempty\"`", + "Volume.Configs []ConfigFile `json:\"configs,omitempty\"`", + "Volume.Name string `json:\"name\"`", + "Volume.Type string `json:\"type\"`", + "VolumeMount.MountPath string `json:\"mountPath\"`", + "VolumeMount.Partition *int `json:\"partition,omitempty\"`", + "VolumeMount.ReadOnly *bool `json:\"isReadOnly,omitempty\"`", + "VolumeMount.SubPath string `json:\"subPath,omitempty\"`", + "VolumeMount.VolumeName string `json:\"volumeName\"`", + "WorkRequest.Accepted time.Time `json:\"timeAccepted\"`", + "WorkRequest.Action string `json:\"actionType\"`", + "WorkRequest.Compartment string `json:\"compartmentId\"`", + "WorkRequest.ErrCode string `json:\"errorCode,omitempty\"`", + "WorkRequest.ErrMsg string `json:\"errorMessage,omitempty\"`", + "WorkRequest.FinishAt *time.Time `json:\"finishAt,omitempty\"`", + "WorkRequest.FinishedAt *time.Time `json:\"timeFinished,omitempty\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.ResourceID string `json:\"resourceId\"`", + "WorkRequest.ResourceType string `json:\"resourceType\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "WorkRequest.Status string `json:\"status\"`", + "WorkRequest.Type string `json:\"operationType\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Instances map[string]*Instance `json:\"instances\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, "ocicore": { "fields": [ "BackupPolicyAssignment.AssetID string `json:\"assetId\"`", @@ -24551,6 +25840,37 @@ ], "version": 1 }, + "ocievents": { + "fields": [ + "Action.ActionType string `json:\"actionType\"`", + "Action.Description string `json:\"description,omitempty\"`", + "Action.FunctionID string `json:\"functionId,omitempty\"`", + "Action.ID string `json:\"id\"`", + "Action.IsEnabled bool `json:\"isEnabled\"`", + "Action.Message string `json:\"lifecycleMessage\"`", + "Action.State string `json:\"lifecycleState\"`", + "Action.StreamID string `json:\"streamId,omitempty\"`", + "Action.TopicID string `json:\"topicId,omitempty\"`", + "Rule.Actions []*Action `json:\"actions\"`", + "Rule.CompartmentID string `json:\"compartmentId\"`", + "Rule.Condition string `json:\"condition\"`", + "Rule.Description string `json:\"description,omitempty\"`", + "Rule.DisplayName string `json:\"displayName\"`", + "Rule.Etag string `json:\"etag\"`", + "Rule.ID string `json:\"id\"`", + "Rule.IsEnabled bool `json:\"isEnabled\"`", + "Rule.Seq int64 `json:\"seq\"`", + "Rule.State string `json:\"state\"`", + "Rule.Tags Tags `json:\"tags\"`", + "Rule.TimeCreated time.Time `json:\"timeCreated\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Rules map[string]*Rule `json:\"rules\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`" + ], + "version": 1 + }, "ocifunctions": { "fields": [ "Application.CompartmentID string `json:\"compartmentId\"`", @@ -24713,6 +26033,572 @@ ], "version": 1 }, + "ociloadbalancer": { + "fields": [ + "Backend.Backup bool `json:\"backup\"`", + "Backend.Drain bool `json:\"drain\"`", + "Backend.IPAddress string `json:\"ipAddress\"`", + "Backend.MaxConnections *int `json:\"maxConnections,omitempty\"`", + "Backend.Name string `json:\"name\"`", + "Backend.Offline bool `json:\"offline\"`", + "Backend.Port int `json:\"port\"`", + "Backend.Weight int `json:\"weight\"`", + "BackendSet.BackendMaxConnections *int `json:\"backendMaxConnections,omitempty\"`", + "BackendSet.Backends []Backend `json:\"backends\"`", + "BackendSet.HealthChecker HealthChecker `json:\"healthChecker\"`", + "BackendSet.LBCookiePersistence *LBCookiePersistence `json:\"lbCookieSessionPersistenceConfiguration,omitempty\"`", + "BackendSet.Name string `json:\"name\"`", + "BackendSet.Policy string `json:\"policy\"`", + "BackendSet.SSL *SSLConfig `json:\"sslConfiguration,omitempty\"`", + "BackendSet.SessionPersistence *SessionPersistence `json:\"sessionPersistenceConfiguration,omitempty\"`", + "Certificate.CA string `json:\"caCertificate\"`", + "Certificate.Name string `json:\"certificateName\"`", + "Certificate.Public string `json:\"publicCertificate\"`", + "ConnectionConfig.BackendTCPProxyProtocolOptions []string `json:\"backendTcpProxyProtocolOptions,omitempty\"`", + "ConnectionConfig.BackendTCPProxyProtocolVersion *int `json:\"backendTcpProxyProtocolVersion,omitempty\"`", + "ConnectionConfig.IdleTimeout int64 `json:\"idleTimeout\"`", + "HealthChecker.IntervalInMillis int `json:\"intervalInMillis\"`", + "HealthChecker.IsForcePlainText bool `json:\"isForcePlainText\"`", + "HealthChecker.Port int `json:\"port\"`", + "HealthChecker.Protocol string `json:\"protocol\"`", + "HealthChecker.ResponseBodyRegex string `json:\"responseBodyRegex\"`", + "HealthChecker.Retries int `json:\"retries\"`", + "HealthChecker.ReturnCode int `json:\"returnCode\"`", + "HealthChecker.TimeoutInMillis int `json:\"timeoutInMillis\"`", + "HealthChecker.URLPath string `json:\"urlPath,omitempty\"`", + "Hostname.Hostname string `json:\"hostname\"`", + "Hostname.Name string `json:\"name\"`", + "IPAddress.IPAddress string `json:\"ipAddress\"`", + "IPAddress.IsPublic bool `json:\"isPublic\"`", + "IPAddress.ReservedIP *ReservedIP `json:\"reservedIp,omitempty\"`", + "IPMaxConnections.IPAddresses []string `json:\"ipAddresses\"`", + "IPMaxConnections.MaxConnections int `json:\"maxConnections\"`", + "LBCookiePersistence.CookieName string `json:\"cookieName,omitempty\"`", + "LBCookiePersistence.DisableFallback *bool `json:\"disableFallback,omitempty\"`", + "LBCookiePersistence.Domain string `json:\"domain,omitempty\"`", + "LBCookiePersistence.IsHTTPOnly *bool `json:\"isHttpOnly,omitempty\"`", + "LBCookiePersistence.IsSecure *bool `json:\"isSecure,omitempty\"`", + "LBCookiePersistence.MaxAgeInSeconds *int `json:\"maxAgeInSeconds,omitempty\"`", + "LBCookiePersistence.Path string `json:\"path,omitempty\"`", + "Listener.Address string `json:\"gopherstackAddress,omitempty\"`", + "Listener.Connection *ConnectionConfig `json:\"connectionConfiguration,omitempty\"`", + "Listener.DefaultSet string `json:\"defaultBackendSetName\"`", + "Listener.Hostnames []string `json:\"hostnameNames,omitempty\"`", + "Listener.Name string `json:\"name\"`", + "Listener.PathRoutes string `json:\"pathRouteSetName,omitempty\"`", + "Listener.Policy string `json:\"routingPolicyName,omitempty\"`", + "Listener.Port int `json:\"port\"`", + "Listener.Protocol string `json:\"protocol\"`", + "Listener.RuleSets []string `json:\"ruleSetNames,omitempty\"`", + "Listener.SSL *SSLConfig `json:\"sslConfiguration,omitempty\"`", + "LoadBalancer.BackendSets map[string]*BackendSet `json:\"backendSets,omitempty\"`", + "LoadBalancer.BusyUntil time.Time `json:\"busyUntil,omitzero\"`", + "LoadBalancer.Certificates map[string]*Certificate `json:\"certificates,omitempty\"`", + "LoadBalancer.CompartmentID string `json:\"compartmentId\"`", + "LoadBalancer.DeleteProtected bool `json:\"isDeleteProtectionEnabled\"`", + "LoadBalancer.DisplayName string `json:\"displayName\"`", + "LoadBalancer.Etag string `json:\"etag,omitempty\"`", + "LoadBalancer.Hostnames map[string]*Hostname `json:\"hostnames,omitempty\"`", + "LoadBalancer.ID string `json:\"id\"`", + "LoadBalancer.IPAddresses []IPAddress `json:\"ipAddresses\"`", + "LoadBalancer.IPMode string `json:\"ipMode,omitempty\"`", + "LoadBalancer.IsPrivate bool `json:\"isPrivate\"`", + "LoadBalancer.Listeners map[string]*Listener `json:\"listeners,omitempty\"`", + "LoadBalancer.NSGs []string `json:\"networkSecurityGroupIds\"`", + "LoadBalancer.PathRouteSets map[string]*PathRouteSet `json:\"pathRouteSets,omitempty\"`", + "LoadBalancer.RequestIDHeader string `json:\"requestIdHeader,omitempty\"`", + "LoadBalancer.RequestIDOn bool `json:\"isRequestIdEnabled\"`", + "LoadBalancer.RoutingPolicies map[string]*RoutingPolicy `json:\"routingPolicies,omitempty\"`", + "LoadBalancer.RuleSets map[string]*RuleSet `json:\"ruleSets,omitempty\"`", + "LoadBalancer.SSLCipherSuites map[string]*SSLCipherSuite `json:\"sslCipherSuites,omitempty\"`", + "LoadBalancer.ShapeDetails *ShapeDetails `json:\"shapeDetails,omitempty\"`", + "LoadBalancer.ShapeName string `json:\"shapeName\"`", + "LoadBalancer.State string `json:\"lifecycleState\"`", + "LoadBalancer.SubnetIDs []string `json:\"subnetIds\"`", + "LoadBalancer.TimeCreated time.Time `json:\"timeCreated\"`", + "LoadBalancer.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "MatchType.MatchType string `json:\"matchType\"`", + "PathRoute.BackendSetName string `json:\"backendSetName\"`", + "PathRoute.Path string `json:\"path\"`", + "PathRoute.PathMatchType MatchType `json:\"pathMatchType\"`", + "PathRouteSet.Name string `json:\"name\"`", + "PathRouteSet.PathRoutes []PathRoute `json:\"pathRoutes\"`", + "RedirectURI.Host *string `json:\"host,omitempty\"`", + "RedirectURI.Path *string `json:\"path,omitempty\"`", + "RedirectURI.Port *int `json:\"port,omitempty\"`", + "RedirectURI.Protocol *string `json:\"protocol,omitempty\"`", + "RedirectURI.Query *string `json:\"query,omitempty\"`", + "ReservedIP.ID string `json:\"id,omitempty\"`", + "RoutingAction.BackendSetName string `json:\"backendSetName\"`", + "RoutingAction.Name string `json:\"name\"`", + "RoutingPolicy.ConditionLanguageVersion string `json:\"conditionLanguageVersion\"`", + "RoutingPolicy.Name string `json:\"name\"`", + "RoutingPolicy.Rules []RoutingRule `json:\"rules\"`", + "RoutingRule.Actions []RoutingAction `json:\"actions\"`", + "RoutingRule.Condition string `json:\"condition\"`", + "RoutingRule.Name string `json:\"name\"`", + "Rule.Action string `json:\"action\"`", + "Rule.AllowedMethods []string `json:\"allowedMethods,omitempty\"`", + "Rule.Conditions []RuleCondition `json:\"conditions,omitempty\"`", + "Rule.DefaultMaxConnections *int `json:\"defaultMaxConnections,omitempty\"`", + "Rule.Description *string `json:\"description,omitempty\"`", + "Rule.HTTPLargeHeaderSizeKB *int `json:\"httpLargeHeaderSizeInKB,omitempty\"`", + "Rule.Header string `json:\"header,omitempty\"`", + "Rule.IPMaxConnections []IPMaxConnections `json:\"ipMaxConnections,omitempty\"`", + "Rule.InvalidCharsAllowed *bool `json:\"areInvalidCharactersAllowed,omitempty\"`", + "Rule.Prefix *string `json:\"prefix,omitempty\"`", + "Rule.RedirectURI *RedirectURI `json:\"redirectUri,omitempty\"`", + "Rule.ResponseCode *int `json:\"responseCode,omitempty\"`", + "Rule.StatusCode *int `json:\"statusCode,omitempty\"`", + "Rule.Suffix *string `json:\"suffix,omitempty\"`", + "Rule.Value *string `json:\"value,omitempty\"`", + "RuleCondition.AttributeName string `json:\"attributeName\"`", + "RuleCondition.AttributeValue string `json:\"attributeValue\"`", + "RuleCondition.Operator string `json:\"operator,omitempty\"`", + "RuleSet.Items []Rule `json:\"items\"`", + "RuleSet.Name string `json:\"name\"`", + "SSLCipherSuite.Ciphers []string `json:\"ciphers\"`", + "SSLCipherSuite.Name string `json:\"name\"`", + "SSLConfig.CertificateIDs []string `json:\"certificateIds,omitempty\"`", + "SSLConfig.CertificateName string `json:\"certificateName,omitempty\"`", + "SSLConfig.CipherSuiteName string `json:\"cipherSuiteName,omitempty\"`", + "SSLConfig.HasSessionResumption *bool `json:\"hasSessionResumption,omitempty\"`", + "SSLConfig.Protocols []string `json:\"protocols,omitempty\"`", + "SSLConfig.ServerOrderPreference string `json:\"serverOrderPreference,omitempty\"`", + "SSLConfig.TrustedCertificateAuthorityIDs []string `json:\"trustedCertificateAuthorityIds,omitempty\"`", + "SSLConfig.VerifyDepth *int `json:\"verifyDepth,omitempty\"`", + "SSLConfig.VerifyPeerCertificate *bool `json:\"verifyPeerCertificate,omitempty\"`", + "SessionPersistence.CookieName string `json:\"cookieName\"`", + "SessionPersistence.DisableFallback *bool `json:\"disableFallback,omitempty\"`", + "ShapeDetails.Maximum int `json:\"maximumBandwidthInMbps\"`", + "ShapeDetails.Minimum int `json:\"minimumBandwidthInMbps\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "Tags.SecurityAttributes map[string]map[string]any `json:\"securityAttributes,omitempty\"`", + "WorkRequest.Accepted time.Time `json:\"accepted\"`", + "WorkRequest.CompartmentID string `json:\"compartmentId\"`", + "WorkRequest.FinishAt time.Time `json:\"finishAt\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.LoadBalancerID string `json:\"loadBalancerId\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "WorkRequest.Type string `json:\"type\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.IPSeq int64 `json:\"ipSeq\"`", + "backendSnapshot.LoadBalancers map[string]*LoadBalancer `json:\"loadBalancers\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, + "ocilogging": { + "fields": [ + "AgentConfig.CompartmentID string `json:\"compartmentId\"`", + "AgentConfig.ConfigType string `json:\"configurationType\"`", + "AgentConfig.Description string `json:\"description,omitempty\"`", + "AgentConfig.Enabled bool `json:\"isEnabled\"`", + "AgentConfig.Etag string `json:\"etag\"`", + "AgentConfig.Groups []string `json:\"groupList,omitempty\"`", + "AgentConfig.ID string `json:\"id\"`", + "AgentConfig.Name string `json:\"displayName\"`", + "AgentConfig.Seq int64 `json:\"seq\"`", + "AgentConfig.ServiceConfig json.RawMessage `json:\"serviceConfiguration\"`", + "AgentConfig.State string `json:\"state\"`", + "AgentConfig.Tags Tags `json:\"tags\"`", + "AgentConfig.TimeCreated time.Time `json:\"timeCreated\"`", + "AgentConfig.TimeModified time.Time `json:\"timeModified\"`", + "AgentConfig.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Entry.Data string `json:\"data\"`", + "Entry.ID string `json:\"id\"`", + "Entry.Ingested time.Time `json:\"ingested\"`", + "Entry.Seq int64 `json:\"seq\"`", + "Entry.Source string `json:\"source\"`", + "Entry.Subject string `json:\"subject,omitempty\"`", + "Entry.Time time.Time `json:\"time\"`", + "Entry.Type string `json:\"type\"`", + "Log.Config *LogConfig `json:\"configuration,omitempty\"`", + "Log.Enabled bool `json:\"isEnabled\"`", + "Log.Etag string `json:\"etag\"`", + "Log.GroupID string `json:\"logGroupId\"`", + "Log.ID string `json:\"id\"`", + "Log.LogType string `json:\"logType\"`", + "Log.Name string `json:\"displayName\"`", + "Log.Retention int `json:\"retentionDuration\"`", + "Log.Seq int64 `json:\"seq\"`", + "Log.State string `json:\"state\"`", + "Log.Tags Tags `json:\"tags\"`", + "Log.TimeCreated time.Time `json:\"timeCreated\"`", + "Log.TimeModified time.Time `json:\"timeModified\"`", + "Log.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "LogConfig.Archiving *bool `json:\"archiving,omitempty\"`", + "LogConfig.CompartmentID string `json:\"compartmentId,omitempty\"`", + "LogConfig.Source *ServiceSource `json:\"source,omitempty\"`", + "LogGroup.CompartmentID string `json:\"compartmentId\"`", + "LogGroup.Description string `json:\"description,omitempty\"`", + "LogGroup.Etag string `json:\"etag\"`", + "LogGroup.ID string `json:\"id\"`", + "LogGroup.Name string `json:\"displayName\"`", + "LogGroup.Seq int64 `json:\"seq\"`", + "LogGroup.State string `json:\"state\"`", + "LogGroup.Tags Tags `json:\"tags\"`", + "LogGroup.TimeCreated time.Time `json:\"timeCreated\"`", + "LogGroup.TimeModified time.Time `json:\"timeModified\"`", + "LogGroup.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "ServiceSource.Category string `json:\"category\"`", + "ServiceSource.Parameters map[string]string `json:\"parameters,omitempty\"`", + "ServiceSource.Resource string `json:\"resource\"`", + "ServiceSource.Service string `json:\"service\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "WorkRequest.Accepted time.Time `json:\"accepted\"`", + "WorkRequest.Action string `json:\"action\"`", + "WorkRequest.CompartmentID string `json:\"compartmentId\"`", + "WorkRequest.EntityID string `json:\"entityId\"`", + "WorkRequest.EntityType string `json:\"entityType\"`", + "WorkRequest.EntityURI string `json:\"entityUri\"`", + "WorkRequest.FinishAt time.Time `json:\"finishAt\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.Operation string `json:\"operation\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "backendSnapshot.Agents map[string]*AgentConfig `json:\"agentConfigurations\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Entries map[string][]*Entry `json:\"entries\"`", + "backendSnapshot.Groups map[string]*LogGroup `json:\"logGroups\"`", + "backendSnapshot.Logs map[string]*Log `json:\"logs\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, + "ocimonitoring": { + "fields": [ + "Alarm.Body string `json:\"body,omitempty\"`", + "Alarm.CompartmentID string `json:\"compartmentId\"`", + "Alarm.Destinations []string `json:\"destinations\"`", + "Alarm.Enabled bool `json:\"isEnabled\"`", + "Alarm.Etag string `json:\"etag\"`", + "Alarm.Extra map[string]string `json:\"extra,omitempty\"`", + "Alarm.History []HistoryEntry `json:\"history,omitempty\"`", + "Alarm.ID string `json:\"id\"`", + "Alarm.InSubtree *bool `json:\"metricCompartmentIdInSubtree,omitempty\"`", + "Alarm.LastNotified *time.Time `json:\"lastNotified,omitempty\"`", + "Alarm.MessageFormat string `json:\"messageFormat\"`", + "Alarm.MetricCompartmentID string `json:\"metricCompartmentId\"`", + "Alarm.Name string `json:\"displayName\"`", + "Alarm.Namespace string `json:\"namespace\"`", + "Alarm.Overrides []json.RawMessage `json:\"overrides,omitempty\"`", + "Alarm.PendingDuration string `json:\"pendingDuration\"`", + "Alarm.PendingSince *time.Time `json:\"pendingSince,omitempty\"`", + "Alarm.PerDimension *bool `json:\"isNotificationsPerMetricDimensionEnabled,omitempty\"`", + "Alarm.Query string `json:\"query\"`", + "Alarm.RepeatNotification string `json:\"repeatNotificationDuration,omitempty\"`", + "Alarm.Resolution string `json:\"resolution\"`", + "Alarm.ResourceGroup string `json:\"resourceGroup,omitempty\"`", + "Alarm.Seq int64 `json:\"seq\"`", + "Alarm.Severity string `json:\"severity\"`", + "Alarm.Status string `json:\"status\"`", + "Alarm.StatusSince time.Time `json:\"statusSince\"`", + "Alarm.Suppression *SuppressionSpec `json:\"suppression,omitempty\"`", + "Alarm.Tags Tags `json:\"tags\"`", + "Alarm.TimeCreated time.Time `json:\"timeCreated\"`", + "Alarm.TimeUpdated time.Time `json:\"timeUpdated\"`", + "HistoryEntry.From string `json:\"from,omitempty\"`", + "HistoryEntry.Summary string `json:\"summary\"`", + "HistoryEntry.Timestamp time.Time `json:\"timestamp\"`", + "HistoryEntry.TimestampTriggered time.Time `json:\"timestampTriggered\"`", + "HistoryEntry.To string `json:\"to\"`", + "Point.N int `json:\"n\"`", + "Point.T time.Time `json:\"t\"`", + "Point.V float64 `json:\"v\"`", + "Stream.CompartmentID string `json:\"compartmentId\"`", + "Stream.Dimensions map[string]string `json:\"dimensions\"`", + "Stream.Metadata map[string]string `json:\"metadata,omitempty\"`", + "Stream.Name string `json:\"name\"`", + "Stream.Namespace string `json:\"namespace\"`", + "Stream.Points []Point `json:\"points\"`", + "Stream.ResourceGroup string `json:\"resourceGroup,omitempty\"`", + "SuppressionSpec.Description string `json:\"description,omitempty\"`", + "SuppressionSpec.From time.Time `json:\"timeSuppressFrom\"`", + "SuppressionSpec.Until time.Time `json:\"timeSuppressUntil\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "backendSnapshot.Alarms map[string]*Alarm `json:\"alarms\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.Streams map[string]*Stream `json:\"streams\"`" + ], + "version": 1 + }, + "ocimysql": { + "fields": [ + "Backup.BackupType string `json:\"backupType\"`", + "Backup.CompartmentID string `json:\"compartmentId\"`", + "Backup.CreationType string `json:\"creationType\"`", + "Backup.DBSystemID string `json:\"dbSystemId\"`", + "Backup.Description string `json:\"description,omitempty\"`", + "Backup.Details string `json:\"details,omitempty\"`", + "Backup.Dump []byte `json:\"dump,omitempty\"`", + "Backup.Etag string `json:\"etag\"`", + "Backup.ID string `json:\"id\"`", + "Backup.Name string `json:\"displayName\"`", + "Backup.Retention int `json:\"retentionInDays\"`", + "Backup.Seq int64 `json:\"seq\"`", + "Backup.SizeGB int `json:\"backupSizeInGBs\"`", + "Backup.Snapshot *DBSystem `json:\"snapshot,omitempty\"`", + "Backup.SoftDelete string `json:\"softDelete\"`", + "Backup.State string `json:\"state\"`", + "Backup.Tags Tags `json:\"tags\"`", + "Backup.TimeCreated time.Time `json:\"timeCreated\"`", + "Backup.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Backup.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "BackupPolicy.CopyPolicies json.RawMessage `json:\"copyPolicies,omitempty\"`", + "BackupPolicy.IsEnabled bool `json:\"isEnabled\"`", + "BackupPolicy.PitrEnabled bool `json:\"pitrEnabled\"`", + "BackupPolicy.RetentionInDays int `json:\"retentionInDays\"`", + "BackupPolicy.SoftDelete string `json:\"softDelete\"`", + "BackupPolicy.Tags Tags `json:\"tags\"`", + "BackupPolicy.WindowStartTime string `json:\"windowStartTime\"`", + "Channel.After string `json:\"after,omitempty\"`", + "Channel.CompartmentID string `json:\"compartmentId\"`", + "Channel.Description string `json:\"description,omitempty\"`", + "Channel.Details string `json:\"details,omitempty\"`", + "Channel.Enabled bool `json:\"isEnabled\"`", + "Channel.Etag string `json:\"etag\"`", + "Channel.ID string `json:\"id\"`", + "Channel.Name string `json:\"displayName\"`", + "Channel.Seq int64 `json:\"seq\"`", + "Channel.Source ChannelSource `json:\"source\"`", + "Channel.State string `json:\"state\"`", + "Channel.Tags Tags `json:\"tags\"`", + "Channel.Target ChannelTarget `json:\"target\"`", + "Channel.TimeCreated time.Time `json:\"timeCreated\"`", + "Channel.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Channel.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "ChannelSource.Extra map[string]json.RawMessage `json:\"extra,omitempty\"`", + "ChannelSource.HasPassword bool `json:\"hasPassword\"`", + "ChannelSource.Hostname string `json:\"hostname\"`", + "ChannelSource.Port int `json:\"port\"`", + "ChannelSource.SourceType string `json:\"sourceType\"`", + "ChannelSource.SslMode string `json:\"sslMode\"`", + "ChannelSource.Username string `json:\"username\"`", + "ChannelTarget.ApplierUsername string `json:\"applierUsername\"`", + "ChannelTarget.ChannelName string `json:\"channelName\"`", + "ChannelTarget.DBSystemID string `json:\"dbSystemId\"`", + "ChannelTarget.DelayInSeconds int `json:\"delayInSeconds\"`", + "ChannelTarget.Filters json.RawMessage `json:\"filters,omitempty\"`", + "ChannelTarget.TablesWithoutPrimaryKeyHandling string `json:\"tablesWithoutPrimaryKeyHandling\"`", + "ChannelTarget.TargetType string `json:\"targetType\"`", + "Configuration.CompartmentID string `json:\"compartmentId\"`", + "Configuration.Description string `json:\"description,omitempty\"`", + "Configuration.Etag string `json:\"etag\"`", + "Configuration.ID string `json:\"id\"`", + "Configuration.LowerCase string `json:\"lowerCaseTableNames,omitempty\"`", + "Configuration.Name string `json:\"displayName\"`", + "Configuration.ParentID string `json:\"parentConfigurationId,omitempty\"`", + "Configuration.Seq int64 `json:\"seq\"`", + "Configuration.Shape string `json:\"shapeName\"`", + "Configuration.State string `json:\"state\"`", + "Configuration.Tags Tags `json:\"tags\"`", + "Configuration.TimeCreated time.Time `json:\"timeCreated\"`", + "Configuration.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Configuration.Type string `json:\"type\"`", + "Configuration.Variables map[string]any `json:\"variables\"`", + "DBSystem.AD string `json:\"availabilityDomain\"`", + "DBSystem.AccessMode string `json:\"accessMode\"`", + "DBSystem.AdminPass string `json:\"adminPassword,omitempty\"`", + "DBSystem.AdminUser string `json:\"adminUsername\"`", + "DBSystem.After string `json:\"after,omitempty\"`", + "DBSystem.AutoExpand bool `json:\"isAutoExpandStorageEnabled\"`", + "DBSystem.BackupPolicy BackupPolicy `json:\"backupPolicy\"`", + "DBSystem.CompartmentID string `json:\"compartmentId\"`", + "DBSystem.ConfigID string `json:\"configurationId\"`", + "DBSystem.CrashRecovery string `json:\"crashRecovery\"`", + "DBSystem.DBManagement string `json:\"databaseManagement\"`", + "DBSystem.DataGB int `json:\"dataStorageSizeInGBs\"`", + "DBSystem.DatabaseMode string `json:\"databaseMode\"`", + "DBSystem.Deletion DeletionPolicy `json:\"deletionPolicy\"`", + "DBSystem.Description string `json:\"description,omitempty\"`", + "DBSystem.Details string `json:\"details,omitempty\"`", + "DBSystem.EngineHost string `json:\"engineHost,omitempty\"`", + "DBSystem.EnginePort int `json:\"enginePort,omitempty\"`", + "DBSystem.Etag string `json:\"etag\"`", + "DBSystem.Extra map[string]json.RawMessage `json:\"extra,omitempty\"`", + "DBSystem.FD string `json:\"faultDomain\"`", + "DBSystem.HA bool `json:\"isHighlyAvailable\"`", + "DBSystem.Hostname string `json:\"hostnameLabel,omitempty\"`", + "DBSystem.ID string `json:\"id\"`", + "DBSystem.IP string `json:\"ipAddress\"`", + "DBSystem.LastAutoBackup time.Time `json:\"lastAutoBackup\"`", + "DBSystem.Maintenance map[string]any `json:\"maintenance,omitempty\"`", + "DBSystem.MaxGB int `json:\"maxStorageSizeInGBs,omitempty\"`", + "DBSystem.Name string `json:\"displayName\"`", + "DBSystem.Port int `json:\"port\"`", + "DBSystem.PortX int `json:\"portX\"`", + "DBSystem.RootSecret string `json:\"rootSecret,omitempty\"`", + "DBSystem.Seq int64 `json:\"seq\"`", + "DBSystem.Shape string `json:\"shapeName\"`", + "DBSystem.Source *Source `json:\"source,omitempty\"`", + "DBSystem.State string `json:\"state\"`", + "DBSystem.SubnetID string `json:\"subnetId\"`", + "DBSystem.Tags Tags `json:\"tags\"`", + "DBSystem.TimeCreated time.Time `json:\"timeCreated\"`", + "DBSystem.TimeUpdated time.Time `json:\"timeUpdated\"`", + "DBSystem.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "DeletionPolicy.AutomaticBackupRetention string `json:\"automaticBackupRetention\"`", + "DeletionPolicy.FinalBackup string `json:\"finalBackup\"`", + "DeletionPolicy.IsDeleteProtected bool `json:\"isDeleteProtected\"`", + "Source.BackupID string `json:\"backupId,omitempty\"`", + "Source.SourceType string `json:\"sourceType\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "WorkRequest.Accepted time.Time `json:\"accepted\"`", + "WorkRequest.Action string `json:\"action\"`", + "WorkRequest.CompartmentID string `json:\"compartmentId\"`", + "WorkRequest.EntityID string `json:\"entityId\"`", + "WorkRequest.EntityType string `json:\"entityType\"`", + "WorkRequest.EntityURI string `json:\"entityUri\"`", + "WorkRequest.Failure string `json:\"failure,omitempty\"`", + "WorkRequest.FinishAt *time.Time `json:\"finishAt,omitempty\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.Operation string `json:\"operation\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Backups map[string]*Backup `json:\"backups\"`", + "backendSnapshot.Channels map[string]*Channel `json:\"channels\"`", + "backendSnapshot.Configs map[string]*Configuration `json:\"configurations\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.Systems map[string]*DBSystem `json:\"dbSystems\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, + "ocinosql": { + "fields": [ + "Column.AsUUID bool `json:\"asUuid,omitempty\"`", + "Column.Default json.RawMessage `json:\"default,omitempty\"`", + "Column.Gen bool `json:\"generated,omitempty\"`", + "Column.Name string `json:\"name\"`", + "Column.NotNull bool `json:\"notNull,omitempty\"`", + "Column.Type string `json:\"type\"`", + "Identity.Always bool `json:\"always\"`", + "Identity.Column string `json:\"column\"`", + "Identity.Increment int64 `json:\"increment\"`", + "Identity.Next int64 `json:\"next\"`", + "Identity.OnNull bool `json:\"onNull,omitempty\"`", + "Index.Created time.Time `json:\"created\"`", + "Index.Keys []IndexKey `json:\"keys\"`", + "Index.Name string `json:\"name\"`", + "Index.State string `json:\"state\"`", + "Index.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "IndexKey.Column string `json:\"column\"`", + "IndexKey.JSONPath string `json:\"jsonPath,omitempty\"`", + "IndexKey.JSONType string `json:\"jsonType,omitempty\"`", + "Limits.Mode string `json:\"mode\"`", + "Limits.ReadUnits int `json:\"readUnits\"`", + "Limits.StorageGB int `json:\"storageGb\"`", + "Limits.WriteUnits int `json:\"writeUnits\"`", + "Row.Expire *time.Time `json:\"expire,omitempty\"`", + "Row.Value json.RawMessage `json:\"value\"`", + "Table.AutoReclaim bool `json:\"autoReclaimable,omitempty\"`", + "Table.Columns []Column `json:\"columns\"`", + "Table.CompartmentID string `json:\"compartmentId\"`", + "Table.DDL string `json:\"ddl\"`", + "Table.Etag string `json:\"etag\"`", + "Table.ID string `json:\"id\"`", + "Table.Identity *Identity `json:\"identity,omitempty\"`", + "Table.Indexes map[string]*Index `json:\"indexes,omitempty\"`", + "Table.Limits Limits `json:\"limits\"`", + "Table.Name string `json:\"name\"`", + "Table.PrimaryKey []string `json:\"primaryKey\"`", + "Table.RowSeq int64 `json:\"rowSeq\"`", + "Table.Rows map[string]*Row `json:\"rows,omitempty\"`", + "Table.ShardKey []string `json:\"shardKey\"`", + "Table.State string `json:\"state\"`", + "Table.TTLHours int `json:\"ttlHours\"`", + "Table.Tags Tags `json:\"tags\"`", + "Table.TimeCreated time.Time `json:\"timeCreated\"`", + "Table.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Table.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Table.Usage map[int64]*Usage `json:\"usage,omitempty\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "Usage.Reads int `json:\"reads\"`", + "Usage.Writes int `json:\"writes\"`", + "WorkRequest.Accepted time.Time `json:\"accepted\"`", + "WorkRequest.Action string `json:\"action\"`", + "WorkRequest.CompartmentID string `json:\"compartmentId\"`", + "WorkRequest.EntityID string `json:\"entityId\"`", + "WorkRequest.FinishAt time.Time `json:\"finishAt\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.Operation string `json:\"operation\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.Tables map[string]*Table `json:\"tables\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, + "ocinotifications": { + "fields": [ + "Delivery.Attempts int `json:\"attempts\"`", + "Delivery.Body string `json:\"body\"`", + "Delivery.Completed time.Time `json:\"completed\"`", + "Delivery.ConfirmationURL string `json:\"confirmationUrl,omitempty\"`", + "Delivery.Deadline time.Time `json:\"deadline\"`", + "Delivery.Endpoint string `json:\"endpoint\"`", + "Delivery.ID string `json:\"id\"`", + "Delivery.Kind string `json:\"kind\"`", + "Delivery.LastError string `json:\"lastError,omitempty\"`", + "Delivery.LastStatus int `json:\"lastStatus,omitempty\"`", + "Delivery.MessageID string `json:\"messageId\"`", + "Delivery.MessageType string `json:\"messageType\"`", + "Delivery.NextAttempt time.Time `json:\"nextAttempt\"`", + "Delivery.Protocol string `json:\"protocol\"`", + "Delivery.Published time.Time `json:\"published\"`", + "Delivery.Seq int64 `json:\"seq\"`", + "Delivery.Status string `json:\"status\"`", + "Delivery.SubscriptionID string `json:\"subscriptionId\"`", + "Delivery.Title string `json:\"title,omitempty\"`", + "Delivery.TopicID string `json:\"topicId\"`", + "Delivery.TopicName string `json:\"topicName\"`", + "Delivery.UnsubscribeURL string `json:\"unsubscribeUrl,omitempty\"`", + "Delivery.inflight bool", + "Subscription.BaseURL string `json:\"baseUrl\"`", + "Subscription.CompartmentID string `json:\"compartmentId\"`", + "Subscription.ConfirmToken string `json:\"confirmToken\"`", + "Subscription.Endpoint string `json:\"endpoint\"`", + "Subscription.Etag string `json:\"etag\"`", + "Subscription.ID string `json:\"id\"`", + "Subscription.MaxRetryMs int `json:\"maxRetryMs\"`", + "Subscription.Metadata string `json:\"metadata,omitempty\"`", + "Subscription.Protocol string `json:\"protocol\"`", + "Subscription.Seq int64 `json:\"seq\"`", + "Subscription.State string `json:\"state\"`", + "Subscription.Tags Tags `json:\"tags\"`", + "Subscription.TimeCreated time.Time `json:\"timeCreated\"`", + "Subscription.TopicID string `json:\"topicId\"`", + "Subscription.UnsubToken string `json:\"unsubToken\"`", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "Topic.CompartmentID string `json:\"compartmentId\"`", + "Topic.Description string `json:\"description\"`", + "Topic.Etag string `json:\"etag\"`", + "Topic.ID string `json:\"id\"`", + "Topic.Name string `json:\"name\"`", + "Topic.Seq int64 `json:\"seq\"`", + "Topic.ShortID string `json:\"shortId\"`", + "Topic.State string `json:\"state\"`", + "Topic.Tags Tags `json:\"tags\"`", + "Topic.TimeCreated time.Time `json:\"timeCreated\"`", + "Topic.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Deliveries map[string]*Delivery `json:\"deliveries\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.Subs map[string]*Subscription `json:\"subscriptions\"`", + "backendSnapshot.Topics map[string]*Topic `json:\"topics\"`" + ], + "version": 1 + }, "ociobjectstorage": { "fields": [ "Bucket.AutoTiering string `json:\"autoTiering,omitempty\"`", @@ -24810,6 +26696,56 @@ ], "version": 1 }, + "ociqueue": { + "fields": [ + "Message.Channel string `json:\"channel,omitempty\"`", + "Message.Content string `json:\"content\"`", + "Message.Created time.Time `json:\"created\"`", + "Message.Deliveries int `json:\"deliveries\"`", + "Message.Expire time.Time `json:\"expire\"`", + "Message.ID int64 `json:\"id\"`", + "Message.Props map[string]string `json:\"props,omitempty\"`", + "Message.Receipt string `json:\"receipt,omitempty\"`", + "Message.VisibleAfter time.Time `json:\"visibleAfter\"`", + "Queue.ChannelLimit int `json:\"channelConsumptionLimit\"`", + "Queue.CompartmentID string `json:\"compartmentId\"`", + "Queue.DLQ []*Message `json:\"dlq,omitempty\"`", + "Queue.DLQCount int `json:\"deadLetterQueueDeliveryCount\"`", + "Queue.Etag string `json:\"etag\"`", + "Queue.ID string `json:\"id\"`", + "Queue.KMSKeyID string `json:\"kmsKeyId,omitempty\"`", + "Queue.Messages []*Message `json:\"messages,omitempty\"`", + "Queue.Name string `json:\"name\"`", + "Queue.NextMsgID int64 `json:\"nextMsgId\"`", + "Queue.Retention int `json:\"retentionInSeconds\"`", + "Queue.Seq int64 `json:\"seq\"`", + "Queue.State string `json:\"state\"`", + "Queue.Tags Tags `json:\"tags\"`", + "Queue.TimeCreated time.Time `json:\"timeCreated\"`", + "Queue.TimeUpdated time.Time `json:\"timeUpdated\"`", + "Queue.Timeout int `json:\"timeoutInSeconds\"`", + "Queue.TransitionAt *time.Time `json:\"transitionAt,omitempty\"`", + "Queue.Visibility int `json:\"visibilityInSeconds\"`", + "Queue.wake chan struct{}", + "Tags.DefinedTags map[string]map[string]any `json:\"definedTags,omitempty\"`", + "Tags.FreeformTags map[string]string `json:\"freeformTags,omitempty\"`", + "WorkRequest.Accepted time.Time `json:\"accepted\"`", + "WorkRequest.Action string `json:\"action\"`", + "WorkRequest.CompartmentID string `json:\"compartmentId\"`", + "WorkRequest.EntityID string `json:\"entityId\"`", + "WorkRequest.EntityType string `json:\"entityType\"`", + "WorkRequest.EntityURI string `json:\"entityUri\"`", + "WorkRequest.FinishAt time.Time `json:\"finishAt\"`", + "WorkRequest.ID string `json:\"id\"`", + "WorkRequest.Operation string `json:\"operation\"`", + "WorkRequest.Seq int64 `json:\"seq\"`", + "backendSnapshot.Auth []ociauth.KeyRecord `json:\"authKeys\"`", + "backendSnapshot.Queues map[string]*Queue `json:\"queues\"`", + "backendSnapshot.Seq int64 `json:\"seq\"`", + "backendSnapshot.WorkReqs map[string]*WorkRequest `json:\"workRequests\"`" + ], + "version": 1 + }, "ocistreaming": { "fields": [ "Group.Instances map[string]time.Time `json:\"instances\"`", @@ -27630,6 +29566,7 @@ "DBClusterSnapshot.Iops int `json:\"iops,omitempty\"`", "DBClusterSnapshot.KmsKeyID string `json:\"kmsKeyId,omitempty\"`", "DBClusterSnapshot.MasterUsername string `json:\"masterUsername,omitempty\"`", + "DBClusterSnapshot.OwnerAccountID string `json:\"ownerAccountID,omitempty\"`", "DBClusterSnapshot.PercentProgress int `json:\"percentProgress\"`", "DBClusterSnapshot.Port int `json:\"port,omitempty\"`", "DBClusterSnapshot.SnapshotCreateTime time.Time `json:\"snapshotCreateTime\"`", @@ -27817,6 +29754,7 @@ "DBSnapshot.KmsKeyID string `json:\"kmsKeyID,omitempty\"`", "DBSnapshot.MasterUsername string `json:\"masterUsername,omitempty\"`", "DBSnapshot.OptionGroupName string `json:\"optionGroupName\"`", + "DBSnapshot.OwnerAccountID string `json:\"ownerAccountID,omitempty\"`", "DBSnapshot.PercentProgress int `json:\"percentProgress\"`", "DBSnapshot.Port int `json:\"port\"`", "DBSnapshot.SnapshotCreateTime time.Time `json:\"snapshotCreateTime\"`", @@ -33299,6 +35237,7 @@ "integrationsSnapshot.glueSyncWaiter asl.GlueSyncWaiter", "integrationsSnapshot.lambdaInvoker asl.LambdaInvoker", "integrationsSnapshot.mockRun *asl.MockRun", + "integrationsSnapshot.rejectObjectItems bool", "integrationsSnapshot.roleAuth roleauth.Authorizer", "integrationsSnapshot.s3Reader asl.S3Reader", "integrationsSnapshot.s3ResultWriter asl.S3Writer", @@ -33826,6 +35765,12 @@ "Datum.TimeSeriesValue []TimeSeriesDataPoint `json:\"TimeSeriesValue,omitempty\"`", "DimensionMapping.DimensionValueType string `json:\"DimensionValueType\"`", "DimensionMapping.Name string `json:\"Name\"`", + "ExecutionStats.BytesMetered int64 `json:\"BytesMetered,omitempty\"`", + "ExecutionStats.CumulativeBytesScanned int64 `json:\"CumulativeBytesScanned,omitempty\"`", + "ExecutionStats.DataWrites int64 `json:\"DataWrites,omitempty\"`", + "ExecutionStats.ExecutionTimeInMillis int64 `json:\"ExecutionTimeInMillis,omitempty\"`", + "ExecutionStats.QueryResultRows int64 `json:\"QueryResultRows,omitempty\"`", + "ExecutionStats.RecordsIngested int64 `json:\"RecordsIngested,omitempty\"`", "MixedMeasureMapping.MeasureName string `json:\"MeasureName,omitempty\"`", "MixedMeasureMapping.MeasureValueType string `json:\"MeasureValueType\"`", "MixedMeasureMapping.MultiMeasureAttributeMappings []MultiMeasureAttributeMapping `json:\"MultiMeasureAttributeMappings,omitempty\"`", @@ -33859,6 +35804,9 @@ "ScheduledQuery.ErrorReportS3BucketName string `json:\"error_report_s3_bucket_name\"`", "ScheduledQuery.ExecutionRoleArn string `json:\"execution_role_arn\"`", "ScheduledQuery.KmsKeyID string `json:\"kms_key_id,omitempty\"`", + "ScheduledQuery.LastRunFailure string `json:\"last_run_failure,omitempty\"`", + "ScheduledQuery.LastRunStats *ExecutionStats `json:\"last_run_stats,omitempty\"`", + "ScheduledQuery.LastRunStatus string `json:\"last_run_status,omitempty\"`", "ScheduledQuery.LastRunTime time.Time `json:\"last_run_time\"`", "ScheduledQuery.Name string `json:\"name\"`", "ScheduledQuery.NotificationTopicArn string `json:\"notification_topic_arn\"`", diff --git a/pkgs/pgengine/pgengine.go b/pkgs/pgengine/pgengine.go new file mode 100644 index 0000000000..9377903d9a --- /dev/null +++ b/pkgs/pgengine/pgengine.go @@ -0,0 +1,421 @@ +// Package pgengine runs real PostgreSQL servers in containers for emulators of managed PostgreSQL services: a named +// data volume, a published loopback port, SQL access as an engine-private superuser, and stop/start that keep data. +package pgengine + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const ( + // Superuser is the engine-private role; emulators keep its password to themselves. + Superuser = "gsroot" + + dataDir = "/var/lib/postgresql/data" + pgData = dataDir + "/pgdata" + servicePort = "5432" + readyInterval = 500 * time.Millisecond + connectLimit = 5 * time.Second + teardownLimit = 30 * time.Second + volumeLabel = "gopherstack.pgengine" + defaultStart = 3 * time.Minute + defaultPrefix = "pgengine" +) + +// ErrRuntimeUnavailable is returned when no container runtime is configured. +var ErrRuntimeUnavailable = errors.New("pgengine: container runtime unavailable") + +var errOp = errors.New("pgengine: operation failed") + +// opErr names a failed operation without echoing driver text, which may quote credentials. +func opErr(label string) error { return fmt.Errorf("%w: %s", errOp, label) } + +// Runtime is the slice of the container runtime the engine uses. +type Runtime interface { + CreateAndStart(ctx context.Context, spec container.Spec) (string, error) + StopAndRemove(ctx context.Context, containerID string) error + StopContainer(ctx context.Context, containerID string) error + StartContainer(ctx context.Context, containerID string) error +} + +// Config configures an Engine. Prefix names containers and volumes ("-..."). +type Config struct { + Runtime Runtime + Volumes container.VolumeRuntime + Ports *portalloc.Allocator + Logger *slog.Logger + Prefix string + StartTimeout time.Duration +} + +// Engine launches PostgreSQL servers. +type Engine struct{ cfg Config } + +// New validates cfg and returns an Engine. +func New(cfg Config) (*Engine, error) { + if cfg.Runtime == nil { + return nil, ErrRuntimeUnavailable + } + + if cfg.Prefix == "" { + cfg.Prefix = defaultPrefix + } + + if cfg.StartTimeout <= 0 { + cfg.StartTimeout = defaultStart + } + + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + + return &Engine{cfg: cfg}, nil +} + +// Spec describes one server. +type Spec struct { + // ID names the container and, when Persist is set, the data volume. + ID string + // Image is the container image, e.g. postgres:16. + Image string + // Password is the superuser password applied on first initialisation; it must match an existing volume. + Password string + Persist bool + Fresh bool +} + +// VolumeName is the data volume of server id. +func (e *Engine) VolumeName(id string) string { return e.cfg.Prefix + "-data-" + sanitize(id) } + +// RemoveVolume deletes the data volume of server id. +func (e *Engine) RemoveVolume(ctx context.Context, id string) { + if e.cfg.Volumes != nil { + _ = e.cfg.Volumes.RemoveVolume(ctx, e.VolumeName(id)) + } +} + +// Launch starts a server and waits until it accepts superuser connections. +func (e *Engine) Launch(ctx context.Context, spec Spec) (*Server, error) { + s := &Server{e: e, spec: spec} + if e.cfg.Volumes != nil && spec.Persist { + s.volume = e.VolumeName(spec.ID) + } + + lctx, cancel := context.WithTimeout(ctx, e.cfg.StartTimeout) + defer cancel() + + if err := s.boot(lctx); err != nil { + s.Close(context.WithoutCancel(ctx), false) + + return nil, err + } + + return s, nil +} + +// Server is one running PostgreSQL container. +type Server struct { + e *Engine + volume string + ctrID string + spec Spec + port int + mu sync.Mutex +} + +func (s *Server) boot(ctx context.Context) error { + if s.volume != "" { + vr := s.e.cfg.Volumes + + if s.spec.Fresh { + if err := vr.RemoveVolume(ctx, s.volume); err != nil { + return fmt.Errorf("discard stale volume %s: %w", s.volume, err) + } + } + + labels := map[string]string{volumeLabel: "true", volumeLabel + "." + s.e.cfg.Prefix: s.spec.ID} + if err := vr.CreateVolume(ctx, s.volume, labels); err != nil { + return fmt.Errorf("create volume %s: %w", s.volume, err) + } + } + + id, err := container.CreateAndStartRetry(ctx, s.e.cfg.Runtime, s.prepare, s.releasePort) + if err != nil { + return fmt.Errorf("start %s: %w", s.spec.Image, err) + } + + s.mu.Lock() + s.ctrID = id + s.mu.Unlock() + + return s.waitReady(ctx) +} + +func (s *Server) prepare() (container.Spec, error) { + port, err := portalloc.AcquireFrom(s.e.cfg.Ports, s.e.cfg.Prefix) + if err != nil { + return container.Spec{}, fmt.Errorf("host port: %w", err) + } + + s.mu.Lock() + s.port = port + s.mu.Unlock() + + spec := container.Spec{ + Name: s.e.cfg.Prefix + "-" + sanitize(s.spec.ID) + "-" + strconv.Itoa(port), + Image: s.spec.Image, + Env: []string{"POSTGRES_USER=" + Superuser, "POSTGRES_PASSWORD=" + s.spec.Password, "PGDATA=" + pgData}, + Ports: []string{container.PortSpec(container.LoopbackHost, strconv.Itoa(port), servicePort)}, + } + + if s.volume != "" { + spec.Mounts = append(spec.Mounts, s.volume+":"+dataDir) + } + + return spec, nil +} + +func (s *Server) releasePort() { + s.mu.Lock() + port := s.port + s.port = 0 + s.mu.Unlock() + + portalloc.ReleaseTo(s.e.cfg.Ports, port) +} + +func (s *Server) container() string { + s.mu.Lock() + defer s.mu.Unlock() + + return s.ctrID +} + +// Endpoint is the host and published port clients connect to. +func (s *Server) Endpoint() (string, int) { + s.mu.Lock() + defer s.mu.Unlock() + + return container.LoopbackHost, s.port +} + +func (s *Server) connect(ctx context.Context, db string) (*pgx.Conn, error) { + h, p := s.Endpoint() + + cfg, err := pgx.ParseConfig("postgres://" + Superuser + "@" + net.JoinHostPort(h, strconv.Itoa(p)) + "/postgres") + if err != nil { + return nil, opErr("connection config") + } + + cfg.Password, cfg.ConnectTimeout = s.spec.Password, connectLimit + + if db != "" { + cfg.Database = db + } + + conn, err := pgx.ConnectConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("pgengine: connect: %w", err) + } + + return conn, nil +} + +func (s *Server) waitReady(ctx context.Context) error { + tick := time.NewTicker(readyInterval) + defer tick.Stop() + + for { + if conn, err := s.connect(ctx, ""); err == nil { + _ = conn.Close(ctx) + + return nil + } + + select { + case <-ctx.Done(): + return fmt.Errorf("postgres did not accept connections: %w", ctx.Err()) + case <-tick.C: + } + } +} + +// Exec runs one statement as the superuser in database db ("" for the default). Callers quote identifiers and +// literals with Ident and Literal. +func (s *Server) Exec(ctx context.Context, db, stmt string) error { + conn, err := s.connect(ctx, db) + if err != nil { + return err + } + defer conn.Close(ctx) + + if _, err = conn.Exec(ctx, stmt); err != nil { + return opErr("statement failed") + } + + return nil +} + +// Ident quotes a SQL identifier. +func Ident(name string) string { return pgx.Identifier{name}.Sanitize() } + +// Literal quotes a SQL string literal (standard_conforming_strings is on in the official images). +func Literal(v string) string { return "'" + strings.ReplaceAll(v, "'", "''") + "'" } + +// EnsureAdmin creates or updates a login role that can create databases and roles and owns what it creates. +func (s *Server) EnsureAdmin(ctx context.Context, user, password string) error { + conn, err := s.connect(ctx, "") + if err != nil { + return err + } + defer conn.Close(ctx) + + var exists bool + const roleQuery = "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)" + if err = conn.QueryRow(ctx, roleQuery, user).Scan(&exists); err != nil { + return opErr("role lookup failed") + } + + verb := "CREATE" + if exists { + verb = "ALTER" + } + + stmt := verb + " ROLE " + Ident(user) + " WITH LOGIN CREATEDB CREATEROLE PASSWORD " + Literal(password) + if _, err = conn.Exec(ctx, stmt); err != nil { + return opErr("admin role update failed") + } + + return nil +} + +// CreateDatabase creates a database owned by owner; an existing one is left alone. +func (s *Server) CreateDatabase(ctx context.Context, name, owner, charset, collation string) error { + conn, err := s.connect(ctx, "") + if err != nil { + return err + } + defer conn.Close(ctx) + + var exists bool + const dbQuery = "SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)" + if err = conn.QueryRow(ctx, dbQuery, name).Scan(&exists); err != nil { + return opErr("database lookup failed") + } + + if exists { + return nil + } + + stmt := "CREATE DATABASE " + Ident(name) + " TEMPLATE template0" + if owner != "" { + stmt += " OWNER " + Ident(owner) + } + + if charset != "" { + stmt += " ENCODING " + Literal(charset) + } + + if collation != "" { + stmt += " LC_COLLATE " + Literal(collation) + " LC_CTYPE " + Literal(collation) + } + + if _, err = conn.Exec(ctx, stmt); err != nil { + return opErr("create database failed") + } + + return nil +} + +// DropDatabase drops a database, disconnecting its sessions; a missing one is not an error. +func (s *Server) DropDatabase(ctx context.Context, name string) error { + return s.Exec(ctx, "", "DROP DATABASE IF EXISTS "+Ident(name)+" WITH (FORCE)") +} + +// Databases lists the non-template databases. +func (s *Server) Databases(ctx context.Context) ([]string, error) { + conn, err := s.connect(ctx, "") + if err != nil { + return nil, err + } + defer conn.Close(ctx) + + rows, err := conn.Query(ctx, "SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname") + if err != nil { + return nil, opErr("database list failed") + } + defer rows.Close() + + var out []string + + for rows.Next() { + var n string + if err = rows.Scan(&n); err != nil { + return nil, opErr("database list failed") + } + + out = append(out, n) + } + + return out, nil +} + +// DropRole removes a login role; a missing one is not an error. +func (s *Server) DropRole(ctx context.Context, user string) error { + return s.Exec(ctx, "", "DROP ROLE IF EXISTS "+Ident(user)) +} + +// Stop stops the container, keeping its data and published port. +func (s *Server) Stop(ctx context.Context) error { + return s.e.cfg.Runtime.StopContainer(ctx, s.container()) //nolint:wrapcheck // runtime errors carry the id +} + +// Start starts a stopped server and waits until it accepts connections. +func (s *Server) Start(ctx context.Context) error { + if err := s.e.cfg.Runtime.StartContainer(ctx, s.container()); err != nil { + return err //nolint:wrapcheck // runtime errors carry the id + } + + return s.waitReady(ctx) +} + +// Close removes the container and releases its port; purge also deletes the data volume. +func (s *Server) Close(ctx context.Context, purge bool) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teardownLimit) + defer cancel() + + if id := s.container(); id != "" { + if err := s.e.cfg.Runtime.StopAndRemove(ctx, id); err != nil { + s.e.cfg.Logger.WarnContext(ctx, "pgengine: container removal failed", "container", id, "error", err) + } + } + + s.releasePort() + + if purge && s.volume != "" { + _ = s.e.cfg.Volumes.RemoveVolume(ctx, s.volume) + } +} + +func sanitize(v string) string { + b := []byte(strings.ToLower(v)) + for i, c := range b { + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { + b[i] = '-' + } + } + + return string(b) +} diff --git a/pkgs/pgengine/pgengine_test.go b/pkgs/pgengine/pgengine_test.go new file mode 100644 index 0000000000..87b8c41f22 --- /dev/null +++ b/pkgs/pgengine/pgengine_test.go @@ -0,0 +1,41 @@ +package pgengine_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/pgengine" +) + +func TestQuoting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + got string + want string + }{ + {"ident plain", pgengine.Ident("app"), `"app"`}, + {"ident quote", pgengine.Ident(`a"b`), `"a""b"`}, + {"literal plain", pgengine.Literal("pw"), `'pw'`}, + {"literal quote", pgengine.Literal("it's"), `'it''s'`}, + {"literal backslash", pgengine.Literal(`a\b`), `'a\b'`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, tt.got) + }) + } +} + +func TestNewRequiresRuntime(t *testing.T) { + t.Parallel() + + _, err := pgengine.New(pgengine.Config{}) + require.ErrorIs(t, err, pgengine.ErrRuntimeUnavailable) +} diff --git a/pkgs/portalloc/listen.go b/pkgs/portalloc/listen.go new file mode 100644 index 0000000000..2f8f254f48 --- /dev/null +++ b/pkgs/portalloc/listen.go @@ -0,0 +1,24 @@ +package portalloc + +import ( + "context" + "fmt" + "net" +) + +// ListenTCP binds ":port" and returns the listener with the port actually bound. +// Port 0 asks the OS for an ephemeral port, so callers never pre-pick one. +func ListenTCP(ctx context.Context, port int) (net.Listener, int, error) { + var lc net.ListenConfig + + ln, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return nil, 0, fmt.Errorf("listen tcp port %d: %w", port, err) + } + + if addr, ok := ln.Addr().(*net.TCPAddr); ok { + return ln, addr.Port, nil + } + + return ln, port, nil +} diff --git a/pkgs/portalloc/listen_test.go b/pkgs/portalloc/listen_test.go new file mode 100644 index 0000000000..4364087665 --- /dev/null +++ b/pkgs/portalloc/listen_test.go @@ -0,0 +1,26 @@ +package portalloc_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +func TestListenTCP(t *testing.T) { + t.Parallel() + + t.Run("port zero is ephemeral", func(t *testing.T) { + t.Parallel() + + ln, port, err := portalloc.ListenTCP(t.Context(), 0) + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + assert.Positive(t, port) + + _, _, err = portalloc.ListenTCP(t.Context(), port) + require.Error(t, err, "the bound port is held, not just probed") + }) +} diff --git a/pkgs/sdktest/serve.go b/pkgs/sdktest/serve.go index cb3e2c7d56..92a668e3a3 100644 --- a/pkgs/sdktest/serve.go +++ b/pkgs/sdktest/serve.go @@ -1,6 +1,7 @@ package sdktest import ( + "net/http" "net/http/httptest" "testing" @@ -29,3 +30,40 @@ func Serve(tb testing.TB, regs ...service.Registerable) string { return srv.URL } + +// InMemoryEndpoint is the base endpoint to pair with the client from ServeInMemory. +const InMemoryEndpoint = "http://gopherstack.invalid" + +type handlerTransport struct{ h http.Handler } + +func (t handlerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + t.h.ServeHTTP(rec, r) + + return rec.Result(), nil +} + +// HandlerClient returns a client that calls h in-process (no socket); usable inside a +// testing/synctest bubble. Pair it with InMemoryEndpoint. +func HandlerClient(h http.Handler) *http.Client { + return &http.Client{Transport: handlerTransport{h: h}} +} + +// ServeInMemory is Serve without a socket: the returned client calls the router in-process, so +// it is usable inside a testing/synctest bubble. Pair it with InMemoryEndpoint. +func ServeInMemory(tb testing.TB, regs ...service.Registerable) *http.Client { + tb.Helper() + + e := echo.New() + registry := service.NewRegistry() + + for _, r := range regs { + if err := registry.Register(r); err != nil { + tb.Fatalf("register %s: %v", r.Name(), err) + } + } + + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + return HandlerClient(e) +} diff --git a/pkgs/service/router.go b/pkgs/service/router.go index 077d5480ff..ff1a9d2909 100644 --- a/pkgs/service/router.go +++ b/pkgs/service/router.go @@ -25,6 +25,7 @@ type Router struct { targetGates [][]string pathGates [][]string cborGates []string + altGates []*AltGate } // Interceptor sees every request after the built-in lookup (entry is nil when no service @@ -47,6 +48,7 @@ func NewServiceRouter(registry *Registry) *Router { targetGates: make([][]string, len(services)), pathGates: make([][]string, len(services)), cborGates: make([]string, len(services)), + altGates: make([]*AltGate, len(services)), } r.idx = buildRouteIndex(r.targetGates, r.pathGates, r.cborGates) @@ -90,6 +92,21 @@ func (r *Router) WithPathGates(gates map[string][]string) *Router { return r } +// WithAltGates declares, by service name, OR-alternative gates (see AltGate). Alt-gated +// services stay in the always-visited list and are skipped cheaply before their matcher. +// Call before serving. +func (r *Router) WithAltGates(gates map[string]AltGate) *Router { + for i, entry := range r.services { + r.altGates[i] = nil + + if g, ok := gates[entry.Registerable.Name()]; ok && g.valid() { + r.altGates[i] = &g + } + } + + return r +} + // WithInterceptor installs i (nil clears it). Call before serving: the router is immutable afterwards. func (r *Router) WithInterceptor(i Interceptor) *Router { r.intercept = i @@ -124,38 +141,42 @@ func (r *Router) RouteHandler() echo.MiddlewareFunc { // Lookup returns the service entry the router selects for c, or nil if none matches. // Only services whose gate keys fit the request are tried, in priority order. func (r *Router) Lookup(c *echo.Context) *Entry { - target := extractTargetHeader(c) - path := extractPath(c) + q := lookupCtx{req: c.Request(), target: extractTargetHeader(c), rawPath: extractPath(c)} + q.path = gatePath(q.rawPath) var pathBucket, targetBucket []int - if len(path) > 1 && path[0] == '/' { - pathBucket = r.idx.byPath[path[1]] + var pathPrefixes [][]string + + if len(q.path) > 1 && q.path[0] == '/' { + pathBucket = r.idx.byPath[q.path[1]] + pathPrefixes = r.idx.byPathPf[q.path[1]] } - if target != "" { - targetBucket = r.idx.byTarget[target[0]] + if q.target != "" { + targetBucket = r.idx.byTarget[q.target[0]] } - cborName := rpcv2CBORServiceName(path) - if cb := r.idx.byCBOR[cborName]; cborName != "" && len(cb) > 0 { + q.cborName = rpcv2CBORServiceName(q.rawPath) + if cb := r.idx.byCBOR[q.cborName]; q.cborName != "" && len(cb) > 0 { targetBucket = mergeAscending(targetBucket, cb) } var ia, ip, it int for { - i := nextCandidate(r.idx.always, pathBucket, targetBucket, &ia, &ip, &it) + i, src := nextCandidate(r.idx.always, pathBucket, targetBucket, &ia, &ip, &it) if i < 0 { return nil } - if g := r.targetGates[i]; g != nil && !hasAnyPrefix(target, g) && - (cborName == "" || r.cborGates[i] != cborName) { - continue + var pfx []string + + if src == fromPath { + pfx = pathPrefixes[ip-1] } - if g := r.pathGates[i]; g != nil && !hasAnyPrefix(path, g) { + if !r.gatesPass(i, pfx, &q) { continue } @@ -165,6 +186,29 @@ func (r *Router) Lookup(c *echo.Context) *Entry { } } +// gatesPass applies every gate of service i; pathPfx, when non-nil, is the part of its path +// gate that can match q.path (see addKeyedPrefixes). +func (r *Router) gatesPass(i int, pathPfx []string, q *lookupCtx) bool { + if g := r.targetGates[i]; g != nil && !hasAnyPrefix(q.target, g) && + (q.cborName == "" || r.cborGates[i] != q.cborName) { + return false + } + + if g := r.pathGates[i]; g != nil { + if pathPfx == nil { + pathPfx = g + } + + if !hasAnyPrefix(q.path, pathPfx) { + return false + } + } + + g := r.altGates[i] + + return g == nil || g.admits(q) +} + // lookupLinear is the reference ungated first-match scan the index must agree with. func (r *Router) lookupLinear(c *echo.Context) *Entry { for _, entry := range r.services { @@ -176,6 +220,21 @@ func (r *Router) lookupLinear(c *echo.Context) *Entry { return nil } +// gatePath collapses leading slashes to one: some matchers trim them before matching, +// and a gate only has to admit what its matcher can claim. +func gatePath(p string) string { + if len(p) < 2 || p[0] != '/' || p[1] != '/' { + return p + } + + k := 2 + for k < len(p) && p[k] == '/' { + k++ + } + + return p[k-1:] +} + func hasAnyPrefix(s string, prefixes []string) bool { for _, p := range prefixes { if strings.HasPrefix(s, p) { diff --git a/pkgs/service/router_alt_gate.go b/pkgs/service/router_alt_gate.go new file mode 100644 index 0000000000..371293ee27 --- /dev/null +++ b/pkgs/service/router_alt_gate.go @@ -0,0 +1,190 @@ +package service + +import ( + "net/http" + "net/url" + "slices" + "strings" + "unicode/utf8" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" +) + +const ( + contentTypeHeader = "Content-Type" + userAgentHeader = "User-Agent" + amzUserAgentHeader = "X-Amz-User-Agent" +) + +// AltGate admits a request when ANY alternative holds, for matchers that accept via +// several independent signals. Every list is a necessary-condition superset of what the +// matcher claims; an empty gate (or one holding an empty string) leaves the service ungated. +type AltGate struct { + // Target: X-Amz-Target prefixes. + Target []string + // Path: path prefixes (after leading-slash collapsing). + Path []string + // PathSuffix: suffixes of the raw URL path. + PathSuffix []string + // Host: lowercase ASCII substrings of the Host header, matched case-insensitively. + Host []string + // UserAgent: lowercase ASCII substrings of User-Agent or X-Amz-User-Agent, matched case-insensitively. + UserAgent []string + // ContentType: lowercase ASCII substrings of Content-Type, matched case-insensitively. + ContentType []string + // Query: case-sensitive substrings of the raw query string. + Query []string + // Scope: exact SigV4 credential-scope service names (header or X-Amz-Credential query). + Scope []string +} + +func (g *AltGate) valid() bool { + lists := [][]string{g.Target, g.Path, g.PathSuffix, g.Host, g.UserAgent, g.ContentType, g.Query, g.Scope} + n := 0 + + for _, l := range lists { + n += len(l) + + if slices.Contains(l, "") { + return false + } + } + + return n > 0 +} + +// lookupCtx carries the per-request values gates read, parsed at most once. +type lookupCtx struct { + req *http.Request + target string + rawPath string + path string + cborName string + scope string + ctype string + ua string + amzUA string + scopeDone bool + ctypeDone bool + uaDone bool +} + +func (q *lookupCtx) contentType() string { + if !q.ctypeDone { + q.ctype = headerFirst(q.req.Header, contentTypeHeader) + q.ctypeDone = true + } + + return q.ctype +} + +func (q *lookupCtx) userAgents() (string, string) { + if !q.uaDone { + q.ua = headerFirst(q.req.Header, userAgentHeader) + q.amzUA = headerFirst(q.req.Header, amzUserAgentHeader) + q.uaDone = true + } + + return q.ua, q.amzUA +} + +func (q *lookupCtx) scopeService() string { + if !q.scopeDone { + q.scope = httputils.ExtractServiceFromRequest(q.req) + q.scopeDone = true + } + + return q.scope +} + +func (g *AltGate) admits(q *lookupCtx) bool { + if q.req == nil { + return true + } + + if hasAnyPrefix(q.target, g.Target) || hasAnyPrefix(q.path, g.Path) || hasAnySuffix(q.rawPath, g.PathSuffix) { + return true + } + + if containsAnyFold(q.req.Host, g.Host) || q.matchesUserAgent(g.UserAgent) || + (len(g.ContentType) > 0 && containsAnyFold(q.contentType(), g.ContentType)) || + hasAnyContains(q.req.URL, g.Query) { + return true + } + + if len(g.Scope) == 0 { + return false + } + + return slices.Contains(g.Scope, q.scopeService()) +} + +// headerFirst is Header.Get for an already-canonical key, minus the key canonicalisation. +func headerFirst(h http.Header, canonicalKey string) string { + if v := h[canonicalKey]; len(v) > 0 { + return v[0] + } + + return "" +} + +func (q *lookupCtx) matchesUserAgent(subs []string) bool { + if len(subs) == 0 { + return false + } + + ua, amzUA := q.userAgents() + + return containsAnyFold(ua, subs) || containsAnyFold(amzUA, subs) +} + +func hasAnyContains(u *url.URL, subs []string) bool { + if u == nil { + return false + } + + return slices.ContainsFunc(subs, func(x string) bool { return strings.Contains(u.RawQuery, x) }) +} + +func hasAnySuffix(s string, suffixes []string) bool { + return slices.ContainsFunc(suffixes, func(x string) bool { return strings.HasSuffix(s, x) }) +} + +// containsAnyFold reports whether s may contain any lowercase-ASCII sub, ignoring case. +// Non-ASCII input is admitted: Unicode lowercasing can fold it onto ASCII letters. +func containsAnyFold(s string, subs []string) bool { + if len(subs) == 0 || s == "" { + return false + } + + for i := range len(s) { + if s[i] >= utf8.RuneSelf { + return true + } + } + + return slices.ContainsFunc(subs, func(sub string) bool { return containsFoldASCII(s, sub) }) +} + +func containsFoldASCII(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + j := 0 + for j < len(sub) && lowerASCII(s[i+j]) == sub[j] { + j++ + } + + if j == len(sub) { + return true + } + } + + return false +} + +func lowerASCII(b byte) byte { + if b >= 'A' && b <= 'Z' { + return b + ('a' - 'A') + } + + return b +} diff --git a/pkgs/service/router_alt_gate_test.go b/pkgs/service/router_alt_gate_test.go new file mode 100644 index 0000000000..84acb00764 --- /dev/null +++ b/pkgs/service/router_alt_gate_test.go @@ -0,0 +1,104 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAltGateAdmits(t *testing.T) { + t.Parallel() + + gate := AltGate{ + Target: []string{"Svc."}, + Path: []string{"/svc/"}, + PathSuffix: []string{"/jwks.json"}, + Host: []string{".svc-url."}, + UserAgent: []string{"mediastore"}, + ContentType: []string{"application/x-www-form-urlencoded"}, + Query: []string{"Action="}, + Scope: []string{"svc"}, + } + + signedFor := func(svc string) map[string]string { + return map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=k/20260101/us-east-1/" + svc + + "/aws4_request, SignedHeaders=host, Signature=x", + } + } + form := map[string]string{"Content-Type": "Application/X-WWW-Form-Urlencoded"} + + tests := []struct { + headers map[string]string + name string + uri string + host string + want bool + }{ + {name: "none", uri: "/bucket/key"}, + {name: "target", uri: "/", headers: map[string]string{"X-Amz-Target": "Svc.Op"}, want: true}, + {name: "target_other", uri: "/", headers: map[string]string{"X-Amz-Target": "Other.Op"}}, + {name: "path", uri: "/svc/x", want: true}, + {name: "path_double_slash", uri: "//svc/x", want: true}, + {name: "path_suffix", uri: "/pool/jwks.json", want: true}, + {name: "host_fold", uri: "/", host: "ABC.SVC-URL.us-east-1.on.aws", want: true}, + {name: "host_other", uri: "/", host: "abc.example.com"}, + {name: "ua_fold", uri: "/", headers: map[string]string{"User-Agent": "x MediaStoreData"}, want: true}, + {name: "ua_non_ascii", uri: "/", headers: map[string]string{"User-Agent": "xİy"}, want: true}, + {name: "amz_ua", uri: "/", headers: map[string]string{"X-Amz-User-Agent": "js mediastore-data"}, want: true}, + {name: "ctype", uri: "/", headers: form, want: true}, + {name: "ctype_json", uri: "/", headers: map[string]string{"Content-Type": "application/json"}}, + {name: "query", uri: "/?Action=Foo", want: true}, + {name: "query_other", uri: "/?x=1"}, + {name: "scope_header", uri: "/", headers: signedFor("svc"), want: true}, + {name: "scope_other", uri: "/", headers: signedFor("s3")}, + { + name: "scope_presigned", want: true, + uri: "/?X-Amz-Credential=k%2F20260101%2Fus-east-1%2Fsvc%2Faws4_request", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "http://localhost:4566"+tt.uri, nil) + if tt.host != "" { + req.Host = tt.host + } + + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + q := lookupCtx{req: req, target: req.Header.Get(amzTargetHeader), rawPath: req.URL.Path} + q.path = gatePath(q.rawPath) + + assert.Equal(t, tt.want, gate.admits(&q)) + }) + } +} + +func TestAltGateValid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + gate AltGate + want bool + }{ + {name: "empty", gate: AltGate{}}, + {name: "empty_string", gate: AltGate{Target: []string{""}}}, + {name: "ok", gate: AltGate{Scope: []string{"s3"}}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, tt.gate.valid()) + }) + } +} diff --git a/pkgs/service/router_index.go b/pkgs/service/router_index.go index 7e4ce438fb..5af1f8fe2a 100644 --- a/pkgs/service/router_index.go +++ b/pkgs/service/router_index.go @@ -10,6 +10,7 @@ import ( type routeIndex struct { byCBOR map[string][]int byPath [256][]int + byPathPf [256][][]string byTarget [256][]int always []int } @@ -32,7 +33,7 @@ func buildRouteIndex(targetGates, pathGates [][]string, cborGates []string) rout for i := range targetGates { switch { case len(pathGates[i]) > 0: - addKeyed(&idx.byPath, i, pathGates[i], 1) + addKeyedPrefixes(&idx, i, pathGates[i]) case len(targetGates[i]) > 0: addKeyed(&idx.byTarget, i, targetGates[i], 0) @@ -58,6 +59,32 @@ func addKeyed(buckets *[256][]int, pos int, prefixes []string, keyAt int) { } } +// addKeyedPrefixes buckets pos under the second byte of each path prefix, recording per +// bucket only the prefixes sharing that byte: they are all a gate check needs there. +func addKeyedPrefixes(idx *routeIndex, pos int, prefixes []string) { + var seen [256]bool + + for _, p := range prefixes { + k := p[1] + if seen[k] { + continue + } + + seen[k] = true + + same := make([]string, 0, len(prefixes)) + + for _, q := range prefixes { + if q[1] == k { + same = append(same, q) + } + } + + idx.byPath[k] = append(idx.byPath[k], pos) + idx.byPathPf[k] = append(idx.byPathPf[k], same) + } +} + func head(s []int, i int) int { if i < len(s) { return s[i] @@ -66,27 +93,34 @@ func head(s []int, i int) int { return math.MaxInt } -// nextCandidate pops the smallest position across the three ascending lists, or -1. -func nextCandidate(a, b, c []int, ia, ib, ic *int) int { +const ( + fromAlways = iota + fromPath + fromTarget +) + +// nextCandidate pops the smallest position across the three ascending lists, or -1, and +// reports which list it came from. +func nextCandidate(a, b, c []int, ia, ib, ic *int) (int, int) { ha, hb, hc := head(a, *ia), head(b, *ib), head(c, *ic) m := min(ha, hb, hc) switch m { case math.MaxInt: - return -1 + return -1, fromAlways case ha: *ia++ - return m + return m, fromAlways case hb: *ib++ - return m + return m, fromPath default: *ic++ - return m + return m, fromTarget } } diff --git a/pkgs/service/router_index_test.go b/pkgs/service/router_index_test.go index 5807e8ef8c..2956cf6858 100644 --- a/pkgs/service/router_index_test.go +++ b/pkgs/service/router_index_test.go @@ -232,3 +232,25 @@ func TestRouterCBORGate(t *testing.T) { }) } } + +func TestGatePath(t *testing.T) { + t.Parallel() + + tests := []struct{ name, in, want string }{ + {"empty", "", ""}, + {"root", "/", "/"}, + {"plain", "/a/b", "/a/b"}, + {"double", "//a", "/a"}, + {"triple", "///a//b", "/a//b"}, + {"only_slashes", "////", "/"}, + {"relative", "a//b", "a//b"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, gatePath(tt.in)) + }) + } +} diff --git a/pkgs/testclient/testclient.go b/pkgs/testclient/testclient.go new file mode 100644 index 0000000000..b8d22dc46a --- /dev/null +++ b/pkgs/testclient/testclient.go @@ -0,0 +1,21 @@ +// Package testclient builds per-test HTTP clients that own their transport, so +// httptest.Server.Close (which closes idle conns on http.DefaultTransport) cannot break them. +package testclient + +import ( + "net/http" + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" +) + +// New returns a client with a private transport and default redirect policy and no timeout, +// matching the stdlib default client. Idle connections are closed when tb finishes. +func New(tb testing.TB) *http.Client { + tb.Helper() + + c := &http.Client{Transport: httpclient.NewTransport()} + tb.Cleanup(c.CloseIdleConnections) + + return c +} diff --git a/pkgs/transparent/s3route_test.go b/pkgs/transparent/s3route_test.go index 51bb405006..c92cc929de 100644 --- a/pkgs/transparent/s3route_test.go +++ b/pkgs/transparent/s3route_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/devtls" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/pkgs/transparent" ) @@ -165,7 +166,7 @@ func TestStartHTTP(t *testing.T) { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+srv.Addr().String()+"/x", nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/scripts/gocache-trim.sh b/scripts/gocache-trim.sh index ac28315fd6..d7a1577d57 100755 --- a/scripts/gocache-trim.sh +++ b/scripts/gocache-trim.sh @@ -6,15 +6,15 @@ # or the cache is over GOCACHE_MAX_GB (entries older than GOCACHE_AGED_MINUTES) # # Env: GOCACHE_TRIM_MINUTES (180) delete entries older than this, -# GOCACHE_CLEAN_GB (200) if the cache exceeds this, `go clean -cache` instead, # GOCACHE_MIN_FREE_GB (60) free-space floor for --if-low, # GOCACHE_MAX_GB (150) / GOCACHE_AGED_MINUTES (720) size cap for --if-low. +# Never runs `go clean -cache`: only entries older than the thresholds are deleted. If the disk is still +# below the floor afterwards the script warns and leaves the rest to the operator. # Two agents building in parallel can add >100GB between runs, so the floor is generous. set -euo pipefail -cache=$(go env GOCACHE) +cache="${GOCACHE:-$(go env GOCACHE)}" minutes="${GOCACHE_TRIM_MINUTES:-180}" -clean_gb="${GOCACHE_CLEAN_GB:-200}" min_free_gb="${GOCACHE_MIN_FREE_GB:-60}" max_gb="${GOCACHE_MAX_GB:-150}" aged_minutes="${GOCACHE_AGED_MINUTES:-720}" @@ -45,11 +45,9 @@ human() { awk -v k="$1" 'BEGIN {printf "%.1fGB", k/1048576}'; } before=$(size_kb) echo "gocache-trim: $cache is $(human "$before") before" -if [[ "$before" -gt $((clean_gb * 1048576)) ]]; then - echo "gocache-trim: over ${clean_gb}GB -- go clean -cache" - go clean -cache -else - find "$cache" -type f -mmin "+$minutes" -delete -fi +find "$cache" -type f -mmin "+$minutes" -delete after=$(size_kb) echo "gocache-trim: $(human "$after") after (freed $(human $((before - after))), $(free_gb)GB free)" +if [[ "${1:-}" == "--if-low" && "$(free_gb)" -lt "$min_free_gb" ]]; then + echo "gocache-trim: WARNING still below ${min_free_gb}GB free after trimming entries older than ${minutes}m" >&2 +fi diff --git a/scripts/gocache_trim_test.sh b/scripts/gocache_trim_test.sh new file mode 100644 index 0000000000..37bf441600 --- /dev/null +++ b/scripts/gocache_trim_test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# gocache-trim.sh must never wipe the cache; it only deletes entries older than the thresholds. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 1 +c=$(mktemp -d); trap 'rm -rf "$c"' EXIT +seed() { rm -rf "${c:?}"/*; mkdir -p "$c/ab"; echo x >"$c/ab/new"; echo x >"$c/ab/old"; touch -d '2 days ago' "$c/ab/old"; echo x >"$c/ab/mid"; touch -d '5 hours ago' "$c/ab/mid"; } +run() { GOCACHE="$c" GOCACHE_CLEAN_GB=0 "$@" 2>&1; } + +seed; out=$(run env GOCACHE_MAX_GB=0 GOCACHE_MIN_FREE_GB=0 bash scripts/gocache-trim.sh --if-low); rc=$? +[[ $rc -eq 0 && ! -e $c/ab/old && -e $c/ab/new && -e $c/ab/mid ]] || { echo "FAIL: over-max aged trim: $out"; exit 1; } + +seed; out=$(run env GOCACHE_MIN_FREE_GB=0 bash scripts/gocache-trim.sh --if-low) +[[ -e $c/ab/old && -e $c/ab/new ]] || { echo "FAIL: trimmed while under thresholds"; exit 1; } + +seed; out=$(run env GOCACHE_MIN_FREE_GB=999999 bash scripts/gocache-trim.sh --if-low) +[[ ! -e $c/ab/old && ! -e $c/ab/mid && -e $c/ab/new && -d $c ]] || { echo "FAIL: low-free trim: $out"; exit 1; } +grep -q WARNING <<<"$out" || { echo "FAIL: no warning when still low"; exit 1; } + +seed; out=$(run bash scripts/gocache-trim.sh) +[[ ! -e $c/ab/old && ! -e $c/ab/mid && -e $c/ab/new ]] || { echo "FAIL: unconditional trim: $out"; exit 1; } +grep -q 'clean -cache' <<<"$out" && { echo "FAIL: full wipe path"; exit 1; } +echo PASS diff --git a/scripts/lint-changed.sh b/scripts/lint-changed.sh index 1d2fdaae30..41d5b7cac9 100755 --- a/scripts/lint-changed.sh +++ b/scripts/lint-changed.sh @@ -211,6 +211,19 @@ run_one() { if [[ $rc -eq 124 ]] || grep -q 'context deadline exceeded' "$out"; then echo "lint-changed: TIMEOUT (LINT_TIMEOUT=$LINT_TIMEOUT) in $desc -- raise LINT_TIMEOUT or lower LINT_BATCH" >&2 fi + runs_total=$((runs_total + 1)) + if [[ $rc -ne 0 ]]; then + runs_failed=$((runs_failed + 1)) + local n + n=$(sed -nE 's/^([0-9]+) issues?[.:]?$/\1/p' "$out" | tail -1) + issues_total=$((issues_total + ${n:-0})) + if [[ -z "$n" || "$n" -eq 0 ]]; then + # Non-zero exit with no findings (e.g. systemd scope/OOM kill, exit 201): + # a tool failure, never a clean lint. + tool_failures=$((tool_failures + 1)) + echo "lint-changed: $desc exited $rc but reported no findings -- tool failure (OOM kill / scope / crash), NOT a clean run; re-run it" >&2 + fi + fi rm -f "$out" return "$rc" } @@ -246,6 +259,7 @@ run_batched() { } overall=0 +runs_total=0 runs_failed=0 issues_total=0 tool_failures=0 mulint_bin="" mulint_bin=$(go tool -n mulint-vet 2>/dev/null) || mulint_bin="" group_keys=("") @@ -259,4 +273,10 @@ for tags in "${group_keys[@]}"; do fi done +if [[ $overall -ne 0 ]]; then + echo "lint-changed: SUMMARY: FAILED -- $issues_total issue(s) across $runs_failed failing run(s) of $runs_total ($tool_failures tool failure(s))" +else + echo "lint-changed: SUMMARY: OK -- 0 issues across $runs_total run(s)" +fi + exit $overall diff --git a/scripts/lint_changed_test.sh b/scripts/lint_changed_test.sh new file mode 100755 index 0000000000..7081cbb060 --- /dev/null +++ b/scripts/lint_changed_test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Repro for gopherstack-hou6t: a known finding must be printed and the exit non-zero; +# a fake tool failure (exit 201, "0 issues.") must be flagged, not look clean. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" +d=internal/zzlintscratch +trap 'rm -rf "$d"' EXIT +mkdir -p "$d" +printf 'package zzlintscratch\n\nimport (\n\t"strings"\n\t"fmt"\n)\n\nfunc F() string { return strings.ToUpper(fmt.Sprint(1)) }\n' >"$d/a.go" +out=$(bash scripts/lint-changed.sh "$d/a.go" 2>&1); rc=$? +[[ $rc -ne 0 ]] || { echo "FAIL: exit 0 with finding"; exit 1; } +grep -q 'goimports' <<<"$out" || { echo "FAIL: finding not printed"; exit 1; } +grep -q 'SUMMARY: FAILED' <<<"$out" || { echo "FAIL: no summary"; exit 1; } +fake=$(mktemp -d); trap 'rm -rf "$d" "$fake"' EXIT +printf '#!/bin/sh\necho "0 issues."\nexit 201\n' >"$fake/golangci-lint"; chmod +x "$fake/golangci-lint" +out=$(PATH="$fake:$PATH" bash scripts/lint-changed.sh "$d/a.go" 2>&1); rc=$? +[[ $rc -ne 0 ]] || { echo "FAIL: fake 201 exit 0"; exit 1; } +grep -q 'tool failure' <<<"$out" || { echo "FAIL: tool failure not flagged"; exit 1; } +echo PASS diff --git a/services/accessanalyzer/iam_enforcement_test.go b/services/accessanalyzer/iam_enforcement_test.go index c6f5107a76..61f2530fac 100644 --- a/services/accessanalyzer/iam_enforcement_test.go +++ b/services/accessanalyzer/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/accessanalyzer" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/access-analyzer/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/account/iam_enforcement_test.go b/services/account/iam_enforcement_test.go index c40678adfc..47e16c14a7 100644 --- a/services/account/iam_enforcement_test.go +++ b/services/account/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/account" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -175,7 +176,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/account/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/acm/iam_enforcement_test.go b/services/acm/iam_enforcement_test.go index 1345894bf1..85bfb6c96a 100644 --- a/services/acm/iam_enforcement_test.go +++ b/services/acm/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/acm" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/acm/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/acmpca/iam_enforcement_test.go b/services/acmpca/iam_enforcement_test.go index 20ae4adf3a..6630431757 100644 --- a/services/acmpca/iam_enforcement_test.go +++ b/services/acmpca/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/acmpca" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -170,7 +171,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/acm-pca/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/amp/dataplane_test.go b/services/amp/dataplane_test.go index 79762b8e4a..583ac4104c 100644 --- a/services/amp/dataplane_test.go +++ b/services/amp/dataplane_test.go @@ -33,7 +33,7 @@ func (f *fixture) do(t *testing.T, method, path string, body io.Reader, hdr map[ req.Header.Set(k, v) } - res, err := http.DefaultClient.Do(req) + res, err := f.http.Do(req) require.NoError(t, err) defer res.Body.Close() diff --git a/services/amp/fixture_test.go b/services/amp/fixture_test.go index f86808ac3d..0c1314815f 100644 --- a/services/amp/fixture_test.go +++ b/services/amp/fixture_test.go @@ -2,6 +2,7 @@ package amp_test import ( "errors" + "net/http" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/aws/smithy-go" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/services/amp" ) @@ -31,6 +33,7 @@ type fixture struct { client *ampsdk.Client backend *amp.InMemoryBackend clock *fakeClock + http *http.Client url string } @@ -57,7 +60,10 @@ func newFixtureFromBackend(t *testing.T, backend *amp.InMemoryBackend, clock *fa client := ampsdk.NewFromConfig(cfg, func(o *ampsdk.Options) { o.BaseEndpoint = aws.String(url) }) - return &fixture{client: client, backend: backend, clock: clock, url: url} + hc := httpclient.New(30 * time.Second) + t.Cleanup(hc.CloseIdleConnections) + + return &fixture{client: client, backend: backend, clock: clock, http: hc, url: url} } func (f *fixture) settle() { f.clock.advance(time.Minute) } diff --git a/services/amplify/iam_enforcement_test.go b/services/amplify/iam_enforcement_test.go index 747d9fcc42..27e2cec11c 100644 --- a/services/amplify/iam_enforcement_test.go +++ b/services/amplify/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/amplify" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/amplify/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/apigateway/deployment_snapshot_test.go b/services/apigateway/deployment_snapshot_test.go index 9a22566ffc..297b6bcf7c 100644 --- a/services/apigateway/deployment_snapshot_test.go +++ b/services/apigateway/deployment_snapshot_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apigateway" ) @@ -59,7 +60,7 @@ func invokeStage(t *testing.T, baseURL, apiID, path string) (int, string) { ) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/apigateway/error_envelope_test.go b/services/apigateway/error_envelope_test.go index 4d28fd3b7c..6c3ef72fe2 100644 --- a/services/apigateway/error_envelope_test.go +++ b/services/apigateway/error_envelope_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apigateway" ) @@ -75,7 +76,7 @@ func TestErrorEnvelope_GetRestApiNotFoundDecodesToTypedError(t *testing.T) { srv.URL+"/restapis/does-not-exist", nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/apigateway/iam_enforcement_test.go b/services/apigateway/iam_enforcement_test.go index 9bfc93227a..a99f3c68d8 100644 --- a/services/apigateway/iam_enforcement_test.go +++ b/services/apigateway/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apigateway" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/apigateway/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/apigateway/response_cache_test.go b/services/apigateway/response_cache_test.go index ef0dc4fb17..9466410449 100644 --- a/services/apigateway/response_cache_test.go +++ b/services/apigateway/response_cache_test.go @@ -18,6 +18,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/cwmetric" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apigateway" ) @@ -214,7 +215,7 @@ func TestStageResponseCache_ControlInvalidation(t *testing.T) { req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=x") } - resp, doErr := http.DefaultClient.Do(req) + resp, doErr := testclient.New(t).Do(req) require.NoError(t, doErr) _ = resp.Body.Close() diff --git a/services/apigateway/wire_persisted_leak_test.go b/services/apigateway/wire_persisted_leak_test.go index 7b29f67079..ee3cf1fc44 100644 --- a/services/apigateway/wire_persisted_leak_test.go +++ b/services/apigateway/wire_persisted_leak_test.go @@ -17,17 +17,19 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apigateway" ) // captureTransport records the raw bytes of the last HTTP response, then // replays them so the real SDK deserializer still sees the full body. type captureTransport struct { + rt http.RoundTripper body []byte } func (c *captureTransport) Do(req *http.Request) (*http.Response, error) { - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := c.rt.RoundTrip(req) if err != nil { return nil, err } @@ -68,7 +70,7 @@ func newCapturingTestAPIGatewayClient(t *testing.T, h *apigateway.Handler) (*api ) require.NoError(t, err) - capture := &captureTransport{} + capture := &captureTransport{rt: testclient.New(t).Transport} client := apigatewaysdk.NewFromConfig(cfg, func(o *apigatewaysdk.Options) { o.BaseEndpoint = aws.String(srv.URL) diff --git a/services/apigatewaymanagementapi/iam_enforcement_test.go b/services/apigatewaymanagementapi/iam_enforcement_test.go index 3d14fbf74d..4af785547d 100644 --- a/services/apigatewaymanagementapi/iam_enforcement_test.go +++ b/services/apigatewaymanagementapi/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apigatewaymanagementapi" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -169,7 +170,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/execute-api/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/apigatewayv2/iam_enforcement_test.go b/services/apigatewayv2/iam_enforcement_test.go index ea13c42723..f23ca3555f 100644 --- a/services/apigatewayv2/iam_enforcement_test.go +++ b/services/apigatewayv2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apigatewayv2" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/apigatewayv2/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/apigatewayv2/rse.go b/services/apigatewayv2/rse.go index 6f742ceba5..56b1fdbecb 100644 --- a/services/apigatewayv2/rse.go +++ b/services/apigatewayv2/rse.go @@ -72,8 +72,20 @@ func parseJSONBody(r *http.Request) map[string]any { return nil } - raw, err := io.ReadAll(r.Body) - _ = r.Body.Close() + orig := r.Body + raw, err := io.ReadAll(io.LimitReader(orig, maxHTTPAPIBodyBytes+1)) + + if len(raw) > int(maxHTTPAPIBodyBytes) { + // Over the payload cap: leave the stream intact for the caller and skip parsing. + r.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(raw), orig), orig} + + return nil + } + + _ = orig.Close() // Restore the body so callers can read it again. r.Body = io.NopCloser(bytes.NewReader(raw)) diff --git a/services/apigatewayv2/rse_body_limit_test.go b/services/apigatewayv2/rse_body_limit_test.go new file mode 100644 index 0000000000..3f4605d082 --- /dev/null +++ b/services/apigatewayv2/rse_body_limit_test.go @@ -0,0 +1,48 @@ +package apigatewayv2_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/apigatewayv2" +) + +func TestEvalRouteSelectionExpression_BodyCap(t *testing.T) { + t.Parallel() + + const limit = 10 * 1024 * 1024 + + prefix := `{"action":"go","pad":"` + suffix := `"}` + + tests := []struct { + name string + want string + size int + }{ + {name: "at cap", size: limit, want: "go"}, + {name: "over cap", size: limit + 1, want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + body := prefix + strings.Repeat("a", tc.size-len(prefix)-len(suffix)) + suffix + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + + got := apigatewayv2.EvalRouteSelectionExpression("${request.body.action}", req) + assert.Equal(t, tc.want, got) + + rest, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Len(t, rest, tc.size) + }) + } +} diff --git a/services/appconfig/errsweep_wire_shape_test.go b/services/appconfig/errsweep_wire_shape_test.go index 9ba211c629..adea34d7dc 100644 --- a/services/appconfig/errsweep_wire_shape_test.go +++ b/services/appconfig/errsweep_wire_shape_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/appconfig" ) @@ -43,7 +44,7 @@ func TestHandler_UnmatchedRouteCarriesTypedErrorCode(t *testing.T) { "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/appconfig/aws4_request, "+ "SignedHeaders=host, Signature=deadbeef") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/appconfig/iam_enforcement_test.go b/services/appconfig/iam_enforcement_test.go index d1e69ec8fb..d552b533ca 100644 --- a/services/appconfig/iam_enforcement_test.go +++ b/services/appconfig/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/appconfig" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/appconfig/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/appconfigdata/iam_enforcement_test.go b/services/appconfigdata/iam_enforcement_test.go index 05a647b5db..172e04574e 100644 --- a/services/appconfigdata/iam_enforcement_test.go +++ b/services/appconfigdata/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/appconfigdata" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -166,7 +167,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/appconfig/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/applicationautoscaling/iam_enforcement_test.go b/services/applicationautoscaling/iam_enforcement_test.go index a42ea05c48..3b9a01df1a 100644 --- a/services/applicationautoscaling/iam_enforcement_test.go +++ b/services/applicationautoscaling/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/applicationautoscaling" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -168,7 +169,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/application-autoscaling/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/appmesh/iam_enforcement_test.go b/services/appmesh/iam_enforcement_test.go index c7d25cf061..45eee13f1a 100644 --- a/services/appmesh/iam_enforcement_test.go +++ b/services/appmesh/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/appmesh" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/appmesh/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/apprunner/iam_enforcement_test.go b/services/apprunner/iam_enforcement_test.go index c088d3d8a9..4992ce214d 100644 --- a/services/apprunner/iam_enforcement_test.go +++ b/services/apprunner/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/apprunner" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/apprunner/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/appstream/iam_enforcement_test.go b/services/appstream/iam_enforcement_test.go index 2db01ee5a9..64a7e5c624 100644 --- a/services/appstream/iam_enforcement_test.go +++ b/services/appstream/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/appstream" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/appstream/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/appsync/iam_enforcement_test.go b/services/appsync/iam_enforcement_test.go index 8b7d001749..3269beb18f 100644 --- a/services/appsync/iam_enforcement_test.go +++ b/services/appsync/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/appsync" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/appsync/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/appsync/wire_persisted_leak_test.go b/services/appsync/wire_persisted_leak_test.go index f1b7321a08..b7c277a654 100644 --- a/services/appsync/wire_persisted_leak_test.go +++ b/services/appsync/wire_persisted_leak_test.go @@ -17,17 +17,19 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/appsync" ) // captureTransport records the raw bytes of the last HTTP response, then // replays them so the real SDK deserializer still sees the full body. type captureTransport struct { + rt http.RoundTripper body []byte } func (c *captureTransport) Do(req *http.Request) (*http.Response, error) { - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := c.rt.RoundTrip(req) if err != nil { return nil, err } @@ -68,7 +70,7 @@ func newCapturingTestAppsyncClient(t *testing.T, h *appsync.Handler) (*appsyncsd ) require.NoError(t, err) - capture := &captureTransport{} + capture := &captureTransport{rt: testclient.New(t).Transport} client := appsyncsdk.NewFromConfig(cfg, func(o *appsyncsdk.Options) { o.BaseEndpoint = aws.String(srv.URL) diff --git a/services/athena/PARITY.md b/services/athena/PARITY.md index a5f399b0fc..e0de172ac6 100644 --- a/services/athena/PARITY.md +++ b/services/athena/PARITY.md @@ -32,6 +32,7 @@ ops: SqlEngine (StartQueryExecution over S3/Glue): {wire: ok, errors: ok, state: ok, persist: ok, note: "SELECT/WITH over Glue tables loads each table's S3 objects (all of Location, or each registered partition's location with partition keys as columns) into a per-query in-memory SQLite (modernc, no new deps), translates the Presto/Trino subset (quoted identifiers, catalog.db.table, approx_distinct, if, strpos, CAST types, DATE/TIMESTAMP literals, EXTRACT, date_trunc/date_add/date_diff/year/month/day/hour, regexp_like, CTEs, joins, window functions) and runs it. Formats: OpenCSVSerde (separatorChar), LazySimpleSerDe (field.delim, skip.header.line.count, \\N nulls), openx/hcatalog JsonSerDe lines; gzip detected by magic bytes. Parquet tables are read for real (see 2026-10-18 note); ORC/Avro fail with NOT_SUPPORTED. Trino syntax layer (2026-10-19): subscripts a[1]/m['k'] (1-based, Trino error text), col.field on ROW columns, lambdas, INTERVAL +/- date arithmetic, GROUP BY ROLLUP/CUBE/GROUPING SETS with grouping(), ORDER BY NULLS LAST default, partition projection, and Trino rendering of ARRAY/MAP/ROW results ([1, 2], {k=v}). Glue-routed DDL: CREATE [EXTERNAL] TABLE ... LOCATION, DROP TABLE, MSCK REPAIR TABLE (Hive key=value dirs), ALTER TABLE ADD PARTITION, SHOW TABLES/DATABASES/PARTITIONS/COLUMNS, DESCRIBE. Failures: SYNTAX_ERROR, COLUMN_NOT_FOUND, TABLE_NOT_FOUND, EXCEEDED_LIMIT (SetEngineLimits caps loaded rows, result rows/bytes, scanned bytes; 30m default timeout -> FAILED). CREATE EXTERNAL TABLE without a LOCATION clause fails with SYNTAX_ERROR "External tables must specify a LOCATION clause" (failure itself is Athena behaviour; the exact message text is UNVERIFIED against live Athena, gopherstack-cefu7.45). Result CSV (all fields quoted, as Athena writes it) plus a JSON .csv.metadata (AWS's binary layout is undocumented) go to OutputLocation. Wired through GlueTableCatalog (internal/app/athena_engine.go methods on athenaGlueAdapter) and the s3 backend's GetObject/ListObjectsV2."} GetQueryResults: {wire: ok, errors: ok, state: ok, persist: ok, note: "ResultSet/Row/Datum/ColumnInfo shapes verified against awsAwsjson11 deserializers; header row only on first page, matching AWS. SQL NULL is a Datum with no VarCharValue (distinct from an empty string); the result CSV leaves NULL empty and unquoted and writes '' as \"\" (Athena's documented CSV behaviour per third-party client docs, not the pinned SDK)."} CTAS/INSERT/UNLOAD (S3 + Glue writes): {wire: ok, errors: ok, state: ok, persist: ok, note: "CREATE TABLE [IF NOT EXISTS] ... WITH (format, external_location, partitioned_by, field_delimiter, write_compression) AS SELECT [WITH [NO] DATA], INSERT INTO t [(cols)] SELECT|VALUES and UNLOAD (SELECT) TO 's3://..' WITH (format, compression, field_delimiter, partitioned_by) run the SELECT through the SQL engine and write PARQUET (default, as Athena), TEXTFILE/CSV (LazySimpleSerDe, \\N nulls) or JSON-lines objects, optionally gzip (Parquet: SNAPPY/GZIP/ZSTD/LZ4/NONE), to the table location (default /tables//), partitioned as key=value/ dirs. CTAS registers the Glue table (StorageDescriptor, SerDe, partition keys) and partitions; INSERT appends objects and registers new partitions. A non-empty target fails HIVE_PATH_ALREADY_EXISTS, an existing table TABLE_ALREADY_EXISTS. Written bytes are capped by SetEngineLimits MaxResultBytes. Error wording and file names are modeled on, not verified against, real Athena."} + Iceberg and Delta Lake tables (Athena SQL engine): {wire: ok, errors: partial, state: ok, persist: ok, note: "Read: Glue tables with Parameters table_type=ICEBERG (metadata_location) and S3 Tables through the federated catalog s3tablescatalog/ (database = namespace) resolve the current snapshot from metadata.json, read the manifest list and manifests (Avro, hamba/avro), then the PARQUET data files, matching columns by Parquet field id before name, using identity-partition values for columns a file lacks and applying position deletes. FOR TIMESTAMP AS OF / FOR VERSION AS OF and the $snapshots, $history, $files, $manifests and $refs metadata tables follow https://docs.aws.amazon.com/athena/latest/ug/querying-iceberg-time-travel-and-version-travel-queries.html and .../querying-iceberg-table-data.html. Write: CREATE TABLE ... PARTITIONED BY (col, year|month|day|hour(col), bucket(N,col), truncate(W,col)) LOCATION ... TBLPROPERTIES ('table_type'='ICEBERG') writes a v2 metadata.json (pkgs/iceberg, shared with services/s3tables) and registers a Glue table with table_type/metadata_location; INSERT INTO writes field-id-tagged Parquet files, a manifest, a manifest list and a new snapshot, then swaps metadata_location with an optimistic compare-and-set (Glue SwapTableParameters, or S3 Tables UpdateTableMetadataLocation's version token) and re-plans on a lost race (up to 4 attempts, then ICEBERG_COMMIT_ERROR). Property allow-list, the External-keyword error and the Delta CREATE EXTERNAL TABLE form are from .../querying-iceberg-creating-tables.html and .../delta-lake-tables-getting-started.html. Delta Lake: CREATE EXTERNAL TABLE ... LOCATION ... TBLPROPERTIES ('table_type'='DELTA') syncs columns and partition keys from the _delta_log schema into Glue; SELECT replays JSON commits plus the latest checkpoint Parquet (pkgs/deltalog), handles name column mapping and rejects reader versions above 3 and deletion vectors. Delta is read-only without time travel, as documented (.../delta-lake-tables.html). Error names ICEBERG_MISSING_METADATA, ICEBERG_INVALID_METADATA, ICEBERG_FILESYSTEM_ERROR, ICEBERG_COMMIT_ERROR, INVALID_ARGUMENTS (unknown snapshot / no history at or before) and DELTA_LAKE_* follow Trino's connector conventions and are NOT stated in the Athena docs: UNVERIFIED against live Athena. Written metadata is validated by round-tripping through this package's own reader, not the Apache Iceberg library (not in go.mod)."} ListQueryExecutions: {wire: ok, errors: ok, state: ok, persist: ok, note: "opaque-token pagination via pkgs' page-token codec"} BatchGetQueryExecution: {wire: ok, errors: ok, state: ok, persist: ok} WorkGroup (Create/Get/List/Update/Delete): {wire: ok, errors: ok, state: ok, persist: fixed, note: "FIXED 2026-08-28 (gopherstack-6flj) — WorkGroupConfiguration had no EngineConfiguration or MonitoringConfiguration field at all (both real members on types.WorkGroupConfiguration/types.WorkGroupConfigurationUpdates), so CreateWorkGroup/UpdateWorkGroup silently dropped them; added both, wired through MergeInto for Update's partial-update semantics. Also fixed EngineConfiguration.Classifications ([]types.Classification), missing from the shared EngineConfiguration model used by both WorkGroup and Session. IdentityCenterConfiguration/ManagedQueryResultsConfiguration/QueryResultsS3AccessGrantsConfiguration remain unmodeled -- see gaps. FIXED (2026-07-23) — WorkGroup carried an invented Tags field (real GetWorkGroupOutput.WorkGroup has none; tags are TagResource/ListTagsForResource-only) that also went stale the moment TagResource/UntagResource were called, since those never touched it. Field removed; CreateWorkGroup's Tags input now flows only into resourceTags. Also FIXED (previous pass) — ResultConfiguration.ACLConfiguration was tagged json:\"ACLConfiguration\"; real wire key is \"AclConfiguration\". 2026-08-21 (gopherstack-1vv2): persist was accept-and-corrupt — UpdateWorkGroupInput.ConfigurationUpdates is types.WorkGroupConfigurationUpdates, a partial-update shape a real client only ever sends the changed fields of, but the handler decoded it into the same WorkGroupConfiguration type as Create and the backend wholesale-replaced wg.Configuration with it -- so any single-field Update (e.g. just EnforceWorkGroupConfiguration) silently erased ResultConfiguration/EngineVersion/etc. set at Create. Fixed: new WorkGroupConfigurationUpdates type (pointer scalars, so omitted is distinguishable from explicit false/0/empty) with a MergeInto that only touches fields actually present. See TestHandler_UpdateWorkGroup_PreservesUnmentionedConfiguration. ResultConfigurationUpdates' Remove* explicit-clear flags remain unmodeled -- separate gap, not fixed this pass."} @@ -54,6 +55,9 @@ families: janitor/leaks: {status: clean, note: "worker.Group-based ticker with ctx cancellation; sweeps queryExecutions+queryResults, sessions, calculations under RLock-collect/Lock-delete with re-verification to avoid racing a concurrent revival. No goroutine leak risk found."} gaps: [] items_still_open: + - "Iceberg: DELETE, UPDATE, MERGE, OPTIMIZE, VACUUM, CTAS with table_type='ICEBERG', ALTER TABLE schema/partition evolution, equality deletes, ORC/Avro data files, writing nested (array/map/struct) columns, the $partitions metadata table, partition pruning from manifest bounds (all data files are read), timestamp precision above milliseconds." + - "S3 Tables catalog: CREATE/DROP TABLE and CTAS through s3tablescatalog/ are rejected (use the S3 Tables API); ListDatabases/GetTableMetadata/ListTableMetadata do not enumerate s3tablescatalog tables; ROW-column dot access is not resolved on S3 Tables tables at plan time." + - "Delta Lake: deletion vectors and reader versions above 3 fail NOT_SUPPORTED; id column-mapping and timestampNtz reads are untested against real writers." - "ORC/Avro input and output formats: no pure-Go ORC or Avro library in go.mod (only parquet-go), so these stay NOT_SUPPORTED until a dependency is approved." - "Complex-type results: Boolean expression results render 1/0 rather than true/false (SQLite has no boolean type); ROW(...) constructors, CAST(... AS ROW(...)) and anonymous rows are unsupported; map and row expression results both report ColumnInfo type map; integral doubles inside arrays render without the .0 suffix; col.field dereference works on ROW columns of a referenced table only (not CTE/subquery outputs or lambda parameters)." - "Lambda functions cover transform, filter, reduce, any_match/all_match/none_match, map_filter, transform_values, transform_keys and zip_with; array_sort(arr, comparator), map_zip_with, reduce_agg and zip_with over more than two arrays are not translated." diff --git a/services/athena/README.md b/services/athena/README.md index bee93fe841..a5e52b5f16 100644 --- a/services/athena/README.md +++ b/services/athena/README.md @@ -7,15 +7,18 @@ | Metric | Value | | --- | --- | -| PARITY entries audited | 26 (26 ok) | +| PARITY entries audited | 27 (26 ok, 1 partial) | | Feature families | 2 (2 ok) | -| Known gaps | 7 | +| Known gaps | 10 | | Structural gaps (can't be emulated) | 6 | | Deferred items | 1 | | Resource leaks | clean | ### Known gaps +- Iceberg: DELETE, UPDATE, MERGE, OPTIMIZE, VACUUM, CTAS with table_type='ICEBERG', ALTER TABLE schema/partition evolution, equality deletes, ORC/Avro data files, writing nested (array/map/struct) columns, the $partitions metadata table, partition pruning from manifest bounds (all data files are read), timestamp precision above milliseconds. +- S3 Tables catalog: CREATE/DROP TABLE and CTAS through s3tablescatalog/ are rejected (use the S3 Tables API); ListDatabases/GetTableMetadata/ListTableMetadata do not enumerate s3tablescatalog tables; ROW-column dot access is not resolved on S3 Tables tables at plan time. +- Delta Lake: deletion vectors and reader versions above 3 fail NOT_SUPPORTED; id column-mapping and timestampNtz reads are untested against real writers. - ORC/Avro input and output formats: no pure-Go ORC or Avro library in go.mod (only parquet-go), so these stay NOT_SUPPORTED until a dependency is approved. - Complex-type results: Boolean expression results render 1/0 rather than true/false (SQLite has no boolean type); ROW(...) constructors, CAST(... AS ROW(...)) and anonymous rows are unsupported; map and row expression results both report ColumnInfo type map; integral doubles inside arrays render without the .0 suffix; col.field dereference works on ROW columns of a referenced table only (not CTE/subquery outputs or lambda parameters). - Lambda functions cover transform, filter, reduce, any_match/all_match/none_match, map_filter, transform_values, transform_keys and zip_with; array_sort(arr, comparator), map_zip_with, reduce_agg and zip_with over more than two arrays are not translated. diff --git a/services/athena/engine_columnar.go b/services/athena/engine_columnar.go new file mode 100644 index 0000000000..2ed6507df7 --- /dev/null +++ b/services/athena/engine_columnar.go @@ -0,0 +1,239 @@ +package athena + +import ( + "bytes" + "compress/gzip" + "context" + "database/sql" + "errors" + "fmt" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk_s3 "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +const ( + tableTypeIceberg = "ICEBERG" + tableTypeDelta = "DELTA" + paramTableType = "table_type" + paramMetadataLoc = "metadata_location" + paramPrevMetaLoc = "previous_metadata_location" + externalTrue = "TRUE" +) + +var errMissingObject = errors.New("object not found") + +func tableTypeOf(def *GlueTableDef) string { + return strings.ToUpper(strings.TrimSpace(lowerParam(def.Parameters, paramTableType))) +} + +// fileCol maps one table column onto a data file: the Parquet column to read +// (matched by field id first, then name) or a constant for columns the file lacks. +type fileCol struct { + constant *string + name string + hive string + fileName string + id int +} + +// fillTable creates the SQLite table for cols and lets fill insert rows. +func fillTable( + ctx context.Context, conn *sql.Conn, schema, name string, cols []Column, + fill func(stmt *sql.Stmt, kinds []colKind) error, +) error { + decls := make([]string, len(cols)) + marks := make([]string, len(cols)) + kinds := make([]colKind, len(cols)) + + for i, c := range cols { + decls[i] = quoteIdent(c.Name) + " " + sqliteDecl(c.Type) + marks[i] = "?" + kinds[i] = kindOf(c.Type) + } + + target := quoteIdent(schema) + "." + quoteIdent(name) + + if err := execAll(ctx, conn, "CREATE TABLE "+target+" ("+strings.Join(decls, ", ")+")"); err != nil { + return err + } + + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return err + } + + //nolint:gosec // target is built from quoteIdent-quoted names + stmt, err := tx.PrepareContext(ctx, "INSERT INTO "+target+" VALUES ("+strings.Join(marks, ",")+")") + if err != nil { + _ = tx.Rollback() + + return err + } + + defer func() { _ = stmt.Close() }() + + if err = fill(stmt, kinds); err != nil { + _ = tx.Rollback() + + return err + } + + return tx.Commit() +} + +// readObject loads a whole S3 object, transparently gunzipping, within the scan limit. +func (l *tableLoader) readObject(ctx context.Context, bucket, key string) ([]byte, error) { + out, err := l.get.GetObject(ctx, &sdk_s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}) + if err != nil { + return nil, fmt.Errorf("%w: s3://%s/%s: %w", errMissingObject, bucket, key, err) + } + defer func() { _ = out.Body.Close() }() + + data, err := io.ReadAll(io.LimitReader(out.Body, l.limits.MaxScanBytes+1)) + if err != nil { + return nil, err + } + + if int64(len(data)) > l.limits.MaxScanBytes { + return nil, fmt.Errorf("%w: more than %d bytes", errScanLimit, l.limits.MaxScanBytes) + } + + if len(data) >= gzipMagicLen && data[0] == gzipMagic0 && data[1] == gzipMagic1 { + gz, gerr := gzip.NewReader(bytes.NewReader(data)) + if gerr != nil { + return nil, gerr + } + + defer func() { _ = gz.Close() }() + + if data, err = io.ReadAll(io.LimitReader(gz, l.limits.MaxScanBytes*decompressRatio)); err != nil { + return nil, err + } + } + + return data, nil +} + +func (l *tableLoader) readURI(ctx context.Context, uri string) ([]byte, error) { + bucket, key, ok := splitS3URI(uri) + if !ok { + return nil, fmt.Errorf("%w %q", errBadLocation, uri) + } + + return l.readObject(ctx, bucket, key) +} + +// boundCol is a table column bound to a Parquet column of one file. +type boundCol struct { + file string + pos int +} + +// resolveFileCols binds each table column to the file's Parquet column. +func resolveFileCols(pf *parquetio.File, cols []fileCol) []boundCol { + ids := pf.ColumnIDs() + have := map[string]string{} + + for _, c := range pf.Columns() { + have[strings.ToLower(c)] = c + } + + var out []boundCol + + for i, c := range cols { + if c.fileName == "" && c.id <= 0 { + continue + } + + if n, ok := ids[c.id]; ok && c.id > 0 { + out = append(out, boundCol{file: n, pos: i}) + + continue + } + + if n, ok := have[strings.ToLower(orDefault(c.fileName, c.name))]; ok { + out = append(out, boundCol{file: n, pos: i}) + } + } + + return out +} + +// loadColumnarFile streams one Parquet data file into sink. skip rows are +// zero-based positions removed by position deletes. +func (l *tableLoader) loadColumnarFile( + ctx context.Context, bucket, key string, cols []fileCol, skip map[int64]bool, sink *rowSink, +) error { + data, err := l.readObject(ctx, bucket, key) + if err != nil { + return fmt.Errorf("HIVE_CANNOT_OPEN_SPLIT: Error opening split s3://%s/%s: %w", bucket, key, err) + } + + pf, err := parquetio.Open(data) + if err != nil { + return fmt.Errorf("HIVE_CANNOT_OPEN_SPLIT: Error opening split s3://%s/%s: %w", bucket, key, err) + } + + var wanted []boundCol + + names := []string{} + + for _, b := range resolveFileCols(pf, cols) { + if l.want.has(cols[b.pos].name) { + wanted = append(wanted, b) + names = append(names, b.file) + } + } + + l.scanned += pf.ScanBytes(names) + if l.scanned > l.limits.MaxScanBytes { + return fmt.Errorf("%w: more than %d bytes", errScanLimit, l.limits.MaxScanBytes) + } + + var row int64 + + err = pf.ForEach(names, func(vals []any) error { + cur := row + row++ + + if skip[cur] { + return nil + } + + rec, nulls := l.fileRow(cols, wanted, vals) + + return l.insertJSON(ctx, sink, rec, nulls) + }) + if errors.Is(err, parquetio.ErrBadFile) { + return fmt.Errorf("HIVE_CURSOR_ERROR: s3://%s/%s: %w", bucket, key, err) + } + + return err +} + +// fileRow assembles one table row: constants first, then the values read from the file. +func (l *tableLoader) fileRow(cols []fileCol, wanted []boundCol, vals []any) ([]string, []bool) { + rec := make([]string, len(cols)) + nulls := make([]bool, len(cols)) + + for i, c := range cols { + if c.constant != nil && l.want.has(c.name) { + rec[i] = *c.constant + } else { + nulls[i] = true + } + } + + for j, v := range vals { + if v != nil { + rec[wanted[j].pos], nulls[wanted[j].pos] = parquetCell(v), false + } + } + + return rec, nulls +} diff --git a/services/athena/engine_ddl.go b/services/athena/engine_ddl.go index d1062a5dab..9bcad86c54 100644 --- a/services/athena/engine_ddl.go +++ b/services/athena/engine_ddl.go @@ -63,6 +63,10 @@ func (b *InMemoryBackend) execGlueCreateTable( catalog, db, table := refTarget(spec.parts, qctx) + if _, isS3T := s3TablesBucket(catalog); isS3T { + return nil, stmtFail(athenaErrTypeSyntax, "%s", errS3TablesDDL.Error()), true + } + cat, ok := b.glueCatalogFor(catalog) if !ok { return nil, stmtOK(), false @@ -70,6 +74,13 @@ func (b *InMemoryBackend) execGlueCreateTable( spec.def.Name = table + switch tableTypeOf(&spec.def) { + case tableTypeIceberg: + return b.createIcebergTable(spec, db, cat) + case tableTypeDelta: + return b.createDeltaTable(spec, db, cat) + } + switch err = cat.CreateTableDef(db, spec.def); { case err == nil: return &sqlResult{}, stmtOK(), true @@ -97,6 +108,10 @@ func (b *InMemoryBackend) execGlueDropTable( parts := strings.Split(strings.ReplaceAll(name, `"`, ""), ".") catalog, db, table := refTarget(parts, qctx) + if _, isS3T := s3TablesBucket(catalog); isS3T { + return nil, stmtFail(athenaErrTypeSyntax, "%s", errS3TablesDDL.Error()), true + } + cat, ok := b.glueCatalogFor(catalog) if !ok { return nil, stmtOK(), false diff --git a/services/athena/engine_ddl_parse.go b/services/athena/engine_ddl_parse.go index 8a92b8f24a..bed51dd473 100644 --- a/services/athena/engine_ddl_parse.go +++ b/services/athena/engine_ddl_parse.go @@ -26,7 +26,9 @@ const ( type createTableSpec struct { def GlueTableDef parts []string + partTokens []token ifNotExists bool + external bool } // sigCursor walks the significant tokens of a statement. @@ -216,6 +218,7 @@ func (s *createTableSpec) clause(c *sigCursor) bool { switch { case c.acceptWords("partitioned", "by"): if inner, ok := c.group(); ok { + s.partTokens = inner s.def.PartitionKeys = parseColumnTokens(inner) } case c.acceptWords("row", "format", "delimited"): @@ -280,7 +283,7 @@ func (s *createTableSpec) finish(external bool) { } if external { - s.def.Parameters["EXTERNAL"] = "TRUE" + s.def.Parameters["EXTERNAL"] = externalTrue } s.def.TableType = tableTypeExternal @@ -321,7 +324,14 @@ func parseGlueCreateTable(sql string) (*createTableSpec, error) { spec.parseClauses(c) - if spec.def.Location == "" || len(spec.def.Columns) == 0 { + spec.external = external + tt := strings.ToUpper(lowerParam(spec.def.Parameters, paramTableType)) + + if tt == tableTypeIceberg { + spec.def.PartitionKeys = nil + } + + if spec.def.Location == "" || (len(spec.def.Columns) == 0 && tt != tableTypeDelta) { return nil, errNotGlueDDL } diff --git a/services/athena/engine_delta.go b/services/athena/engine_delta.go new file mode 100644 index 0000000000..a711503674 --- /dev/null +++ b/services/athena/engine_delta.go @@ -0,0 +1,155 @@ +package athena + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk_s3 "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/blackbirdworks/gopherstack/pkgs/deltalog" +) + +// deltaStorage reads a Delta table's objects through the engine's S3 access. +type deltaStorage struct { + l *tableLoader + bucket string + prefix string +} + +func newDeltaStorage(l *tableLoader, location string) (*deltaStorage, error) { + bucket, prefix, ok := splitS3URI(location) + if !ok { + return nil, fmt.Errorf("%w %q", errBadLocation, location) + } + + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + return &deltaStorage{l: l, bucket: bucket, prefix: prefix}, nil +} + +func (d *deltaStorage) List(ctx context.Context, prefix string) ([]string, error) { + var keys []string + + var token *string + + for { + out, err := d.l.list.ListObjectsV2(ctx, &sdk_s3.ListObjectsV2Input{ + Bucket: aws.String(d.bucket), Prefix: aws.String(d.prefix + prefix), ContinuationToken: token, + }) + if err != nil { + return nil, err + } + + for _, o := range out.Contents { + keys = append(keys, strings.TrimPrefix(aws.ToString(o.Key), d.prefix)) + } + + if !aws.ToBool(out.IsTruncated) || out.NextContinuationToken == nil { + return keys, nil + } + + token = out.NextContinuationToken + } +} + +func (d *deltaStorage) Get(ctx context.Context, key string) ([]byte, error) { + return d.l.readObject(ctx, d.bucket, d.prefix+key) +} + +func deltaFailure(err error, display string) error { + switch { + case errors.Is(err, deltalog.ErrNotDeltaTable): + return fmt.Errorf("DELTA_LAKE_INVALID_SCHEMA: %s is not a Delta Lake table: %w", display, err) + case errors.Is(err, deltalog.ErrUnsupported): + return fmt.Errorf("%w: %w", errUnsupportedFormat, err) + case errors.Is(err, deltalog.ErrBadLog): + return fmt.Errorf("DELTA_LAKE_BAD_DATA: %w", err) + default: + return fmt.Errorf("HIVE_METASTORE_ERROR: cannot read Delta log of %s: %w", display, err) + } +} + +func (l *tableLoader) loadDeltaSnapshot(ctx context.Context, location, display string) (*deltalog.Snapshot, error) { + st, err := newDeltaStorage(l, location) + if err != nil { + return nil, err + } + + snap, err := deltalog.Load(ctx, st) + if err != nil { + return nil, deltaFailure(err, display) + } + + return snap, nil +} + +func (t *planTable) loadDelta(ctx context.Context, conn *sql.Conn, l *tableLoader) error { + if t.travel.kind != "" { + return fmt.Errorf("%w: time travel is not supported for Delta Lake tables", errUnsupportedFormat) + } + + snap, err := l.loadDeltaSnapshot(ctx, t.def.Location, t.display) + if err != nil { + return err + } + + st, err := newDeltaStorage(l, t.def.Location) + if err != nil { + return err + } + + cols := make([]Column, len(snap.Columns)) + for i, c := range snap.Columns { + cols[i] = Column{Name: c.Name, Type: c.Type} + } + + return fillTable(ctx, conn, t.schema, t.name, cols, func(stmt *sql.Stmt, kinds []colKind) error { + sink := &rowSink{stmt: stmt, kinds: kinds, nargs: len(cols)} + + for _, f := range snap.Files { + bucket, key := st.bucket, st.prefix+f.Path + + if b, k, abs := splitS3URI(f.Path); abs { + bucket, key = b, k + } + + if lerr := l.loadColumnarFile(ctx, bucket, key, deltaFileCols(snap, f), nil, sink); lerr != nil { + return lerr + } + } + + return nil + }) +} + +// deltaFileCols binds the table columns to one data file; partition columns come from the log. +func deltaFileCols(snap *deltalog.Snapshot, f deltalog.AddFile) []fileCol { + fcs := make([]fileCol, len(snap.Columns)) + + for i, c := range snap.Columns { + fcs[i] = fileCol{name: c.Name, hive: c.Type, fileName: c.PhysicalName} + if snap.ColumnMapping == "id" { + fcs[i].id = c.ID + } + + if !slices.Contains(snap.PartitionColumns, c.Name) { + continue + } + + v, ok := f.PartitionValues[c.PhysicalName] + if !ok { + v = f.PartitionValues[c.Name] + } + + fcs[i].constant, fcs[i].fileName, fcs[i].id = v, "", 0 + } + + return fcs +} diff --git a/services/athena/engine_delta_test.go b/services/athena/engine_delta_test.go new file mode 100644 index 0000000000..172165f543 --- /dev/null +++ b/services/athena/engine_delta_test.go @@ -0,0 +1,144 @@ +package athena_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" + "github.com/blackbirdworks/gopherstack/services/athena" +) + +const deltaSchema = `{"type":"struct","fields":[` + + `{"name":"id","type":"long","nullable":true,"metadata":{}},` + + `{"name":"name","type":"string","nullable":true,"metadata":{}},` + + `{"name":"dt","type":"string","nullable":true,"metadata":{}}]}` + +func deltaCommit(lines ...string) []byte { return []byte(strings.Join(lines, "\n") + "\n") } + +func seedDelta(t *testing.T, e *iceEnv) { + t.Helper() + + cols := []parquetio.Column{{Name: "id", Type: "bigint"}, {Name: "name", Type: "string"}} + putParquet(t, e.store, "dl/part-0.parquet", cols, [][]any{{int64(1), "old"}}) + putParquet(t, e.store, "dl/part-1.parquet", cols, [][]any{{int64(2), "b"}}) + putParquet(t, e.store, "dl/dt=2024-01-02/part-2.parquet", cols, [][]any{{int64(3), "c"}, {int64(4), "d"}}) + + schema := strings.ReplaceAll(deltaSchema, `"`, `\"`) + e.store.objects["lake/dl/_delta_log/00000000000000000000.json"] = deltaCommit( + `{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}`, + `{"metaData":{"id":"x","format":{"provider":"parquet"},"schemaString":"`+schema+ + `","partitionColumns":["dt"],"configuration":{}}}`, + `{"add":{"path":"part-0.parquet","partitionValues":{"dt":"2024-01-01"},"size":10,"dataChange":true}}`, + `{"add":{"path":"part-1.parquet","partitionValues":{"dt":"2024-01-01"},"size":10,"dataChange":true}}`, + ) + e.store.objects["lake/dl/_delta_log/00000000000000000001.json"] = deltaCommit( + `{"remove":{"path":"part-0.parquet","deletionTimestamp":1,"dataChange":true}}`, + `{"add":{"path":"dt%3D2024-01-02/part-2.parquet","partitionValues":{"dt":"2024-01-02"},"size":10,"dataChange":true}}`, + ) +} + +func TestDelta_CreateAndSelect(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + sql string + want [][]string + }{ + {"active files only", "SELECT id, name, dt FROM dl ORDER BY id", + [][]string{{"2", "b", "2024-01-01"}, {"3", "c", "2024-01-02"}, {"4", "d", "2024-01-02"}}}, + {"partition filter", "SELECT count(*) FROM dl WHERE dt = '2024-01-02'", [][]string{{"2"}}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + seedDelta(t, e) + e.mustRun(t, "CREATE EXTERNAL TABLE dl LOCATION 's3://lake/dl/' TBLPROPERTIES ('table_type'='DELTA')") + + def := e.cat.defs["shop/dl"] + require.NotNil(t, def) + assert.Equal(t, "DELTA", def.Parameters["table_type"]) + assert.Equal(t, []athena.Column{{Name: "id", Type: "bigint"}, {Name: "name", Type: "string"}}, def.Columns) + assert.Equal(t, []athena.Column{{Name: "dt", Type: "string"}}, def.PartitionKeys) + + assert.Equal(t, tc.want, e.mustRun(t, tc.sql)) + }) + } +} + +func TestDelta_Errors(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + setup func(e *iceEnv) + sql string + want string + registered bool + }{ + { + name: "no transaction log", + sql: "CREATE EXTERNAL TABLE d LOCATION 's3://lake/none/' TBLPROPERTIES ('table_type'='DELTA')", + want: "is not a Delta Lake table", + }, + { + name: "columns not allowed", + sql: "CREATE EXTERNAL TABLE d (id int) LOCATION 's3://lake/dl/' TBLPROPERTIES ('table_type'='DELTA')", + want: "accepts only LOCATION", + }, + { + name: "insert is read only", + setup: func(e *iceEnv) { seedDelta(t, e) }, registered: true, + sql: "INSERT INTO dl VALUES (9, 'x', 'y')", + want: "Delta Lake tables are read-only", + }, + { + name: "time travel unsupported", + setup: func(e *iceEnv) { seedDelta(t, e) }, registered: true, + sql: "SELECT * FROM dl FOR VERSION AS OF 0", + want: "time travel is not supported for Delta Lake", + }, + { + name: "reader version too new", + setup: func(e *iceEnv) { + e.store.objects["lake/new/_delta_log/00000000000000000000.json"] = deltaCommit( + `{"protocol":{"minReaderVersion":9,"minWriterVersion":9}}`) + }, + sql: "CREATE EXTERNAL TABLE d LOCATION 's3://lake/new/' TBLPROPERTIES ('table_type'='DELTA')", + want: "NOT_SUPPORTED", + }, + { + name: "missing commit", + setup: func(e *iceEnv) { + e.store.objects["lake/gap/_delta_log/00000000000000000000.json"] = deltaCommit( + `{"metaData":{"schemaString":"{\"type\":\"struct\",\"fields\":[]}","partitionColumns":[]}}`) + e.store.objects["lake/gap/_delta_log/00000000000000000002.json"] = deltaCommit(`{}`) + }, + sql: "CREATE EXTERNAL TABLE d LOCATION 's3://lake/gap/' TBLPROPERTIES ('table_type'='DELTA')", + want: "DELTA_LAKE_BAD_DATA", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + if tc.setup != nil { + tc.setup(e) + } + + if tc.registered { + e.mustRun(t, "CREATE EXTERNAL TABLE dl LOCATION 's3://lake/dl/' TBLPROPERTIES ('table_type'='DELTA')") + } + + assert.Contains(t, e.failure(t, tc.sql), tc.want) + }) + } +} diff --git a/services/athena/engine_embed.go b/services/athena/engine_embed.go new file mode 100644 index 0000000000..fe57c5ff55 --- /dev/null +++ b/services/athena/engine_embed.go @@ -0,0 +1,30 @@ +package athena + +import "database/sql" + +// TranslateTrino rewrites Presto/Trino SQL onto the SQLite engine. resolve maps a +// table reference to the SQLite schema and table it was loaded into; unresolved +// references are returned by name. +func TranslateTrino( + query string, resolve func(parts []string) (schema, name string, ok bool), +) (string, []string, error) { + return translateSQL(query, func(parts []string) resolvedRef { + schema, name, ok := resolve(parts) + + return resolvedRef{schema: schema, name: name, found: ok} + }) +} + +// OpenEngineDB opens a private in-memory SQLite database with the Trino function set registered. +func OpenEngineDB() (*sql.DB, error) { + registerEngineFuncs() + + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(1) + + return db, nil +} diff --git a/services/athena/engine_iceberg.go b/services/athena/engine_iceberg.go new file mode 100644 index 0000000000..63d14c7c63 --- /dev/null +++ b/services/athena/engine_iceberg.go @@ -0,0 +1,416 @@ +package athena + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +const ( + secondsPerDayInt = 86400 + travelTimestamp = "ts" + travelVersion = "ver" + tsMillisLayout = "2006-01-02 15:04:05.000" +) + +// travelSpec is a FOR TIMESTAMP/VERSION AS OF clause on a table reference. +type travelSpec struct { + kind string + value string +} + +// icebergTable is a table's resolved current metadata. +type icebergTable struct { + meta *iceberg.TableMetadata + location string + display string +} + +// icebergError is a failure surfaced with an Athena error name prefix (ICEBERG_MISSING_METADATA: ...). +type icebergError struct{ msg string } + +func (e *icebergError) Error() string { return e.msg } + +func icebergFailf(code, format string, args ...any) error { + return &icebergError{msg: code + ": " + fmt.Sprintf(format, args...)} +} + +var errBadTimestamp = errors.New("unparseable timestamp") + +// openIceberg reads the table's current metadata.json named by the catalog. +func (l *tableLoader) openIceberg(ctx context.Context, def *GlueTableDef, display string) (*icebergTable, error) { + loc := lowerParam(def.Parameters, paramMetadataLoc) + if loc == "" { + return nil, icebergFailf( + "ICEBERG_INVALID_METADATA", + "Table is missing [%s] property: %s", + paramMetadataLoc, + display, + ) + } + + data, err := l.readURI(ctx, loc) + if err != nil { + if errors.Is(err, errMissingObject) { + return nil, icebergFailf("ICEBERG_MISSING_METADATA", + "Metadata not found in metadata location for table %s", display) + } + + return nil, icebergFailf("ICEBERG_FILESYSTEM_ERROR", "Failed to read metadata for table %s: %v", display, err) + } + + meta, err := iceberg.ParseMetadata(data) + if err != nil { + return nil, icebergFailf("ICEBERG_INVALID_METADATA", "Invalid metadata file for table %s: %v", display, err) + } + + return &icebergTable{meta: meta, location: loc, display: display}, nil +} + +// pickSnapshot applies time travel; ok=false means the table has no data yet. +func (t *icebergTable) pickSnapshot(tr travelSpec) (iceberg.Snapshot, bool, error) { + switch tr.kind { + case travelVersion: + id, err := strconv.ParseInt(strings.TrimSpace(tr.value), 10, 64) + if err != nil { + return iceberg.Snapshot{}, false, icebergFailf( + "INVALID_ARGUMENTS", + "Invalid Iceberg snapshot ID: %s", + tr.value, + ) + } + + s, ok := t.meta.SnapshotByID(id) + if !ok { + return iceberg.Snapshot{}, false, icebergFailf("INVALID_ARGUMENTS", + "Iceberg snapshot ID does not exists: %d", id) + } + + return s, true, nil + case travelTimestamp: + at, err := parseTravelTime(tr.value) + if err != nil { + return iceberg.Snapshot{}, false, icebergFailf("INVALID_ARGUMENTS", "Invalid timestamp %q", tr.value) + } + + s, err := t.meta.SnapshotAsOf(at) + if err != nil { + return iceberg.Snapshot{}, false, icebergFailf("INVALID_ARGUMENTS", + "No version history table %s at or before %s", t.display, at.UTC().Format(tsMillisLayout+" MST")) + } + + return s, true, nil + default: + s, ok := t.meta.CurrentSnapshot() + + return s, ok, nil + } +} + +func parseTravelTime(s string) (time.Time, error) { + s = strings.TrimSpace(s) + loc := time.UTC + + if i := strings.LastIndex(s, " "); i > 0 && i > strings.Index(s, ":") { + if l, err := time.LoadLocation(s[i+1:]); err == nil { + loc, s = l, strings.TrimSpace(s[:i]) + } + } + + for _, layout := range []string{ + "2006-01-02 15:04:05.999999999", time.RFC3339Nano, dateLayout, + } { + if t, err := time.ParseInLocation(layout, s, loc); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("%w %q", errBadTimestamp, s) +} + +func (t *icebergTable) schemaFor(s iceberg.Snapshot, hasSnap bool) iceberg.Schema { + if hasSnap && s.SchemaID != nil { + if sc, ok := t.meta.SchemaByID(*s.SchemaID); ok { + return sc + } + } + + return t.meta.CurrentSchema() +} + +func icebergColumns(sc iceberg.Schema, display string) ([]Column, error) { + cols := make([]Column, 0, len(sc.Fields)) + + for _, f := range sc.Fields { + ht, err := iceberg.HiveType(f.Type) + if err != nil { + return nil, fmt.Errorf("%w: column %s of %s: %w", errUnsupportedFormat, f.Name, display, err) + } + + cols = append(cols, Column{Name: f.Name, Type: ht, Comment: f.Doc}) + } + + return cols, nil +} + +// manifestFiles lists the data and delete files of a snapshot. +type snapshotFiles struct { + data []fileEntry + deletes []fileEntry +} + +type fileEntry struct { + entry iceberg.ManifestEntry + spec int + seq int64 +} + +func (l *tableLoader) snapshotFiles(ctx context.Context, snap iceberg.Snapshot) (snapshotFiles, error) { + var out snapshotFiles + + if snap.ManifestList == "" { + return out, nil + } + + listBytes, err := l.readURI(ctx, snap.ManifestList) + if err != nil { + return out, icebergFailf("ICEBERG_MISSING_METADATA", "Manifest list not found: %s", snap.ManifestList) + } + + manifests, err := iceberg.ReadManifestList(listBytes) + if err != nil { + return out, icebergFailf("ICEBERG_INVALID_METADATA", "Invalid manifest list %s: %v", snap.ManifestList, err) + } + + for _, mf := range manifests { + body, rerr := l.readURI(ctx, mf.Path) + if rerr != nil { + return out, icebergFailf("ICEBERG_MISSING_METADATA", "Manifest not found: %s", mf.Path) + } + + entries, perr := iceberg.ReadManifest(body) + if perr != nil { + return out, icebergFailf("ICEBERG_INVALID_METADATA", "Invalid manifest %s: %v", mf.Path, perr) + } + + for _, e := range entries { + if e.Status == iceberg.StatusDeleted { + continue + } + + seq := mf.SequenceNumber + if e.SequenceNumber != nil { + seq = *e.SequenceNumber + } + + fe := fileEntry{entry: e, spec: mf.SpecID, seq: seq} + if e.DataFile.Content == iceberg.ContentData { + out.data = append(out.data, fe) + } else { + out.deletes = append(out.deletes, fe) + } + } + } + + return out, nil +} + +// positionDeletes loads position delete files; equality deletes are rejected. +func (l *tableLoader) positionDeletes(ctx context.Context, files []fileEntry) (map[string][]deleteSet, error) { + out := map[string][]deleteSet{} + + for _, d := range files { + if d.entry.DataFile.Content == iceberg.ContentEqualityDeletes { + return nil, fmt.Errorf("%w: Iceberg equality delete files", errUnsupportedFormat) + } + + data, err := l.readURI(ctx, d.entry.DataFile.Path) + if err != nil { + return nil, icebergFailf("ICEBERG_CANNOT_OPEN_SPLIT", "Cannot open delete file %s", d.entry.DataFile.Path) + } + + pf, err := parquetio.Open(data) + if err != nil { + return nil, icebergFailf( + "ICEBERG_CANNOT_OPEN_SPLIT", + "Cannot open delete file %s: %v", + d.entry.DataFile.Path, + err, + ) + } + + perFile := map[string]map[int64]bool{} + + err = pf.ForEach([]string{"file_path", "pos"}, func(vals []any) error { + p, _ := vals[0].(string) + n, _ := vals[1].(int64) + + if perFile[p] == nil { + perFile[p] = map[int64]bool{} + } + + perFile[p][n] = true + + return nil + }) + if err != nil { + return nil, icebergFailf( + "ICEBERG_CURSOR_ERROR", + "Cannot read delete file %s: %v", + d.entry.DataFile.Path, + err, + ) + } + + for p, set := range perFile { + out[p] = append(out[p], deleteSet{seq: d.seq, rows: set}) + } + } + + return out, nil +} + +type deleteSet struct { + rows map[int64]bool + seq int64 +} + +func skipRows(sets []deleteSet, dataSeq int64) map[int64]bool { + var out map[int64]bool + + for _, s := range sets { + if dataSeq > s.seq { + continue + } + + if out == nil { + out = map[int64]bool{} + } + + for r := range s.rows { + out[r] = true + } + } + + return out +} + +func (t *planTable) loadIceberg(ctx context.Context, conn *sql.Conn, l *tableLoader) error { + tbl, err := l.openIceberg(ctx, t.def, t.display) + if err != nil { + return err + } + + snap, hasSnap, err := tbl.pickSnapshot(t.travel) + if err != nil { + return err + } + + if t.metaTable != "" { + return t.loadIcebergMetaTable(ctx, conn, l, tbl, snap, hasSnap) + } + + schema := tbl.schemaFor(snap, hasSnap) + + cols, err := icebergColumns(schema, t.display) + if err != nil { + return err + } + + return fillTable(ctx, conn, t.schema, t.name, cols, func(stmt *sql.Stmt, kinds []colKind) error { + if !hasSnap { + return nil + } + + files, ferr := l.snapshotFiles(ctx, snap) + if ferr != nil { + return ferr + } + + dels, ferr := l.positionDeletes(ctx, files.deletes) + if ferr != nil { + return ferr + } + + sink := &rowSink{stmt: stmt, kinds: kinds, nargs: len(cols)} + + for _, f := range files.data { + if lerr := l.loadIcebergDataFile(ctx, tbl, schema, cols, f, dels, sink); lerr != nil { + return lerr + } + } + + return nil + }) +} + +func (l *tableLoader) loadIcebergDataFile( + ctx context.Context, tbl *icebergTable, schema iceberg.Schema, cols []Column, f fileEntry, + dels map[string][]deleteSet, sink *rowSink, +) error { + df := f.entry.DataFile + if !strings.EqualFold(df.Format, "PARQUET") { + return fmt.Errorf("%w: Iceberg data file format %s (%s)", errUnsupportedFormat, df.Format, df.Path) + } + + bucket, key, ok := splitS3URI(df.Path) + if !ok { + return icebergFailf("ICEBERG_CANNOT_OPEN_SPLIT", "Invalid data file location %q", df.Path) + } + + spec, _ := tbl.meta.SpecByID(f.spec) + fcs := make([]fileCol, len(cols)) + + for i, c := range cols { + fcs[i] = fileCol{name: c.Name, hive: c.Type, fileName: c.Name, id: schema.Fields[i].ID} + + for _, pf := range spec.Fields { + if pf.SourceID != schema.Fields[i].ID || !strings.EqualFold(pf.Transform, "identity") { + continue + } + + if v, has := df.Partition[pf.Name]; has && v != nil { + s := partitionText(iceberg.PrimitiveName(schema.Fields[i].Type), v) + fcs[i].constant = &s + } + } + } + + return l.loadColumnarFile(ctx, bucket, key, fcs, skipRows(dels[df.Path], f.seq), sink) +} + +// partitionText renders a manifest partition value as the text a column read would produce. +func partitionText(icebergType string, v any) string { + switch n := v.(type) { + case int32, int: + if icebergType == "date" { + return time.Unix(toInt64(n)*secondsPerDayInt, 0).UTC().Format(dateLayout) + } + case int64: + if icebergType == typeTimestamp || icebergType == "timestamptz" { + return time.UnixMicro(n).UTC().Format(tsMillisLayout) + } + } + + return fmt.Sprint(v) +} + +func toInt64(v any) int64 { + switch n := v.(type) { + case int: + return int64(n) + case int32: + return int64(n) + case int64: + return n + default: + return 0 + } +} diff --git a/services/athena/engine_iceberg_ddl.go b/services/athena/engine_iceberg_ddl.go new file mode 100644 index 0000000000..9d357d1097 --- /dev/null +++ b/services/athena/engine_iceberg_ddl.go @@ -0,0 +1,338 @@ +package athena + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" +) + +const ( + partitionFieldIDBase = 1000 + millisPerSecondInt = 1000 +) + +const ( + fnTruncate = "truncate" + propFormat = "format" + propWriteCompression = "write_compression" +) + +// icebergTableProps maps the TBLPROPERTIES Athena accepts for Iceberg tables to Iceberg table properties. +func icebergTableProps() map[string]string { + return map[string]string{ + propFormat: "write.format.default", + propWriteCompression: propParquetCodec, + "optimize_rewrite_data_file_threshold": "optimize_rewrite_data_file_threshold", + "optimize_rewrite_delete_file_threshold": "optimize_rewrite_delete_file_threshold", + "vacuum_min_snapshots_to_keep": "history.expire.min-snapshots-to-keep", + "vacuum_max_snapshot_age_seconds": "history.expire.max-snapshot-age-ms", + "vacuum_max_metadata_files_to_keep": "write.metadata.previous-versions-max", + "write_data_path_enabled": "write.data.path.enabled", + } +} + +func (b *InMemoryBackend) ddlDeps() (S3Getter, S3Lister, S3Storer, bool) { + b.mu.RLock("ddlDeps") + defer b.mu.RUnlock() + + _, get, list, ok := b.engineDeps() + + return get, list, b.s3, ok && b.s3 != nil +} + +func icebergProps(raw map[string]string) (map[string]string, error) { + out := map[string]string{} + + for k, v := range raw { + lk := strings.ToLower(k) + if lk == paramTableType { + continue + } + + target, ok := icebergTableProps()[lk] + if !ok { + return nil, &syntaxError{"Unsupported table property key: " + k} + } + + switch lk { + case propFormat: + if !strings.EqualFold(v, "parquet") { + return nil, fmt.Errorf( + "%w: Iceberg format %s; the embedded engine writes PARQUET", + errUnsupportedFormat, + v, + ) + } + + v = strings.ToLower(v) + case propWriteCompression: + v = strings.ToLower(v) + case "vacuum_max_snapshot_age_seconds": + secs, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid value %q for %s", v, k) //nolint:err113 // user message + } + + v = strconv.FormatInt(secs*millisPerSecondInt, 10) + } + + out[target] = v + } + + return out, nil +} + +func (b *InMemoryBackend) createIcebergTable( + spec *createTableSpec, db string, cat GlueTableCatalog, +) (*sqlResult, statementOutcome, bool) { + if spec.external { + return nil, stmtFail(athenaErrTypeSyntax, "External keyword not supported for table type ICEBERG"), true + } + + _, _, put, ok := b.ddlDeps() + if !ok { + return nil, stmtOK(), false + } + + if _, err := cat.GetTableDef(db, spec.def.Name); err == nil { + if spec.ifNotExists { + return &sqlResult{}, stmtOK(), true + } + + return nil, stmtFail(athenaErrTypeEntityFound, "Table %s already exists", spec.def.Name), true + } + + meta, err := newIcebergMetadata(spec) + if err != nil { + return nil, engineFailure(err), true + } + + body, err := iceberg.EncodeMetadata(meta) + if err != nil { + return nil, engineFailure(err), true + } + + loc := iceberg.InitialMetadataLocation(meta.Location) + wp := &writePlan{put: put} + + if out := wp.putObjectAt(context.Background(), loc, body); out.failed { + return nil, out, true + } + + spec.def.Location = meta.Location + spec.def.Parameters = map[string]string{paramTableType: tableTypeIceberg, paramMetadataLoc: loc} + spec.def.SerdeParameters = nil + + switch err = cat.CreateTableDef(db, spec.def); { + case err == nil: + return &sqlResult{}, stmtOK(), true + case errors.Is(err, ErrCatalogEntityExists): + return nil, stmtFail(athenaErrTypeEntityFound, "Table %s already exists", spec.def.Name), true + case errors.Is(err, ErrCatalogEntityNotFound): + return nil, stmtFail(athenaErrTypeEntityMiss, "Database does not exist: %s", db), true + default: + return nil, stmtFail(athenaErrTypeSyntax, "%s", err.Error()), true + } +} + +func newIcebergMetadata(spec *createTableSpec) (*iceberg.TableMetadata, error) { + cols := make([]iceberg.ColumnSpec, len(spec.def.Columns)) + for i, c := range spec.def.Columns { + cols[i] = iceberg.ColumnSpec{Name: c.Name, Type: c.Type, Comment: c.Comment} + } + + schema, err := iceberg.SchemaFromColumns(cols) + if err != nil { + return nil, fmt.Errorf("%w: %w", errUnsupportedFormat, err) + } + + pspec, err := parsePartitionSpec(schema, spec.partTokens) + if err != nil { + return nil, err + } + + props, err := icebergProps(spec.def.Parameters) + if err != nil { + return nil, err + } + + if _, _, ok := splitS3URI(spec.def.Location); !ok { + return nil, fmt.Errorf("%w %q", errBadLocation, spec.def.Location) + } + + return iceberg.NewTableMetadata(iceberg.NewParams{ + Location: strings.TrimSuffix(spec.def.Location, "/"), Schema: schema, Spec: pspec, + Properties: props, Now: time.Now(), + }), nil +} + +// parsePartitionSpec reads PARTITIONED BY (col, bucket(16, col), day(ts), ...). +func parsePartitionSpec(schema iceberg.Schema, toks []token) (iceberg.PartitionSpec, error) { + spec := iceberg.PartitionSpec{Fields: []iceberg.PartitionField{}} + + for _, part := range splitCommas(toks) { + sig := significant(part) + if len(sig) == 0 { + continue + } + + pf, err := partitionField(schema, sig) + if err != nil { + return spec, err + } + + pf.FieldID = partitionFieldIDBase + len(spec.Fields) + spec.Fields = append(spec.Fields, pf) + } + + return spec, nil +} + +func fieldByName(schema iceberg.Schema, name string) (iceberg.Field, error) { + for _, f := range schema.Fields { + if strings.EqualFold(f.Name, name) { + return f, nil + } + } + + return iceberg.Field{}, &syntaxError{ + fmt.Sprintf("COLUMN_NOT_FOUND: line 1:1: Partition column '%s' not found in table", name), + } +} + +type transformCall struct { + fn string + width string + col string +} + +func ddlSyntax(msg string) error { return &syntaxError{"SYNTAX_ERROR: line 1:1: " + msg} } + +// parseTransformCall reads fn(col) or fn(width, col). +func parseTransformCall(sig []token) (transformCall, error) { + if len(sig) < argsThree || !sig[1].isPunct("(") || !sig[len(sig)-1].isPunct(")") { + return transformCall{}, ddlSyntax("invalid partition transform") + } + + c := transformCall{fn: strings.ToLower(sig[0].text)} + args := splitCommas(sig[2 : len(sig)-1]) + colArg := 0 + + if c.fn == "bucket" || c.fn == fnTruncate { + if len(args) != argsTwo { + return c, ddlSyntax(c.fn + " takes a width and a column") + } + + c.width, colArg = strings.TrimSpace(joinTokens(args[0])), 1 + } else if len(args) != 1 { + return c, ddlSyntax(c.fn + " takes one column") + } + + colToks := significant(args[colArg]) + if len(colToks) != 1 { + return c, ddlSyntax("invalid partition column") + } + + c.col = colToks[0].ident() + + return c, nil +} + +func partitionField(schema iceberg.Schema, sig []token) (iceberg.PartitionField, error) { + if len(sig) == 1 { + f, err := fieldByName(schema, sig[0].ident()) + + return iceberg.PartitionField{Name: f.Name, Transform: "identity", SourceID: f.ID}, err + } + + c, err := parseTransformCall(sig) + if err != nil { + return iceberg.PartitionField{}, err + } + + f, err := fieldByName(schema, c.col) + if err != nil { + return iceberg.PartitionField{}, err + } + + transform, suffix := c.fn, "_"+c.fn + + switch c.fn { + case "bucket", fnTruncate: + transform = c.fn + "[" + c.width + "]" + if c.fn == fnTruncate { + suffix = "_trunc" + } + case "year", "month", "day", "hour": + default: + return iceberg.PartitionField{}, ddlSyntax("unknown partition transform '" + c.fn + "'") + } + + if _, perr := iceberg.ParseTransform(transform); perr != nil { + return iceberg.PartitionField{}, ddlSyntax("invalid partition transform '" + transform + "'") + } + + return iceberg.PartitionField{Name: f.Name + suffix, Transform: transform, SourceID: f.ID}, nil +} + +func (b *InMemoryBackend) createDeltaTable( + spec *createTableSpec, db string, cat GlueTableCatalog, +) (*sqlResult, statementOutcome, bool) { + get, list, _, ok := b.ddlDeps() + if !ok { + return nil, stmtOK(), false + } + + if len(spec.def.Columns) > 0 { + return nil, stmtFail(athenaErrTypeSyntax, + "NOT_SUPPORTED: CREATE TABLE for Delta Lake tables accepts only LOCATION and the table_type property"), true + } + + if _, err := cat.GetTableDef(db, spec.def.Name); err == nil { + if spec.ifNotExists { + return &sqlResult{}, stmtOK(), true + } + + return nil, stmtFail(athenaErrTypeEntityFound, "Table %s already exists", spec.def.Name), true + } + + l := &tableLoader{get: get, list: list, limits: b.limitsSnapshot()} + + snap, err := l.loadDeltaSnapshot(context.Background(), spec.def.Location, spec.def.Name) + if err != nil { + return nil, engineFailure(err), true + } + + part := map[string]bool{} + for _, p := range snap.PartitionColumns { + part[p] = true + } + + def := spec.def + def.Columns, def.PartitionKeys = nil, nil + + for _, c := range snap.Columns { + if part[c.Name] { + def.PartitionKeys = append(def.PartitionKeys, Column{Name: c.Name, Type: c.Type}) + } else { + def.Columns = append(def.Columns, Column{Name: c.Name, Type: c.Type}) + } + } + + def.Parameters = map[string]string{paramTableType: tableTypeDelta, "EXTERNAL": externalTrue} + def.SerdeLibrary, def.InputFormat, def.OutputFormat = parquetSerde, parquetInputFmt, parquetOutFmt + + switch err = cat.CreateTableDef(db, def); { + case err == nil: + return &sqlResult{}, stmtOK(), true + case errors.Is(err, ErrCatalogEntityNotFound): + return nil, stmtFail(athenaErrTypeEntityMiss, "Database does not exist: %s", db), true + default: + return nil, stmtFail(athenaErrTypeSyntax, "%s", err.Error()), true + } +} diff --git a/services/athena/engine_iceberg_meta.go b/services/athena/engine_iceberg_meta.go new file mode 100644 index 0000000000..8b03e9da91 --- /dev/null +++ b/services/athena/engine_iceberg_meta.go @@ -0,0 +1,231 @@ +package athena + +import ( + "context" + "database/sql" + "encoding/json" + "sort" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" +) + +// icebergMetaSuffix splits "tbl$snapshots" into the table and metadata table names. +func icebergMetaSuffix(name string) (string, string, bool) { + base, meta, ok := strings.Cut(name, "$") + if !ok { + return name, "", false + } + + for _, m := range []string{"snapshots", "history", "files", "manifests", "refs"} { + if strings.EqualFold(meta, m) { + return base, m, true + } + } + + return name, "", false +} + +const colSnapshotID = "snapshot_id" + +func tsText(ms int64) string { return time.UnixMilli(ms).UTC().Format(tsMillisLayout) } + +func mapJSON(m map[string]string) string { + b, _ := json.Marshal(m) + + return string(b) +} + +func optID(p *int64) any { + if p == nil { + return nil + } + + return *p +} + +func (t *planTable) loadIcebergMetaTable( + ctx context.Context, conn *sql.Conn, l *tableLoader, tbl *icebergTable, snap iceberg.Snapshot, hasSnap bool, +) error { + var cols []Column + + var rows [][]any + + switch t.metaTable { + case "snapshots": + cols, rows = snapshotsTable(tbl.meta) + case "history": + cols, rows = historyTable(tbl.meta) + case "refs": + cols, rows = refsTable(tbl.meta) + case "manifests": + var err error + + if cols, rows, err = l.manifestsTable(ctx, snap, hasSnap); err != nil { + return err + } + default: + var err error + + if cols, rows, err = l.filesTable(ctx, snap, hasSnap); err != nil { + return err + } + } + + return fillTable(ctx, conn, t.schema, t.name, cols, func(stmt *sql.Stmt, _ []colKind) error { + for _, r := range rows { + if err := l.checkRow(ctx); err != nil { + return err + } + + if _, err := stmt.ExecContext(ctx, r...); err != nil { + return err + } + } + + return nil + }) +} + +func snapshotsTable(m *iceberg.TableMetadata) ([]Column, [][]any) { + cols := []Column{ + {Name: "committed_at", Type: "timestamp"}, {Name: colSnapshotID, Type: typeBigint}, + {Name: "parent_id", Type: typeBigint}, {Name: "operation", Type: typeVarchar}, + {Name: "manifest_list", Type: typeVarchar}, {Name: "summary", Type: typeVarchar}, + } + rows := make([][]any, 0, len(m.Snapshots)) + + for _, s := range m.Snapshots { + rows = append(rows, []any{ + tsText(s.TimestampMS), s.SnapshotID, optID(s.ParentSnapshotID), + s.Summary["operation"], s.ManifestList, mapJSON(s.Summary), + }) + } + + return cols, rows +} + +func historyTable(m *iceberg.TableMetadata) ([]Column, [][]any) { + cols := []Column{ + {Name: "made_current_at", Type: "timestamp"}, {Name: colSnapshotID, Type: typeBigint}, + {Name: "parent_id", Type: typeBigint}, {Name: "is_current_ancestor", Type: "boolean"}, + } + ancestors := map[int64]bool{} + + for id := m.CurrentSnapshotID; id != iceberg.NoSnapshot; { + s, ok := m.SnapshotByID(id) + if !ok { + break + } + + ancestors[id] = true + + if s.ParentSnapshotID == nil { + break + } + + id = *s.ParentSnapshotID + } + + rows := make([][]any, 0, len(m.SnapshotLog)) + + for _, e := range m.SnapshotLog { + var parent any + + if s, ok := m.SnapshotByID(e.SnapshotID); ok { + parent = optID(s.ParentSnapshotID) + } + + rows = append(rows, []any{tsText(e.TimestampMS), e.SnapshotID, parent, boolInt(ancestors[e.SnapshotID])}) + } + + return cols, rows +} + +func refsTable(m *iceberg.TableMetadata) ([]Column, [][]any) { + cols := []Column{ + {Name: "name", Type: typeVarchar}, {Name: "type", Type: typeVarchar}, {Name: colSnapshotID, Type: typeBigint}, + } + names := make([]string, 0, len(m.Refs)) + + for n := range m.Refs { + names = append(names, n) + } + + sort.Strings(names) + + rows := make([][]any, 0, len(names)) + for _, n := range names { + r := m.Refs[n] + rows = append(rows, []any{n, strings.ToUpper(r.Type), r.SnapshotID}) + } + + return cols, rows +} + +func (l *tableLoader) manifestsTable( + ctx context.Context, snap iceberg.Snapshot, hasSnap bool, +) ([]Column, [][]any, error) { + cols := []Column{ + {Name: "path", Type: typeVarchar}, {Name: "length", Type: typeBigint}, + {Name: "partition_spec_id", Type: typeInteger}, {Name: "added_snapshot_id", Type: typeBigint}, + {Name: "added_data_files_count", Type: typeInteger}, {Name: "added_rows_count", Type: typeBigint}, + {Name: "existing_data_files_count", Type: typeInteger}, {Name: "existing_rows_count", Type: typeBigint}, + {Name: "deleted_data_files_count", Type: typeInteger}, {Name: "deleted_rows_count", Type: typeBigint}, + } + + if !hasSnap || snap.ManifestList == "" { + return cols, nil, nil + } + + raw, err := l.readURI(ctx, snap.ManifestList) + if err != nil { + return nil, nil, icebergFailf("ICEBERG_MISSING_METADATA", "Manifest list not found: %s", snap.ManifestList) + } + + list, err := iceberg.ReadManifestList(raw) + if err != nil { + return nil, nil, icebergFailf("ICEBERG_INVALID_METADATA", "Invalid manifest list: %v", err) + } + + rows := make([][]any, 0, len(list)) + for _, m := range list { + rows = append(rows, []any{ + m.Path, m.Length, int64(m.SpecID), m.AddedSnapshotID, int64(m.AddedFiles), m.AddedRows, + int64(m.ExistingFiles), m.ExistingRows, int64(m.DeletedFiles), m.DeletedRows, + }) + } + + return cols, rows, nil +} + +func (l *tableLoader) filesTable(ctx context.Context, snap iceberg.Snapshot, hasSnap bool) ([]Column, [][]any, error) { + cols := []Column{ + {Name: "content", Type: typeInteger}, {Name: "file_path", Type: typeVarchar}, + {Name: "file_format", Type: typeVarchar}, {Name: "spec_id", Type: typeInteger}, + {Name: "partition", Type: typeVarchar}, {Name: "record_count", Type: typeBigint}, + {Name: "file_size_in_bytes", Type: typeBigint}, + } + + if !hasSnap { + return cols, nil, nil + } + + files, err := l.snapshotFiles(ctx, snap) + if err != nil { + return nil, nil, err + } + + var rows [][]any + + for _, f := range append(files.data, files.deletes...) { + df := f.entry.DataFile + part, _ := json.Marshal(df.Partition) + rows = append(rows, []any{ + int64(df.Content), df.Path, df.Format, int64(f.spec), string(part), df.RecordCount, df.FileSize, + }) + } + + return cols, rows, nil +} diff --git a/services/athena/engine_iceberg_test.go b/services/athena/engine_iceberg_test.go new file mode 100644 index 0000000000..f5e40cacef --- /dev/null +++ b/services/athena/engine_iceberg_test.go @@ -0,0 +1,596 @@ +package athena_test + +import ( + "bytes" + "context" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk_s3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" + "github.com/blackbirdworks/gopherstack/services/athena" +) + +// iceCatalog adds the optimistic metadata_location swap Glue offers to the engine fake catalog. +type iceCatalog struct { + *engineCatalog + mu sync.Mutex +} + +func (c *iceCatalog) CommitTableMetadata(db, name, expected, next string) error { + c.mu.Lock() + defer c.mu.Unlock() + + d, ok := c.defs[db+"/"+name] + if !ok { + return athena.ErrCatalogEntityNotFound + } + + if d.Parameters["metadata_location"] != expected { + return athena.ErrCatalogCommitConflict + } + + d.Parameters["metadata_location"] = next + + return nil +} + +type stepClock struct { + t time.Time + mu sync.Mutex +} + +func (c *stepClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + c.t = c.t.Add(time.Second) + + return c.t +} + +type iceEnv struct { + b *athena.InMemoryBackend + store *engineS3 + cat *iceCatalog +} + +func newIceEnv(t *testing.T) *iceEnv { + t.Helper() + + cat := &iceCatalog{engineCatalog: &engineCatalog{ + fakeGlueSource: newFakeGlueSource(), + defs: map[string]*athena.GlueTableDef{}, + parts: map[string][]athena.GluePartitionDef{}, + }} + store := &engineS3{objects: map[string][]byte{}} + clock := &stepClock{t: time.Now().UTC().Truncate(time.Second)} + + b := athena.NewInMemoryBackend("us-east-1", "123456789012") + b.SetGlueMetadataSource(cat) + b.SetS3Backend(store) + b.SetClock(clock.now) + + return &iceEnv{b: b, store: store, cat: cat} +} + +func (e *iceEnv) run(t *testing.T, sql string) (*athena.QueryExecution, [][]string) { + t.Helper() + + id := start(t, e.b, sql) + qe := waitDone(t, e.b, id) + + if qe.Status.State != "SUCCEEDED" { + return qe, nil + } + + return qe, resultRows(t, e.b, id) +} + +func (e *iceEnv) mustRun(t *testing.T, sql string) [][]string { + t.Helper() + + qe, rows := e.run(t, sql) + require.Equal(t, "SUCCEEDED", qe.Status.State, "%s: %s", sql, qe.Status.StateChangeReason) + + return rows +} + +func (e *iceEnv) failure(t *testing.T, sql string) string { + t.Helper() + + qe, _ := e.run(t, sql) + require.Equal(t, "FAILED", qe.Status.State, sql) + + return qe.Status.StateChangeReason +} + +func (e *iceEnv) metadata(t *testing.T, table string) (*iceberg.TableMetadata, string) { + t.Helper() + + loc := e.cat.defs["shop/"+table].Parameters["metadata_location"] + require.NotEmpty(t, loc) + + return parseMetadataAt(t, e.store, loc), loc +} + +func (e *iceEnv) objectAt(t *testing.T, uri string) []byte { + t.Helper() + + body, ok := e.store.objects[strings.TrimPrefix(uri, "s3://")] + require.True(t, ok, "missing object %s", uri) + + return body +} + +func parseMetadataAt(t *testing.T, store *engineS3, uri string) *iceberg.TableMetadata { + t.Helper() + + body, ok := store.objects[strings.TrimPrefix(uri, "s3://")] + require.True(t, ok, "missing metadata %s", uri) + + m, err := iceberg.ParseMetadata(body) + require.NoError(t, err) + + return m +} + +func TestIceberg_CreateInsertSelectRoundTrip(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + partition string + wantTrans []string + wantParts int + }{ + {name: "unpartitioned", partition: "", wantParts: 0}, + { + name: "partitioned", partition: "PARTITIONED BY (category, bucket(4, id))", wantParts: 2, + wantTrans: []string{"identity", "bucket[4]"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + + e.mustRun(t, "CREATE TABLE events (id bigint, category string, note string) "+tc.partition+ + " LOCATION 's3://lake/events/' TBLPROPERTIES ('table_type'='ICEBERG')") + + def := e.cat.defs["shop/events"] + require.NotNil(t, def) + assert.Equal(t, "ICEBERG", def.Parameters["table_type"]) + assert.Empty(t, def.PartitionKeys) + + meta, _ := e.metadata(t, "events") + assert.Equal(t, 2, meta.FormatVersion) + assert.Equal(t, iceberg.NoSnapshot, meta.CurrentSnapshotID) + assert.Len(t, meta.DefaultSpec().Fields, tc.wantParts) + + for i, tr := range tc.wantTrans { + assert.Equal(t, tr, meta.DefaultSpec().Fields[i].Transform) + } + + assert.Empty(t, e.mustRun(t, "SELECT * FROM events")) + + e.mustRun(t, "INSERT INTO events VALUES (1, 'a', 'one'), (2, 'b', 'two'), (3, 'a', NULL)") + e.mustRun(t, "INSERT INTO events VALUES (4, 'c', 'four')") + + assert.Equal(t, [][]string{{"1", "a", "one"}, {"2", "b", "two"}, {"3", "a", ""}, {"4", "c", "four"}}, + e.mustRun(t, "SELECT id, category, note FROM events ORDER BY id")) + assert.Equal(t, [][]string{{"2"}}, e.mustRun(t, "SELECT count(*) FROM events WHERE category = 'a'")) + + meta, loc := e.metadata(t, "events") + require.Len(t, meta.Snapshots, 2) + assert.Len(t, meta.MetadataLog, 2) + assert.Contains(t, loc, "/metadata/00002-") + assert.Equal(t, "4", meta.Snapshots[1].Summary["total-records"]) + + cur, ok := meta.CurrentSnapshot() + require.True(t, ok) + + list, err := iceberg.ReadManifestList(e.objectAt(t, cur.ManifestList)) + require.NoError(t, err) + require.Len(t, list, 2) + + var files int + + for _, mf := range list { + entries, merr := iceberg.ReadManifest(e.objectAt(t, mf.Path)) + require.NoError(t, merr) + + for _, en := range entries { + files++ + + pf, perr := parquetio.Open(e.objectAt(t, en.DataFile.Path)) + require.NoError(t, perr) + assert.Equal(t, en.DataFile.RecordCount, pf.NumRows()) + assert.Equal(t, map[int]string{1: "id", 2: "category", 3: "note"}, pf.ColumnIDs()) + } + } + + assert.GreaterOrEqual(t, files, 2) + }) + } +} + +func TestIceberg_TimeTravelAndMetadataTables(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + e.mustRun(t, "CREATE TABLE t (id int, v string) LOCATION 's3://lake/t' TBLPROPERTIES ('table_type'='ICEBERG')") + e.mustRun(t, "INSERT INTO t VALUES (1, 'a')") + e.mustRun(t, "INSERT INTO t VALUES (2, 'b')") + e.mustRun(t, "INSERT INTO t VALUES (3, 'c')") + + meta, _ := e.metadata(t, "t") + require.Len(t, meta.Snapshots, 3) + + ts := func(s iceberg.Snapshot, plus time.Duration) string { + return time.UnixMilli(s.TimestampMS).UTC().Add(plus).Format("2006-01-02 15:04:05.000") + } + + cases := []struct { + name string + sql string + want [][]string + }{ + { + "version first", + fmt.Sprintf("SELECT id FROM t FOR VERSION AS OF %d ORDER BY id", meta.Snapshots[0].SnapshotID), + [][]string{{"1"}}, + }, + { + "version second", + fmt.Sprintf("SELECT id FROM t FOR VERSION AS OF %d ORDER BY id", meta.Snapshots[1].SnapshotID), + [][]string{{"1"}, {"2"}}, + }, + { + "timestamp between", + "SELECT id FROM t FOR TIMESTAMP AS OF TIMESTAMP '" + ts(meta.Snapshots[1], 500*time.Millisecond) + + " UTC' ORDER BY id", + [][]string{{"1"}, {"2"}}, + }, + { + "timestamp literal string", "SELECT count(*) FROM t FOR TIMESTAMP AS OF '" + ts(meta.Snapshots[0], 0) + "'", + [][]string{{"1"}}, + }, + { + "timestamp expression", "SELECT count(*) FROM t FOR TIMESTAMP AS OF (current_timestamp + interval '1' day)", + [][]string{{"3"}}, + }, + { + "time and version join", + fmt.Sprintf( + "SELECT a.id, b.id FROM t FOR VERSION AS OF %d a, t b WHERE b.id = 3", + meta.Snapshots[0].SnapshotID, + ), + [][]string{{"1", "3"}}, + }, + {"snapshots", "SELECT count(*), min(operation) FROM \"t$snapshots\"", [][]string{{"3", "append"}}}, + {"history", "SELECT count(*), sum(is_current_ancestor) FROM \"t$history\"", [][]string{{"3", "3"}}}, + {"files", "SELECT count(*), sum(record_count) FROM \"t$files\"", [][]string{{"3", "3"}}}, + {"manifests", "SELECT count(*) FROM \"t$manifests\"", [][]string{{"3"}}}, + {"refs", "SELECT name, type FROM \"t$refs\"", [][]string{{"main", "BRANCH"}}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, e.mustRun(t, tc.sql)) + }) + } + + fails := []struct { + name string + sql string + want string + }{ + {"unknown version", "SELECT * FROM t FOR VERSION AS OF 42", "Iceberg snapshot ID does not exists: 42"}, + { + "before history", "SELECT * FROM t FOR TIMESTAMP AS OF TIMESTAMP '2001-01-01 00:00:00'", + "No version history table shop.t at or before", + }, + } + + for _, tc := range fails { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Contains(t, e.failure(t, tc.sql), tc.want) + }) + } +} + +func TestIceberg_ErrorsAndValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + setup func(e *iceEnv) + sql string + want string + }{ + { + name: "metadata file missing", + setup: func(e *iceEnv) { + e.cat.defs["shop/gone"] = &athena.GlueTableDef{ + Name: "gone", + Location: "s3://lake/gone/", + Columns: []athena.Column{{Name: "id", Type: "int"}}, + Parameters: map[string]string{ + "table_type": "ICEBERG", + "metadata_location": "s3://lake/gone/metadata/0.json", + }, + } + }, + sql: "SELECT * FROM gone", + want: "ICEBERG_MISSING_METADATA: Metadata not found in metadata location for table shop.gone", + }, + { + name: "metadata location absent", + setup: func(e *iceEnv) { + e.cat.defs["shop/bare"] = &athena.GlueTableDef{ + Name: "bare", Location: "s3://lake/bare/", Parameters: map[string]string{"table_type": "ICEBERG"}, + } + }, + sql: "SELECT * FROM bare", want: "ICEBERG_INVALID_METADATA: Table is missing [metadata_location] property", + }, + { + name: "metadata corrupt", + setup: func(e *iceEnv) { + e.store.objects["lake/bad/metadata/0.json"] = []byte("{not json") + e.cat.defs["shop/bad"] = &athena.GlueTableDef{ + Name: "bad", + Location: "s3://lake/bad/", + Parameters: map[string]string{ + "table_type": "ICEBERG", + "metadata_location": "s3://lake/bad/metadata/0.json", + }, + } + }, + sql: "SELECT * FROM bad", want: "ICEBERG_INVALID_METADATA: Invalid metadata file for table shop.bad", + }, + { + name: "external keyword", sql: "CREATE EXTERNAL TABLE x (id int) LOCATION 's3://lake/x/' " + + "TBLPROPERTIES ('table_type'='ICEBERG')", + want: "External keyword not supported for table type ICEBERG", + }, + { + name: "unknown property", sql: "CREATE TABLE x (id int) LOCATION 's3://lake/x/' " + + "TBLPROPERTIES ('table_type'='ICEBERG', 'bogus'='1')", + want: "Unsupported table property key: bogus", + }, + { + name: "orc format", sql: "CREATE TABLE x (id int) LOCATION 's3://lake/x/' " + + "TBLPROPERTIES ('table_type'='ICEBERG', 'format'='orc')", + want: "NOT_SUPPORTED", + }, + { + name: "partition column missing", + sql: "CREATE TABLE x (id int) PARTITIONED BY (nope) LOCATION 's3://lake/x/' " + + "TBLPROPERTIES ('table_type'='ICEBERG')", + want: "COLUMN_NOT_FOUND", + }, + { + name: "travel on hive table", + setup: func(e *iceEnv) { + e.cat.defs["shop/h"] = salesDef(serdeCSV, nil) + e.cat.defs["shop/h"].Name = "h" + }, + sql: "SELECT * FROM h FOR VERSION AS OF 1", want: "does not support versioned tables", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + if tc.setup != nil { + tc.setup(e) + } + + assert.Contains(t, e.failure(t, tc.sql), tc.want) + }) + } +} + +func putParquet(t *testing.T, store *engineS3, key string, cols []parquetio.Column, rows [][]any) int { + t.Helper() + + var buf bytes.Buffer + require.NoError(t, parquetio.Write(&buf, cols, rows, "SNAPPY")) + + _, err := store.PutObject(context.Background(), &sdk_s3.PutObjectInput{ + Bucket: aws.String("lake"), Key: aws.String(key), Body: bytes.NewReader(buf.Bytes()), + }) + require.NoError(t, err) + + return buf.Len() +} + +func putObj(t *testing.T, store *engineS3, uri string, body []byte) { + t.Helper() + + bucket, key, _ := strings.Cut(strings.TrimPrefix(uri, "s3://"), "/") + _, err := store.PutObject(context.Background(), &sdk_s3.PutObjectInput{ + Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(body), + }) + require.NoError(t, err) +} + +// seedExternalIceberg lays out a table the way another engine would: renamed +// Parquet columns matched by field id, an identity-partition column kept out of +// the data file, and a position delete removing one row. +func seedExternalIceberg(t *testing.T, e *iceEnv) { + t.Helper() + + str, _ := iceberg.FromHive("string") + lng, _ := iceberg.FromHive("bigint") + schema := iceberg.Schema{Fields: []iceberg.Field{ + {ID: 10, Name: "id", Type: []byte(`"` + lng + `"`)}, + {ID: 20, Name: "label", Type: []byte(`"` + str + `"`)}, + {ID: 30, Name: "region", Type: []byte(`"` + str + `"`)}, + }} + spec := iceberg.PartitionSpec{Fields: []iceberg.PartitionField{ + {Name: "region", Transform: "identity", SourceID: 30, FieldID: 1000}, + }} + now := time.Now().UTC() + meta := iceberg.NewTableMetadata(iceberg.NewParams{Location: "s3://lake/ext", Schema: schema, Spec: spec, Now: now}) + + dataKey := "ext/data/region=eu/f1.parquet" + size := putParquet(t, e.store, dataKey, + []parquetio.Column{{Name: "id_old", Type: "bigint", FieldID: 10}, {Name: "lbl", Type: "string", FieldID: 20}}, + [][]any{{int64(1), "keep"}, {int64(2), "drop"}, {int64(3), "keep"}}) + + delKey := "ext/data/deletes.parquet" + delSize := putParquet(t, e.store, delKey, + []parquetio.Column{{Name: "file_path", Type: "string"}, {Name: "pos", Type: "bigint"}}, + [][]any{{"s3://lake/" + dataKey, int64(1)}}) + + snapID, seq := int64(111), int64(1) + dataEntry := iceberg.ManifestEntry{ + Status: iceberg.StatusAdded, + SnapshotID: &snapID, + SequenceNumber: &seq, + DataFile: iceberg.DataFile{ + Path: "s3://lake/" + dataKey, Format: "PARQUET", Partition: map[string]any{"region": "eu"}, + RecordCount: 3, FileSize: int64(size), + }, + } + delSeq := int64(2) + delEntry := iceberg.ManifestEntry{ + Status: iceberg.StatusAdded, + SnapshotID: &snapID, + SequenceNumber: &delSeq, + DataFile: iceberg.DataFile{ + Path: "s3://lake/" + delKey, Format: "PARQUET", Partition: map[string]any{"region": "eu"}, + Content: iceberg.ContentPositionDeletes, RecordCount: 1, FileSize: int64(delSize), + }, + } + + dataManifest, err := iceberg.EncodeManifest(meta, spec, iceberg.ContentData, []iceberg.ManifestEntry{dataEntry}) + require.NoError(t, err) + + delManifest, err := iceberg.EncodeManifest( + meta, + spec, + iceberg.ContentPositionDeletes, + []iceberg.ManifestEntry{delEntry}, + ) + require.NoError(t, err) + + putObj(t, e.store, "s3://lake/ext/metadata/m-data.avro", dataManifest) + putObj(t, e.store, "s3://lake/ext/metadata/m-del.avro", delManifest) + + list, err := iceberg.EncodeManifestList(snapID, nil, 2, []iceberg.ManifestFile{ + { + Path: "s3://lake/ext/metadata/m-data.avro", + Length: int64(len(dataManifest)), + Content: iceberg.ContentData, + SequenceNumber: 1, + MinSequenceNumber: 1, + AddedSnapshotID: snapID, + AddedFiles: 1, + AddedRows: 3, + }, + { + Path: "s3://lake/ext/metadata/m-del.avro", + Length: int64(len(delManifest)), + Content: 1, + SequenceNumber: 2, + MinSequenceNumber: 2, + AddedSnapshotID: snapID, + AddedFiles: 1, + AddedRows: 1, + }, + }) + require.NoError(t, err) + putObj(t, e.store, "s3://lake/ext/metadata/snap-111.avro", list) + + next := meta.AppendSnapshot(iceberg.Snapshot{ + SnapshotID: snapID, SequenceNumber: 2, TimestampMS: now.UnixMilli(), + ManifestList: "s3://lake/ext/metadata/snap-111.avro", Summary: map[string]string{"operation": "overwrite"}, + }, "", now) + + body, err := iceberg.EncodeMetadata(next) + require.NoError(t, err) + + putObj(t, e.store, "s3://lake/ext/metadata/00001.metadata.json", body) + + e.cat.defs["shop/ext"] = &athena.GlueTableDef{ + Name: "ext", + Location: "s3://lake/ext", + Parameters: map[string]string{ + "table_type": "ICEBERG", + "metadata_location": "s3://lake/ext/metadata/00001.metadata.json", + }, + } +} + +func TestIceberg_ReadsTablesWrittenByOtherEngines(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + sql string + want [][]string + }{ + {"field ids partition constants and position deletes", "SELECT id, label, region FROM ext ORDER BY id", + [][]string{{"1", "keep", "eu"}, {"3", "keep", "eu"}}}, + {"files metadata", "SELECT count(*) FROM \"ext$files\"", [][]string{{"2"}}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + seedExternalIceberg(t, e) + + assert.Equal(t, tc.want, e.mustRun(t, tc.sql)) + }) + } +} + +func TestIceberg_InsertRetriesLostCommit(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + e.mustRun(t, "CREATE TABLE r (id int) LOCATION 's3://lake/r' TBLPROPERTIES ('table_type'='ICEBERG')") + + conflicts := &conflictOnce{iceCatalog: e.cat} + e.b.SetGlueMetadataSource(conflicts) + + e.mustRun(t, "INSERT INTO r VALUES (1)") + assert.Equal(t, 1, conflicts.hits) + assert.Equal(t, [][]string{{"1"}}, e.mustRun(t, "SELECT id FROM r")) +} + +type conflictOnce struct { + *iceCatalog + hits int +} + +func (c *conflictOnce) CommitTableMetadata(db, name, expected, next string) error { + if c.hits == 0 { + c.hits++ + + return athena.ErrCatalogCommitConflict + } + + return c.iceCatalog.CommitTableMetadata(db, name, expected, next) +} diff --git a/services/athena/engine_iceberg_travel.go b/services/athena/engine_iceberg_travel.go new file mode 100644 index 0000000000..845e0252ec --- /dev/null +++ b/services/athena/engine_iceberg_travel.go @@ -0,0 +1,113 @@ +package athena + +import ( + "context" + "strings" +) + +const travelSentinel = "\x00travel:" + +// travelClause recognises FOR TIMESTAMP|VERSION AS OF after the table +// reference ending at end and returns ":" and the last consumed token. +func (x *translator) travelClause(toks []token, end int) (string, int) { + n := nextSig(toks, end) + if n < 0 || !toks[n].isWord("for") { + return "", end + } + + k := nextSig(toks, n) + if k < 0 || (!toks[k].isWord("timestamp") && !toks[k].isWord("version")) { + return "", end + } + + a := nextSig(toks, k) + if a < 0 || !toks[a].isWord("as") { + return "", end + } + + o := nextSig(toks, a) + if o < 0 || !toks[o].isWord("of") { + return "", end + } + + first := nextSig(toks, o) + if first < 0 { + x.err = &syntaxError{"line 1:1: mismatched input '': expecting a value after AS OF"} + + return "", end + } + + last := travelExprEnd(toks, first) + if last < 0 { + x.err = &syntaxError{"line 1:1: invalid expression after FOR " + strings.ToUpper(toks[k].text) + " AS OF"} + + return "", end + } + + kind := travelTimestamp + if toks[k].isWord("version") { + kind = travelVersion + } + + return kind + ":" + strings.TrimSpace(joinTokens(toks[first:last+1])), last +} + +// travelExprEnd returns the index of the last token of the AS OF operand: a +// literal, TIMESTAMP 'literal', a niladic function, or a parenthesised expression. +func travelExprEnd(toks []token, first int) int { + t := toks[first] + + switch { + case t.isPunct("("): + return matchParen(toks, first) + case t.kind == tkString || t.kind == tkNumber: + return first + case t.isWord("timestamp"): + if n := nextSig(toks, first); n >= 0 && toks[n].kind == tkString { + return n + } + case t.kind == tkWord: + if n := nextSig(toks, first); n >= 0 && toks[n].isPunct("(") { + return matchParen(toks, n) + } + + return first + } + + return -1 +} + +func splitTravel(parts []string) ([]string, travelSpec) { + if len(parts) == 0 || !strings.HasPrefix(parts[len(parts)-1], travelSentinel) { + return parts, travelSpec{} + } + + spec := strings.TrimPrefix(parts[len(parts)-1], travelSentinel) + kind, expr, _ := strings.Cut(spec, ":") + + return parts[:len(parts)-1], travelSpec{kind: kind, value: evalTravelExpr(expr)} +} + +// evalTravelExpr evaluates the AS OF operand to its text; unevaluable input is returned as written. +func evalTravelExpr(expr string) string { + toks, err := tokenize(expr) + if err == nil { + if sig := significant(toks); len(sig) == 1 && sig[0].kind == tkString { + return stringLiteral(sig[0]) + } + } + + sqlText, _, terr := translateSQL("SELECT "+expr, func([]string) resolvedRef { return resolvedRef{} }) + if terr != nil { + return expr + } + + plan := &enginePlan{sql: sqlText} + + res, _, out := plan.run(context.Background(), defaultEngineLimits()) + if out.failed || res == nil || len(res.rows) == 0 || len(res.rows[0]) == 0 { + return expr + } + + return res.rows[0][0] +} diff --git a/services/athena/engine_iceberg_write.go b/services/athena/engine_iceberg_write.go new file mode 100644 index 0000000000..995d218b46 --- /dev/null +++ b/services/athena/engine_iceberg_write.go @@ -0,0 +1,314 @@ +package athena + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk_s3 "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" +) + +const ( + maxCommitAttempts = 4 + propParquetCodec = "write.parquet.compression-codec" + nullPartition = "null" +) + +func (p *writePlan) precheckIcebergInsert(ctx context.Context, def *GlueTableDef) statementOutcome { + l := &tableLoader{get: p.get, list: p.list, limits: defaultEngineLimits()} + + tbl, err := l.openIceberg(ctx, def, p.db+"."+p.table) + if err != nil { + return engineFailure(err) + } + + if _, ok := p.cat.(tableCommitter); !ok { + return stmtFail(athenaErrTypeSyntax, "NOT_SUPPORTED: the catalog cannot commit Iceberg metadata") + } + + p.def, p.ice = def, tbl + + return stmtOK() +} + +func writeHiveType(t string) string { + if strings.HasPrefix(t, typeTimestamp) { + return typeTimestamp + } + + return t +} + +func (p *writePlan) applyIcebergInsert(ctx context.Context, res *sqlResult, limits EngineLimits) statementOutcome { + meta := p.ice.meta + schema := meta.CurrentSchema() + + dest, err := icebergColumns(schema, p.display()) + if err != nil { + return engineFailure(err) + } + + src, out := p.insertSources(dest, len(res.columns)) + if out.failed { + return out + } + + cols := make([]outCol, len(dest)) + + for i, c := range dest { + if iceberg.PrimitiveName(schema.Fields[i].Type) == "" { + return stmtFail(athenaErrTypeSyntax, + "NOT_SUPPORTED: writing nested column '%s' to an Iceberg table is not supported", c.Name) + } + + cols[i] = outCol{name: c.Name, typ: writeHiveType(c.Type), src: src[i], id: schema.Fields[i].ID} + } + + groups, err := p.icebergGroups(res, schema, cols) + if err != nil { + return stmtFail(athenaErrTypeSyntax, "NOT_SUPPORTED: %s", err.Error()) + } + + var ( + files []iceberg.DataFile + total int64 + ) + + for seq, g := range groups { + if err = ctx.Err(); err != nil { + return engineFailure(err) + } + + body, eerr := encodeParquetRows(res, g.rows, cols, meta.Properties[propParquetCodec]) + if eerr != nil { + return engineFailure(eerr) + } + + if total += int64(len(body)); total > limits.MaxResultBytes { + return engineFailure(fmt.Errorf("%w: more than %d bytes", errResultBytes, limits.MaxResultBytes)) + } + + path := fmt.Sprintf("%s/data/%s%s_%0*d.parquet", strings.TrimSuffix(meta.Location, "/"), + icebergPartDir(meta.DefaultSpec(), g.partition), p.queryID, fileSeqWidth, seq) + + if out = p.putObjectAt(ctx, path, body); out.failed { + return out + } + + files = append(files, iceberg.DataFile{ + Path: path, Format: "PARQUET", Partition: g.partition, RecordCount: int64(len(g.rows)), + FileSize: int64(len(body)), ValueCounts: countsFor(res, g.rows, cols, false), + NullCounts: countsFor(res, g.rows, cols, true), + }) + } + + if len(files) == 0 { + return stmtOK() + } + + return p.commitIcebergAppend(ctx, files) +} + +type icebergGroup struct { + partition map[string]any + rows []int +} + +func (p *writePlan) icebergGroups(res *sqlResult, schema iceberg.Schema, cols []outCol) ([]*icebergGroup, error) { + spec := p.ice.meta.DefaultSpec() + trs := make([]iceberg.Transform, len(spec.Fields)) + srcCol := make([]int, len(spec.Fields)) + srcType := make([]string, len(spec.Fields)) + + for i, pf := range spec.Fields { + tr, err := iceberg.ParseTransform(pf.Transform) + if err != nil { + return nil, err + } + + trs[i] = tr + + for ci, c := range cols { + if c.id == pf.SourceID { + srcCol[i], srcType[i] = ci, iceberg.PrimitiveName(schema.Fields[ci].Type) + } + } + } + + var order []*icebergGroup + + byKey := map[string]*icebergGroup{} + + for r := range res.rows { + part := make(map[string]any, len(spec.Fields)) + keyParts := make([]string, len(spec.Fields)) + + for i, pf := range spec.Fields { + var raw *string + + if v, null := res.cell(r, cols[srcCol[i]].src); !null { + raw = &v + } + + val, err := trs[i].Apply(srcType[i], raw) + if err != nil { + return nil, err + } + + part[pf.Name] = val + keyParts[i] = fmt.Sprint(val) + } + + key := strings.Join(keyParts, "\x00") + + g, ok := byKey[key] + if !ok { + g = &icebergGroup{partition: part} + byKey[key] = g + order = append(order, g) + } + + g.rows = append(g.rows, r) + } + + return order, nil +} + +func icebergPartDir(spec iceberg.PartitionSpec, part map[string]any) string { + var sb strings.Builder + + for _, pf := range spec.Fields { + v := nullPartition + if part[pf.Name] != nil { + v = fmt.Sprint(part[pf.Name]) + } + + sb.WriteString(url.PathEscape(pf.Name) + "=" + url.PathEscape(v) + "/") + } + + return sb.String() +} + +func countsFor(res *sqlResult, rows []int, cols []outCol, nullsOnly bool) map[int]int64 { + out := make(map[int]int64, len(cols)) + + for _, c := range cols { + var n int64 + + for _, r := range rows { + _, null := res.cell(r, c.src) + if !nullsOnly || null { + n++ + } + } + + out[c.id] = n + } + + return out +} + +func (p *writePlan) putObjectAt(ctx context.Context, uri string, body []byte) statementOutcome { + bucket, key, ok := splitS3URI(uri) + if !ok { + return stmtFail(athenaErrTypeSyntax, "line 1:1: invalid S3 location %q", uri) + } + + _, err := p.put.PutObject(ctx, &sdk_s3.PutObjectInput{ + Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(body), + }) + if err != nil { + return stmtFail(athenaErrTypeSyntax, "ICEBERG_FILESYSTEM_ERROR: cannot write s3://%s/%s: %s", bucket, key, err) + } + + return stmtOK() +} + +// currentManifests lists the manifests of the table's current snapshot. +func (l *tableLoader) currentManifests(ctx context.Context, tbl *icebergTable) ([]iceberg.ManifestFile, error) { + snap, ok := tbl.meta.CurrentSnapshot() + if !ok || snap.ManifestList == "" { + return nil, nil + } + + raw, err := l.readURI(ctx, snap.ManifestList) + if err != nil { + return nil, icebergFailf("ICEBERG_MISSING_METADATA", "Manifest list not found: %s", snap.ManifestList) + } + + list, err := iceberg.ReadManifestList(raw) + if err != nil { + return nil, icebergFailf("ICEBERG_INVALID_METADATA", "Invalid manifest list: %v", err) + } + + return list, nil +} + +// commitIcebergAppend writes the manifest, manifest list and metadata, then +// swaps the catalog pointer; a lost race re-plans the append on the new head. +func (p *writePlan) commitIcebergAppend(ctx context.Context, files []iceberg.DataFile) statementOutcome { + l := &tableLoader{get: p.get, list: p.list, limits: defaultEngineLimits()} + tbl := p.ice + + for range maxCommitAttempts { + conflict, out := p.tryCommit(ctx, l, tbl, files) + if !conflict { + return out + } + + def, err := p.cat.GetTableDef(p.db, p.table) + if err != nil { + return stmtFail( + athenaErrTypeEntityMiss, + "TABLE_NOT_FOUND: line 1:1: Table '%s' does not exist", + p.display(), + ) + } + + if tbl, err = l.openIceberg(ctx, def, p.db+"."+p.table); err != nil { + return engineFailure(err) + } + } + + return stmtFail(athenaErrTypeSyntax, + "ICEBERG_COMMIT_ERROR: Failed to commit Iceberg update to table: %s (concurrent modification)", p.display()) +} + +// tryCommit makes one commit attempt; conflict=true means the catalog pointer moved. +func (p *writePlan) tryCommit( + ctx context.Context, l *tableLoader, tbl *icebergTable, files []iceberg.DataFile, +) (bool, statementOutcome) { + prev, err := l.currentManifests(ctx, tbl) + if err != nil { + return false, engineFailure(err) + } + + plan, err := tbl.meta.PlanAppend(tbl.location, prev, files, p.now().UTC()) + if err != nil { + return false, engineFailure(icebergFailf("ICEBERG_COMMIT_ERROR", "Cannot plan Iceberg update: %v", err)) + } + + for _, o := range plan.Objects { + if out := p.putObjectAt(ctx, o.Location, o.Body); out.failed { + return false, out + } + } + + committer, _ := p.cat.(tableCommitter) + + switch err = committer.CommitTableMetadata(p.db, p.table, tbl.location, plan.MetadataLocation); { + case err == nil: + return false, stmtOK() + case errors.Is(err, ErrCatalogCommitConflict): + return true, stmtOK() + default: + return false, stmtFail(athenaErrTypeSyntax, + "ICEBERG_COMMIT_ERROR: Failed to commit Iceberg update to table: %s: %s", p.display(), err) + } +} diff --git a/services/athena/engine_parquet.go b/services/athena/engine_parquet.go index af901dfffe..6b0c57108a 100644 --- a/services/athena/engine_parquet.go +++ b/services/athena/engine_parquet.go @@ -137,7 +137,7 @@ func parquetCell(v any) string { func encodeParquetRows(res *sqlResult, rows []int, cols []outCol, codec string) ([]byte, error) { schema := make([]parquetio.Column, len(cols)) for i, c := range cols { - schema[i] = parquetio.Column{Name: c.name, Type: c.typ} + schema[i] = parquetio.Column{Name: c.name, Type: c.typ, FieldID: c.id} } cells := make([][]any, len(rows)) diff --git a/services/athena/engine_run.go b/services/athena/engine_run.go index 3cb5b6dd42..a87f671643 100644 --- a/services/athena/engine_run.go +++ b/services/athena/engine_run.go @@ -19,10 +19,13 @@ var ( ) type planTable struct { - def *GlueTableDef - schema string - name string - parts []GluePartitionDef + def *GlueTableDef + schema string + name string + display string + metaTable string + travel travelSpec + parts []GluePartitionDef } type enginePlan struct { @@ -99,12 +102,17 @@ func (b *InMemoryBackend) planEngineQuery( return b.planWrite(query, qctx, cat, get, list) } - plan, unresolved, err := b.buildPlan(query, qctx, cat, get, list) + plan, unresolved, err := b.buildPlan(query, qctx, get, list) if err != nil { return nil, planFailure(err), true } if len(plan.tables) == 0 && !plan.standalone() { + if len(unresolved) > 0 && strings.HasPrefix(strings.ToLower(unresolved[0]), s3TablesCatalogPrefix) { + return nil, stmtFail(athenaErrTypeEntityMiss, + "TABLE_NOT_FOUND: line 1:15: Table '%s' does not exist", unresolved[0]), true + } + return nil, stmtOK(), false } @@ -127,18 +135,20 @@ func planFailure(err error) statementOutcome { // buildPlan translates one SELECT and resolves its Glue-backed tables. // Callers must hold b.mu. func (b *InMemoryBackend) buildPlan( - query string, qctx QueryExecutionContext, cat GlueTableCatalog, get S3Getter, list S3Lister, + query string, qctx QueryExecutionContext, get S3Getter, list S3Lister, ) (*enginePlan, []string, error) { plan := &enginePlan{get: get, list: list} seen := map[string]*planTable{} resolve := func(parts []string) resolvedRef { + parts, travel := splitTravel(parts) catalog, db, table := resolveTableRef(strings.Join(parts, "."), qctx) catalog, db, table = normalizeCatalog(catalog), strings.ToLower(db), strings.ToLower(table) display := strings.ToLower(catalog) + "." + db + "." + table + tcat, tok := b.tableCatalogFor(catalog) - if db == "information_schema" || !b.isGlueBacked(catalog) { + if db == "information_schema" || !tok { return resolvedRef{} } @@ -146,20 +156,27 @@ func (b *InMemoryBackend) buildPlan( return resolvedRef{} } - key := db + "." + table + base, metaTable, _ := icebergMetaSuffix(table) + key := db + "." + table + "|" + travel.kind + travel.value + if pt, dup := seen[key]; dup { return resolvedRef{schema: pt.schema, name: pt.name, found: true} } - def, err := cat.GetTableDef(db, table) + def, err := tcat.GetTableDef(db, base) if err != nil || def.Location == "" { return resolvedRef{display: display} } - pt := &planTable{def: def, schema: glueSchema(db), name: table} + pt := &planTable{def: def, schema: glueSchema(db), name: table, display: db + "." + base, metaTable: metaTable} + pt.travel = travel + + if travel.kind != "" { + pt.name = fmt.Sprintf("%s@%d", table, len(plan.tables)) + } - if len(def.PartitionKeys) > 0 { - pt.parts, _ = cat.GetPartitionDefs(db, table) + if len(def.PartitionKeys) > 0 && tableTypeOf(def) == "" { + pt.parts, _ = tcat.GetPartitionDefs(db, table) } seen[key] = pt @@ -306,6 +323,17 @@ func tableColumns(def *GlueTableDef) []Column { } func (t *planTable) loadInto(ctx context.Context, conn *sql.Conn, l *tableLoader) error { + switch tableTypeOf(t.def) { + case tableTypeIceberg: + return t.loadIceberg(ctx, conn, l) + case tableTypeDelta: + return t.loadDelta(ctx, conn, l) + } + + if t.travel.kind != "" || t.metaTable != "" { + return fmt.Errorf("%w: this connector does not support versioned tables", errUnsupportedFormat) + } + format, err := detectFormat(t.def) if err != nil { return err diff --git a/services/athena/engine_s3tables.go b/services/athena/engine_s3tables.go new file mode 100644 index 0000000000..c2f6256491 --- /dev/null +++ b/services/athena/engine_s3tables.go @@ -0,0 +1,102 @@ +package athena + +import ( + "errors" + "fmt" + "strings" +) + +const s3TablesCatalogPrefix = "s3tablescatalog/" + +// ErrCatalogCommitConflict is returned when a catalog commit finds the table's +// metadata location moved since it was read. +var ErrCatalogCommitConflict = errors.New("catalog commit conflict") + +var errS3TablesDDL = errors.New( + "NOT_SUPPORTED: schema changes in S3 Tables catalogs are not supported; use the S3 Tables API", +) + +// S3TableInfo is an S3 Tables table as the SQL engine sees it. +type S3TableInfo struct { + MetadataLocation string + Location string +} + +// S3TablesSource resolves tables of the federated s3tablescatalog/ +// catalog (database = namespace), wired via SetS3TablesSource. +type S3TablesSource interface { + GetTableInfo(region, tableBucket, namespace, table string) (*S3TableInfo, error) + CommitMetadataLocation(region, tableBucket, namespace, table, expected, next string) error +} + +// tableCommitter is implemented by catalogs that can swap an Iceberg table's metadata location. +type tableCommitter interface { + CommitTableMetadata(db, name, expected, next string) error +} + +// SetS3TablesSource wires the S3 Tables federated catalog. +func (b *InMemoryBackend) SetS3TablesSource(src S3TablesSource) { b.s3tables = src } + +func s3TablesBucket(catalog string) (string, bool) { + if len(catalog) <= len(s3TablesCatalogPrefix) || + !strings.EqualFold(catalog[:len(s3TablesCatalogPrefix)], s3TablesCatalogPrefix) { + return "", false + } + + return catalog[len(s3TablesCatalogPrefix):], true +} + +// tableCatalogFor returns the table catalog serving a catalog name: the wired +// Glue catalog for GLUE data catalogs, or an S3 Tables view for +// s3tablescatalog/. Callers must hold b.mu. +func (b *InMemoryBackend) tableCatalogFor(catalog string) (GlueTableCatalog, bool) { + if bucket, ok := s3TablesBucket(catalog); ok { + if b.s3tables == nil { + return nil, false + } + + return &s3TablesCatalog{src: b.s3tables, region: b.region, bucket: bucket}, true + } + + if !b.isGlueBacked(catalog) { + return nil, false + } + + cat, ok := b.glueSource.(GlueTableCatalog) + + return cat, ok +} + +type s3TablesCatalog struct { + src S3TablesSource + region string + bucket string +} + +func (c *s3TablesCatalog) GetTableDef(db, name string) (*GlueTableDef, error) { + info, err := c.src.GetTableInfo(c.region, c.bucket, db, name) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCatalogEntityNotFound, err) + } + + return &GlueTableDef{ + Name: name, TableType: tableTypeExternal, Location: info.Location, + Parameters: map[string]string{paramTableType: tableTypeIceberg, paramMetadataLoc: info.MetadataLocation}, + }, nil +} + +func (c *s3TablesCatalog) GetPartitionDefs(string, string) ([]GluePartitionDef, error) { + return nil, nil +} + +func (c *s3TablesCatalog) CreateTableDef(string, GlueTableDef) error { return errS3TablesDDL } + +func (c *s3TablesCatalog) DeleteTable(string, string) error { return errS3TablesDDL } + +func (c *s3TablesCatalog) CreatePartitionDefs(string, string, []GluePartitionDef) error { + return errS3TablesDDL +} + +func (c *s3TablesCatalog) CommitTableMetadata(db, name, expected, next string) error { + return c.src.CommitMetadataLocation(c.region, c.bucket, db, name, expected, next) +} diff --git a/services/athena/engine_s3tables_test.go b/services/athena/engine_s3tables_test.go new file mode 100644 index 0000000000..397c9fc887 --- /dev/null +++ b/services/athena/engine_s3tables_test.go @@ -0,0 +1,153 @@ +package athena_test + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" + "github.com/blackbirdworks/gopherstack/services/athena" +) + +var errNoSuchTable = errors.New("no such table") + +type fakeS3Tables struct { + tables map[string]*athena.S3TableInfo + mu sync.Mutex +} + +func (f *fakeS3Tables) GetTableInfo(_, bucket, ns, table string) (*athena.S3TableInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + + info, ok := f.tables[bucket+"/"+ns+"/"+table] + if !ok { + return nil, fmt.Errorf("%w: %s", errNoSuchTable, table) + } + + cp := *info + + return &cp, nil +} + +func (f *fakeS3Tables) CommitMetadataLocation(_, bucket, ns, table, expected, next string) error { + f.mu.Lock() + defer f.mu.Unlock() + + info := f.tables[bucket+"/"+ns+"/"+table] + if info == nil || info.MetadataLocation != expected { + return athena.ErrCatalogCommitConflict + } + + info.MetadataLocation = next + + return nil +} + +func seedS3Table(t *testing.T, e *iceEnv, src *fakeS3Tables) { + t.Helper() + + schema, err := iceberg.SchemaFromColumns( + []iceberg.ColumnSpec{{Name: "id", Type: "bigint"}, {Name: "name", Type: "string"}}, + ) + require.NoError(t, err) + + const wh = "s3://abc123--table-s3/ns/tbl" + + meta := iceberg.NewTableMetadata(iceberg.NewParams{ + Location: wh, Schema: schema, Spec: iceberg.PartitionSpec{Fields: []iceberg.PartitionField{}}, Now: time.Now(), + }) + + body, err := iceberg.EncodeMetadata(meta) + require.NoError(t, err) + + loc := iceberg.InitialMetadataLocation(wh) + putObj(t, e.store, loc, body) + src.tables["mybucket/ns/tbl"] = &athena.S3TableInfo{Location: wh, MetadataLocation: loc} +} + +func TestS3TablesCatalog_QueryAndAppend(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + sql []string + want [][]string + }{ + { + "insert then select qualified", + []string{`INSERT INTO "s3tablescatalog/mybucket"."ns"."tbl" VALUES (1, 'a'), (2, 'b')`}, + [][]string{{"1", "a"}, {"2", "b"}}, + }, + { + "two appends", + []string{ + `INSERT INTO "s3tablescatalog/mybucket"."ns"."tbl" VALUES (1, 'a')`, + `INSERT INTO "s3tablescatalog/mybucket"."ns"."tbl" VALUES (2, 'b')`, + }, + [][]string{{"1", "a"}, {"2", "b"}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + src := &fakeS3Tables{tables: map[string]*athena.S3TableInfo{}} + e.b.SetS3TablesSource(src) + seedS3Table(t, e, src) + + for _, q := range tc.sql { + e.mustRun(t, q) + } + + assert.Equal(t, tc.want, + e.mustRun(t, `SELECT id, name FROM "s3tablescatalog/mybucket"."ns"."tbl" ORDER BY id`)) + + meta := parseMetadataAt(t, e.store, src.tables["mybucket/ns/tbl"].MetadataLocation) + assert.Len(t, meta.Snapshots, len(tc.sql)) + }) + } +} + +func TestS3TablesCatalog_ContextAndErrors(t *testing.T) { + t.Parallel() + + e := newIceEnv(t) + src := &fakeS3Tables{tables: map[string]*athena.S3TableInfo{}} + e.b.SetS3TablesSource(src) + seedS3Table(t, e, src) + e.mustRun(t, `INSERT INTO "s3tablescatalog/mybucket".ns.tbl VALUES (7, 'z')`) + + id, err := e.b.StartQueryExecution("SELECT id FROM tbl", "primary", + athena.QueryExecutionContext{Catalog: "s3tablescatalog/mybucket", Database: "ns"}, + athena.ResultConfiguration{OutputLocation: "s3://out/res/"}, nil, nil) + require.NoError(t, err) + + qe := waitDone(t, e.b, id) + require.Equal(t, "SUCCEEDED", qe.Status.State, qe.Status.StateChangeReason) + assert.Equal(t, [][]string{{"7"}}, resultRows(t, e.b, id)) + + fails := []struct{ name, sql, want string }{ + {"missing table", `SELECT * FROM "s3tablescatalog/mybucket".ns.ghost`, "TABLE_NOT_FOUND"}, + { + "create unsupported", `CREATE TABLE "s3tablescatalog/mybucket".ns.n (id int) LOCATION 's3://x/y/' ` + + `TBLPROPERTIES ('table_type'='ICEBERG')`, + "S3 Tables API", + }, + } + + for _, tc := range fails { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Contains(t, e.failure(t, tc.sql), tc.want) + }) + } +} diff --git a/services/athena/engine_sql.go b/services/athena/engine_sql.go index d6cc1aca01..8467536e0c 100644 --- a/services/athena/engine_sql.go +++ b/services/athena/engine_sql.go @@ -71,7 +71,7 @@ func renamedAlias(lw string) string { return "min" case "ceiling": return "ceil" - case "truncate": + case fnTruncate: return "trunc" default: return "" @@ -415,20 +415,20 @@ func (x *translator) tableRef(toks []token, i int, out []token) ([]token, int) { return out, j - 1 } - parts := []string{toks[j].ident()} - end := j - - for end+2 < len(toks) && toks[end+1].isPunct(".") && - (toks[end+2].kind == tkWord || toks[end+2].kind == tkQuoted) { - parts = append(parts, toks[end+2].ident()) - end += 2 - } + parts, end := qualifiedRef(toks, j) if len(parts) == 1 && x.ctes[parts[0]] { return append(out, toks[j:end+1]...), end } - ref := x.resolve(parts) + resolveParts := parts + + if tr, tend := x.travelClause(toks, end); tr != "" { + resolveParts = append(append([]string{}, parts...), travelSentinel+tr) + end = tend + } + + ref := x.resolve(resolveParts) if !ref.found { x.unresolved = append(x.unresolved, orDefault(ref.display, strings.Join(parts, "."))) @@ -444,6 +444,20 @@ func (x *translator) tableRef(toks []token, i int, out []token) ([]token, int) { ), end } +// qualifiedRef reads a dotted table reference starting at j and returns its parts and last token index. +func qualifiedRef(toks []token, j int) ([]string, int) { + parts := []string{toks[j].ident()} + end := j + + for end+2 < len(toks) && toks[end+1].isPunct(".") && + (toks[end+2].kind == tkWord || toks[end+2].kind == tkQuoted) { + parts = append(parts, toks[end+2].ident()) + end += 2 + } + + return parts, end +} + // noteTable records the qualifiers (table name and alias) and ROW columns of a resolved table. func (x *translator) noteTable(toks []token, end int, name string, ref resolvedRef) { x.qualifiers[name] = true diff --git a/services/athena/engine_write.go b/services/athena/engine_write.go index 951c11be11..e7187c893e 100644 --- a/services/athena/engine_write.go +++ b/services/athena/engine_write.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "strings" + "time" "github.com/aws/aws-sdk-go-v2/aws" sdk_s3 "github.com/aws/aws-sdk-go-v2/service/s3" @@ -33,8 +34,8 @@ var ( func isWriteProp(k string) bool { switch k { - case "format", "external_location", "partitioned_by", "field_delimiter", - "write_compression", "compression", "parquet_compression", "orc_compression", "is_external": + case propFormat, "external_location", "partitioned_by", "field_delimiter", + propWriteCompression, "compression", "parquet_compression", "orc_compression", "is_external": return true default: return false @@ -54,6 +55,7 @@ type outCol struct { name string typ string src int + id int } // writePlan is the write side of a CTAS, INSERT or UNLOAD. @@ -61,16 +63,20 @@ type writePlan struct { cat GlueTableCatalog put S3Storer list S3Lister + get S3Getter + now func() time.Time def *GlueTableDef stmt *writeStmt catalog string db string table string + ice *icebergTable queryID string outputLocation string location string partitionBy []string format outFormat + s3tables bool } // bind supplies the query id and effective result location, which are only @@ -97,18 +103,22 @@ func (b *InMemoryBackend) planWrite( return nil, stmtOK(), false } - wp := &writePlan{stmt: w, cat: cat, put: b.s3, list: list} + wp := &writePlan{stmt: w, cat: cat, put: b.s3, list: list, get: get, now: b.now} if w.kind != writeUnload { catalog, db, table := refTarget(w.target, qctx) - if !b.isGlueBacked(catalog) { + + tcat, found := b.tableCatalogFor(catalog) + if !found { return nil, stmtOK(), false } + _, wp.s3tables = s3TablesBucket(catalog) + wp.cat = tcat wp.catalog, wp.db, wp.table = catalog, db, table } - plan, unresolved, err := b.buildPlan(w.body, qctx, cat, get, list) + plan, unresolved, err := b.buildPlan(w.body, qctx, get, list) if err != nil { return nil, planFailure(err), true } @@ -136,6 +146,10 @@ func (p *writePlan) validate(b *InMemoryBackend) statementOutcome { case writeInsert: return stmtOK() case writeCTAS: + if p.s3tables { + return stmtFail(athenaErrTypeSyntax, "%s", errS3TablesDDL.Error()) + } + if _, err := b.glueSource.GetDatabase(p.db); err != nil { return stmtFail(athenaErrTypeEntityMiss, "SCHEMA_NOT_FOUND: Schema '%s' does not exist", p.db) } @@ -155,7 +169,7 @@ func (p *writePlan) validateProps() statementOutcome { } } - name := strings.ToUpper(firstProp(props, "format")) + name := strings.ToUpper(firstProp(props, propFormat)) if name == "" { name = "PARQUET" } @@ -196,7 +210,7 @@ func (p *writePlan) validateProps() statementOutcome { func (p *writePlan) validateCompression() statementOutcome { props := p.stmt.props - c := strings.ToUpper(orDefault(firstProp(props, "write_compression"), + c := strings.ToUpper(orDefault(firstProp(props, propWriteCompression), orDefault(firstProp(props, "compression"), firstProp(props, "parquet_compression")))) if out := p.checkCompression(c); out.failed { @@ -257,7 +271,7 @@ func withSlash(s string) string { func (p *writePlan) precheck(ctx context.Context) (statementOutcome, bool) { switch p.stmt.kind { case writeInsert: - return p.precheckInsert(), false + return p.precheckInsert(ctx), false case writeUnload: p.location = withSlash(p.stmt.location) default: @@ -319,13 +333,20 @@ func (p *writePlan) checkEmpty(ctx context.Context) statementOutcome { return stmtOK() } -func (p *writePlan) precheckInsert() statementOutcome { +func (p *writePlan) precheckInsert(ctx context.Context) statementOutcome { def, err := p.cat.GetTableDef(p.db, p.table) if err != nil { return stmtFail(athenaErrTypeEntityMiss, "TABLE_NOT_FOUND: line 1:1: Table '%s' does not exist", p.display()) } + switch tableTypeOf(def) { + case tableTypeIceberg: + return p.precheckIcebergInsert(ctx, def) + case tableTypeDelta: + return stmtFail(athenaErrTypeSyntax, "NOT_SUPPORTED: Delta Lake tables are read-only in Athena") + } + f, err := detectFormat(def) if err != nil { return engineFailure(err) @@ -416,7 +437,7 @@ func (p *writePlan) ctasDef(data, parts []outCol) GlueTableDef { Name: p.table, TableType: tableTypeExternal, Location: p.location, - Parameters: map[string]string{"EXTERNAL": "TRUE"}, + Parameters: map[string]string{"EXTERNAL": externalTrue}, Columns: outColumns(data), PartitionKeys: outColumns(parts), InputFormat: textInputFormat, @@ -504,6 +525,10 @@ func (p *writePlan) registerPartitions(parts []GluePartitionDef) statementOutcom } func (p *writePlan) applyInsert(ctx context.Context, res *sqlResult, limits EngineLimits) statementOutcome { + if p.ice != nil { + return p.applyIcebergInsert(ctx, res, limits) + } + def := p.def dest := tableColumns(def) keys := insertPartitionKeys(def) diff --git a/services/athena/handler.go b/services/athena/handler.go index 7778977b16..7d1742534f 100644 --- a/services/athena/handler.go +++ b/services/athena/handler.go @@ -57,6 +57,7 @@ func (h *Handler) EnableRegions(ctx context.Context) { h.peers = regionpeers.New(home.region, func(region string) *Handler { nb := NewInMemoryBackend(region, home.accountID) nb.s3 = home.s3 + nb.s3tables = home.s3tables nb.limits = home.limits if h.glueFor != nil { diff --git a/services/athena/iam_enforcement_test.go b/services/athena/iam_enforcement_test.go index fc4650f142..03d7fdacc5 100644 --- a/services/athena/iam_enforcement_test.go +++ b/services/athena/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/athena" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/athena/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/athena/store.go b/services/athena/store.go index 355154cf6a..a1a14d84ea 100644 --- a/services/athena/store.go +++ b/services/athena/store.go @@ -56,6 +56,7 @@ func isValidDataCatalogType(t string) bool { type InMemoryBackend struct { now func() time.Time glueSource GlueMetadataSource + s3tables S3TablesSource s3 S3Storer registry *store.Registry databasesByCatalog *store.Index[Database] diff --git a/services/autoscaling/iam_enforcement_test.go b/services/autoscaling/iam_enforcement_test.go index c193397291..341220cab8 100644 --- a/services/autoscaling/iam_enforcement_test.go +++ b/services/autoscaling/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/autoscaling" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/autoscaling/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/autoscaling/launch_delay_test.go b/services/autoscaling/launch_delay_test.go index 57c8c40767..ba4309dbf6 100644 --- a/services/autoscaling/launch_delay_test.go +++ b/services/autoscaling/launch_delay_test.go @@ -1,13 +1,14 @@ package autoscaling_test import ( + "net/http" "net/http/httptest" "strings" "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" assdk "github.com/aws/aws-sdk-go-v2/service/autoscaling" "github.com/labstack/echo/v5" @@ -18,7 +19,18 @@ import ( "github.com/blackbirdworks/gopherstack/services/autoscaling" ) -func newClientWithBackend(t *testing.T) (*assdk.Client, *autoscaling.InMemoryBackend) { +type handlerTransport struct{ h http.Handler } + +func (ht handlerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + ht.h.ServeHTTP(rec, r) + + return rec.Result(), nil +} + +// newInMemClientWithBackend serves the handler through an in-memory transport so the client can +// run inside a synctest bubble (no real network). +func newInMemClientWithBackend(t *testing.T) (*assdk.Client, *autoscaling.InMemoryBackend) { t.Helper() backend := autoscaling.NewInMemoryBackend() @@ -27,14 +39,14 @@ func newClientWithBackend(t *testing.T) (*assdk.Client, *autoscaling.InMemoryBac require.NoError(t, registry.Register(autoscaling.NewHandler(backend))) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig(t.Context(), awscfg.WithRegion("us-east-1"), - awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", ""))) - require.NoError(t, err) + cfg := aws.Config{ + Region: "us-east-1", + BaseEndpoint: aws.String("http://autoscaling.invalid"), + HTTPClient: &http.Client{Transport: handlerTransport{h: e}}, + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), + } - return assdk.NewFromConfig(cfg, func(o *assdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }), backend + return assdk.NewFromConfig(cfg), backend } func instanceStates(t *testing.T, c *assdk.Client) (int, int) { @@ -83,7 +95,7 @@ func TestLaunchDelay_PendingThenInService(t *testing.T) { wantPending int wantInService int }{ - {name: "delayed", delay: 40 * time.Millisecond, wantPending: 3}, + {name: "delayed", delay: time.Minute, wantPending: 3}, {name: "instant", delay: 0, wantInService: 3}, } @@ -91,32 +103,36 @@ func TestLaunchDelay_PendingThenInService(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - c, backend := newClientWithBackend(t) - backend.SetLaunchDelay(tt.delay) - seedGroup(t, c, "g", 3, 5) + synctest.Test(t, func(t *testing.T) { + c, backend := newInMemClientWithBackend(t) + backend.SetLaunchDelay(tt.delay) + seedGroup(t, c, "g", 3, 5) - inService, pending := instanceStates(t, c) - assert.Equal(t, tt.wantPending, pending) - assert.Equal(t, tt.wantInService, inService) + inService, pending := instanceStates(t, c) + assert.Equal(t, tt.wantPending, pending) + assert.Equal(t, tt.wantInService, inService) - if tt.delay == 0 { - assert.Zero(t, inProgressLaunches(t, c)) + if tt.delay == 0 { + assert.Zero(t, inProgressLaunches(t, c)) - return - } + return + } - assert.Positive(t, inProgressLaunches(t, c)) + assert.Positive(t, inProgressLaunches(t, c)) - _, err := c.SetDesiredCapacity(t.Context(), &assdk.SetDesiredCapacityInput{ - AutoScalingGroupName: aws.String("g"), DesiredCapacity: aws.Int32(5), - }) - require.NoError(t, err) + _, err := c.SetDesiredCapacity(t.Context(), &assdk.SetDesiredCapacityInput{ + AutoScalingGroupName: aws.String("g"), DesiredCapacity: aws.Int32(5), + }) + require.NoError(t, err) - require.Eventually(t, func() bool { - inSvc, pend := instanceStates(t, c) + time.Sleep(tt.delay) + synctest.Wait() - return inSvc == 5 && pend == 0 && inProgressLaunches(t, c) == 0 - }, 5*time.Second, 5*time.Millisecond) + inSvc, pend := instanceStates(t, c) + assert.Equal(t, 5, inSvc) + assert.Zero(t, pend) + assert.Zero(t, inProgressLaunches(t, c)) + }) }) } } diff --git a/services/awsconfig/account_scoping_sdk_test.go b/services/awsconfig/account_scoping_sdk_test.go new file mode 100644 index 0000000000..bd5d8eb1a1 --- /dev/null +++ b/services/awsconfig/account_scoping_sdk_test.go @@ -0,0 +1,292 @@ +package awsconfig_test + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + configservicesdk "github.com/aws/aws-sdk-go-v2/service/configservice" + "github.com/aws/aws-sdk-go-v2/service/configservice/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/awsconfig" +) + +const ( + acctHome = "000000000000" + acctOther = "111111111111" +) + +type scopedServer struct { + url string +} + +func newScopedServer(t *testing.T, h *awsconfig.Handler) scopedServer { + t.Helper() + + e := echo.New() + registry := service.NewRegistry() + require.NoError(t, registry.Register(h)) + e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + r := c.Request() + c.SetRequest(r.WithContext(awsmeta.Set(r.Context(), awsmeta.FromRequest(r, "us-east-1")))) + + return next(c) + } + }) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + return scopedServer{url: srv.URL} +} + +func (s scopedServer) client(t *testing.T, account, region string) *configservicesdk.Client { + t.Helper() + + cfg, err := awscfg.LoadDefaultConfig( + t.Context(), + awscfg.WithRegion(region), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(account, "secret", "")), + ) + require.NoError(t, err) + + return configservicesdk.NewFromConfig(cfg, func(o *configservicesdk.Options) { + o.BaseEndpoint = aws.String(s.url) + }) +} + +func newScopedHandler() *awsconfig.Handler { + h := awsconfig.NewHandler(awsconfig.NewInMemoryBackendWithMeta(acctHome, "us-east-1")) + h.EnableRegions() + + return h +} + +func putRecorder(t *testing.T, c *configservicesdk.Client, name string) error { + t.Helper() + + _, err := c.PutConfigurationRecorder(t.Context(), &configservicesdk.PutConfigurationRecorderInput{ + ConfigurationRecorder: &types.ConfigurationRecorder{ + Name: aws.String(name), + RoleARN: aws.String("arn:aws:iam::000000000000:role/config"), + }, + }) + + return err +} + +func recorderNames(t *testing.T, c *configservicesdk.Client) []string { + t.Helper() + + out, err := c.DescribeConfigurationRecorders(t.Context(), &configservicesdk.DescribeConfigurationRecordersInput{}) + require.NoError(t, err) + + names := make([]string, 0, len(out.ConfigurationRecorders)) + for _, r := range out.ConfigurationRecorders { + names = append(names, aws.ToString(r.Name)) + } + + return names +} + +func TestAccountScoping_Recorders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + acctA string + regionA string + acctB string + regionB string + nameA string + nameB string + wantLimitB bool + }{ + {name: "different_accounts", acctA: acctHome, regionA: "us-east-1", acctB: acctOther, regionB: "us-east-1", + nameA: "rec-a", nameB: "rec-b"}, + {name: "different_regions", acctA: acctHome, regionA: "us-east-1", acctB: acctHome, regionB: "eu-west-1", + nameA: "rec-a", nameB: "rec-b"}, + {name: "different_both", acctA: acctHome, regionA: "us-east-1", acctB: acctOther, regionB: "eu-west-1", + nameA: "rec-a", nameB: "rec-b"}, + {name: "same_scope_hits_limit", acctA: acctOther, regionA: "eu-west-1", acctB: acctOther, regionB: "eu-west-1", + nameA: "rec-a", nameB: "rec-b", wantLimitB: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := newScopedServer(t, newScopedHandler()) + a := srv.client(t, tc.acctA, tc.regionA) + b := srv.client(t, tc.acctB, tc.regionB) + + require.NoError(t, putRecorder(t, a, tc.nameA)) + + errB := putRecorder(t, b, tc.nameB) + if tc.wantLimitB { + var limit *types.MaxNumberOfConfigurationRecordersExceededException + require.ErrorAs(t, errB, &limit) + assert.Equal(t, []string{tc.nameA}, recorderNames(t, b)) + + return + } + + require.NoError(t, errB) + assert.Equal(t, []string{tc.nameA}, recorderNames(t, a)) + assert.Equal(t, []string{tc.nameB}, recorderNames(t, b)) + + _, err := b.StartConfigurationRecorder(t.Context(), &configservicesdk.StartConfigurationRecorderInput{ + ConfigurationRecorderName: aws.String(tc.nameA), + }) + var nosuch *types.NoSuchConfigurationRecorderException + require.ErrorAs(t, err, &nosuch) + + _, err = b.DeleteConfigurationRecorder(t.Context(), &configservicesdk.DeleteConfigurationRecorderInput{ + ConfigurationRecorderName: aws.String(tc.nameA), + }) + require.ErrorAs(t, err, &nosuch) + assert.Equal(t, []string{tc.nameA}, recorderNames(t, a)) + }) + } +} + +func TestAccountScoping_OtherResources(t *testing.T) { + t.Parallel() + + srv := newScopedServer(t, newScopedHandler()) + a := srv.client(t, acctHome, "us-east-1") + b := srv.client(t, acctOther, "us-east-1") + c := srv.client(t, acctHome, "eu-west-1") + ctx := t.Context() + + _, err := a.PutDeliveryChannel(ctx, &configservicesdk.PutDeliveryChannelInput{ + DeliveryChannel: &types.DeliveryChannel{Name: aws.String("chan"), S3BucketName: aws.String("bkt")}, + }) + require.NoError(t, err) + + _, err = a.PutConfigRule(ctx, &configservicesdk.PutConfigRuleInput{ConfigRule: &types.ConfigRule{ + ConfigRuleName: aws.String("rule"), + Source: &types.Source{ + Owner: types.OwnerAws, SourceIdentifier: aws.String("S3_BUCKET_VERSIONING_ENABLED"), + }, + }}) + require.NoError(t, err) + + _, err = a.PutEvaluations(ctx, &configservicesdk.PutEvaluationsInput{ + ResultToken: aws.String("rule"), + Evaluations: []types.Evaluation{{ + ComplianceResourceType: aws.String("AWS::S3::Bucket"), + ComplianceResourceId: aws.String("b1"), + ComplianceType: types.ComplianceTypeCompliant, + OrderingTimestamp: aws.Time(time.Now()), + }}, + }) + require.NoError(t, err) + + _, err = a.PutConfigurationAggregator(ctx, &configservicesdk.PutConfigurationAggregatorInput{ + ConfigurationAggregatorName: aws.String("agg"), + AccountAggregationSources: []types.AccountAggregationSource{ + {AccountIds: []string{acctHome}, AllAwsRegions: true}, + }, + }) + require.NoError(t, err) + + _, err = a.PutRetentionConfiguration(ctx, &configservicesdk.PutRetentionConfigurationInput{ + RetentionPeriodInDays: aws.Int32(30), + }) + require.NoError(t, err) + + for _, other := range []*configservicesdk.Client{b, c} { + ch, chErr := other.DescribeDeliveryChannels(ctx, &configservicesdk.DescribeDeliveryChannelsInput{}) + require.NoError(t, chErr) + assert.Empty(t, ch.DeliveryChannels) + + rules, rulesErr := other.DescribeConfigRules(ctx, &configservicesdk.DescribeConfigRulesInput{}) + require.NoError(t, rulesErr) + assert.Empty(t, rules.ConfigRules) + + cmp, cmpErr := other.DescribeComplianceByConfigRule( + ctx, &configservicesdk.DescribeComplianceByConfigRuleInput{}, + ) + require.NoError(t, cmpErr) + assert.Empty(t, cmp.ComplianceByConfigRules) + + det, detErr := other.GetComplianceDetailsByConfigRule( + ctx, &configservicesdk.GetComplianceDetailsByConfigRuleInput{ConfigRuleName: aws.String("rule")}, + ) + if detErr == nil { + assert.Empty(t, det.EvaluationResults) + } + + aggs, aggErr := other.DescribeConfigurationAggregators( + ctx, &configservicesdk.DescribeConfigurationAggregatorsInput{}, + ) + require.NoError(t, aggErr) + assert.Empty(t, aggs.ConfigurationAggregators) + + ret, retErr := other.DescribeRetentionConfigurations( + ctx, + &configservicesdk.DescribeRetentionConfigurationsInput{}, + ) + require.NoError(t, retErr) + assert.Empty(t, ret.RetentionConfigurations) + } + + comp, err := a.DescribeComplianceByConfigRule(ctx, &configservicesdk.DescribeComplianceByConfigRuleInput{ + ConfigRuleNames: []string{"rule"}, + }) + require.NoError(t, err) + assert.Len(t, comp.ComplianceByConfigRules, 1) +} + +func TestAccountScoping_Persistence(t *testing.T) { + t.Parallel() + + t.Run("round_trip", func(t *testing.T) { + t.Parallel() + + h := newScopedHandler() + srv := newScopedServer(t, h) + require.NoError(t, putRecorder(t, srv.client(t, acctHome, "us-east-1"), "home-rec")) + require.NoError(t, putRecorder(t, srv.client(t, acctOther, "us-east-1"), "other-rec")) + require.NoError(t, putRecorder(t, srv.client(t, acctOther, "eu-west-1"), "other-eu-rec")) + + snap := h.Snapshot(t.Context()) + require.NotNil(t, snap) + + h2 := newScopedHandler() + require.NoError(t, h2.Restore(t.Context(), snap)) + srv2 := newScopedServer(t, h2) + + assert.Equal(t, []string{"home-rec"}, recorderNames(t, srv2.client(t, acctHome, "us-east-1"))) + assert.Equal(t, []string{"other-rec"}, recorderNames(t, srv2.client(t, acctOther, "us-east-1"))) + assert.Equal(t, []string{"other-eu-rec"}, recorderNames(t, srv2.client(t, acctOther, "eu-west-1"))) + }) + + t.Run("old_format_loads_into_default_account", func(t *testing.T) { + t.Parallel() + + old := awsconfig.NewInMemoryBackendWithMeta(acctHome, "us-east-1") + require.NoError(t, old.PutConfigurationRecorder("legacy", "arn:aws:iam::000000000000:role/r", nil)) + snap := old.Snapshot(t.Context()) + require.NotNil(t, snap) + + h := newScopedHandler() + srv := newScopedServer(t, h) + require.NoError(t, putRecorder(t, srv.client(t, acctOther, "us-east-1"), "stale")) + require.NoError(t, h.Restore(t.Context(), snap)) + + assert.Equal(t, []string{"legacy"}, recorderNames(t, srv.client(t, acctHome, "us-east-1"))) + assert.Empty(t, recorderNames(t, srv.client(t, acctOther, "us-east-1"))) + }) +} diff --git a/services/awsconfig/accounts.go b/services/awsconfig/accounts.go new file mode 100644 index 0000000000..96c030130f --- /dev/null +++ b/services/awsconfig/accounts.go @@ -0,0 +1,161 @@ +package awsconfig + +import ( + "encoding/json" + "maps" + "regexp" + "slices" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + accountsSnapshotKey = "accounts" + maxAccountPeers = 64 +) + +var accountIDPattern = regexp.MustCompile(`^[0-9]{12}$`) + +// accountSet holds the per-account sibling handlers of one home handler; each +// sibling owns its own account-scoped backend and region peers. +type accountSet struct { + peers map[string]*Handler + build func(account string) *Handler + mu *lockmetrics.RWMutex + home string +} + +func newAccountSet(home string, build func(account string) *Handler) *accountSet { + return &accountSet{ + home: home, + build: build, + peers: make(map[string]*Handler), + mu: lockmetrics.New("awsconfig-accounts"), + } +} + +// get returns account's sibling, building it on first use; nil means the home +// handler serves (home account, empty/malformed account, or the cap is reached). +func (s *accountSet) get(account string) *Handler { + if s == nil || account == "" || account == s.home || !accountIDPattern.MatchString(account) { + return nil + } + + s.mu.Lock("get") + defer s.mu.Unlock() + + p, ok := s.peers[account] + if !ok { + if len(s.peers) >= maxAccountPeers { + return nil + } + + p = s.build(account) + s.peers[account] = p + } + + return p +} + +func (s *accountSet) all() []*Handler { + if s == nil { + return nil + } + + s.mu.RLock("all") + defer s.mu.RUnlock() + + out := make([]*Handler, 0, len(s.peers)) + for _, a := range slices.Sorted(maps.Keys(s.peers)) { + out = append(out, s.peers[a]) + } + + return out +} + +func (s *accountSet) drain() []*Handler { + if s == nil { + return nil + } + + s.mu.Lock("drain") + defer s.mu.Unlock() + + out := make([]*Handler, 0, len(s.peers)) + for _, a := range slices.Sorted(maps.Keys(s.peers)) { + out = append(out, s.peers[a]) + } + + s.peers = make(map[string]*Handler) + + return out +} + +// snapshot adds an additive "accounts" object to base; with no siblings base is returned unchanged. +func (s *accountSet) snapshot(base []byte, snap func(*Handler) []byte) []byte { + if s == nil || base == nil { + return base + } + + s.mu.RLock("snapshot") + accounts := make(map[string]json.RawMessage, len(s.peers)) + + for a, p := range s.peers { + if data := snap(p); data != nil { + accounts[a] = data + } + } + s.mu.RUnlock() + + if len(accounts) == 0 { + return base + } + + doc := make(map[string]json.RawMessage) + if err := json.Unmarshal(base, &doc); err != nil { + return base + } + + enc, err := json.Marshal(accounts) + if err != nil { + return base + } + + doc[accountsSnapshotKey] = enc + + out, err := json.Marshal(doc) + if err != nil { + return base + } + + return out +} + +// restore rebuilds siblings from the "accounts" object in data; older snapshots without it just clear them. +func (s *accountSet) restore(data []byte, restore func(*Handler, []byte) error) error { + if s == nil { + return nil + } + + for _, p := range s.drain() { + p.Reset() + } + + var doc struct { + Accounts map[string]json.RawMessage `json:"accounts"` + } + + if err := json.Unmarshal(data, &doc); err != nil { + return err + } + + for _, a := range slices.Sorted(maps.Keys(doc.Accounts)) { + if p := s.get(a); p != nil { + if err := restore(p, doc.Accounts[a]); err != nil { + return err + } + } + } + + return nil +} diff --git a/services/awsconfig/handler.go b/services/awsconfig/handler.go index 0aa480fcd3..5889902770 100644 --- a/services/awsconfig/handler.go +++ b/services/awsconfig/handler.go @@ -57,6 +57,7 @@ type Handler struct { Backend *InMemoryBackend dispatchTable map[string]service.JSONOpFunc peers *regionpeers.Set[Handler] + accounts *accountSet } // NewHandler creates a new AWS Config handler. @@ -222,6 +223,10 @@ func extractNamedField(body []byte, key string) string { // Handler returns the Echo handler function. func (h *Handler) Handler() echo.HandlerFunc { return func(c *echo.Context) error { + if a := h.accounts.get(awsmeta.Account(c.Request().Context())); a != nil { + return a.Handler()(c) + } + if p := h.peers.Get(awsmeta.Region(c.Request().Context())); p != nil { return p.Handler()(c) } diff --git a/services/awsconfig/iam_enforcement_test.go b/services/awsconfig/iam_enforcement_test.go index 78dee1f930..9b772c683b 100644 --- a/services/awsconfig/iam_enforcement_test.go +++ b/services/awsconfig/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/awsconfig" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/config/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/awsconfig/persistence.go b/services/awsconfig/persistence.go index d7a6e30f62..003a287047 100644 --- a/services/awsconfig/persistence.go +++ b/services/awsconfig/persistence.go @@ -242,7 +242,9 @@ func (b *InMemoryBackend) restoreRemediationExecutionsFromDTO(tables map[string] // Snapshot implements persistence.Persistable; other regions ride in an additive "regions" key. func (h *Handler) Snapshot(ctx context.Context) []byte { - return h.peers.Snapshot(h.Backend.Snapshot(ctx), func(p *Handler) []byte { return p.Backend.Snapshot(ctx) }) + base := h.peers.Snapshot(h.Backend.Snapshot(ctx), func(p *Handler) []byte { return p.Backend.Snapshot(ctx) }) + + return h.accounts.snapshot(base, func(a *Handler) []byte { return a.Snapshot(ctx) }) } // Restore implements persistence.Persistable. @@ -251,11 +253,16 @@ func (h *Handler) Restore(ctx context.Context, data []byte) error { return err } - return h.peers.Restore( + err := h.peers.Restore( data, func(p *Handler, d []byte) error { return p.Backend.Restore(ctx, d) }, func(p *Handler) { p.Backend.Reset() }, ) + if err != nil { + return err + } + + return h.accounts.restore(data, func(a *Handler, d []byte) error { return a.Restore(ctx, d) }) } // Reset implements service.Resettable. @@ -265,4 +272,8 @@ func (h *Handler) Reset() { for _, p := range h.peers.Drain() { p.Backend.Reset() } + + for _, a := range h.accounts.drain() { + a.Reset() + } } diff --git a/services/awsconfig/regions.go b/services/awsconfig/regions.go index 90edff0c7a..829ebf97b0 100644 --- a/services/awsconfig/regions.go +++ b/services/awsconfig/regions.go @@ -4,6 +4,24 @@ import "github.com/blackbirdworks/gopherstack/pkgs/regionpeers" // EnableRegions makes h serve every other region through lazily built per-region siblings. func (h *Handler) EnableRegions() { + h.enableRegions() + + home := h.Backend + h.accounts = newAccountSet(home.accountID, func(account string) *Handler { + nb := NewInMemoryBackendWithMeta(account, home.region) + nb.s3Writer, nb.snsPublisher = home.deliveryTargets() + home.mu.RLock("inheritTemplates") + nb.templates = home.templates + home.mu.RUnlock() + + ah := NewHandler(nb) + ah.enableRegions() + + return ah + }) +} + +func (h *Handler) enableRegions() { home := h.Backend h.peers = regionpeers.New(home.region, func(region string) *Handler { @@ -31,7 +49,7 @@ func (h *Handler) BackendFor(region string) *InMemoryBackend { return h.RegionHandler(region).Backend } -// RegionBackends returns the home backend followed by every sibling built so far. +// RegionBackends returns the home backend followed by every region and account sibling built so far. func (h *Handler) RegionBackends() []*InMemoryBackend { peers := h.peers.All() out := make([]*InMemoryBackend, 0, 1+len(peers)) @@ -41,6 +59,10 @@ func (h *Handler) RegionBackends() []*InMemoryBackend { out = append(out, p.Backend) } + for _, a := range h.accounts.all() { + out = append(out, a.RegionBackends()...) + } + return out } @@ -50,3 +72,12 @@ func (b *InMemoryBackend) deliveryTargets() (S3Writer, SNSPublisher) { return b.s3Writer, b.snsPublisher } + +// BackendForAccount returns the backend serving account in region; home and unknown accounts use the home set. +func (h *Handler) BackendForAccount(account, region string) *InMemoryBackend { + if a := h.accounts.get(account); a != nil { + return a.BackendFor(region) + } + + return h.BackendFor(region) +} diff --git a/services/awsconfig/wire_recorder_status_leak_test.go b/services/awsconfig/wire_recorder_status_leak_test.go index e4b70da4ab..963c3468cb 100644 --- a/services/awsconfig/wire_recorder_status_leak_test.go +++ b/services/awsconfig/wire_recorder_status_leak_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/awsconfig" ) @@ -24,11 +25,12 @@ import ( // response, then replays them so the real SDK deserializer still sees the // full body. type recorderStatusCaptureTransport struct { + rt http.RoundTripper body []byte } func (c *recorderStatusCaptureTransport) Do(req *http.Request) (*http.Response, error) { - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := c.rt.RoundTrip(req) if err != nil { return nil, err } @@ -71,7 +73,7 @@ func newCapturingWireTestAWSConfigClient( ) require.NoError(t, err) - capture := &recorderStatusCaptureTransport{} + capture := &recorderStatusCaptureTransport{rt: testclient.New(t).Transport} client := configservicesdk.NewFromConfig(cfg, func(o *configservicesdk.Options) { o.BaseEndpoint = aws.String(srv.URL) diff --git a/services/azurearm/PARITY.md b/services/azurearm/PARITY.md index 31a382e90f..4f9cfc1302 100644 --- a/services/azurearm/PARITY.md +++ b/services/azurearm/PARITY.md @@ -29,11 +29,27 @@ ops: DocumentDBAccountsSQL: {wire: ok, errors: ok, state: ok, persist: ok, note: "Microsoft.DocumentDB databaseAccounts (PUT/GET/PATCH/DELETE/LIST, listKeys, readonlykeys, listConnectionStrings, regenerateKey) and sqlDatabases/containers/throughputSettings (rp_cosmos*.go). Databases and containers live in services/cosmosdb through the CosmosResources seam, so ARM- and data-plane-created ones see each other; account keys are the data plane's master key plus generated keys it accepts (cosmosdb Handler.AcceptKey). One data plane serves every account: ARM-created databases belong to their account, data-plane-created ones show under all. PUT answers 200 synchronously; DELETE answers 202 with an Azure-AsyncOperation that is already Succeeded. Throughput and analyticalStorageTtl/partitionKey.version are ARM-side only. Verified by test/terraform/azure TestTerraform_Azure_CosmosDB (apply, no-drift plan, azcosmos item round trip with the listKeys key, destroy). Not implemented: migrateToAutoscale/Manual, failoverPriorityChange, other APIs (Mongo, Table, Cassandra, Gremlin), regenerating the primary key does not revoke the configured master key."} families: RedisCacheCRUD: {wire: ok, errors: ok, state: ok, persist: ok, note: "Microsoft.Cache redis caches (PUT/PATCH/DELETE as async operations, listKeys, regenerateKey, forceReboot, import, export) with firewallRules, patchSchedules and linkedServers children (rp_redis*.go). Real Redis servers, keys and data live in services/azureredis through the Redis seam; ARM-side metadata persists in the redis snapshot field. Not implemented: Microsoft.Cache/redisEnterprise, access policies, private endpoints. Applied without drift by test/terraform/azure/redis_test.go."} + ContainerAppsCRUD: {wire: ok, errors: partial, state: ok, persist: ok, note: "Microsoft.App at api-version 2025-07-01 (rp_containerapps*.go), checked against armappcontainers/v4@v4.0.0 and driven by the SDK pollers in aca_sdk_test.go, aca_engine_test.go and aca_jobs_test.go. managedEnvironments: PUT/GET/PATCH/DELETE/list (appLogsConfiguration with the Log Analytics shared key write-only, vnetConfiguration checked against Microsoft.Network subnets when the VNet exists and sized /27 for workload-profile or /23 for consumption-only environments, workloadProfiles validated, immutable vnet and zoneRedundant, defaultDomain -<6 hex>..azurecontainerapps.io, staticIp 127.0.0.1, delete refused while apps or jobs use the environment) and storages (AzureFile / NfsAzureFile, account key write-only). containerApps: PUT/GET/PATCH/DELETE/list, start, stop, listSecrets. configuration (secrets inline or keyVaultUrl with identity checked, registries, ingress external/internal with targetPort, transport, ipSecurityRestrictions and traffic, activeRevisionsMode Single/Multiple/Labels, dapr stored, maxInactiveRevisions pruning), template (containers with env/secretRef, vCPU and memory totals checked against the documented Consumption combinations, probes, volumes EmptyDir/Secret/AzureFile, scale min/max and rules), identity through rp_identity.go. A template change creates revision -- (7 hex or revisionSuffix; a name conflict is 400); Single mode retires older revisions once the new one is ready and keeps the old one serving when the new one fails; Multiple mode keeps them. Revisions list/get/activate/deactivate/restart and replicas list/get. Traffic weights must total 100 and name existing revisions; labels are validated. PUT/PATCH/DELETE answer with Azure-AsyncOperation (+Location on delete) already Succeeded; ETag and If-Match/If-None-Match are honoured on environments, apps and jobs. jobs: PUT/GET/PATCH/DELETE/list, start (with container overrides), stop, stop execution, executions list, listSecrets for Manual, Schedule and Event triggers. Replicas run in the Container Instances engine (same --azure-arm-aci-engine flag, same fake-able ACIRuntime): ingress hosts ., .internal. (internal), . and ---[INST] hi [/INST]","max_tokens":8,"stop":["S"],"top_k":40}`, + check: func(t *testing.T, req, out map[string]any) { + t.Helper() + assert.Equal(t, "mistral", req["model"]) + assert.InDelta(t, 40, req["options"].(map[string]any)["top_k"], 0) + o := out["outputs"].([]any)[0].(map[string]any) + assert.Equal(t, "m", o["text"]) + assert.Equal(t, "stop", o["stop_reason"]) + }, + }, + { + name: "cohere embed", + modelID: "cohere.embed-english-v3", + wantPath: "/api/embed", + reply: []map[string]any{embed}, + body: `{"texts":["a","b"],"input_type":"search_document"}`, + check: func(t *testing.T, req, out map[string]any) { + t.Helper() + assert.Equal(t, []any{"a", "b"}, req["input"]) + assert.Equal(t, "embeddings_floats", out["response_type"]) + assert.Len(t, out["embeddings"], 2) + assert.Equal(t, []any{"a", "b"}, out["texts"]) + assert.NotEmpty(t, out["id"]) + }, + }, + { + name: "cohere embed by type", + modelID: "cohere.embed-english-v3", + wantPath: "/api/embed", + reply: []map[string]any{embed}, + body: `{"texts":["a","b"],"input_type":"search_query","embedding_types":["float"]}`, + check: func(t *testing.T, _, out map[string]any) { + t.Helper() + assert.Equal(t, "embeddings_by_type", out["response_type"]) + assert.Len(t, out["embeddings"].(map[string]any)["float"], 2) + }, + }, + { + name: "cohere command r", modelID: "cohere.command-r-v1:0", wantPath: "/api/chat", reply: chatLines("ok"), + body: `{"message":"next","preamble":"pre","max_tokens":12,"k":10, +"chat_history":[{"role":"USER","message":"q"},{"role":"CHATBOT","message":"a"}]}`, + check: func(t *testing.T, req, out map[string]any) { + t.Helper() + msgs := req["messages"].([]any) + require.Len(t, msgs, 4) + assert.Equal(t, "assistant", msgs[2].(map[string]any)["role"]) + assert.Equal(t, "ok", out["text"]) + assert.Equal(t, "COMPLETE", out["finish_reason"]) + assert.Len(t, out["chat_history"], 4) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + url, rec := newFakeOllama(t, func(string, map[string]any) fakeReply { return fakeReply{lines: tc.reply} }) + client := newOllamaSDKClient( + t, + bedrockruntime.LLMConfig{Backend: "ollama", OllamaURL: url, ModelMap: testModelMap}, + ) + + resp, err := client.InvokeModel(t.Context(), &bedrockruntimesdk.InvokeModelInput{ + ModelId: aws.String(tc.modelID), Body: []byte(tc.body), ContentType: aws.String("application/json"), + }) + require.NoError(t, err) + + path, req := rec.last(t) + assert.Equal(t, tc.wantPath, path) + + var out map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &out)) + tc.check(t, req, out) + }) + } +} + +func TestOllamaBackend_InvokeModelStream(t *testing.T) { + t.Parallel() + + cases := []struct { + check func(t *testing.T, chunks []map[string]any) + name string + modelID string + body string + reply []map[string]any + }{ + { + name: "titan", + modelID: "amazon.titan-text-express-v1", + reply: genLines("a", "b"), + body: `{"inputText":"x"}`, + check: func(t *testing.T, ch []map[string]any) { + t.Helper() + require.Len(t, ch, 3) + assert.Equal(t, "a", ch[0]["outputText"]) + assert.Nil(t, ch[0]["completionReason"]) + assert.Equal(t, "FINISH", ch[2]["completionReason"]) + assert.InDelta(t, 2, ch[2]["totalOutputTextTokenCount"], 0) + assert.Contains(t, ch[2], "amazon-bedrock-invocationMetrics") + }, + }, + { + name: "llama", modelID: "meta.llama3-8b-instruct-v1:0", reply: genLines("a", "b"), body: `{"prompt":"x"}`, + check: func(t *testing.T, ch []map[string]any) { + t.Helper() + require.Len(t, ch, 3) + assert.Equal(t, "b", ch[1]["generation"]) + assert.Equal(t, "stop", ch[2]["stop_reason"]) + assert.InDelta(t, 4, ch[2]["prompt_token_count"], 0) + }, + }, + { + name: "mistral", + modelID: "mistral.mixtral-8x7b-instruct-v0:1", + reply: genLines("a"), + body: `{"prompt":"x"}`, + check: func(t *testing.T, ch []map[string]any) { + t.Helper() + require.Len(t, ch, 2) + assert.Equal(t, "a", ch[0]["outputs"].([]any)[0].(map[string]any)["text"]) + assert.Equal(t, "stop", ch[1]["outputs"].([]any)[0].(map[string]any)["stop_reason"]) + }, + }, + { + name: "anthropic", + modelID: "anthropic.claude-3-haiku-20240307-v1:0", + reply: chatLines("a", "b"), + body: `{"anthropic_version":"bedrock-2023-05-31","max_tokens":5,"messages":[{"role":"user","content":"x"}]}`, + check: func(t *testing.T, ch []map[string]any) { + t.Helper() + types := make([]any, 0, len(ch)) + for _, c := range ch { + types = append(types, c["type"]) + } + assert.Equal( + t, + []any{"message_start", "content_block_start", "content_block_delta", "content_block_delta", + "content_block_stop", "message_delta", "message_stop"}, + types, + ) + assert.Equal(t, "text_delta", ch[2]["delta"].(map[string]any)["type"]) + assert.Equal(t, "end_turn", ch[5]["delta"].(map[string]any)["stop_reason"]) + assert.InDelta(t, 3, ch[5]["usage"].(map[string]any)["output_tokens"], 0) + assert.Contains(t, ch[6], "amazon-bedrock-invocationMetrics") + }, + }, + { + name: "nova", modelID: "amazon.nova-pro-v1:0", reply: chatLines("a"), + body: `{"schemaVersion":"messages-v1","messages":[{"role":"user","content":[{"text":"x"}]}]}`, + check: func(t *testing.T, ch []map[string]any) { + t.Helper() + require.GreaterOrEqual(t, len(ch), 5) + assert.Contains(t, ch[0], "messageStart") + assert.Contains(t, ch[len(ch)-2], "messageStop") + assert.Contains(t, ch[len(ch)-1], "metadata") + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + url, rec := newFakeOllama(t, func(string, map[string]any) fakeReply { return fakeReply{lines: tc.reply} }) + client := newOllamaSDKClient( + t, + bedrockruntime.LLMConfig{Backend: "ollama", OllamaURL: url, ModelMap: testModelMap}, + ) + + resp, err := client.InvokeModelWithResponseStream( + t.Context(), + &bedrockruntimesdk.InvokeModelWithResponseStreamInput{ + ModelId: aws.String(tc.modelID), Body: []byte(tc.body), ContentType: aws.String("application/json"), + }, + ) + require.NoError(t, err) + stream := resp.GetStream() + t.Cleanup(func() { _ = stream.Close() }) + + var chunks []map[string]any + for ev := range stream.Events() { + part, ok := ev.(*types.ResponseStreamMemberChunk) + require.True(t, ok, "unexpected event %T", ev) + var m map[string]any + require.NoError(t, json.Unmarshal(part.Value.Bytes, &m)) + chunks = append(chunks, m) + } + require.NoError(t, stream.Err()) + + _, req := rec.last(t) + assert.Equal(t, true, req["stream"]) + tc.check(t, chunks) + }) + } +} + +func isErr[E error](err error) bool { + var target E + + return errors.As(err, &target) +} + +func TestOllamaBackend_Errors(t *testing.T) { + t.Parallel() + + converse := func(client *bedrockruntimesdk.Client, id string) error { + _, err := client.Converse( + t.Context(), + &bedrockruntimesdk.ConverseInput{ModelId: aws.String(id), Messages: []types.Message{userText("hi")}}, + ) + + return err + } + invoke := func(client *bedrockruntimesdk.Client, id, body string) error { + _, err := client.InvokeModel( + t.Context(), + &bedrockruntimesdk.InvokeModelInput{ModelId: aws.String(id), Body: []byte(body)}, + ) + + return err + } + + cases := []struct { + call func(c *bedrockruntimesdk.Client) error + wantErr func(error) bool + name string + reply fakeReply + down bool + }{ + { + name: "ollama down converse", + down: true, + call: func(c *bedrockruntimesdk.Client) error { return converse(c, "anthropic.claude-3-haiku-20240307-v1:0") }, + wantErr: isErr[*types.ServiceUnavailableException], + }, + {name: "ollama down invoke", down: true, call: func(c *bedrockruntimesdk.Client) error { + return invoke(c, "meta.llama3-8b-instruct-v1:0", `{"prompt":"x"}`) + }, wantErr: isErr[*types.ServiceUnavailableException]}, + { + name: "model not pulled", + reply: fakeReply{status: 404, errMsg: "model not found, try pulling it first"}, + call: func(c *bedrockruntimesdk.Client) error { return converse(c, "anthropic.claude-3-haiku-20240307-v1:0") }, + wantErr: isErr[*types.ModelNotReadyException], + }, + { + name: "ollama bad request", + reply: fakeReply{status: 400, errMsg: "does not support tools"}, + call: func(c *bedrockruntimesdk.Client) error { return converse(c, "anthropic.claude-3-haiku-20240307-v1:0") }, + wantErr: isErr[*types.ValidationException], + }, + { + name: "ollama 500", + reply: fakeReply{status: 500, errMsg: "boom"}, + call: func(c *bedrockruntimesdk.Client) error { return converse(c, "anthropic.claude-3-haiku-20240307-v1:0") }, + wantErr: isErr[*types.ModelErrorException], + }, + {name: "embedding stream", call: func(c *bedrockruntimesdk.Client) error { + _, err := c.InvokeModelWithResponseStream( + t.Context(), + &bedrockruntimesdk.InvokeModelWithResponseStreamInput{ + ModelId: aws.String("amazon.titan-embed-text-v1"), Body: []byte(`{"inputText":"x"}`), + }, + ) + + return err + }, wantErr: isErr[*types.ValidationException]}, + { + name: "unmapped model", + call: func(c *bedrockruntimesdk.Client) error { return converse(c, "cohere.command-text-v14") }, + wantErr: isErr[*types.ResourceNotFoundException], + }, + { + name: "unsupported family", + call: func(c *bedrockruntimesdk.Client) error { return invoke(c, "ai21.jamba-1-5-large-v1:0", `{}`) }, + wantErr: isErr[*types.ValidationException], + }, + { + name: "malformed body", + call: func(c *bedrockruntimesdk.Client) error { return invoke(c, "meta.llama3-8b-instruct-v1:0", `{not json`) }, + wantErr: isErr[*types.ValidationException], + }, + { + name: "missing prompt", + call: func(c *bedrockruntimesdk.Client) error { return invoke(c, "meta.llama3-8b-instruct-v1:0", `{}`) }, + wantErr: isErr[*types.ValidationException], + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + url, _ := newFakeOllama(t, func(string, map[string]any) fakeReply { return tc.reply }) + if tc.down { + url = "http://127.0.0.1:1" + } + client := newOllamaSDKClient( + t, + bedrockruntime.LLMConfig{Backend: "ollama", OllamaURL: url, ModelMap: testModelMap}, + ) + + err := tc.call(client) + require.Error(t, err) + assert.True(t, tc.wantErr(err), "wrong error type: %T %v", err, err) + }) + } +} + +func TestOllamaBackend_MockDefaultUnchanged(t *testing.T) { + t.Parallel() + + for _, backend := range []string{"", "mock"} { + t.Run("backend="+backend, func(t *testing.T) { + t.Helper() + t.Parallel() + + client := newOllamaSDKClient(t, bedrockruntime.LLMConfig{Backend: backend, OllamaURL: "http://127.0.0.1:1"}) + out, err := client.Converse(t.Context(), &bedrockruntimesdk.ConverseInput{ + ModelId: aws.String( + "anthropic.claude-3-haiku-20240307-v1:0", + ), Messages: []types.Message{userText("hi")}, + }) + require.NoError(t, err) + msg := out.Output.(*types.ConverseOutputMemberMessage).Value + assert.Equal( + t, + "This is a mock response from Gopherstack.", + msg.Content[0].(*types.ContentBlockMemberText).Value, + ) + }) + } +} + +func TestOllamaBackend_ModelMapMatching(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + modelID string + want string + }{ + {name: "plain", modelID: "anthropic.claude-3-haiku-20240307-v1:0", want: "llama3.2"}, + {name: "geo profile", modelID: "eu.anthropic.claude-3-haiku-20240307-v1:0", want: "llama3.2"}, + { + name: "profile arn", + modelID: "arn:aws:bedrock:us-east-1:111122223333:inference-profile/us.anthropic.claude-3-sonnet-20240229-v1:0", + want: "llama3.2", + }, + {name: "second rule", modelID: "amazon.titan-embed-text-v2:0", want: "nomic-embed-text"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + url, rec := newFakeOllama(t, func(p string, _ map[string]any) fakeReply { + if p == "/api/embed" { + return fakeReply{lines: []map[string]any{{"embeddings": []any{[]any{1.0}}}}} + } + + return fakeReply{lines: chatLines("x")} + }) + client := newOllamaSDKClient( + t, + bedrockruntime.LLMConfig{Backend: "ollama", OllamaURL: url, ModelMap: testModelMap}, + ) + + var err error + if tc.want == "nomic-embed-text" { + _, err = client.InvokeModel( + t.Context(), + &bedrockruntimesdk.InvokeModelInput{ + ModelId: aws.String(tc.modelID), + Body: []byte(`{"inputText":"a"}`), + }, + ) + } else { + _, err = client.Converse( + t.Context(), + &bedrockruntimesdk.ConverseInput{ + ModelId: aws.String(tc.modelID), + Messages: []types.Message{userText("hi")}, + }, + ) + } + require.NoError(t, err) + + _, req := rec.last(t) + assert.Equal(t, tc.want, req["model"]) + }) + } +} + +func TestOllamaBackend_SetLLMValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + cfg bedrockruntime.LLMConfig + bad bool + }{ + {name: "mock", cfg: bedrockruntime.LLMConfig{Backend: "mock"}}, + {name: "ollama defaults", cfg: bedrockruntime.LLMConfig{Backend: "ollama"}}, + {name: "unknown backend", cfg: bedrockruntime.LLMConfig{Backend: "bedrock"}, bad: true}, + {name: "bad url", cfg: bedrockruntime.LLMConfig{Backend: "ollama", OllamaURL: "localhost:11434"}, bad: true}, + {name: "bad map entry", cfg: bedrockruntime.LLMConfig{Backend: "ollama", ModelMap: "anthropic.*"}, bad: true}, + {name: "bad glob", cfg: bedrockruntime.LLMConfig{Backend: "ollama", ModelMap: "[=x"}, bad: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + err := bedrockruntime.NewInMemoryBackend("000000000000", "us-east-1").SetLLM(tc.cfg) + if tc.bad { + require.ErrorIs(t, err, bedrockruntime.ErrInvalidLLMConfig) + + return + } + require.NoError(t, err) + }) + } +} + +// TestOllamaBackend_Live talks to a real Ollama; set GOPHERSTACK_TEST_OLLAMA_URL and +// GOPHERSTACK_TEST_OLLAMA_MODEL (a pulled chat model) to run it. +func TestOllamaBackend_Live(t *testing.T) { + t.Parallel() + + url, model := os.Getenv("GOPHERSTACK_TEST_OLLAMA_URL"), os.Getenv("GOPHERSTACK_TEST_OLLAMA_MODEL") + if url == "" || model == "" { + t.Skip("GOPHERSTACK_TEST_OLLAMA_URL / GOPHERSTACK_TEST_OLLAMA_MODEL not set") + } + + client := newOllamaSDKClient(t, bedrockruntime.LLMConfig{Backend: "ollama", OllamaURL: url, ModelMap: "*=" + model}) + out, err := client.Converse(t.Context(), &bedrockruntimesdk.ConverseInput{ + ModelId: aws.String("anthropic.claude-3-haiku-20240307-v1:0"), + Messages: []types.Message{userText("Reply with the single word: pong")}, + }) + require.NoError(t, err) + msg := out.Output.(*types.ConverseOutputMemberMessage).Value + require.NotEmpty(t, msg.Content) + assert.Positive(t, aws.ToInt32(out.Usage.OutputTokens)) +} diff --git a/services/bedrockruntime/ollama_client.go b/services/bedrockruntime/ollama_client.go new file mode 100644 index 0000000000..c4c5f3367f --- /dev/null +++ b/services/bedrockruntime/ollama_client.go @@ -0,0 +1,251 @@ +package bedrockruntime + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const maxOllamaErrBody = 4096 + +type ollamaOptions struct { + NumPredict *int `json:"num_predict,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Stop []string `json:"stop,omitempty"` +} + +type ollamaToolFunc struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +type ollamaToolCall struct { + ID string `json:"id,omitempty"` + Function ollamaToolFunc `json:"function"` +} + +type ollamaTool struct { + Type string `json:"type"` + Function ollamaToolFunc `json:"function"` +} + +type ollamaMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ToolName string `json:"tool_name,omitempty"` + Images []string `json:"images,omitempty"` + ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"` +} + +type ollamaChatReq struct { + Options *ollamaOptions `json:"options,omitempty"` + Model string `json:"model"` + Messages []ollamaMessage `json:"messages"` + Tools []ollamaTool `json:"tools,omitempty"` + Stream bool `json:"stream"` +} + +type ollamaGenerateReq struct { + Options *ollamaOptions `json:"options,omitempty"` + Model string `json:"model"` + Prompt string `json:"prompt"` + System string `json:"system,omitempty"` + Stream bool `json:"stream"` +} + +type ollamaEmbedReq struct { + Dimensions *int `json:"dimensions,omitempty"` + Model string `json:"model"` + Input []string `json:"input"` +} + +type ollamaChunk struct { + Message ollamaMessage `json:"message"` + Model string `json:"model"` + Response string `json:"response"` + DoneReason string `json:"done_reason"` + Error string `json:"error"` + Embeddings [][]float64 `json:"embeddings"` + PromptEvalCount int `json:"prompt_eval_count"` + EvalCount int `json:"eval_count"` + Done bool `json:"done"` +} + +// ollamaCall is exactly one of the three Ollama requests. +type ollamaCall struct { + chat *ollamaChatReq + gen *ollamaGenerateReq + embed *ollamaEmbedReq +} + +func (c *ollamaCall) endpoint() (string, any) { + switch { + case c.chat != nil: + return "/api/chat", c.chat + case c.gen != nil: + return "/api/generate", c.gen + default: + return "/api/embed", c.embed + } +} + +func (c *ollamaCall) setStream(on bool) { + switch { + case c.chat != nil: + c.chat.Stream = on + case c.gen != nil: + c.gen.Stream = on + } +} + +// ollamaResult is the merged outcome of one Ollama call. +type ollamaResult struct { + Text string + DoneReason string + ToolCalls []ollamaToolCall + Embeddings [][]float64 + PromptTokens int + EvalTokens int +} + +var ( + errOllamaUnreachable = errors.New("ollama backend unreachable") + errUnmappedModel = errors.New("no ollama model mapped") +) + +// ollamaStatusError is a non-2xx answer from Ollama. +type ollamaStatusError struct { + msg string + status int +} + +func (e *ollamaStatusError) Error() string { + return fmt.Sprintf("ollama returned %d: %s", e.status, e.msg) +} + +func (o *ollamaBackend) post(ctx context.Context, call *ollamaCall) (*http.Response, error) { + endpoint, payload := call.endpoint() + + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal ollama request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.baseURL+endpoint, bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("%w: %w", errOllamaUnreachable, err) + } + + req.Header.Set("Content-Type", contentTypeJSON) + + resp, err := o.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", errOllamaUnreachable, err) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + defer resp.Body.Close() + + b, _ := io.ReadAll(io.LimitReader(resp.Body, maxOllamaErrBody)) + msg := strings.TrimSpace(string(b)) + + var e ollamaChunk + if json.Unmarshal(b, &e) == nil && e.Error != "" { + msg = e.Error + } + + return nil, &ollamaStatusError{status: resp.StatusCode, msg: msg} + } + + return resp, nil +} + +// run executes call, invoking onText/onTool for streamed output when non-nil. +func (o *ollamaBackend) run( + ctx context.Context, + call *ollamaCall, + onText func(string) error, + onTool func(ollamaToolCall) error, +) (*ollamaResult, error) { + call.setStream(onText != nil || onTool != nil) + + resp, err := o.post(ctx, call) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + res := &ollamaResult{} + dec := json.NewDecoder(resp.Body) + + for { + var ch ollamaChunk + + if derr := dec.Decode(&ch); derr != nil { + return nil, fmt.Errorf("%w: reading ollama response: %w", errOllamaUnreachable, derr) + } + + if ch.Error != "" { + return nil, &ollamaStatusError{status: http.StatusInternalServerError, msg: ch.Error} + } + + if aerr := res.absorb(&ch, onText, onTool); aerr != nil { + return nil, aerr + } + + if ch.Done || call.embed != nil { + return res, nil + } + } +} + +func (r *ollamaResult) absorb(ch *ollamaChunk, onText func(string) error, onTool func(ollamaToolCall) error) error { + text := ch.Message.Content + ch.Response + if text != "" { + r.Text += text + + if onText != nil { + if err := onText(text); err != nil { + return err + } + } + } + + for _, tc := range ch.Message.ToolCalls { + r.ToolCalls = append(r.ToolCalls, tc) + + if onTool != nil { + if err := onTool(tc); err != nil { + return err + } + } + } + + if len(ch.Embeddings) > 0 { + r.Embeddings = ch.Embeddings + } + + if ch.Done { + r.DoneReason = ch.DoneReason + if r.DoneReason == "" { + r.DoneReason = stopStop + } + + r.PromptTokens, r.EvalTokens = ch.PromptEvalCount, ch.EvalCount + } + + if ch.PromptEvalCount > 0 && r.PromptTokens == 0 { + r.PromptTokens = ch.PromptEvalCount + } + + return nil +} diff --git a/services/bedrockruntime/ollama_config.go b/services/bedrockruntime/ollama_config.go new file mode 100644 index 0000000000..d77644eebb --- /dev/null +++ b/services/bedrockruntime/ollama_config.go @@ -0,0 +1,174 @@ +package bedrockruntime + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" +) + +// Environment variables selecting the inference backend. +const ( + EnvBackend = "GOPHERSTACK_BEDROCK_BACKEND" + EnvOllamaURL = "GOPHERSTACK_BEDROCK_OLLAMA_URL" + EnvModelMap = "GOPHERSTACK_BEDROCK_MODEL_MAP" + + BackendMock = "mock" + BackendOllama = "ollama" + + defaultOllamaURL = "http://localhost:11434" +) + +// ErrInvalidLLMConfig is returned for an unusable backend setting. +var ErrInvalidLLMConfig = errors.New("invalid bedrock inference backend config") + +// LLMConfig selects how Converse and InvokeModel produce completions. +type LLMConfig struct { + // Backend is "mock" (default, deterministic placeholder output) or "ollama". + Backend string + // OllamaURL is the Ollama base URL; empty means http://localhost:11434. + OllamaURL string + // ModelMap is a comma list of "bedrock-id-glob=ollama-model" rules, first match wins. + ModelMap string +} + +// LLMConfigFromEnv reads the GOPHERSTACK_BEDROCK_* variables. +func LLMConfigFromEnv() LLMConfig { + return LLMConfig{ + Backend: os.Getenv(EnvBackend), + OllamaURL: os.Getenv(EnvOllamaURL), + ModelMap: os.Getenv(EnvModelMap), + } +} + +type modelRule struct { + pattern string + target string +} + +type ollamaBackend struct { + client *http.Client + baseURL string + rules []modelRule +} + +func newOllamaBackend(cfg LLMConfig) (*ollamaBackend, error) { + base := strings.TrimRight(strings.TrimSpace(cfg.OllamaURL), "/") + if base == "" { + base = defaultOllamaURL + } + + u, err := url.Parse(base) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("%w: %s must be an http(s) URL, got %q", ErrInvalidLLMConfig, EnvOllamaURL, base) + } + + rules, err := parseModelMap(cfg.ModelMap) + if err != nil { + return nil, err + } + + return &ollamaBackend{baseURL: base, rules: rules, client: httpclient.New(0)}, nil +} + +func parseModelMap(spec string) ([]modelRule, error) { + var rules []modelRule + + for entry := range strings.SplitSeq(spec, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + pattern, target, ok := strings.Cut(entry, "=") + pattern, target = strings.TrimSpace(pattern), strings.TrimSpace(target) + + if !ok || pattern == "" || target == "" { + return nil, fmt.Errorf("%w: %s entry %q must look like pattern=ollama-model", + ErrInvalidLLMConfig, EnvModelMap, entry) + } + + if _, err := path.Match(pattern, ""); err != nil { + return nil, fmt.Errorf("%w: %s pattern %q: %w", ErrInvalidLLMConfig, EnvModelMap, pattern, err) + } + + rules = append(rules, modelRule{pattern: pattern, target: target}) + } + + return rules, nil +} + +// modelCandidates lists the identifiers a rule may match: the id itself, the resource +// part of an ARN, and either without a cross-region inference-profile prefix. +func modelCandidates(modelID string) []string { + id := modelID + if strings.HasPrefix(id, "arn:") { + if i := strings.LastIndex(id, "/"); i >= 0 { + id = id[i+1:] + } + } + + out := []string{modelID} + if id != modelID { + out = append(out, id) + } + + for _, p := range []string{"us-gov.", "us.", "eu.", "apac.", "au.", "ca.", "jp.", "global."} { + if rest, ok := strings.CutPrefix(id, p); ok { + out = append(out, rest) + + break + } + } + + return out +} + +// normalizedModelID is the identifier used to pick a request-body family. +func normalizedModelID(modelID string) string { + c := modelCandidates(modelID) + + return strings.ToLower(c[len(c)-1]) +} + +// resolve returns the Ollama model for a Bedrock model id. +func (o *ollamaBackend) resolve(modelID string) (string, bool) { + cands := modelCandidates(modelID) + + for _, r := range o.rules { + for _, c := range cands { + if ok, _ := path.Match(r.pattern, c); ok { + return r.target, true + } + } + } + + return "", false +} + +// SetLLM selects the inference backend; BackendMock (or empty) restores the default. +func (b *InMemoryBackend) SetLLM(cfg LLMConfig) error { + switch strings.ToLower(strings.TrimSpace(cfg.Backend)) { + case "", BackendMock: + b.llm.Store(nil) + + return nil + case BackendOllama: + o, err := newOllamaBackend(cfg) + if err != nil { + return err + } + + b.llm.Store(o) + + return nil + default: + return fmt.Errorf("%w: %s must be %q or %q, got %q", + ErrInvalidLLMConfig, EnvBackend, BackendMock, BackendOllama, cfg.Backend) + } +} diff --git a/services/bedrockruntime/ollama_converse.go b/services/bedrockruntime/ollama_converse.go new file mode 100644 index 0000000000..69f9b52c7d --- /dev/null +++ b/services/bedrockruntime/ollama_converse.go @@ -0,0 +1,527 @@ +package bedrockruntime + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" +) + +const ( + stopReasonToolUse = "tool_use" + stopReasonMaxTokens = "max_tokens" + ollamaDoneLength = "length" + toolUseIDPrefix = "tooluse_" +) + +// errUnsupportedContent marks Converse content Ollama cannot represent. +var errUnsupportedContent = errors.New("unsupported content block") + +type cvBlock struct { + Text *string `json:"text"` + Image *cvImage `json:"image"` + Document *cvDocument `json:"document"` + ToolUse *cvToolUse `json:"toolUse"` + Result *cvToolResult `json:"toolResult"` +} + +type cvImage struct { + Source struct { + Bytes string `json:"bytes"` + } `json:"source"` +} + +type cvDocument struct { + Format string `json:"format"` + Name string `json:"name"` + Source struct { + Bytes string `json:"bytes"` + Text string `json:"text"` + } `json:"source"` +} + +type cvToolUse struct { + ToolUseID string `json:"toolUseId"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +type cvToolResult struct { + ToolUseID string `json:"toolUseId"` + Content []struct { + Text *string `json:"text"` + JSON json.RawMessage `json:"json"` + } `json:"content"` +} + +type cvMessage struct { + Role string `json:"role"` + Content []cvBlock `json:"content"` +} + +type cvSystemBlock struct { + Text *string `json:"text"` +} + +type cvRequest struct { + InferenceConfig json.RawMessage `json:"inferenceConfig"` + ToolConfig json.RawMessage `json:"toolConfig"` + Messages []cvMessage `json:"messages"` + System []cvSystemBlock `json:"system"` +} + +func textDocumentFormat(f string) bool { + switch f { + case "txt", "md", "csv", "html": + return true + default: + return false + } +} + +func decodeDocumentText(d *cvDocument) (string, error) { + if !textDocumentFormat(d.Format) { + return "", fmt.Errorf("%w: document format %q cannot be sent to ollama (supported: txt, md, csv, html)", + errUnsupportedContent, d.Format) + } + + if d.Source.Text != "" { + return d.Source.Text, nil + } + + raw, err := base64.StdEncoding.DecodeString(d.Source.Bytes) + if err != nil { + return "", fmt.Errorf("%w: document bytes are not base64", errUnsupportedContent) + } + + return string(raw), nil +} + +func toolResultText(r *cvToolResult) string { + parts := make([]string, 0, len(r.Content)) + + for _, c := range r.Content { + switch { + case c.Text != nil: + parts = append(parts, *c.Text) + case len(c.JSON) > 0: + parts = append(parts, string(c.JSON)) + } + } + + return strings.Join(parts, "\n") +} + +// ollamaMessages maps Converse system prompts and turns to Ollama chat messages. +func ollamaMessages(req *cvRequest) ([]ollamaMessage, error) { + var out []ollamaMessage + + var sys []string + + for _, s := range req.System { + if s.Text != nil { + sys = append(sys, *s.Text) + } + } + + if len(sys) > 0 { + out = append(out, ollamaMessage{Role: "system", Content: strings.Join(sys, "\n")}) + } + + toolNames := map[string]string{} + + for _, m := range req.Messages { + var ( + texts []string + images []string + calls []ollamaToolCall + results []ollamaMessage + ) + + for _, b := range m.Content { + switch { + case b.Text != nil: + texts = append(texts, *b.Text) + case b.Image != nil: + images = append(images, b.Image.Source.Bytes) + case b.Document != nil: + t, err := decodeDocumentText(b.Document) + if err != nil { + return nil, err + } + + texts = append(texts, fmt.Sprintf("%s:\n%s", b.Document.Name, t)) + case b.ToolUse != nil: + toolNames[b.ToolUse.ToolUseID] = b.ToolUse.Name + calls = append(calls, ollamaToolCall{ + ID: b.ToolUse.ToolUseID, + Function: ollamaToolFunc{Name: b.ToolUse.Name, Arguments: objectOrEmpty(b.ToolUse.Input)}, + }) + case b.Result != nil: + results = append(results, ollamaMessage{ + Role: "tool", Content: toolResultText(b.Result), ToolName: toolNames[b.Result.ToolUseID], + }) + } + } + + out = append(out, results...) + + if len(texts) > 0 || len(images) > 0 || len(calls) > 0 { + out = append(out, ollamaMessage{ + Role: m.Role, Content: strings.Join(texts, "\n"), Images: images, ToolCalls: calls, + }) + } + } + + return out, nil +} + +func objectOrEmpty(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 || string(raw) == "null" { + return json.RawMessage("{}") + } + + return raw +} + +func ollamaTools(raw json.RawMessage) []ollamaTool { + if len(raw) == 0 { + return nil + } + + var tc struct { + Tools []struct { + ToolSpec *struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema struct { + JSON json.RawMessage `json:"json"` + } `json:"inputSchema"` + } `json:"toolSpec"` + } `json:"tools"` + } + if json.Unmarshal(raw, &tc) != nil { + return nil + } + + var out []ollamaTool + + for _, t := range tc.Tools { + if t.ToolSpec == nil { + continue + } + + out = append(out, ollamaTool{Type: "function", Function: ollamaToolFunc{ + Name: t.ToolSpec.Name, Description: t.ToolSpec.Description, Parameters: t.ToolSpec.InputSchema.JSON, + }}) + } + + return out +} + +func ollamaOptionsFromInference(raw json.RawMessage) *ollamaOptions { + if len(raw) == 0 { + return nil + } + + var ic inferenceConfigBody + if json.Unmarshal(raw, &ic) != nil { + return nil + } + + return &ollamaOptions{NumPredict: ic.MaxTokens, Temperature: ic.Temperature, TopP: ic.TopP, Stop: ic.StopSequences} +} + +func (o *ollamaBackend) converseCall(modelID string, body []byte) (*ollamaCall, error) { + target, ok := o.resolve(modelID) + if !ok { + return nil, errUnmappedModel + } + + var req cvRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("%w: %w", errUnsupportedContent, err) + } + + msgs, err := ollamaMessages(&req) + if err != nil { + return nil, err + } + + return &ollamaCall{chat: &ollamaChatReq{ + Model: target, + Messages: msgs, + Tools: ollamaTools(req.ToolConfig), + Options: ollamaOptionsFromInference(req.InferenceConfig), + }}, nil +} + +// converseStopReason maps an Ollama outcome to types.StopReason. Ollama reports stopStop +// for both end of turn and stop-sequence hits, so stop_sequence is never produced. +func converseStopReason(res *ollamaResult) string { + switch { + case len(res.ToolCalls) > 0: + return stopReasonToolUse + case res.DoneReason == ollamaDoneLength: + return stopReasonMaxTokens + default: + return stopReasonEndTurn + } +} + +func toolUseBlock(tc ollamaToolCall) map[string]any { + id := tc.ID + if id == "" { + id = awsid.New(toolUseIDPrefix) + } + + return map[string]any{"toolUseId": id, keyName: tc.Function.Name, keyInput: objectOrEmpty(tc.Function.Arguments)} +} + +func tokenUsage(res *ollamaResult) map[string]any { + return map[string]any{ + keyInputTokens: res.PromptTokens, + convOutputTokens: res.EvalTokens, + convTotalTokens: res.PromptTokens + res.EvalTokens, + } +} + +func buildOllamaConverseResponse(res *ollamaResult, took time.Duration) map[string]any { + content := []map[string]any{} + if res.Text != "" { + content = append(content, map[string]any{keyText: res.Text}) + } + + for _, tc := range res.ToolCalls { + content = append(content, map[string]any{keyToolUse: toolUseBlock(tc)}) + } + + return map[string]any{ + keyOutput: map[string]any{keyMessage: map[string]any{keyRole: roleAssistant, keyContent: content}}, + convStopReasonKey: converseStopReason(res), + keyUsage: tokenUsage(res), + keyMetrics: map[string]any{keyLatencyMs: took.Milliseconds()}, + } +} + +func (h *Handler) handleOllamaConverse( + c *echo.Context, + o *ollamaBackend, + modelID string, + body []byte, + req *converseRequest, +) error { + call, err := o.converseCall(modelID, body) + if err != nil { + return writeLLMError(c, err) + } + + start := time.Now() + + res, err := o.run(c.Request().Context(), call, nil, nil) + if err != nil { + return writeLLMError(c, err) + } + + resp := buildOllamaConverseResponse(res, time.Since(start)) + echoConverseConfig(resp, req) + + out, err := json.Marshal(resp) + if err != nil { + return c.JSON(http.StatusInternalServerError, errorResponse("InternalServerException", "internal server error")) + } + + h.Backend.RecordInvocation(opConverse, modelID, truncateString(string(body)), truncateString(string(out))) + + return c.JSONBlob(http.StatusOK, out) +} + +// converseStreamer turns Ollama deltas into the documented ConverseStream event order. +type converseStreamer struct { + emit func(eventType string, payload any) + onBegin func() + started bool + textOpen bool + idx int +} + +func (s *converseStreamer) begin() { + if s.started { + return + } + + s.started = true + + if s.onBegin != nil { + s.onBegin() + } + + s.emit("messageStart", map[string]any{keyRole: roleAssistant}) +} + +func (s *converseStreamer) closeText() { + if s.textOpen { + s.emit("contentBlockStop", map[string]any{convContentIdx: s.idx}) + s.idx++ + s.textOpen = false + } +} + +func (s *converseStreamer) text(t string) error { + s.begin() + + if !s.textOpen { + s.emit("contentBlockStart", map[string]any{convContentIdx: s.idx}) + s.textOpen = true + } + + s.emit("contentBlockDelta", map[string]any{convContentIdx: s.idx, keyDelta: map[string]any{keyText: t}}) + + return nil +} + +func (s *converseStreamer) tool(tc ollamaToolCall) error { + s.begin() + s.closeText() + + tu := toolUseBlock(tc) + s.emit("contentBlockStart", map[string]any{ + convContentIdx: s.idx, + "start": map[string]any{keyToolUse: map[string]any{"toolUseId": tu["toolUseId"], keyName: tu[keyName]}}, + }) + s.emit("contentBlockDelta", map[string]any{ + convContentIdx: s.idx, + keyDelta: map[string]any{ + keyToolUse: map[string]any{keyInput: string(objectOrEmpty(tc.Function.Arguments))}, + }, + }) + s.emit("contentBlockStop", map[string]any{convContentIdx: s.idx}) + s.idx++ + + return nil +} + +func (s *converseStreamer) finish(res *ollamaResult, meta map[string]any) { + s.begin() + s.closeText() + s.emit("messageStop", map[string]any{convStopReasonKey: converseStopReason(res)}) + + meta[keyUsage] = tokenUsage(res) + s.emit("metadata", meta) +} + +func frameWriter(c *echo.Context) (func(string, any), func(string, string)) { + write := func(headers [][2]string, payload any) { + data, err := json.Marshal(payload) + if err != nil { + return + } + + _, _ = c.Response().Write(encodeEventStreamMsg(headers, data)) + flushResponse(c.Response()) + } + + emit := func(eventType string, payload any) { + write([][2]string{ + {hdrMessageType, hdrMessageTypeEvent}, + {hdrEventType, eventType}, + {hdrContentType, contentTypeJSON}, + }, payload) + } + exception := func(excType, msg string) { + write([][2]string{ + {hdrMessageType, "exception"}, + {":exception-type", excType}, + {hdrContentType, contentTypeJSON}, + }, map[string]string{keyMessage: msg}) + } + + return emit, exception +} + +func (h *Handler) handleOllamaConverseStream( + c *echo.Context, o *ollamaBackend, modelID string, body []byte, req *converseRequest, +) error { + call, err := o.converseCall(modelID, body) + if err != nil { + return writeLLMError(c, err) + } + + emit, exception := frameWriter(c) + s := &converseStreamer{emit: emit, onBegin: func() { + c.Response().Header().Set("Content-Type", "application/vnd.amazon.eventstream") + c.Response().WriteHeader(http.StatusOK) + }} + start := time.Now() + + res, err := o.run(c.Request().Context(), call, s.text, s.tool) + if err != nil { + if !s.started { + return writeLLMError(c, err) + } + + exception("modelStreamErrorException", err.Error()) + + return nil + } + + took := time.Since(start) + meta := map[string]any{keyMetrics: map[string]any{keyLatencyMs: took.Milliseconds()}} + echoConverseConfig(meta, req) + s.finish(res, meta) + + h.Backend.RecordInvocation(opConverseStream, modelID, truncateString(string(body)), + truncateString(string(mustJSON(buildOllamaConverseResponse(res, took))))) + + return nil +} + +func mustJSON(v any) []byte { + b, _ := json.Marshal(v) + + return b +} + +func llmErrorBody(excType, msg string, extra map[string]any) map[string]any { + m := map[string]any{"__type": excType, keyMessage: msg} + maps.Copy(m, extra) + + return m +} + +// writeLLMError maps backend failures to the exceptions the Bedrock runtime models: +// unreachable -> ServiceUnavailableException (503), model not pulled -> +// ModelNotReadyException (429), unmapped model -> ResourceNotFoundException (404). +func writeLLMError(c *echo.Context, err error) error { + var se *ollamaStatusError + + switch { + case errors.Is(err, errUnmappedModel): + return c.JSON(http.StatusNotFound, errorResponse("ResourceNotFoundException", + "The specified model is not mapped to an Ollama model. Set "+EnvModelMap+".")) + case errors.Is(err, errUnsupportedContent): + return c.JSON(http.StatusBadRequest, errorResponse("ValidationException", err.Error())) + case errors.Is(err, context.Canceled): + return c.JSON(http.StatusServiceUnavailable, errorResponse("ServiceUnavailableException", "request canceled")) + case errors.Is(err, errOllamaUnreachable): + return c.JSON(http.StatusServiceUnavailable, errorResponse("ServiceUnavailableException", err.Error())) + case errors.As(err, &se) && se.status == http.StatusNotFound: + return c.JSON(http.StatusTooManyRequests, errorResponse("ModelNotReadyException", se.msg)) + case errors.As(err, &se) && se.status == http.StatusBadRequest: + return c.JSON(http.StatusBadRequest, errorResponse("ValidationException", se.msg)) + case errors.As(err, &se): + return c.JSON(http.StatusFailedDependency, llmErrorBody("ModelErrorException", se.msg, + map[string]any{"originalStatusCode": se.status})) + default: + return c.JSON(http.StatusInternalServerError, errorResponse("InternalServerException", "internal server error")) + } +} diff --git a/services/bedrockruntime/ollama_invoke.go b/services/bedrockruntime/ollama_invoke.go new file mode 100644 index 0000000000..4bc547f5a9 --- /dev/null +++ b/services/bedrockruntime/ollama_invoke.go @@ -0,0 +1,989 @@ +package bedrockruntime + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" +) + +const ( + keyInvocationMetrics = "amazon-bedrock-invocationMetrics" + cohereComplete = "COMPLETE" + cohereMaxTokens = "MAX_TOKENS" + titanFinish = "FINISH" + titanLength = "LENGTH" + stopLength = "length" + typeToolUse = "tool_use" + typeText = "text" + typeEvent = "type" + keyIndex = "index" + chatTurnPair = 2 +) + +var ( + errMalformed = errors.New("malformed input request") + errMissingKey = errors.New("required key not found") + errImageSource = errors.New("image block requires source") +) + +func missingKey(name string) error { + return malformed(fmt.Errorf("%w: [%s]", errMissingKey, name)) +} + +// invokePlan is one family's translation of an InvokeModel body. +type invokePlan struct { + call *ollamaCall + render func(res *ollamaResult) any + streamer func(modelID string) invokeStreamer +} + +// invokeStreamer yields the family's documented chunk bodies for Ollama deltas. +type invokeStreamer interface { + begin() []any + text(string) []any + tool(ollamaToolCall) []any + end(res *ollamaResult, took time.Duration) []any +} + +type familyBuilder func(target, modelID string, body []byte) (*invokePlan, error) + +func malformed(err error) error { return fmt.Errorf("%w: %w", errMalformed, err) } + +func familyFor(id string) familyBuilder { + switch { + case strings.HasPrefix(id, "anthropic.claude"): + return buildAnthropic + case strings.HasPrefix(id, "amazon.titan-embed-text"): + return buildTitanEmbed + case strings.HasPrefix(id, "amazon.titan-text"): + return buildTitanText + case strings.HasPrefix(id, "amazon.nova-micro"), strings.HasPrefix(id, "amazon.nova-lite"), + strings.HasPrefix(id, "amazon.nova-pro"), strings.HasPrefix(id, "amazon.nova-premier"): + return buildNova + case strings.HasPrefix(id, "meta.llama"): + return buildLlama + case strings.HasPrefix(id, "mistral."): + return buildMistral + case strings.HasPrefix(id, "cohere.embed"): + return buildCohereEmbed + case strings.HasPrefix(id, "cohere.command-r"): + return buildCohereChat + default: + return nil + } +} + +func parseBody(body []byte, v any) error { + if err := json.Unmarshal(body, v); err != nil { + return malformed(err) + } + + return nil +} + +func opts(maxTokens *int, temp, topP *float64, topK *int, stop []string) *ollamaOptions { + return &ollamaOptions{NumPredict: maxTokens, Temperature: temp, TopP: topP, TopK: topK, Stop: stop} +} + +func metricsBlock(res *ollamaResult, took time.Duration) map[string]any { + ms := took.Milliseconds() + + return map[string]any{ + "inputTokenCount": res.PromptTokens, "outputTokenCount": res.EvalTokens, + "invocationLatency": ms, "firstByteLatency": ms, + } +} + +// --- Anthropic --------------------------------------------------------------- + +type anthropicBlock struct { + Source *struct { + Data string `json:"data"` + } `json:"source"` + Type string `json:"type"` + Text string `json:"text"` + ID string `json:"id"` + Name string `json:"name"` + ToolUseID string `json:"tool_use_id"` + Input json.RawMessage `json:"input"` + Content json.RawMessage `json:"content"` +} + +type anthropicRequest struct { + MaxTokens *int `json:"max_tokens"` + MaxTokensToSample *int `json:"max_tokens_to_sample"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + TopK *int `json:"top_k"` + Prompt string `json:"prompt"` + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + System json.RawMessage `json:"system"` + Tools []anthropicTool `json:"tools"` + StopSequences []string `json:"stop_sequences"` +} + +type anthropicTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` +} + +// anthropicBlocks reads a content value that is either a string or an array of blocks. +func anthropicBlocks(raw json.RawMessage) ([]anthropicBlock, error) { + var s string + if json.Unmarshal(raw, &s) == nil { + return []anthropicBlock{{Type: typeText, Text: s}}, nil + } + + var blocks []anthropicBlock + if err := json.Unmarshal(raw, &blocks); err != nil { + return nil, malformed(err) + } + + return blocks, nil +} + +func anthropicToCv(role string, raw json.RawMessage) (cvMessage, error) { + blocks, err := anthropicBlocks(raw) + if err != nil { + return cvMessage{}, err + } + + m := cvMessage{Role: role} + + for _, b := range blocks { + switch b.Type { + case typeText: + t := b.Text + m.Content = append(m.Content, cvBlock{Text: &t}) + case "image": + if b.Source == nil { + return m, malformed(errImageSource) + } + + img := &cvImage{} + img.Source.Bytes = b.Source.Data + m.Content = append(m.Content, cvBlock{Image: img}) + case typeToolUse: + m.Content = append(m.Content, cvBlock{ToolUse: &cvToolUse{ToolUseID: b.ID, Name: b.Name, Input: b.Input}}) + case "tool_result": + inner, ierr := anthropicBlocks(b.Content) + if ierr != nil && len(b.Content) > 0 { + return m, ierr + } + + res := &cvToolResult{ToolUseID: b.ToolUseID} + + for _, ib := range inner { + t := ib.Text + res.Content = append(res.Content, struct { + Text *string `json:"text"` + JSON json.RawMessage `json:"json"` + }{Text: &t}) + } + + m.Content = append(m.Content, cvBlock{Result: res}) + default: + return m, fmt.Errorf("%w: content block type %q", errUnsupportedContent, b.Type) + } + } + + return m, nil +} + +func anthropicSystem(raw json.RawMessage) ([]cvSystemBlock, error) { + if len(raw) == 0 { + return nil, nil + } + + blocks, err := anthropicBlocks(raw) + if err != nil { + return nil, err + } + + out := make([]cvSystemBlock, 0, len(blocks)) + + for _, b := range blocks { + t := b.Text + out = append(out, cvSystemBlock{Text: &t}) + } + + return out, nil +} + +func buildAnthropic(target, modelID string, body []byte) (*invokePlan, error) { + var req anthropicRequest + if err := parseBody(body, &req); err != nil { + return nil, err + } + + if len(req.Messages) == 0 && req.Prompt != "" { + return buildAnthropicCompletion(target, &req), nil + } + + cv := &cvRequest{} + + sys, err := anthropicSystem(req.System) + if err != nil { + return nil, err + } + + cv.System = sys + + for _, m := range req.Messages { + cm, merr := anthropicToCv(m.Role, m.Content) + if merr != nil { + return nil, merr + } + + cv.Messages = append(cv.Messages, cm) + } + + msgs, err := ollamaMessages(cv) + if err != nil { + return nil, err + } + + var tools []ollamaTool + for _, t := range req.Tools { + tools = append(tools, ollamaTool{Type: "function", Function: ollamaToolFunc{ + Name: t.Name, Description: t.Description, Parameters: t.InputSchema, + }}) + } + + call := &ollamaCall{chat: &ollamaChatReq{ + Model: target, Messages: msgs, Tools: tools, + Options: opts(req.MaxTokens, req.Temperature, req.TopP, req.TopK, req.StopSequences), + }} + + return &invokePlan{ + call: call, + render: func(res *ollamaResult) any { + return map[string]any{ + "id": awsid.New("msg_"), typeEvent: keyMessage, keyRole: roleAssistant, keyModel: modelID, + keyContent: anthropicContent(res), keyStopReason: anthropicStopReason(res), keyStopSequence: nil, + keyUsage: map[string]any{keyInputCountSnake: res.PromptTokens, keyOutputCountSnake: res.EvalTokens}, + } + }, + streamer: func(id string) invokeStreamer { return &anthropicStreamer{modelID: id} }, + }, nil +} + +func buildAnthropicCompletion(target string, req *anthropicRequest) *invokePlan { + return &invokePlan{ + call: &ollamaCall{gen: &ollamaGenerateReq{ + Model: target, Prompt: req.Prompt, + Options: opts(req.MaxTokensToSample, req.Temperature, req.TopP, req.TopK, req.StopSequences), + }}, + render: func(res *ollamaResult) any { + out := map[string]any{keyCompletion: res.Text, keyStopReason: keyStopSequence, stopStop: "\n\nHuman:"} + if res.DoneReason == stopLength { + out[keyStopReason], out[stopStop] = stopReasonMaxTokens, nil + } + + return out + }, + } +} + +func anthropicStopReason(res *ollamaResult) string { + switch { + case len(res.ToolCalls) > 0: + return typeToolUse + case res.DoneReason == stopLength: + return stopReasonMaxTokens + default: + return stopReasonEndTurn + } +} + +func anthropicToolUse(tc ollamaToolCall) map[string]any { + tu := toolUseBlock(tc) + + return map[string]any{typeEvent: typeToolUse, "id": tu["toolUseId"], keyName: tu[keyName], keyInput: tu[keyInput]} +} + +func anthropicContent(res *ollamaResult) []map[string]any { + out := []map[string]any{} + if res.Text != "" { + out = append(out, map[string]any{typeEvent: typeText, keyText: res.Text}) + } + + for _, tc := range res.ToolCalls { + out = append(out, anthropicToolUse(tc)) + } + + return out +} + +type anthropicStreamer struct { + modelID string + idx int + textOpen bool + started bool +} + +func (s *anthropicStreamer) begin() []any { + if s.started { + return nil + } + + s.started = true + + return []any{map[string]any{ + typeEvent: "message_start", + keyMessage: map[string]any{ + "id": awsid.New("msg_"), typeEvent: keyMessage, keyRole: roleAssistant, keyModel: s.modelID, + keyContent: []any{}, keyStopReason: nil, keyStopSequence: nil, + keyUsage: map[string]any{keyInputCountSnake: 0, keyOutputCountSnake: 0}, + }, + }} +} + +func (s *anthropicStreamer) closeText() []any { + if !s.textOpen { + return nil + } + + ev := map[string]any{typeEvent: "content_block_stop", keyIndex: s.idx} + s.idx++ + s.textOpen = false + + return []any{ev} +} + +func (s *anthropicStreamer) text(t string) []any { + out := s.begin() + + if !s.textOpen { + s.textOpen = true + out = append(out, map[string]any{ + typeEvent: "content_block_start", keyIndex: s.idx, + "content_block": map[string]any{typeEvent: typeText, keyText: ""}, + }) + } + + return append(out, map[string]any{ + typeEvent: "content_block_delta", keyIndex: s.idx, + keyDelta: map[string]any{typeEvent: "text_delta", keyText: t}, + }) +} + +func (s *anthropicStreamer) tool(tc ollamaToolCall) []any { + out := append(s.begin(), s.closeText()...) + tu := anthropicToolUse(tc) + input := tu[keyInput] + tu[keyInput] = map[string]any{} + + out = append(out, + map[string]any{typeEvent: "content_block_start", keyIndex: s.idx, "content_block": tu}, + map[string]any{ + typeEvent: "content_block_delta", keyIndex: s.idx, + keyDelta: map[string]any{typeEvent: "input_json_delta", "partial_json": string(mustJSON(input))}, + }, + map[string]any{typeEvent: "content_block_stop", keyIndex: s.idx}, + ) + s.idx++ + + return out +} + +func (s *anthropicStreamer) end(res *ollamaResult, took time.Duration) []any { + out := append(s.begin(), s.closeText()...) + + return append(out, + map[string]any{ + typeEvent: "message_delta", + keyDelta: map[string]any{keyStopReason: anthropicStopReason(res), keyStopSequence: nil}, + keyUsage: map[string]any{keyOutputCountSnake: res.EvalTokens}, + }, + map[string]any{typeEvent: "message_stop", keyInvocationMetrics: metricsBlock(res, took)}, + ) +} + +// --- Amazon Titan ------------------------------------------------------------ + +func buildTitanText(target, _ string, body []byte) (*invokePlan, error) { + var req struct { + Config *struct { + MaxTokenCount *int `json:"maxTokenCount"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"topP"` + StopSequences []string `json:"stopSequences"` + } `json:"textGenerationConfig"` + InputText string `json:"inputText"` + } + if err := parseBody(body, &req); err != nil { + return nil, err + } + + if req.InputText == "" { + return nil, missingKey("inputText") + } + + o := &ollamaOptions{} + if c := req.Config; c != nil { + o = opts(c.MaxTokenCount, c.Temperature, c.TopP, nil, c.StopSequences) + } + + return &invokePlan{ + call: &ollamaCall{gen: &ollamaGenerateReq{Model: target, Prompt: req.InputText, Options: o}}, + render: func(res *ollamaResult) any { + return map[string]any{ + keyInputTextCount: res.PromptTokens, + keyResults: []map[string]any{{ + "tokenCount": res.EvalTokens, keyOutputText: res.Text, keyCompletionReason: titanReason(res), + }}, + } + }, + streamer: func(string) invokeStreamer { return &titanStreamer{} }, + }, nil +} + +func titanReason(res *ollamaResult) string { + if res.DoneReason == stopLength { + return titanLength + } + + return titanFinish +} + +type titanStreamer struct{ n int } + +func (s *titanStreamer) begin() []any { return nil } + +func (s *titanStreamer) text(t string) []any { + s.n++ + + return []any{map[string]any{keyOutputText: t, keyIndex: s.n - 1, keyCompletionReason: nil}} +} + +func (*titanStreamer) tool(ollamaToolCall) []any { return nil } + +func (s *titanStreamer) end(res *ollamaResult, took time.Duration) []any { + return []any{map[string]any{ + keyOutputText: "", keyIndex: s.n, "totalOutputTextTokenCount": res.EvalTokens, + keyCompletionReason: titanReason(res), keyInputTextCount: res.PromptTokens, + keyInvocationMetrics: metricsBlock(res, took), + }} +} + +func buildTitanEmbed(target, modelID string, body []byte) (*invokePlan, error) { + var req struct { + Normalize *bool `json:"normalize"` + Dimensions *int `json:"dimensions"` + InputText string `json:"inputText"` + EmbeddingTypes []string `json:"embeddingTypes"` + } + if err := parseBody(body, &req); err != nil { + return nil, err + } + + if req.InputText == "" { + return nil, missingKey("inputText") + } + + for _, t := range req.EmbeddingTypes { + if t != keyFloat { + return nil, fmt.Errorf("%w: embeddingTypes %q is not supported by the ollama backend (float only)", + errUnsupportedContent, t) + } + } + + v2 := strings.Contains(modelID, "-v2") + e := &ollamaEmbedReq{Model: target, Input: []string{req.InputText}} + + if v2 { + e.Dimensions = req.Dimensions + } + + return &invokePlan{ + call: &ollamaCall{embed: e}, + render: func(res *ollamaResult) any { + out := map[string]any{"embedding": firstEmbedding(res), keyInputTextCount: res.PromptTokens} + if v2 { + out["embeddingsByType"] = map[string]any{keyFloat: firstEmbedding(res)} + } + + return out + }, + }, nil +} + +func firstEmbedding(res *ollamaResult) []float64 { + if len(res.Embeddings) == 0 { + return []float64{} + } + + return res.Embeddings[0] +} + +// --- Amazon Nova --------------------------------------------------------------- + +func buildNova(target, _ string, body []byte) (*invokePlan, error) { + var req struct { + Inference *struct { + MaxNewTokens *int `json:"max_new_tokens"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + TopK *int `json:"top_k"` + StopSequences []string `json:"stopSequences"` + } `json:"inferenceConfig"` + cvRequest + } + if err := parseBody(body, &req); err != nil { + return nil, err + } + + msgs, err := ollamaMessages(&req.cvRequest) + if err != nil { + return nil, err + } + + o := &ollamaOptions{} + if c := req.Inference; c != nil { + o = opts(c.MaxNewTokens, c.Temperature, c.TopP, c.TopK, c.StopSequences) + } + + return &invokePlan{ + call: &ollamaCall{chat: &ollamaChatReq{ + Model: target, Messages: msgs, Tools: ollamaTools(req.ToolConfig), Options: o, + }}, + render: func(res *ollamaResult) any { + out := buildOllamaConverseResponse(res, 0) + delete(out, keyMetrics) + + return out + }, + streamer: func(string) invokeStreamer { return &novaStreamer{} }, + }, nil +} + +// novaStreamer wraps ConverseStream-shaped events, which is what Nova emits for InvokeModel streams. +type novaStreamer struct { + conv converseStreamer + buf []any +} + +func (s *novaStreamer) init() { + if s.conv.emit == nil { + s.conv.emit = func(eventType string, payload any) { s.buf = append(s.buf, map[string]any{eventType: payload}) } + } +} + +func (s *novaStreamer) drain() []any { + out := s.buf + s.buf = nil + + return out +} + +func (s *novaStreamer) begin() []any { + s.init() + s.conv.begin() + + return s.drain() +} + +func (s *novaStreamer) text(t string) []any { + s.init() + _ = s.conv.text(t) + + return s.drain() +} + +func (s *novaStreamer) tool(tc ollamaToolCall) []any { + s.init() + _ = s.conv.tool(tc) + + return s.drain() +} + +func (s *novaStreamer) end(res *ollamaResult, took time.Duration) []any { + s.init() + s.conv.finish(res, map[string]any{keyInvocationMetrics: metricsBlock(res, took)}) + + return s.drain() +} + +// --- Meta Llama / Mistral ------------------------------------------------------ + +func buildLlama(target, _ string, body []byte) (*invokePlan, error) { + var req struct { + MaxGenLen *int `json:"max_gen_len"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + Prompt string `json:"prompt"` + } + if err := parseBody(body, &req); err != nil { + return nil, err + } + + if req.Prompt == "" { + return nil, missingKey("prompt") + } + + reason := func(res *ollamaResult) string { + if res.DoneReason == stopLength { + return stopLength + } + + return stopStop + } + + return &invokePlan{ + call: &ollamaCall{gen: &ollamaGenerateReq{ + Model: target, Prompt: req.Prompt, Options: opts(req.MaxGenLen, req.Temperature, req.TopP, nil, nil), + }}, + render: func(res *ollamaResult) any { + return map[string]any{ + keyGeneration: res.Text, keyPromptCount: res.PromptTokens, + keyGenCount: res.EvalTokens, keyStopReason: reason(res), + } + }, + streamer: func(string) invokeStreamer { + return &simpleStreamer{ + delta: func(t string) any { + return map[string]any{ + keyGeneration: t, + keyPromptCount: nil, + keyGenCount: nil, + keyStopReason: nil, + } + }, + last: func(res *ollamaResult, took time.Duration) any { + return map[string]any{ + keyGeneration: "", keyPromptCount: res.PromptTokens, + keyGenCount: res.EvalTokens, keyStopReason: reason(res), + keyInvocationMetrics: metricsBlock(res, took), + } + }, + } + }, + }, nil +} + +func buildMistral(target, _ string, body []byte) (*invokePlan, error) { + var req struct { + MaxTokens *int `json:"max_tokens"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + TopK *int `json:"top_k"` + Prompt string `json:"prompt"` + Stop []string `json:"stop"` + } + if err := parseBody(body, &req); err != nil { + return nil, err + } + + if req.Prompt == "" { + return nil, missingKey("prompt") + } + + reason := func(res *ollamaResult) string { + if res.DoneReason == stopLength { + return stopLength + } + + return stopStop + } + output := func(t string, stop any) any { + return map[string]any{keyOutputs: []map[string]any{{keyText: t, keyStopReason: stop}}} + } + + return &invokePlan{ + call: &ollamaCall{gen: &ollamaGenerateReq{ + Model: target, Prompt: req.Prompt, + Options: opts(req.MaxTokens, req.Temperature, req.TopP, req.TopK, req.Stop), + }}, + render: func(res *ollamaResult) any { return output(res.Text, reason(res)) }, + streamer: func(string) invokeStreamer { + return &simpleStreamer{ + delta: func(t string) any { return output(t, nil) }, + last: func(res *ollamaResult, took time.Duration) any { + o, _ := output("", reason(res)).(map[string]any) + o[keyInvocationMetrics] = metricsBlock(res, took) + + return o + }, + } + }, + }, nil +} + +// simpleStreamer emits one chunk per text delta plus a closing chunk. +type simpleStreamer struct { + delta func(string) any + last func(*ollamaResult, time.Duration) any +} + +func (*simpleStreamer) begin() []any { return nil } +func (s *simpleStreamer) text(t string) []any { return []any{s.delta(t)} } +func (*simpleStreamer) tool(ollamaToolCall) []any { return nil } +func (s *simpleStreamer) end(r *ollamaResult, d time.Duration) []any { return []any{s.last(r, d)} } + +// --- Cohere ---------------------------------------------------------------------- + +func buildCohereEmbed(target, _ string, body []byte) (*invokePlan, error) { + var req struct { + Texts []string `json:"texts"` + Images []string `json:"images"` + EmbeddingTypes []string `json:"embedding_types"` + } + if err := parseBody(body, &req); err != nil { + return nil, err + } + + if len(req.Images) > 0 { + return nil, fmt.Errorf("%w: image embeddings are not supported by the ollama backend", errUnsupportedContent) + } + + if len(req.Texts) == 0 { + return nil, missingKey("texts") + } + + for _, t := range req.EmbeddingTypes { + if t != keyFloat { + return nil, fmt.Errorf("%w: embedding_types %q is not supported by the ollama backend (float only)", + errUnsupportedContent, t) + } + } + + byType := len(req.EmbeddingTypes) > 0 + + return &invokePlan{ + call: &ollamaCall{embed: &ollamaEmbedReq{Model: target, Input: req.Texts}}, + render: func(res *ollamaResult) any { + out := map[string]any{"id": awsid.New(""), "texts": req.Texts} + if byType { + out["response_type"] = "embeddings_by_type" + out["embeddings"] = map[string]any{keyFloat: res.Embeddings} + } else { + out["response_type"] = "embeddings_floats" + out["embeddings"] = res.Embeddings + } + + return out + }, + }, nil +} + +func buildCohereChat(target, _ string, body []byte) (*invokePlan, error) { + var req struct { + MaxTokens *int `json:"max_tokens"` + Temperature *float64 `json:"temperature"` + P *float64 `json:"p"` + K *float64 `json:"k"` + History []struct { + Role string `json:"role"` + Message string `json:"message"` + } `json:"chat_history"` + Message string `json:"message"` + Preamble string `json:"preamble"` + StopSequences []string `json:"stop_sequences"` + } + if err := parseBody(body, &req); err != nil { + return nil, err + } + + if req.Message == "" { + return nil, missingKey("message") + } + + var msgs []ollamaMessage + if req.Preamble != "" { + msgs = append(msgs, ollamaMessage{Role: "system", Content: req.Preamble}) + } + + for _, h := range req.History { + role := "user" + if strings.EqualFold(h.Role, "CHATBOT") { + role = roleAssistant + } + + msgs = append(msgs, ollamaMessage{Role: role, Content: h.Message}) + } + + msgs = append(msgs, ollamaMessage{Role: "user", Content: req.Message}) + + var topK *int + if req.K != nil { + k := int(*req.K) + topK = &k + } + + history := func(res *ollamaResult) []map[string]string { + h := make([]map[string]string, 0, len(req.History)+chatTurnPair) + for _, m := range req.History { + h = append(h, map[string]string{keyRole: strings.ToUpper(m.Role), keyMessage: m.Message}) + } + + return append(h, + map[string]string{keyRole: "USER", keyMessage: req.Message}, + map[string]string{keyRole: "CHATBOT", keyMessage: res.Text}) + } + finish := func(res *ollamaResult) string { + if res.DoneReason == stopLength { + return cohereMaxTokens + } + + return cohereComplete + } + final := func(res *ollamaResult) map[string]any { + return map[string]any{ + "response_id": awsid.New(""), "text": res.Text, "generation_id": awsid.New(""), + "chat_history": history(res), keyFinishReason: finish(res), + } + } + + return &invokePlan{ + call: &ollamaCall{chat: &ollamaChatReq{ + Model: target, + Messages: msgs, + Options: opts(req.MaxTokens, req.Temperature, req.P, topK, req.StopSequences), + }}, + render: func(res *ollamaResult) any { return final(res) }, + streamer: func(string) invokeStreamer { + return &simpleStreamer{ + delta: func(t string) any { + return map[string]any{"is_finished": false, "event_type": "text-generation", keyText: t} + }, + last: func(res *ollamaResult, _ time.Duration) any { + return map[string]any{ + "is_finished": true, "event_type": "stream-end", + keyFinishReason: finish(res), "response": final(res), + } + }, + } + }, + }, nil +} + +// --- handlers ---------------------------------------------------------------------- + +func (o *ollamaBackend) invokePlanFor(modelID string, body []byte) (*invokePlan, error) { + target, ok := o.resolve(modelID) + if !ok { + return nil, errUnmappedModel + } + + build := familyFor(normalizedModelID(modelID)) + if build == nil { + return nil, fmt.Errorf("%w: model %q is not supported by the ollama backend", errUnsupportedContent, modelID) + } + + return build(target, modelID, body) +} + +func writeInvokeError(c *echo.Context, err error) error { + if errors.Is(err, errMalformed) { + return c.JSON(http.StatusBadRequest, errorResponse("ValidationException", + "Malformed input request: "+strings.TrimPrefix(err.Error(), errMalformed.Error()+": ")+ + ", please reformat your input and try again.")) + } + + return writeLLMError(c, err) +} + +func (h *Handler) handleOllamaInvoke(c *echo.Context, o *ollamaBackend, modelID string, body []byte) error { + plan, err := o.invokePlanFor(modelID, body) + if err != nil { + return writeInvokeError(c, err) + } + + res, err := o.run(c.Request().Context(), plan.call, nil, nil) + if err != nil { + return writeInvokeError(c, err) + } + + out, err := json.Marshal(plan.render(res)) + if err != nil { + return c.JSON(http.StatusInternalServerError, errorResponse("InternalServerException", "internal server error")) + } + + h.Backend.RecordInvocation(opInvokeModel, modelID, truncateString(string(body)), truncateString(string(out))) + + c.Response().Header().Set("Content-Type", resolveResponseContentType(c.Request())) + c.Response().Header().Set(hdrBedrockInputTokenCount, strconv.Itoa(res.PromptTokens)) + c.Response().Header().Set(hdrBedrockOutputTokenCount, strconv.Itoa(res.EvalTokens)) + echoPerformanceConfigLatency(c) + + return c.JSONBlob(http.StatusOK, out) +} + +func (h *Handler) handleOllamaInvokeStream(c *echo.Context, o *ollamaBackend, modelID string, body []byte) error { + plan, err := o.invokePlanFor(modelID, body) + if err != nil { + return writeInvokeError(c, err) + } + + if plan.streamer == nil { + return c.JSON(http.StatusBadRequest, errorResponse("ValidationException", + "The model does not support response streaming for this request.")) + } + + st := plan.streamer(modelID) + emit, exception := frameWriter(c) + started := false + + send := func(chunks []any) { + if len(chunks) == 0 { + return + } + + if !started { + started = true + + c.Response().Header().Set("Content-Type", "application/vnd.amazon.eventstream") + c.Response().Header().Set(hdrBedrockContentType, resolveStreamResponseContentType(c.Request())) + echoPerformanceConfigLatency(c) + c.Response().WriteHeader(http.StatusOK) + } + + for _, ch := range chunks { + emit("chunk", json.RawMessage(modelResponsePayloadPart(mustJSON(ch)))) + } + } + + start := time.Now() + + res, err := o.run(c.Request().Context(), plan.call, + func(t string) error { + send(append(st.begin(), st.text(t)...)) + + return nil + }, + func(tc ollamaToolCall) error { + send(append(st.begin(), st.tool(tc)...)) + + return nil + }, + ) + if err != nil { + if !started { + return writeInvokeError(c, err) + } + + exception("modelStreamErrorException", err.Error()) + + return nil + } + + send(append(st.begin(), st.end(res, time.Since(start))...)) + + h.Backend.RecordInvocation(opInvokeModelWithResponseStream, modelID, + truncateString(string(body)), truncateString(string(mustJSON(plan.render(res))))) + + return nil +} diff --git a/services/bedrockruntime/provider.go b/services/bedrockruntime/provider.go index 3d527f2022..ce54b4e8ae 100644 --- a/services/bedrockruntime/provider.go +++ b/services/bedrockruntime/provider.go @@ -34,6 +34,10 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { } backend := NewInMemoryBackendWithContext(svcCtx, accountID, region) + if err := backend.SetLLM(LLMConfigFromEnv()); err != nil { + return nil, err + } + wireGuardrailLookup(ctx, backend) handler := NewHandler(backend) handler.EnableRegions() diff --git a/services/bedrockruntime/regions.go b/services/bedrockruntime/regions.go index 7e07f230f0..40b7604d0f 100644 --- a/services/bedrockruntime/regions.go +++ b/services/bedrockruntime/regions.go @@ -12,6 +12,7 @@ func (h *Handler) EnableRegions() { h.peers = regionpeers.New(home.region, func(region string) *Handler { pb := NewInMemoryBackendWithContext(home.svcCtx, home.accountID, region) pb.inheritGuardrailLookup(home) + pb.llm.Store(home.llm.Load()) p := NewHandler(pb) if ctx := h.workerCtx.Load(); ctx != nil { diff --git a/services/bedrockruntime/store.go b/services/bedrockruntime/store.go index f4b33cde4f..8329fdb306 100644 --- a/services/bedrockruntime/store.go +++ b/services/bedrockruntime/store.go @@ -3,6 +3,7 @@ package bedrockruntime import ( "context" "maps" + "sync/atomic" "time" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" @@ -20,6 +21,7 @@ type InMemoryBackend struct { tokenIndex map[string]string accountID string region string + llm atomic.Pointer[ollamaBackend] invocations invocationRing asyncInvokeCounter int } diff --git a/services/bedrockruntime/wire_keys.go b/services/bedrockruntime/wire_keys.go new file mode 100644 index 0000000000..f4d14a983f --- /dev/null +++ b/services/bedrockruntime/wire_keys.go @@ -0,0 +1,27 @@ +package bedrockruntime + +// JSON member names and enum values shared by the mock and Ollama paths. +const ( + keyOutput = "output" + keyMetrics = "metrics" + keyLatencyMs = "latencyMs" + keyDelta = "delta" + keyResults = "results" + keyOutputs = "outputs" + keyStopSequence = "stop_sequence" + keyInputCountSnake = "input_tokens" + keyOutputCountSnake = "output_tokens" + keyCompletion = "completion" + keyCompletionReason = "completionReason" + keyInputTextCount = "inputTextTokenCount" + keyGeneration = "generation" + keyPromptCount = "prompt_token_count" + keyGenCount = "generation_token_count" + keyFinishReason = "finish_reason" + keyInput = "input" + keyToolUse = "toolUse" + keyOutputText = "outputText" + keyName = "name" + keyFloat = "float" + stopStop = "stop" +) diff --git a/services/ce/iam_enforcement_test.go b/services/ce/iam_enforcement_test.go index e811b1654d..0b72c45a2d 100644 --- a/services/ce/iam_enforcement_test.go +++ b/services/ce/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/ce" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -170,7 +171,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ce/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cleanrooms/iam_enforcement_test.go b/services/cleanrooms/iam_enforcement_test.go index ec73ea183c..53a9cc955b 100644 --- a/services/cleanrooms/iam_enforcement_test.go +++ b/services/cleanrooms/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cleanrooms" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -174,7 +175,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cleanrooms/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudcontrol/iam_enforcement_test.go b/services/cloudcontrol/iam_enforcement_test.go index 325eeed36e..e9d685db42 100644 --- a/services/cloudcontrol/iam_enforcement_test.go +++ b/services/cloudcontrol/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudcontrol" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cloudcontrol/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudformation/PARITY.md b/services/cloudformation/PARITY.md index e8441bc339..09cab579ec 100644 --- a/services/cloudformation/PARITY.md +++ b/services/cloudformation/PARITY.md @@ -283,7 +283,7 @@ structural_gaps: - "changeset_diff.go requiresRecreation() covers a curated subset of resource types' replacement-forcing properties: CloudFormation resource property schemas are not part of aws-sdk-go-v2, so the full set cannot be derived (gopherstack-e5h)." - "ListResourceScanRelatedResources always returns an empty list: no cross-resource relationship graph is computed for a scan, so MaxResults/Resources have nothing to page or seed from." items_still_open: - - "Resource-type strictness is by service namespace only: an AWS::Svc::Res whose Svc is unknown to the spec fixture and the provisioner fails with \"Template format error: Unrecognized resource types: [X]\" (cfn_resource_services.json, generated by cmd/cfnattrgen), but an unmodelled resource of a known service still falls through to the stub path because the committed spec fixture lists types with attributes only. Unknown/mistyped properties and wrong property types are not rejected: no property schema is vendored (resource_type_schemas.go is trimmed; refreshing the full CloudFormationResourceSpecification needs network). Only the required-property set for ~21 certain types is enforced (cfn_required_properties.json); Rules AWS-specific parameter types resolve only for AWS::EC2::VPC/Subnet/SecurityGroup::Id (Fn::RefAll/ValueOf/ValueOfAll attributes per the Rule functions page: DefaultNetworkAcl, DefaultSecurityGroup, AvailabilityZone, VpcId, Tags.); other AWS-specific types yield empty lists. The Parameter-constraint, Rules-assertion and Unrecognized-parameter-type wordings are best-effort recollections of CloudFormation text, not SDK-verifiable." + - "Resource-type strictness is by service namespace only: an AWS::Svc::Res whose Svc is unknown to the spec fixture and the provisioner fails with \"Template format error: Unrecognized resource types: [X]\" (cfn_resource_services.json, generated by cmd/cfnattrgen), but an unmodelled resource of a known service still falls through to the stub path because the committed spec fixture lists types with attributes only. Property schemas are checked only at the primitive level (nested property types are not walked) and the resource spec marks required keys per legacy spec, so registry-only conditional requirements are not modeled; Rules AWS-specific parameter types resolve only for AWS::EC2::VPC/Subnet/SecurityGroup::Id (Fn::RefAll/ValueOf/ValueOfAll attributes per the Rule functions page: DefaultNetworkAcl, DefaultSecurityGroup, AvailabilityZone, VpcId, Tags.); other AWS-specific types yield empty lists. The Parameter-constraint, Rules-assertion and Unrecognized-parameter-type wordings are best-effort recollections of CloudFormation text, not SDK-verifiable." - "Async stack provisioning is opt-in (GOPHERSTACK_CFN_PROVISIONING_DELAY / SetProvisioningDelay; default stays synchronous): when on, DescribeStacks shows CREATE_IN_PROGRESS then CREATE_COMPLETE, and UPDATE_IN_PROGRESS then UPDATE_COMPLETE_CLEANUP_IN_PROGRESS then UPDATE_COMPLETE. Resources are still created in one step when the delayed step runs (no per-resource progress), failures go straight to their terminal status without a ROLLBACK_IN_PROGRESS interval, and ExecuteChangeSet, nested stacks, StackSet instances and DeleteStack stay synchronous." - "Stack policy: Replacement Conditionally is treated as Update:Replace (deliberate, not an AWS-documented rule), and AWS's separate logical-ID/resource-type default-deny note is not modeled; NotAction/NotResource follow plain inversion (gopherstack-cqy3)." - "No nested-stack change-set or public-extension version machinery exists, so these stay unmodeled: CreateChangeSet IncludeNestedStacks (no nested-stack diff machinery), UpdateStack RetainExceptOnCreate, RollbackStack RoleARN, ActivateType MajorVersion/VersionBump/TypeNameAlias (no public extension version catalog) (gopherstack-xhu2t)." @@ -294,6 +294,10 @@ leaks: {status: clean, note: "no goroutines/janitors/tickers introduced this pas ## Notes +### 2026-10-31: property validation from the resource spec (gopherstack-49qct.37) + +`cmd/cfnpropgen` (`task cfn-props-gen SPEC=...`) now also emits `cfn_required_properties.json` from the spec's `Required` flags for all 428 provisionable types (two registry-vs-spec overrides live in the generator). `--cloudformation-validate-properties` (default on, `--no-...` to disable) gates the required/unknown/wrong-type checks at CreateStack/UpdateStack; failures roll the stack back. Fixtures that omitted genuinely required properties (EKS ResourcesVpcConfig, Neptune/DocDB parameter group Parameters, ElastiCache GlobalReplicationGroup Members, CodeDeploy ServiceRoleArn, SageMaker Domain DefaultUserSettings) were corrected. Tests: `TestCreateStack_PropertyValidationToggle`, `TestCloudFormationValidatePropertiesFlag`. + ### 2026-10-25: Rules functions, async stack status, SAM state machine events Rules: Fn::RefAll, Fn::ValueOf and Fn::ValueOfAll evaluate against the EC2 backend for AWS::EC2::VPC::Id, AWS::EC2::Subnet::Id and AWS::EC2::SecurityGroup::Id (also List<...>); untagged resources are skipped by Tags. as the Rule functions page describes (`rules.go`). Async provisioning (`stack_async.go`): the CREATE/UPDATE steps are deferred to a scheduler (timer or injected), the in-flight op is persisted and settled on Restore. DescribeStack/ListAll return copies so readers do not race the deferred step. SAM: Api event RequestParameters (swagger `parameters`, `cacheKeyParameters` when Caching), TimeoutInMillis (50-29000, `timeoutInMillis`), Auth.ApiKeyRequired (`api_key` security scheme, also an Api-level default); StateMachine Events generate `` plus `Role` (`StartExecutionPolicy`) as in the aws/serverless-application-model translator fixtures state_machine_with_cwe, _schedule_role, _schedule_dlq_retry_policy, _implicit_api, _api_single. AWS::Events::Rule/Archive EventPattern given as a JSON object is now marshalled instead of rejected. Tests: `rules_param_types_test.go`, `stack_async_test.go`, `sam_statemachine_events_test.go`. diff --git a/services/cloudformation/README.md b/services/cloudformation/README.md index a2c252e67c..619fca6ce3 100644 --- a/services/cloudformation/README.md +++ b/services/cloudformation/README.md @@ -16,7 +16,7 @@ ### Known gaps -- Resource-type strictness is by service namespace only: an AWS::Svc::Res whose Svc is unknown to the spec fixture and the provisioner fails with "Template format error: Unrecognized resource types: [X]" (cfn_resource_services.json, generated by cmd/cfnattrgen), but an unmodelled resource of a known service still falls through to the stub path because the committed spec fixture lists types with attributes only. Unknown/mistyped properties and wrong property types are not rejected: no property schema is vendored (resource_type_schemas.go is trimmed; refreshing the full CloudFormationResourceSpecification needs network). Only the required-property set for ~21 certain types is enforced (cfn_required_properties.json); Rules AWS-specific parameter types resolve only for AWS::EC2::VPC/Subnet/SecurityGroup::Id (Fn::RefAll/ValueOf/ValueOfAll attributes per the Rule functions page: DefaultNetworkAcl, DefaultSecurityGroup, AvailabilityZone, VpcId, Tags.); other AWS-specific types yield empty lists. The Parameter-constraint, Rules-assertion and Unrecognized-parameter-type wordings are best-effort recollections of CloudFormation text, not SDK-verifiable. +- Resource-type strictness is by service namespace only: an AWS::Svc::Res whose Svc is unknown to the spec fixture and the provisioner fails with "Template format error: Unrecognized resource types: [X]" (cfn_resource_services.json, generated by cmd/cfnattrgen), but an unmodelled resource of a known service still falls through to the stub path because the committed spec fixture lists types with attributes only. Property schemas are checked only at the primitive level (nested property types are not walked) and the resource spec marks required keys per legacy spec, so registry-only conditional requirements are not modeled; Rules AWS-specific parameter types resolve only for AWS::EC2::VPC/Subnet/SecurityGroup::Id (Fn::RefAll/ValueOf/ValueOfAll attributes per the Rule functions page: DefaultNetworkAcl, DefaultSecurityGroup, AvailabilityZone, VpcId, Tags.); other AWS-specific types yield empty lists. The Parameter-constraint, Rules-assertion and Unrecognized-parameter-type wordings are best-effort recollections of CloudFormation text, not SDK-verifiable. - Async stack provisioning is opt-in (GOPHERSTACK_CFN_PROVISIONING_DELAY / SetProvisioningDelay; default stays synchronous): when on, DescribeStacks shows CREATE_IN_PROGRESS then CREATE_COMPLETE, and UPDATE_IN_PROGRESS then UPDATE_COMPLETE_CLEANUP_IN_PROGRESS then UPDATE_COMPLETE. Resources are still created in one step when the delayed step runs (no per-resource progress), failures go straight to their terminal status without a ROLLBACK_IN_PROGRESS interval, and ExecuteChangeSet, nested stacks, StackSet instances and DeleteStack stay synchronous. - Stack policy: Replacement Conditionally is treated as Update:Replace (deliberate, not an AWS-documented rule), and AWS's separate logical-ID/resource-type default-deny note is not modeled; NotAction/NotResource follow plain inversion (gopherstack-cqy3). - No nested-stack change-set or public-extension version machinery exists, so these stay unmodeled: CreateChangeSet IncludeNestedStacks (no nested-stack diff machinery), UpdateStack RetainExceptOnCreate, RollbackStack RoleARN, ActivateType MajorVersion/VersionBump/TypeNameAlias (no public extension version catalog) (gopherstack-xhu2t). diff --git a/services/cloudformation/cfn_required_properties.json b/services/cloudformation/cfn_required_properties.json index 2d9b96cabf..4c4b1e8485 100644 --- a/services/cloudformation/cfn_required_properties.json +++ b/services/cloudformation/cfn_required_properties.json @@ -1,22 +1,688 @@ { + "AWS::AccessAnalyzer::Analyzer": [ + "Type" + ], + "AWS::AccessAnalyzer::ArchiveRule": [ + "AnalyzerName", + "Filter", + "RuleName" + ], + "AWS::Amplify::App": [ + "Name" + ], + "AWS::Amplify::Branch": [ + "AppId", + "BranchName" + ], + "AWS::ApiGateway::Authorizer": [ + "Name", + "RestApiId", + "Type" + ], + "AWS::ApiGateway::BasePathMapping": [ + "DomainName" + ], + "AWS::ApiGateway::Deployment": [ + "RestApiId" + ], + "AWS::ApiGateway::DocumentationPart": [ + "Location", + "Properties", + "RestApiId" + ], + "AWS::ApiGateway::DocumentationVersion": [ + "DocumentationVersion", + "RestApiId" + ], + "AWS::ApiGateway::GatewayResponse": [ + "ResponseType", + "RestApiId" + ], + "AWS::ApiGateway::Method": [ + "HttpMethod", + "ResourceId", + "RestApiId" + ], + "AWS::ApiGateway::Model": [ + "RestApiId" + ], + "AWS::ApiGateway::RequestValidator": [ + "RestApiId" + ], "AWS::ApiGateway::Resource": [ "ParentId", "PathPart", "RestApiId" ], + "AWS::ApiGateway::Stage": [ + "RestApiId" + ], + "AWS::ApiGateway::UsagePlanKey": [ + "KeyId", + "KeyType", + "UsagePlanId" + ], + "AWS::ApiGateway::VpcLink": [ + "Name", + "TargetArns" + ], + "AWS::ApiGatewayV2::ApiMapping": [ + "ApiId", + "DomainName", + "Stage" + ], + "AWS::ApiGatewayV2::Authorizer": [ + "ApiId", + "AuthorizerType", + "Name" + ], + "AWS::ApiGatewayV2::DomainName": [ + "DomainName" + ], + "AWS::ApiGatewayV2::Integration": [ + "ApiId", + "IntegrationType" + ], + "AWS::ApiGatewayV2::Route": [ + "ApiId", + "RouteKey" + ], + "AWS::ApiGatewayV2::Stage": [ + "ApiId", + "StageName" + ], + "AWS::ApiGatewayV2::VpcLink": [ + "Name", + "SubnetIds" + ], + "AWS::AppConfig::Application": [ + "Name" + ], + "AWS::AppConfig::ConfigurationProfile": [ + "ApplicationId", + "LocationUri", + "Name" + ], + "AWS::AppConfig::DeploymentStrategy": [ + "DeploymentDurationInMinutes", + "GrowthFactor", + "Name", + "ReplicateTo" + ], + "AWS::AppConfig::Environment": [ + "ApplicationId", + "Name" + ], + "AWS::AppIntegrations::Application": [ + "ApplicationSourceConfig", + "Name", + "Namespace" + ], + "AWS::AppIntegrations::DataIntegration": [ + "KmsKey", + "Name", + "SourceURI" + ], + "AWS::AppIntegrations::EventIntegration": [ + "EventBridgeBus", + "EventFilter", + "Name" + ], + "AWS::AppSync::ApiKey": [ + "ApiId" + ], + "AWS::AppSync::ChannelNamespace": [ + "ApiId", + "Name" + ], + "AWS::AppSync::DataSource": [ + "ApiId", + "Name", + "Type" + ], + "AWS::AppSync::DomainName": [ + "CertificateArn", + "DomainName" + ], + "AWS::AppSync::FunctionConfiguration": [ + "ApiId", + "DataSourceName", + "Name" + ], + "AWS::AppSync::GraphQLApi": [ + "AuthenticationType", + "Name" + ], + "AWS::AppSync::GraphQLSchema": [ + "ApiId" + ], + "AWS::AppSync::Resolver": [ + "ApiId", + "FieldName", + "TypeName" + ], + "AWS::ApplicationAutoScaling::ScalableTarget": [ + "MaxCapacity", + "MinCapacity", + "ResourceId", + "ScalableDimension", + "ServiceNamespace" + ], + "AWS::ApplicationAutoScaling::ScalingPolicy": [ + "PolicyName", + "PolicyType" + ], + "AWS::Athena::CapacityReservation": [ + "Name", + "TargetDpus" + ], + "AWS::Athena::DataCatalog": [ + "Name", + "Type" + ], + "AWS::Athena::NamedQuery": [ + "Database", + "QueryString" + ], + "AWS::Athena::PreparedStatement": [ + "QueryStatement", + "StatementName", + "WorkGroup" + ], + "AWS::Athena::WorkGroup": [ + "Name" + ], + "AWS::AutoScaling::AutoScalingGroup": [ + "MaxSize", + "MinSize" + ], + "AWS::AutoScaling::LaunchConfiguration": [ + "ImageId", + "InstanceType" + ], + "AWS::AutoScaling::LifecycleHook": [ + "AutoScalingGroupName", + "LifecycleTransition" + ], + "AWS::AutoScaling::ScalingPolicy": [ + "AutoScalingGroupName" + ], + "AWS::AutoScaling::ScheduledAction": [ + "AutoScalingGroupName" + ], + "AWS::Backup::BackupPlan": [ + "BackupPlan" + ], + "AWS::Backup::BackupSelection": [ + "BackupPlanId", + "BackupSelection" + ], + "AWS::Backup::BackupVault": [ + "BackupVaultName" + ], + "AWS::Backup::Framework": [ + "FrameworkControls" + ], + "AWS::Backup::ReportPlan": [ + "ReportDeliveryChannel", + "ReportSetting" + ], + "AWS::Batch::ComputeEnvironment": [ + "Type" + ], + "AWS::Batch::JobDefinition": [ + "Type" + ], + "AWS::Batch::JobQueue": [ + "Priority" + ], + "AWS::Batch::ServiceEnvironment": [ + "CapacityLimits", + "ServiceEnvironmentType" + ], + "AWS::CertificateManager::Certificate": [ + "DomainName" + ], + "AWS::CloudFront::CachePolicy": [ + "CachePolicyConfig" + ], + "AWS::CloudFront::CloudFrontOriginAccessIdentity": [ + "CloudFrontOriginAccessIdentityConfig" + ], + "AWS::CloudFront::ContinuousDeploymentPolicy": [ + "ContinuousDeploymentPolicyConfig" + ], + "AWS::CloudFront::Distribution": [ + "DistributionConfig" + ], + "AWS::CloudFront::Function": [ + "FunctionCode", + "FunctionConfig", + "Name" + ], + "AWS::CloudFront::KeyGroup": [ + "KeyGroupConfig" + ], + "AWS::CloudFront::KeyValueStore": [ + "Name" + ], + "AWS::CloudFront::OriginAccessControl": [ + "OriginAccessControlConfig" + ], + "AWS::CloudFront::OriginRequestPolicy": [ + "OriginRequestPolicyConfig" + ], + "AWS::CloudFront::PublicKey": [ + "PublicKeyConfig" + ], + "AWS::CloudFront::RealtimeLogConfig": [ + "EndPoints", + "Fields", + "Name", + "SamplingRate" + ], + "AWS::CloudFront::ResponseHeadersPolicy": [ + "ResponseHeadersPolicyConfig" + ], + "AWS::CloudHSM::Cluster": [ + "HsmType" + ], + "AWS::CloudTrail::Trail": [ + "IsLogging", + "S3BucketName" + ], "AWS::CloudWatch::Alarm": [ "ComparisonOperator", "EvaluationPeriods" ], + "AWS::CloudWatch::CompositeAlarm": [ + "AlarmRule" + ], + "AWS::CloudWatch::Dashboard": [ + "DashboardBody" + ], + "AWS::CloudWatch::InsightRule": [ + "RuleBody", + "RuleName", + "RuleState" + ], + "AWS::CloudWatch::MetricStream": [ + "FirehoseArn", + "OutputFormat", + "RoleArn" + ], + "AWS::CodeArtifact::Domain": [ + "DomainName" + ], + "AWS::CodeArtifact::PackageGroup": [ + "DomainName", + "Pattern" + ], + "AWS::CodeArtifact::Repository": [ + "DomainName", + "RepositoryName" + ], + "AWS::CodeBuild::Project": [ + "Artifacts", + "Environment", + "ServiceRole", + "Source" + ], + "AWS::CodeBuild::ReportGroup": [ + "ExportConfig", + "Type" + ], + "AWS::CodeDeploy::DeploymentGroup": [ + "ApplicationName", + "ServiceRoleArn" + ], + "AWS::CodeGuruReviewer::RepositoryAssociation": [ + "Name", + "Type" + ], + "AWS::CodePipeline::Pipeline": [ + "RoleArn", + "Stages" + ], + "AWS::Cognito::IdentityPool": [ + "AllowUnauthenticatedIdentities" + ], + "AWS::Cognito::IdentityPoolRoleAttachment": [ + "IdentityPoolId" + ], + "AWS::Cognito::UserPoolClient": [ + "UserPoolId" + ], + "AWS::Cognito::UserPoolDomain": [ + "Domain", + "UserPoolId" + ], + "AWS::Cognito::UserPoolGroup": [ + "UserPoolId" + ], + "AWS::Cognito::UserPoolIdentityProvider": [ + "ProviderDetails", + "ProviderName", + "ProviderType", + "UserPoolId" + ], + "AWS::Cognito::UserPoolResourceServer": [ + "Identifier", + "Name", + "UserPoolId" + ], + "AWS::Config::AggregationAuthorization": [ + "AuthorizedAccountId", + "AuthorizedAwsRegion" + ], + "AWS::Config::ConfigRule": [ + "Source" + ], + "AWS::Config::ConfigurationRecorder": [ + "RoleARN" + ], + "AWS::Config::ConformancePack": [ + "ConformancePackName" + ], + "AWS::Config::DeliveryChannel": [ + "S3BucketName" + ], + "AWS::Config::RemediationConfiguration": [ + "ConfigRuleName", + "TargetId", + "TargetType" + ], + "AWS::Config::StoredQuery": [ + "QueryExpression", + "QueryName" + ], + "AWS::Connect::AgentStatus": [ + "InstanceArn", + "Name", + "State" + ], + "AWS::Connect::ApprovedOrigin": [ + "InstanceId", + "Origin" + ], + "AWS::Connect::ContactFlow": [ + "Content", + "InstanceArn", + "Name", + "Type" + ], + "AWS::Connect::ContactFlowModule": [ + "Content", + "InstanceArn", + "Name" + ], + "AWS::Connect::DataTable": [ + "InstanceArn", + "Name", + "Status", + "TimeZone", + "ValueLockLevel" + ], + "AWS::Connect::DataTableAttribute": [ + "DataTableArn", + "InstanceArn", + "Name", + "ValueType" + ], + "AWS::Connect::EmailAddress": [ + "EmailAddress", + "InstanceArn" + ], + "AWS::Connect::EvaluationForm": [ + "InstanceArn", + "Items", + "Status", + "Title" + ], + "AWS::Connect::HoursOfOperation": [ + "Config", + "InstanceArn", + "Name", + "TimeZone" + ], + "AWS::Connect::Instance": [ + "Attributes", + "IdentityManagementType" + ], + "AWS::Connect::InstanceStorageConfig": [ + "InstanceArn", + "ResourceType", + "StorageType" + ], + "AWS::Connect::IntegrationAssociation": [ + "InstanceId", + "IntegrationArn", + "IntegrationType" + ], + "AWS::Connect::PhoneNumber": [ + "TargetArn" + ], + "AWS::Connect::PredefinedAttribute": [ + "InstanceArn", + "Name" + ], + "AWS::Connect::Prompt": [ + "InstanceArn", + "Name" + ], + "AWS::Connect::Queue": [ + "HoursOfOperationArn", + "InstanceArn", + "Name" + ], + "AWS::Connect::QuickConnect": [ + "InstanceArn", + "Name", + "QuickConnectConfig" + ], + "AWS::Connect::RoutingProfile": [ + "DefaultOutboundQueueArn", + "Description", + "InstanceArn", + "MediaConcurrencies", + "Name" + ], + "AWS::Connect::Rule": [ + "Actions", + "Function", + "InstanceArn", + "Name", + "PublishStatus", + "TriggerEventSource" + ], + "AWS::Connect::SecurityKey": [ + "InstanceId", + "Key" + ], + "AWS::Connect::SecurityProfile": [ + "InstanceArn", + "SecurityProfileName" + ], + "AWS::Connect::TaskTemplate": [ + "InstanceArn" + ], + "AWS::Connect::TrafficDistributionGroup": [ + "InstanceArn", + "Name" + ], + "AWS::Connect::UseCase": [ + "InstanceId", + "IntegrationAssociationId", + "UseCaseType" + ], + "AWS::Connect::User": [ + "InstanceArn", + "RoutingProfileArn", + "SecurityProfileArns", + "Username" + ], + "AWS::Connect::UserHierarchyGroup": [ + "InstanceArn", + "Name" + ], + "AWS::Connect::View": [ + "Actions", + "InstanceArn", + "Name", + "Template" + ], + "AWS::Connect::ViewVersion": [ + "ViewArn" + ], + "AWS::Connect::Vocabulary": [ + "Content", + "InstanceId", + "LanguageCode", + "VocabularyName" + ], + "AWS::Connect::Workspace": [ + "InstanceArn", + "Name" + ], + "AWS::DataSync::LocationS3": [ + "S3Config" + ], + "AWS::DataSync::Task": [ + "DestinationLocationArn", + "SourceLocationArn" + ], + "AWS::DocDB::DBClusterParameterGroup": [ + "Description", + "Family", + "Parameters" + ], + "AWS::DocDB::DBInstance": [ + "DBClusterIdentifier", + "DBInstanceClass" + ], + "AWS::DocDB::DBSubnetGroup": [ + "DBSubnetGroupDescription", + "SubnetIds" + ], + "AWS::DocDB::GlobalCluster": [ + "GlobalClusterIdentifier" + ], + "AWS::DynamoDB::GlobalTable": [ + "Replicas" + ], "AWS::DynamoDB::Table": [ "KeySchema" ], + "AWS::EC2::CapacityReservation": [ + "InstanceCount", + "InstancePlatform", + "InstanceType" + ], + "AWS::EC2::CarrierGateway": [ + "VpcId" + ], + "AWS::EC2::ClientVpnAuthorizationRule": [ + "ClientVpnEndpointId", + "TargetNetworkCidr" + ], + "AWS::EC2::ClientVpnEndpoint": [ + "AuthenticationOptions", + "ConnectionLogOptions", + "ServerCertificateArn" + ], + "AWS::EC2::ClientVpnRoute": [ + "ClientVpnEndpointId", + "DestinationCidrBlock" + ], + "AWS::EC2::ClientVpnTargetNetworkAssociation": [ + "ClientVpnEndpointId" + ], + "AWS::EC2::CustomerGateway": [ + "IpAddress", + "Type" + ], + "AWS::EC2::EgressOnlyInternetGateway": [ + "VpcId" + ], + "AWS::EC2::FlowLog": [ + "ResourceId", + "ResourceType" + ], + "AWS::EC2::Host": [ + "AvailabilityZone" + ], + "AWS::EC2::IPAMPool": [ + "AddressFamily", + "IpamScopeId" + ], + "AWS::EC2::IPAMPoolCidr": [ + "IpamPoolId" + ], + "AWS::EC2::IPAMScope": [ + "IpamId" + ], + "AWS::EC2::InstanceConnectEndpoint": [ + "SubnetId" + ], + "AWS::EC2::KeyPair": [ + "KeyName" + ], + "AWS::EC2::LaunchTemplate": [ + "LaunchTemplateData" + ], + "AWS::EC2::NetworkAcl": [ + "VpcId" + ], + "AWS::EC2::NetworkAclEntry": [ + "NetworkAclId", + "Protocol", + "RuleAction", + "RuleNumber" + ], + "AWS::EC2::NetworkInsightsPath": [ + "Protocol", + "Source" + ], + "AWS::EC2::NetworkInterface": [ + "SubnetId" + ], + "AWS::EC2::NetworkInterfacePermission": [ + "AwsAccountId", + "NetworkInterfaceId", + "Permission" + ], + "AWS::EC2::PrefixList": [ + "AddressFamily", + "PrefixListName" + ], + "AWS::EC2::Route": [ + "RouteTableId" + ], + "AWS::EC2::RouteServer": [ + "AmazonSideAsn" + ], + "AWS::EC2::RouteServerEndpoint": [ + "RouteServerId", + "SubnetId" + ], + "AWS::EC2::RouteServerPeer": [ + "BgpOptions", + "PeerAddress", + "RouteServerEndpointId" + ], "AWS::EC2::RouteTable": [ "VpcId" ], "AWS::EC2::SecurityGroup": [ "GroupDescription" ], + "AWS::EC2::SecurityGroupEgress": [ + "GroupId", + "IpProtocol" + ], + "AWS::EC2::SecurityGroupIngress": [ + "IpProtocol" + ], "AWS::EC2::Subnet": [ "VpcId" ], @@ -24,9 +690,281 @@ "RouteTableId", "SubnetId" ], + "AWS::EC2::TrafficMirrorFilterRule": [ + "DestinationCidrBlock", + "RuleAction", + "RuleNumber", + "SourceCidrBlock", + "TrafficDirection", + "TrafficMirrorFilterId" + ], + "AWS::EC2::TrafficMirrorSession": [ + "NetworkInterfaceId", + "SessionNumber", + "TrafficMirrorFilterId", + "TrafficMirrorTargetId" + ], + "AWS::EC2::TransitGatewayAttachment": [ + "SubnetIds", + "TransitGatewayId", + "VpcId" + ], + "AWS::EC2::TransitGatewayMulticastDomain": [ + "TransitGatewayId" + ], + "AWS::EC2::TransitGatewayPeeringAttachment": [ + "PeerAccountId", + "PeerRegion", + "PeerTransitGatewayId", + "TransitGatewayId" + ], + "AWS::EC2::TransitGatewayRoute": [ + "DestinationCidrBlock", + "TransitGatewayRouteTableId" + ], + "AWS::EC2::TransitGatewayRouteTable": [ + "TransitGatewayId" + ], + "AWS::EC2::TransitGatewayVpcAttachment": [ + "SubnetIds", + "TransitGatewayId", + "VpcId" + ], + "AWS::EC2::VPCDHCPOptionsAssociation": [ + "DhcpOptionsId", + "VpcId" + ], + "AWS::EC2::VPCEndpoint": [ + "VpcId" + ], + "AWS::EC2::VPCGatewayAttachment": [ + "VpcId" + ], + "AWS::EC2::VPCPeeringConnection": [ + "PeerVpcId", + "VpcId" + ], + "AWS::EC2::VPNConnection": [ + "CustomerGatewayId", + "Type" + ], + "AWS::EC2::VPNGateway": [ + "Type" + ], + "AWS::EC2::VolumeAttachment": [ + "InstanceId", + "VolumeId" + ], + "AWS::ECR::RegistryPolicy": [ + "PolicyText" + ], + "AWS::ECR::RepositoryCreationTemplate": [ + "AppliedFor", + "Prefix" + ], + "AWS::ECS::ClusterCapacityProviderAssociations": [ + "Cluster", + "DefaultCapacityProviderStrategy" + ], + "AWS::ECS::PrimaryTaskSet": [ + "Cluster", + "Service", + "TaskSetId" + ], + "AWS::ECS::TaskSet": [ + "Cluster", + "Service", + "TaskDefinition" + ], + "AWS::EFS::AccessPoint": [ + "FileSystemId" + ], + "AWS::EFS::MountTarget": [ + "FileSystemId", + "SecurityGroups", + "SubnetId" + ], + "AWS::EKS::AccessEntry": [ + "ClusterName", + "PrincipalArn" + ], + "AWS::EKS::Addon": [ + "AddonName", + "ClusterName" + ], + "AWS::EKS::Cluster": [ + "ResourcesVpcConfig", + "RoleArn" + ], + "AWS::EKS::FargateProfile": [ + "ClusterName", + "PodExecutionRoleArn", + "Selectors" + ], + "AWS::EKS::IdentityProviderConfig": [ + "ClusterName", + "Type" + ], + "AWS::EKS::Nodegroup": [ + "ClusterName", + "NodeRole", + "Subnets" + ], + "AWS::EKS::PodIdentityAssociation": [ + "ClusterName", + "Namespace", + "RoleArn", + "ServiceAccount" + ], + "AWS::EMR::Cluster": [ + "Instances", + "JobFlowRole", + "Name", + "ServiceRole" + ], + "AWS::ElastiCache::CacheCluster": [ + "CacheNodeType", + "Engine", + "NumCacheNodes" + ], + "AWS::ElastiCache::GlobalReplicationGroup": [ + "Members" + ], + "AWS::ElastiCache::ParameterGroup": [ + "CacheParameterGroupFamily", + "Description" + ], + "AWS::ElastiCache::ReplicationGroup": [ + "ReplicationGroupDescription" + ], + "AWS::ElastiCache::SecurityGroup": [ + "Description" + ], + "AWS::ElastiCache::SubnetGroup": [ + "Description", + "SubnetIds" + ], + "AWS::ElastiCache::User": [ + "Engine", + "UserId", + "UserName" + ], + "AWS::ElastiCache::UserGroup": [ + "Engine", + "UserGroupId", + "UserIds" + ], + "AWS::ElasticLoadBalancingV2::Listener": [ + "DefaultActions", + "LoadBalancerArn" + ], + "AWS::ElasticLoadBalancingV2::ListenerRule": [ + "Actions", + "Conditions", + "Priority" + ], + "AWS::Events::ApiDestination": [ + "ConnectionArn", + "HttpMethod", + "InvocationEndpoint" + ], + "AWS::Events::Archive": [ + "SourceArn" + ], + "AWS::Events::Endpoint": [ + "EventBuses", + "RoutingConfig" + ], + "AWS::Events::EventBus": [ + "Name" + ], + "AWS::Events::EventBusPolicy": [ + "StatementId" + ], + "AWS::GlobalAccelerator::Accelerator": [ + "Name" + ], + "AWS::GlobalAccelerator::CrossAccountAttachment": [ + "Name" + ], + "AWS::GlobalAccelerator::EndpointGroup": [ + "EndpointGroupRegion", + "ListenerArn" + ], + "AWS::GlobalAccelerator::Listener": [ + "AcceleratorArn", + "PortRanges", + "Protocol" + ], + "AWS::Glue::Blueprint": [ + "BlueprintLocation", + "Name" + ], + "AWS::Glue::Connection": [ + "CatalogId", + "ConnectionInput" + ], + "AWS::Glue::Crawler": [ + "Role", + "Targets" + ], + "AWS::Glue::Database": [ + "CatalogId", + "DatabaseInput" + ], + "AWS::Glue::DevEndpoint": [ + "RoleArn" + ], + "AWS::Glue::Job": [ + "Command", + "Role" + ], + "AWS::Glue::Partition": [ + "CatalogId", + "DatabaseName", + "PartitionInput", + "TableName" + ], + "AWS::Glue::Registry": [ + "Name" + ], + "AWS::Glue::Schema": [ + "Compatibility", + "DataFormat", + "Name" + ], + "AWS::Glue::SecurityConfiguration": [ + "EncryptionConfiguration", + "Name" + ], + "AWS::Glue::Table": [ + "CatalogId", + "DatabaseName" + ], + "AWS::Glue::Trigger": [ + "Actions", + "Type" + ], + "AWS::GuardDuty::Detector": [ + "Enable" + ], + "AWS::GuardDuty::IPSet": [ + "Format", + "Location" + ], + "AWS::IAM::AccessKey": [ + "UserName" + ], + "AWS::IAM::GroupPolicy": [ + "GroupName", + "PolicyName" + ], "AWS::IAM::InstanceProfile": [ "Roles" ], + "AWS::IAM::ManagedPolicy": [ + "PolicyDocument" + ], "AWS::IAM::Policy": [ "PolicyDocument", "PolicyName" @@ -34,10 +972,110 @@ "AWS::IAM::Role": [ "AssumeRolePolicyDocument" ], + "AWS::IAM::RolePolicy": [ + "PolicyName", + "RoleName" + ], + "AWS::IAM::UserPolicy": [ + "PolicyName", + "UserName" + ], + "AWS::IAM::UserToGroupAddition": [ + "GroupName", + "Users" + ], + "AWS::IAM::VirtualMFADevice": [ + "Users" + ], + "AWS::IoT::Authorizer": [ + "AuthorizerFunctionArn" + ], + "AWS::IoT::Certificate": [ + "Status" + ], + "AWS::IoT::CustomMetric": [ + "MetricType" + ], + "AWS::IoT::Dimension": [ + "StringValues", + "Type" + ], + "AWS::IoT::FleetMetric": [ + "MetricName" + ], + "AWS::IoT::JobTemplate": [ + "Description", + "JobTemplateId" + ], + "AWS::IoT::MitigationAction": [ + "ActionParams", + "RoleArn" + ], + "AWS::IoT::Policy": [ + "PolicyDocument" + ], + "AWS::IoT::ProvisioningTemplate": [ + "ProvisioningRoleArn", + "TemplateBody" + ], + "AWS::IoT::RoleAlias": [ + "RoleArn" + ], + "AWS::IoT::ScheduledAudit": [ + "Frequency", + "TargetCheckNames" + ], + "AWS::IoT::TopicRule": [ + "TopicRulePayload" + ], "AWS::KMS::Alias": [ "AliasName", "TargetKeyId" ], + "AWS::KMS::ReplicaKey": [ + "KeyPolicy", + "PrimaryKeyArn" + ], + "AWS::KafkaConnect::Connector": [ + "Capacity", + "ConnectorConfiguration", + "ConnectorName", + "KafkaCluster", + "KafkaClusterClientAuthentication", + "KafkaClusterEncryptionInTransit", + "KafkaConnectVersion", + "Plugins", + "ServiceExecutionRoleArn" + ], + "AWS::KafkaConnect::CustomPlugin": [ + "ContentType", + "Location", + "Name" + ], + "AWS::KafkaConnect::WorkerConfiguration": [ + "Name", + "PropertiesFileContent" + ], + "AWS::Kinesis::ResourcePolicy": [ + "ResourceArn", + "ResourcePolicy" + ], + "AWS::Kinesis::StreamConsumer": [ + "ConsumerName", + "StreamARN" + ], + "AWS::Lambda::Alias": [ + "FunctionName", + "FunctionVersion", + "Name" + ], + "AWS::Lambda::CodeSigningConfig": [ + "AllowedPublishers" + ], + "AWS::Lambda::EventInvokeConfig": [ + "FunctionName", + "Qualifier" + ], "AWS::Lambda::EventSourceMapping": [ "FunctionName" ], @@ -45,35 +1083,462 @@ "Code", "Role" ], + "AWS::Lambda::LayerVersion": [ + "Content" + ], + "AWS::Lambda::LayerVersionPermission": [ + "Action", + "LayerVersionArn", + "Principal" + ], "AWS::Lambda::Permission": [ "Action", "FunctionName", "Principal" ], + "AWS::Lambda::ResourcePolicy": [ + "PolicyDocument", + "ResourceArn" + ], + "AWS::Lambda::Url": [ + "AuthType", + "TargetFunctionArn" + ], + "AWS::Lambda::Version": [ + "FunctionName" + ], + "AWS::Logs::Delivery": [ + "DeliveryDestinationArn", + "DeliverySourceName" + ], + "AWS::Logs::DeliveryDestination": [ + "Name" + ], + "AWS::Logs::DeliverySource": [ + "Name" + ], + "AWS::Logs::Destination": [ + "DestinationName", + "RoleArn", + "TargetArn" + ], + "AWS::Logs::Integration": [ + "IntegrationName", + "IntegrationType", + "ResourceConfig" + ], "AWS::Logs::LogStream": [ "LogGroupName" ], + "AWS::Logs::MetricFilter": [ + "FilterPattern", + "LogGroupName", + "MetricTransformations" + ], + "AWS::Logs::QueryDefinition": [ + "Name", + "QueryString" + ], + "AWS::Logs::ResourcePolicy": [ + "PolicyDocument", + "PolicyName" + ], + "AWS::Logs::ScheduledQuery": [ + "ExecutionRoleArn", + "Name", + "QueryLanguage", + "QueryString", + "ScheduleExpression" + ], + "AWS::Logs::SubscriptionFilter": [ + "DestinationArn", + "FilterPattern", + "LogGroupName" + ], + "AWS::MSK::Cluster": [ + "BrokerNodeGroupInfo", + "ClusterName", + "KafkaVersion", + "NumberOfBrokerNodes" + ], + "AWS::Macie::AllowList": [ + "Criteria", + "Name" + ], + "AWS::Macie::FindingsFilter": [ + "FindingCriteria", + "Name" + ], + "AWS::MemoryDB::ACL": [ + "ACLName" + ], + "AWS::MemoryDB::Cluster": [ + "ACLName", + "ClusterName", + "NodeType" + ], + "AWS::MemoryDB::ParameterGroup": [ + "Family", + "ParameterGroupName" + ], + "AWS::MemoryDB::SubnetGroup": [ + "SubnetGroupName", + "SubnetIds" + ], + "AWS::MemoryDB::User": [ + "UserName" + ], + "AWS::Neptune::DBClusterParameterGroup": [ + "Description", + "Family", + "Parameters" + ], + "AWS::Neptune::DBInstance": [ + "DBInstanceClass" + ], + "AWS::Neptune::DBParameterGroup": [ + "Description", + "Family", + "Parameters" + ], + "AWS::Neptune::DBSubnetGroup": [ + "DBSubnetGroupDescription", + "SubnetIds" + ], + "AWS::NetworkManager::ConnectAttachment": [ + "CoreNetworkId", + "EdgeLocation", + "Options", + "TransportAttachmentId" + ], + "AWS::NetworkManager::ConnectPeer": [ + "ConnectAttachmentId", + "PeerAddress" + ], + "AWS::NetworkManager::CoreNetwork": [ + "GlobalNetworkId" + ], + "AWS::NetworkManager::CustomerGatewayAssociation": [ + "CustomerGatewayArn", + "DeviceId", + "GlobalNetworkId" + ], + "AWS::NetworkManager::Device": [ + "GlobalNetworkId" + ], + "AWS::NetworkManager::DirectConnectGatewayAttachment": [ + "CoreNetworkId", + "DirectConnectGatewayArn", + "EdgeLocations" + ], + "AWS::NetworkManager::Link": [ + "Bandwidth", + "GlobalNetworkId", + "SiteId" + ], + "AWS::NetworkManager::LinkAssociation": [ + "DeviceId", + "GlobalNetworkId", + "LinkId" + ], + "AWS::NetworkManager::Site": [ + "GlobalNetworkId" + ], + "AWS::NetworkManager::SiteToSiteVpnAttachment": [ + "CoreNetworkId", + "VpnConnectionArn" + ], + "AWS::NetworkManager::TransitGatewayPeering": [ + "CoreNetworkId", + "TransitGatewayArn" + ], + "AWS::NetworkManager::TransitGatewayRegistration": [ + "GlobalNetworkId", + "TransitGatewayArn" + ], + "AWS::NetworkManager::TransitGatewayRouteTableAttachment": [ + "PeeringId", + "TransitGatewayRouteTableArn" + ], + "AWS::NetworkManager::VpcAttachment": [ + "CoreNetworkId", + "SubnetArns", + "VpcArn" + ], + "AWS::Oam::Link": [ + "ResourceTypes", + "SinkIdentifier" + ], + "AWS::Oam::Sink": [ + "Name" + ], + "AWS::Pipes::Pipe": [ + "RoleArn", + "Source", + "Target" + ], + "AWS::RDS::DBClusterParameterGroup": [ + "Description", + "Family", + "Parameters" + ], + "AWS::RDS::DBParameterGroup": [ + "Description", + "Family" + ], + "AWS::RDS::DBProxy": [ + "DBProxyName", + "EngineFamily", + "RoleArn", + "VpcSubnetIds" + ], + "AWS::RDS::DBProxyEndpoint": [ + "DBProxyEndpointName", + "DBProxyName", + "VpcSubnetIds" + ], + "AWS::RDS::DBSubnetGroup": [ + "DBSubnetGroupDescription", + "SubnetIds" + ], + "AWS::RDS::EventSubscription": [ + "SnsTopicArn" + ], + "AWS::RDS::OptionGroup": [ + "EngineName", + "MajorEngineVersion", + "OptionGroupDescription" + ], + "AWS::RUM::AppMonitor": [ + "Name" + ], + "AWS::Redshift::Cluster": [ + "ClusterType", + "DBName", + "MasterUsername", + "NodeType" + ], + "AWS::Redshift::ClusterParameterGroup": [ + "Description", + "ParameterGroupFamily" + ], + "AWS::Redshift::ClusterSubnetGroup": [ + "Description", + "SubnetIds" + ], + "AWS::ResilienceHub::App": [ + "AppTemplateBody", + "Name", + "ResourceMappings" + ], + "AWS::ResilienceHub::ResiliencyPolicy": [ + "Policy", + "PolicyName", + "Tier" + ], + "AWS::Route53::HealthCheck": [ + "HealthCheckConfig" + ], + "AWS::Route53::RecordSet": [ + "Name", + "Type" + ], + "AWS::Route53Resolver::FirewallRuleGroupAssociation": [ + "FirewallRuleGroupId", + "Priority", + "VpcId" + ], + "AWS::Route53Resolver::OutpostResolver": [ + "Name", + "OutpostArn", + "PreferredInstanceType" + ], + "AWS::Route53Resolver::ResolverEndpoint": [ + "Direction", + "IpAddresses", + "SecurityGroupIds" + ], + "AWS::Route53Resolver::ResolverRule": [ + "RuleType" + ], + "AWS::Route53Resolver::ResolverRuleAssociation": [ + "ResolverRuleId", + "VPCId" + ], "AWS::S3::BucketPolicy": [ "Bucket", "PolicyDocument" ], + "AWS::SES::EmailIdentity": [ + "EmailIdentity" + ], "AWS::SNS::Subscription": [ "Protocol", "TopicArn" ], + "AWS::SNS::TopicInlinePolicy": [ + "PolicyDocument", + "TopicArn" + ], "AWS::SNS::TopicPolicy": [ "PolicyDocument", "Topics" ], + "AWS::SQS::QueueInlinePolicy": [ + "PolicyDocument", + "Queue" + ], "AWS::SQS::QueuePolicy": [ "PolicyDocument", "Queues" ], + "AWS::SSM::Association": [ + "Name" + ], + "AWS::SSM::Document": [ + "Content" + ], + "AWS::SSM::MaintenanceWindow": [ + "AllowUnassociatedTargets", + "Cutoff", + "Duration", + "Name", + "Schedule" + ], + "AWS::SSM::MaintenanceWindowTarget": [ + "ResourceType", + "Targets", + "WindowId" + ], + "AWS::SSM::MaintenanceWindowTask": [ + "Priority", + "TaskArn", + "TaskType", + "WindowId" + ], "AWS::SSM::Parameter": [ "Type", "Value" ], + "AWS::SSM::PatchBaseline": [ + "Name" + ], + "AWS::SSM::ResourceDataSync": [ + "SyncName" + ], + "AWS::SSM::ResourcePolicy": [ + "Policy", + "ResourceArn" + ], + "AWS::SageMaker::CodeRepository": [ + "GitConfig" + ], + "AWS::SageMaker::Domain": [ + "AuthMode", + "DefaultUserSettings", + "DomainName" + ], + "AWS::SageMaker::Endpoint": [ + "EndpointConfigName" + ], + "AWS::SageMaker::EndpointConfig": [ + "ProductionVariants" + ], + "AWS::SageMaker::FeatureGroup": [ + "EventTimeFeatureName", + "FeatureDefinitions", + "FeatureGroupName", + "RecordIdentifierFeatureName" + ], + "AWS::SageMaker::Image": [ + "ImageName", + "ImageRoleArn" + ], + "AWS::SageMaker::ImageVersion": [ + "BaseImage", + "ImageName" + ], + "AWS::SageMaker::ModelPackageGroup": [ + "ModelPackageGroupName" + ], + "AWS::SageMaker::NotebookInstance": [ + "InstanceType", + "RoleArn" + ], + "AWS::SageMaker::Pipeline": [ + "PipelineDefinition", + "PipelineName", + "RoleArn" + ], + "AWS::SageMaker::Project": [ + "ProjectName" + ], + "AWS::SageMaker::Workteam": [ + "MemberDefinitions" + ], + "AWS::Scheduler::Schedule": [ + "FlexibleTimeWindow", + "ScheduleExpression", + "Target" + ], + "AWS::SecretsManager::ResourcePolicy": [ + "ResourcePolicy", + "SecretId" + ], + "AWS::SecretsManager::RotationSchedule": [ + "SecretId" + ], + "AWS::SecretsManager::SecretTargetAttachment": [ + "SecretId", + "TargetId", + "TargetType" + ], + "AWS::ServiceDiscovery::HttpNamespace": [ + "Name" + ], + "AWS::ServiceDiscovery::Instance": [ + "InstanceAttributes", + "ServiceId" + ], + "AWS::ServiceDiscovery::PrivateDnsNamespace": [ + "Name" + ], + "AWS::ServiceDiscovery::PublicDnsNamespace": [ + "Name" + ], + "AWS::StepFunctions::Activity": [ + "Name" + ], "AWS::StepFunctions::StateMachine": [ "RoleArn" + ], + "AWS::StepFunctions::StateMachineVersion": [ + "StateMachineArn" + ], + "AWS::Transfer::Profile": [ + "As2Id", + "ProfileType" + ], + "AWS::Transfer::Workflow": [ + "Steps" + ], + "AWS::WAFv2::IPSet": [ + "Addresses", + "IPAddressVersion", + "Scope" + ], + "AWS::WAFv2::RuleGroup": [ + "Capacity", + "Scope", + "VisibilityConfig" + ], + "AWS::WAFv2::WebACL": [ + "DefaultAction", + "Scope", + "VisibilityConfig" + ], + "AWS::WAFv2::WebACLAssociation": [ + "ResourceArn", + "WebACLArn" ] } diff --git a/services/cloudformation/iam_enforcement_test.go b/services/cloudformation/iam_enforcement_test.go index 45185e2b7b..a122dffdeb 100644 --- a/services/cloudformation/iam_enforcement_test.go +++ b/services/cloudformation/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudformation" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cloudformation/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudformation/provider.go b/services/cloudformation/provider.go index d5e90faefc..f41067b957 100644 --- a/services/cloudformation/provider.go +++ b/services/cloudformation/provider.go @@ -319,6 +319,12 @@ func getHandler[T any](h service.Registerable) (T, bool) { return v, ok } +// PropertyValidationConfig is the optional config extension that toggles +// resource property validation. +type PropertyValidationConfig interface { + GetCloudFormationValidateProperties() bool +} + // provisioningDelayEnv opts into async stack provisioning (e.g. "2s"). const provisioningDelayEnv = "GOPHERSTACK_CFN_PROVISIONING_DELAY" @@ -352,6 +358,10 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { backend.SetProvisioningDelay(d) } + if pc, ok := ctx.Config.(PropertyValidationConfig); ok { + backend.SetPropertyValidation(pc.GetCloudFormationValidateProperties()) + } + handler := NewHandler(backend) handler.EnableRegions() diff --git a/services/cloudformation/resources_cloudhsm_test.go b/services/cloudformation/resources_cloudhsm_test.go index bd5eef4389..2273969dbf 100644 --- a/services/cloudformation/resources_cloudhsm_test.go +++ b/services/cloudformation/resources_cloudhsm_test.go @@ -4,6 +4,7 @@ import ( "fmt" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -160,7 +161,10 @@ func TestCreateStack_CloudHSM(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - tc.run(t, newCloudHSMStackEnv(t)) + + synctest.Test(t, func(t *testing.T) { + tc.run(t, newCloudHSMStackEnv(t)) + }) }) } } diff --git a/services/cloudformation/resources_codedeploy_test.go b/services/cloudformation/resources_codedeploy_test.go index ac5afea854..235b00380f 100644 --- a/services/cloudformation/resources_codedeploy_test.go +++ b/services/cloudformation/resources_codedeploy_test.go @@ -126,7 +126,8 @@ func TestCreateStack_CodeDeployNilBackend(t *testing.T) { { name: "deployment_group", tmpl: `{"Resources":{"Grp":{"Type":"AWS::CodeDeploy::DeploymentGroup", -"Properties":{"ApplicationName":"app","DeploymentGroupName":"grp"}}}, +"Properties":{"ApplicationName":"app","DeploymentGroupName":"grp", +"ServiceRoleArn":"arn:aws:iam::000000000000:role/CodeDeployRole"}}}, "Outputs":{"Id":{"Value":{"Ref":"Grp"}}}}`, }, } diff --git a/services/cloudformation/resources_docdb_more_test.go b/services/cloudformation/resources_docdb_more_test.go index ea8b769591..2a130a3fa9 100644 --- a/services/cloudformation/resources_docdb_more_test.go +++ b/services/cloudformation/resources_docdb_more_test.go @@ -74,7 +74,8 @@ func testDocDBDBClusterParameterGroup(t *testing.T) { "Properties": { "Name": "my-docdb-cluster-pg", "Family": "docdb5.0", - "Description": "test cluster parameter group" + "Description": "test cluster parameter group", + "Parameters": {"audit_logs": "disabled"} } } }, diff --git a/services/cloudformation/resources_eks_more_test.go b/services/cloudformation/resources_eks_more_test.go index 80805669d9..2c840411c6 100644 --- a/services/cloudformation/resources_eks_more_test.go +++ b/services/cloudformation/resources_eks_more_test.go @@ -27,7 +27,8 @@ func newEKSMoreTestClient(t *testing.T) (*cloudformation.ServiceBackends, *cfnsd func eksClusterTemplateFragment(clusterName string) string { return `"Cluster": { "Type": "AWS::EKS::Cluster", - "Properties": {"Name": "` + clusterName + `", "RoleArn": "arn:aws:iam::000000000000:role/EKSRole"} + "Properties": {"Name": "` + clusterName + `", "RoleArn": "arn:aws:iam::000000000000:role/EKSRole", + "ResourcesVpcConfig": {"SubnetIds": ["subnet-1", "subnet-2"]}} },` } diff --git a/services/cloudformation/resources_elasticache_more_test.go b/services/cloudformation/resources_elasticache_more_test.go index 7db946907d..49d26e0741 100644 --- a/services/cloudformation/resources_elasticache_more_test.go +++ b/services/cloudformation/resources_elasticache_more_test.go @@ -110,7 +110,8 @@ func testElastiCacheGlobalReplicationGroup(t *testing.T) { "Type": "AWS::ElastiCache::GlobalReplicationGroup", "Properties": { "GlobalReplicationGroupIdSuffix": "my-suffix", - "GlobalReplicationGroupDescription": "test global replication group" + "GlobalReplicationGroupDescription": "test global replication group", + "Members": [{"ReplicationGroupId": "rg-1", "ReplicationGroupRegion": "us-east-1", "Role": "PRIMARY"}] } } }, diff --git a/services/cloudformation/resources_neptune_more_test.go b/services/cloudformation/resources_neptune_more_test.go index 37df57ba41..43feac6748 100644 --- a/services/cloudformation/resources_neptune_more_test.go +++ b/services/cloudformation/resources_neptune_more_test.go @@ -75,7 +75,8 @@ func testNeptuneDBClusterParameterGroup(t *testing.T) { "Properties": { "Name": "my-neptune-cluster-pg", "Family": "neptune1.3", - "Description": "test cluster parameter group" + "Description": "test cluster parameter group", + "Parameters": {"neptune_enable_audit_log": "0"} } } }, @@ -108,7 +109,8 @@ func testNeptuneDBParameterGroup(t *testing.T) { "Properties": { "Name": "my-neptune-pg", "Family": "neptune1.3", - "Description": "test parameter group" + "Description": "test parameter group", + "Parameters": {"neptune_query_timeout": "120000"} } } }, diff --git a/services/cloudformation/resources_sagemaker_athena_wafv2_test.go b/services/cloudformation/resources_sagemaker_athena_wafv2_test.go index bd40d27805..a11b2cb7f9 100644 --- a/services/cloudformation/resources_sagemaker_athena_wafv2_test.go +++ b/services/cloudformation/resources_sagemaker_athena_wafv2_test.go @@ -217,7 +217,8 @@ func testSageMakerDomain(t *testing.T) { tmpl := `{ "Resources": {"D": {"Type": "AWS::SageMaker::Domain", "Properties": { "DomainName": "domain-1", "AuthMode": "IAM", "VpcId": "vpc-test", - "SubnetIds": ["subnet-1", "subnet-2"] + "SubnetIds": ["subnet-1", "subnet-2"], + "DefaultUserSettings": {"ExecutionRole": "arn:aws:iam::000000000000:role/sm"} }}}, "Outputs": {"Ref": {"Value": {"Ref": "D"}}} }` diff --git a/services/cloudformation/sdk_roundtrip_helper_test.go b/services/cloudformation/sdk_roundtrip_helper_test.go index f1bd381c13..b6f95864b2 100644 --- a/services/cloudformation/sdk_roundtrip_helper_test.go +++ b/services/cloudformation/sdk_roundtrip_helper_test.go @@ -2,16 +2,15 @@ package cloudformation_test import ( "net/http" - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" cfnsdk "github.com/aws/aws-sdk-go-v2/service/cloudformation" "github.com/labstack/echo/v5" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/cloudformation" ) @@ -59,23 +58,16 @@ func newTestClientForBackend(t *testing.T, backend *cloudformation.InMemoryBacke require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { e.ServeHTTP(w, r) backend.WaitForStackSetOperations() - })) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(rtTestRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - client := cfnsdk.NewFromConfig(cfg, func(o *cfnsdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + }) + + client := cfnsdk.NewFromConfig(aws.Config{ + Region: rtTestRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(wrapped), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) return client diff --git a/services/cloudformation/stack_async_test.go b/services/cloudformation/stack_async_test.go index e957b2509f..0040f6e6cb 100644 --- a/services/cloudformation/stack_async_test.go +++ b/services/cloudformation/stack_async_test.go @@ -2,6 +2,7 @@ package cloudformation_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -168,22 +169,24 @@ func asyncUpdateRejected(t *testing.T, _ *cloudformation.InMemoryBackend, c *cfn func TestAsyncProvisioning_WaiterCompletes(t *testing.T) { t.Parallel() - b, client := newTestHandlerAndClientWithBackend(t) - b.SetProvisioningDelay(time.Millisecond) - t.Cleanup(b.WaitForStackProvisioning) + synctest.Test(t, func(t *testing.T) { + b, client := newTestHandlerAndClientWithBackend(t) + b.SetProvisioningDelay(time.Millisecond) + t.Cleanup(b.WaitForStackProvisioning) - _, err := client.CreateStack(t.Context(), &cfnsdk.CreateStackInput{ - StackName: aws.String("async-waiter"), TemplateBody: aws.String(asyncTemplate), - }) - require.NoError(t, err) + _, err := client.CreateStack(t.Context(), &cfnsdk.CreateStackInput{ + StackName: aws.String("async-waiter"), TemplateBody: aws.String(asyncTemplate), + }) + require.NoError(t, err) - waiter := cfnsdk.NewStackCreateCompleteWaiter(client, func(o *cfnsdk.StackCreateCompleteWaiterOptions) { - o.MinDelay = time.Millisecond - o.MaxDelay = 5 * time.Millisecond + waiter := cfnsdk.NewStackCreateCompleteWaiter(client, func(o *cfnsdk.StackCreateCompleteWaiterOptions) { + o.MinDelay = time.Millisecond + o.MaxDelay = 5 * time.Millisecond + }) + require.NoError(t, waiter.Wait(t.Context(), &cfnsdk.DescribeStacksInput{ + StackName: aws.String("async-waiter"), + }, 30*time.Second)) }) - require.NoError(t, waiter.Wait(t.Context(), &cfnsdk.DescribeStacksInput{ - StackName: aws.String("async-waiter"), - }, 30*time.Second)) } func TestAsyncProvisioning_SnapshotResumesInFlight(t *testing.T) { diff --git a/services/cloudformation/stacks.go b/services/cloudformation/stacks.go index 9f7dce79c8..378ce9f08e 100644 --- a/services/cloudformation/stacks.go +++ b/services/cloudformation/stacks.go @@ -533,7 +533,7 @@ func (b *InMemoryBackend) createStackFromTemplate( pruneConditional(tmpl, resolvedParams) if !disableValidation { - if propErr := validateTemplateProperties(tmpl); propErr != nil { + if propErr := b.validateProperties(tmpl); propErr != nil { b.failAndRollback(stack, propErr.Error()) return @@ -947,7 +947,7 @@ func (b *InMemoryBackend) parseAndValidateUpdateTemplate( pruneConditional(tmpl, resolvedParams) if !disableValidation { - if propErr := validateTemplateProperties(tmpl); propErr != nil { + if propErr := b.validateProperties(tmpl); propErr != nil { b.updateFailAndRollback(stack, propErr.Error()) return nil, nil diff --git a/services/cloudformation/store.go b/services/cloudformation/store.go index 606cdca51a..dbd02ab477 100644 --- a/services/cloudformation/store.go +++ b/services/cloudformation/store.go @@ -3,6 +3,7 @@ package cloudformation import ( "context" "sync" + "sync/atomic" "time" "github.com/blackbirdworks/gopherstack/pkgs/config" @@ -229,6 +230,7 @@ type InMemoryBackend struct { provisionWG sync.WaitGroup stackSetBatchDelay time.Duration orgAccessEnabled bool + propertyChecksOff atomic.Bool } const ( diff --git a/services/cloudformation/template_strictness.go b/services/cloudformation/template_strictness.go index da406cfde2..fe5707dcf2 100644 --- a/services/cloudformation/template_strictness.go +++ b/services/cloudformation/template_strictness.go @@ -318,6 +318,18 @@ func validateRequiredProperties(tmpl *Template) error { return nil } +// SetPropertyValidation toggles the required-key and unknown/wrong-type +// property checks at stack operations (on by default). +func (b *InMemoryBackend) SetPropertyValidation(enabled bool) { b.propertyChecksOff.Store(!enabled) } + +func (b *InMemoryBackend) validateProperties(tmpl *Template) error { + if b.propertyChecksOff.Load() { + return nil + } + + return validateTemplateProperties(tmpl) +} + // validateTemplateProperties runs the required-key and vendored-schema checks. func validateTemplateProperties(tmpl *Template) error { if err := validateRequiredProperties(tmpl); err != nil { diff --git a/services/cloudformation/template_strictness_test.go b/services/cloudformation/template_strictness_test.go index b3abb65888..1078ad8b9c 100644 --- a/services/cloudformation/template_strictness_test.go +++ b/services/cloudformation/template_strictness_test.go @@ -309,3 +309,71 @@ func TestCreateStack_DuplicateExportMessage(t *testing.T) { require.NoError(t, err) assert.Equal(t, "Export with name shared is already exported by stack owner", stack.StackStatusReason) } + +func TestCreateStack_PropertyValidationToggle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource string + wantStatus string + wantReason string + disabled bool + }{ + { + name: "unknown_property", + resource: `{"Type":"AWS::SQS::Queue","Properties":{"Bogus":"x"}}`, + wantStatus: "ROLLBACK_COMPLETE", + wantReason: "Encountered unsupported property Bogus", + }, + { + name: "wrong_type", + resource: `{"Type":"AWS::SQS::Queue","Properties":{"DelaySeconds":"abc"}}`, + wantStatus: "ROLLBACK_COMPLETE", + wantReason: "#/DelaySeconds: expected type: number, found: String", + }, + { + name: "missing_required_from_spec", + resource: `{"Type":"AWS::EKS::Cluster","Properties":{"RoleArn":"arn:aws:iam::000000000000:role/r"}}`, + wantStatus: "ROLLBACK_COMPLETE", + wantReason: "#: required key [ResourcesVpcConfig] not found", + }, + { + name: "intrinsic_skips_type_check", + resource: `{"Type":"AWS::SQS::Queue","Properties":{"DelaySeconds":{"Ref":"AWS::StackName"}}}`, + wantStatus: "CREATE_COMPLETE", + }, + { + name: "valid", + resource: `{"Type":"AWS::SQS::Queue","Properties":{"DelaySeconds":5}}`, + wantStatus: "CREATE_COMPLETE", + }, + { + name: "disabled_unknown_property", + resource: `{"Type":"AWS::SQS::Queue","Properties":{"Bogus":"x"}}`, + disabled: true, + wantStatus: "CREATE_COMPLETE", + }, + { + name: "disabled_missing_required", + resource: `{"Type":"AWS::EKS::Cluster","Properties":{}}`, + disabled: true, + wantStatus: "CREATE_COMPLETE", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + b := newBackend() + b.SetPropertyValidation(!tc.disabled) + + stack, err := b.CreateStack(t.Context(), "pv-"+tc.name, `{"Resources":{"R":`+tc.resource+`}}`, nil, + cloudformation.StackOptions{}) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, stack.StackStatus) + assert.Contains(t, stack.StackStatusReason, tc.wantReason) + }) + } +} diff --git a/services/cloudfront/PARITY.md b/services/cloudfront/PARITY.md index ee1d65f1cc..7df582a342 100644 --- a/services/cloudfront/PARITY.md +++ b/services/cloudfront/PARITY.md @@ -240,8 +240,9 @@ items_still_open: - "Data plane unverified against real AWS: disabled distribution answers 403, redirect-to-https answers 301 (GET/HEAD) or 307 (other methods), POST to an S3 origin answers 403, and non-trailing-wildcard aliases are not matched." - "AnycastIPList.AnycastIps are always IPv4-formatted whatever IpAddressType says: the pinned SDK documents no ipv6 or dualstack address count, so the dualstack shape is unverifiable." - "CloudFront Functions origin helpers: parameters are validated against the documented shape but sslProtocols, ipAddressType, originShield, connectionTimeout, keepAliveTimeout and originAccessControlConfig signing settings have no data-plane effect; the exact AWS validation messages and the originIds object form are unverified against real AWS. The origin choice does not enter the cache key." + - "Lambda@Edge data plane (dataplane_edge*.go): the X-Cache labels LambdaGeneratedResponse/LambdaValidationError/LambdaExecutionError/LambdaLimitExceeded, the 5 s viewer-event cap (docs list only a 30 s quota), ignoring a viewer-response status change, and the origin-request header set (rebuilt from the outbound request, not the AWS-internal one) are unverified against real AWS. Origin failover groups do not re-run origin-request/response per member." structural_gaps: - - "Lambda@Edge and field-level encryption are not executed on the data plane: edge Lambda needs a regional edge runtime. CloudFront Functions (viewer-request/response) do run (dataplane_function.go)." + - "Field-level encryption is not executed on the data plane. CloudFront Functions (dataplane_function.go) and Lambda@Edge (dataplane_edge.go) do run." - "Viewer TLS is not terminated here: https is inferred from X-Forwarded-Proto/CloudFront-Forwarded-Proto or a TLS listener, so viewer certificates and SNI are not modelled." - "Real edge-location behaviour (multi-POP caches, origin shield, regional edge caches, Range-request caching, ErrorCachingMinTTL, origin failover groups, WAF, real-time logs) is not emulated." - "ManagedCertificateRequest: ValidationTokenHost is stored and reflected by GetManagedCertificateDetails, but the certificate itself needs real ACM DNS/HTTP validation, which no emulator performs." @@ -262,6 +263,10 @@ Create/UpdateDistributionTenant store ManagedCertificateRequest and GetManagedCe its ValidationTokenHost (`TestManagedCertificateRequest_TokenHost`); the cached details are dropped on tenant update/delete. +## 2026-10-28: Lambda@Edge data plane (gopherstack-49qct.68) + +Associations are validated on Create/Update/CreateWithTags (InvalidLambdaFunctionAssociation: numbered version in us-east-1 owned by the distribution account, existing version, valid event, IncludeBody on request events only, one function per event, no CloudFront Function on the same viewer event). The data plane invokes the Lambda backend through the narrow `LambdaEdge` interface wired in internal/app/cloudfront_dataplane.go. Sources: AWS docs "Lambda@Edge event structure", "Restrictions on edge functions", "Restrictions on Lambda@Edge", "Work with requests and responses", HTTP 502/503 troubleshooting pages. + ## Notes ### 2026-09-19 cloudfront-and-route53/15 terraform sweep (gopherstack-101r) diff --git a/services/cloudfront/README.md b/services/cloudfront/README.md index 69378e781f..3d3dc28a91 100644 --- a/services/cloudfront/README.md +++ b/services/cloudfront/README.md @@ -9,7 +9,7 @@ | --- | --- | | PARITY entries audited | 60 (60 ok) | | Feature families | 18 (18 ok) | -| Known gaps | 3 | +| Known gaps | 4 | | Structural gaps (can't be emulated) | 4 | | Deferred items | 4 | | Resource leaks | clean | @@ -19,12 +19,13 @@ - Data plane unverified against real AWS: disabled distribution answers 403, redirect-to-https answers 301 (GET/HEAD) or 307 (other methods), POST to an S3 origin answers 403, and non-trailing-wildcard aliases are not matched. - AnycastIPList.AnycastIps are always IPv4-formatted whatever IpAddressType says: the pinned SDK documents no ipv6 or dualstack address count, so the dualstack shape is unverifiable. - CloudFront Functions origin helpers: parameters are validated against the documented shape but sslProtocols, ipAddressType, originShield, connectionTimeout, keepAliveTimeout and originAccessControlConfig signing settings have no data-plane effect; the exact AWS validation messages and the originIds object form are unverified against real AWS. The origin choice does not enter the cache key. +- Lambda@Edge data plane (dataplane_edge*.go): the X-Cache labels LambdaGeneratedResponse/LambdaValidationError/LambdaExecutionError/LambdaLimitExceeded, the 5 s viewer-event cap (docs list only a 30 s quota), ignoring a viewer-response status change, and the origin-request header set (rebuilt from the outbound request, not the AWS-internal one) are unverified against real AWS. Origin failover groups do not re-run origin-request/response per member. ### Structural gaps These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. -- Lambda@Edge and field-level encryption are not executed on the data plane: edge Lambda needs a regional edge runtime. CloudFront Functions (viewer-request/response) do run (dataplane_function.go). +- Field-level encryption is not executed on the data plane. CloudFront Functions (dataplane_function.go) and Lambda@Edge (dataplane_edge.go) do run. - Viewer TLS is not terminated here: https is inferred from X-Forwarded-Proto/CloudFront-Forwarded-Proto or a TLS listener, so viewer certificates and SNI are not modelled. - Real edge-location behaviour (multi-POP caches, origin shield, regional edge caches, Range-request caching, ErrorCachingMinTTL, origin failover groups, WAF, real-time logs) is not emulated. - ManagedCertificateRequest: ValidationTokenHost is stored and reflected by GetManagedCertificateDetails, but the certificate itself needs real ACM DNS/HTTP validation, which no emulator performs. diff --git a/services/cloudfront/dataplane.go b/services/cloudfront/dataplane.go index 0f3dfe8e89..01db95a5e1 100644 --- a/services/cloudfront/dataplane.go +++ b/services/cloudfront/dataplane.go @@ -51,6 +51,7 @@ type ALBForwarder interface { type dataPlane struct { s3 atomic.Pointer[func() s3backend.StorageBackend] alb atomic.Pointer[func() ALBForwarder] + edge atomic.Pointer[func() LambdaEdge] cache *responseCache client *http.Client } @@ -153,6 +154,7 @@ type viewerReq struct { rawPath string scheme string origin *originDirective + edge *edgeOverride body []byte } @@ -238,7 +240,15 @@ func (h *Handler) serveBehavior(w http.ResponseWriter, v *viewerReq, d *Distribu r := v.r beh := cfg.behaviorFor(v.path) - if !h.admit(w, v, d, beh) || h.applyViewerRequest(w, d, cfg, beh, v) { + if !h.admit(w, v, d, beh) { + return + } + + if hasRequestBody(r.Method) && r.Body != nil { + v.body, _ = io.ReadAll(io.LimitReader(r.Body, maxRequestBody)) + } + + if h.applyViewerRequest(w, d, cfg, beh, v) || h.applyEdgeViewerRequest(w, d, beh, v) { return } @@ -247,11 +257,6 @@ func (h *Handler) serveBehavior(w http.ResponseWriter, v *viewerReq, d *Distribu reqPath = "/" + strings.TrimPrefix(cfg.DefaultRootObject, "/") } - if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions && - r.Body != nil { - v.body, _ = io.ReadAll(io.LimitReader(r.Body, maxRequestBody)) - } - pol := h.Backend.resolvePolicy(beh) rhp := h.responseHeadersPolicy(beh) key := cacheKey(d.ID, v, &pol) @@ -271,7 +276,7 @@ func (h *Handler) serveBehavior(w http.ResponseWriter, v *viewerReq, d *Distribu } } - resp := h.fetchOrigin(r.Context(), d, cfg, beh, &pol, v, reqPath) + resp := h.originPhase(r.Context(), d, cfg, beh, &pol, v, reqPath) xcache := xCacheMiss if resp.status >= http.StatusBadRequest { @@ -391,9 +396,13 @@ func edgeErrorBody(status int, msg, reqID string) string { } func writeEdgeError(w http.ResponseWriter, v *viewerReq, status int, msg string) { + writeEdgeErrorCache(w, v, status, msg, xCacheError) +} + +func writeEdgeErrorCache(w http.ResponseWriter, v *viewerReq, status int, msg, xcache string) { hd := w.Header() hd.Set(headerContentType, "text/html") - hd.Set("X-Cache", xCacheError) + hd.Set("X-Cache", xcache) hd.Set("Via", viaValue()) hd.Set("X-Amz-Cf-Pop", popLocation) hd.Set("X-Amz-Cf-Id", v.cfID) @@ -409,6 +418,10 @@ func (h *Handler) writeResponse( ) { hd := w.Header() + if resp.xcache != "" { + xcache = resp.xcache + } + for k, vs := range resp.header { if isHopByHop(k) { continue diff --git a/services/cloudfront/dataplane_config.go b/services/cloudfront/dataplane_config.go index 247038a324..ee1d9054a0 100644 --- a/services/cloudfront/dataplane_config.go +++ b/services/cloudfront/dataplane_config.go @@ -92,6 +92,7 @@ type dpBehavior struct { Methods []string `xml:"AllowedMethods>Items>Method"` CachedMethods []string `xml:"AllowedMethods>CachedMethods>Items>Method"` Functions []dpFunctionAssoc `xml:"FunctionAssociations>Items>FunctionAssociation"` + Edge []dpEdgeAssoc `xml:"LambdaFunctionAssociations>Items>LambdaFunctionAssociation"` KeyGroups dpTrustedKeyGroups `xml:"TrustedKeyGroups"` } diff --git a/services/cloudfront/dataplane_edge.go b/services/cloudfront/dataplane_edge.go new file mode 100644 index 0000000000..f34aac37f4 --- /dev/null +++ b/services/cloudfront/dataplane_edge.go @@ -0,0 +1,480 @@ +package cloudfront + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "slices" + "strconv" + "strings" + "time" +) + +const ( + viewerEdgeTimeout = 5 * time.Second + originEdgeTimeout = 30 * time.Second + + viewerEdgeMaxBody = 40 << 10 + originEdgeMaxBody = 1 << 20 + viewerEdgeMaxB64 = 54476 + originEdgeMaxB64 = 1394606 + edgeMaxURI = 8192 + + xCacheEdgeGenerated = "LambdaGeneratedResponse from cloudfront" + xCacheEdgeValidation = "LambdaValidationError from cloudfront" + xCacheEdgeExecution = "LambdaExecutionError from cloudfront" + xCacheEdgeLimit = "LambdaLimitExceeded from cloudfront" + + bodyActionReplace = "replace" + encodingBase64 = "base64" + encodingText = "text" + keyHeaders = "headers" + keyBody = "body" + keyContentLength = "content-length" + keyTransferEncoding = "transfer-encoding" + keyVia = "via" + keyConfig = "config" + keyRequest = "request" + keyCustom = "custom" +) + +type edgeError struct { + xcache string + msg string + status int +} + +func edgeValidation(msg string) *edgeError { + return &edgeError{status: http.StatusBadGateway, xcache: xCacheEdgeValidation, msg: msg} +} + +func (e *edgeError) resp() originResp { + r := httpErrorResp(e.status, e.msg) + r.xcache = e.xcache + + return r +} + +type edgeOverride struct { + header http.Header + query *string +} + +type edgeLimit struct { + timeout time.Duration + maxBody int + maxB64 int +} + +func edgeLimits(eventType string) edgeLimit { + if eventType == eventViewerRequest || eventType == eventViewerResponse { + return edgeLimit{viewerEdgeTimeout, viewerEdgeMaxBody, viewerEdgeMaxB64} + } + + return edgeLimit{originEdgeTimeout, originEdgeMaxBody, originEdgeMaxB64} +} + +func (h *Handler) edgeAssoc(beh *dpBehavior, eventType string) *dpEdgeAssoc { + for i := range beh.Edge { + if beh.Edge[i].EventType == eventType { + return &beh.Edge[i] + } + } + + return nil +} + +func edgeDisallowed(name string) bool { + n := strings.ToLower(name) + if strings.HasPrefix(n, "x-amz-cf-") || strings.HasPrefix(n, "x-edge-") { + return true + } + + return slices.Contains([]string{ + "connection", "expect", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection", + "trailer", "upgrade", "x-accel-buffering", "x-accel-charset", "x-accel-limit-rate", "x-accel-redirect", + "x-amzn-auth", "x-amzn-cf-billing", "x-amzn-cf-id", "x-amzn-cf-xff", "x-amzn-errortype", + "x-amzn-fle-profile", "x-amzn-header-count", "x-amzn-header-order", "x-amzn-lambda-integration-tag", + "x-amzn-requestid", "x-cache", "x-forwarded-proto", "x-real-ip", "cloudfront-viewer-cert-pem", + "client-cert", "client-cert-chain", + }, n) +} + +func edgeReadOnly(eventType string) []string { + switch eventType { + case eventViewerRequest: + return []string{"cdn-loop", keyContentLength, "host", keyTransferEncoding, keyVia} + case eventOriginRequest: + return []string{ + "accept-encoding", "cdn-loop", keyContentLength, "if-modified-since", "if-none-match", "if-range", + "if-unmodified-since", keyTransferEncoding, keyVia, + } + case eventOriginResponse: + return []string{keyTransferEncoding, keyVia} + default: + return []string{"warning", keyVia, keyContentLength, "content-encoding", keyTransferEncoding} + } +} + +func edgeVisible(src http.Header, host string) http.Header { + out := http.Header{} + + for k, vs := range src { + if !edgeDisallowed(k) && !strings.EqualFold(k, "Host") { + out[http.CanonicalHeaderKey(k)] = slices.Clone(vs) + } + } + + if host != "" { + out.Set("Host", host) + } + + return out +} + +func edgeHeaderMap(hd http.Header) map[string]any { + out := make(map[string]any, len(hd)) + + for k, vs := range hd { + items := make([]map[string]string, len(vs)) + for i, s := range vs { + items[i] = map[string]string{"key": k, keyValue: s} + } + + out[strings.ToLower(k)] = items + } + + return out +} + +func parseEdgeHeaders(raw any) (http.Header, *edgeError) { + out := http.Header{} + + m, ok := raw.(map[string]any) + if !ok { + if raw == nil { + return out, nil + } + + return nil, edgeValidation("The headers field of the Lambda function result must be an object.") + } + + for name, v := range m { + items, isList := v.([]any) + if !isList { + return nil, edgeValidation("The header " + name + " must be an array of key/value pairs.") + } + + for _, it := range items { + kv, isObj := it.(map[string]any) + val, isStr := kv[keyValue].(string) + + if !isObj || !isStr { + return nil, edgeValidation("The header " + name + " has an entry without a string value.") + } + + key := name + if k, hasKey := kv["key"].(string); hasKey && k != "" { + key = k + } + + out.Add(key, val) + } + } + + return out, nil +} + +func checkEdgeHeaders(in, out http.Header, eventType string) *edgeError { + for name := range out { + if edgeDisallowed(name) { + return edgeValidation("The Lambda function added the disallowed header " + name + ".") + } + } + + for _, ro := range edgeReadOnly(eventType) { + if !slices.Equal(in.Values(ro), out.Values(ro)) { + return edgeValidation("The Lambda function modified the read-only header " + ro + ".") + } + } + + return nil +} + +func (h *Handler) edgeConfig(d *Distribution, v *viewerReq, eventType string) map[string]any { + return map[string]any{ + "distributionDomainName": d.DomainName, "distributionId": d.ID, + "eventType": eventType, "requestId": v.cfID, + } +} + +func edgeRequestObject(v *viewerReq, hd http.Header, query, uri string) map[string]any { + return map[string]any{ + "clientIp": clientIP(v.r), keyHeaders: edgeHeaderMap(hd), "method": v.r.Method, + keyQuerystring: query, "uri": uri, + } +} + +func edgeBodyObject(body []byte, maxBody int) map[string]any { + truncated := len(body) > maxBody + if truncated { + body = body[:maxBody] + } + + return map[string]any{ + "inputTruncated": truncated, "action": "read-only", "encoding": encodingBase64, + "data": base64.StdEncoding.EncodeToString(body), + } +} + +func (h *Handler) edgeInvoke( + ctx context.Context, a *dpEdgeAssoc, eventType string, cf map[string]any, +) (map[string]any, *edgeError) { + ctx, cancel := context.WithTimeout(ctx, edgeLimits(eventType).timeout) + + defer cancel() + + arn, _ := parseEdgeARN(a.ARN) + edge := h.lambdaEdge() + + if edge == nil { + return nil, &edgeError{ + status: http.StatusServiceUnavailable, xcache: xCacheEdgeExecution, + msg: "The Lambda function associated with the CloudFront distribution could not be run.", + } + } + + payload, _ := json.Marshal(map[string]any{"Records": []any{map[string]any{"cf": cf}}}) + + res, fnErr, err := edge.InvokeEdge(ctx, arn.name, arn.version, payload) + + switch { + case errors.Is(err, ErrEdgeLimitExceeded) || ctx.Err() != nil: + return nil, &edgeError{ + status: http.StatusServiceUnavailable, xcache: xCacheEdgeLimit, + msg: "The Lambda function associated with the CloudFront distribution was throttled or timed out.", + } + case err != nil || fnErr != "": + return nil, &edgeError{ + status: http.StatusServiceUnavailable, xcache: xCacheEdgeExecution, + msg: "The Lambda function associated with the CloudFront distribution failed during execution.", + } + } + + var out map[string]any + if json.Unmarshal(res, &out) != nil || out == nil { + return nil, edgeValidation( + "The Lambda function returned a value that is not a valid request or response object.", + ) + } + + return out, nil +} + +func decodeEdgeBody(data, encoding string, maxBody, maxB64 int) ([]byte, *edgeError) { + if encoding == encodingBase64 { + if len(data) > maxB64 { + return nil, edgeValidation("The Lambda function returned a body that is too large.") + } + + b, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, edgeValidation("The Lambda function returned a body that is not valid base64.") + } + + return b, nil + } + + if len(data) > maxBody { + return nil, edgeValidation("The Lambda function returned a body that is too large.") + } + + return []byte(data), nil +} + +func headerBytes(hd http.Header) int { + n := 0 + + for k, vs := range hd { + for _, s := range vs { + n += len(k) + len(s) + } + } + + return n +} + +func parseEdgeStatus(out map[string]any) (int, *edgeError) { + var status int + + switch t := out["status"].(type) { + case string: + status, _ = strconv.Atoi(t) + case float64: + status = int(t) + } + + if status < http.StatusOK || status > 599 { + return 0, edgeValidation("The Lambda function returned a status that is not between 200 and 599.") + } + + return status, nil +} + +func parseEdgeResponse(out map[string]any, eventType string, in http.Header) (originResp, *edgeError) { + lim := edgeLimits(eventType) + maxBody, maxB64 := lim.maxBody, lim.maxB64 + + status, serr := parseEdgeStatus(out) + if serr != nil { + return originResp{}, serr + } + + hd, herr := parseEdgeHeaders(out[keyHeaders]) + if herr != nil { + return originResp{}, herr + } + + roEvent := eventType + if isRequestEvent(eventType) { + roEvent = eventOriginResponse + } + + if cerr := checkEdgeHeaders(in, hd, roEvent); cerr != nil { + return originResp{}, cerr + } + + resp := originResp{status: status, header: hd} + + if err := setEdgeResponseBody(&resp, out, maxBody, maxB64); err != nil { + return originResp{}, err + } + + if status == http.StatusNoContent && len(resp.body) > 0 { + return originResp{}, edgeValidation("The Lambda function returned a body with a 204 status.") + } + + if len(resp.body)+headerBytes(hd) > maxBody { + return originResp{}, edgeValidation("The Lambda function generated a response that is too large.") + } + + return resp, nil +} + +func setEdgeResponseBody(resp *originResp, out map[string]any, maxBody, maxB64 int) *edgeError { + raw, has := out[keyBody] + if !has || raw == nil { + return nil + } + + data, ok := raw.(string) + if !ok { + return edgeValidation("The Lambda function returned a body that is not a string.") + } + + enc, _ := out["bodyEncoding"].(string) + if enc != "" && enc != encodingText && enc != encodingBase64 { + return edgeValidation("The Lambda function returned an unsupported bodyEncoding.") + } + + b, err := decodeEdgeBody(data, enc, maxBody, maxB64) + if err != nil { + return err + } + + resp.body = b + + return nil +} + +type edgeReqResult struct { + header http.Header + origin *originParams + uri string + query string + body []byte + replaceBod bool +} + +func parseEdgeRequest( + out map[string]any, in http.Header, eventType string, includeBody bool, curURI, curQuery string, +) (*edgeReqResult, *edgeError) { + lim := edgeLimits(eventType) + maxBody, maxB64 := lim.maxBody, lim.maxB64 + res := &edgeReqResult{uri: curURI, query: curQuery} + + if s, ok := out["uri"].(string); ok { + if !strings.HasPrefix(s, "/") { + return nil, edgeValidation("The Lambda function returned a uri that does not begin with a forward slash.") + } + + res.uri = s + } + + if s, ok := out[keyQuerystring].(string); ok { + res.query = s + } + + if strings.ContainsAny(res.query, " #\x00\r\n\t") || len(res.uri)+len(res.query) >= edgeMaxURI { + return nil, edgeValidation("The Lambda function returned an invalid uri or query string.") + } + + hd, herr := parseEdgeHeaders(out[keyHeaders]) + if herr != nil { + return nil, herr + } + + if cerr := checkEdgeHeaders(in, hd, eventType); cerr != nil { + return nil, cerr + } + + res.header = hd + + if berr := parseEdgeRequestBody(res, out, includeBody, maxBody, maxB64); berr != nil { + return nil, berr + } + + if raw, has := out["origin"]; has && eventType == eventOriginRequest { + p, oerr := parseEdgeOrigin(raw) + if oerr != nil { + return nil, oerr + } + + res.origin = p + } + + return res, nil +} + +func parseEdgeRequestBody(res *edgeReqResult, out map[string]any, includeBody bool, maxBody, maxB64 int) *edgeError { + b, ok := out[keyBody].(map[string]any) + if !ok || !includeBody { + return nil + } + + if action, _ := b["action"].(string); action != bodyActionReplace { + return nil + } + + data, _ := b["data"].(string) + enc, _ := b["encoding"].(string) + + if enc == "" { + enc = encodingBase64 + } + + if enc != encodingBase64 && enc != encodingText { + return edgeValidation("The Lambda function returned an unsupported body encoding.") + } + + dec, err := decodeEdgeBody(data, enc, maxBody, maxB64) + if err != nil { + return err + } + + res.body, res.replaceBod = dec, true + + return nil +} diff --git a/services/cloudfront/dataplane_edge_flow.go b/services/cloudfront/dataplane_edge_flow.go new file mode 100644 index 0000000000..bebd37454c --- /dev/null +++ b/services/cloudfront/dataplane_edge_flow.go @@ -0,0 +1,365 @@ +package cloudfront + +import ( + "context" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" +) + +const ( + edgeDefaultReadTimeout = 30 + edgeDefaultKeepAlive = 5 +) + +var edgeS3Region = regexp.MustCompile(`\.s3[.-]([a-z0-9-]+)\.amazonaws\.com`) + +func hasRequestBody(m string) bool { + return m != http.MethodGet && m != http.MethodHead && m != http.MethodOptions +} + +func (e *edgeError) write(w http.ResponseWriter, v *viewerReq) { + writeEdgeErrorCache(w, v, e.status, e.msg, e.xcache) +} + +// applyEdgeViewerRequest runs the viewer-request function; it returns true when the response was written. +func (h *Handler) applyEdgeViewerRequest(w http.ResponseWriter, d *Distribution, beh *dpBehavior, v *viewerReq) bool { + a := h.edgeAssoc(beh, eventViewerRequest) + if a == nil { + return false + } + + maxBody := edgeLimits(eventViewerRequest).maxBody + in := edgeVisible(v.r.Header, v.r.Host) + req := edgeRequestObject(v, in, v.r.URL.RawQuery, v.path) + + if a.IncludeBody && hasRequestBody(v.r.Method) { + req[keyBody] = edgeBodyObject(v.body, maxBody) + } + + cf := map[string]any{keyConfig: h.edgeConfig(d, v, eventViewerRequest), keyRequest: req} + + out, ferr := h.edgeInvoke(v.r.Context(), a, eventViewerRequest, cf) + if ferr != nil { + ferr.write(w, v) + + return true + } + + if _, isResp := out["status"]; isResp { + resp, gerr := parseEdgeResponse(out, eventViewerRequest, http.Header{}) + if gerr != nil { + gerr.write(w, v) + + return true + } + + resp.xcache = xCacheEdgeGenerated + h.writeResponse(w, v, nil, &resp, xCacheEdgeGenerated, nil) + + return true + } + + res, perr := parseEdgeRequest(out, in, eventViewerRequest, a.IncludeBody, v.path, v.r.URL.RawQuery) + if perr != nil { + perr.write(w, v) + + return true + } + + res.header.Del("Host") + v.r.Header = res.header + v.path = res.uri + v.rawPath = (&url.URL{Path: res.uri}).EscapedPath() + v.r.URL.Path = res.uri + v.r.URL.RawQuery = res.query + + if res.replaceBod { + v.body = res.body + } + + return false +} + +func edgeOriginObject(o *dpOrigin, viewerScheme string) map[string]any { + hdrs := map[string]any{} + + for _, hp := range o.Headers { + hdrs[strings.ToLower(hp.Name)] = []map[string]string{{"key": hp.Name, keyValue: hp.Value}} + } + + if o.Custom == nil { + auth, region := "none", "" + + if o.S3 != nil && o.S3.OAI != "" { + auth, region = "origin-access-identity", edgeRegion + if m := edgeS3Region.FindStringSubmatch(o.Domain); m != nil { + region = m[1] + } + } + + return map[string]any{"s3": map[string]any{ + "authMethod": auth, "customHeaders": hdrs, "domainName": o.Domain, "path": o.Path, "region": region, + }} + } + + proto, port := protoHTTPS, o.Custom.HTTPSPort + + switch o.Custom.Policy { + case originPolicyHTTP: + proto, port = protoHTTP, o.Custom.HTTPPort + case originPolicyMatch: + if viewerScheme == schemeHTTP { + proto, port = protoHTTP, o.Custom.HTTPPort + } + } + + if port == 0 { + port = defaultHTTPSPort + if proto == protoHTTP { + port = defaultHTTPPort + } + } + + return map[string]any{keyCustom: map[string]any{ + "customHeaders": hdrs, "domainName": o.Domain, "path": o.Path, "port": port, "protocol": proto, + "keepaliveTimeout": max(o.Custom.KeepAlive, edgeDefaultKeepAlive), + "readTimeout": max(o.Custom.ReadTimeout, edgeDefaultReadTimeout), + "responseCompletionTimeout": max(o.Custom.ReadTimeout, edgeDefaultReadTimeout), + "sslProtocols": []string{"TLSv1.2"}, + }} +} + +func numField(m map[string]any, key string) int { + f, _ := m[key].(float64) + + return int(f) +} + +func parseEdgeOrigin(raw any) (*originParams, *edgeError) { + m, ok := raw.(map[string]any) + if !ok { + return nil, edgeValidation("The Lambda function returned an origin that is not an object.") + } + + custom, hasCustom := m[keyCustom].(map[string]any) + s3o, hasS3 := m["s3"].(map[string]any) + + if hasCustom == hasS3 { + return nil, edgeValidation("The Lambda function origin must contain exactly one of custom or s3.") + } + + body := custom + if hasS3 { + body = s3o + } + + domain, _ := body["domainName"].(string) + if domain == "" || strings.Contains(domain, ":") && hasCustom { + return nil, edgeValidation("The Lambda function returned an origin with an invalid domainName.") + } + + p := &originParams{Domain: domain, Headers: map[string]string{}} + + if path, has := body["path"].(string); has { + p.Path = &path + } + + hdrs, herr := parseEdgeHeaders(body["customHeaders"]) + if herr != nil { + return nil, herr + } + + for name, vs := range hdrs { + p.Headers[name] = vs[0] + } + + if hasCustom { + p.Protocol, _ = custom["protocol"].(string) + p.Port = numField(custom, "port") + p.ReadTimeout = numField(custom, "readTimeout") + p.KeepAlive = numField(custom, "keepaliveTimeout") + } + + return p, nil +} + +// originPhase runs the origin-request function, the origin fetch and the origin-response function. +func (h *Handler) originPhase( + ctx context.Context, + d *Distribution, + cfg *dpDistConfig, + beh *dpBehavior, + pol *dpPolicy, + v *viewerReq, + reqPath string, +) originResp { + pv, path, gen, ferr := h.applyEdgeOriginRequest(ctx, d, cfg, beh, pol, v, reqPath) + if ferr != nil { + return ferr.resp() + } + + if gen != nil { + return *gen + } + + resp := h.fetchOrigin(ctx, d, cfg, beh, pol, pv, path) + + return h.applyEdgeOriginResponse(ctx, d, cfg, beh, pv, resp) +} + +func (h *Handler) applyEdgeOriginRequest( + ctx context.Context, + d *Distribution, + cfg *dpDistConfig, + beh *dpBehavior, + pol *dpPolicy, + v *viewerReq, + reqPath string, +) (*viewerReq, string, *originResp, *edgeError) { + a := h.edgeAssoc(beh, eventOriginRequest) + plan := cfg.plan(beh.TargetOriginID, v.origin) + + if a == nil || plan == nil { + return v, reqPath, nil, nil + } + + member := plan.members[0] + + outReq, err := h.outboundRequest(ctx, &member, pol, v, reqPath) + if err != nil { + return v, reqPath, nil, nil + } + + maxBody := edgeLimits(eventOriginRequest).maxBody + query := forwardedQuery(v.r.URL.RawQuery, pol) + in := edgeVisible(outReq.Header, outReq.Host) + req := edgeRequestObject(v, in, query, reqPath) + req["origin"] = edgeOriginObject(&member, v.scheme) + + if a.IncludeBody && hasRequestBody(v.r.Method) { + req[keyBody] = edgeBodyObject(v.body, maxBody) + } + + cf := map[string]any{keyConfig: h.edgeConfig(d, v, eventOriginRequest), keyRequest: req} + + out, ferr := h.edgeInvoke(ctx, a, eventOriginRequest, cf) + if ferr != nil { + return nil, "", nil, ferr + } + + if _, isResp := out["status"]; isResp { + resp, gerr := parseEdgeResponse(out, eventOriginRequest, http.Header{}) + if gerr != nil { + return nil, "", nil, gerr + } + + resp.xcache = xCacheEdgeGenerated + + return v, reqPath, &resp, nil + } + + res, perr := parseEdgeRequest(out, in, eventOriginRequest, a.IncludeBody, reqPath, query) + if perr != nil { + return nil, "", nil, perr + } + + pv := *v + pv.edge = &edgeOverride{header: res.header, query: &res.query} + + if res.replaceBod { + pv.body = res.body + } + + if res.origin != nil && (v.origin == nil || v.origin.Group == nil) { + dir := originDirective{} + if v.origin != nil { + dir = *v.origin + } + + dir.Params = res.origin + pv.origin = &dir + } + + return &pv, res.uri, nil, nil +} + +func (h *Handler) edgeResponseEvent( + ctx context.Context, a *dpEdgeAssoc, d *Distribution, v *viewerReq, eventType string, resp originResp, + origin map[string]any, +) (originResp, *edgeError) { + req := edgeRequestObject(v, edgeVisible(v.r.Header, v.r.Host), v.r.URL.RawQuery, v.path) + if origin != nil { + req["origin"] = origin + } + + in := edgeVisible(resp.header, "") + cf := map[string]any{ + keyConfig: h.edgeConfig(d, v, eventType), + keyRequest: req, + "response": map[string]any{ + "status": strconv.Itoa(resp.status), "statusDescription": http.StatusText(resp.status), + keyHeaders: edgeHeaderMap(in), + }, + } + + out, ferr := h.edgeInvoke(ctx, a, eventType, cf) + if ferr != nil { + return originResp{}, ferr + } + + got, perr := parseEdgeResponse(out, eventType, in) + if perr != nil { + return originResp{}, perr + } + + resp.header = got.header + if _, has := out[keyBody]; has { + resp.body = got.body + } + + if eventType == eventOriginResponse { + resp.status = got.status + } + + return resp, nil +} + +func (h *Handler) applyEdgeOriginResponse( + ctx context.Context, d *Distribution, cfg *dpDistConfig, beh *dpBehavior, v *viewerReq, resp originResp, +) originResp { + a := h.edgeAssoc(beh, eventOriginResponse) + if a == nil { + return resp + } + + var origin map[string]any + + if plan := cfg.plan(beh.TargetOriginID, v.origin); plan != nil { + origin = edgeOriginObject(&plan.members[0], v.scheme) + } + + out, ferr := h.edgeResponseEvent(ctx, a, d, v, eventOriginResponse, resp, origin) + if ferr != nil { + return ferr.resp() + } + + return out +} + +// applyEdgeViewerResponse runs the viewer-response function; CloudFront skips it for statuses of 400 and above. +func (h *Handler) applyEdgeViewerResponse(d *Distribution, beh *dpBehavior, v *viewerReq, resp originResp) originResp { + a := h.edgeAssoc(beh, eventViewerResponse) + if a == nil || resp.status >= http.StatusBadRequest { + return resp + } + + out, ferr := h.edgeResponseEvent(v.r.Context(), a, d, v, eventViewerResponse, resp, nil) + if ferr != nil { + return ferr.resp() + } + + return out +} diff --git a/services/cloudfront/dataplane_function.go b/services/cloudfront/dataplane_function.go index 817f4132a2..5f26a50e0e 100644 --- a/services/cloudfront/dataplane_function.go +++ b/services/cloudfront/dataplane_function.go @@ -84,7 +84,7 @@ func (h *Handler) functionEvent(d *Distribution, v *viewerReq, eventType string, "eventType": eventType, "requestId": v.cfID, }, "viewer": map[string]any{"ip": clientIP(v.r)}, - "request": map[string]any{ + keyRequest: map[string]any{ "method": v.r.Method, "uri": v.path, keyQuerystring: fnValueMap(v.r.URL.Query(), false), "headers": fnValueMap(v.r.Header, true), @@ -174,7 +174,7 @@ const xCacheFromFn = "FunctionGeneratedResponse from cloudfront" func (h *Handler) applyViewerResponse(d *Distribution, beh *dpBehavior, v *viewerReq, resp originResp) originResp { fn := h.viewerFunction(beh, eventViewerResponse) if fn == nil { - return resp + return h.applyEdgeViewerResponse(d, beh, v, resp) } out, res, ok := h.runFunction(fn, eventViewerResponse, h.functionEvent(d, v, eventViewerResponse, &resp), nil) diff --git a/services/cloudfront/dataplane_origin.go b/services/cloudfront/dataplane_origin.go index 8145df99d6..c1d904d4fd 100644 --- a/services/cloudfront/dataplane_origin.go +++ b/services/cloudfront/dataplane_origin.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/url" + "slices" "sort" "strconv" "strings" @@ -116,7 +117,12 @@ func (h *Handler) outboundRequest( } target := scheme + "://" + host + strings.TrimSuffix(o.Path, "/") + rawPath - if q := forwardedQuery(v.r.URL.RawQuery, pol); q != "" { + q := forwardedQuery(v.r.URL.RawQuery, pol) + if v.edge != nil && v.edge.query != nil { + q = *v.edge.query + } + + if q != "" { target += "?" + q } @@ -174,6 +180,30 @@ func setOutboundHeaders(req *http.Request, o *dpOrigin, pol *dpPolicy, v *viewer req.Header.Set(hp.Name, hp.Value) } } + + applyEdgeHeaders(req, v.edge) +} + +// applyEdgeHeaders makes the origin request carry exactly the headers an origin-request function returned. +func applyEdgeHeaders(req *http.Request, e *edgeOverride) { + if e == nil || e.header == nil { + return + } + + for name := range req.Header { + if _, kept := e.header[name]; !kept && !edgeDisallowed(name) { + delete(req.Header, name) + } + } + + for name, vals := range e.header { + switch { + case strings.EqualFold(name, "Host"): + req.Host = vals[0] + case !edgeDisallowed(name) && !isHopByHop(name) && !strings.EqualFold(name, "Content-Length"): + req.Header[name] = slices.Clone(vals) + } + } } func skipOutboundHeader(name string) bool { diff --git a/services/cloudfront/dataplane_s3.go b/services/cloudfront/dataplane_s3.go index b5be4fee49..b27b735eb0 100644 --- a/services/cloudfront/dataplane_s3.go +++ b/services/cloudfront/dataplane_s3.go @@ -26,6 +26,7 @@ var s3HostPattern = regexp.MustCompile(`^(.+?)\.s3[.-]`) type originResp struct { header http.Header + xcache string body []byte status int } diff --git a/services/cloudfront/handler_cache_policies.go b/services/cloudfront/handler_cache_policies.go index a4be4cb65f..e017b9ac07 100644 --- a/services/cloudfront/handler_cache_policies.go +++ b/services/cloudfront/handler_cache_policies.go @@ -240,7 +240,7 @@ func filterByManagedType[T any](typeParam string, managed func(T) bool, items [] switch typeParam { case "managed": return filterSlice(items, managed) - case "custom": + case keyCustom: return filterSlice(items, func(v T) bool { return !managed(v) }) default: return items diff --git a/services/cloudfront/handler_dispatch.go b/services/cloudfront/handler_dispatch.go index 720a5d4467..13b8ae330b 100644 --- a/services/cloudfront/handler_dispatch.go +++ b/services/cloudfront/handler_dispatch.go @@ -912,6 +912,7 @@ var errCodeMapping = []struct { {ErrStreamingDistributionNotDisabled, "StreamingDistributionNotDisabled", http.StatusConflict}, {ErrCNAMEAlreadyExists, "CNAMEAlreadyExists", http.StatusConflict}, {ErrInconsistentQuantities, "InconsistentQuantities", http.StatusBadRequest}, + {ErrInvalidLambdaFunctionAssociation, "InvalidLambdaFunctionAssociation", http.StatusBadRequest}, {ErrValidation, "InvalidArgument", http.StatusBadRequest}, } diff --git a/services/cloudfront/handler_distributions.go b/services/cloudfront/handler_distributions.go index 5179115bf1..e100e1496c 100644 --- a/services/cloudfront/handler_distributions.go +++ b/services/cloudfront/handler_distributions.go @@ -198,6 +198,10 @@ func (h *Handler) handleCreateDistribution(c *echo.Context) error { return h.handleError(c, qErr) } + if eErr := h.validateEdgeAssociations(body); eErr != nil { + return h.handleError(c, eErr) + } + var cfg distributionConfigMinimal if xmlErr := xml.Unmarshal(body, &cfg); xmlErr != nil { return xmlResp( @@ -255,6 +259,10 @@ func (h *Handler) handleUpdateDistribution(c *echo.Context, id string) error { return h.handleError(c, qErr) } + if eErr := h.validateEdgeAssociations(body); eErr != nil { + return h.handleError(c, eErr) + } + var cfg distributionConfigMinimal if xmlErr := xml.Unmarshal(body, &cfg); xmlErr != nil { return xmlResp( @@ -666,6 +674,10 @@ func (h *Handler) handleCreateDistributionWithTags(c *echo.Context) error { []byte("" + req.DistributionConfig.Inner + ""), ) + if eErr := h.validateEdgeAssociations(rawConfig); eErr != nil { + return h.handleError(c, eErr) + } + var cfg distributionConfigMinimal if xmlErr := xml.Unmarshal(rawConfig, &cfg); xmlErr != nil { return xmlResp(c, http.StatusBadRequest, cfErrorXML("MalformedXML", "invalid DistributionConfig XML")) diff --git a/services/cloudfront/iam_enforcement_test.go b/services/cloudfront/iam_enforcement_test.go index 887a9112d0..29da6850d0 100644 --- a/services/cloudfront/iam_enforcement_test.go +++ b/services/cloudfront/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudfront" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -166,7 +167,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cloudfront/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudfront/lambda_edge.go b/services/cloudfront/lambda_edge.go new file mode 100644 index 0000000000..0a30b1c477 --- /dev/null +++ b/services/cloudfront/lambda_edge.go @@ -0,0 +1,158 @@ +package cloudfront + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/awserr" +) + +const ( + eventOriginRequest = "origin-request" + eventOriginResponse = "origin-response" + edgeRegion = "us-east-1" + edgeARNParts = 8 + edgeARNPartsNoVer = 7 +) + +// ErrInvalidLambdaFunctionAssociation is returned for a Lambda@Edge association that breaks the documented rules. +var ErrInvalidLambdaFunctionAssociation = awserr.New("InvalidLambdaFunctionAssociation", awserr.ErrInvalidParameter) + +// ErrEdgeLimitExceeded reports a Lambda@Edge throttle or timeout; the edge answers 503. +var ErrEdgeLimitExceeded = errors.New("lambda@edge limit exceeded") + +// LambdaEdge runs and resolves the Lambda function versions associated with cache behaviors. +type LambdaEdge interface { + // InvokeEdge runs a published version synchronously; fnErr is the Lambda function error type, empty on success. + InvokeEdge(ctx context.Context, name, version string, payload []byte) (out []byte, fnErr string, err error) + // EdgeVersionExists reports whether name has the published numbered version. + EdgeVersionExists(name, version string) bool +} + +type dpEdgeAssoc struct { + ARN string `xml:"LambdaFunctionARN"` + EventType string `xml:"EventType"` + IncludeBody bool `xml:"IncludeBody"` +} + +// SetLambdaEdge wires a resolver for the Lambda backend, called per request so wiring order does not matter. +func (h *Handler) SetLambdaEdge(f func() LambdaEdge) { h.dp.edge.Store(&f) } + +func (h *Handler) lambdaEdge() LambdaEdge { + if f := h.dp.edge.Load(); f != nil { + return (*f)() + } + + return nil +} + +type edgeARN struct{ account, region, name, version string } + +func parseEdgeARN(s string) (edgeARN, bool) { + p := strings.Split(s, ":") + if len(p) != edgeARNParts && len(p) != edgeARNPartsNoVer { + return edgeARN{}, false + } + + if p[0] != "arn" || p[2] != "lambda" || p[5] != "function" || p[6] == "" { + return edgeARN{}, false + } + + a := edgeARN{region: p[3], account: p[4], name: p[6]} + if len(p) == edgeARNParts { + a.version = p[7] + } + + return a, true +} + +func isNumberedVersion(v string) bool { + if v == "" { + return false + } + + for _, c := range v { + if c < '0' || c > '9' { + return false + } + } + + return v != "0" +} + +func edgeInvalid(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidLambdaFunctionAssociation, fmt.Sprintf(format, args...)) +} + +func isRequestEvent(e string) bool { return e == eventViewerRequest || e == eventOriginRequest } + +func isEdgeEvent(e string) bool { + return isRequestEvent(e) || e == eventViewerResponse || e == eventOriginResponse +} + +func (h *Handler) validateEdgeAssociations(raw []byte) error { + cfg, err := parseDistConfig(raw) + if err == nil { + edge := h.lambdaEdge() + behaviors := append([]dpBehavior{cfg.Default}, cfg.Behaviors...) + + for i := range behaviors { + if vErr := validateBehaviorEdge(&behaviors[i], edge, h.Backend.accountID); vErr != nil { + return vErr + } + } + } + + return nil +} + +func validateBehaviorEdge(beh *dpBehavior, edge LambdaEdge, account string) error { + seen := map[string]bool{} + + for i := range beh.Edge { + a := &beh.Edge[i] + if err := validateEdgeAssoc(a, edge, account); err != nil { + return err + } + + if seen[a.EventType] { + return edgeInvalid("more than one Lambda@Edge function is associated with %s", a.EventType) + } + + seen[a.EventType] = true + + for _, f := range beh.Functions { + if f.EventType == a.EventType && (a.EventType == eventViewerRequest || a.EventType == eventViewerResponse) { + return edgeInvalid("a CloudFront function and a Lambda@Edge function cannot share the %s event", + a.EventType) + } + } + } + + return nil +} + +func validateEdgeAssoc(a *dpEdgeAssoc, edge LambdaEdge, account string) error { + arn, ok := parseEdgeARN(a.ARN) + + switch { + case !ok: + return edgeInvalid("%q is not a valid Lambda function ARN", a.ARN) + case arn.region != edgeRegion: + return edgeInvalid("the Lambda function must be in the %s Region", edgeRegion) + case account != "" && arn.account != account: + return edgeInvalid("the Lambda function must be owned by the account that owns the distribution") + case !isNumberedVersion(arn.version): + return edgeInvalid("the Lambda function ARN must reference a numbered version, not $LATEST or an alias") + case !isEdgeEvent(a.EventType): + return edgeInvalid("%q is not a valid Lambda@Edge event type", a.EventType) + case a.IncludeBody && !isRequestEvent(a.EventType): + return edgeInvalid("IncludeBody is only valid for viewer-request and origin-request events") + case edge != nil && !edge.EdgeVersionExists(arn.name, arn.version): + return edgeInvalid("the Lambda function version %s:%s does not exist", arn.name, arn.version) + } + + return nil +} diff --git a/services/cloudfront/lambda_edge_test.go b/services/cloudfront/lambda_edge_test.go new file mode 100644 index 0000000000..8d6be91e34 --- /dev/null +++ b/services/cloudfront/lambda_edge_test.go @@ -0,0 +1,834 @@ +package cloudfront_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + cfsdk "github.com/aws/aws-sdk-go-v2/service/cloudfront" + "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cloudfront" +) + +const ( + edgeAccount = "123456789012" + edgeARNBase = "arn:aws:lambda:us-east-1:" + edgeAccount + ":function:" +) + +var errFakeEdge = errors.New("fake edge failure") + +type edgeFn func(cf map[string]any) (any, string, error) + +type fakeEdge struct { + fns map[string]edgeFn + events map[string][]map[string]any + calls map[string]int + mu sync.Mutex + absent bool +} + +func newFakeEdge(fns map[string]edgeFn) *fakeEdge { + return &fakeEdge{fns: fns, events: map[string][]map[string]any{}, calls: map[string]int{}} +} + +func (f *fakeEdge) InvokeEdge(_ context.Context, name, version string, payload []byte) ([]byte, string, error) { + var in struct { + Records []struct { + CF map[string]any `json:"cf"` + } `json:"Records"` + } + + if err := json.Unmarshal(payload, &in); err != nil || len(in.Records) != 1 { + return nil, "", fmt.Errorf("%w: bad event", errFakeEdge) + } + + if version != "1" { + return nil, "", fmt.Errorf("%w: unexpected version %s", errFakeEdge, version) + } + + var snapshot struct { + Records []struct { + CF map[string]any `json:"cf"` + } `json:"Records"` + } + + _ = json.Unmarshal(payload, &snapshot) + + f.mu.Lock() + f.calls[name]++ + f.events[name] = append(f.events[name], snapshot.Records[0].CF) + fn := f.fns[name] + f.mu.Unlock() + + out, fnErr, err := fn(in.Records[0].CF) + if err != nil || fnErr != "" { + return nil, fnErr, err + } + + b, _ := json.Marshal(out) + + return b, "", nil +} + +func (f *fakeEdge) EdgeVersionExists(_, version string) bool { return !f.absent && version == "1" } + +func (f *fakeEdge) count(name string) int { + f.mu.Lock() + defer f.mu.Unlock() + + return f.calls[name] +} + +func (f *fakeEdge) lastEvent(t *testing.T, name string) map[string]any { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.NotEmpty(t, f.events[name]) + + return f.events[name][len(f.events[name])-1] +} + +func hv(v string) []any { return []any{map[string]any{"value": v}} } + +func sub(cf map[string]any, key string) map[string]any { + m, _ := cf[key].(map[string]any) + + return m +} + +func hdrs(m map[string]any) map[string]any { + h, _ := m["headers"].(map[string]any) + + return h +} + +func passRequest(cf map[string]any) (any, string, error) { return sub(cf, "request"), "", nil } + +func passResponse(cf map[string]any) (any, string, error) { return sub(cf, "response"), "", nil } + +func edgeAssoc(name string, event types.EventType, includeBody bool) types.LambdaFunctionAssociation { + return types.LambdaFunctionAssociation{ + EventType: event, LambdaFunctionARN: aws.String(edgeARNBase + name + ":1"), IncludeBody: aws.Bool(includeBody), + } +} + +func edgeBehavior(cachePolicy string, assocs ...types.LambdaFunctionAssociation) types.DefaultCacheBehavior { + def := behavior("api", cachePolicy) + def.AllowedMethods = allowed(types.MethodGet, types.MethodHead, types.MethodOptions, types.MethodPut, + types.MethodPost, types.MethodPatch, types.MethodDelete) + def.LambdaFunctionAssociations = &types.LambdaFunctionAssociations{ + Quantity: aws.Int32(int32(len(assocs))), Items: assocs, + } + + return def +} + +func TestLambdaEdge_DataPlane(t *testing.T) { + t.Parallel() + + big := strings.Repeat("a", 41<<10) + huge := strings.Repeat("a", 100<<10) + + tests := []struct { + fn edgeFn + wantHeader [2]string + wantOrigin [2]string + wantBody string + method string + reqBody string + event types.EventType + wantXCache string + name string + wantURI string + wantStatus int + wantHits int32 + includeBod bool + }{ + { + name: "viewer_request_rewrites_uri_and_query", event: types.EventTypeViewerRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + r["uri"] = "/rewritten" + r["querystring"] = "a=1" + + return r, "", nil + }, + wantStatus: http.StatusOK, wantURI: "/rewritten?a=1", wantHits: 1, + }, + { + name: "viewer_request_generated_response", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{ + "status": "302", "statusDescription": "Found", "body": "moved", + "headers": map[string]any{"location": hv("/elsewhere")}, + }, "", nil + }, + wantStatus: http.StatusFound, wantBody: "moved", wantHeader: [2]string{"Location", "/elsewhere"}, + wantXCache: "LambdaGeneratedResponse from cloudfront", + }, + { + name: "generated_base64_body", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{ + "status": "200", "bodyEncoding": "base64", "body": base64.StdEncoding.EncodeToString([]byte("hi")), + }, "", nil + }, + wantStatus: http.StatusOK, wantBody: "hi", + }, + { + name: "origin_request_adds_header", event: types.EventTypeOriginRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + hdrs(r)["x-edge"] = hv("added") + + return r, "", nil + }, + wantStatus: http.StatusOK, wantOrigin: [2]string{"X-Edge", "added"}, wantHits: 1, + }, + { + name: "origin_request_rewrites_uri", event: types.EventTypeOriginRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + r["uri"] = "/at-origin" + + return r, "", nil + }, + wantStatus: http.StatusOK, wantURI: "/at-origin", wantHits: 1, + }, + { + name: "origin_request_generated_response", event: types.EventTypeOriginRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{"status": "200", "body": huge[:1000]}, "", nil + }, + wantStatus: http.StatusOK, wantBody: huge[:1000], wantXCache: "LambdaGeneratedResponse from cloudfront", + }, + { + name: "origin_response_adds_header", event: types.EventTypeOriginResponse, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "response") + hdrs(r)["x-resp"] = hv("from-lambda") + + return r, "", nil + }, + wantStatus: http.StatusOK, wantHeader: [2]string{"X-Resp", "from-lambda"}, wantHits: 1, + }, + { + name: "origin_response_replaces_status_and_body", event: types.EventTypeOriginResponse, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "response") + r["status"] = "404" + r["body"] = "gone" + + return r, "", nil + }, + wantStatus: http.StatusNotFound, wantBody: "gone", wantHits: 1, + }, + { + name: "viewer_response_adds_header", event: types.EventTypeViewerResponse, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "response") + hdrs(r)["x-viewer"] = hv("v") + + return r, "", nil + }, + wantStatus: http.StatusOK, wantHeader: [2]string{"X-Viewer", "v"}, wantHits: 1, + }, + { + name: "viewer_response_cannot_change_status", event: types.EventTypeViewerResponse, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "response") + r["status"] = "500" + + return r, "", nil + }, + wantStatus: http.StatusOK, wantHits: 1, + }, + { + name: "viewer_request_read_only_header", event: types.EventTypeViewerRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + hdrs(r)["content-length"] = hv("5") + + return r, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "viewer_request_host_is_read_only", event: types.EventTypeViewerRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + hdrs(r)["host"] = hv("evil.example.com") + + return r, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "origin_request_read_only_header", event: types.EventTypeOriginRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + hdrs(r)["via"] = hv("1.1 forged") + + return r, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "origin_response_read_only_header", event: types.EventTypeOriginResponse, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "response") + hdrs(r)["transfer-encoding"] = hv("chunked") + + return r, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", wantHits: 1, + }, + { + name: "viewer_response_read_only_header", event: types.EventTypeViewerResponse, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "response") + hdrs(r)["content-encoding"] = hv("gzip") + + return r, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", wantHits: 1, + }, + { + name: "disallowed_header", event: types.EventTypeViewerRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + hdrs(r)["x-amz-cf-id"] = hv("forged") + + return r, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "uri_without_leading_slash", event: types.EventTypeViewerRequest, + fn: func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + r["uri"] = "nope" + + return r, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "generated_status_out_of_range", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{"status": "199"}, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "generated_204_with_body", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{"status": "204", "body": "x"}, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "generated_invalid_base64", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{"status": "200", "bodyEncoding": "base64", "body": "!!!"}, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "viewer_generated_body_over_40kb", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{"status": "200", "body": big}, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "origin_generated_body_over_1mb", event: types.EventTypeOriginRequest, + fn: func(map[string]any) (any, string, error) { + return map[string]any{"status": "200", "body": strings.Repeat("a", 1<<20+1)}, "", nil + }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "invalid_result", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { return "just a string", "", nil }, + wantStatus: http.StatusBadGateway, wantXCache: "LambdaValidationError from cloudfront", + }, + { + name: "function_error", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { return nil, "Unhandled", nil }, + wantStatus: http.StatusServiceUnavailable, wantXCache: "LambdaExecutionError from cloudfront", + }, + { + name: "invoke_error", event: types.EventTypeOriginRequest, + fn: func(map[string]any) (any, string, error) { return nil, "", errFakeEdge }, + wantStatus: http.StatusServiceUnavailable, wantXCache: "LambdaExecutionError from cloudfront", + }, + { + name: "limit_exceeded", event: types.EventTypeViewerRequest, + fn: func(map[string]any) (any, string, error) { + return nil, "", cloudfront.ErrEdgeLimitExceeded + }, + wantStatus: http.StatusServiceUnavailable, wantXCache: "LambdaLimitExceeded from cloudfront", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + edge := newFakeEdge(map[string]edgeFn{"fn": tt.fn}) + f.h.SetLambdaEdge(func() cloudfront.LambdaEdge { return edge }) + + origin := newOriginServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(r.RequestURI)) + }) + + def := edgeBehavior(f.cachePolicyQuery(t, 0, 0, types.CachePolicyQueryStringBehaviorAll), + edgeAssoc("fn", tt.event, tt.includeBod)) + d := f.dist(t, distSpec{origins: []types.Origin{customOrigin("api", origin.hostPort())}, def: def}) + + got := f.viewer(t, d, http.MethodGet, "/orig", nil) + assert.Equal(t, tt.wantStatus, got.status, got.body) + assert.Equal(t, tt.wantHits, origin.hits.Load()) + + if tt.wantXCache != "" { + assert.Equal(t, tt.wantXCache, got.xcache()) + } + + if tt.wantBody != "" { + assert.Equal(t, tt.wantBody, got.body) + } + + if tt.wantURI != "" { + assert.Equal(t, tt.wantURI, origin.last(t).uri) + } + + if tt.wantHeader[0] != "" { + assert.Equal(t, tt.wantHeader[1], got.header.Get(tt.wantHeader[0])) + } + + if tt.wantOrigin[0] != "" { + assert.Equal(t, tt.wantOrigin[1], origin.last(t).header.Get(tt.wantOrigin[0])) + } + }) + } +} + +func TestLambdaEdge_IncludeBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + event types.EventType + wantAtOrig string + includeBody bool + replace bool + wantEvBody bool + }{ + { + name: "viewer_read_only", + event: types.EventTypeViewerRequest, + includeBody: true, + wantAtOrig: "payload", + wantEvBody: true, + }, + { + name: "viewer_replace", + event: types.EventTypeViewerRequest, + includeBody: true, + replace: true, + wantAtOrig: "swapped", + wantEvBody: true, + }, + { + name: "origin_replace", + event: types.EventTypeOriginRequest, + includeBody: true, + replace: true, + wantAtOrig: "swapped", + wantEvBody: true, + }, + {name: "not_requested", event: types.EventTypeViewerRequest, wantAtOrig: "payload"}, + { + name: "replace_ignored_without_include", + event: types.EventTypeOriginRequest, + replace: true, + wantAtOrig: "payload", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + edge := newFakeEdge(map[string]edgeFn{"fn": func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + if tt.replace { + r["body"] = map[string]any{ + "action": "replace", "encoding": "text", "data": "swapped", + } + } + + return r, "", nil + }}) + f.h.SetLambdaEdge(func() cloudfront.LambdaEdge { return edge }) + + origin := newOriginServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + def := edgeBehavior(f.cachePolicy(t, 0), edgeAssoc("fn", tt.event, tt.includeBody)) + d := f.dist(t, distSpec{origins: []types.Origin{customOrigin("api", origin.hostPort())}, def: def}) + + got := f.do(t, http.MethodPost, f.base+"/submit", d.domain, strings.NewReader("payload"), nil) + require.Equal(t, http.StatusOK, got.status, got.body) + assert.Equal(t, tt.wantAtOrig, origin.last(t).body) + + body := sub(sub(edge.lastEvent(t, "fn"), "request"), "body") + has := body != nil + + require.Equal(t, tt.wantEvBody, has) + + if has { + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("payload")), body["data"]) + assert.Equal(t, "read-only", body["action"]) + assert.Equal(t, "base64", body["encoding"]) + assert.Equal(t, false, body["inputTruncated"]) + } + }) + } +} + +func TestLambdaEdge_EventShape(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + event types.EventType + fn string + }{ + {name: "viewer_request", event: types.EventTypeViewerRequest, fn: "vreq"}, + {name: "origin_request", event: types.EventTypeOriginRequest, fn: "oreq"}, + {name: "origin_response", event: types.EventTypeOriginResponse, fn: "oresp"}, + {name: "viewer_response", event: types.EventTypeViewerResponse, fn: "vresp"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + fn := passRequest + if strings.HasSuffix(tt.name, "response") { + fn = passResponse + } + + edge := newFakeEdge(map[string]edgeFn{tt.fn: fn}) + f.h.SetLambdaEdge(func() cloudfront.LambdaEdge { return edge }) + + origin := newOriginServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Origin", "yes") + _, _ = w.Write([]byte("ok")) + }) + def := edgeBehavior(f.cachePolicyQuery(t, 0, 0, types.CachePolicyQueryStringBehaviorAll), + edgeAssoc(tt.fn, tt.event, false)) + d := f.dist(t, distSpec{origins: []types.Origin{customOrigin("api", origin.hostPort())}, def: def}) + + got := f.viewer(t, d, http.MethodGet, "/p?q=1", map[string]string{"X-Custom": "c"}) + require.Equal(t, http.StatusOK, got.status, got.body) + + cf := edge.lastEvent(t, tt.fn) + cfg := sub(cf, "config") + assert.Equal(t, d.id, cfg["distributionId"]) + assert.Equal(t, d.domain, cfg["distributionDomainName"]) + assert.Equal(t, string(tt.event), cfg["eventType"]) + assert.NotEmpty(t, cfg["requestId"]) + + req := sub(cf, "request") + assert.Equal(t, "GET", req["method"]) + assert.Equal(t, "q=1", req["querystring"]) + assert.Equal(t, "/p", req["uri"]) + assert.NotEmpty(t, req["clientIp"]) + + xc, _ := hdrs(req)["x-custom"].([]any) + if tt.event == types.EventTypeViewerRequest || tt.event == types.EventTypeViewerResponse { + require.Len(t, xc, 1) + assert.Equal(t, map[string]any{"key": "X-Custom", "value": "c"}, xc[0]) + } + + _, hasOrigin := req["origin"] + assert.Equal(t, strings.HasPrefix(string(tt.event), "origin"), hasOrigin) + + resp := sub(cf, "response") + if strings.HasSuffix(tt.name, "response") { + assert.Equal(t, "200", resp["status"]) + assert.Equal(t, "OK", resp["statusDescription"]) + assert.NotNil(t, hdrs(resp)["x-origin"]) + } else { + assert.Nil(t, resp) + } + }) + } +} + +func TestLambdaEdge_CacheSkipsOriginEvents(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + pass := func(fn edgeFn) edgeFn { return fn } + edge := newFakeEdge(map[string]edgeFn{ + "vreq": pass(passRequest), "oreq": pass(passRequest), "oresp": pass(passResponse), "vresp": pass(passResponse), + }) + f.h.SetLambdaEdge(func() cloudfront.LambdaEdge { return edge }) + + origin := newOriginServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("body")) }) + def := edgeBehavior( + f.cachePolicy(t, 60), + edgeAssoc("vreq", types.EventTypeViewerRequest, false), + edgeAssoc("oreq", types.EventTypeOriginRequest, false), + edgeAssoc( + "oresp", + types.EventTypeOriginResponse, + false, + ), + edgeAssoc("vresp", types.EventTypeViewerResponse, false), + ) + d := f.dist(t, distSpec{origins: []types.Origin{customOrigin("api", origin.hostPort())}, def: def}) + + first := f.viewer(t, d, http.MethodGet, "/cached", nil) + second := f.viewer(t, d, http.MethodGet, "/cached", nil) + + assert.Equal(t, "Miss from cloudfront", first.xcache()) + assert.Equal(t, "Hit from cloudfront", second.xcache()) + assert.Equal(t, int32(1), origin.hits.Load()) + assert.Equal(t, 2, edge.count("vreq")) + assert.Equal(t, 2, edge.count("vresp")) + assert.Equal(t, 1, edge.count("oreq")) + assert.Equal(t, 1, edge.count("oresp")) +} + +func TestLambdaEdge_OriginRequestGeneratedResponseIsCached(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + edge := newFakeEdge(map[string]edgeFn{"oreq": func(map[string]any) (any, string, error) { + return map[string]any{"status": "200", "body": "generated"}, "", nil + }}) + f.h.SetLambdaEdge(func() cloudfront.LambdaEdge { return edge }) + + origin := newOriginServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("origin")) }) + def := edgeBehavior(f.cachePolicy(t, 60), edgeAssoc("oreq", types.EventTypeOriginRequest, false)) + d := f.dist(t, distSpec{origins: []types.Origin{customOrigin("api", origin.hostPort())}, def: def}) + + first := f.viewer(t, d, http.MethodGet, "/g", nil) + second := f.viewer(t, d, http.MethodGet, "/g", nil) + + assert.Equal(t, "generated", first.body) + assert.Equal(t, "generated", second.body) + assert.Equal(t, "Hit from cloudfront", second.xcache()) + assert.Equal(t, 1, edge.count("oreq")) + assert.Zero(t, origin.hits.Load()) +} + +func TestLambdaEdge_OriginRequestSwitchesOrigin(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + other := newOriginServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("other")) }) + edge := newFakeEdge(map[string]edgeFn{"oreq": func(cf map[string]any) (any, string, error) { + r := sub(cf, "request") + custom := sub(sub(r, "origin"), "custom") + _, port, _ := strings.Cut(other.hostPort(), ":") + custom["port"] = float64(atoi32(port)) + custom["protocol"] = "http" + custom["path"] = "/pre" + + return r, "", nil + }}) + f.h.SetLambdaEdge(func() cloudfront.LambdaEdge { return edge }) + + first := newOriginServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("first")) }) + def := edgeBehavior(f.cachePolicy(t, 0), edgeAssoc("oreq", types.EventTypeOriginRequest, false)) + d := f.dist(t, distSpec{origins: []types.Origin{customOrigin("api", first.hostPort())}, def: def}) + + got := f.viewer(t, d, http.MethodGet, "/x", nil) + assert.Equal(t, "other", got.body) + assert.Equal(t, "/pre/x", other.last(t).uri) + assert.Zero(t, first.hits.Load()) + + custom := sub(sub(sub(edge.lastEvent(t, "oreq"), "request"), "origin"), "custom") + assert.Equal(t, "127.0.0.1", custom["domainName"]) + assert.Equal(t, "http", custom["protocol"]) +} + +func TestLambdaEdge_Associations(t *testing.T) { + t.Parallel() + + const validARN = edgeARNBase + "fn:1" + + tests := []struct { + name string + arn string + event types.EventType + cfFn types.EventType + body bool + dup bool + absent bool + update bool + wantError bool + }{ + {name: "valid", arn: validARN, event: types.EventTypeViewerRequest}, + {name: "valid_include_body", arn: validARN, event: types.EventTypeOriginRequest, body: true}, + {name: "valid_on_update", arn: validARN, event: types.EventTypeOriginResponse, update: true}, + {name: "latest", arn: edgeARNBase + "fn:$LATEST", event: types.EventTypeViewerRequest, wantError: true}, + {name: "alias", arn: edgeARNBase + "fn:prod", event: types.EventTypeViewerRequest, wantError: true}, + {name: "unqualified", arn: edgeARNBase + "fn", event: types.EventTypeViewerRequest, wantError: true}, + { + name: "latest_on_update", + arn: edgeARNBase + "fn:$LATEST", + event: types.EventTypeViewerRequest, + update: true, + wantError: true, + }, + { + name: "wrong_region", arn: "arn:aws:lambda:eu-west-1:" + edgeAccount + ":function:fn:1", + event: types.EventTypeViewerRequest, wantError: true, + }, + { + name: "wrong_account", arn: "arn:aws:lambda:us-east-1:999999999999:function:fn:1", + event: types.EventTypeViewerRequest, wantError: true, + }, + {name: "not_an_arn", arn: "fn:1", event: types.EventTypeViewerRequest, wantError: true}, + {name: "bad_event", arn: validARN, event: types.EventType("sometimes"), wantError: true}, + { + name: "body_on_viewer_response", + arn: validARN, + event: types.EventTypeViewerResponse, + body: true, + wantError: true, + }, + { + name: "body_on_origin_response", + arn: validARN, + event: types.EventTypeOriginResponse, + body: true, + wantError: true, + }, + {name: "duplicate_event", arn: validARN, event: types.EventTypeViewerRequest, dup: true, wantError: true}, + {name: "version_missing", arn: validARN, event: types.EventTypeViewerRequest, absent: true, wantError: true}, + { + name: "cf_function_same_viewer_event", arn: validARN, event: types.EventTypeViewerRequest, + cfFn: types.EventTypeViewerRequest, wantError: true, + }, + { + name: "cf_function_on_other_viewer_event", arn: validARN, event: types.EventTypeViewerRequest, + cfFn: types.EventTypeViewerResponse, + }, + { + name: "cf_function_with_origin_event", arn: validARN, event: types.EventTypeOriginRequest, + cfFn: types.EventTypeViewerRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + edge := newFakeEdge(nil) + edge.absent = tt.absent + f.h.SetLambdaEdge(func() cloudfront.LambdaEdge { return edge }) + + origin := newOriginServer(t, func(http.ResponseWriter, *http.Request) {}) + def := behavior("api", f.cachePolicy(t, 0)) + assocs := []types.LambdaFunctionAssociation{{ + EventType: tt.event, LambdaFunctionARN: aws.String(tt.arn), IncludeBody: aws.Bool(tt.body), + }} + + if tt.dup { + assocs = append(assocs, assocs[0]) + } + + def.LambdaFunctionAssociations = &types.LambdaFunctionAssociations{ + Quantity: aws.Int32(int32(len(assocs))), Items: assocs, + } + + if tt.cfFn != "" { + cfARN := publishFn(t, f, "cffn", `function handler(e){return e.request;}`) + def.FunctionAssociations = &types.FunctionAssociations{ + Quantity: aws.Int32(1), + Items: []types.FunctionAssociation{{ + EventType: tt.cfFn, FunctionARN: aws.String(cfARN), + }}, + } + } + + var err error + + if tt.update { + base := f.dist(t, distSpec{ + origins: []types.Origin{ + customOrigin("api", origin.hostPort()), + }, def: behavior("api", f.cachePolicy(t, 0)), + }) + err = updateWithBehavior(t, f.cf, base.id, def) + } else { + err = createWithBehavior(t, f, origin, def) + } + + if !tt.wantError { + require.NoError(t, err) + + return + } + + var bad *types.InvalidLambdaFunctionAssociation + + require.ErrorAs(t, err, &bad) + }) + } +} + +func createWithBehavior(t *testing.T, f *dpFixture, origin *originServer, def types.DefaultCacheBehavior) error { + t.Helper() + + f.callID++ + + _, err := f.cf.CreateDistribution(t.Context(), &cfsdk.CreateDistributionInput{ + DistributionConfig: &types.DistributionConfig{ + CallerReference: aws.String("assoc-" + strings.Repeat("r", f.callID)), + Comment: aws.String("c"), + Enabled: aws.Bool(true), + Origins: &types.Origins{ + Quantity: aws.Int32(1), + Items: []types.Origin{customOrigin("api", origin.hostPort())}, + }, + DefaultCacheBehavior: &def, + }, + }) + + return err +} + +func updateWithBehavior(t *testing.T, c *cfsdk.Client, id string, def types.DefaultCacheBehavior) error { + t.Helper() + + cur, err := c.GetDistributionConfig(t.Context(), &cfsdk.GetDistributionConfigInput{Id: aws.String(id)}) + require.NoError(t, err) + + cur.DistributionConfig.DefaultCacheBehavior = &def + _, err = c.UpdateDistribution(t.Context(), &cfsdk.UpdateDistributionInput{ + Id: aws.String(id), IfMatch: cur.ETag, DistributionConfig: cur.DistributionConfig, + }) + + return err +} diff --git a/services/cloudfrontkeyvaluestore/iam_enforcement_test.go b/services/cloudfrontkeyvaluestore/iam_enforcement_test.go index f67338ffce..fdc8b277d4 100644 --- a/services/cloudfrontkeyvaluestore/iam_enforcement_test.go +++ b/services/cloudfrontkeyvaluestore/iam_enforcement_test.go @@ -14,6 +14,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudfront" "github.com/blackbirdworks/gopherstack/services/cloudfrontkeyvaluestore" "github.com/blackbirdworks/gopherstack/services/iam" @@ -182,7 +183,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cloudfrontkeyvaluestore/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudtrail/iam_enforcement_test.go b/services/cloudtrail/iam_enforcement_test.go index 774a474fc3..dc8efcbc64 100644 --- a/services/cloudtrail/iam_enforcement_test.go +++ b/services/cloudtrail/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudtrail" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cloudtrail/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudwatch/handler_oversized_body_test.go b/services/cloudwatch/handler_oversized_body_test.go index 76a7517516..996bc6ad56 100644 --- a/services/cloudwatch/handler_oversized_body_test.go +++ b/services/cloudwatch/handler_oversized_body_test.go @@ -18,6 +18,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudwatch" ) @@ -56,7 +57,7 @@ func TestRouteMatcher_OversizedFormBodyRoutesInsteadOf404(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "aws-sdk-go2/1.30.0 api/cloudwatch#1.66.3") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudwatch/handler_target_request_test.go b/services/cloudwatch/handler_target_request_test.go index 2c63dd045e..2134903d17 100644 --- a/services/cloudwatch/handler_target_request_test.go +++ b/services/cloudwatch/handler_target_request_test.go @@ -14,6 +14,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudwatch" ) @@ -93,7 +94,7 @@ func doTargetRequest(t *testing.T, srv *httptest.Server, body []byte) *http.Resp require.NoError(t, err) req.Header.Set("X-Amz-Target", "GraniteServiceVersion20100801.ListMetrics") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/services/cloudwatch/iam_enforcement_test.go b/services/cloudwatch/iam_enforcement_test.go index f436928b62..c4d45051d7 100644 --- a/services/cloudwatch/iam_enforcement_test.go +++ b/services/cloudwatch/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudwatch" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cloudwatch/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudwatch/json_target_test.go b/services/cloudwatch/json_target_test.go index 85d4ac6f28..7476b280f0 100644 --- a/services/cloudwatch/json_target_test.go +++ b/services/cloudwatch/json_target_test.go @@ -14,6 +14,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func doJSONTarget(t *testing.T, url, op, body string) (*http.Response, []byte) { @@ -26,7 +28,7 @@ func doJSONTarget(t *testing.T, url, op, body string) (*http.Response, []byte) { req.Header.Set("X-Amz-Target", "GraniteServiceVersion20100801."+op) req.Header.Set("Content-Type", "application/x-amz-json-1.0") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudwatch/put_metric_data_compression_test.go b/services/cloudwatch/put_metric_data_compression_test.go index 088dcbbeb2..573b523619 100644 --- a/services/cloudwatch/put_metric_data_compression_test.go +++ b/services/cloudwatch/put_metric_data_compression_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudwatch" ) @@ -164,7 +165,7 @@ func TestPutMetricData_RawEncodings(t *testing.T) { req.Header.Set("Content-Type", "application/cbor") req.Header.Set("Content-Encoding", tt.encoding) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -204,7 +205,7 @@ func TestPutMetricData_FormGzipRoutes(t *testing.T) { req.Header.Set("Content-Encoding", tt.encoding) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudwatchlogs/iam_enforcement_test.go b/services/cloudwatchlogs/iam_enforcement_test.go index fc32a8e6f6..17bc64b2cd 100644 --- a/services/cloudwatchlogs/iam_enforcement_test.go +++ b/services/cloudwatchlogs/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudwatchlogs" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/logs/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cloudwatchlogs/wire_ptr_leak_test.go b/services/cloudwatchlogs/wire_ptr_leak_test.go index 4119c26815..053fdad3ed 100644 --- a/services/cloudwatchlogs/wire_ptr_leak_test.go +++ b/services/cloudwatchlogs/wire_ptr_leak_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cloudwatchlogs" ) @@ -29,6 +30,7 @@ const ptrLeakLogStream = "stream-1" // ptrLeakCaptureTransport records the raw bytes of the last HTTP response, // then replays them so the real SDK deserializer still sees the full body. type ptrLeakCaptureTransport struct { + rt http.RoundTripper body []byte mu sync.Mutex } @@ -43,7 +45,7 @@ func (c *ptrLeakCaptureTransport) Body() []byte { } func (c *ptrLeakCaptureTransport) Do(req *http.Request) (*http.Response, error) { - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := c.rt.RoundTrip(req) if err != nil { return nil, err } @@ -88,7 +90,7 @@ func newCapturingWireTestCloudWatchLogsClient( ) require.NoError(t, err) - capture := &ptrLeakCaptureTransport{} + capture := &ptrLeakCaptureTransport{rt: testclient.New(t).Transport} client := cwlsdk.NewFromConfig(cfg, func(o *cwlsdk.Options) { o.BaseEndpoint = aws.String(srv.URL) diff --git a/services/codeartifact/handler.go b/services/codeartifact/handler.go index 77d0953c87..1ad712b2be 100644 --- a/services/codeartifact/handler.go +++ b/services/codeartifact/handler.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "maps" "net/http" "strconv" @@ -185,8 +184,13 @@ var errInvalidRequest = errors.New("invalid request") type Handler struct { ops map[string]func(*echo.Context, []byte) error Backend *InMemoryBackend + + maxAssetBytes int64 } +// maxAssetSize is the documented 5 GiB per-asset ceiling. +const maxAssetSize int64 = 5 << 30 + // NewHandler creates a new CodeArtifact handler. func NewHandler(backend *InMemoryBackend) *Handler { @@ -569,7 +573,15 @@ func (h *Handler) Handler() echo.HandlerFunc { log.Debug("codeartifact request", "operation", route.operation, "path", path) - return h.dispatch(c, route, readRequestBody(c, route.operation)) + body, readErr := h.readRequestBody(c, route.operation) + if readErr != nil { + return c.JSON( + http.StatusPaymentRequired, + errResp("ServiceQuotaExceededException", "asset exceeds the maximum asset size of 5 GiB"), + ) + } + + return h.dispatch(c, route, body) } } @@ -601,27 +613,32 @@ func (h *Handler) checkDomainOwner(owner string) error { // not a JSON document -- attempting a JSON decode there would silently discard every // published asset's bytes. Every other op's httpPayload-less body is a JSON document. -func readRequestBody(c *echo.Context, op string) []byte { +func (h *Handler) readRequestBody(c *echo.Context, op string) ([]byte, error) { if c.Request().Body == nil { - return nil + return nil, nil } if op == opPublishPackageVersion { - raw, err := io.ReadAll(c.Request().Body) - if err != nil { - return nil + limit := maxAssetSize + if h.maxAssetBytes > 0 { + limit = h.maxAssetBytes + } + + raw, err := httputils.ReadBodyLimit(c.Request(), limit) + if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge { + return nil, err } - return raw + return raw, nil } decoder := json.NewDecoder(c.Request().Body) var raw json.RawMessage if err := decoder.Decode(&raw); err != nil { - return nil + raw = nil } - return raw + return raw, nil } func (h *Handler) dispatch(c *echo.Context, route codeartifactRoute, body []byte) error { diff --git a/services/codeartifact/iam_enforcement_test.go b/services/codeartifact/iam_enforcement_test.go index 39a6461df9..6e6b702a5a 100644 --- a/services/codeartifact/iam_enforcement_test.go +++ b/services/codeartifact/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/codeartifact" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/codeartifact/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/codeartifact/publish_asset_limit_test.go b/services/codeartifact/publish_asset_limit_test.go new file mode 100644 index 0000000000..4543b8419d --- /dev/null +++ b/services/codeartifact/publish_asset_limit_test.go @@ -0,0 +1,57 @@ +package codeartifact //nolint:testpackage // sets the unexported asset limit. + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPublishPackageVersion_AssetSizeLimit(t *testing.T) { + t.Parallel() + + const limit = 64 + + tests := []struct { + name string + wantCode string + size int + wantStatus int + }{ + {name: "at limit", size: limit, wantStatus: http.StatusNotFound, wantCode: "ResourceNotFoundException"}, + { + name: "over limit", size: limit + 1, + wantStatus: http.StatusPaymentRequired, wantCode: "ServiceQuotaExceededException", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + h := NewHandler(NewInMemoryBackend("000000000000", "us-east-1")) + h.maxAssetBytes = limit + + body := []byte(strings.Repeat("a", tc.size)) + sum := sha256.Sum256(body) + req := httptest.NewRequest( + http.MethodPost, + "/v1/package/version/publish?domain=d&repository=r&format=generic&package=p&version=1&asset=a", + bytes.NewReader(body), + ) + req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(sum[:])) + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.New().NewContext(req, rec))) + + assert.Equal(t, tc.wantStatus, rec.Code) + assert.Contains(t, rec.Body.String(), tc.wantCode) + }) + } +} diff --git a/services/codebuild/iam_enforcement_test.go b/services/codebuild/iam_enforcement_test.go index 8804752678..85d83ec319 100644 --- a/services/codebuild/iam_enforcement_test.go +++ b/services/codebuild/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/codebuild" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -175,7 +176,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/codebuild/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/codecommit/iam_enforcement_test.go b/services/codecommit/iam_enforcement_test.go index a176ab6179..cf08469dc1 100644 --- a/services/codecommit/iam_enforcement_test.go +++ b/services/codecommit/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/codecommit" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/codecommit/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/codeconnections/hosts_vpc_roundtrip_test.go b/services/codeconnections/hosts_vpc_roundtrip_test.go index 0b171281e7..4a4ec19fe0 100644 --- a/services/codeconnections/hosts_vpc_roundtrip_test.go +++ b/services/codeconnections/hosts_vpc_roundtrip_test.go @@ -1,11 +1,9 @@ package codeconnections_test import ( - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" codeconnectionssdk "github.com/aws/aws-sdk-go-v2/service/codeconnections" codeconnectionstypes "github.com/aws/aws-sdk-go-v2/service/codeconnections/types" @@ -13,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/codeconnections" ) @@ -32,20 +31,11 @@ func newTestCodeConnectionsClient(t *testing.T, h *codeconnections.Handler) *cod require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(vpcRoundTripRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return codeconnectionssdk.NewFromConfig(cfg, func(o *codeconnectionssdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + return codeconnectionssdk.NewFromConfig(aws.Config{ + Region: vpcRoundTripRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/codeconnections/iam_enforcement_test.go b/services/codeconnections/iam_enforcement_test.go index 838ddd9866..9ccc03f6fd 100644 --- a/services/codeconnections/iam_enforcement_test.go +++ b/services/codeconnections/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/codeconnections" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/codeconnections/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/codeconnections/lifecycle_validation_test.go b/services/codeconnections/lifecycle_validation_test.go index 1c125ab259..73397b6c13 100644 --- a/services/codeconnections/lifecycle_validation_test.go +++ b/services/codeconnections/lifecycle_validation_test.go @@ -3,6 +3,7 @@ package codeconnections_test import ( "strings" "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -212,17 +213,22 @@ func TestSDK_StatusLifecycle(t *testing.T) { func TestSDK_StatusCompletes(t *testing.T) { t.Parallel() - client, b := newCCFixture(t) - b.SetConnectionHandshakeDelay(20 * time.Millisecond) + synctest.Test(t, func(t *testing.T) { + client, b := newCCFixture(t) + b.SetConnectionHandshakeDelay(20 * time.Millisecond) - conn, err := client.CreateConnection(t.Context(), &ccsdk.CreateConnectionInput{ - ConnectionName: aws.String("c"), ProviderType: cctypes.ProviderTypeGithub, - }) - require.NoError(t, err) + conn, err := client.CreateConnection(t.Context(), &ccsdk.CreateConnectionInput{ + ConnectionName: aws.String("c"), ProviderType: cctypes.ProviderTypeGithub, + }) + require.NoError(t, err) - require.Eventually(t, func() bool { - got, getErr := client.GetConnection(t.Context(), &ccsdk.GetConnectionInput{ConnectionArn: conn.ConnectionArn}) + require.Eventually(t, func() bool { + got, getErr := client.GetConnection( + t.Context(), + &ccsdk.GetConnectionInput{ConnectionArn: conn.ConnectionArn}, + ) - return getErr == nil && got.Connection.ConnectionStatus == cctypes.ConnectionStatusAvailable - }, 5*time.Second, 10*time.Millisecond) + return getErr == nil && got.Connection.ConnectionStatus == cctypes.ConnectionStatusAvailable + }, 5*time.Second, 10*time.Millisecond) + }) } diff --git a/services/codedeploy/iam_enforcement_test.go b/services/codedeploy/iam_enforcement_test.go index 2f9fb066e3..d66dfc5971 100644 --- a/services/codedeploy/iam_enforcement_test.go +++ b/services/codedeploy/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/codedeploy" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/codedeploy/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/codepipeline/PARITY.md b/services/codepipeline/PARITY.md index 2fd257a779..d9f8965a2d 100644 --- a/services/codepipeline/PARITY.md +++ b/services/codepipeline/PARITY.md @@ -139,6 +139,9 @@ families: approvalGate: {status: ok, note: "NEW in the 2026-07-23 pass: StartPipelineExecution/PutActionRevision/PutApprovalResult/RetryStageExecution/RollbackStage all now share one action-run engine (action_engine.go) that gates on Approval-category actions with a real system-generated token, exposed only via GetPipelineState (matching real AWS -- there is no other way for a real client to obtain it). This closed 3 of the 4 gaps and all 3 deferred items from the 2026-07-12 audit at once, since they all stemmed from the SAME missing action-state machine. Unchanged, not re-diffed this pass."} gaps: [] items_still_open: + - "Source/CodeStarSourceConnection always FAILS the action (ConfigurationError): the emulator's codeconnections service stores connection metadata only and holds no GitHub/Bitbucket/GitLab repository content to build an artifact from. Source/CodeCommit supports OutputArtifactFormat CODE_ZIP only (CODEBUILD_CLONE_REF fails)." + - "No automatic source-change detection: PollForSourceChanges and EventBridge-driven triggers for S3/ECR/CodeCommit sources are not modelled; executions start via StartPipelineExecution (or an EventBridge/Scheduler target). Source/ECR therefore always reads the current tag at execution start and ignores source-revision overrides." + - "Deploy/ECS completion is read from the ECS backend's per-deployment rolloutState (a single remaining deployment with rollout COMPLETED, falling back to runningCount >= desiredCount); the emulator's ECS reconciler takes ~10s to settle a one-task service. DeploymentTimeout (default 60 min) is enforced against the backend clock (SetClock). Deploy/CloudFormation ignores RoleArn for authorization and writes OutputFileName as the only file in the output artifact (real CodePipeline's artifact layout for it is undocumented beyond 'a JSON file with the specified name'). Fn::GetArtifactAtt URL is https://s3..amazonaws.com//." - "gopherstack-wlab (2026-09-08, reconfirmed 2026-09-18): CreateCustomActionType/DeleteCustomActionType/DeletePipeline/UpdatePipeline/OverrideStageCondition/RetryStageExecution/StopPipelineExecution each emit a wire error code (InvalidStructureException/ActionTypeNotFoundException/PipelineNotFoundException x2/PipelineExecutionNotFoundException x3) absent from that op's own declared set per botocore codepipeline/2015-07-09/service-2.json; every declared candidate's doc text was checked and none fits, and a real client's errors.As still resolves each to its own concrete type (undeclared_error_codes_test.go). The real code is not determinable from the SDK." - "No AWS-documented derivation or evidence: ListRuleTypes omits RuleType.InputArtifactDetails (no documented per-provider min/max counts)." - "handleError's InvalidActionException (unknown-action routing fallback) names no type codepipeline@v1.49.4 declares; see the 2026-08-29 errcodeaudit note." @@ -148,7 +151,7 @@ structural_gaps: - "JobData/ThirdPartyJobData omit ArtifactCredentials, InputArtifacts, OutputArtifacts and EncryptionKey: no artifact store or STS exists to issue them." - "ListDeployActionExecutionTargets returns empty: no real deploy targets exist behind Deploy actions." - "ListWebhookItem.ErrorCode/ErrorMessage are never set: webhook registration against a third party cannot fail in an emulator." - - "Built-in action providers other than Build/CodeBuild, Invoke/Lambda, Deploy/CodeDeploy and Source/Deploy S3 (ECR, CodeCommit, ...) always Succeed in runOneAction: no real source/deploy targets. Custom-owner actions are real: a Queued job is created for the worker (PollForJobs/Put*JobResult)." + - "Built-in action providers the emulator does not implement (Build/ECRBuildAndPublish, Test/DeviceFarm, Deploy/ElasticBeanstalk, ServiceCatalog, AppConfig, OpsWorks, Alexa, CodeDeployToECS, CloudFormationStackSet, CloudFormationStackInstances, EKS, EC2, Invoke/StepFunctions, Invoke/InspectorScan, Compute/Commands) FAIL the action with ConfigurationError 'not supported by this emulator' rather than succeeding. Implemented built-ins: Manual approval, CodeBuild, Lambda, CodeDeploy, S3 source/deploy, CloudFormation deploy, ECS deploy, ECR source, CodeCommit source. Custom-owner actions are real: a Queued job is created for the worker (PollForJobs/Put*JobResult)." - "Build/CodeBuild waits for the real build status, but the emulated build runs no buildspec: the primary input artifact is passed through unchanged as every output artifact, and exported variables are not produced. Source/S3 and Deploy/S3 move real objects when the S3 seam is wired (internal/app/cli.go wireCodePipelineS3); the artifact store key is derived from pipeline, artifact name and execution ID. A missing source object fails the action with ConfigurationError (code chosen from the FailureType enum; the docs name none)." deferred: - "Subsystem-gated work: see items_still_open." @@ -657,3 +660,18 @@ GetPipelineState now returns stage-level latestExecution ({pipelineExecutionId, ## 2026-10-12 (deep realism pass) ListActionExecutions returns each action's `input` (actionTypeId, configuration, region, roleArn, namespace) from the pipeline declaration and the execution's pipelineVersion (`TestListActionExecutions_InputAndVersion`). Still open, needing internal/app/cli.go wiring: Source/S3 and Deploy/S3 actions do not touch S3 (no revision ids, no artifacts written), and Build/CodeBuild succeeds on StartBuild acceptance instead of waiting for the build's final status; both need a status-returning CodeBuild seam and an S3 seam passed in from internal/app/cli.go. + +### 2026-10-27: built-in action engine (gopherstack-49qct.67) + +Unknown AWS-owned action types (category/provider/version not in the action structure reference, or a +CloudFormation ActionMode outside the five documented values) are rejected at CreatePipeline/UpdatePipeline with +InvalidActionDeclarationException; required-configuration keys are not enforced at declaration time. At run time +any AWS-owned provider the emulator does not implement fails with ConfigurationError instead of the previous silent +Succeeded. New real providers: Deploy/CloudFormation (all five ActionModes, TemplatePath/TemplateConfiguration, +ParameterOverrides with Fn::GetParam/Fn::GetArtifactAtt, Capabilities, OutputFileName, stack outputs as output +variables; waits for the stack's terminal status and fails with its status reason), Deploy/ECS (imagedefinitions.json +-> new task definition revision -> UpdateService -> wait for rollout), Source/ECR (imageDetail.json per the +file-reference docs, output variables RegistryId/RepositoryName/ImageTag/ImageDigest/ImageURI), Source/CodeCommit +(branch head files as a zip, CommitId/BranchName/RepositoryName/CommitMessage variables). Backends are wired in +internal/app/codepipeline_action_wiring.go. Tests: internal/app/codepipeline_actions_test.go (SDK) and +services/codepipeline/async_builtin_actions_test.go. diff --git a/services/codepipeline/README.md b/services/codepipeline/README.md index 73cda1bc22..01ad38e478 100644 --- a/services/codepipeline/README.md +++ b/services/codepipeline/README.md @@ -9,13 +9,16 @@ | --- | --- | | PARITY entries audited | 22 (17 ok, 4 partial, 1 gap) | | Feature families | 6 (6 ok) | -| Known gaps | 3 | +| Known gaps | 6 | | Structural gaps (can't be emulated) | 7 | | Deferred items | 1 | | Resource leaks | clean | ### Known gaps +- Source/CodeStarSourceConnection always FAILS the action (ConfigurationError): the emulator's codeconnections service stores connection metadata only and holds no GitHub/Bitbucket/GitLab repository content to build an artifact from. Source/CodeCommit supports OutputArtifactFormat CODE_ZIP only (CODEBUILD_CLONE_REF fails). +- No automatic source-change detection: PollForSourceChanges and EventBridge-driven triggers for S3/ECR/CodeCommit sources are not modelled; executions start via StartPipelineExecution (or an EventBridge/Scheduler target). Source/ECR therefore always reads the current tag at execution start and ignores source-revision overrides. +- Deploy/ECS completion is read from the ECS backend's per-deployment rolloutState (a single remaining deployment with rollout COMPLETED, falling back to runningCount >= desiredCount); the emulator's ECS reconciler takes ~10s to settle a one-task service. DeploymentTimeout (default 60 min) is enforced against the backend clock (SetClock). Deploy/CloudFormation ignores RoleArn for authorization and writes OutputFileName as the only file in the output artifact (real CodePipeline's artifact layout for it is undocumented beyond 'a JSON file with the specified name'). Fn::GetArtifactAtt URL is https://s3..amazonaws.com//. - gopherstack-wlab (2026-09-08, reconfirmed 2026-09-18): CreateCustomActionType/DeleteCustomActionType/DeletePipeline/UpdatePipeline/OverrideStageCondition/RetryStageExecution/StopPipelineExecution each emit a wire error code (InvalidStructureException/ActionTypeNotFoundException/PipelineNotFoundException x2/PipelineExecutionNotFoundException x3) absent from that op's own declared set per botocore codepipeline/2015-07-09/service-2.json; every declared candidate's doc text was checked and none fits, and a real client's errors.As still resolves each to its own concrete type (undeclared_error_codes_test.go). The real code is not determinable from the SDK. - No AWS-documented derivation or evidence: ListRuleTypes omits RuleType.InputArtifactDetails (no documented per-provider min/max counts). - handleError's InvalidActionException (unknown-action routing fallback) names no type codepipeline@v1.49.4 declares; see the 2026-08-29 errcodeaudit note. @@ -29,7 +32,7 @@ These do not block an A grade — no implementation could produce real data here - JobData/ThirdPartyJobData omit ArtifactCredentials, InputArtifacts, OutputArtifacts and EncryptionKey: no artifact store or STS exists to issue them. - ListDeployActionExecutionTargets returns empty: no real deploy targets exist behind Deploy actions. - ListWebhookItem.ErrorCode/ErrorMessage are never set: webhook registration against a third party cannot fail in an emulator. -- Built-in action providers other than Build/CodeBuild, Invoke/Lambda, Deploy/CodeDeploy and Source/Deploy S3 (ECR, CodeCommit, ...) always Succeed in runOneAction: no real source/deploy targets. Custom-owner actions are real: a Queued job is created for the worker (PollForJobs/Put*JobResult). +- Built-in action providers the emulator does not implement (Build/ECRBuildAndPublish, Test/DeviceFarm, Deploy/ElasticBeanstalk, ServiceCatalog, AppConfig, OpsWorks, Alexa, CodeDeployToECS, CloudFormationStackSet, CloudFormationStackInstances, EKS, EC2, Invoke/StepFunctions, Invoke/InspectorScan, Compute/Commands) FAIL the action with ConfigurationError 'not supported by this emulator' rather than succeeding. Implemented built-ins: Manual approval, CodeBuild, Lambda, CodeDeploy, S3 source/deploy, CloudFormation deploy, ECS deploy, ECR source, CodeCommit source. Custom-owner actions are real: a Queued job is created for the worker (PollForJobs/Put*JobResult). - Build/CodeBuild waits for the real build status, but the emulated build runs no buildspec: the primary input artifact is passed through unchanged as every output artifact, and exported variables are not produced. Source/S3 and Deploy/S3 move real objects when the S3 seam is wired (internal/app/cli.go wireCodePipelineS3); the artifact store key is derived from pipeline, artifact name and execution ID. A missing source object fails the action with ConfigurationError (code chosen from the FailureType enum; the docs name none). ### Deferred diff --git a/services/codepipeline/action_catalog.go b/services/codepipeline/action_catalog.go new file mode 100644 index 0000000000..2b235514b2 --- /dev/null +++ b/services/codepipeline/action_catalog.go @@ -0,0 +1,88 @@ +package codepipeline + +import "fmt" + +const ( + actionProviderCloudFormation = "CloudFormation" + actionProviderECS = "ECS" + actionProviderECR = "ECR" + actionProviderCodeCommit = "CodeCommit" + actionProviderCodeStar = "CodeStarSourceConnection" + + awsActionVersion = "1" +) + +// awsActionKnown reports whether category/provider is an AWS-owned action type from the CodePipeline action +// structure reference. +func awsActionKnown(category, provider string) bool { + switch category + "/" + provider { + case "Source/S3", "Source/CodeCommit", "Source/ECR", "Source/CodeStarSourceConnection", + "Build/CodeBuild", "Build/ECRBuildAndPublish", + "Test/CodeBuild", "Test/DeviceFarm", + "Deploy/S3", "Deploy/CloudFormation", "Deploy/CodeDeploy", "Deploy/ECS", "Deploy/CodeDeployToECS", + "Deploy/ElasticBeanstalk", "Deploy/ServiceCatalog", "Deploy/AppConfig", "Deploy/OpsWorks", "Deploy/Alexa", + "Deploy/CloudFormationStackSet", "Deploy/CloudFormationStackInstances", "Deploy/EKS", "Deploy/EC2", + "Invoke/Lambda", "Invoke/StepFunctions", "Invoke/InspectorScan", + "Approval/Manual", "Compute/Commands": + return true + } + + return false +} + +func validCFNActionMode(mode string) bool { + switch mode { + case cfnModeCreateUpdate, cfnModeReplaceOnFailure, cfnModeDeleteOnly, cfnModeChangeSetReplace, + cfnModeChangeSetExecute: + return true + } + + return false +} + +// validateActionDeclarations rejects AWS-owned actions whose type real CodePipeline refuses with +// InvalidActionDeclarationException. Custom and ThirdParty owners are validated elsewhere. +func validateActionDeclarations(stages []Stage) error { + for _, s := range stages { + for _, a := range s.Actions { + if err := validateAWSAction(s.Name, a); err != nil { + return err + } + } + } + + return nil +} + +func validateAWSAction(stage string, a Action) error { + id := a.ActionTypeID + if id.Owner != actionOwnerAWS { + return nil + } + + if !awsActionKnown(id.Category, id.Provider) || id.Version != awsActionVersion { + return fmt.Errorf( + "%w: ActionType (Category: '%s', Owner: '%s', Provider: '%s', Version: '%s') is not a valid action type "+ + "(action '%s' in stage '%s')", + ErrInvalidActionDeclaration, id.Category, id.Owner, id.Provider, id.Version, a.Name, stage) + } + + if id.Provider == actionProviderCloudFormation && id.Category == actionCategoryDeploy && + !validCFNActionMode(a.Configuration["ActionMode"]) { + return fmt.Errorf("%w: Action configuration for action '%s' has invalid ActionMode '%s'", + ErrInvalidActionDeclaration, a.Name, a.Configuration["ActionMode"]) + } + + return nil +} + +// legacyUnwiredProvider reports the providers whose actions historically completed instantly when their backend seam +// is unwired; they keep that behaviour so partially wired backends still run. +func legacyUnwiredProvider(provider string) bool { + switch provider { + case actionProviderCodeBuild, actionProviderLambda, actionProviderCodeDeploy, actionProviderS3: + return true + } + + return false +} diff --git a/services/codepipeline/action_dispatch.go b/services/codepipeline/action_dispatch.go new file mode 100644 index 0000000000..d29f9e962d --- /dev/null +++ b/services/codepipeline/action_dispatch.go @@ -0,0 +1,45 @@ +package codepipeline + +// runBuiltinAction runs an AWS-owned action none of the seam-specific cases in runOneAction took. Providers the +// emulator implements are dispatched; the legacy ones whose seam is unwired keep completing instantly; anything +// else fails rather than reporting work that never happened. +func (b *InMemoryBackend) runBuiltinAction( + region string, + p *Pipeline, + execID string, + action Action, + ae *ActionExecution, +) { + ctx := b.regionContext(region) + id := action.ActionTypeID + + switch { + case id.Category == actionCategoryDeploy && id.Provider == actionProviderCloudFormation: + b.runCloudFormationAction(ctx, p, execID, action, ae) + case id.Category == actionCategoryDeploy && id.Provider == actionProviderECS: + b.runECSAction(ctx, p, execID, action, ae) + case id.Category == actionCategorySource && id.Provider == actionProviderECR: + b.runECRSourceAction(ctx, p, execID, action, ae) + case id.Category == actionCategorySource && id.Provider == actionProviderCodeCommit: + b.runCodeCommitSourceAction(ctx, p, execID, action, ae) + case id.Category == actionCategorySource && id.Provider == actionProviderCodeStar: + runCodeStarSourceAction(action, ae) + case legacyUnwiredProvider(id.Provider): + default: + if awsActionKnown(id.Category, id.Provider) { + failActionf(ae, failureConfigurationError, + "Action type %s/%s/%s is not supported by this emulator", id.Category, id.Provider, id.Version) + } else { + failActionf(ae, failureConfigurationError, + "Action type %s/%s/%s/%s is not a valid CodePipeline action type", + id.Category, id.Owner, id.Provider, id.Version) + } + } +} + +// isAsyncBuiltin reports whether action is an AWS-owned action that stays InProgress until reconcileAsyncActions +// observes its backend resource settle. +func isAsyncBuiltin(action Action) bool { + return action.ActionTypeID.Category == actionCategoryDeploy && + (isBuiltinAction(action, actionProviderCloudFormation) || isBuiltinAction(action, actionProviderECS)) +} diff --git a/services/codepipeline/action_engine.go b/services/codepipeline/action_engine.go index 58ce476df1..2f041cca1f 100644 --- a/services/codepipeline/action_engine.go +++ b/services/codepipeline/action_engine.go @@ -193,9 +193,10 @@ func resolvedActionStatus(byKey map[string]*ActionExecution, stageName, actionNa // actions gate the run (InProgress + a fresh token); a built-in Build/ // CodeBuild, Invoke/Lambda, or Deploy/CodeDeploy action calls its wired // backend and fails the action if that call does (runCodeBuildAction/ -// runLambdaAction/runCodeDeployAction); every other action, and any of -// those three when unwired, completes immediately (Succeeded). Callers -// must hold b.mu.Lock. +// runLambdaAction/runCodeDeployAction); other AWS-owned providers are +// dispatched by runBuiltinAction, which fails what the emulator does not +// implement. Non-AWS non-job-worker actions and the legacy providers when +// unwired complete immediately (Succeeded). Callers must hold b.mu.Lock. func (b *InMemoryBackend) runOneAction( region, pipelineName, executionID, stageName string, action Action, @@ -230,6 +231,8 @@ func (b *InMemoryBackend) runOneAction( ae.Status = b.runLambdaAction(b.regionContext(region), action) case isBuiltinAction(action, actionProviderCodeDeploy) && b.codeDeployBackend != nil: ae.Status = b.runCodeDeployAction(b.regionContext(region), action) + case action.ActionTypeID.Owner == actionOwnerAWS: + b.runBuiltinAction(region, b.pipelineOf(region, pipelineName), executionID, action, ae) } store := b.actionExecutionsStore(region) diff --git a/services/codepipeline/action_wiring.go b/services/codepipeline/action_wiring.go new file mode 100644 index 0000000000..8c75155603 --- /dev/null +++ b/services/codepipeline/action_wiring.go @@ -0,0 +1,51 @@ +package codepipeline + +import "time" + +// SetStackDeployer wires CloudFormation for Deploy/CloudFormation actions. +func (b *InMemoryBackend) SetStackDeployer(d StackDeployer) { + b.mu.Lock("SetStackDeployer") + defer b.mu.Unlock() + + b.stackDeployer = d +} + +// SetECSDeployer wires ECS for Deploy/ECS actions. +func (b *InMemoryBackend) SetECSDeployer(d ECSDeployer) { + b.mu.Lock("SetECSDeployer") + defer b.mu.Unlock() + + b.ecsDeployer = d +} + +// SetImageSource wires ECR for Source/ECR actions. +func (b *InMemoryBackend) SetImageSource(s ImageSource) { + b.mu.Lock("SetImageSource") + defer b.mu.Unlock() + + b.imageSource = s +} + +// SetRepositoryReader wires CodeCommit for Source/CodeCommit actions. +func (b *InMemoryBackend) SetRepositoryReader(r RepositoryReader) { + b.mu.Lock("SetRepositoryReader") + defer b.mu.Unlock() + + b.repositoryReader = r +} + +// SetClock overrides the time source used for action timeouts; nil restores time.Now. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + + b.clock = now +} + +func (b *InMemoryBackend) now() time.Time { + if b.clock != nil { + return b.clock().UTC() + } + + return time.Now().UTC() +} diff --git a/services/codepipeline/artifact_files.go b/services/codepipeline/artifact_files.go new file mode 100644 index 0000000000..6240aa7913 --- /dev/null +++ b/services/codepipeline/artifact_files.go @@ -0,0 +1,137 @@ +package codepipeline + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "sort" + "strings" +) + +const artifactRefSeparator = "::" + +// zipFiles builds a zip artifact from name->content. +func zipFiles(files map[string][]byte) ([]byte, error) { + names := make([]string, 0, len(files)) + for n := range files { + names = append(names, n) + } + + sort.Strings(names) + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + + for _, n := range names { + w, err := zw.Create(n) + if err != nil { + return nil, err + } + + if _, err = w.Write(files[n]); err != nil { + return nil, err + } + } + + if err := zw.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// zipEntry returns one file from a zip artifact. +func zipEntry(data []byte, name string) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, errArtifactNotZip + } + + for _, f := range zr.File { + if f.Name != strings.TrimPrefix(name, "/") { + continue + } + + rc, openErr := f.Open() + if openErr != nil { + return nil, openErr + } + + defer rc.Close() + + return io.ReadAll(io.LimitReader(rc, maxExtractedBytes)) + } + + return nil, fmt.Errorf("%w: file %q not found in artifact", ErrValidation, name) +} + +// artifactFile reads file from the named input artifact of action. +func (b *InMemoryBackend) artifactFile( + ctx context.Context, p *Pipeline, execID string, action Action, artifact, file string, +) ([]byte, error) { + if b.artifactS3 == nil { + return nil, fmt.Errorf("%w: no artifact store is available", ErrValidation) + } + + found := false + + for _, in := range action.InputArtifacts { + if in.Name == artifact { + found = true + + break + } + } + + if !found { + return nil, fmt.Errorf("%w: %q is not an input artifact of action %q", ErrValidation, artifact, action.Name) + } + + data, err := b.getArtifact(ctx, p, action, execID, artifact) + if err != nil { + return nil, err + } + + return zipEntry(data, file) +} + +// artifactRef reads an "ArtifactName::path/to/file" reference. +func (b *InMemoryBackend) artifactRef( + ctx context.Context, p *Pipeline, execID string, action Action, ref string, +) ([]byte, error) { + artifact, file, ok := strings.Cut(ref, artifactRefSeparator) + if !ok || artifact == "" || file == "" { + return nil, fmt.Errorf("%w: %q is not of the form ArtifactName::FileName", ErrValidation, ref) + } + + return b.artifactFile(ctx, p, execID, action, artifact, file) +} + +// putOutputFiles writes files as every output artifact of action. +func (b *InMemoryBackend) putOutputFiles( + ctx context.Context, p *Pipeline, execID string, action Action, files map[string][]byte, +) error { + if len(action.OutputArtifacts) == 0 { + return nil + } + + if b.artifactS3 == nil { + return fmt.Errorf("%w: no artifact store is available", ErrValidation) + } + + data, err := zipFiles(files) + if err != nil { + return err + } + + for _, out := range action.OutputArtifacts { + if err = b.putArtifact(ctx, p, action, execID, out.Name, data); err != nil { + return err + } + } + + return nil +} diff --git a/services/codepipeline/async_builtin_actions_test.go b/services/codepipeline/async_builtin_actions_test.go new file mode 100644 index 0000000000..a945dce975 --- /dev/null +++ b/services/codepipeline/async_builtin_actions_test.go @@ -0,0 +1,257 @@ +package codepipeline_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awserr" + "github.com/blackbirdworks/gopherstack/services/codepipeline" +) + +// scriptedStacks is a StackDeployer whose DescribeStack walks through a fixed status script. +type scriptedStacks struct { + codepipeline.StackDeployer + + script []codepipeline.StackState + mu sync.Mutex + deleted int + calls int +} + +func (s *scriptedStacks) DescribeStack(context.Context, string) (*codepipeline.StackState, error) { + s.mu.Lock() + defer s.mu.Unlock() + + i := min(s.calls, len(s.script)-1) + s.calls++ + st := s.script[i] + + return &st, nil +} + +func (s *scriptedStacks) DeleteStack(context.Context, string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.deleted++ + + return nil +} + +type memArtifacts struct { + data map[string][]byte + seed []byte + mu sync.Mutex +} + +func (m *memArtifacts) PutObject(_ context.Context, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) { + body, err := io.ReadAll(in.Body) + if err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.data[aws.ToString(in.Bucket)+"/"+aws.ToString(in.Key)] = body + + return &s3.PutObjectOutput{}, nil +} + +func (m *memArtifacts) GetObject(_ context.Context, in *s3.GetObjectInput) (*s3.GetObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + + body, ok := m.data[aws.ToString(in.Bucket)+"/"+aws.ToString(in.Key)] + if !ok && m.seed != nil { + body, ok = m.seed, true + } + + if !ok { + return nil, awserr.ErrNotFound + } + + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(body))}, nil +} + +type stuckECS struct { + codepipeline.ECSDeployer +} + +func (stuckECS) DescribeService(context.Context, string, string) (*codepipeline.ECSServiceState, error) { + return &codepipeline.ECSServiceState{ + TaskDefinition: "td:1", + Deployments: []codepipeline.ECSDeploymentState{ + {TaskDefinition: "td:2", Status: "PRIMARY", RolloutState: "IN_PROGRESS"}, + {TaskDefinition: "td:1", Status: "ACTIVE", RolloutState: "COMPLETED"}, + }, + }, nil +} + +func (stuckECS) RegisterRevisionWithImages(context.Context, string, map[string]string) (string, error) { + return "td:2", nil +} + +func (stuckECS) UpdateServiceTaskDefinition(context.Context, string, string, string) error { + return nil +} + +func builtinPipeline(name string, action codepipeline.Action) codepipeline.PipelineDeclaration { + return codepipeline.PipelineDeclaration{ + Name: name, + RoleArn: "arn:aws:iam::000000000000:role/p", + ArtifactStore: codepipeline.ArtifactStore{Type: "S3", Location: "art"}, + Stages: []codepipeline.Stage{{Name: "Deploy", Actions: []codepipeline.Action{action}}}, + } +} + +func awsDeploy(provider string, cfg map[string]string, inputs ...string) codepipeline.Action { + a := codepipeline.Action{ + Name: "Act", + ActionTypeID: codepipeline.ActionTypeID{ + Category: "Deploy", Owner: "AWS", Provider: provider, Version: "1", + }, + Configuration: cfg, + } + + for _, in := range inputs { + a.InputArtifacts = append(a.InputArtifacts, codepipeline.ArtifactRef{Name: in}) + } + + return a +} + +func TestBuiltinActions_WaitForBackendState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantStatus string + wantMsg string + script []codepipeline.StackState + wantDelete int + }{ + { + name: "delete waits then succeeds", + script: []codepipeline.StackState{ + {Status: "CREATE_COMPLETE"}, {Status: "DELETE_IN_PROGRESS"}, {Status: "DELETE_COMPLETE"}, + }, + wantStatus: "Succeeded", + wantDelete: 1, + }, + { + name: "delete failure surfaces stack reason", + script: []codepipeline.StackState{ + {Status: "CREATE_COMPLETE"}, {Status: "DELETE_IN_PROGRESS"}, + {Status: "DELETE_FAILED", Reason: "bucket not empty"}, + }, + wantStatus: "Failed", + wantMsg: "bucket not empty", + wantDelete: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newTestHandler(t) + stacks := &scriptedStacks{script: tt.script} + h.Backend.SetStackDeployer(stacks) + + ctx := t.Context() + decl := builtinPipeline("p", awsDeploy("CloudFormation", + map[string]string{"ActionMode": "DELETE_ONLY", "StackName": "s"})) + _, err := h.Backend.CreatePipeline(ctx, decl, nil) + require.NoError(t, err) + + exec, err := h.Backend.StartPipelineExecution(ctx, "p") + require.NoError(t, err) + assert.Equal(t, "InProgress", exec.Status, "action waits for the stack") + + require.Eventually(t, func() bool { + got, gErr := h.Backend.GetPipelineExecution(ctx, "p", exec.PipelineExecutionID) + + return gErr == nil && got.Status != "InProgress" + }, 10*time.Second, 20*time.Millisecond) + + got, err := h.Backend.GetPipelineExecution(ctx, "p", exec.PipelineExecutionID) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + + stacks.mu.Lock() + assert.Equal(t, tt.wantDelete, stacks.deleted) + stacks.mu.Unlock() + + if tt.wantMsg != "" { + details, lErr := h.Backend.ListActionExecutions(ctx, "p", exec.PipelineExecutionID) + require.NoError(t, lErr) + + raw, mErr := json.Marshal(details) + require.NoError(t, mErr) + assert.Contains(t, string(raw), tt.wantMsg) + } + }) + } +} + +func TestECSDeploy_TimesOutOnInjectedClock(t *testing.T) { + t.Parallel() + + h := newTestHandler(t) + arts := &memArtifacts{data: map[string][]byte{}} + h.Backend.SetArtifactS3(arts) + h.Backend.SetECSDeployer(stuckECS{}) + + now := time.Now() + var mu sync.Mutex + + h.Backend.SetClock(func() time.Time { + mu.Lock() + defer mu.Unlock() + + return now + }) + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + w, err := zw.Create("imagedefinitions.json") + require.NoError(t, err) + _, err = w.Write([]byte(`[{"name":"web","imageUri":"nginx:2"}]`)) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + ctx := t.Context() + decl := builtinPipeline("p", awsDeploy("ECS", + map[string]string{"ClusterName": "c", "ServiceName": "s", "DeploymentTimeout": "5"}, "In")) + _, err = h.Backend.CreatePipeline(ctx, decl, nil) + require.NoError(t, err) + + arts.seed = buf.Bytes() + + exec, err := h.Backend.StartPipelineExecution(ctx, "p") + require.NoError(t, err) + assert.Equal(t, "InProgress", exec.Status) + + mu.Lock() + now = now.Add(6 * time.Minute) + mu.Unlock() + + require.Eventually(t, func() bool { + got, gErr := h.Backend.GetPipelineExecution(ctx, "p", exec.PipelineExecutionID) + + return gErr == nil && got.Status == "Failed" + }, 10*time.Second, 20*time.Millisecond) +} diff --git a/services/codepipeline/build_actions.go b/services/codepipeline/build_actions.go index fd4059bc5e..07c74e37df 100644 --- a/services/codepipeline/build_actions.go +++ b/services/codepipeline/build_actions.go @@ -71,21 +71,21 @@ func (b *InMemoryBackend) runCodeBuildAction( ae.Status = statusInProgress } -// pendingBuild identifies an InProgress Build action execution awaiting its build. -type pendingBuild struct { +// pendingAction identifies an InProgress backend-driven action execution awaiting its backend resource. +type pendingAction struct { + action *Action ae *ActionExecution region, pipeline string } -func (b *InMemoryBackend) pendingBuildsLocked() []pendingBuild { - var out []pendingBuild +func (b *InMemoryBackend) pendingActionsLocked(includeBuilds bool) []pendingAction { + var out []pendingAction for region, byPipeline := range b.actionExecutions { for name, aes := range byPipeline { for _, ae := range aes { - if ae.Status == statusInProgress && ae.Token == "" && ae.ExternalExecutionID != "" && - b.isBuildAction(region, name, ae) { - out = append(out, pendingBuild{region: region, pipeline: name, ae: ae}) + if action := b.pendingDeclaration(region, name, ae, includeBuilds); action != nil { + out = append(out, pendingAction{region: region, pipeline: name, ae: ae, action: action}) } } } @@ -94,64 +94,104 @@ func (b *InMemoryBackend) pendingBuildsLocked() []pendingBuild { return out } -func (b *InMemoryBackend) isBuildAction(region, pipelineName string, ae *ActionExecution) bool { +// pendingDeclaration returns the declaration of ae's action when ae is waiting on a backend resource. +func (b *InMemoryBackend) pendingDeclaration( + region, pipelineName string, ae *ActionExecution, includeBuilds bool, +) *Action { + if ae.Status != statusInProgress || ae.Token != "" { + return nil + } + + action := b.declaredAction(region, pipelineName, ae) + if action == nil { + return nil + } + + isBuild := includeBuilds && ae.ExternalExecutionID != "" && isBuiltinAction(*action, actionProviderCodeBuild) + if !isBuild && !isAsyncBuiltin(*action) { + return nil + } + + return action +} + +// declaredAction returns the pipeline's declaration of the action behind ae, or nil. +func (b *InMemoryBackend) declaredAction(region, pipelineName string, ae *ActionExecution) *Action { p, ok := b.pipelines.Get(regionKey(region, pipelineName)) if !ok { - return false + return nil } stage := findStage(p, ae.StageName) if stage == nil { - return false + return nil } - action := findAction(stage, ae.ActionName) - - return action != nil && isBuiltinAction(*action, actionProviderCodeBuild) + return findAction(stage, ae.ActionName) } -// reconcileBuildActions polls the build behind every InProgress Build action and, once a -// build is final, completes the action and resumes its pipeline execution. +// reconcileBuildActions polls the backend resource behind every InProgress Build, CloudFormation and ECS action +// and, once it settles, completes the action and resumes its pipeline execution. func (b *InMemoryBackend) reconcileBuildActions(_ context.Context) { b.mu.RLock("reconcileBuildActionsScan") _, isRunner := b.codeBuildBackend.(CodeBuildRunner) - pending := b.pendingBuildsLocked() + pending := b.pendingActionsLocked(isRunner) b.mu.RUnlock() - if !isRunner || len(pending) == 0 { + if len(pending) == 0 { return } b.mu.Lock("reconcileBuildActions") defer b.mu.Unlock() - runner, ok := b.codeBuildBackend.(CodeBuildRunner) - if !ok { - return - } + runner, _ := b.codeBuildBackend.(CodeBuildRunner) - for _, pb := range b.pendingBuildsLocked() { - b.settleBuild(runner, pb) + for _, pa := range b.pendingActionsLocked(runner != nil) { + b.settleAction(runner, pa) } } -func (b *InMemoryBackend) settleBuild(runner CodeBuildRunner, pb pendingBuild) { - ctx := b.regionContext(pb.region) - ae := pb.ae +func (b *InMemoryBackend) settleAction(runner CodeBuildRunner, pa pendingAction) { + ctx := b.regionContext(pa.region) + ae := pa.ae - status, err := runner.BuildStatus(ctx, ae.ExternalExecutionID) - if err == nil && status == buildStatusInProgress { + p, ok := b.pipelines.Get(regionKey(pa.region, pa.pipeline)) + if !ok { return } - p, ok := b.pipelines.Get(regionKey(pb.region, pb.pipeline)) - if !ok { + switch { + case isBuiltinAction((*pa.action), actionProviderCodeBuild): + b.settleBuild(ctx, runner, p, pa) + case isBuiltinAction((*pa.action), actionProviderCloudFormation): + b.advanceCloudFormation(ctx, p, ae.PipelineExecutionID, (*pa.action), ae) + case isBuiltinAction((*pa.action), actionProviderECS): + b.advanceECS(ctx, p, ae.PipelineExecutionID, (*pa.action), ae) + } + + if ae.Status == statusInProgress { return } ae.LastUpdateTime = time.Now().UTC() + exec := findExecution(b.executionsStore(pa.region)[pa.pipeline], ae.PipelineExecutionID) + if exec == nil || exec.Status != statusInProgress { + return + } + + b.runPipelineActions(pa.region, p, exec) + exec.LastUpdateTime = ae.LastUpdateTime +} + +func (b *InMemoryBackend) settleBuild(ctx context.Context, runner CodeBuildRunner, p *Pipeline, pa pendingAction) { + ae := pa.ae + + status, err := runner.BuildStatus(ctx, ae.ExternalExecutionID) + switch { + case err == nil && status == buildStatusInProgress: case err != nil: failActionf(ae, failureJobFailed, "Unable to read build %s: %v", ae.ExternalExecutionID, err) case status != buildStatusSucceeded: @@ -159,19 +199,10 @@ func (b *InMemoryBackend) settleBuild(runner CodeBuildRunner, pb pendingBuild) { default: ae.Status = statusSucceeded - if perr := b.passThroughArtifacts(ctx, p, ae.PipelineExecutionID, *findAction( - findStage(p, ae.StageName), ae.ActionName)); perr != nil { + if perr := b.passThroughArtifacts(ctx, p, ae.PipelineExecutionID, (*pa.action)); perr != nil { failActionf(ae, failureJobFailed, "%v", perr) } } - - exec := findExecution(b.executionsStore(pb.region)[pb.pipeline], ae.PipelineExecutionID) - if exec == nil || exec.Status != statusInProgress { - return - } - - b.runPipelineActions(pb.region, p, exec) - exec.LastUpdateTime = ae.LastUpdateTime } // StartWorker polls in-flight Build actions until ctx ends. It implements service.Registerable's worker hook. diff --git a/services/codepipeline/deploy_cloudformation.go b/services/codepipeline/deploy_cloudformation.go new file mode 100644 index 0000000000..bb52766062 --- /dev/null +++ b/services/codepipeline/deploy_cloudformation.go @@ -0,0 +1,558 @@ +package codepipeline + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "strings" +) + +const ( + cfnModeCreateUpdate = "CREATE_UPDATE" + cfnModeReplaceOnFailure = "REPLACE_ON_FAILURE" + cfnModeDeleteOnly = "DELETE_ONLY" + cfnModeChangeSetReplace = "CHANGE_SET_REPLACE" + cfnModeChangeSetExecute = "CHANGE_SET_EXECUTE" + + stackStatusDeleteComplete = "DELETE_COMPLETE" + stackStatusDeleteInProgress = "DELETE_IN_PROGRESS" + changeSetStatusComplete = "CREATE_COMPLETE" + changeSetStatusFailed = "FAILED" + statusSuffixInProgress = "_IN_PROGRESS" + + configKeyOutputFileName = "OutputFileName" +) + +var errUnresolvableParameter = errors.New("unresolvable parameter override") + +// cfnSettled reports the terminal outcome of a stack status: done is false while in progress. +func cfnSettled(status string) (bool, bool) { + switch status { + case "CREATE_COMPLETE", "UPDATE_COMPLETE", "IMPORT_COMPLETE": + return true, true + } + + return !strings.HasSuffix(status, statusSuffixInProgress), false +} + +func cfnFailedState(status string) bool { + switch status { + case "ROLLBACK_FAILED", "CREATE_FAILED", "DELETE_FAILED", "UPDATE_ROLLBACK_FAILED", "ROLLBACK_COMPLETE": + return true + } + + return false +} + +// runCloudFormationAction starts a Deploy/CloudFormation action and settles it as far as the stack allows; +// reconcileAsyncActions drives it the rest of the way. +func (b *InMemoryBackend) runCloudFormationAction( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + if b.stackDeployer == nil { + failActionf(ae, failureConfigurationError, "CloudFormation is not available in this emulator instance") + + return + } + + ae.Status = statusInProgress + + b.advanceCloudFormation(ctx, p, execID, action, ae) +} + +func (b *InMemoryBackend) advanceCloudFormation( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + cfg := action.Configuration + stack := cfg["StackName"] + + switch cfg["ActionMode"] { + case cfnModeDeleteOnly: + b.cfnDelete(ctx, stack, ae) + case cfnModeChangeSetReplace: + b.cfnChangeSetReplace(ctx, p, execID, action, ae) + case cfnModeChangeSetExecute: + b.cfnChangeSetExecute(ctx, p, execID, action, ae) + case cfnModeCreateUpdate, cfnModeReplaceOnFailure: + b.cfnCreateOrUpdate(ctx, p, execID, action, ae) + default: + failActionf(ae, failureConfigurationError, "Unsupported CloudFormation ActionMode %q", cfg["ActionMode"]) + } +} + +func (b *InMemoryBackend) cfnDelete(ctx context.Context, name string, ae *ActionExecution) { + st, err := b.stackDeployer.DescribeStack(ctx, name) + if err == nil && ae.ExternalExecutionID == "" { + var started bool + + if started, err = b.cfnBeginDelete(ctx, name, st, ae); started || err != nil { + if err != nil { + failActionf(ae, failureJobFailed, "%v", err) + } + + return + } + + st, err = b.stackDeployer.DescribeStack(ctx, name) + } + + switch { + case err != nil: + failActionf(ae, failureJobFailed, "%v", err) + case st == nil || st.Status == stackStatusDeleteComplete: + ae.Status = statusSucceeded + case st.Status == "DELETE_FAILED": + failActionf(ae, failureJobFailed, "%s", stackFailure(name, st)) + } +} + +// cfnBeginDelete issues the delete. It reports true when the action already succeeded because there is nothing to +// delete. +func (b *InMemoryBackend) cfnBeginDelete( + ctx context.Context, name string, st *StackState, ae *ActionExecution, +) (bool, error) { + if st == nil || st.Status == stackStatusDeleteComplete { + ae.Status = statusSucceeded + + return true, nil + } + + if st.Status != stackStatusDeleteInProgress { + if err := b.stackDeployer.DeleteStack(ctx, name); err != nil { + return false, err + } + } + + ae.ExternalExecutionID = name + + return false, nil +} + +func stackFailure(name string, st *StackState) string { + if st.Reason != "" { + return fmt.Sprintf("Stack [%s] is in %s state: %s", name, st.Status, st.Reason) + } + + return fmt.Sprintf("Stack [%s] is in %s state", name, st.Status) +} + +func (b *InMemoryBackend) cfnCreateOrUpdate( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + name := action.Configuration["StackName"] + + if ae.ExternalExecutionID == "" { + if !b.cfnStartDeployment(ctx, p, execID, action, ae) || ae.Status != statusInProgress { + return + } + } + + b.cfnSettleStack(ctx, p, execID, action, ae, name) +} + +// cfnStartDeployment issues the create or update. It returns false when the action already finished or must wait +// for the stack to leave a transient state before it can proceed. +func (b *InMemoryBackend) cfnStartDeployment( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) bool { + name := action.Configuration["StackName"] + + st, ready, err := b.cfnPrepareStack(ctx, name, action.Configuration["ActionMode"]) + if err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return false + } + + if !ready { + return false + } + + req, err := b.cfnRequest(ctx, p, execID, action) + if err != nil { + failActionf(ae, failureConfigurationError, "%v", err) + + return false + } + + if st == nil { + err = b.stackDeployer.CreateStack(ctx, req) + } else { + err = b.stackDeployer.UpdateStack(ctx, req) + } + + ae.ExternalExecutionID = name + + if errors.Is(err, ErrNoStackUpdates) { + return true + } + + if err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return false + } + + return true +} + +// cfnPrepareStack returns the stack to update (nil to create) and whether the deployment may start now. +// REPLACE_ON_FAILURE deletes a failed stack first and waits for the deletion. +func (b *InMemoryBackend) cfnPrepareStack(ctx context.Context, name, mode string) (*StackState, bool, error) { + st, err := b.stackDeployer.DescribeStack(ctx, name) + if err != nil { + return nil, false, err + } + + if st != nil && st.Status == stackStatusDeleteComplete { + st = nil + } + + if st != nil && mode == cfnModeReplaceOnFailure && cfnFailedState(st.Status) { + if st, err = b.cfnDeleteFailedStack(ctx, name, st); err != nil || st != nil { + return nil, false, err + } + } + + if st != nil && strings.HasSuffix(st.Status, statusSuffixInProgress) { + return nil, false, nil + } + + return st, true, nil +} + +// cfnDeleteFailedStack deletes st and returns the stack's state afterwards (nil once it is gone). +func (b *InMemoryBackend) cfnDeleteFailedStack(ctx context.Context, name string, st *StackState) (*StackState, error) { + if st.Status != stackStatusDeleteInProgress { + if err := b.stackDeployer.DeleteStack(ctx, name); err != nil { + return nil, err + } + } + + after, err := b.stackDeployer.DescribeStack(ctx, name) + if err != nil { + return nil, err + } + + if after == nil || after.Status == stackStatusDeleteComplete { + return nil, nil //nolint:nilnil // gone + } + + return after, nil +} + +// cfnSettleStack finishes the action once the stack reaches a terminal status. +func (b *InMemoryBackend) cfnSettleStack( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, name string, +) { + st, err := b.stackDeployer.DescribeStack(ctx, name) + if err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return + } + + if st == nil { + failActionf(ae, failureJobFailed, "Stack [%s] does not exist", name) + + return + } + + done, ok := cfnSettled(st.Status) + + switch { + case !done: + return + case !ok: + failActionf(ae, failureJobFailed, "%s", stackFailure(name, st)) + default: + b.cfnPublishOutputs(ctx, p, execID, action, ae, st) + } +} + +func (b *InMemoryBackend) cfnPublishOutputs( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, st *StackState, +) { + ae.Status = statusSucceeded + ae.OutputVariables = make(map[string]string, len(st.Outputs)) + + maps.Copy(ae.OutputVariables, st.Outputs) + + file := action.Configuration[configKeyOutputFileName] + if file == "" { + return + } + + body, err := json.Marshal(st.Outputs) + if err == nil { + err = b.putOutputFiles(ctx, p, execID, action, map[string][]byte{file: body}) + } + + if err != nil { + failActionf(ae, failureJobFailed, "Unable to write stack outputs to %s: %v", file, err) + } +} + +func (b *InMemoryBackend) cfnChangeSetReplace( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + name, csName := action.Configuration["StackName"], action.Configuration["ChangeSetName"] + + if ae.ExternalExecutionID == "" { + req, err := b.cfnRequest(ctx, p, execID, action) + if err != nil { + failActionf(ae, failureConfigurationError, "%v", err) + + return + } + + st, err := b.stackDeployer.DescribeStack(ctx, name) + if err == nil { + err = b.cfnReplaceChangeSet(ctx, req, csName, st == nil || st.Status == stackStatusDeleteComplete) + } + + if err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return + } + + ae.ExternalExecutionID = name + } + + cs, err := b.stackDeployer.DescribeChangeSet(ctx, name, csName) + + switch { + case err != nil: + failActionf(ae, failureJobFailed, "%v", err) + case cs == nil: + failActionf(ae, failureJobFailed, "Change set [%s] does not exist", csName) + case cs.Status == changeSetStatusFailed: + failActionf(ae, failureJobFailed, "Change set [%s] failed: %s", csName, cs.Reason) + case cs.Status == changeSetStatusComplete: + ae.Status = statusSucceeded + } +} + +func (b *InMemoryBackend) cfnReplaceChangeSet(ctx context.Context, req StackRequest, csName string, forNew bool) error { + existing, err := b.stackDeployer.DescribeChangeSet(ctx, req.StackName, csName) + if err != nil { + return err + } + + if existing != nil { + if err = b.stackDeployer.DeleteChangeSet(ctx, req.StackName, csName); err != nil { + return err + } + } + + return b.stackDeployer.CreateChangeSet(ctx, req, csName, forNew) +} + +func (b *InMemoryBackend) cfnChangeSetExecute( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + name, csName := action.Configuration["StackName"], action.Configuration["ChangeSetName"] + + if ae.ExternalExecutionID == "" { + cs, err := b.stackDeployer.DescribeChangeSet(ctx, name, csName) + + switch { + case err != nil: + failActionf(ae, failureJobFailed, "%v", err) + + return + case cs == nil: + failActionf(ae, failureJobFailed, "Change set [%s] does not exist for stack [%s]", csName, name) + + return + } + + if err = b.stackDeployer.ExecuteChangeSet(ctx, name, csName); err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return + } + + ae.ExternalExecutionID = name + } + + b.cfnSettleStack(ctx, p, execID, action, ae, name) +} + +// cfnRequest assembles the stack request from the action configuration and its input artifacts. +func (b *InMemoryBackend) cfnRequest( + ctx context.Context, p *Pipeline, execID string, action Action, +) (StackRequest, error) { + cfg := action.Configuration + req := StackRequest{ + StackName: cfg["StackName"], + RoleARN: cfg["RoleArn"], + Tags: map[string]string{}, + } + + for c := range strings.SplitSeq(cfg["Capabilities"], ",") { + if c = strings.TrimSpace(c); c != "" { + req.Capabilities = append(req.Capabilities, c) + } + } + + template, err := b.artifactRef(ctx, p, execID, action, cfg["TemplatePath"]) + if err != nil { + return req, fmt.Errorf("unable to read template %q: %w", cfg["TemplatePath"], err) + } + + req.TemplateBody = string(template) + req.Parameters = map[string]string{} + + if ref := cfg["TemplateConfiguration"]; ref != "" { + if err = b.applyTemplateConfiguration(ctx, p, execID, action, ref, &req); err != nil { + return req, err + } + } + + if raw := cfg["ParameterOverrides"]; raw != "" { + if err = b.applyParameterOverrides(ctx, p, execID, action, raw, &req); err != nil { + return req, err + } + } + + return req, nil +} + +func (b *InMemoryBackend) applyTemplateConfiguration( + ctx context.Context, p *Pipeline, execID string, action Action, ref string, req *StackRequest, +) error { + body, err := b.artifactRef(ctx, p, execID, action, ref) + if err != nil { + return fmt.Errorf("unable to read template configuration %q: %w", ref, err) + } + + var doc struct { + Parameters map[string]any `json:"Parameters"` + Tags map[string]any `json:"Tags"` + StackPolicy json.RawMessage `json:"StackPolicy"` + } + + if err = json.Unmarshal(body, &doc); err != nil { + return fmt.Errorf("template configuration %q is not valid JSON: %w", ref, err) + } + + for k, v := range doc.Parameters { + req.Parameters[k] = scalarString(v) + } + + for k, v := range doc.Tags { + req.Tags[k] = scalarString(v) + } + + if len(doc.StackPolicy) > 0 { + req.StackPolicy = string(doc.StackPolicy) + } + + return nil +} + +func scalarString(v any) string { + if s, ok := v.(string); ok { + return s + } + + return fmt.Sprint(v) +} + +// applyParameterOverrides resolves the ParameterOverrides JSON object, expanding Fn::GetArtifactAtt and +// Fn::GetParam against the action's input artifacts. +func (b *InMemoryBackend) applyParameterOverrides( + ctx context.Context, p *Pipeline, execID string, action Action, raw string, req *StackRequest, +) error { + var overrides map[string]any + if err := json.Unmarshal([]byte(raw), &overrides); err != nil { + return fmt.Errorf("ParameterOverrides is not a valid JSON object: %w", err) + } + + for k, v := range overrides { + fn, isFn := v.(map[string]any) + if !isFn { + req.Parameters[k] = scalarString(v) + + continue + } + + val, err := b.resolveParameterFunction(ctx, p, execID, action, fn) + if err != nil { + return fmt.Errorf("%w %q: %w", errUnresolvableParameter, k, err) + } + + req.Parameters[k] = val + } + + return nil +} + +func (b *InMemoryBackend) resolveParameterFunction( + ctx context.Context, p *Pipeline, execID string, action Action, fn map[string]any, +) (string, error) { + if args, ok := fn["Fn::GetArtifactAtt"].([]any); ok && len(args) == 2 { + return b.getArtifactAtt(p, execID, action, scalarString(args[0]), scalarString(args[1])) + } + + if args, ok := fn["Fn::GetParam"].([]any); ok && len(args) == 3 { + body, err := b.artifactFile(ctx, p, execID, action, scalarString(args[0]), scalarString(args[1])) + if err != nil { + return "", err + } + + var doc map[string]any + if err = json.Unmarshal(body, &doc); err != nil { + return "", fmt.Errorf("%s is not a JSON object: %w", scalarString(args[1]), err) + } + + val, found := doc[scalarString(args[2])] + if !found { + return "", fmt.Errorf( + "%w: key %q not found in %s", + ErrValidation, + scalarString(args[2]), + scalarString(args[1]), + ) + } + + return scalarString(val), nil + } + + return "", fmt.Errorf("%w: unsupported function %v", ErrValidation, fn) +} + +func (b *InMemoryBackend) getArtifactAtt( + p *Pipeline, + execID string, + action Action, + artifact, attr string, +) (string, error) { + bucket, key, ok := artifactLocation(p, action, execID, artifact) + if !ok { + return "", fmt.Errorf("%w: pipeline has no artifact store location", ErrValidation) + } + + switch attr { + case "BucketName": + return bucket, nil + case "ObjectKey": + return key, nil + case "URL": + return "https://s3." + b.regionOf(action, p) + ".amazonaws.com/" + bucket + "/" + key, nil + } + + return "", fmt.Errorf("%w: unknown artifact attribute %q", ErrValidation, attr) +} + +func (b *InMemoryBackend) regionOf(_ Action, p *Pipeline) string { + if p.region != "" { + return p.region + } + + return b.region +} diff --git a/services/codepipeline/deploy_ecs.go b/services/codepipeline/deploy_ecs.go new file mode 100644 index 0000000000..3c49a802fd --- /dev/null +++ b/services/codepipeline/deploy_ecs.go @@ -0,0 +1,199 @@ +package codepipeline + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" +) + +const ( + configKeyECSCluster = "ClusterName" + configKeyECSService = "ServiceName" + configKeyECSFileName = "FileName" + configKeyECSTimeout = "DeploymentTimeout" + defaultImageDefsFile = "imagedefinitions.json" + maxImageDefsBytes = 100 << 10 + defaultECSDeployWindow = 60 * time.Minute + + ecsRolloutCompleted = "COMPLETED" + ecsRolloutFailed = "FAILED" + ecsDeploymentActive = "ACTIVE" + ecsDeploymentPrimary = "PRIMARY" +) + +type imageDefinition struct { + Name string `json:"name"` + ImageURI string `json:"imageUri"` +} + +// runECSAction rolls the service onto a new task definition revision built from the image definitions file. The +// emulator's ECS backend reports rollout state per deployment; the action completes when the new revision's +// deployment is the only one left and its rollout is COMPLETED (or, when no rollout state is reported, its +// running count has reached its desired count). +func (b *InMemoryBackend) runECSAction( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + if b.ecsDeployer == nil { + failActionf(ae, failureConfigurationError, "ECS is not available in this emulator instance") + + return + } + + ae.Status = statusInProgress + + b.advanceECS(ctx, p, execID, action, ae) +} + +func (b *InMemoryBackend) advanceECS( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + cluster, service := action.Configuration[configKeyECSCluster], action.Configuration[configKeyECSService] + + if ae.ExternalExecutionID == "" { + newTD, err := b.ecsStartDeployment(ctx, p, execID, action, cluster, service) + if err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return + } + + ae.ExternalExecutionID = newTD + } + + svc, err := b.ecsDeployer.DescribeService(ctx, cluster, service) + + switch { + case err != nil: + failActionf(ae, failureJobFailed, "%v", err) + case svc == nil: + failActionf(ae, failureJobFailed, "Service %s was not found in cluster %s", service, cluster) + default: + b.ecsEvaluate(action, ae, svc) + } +} + +func (b *InMemoryBackend) ecsStartDeployment( + ctx context.Context, p *Pipeline, execID string, action Action, cluster, service string, +) (string, error) { + file := action.Configuration[configKeyECSFileName] + if file == "" { + file = defaultImageDefsFile + } + + in := primaryInputArtifact(action) + if in == "" { + return "", fmt.Errorf("%w: action %q has no input artifact", ErrValidation, action.Name) + } + + body, err := b.artifactFile(ctx, p, execID, action, in, file) + if err != nil { + return "", fmt.Errorf("unable to read image definitions file %q: %w", file, err) + } + + if len(body) > maxImageDefsBytes { + return "", fmt.Errorf("%w: image definitions file %q exceeds %d bytes", ErrValidation, file, maxImageDefsBytes) + } + + var defs []imageDefinition + if err = json.Unmarshal(body, &defs); err != nil || len(defs) == 0 { + return "", fmt.Errorf( + "%w: image definitions file %q must be a non-empty JSON array of name/imageUri", + ErrValidation, + file, + ) + } + + images := make(map[string]string, len(defs)) + for _, d := range defs { + if d.Name == "" || d.ImageURI == "" { + return "", fmt.Errorf( + "%w: image definitions file %q has an entry without name or imageUri", + ErrValidation, + file, + ) + } + + images[d.Name] = d.ImageURI + } + + svc, err := b.ecsDeployer.DescribeService(ctx, cluster, service) + if err != nil { + return "", err + } + + if svc == nil { + return "", fmt.Errorf("%w: service %s was not found in cluster %s", ErrValidation, service, cluster) + } + + newTD, err := b.ecsDeployer.RegisterRevisionWithImages(ctx, svc.TaskDefinition, images) + if err != nil { + return "", err + } + + if err = b.ecsDeployer.UpdateServiceTaskDefinition(ctx, cluster, service, newTD); err != nil { + return "", err + } + + return newTD, nil +} + +func (b *InMemoryBackend) ecsEvaluate(action Action, ae *ActionExecution, svc *ECSServiceState) { + var primary *ECSDeploymentState + + live := 0 + + for i := range svc.Deployments { + d := &svc.Deployments[i] + if d.Status == ecsDeploymentActive || d.Status == ecsDeploymentPrimary { + live++ + } + + if d.Status == ecsDeploymentPrimary && d.TaskDefinition == ae.ExternalExecutionID { + primary = d + } + } + + switch { + case primary == nil && live > 0 && b.ecsTimedOut(action, ae): + failActionf( + ae, + failureJobFailed, + "Deployment of %s did not start before the deployment timeout", + ae.ExternalExecutionID, + ) + case primary == nil: + return + case primary.RolloutState == ecsRolloutFailed: + failActionf( + ae, + failureJobFailed, + "Deployment of %s failed: %s", + ae.ExternalExecutionID, + primary.RolloutStateReason, + ) + case live == 1 && ecsSteady(primary): + ae.Status = statusSucceeded + case b.ecsTimedOut(action, ae): + failActionf(ae, failureJobFailed, "Service did not reach a steady state within the deployment timeout") + } +} + +func ecsSteady(d *ECSDeploymentState) bool { + if d.RolloutState != "" { + return d.RolloutState == ecsRolloutCompleted + } + + return d.RunningCount >= d.DesiredCount +} + +func (b *InMemoryBackend) ecsTimedOut(action Action, ae *ActionExecution) bool { + window := defaultECSDeployWindow + + if m, err := strconv.Atoi(action.Configuration[configKeyECSTimeout]); err == nil && m > 0 { + window = time.Duration(m) * time.Minute + } + + return b.now().Sub(ae.StartTime) > window +} diff --git a/services/codepipeline/errors.go b/services/codepipeline/errors.go index 472a944fc0..3f14a09b4a 100644 --- a/services/codepipeline/errors.go +++ b/services/codepipeline/errors.go @@ -33,6 +33,8 @@ var ( ErrStageNotFound = awserr.New("StageNotFoundException", awserr.ErrNotFound) // ErrInvalidStructure is returned for structural pipeline validation errors. ErrInvalidStructure = awserr.New("InvalidStructureException", awserr.ErrInvalidParameter) + // ErrInvalidActionDeclaration is returned for an invalid action declaration. + ErrInvalidActionDeclaration = awserr.New("InvalidActionDeclarationException", awserr.ErrInvalidParameter) // ErrExecutionNotFound is returned when a requested pipeline execution ID does not exist. ErrExecutionNotFound = awserr.New("PipelineExecutionNotFoundException", awserr.ErrNotFound) // ErrVersionNotFound is returned when a requested pipeline version does not exist. diff --git a/services/codepipeline/handler.go b/services/codepipeline/handler.go index 0b49e33c2e..d2c660af97 100644 --- a/services/codepipeline/handler.go +++ b/services/codepipeline/handler.go @@ -318,6 +318,7 @@ func (h *Handler) handleError(_ context.Context, c *echo.Context, _ string, err {ErrResourceNotFound, "ResourceNotFoundException"}, {ErrStageNotFound, "StageNotFoundException"}, {ErrInvalidStructure, "InvalidStructureException"}, + {ErrInvalidActionDeclaration, "InvalidActionDeclarationException"}, {ErrExecutionNotFound, "PipelineExecutionNotFoundException"}, {ErrVersionNotFound, "PipelineVersionNotFoundException"}, {ErrActionNotFound, "ActionNotFoundException"}, diff --git a/services/codepipeline/iam_enforcement_test.go b/services/codepipeline/iam_enforcement_test.go index 490b41414e..547efe9fb6 100644 --- a/services/codepipeline/iam_enforcement_test.go +++ b/services/codepipeline/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/codepipeline" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -171,7 +172,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/codepipeline/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/codepipeline/interfaces.go b/services/codepipeline/interfaces.go index 4500da143b..3649c7f19d 100644 --- a/services/codepipeline/interfaces.go +++ b/services/codepipeline/interfaces.go @@ -2,6 +2,8 @@ package codepipeline import ( "context" + "errors" + "time" "github.com/aws/aws-sdk-go-v2/service/s3" ) @@ -66,3 +68,101 @@ type ArtifactS3 interface { GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) PutObject(ctx context.Context, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) } + +// StackState is a CloudFormation stack as the CloudFormation deploy action observes it. +type StackState struct { + Outputs map[string]string + Status string + Reason string +} + +// ChangeSetState is a CloudFormation change set as the CloudFormation deploy action observes it. +type ChangeSetState struct { + Status string + Reason string +} + +// StackRequest is what a CloudFormation deploy action hands CloudFormation to create or update a stack. +type StackRequest struct { + Parameters map[string]string + Tags map[string]string + StackName string + TemplateBody string + StackPolicy string + RoleARN string + Capabilities []string +} + +// StackDeployer is the CloudFormation surface the Deploy/CloudFormation action needs, wired via SetStackDeployer. +// Absent stacks and change sets are reported as nil with no error. +type StackDeployer interface { + DescribeStack(ctx context.Context, name string) (*StackState, error) + CreateStack(ctx context.Context, req StackRequest) error + // UpdateStack returns ErrNoStackUpdates when the template and parameters match the deployed stack. + UpdateStack(ctx context.Context, req StackRequest) error + DeleteStack(ctx context.Context, name string) error + CreateChangeSet(ctx context.Context, req StackRequest, changeSetName string, forNewStack bool) error + DescribeChangeSet(ctx context.Context, stackName, changeSetName string) (*ChangeSetState, error) + DeleteChangeSet(ctx context.Context, stackName, changeSetName string) error + ExecuteChangeSet(ctx context.Context, stackName, changeSetName string) error +} + +// ECSDeploymentState is one ECS service deployment as the Deploy/ECS action observes it. +type ECSDeploymentState struct { + TaskDefinition string + Status string + RolloutState string + RolloutStateReason string + DesiredCount int + RunningCount int + FailedTasks int +} + +// ECSServiceState is an ECS service as the Deploy/ECS action observes it. +type ECSServiceState struct { + TaskDefinition string + Deployments []ECSDeploymentState +} + +// ECSDeployer is the ECS surface the Deploy/ECS action needs, wired via SetECSDeployer. +type ECSDeployer interface { + // DescribeService returns nil with no error when the service does not exist. + DescribeService(ctx context.Context, cluster, service string) (*ECSServiceState, error) + // RegisterRevisionWithImages registers a new revision of baseTaskDefinition with each named container's image + // replaced, returning the new revision's ARN. It fails when a named container is not in the task definition. + RegisterRevisionWithImages(ctx context.Context, baseTaskDefinition string, images map[string]string) (string, error) + UpdateServiceTaskDefinition(ctx context.Context, cluster, service, taskDefinition string) error +} + +// ImageInfo is an ECR image as the Source/ECR action observes it. +type ImageInfo struct { + PushedAt time.Time + RegistryID string + RepositoryName string + RepositoryURI string + Digest string + Tags []string + SizeInBytes int64 +} + +// ImageSource is the ECR surface the Source/ECR action needs, wired via SetImageSource. +type ImageSource interface { + // DescribeImage returns the image tagged tag, or an error wrapping awserr.ErrNotFound when it does not exist. + DescribeImage(ctx context.Context, repository, tag string) (*ImageInfo, error) +} + +// RepositorySnapshot is the head of a CodeCommit branch with its files. +type RepositorySnapshot struct { + Files map[string][]byte + CommitID string + CommitMessage string +} + +// RepositoryReader is the CodeCommit surface the Source/CodeCommit action needs, wired via SetRepositoryReader. +type RepositoryReader interface { + // Snapshot returns the branch head, or an error wrapping awserr.ErrNotFound for a missing repository or branch. + Snapshot(ctx context.Context, repository, branch string) (*RepositorySnapshot, error) +} + +// ErrNoStackUpdates is returned by StackDeployer.UpdateStack when there is nothing to update. +var ErrNoStackUpdates = errors.New("no updates are to be performed") diff --git a/services/codepipeline/pipelines.go b/services/codepipeline/pipelines.go index 7de173b5ba..9ed2a05d05 100644 --- a/services/codepipeline/pipelines.go +++ b/services/codepipeline/pipelines.go @@ -27,6 +27,10 @@ func (b *InMemoryBackend) CreatePipeline( return nil, err } + if err := validateActionDeclarations(decl.Stages); err != nil { + return nil, err + } + if b.pipelines.Has(regionKey(region, decl.Name)) { return nil, fmt.Errorf("%w: pipeline %q already exists", ErrPipelineNameInUse, decl.Name) } @@ -110,6 +114,10 @@ func (b *InMemoryBackend) UpdatePipeline(ctx context.Context, decl PipelineDecla return nil, err } + if err := validateActionDeclarations(decl.Stages); err != nil { + return nil, err + } + currentVersion := p.Declaration.Version p.Declaration = decl p.Declaration.Version = currentVersion + 1 diff --git a/services/codepipeline/source_actions.go b/services/codepipeline/source_actions.go new file mode 100644 index 0000000000..db50e1ea98 --- /dev/null +++ b/services/codepipeline/source_actions.go @@ -0,0 +1,140 @@ +package codepipeline + +import ( + "context" + "encoding/json" + "errors" + "strconv" + + "github.com/blackbirdworks/gopherstack/pkgs/awserr" +) + +const ( + imageDetailFile = "imageDetail.json" + imageDetailVersion = "1.0" + imageDetailTimeLayout = "Mon Jan 02 15:04:05 MST 2006" + defaultImageTag = "latest" + + outputFormatCodeZip = "CODE_ZIP" +) + +// runECRSourceAction stages imageDetail.json for the tagged image as the action's output artifact. +func (b *InMemoryBackend) runECRSourceAction( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + if b.imageSource == nil { + failActionf(ae, failureConfigurationError, "ECR is not available in this emulator instance") + + return + } + + repo, tag := action.Configuration["RepositoryName"], action.Configuration["ImageTag"] + if tag == "" { + tag = defaultImageTag + } + + img, err := b.imageSource.DescribeImage(ctx, repo, tag) + if err != nil { + if errors.Is(err, awserr.ErrNotFound) { + failActionf(ae, failureConfigurationError, + "The image with tag '%s' in repository '%s' does not exist", tag, repo) + } else { + failActionf(ae, failureJobFailed, "Unable to describe image %s:%s: %v", repo, tag, err) + } + + return + } + + uri := img.RepositoryURI + "@" + img.Digest + + detail, err := json.Marshal(struct { + ImageSizeInBytes string `json:"ImageSizeInBytes"` + ImageDigest string `json:"ImageDigest"` + Version string `json:"Version"` + ImagePushedAt string `json:"ImagePushedAt"` + RegistryID string `json:"RegistryId"` + RepositoryName string `json:"RepositoryName"` + ImageURI string `json:"ImageURI"` + ImageTags []string `json:"ImageTags"` + }{ + ImageSizeInBytes: strconv.FormatInt(img.SizeInBytes, 10), + ImageDigest: img.Digest, + Version: imageDetailVersion, + ImagePushedAt: img.PushedAt.UTC().Format(imageDetailTimeLayout), + RegistryID: img.RegistryID, + RepositoryName: img.RepositoryName, + ImageURI: uri, + ImageTags: img.Tags, + }) + if err == nil { + err = b.putOutputFiles(ctx, p, execID, action, map[string][]byte{imageDetailFile: detail}) + } + + if err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return + } + + ae.OutputVariables = map[string]string{ + "RegistryId": img.RegistryID, + "RepositoryName": img.RepositoryName, + "ImageDigest": img.Digest, + "ImageURI": uri, + "ImageTag": tag, + } +} + +// runCodeCommitSourceAction stages the branch head's files as the action's output artifact (CODE_ZIP). +func (b *InMemoryBackend) runCodeCommitSourceAction( + ctx context.Context, p *Pipeline, execID string, action Action, ae *ActionExecution, +) { + if b.repositoryReader == nil { + failActionf(ae, failureConfigurationError, "CodeCommit is not available in this emulator instance") + + return + } + + if f := action.Configuration["OutputArtifactFormat"]; f != "" && f != outputFormatCodeZip { + failActionf(ae, failureConfigurationError, + "OutputArtifactFormat %s is not supported by this emulator; only %s", f, outputFormatCodeZip) + + return + } + + repo, branch := action.Configuration["RepositoryName"], action.Configuration["BranchName"] + + snap, err := b.repositoryReader.Snapshot(ctx, repo, branch) + if err != nil { + if errors.Is(err, awserr.ErrNotFound) { + failActionf(ae, failureConfigurationError, + "The repository '%s' or branch '%s' does not exist", repo, branch) + } else { + failActionf(ae, failureJobFailed, "Unable to read %s/%s: %v", repo, branch, err) + } + + return + } + + if err = b.putOutputFiles(ctx, p, execID, action, snap.Files); err != nil { + failActionf(ae, failureJobFailed, "%v", err) + + return + } + + ae.OutputVariables = map[string]string{ + "RepositoryName": repo, + "BranchName": branch, + "CommitId": snap.CommitID, + "CommitMessage": snap.CommitMessage, + } +} + +// runCodeStarSourceAction fails the action: the emulator holds no repository content for connections to +// third-party providers (GitHub, Bitbucket, GitLab), so there is nothing to build an artifact from. +func runCodeStarSourceAction(action Action, ae *ActionExecution) { + failActionf(ae, failureConfigurationError, + "Source/CodeStarSourceConnection cannot fetch %q from connection %q: the emulator has no access to "+ + "third-party repository content", + action.Configuration["FullRepositoryId"], action.Configuration["ConnectionArn"]) +} diff --git a/services/codepipeline/store.go b/services/codepipeline/store.go index ba376d2915..33e0d23634 100644 --- a/services/codepipeline/store.go +++ b/services/codepipeline/store.go @@ -3,6 +3,7 @@ package codepipeline import ( "context" + "time" "github.com/blackbirdworks/gopherstack/pkgs/arn" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" @@ -176,6 +177,11 @@ type InMemoryBackend struct { lambdaBackend LambdaInvoker codeDeployBackend CodeDeployStarter artifactS3 ArtifactS3 + stackDeployer StackDeployer + ecsDeployer ECSDeployer + imageSource ImageSource + repositoryReader RepositoryReader + clock func() time.Time mu *lockmetrics.RWMutex accountID string region string diff --git a/services/codestarconnections/handler_create_tags_test.go b/services/codestarconnections/handler_create_tags_test.go index 4179c8d942..dd245cb1c5 100644 --- a/services/codestarconnections/handler_create_tags_test.go +++ b/services/codestarconnections/handler_create_tags_test.go @@ -1,11 +1,9 @@ package codestarconnections_test import ( - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" codestarconnectionssdk "github.com/aws/aws-sdk-go-v2/service/codestarconnections" codestarconnectionstypes "github.com/aws/aws-sdk-go-v2/service/codestarconnections/types" @@ -13,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/codestarconnections" ) @@ -31,20 +30,11 @@ func newTestCodeStarConnectionsClient(t *testing.T, h *codestarconnections.Handl require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(tagsRTRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return codestarconnectionssdk.NewFromConfig(cfg, func(o *codestarconnectionssdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + return codestarconnectionssdk.NewFromConfig(aws.Config{ + Region: tagsRTRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/codestarconnections/iam_enforcement_test.go b/services/codestarconnections/iam_enforcement_test.go index 9f8067c7c5..870a08bbba 100644 --- a/services/codestarconnections/iam_enforcement_test.go +++ b/services/codestarconnections/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/codestarconnections" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -163,7 +164,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/codestar-connections/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/codestarconnections/lifecycle_pagination_test.go b/services/codestarconnections/lifecycle_pagination_test.go index 7e3b457a4f..356a8c5940 100644 --- a/services/codestarconnections/lifecycle_pagination_test.go +++ b/services/codestarconnections/lifecycle_pagination_test.go @@ -2,6 +2,7 @@ package codestarconnections_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -118,28 +119,33 @@ func TestSDK_StatusLifecycle(t *testing.T) { func TestSDK_StatusCompletes(t *testing.T) { t.Parallel() - client, b := newCSCFixture(t) - b.SetConnectionHandshakeDelay(20 * time.Millisecond) - b.SetHostSetupDelay(20 * time.Millisecond) + synctest.Test(t, func(t *testing.T) { + client, b := newCSCFixture(t) + b.SetConnectionHandshakeDelay(20 * time.Millisecond) + b.SetHostSetupDelay(20 * time.Millisecond) - conn, err := client.CreateConnection(t.Context(), &cscsdk.CreateConnectionInput{ - ConnectionName: aws.String("c"), ProviderType: csctypes.ProviderTypeGithub, - }) - require.NoError(t, err) + conn, err := client.CreateConnection(t.Context(), &cscsdk.CreateConnectionInput{ + ConnectionName: aws.String("c"), ProviderType: csctypes.ProviderTypeGithub, + }) + require.NoError(t, err) - host, err := client.CreateHost(t.Context(), &cscsdk.CreateHostInput{ - Name: aws.String("h"), - ProviderType: csctypes.ProviderTypeGithubEnterpriseServer, - ProviderEndpoint: aws.String("https://ghe.example.com"), - }) - require.NoError(t, err) + host, err := client.CreateHost(t.Context(), &cscsdk.CreateHostInput{ + Name: aws.String("h"), + ProviderType: csctypes.ProviderTypeGithubEnterpriseServer, + ProviderEndpoint: aws.String("https://ghe.example.com"), + }) + require.NoError(t, err) - require.Eventually(t, func() bool { - got, getErr := client.GetConnection(t.Context(), &cscsdk.GetConnectionInput{ConnectionArn: conn.ConnectionArn}) - gotHost, hostErr := client.GetHost(t.Context(), &cscsdk.GetHostInput{HostArn: host.HostArn}) + require.Eventually(t, func() bool { + got, getErr := client.GetConnection( + t.Context(), + &cscsdk.GetConnectionInput{ConnectionArn: conn.ConnectionArn}, + ) + gotHost, hostErr := client.GetHost(t.Context(), &cscsdk.GetHostInput{HostArn: host.HostArn}) - return getErr == nil && hostErr == nil && - got.Connection.ConnectionStatus == csctypes.ConnectionStatusAvailable && - aws.ToString(gotHost.Status) == "AVAILABLE" - }, 5*time.Second, 10*time.Millisecond) + return getErr == nil && hostErr == nil && + got.Connection.ConnectionStatus == csctypes.ConnectionStatusAvailable && + aws.ToString(gotHost.Status) == "AVAILABLE" + }, 5*time.Second, 10*time.Millisecond) + }) } diff --git a/services/cognitoidentity/iam_enforcement_test.go b/services/cognitoidentity/iam_enforcement_test.go index 98be3fe02a..95ad6fbbfa 100644 --- a/services/cognitoidentity/iam_enforcement_test.go +++ b/services/cognitoidentity/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cognitoidentity" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cognito-identity/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cognitoidp/auth_tokens.go b/services/cognitoidp/auth_tokens.go index 0394f5c2d2..e6e1fe8a41 100644 --- a/services/cognitoidp/auth_tokens.go +++ b/services/cognitoidp/auth_tokens.go @@ -169,6 +169,25 @@ func (b *InMemoryBackend) GetJWTPublicKey(issuerURL, kid string) (*rsa.PublicKey return nil, ErrJWTIssuerUnknown } +// UserPoolSigningKey returns the RSA public key with the given kid for userPoolID and the +// issuer URL the pool stamps into its tokens. +func (b *InMemoryBackend) UserPoolSigningKey(userPoolID, kid string) (*rsa.PublicKey, string, error) { + b.mu.RLock("UserPoolSigningKey") + defer b.mu.RUnlock() + + pool, ok := b.pools.Get(userPoolID) + if !ok || pool.issuer == nil { + return nil, "", fmt.Errorf("%w: pool %q not found", ErrUserPoolNotFound, userPoolID) + } + + key, ok := pool.issuer.PublicKeyForKID(kid) + if !ok { + return nil, "", fmt.Errorf("%w: %q for pool %q", ErrJWTKeyNotFound, kid, userPoolID) + } + + return key, pool.issuer.issuerURL, nil +} + // resolveClientTokenSettings looks up clientID and returns its token issuance // settings, falling back to AWS defaults (no custom scopes, default token // lifetimes, defaultRefreshTokenTTL) when the client is not found -- token diff --git a/services/cognitoidp/iam_enforcement_test.go b/services/cognitoidp/iam_enforcement_test.go index 22ba4e4ca9..ba381af41f 100644 --- a/services/cognitoidp/iam_enforcement_test.go +++ b/services/cognitoidp/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cognitoidp" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/cognito-idp/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/cognitoidp/regions.go b/services/cognitoidp/regions.go index d69cc80c35..b7002fc752 100644 --- a/services/cognitoidp/regions.go +++ b/services/cognitoidp/regions.go @@ -105,6 +105,25 @@ func (h *Handler) GetJWTPublicKey(issuerURL, kid string) (*rsa.PublicKey, error) return nil, ErrJWTIssuerUnknown } +// UserPoolSigningKey resolves a pool's signing key and token issuer across every region. +func (h *Handler) UserPoolSigningKey(userPoolID, kid string) (*rsa.PublicKey, string, error) { + var lastErr error + + for _, p := range h.handlers() { + key, iss, err := p.Backend.UserPoolSigningKey(userPoolID, kid) + if err == nil { + return key, iss, nil + } + + lastErr = err + if !errors.Is(err, ErrUserPoolNotFound) { + return nil, "", err + } + } + + return nil, "", lastErr +} + // ownerHints are the identifiers a request carries that name the region owning its resources. type ownerHints struct { poolID, arn, clientID, token, domain, session string diff --git a/services/comprehend/iam_enforcement_test.go b/services/comprehend/iam_enforcement_test.go index 5b6c7b1e2b..8482e5ae54 100644 --- a/services/comprehend/iam_enforcement_test.go +++ b/services/comprehend/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/comprehend" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -168,7 +169,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/comprehend/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/comprehend/validation.go b/services/comprehend/validation.go index 0686ab8184..fbb57b058c 100644 --- a/services/comprehend/validation.go +++ b/services/comprehend/validation.go @@ -3,6 +3,7 @@ package comprehend import ( "fmt" "regexp" + "sync" ) const ( @@ -12,7 +13,9 @@ const ( var ( // Brackets are admitted so the "[fail]" job-name failure marker stays usable. - jobNameRe = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-%@\[\]]{1,256}$`) + jobNameRe = sync.OnceValue(func() *regexp.Regexp { //nolint:gochecknoglobals // lazy: eager compile costs MBs + return regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-%@\[\]]{1,256}$`) + }) codePrefixRe = regexp.MustCompile(`^[A-Za-z]+Exception: `) ) @@ -21,7 +24,7 @@ func errMessage(err error) string { } func validateJobName(name string) error { - if !jobNameRe.MatchString(name) { + if !jobNameRe().MatchString(name) { return fmt.Errorf("%w: JobName must match ^([\\p{L}\\p{Z}\\p{N}_.:/=+\\-%%@]*)$ and be 1-256 characters", ErrValidation) } diff --git a/services/cosmosdb/PARITY.md b/services/cosmosdb/PARITY.md index 9f5ddc57c4..536f7ee7b1 100644 --- a/services/cosmosdb/PARITY.md +++ b/services/cosmosdb/PARITY.md @@ -3,7 +3,7 @@ service: cosmosdb sdk_module: azure-sdk-for-go/sdk/data/azcosmos@v1.5.0 last_audit_commit: 658ecc434 last_audit_date: 2026-10-20 -overall: B +overall: A # Per-op or per-op-family status. Values: ok | partial | gap | deferred. # wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. ops: @@ -12,7 +12,7 @@ ops: GetDatabase: {wire: ok, errors: ok, state: ok, persist: n/a, note: "GET /dbs/{db}. 200 + database body; 404 NotFound if absent."} ListDatabases: {wire: ok, errors: ok, state: ok, persist: n/a, note: "GET /dbs -> {\"_rid\":\"\",\"Databases\":[...],\"_count\":N}, sorted by id. x-ms-max-item-count / x-ms-continuation page the feed (see families.continuation); a malformed token is 400."} DeleteDatabase: {wire: ok, errors: ok, state: ok, persist: ok, note: "DELETE /dbs/{db}. 204; cascades to delete every container/document beneath it. 404 NotFound if absent."} - CreateContainer: {wire: ok, errors: ok, state: ok, persist: ok, note: "POST /dbs/{db}/colls, body {id, partitionKey:{paths,kind,version}, defaultTtl, indexingPolicy}. One to three distinct \"/\"-prefixed paths; more than one needs kind MultiHash (hierarchical keys, see families.partition_keys); 400 otherwise. defaultTtl must be -1 or a positive integer. indexingPolicy is stored and echoed (default policy when absent) and enforced on queries (families.indexing). uniqueKeyPolicy (up to 10 keys of up to 16 "/"-paths), geospatialConfig (Geography|Geometry), vectorEmbeddingPolicy, fullTextPolicy and computedProperties are validated, stored, persisted and echoed (unset unique-key and geospatial settings echo their defaults). _etag and _ts derive from a per-container modification stamp. 201 + container body; 409 on duplicate id; 404 if the database is missing."} + CreateContainer: {wire: ok, errors: ok, state: ok, persist: ok, note: "POST /dbs/{db}/colls, body {id, partitionKey:{paths,kind,version}, defaultTtl, indexingPolicy}. One to three distinct \"/\"-prefixed paths; more than one needs kind MultiHash (hierarchical keys, see families.partition_keys); 400 otherwise. defaultTtl must be -1 or a positive integer. indexingPolicy is stored and echoed (default policy when absent) and enforced on queries (families.indexing). uniqueKeyPolicy (up to 10 keys of up to 16 "/"-paths), geospatialConfig (Geography|Geometry), vectorEmbeddingPolicy, fullTextPolicy and computedProperties are validated, stored, persisted and echoed (unset unique-key and geospatial settings echo their defaults). A Geometry container (geospatialConfig) runs the spatial functions in planar coordinates and needs a boundingBox on every spatial index (families.indexing, families.sql_query); the bounding box constrains the real service spatial index only, so this engine, which has none, never lets it change a result. Computed properties follow the Learn page: at most 20, unique, top-level, not id or a system property, a SELECT VALUE over the root item with no JOIN, WHERE, GROUP BY, ORDER BY, TOP, DISTINCT, OFFSET LIMIT, subquery, aggregate, spatial function, UDF or nondeterministic function (400 otherwise); they are not indexed by default and the wildcard path does not cover them (only an explicit /name/? included path or a composite index does), so ORDER BY an unindexed one is 400 while a filter or GROUP BY evaluates at run time; a computed property wins over a persisted one of the same name in queries, and SELECT * returns only persisted properties; a spatial index on one is 400. The conflict resolution policy and the unique key policy can only be set at creation (400 on a replace that changes them; the service text is not documented, so the messages are ours). _etag and _ts derive from a per-container modification stamp. 201 + container body; 409 on duplicate id; 404 if the database is missing."} GetContainer: {wire: ok, errors: ok, state: ok, persist: n/a, note: "GET /dbs/{db}/colls/{coll}. 200 + container body (including its partitionKey definition); 404 if absent."} ListContainers: {wire: ok, errors: ok, state: ok, persist: n/a, note: "GET /dbs/{db}/colls -> {\"_rid\":\"\",\"DocumentCollections\":[...],\"_count\":N}, sorted by id; paged like ListDatabases."} DeleteContainer: {wire: ok, errors: ok, state: ok, persist: ok, note: "DELETE /dbs/{db}/colls/{coll}. 204; cascades to delete every document in it. 404 if absent."} @@ -32,27 +32,31 @@ ops: QueryOffers: {wire: ok, errors: ok, state: ok, persist: n/a, note: "POST /offers with the query header: the SQL engine runs over offer resources; azcosmos ReadThroughput/ReplaceThroughput query SELECT * FROM c WHERE c.offerResourceId = ''."} GetOffer: {wire: ok, errors: ok, state: ok, persist: n/a, note: "GET /offers/{offerRid}: content.offerThroughput, or content.offerAutopilotSettings.maxThroughput (+ autoUpgradePolicy.throughputPolicy.incrementPercent) for autoscale; x-ms-cosmos-min-throughput and x-ms-offer-replace-pending headers. Master-key signing uses the lower-cased offer rid, as the SDK does. 404 for an unknown rid."} ReplaceOffer: {wire: ok, errors: ok, state: ok, persist: ok, note: "PUT /offers/{offerRid} with the offer body; validation errors are 400 (families.throughput), unknown rid 404."} - ReadPartitionKeyRanges: {wire: ok, errors: ok, state: ok, persist: n/a, note: "GET .../colls/{coll}/pkranges: one physical range per ceil(RU/10000) of the offer (id 0..N-1) with an ETag derived from the count (If-None-Match -> 304). It is what azcosmos needs to resolve a change feed FeedRange; the emulator never splits."} + ReadPartitionKeyRanges: {wire: ok, errors: ok, state: ok, persist: ok, note: "GET .../colls/{coll}/pkranges: the container physical partition layout (families.throughput): id, _rid, minInclusive/maxExclusive EPK bounds, parents (all ancestors after a split), throughputFraction 1/N, status online, with an ETag that changes on every split (If-None-Match -> 304). Throughput and storage splits are applied before the read. It is what azcosmos needs to resolve a change feed FeedRange."} StoredProcedures: {wire: ok, errors: ok, state: ok, persist: ok, note: "Create (POST /sprocs, 201/409), Get, List (StoredProcedures feed, paged), Query, Replace (PUT, id must match), Delete on /dbs/{db}/colls/{coll}/sprocs[/{id}]. The body must compile as a single JavaScript function (400 otherwise)."} Triggers: {wire: ok, errors: ok, state: ok, persist: ok, note: "CRUD on /triggers[/{id}] as StoredProcedures, plus triggerType (Pre|Post) and triggerOperation (All|Create|Delete|Replace|Update), both validated and canonicalized."} UserDefinedFunctions: {wire: ok, errors: ok, state: ok, persist: ok, note: "CRUD on /udfs[/{id}] as StoredProcedures; callable from SQL, see families.server_side_scripts."} Users: {wire: ok, errors: ok, state: ok, persist: ok, note: "CRUD, list and SQL query on /dbs/{db}/users[/{user}] (users.go, users_ops.go). Body: id/_rid/_self/_etag/_ts/_permissions. 409 on duplicate id or rename onto an existing user; 404 for a missing database or user; deleting a user deletes its permissions."} - Permissions: {wire: ok, errors: ok, state: ok, persist: ok, note: "CRUD, list and query on /dbs/{db}/users/{user}/permissions[/{id}] (permission_ops.go). permissionMode All|Read; resource is a container or document link, id- or RID-based (400 for a database link, a missing resource or a bad mode); resourcePartitionKey needs a container resource. Every create, read, list and replace returns a fresh _token; x-ms-documentdb-expiry-seconds sets its lifetime. See families.resource_tokens."} + Permissions: {wire: ok, errors: ok, state: ok, persist: ok, note: "CRUD, list and query on /dbs/{db}/users/{user}/permissions[/{id}] (permission_ops.go). permissionMode All|Read; resource is a container, document, stored procedure, trigger or UDF link, id- or RID-based (400 for a database link, a missing resource or a bad mode); resourcePartitionKey scopes a container, document or script permission to one key. Every create, read, list and replace returns a fresh _token; x-ms-documentdb-expiry-seconds sets its lifetime. See families.resource_tokens."} ExecuteStoredProcedure: {wire: ok, errors: ok, state: ok, persist: ok, note: "POST /sprocs/{id} with the partition key header and a JSON array of parameters. 200 + the JSON body set with getResponse().setBody (empty body when never set). A thrown error is 400 with the JS message, a run over the time limit is 408; either way every write the script made is rolled back. x-ms-documentdb-script-enable-logging returns console.log output in x-ms-documentdb-script-log-results. Not served by azcosmos v1.5.0 (no sproc API), so tests use signed raw REST."} + ListConflicts: {wire: ok, errors: ok, state: ok, persist: ok, note: "GET /dbs/{db}/colls/{coll}/conflicts -> {_rid, Conflicts, _count}: the serving region conflicts feed (families.conflict_resolution); POST with the query header runs the SQL engine over the entries."} + GetConflict: {wire: ok, errors: ok, state: ok, persist: n/a, note: "GET .../conflicts/{id}: one entry; 404 when absent."} + DeleteConflict: {wire: ok, errors: ok, state: ok, persist: ok, note: "DELETE .../conflicts/{id}: 204 once the application has merged it; 404 when absent."} families: - server_side_scripts: {status: ok, note: "Stored procedures, triggers and UDFs run on the goja engine already used by AppSync (scripts.go, script_engine.go, script_env.go, script_exec.go, triggers.go, udf.go). Sandbox: a bare runtime with no require, process, fetch, timers or file/network access, a call-stack cap, and a time limit (5s, like Cosmos) enforced with vm.Interrupt. getContext() offers getCollection() (getSelfLink, createDocument, upsertDocument, readDocument, replaceDocument, deleteDocument with options.etag, queryDocuments with a SQL string or {query, parameters}), getResponse() and getRequest() (getBody/setBody), plus console.log. Every operation is confined to the request's partition (a document of another partition is a 400 passed to the callback) and runs in one transaction with the script: callbacks are invoked synchronously, an error passed to a callback is only thrown if the script throws it, and any thrown error, timeout or failed trigger rolls the whole run back (containerTx undo log; the change feed sequence is restored too). Triggers are named in x-ms-documentdb-pre-trigger-include / post-trigger-include on create, upsert, replace and delete (comma separated); an unknown trigger or one whose type/operation does not match is 400. A pre-trigger sees and may replace the request body, a post-trigger sees the stored document and may replace the response body, and both run in the write's transaction. UDFs are called as udf.name(args) in SQL (the parser only treats udf.x( as a call, so an alias named udf still works); an unknown or throwing UDF is a 400, a runaway one 408. A stored procedure runs on a private copy of its container without the backend lock and commits only if the container was not written meanwhile (retried up to 4 times, then run under the write lock); triggers still run under the write lock as part of their write."} - change_feed: {status: ok, note: "Latest-version and all-versions-and-deletes incremental feeds (ops.ReadChangeFeed), per physical partition key range. Continuations are per-container sequences (deletes consume one too), so a token stays valid across restarts but not across a container delete and recreate. The all-versions log keeps the newest 10000 versions per container; there is no retention window or continuous-backup requirement."} - conflict_resolution: {status: ok, note: "conflictResolutionPolicy is accepted on container create and replace, persisted and echoed (LastWriterWins on /_ts when absent). The emulator is a single region with a single writer, so no conflict ever arises and the /conflicts feed does not exist."} + server_side_scripts: {status: ok, note: "Stored procedures, triggers and UDFs run on the goja engine already used by AppSync (scripts.go, script_engine.go, script_env.go, script_exec.go, triggers.go, udf.go). Sandbox: a bare runtime with no require, process, fetch, timers or file/network access, a call-stack cap, and a time limit (5s, like Cosmos) enforced with vm.Interrupt. getContext() offers getCollection() (getSelfLink, createDocument, upsertDocument, readDocument, replaceDocument, deleteDocument with options.etag, queryDocuments with a SQL string or {query, parameters}), getResponse() and getRequest() (getBody/setBody), plus console.log. Every operation is confined to the request's partition (a document of another partition is a 400 passed to the callback) and runs in one transaction with the script: callbacks are invoked synchronously, an error passed to a callback is only thrown if the script throws it, and any thrown error, timeout or failed trigger rolls the whole run back (containerTx undo log; the change feed sequence is restored too). Triggers are named in x-ms-documentdb-pre-trigger-include / post-trigger-include on create, upsert, replace, patch and delete (comma separated; the .NET PatchItemRequestOptions inherits PreTriggers/PostTriggers from ItemRequestOptions, so a patch is a Replace/Update operation whose pre-trigger sees the patch body {operations, condition} and may rewrite it; azcosmos exposes triggers on neither patch nor transactional batch, and TransactionalBatch has no trigger option, so a batch runs none); an unknown trigger or one whose type/operation does not match is 400. A pre-trigger sees and may replace the request body, a post-trigger sees the stored document and may replace the response body, and both run in the write's transaction. UDFs are called as udf.name(args) in SQL (the parser only treats udf.x( as a call, so an alias named udf still works); an unknown or throwing UDF is a 400, a runaway one 408. A stored procedure runs on a private copy of its container without the backend lock and commits only if the container was not written meanwhile (retried up to 4 times, then run under the write lock); triggers still run under the write lock as part of their write."} + change_feed: {status: ok, note: "Latest-version and all-versions-and-deletes incremental feeds (ops.ReadChangeFeed), per physical partition key range. Continuations are per-container sequences (deletes consume one too), so a token stays valid across restarts but not across a container delete and recreate. The all-versions-and-deletes log follows the Change feed modes page: it exists only on an account with continuous backup (an ARM account with periodic backup answers 400 to any read in that mode; a standalone emulator defaults to the 7-day tier, ARM Continuous7Days/Continuous30Days set 7 or 30 days), keeps every version inside that window (a 1,000,000-record guard stands in for memory only), starts from now or a continuation but not from the beginning or a point in time (400), and answers 400 for a continuation older than the window. Entries are {current, metadata{operationType, lsn, crts, previousImageLSN, id, partitionKey, timeToLiveExpired}} with no previous image, as the page says none is available; each write has its own lsn rather than sharing one per batch. Deletes by TTL are logged when the sweeper purges them."} + conflict_resolution: {status: ok, note: "conflictResolutionPolicy is validated on container create (mode LastWriterWins or Custom, path starting with /) and, as the conflicts pages say, can only be set at creation: a replace naming a different policy is 400. It takes effect on a replicated multi-write account (families.multi_region). LastWriterWins compares the numeric value at conflictResolutionPath (default /_ts; a missing or non-numeric value falls back to _ts) and the higher wins, ties go to the later write; a delete always beats an insert or replace; the loser is dropped and never reaches the feed. Custom runs the registered merge stored procedure in the receiving region as one transaction with (incomingItem, existingItem, isTombstone, conflictingItems) exactly as the Learn sample uses them (existingItem._self and collection.getSelfLink() work); a missing or throwing procedure, or Custom with no procedure, writes the incoming write to that region conflicts feed. conflictingItems is always empty: unique-index conflicts between different ids are not looked for. Conflicts feed: GET/POST(query) /dbs/{db}/colls/{coll}/conflicts, GET/DELETE .../conflicts/{id}, per region, entries {id, _rid, _self, _ts, operationType create|replace|delete, resourceType document, resourceId, conflictLSN, content (the incoming document as a JSON string)}. azcosmos has no conflicts API, so tests use signed raw REST; the entry field names beyond operationType, resourceType, content are inferred from the .NET ConflictProperties shape."} + multi_region: {status: ok, note: "An account with more than one region (set through services/azurearm locations and failoverPriority, or the admin API) gives every region its own listener, advertised in readableLocations/writableLocations with a distinct host:port (Handler.RegionPortBase fixes the ports, otherwise ephemeral); the account endpoint stays the global one and serves the current write region. Single-writer: the lowest failover priority among online regions writes and a write sent to a read region is 403 with x-ms-substatus 3 (WriteForbidden); multi-write accepts writes everywhere. The default account (one region) is unchanged. Consistency (userConsistencyPolicy, from ARM consistencyPolicy or the admin API): with ReplicationLagMs set on a region, or multi-write, regions keep separate copies (repl.go) and a write reaches each other region after that region lag. Strong replicates synchronously; Bounded Staleness caps the lag at maxIntervalInSeconds and forces the oldest write in once a region trails by more than maxStalenessPrefix writes; Session, Consistent Prefix and Eventual deliver after the lag in order. Session reads carry x-ms-session-token and a region that has not applied it answers 404 with x-ms-substatus 1002 (ReadSessionNotAvailable, which azcosmos retries on the write region); a request x-ms-consistency-level of Eventual or a weaker level than Session skips the check. Admin API (unauthenticated, like /_gopherstack/reset): GET/PUT /_gopherstack/cosmosdb/regions (regions with name, failoverPriority, replicationLagMs, offline; multiWrite; automaticFailover; consistency; PUT validates Strong vs multi-write and the bounded-staleness minimums) and POST /_gopherstack/cosmosdb/regions/{name}/offline|online. An offline region answers 403 with x-ms-substatus 1008 (DatabaseAccountNotFound); when the write region goes offline and automaticFailover is on (ARM enableAutomaticFailover, default on for a standalone emulator) the next online region by failover priority becomes the write region and stays it after the old one returns (failback is a priority change). Writes still in flight from the failed region wait for it; when it returns they land, or, if the new write region wrote the same item meanwhile, go to the conflicts feed (the consistency page RPO). Replicated state is rebuilt converged on restore; the topology, lag, offline state, consistency and conflicts feed persist. A replicated account serialises its data-plane requests on one gate. Not modelled: preferred-region routing is client side (azcosmos PreferredRegions works against the advertised endpoints), writes throttled by staleness, per-partition lag, dynamic quorum."} auth: {status: ok, note: "Opt-in (--cosmosdb-validate-auth / COSMOSDB_VALIDATE_AUTH, default off: every request accepted, malformed headers only logged). When on, EVERY route (account root, databases, containers, documents, Table API) needs a valid Authorization: type=master&ver=1.0&sig= over verb/resourceType/resourceId/x-ms-date (masterkey.go, known-answer vector in masterkey_test.go). A missing header, a missing or unparsable x-ms-date/Date, a date more than 15 minutes from server time, and a bad signature all return 401 Unauthorized. Table API routes also accept SharedKey/SharedKeyLite signed with the same key (pkgs/azureauth). The dashboard proxy signs its forwarded requests server-side with the in-process key (Handler.SignRequest), so it keeps working with validation on and no key reaches the browser. Verified end to end with the azcosmos SDK (sdk_test.go)."} - resource_tokens: {status: partial, note: "A permission's _token is type=resource&ver=1&sig=; (token.go). Claims name only db/user/permission plus iat/exp; the HMAC key is derived from the master key (HMAC of a fixed context label), so tokens survive restarts and rotating the master key invalidates them. With --cosmosdb-validate-auth the data plane verifies the MAC in constant time, checks the validity window and then reads the LIVE permission, so replacing or deleting a permission revokes its tokens at once (401 once deleted). A token grants only its permission's scope: Read allows GET/HEAD and SQL queries, All allows writes; a container permission covers that container, its documents, sprocs, triggers, UDFs and pkranges; a document permission covers that one document only; a partition key permission needs a matching x-ms-documentdb-partitionkey on every document request (cross-partition queries are 403). Account root GET is always allowed; databases, other containers, users, permissions, offers and the Table API are 403 with the real message. Bad MAC or malformed token is 401. Expired is 403 with the real 'not valid at the current time' message. Lifetime default 3600 s, max 18000 s; the minimum is unverified so any value from 1 is accepted. azcosmos v1.5.0 cannot use resource tokens (NewClientWithKey takes a master key, NewClient an AAD TokenCredential whose header is type=aad), so coverage is raw REST in users_test.go."} - wire_protocol: {status: ok, note: "REST+JSON. x-ms-version (2020-07-15), x-ms-request-id, Date, x-ms-request-charge (RU model, families.request_charge), x-ms-session-token (static 0:0#0), x-ms-activity-id on every response; x-ms-item-count and x-ms-continuation on feeds and queries. Errors use {\"code\",\"message\"} with the matching status."} - sql_query: {status: ok, note: "Lexer -> recursive-descent parser -> resolver -> executor (sql_tokenizer.go, sql_parser.go, sql_resolve.go, sql_exec.go, sql_eval.go, sql_funcs.go, sql_agg.go). SELECT [DISTINCT] [TOP n] (* | VALUE expr | expr [AS name], ...) FROM container [alias] (JOIN x IN expr | JOIN expr alias)* WHERE GROUP BY ORDER BY (multiple keys, ASC/DESC, any expression) OFFSET n LIMIT n, with @params anywhere a value fits (including TOP/OFFSET/LIMIT). Expressions: comparison, AND/OR/NOT, IN, BETWEEN, LIKE [ESCAPE], IS [NOT] NULL, arithmetic, ||, ??, ?:, property and [index] access, array and object literals, undefined. Subqueries: EXISTS(...), ARRAY(...), scalar (...), correlated, with their own FROM x IN c.arr. Aggregates COUNT/SUM/AVG/MIN/MAX (also VALUE COUNT(1)); an aggregate over an empty set yields COUNT 0 and an undefined SUM/AVG/MIN/MAX. Built-ins: string (CONCAT CONTAINS STARTSWITH ENDSWITH LOWER UPPER LENGTH SUBSTRING REPLACE TRIM LTRIM RTRIM LEFT RIGHT REVERSE REPLICATE INDEX_OF TOSTRING STRINGEQUALS REGEXMATCH STRINGTONUMBER STRINGTOBOOLEAN STRINGTONULL), math (ABS CEILING FLOOR ROUND TRUNC SQRT SQUARE POWER SIGN EXP LOG LOG10 PI), type checks (IS_DEFINED IS_NULL IS_NUMBER IS_STRING IS_BOOL IS_ARRAY IS_OBJECT IS_PRIMITIVE IS_FINITE_NUMBER IS_INTEGER), array (ARRAY_CONTAINS ARRAY_LENGTH ARRAY_SLICE ARRAY_CONCAT SETINTERSECT SETUNION), conversion (StringToArray StringToObject StringToBoolean StringToNull StringToNumber ToString), date (GetCurrentDateTime/Timestamp/Ticks and Static variants, DateTimeAdd, DateTimeDiff, DateTimePart, DateTimeFromParts, DateTimeToTicks, TicksToDateTime, DateTimeToTimestamp, TimestampToDateTime; ticks are 100ns since 1970), spatial (ST_DISTANCE in meters, ST_WITHIN, ST_INTERSECTS, ST_ISVALID, ST_ISVALIDDETAILED over GeoJSON Point, LineString and Polygon with holes; planar containment on longitude/latitude and haversine distance on a 6371008.8 m sphere, so polygons crossing the antimeridian or a pole and long edges are approximate; Multi* types are validated only), REGEXMATCH modifiers i m s x (x strips whitespace and # comments outside classes and escapes). Three-valued logic: a missing field or unbound parameter is undefined and never flips under NOT; only a definite true keeps a row; IS NULL on undefined is false. Every identifier must resolve to a FROM/JOIN/subquery alias (unqualified or unknown names are 400). Numbers stay json.Number, int64-exact beyond 2^53. Recursion is bounded (maxQueryDepth=100). Unnamed projections are named $1, $2 as in Cosmos. Parse and runtime-argument errors are 400, never a panic."} - indexing: {status: partial, note: "indexingPolicy is parsed per query (indexing.go, QueryOptions). A multi-key ORDER BY over plain properties needs a composite index whose leading paths match in order with all directions equal or all opposite, else 400 BadRequest \"The order by query does not have a corresponding composite index that it can be served from.\". A WHERE the index cannot serve is 400 \"An invalid query has been specified with filters against path(s) excluded from indexing. Consider adding allow scan header in the request.\" unless x-ms-documentdb-query-enable-scan (REST name) or x-ms-documentdb-force-query-scan (azcosmos EnableScanInQuery) is true. A path is indexed by the longest matching includedPaths/excludedPaths entry (/a/?, /a/*, /*); an AND is served when either side is, an OR or single predicate only when everything it touches is; indexingMode none indexes only id and _rid. The two messages are from the Azure documentation and gateway behaviour as reported by users, not from an SDK fixture: azcosmos carries no server text. Not modelled: ORDER BY on one excluded path, composite-index requirements for filter plus ORDER BY, spatial index checks, the requirement that vector paths be excluded from the regular index, lazy-mode staleness, and queries issued from stored procedures (never checked). vectorIndexes (flat, quantizedFlat, diskANN) are validated against vectorEmbeddingPolicy and fullTextIndexes against fullTextPolicy at create/replace (400); ORDER BY VectorDistance without a vector index on the path (unless bruteForce is true) and ORDER BY RANK FullTextScore without a full-text index are 400 (message text unverified)."} - search: {status: ok, note: "vectorEmbeddingPolicy (path, dataType float16/float32/uint8/int8, dimensions 1..4096, distanceFunction cosine/dotproduct/euclidean) and fullTextPolicy (languages as tags like en-US) are validated and immutable after creation. VectorDistance(a, b[, bruteForce[, {distanceFunction, dataType}]]) is evaluated exactly (brute force) whatever the index type, in SELECT, WHERE and ORDER BY (best match first for the metric whatever ASC/DESC is written; undefined for mismatched dimensions, non-numeric vectors or a zero vector under cosine). FullTextContains/ContainsAll/ContainsAny match lowercased alphanumeric tokens (no stemming or stop words; every language tokenizes the same); FullTextScore is Okapi BM25 (k1 1.2, b 0.75) over the scanned container rather than per physical partition; ORDER BY RANK FullTextScore(...) and RANK RRF(...) (reciprocal rank fusion, k 60, optional weights array) order the rows. Verified through azcosmos container policies and queries; result ordering for ties and exact scores are not compared with the service. ST_ functions already existed (sql_spatial.go)."} + resource_tokens: {status: ok, note: "A permission's _token is type=resource&ver=1&sig=; (token.go). Claims name only db/user/permission plus iat/exp; the HMAC key is derived from the master key (HMAC of a fixed context label), so tokens survive restarts and rotating the master key invalidates them. With --cosmosdb-validate-auth the data plane verifies the MAC in constant time, checks the validity window and then reads the LIVE permission, so replacing or deleting a permission revokes its tokens at once (401 once deleted). A token grants only its permission's scope: Read allows GET/HEAD and SQL queries, All allows writes; a container permission covers that container, its documents, sprocs, triggers, UDFs and pkranges; a document permission covers that one document only; a stored procedure, trigger or UDF permission covers that one script (executing a procedure is a write, so needs All); a partition key permission (container, document or script) needs a matching x-ms-documentdb-partitionkey on every request (cross-partition queries are 403). Attachments are not implemented by the service, so no attachment permission exists. Account root GET is always allowed; databases, other containers, users, permissions, offers and the Table API are 403 with the real message. Bad MAC or malformed token is 401. Expired is 403 with the real 'not valid at the current time' message. Lifetime default 3600 s, 600 s minimum and 86400 s maximum per the Azure Cosmos DB Service quotas page (Per-request limits: minimum 10 min, maximum 24 h by default). The Create Permission REST page (revised 2020) still says 18000 s maximum; the quotas page is current, so 86400 is the default, and --cosmosdb-resource-token-max-seconds / COSMOSDB_RESOURCE_TOKEN_MAX_SECONDS (600..86400, anything else fails startup) lowers the cap, e.g. to 18000. Anything outside 600..max is 400 with the configured max in the text (error text ours). azcosmos v1.5.0 cannot use resource tokens (NewClientWithKey takes a master key, NewClient an AAD TokenCredential whose header is type=aad), so coverage is raw REST in users_test.go."} + wire_protocol: {status: ok, note: "REST+JSON. x-ms-version (2020-07-15), x-ms-request-id, Date, x-ms-request-charge (RU model, families.request_charge), x-ms-session-token (static 0:0# on an unreplicated account; on a replicated one 0:-1##=... naming the writes the serving region holds), x-ms-activity-id on every response; x-ms-item-count and x-ms-continuation on feeds and queries. x-ms-consistency-level may only weaken the account default (400 otherwise). Errors use {\"code\",\"message\"} with the matching status."} + sql_query: {status: ok, note: "Lexer -> recursive-descent parser -> resolver -> executor (sql_tokenizer.go, sql_parser.go, sql_resolve.go, sql_exec.go, sql_eval.go, sql_funcs.go, sql_funcs_more.go, sql_agg.go, sql_spatial.go, sql_geography.go). SELECT [DISTINCT] [TOP n] (* | VALUE expr | expr [AS name], ...) [FROM container [alias] (JOIN x IN expr | JOIN expr alias | JOIN (subquery) [alias])*] WHERE GROUP BY ORDER BY (multiple keys, ASC/DESC, any expression) OFFSET n LIMIT n, with @params anywhere a value fits. The FROM clause is optional (the SELECT page examples SELECT VALUE 1 and SELECT 'a', 'b'); a join over a subquery iterates its rows. GROUP BY with ORDER BY is 400 with the service text \"'ORDER BY' is not supported in presence of GROUP BY.\" (the gocosmos known-issues list quotes it; the GROUP BY and ORDER BY reference pages state neither a restriction nor a text). Expressions: comparison, AND/OR/NOT, IN, BETWEEN, LIKE [ESCAPE], IS [NOT] NULL, arithmetic, ||, ??, ?:, property and [index] access, array and object literals, undefined. Subqueries: EXISTS(...), ARRAY(...), scalar (...), correlated. Aggregates COUNT/COUNTIF/SUM/AVG/MIN/MAX. Every built-in on the Learn Functions page is implemented: math (ABS ACOS ASIN ATAN ATN2 CEILING COS COT DEGREES EXP FLOOR INT* LOG LOG10 NUMBERBIN PI POWER RADIANS RAND ROUND SIGN SIN SQRT SQUARE TAN TRUNC; the INT functions take integers only and have no result for a decimal, a zero divisor or an overflow), array (ARRAY_CONCAT ARRAY_CONTAINS ARRAY_CONTAINS_ALL ARRAY_CONTAINS_ANY ARRAY_LENGTH ARRAY_SLICE CHOOSE OBJECTTOARRAY SETINTERSECT SETUNION), string (CONCAT CONTAINS ENDSWITH INDEX_OF LASTSUBSTRINGAFTER/BEFORE LEFT LENGTH LOWER LTRIM REGEXEXTRACT[ALL] REGEXMATCH REGEXREPLACE[ALL] REPLACE REPLICATE REVERSE RIGHT RTRIM STARTSWITH STRINGEQUALS STRINGJOIN STRINGSPLIT STRINGTO* SUBSTRING SUBSTRINGAFTER/BEFORE TOSTRING TRIM UPPER; regex options i m s x), date (DATETIMEADD DATETIMEBIN DATETIMEDIFF DATETIMEFROMPARTS DATETIMEPART DATETIMETOTICKS DATETIMETOTIMESTAMP GETCURRENT*, TICKSTODATETIME TIMESTAMPTODATETIME; date strings follow the IS_DATETIME rules), DOCUMENTID (the item change-feed sequence), IIF, type checks (IS_* including IS_DATETIME), full-text and vector functions (families.search) and spatial functions. Each documented example in the function pages is a test (sql_functions_doc_test.go). Where a page is silent the result is undefined, notably SUBSTRINGAFTER/BEFORE and LASTSUBSTRINGAFTER/BEFORE when the delimiter is absent (unverified). Spatial (GeoJSON Point, LineString, Polygon with holes, MultiPolygon): a Geography container computes on WGS 84 - ST_DISTANCE is the geodesic (Vincenty) in metres, matching the documented 3.345269817 km and 1907.438421 km to 1e-7, ST_AREA the authalic-sphere area in square metres (735,970,283.11 against the documented 735,970,283.05), edges are great-circle arcs, and a polygon holds the region to the left of its rings so a clockwise ring is the inverse region as the geospatial page says; ST_ISVALIDDETAILED names each out-of-range axis (the documented latitude text); a Geometry container (geospatialConfig) uses planar coordinates: Euclidean distance and shoelace area in coordinate units, even-odd containment, any finite coordinate valid. Edge-to-shape distances use the closest point of the arc then the geodesic; overlapping MultiPolygon members and self-intersecting rings are not diagnosed. Three-valued logic: a missing field or unbound parameter is undefined and never flips under NOT; only a definite true keeps a row. Every identifier must resolve to a FROM/JOIN/subquery alias. Numbers stay json.Number, int64-exact beyond 2^53. Recursion is bounded (maxQueryDepth=100). Unnamed projections are named $1, $2. A function the Functions page does not list is a 400 parse error."} + indexing: {status: ok, note: "indexingPolicy is parsed per query (indexing.go, QueryOptions) for HTTP queries and for queryDocuments inside stored procedures (script_env.go; the server-side FeedOptions are pageSize default 100, continuation and enableScan, from azure-cosmosdb-js-server Collection.queryDocuments, and an error reaches the callback as err.number 400). A multi-key ORDER BY over plain properties needs a composite index whose leading paths match in order with all directions equal or all opposite, else 400 BadRequest. A single-key ORDER BY on a path the policy excludes is 400. A WHERE the index cannot serve is 400 unless x-ms-documentdb-query-enable-scan (REST name), x-ms-documentdb-force-query-scan (azcosmos EnableScanInQuery) or enableScan is true; a filter plus ORDER BY needs no composite index (the index-policy page: the query still succeeds, only RU cost differs). A path is indexed by the longest matching includedPaths/excludedPaths entry (/a/?, /a/*, /*); id and _ts are always indexed in consistent mode; an AND is served when either side is, an OR or single predicate only when everything it touches is; indexingMode none indexes only id and _rid; lazy is 400 (the page: new containers cannot select it; the exemption by contacting the service team is not modelled). spatialIndexes (spatial_index.go) need a path starting with / and ending in /? or /*, one or more of Point, Polygon, MultiPolygon, LineString (azcosmos SpatialType), and on a Geometry container a boundingBox with xmin null); point operations need every component (400 otherwise); the body's key must agree with the request's on replace (400). A query's x-ms-documentdb-partitionkey header scopes it to that partition, a leading prefix on hierarchical containers; an empty array means cross-partition."} tls: {status: ok, note: "Opt-in HTTPS via COSMOSDB_TLS=true (read in Provider.Init; COSMOSDB_TLS_HOSTS adds comma-separated names/IPs to the certificate): the listener serves an in-memory self-signed certificate from pkgs/devtls valid for localhost, 127.0.0.1 and ::1. Handler.TLSCertPEM returns the certificate for clients to trust. The account root advertises https:// endpoints when TLS is on. Default is plain HTTP."} edm_fidelity: {status: ok, note: "Document bodies are decoded (decodeJSONObject) and deep-copied (deepCopyBody/deepCopyJSONValue) via json.Decoder.UseNumber throughout the create/replace/get/query/snapshot paths, so a JSON number beyond float64's 53-bit mantissa (e.g. 9007199254740993) never silently loses precision -- covered end-to-end by TestInMemoryBackend_Int64PrecisionPreservedThroughStorage, TestBackend_SnapshotRestore_Int64PrecisionRoundTrip, and TestExecuteQuery_Int64PrecisionInComparison, at the math.MaxInt64/MinInt64 boundaries too."} @@ -62,18 +66,9 @@ families: persistence: {status: ok, note: "Snapshot version 1, unchanged: new container fields (DefaultTTL, PartitionKeyPaths, IndexingPolicy) and the Table API state (tableApi, the odatatable snapshot) are optional additions that older snapshots simply lack. storedDocument.Body round-trips through json.RawMessage with UseNumber so numbers beyond 2^53 survive. Restore tolerates null nested maps, rejects null entries, and a snapshot without tableApi empties the Table API."} routing_isolation: {status: ok, note: "Runs on its own dedicated *http.Server, bound synchronously in StartWorker to a fixed port (default 8081 via --cosmosdb-port/COSMOSDB_PORT, the real Cosmos DB Local Emulator's own published default -- NOT any of Azurite's 10000/10001/10002; no fallback pool -- fails fast if unavailable). Unlike AzureBlob/AzureQueue/AzureTable, 8081 sits OUTSIDE --port-range-start/--port-range-end's own default range (10000-10100), exactly like services/iot's MQTT broker default (1883) -- but cli.go's reserveFixedServicePorts still reserves it in the shared PortAlloc pool, since a custom operator-configured range could include 8081. See cli_cosmosdb_port_reservation_test.go's table, which (unlike AzureTable's) covers both the outside-range default case and a custom in-range case."} observability: {status: ok, note: "StartWorker wraps its Echo handler with telemetry.WrapEchoHandler so ExtractOperation/ExtractResource feed Prometheus metrics. InMemoryBackend uses *lockmetrics.RWMutex instead of raw sync.RWMutex, matching repo convention."} - table_api: {status: partial, note: "Table API CRUD, $filter and $batch on the same port, routed by path shape, delegating to pkgs/odatatable via an independent TableBackend; auth as families.auth (master key or SharedKey). Table state is included in Snapshot/Restore/Reset. POST /$batch takes one multipart/mixed changeset of up to 100 insert, update (PUT + If-Match), merge (MERGE, PATCH or POST + X-HTTP-Method), delete and upsert operations on one table and partition, one operation per row, applied atomically by odatatable.ExecuteBatch. Any failure answers 202 with a changeset holding only the failing operation (409 EntityAlreadyExists, 412 UpdateConditionNotSatisfied, 404 ResourceNotFound/TableNotFound, 400 CommandsInBatchActOnDifferentPartitions/InvalidDuplicateRow) whose message value is \":\\nRequestId:..\\nTime:..\"; success answers 202 with one part per operation (201 + entity for an insert without Prefer: return-no-content, otherwise 204 + ETag) echoing Content-ID. Verified with aztables SubmitTransaction. See services/azuretable/PARITY.md for the engine's own gaps. $batch also answers one bare point retrieve outside a changeset (GET of PartitionKey/RowKey, 202 with the operation response and no changeset wrapper); a retrieve mixed with writes, a second part, or a payload over 4 MiB is 400."} + table_api: {status: ok, note: "Table API CRUD, $filter and $batch on the same port, routed by path shape, delegating to pkgs/odatatable via an independent TableBackend; auth as families.auth (master key or SharedKey). Table state is included in Snapshot/Restore/Reset. POST /$batch takes one multipart/mixed changeset of up to 100 insert, update (PUT + If-Match), merge (MERGE, PATCH or POST + X-HTTP-Method), delete and upsert operations on one table and partition, one operation per row, applied atomically by odatatable.ExecuteBatch. Any failure answers 202 with a changeset holding only the failing operation (409 EntityAlreadyExists, 412 UpdateConditionNotSatisfied, 404 ResourceNotFound/TableNotFound, 400 CommandsInBatchActOnDifferentPartitions/InvalidDuplicateRow) whose message value is \":\\nRequestId:..\\nTime:..\"; success answers 202 with one part per operation (201 + entity for an insert without Prefer: return-no-content, otherwise 204 + ETag) echoing Content-ID. Verified with aztables SubmitTransaction. $batch also answers one bare point retrieve outside a changeset (GET of PartitionKey/RowKey, 202 with the operation response and no changeset wrapper); a retrieve mixed with writes, a second part, a second changeset, or a payload over 4 MiB is 400. $select is honoured literally here, including PartitionKey, RowKey and Timestamp (the shared engine always emits them; this service strips the unselected ones). Query Tables pages by $top (max 1,000) with x-ms-continuation-NextTableName and NextTableName. Query Entities (direct and as a $batch query part) returns pages of at most 1,000 entities (or $top) with x-ms-continuation-NextPartitionKey/NextRowKey headers and honours the NextPartitionKey/NextRowKey parameters (Learn: Query Entities, Performing entity group transactions)."} gaps: [] -items_still_open: - - Throughput: physical partitions are modelled from RU only, ceil(RU/10000) of the offer (autoscale uses its maximum); the documented 50 GB storage term is not modelled, a lowered throughput shrinks the ranges at once (the service never merges partitions), range ids are always 0..N-1 (no split parents or ids), partition keys map to ranges by an even split of a SHA-256 hash space with 64-bit EPK bounds (the service uses MurmurHash3 over the encoded key), a cross-partition request charges its RU evenly across all partitions rather than by work done, and a shared database offer is partitioned by its total RU; burst capacity is a model of the documented 300 s idle bank spent at up to 3000 RU/s total per partition (applicability conditions unverified, not enforced for partitions already at 3000 RU/s or more); autoscale reports only its minimum, x-ms-cosmos-current-throughput is not exposed by azcosmos and is not emitted; serverless accounts are a single process-wide mode (--cosmosdb-serverless) with the 5000 RU/s container cap but no 50 GB storage limit; the replace between manual and autoscale is accepted on the data plane; minimum throughput is a flat 400 RU/s (not storage- or container-count-derived); Table API and metadata operations are not metered; the throughput error texts (min 400 manual / 1000 autoscale, steps of 100 / 1000, max 1,000,000) are from documented limits and the 429 text follows the task wording, not captured service responses - - IndexingPolicy: multi-key ORDER BY composite-index, excluded-path/scan and vector/full-text index enforcement exist (families.indexing, families.search); still open are spatial index validation and enforcement inside stored-procedure queries (the docs state neither); a single-key ORDER BY on a path the policy does not index is 400 (text from the Mongo-API troubleshooting page and Microsoft Q&A, same engine; the scan header does not bypass it), `id` and `_ts` are always indexed in consistent mode, and lazy mode is rejected with 400 because the index-policy page says new containers cannot select it (error text paraphrased, exemptions not modelled). Filter plus ORDER BY needs no composite index (the index-policy page: the query still succeeds, only RU cost differs) and the current page no longer requires vector paths in excludedPaths, so neither is enforced - - MultiRegion: regions, failoverPriority, multi-write and manual failover (ARM failoverPriorityChange) are modelled as account metadata over ONE physical store, so there is no replication lag, no regional endpoint, no automatic failover, no preferred-region routing and no write conflicts or conflicts feed; the data plane is one account, so the last ARM account written decides the topology; consistency levels other than a static session token are not modeled - - RequestCharge: an approximation of documented costs, not a metering of index and storage work - - SQL: ORDER BY combined with GROUP BY is accepted although the real service may reject it (the ORDER BY and GROUP BY reference pages, checked 2026-10-24, state neither a restriction nor an error text, so behaviour stays permissive); spatial functions are planar/haversine approximations with no Multi* geometry operations and no Geometry-mode (planar metre) semantics; ST_ISVALIDDETAILED reasons beyond the unclosed-ring text are paraphrased; VectorDistance is exact (no approximate index, no quantization error, dataType only validated) and FullTextScore tokenization has no stemming (families.search); unsupported functions are a 400 parse error - - ItemOperations: the all-versions-and-deletes feed keeps only the newest 10000 versions per container and ignores the continuous-backup retention window; pre/post triggers fire for create, upsert, replace and delete only (the triggers REST page: "creation, deletion, and replacement of a document"; the patch REST page has no trigger header and azcosmos transactional batch has no trigger option), so PATCH and batch run none; stored procedure callbacks run synchronously, and a stored procedure runs on a private copy of its container without the backend lock, committing only if the container was not written meanwhile (up to 4 tries, then under the write lock), so a long script no longer stalls other containers but a busy container can fall back to the lock; there is no server-side bulk operation (the SDKs implement bulk client-side over batches and point operations) - - Containers: geospatialConfig is stored and echoed only (no planar Geometry behaviour); vectorEmbeddingPolicy and fullTextPolicy are validated and enforced (families.search); computed properties are not used for index selection; the replace-time error text for a changed unique key policy is unverified - - TableAPI: a $batch answers a single bare query (point retrieve or entity-collection query with $filter/$top/$select, 200 feed in the part; the docs show only the point retrieve, and a query part carries no continuation headers because the Table API here pages nothing), but several changesets (the service processes the first and rejects the rest with 400; here the whole request is 400 because the rejection response shape is undocumented), is 400; the 4 MiB payload limit is enforced as 400 InvalidInput with no documented service error code; Table API responses carry no per-operation request charge or continuation beyond what pkgs/odatatable provides - - ResourceTokens: a document permission matches by document id across all partition keys (ids repeat across keys; the permission docs define no partition scoping for a document resource, while container permissions with resourcePartitionKey are already restricted to that key); permissions on stored procedures, triggers, UDFs and attachments are rejected rather than modelled; a permission left pointing at a deleted container simply grants nothing; the minimum x-ms-documentdb-expiry-seconds is unverified (docs give only the 3600 default and the 18000 maximum) +items_still_open: [] structural_gaps: [] deferred: [] leaks: {status: clean, note: "The listener http.Server and the TTL sweeper worker.Group are both stopped by Shutdown (Group.Stop joins the sweeper goroutine); TestTTL_JanitorSweepsAndStops covers start and double shutdown."} @@ -192,12 +187,11 @@ milestone. It is **not** a reimplementation: it delegates entity CRUD, the exact same shared engine `services/azuretable` imports (see that package's doc comment for the extraction this milestone performed). This service's own `families.table_api` entry above covers what's new here -(routing, its own independent `TableBackend` instance, auth reuse); for the -engine's own known gaps -- pagination, `$select`'s PartitionKey/RowKey/ -Timestamp behavior, the Edm.Double/Int32 unannotated-number ambiguity, `$batch` -deferral, and so on -- see **`services/azuretable/PARITY.md`** directly -rather than duplicating that list here: every gap recorded there applies -here identically, since it's the same code. +(routing, its own independent `TableBackend` instance, auth reuse). The +engine's own gaps that apply to the Azure Table service (no SAS, unenforced +auth) do not apply here: this service layers its own routing, pagination, +`$select` and `$batch` handling over the engine and authenticates with the +Cosmos master key or a resource token. Table API state is part of `Handler.Snapshot`/`Restore`/`Reset`: the `odatatable` snapshot rides in the backend snapshot under `tableApi`. diff --git a/services/cosmosdb/README.md b/services/cosmosdb/README.md index 4206615108..289c5d44c5 100644 --- a/services/cosmosdb/README.md +++ b/services/cosmosdb/README.md @@ -1,30 +1,18 @@ # Azure Cosmos DB -**Parity grade: B** · SDK `azure-sdk-for-go/sdk/data/azcosmos@v1.5.0` · last audited 2026-10-20 (`658ecc434`) +**Parity grade: A** · SDK `azure-sdk-for-go/sdk/data/azcosmos@v1.5.0` · last audited 2026-10-20 (`658ecc434`) ## Coverage | Metric | Value | | --- | --- | -| PARITY entries audited | 32 (32 ok) | -| Feature families | 23 (19 ok, 4 partial) | -| Known gaps | 9 | +| PARITY entries audited | 35 (35 ok) | +| Feature families | 24 (24 ok) | +| Known gaps | none | | Deferred items | 0 | | Resource leaks | clean | -### Known gaps - -- Throughput: physical partitions are modelled from RU only, ceil(RU/10000) of the offer (autoscale uses its maximum); the documented 50 GB storage term is not modelled, a lowered throughput shrinks the ranges at once (the service never merges partitions), range ids are always 0..N-1 (no split parents or ids), partition keys map to ranges by an even split of a SHA-256 hash space with 64-bit EPK bounds (the service uses MurmurHash3 over the encoded key), a cross-partition request charges its RU evenly across all partitions rather than by work done, and a shared database offer is partitioned by its total RU; burst capacity is a model of the documented 300 s idle bank spent at up to 3000 RU/s total per partition (applicability conditions unverified, not enforced for partitions already at 3000 RU/s or more); autoscale reports only its minimum, x-ms-cosmos-current-throughput is not exposed by azcosmos and is not emitted; serverless accounts are a single process-wide mode (--cosmosdb-serverless) with the 5000 RU/s container cap but no 50 GB storage limit; the replace between manual and autoscale is accepted on the data plane; minimum throughput is a flat 400 RU/s (not storage- or container-count-derived); Table API and metadata operations are not metered; the throughput error texts (min 400 manual / 1000 autoscale, steps of 100 / 1000, max 1,000,000) are from documented limits and the 429 text follows the task wording, not captured service responses -- IndexingPolicy: multi-key ORDER BY composite-index, excluded-path/scan and vector/full-text index enforcement exist (families.indexing, families.search); still open are spatial index validation and enforcement inside stored-procedure queries (the docs state neither); a single-key ORDER BY on a path the policy does not index is 400 (text from the Mongo-API troubleshooting page and Microsoft Q&A, same engine; the scan header does not bypass it), `id` and `_ts` are always indexed in consistent mode, and lazy mode is rejected with 400 because the index-policy page says new containers cannot select it (error text paraphrased, exemptions not modelled). Filter plus ORDER BY needs no composite index (the index-policy page: the query still succeeds, only RU cost differs) and the current page no longer requires vector paths in excludedPaths, so neither is enforced -- MultiRegion: regions, failoverPriority, multi-write and manual failover (ARM failoverPriorityChange) are modelled as account metadata over ONE physical store, so there is no replication lag, no regional endpoint, no automatic failover, no preferred-region routing and no write conflicts or conflicts feed; the data plane is one account, so the last ARM account written decides the topology; consistency levels other than a static session token are not modeled -- RequestCharge: an approximation of documented costs, not a metering of index and storage work -- SQL: ORDER BY combined with GROUP BY is accepted although the real service may reject it (the ORDER BY and GROUP BY reference pages, checked 2026-10-24, state neither a restriction nor an error text, so behaviour stays permissive); spatial functions are planar/haversine approximations with no Multi* geometry operations and no Geometry-mode (planar metre) semantics; ST_ISVALIDDETAILED reasons beyond the unclosed-ring text are paraphrased; VectorDistance is exact (no approximate index, no quantization error, dataType only validated) and FullTextScore tokenization has no stemming (families.search); unsupported functions are a 400 parse error -- ItemOperations: the all-versions-and-deletes feed keeps only the newest 10000 versions per container and ignores the continuous-backup retention window; pre/post triggers fire for create, upsert, replace and delete only (the triggers REST page: "creation, deletion, and replacement of a document"; the patch REST page has no trigger header and azcosmos transactional batch has no trigger option), so PATCH and batch run none; stored procedure callbacks run synchronously, and a stored procedure runs on a private copy of its container without the backend lock, committing only if the container was not written meanwhile (up to 4 tries, then under the write lock), so a long script no longer stalls other containers but a busy container can fall back to the lock; there is no server-side bulk operation (the SDKs implement bulk client-side over batches and point operations) -- Containers: geospatialConfig is stored and echoed only (no planar Geometry behaviour); vectorEmbeddingPolicy and fullTextPolicy are validated and enforced (families.search); computed properties are not used for index selection; the replace-time error text for a changed unique key policy is unverified -- TableAPI: a $batch answers a single bare query (point retrieve or entity-collection query with $filter/$top/$select, 200 feed in the part; the docs show only the point retrieve, and a query part carries no continuation headers because the Table API here pages nothing), but several changesets (the service processes the first and rejects the rest with 400; here the whole request is 400 because the rejection response shape is undocumented), is 400; the 4 MiB payload limit is enforced as 400 InvalidInput with no documented service error code; Table API responses carry no per-operation request charge or continuation beyond what pkgs/odatatable provides -- ResourceTokens: a document permission matches by document id across all partition keys (ids repeat across keys; the permission docs define no partition scoping for a document resource, while container permissions with resourcePartitionKey are already restricted to that key); permissions on stored procedures, triggers, UDFs and attachments are rejected rather than modelled; a permission left pointing at a deleted container simply grants nothing; the minimum x-ms-documentdb-expiry-seconds is unverified (docs give only the 3600 default and the 18000 maximum) - ## More - [Full parity audit](PARITY.md) diff --git a/services/cosmosdb/account_ops.go b/services/cosmosdb/account_ops.go index 2257a1b353..7b3257e9e2 100644 --- a/services/cosmosdb/account_ops.go +++ b/services/cosmosdb/account_ops.go @@ -60,14 +60,22 @@ func (h *Handler) accountPropertiesBody(r *http.Request) map[string]any { scheme = "https" } - endpoint := scheme + "://" + r.Host + "/" - regions := h.accountRegions() - readable := locationEntries(regions.Regions, endpoint) + readable := h.locationEntries(regions.Regions, scheme, r.Host) writable := readable[:1] if regions.MultiWrite { writable = readable + } else if _, i, ok := regions.find(regions.home()); ok { + writable = readable[i : i+1] + } + + pol := regions.consistency() + policy := map[string]any{"defaultConsistencyLevel": pol.Level} + + if pol.Level == ConsistencyBoundedStaleness { + policy["maxStalenessPrefix"] = pol.MaxStalenessPrefix + policy["maxIntervalInSeconds"] = pol.MaxIntervalInSeconds } return map[string]any{ @@ -80,6 +88,6 @@ func (h *Handler) accountPropertiesBody(r *http.Request) map[string]any { "writableLocations": writable, "readableLocations": readable, "enableMultipleWriteLocations": regions.MultiWrite, - "userConsistencyPolicy": map[string]any{"defaultConsistencyLevel": "Session"}, + "userConsistencyPolicy": policy, } } diff --git a/services/cosmosdb/batch.go b/services/cosmosdb/batch.go index 590476e640..a3810d3704 100644 --- a/services/cosmosdb/batch.go +++ b/services/cosmosdb/batch.go @@ -157,6 +157,8 @@ func (tx *containerTx) runBatchOp(pk string, op batchOp) (BatchResult, error) { charge = replaceCharge ) + readPrice := func(size, _ int) float64 { return readCharge(size) } + switch op.kind { case batchCreate: info, _, err = tx.createDoc(pk, op.body, false) @@ -172,7 +174,7 @@ func (tx *containerTx) runBatchOp(pk string, op batchOp) (BatchResult, error) { } case batchRead: info, err = tx.readDoc(pk, op.id) - charge = readCharge + charge = readPrice case batchReplace: info, err = tx.replaceDoc(pk, op.id, op.body, op.ifMatch) case batchPatch: @@ -191,13 +193,18 @@ func (tx *containerTx) runBatchOp(pk string, op batchOp) (BatchResult, error) { size, _ := json.Marshal(body) return BatchResult{ - StatusCode: status, ETag: info.ETag, Body: body, RequestCharge: charge(len(size)), + StatusCode: status, ETag: info.ETag, Body: body, + RequestCharge: charge(len(size), countIndexedPaths(parseIndexingPolicy(tx.c.IndexingPolicy), info.Body)), }, nil } // statusForError maps a backend error to the HTTP status a batch result reports. func statusForError(err error) int { + var indexErr *queryIndexError + switch { + case errors.As(err, &indexErr): + return http.StatusBadRequest case errors.Is(err, ErrDocumentNotFound): return http.StatusNotFound case errors.Is(err, ErrDocumentAlreadyExists), errors.Is(err, ErrUniqueKeyViolation): @@ -234,7 +241,7 @@ func (h *Handler) executeBatch(c *echo.Context, dbID, collID string) error { return h.writeDocumentError(c, parseErr) } - exec, ok := h.Backend.(batchExecutor) + exec, ok := h.be(c).(batchExecutor) if !ok { return h.writeError(c, http.StatusMethodNotAllowed, "MethodNotAllowed", "The backend cannot run batches.") } diff --git a/services/cosmosdb/changefeed.go b/services/cosmosdb/changefeed.go index fdbe207147..329d869a4e 100644 --- a/services/cosmosdb/changefeed.go +++ b/services/cosmosdb/changefeed.go @@ -29,8 +29,7 @@ type changeFeedRequest struct { Since time.Time PartitionKey string RangeID string - rangeIdx int - rangeCount int + hashes hashRange AfterSeq int64 Limit int FromNow bool @@ -106,7 +105,7 @@ func (b *InMemoryBackend) readChangeFeed(dbID, collID string, req changeFeedRequ b.mu.RLock("readChangeFeed") defer b.mu.RUnlock() - c, err := b.findContainer(dbID, collID) + c, err := b.findDataContainer(dbID, collID, true) if err != nil { return changeFeedPage{}, err } @@ -117,6 +116,10 @@ func (b *InMemoryBackend) readChangeFeed(dbID, collID string, req changeFeedRequ return changeFeedPage{}, err } + if req.AllVersions && b.changeRetention() < 0 { + return changeFeedPage{}, ErrChangeFeedBackup + } + if req.FromNow { return changeFeedPage{RID: rid, Seq: c.ChangeSeq}, nil } @@ -147,10 +150,14 @@ func (b *InMemoryBackend) readChangeFeed(dbID, collID string, req changeFeedRequ func (b *InMemoryBackend) readVersionFeed( dbID, collID string, c *storedContainer, rid string, req changeFeedRequest, ) (changeFeedPage, error) { - if !req.Started { + if !req.Started || !req.Since.IsZero() { return changeFeedPage{}, ErrChangeFeedStart } + if req.AfterSeq < c.ChangeFloor { + return changeFeedPage{}, ErrChangeFeedRetention + } + recs := c.changedVersions(req) seq := c.ChangeSeq @@ -174,14 +181,23 @@ func (b *InMemoryBackend) resolveRange(dbID, collID string, req *changeFeedReque return nil } - n := b.budgetOf(b.databases[dbID], collID).Partitions + l := b.layoutView(b.databases[dbID], b.databases[dbID].Containers[collID]) + + id, err := strconv.Atoi(req.RangeID) + if err != nil || id < 0 { + return fmt.Errorf("%w: %q", ErrInvalidPKRange, req.RangeID) + } + + idx, active := l.byID(id) + if !active { + if l.gone(id) { + return fmt.Errorf("%w: %q", ErrPKRangeGone, req.RangeID) + } - idx, err := strconv.Atoi(req.RangeID) - if err != nil || idx < 0 || idx >= n { return fmt.Errorf("%w: %q", ErrInvalidPKRange, req.RangeID) } - req.rangeIdx, req.rangeCount = idx, n + req.hashes = l.rangeOf(idx) return nil } @@ -202,7 +218,7 @@ func changedDocs(live []*storedDocument, req changeFeedRequest) []*storedDocumen continue } - if req.rangeCount > 0 && rangeIndex(d.PartitionKeyJSON, req.rangeCount) != req.rangeIdx { + if !req.hashes.contains(d.PartitionKeyJSON) { continue } @@ -226,7 +242,7 @@ func (h *Handler) readChangeFeed(c *echo.Context, dbID, collID string) error { return h.writeDocumentError(c, reqErr) } - reader, ok := h.Backend.(changeFeedReader) + reader, ok := h.be(c).(changeFeedReader) if !ok { return h.writeError(c, http.StatusMethodNotAllowed, "MethodNotAllowed", "The backend has no change feed.") } @@ -258,8 +274,16 @@ func (h *Handler) readChangeFeed(c *echo.Context, dbID, collID string) error { setPageHeaders(c, len(values), "") - return h.writeJSONCharged(c, http.StatusOK, - map[string]any{sysPropRID: pg.RID, listDocumentsKey: values, sysPropCount: len(values)}, readCharge) + return h.writeJSONCharged( + c, + http.StatusOK, + map[string]any{ + sysPropRID: pg.RID, + listDocumentsKey: values, + sysPropCount: len(values), + }, + h.feedCharger(c, values), + ) } // writeVersionFeed renders the all-versions-and-deletes page: {current, previous, metadata} per entry. @@ -272,8 +296,16 @@ func (h *Handler) writeVersionFeed(c *echo.Context, pg changeFeedPage) error { setPageHeaders(c, len(values), "") - return h.writeJSONCharged(c, http.StatusOK, - map[string]any{sysPropRID: pg.RID, listDocumentsKey: values, sysPropCount: len(values)}, readCharge) + return h.writeJSONCharged( + c, + http.StatusOK, + map[string]any{ + sysPropRID: pg.RID, + listDocumentsKey: values, + sysPropCount: len(values), + }, + h.feedCharger(c, values), + ) } func (h *Handler) encodeVersion(v ChangeVersion) map[string]any { @@ -293,10 +325,6 @@ func (h *Handler) encodeVersion(v ChangeVersion) map[string]any { out["current"] = cur } - if v.Previous != nil { - out["previous"] = h.encodeDocument(*v.Previous) - } - if v.Op == opDelete { meta["timeToLiveExpired"] = v.TTLExpired } @@ -310,17 +338,12 @@ func (h *Handler) handlePKRanges(c *echo.Context, dbID, collID string) error { "The resource doesn't support the specified HTTP verb.") } - info, err := h.Backend.GetContainer(dbID, collID) + pr, err := h.partitionRanges(dbID, collID) if err != nil { return h.writeDocumentError(c, err) } - n := 1 - if tb, ok := h.Backend.(ThroughputBackend); ok { - n = max(1, tb.ThroughputBudget(dbID, collID).Partitions) - } - - etag := pkRangeETagFor(n) + etag := pr.Layout.etag() c.Response().Header().Set("ETag", etag) if strings.TrimSpace(c.Request().Header.Get(headerIfNoneMatch)) == etag { @@ -329,13 +352,31 @@ func (h *Handler) handlePKRanges(c *echo.Context, dbID, collID string) error { setCharge(c, ruMetadata) + n := len(pr.Layout.Spans) ranges := make([]map[string]any, n) + for i := range ranges { - ranges[i] = pkRangeBody(info.RID, dbID, collID, i, n) + ranges[i] = pr.Layout.pkRangeBody(pr.RID, dbID, collID, i) ranges[i][sysPropTS] = time.Now().Unix() } return h.writeJSON(c, http.StatusOK, map[string]any{ - sysPropRID: info.RID, "PartitionKeyRanges": ranges, sysPropCount: n, + sysPropRID: pr.RID, "PartitionKeyRanges": ranges, sysPropCount: n, }) } + +// partitionRanges reads the container's current partition layout. +func (h *Handler) partitionRanges(dbID, collID string) (PartitionKeyRanges, error) { + if pb, ok := h.Backend.(interface { + partitionRanges(db, coll string) (PartitionKeyRanges, error) + }); ok { + return pb.partitionRanges(dbID, collID) + } + + info, err := h.Backend.GetContainer(dbID, collID) + if err != nil { + return PartitionKeyRanges{}, err + } + + return PartitionKeyRanges{Layout: evenLayout(1), RID: info.RID}, nil +} diff --git a/services/cosmosdb/changefeed_versions_test.go b/services/cosmosdb/changefeed_versions_test.go index 57cf7f54da..8ff2331d2b 100644 --- a/services/cosmosdb/changefeed_versions_test.go +++ b/services/cosmosdb/changefeed_versions_test.go @@ -81,8 +81,8 @@ func TestRaw_AllVersionsAndDeletes(t *testing.T) { assert.Equal(t, []versionEntry{ {op: "create", id: "x"}, - {op: "replace", id: "x", hasPrev: true}, - {op: "delete", id: "x", hasPrev: true, curEmpty: true}, + {op: "replace", id: "x"}, + {op: "delete", id: "x", curEmpty: true}, }, versionEntries(t, feed)) first, _ := feed.object(t)["Documents"].([]any) @@ -118,11 +118,11 @@ func TestRaw_AllVersionsStartRules(t *testing.T) { wantStatus: http.StatusOK, }, { - name: "start_time", + name: "start_time_rejected", headers: avadHeaders(map[string]string{ "If-Modified-Since": time.Now().Add(-time.Hour).UTC().Format(time.RFC1123), }), - wantStatus: http.StatusOK, + wantStatus: http.StatusBadRequest, }, {name: "bad_range", headers: avadHeaders(map[string]string{ "If-None-Match": `"0"`, "X-Ms-Documentdb-Partitionkeyrangeid": "9", @@ -162,7 +162,7 @@ func TestRaw_AllVersionsTTLExpiry(t *testing.T) { require.Equal(t, http.StatusOK, feed.status) assert.Equal(t, []versionEntry{ {op: "create", id: "e"}, - {op: "delete", id: "e", hasPrev: true, curEmpty: true, ttl: true}, + {op: "delete", id: "e", curEmpty: true, ttl: true}, }, versionEntries(t, feed)) } @@ -185,7 +185,7 @@ func TestRaw_AllVersionsRecreateOverExpired(t *testing.T) { feed := s.do(http.MethodGet, "/dbs/d/colls/t/docs", avadHeaders(map[string]string{"If-None-Match": `"0"`}), "") assert.Equal(t, []versionEntry{ {op: "create", id: "e"}, - {op: "delete", id: "e", hasPrev: true, curEmpty: true, ttl: true}, + {op: "delete", id: "e", curEmpty: true, ttl: true}, {op: "create", id: "e"}, }, versionEntries(t, feed)) } diff --git a/services/cosmosdb/changelog.go b/services/cosmosdb/changelog.go index 1bd052ecde..b26d14e64f 100644 --- a/services/cosmosdb/changelog.go +++ b/services/cosmosdb/changelog.go @@ -7,7 +7,14 @@ import ( ) const ( - maxChangeLog = 10_000 + // maxChangeLog is a memory guard, not a retention rule: the log keeps every version inside the continuous + // backup window unless a container writes more than this many. + maxChangeLog = 1_000_000 + + // defaultChangeRetention is the window when no account sets a backup policy (the 7-day continuous tier). + defaultChangeRetention = 7 * 24 * time.Hour + noChangeLog = time.Duration(-1) + continuous30Retention = 30 * 24 * time.Hour opCreate = "create" opReplace = "replace" @@ -29,12 +36,32 @@ type changeRecord struct { TTLExpired bool `json:"TTLExpired,omitempty"` } -// appendChange adds rec to the container's log, dropping the oldest records beyond the retention bound. -func (c *storedContainer) appendChange(rec *changeRecord) { +// appendChange adds rec to the container's log and drops what has fallen out of the continuous backup window +// (keep: zero is the default seven days, negative keeps nothing, as on an account with periodic backup). +func (c *storedContainer) appendChange(rec *changeRecord, now time.Time) { + if c.keep < 0 { + c.ChangeFloor = max(c.ChangeFloor, rec.Seq) + + return + } + c.ChangeLog = append(c.ChangeLog, rec) - if len(c.ChangeLog) >= 2*maxChangeLog { - c.ChangeLog = append([]*changeRecord(nil), c.ChangeLog[len(c.ChangeLog)-maxChangeLog:]...) + keep := c.keep + if keep == 0 { + keep = defaultChangeRetention + } + + cut := now.Add(-keep) + n := 0 + + for n < len(c.ChangeLog) && (c.ChangeLog[n].Time.Before(cut) || len(c.ChangeLog)-n > maxChangeLog) { + c.ChangeFloor = max(c.ChangeFloor, c.ChangeLog[n].Seq) + n++ + } + + if n > 0 { + c.ChangeLog = append([]*changeRecord(nil), c.ChangeLog[n:]...) } } @@ -49,14 +76,14 @@ func (c *storedContainer) truncateChangeLog(seq int64) { } // recordExpired logs the TTL expiry of d as a delete. -func (c *storedContainer) recordExpired(d *storedDocument) { +func (c *storedContainer) recordExpired(d *storedDocument, now time.Time) { c.ChangeSeq++ at, _ := c.expiry(d) c.appendChange(&changeRecord{ Time: at, Previous: d, Op: opDelete, ID: d.ID, PartitionKeyJSON: d.PartitionKeyJSON, Seq: c.ChangeSeq, PrevSeq: d.Seq, TTLExpired: true, - }) + }, now) } // ChangeVersion is one entry of the all-versions-and-deletes change feed. @@ -123,7 +150,7 @@ func (c *storedContainer) changedVersions(req changeFeedRequest) []*changeRecord case r.Seq <= req.AfterSeq: case !req.Since.IsZero() && r.Time.Unix() < req.Since.Unix(): case req.PartitionKey != "" && !partitionKeyMatches(r.PartitionKeyJSON, req.PartitionKey): - case req.rangeCount > 0 && rangeIndex(r.PartitionKeyJSON, req.rangeCount) != req.rangeIdx: + case !req.hashes.contains(r.PartitionKeyJSON): default: out = append(out, r) } diff --git a/services/cosmosdb/computed_properties_test.go b/services/cosmosdb/computed_properties_test.go new file mode 100644 index 0000000000..a6aeefaad9 --- /dev/null +++ b/services/cosmosdb/computed_properties_test.go @@ -0,0 +1,229 @@ +package cosmosdb_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +func computedBackend(t *testing.T, indexing string, props string) *cosmosdb.InMemoryBackend { + t.Helper() + + b := cosmosdb.NewInMemoryBackend() + _, err := b.CreateDatabase("d") + require.NoError(t, err) + + spec := cosmosdb.ContainerSpec{ + ID: "c", PartitionKeyPath: "/pk", ComputedProperties: json.RawMessage(props), + } + if indexing != "" { + spec.IndexingPolicy = json.RawMessage(indexing) + } + + _, err = b.CreateContainer("d", spec) + require.NoError(t, err) + + for i, name := range []string{"Bob", "alice", "Carol"} { + _, err = b.CreateDocument("d", "c", map[string]any{"id": fmt.Sprintf("d%d", i), "pk": "k", "name": name}, false) + require.NoError(t, err) + } + + return b +} + +const lowerName = `[{"name":"cp_lower","query":"SELECT VALUE LOWER(c.name) FROM c"}]` + +func TestComputedProperties_IndexSelection(t *testing.T) { + t.Parallel() + + const ( + defaultPolicy = "" + indexed = `{"indexingMode":"consistent",` + + `"includedPaths":[{"path":"/*"},{"path":"/cp_lower/?"}],"excludedPaths":[]}` + composite = `{"indexingMode":"consistent","includedPaths":[{"path":"/*"}],"excludedPaths":[],` + + `"compositeIndexes":[[{"path":"/cp_lower","order":"ascending"},{"path":"/name","order":"ascending"}]]}` + ) + + tests := []struct { + name string + indexing string + query string + wantErr string + want []any + }{ + { + name: "filter on an unindexed computed property is evaluated at run time", indexing: defaultPolicy, + query: `SELECT VALUE c.id FROM c WHERE c.cp_lower = "alice"`, want: []any{"d1"}, + }, + { + name: "the wildcard path does not index a computed property", indexing: defaultPolicy, + query: `SELECT VALUE c.id FROM c ORDER BY c.cp_lower`, wantErr: "order-by item is excluded", + }, + { + name: "an explicit path indexes it for ORDER BY", indexing: indexed, + query: `SELECT VALUE c.id FROM c ORDER BY c.cp_lower`, want: []any{"d1", "d0", "d2"}, + }, + { + name: "a composite index can include it", indexing: composite, + query: `SELECT VALUE c.id FROM c ORDER BY c.cp_lower, c.name`, want: []any{"d1", "d0", "d2"}, + }, + { + name: "group by a computed property", + indexing: defaultPolicy, + query: `SELECT VALUE COUNT(1) FROM c GROUP BY c.cp_lower`, + want: []any{json.Number("1"), json.Number("1"), json.Number("1")}, + }, + { + name: "select star leaves computed properties out", indexing: defaultPolicy, + query: `SELECT VALUE IS_DEFINED(c.cp_lower) FROM c WHERE c.id = "d0"`, want: []any{true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := computedBackend(t, tt.indexing, lowerName) + + res, err := b.RunQueryOpts("d", "c", tt.query, nil, "", cosmosdb.QueryOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + + return + } + + require.NoError(t, err) + assert.ElementsMatch(t, tt.want, res.Rows) + }) + } +} + +func TestComputedProperties_ShadowAPersistedProperty(t *testing.T) { + t.Parallel() + + b := computedBackend(t, "", `[{"name":"name","query":"SELECT VALUE UPPER(c.name) FROM c"}]`) + + byName, err := b.RunQuery("d", "c", `SELECT VALUE c.name FROM c WHERE c.id = "d1"`, nil, "") + require.NoError(t, err) + assert.Equal(t, []any{"ALICE"}, byName.Rows, "queries use the computed property over the persisted one") + + star, err := b.RunQuery("d", "c", `SELECT * FROM c WHERE c.id = "d1"`, nil, "") + require.NoError(t, err) + require.Len(t, star.Rows, 1) + assert.Equal(t, "alice", star.Rows[0].(map[string]any)["name"], "a wildcard projection returns the persisted value") +} + +func TestComputedProperties_DefinitionRules(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + props string + want int + }{ + {name: "valid", props: lowerName, want: http.StatusCreated}, + { + name: "root alias forms", + props: `[{"name":"a","query":"SELECT VALUE c.x FROM root c"}]`, + want: http.StatusCreated, + }, + { + name: "reserved name", + props: `[{"name":"id","query":"SELECT VALUE c.x FROM c"}]`, + want: http.StatusBadRequest, + }, + { + name: "system property name", + props: `[{"name":"_ts","query":"SELECT VALUE c.x FROM c"}]`, + want: http.StatusBadRequest, + }, + { + name: "duplicate names", + props: `[{"name":"a","query":"SELECT VALUE 1 FROM c"},{"name":"a","query":"SELECT VALUE 2 FROM c"}]`, + want: http.StatusBadRequest, + }, + {name: "no VALUE", props: `[{"name":"a","query":"SELECT c.x FROM c"}]`, want: http.StatusBadRequest}, + { + name: "where clause", + props: `[{"name":"a","query":"SELECT VALUE c.x FROM c WHERE c.y = 1"}]`, + want: http.StatusBadRequest, + }, + { + name: "join", + props: `[{"name":"a","query":"SELECT VALUE t FROM c JOIN t IN c.tags"}]`, + want: http.StatusBadRequest, + }, + { + name: "nondeterministic", + props: `[{"name":"a","query":"SELECT VALUE RAND() FROM c"}]`, + want: http.StatusBadRequest, + }, + { + name: "current time", + props: `[{"name":"a","query":"SELECT VALUE GetCurrentTimestamp() FROM c"}]`, + want: http.StatusBadRequest, + }, + { + name: "aggregate", + props: `[{"name":"a","query":"SELECT VALUE COUNT(1) FROM c"}]`, + want: http.StatusBadRequest, + }, + { + name: "spatial function", + props: `[{"name":"a","query":"SELECT VALUE ST_ISVALID(c.loc) FROM c"}]`, + want: http.StatusBadRequest, + }, + { + name: "scalar subquery", + props: `[{"name":"a","query":"SELECT VALUE (SELECT VALUE 1) FROM c"}]`, + want: http.StatusBadRequest, + }, + {name: "not a query", props: `[{"name":"a","query":"SELECT"}]`, want: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + resp := s.do(http.MethodPost, "/dbs/d/colls", nil, + `{"id":"cp","partitionKey":{"paths":["/pk"],"kind":"Hash"},"computedProperties":`+tt.props+`}`) + assert.Equal(t, tt.want, resp.status, string(resp.body)) + }) + } + + t.Run("more than twenty", func(t *testing.T) { + t.Parallel() + + props := make([]map[string]string, 0, 21) + for i := range 21 { + props = append(props, map[string]string{"name": fmt.Sprintf("p%d", i), "query": "SELECT VALUE 1 FROM c"}) + } + + raw, err := json.Marshal(props) + require.NoError(t, err) + + s := newLiveServer(t) + resp := s.do(http.MethodPost, "/dbs/d/colls", nil, + `{"id":"cp","partitionKey":{"paths":["/pk"],"kind":"Hash"},"computedProperties":`+string(raw)+`}`) + assert.Equal(t, http.StatusBadRequest, resp.status) + }) + + t.Run("a spatial index on a computed property", func(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + resp := s.do(http.MethodPost, "/dbs/d/colls", nil, `{"id":"cp","partitionKey":{"paths":["/pk"],"kind":"Hash"},`+ + `"computedProperties":[{"name":"cp_loc","query":"SELECT VALUE c.loc FROM c"}],`+ + `"indexingPolicy":{"indexingMode":"consistent","includedPaths":[{"path":"/*"}],"excludedPaths":[],`+ + `"spatialIndexes":[{"path":"/cp_loc/*","types":["Point"]}]}}`) + assert.Equal(t, http.StatusBadRequest, resp.status) + }) +} diff --git a/services/cosmosdb/computed_rules.go b/services/cosmosdb/computed_rules.go new file mode 100644 index 0000000000..d60e14b0ea --- /dev/null +++ b/services/cosmosdb/computed_rules.go @@ -0,0 +1,155 @@ +package cosmosdb + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Computed property definition rules (Learn, "Computed properties"): top-level name, unique, not a system property; +// a SELECT VALUE query over the root item with no JOIN, WHERE, GROUP BY, ORDER BY, TOP, DISTINCT or OFFSET LIMIT, no +// subquery, aggregate, spatial function, UDF or nondeterministic function. + +// exprChildren lists the direct sub-expressions of x. +func exprChildren(x sqlExpr) []sqlExpr { + switch n := x.(type) { + case *exprProp: + return []sqlExpr{n.base} + case *exprIndex: + return []sqlExpr{n.base, n.idx} + case *exprUnary: + return []sqlExpr{n.x} + case *exprBinary: + return []sqlExpr{n.l, n.r} + case *exprIsNull: + return []sqlExpr{n.x} + case *exprIn: + return append([]sqlExpr{n.x}, n.list...) + case *exprBetween: + return []sqlExpr{n.x, n.lo, n.hi} + case *exprLike: + return []sqlExpr{n.x, n.pattern} + case *exprTernary: + return []sqlExpr{n.cond, n.a, n.b} + case *exprCall: + return n.args + case *exprArrayLit: + return n.items + case *exprObjectLit: + return n.vals + } + + return nil +} + +// forbiddenComputedUse names the construct of x a computed property query may not contain, or "". +func forbiddenComputedUse(x sqlExpr) string { + switch n := x.(type) { + case *exprSubquery: + return "a subquery" + case *exprCall: + if why := forbiddenComputedCall(n); why != "" { + return why + } + } + + for _, child := range exprChildren(x) { + if why := forbiddenComputedUse(child); why != "" { + return why + } + } + + return "" +} + +func forbiddenComputedCall(n *exprCall) string { + if n.kind == fnAggregate { + return "an aggregate function" + } + + if n.kind == fnSpatial { + return "a spatial function" + } + + if n.kind == fnUDF { + return "a user-defined function" + } + + if n.name == biRand || strings.HasPrefix(n.name, "GETCURRENT") { + return "the nondeterministic function " + n.name + } + + if n.name == biStArea { + return "a spatial function" + } + + return "" +} + +// firstSegment is the top-level property of an index path such as "/cp/a/?". +func firstSegment(path string) string { + segs := splitIndexPath(path) + if len(segs) == 0 { + return "" + } + + return segs[0] +} + +// computedIndexed reports whether the policy indexes a computed property path: only an explicit path under the +// property's own name counts, the root wildcard does not cover computed properties. +func (p indexingPolicy) computedIndexed(path string) bool { + name := firstSegment(path) + segs := splitIndexPath(path) + best, bestLen := false, -1 + + for _, inc := range p.IncludedPaths { + if firstSegment(inc.Path) != name { + continue + } + + if l, ok := matchIndexPattern(inc.Path, segs); ok && l >= bestLen { + best, bestLen = true, l + } + } + + for _, exc := range p.ExcludedPaths { + if firstSegment(exc.Path) != name { + continue + } + + if l, ok := matchIndexPattern(exc.Path, segs); ok && l > bestLen { + best, bestLen = false, l + } + } + + return best +} + +func (p indexingPolicy) isComputed(path string) bool { + _, ok := p.computed[firstSegment(path)] + + return ok +} + +// checkComputedIndexes rejects a spatial index on a computed property. +func checkComputedIndexes(pol indexingPolicy, raw json.RawMessage) error { + props, err := parseComputedProperties(raw) + if err != nil || len(props) == 0 { + return err + } + + for _, idx := range pol.SpatialIndexes { + for _, cp := range props { + if firstSegment(idx.Path) == cp.Name { + return fmt.Errorf( + "%w: computed property %q cannot have a spatial index", + ErrInvalidContainerPolicy, + cp.Name, + ) + } + } + } + + return nil +} diff --git a/services/cosmosdb/conflicts.go b/services/cosmosdb/conflicts.go new file mode 100644 index 0000000000..848c2c9c28 --- /dev/null +++ b/services/cosmosdb/conflicts.go @@ -0,0 +1,310 @@ +package cosmosdb + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// Conflict resolution modes (azcosmos ConflictResolutionMode) and the default last-writer-wins path. +const ( + conflictModeLWW = "LastWriterWins" + conflictModeCustom = "Custom" + conflictPathTS = "/_ts" +) + +// ErrConflictNotFound is returned for an unknown conflicts-feed entry. +var ErrConflictNotFound = errors.New("cosmosdb: conflict not found") + +type conflictPolicy struct { + Mode string `json:"mode"` + Path string `json:"conflictResolutionPath"` + Procedure string `json:"conflictResolutionProcedure"` +} + +func parseConflictPolicy(raw json.RawMessage) conflictPolicy { + p := conflictPolicy{Mode: conflictModeLWW, Path: conflictPathTS} + + if len(raw) > 0 { + _ = json.Unmarshal(raw, &p) + } + + if p.Mode == "" { + p.Mode = conflictModeLWW + } + + if p.Path == "" { + p.Path = conflictPathTS + } + + return p +} + +// conflictRecord is one entry of a region's conflicts feed: a write that lost to, or could not be reconciled with, +// the version committed there. +type conflictRecord struct { + Time time.Time `json:"Time"` + Doc *storedDocument `json:"Doc,omitempty"` + ID string `json:"ID"` + Region string `json:"Region"` + Op string `json:"Op"` + DocID string `json:"DocID"` + PartitionKeyJSON string `json:"PartitionKeyJSON"` + LSN int64 `json:"LSN"` +} + +// conflictValue is the number last-writer-wins compares: the container's resolution path in the document, or _ts +// when the path is absent, not a number or the default. +func conflictValue(d *storedDocument, path string) float64 { + ts := float64(d.Timestamp.Unix()) + + if path == conflictPathTS || path == "" { + return ts + } + + var cur any = d.Body + + for seg := range strings.SplitSeq(strings.Trim(path, "/"), "/") { + m, ok := cur.(map[string]any) + if !ok { + return ts + } + + cur = m[seg] + } + + switch n := cur.(type) { + case json.Number: + if f, err := n.Float64(); err == nil { + return f + } + case float64: + return n + } + + return ts +} + +// resolveConflict applies the container's policy to a write that arrived at a region holding a version the writer +// had not seen (cur, when exists), or a delete of the item (a tombstone) when !exists. +func (b *InMemoryBackend) resolveConflict( + dbID string, c, dest *storedContainer, r *replState, ev *replEvent, regs AccountRegions, now time.Time, + cur *storedDocument, exists bool, +) { + pol := parseConflictPolicy(c.ConflictResolutionPolicy) + tx := b.replTx(dbID, c, dest, r, ev.Dest, now, regs, true) + + if ev.Stale || pol.Mode == conflictModeCustom { + if !ev.Stale && b.runResolver(dbID, c, dest, r, ev, regs, now, pol, cur, exists) { + return + } + + b.addConflict(c, ev, now) + + return + } + + switch { + case ev.Doc == nil: + if exists { + tx.remove(ev.Key) + b.noteTomb(r, ev) + } + case !exists: + default: + in, ex := conflictValue(ev.Doc, pol.Path), conflictValue(cur, pol.Path) + if in > ex || (in == ex && ev.LSN > cur.GLSN) { + b.putReplicated(tx, r, ev) + } + } +} + +// runResolver runs the container's merge stored procedure in the destination region as one transaction; it reports +// whether the conflict was resolved (a missing or failing procedure sends the conflict to the feed). +func (b *InMemoryBackend) runResolver( + dbID string, c, dest *storedContainer, r *replState, ev *replEvent, regs AccountRegions, now time.Time, + pol conflictPolicy, cur *storedDocument, exists bool, +) bool { + name := pol.Procedure[strings.LastIndex(pol.Procedure, "/")+1:] + if name == "" { + return false + } + + s, ok := dest.scriptMap(kindSproc, false)[name] + if !ok { + return false + } + + tx := b.replTx(dbID, c, dest, r, ev.Dest, now, regs, false) + + args := []any{nil, nil, ev.Doc == nil && !exists, []any{}} + + if ev.Doc != nil { + args[0] = documentAsMap(b.documentInfo(dbID, c.ID, ev.Doc)) + } + + if exists { + args[1] = documentAsMap(b.documentInfo(dbID, c.ID, cur)) + } + + if _, err := tx.runSproc(s, ev.Key.PartitionKeyJSON, args); err != nil { + tx.rollback() + + return false + } + + return true +} + +func (b *InMemoryBackend) addConflict(c *storedContainer, ev *replEvent, now time.Time) { + var doc *storedDocument + + if ev.Doc != nil { + cp := *ev.Doc + doc = &cp + } + + c.Conflicts = append(c.Conflicts, &conflictRecord{ + ID: newDocumentID(), Region: ev.Dest, Op: ev.Op, Doc: doc, DocID: ev.Key.ID, + PartitionKeyJSON: ev.Key.PartitionKeyJSON, LSN: ev.LSN, Time: now, + }) +} + +// ConflictInfo is a read-only conflicts-feed entry. +type ConflictInfo struct { + Time time.Time + Content *DocumentInfo + ID string + RID string + Self string + Op string + DocID string + DocRID string + PartitionKeyJSON string + LSN int64 +} + +func (b *InMemoryBackend) conflictInfo(dbID string, c *storedContainer, rec *conflictRecord) ConflictInfo { + path := "dbs/" + dbID + "/colls/" + c.ID + "/conflicts/" + rec.ID + info := ConflictInfo{ + ID: rec.ID, RID: fakeRID(path), Self: path + "/", Op: rec.Op, DocID: rec.DocID, Time: rec.Time, + DocRID: fakeRID("dbs/" + dbID + "/colls/" + c.ID + "/docs/" + rec.DocID), LSN: rec.LSN, + PartitionKeyJSON: rec.PartitionKeyJSON, + } + + if rec.Doc != nil { + d := b.documentInfo(dbID, c.ID, rec.Doc) + info.Content = &d + } + + return info +} + +// ListConflicts returns the region's conflicts feed, oldest first. An empty region means the write region. +func (b *InMemoryBackend) ListConflicts(dbID, collID, region string) ([]ConflictInfo, error) { + b.mu.RLock("ListConflicts") + defer b.mu.RUnlock() + + c, err := b.findContainer(dbID, collID) + if err != nil { + return nil, err + } + + region = b.resolveRegion(region) + out := make([]ConflictInfo, 0) + + for _, rec := range c.Conflicts { + if strings.EqualFold(rec.Region, region) { + out = append(out, b.conflictInfo(dbID, c, rec)) + } + } + + return out, nil +} + +// GetConflict returns one conflicts-feed entry. +func (b *InMemoryBackend) GetConflict(dbID, collID, region, id string) (ConflictInfo, error) { + b.mu.RLock("GetConflict") + defer b.mu.RUnlock() + + c, err := b.findContainer(dbID, collID) + if err != nil { + return ConflictInfo{}, err + } + + region = b.resolveRegion(region) + + for _, rec := range c.Conflicts { + if rec.ID == id && strings.EqualFold(rec.Region, region) { + return b.conflictInfo(dbID, c, rec), nil + } + } + + return ConflictInfo{}, ErrConflictNotFound +} + +// DeleteConflict removes a conflicts-feed entry once the application has merged it. +func (b *InMemoryBackend) DeleteConflict(dbID, collID, region, id string) error { + b.mu.Lock("DeleteConflict") + defer b.mu.Unlock() + + c, err := b.findContainer(dbID, collID) + if err != nil { + return err + } + + region = b.resolveRegion(region) + + for i, rec := range c.Conflicts { + if rec.ID == id && strings.EqualFold(rec.Region, region) { + c.Conflicts = append(c.Conflicts[:i], c.Conflicts[i+1:]...) + + return nil + } + } + + return ErrConflictNotFound +} + +// resolveRegion maps the empty region to the write region; the lock must be held. +func (b *InMemoryBackend) resolveRegion(region string) string { + if region == "" { + return b.regionsView().home() + } + + return region +} + +// validateConflictPolicy checks a container's conflictResolutionPolicy: mode LastWriterWins or Custom, a path that +// starts with /. +func validateConflictPolicy(raw json.RawMessage) error { + if len(raw) == 0 || isJSONNull(raw) { + return nil + } + + var p conflictPolicy + if err := json.Unmarshal(raw, &p); err != nil { + return fmt.Errorf("%w: conflictResolutionPolicy is malformed", ErrInvalidContainerPolicy) + } + + if p.Mode != "" && p.Mode != conflictModeLWW && p.Mode != conflictModeCustom { + return fmt.Errorf("%w: conflictResolutionPolicy.mode must be %s or %s", ErrInvalidContainerPolicy, + conflictModeLWW, conflictModeCustom) + } + + if p.Path != "" && !strings.HasPrefix(p.Path, "/") { + return fmt.Errorf("%w: conflictResolutionPolicy.conflictResolutionPath must start with '/'", + ErrInvalidContainerPolicy) + } + + return nil +} + +// conflictPolicyChanged reports whether a replace request names a policy different from the stored one; the policy +// can only be set at container creation (Learn, "Manage conflicts between regions"). +func conflictPolicyChanged(stored, requested json.RawMessage) bool { + return len(requested) > 0 && !isJSONNull(requested) && + parseConflictPolicy(stored) != parseConflictPolicy(requested) +} diff --git a/services/cosmosdb/container_policy.go b/services/cosmosdb/container_policy.go index d2575e52cc..299d4d190b 100644 --- a/services/cosmosdb/container_policy.go +++ b/services/cosmosdb/container_policy.go @@ -95,6 +95,14 @@ func validateSearchIndexes(indexing json.RawMessage, p ContainerPolicies) error return fmt.Errorf("%w: %s", ErrInvalidContainerPolicy, msgLazyIndexing) } + if spErr := validateSpatialIndexes(pol.SpatialIndexes, geospatialType(p.GeospatialConfig)); spErr != nil { + return spErr + } + + if cpErr := checkComputedIndexes(pol, p.ComputedProperties); cpErr != nil { + return cpErr + } + vp, err := parseVectorPolicy(p.VectorEmbeddingPolicy) if err != nil { return err @@ -123,6 +131,14 @@ func (c *storedContainer) checkReplaceIndexes(spec ContainerSpec) error { eff.FullTextPolicy = c.FullTextPolicy } + if eff.GeospatialConfig == nil { + eff.GeospatialConfig = c.GeospatialConfig + } + + if eff.ComputedProperties == nil { + eff.ComputedProperties = c.ComputedProperties + } + indexing := spec.IndexingPolicy if len(indexing) == 0 { indexing = c.IndexingPolicy @@ -241,6 +257,10 @@ func validateComputedProperty(p computedProperty, seen map[string]struct{}) erro ErrInvalidContainerPolicy, p.Name) } + if why := forbiddenComputedUse(stmt.items[0].expr); why != "" { + return fmt.Errorf("%w: computed property %q cannot use %s", ErrInvalidContainerPolicy, p.Name, why) + } + return nil } diff --git a/services/cosmosdb/container_policy_test.go b/services/cosmosdb/container_policy_test.go index 0382a1b7fa..2eb021bf59 100644 --- a/services/cosmosdb/container_policy_test.go +++ b/services/cosmosdb/container_policy_test.go @@ -318,7 +318,9 @@ func TestComputedProperties_UsableInQueries(t *testing.T) { h := newPolicyHandler(t, `{"id":"c","partitionKey":{"paths":["/pk"],"kind":"Hash"},`+ `"computedProperties":[{"name":"cp_lower","query":"SELECT VALUE LOWER(c.name) FROM c"},`+ - `{"name":"cp_len","query":"SELECT VALUE LENGTH(c.name) FROM c"}]}`) + `{"name":"cp_len","query":"SELECT VALUE LENGTH(c.name) FROM c"}],`+ + `"indexingPolicy":{"indexingMode":"consistent","includedPaths":[{"path":"/*"},{"path":"/cp_len/?"}],`+ + `"excludedPaths":[]}}`) for _, d := range []string{`{"id":"1","pk":"x","name":"Alice"}`, `{"id":"2","pk":"x","name":"BOB"}`} { require.Equal(t, http.StatusCreated, doRequest(t, h, http.MethodPost, collDocsPath, nil, []byte(d)).Code) diff --git a/services/cosmosdb/coverage_test.go b/services/cosmosdb/coverage_test.go index 42daabe1f9..950c263992 100644 --- a/services/cosmosdb/coverage_test.go +++ b/services/cosmosdb/coverage_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cosmosdb" ) @@ -59,7 +60,7 @@ func TestStartWorker_BindsAndServes(t *testing.T) { return false } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) if err != nil { return false } diff --git a/services/cosmosdb/document_ops.go b/services/cosmosdb/document_ops.go index c868dc8e6d..d82601a695 100644 --- a/services/cosmosdb/document_ops.go +++ b/services/cosmosdb/document_ops.go @@ -134,8 +134,10 @@ func (h *Handler) writeDocumentError(c *echo.Context, err error) error { "Operation cannot be performed because one of the specified precondition is not met.") case errors.Is(err, ErrInvalidDocument), errors.Is(err, ErrPartitionKeyMismatch), errors.Is(err, ErrInvalidPatch), errors.Is(err, ErrBatchTooLarge), errors.Is(err, ErrInvalidTrigger), errors.Is(err, ErrInvalidPKRange), - errors.Is(err, ErrChangeFeedStart): + errors.Is(err, ErrChangeFeedStart), errors.Is(err, ErrChangeFeedBackup), errors.Is(err, ErrChangeFeedRetention): return h.writeError(c, http.StatusBadRequest, "BadRequest", err.Error()) + case isRoutingError(err): + return h.writeRoutingError(c, err) case errors.Is(err, ErrScriptNotFound): return h.writeError(c, http.StatusNotFound, "NotFound", "Resource Not Found") case errors.Is(err, ErrScriptTimeout): @@ -174,14 +176,15 @@ func (h *Handler) createDocument(c *echo.Context, dbID, collID string) error { return h.runWithTriggers(c, dbID, collID, docOp{kind: kind, body: doc, pre: trig.pre, post: trig.post}) } - info, createErr := h.Backend.CreateDocument(dbID, collID, doc, upsert) + info, createErr := h.be(c).CreateDocument(dbID, collID, doc, upsert) if createErr != nil { return h.writeDocumentError(c, createErr) } c.Response().Header().Set("ETag", info.ETag) - return h.writeJSONCharged(c, http.StatusCreated, h.encodeDocument(info), createCharge) + return h.writeJSONCharged(c, http.StatusCreated, h.encodeDocument(info), + h.writeCharger(dbID, collID, info.Body, false)) } func (h *Handler) getDocument(c *echo.Context, dbID, collID, docID string) error { @@ -190,14 +193,14 @@ func (h *Handler) getDocument(c *echo.Context, dbID, collID, docID string) error return h.writeError(c, http.StatusBadRequest, "BadRequest", pkErr.Error()) } - info, err := h.Backend.GetDocument(dbID, collID, pk, docID) + info, err := h.be(c).GetDocument(dbID, collID, pk, docID) if err != nil { return h.writeDocumentError(c, err) } c.Response().Header().Set("ETag", info.ETag) - return h.writeJSONCharged(c, http.StatusOK, h.encodeDocument(info), readCharge) + return h.writeJSONCharged(c, http.StatusOK, h.encodeDocument(info), h.readCharger(c)) } func (h *Handler) replaceDocument(c *echo.Context, dbID, collID, docID string) error { @@ -225,14 +228,15 @@ func (h *Handler) replaceDocument(c *echo.Context, dbID, collID, docID string) e }) } - info, replaceErr := h.Backend.ReplaceDocument(dbID, collID, pk, docID, doc, r.Header.Get("If-Match")) + info, replaceErr := h.be(c).ReplaceDocument(dbID, collID, pk, docID, doc, r.Header.Get("If-Match")) if replaceErr != nil { return h.writeDocumentError(c, replaceErr) } c.Response().Header().Set("ETag", info.ETag) - return h.writeJSONCharged(c, http.StatusOK, h.encodeDocument(info), replaceCharge) + return h.writeJSONCharged(c, http.StatusOK, h.encodeDocument(info), + h.writeCharger(dbID, collID, info.Body, true)) } func (h *Handler) patchDocument(c *echo.Context, dbID, collID, docID string) error { @@ -253,7 +257,19 @@ func (h *Handler) patchDocument(c *echo.Context, dbID, collID, docID string) err return h.writeDocumentError(c, parseErr) } - patcher, ok := h.Backend.(interface { + if trig := triggerRequest(r); trig.requested() { + patchBody, decErr := decodeJSONObject(body) + if decErr != nil { + return h.writeError(c, http.StatusBadRequest, "BadRequest", "The input is not valid JSON.") + } + + return h.runWithTriggers(c, dbID, collID, docOp{ + kind: batchPatch, body: patchBody, pk: pk, id: docID, ifMatch: r.Header.Get("If-Match"), + pre: trig.pre, post: trig.post, + }) + } + + patcher, ok := h.be(c).(interface { PatchDocument(dbID, containerID, partitionKey, id string, req patchRequest, ifMatch string) (DocumentInfo, error) }) if !ok { @@ -267,7 +283,8 @@ func (h *Handler) patchDocument(c *echo.Context, dbID, collID, docID string) err c.Response().Header().Set("ETag", info.ETag) - return h.writeJSONCharged(c, http.StatusOK, h.encodeDocument(info), replaceCharge) + return h.writeJSONCharged(c, http.StatusOK, h.encodeDocument(info), + h.writeCharger(dbID, collID, info.Body, true)) } func (h *Handler) deleteDocument(c *echo.Context, dbID, collID, docID string) error { @@ -284,7 +301,7 @@ func (h *Handler) deleteDocument(c *echo.Context, dbID, collID, docID string) er }) } - if err := h.Backend.DeleteDocument(dbID, collID, pk, docID, r.Header.Get("If-Match")); err != nil { + if err := h.be(c).DeleteDocument(dbID, collID, pk, docID, r.Header.Get("If-Match")); err != nil { return h.writeDocumentError(c, err) } @@ -306,13 +323,13 @@ func (h *Handler) listDocuments(c *echo.Context, dbID, collID string) error { var pg page.Page[DocumentInfo] - if lister, ok := h.Backend.(pagedDocumentLister); ok { + if lister, ok := h.be(c).(pagedDocumentLister); ok { var err error if pg, err = lister.ListDocumentsPaged(dbID, collID, token, limit); err != nil { return h.writeDocumentError(c, err) } } else { - infos, err := h.Backend.ListDocuments(dbID, collID) + infos, err := h.be(c).ListDocuments(dbID, collID) if err != nil { return h.writeDocumentError(c, err) } @@ -328,7 +345,7 @@ func (h *Handler) listDocuments(c *echo.Context, dbID, collID string) error { setPageHeaders(c, len(values), pg.Next) return h.writeJSONCharged(c, http.StatusOK, - map[string]any{sysPropRID: "", listDocumentsKey: values, sysPropCount: len(values)}, readCharge) + map[string]any{sysPropRID: "", listDocumentsKey: values, sysPropCount: len(values)}, h.feedCharger(c, values)) } // queryRequestBody is the request body shape for a SQL query POST: @@ -365,7 +382,7 @@ func (h *Handler) queryDocuments(c *echo.Context, dbID, collID string) error { return h.writeError(c, http.StatusBadRequest, "BadRequest", scopeErr.Error()) } - res, execErr := h.runQuery(dbID, collID, req, scope, queryOptions(r)) + res, execErr := h.runQuery(h.be(c), dbID, collID, req, scope, queryOptions(r)) if execErr != nil { return h.writeDocumentError(c, execErr) } @@ -375,9 +392,14 @@ func (h *Handler) queryDocuments(c *echo.Context, dbID, collID string) error { payload := map[string]any{sysPropRID: "", listDocumentsKey: pg.Data, sysPropCount: len(pg.Data)} - return h.writeJSONCharged(c, http.StatusOK, payload, func(size int) float64 { - return queryCharge(res.Scanned, size) - }) + share := 1.0 + if len(res.Rows) > 0 { + share = float64(len(pg.Data)) / float64(len(res.Rows)) + } + + charge := h.readFactor(c) * queryCharge(res.Loaded*share, h.queryPartitions(dbID, collID, scope)) + + return h.writeJSONCharged(c, http.StatusOK, payload, func(int) float64 { return charge }) } // queryOptions reads the scan switch; azcosmos sends x-ms-documentdb-force-query-scan, the REST docs @@ -427,17 +449,17 @@ type indexedQuerier interface { // runQuery uses the backend's direct query path when it has one, otherwise ListDocuments plus ExecuteQuery. func (h *Handler) runQuery( - dbID, collID string, req queryRequestBody, partitionKey string, opts QueryOptions, + be StorageBackend, dbID, collID string, req queryRequestBody, partitionKey string, opts QueryOptions, ) (QueryResult, error) { - if q, ok := h.Backend.(indexedQuerier); ok { + if q, ok := be.(indexedQuerier); ok { return q.RunQueryOpts(dbID, collID, req.Query, req.Parameters, partitionKey, opts) } - if q, ok := h.Backend.(partitionQuerier); ok { + if q, ok := be.(partitionQuerier); ok { return q.RunQuery(dbID, collID, req.Query, req.Parameters, partitionKey) } - infos, err := h.Backend.ListDocuments(dbID, collID) + infos, err := be.ListDocuments(dbID, collID) if err != nil { return QueryResult{}, err } @@ -456,5 +478,32 @@ func (h *Handler) runQuery( rows, execErr := ExecuteQuery(req.Query, req.Parameters, infos) - return QueryResult{Rows: rows, Scanned: len(infos)}, execErr + res := QueryResult{Rows: rows, Scanned: len(infos)} + for _, info := range infos { + raw, _ := jsonMarshal(info.Body) + res.Loaded += readCharge(len(raw)) + } + + return res, execErr +} + +// isRoutingError reports an error that depends on where the request ran: a split partition, a lagging region. +func isRoutingError(err error) bool { + return errors.Is(err, ErrReadSessionNotAvailable) || errors.Is(err, ErrConflictNotFound) || + errors.Is(err, ErrPKRangeGone) +} + +func (h *Handler) writeRoutingError(c *echo.Context, err error) error { + switch { + case errors.Is(err, ErrReadSessionNotAvailable): + c.Response().Header().Set(headerSubStatus, subStatusSessionGone) + + return h.writeError(c, http.StatusNotFound, "NotFound", err.Error()) + case errors.Is(err, ErrPKRangeGone): + c.Response().Header().Set(headerSubStatus, subStatusPKRangeGone) + + return h.writeError(c, http.StatusGone, "Gone", err.Error()) + } + + return h.writeError(c, http.StatusNotFound, "NotFound", "Resource Not Found") } diff --git a/services/cosmosdb/errors.go b/services/cosmosdb/errors.go index e9bffd1903..d04964f650 100644 --- a/services/cosmosdb/errors.go +++ b/services/cosmosdb/errors.go @@ -97,6 +97,18 @@ var ( ErrScriptTimeout = errors.New("cosmosdb: script execution timed out") ErrInvalidTrigger = errors.New("cosmosdb: trigger not usable for this operation") ErrInvalidPKRange = errors.New("cosmosdb: unknown partition key range") + ErrPKRangeGone = errors.New("cosmosdb: partition key range was split") // ErrChangeFeedStart is returned when an all-versions-and-deletes read names no start. - ErrChangeFeedStart = errors.New("cosmosdb: all versions and deletes change feed cannot start from the beginning") + ErrChangeFeedStart = errors.New( + "cosmosdb: all versions and deletes change feed starts from now or from a continuation, not from the beginning " + + "or a point in time", + ) + // ErrChangeFeedBackup is returned for an all-versions-and-deletes read on an account without continuous backup. + ErrChangeFeedBackup = errors.New( + "cosmosdb: all versions and deletes change feed requires continuous backup on the account", + ) + // ErrChangeFeedRetention is returned when the start position is older than the continuous backup window. + ErrChangeFeedRetention = errors.New( + "cosmosdb: the change feed position is outside the continuous backup retention window", + ) ) diff --git a/services/cosmosdb/export_test.go b/services/cosmosdb/export_test.go index 79ca138806..30fd877976 100644 --- a/services/cosmosdb/export_test.go +++ b/services/cosmosdb/export_test.go @@ -1,7 +1,6 @@ package cosmosdb import ( - "fmt" "net/http" "time" ) @@ -119,4 +118,4 @@ func RangeIndex(pkJSON string, n int) int { return rangeIndex(pkJSON, n) } func RangeBounds(i, n int) (string, string) { return rangeBound(i, n), rangeBound(i+1, n) } // EPKHex exposes the hex hash of a canonical partition key. -func EPKHex(pkJSON string) string { return fmt.Sprintf("%016X", epkHash(pkJSON)) } +func EPKHex(pkJSON string) string { return epkHash(pkJSON).String() } diff --git a/services/cosmosdb/features_test.go b/services/cosmosdb/features_test.go index 0259c26dd8..c82101b94c 100644 --- a/services/cosmosdb/features_test.go +++ b/services/cosmosdb/features_test.go @@ -38,7 +38,7 @@ func TestRU_ChargeScalesWithPayloadAndOperation(t *testing.T) { fmt.Appendf(nil, `{"id":"b","pk":"a","blob":%q}`, string(make([]byte, 0))+fmt.Sprintf("%0*d", 20000, 0))) require.Equal(t, http.StatusCreated, big.Code) - assert.InDelta(t, 5.5, charge(small), 0.01, "small create ~5.5 RU") + assert.InDelta(t, 5.5+2*0.3, charge(small), 0.01, "small create: 5.5 RU plus 0.3 for each of its two indexed paths") assert.Greater(t, charge(big), charge(small)+5, "larger payload costs more") readSmall := doRequest(t, h, http.MethodGet, "/dbs/mydb/colls/mycoll/docs/s", pkHdr, nil) @@ -84,7 +84,7 @@ func TestRU_QueryChargeGrowsWithScannedItems(t *testing.T) { few, many := run(1), run(400) - assert.GreaterOrEqual(t, few, 2.3) + assert.GreaterOrEqual(t, few, 1.4) assert.Greater(t, many, few) } diff --git a/services/cosmosdb/ft_analyze.go b/services/cosmosdb/ft_analyze.go new file mode 100644 index 0000000000..449d0ec2e0 --- /dev/null +++ b/services/cosmosdb/ft_analyze.go @@ -0,0 +1,103 @@ +package cosmosdb + +import ( + "slices" + "strings" + "unicode" + + "github.com/blevesearch/snowballstem" + "github.com/blevesearch/snowballstem/english" + "github.com/blevesearch/snowballstem/french" + "github.com/blevesearch/snowballstem/german" + "github.com/blevesearch/snowballstem/italian" + "github.com/blevesearch/snowballstem/portuguese" + "github.com/blevesearch/snowballstem/spanish" +) + +// Full-text analysis (Learn, "Use full-text search"): text is tokenized, English stop words are removed and words +// are stemmed. Multi-language support (preview) stems de-DE, es-ES, fr-FR, it-IT and pt-PT/pt-BR with their Snowball +// stemmers; stop-word removal is English only, as that page says. The stop list is the common English list the +// pages do not publish. A language outside that set is tokenized and lower-cased only. +const defaultFullTextLanguage = "en-US" + +func stemmerFor(lang string) func(*snowballstem.Env) bool { + switch strings.ToLower(lang) { + case "en-us", "en-gb", "en": + return english.Stem + case "de-de": + return german.Stem + case "es-es": + return spanish.Stem + case "fr-fr": + return french.Stem + case "it-it": + return italian.Stem + case "pt-pt", "pt-br": + return portuguese.Stem + } + + return nil +} + +func isEnglishStopWord(w string) bool { + return slices.Contains([]string{ + "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it", "no", "not", + "of", "on", "or", "such", "that", "the", "their", "then", "there", "these", "they", "this", "to", "was", + "will", "with", + }, w) +} + +// ftAnalyze splits s into lower-cased words, drops English stop words and stems what is left. +func ftAnalyze(lang, s string) []string { + words := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + + stem := stemmerFor(lang) + english := strings.HasPrefix(strings.ToLower(lang), "en") + out := make([]string, 0, len(words)) + + for _, w := range words { + if english && isEnglishStopWord(w) { + continue + } + + if stem != nil { + env := snowballstem.NewEnv(w) + stem(env) + w = env.Current() + } + + out = append(out, w) + } + + return out +} + +// editDistance is the Levenshtein distance between a and b. +func editDistance(a, b string) int { + ra, rb := []rune(a), []rune(b) + prev := make([]int, len(rb)+1) + + for j := range prev { + prev[j] = j + } + + for i := 1; i <= len(ra); i++ { + cur := make([]int, len(rb)+1) + cur[0] = i + + for j := 1; j <= len(rb); j++ { + cost := 1 + if ra[i-1] == rb[j-1] { + cost = 0 + } + + cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost) + } + + prev = cur + } + + return prev[len(rb)] +} diff --git a/services/cosmosdb/fulltext.go b/services/cosmosdb/fulltext.go index 80ff5b0582..aa241c758c 100644 --- a/services/cosmosdb/fulltext.go +++ b/services/cosmosdb/fulltext.go @@ -7,7 +7,6 @@ import ( "regexp" "slices" "strings" - "unicode" ) const ( @@ -95,16 +94,18 @@ func validateFullTextIndexes(pol indexingPolicy, fp fullTextPolicy) error { return nil } -// ftTokens lowercases s and splits it on anything that is not a letter or digit. There is no stemming or stop-word -// removal. -func ftTokens(s string) []string { - return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { - return !unicode.IsLetter(r) && !unicode.IsDigit(r) - }) +// ftTerm is one search term: the analysed word and how many edits a document word may differ by (fuzzy search, +// at most two). +type ftTerm struct { + text string + dist int } -// ftTerms collects the search terms from string arguments or one array of strings, tokenized and deduplicated. -func ftTerms(args []any) ([]string, bool) { +const maxFuzzyDistance = 2 + +// ftTerms collects the search terms from string arguments, {"term": ..., "distance": n} objects or one array of +// either, analysed and deduplicated. A string with several words contributes each word. +func ftTerms(lang string, args []any) ([]ftTerm, bool) { var raw []any if len(args) == 1 { if arr, ok := args[0].([]any); ok { @@ -120,17 +121,18 @@ func ftTerms(args []any) ([]string, bool) { return nil, false } - var terms []string + var terms []ftTerm for _, a := range raw { - s, ok := a.(string) + text, dist, ok := ftTermOf(a) if !ok { return nil, false } - for _, t := range ftTokens(s) { - if !slices.Contains(terms, t) { - terms = append(terms, t) + for _, t := range ftAnalyze(lang, text) { + term := ftTerm{text: t, dist: dist} + if !slices.Contains(terms, term) { + terms = append(terms, term) } } } @@ -138,6 +140,33 @@ func ftTerms(args []any) ([]string, bool) { return terms, len(terms) > 0 } +func ftTermOf(a any) (string, int, bool) { + switch t := a.(type) { + case string: + return t, 0, true + case map[string]any: + text, ok := t["term"].(string) + if !ok { + return "", 0, false + } + + dist := 0 + + if d, has := t["distance"]; has { + n, nok := intArg(d) + if !nok || n < 0 || n > maxFuzzyDistance { + return "", 0, false + } + + dist = n + } + + return text, dist, true + } + + return "", 0, false +} + func termFreq(tokens []string) map[string]int { tf := make(map[string]int, len(tokens)) for _, t := range tokens { @@ -154,12 +183,15 @@ func (ec *evalCtx) evalFullText(n *exprCall, args []any) any { return undef{} } - terms, ok := ftTerms(args[1:]) + lang := ec.ftLanguage(n) + + terms, ok := ftTerms(lang, args[1:]) if !ok { return undef{} } - tf := termFreq(ftTokens(text)) + tokens := ftAnalyze(lang, text) + tf := termFreq(tokens) switch n.name { case biFullTextContains, biFullTextContainsAll: @@ -167,15 +199,21 @@ func (ec *evalCtx) evalFullText(n *exprCall, args []any) any { case biFullTextContainsAny: return countPresent(tf, terms) > 0 default: - return floatResult(ec.bm25(n, tf, len(ftTokens(text)), terms)) + for _, t := range terms { + if t.dist > 0 { + return undef{} + } + } + + return floatResult(ec.bm25(n, tf, len(tokens), terms)) } } -func countPresent(tf map[string]int, terms []string) int { +func countPresent(tf map[string]int, terms []ftTerm) int { c := 0 for _, t := range terms { - if tf[t] > 0 { + if termPresent(tf, t) { c++ } } @@ -183,6 +221,42 @@ func countPresent(tf map[string]int, terms []string) int { return c } +func termPresent(tf map[string]int, t ftTerm) bool { + if tf[t.text] > 0 { + return true + } + + if t.dist == 0 { + return false + } + + for word := range tf { + if editDistance(word, t.text) <= t.dist { + return true + } + } + + return false +} + +// ftLanguage is the language of the text path a call reads: the path's language in the full-text policy, else the +// policy default, else en-US. +func (ec *evalCtx) ftLanguage(n *exprCall) string { + if ec.coll != nil { + if path, ok := rootPath(n.args[0]); ok { + if lang := ec.coll.ftLang[path]; lang != "" { + return lang + } + } + + if ec.coll.ftDefaultLang != "" { + return ec.coll.ftDefaultLang + } + } + + return defaultFullTextLanguage +} + // bm25Stats are the corpus statistics of one full-text path. type bm25Stats struct { df map[string]int @@ -192,11 +266,12 @@ type bm25Stats struct { // bm25 scores a document against terms with Okapi BM25 over the whole scanned container (the real service // scores per physical partition). -func (ec *evalCtx) bm25(n *exprCall, tf map[string]int, docLen int, terms []string) float64 { +func (ec *evalCtx) bm25(n *exprCall, tf map[string]int, docLen int, terms []ftTerm) float64 { st := ec.ftCorpus(n) score := 0.0 - for _, t := range terms { + for _, term := range terms { + t := term.text f := float64(tf[t]) if f == 0 { continue @@ -233,7 +308,7 @@ func (ec *evalCtx) ftCorpus(n *exprCall) *bm25Stats { continue } - tokens := ftTokens(text) + tokens := ftAnalyze(ec.ftLanguage(n), text) st.n++ total += len(tokens) diff --git a/services/cosmosdb/fulltext_analysis_test.go b/services/cosmosdb/fulltext_analysis_test.go new file mode 100644 index 0000000000..d9ad640a26 --- /dev/null +++ b/services/cosmosdb/fulltext_analysis_test.go @@ -0,0 +1,141 @@ +package cosmosdb_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +func ftBackend(t *testing.T, lang string, texts ...string) *cosmosdb.InMemoryBackend { + t.Helper() + + b := cosmosdb.NewInMemoryBackend() + _, err := b.CreateDatabase("d") + require.NoError(t, err) + + policy := fmt.Sprintf(`{"defaultLanguage":%q,"fullTextPaths":[{"path":"/text","language":%q}]}`, lang, lang) + _, err = b.CreateContainer("d", cosmosdb.ContainerSpec{ + ID: "c", PartitionKeyPath: "/pk", + IndexingPolicy: json.RawMessage( + `{"indexingMode":"consistent","includedPaths":[{"path":"/*"}],"excludedPaths":[],` + + `"fullTextIndexes":[{"path":"/text"}]}`), + FullTextPolicy: json.RawMessage(policy), + }) + require.NoError(t, err) + + for i, text := range texts { + _, err = b.CreateDocument("d", "c", map[string]any{"id": fmt.Sprintf("d%d", i), "pk": "k", "text": text}, false) + require.NoError(t, err) + } + + return b +} + +func ftIDs(t *testing.T, b *cosmosdb.InMemoryBackend, query string) []string { + t.Helper() + + res, err := b.RunQuery("d", "c", query, nil, "") + require.NoError(t, err) + + out := make([]string, 0, len(res.Rows)) + for _, r := range res.Rows { + out = append(out, r.(string)) + } + + return out +} + +func TestFullText_StemmingStopWordsAndFuzzy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lang string + texts []string + query string + want []string + }{ + { + name: "stemming matches word forms", lang: "en-US", + texts: []string{"He was running fast", "A slow walk", "She runs daily"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, "run")`, want: []string{"d0", "d2"}, + }, + { + name: "the query side is stemmed too", lang: "en-US", + texts: []string{"bicycles for sale"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, "bicycle")`, want: []string{"d0"}, + }, + { + name: "stop words are ignored in the query", + lang: "en-US", + texts: []string{"red bicycle", "blue car"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContainsAll(c.text, "the", "red", "bicycle")`, + want: []string{"d0"}, + }, + { + name: "a query of only stop words matches nothing", lang: "en-US", + texts: []string{"the cat"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, "the")`, want: []string{}, + }, + { + name: "fuzzy search within the distance", lang: "en-US", + texts: []string{"a red bicycle", "a blue car"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, {"term":"bycycle","distance":2})`, + want: []string{"d0"}, + }, + { + name: "fuzzy search beyond the distance", + lang: "en-US", + texts: []string{"a red bicycle"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, {"term":"bxcxcle","distance":1})`, + want: []string{}, + }, + { + name: "distance above two is not a valid search", + lang: "en-US", + texts: []string{"a red bicycle"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, {"term":"bicycle","distance":3})`, + want: []string{}, + }, + { + name: "german words are stemmed", lang: "de-DE", + texts: []string{"Die Häuser sind groß", "Ein Auto"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, "Haus")`, want: []string{"d0"}, + }, + { + name: "stop words are only removed in english", lang: "de-DE", + texts: []string{"die katze"}, + query: `SELECT VALUE c.id FROM c WHERE FullTextContains(c.text, "die")`, want: []string{"d0"}, + }, + { + name: "score ranks the text with more occurrences first", + lang: "en-US", + texts: []string{"bike", "bikes biking bike rides", "car"}, + query: `SELECT VALUE c.id FROM c ORDER BY RANK FullTextScore(c.text, "bike")`, + want: []string{"d1", "d0", "d2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := ftBackend(t, tt.lang, tt.texts...) + got := ftIDs(t, b, tt.query) + + if tt.name == "score ranks the text with more occurrences first" { + require.NotEmpty(t, got) + assert.Equal(t, tt.want[0], got[0]) + + return + } + + assert.ElementsMatch(t, tt.want, got) + }) + } +} diff --git a/services/cosmosdb/handler.go b/services/cosmosdb/handler.go index 8293018d04..33d7407a26 100644 --- a/services/cosmosdb/handler.go +++ b/services/cosmosdb/handler.go @@ -74,7 +74,7 @@ const ( ) // coreOpsCapacity is the number of Core/SQL operations GetSupportedOperations lists. -const coreOpsCapacity = 57 +const coreOpsCapacity = 61 // defaultRequestCharge is reported by routes without a size-based charge (see ru.go). const defaultRequestCharge = "1" @@ -82,30 +82,36 @@ const defaultRequestCharge = "1" // Handler is the Echo HTTP handler for Azure Cosmos DB (Core/SQL API) // operations. type Handler struct { - Backend StorageBackend - TableBackend *odatatable.InMemoryBackend - srvMu *lockmetrics.RWMutex - keysMu *lockmetrics.RWMutex - extraKeys map[string]struct{} - srv *http.Server - janitor *worker.Group - clock func() time.Time - meter *ruMeter - MasterKey string - certPEM []byte - TLSHosts []string - Port int - SweepInterval time.Duration - ValidateAuth bool - TLS bool - // EnforceThroughput rejects requests with 429 once a container's provisioned RU/s budget is overdrawn. - EnforceThroughput bool - // BurstCapacity lets a partition spend up to 300 s of idle RU/s, at up to 3000 RU/s, before throttling. - BurstCapacity bool + workerCtx context.Context + Backend StorageBackend + janitor *worker.Group + keysMu *lockmetrics.RWMutex + extraKeys map[string]struct{} + srv *http.Server + srvMu *lockmetrics.RWMutex + clock func() time.Time + meter *ruMeter + TableBackend *odatatable.InMemoryBackend + tlsCert *tls.Certificate + regionSrvs map[string]*regionListener + MasterKey string + certPEM []byte + TLSHosts []string + RegionPortBase int + Port int + ResourceTokenMaxSeconds int + SweepInterval time.Duration + ValidateAuth bool + TLS bool + EnforceThroughput bool + BurstCapacity bool } // defaultSweepInterval is the TTL janitor period; reads already hide expired documents. -const defaultSweepInterval = 10 * time.Second +const ( + defaultSweepInterval = 10 * time.Second + replicationPumpInterval = time.Second +) // tableAPIBackendLockMetricsLabel is the lockmetrics label for TableBackend, // distinguishing its lock-contention metrics from services/azuretable's own @@ -130,6 +136,11 @@ func NewHandler(backend StorageBackend) *Handler { keysMu: lockmetrics.New("cosmosdb.keys"), extraKeys: make(map[string]struct{}), meter: newRUMeter(), + regionSrvs: map[string]*regionListener{}, + } + + if mem, ok := backend.(*InMemoryBackend); ok { + mem.regionsChanged = h.syncRegionListeners } if a, ok := backend.(interface{ AttachTableAPI(t tableSnapshotter) }); ok { @@ -160,6 +171,7 @@ func (h *Handler) GetSupportedOperations() []string { opPatchDocument, opExecuteBatch, opReadChangeFeed, opReadPKRanges, opListOffers, opQueryOffers, opGetOffer, opReplaceOffer, ) + coreOps = append(coreOps, conflictSupportedOperations()...) coreOps = append(coreOps, userSupportedOperations()...) coreOps = append(coreOps, scriptSupportedOperations()...) tableOps := tableAPISupportedOperations() @@ -209,6 +221,10 @@ func (h *Handler) Handler() echo.HandlerFunc { h.setCommonHeaders(c) + if strings.HasPrefix(r.URL.Path, adminPrefix) { + return h.handleAdmin(c) + } + if authErr := h.authorize(r); authErr != nil { logger.Load(r.Context()).WarnContext(r.Context(), "cosmosdb: request rejected", "error", authErr) @@ -221,12 +237,20 @@ func (h *Handler) Handler() echo.HandlerFunc { return h.handleTableAPI(c, strings.Trim(r.URL.Path, "/")) } + if handled, err := h.guardRegion(c); handled { + return err + } + kind, dbID, collID, docID := parseResourcePath(r.URL.Path) if h.EnforceThroughput && isMetered(kind) { return h.serveMetered(c, dbID, collID, func() error { return h.dispatch(c, kind, dbID, collID, docID) }) } + if h.EnforceThroughput && isMetadata(kind) { + return h.serveMetadata(c, func() error { return h.dispatch(c, kind, dbID, collID, docID) }) + } + return h.dispatch(c, kind, dbID, collID, docID) } } @@ -258,6 +282,10 @@ func (h *Handler) dispatch(c *echo.Context, kind resourceKind, dbID, collID, doc return err } + if handled, err := h.dispatchConflicts(c, kind, dbID, collID, docID); handled { + return err + } + if sk, isItem, ok := scriptKindOf(kind); ok { return h.handleScripts(c, sk, isItem, dbID, collID, docID) } @@ -327,6 +355,8 @@ const ( resourcePKRanges resourceOffers resourceOfferItem + resourceConflicts + resourceConflictItem resourceUsers resourceUserItem resourcePermissions @@ -430,6 +460,8 @@ func collectionKindsFor(segment string) ([2]resourceKind, bool) { return [2]resourceKind{resourceTriggers, resourceTriggerItem}, true case "udfs": return [2]resourceKind{resourceUDFs, resourceUDFItem}, true + case conflictsSegment: + return [2]resourceKind{resourceConflicts, resourceConflictItem}, true default: return [2]resourceKind{}, false } @@ -509,6 +541,10 @@ func operationFor(r *http.Request) string { case resourcePKRanges: return opReadPKRanges default: + if op, ok := conflictOperationFor(kind, r); ok { + return op + } + return offerOrScriptOperationFor(kind, r) } } @@ -638,9 +674,12 @@ func (h *Handler) StartWorker(ctx context.Context) error { h.srvMu.Lock("StartWorker") h.srv = srv + h.workerCtx = ctx h.janitor = h.startJanitor(ctx) h.srvMu.Unlock() + h.syncRegionListeners() + log.InfoContext(workerCtx, "cosmosdb: starting dedicated listener", "port", h.Port, "tls", h.TLS) go func() { @@ -666,6 +705,7 @@ func (h *Handler) wrapTLS(listener net.Listener) (net.Listener, error) { h.srvMu.Lock("wrapTLS") h.certPEM = certPEM + h.tlsCert = &cert h.srvMu.Unlock() return tls.NewListener(listener, &tls.Config{ @@ -704,6 +744,23 @@ func (h *Handler) startJanitor(ctx context.Context) *worker.Group { } }) + if rp, rpOK := h.Backend.(interface{ SweepReplication() int }); rpOK { + g.Ticker("ReplicationPump", replicationPumpInterval, 0, func(context.Context) { + if n := rp.SweepReplication(); n > 0 { + telemetry.RecordWorkerItems("cosmosdb", "ReplicationPump", n) + } + }) + } + + if sp, spOK := h.Backend.(interface{ SweepPartitions() int }); spOK { + g.Ticker("PartitionSplitter", interval, 0, func(sweepCtx context.Context) { + if n := sp.SweepPartitions(); n > 0 { + telemetry.RecordWorkerItems("cosmosdb", "PartitionSplitter", n) + logger.Load(sweepCtx).DebugContext(sweepCtx, "cosmosdb: physical partitions split", "count", n) + } + }) + } + return g } @@ -733,6 +790,8 @@ const ( // Shutdown stops the dedicated Cosmos DB listener. func (h *Handler) Shutdown(ctx context.Context) { + h.stopRegionListeners() + h.srvMu.Lock("Shutdown") srv, janitor := h.srv, h.janitor h.srv, h.janitor = nil, nil diff --git a/services/cosmosdb/indexing.go b/services/cosmosdb/indexing.go index 8a4cdb147e..66e034d22a 100644 --- a/services/cosmosdb/indexing.go +++ b/services/cosmosdb/indexing.go @@ -39,6 +39,8 @@ type indexingPolicy struct { FullTextIndexes []struct { Path string `json:"path"` } `json:"fullTextIndexes"` + SpatialIndexes []spatialIndex `json:"spatialIndexes"` + computed map[string]struct{} CompositeIndexes [][]struct { Path string `json:"path"` Order string `json:"order"` @@ -61,8 +63,15 @@ func parseIndexingPolicy(raw json.RawMessage) indexingPolicy { // checkQueryIndexing applies the indexing policy to stmt: multi-property ORDER BY needs a composite index, and a // query whose filter cannot use the index needs the scan switch. -func checkQueryIndexing(stmt *sqlSelectStmt, raw json.RawMessage, opts QueryOptions) error { +func checkQueryIndexing( + stmt *sqlSelectStmt, raw json.RawMessage, opts QueryOptions, computed map[string]sqlExpr, +) error { pol := parseIndexingPolicy(raw) + pol.computed = make(map[string]struct{}, len(computed)) + + for name := range computed { + pol.computed[name] = struct{}{} + } if !pol.hasCompositeFor(stmt.orderBy) { return &queryIndexError{msg: msgNoCompositeIndex} @@ -173,6 +182,10 @@ func normalizeIndexPath(p string) string { // indexed reports whether the policy indexes the filter path; the longest matching include or exclude wins. func (p indexingPolicy) indexed(path string) bool { + if p.isComputed(path) { + return !strings.EqualFold(p.IndexingMode, indexModeNone) && p.computedIndexed(path) + } + if strings.EqualFold(p.IndexingMode, indexModeNone) { return path == "/id" || path == "/"+sysPropRID } @@ -249,7 +262,7 @@ func (p indexingPolicy) needsScan(x sqlExpr) bool { func (p indexingPolicy) touchesUnindexed(x sqlExpr) bool { if path, ok := rootPath(x); ok { - return path != "" && !p.indexed(path) + return path != "" && !p.isComputed(path) && !p.indexed(path) } switch n := x.(type) { diff --git a/services/cosmosdb/item_ops_test.go b/services/cosmosdb/item_ops_test.go new file mode 100644 index 0000000000..d638246acd --- /dev/null +++ b/services/cosmosdb/item_ops_test.go @@ -0,0 +1,166 @@ +package cosmosdb_test + +import ( + "maps" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +func versionFeedStatus(s *liveServer, cont string) int { + return s.do(http.MethodGet, "/dbs/d/colls/c/docs", avadHeaders(map[string]string{"If-None-Match": cont}), "").status +} + +func TestChangeFeed_VersionsFollowTheContinuousBackupWindow(t *testing.T) { + t.Parallel() + + const day = 24 * time.Hour + + tests := []struct { + name string + backup string + wantOld int + wantNewest int + }{ + {name: "seven day tier", backup: "Continuous7Days", wantOld: http.StatusBadRequest, wantNewest: http.StatusOK}, + {name: "thirty day tier", backup: "Continuous30Days", wantOld: http.StatusOK, wantNewest: http.StatusOK}, + {name: "default is the seven day tier", wantOld: http.StatusBadRequest, wantNewest: http.StatusOK}, + { + name: "periodic backup keeps nothing", + backup: "Periodic", + wantOld: http.StatusBadRequest, + wantNewest: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + clk := &fakeClock{} + cosmosdb.SetNowFunc(s.backend, clk.now) + + if tt.backup != "" { + s.backend.SetAccountPolicies(cosmosdb.AccountPolicies{Consistency: "Session", Backup: tt.backup}) + } + + pk := map[string]string{pkHeader: `["a"]`} + put := func(id string) { + resp := s.do(http.MethodPost, "/dbs/d/colls/c/docs", pk, `{"id":"`+id+`","pk":"a"}`) + require.Equal(t, http.StatusCreated, resp.status, string(resp.body)) + } + + put("old") + clk.advance(8 * day) + put("new") + + assert.Equal(t, tt.wantOld, versionFeedStatus(s, `"0"`), "a position before the window") + assert.Equal(t, tt.wantNewest, versionFeedStatus(s, `"1"`), "the position the first write left") + }) + } +} + +func TestChangeFeed_VersionsKeepMoreThanTenThousandInsideTheWindow(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + pk := map[string]string{pkHeader: `["a"]`} + + for i := range 10_200 { + resp := s.do(http.MethodPost, "/dbs/d/colls/c/docs", pk, `{"id":"`+time.Duration(i).String()+`","pk":"a"}`) + require.Equal(t, http.StatusCreated, resp.status) + } + + feed := s.do(http.MethodGet, "/dbs/d/colls/c/docs", avadHeaders(map[string]string{"If-None-Match": `"0"`}), "") + require.Equal(t, http.StatusOK, feed.status) + assert.Equal(t, `"10200"`, feed.header.Get("ETag"), "no entry was dropped by a count cap") +} + +func TestPatch_RunsPreAndPostTriggers(t *testing.T) { + t.Parallel() + + const ( + pre = `function pre() { + var req = getContext().getRequest(); + var body = req.getBody(); + body.operations.push({op: "set", path: "/touched", value: true}); + req.setBody(body); + }` + post = `function post() { + var doc = getContext().getResponse().getBody(); + doc.postSeen = true; + getContext().getResponse().setBody(doc); + }` + failing = `function failing() { throw new Error("no patch today"); }` + ) + + s := newLiveServer(t) + require.Equal(t, http.StatusCreated, s.putScript("triggers", "pre", pre, + map[string]string{"triggerType": "Pre", "triggerOperation": "Replace"}).status) + require.Equal(t, http.StatusCreated, s.putScript("triggers", "post", post, + map[string]string{"triggerType": "Post", "triggerOperation": "All"}).status) + require.Equal(t, http.StatusCreated, s.putScript("triggers", "failing", failing, + map[string]string{"triggerType": "Pre", "triggerOperation": "Create"}).status) + require.Equal(t, http.StatusCreated, s.create(`{"id":"p","pk":"a","n":1}`).status) + + patch := func(headers map[string]string) rawResponse { + h := map[string]string{pkHeader: `["a"]`} + maps.Copy(h, headers) + + return s.do(http.MethodPatch, "/dbs/d/colls/c/docs/p", h, + `{"operations":[{"op":"incr","path":"/n","value":1}]}`) + } + + tests := []struct { + headers map[string]string + name string + wantStatus int + wantN float64 + wantTouch bool + wantPost bool + }{ + { + name: "pre-trigger edits the operations", + headers: map[string]string{"X-Ms-Documentdb-Pre-Trigger-Include": "pre"}, + wantStatus: http.StatusOK, + wantTouch: true, + wantN: 2, + }, + { + name: "post-trigger edits the response", + headers: map[string]string{"X-Ms-Documentdb-Post-Trigger-Include": "post"}, + wantStatus: http.StatusOK, + wantTouch: true, + wantPost: true, + wantN: 3, + }, + { + name: "a trigger of another operation is refused", + headers: map[string]string{"X-Ms-Documentdb-Pre-Trigger-Include": "failing"}, + wantStatus: http.StatusBadRequest, + }, + {name: "no trigger header runs none", wantStatus: http.StatusOK, wantTouch: true, wantN: 4}, + } + + for _, tt := range tests { + resp := patch(tt.headers) + require.Equal(t, tt.wantStatus, resp.status, tt.name+": "+string(resp.body)) + + if tt.wantStatus != http.StatusOK { + continue + } + + body := resp.object(t) + _, touched := body["touched"] + _, post := body["postSeen"] + assert.Equal(t, tt.wantTouch, touched, tt.name) + assert.Equal(t, tt.wantPost, post, tt.name) + assert.InDelta(t, tt.wantN, body["n"], 0, tt.name) + } +} diff --git a/services/cosmosdb/item_tx.go b/services/cosmosdb/item_tx.go index abc12023f7..b3b1b88929 100644 --- a/services/cosmosdb/item_tx.go +++ b/services/cosmosdb/item_tx.go @@ -11,24 +11,48 @@ type undoEntry struct { } // containerTx is a write transaction over one container. The caller holds the backend write lock for its whole -// life; rollback restores every document and the change-feed sequence touched since begin. +// life; rollback restores every document and the change-feed sequence touched since begin. replicating marks the +// application of a write that came from another region: it keeps the write's identity and emits nothing. type containerTx struct { - now time.Time - b *InMemoryBackend - c *storedContainer - dbID string - collID string - undo []undoEntry - seq0 int64 + now time.Time + repl *replState + b *InMemoryBackend + c *storedContainer + region string + collID string + dbID string + undo []undoEntry + regs AccountRegions + seq0 int64 + lsn0 int64 + evLen int + replicating bool } +// beginTx starts a write transaction; the caller holds the backend write lock. func (b *InMemoryBackend) beginTx(dbID, collID string) (*containerTx, error) { - c, err := b.findContainer(dbID, collID) + tx, err := b.beginTxAt(dbID, collID, false) if err != nil { return nil, err } - return &containerTx{b: b, c: c, dbID: dbID, collID: collID, now: b.now(), seq0: c.ChangeSeq}, nil + tx.c.keep = b.changeRetention() + + return tx, nil +} + +// beginTxAt starts a transaction on the container of the current request's region; read adds the session check. +// It never mutates the container, so it is safe under the read lock. +func (b *InMemoryBackend) beginTxAt(dbID, collID string, read bool) (*containerTx, error) { + c, err := b.findDataContainer(dbID, collID, read) + if err != nil { + return nil, err + } + + tx := &containerTx{b: b, c: c, dbID: dbID, collID: collID, now: b.now(), seq0: c.ChangeSeq} + b.attachRepl(tx) + + return tx, nil } func (tx *containerTx) put(key documentCompositeKey, doc *storedDocument) { @@ -47,13 +71,19 @@ func (tx *containerTx) put(key documentCompositeKey, doc *storedDocument) { if tx.c.alive(prev, tx.now) { rec.Op, rec.Previous, rec.PrevSeq = opReplace, prev, prev.Seq } else { - tx.c.recordExpired(prev) + tx.c.recordExpired(prev, tx.now) } } + if prev != nil && !tx.c.alive(prev, tx.now) { + prev = nil + } + + tx.emit(key, doc, prev, rec.Op) + tx.c.ChangeSeq++ doc.Seq, rec.Seq = tx.c.ChangeSeq, tx.c.ChangeSeq - tx.c.appendChange(rec) + tx.c.appendChange(rec, tx.now) tx.c.Documents[key] = doc tx.c.sorted.Store(nil) } @@ -65,11 +95,12 @@ func (tx *containerTx) remove(key documentCompositeKey) { } tx.undo = append(tx.undo, undoEntry{key: key, prev: prev}) + tx.emit(key, nil, prev, opDelete) tx.c.ChangeSeq++ tx.c.appendChange(&changeRecord{ Time: tx.now, Previous: prev, Op: opDelete, ID: prev.ID, PartitionKeyJSON: prev.PartitionKeyJSON, Seq: tx.c.ChangeSeq, PrevSeq: prev.Seq, - }) + }, tx.now) delete(tx.c.Documents, key) tx.c.sorted.Store(nil) } @@ -87,6 +118,7 @@ func (tx *containerTx) rollback() { tx.c.ChangeSeq = tx.seq0 tx.c.truncateChangeLog(tx.seq0) tx.c.sorted.Store(nil) + tx.rollbackRepl() } func (tx *containerTx) info(d *storedDocument) DocumentInfo { diff --git a/services/cosmosdb/live_server_test.go b/services/cosmosdb/live_server_test.go index 762677b67b..056b26c1a9 100644 --- a/services/cosmosdb/live_server_test.go +++ b/services/cosmosdb/live_server_test.go @@ -13,6 +13,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cosmosdb" ) @@ -72,7 +73,7 @@ func (s *liveServer) do(method, path string, headers map[string]string, body str require.NoError(s.t, s.h.SignRequest(req)) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(s.t).Do(req) require.NoError(s.t, err) defer func() { _ = resp.Body.Close() }() diff --git a/services/cosmosdb/models.go b/services/cosmosdb/models.go index 3c3b5fecb2..0650f84281 100644 --- a/services/cosmosdb/models.go +++ b/services/cosmosdb/models.go @@ -157,25 +157,30 @@ type storedDatabase struct { // storedContainer is the backend's internal representation of a container. type storedContainer struct { Modified time.Time `json:"Modified"` - Offer *storedOffer `json:"Offer,omitempty"` + DefaultTTL *int `json:"DefaultTTL,omitempty"` Triggers map[string]*storedScript `json:"Triggers,omitempty"` + repl *replState + UDFs map[string]*storedScript `json:"UDFs,omitempty"` + Documents map[documentCompositeKey]*storedDocument + Offer *storedOffer `json:"Offer,omitempty"` + PKLayout *pkLayout `json:"PKLayout,omitempty"` sorted atomic.Pointer[[]*storedDocument] Sprocs map[string]*storedScript `json:"Sprocs,omitempty"` - Documents map[documentCompositeKey]*storedDocument - UDFs map[string]*storedScript `json:"UDFs,omitempty"` - DefaultTTL *int `json:"DefaultTTL,omitempty"` - ID string PartitionKeyPath string - ConflictResolutionPolicy json.RawMessage `json:"ConflictResolutionPolicy,omitempty"` - IndexingPolicy json.RawMessage `json:"IndexingPolicy,omitempty"` - PartitionKeyPaths []string `json:"PartitionKeyPaths,omitempty"` - UniqueKeyPolicy json.RawMessage `json:"UniqueKeyPolicy,omitempty"` - GeospatialConfig json.RawMessage `json:"GeospatialConfig,omitempty"` - VectorEmbeddingPolicy json.RawMessage `json:"VectorEmbeddingPolicy,omitempty"` - FullTextPolicy json.RawMessage `json:"FullTextPolicy,omitempty"` - ComputedProperties json.RawMessage `json:"ComputedProperties,omitempty"` - ChangeLog []*changeRecord `json:"ChangeLog,omitempty"` - ChangeSeq int64 `json:"ChangeSeq,omitempty"` + ID string + GeospatialConfig json.RawMessage `json:"GeospatialConfig,omitempty"` + UniqueKeyPolicy json.RawMessage `json:"UniqueKeyPolicy,omitempty"` + ConflictResolutionPolicy json.RawMessage `json:"ConflictResolutionPolicy,omitempty"` + Conflicts []*conflictRecord `json:"Conflicts,omitempty"` + PartitionKeyPaths []string `json:"PartitionKeyPaths,omitempty"` + VectorEmbeddingPolicy json.RawMessage `json:"VectorEmbeddingPolicy,omitempty"` + FullTextPolicy json.RawMessage `json:"FullTextPolicy,omitempty"` + ComputedProperties json.RawMessage `json:"ComputedProperties,omitempty"` + ChangeLog []*changeRecord `json:"ChangeLog,omitempty"` + IndexingPolicy json.RawMessage `json:"IndexingPolicy,omitempty"` + keep time.Duration + ChangeFloor int64 `json:"ChangeFloor,omitempty"` + ChangeSeq int64 `json:"ChangeSeq,omitempty"` } // storedDocument is the backend's internal representation of a document. @@ -184,7 +189,9 @@ type storedDocument struct { Body map[string]any ID string PartitionKeyJSON string + Origin string Seq int64 + GLSN int64 } // storedDocumentWire is storedDocument's on-disk (persistence snapshot) @@ -199,8 +206,10 @@ type storedDocumentWire struct { Timestamp time.Time `json:"Timestamp"` ID string `json:"ID"` PartitionKeyJSON string `json:"PartitionKeyJSON"` + Origin string `json:"Origin,omitempty"` Body json.RawMessage `json:"Body"` Seq int64 `json:"Seq,omitempty"` + GLSN int64 `json:"GLSN,omitempty"` } // MarshalJSON implements json.Marshaler for persistence snapshots. @@ -212,6 +221,7 @@ func (d *storedDocument) MarshalJSON() ([]byte, error) { return json.Marshal(storedDocumentWire{ Body: bodyBytes, Timestamp: d.Timestamp, ID: d.ID, PartitionKeyJSON: d.PartitionKeyJSON, Seq: d.Seq, + GLSN: d.GLSN, Origin: d.Origin, }) } @@ -246,6 +256,7 @@ func (d *storedDocument) UnmarshalJSON(data []byte) error { } d.Body, d.Timestamp, d.ID, d.PartitionKeyJSON, d.Seq = body, wire.Timestamp, wire.ID, wire.PartitionKeyJSON, wire.Seq + d.GLSN, d.Origin = wire.GLSN, wire.Origin return nil } diff --git a/services/cosmosdb/offer_ops.go b/services/cosmosdb/offer_ops.go index 5db7beac9a..5032d3b538 100644 --- a/services/cosmosdb/offer_ops.go +++ b/services/cosmosdb/offer_ops.go @@ -247,8 +247,8 @@ func (h *Handler) writeOffer(c *echo.Context, status int, info ThroughputInfo, e } hdr := c.Response().Header() - hdr.Set(headerOfferMinimum, strconv.Itoa(info.MinimumThroughput())) - hdr.Set(headerOfferPending, "false") + hdr.Set(headerOfferMinimum, strconv.Itoa(info.Minimum)) + hdr.Set(headerOfferPending, strconv.FormatBool(info.Pending)) hdr.Set("ETag", resourceETag(info.Modified, info.OfferRID)) setCharge(c, ruMetadata) diff --git a/services/cosmosdb/partition_epk.go b/services/cosmosdb/partition_epk.go new file mode 100644 index 0000000000..287ed577d3 --- /dev/null +++ b/services/cosmosdb/partition_epk.go @@ -0,0 +1,234 @@ +package cosmosdb + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "math/big" +) + +// Effective partition key hashing for partition key version 2. Ported from the hashing azcosmos v1.5.0 uses to +// route change-feed feed ranges (sdk/data/azcosmos/internal/epk/epk.go): MurmurHash3 x64 128 over the typed +// component bytes, the two top bits masked so every hash is below the service's "FF" sentinel. +const ( + epkComponentNull byte = 0x01 + epkComponentFalse byte = 0x02 + epkComponentTrue byte = 0x03 + epkComponentNumber byte = 0x05 + epkComponentString byte = 0x08 + epkStringSuffix byte = 0xFF + epkTopMask = uint64(0x3FFFFFFFFFFFFFFF) + epkSpaceBits = 126 + murmurC1 = uint64(0x87c37b91114253d5) + murmurC2 = uint64(0x4cf5ad432745937f) + rotA = 31 + rotB = 27 + rotC = 33 + bitsPerByte = 8 + blockHalf = 8 + fmixShift = 33 + wordBits = 64 + murmurMul = 5 + murmurAddA = 0x52dce729 + murmurAddB = 0x38495ab5 +) + +// u128 is a point in the 128-bit effective partition key space, most significant half first. +type u128 struct{ hi, lo uint64 } + +func (a u128) cmp(b u128) int { + switch { + case a.hi != b.hi: + if a.hi < b.hi { + return -1 + } + + return 1 + case a.lo != b.lo: + if a.lo < b.lo { + return -1 + } + + return 1 + } + + return 0 +} + +func (a u128) big() *big.Int { + var raw [16]byte + + binary.BigEndian.PutUint64(raw[:8], a.hi) + binary.BigEndian.PutUint64(raw[8:], a.lo) + + return new(big.Int).SetBytes(raw[:]) +} + +func u128FromBig(v *big.Int) u128 { + var raw [16]byte + + v.FillBytes(raw[:]) + + return u128{hi: binary.BigEndian.Uint64(raw[:8]), lo: binary.BigEndian.Uint64(raw[8:])} +} + +func (a u128) String() string { return fmt.Sprintf("%016X%016X", a.hi, a.lo) } + +func (a u128) MarshalText() ([]byte, error) { return []byte(a.String()), nil } + +func (a *u128) UnmarshalText(text []byte) error { + _, err := fmt.Sscanf(string(text), "%016X%016X", &a.hi, &a.lo) + + return err +} + +func rotl64(v uint64, r uint) uint64 { return v<>(wordBits-r) } + +func fmix64(v uint64) uint64 { + v ^= v >> fmixShift + v *= 0xff51afd7ed558ccd + v ^= v >> fmixShift + v *= 0xc4ceb9fe1a85ec53 + v ^= v >> fmixShift + + return v +} + +// murmur3x64 is MurmurHash3_x64_128 with seed 0, returning (low, high). +func murmur3x64(data []byte) (uint64, uint64) { + var h1, h2 uint64 + + pos := 0 + for ; pos+16 <= len(data); pos += 16 { + k1 := binary.LittleEndian.Uint64(data[pos:]) + k2 := binary.LittleEndian.Uint64(data[pos+8:]) + + k1 = rotl64(k1*murmurC1, rotA) * murmurC2 + h1 ^= k1 + h1 = (rotl64(h1, rotB)+h2)*murmurMul + murmurAddA + + k2 = rotl64(k2*murmurC2, rotC) * murmurC1 + h2 ^= k2 + h2 = (rotl64(h2, rotA)+h1)*murmurMul + murmurAddB + } + + tail := data[pos:] + + var k1, k2 uint64 + + for i := len(tail) - 1; i >= blockHalf; i-- { + k2 ^= uint64(tail[i]) << (bitsPerByte * uint(i-blockHalf)) + } + + if len(tail) > blockHalf { + h2 ^= rotl64(k2*murmurC2, rotC) * murmurC1 + } + + for i := min(len(tail), blockHalf) - 1; i >= 0; i-- { + k1 ^= uint64(tail[i]) << (bitsPerByte * uint(i)) + } + + if len(tail) > 0 { + h1 ^= rotl64(k1*murmurC1, rotA) * murmurC2 + } + + n := uint64(len(data)) + h1 ^= n + h2 ^= n + h1 += h2 + h2 += h1 + h1, h2 = fmix64(h1), fmix64(h2) + h1 += h2 + h2 += h1 + + return h1, h2 +} + +// appendEPKComponent encodes one partition key component for hashing. +func appendEPKComponent(buf []byte, v any) []byte { + switch x := v.(type) { + case nil: + return append(buf, epkComponentNull) + case bool: + if x { + return append(buf, epkComponentTrue) + } + + return append(buf, epkComponentFalse) + case json.Number: + f, _ := x.Float64() + + return appendEPKNumber(buf, f) + case float64: + return appendEPKNumber(buf, x) + case string: + buf = append(buf, epkComponentString) + buf = append(buf, x...) + + return append(buf, epkStringSuffix) + } + + return buf +} + +func appendEPKNumber(buf []byte, f float64) []byte { + buf = append(buf, epkComponentNumber) + + return binary.LittleEndian.AppendUint64(buf, math.Float64bits(f)) +} + +func epkOfValues(vals []any) u128 { + var buf []byte + for _, v := range vals { + buf = appendEPKComponent(buf, v) + } + + low, high := murmur3x64(buf) + + return u128{hi: high & epkTopMask, lo: low} +} + +// epkHash is the routing hash of a canonical partition key: a scalar hashes as a whole; a hierarchical key (a JSON +// array) routes by its first component, whose hash opens its effective partition key. +func epkHash(pkJSON string) u128 { + dec := json.NewDecoder(bytes.NewReader([]byte(pkJSON))) + dec.UseNumber() + + var v any + if err := dec.Decode(&v); err != nil { + return epkOfValues([]any{pkJSON}) + } + + if arr, ok := v.([]any); ok { + if len(arr) == 0 { + return u128{} + } + + return epkOfValues(arr[:1]) + } + + return epkOfValues([]any{v}) +} + +func epkSpace() *big.Int { return new(big.Int).Lsh(big.NewInt(1), epkSpaceBits) } + +// evenLow is the inclusive lower bound of range i of n: ceil(i * 2^126 / n). +func evenLow(i, n int) u128 { + if i <= 0 || n <= 0 { + return u128{} + } + + num := new(big.Int).Mul(epkSpace(), big.NewInt(int64(i))) + num.Add(num, big.NewInt(int64(n-1))) + + return u128FromBig(num.Div(num, big.NewInt(int64(n)))) +} + +// midpoint of [lo, hi). +func midpoint(lo, hi u128) u128 { + sum := new(big.Int).Add(lo.big(), hi.big()) + + return u128FromBig(sum.Rsh(sum, 1)) +} diff --git a/services/cosmosdb/partition_ops.go b/services/cosmosdb/partition_ops.go new file mode 100644 index 0000000000..dcb30aea4e --- /dev/null +++ b/services/cosmosdb/partition_ops.go @@ -0,0 +1,264 @@ +package cosmosdb + +import ( + "sort" + "time" +) + +// SetPartitionLimits overrides the physical partition storage limit (default 50 GB) and sets how long an +// asynchronous throughput scale-up takes (default 0: instant; the service takes "typically 4-6 hours"). +func (b *InMemoryBackend) SetPartitionLimits(maxBytes int64, splitDelay time.Duration) { + b.mu.Lock("SetPartitionLimits") + defer b.mu.Unlock() + + b.partitionMaxBytes = maxBytes + b.splitDelay = splitDelay +} + +func (b *InMemoryBackend) storageLimit() int64 { + if b.partitionMaxBytes > 0 { + return b.partitionMaxBytes + } + + return partitionMaxBytes +} + +// layoutTarget is the partition count the governing offer needs. +func (b *InMemoryBackend) layoutTarget(d *storedDatabase, c *storedContainer) int { + return partitionsForRU(int(b.budgetOf(d, c.ID).Rate)) +} + +// layoutView is the container's current layout, the implicit even one when none was materialised. +func (b *InMemoryBackend) layoutView(d *storedDatabase, c *storedContainer) *pkLayout { + if c.PKLayout != nil { + return c.PKLayout.clone() + } + + return evenLayout(b.layoutTarget(d, c)) +} + +// prepareLayout pins the implicit layout of a container that has an offer, so a later change splits it. +func (b *InMemoryBackend) prepareLayout(d *storedDatabase, c *storedContainer) { + if c.PKLayout == nil && b.budgetOf(d, c.ID).Rate > 0 { + c.PKLayout = evenLayout(b.layoutTarget(d, c)) + } +} + +// syncLayout brings the container's partitions up to what its offer needs; the lock must be held. +func (b *InMemoryBackend) syncLayout(d *storedDatabase, c *storedContainer) { + target := b.layoutTarget(d, c) + + if c.PKLayout == nil { + if target > 1 { + c.PKLayout = evenLayout(target) + b.budgetEpoch++ + } + + return + } + + l := c.PKLayout + + switch { + case target <= len(l.Spans): + l.PendingTarget, l.PendingDue = 0, time.Time{} + case b.splitDelay <= 0: + l.growTo(target) + l.PendingTarget, l.PendingDue = 0, time.Time{} + b.budgetEpoch++ + default: + if l.PendingDue.IsZero() { + l.PendingDue = b.now().Add(b.splitDelay) + } + + l.PendingTarget = target + } + + b.recomputeNextDue() +} + +func (b *InMemoryBackend) recomputeNextDue() { + b.nextSplitDue = time.Time{} + + for _, d := range b.databases { + for _, c := range d.Containers { + l := c.PKLayout + if l == nil || l.PendingDue.IsZero() { + continue + } + + if b.nextSplitDue.IsZero() || l.PendingDue.Before(b.nextSplitDue) { + b.nextSplitDue = l.PendingDue + } + } + } +} + +// settleDue completes every pending scale-up whose time has come. +func (b *InMemoryBackend) settleDue() { + b.mu.RLock("settleDue") + due := b.nextSplitDue + b.mu.RUnlock() + + if due.IsZero() || b.now().Before(due) { + return + } + + b.mu.Lock("settleDue") + defer b.mu.Unlock() + + b.settleLocked() +} + +func (b *InMemoryBackend) settleLocked() int { + now := b.now() + splits := 0 + + for _, d := range b.databases { + for _, c := range d.Containers { + l := c.PKLayout + if l == nil || l.PendingDue.IsZero() || now.Before(l.PendingDue) { + continue + } + + splits += l.growTo(max(l.PendingTarget, b.layoutTarget(d, c))) + l.PendingTarget, l.PendingDue = 0, time.Time{} + b.budgetEpoch++ + } + } + + b.recomputeNextDue() + + return splits +} + +// docBytes is the stored size of a document: its JSON body. +func docBytes(d *storedDocument) int64 { + raw, err := jsonMarshal(d.Body) + if err != nil { + return 0 + } + + return int64(len(raw)) +} + +// splitOversized splits every partition holding more than the storage limit, at the median key hash so the +// children share the data; a partition holding one logical partition cannot split. The lock must be held. +func (b *InMemoryBackend) splitOversized(d *storedDatabase, c *storedContainer) int { + limit := b.storageLimit() + splits := 0 + + for range maxStorageSplitsPerPass { + l := b.layoutView(d, c) + sizes := make([]int64, len(l.Spans)) + hashes := make([][]u128, len(l.Spans)) + + for _, doc := range c.Documents { + h := epkHash(doc.PartitionKeyJSON) + i := l.indexOf(h) + sizes[i] += docBytes(doc) + hashes[i] = append(hashes[i], h) + } + + split := false + + for i := range l.Spans { + if sizes[i] <= limit { + continue + } + + sort.Slice(hashes[i], func(x, y int) bool { return hashes[i][x].cmp(hashes[i][y]) < 0 }) + + at := hashes[i][len(hashes[i])/2] + if hashes[i][0].cmp(hashes[i][len(hashes[i])-1]) == 0 || !l.splitAt(i, at) { + continue + } + + c.PKLayout = l + split = true + + break + } + + if !split { + return splits + } + + splits++ + b.budgetEpoch++ + } + + return splits +} + +// SweepPartitions completes due scale-ups and splits partitions over the storage limit; it returns the splits. +func (b *InMemoryBackend) SweepPartitions() int { + b.mu.Lock("SweepPartitions") + defer b.mu.Unlock() + + splits := b.settleLocked() + + for _, d := range b.databases { + for _, c := range d.Containers { + splits += b.splitOversized(d, c) + } + } + + return splits +} + +// PartitionKeyRanges is the container's partition key range feed. +type PartitionKeyRanges struct { + Layout *pkLayout + RID string + Pending bool +} + +// partitionRanges returns the current layout after settling due splits and storage splits. +func (b *InMemoryBackend) partitionRanges(dbID, collID string) (PartitionKeyRanges, error) { + b.SweepPartitions() + + b.mu.RLock("partitionRanges") + defer b.mu.RUnlock() + + c, err := b.findContainer(dbID, collID) + if err != nil { + return PartitionKeyRanges{}, err + } + + d := b.databases[dbID] + l := b.layoutView(d, c) + + return PartitionKeyRanges{Layout: l, RID: b.containerInfo(dbID, c).RID, Pending: l.pending()}, nil +} + +// RangeShares reports each partition's share of the container's documents, in layout order; an empty +// container shares evenly. +func (b *InMemoryBackend) RangeShares(dbID, collID string) []float64 { + b.mu.RLock("RangeShares") + defer b.mu.RUnlock() + + c, err := b.findContainer(dbID, collID) + if err != nil { + return nil + } + + l := b.layoutView(b.databases[dbID], c) + counts := make([]float64, len(l.Spans)) + + for _, doc := range c.Documents { + counts[l.indexOf(epkHash(doc.PartitionKeyJSON))]++ + } + + total := float64(len(c.Documents)) + + for i := range counts { + if total == 0 { + counts[i] = 1 / float64(len(counts)) + } else { + counts[i] /= total + } + } + + return counts +} diff --git a/services/cosmosdb/partition_split_test.go b/services/cosmosdb/partition_split_test.go new file mode 100644 index 0000000000..f6e2948d11 --- /dev/null +++ b/services/cosmosdb/partition_split_test.go @@ -0,0 +1,322 @@ +package cosmosdb_test + +import ( + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +func (s *throughputServer) setOffer(ru int) rawResponse { + s.t.Helper() + + info, err := s.backend.GetThroughput("d", "c") + require.NoError(s.t, err) + + return s.do(http.MethodPut, "/offers/"+info.OfferRID, nil, fmt.Sprintf(`{"content":{"offerThroughput":%d}}`, ru)) +} + +type rangeDoc struct { + id string + min string + max string + parents []string +} + +func (s *throughputServer) ranges() []rangeDoc { + s.t.Helper() + + resp := s.do(http.MethodGet, "/dbs/d/colls/c/pkranges", nil, "") + require.Equal(s.t, http.StatusOK, resp.status) + + raw, _ := resp.object(s.t)["PartitionKeyRanges"].([]any) + out := make([]rangeDoc, 0, len(raw)) + + for _, r := range raw { + m := rowObj(s.t, r) + d := rangeDoc{id: m["id"].(string), min: m["minInclusive"].(string), max: m["maxExclusive"].(string)} + + for _, p := range m["parents"].([]any) { + d.parents = append(d.parents, p.(string)) + } + + out = append(out, d) + } + + return out +} + +func ids(rs []rangeDoc) []string { + out := make([]string, len(rs)) + for i, r := range rs { + out[i] = r.id + } + + return out +} + +func TestPartitionSplit_ThroughputRule(t *testing.T) { + t.Parallel() + + tests := []struct { + check func(t *testing.T, s *throughputServer) + name string + }{ + {name: "increase splits widest range with parents", check: func(t *testing.T, s *throughputServer) { + t.Helper() + + s.container("c", manualHeader(20000)) + require.Equal(t, []string{"0", "1"}, ids(s.ranges())) + require.Equal(t, http.StatusOK, s.setOffer(30000).status) + + rs := s.ranges() + require.Len(t, rs, 3, "ROUNDUP(30000/10000) partitions") + assert.Contains(t, ids(rs), "1", "the unsplit partition keeps its id") + + children := 0 + + for _, r := range rs { + if len(r.parents) > 0 { + children++ + + assert.Equal(t, []string{"0"}, r.parents) + } + } + + assert.Equal(t, 2, children) + }}, + {name: "ranges still tile the hash space after a split", check: func(t *testing.T, s *throughputServer) { + t.Helper() + + s.container("c", manualHeader(10000)) + require.Equal(t, http.StatusOK, s.setOffer(50000).status) + + rs := s.ranges() + require.Len(t, rs, 5) + + prev := "" + for _, r := range rs { + assert.Equal(t, prev, r.min) + prev = r.max + } + + assert.Equal(t, "FF", prev) + }}, + {name: "lowering throughput never merges", check: func(t *testing.T, s *throughputServer) { + t.Helper() + + s.container("c", manualHeader(30000)) + require.Equal(t, http.StatusOK, s.setOffer(5000).status) + + assert.Len(t, s.ranges(), 3) + }}, + {name: "a retired range id answers 410 with substatus 1002", check: func(t *testing.T, s *throughputServer) { + t.Helper() + + s.container("c", manualHeader(10000)) + require.Equal(t, http.StatusOK, s.setOffer(20000).status) + + feed := func(id string) rawResponse { + return s.do(http.MethodGet, "/dbs/d/colls/c/docs", + map[string]string{"A-Im": "Incremental feed", "X-Ms-Documentdb-Partitionkeyrangeid": id}, "") + } + + gone := feed("0") + assert.Equal(t, http.StatusGone, gone.status) + assert.Equal(t, "1002", gone.header.Get("X-Ms-Substatus")) + assert.Equal(t, http.StatusBadRequest, feed("99").status, "an id never issued is not gone") + assert.Equal(t, http.StatusNotModified, feed("1").status) + }}, + {name: "scale-up is asynchronous on the clock", check: func(t *testing.T, s *throughputServer) { + t.Helper() + + cosmosdb.SetNowFunc(s.backend, s.clock.now) + s.backend.SetPartitionLimits(0, 5*time.Hour) + s.container("c", manualHeader(10000)) + + put := s.setOffer(30000) + require.Equal(t, http.StatusOK, put.status) + assert.Equal(t, "true", put.header.Get("X-Ms-Offer-Replace-Pending")) + assert.Len(t, s.ranges(), 1) + + s.clock.advance(4 * time.Hour) + assert.Len(t, s.ranges(), 1) + + s.clock.advance(time.Hour) + assert.Len(t, s.ranges(), 3) + + info, err := s.backend.GetThroughput("d", "c") + require.NoError(t, err) + assert.False(t, info.Pending) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newThroughputServer(t, false, false) + s.mustCreate("/dbs", `{"id":"d"}`, nil) + tt.check(t, s) + }) + } +} + +func TestPartitionSplit_StorageRule(t *testing.T) { + t.Parallel() + + const limit = 8 * 1024 + + tests := []struct { + name string + docs int + sameKey bool + wantSplit bool + }{ + {name: "under the limit stays whole", docs: 4}, + {name: "over the limit splits at the median key", docs: 40, wantSplit: true}, + {name: "one logical partition cannot split", docs: 40, sameKey: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newThroughputServer(t, false, false) + s.backend.SetPartitionLimits(limit, 0) + s.mustCreate("/dbs", `{"id":"d"}`, nil) + s.container("c", manualHeader(10000)) + + for i := range tt.docs { + pk := "same" + if !tt.sameKey { + pk = "key-" + strconv.Itoa(i) + } + + require.Equal(t, http.StatusCreated, s.putPK("c", pk, 1).status) + } + + got := len(s.ranges()) + if tt.wantSplit { + assert.GreaterOrEqual(t, got, 2) + } else { + assert.Equal(t, 1, got) + } + }) + } +} + +func TestPartitionSplit_StorageChildrenFit(t *testing.T) { + t.Parallel() + + s := newThroughputServer(t, false, false) + s.backend.SetPartitionLimits(8*1024, 0) + s.mustCreate("/dbs", `{"id":"d"}`, nil) + s.container("c", manualHeader(10000)) + + for i := range 40 { + require.Equal(t, http.StatusCreated, s.putPK("c", "key-"+strconv.Itoa(i), 1).status) + } + + rs := s.ranges() + require.Greater(t, len(rs), 1) + + for _, r := range rs[len(rs)-2:] { + assert.NotEmpty(t, r.parents) + } + + assert.True(t, slices.IsSorted(func() []string { + mins := make([]string, len(rs)) + for i, r := range rs { + mins[i] = r.min + } + + return mins + }()), "ranges are listed in hash order") +} + +func TestPartitionCharge_CrossPartitionFollowsWork(t *testing.T) { + t.Parallel() + + s := newThroughputServer(t, true, false) + s.mustCreate("/dbs", `{"id":"d"}`, nil) + s.container("c", manualHeader(20000)) + + hot, cold := pkInRange(t, 0), pkInRange(t, 1) + for range 30 { + require.Equal(t, http.StatusCreated, s.putPK("c", hot, padKB).status) + } + + s.clock.advance(2 * time.Second) + + throttled := false + + for range 100 { + count := s.do(http.MethodPost, "/dbs/d/colls/c/docs", + map[string]string{"X-Ms-Documentdb-Isquery": "true", "Content-Type": "application/query+json"}, + `{"query":"SELECT VALUE COUNT(1) FROM c"}`) + if count.status == http.StatusTooManyRequests { + throttled = true + + break + } + } + + require.True(t, throttled, "the scanned partition runs out of budget") + assert.Equal(t, http.StatusCreated, s.putPK("c", cold, padKB).status, + "the partition that held no documents was not charged for the scan") +} + +// The expected hashes are the V2 baselines from azcosmos v1.5.0 (internal/epk/testdata, shared with the .NET SDK) +// with the two top bits cleared, which is how the service keeps every hash below its FF sentinel. +func TestPartitionHash_MatchesSDKBaselines(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + want string + }{ + {name: "empty string", key: `""`, want: "32E9366E637A71B4E710384B2F4970A0"}, + {name: "short string", key: `"asdf"`, want: "208BF1D598F5B63AD5520F04FE58C5E8"}, + {name: "99 byte string", key: `"` + strings.Repeat("a", 99) + `"`, want: "3C02F609961C32AAC2B912B940D1209A"}, + {name: "100 byte string", key: `"` + strings.Repeat("a", 100) + `"`, want: "0DBD7D229437BB665DE6417747994D57"}, + { + name: "hierarchical routes by first component", key: `["asdf","other"]`, + want: "208BF1D598F5B63AD5520F04FE58C5E8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, cosmosdb.EPKHex(tt.key)) + }) + } +} + +func TestPartitionSplit_SnapshotKeepsLayout(t *testing.T) { + t.Parallel() + + s := newThroughputServer(t, false, false) + s.mustCreate("/dbs", `{"id":"d"}`, nil) + s.container("c", manualHeader(10000)) + require.Equal(t, http.StatusOK, s.setOffer(30000).status) + require.Equal(t, http.StatusOK, s.setOffer(10000).status) + + want := s.ranges() + require.Len(t, want, 3) + + s2 := newThroughputServer(t, false, false) + require.NoError(t, s2.backend.Restore(t.Context(), s.backend.Snapshot(t.Context()))) + assert.Equal(t, want, s2.ranges()) +} diff --git a/services/cosmosdb/partitions.go b/services/cosmosdb/partitions.go index 983cb521d1..067d211297 100644 --- a/services/cosmosdb/partitions.go +++ b/services/cosmosdb/partitions.go @@ -1,103 +1,260 @@ package cosmosdb import ( - "crypto/sha256" - "encoding/binary" - "fmt" - "math" - "math/bits" + "math/big" "net/http" + "slices" + "sort" "strconv" + "time" ) -// A physical partition serves up to 10,000 RU/s (and 50 GB, which the emulator does not model). +// Physical partition limits (Learn, "Partitioning and horizontal scaling" and "Best practices for scaling +// provisioned throughput"): up to 10,000 RU/s and 50 GB each. const ( - partitionMaxRUs = 10_000 - pkRangeMax = "FF" - pkRangeRIDLetters = 26 + partitionMaxRUs = 10_000 + partitionMaxBytes = int64(50) << 30 + pkRangeMax = "FF" + pkRangeRIDLetters = 26 + maxStorageSplitsPerPass = 256 ) -// partitionsForRU is the physical partition count of an offer: ceil(RU / 10000), at least one. +// partitionsForRU is the physical partition count an offer needs: ROUNDUP(RU / 10000), at least one. func partitionsForRU(ru int) int { return max(1, (ru+partitionMaxRUs-1)/partitionMaxRUs) } -func epkHash(pkJSON string) uint64 { - sum := sha256.Sum256([]byte(pkJSON)) +// rangeIndex maps a canonical partition key to its owner in an even split of the hash space into n ranges. +func rangeIndex(pkJSON string, n int) int { + l := evenLayout(n) - return binary.BigEndian.Uint64(sum[:8]) + return l.indexOf(epkHash(pkJSON)) } -// rangeIndex maps a canonical partition key to the physical partition owning it, splitting the hash space evenly. -func rangeIndex(pkJSON string, n int) int { - if n <= 1 { - return 0 +func rangeBound(i, n int) string { + switch { + case i <= 0: + return "" + case i >= n || n <= 0: + return pkRangeMax } - hi, _ := bits.Mul64(epkHash(pkJSON), uint64(n)) + return evenLow(i, n).String() +} - return int(hi & math.MaxInt32) +func rangeRIDSuffix(i int) string { + return string([]byte{'A', 'A', byte('A' + i/pkRangeRIDLetters%pkRangeRIDLetters), byte('A' + i%pkRangeRIDLetters)}) } -// rangeBound is the inclusive lower EPK bound of range i of n: the smallest hash h with rangeIndex(h) >= i. -func rangeBound(i, n int) string { - if i <= 0 { - return "" +// pkSpan is one physical partition: it owns the hashes from Lo up to the next span's Lo. +type pkSpan struct { + Parents []int `json:"parents,omitempty"` + Lo u128 `json:"lo"` + ID int `json:"id"` +} + +// pkLayout is a container's physical partitions. Splits only add partitions (merge is a separate preview feature), +// a child lists its ancestors, and ids are never reused. A throughput increase that needs more partitions than +// exist is pending until PendingDue (the documented asynchronous scale-up). +type pkLayout struct { + PendingDue time.Time `json:"pendingDue,omitzero"` + Spans []pkSpan `json:"spans"` + NextID int `json:"nextId"` + PendingTarget int `json:"pendingTarget,omitempty"` +} + +func evenLayout(n int) *pkLayout { + n = max(1, n) + l := &pkLayout{Spans: make([]pkSpan, n), NextID: n} + + for i := range l.Spans { + l.Spans[i] = pkSpan{ID: i, Lo: evenLow(i, n)} } - if i >= n || n <= 0 { - return pkRangeMax + return l +} + +func (l *pkLayout) clone() *pkLayout { + out := *l + out.Spans = slices.Clone(l.Spans) + + return &out +} + +// indexOf is the position of the span owning hash h. +func (l *pkLayout) indexOf(h u128) int { + return max(0, sort.Search(len(l.Spans), func(i int) bool { return l.Spans[i].Lo.cmp(h) > 0 })-1) +} + +func (l *pkLayout) owner(pkJSON string) pkSpan { return l.Spans[l.indexOf(epkHash(pkJSON))] } + +// upper is the exclusive upper bound of span i; open is true for the last span (the whole top of the space). +func (l *pkLayout) upper(i int) (u128, bool) { + if i+1 >= len(l.Spans) { + return u128{}, true } - q, r := bits.Div64(uint64(i), 0, uint64(n)) - if r != 0 { - q++ + return l.Spans[i+1].Lo, false +} + +func (l *pkLayout) byID(id int) (int, bool) { + for i, s := range l.Spans { + if s.ID == id { + return i, true + } } - return fmt.Sprintf("%016X", q) + return 0, false } -func rangeRIDSuffix(i int) string { - return string([]byte{'A', 'A', byte('A' + i/pkRangeRIDLetters%pkRangeRIDLetters), byte('A' + i%pkRangeRIDLetters)}) +// splitAt replaces span i with two children divided at hash at, which must fall strictly inside the span. +func (l *pkLayout) splitAt(i int, at u128) bool { + hi, open := l.upper(i) + if at.cmp(l.Spans[i].Lo) <= 0 || (!open && at.cmp(hi) >= 0) { + return false + } + + parent := l.Spans[i] + lineage := append(slices.Clone(parent.Parents), parent.ID) + left := pkSpan{ID: l.NextID, Lo: parent.Lo, Parents: lineage} + right := pkSpan{ID: l.NextID + 1, Lo: at, Parents: slices.Clone(lineage)} + l.NextID += 2 + l.Spans = slices.Replace(l.Spans, i, i+1, left, right) + + return true } -func pkRangeETagFor(n int) string { return `"` + strconv.Itoa(n) + `"` } +func (l *pkLayout) width(i int) *big.Int { + hi, open := l.upper(i) + top := epkSpace() + if !open { + top = hi.big() + } + + return new(big.Int).Sub(top, l.Spans[i].Lo.big()) +} + +func (l *pkLayout) splitMid(i int) bool { + hi, open := l.upper(i) + if open { + hi = u128FromBig(epkSpace()) + } + + return l.splitAt(i, midpoint(l.Spans[i].Lo, hi)) +} + +// growTo splits the widest partition (lowest first on a tie) until n partitions exist. +func (l *pkLayout) growTo(n int) int { + splits := 0 + + for len(l.Spans) < n { + widest := 0 + for i := range l.Spans { + if l.width(i).Cmp(l.width(widest)) > 0 { + widest = i + } + } + + if !l.splitMid(widest) { + break + } + + splits++ + } + + return splits +} + +func (l *pkLayout) pending() bool { return l.PendingTarget > len(l.Spans) } + +// gone reports whether id once named a partition that has since split. +func (l *pkLayout) gone(id int) bool { + _, active := l.byID(id) + + return !active && id >= 0 && id < l.NextID +} + +func (l *pkLayout) etag() string { return `"` + strconv.Itoa(l.NextID) + `"` } + +func hashBound(h u128) string { return h.String() } // pkRangeBody is one partition key range document. -func pkRangeBody(containerRID, dbID, collID string, i, n int) map[string]any { +func (l *pkLayout) pkRangeBody(containerRID, dbID, collID string, i int) map[string]any { + s := l.Spans[i] + minB, maxB := "", pkRangeMax + + if i > 0 { + minB = hashBound(s.Lo) + } + + if hi, open := l.upper(i); !open { + maxB = hashBound(hi) + } + + parents := make([]string, len(s.Parents)) + for j, p := range s.Parents { + parents[j] = strconv.Itoa(p) + } + return map[string]any{ - "id": strconv.Itoa(i), sysPropRID: containerRID + rangeRIDSuffix(i), sysPropETag: pkRangeETagFor(n), - "minInclusive": rangeBound(i, n), "maxExclusive": rangeBound(i+1, n), "ridPrefix": i, - sysPropSelf: "dbs/" + dbID + "/colls/" + collID + "/pkranges/" + strconv.Itoa(i) + "/", - "throughputFraction": 1 / float64(n), "status": "online", "parents": []string{}, "lsn": 1, + "id": strconv.Itoa(s.ID), sysPropRID: containerRID + rangeRIDSuffix(s.ID), sysPropETag: l.etag(), + "minInclusive": minB, "maxExclusive": maxB, "ridPrefix": s.ID, + sysPropSelf: "dbs/" + dbID + "/colls/" + collID + "/pkranges/" + strconv.Itoa(s.ID) + "/", + "throughputFraction": 1 / float64(len(l.Spans)), "status": "online", "parents": parents, "lsn": 1, + } +} + +// hashRange is the hash interval of one partition: [lo, hi), or [lo, top] when open. +type hashRange struct { + lo, hi u128 + open bool + set bool +} + +func (r hashRange) contains(pkJSON string) bool { + if !r.set { + return true } + + h := epkHash(pkJSON) + + return h.cmp(r.lo) >= 0 && (r.open || h.cmp(r.hi) < 0) } -// partitionIndexes lists the physical partitions a request touches: the key's owner, or all for a cross-partition +func (l *pkLayout) rangeOf(i int) hashRange { + hi, open := l.upper(i) + + return hashRange{lo: l.Spans[i].Lo, hi: hi, open: open, set: true} +} + +// partitionSlots lists the physical partitions a request touches: the key's owner, or all for a cross-partition // request. -func partitionIndexes(r *http.Request, n int) []int { - if n > 1 { +func partitionSlots(r *http.Request, spans []pkSpan) []pkSpan { + l := pkLayout{Spans: spans} + + if len(spans) > 1 { if pk, err := queryPartitionScope(r); err == nil && pk != "" { - return []int{rangeIndex(pk, n)} + return []pkSpan{l.owner(pk)} } } - out := make([]int, n) - for i := range out { - out[i] = i - } - - return out + return spans } func (h *Handler) slotsFor(r *http.Request, bud RUBudget) []meterSlot { - n := max(1, bud.Partitions) - rate := bud.Rate / float64(n) - idx := partitionIndexes(r, n) - slots := make([]meterSlot, len(idx)) + spans := bud.Ranges + if len(spans) == 0 { + spans = []pkSpan{{}} + } - for i, p := range idx { - slots[i] = meterSlot{key: bud.Key + "#" + strconv.Itoa(p), rate: rate, burst: h.BurstCapacity} + rate := bud.Rate / float64(len(spans)) + touched := partitionSlots(r, spans) + slots := make([]meterSlot, len(touched)) + + for i, p := range touched { + slots[i] = meterSlot{ + key: bud.Key + "#" + strconv.Itoa(p.ID), rate: rate, burst: h.BurstCapacity, weight: 1, + } } return slots diff --git a/services/cosmosdb/partitions_test.go b/services/cosmosdb/partitions_test.go index cf12a949b2..0fd22d3300 100644 --- a/services/cosmosdb/partitions_test.go +++ b/services/cosmosdb/partitions_test.go @@ -19,9 +19,11 @@ const padKB = 100 func quoted(pk string) string { return strconv.Quote(pk) } // pkInRange finds a partition key value owned by range idx of n. -func pkInRange(t *testing.T, idx, n int) string { +func pkInRange(t *testing.T, idx int) string { t.Helper() + const n = 2 + for i := range 1000 { pk := fmt.Sprintf("k%d", i) if cosmosdb.RangeIndex(quoted(pk), n) == idx { @@ -57,7 +59,7 @@ func (s *throughputServer) spike(coll, pk string, limit int) int { return limit } -func (s *throughputServer) query(coll, pk string) rawResponse { +func (s *throughputServer) query(pk string) rawResponse { s.t.Helper() h := map[string]string{"X-Ms-Documentdb-Isquery": "true", "Content-Type": "application/query+json"} @@ -65,7 +67,7 @@ func (s *throughputServer) query(coll, pk string) rawResponse { h["X-Ms-Documentdb-Partitionkey"] = "[" + quoted(pk) + "]" } - return s.do(http.MethodPost, "/dbs/d/colls/"+coll+"/docs", h, `{"query":"SELECT * FROM c"}`) + return s.do(http.MethodPost, "/dbs/d/colls/c/docs", h, `{"query":"SELECT * FROM c"}`) } func TestPartitions_HotKeyThrottlesWhileTotalUnderBudget(t *testing.T) { @@ -90,13 +92,13 @@ func TestPartitions_HotKeyThrottlesWhileTotalUnderBudget(t *testing.T) { assert.Equal(s.t, http.StatusCreated, s.putPK("c", hot, padKB).status) }}, {name: "cross partition query touches hot", check: func(s *throughputServer, _, _ string) { - assert.Equal(s.t, http.StatusTooManyRequests, s.query("c", "").status) + assert.Equal(s.t, http.StatusTooManyRequests, s.query("").status) }}, {name: "scoped query on cold passes", check: func(s *throughputServer, _, cold string) { - assert.Equal(s.t, http.StatusOK, s.query("c", cold).status) + assert.Equal(s.t, http.StatusOK, s.query(cold).status) }}, {name: "scoped query on hot throttles", check: func(s *throughputServer, hot, _ string) { - assert.Equal(s.t, http.StatusTooManyRequests, s.query("c", hot).status) + assert.Equal(s.t, http.StatusTooManyRequests, s.query(hot).status) }}, } @@ -108,13 +110,13 @@ func TestPartitions_HotKeyThrottlesWhileTotalUnderBudget(t *testing.T) { s.mustCreate("/dbs", `{"id":"d"}`, nil) s.container("c", manualHeader(10100)) - hot, cold := pkInRange(t, 0, 2), pkInRange(t, 1, 2) + hot, cold := pkInRange(t, 0), pkInRange(t, 1) admitted := s.spike("c", hot, 500) require.Less(t, admitted, 500, "hot partition must throttle") require.Positive(t, admitted) - // Each admitted write costs ~78 RU, so the hot partition spent about half the 10100 RU/s offer. - assert.Less(t, float64(admitted)*78, 10100.0) + // Each admitted write costs ~56 RU, so the hot partition spent about half the 10100 RU/s offer. + assert.Less(t, float64(admitted)*56, 10100.0) tt.check(s, hot, cold) }) @@ -133,7 +135,7 @@ func TestPartitions_BurstCapacity(t *testing.T) { }{ {name: "burst off", burst: false, idle: 100 * time.Second, minimum: 5, maximum: 20}, {name: "burst on without idle time", burst: true, minimum: 5, maximum: 20}, - {name: "burst on after idle", burst: true, idle: 100 * time.Second, minimum: 30, maximum: 50}, + {name: "burst on after idle", burst: true, idle: 100 * time.Second, minimum: 40, maximum: 70}, } for _, tt := range tests { @@ -193,9 +195,8 @@ func assertRangesTile(t *testing.T, s *throughputServer, want int) { prevMax := "" - for i, r := range ranges { + for _, r := range ranges { m := rowObj(t, r) - assert.Equal(t, strconv.Itoa(i), m["id"]) assert.Equal(t, prevMax, m["minInclusive"]) prevMax, _ = m["maxExclusive"].(string) diff --git a/services/cosmosdb/permission_ops.go b/services/cosmosdb/permission_ops.go index 55ca28e1b7..e53e86398c 100644 --- a/services/cosmosdb/permission_ops.go +++ b/services/cosmosdb/permission_ops.go @@ -60,7 +60,7 @@ func (h *Handler) readPermissionSpec(c *echo.Context) (PermissionSpec, bool) { } func (h *Handler) createPermission(c *echo.Context, dbID, userID string) error { - ttl, err := tokenExpiry(c.Request()) + ttl, err := h.tokenExpiry(c.Request()) if err != nil { return h.writeUserError(c, err) } @@ -79,7 +79,7 @@ func (h *Handler) createPermission(c *echo.Context, dbID, userID string) error { } func (h *Handler) getPermission(c *echo.Context, dbID, userID, permID string) error { - ttl, err := tokenExpiry(c.Request()) + ttl, err := h.tokenExpiry(c.Request()) if err != nil { return h.writeUserError(c, err) } @@ -93,7 +93,7 @@ func (h *Handler) getPermission(c *echo.Context, dbID, userID, permID string) er } func (h *Handler) replacePermission(c *echo.Context, dbID, userID, permID string) error { - ttl, err := tokenExpiry(c.Request()) + ttl, err := h.tokenExpiry(c.Request()) if err != nil { return h.writeUserError(c, err) } @@ -131,7 +131,7 @@ func (h *Handler) writePermission(c *echo.Context, status int, info PermissionIn } func (h *Handler) permissionBodies(c *echo.Context, dbID, userID string) ([]map[string]any, error) { - ttl, err := tokenExpiry(c.Request()) + ttl, err := h.tokenExpiry(c.Request()) if err != nil { return nil, err } diff --git a/services/cosmosdb/persistence.go b/services/cosmosdb/persistence.go index d529349486..749e1ee803 100644 --- a/services/cosmosdb/persistence.go +++ b/services/cosmosdb/persistence.go @@ -62,6 +62,8 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { return err } + defer b.notifyRegions() + b.mu.Lock("Restore") defer b.mu.Unlock() @@ -92,12 +94,15 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { } b.databases = snap.Databases + b.recomputeNextDue() b.regions = AccountRegions{} if snap.Regions != nil { b.regions = snap.Regions.normalized() } + b.reconcileRepl() + return nil } diff --git a/services/cosmosdb/provider.go b/services/cosmosdb/provider.go index 394a7d3e4d..c8fa28900e 100644 --- a/services/cosmosdb/provider.go +++ b/services/cosmosdb/provider.go @@ -2,6 +2,7 @@ package cosmosdb import ( "errors" + "fmt" "os" "strings" @@ -61,12 +62,18 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { settings = cp.GetCosmosDBSettings() } + m := settings.ResourceTokenMaxSeconds + if m != 0 && (m < MinResourceTokenMaxSeconds || m > MaxResourceTokenMaxSeconds) { + return nil, fmt.Errorf("%w: got %d", ErrInvalidResourceTokenMax, m) + } + backend := NewInMemoryBackend() backend.SetServerless(settings.Serverless) handler := NewHandler(backend) handler.Port = settings.Port handler.MasterKey = settings.MasterKey handler.ValidateAuth = settings.ValidateAuth + handler.ResourceTokenMaxSeconds = settings.ResourceTokenMaxSeconds handler.EnforceThroughput = settings.EnforceThroughput handler.BurstCapacity = settings.BurstCapacity handler.TLS = os.Getenv(envTLS) == trueText diff --git a/services/cosmosdb/provider_test.go b/services/cosmosdb/provider_test.go index 1975c2c438..9c66d32120 100644 --- a/services/cosmosdb/provider_test.go +++ b/services/cosmosdb/provider_test.go @@ -67,3 +67,39 @@ func TestDefaultSettings(t *testing.T) { assert.Equal(t, cosmosdb.DefaultMasterKey, s.MasterKey) assert.False(t, s.ValidateAuth) } + +func TestProvider_Init_ResourceTokenMax(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + max int + wantErr bool + }{ + {"unset", 0, false}, + {"legacy cap", 18000, false}, + {"upper bound", 86400, false}, + {"below minimum", 599, true}, + {"above maximum", 86401, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := &cosmosdb.Provider{} + reg, err := p.Init(&service.AppContext{ + Config: fakeConfigProvider{settings: cosmosdb.Settings{ResourceTokenMaxSeconds: tc.max}}, + }) + + if tc.wantErr { + require.ErrorIs(t, err, cosmosdb.ErrInvalidResourceTokenMax) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.max, reg.(*cosmosdb.Handler).ResourceTokenMaxSeconds) + }) + } +} diff --git a/services/cosmosdb/query_rows.go b/services/cosmosdb/query_rows.go index 369001baa8..cf294c6b9b 100644 --- a/services/cosmosdb/query_rows.go +++ b/services/cosmosdb/query_rows.go @@ -47,13 +47,14 @@ func (r sqlRow) lookup(seg string) (any, bool) { } } - v, ok := r.m[seg] - if !ok && r.src != nil { + if r.src != nil { if expr, isComputed := r.src.computed[seg]; isComputed { return r.computedValue(expr), true } } + v, ok := r.m[seg] + if i, isInt := v.(int64); isInt { return json.Number(strconv.FormatInt(i, 10)), true } @@ -151,7 +152,7 @@ func (b *InMemoryBackend) RunQuery( b.mu.RLock("QueryDocuments") defer b.mu.RUnlock() - c, err := b.findContainer(dbID, containerID) + c, err := b.findDataContainer(dbID, containerID, true) if err != nil { return QueryResult{}, err } @@ -166,7 +167,7 @@ func (b *InMemoryBackend) RunQueryOpts( b.mu.RLock("QueryDocuments") defer b.mu.RUnlock() - c, err := b.findContainer(dbID, containerID) + c, err := b.findDataContainer(dbID, containerID, true) if err != nil { return QueryResult{}, err } @@ -176,7 +177,7 @@ func (b *InMemoryBackend) RunQueryOpts( return QueryResult{}, err } - if idxErr := checkQueryIndexing(stmt, c.IndexingPolicy, opts); idxErr != nil { + if idxErr := checkQueryIndexing(stmt, c.IndexingPolicy, opts, b.computedExprs(c)); idxErr != nil { return QueryResult{}, idxErr } @@ -212,5 +213,25 @@ func (b *InMemoryBackend) queryLocked( udf := b.newUDFRuntime(c.UDFs) defer udf.close() - return executeStatementWith(stmt, params, rows, udf, b.newQueryColl(c)) + res, err := executeStatementWith(stmt, params, rows, udf, b.newQueryColl(c)) + res.Loaded = loadedCharge(stmt, c.IndexingPolicy, res) + + return res, err +} + +// loadedCharge sums the read cost of the documents the query had to retrieve: those its filter kept when the +// indexing policy can serve the filter, every examined document otherwise (or when there is no filter). +func loadedCharge(stmt *sqlSelectStmt, indexing json.RawMessage, res QueryResult) float64 { + docs := res.Matched + if stmt.where == nil || parseIndexingPolicy(indexing).needsScan(stmt.where) { + docs = res.Visited + } + + var total float64 + + for _, d := range docs { + total += readCharge(int(docBytes(d))) + } + + return total } diff --git a/services/cosmosdb/region_backend.go b/services/cosmosdb/region_backend.go new file mode 100644 index 0000000000..a298e69657 --- /dev/null +++ b/services/cosmosdb/region_backend.go @@ -0,0 +1,392 @@ +package cosmosdb + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +// ErrReadSessionNotAvailable is returned when a read names a session token the region has not caught up with +// (HTTP 404, substatus 1002). +var ErrReadSessionNotAvailable = errors.New("cosmosdb: read session is not available for the input session token") + +// sessionTokenMinParts is the segments of a token with no per-region positions: "0:-1#". +const sessionTokenMinParts = 2 + +// regionCall is the region a data-plane request runs in. The zero value is the unreplicated account. +type regionCall struct { + token map[string]int64 + region string + level string + hasToken bool +} + +func (b *InMemoryBackend) notifyRegions() { + if b.regionsChanged != nil { + b.regionsChanged() + } +} + +func (b *InMemoryBackend) setRegionsLocked(next AccountRegions) { + b.regions = next + b.reconcileRepl() + b.budgetEpoch++ +} + +// findDataContainer finds the container as the current request's region sees it. read adds the session check. +func (b *InMemoryBackend) findDataContainer(dbID, containerID string, read bool) (*storedContainer, error) { + c, err := b.findContainer(dbID, containerID) + if err != nil { + return nil, err + } + + rc := b.cur.Load() + if rc == nil || c.repl == nil { + return c, nil + } + + r := c.repl + if read && rc.level == ConsistencySession && rc.hasToken && !r.satisfies(rc.region, rc.token) { + return nil, ErrReadSessionNotAvailable + } + + dest := r.container(c, rc.region) + if dest != c { + copyConfig(dest, c) + } + + return dest, nil +} + +// satisfies reports whether the region has applied every write the token names. +func (r *replState) satisfies(region string, token map[string]int64) bool { + have := r.appliedFor(region) + + for origin, lsn := range token { + if have[origin] < lsn { + return false + } + } + + return true +} + +// sessionToken renders the region's progress as an x-ms-session-token value: the highest sequence number it holds +// and the per-origin positions (ids are the regions' 1-based order by name). +func (b *InMemoryBackend) sessionToken(c *storedContainer, region string) string { + regs := b.regionsView() + have := c.repl.appliedFor(region) + + var top int64 + + parts := make([]string, 0, len(have)) + + for _, reg := range regs.Regions { + if lsn := have[reg.Name]; lsn > 0 { + top = max(top, lsn) + parts = append(parts, strconv.Itoa(regs.regionID(reg.Name))+"="+strconv.FormatInt(lsn, 10)) + } + } + + return "0:-1#" + strconv.FormatInt(top, 10) + joinHash(parts) +} + +func joinHash(parts []string) string { + if len(parts) == 0 { + return "" + } + + return "#" + strings.Join(parts, "#") +} + +// parseSessionToken reads the per-origin positions of a session token; an unreadable token names none. +func (b *InMemoryBackend) parseSessionToken(raw string) (map[string]int64, bool) { + segs := strings.Split(raw, "#") + if len(segs) < sessionTokenMinParts { + return nil, false + } + + regs := b.regionsView() + out := map[string]int64{} + + for _, seg := range segs[2:] { + id, lsn, ok := strings.Cut(seg, "=") + n, idErr := strconv.Atoi(id) + v, lsnErr := strconv.ParseInt(lsn, 10, 64) + + if !ok || idErr != nil || lsnErr != nil { + continue + } + + for _, reg := range regs.Regions { + if regs.regionID(reg.Name) == n { + out[reg.Name] = v + } + } + } + + return out, true +} + +// pumpDue delivers every write whose replication lag has elapsed. +func (b *InMemoryBackend) pumpDue() { + b.mu.Lock("pumpDue") + defer b.mu.Unlock() + + regs := b.regionsView() + if !regs.replicated() { + return + } + + for _, d := range b.databases { + for _, c := range d.Containers { + if c.repl == nil { + b.reconcileContainer(d.ID, c, regs) + } + + b.deliver(d.ID, c, regs, false) + } + } +} + +// SweepReplication delivers due replicated writes; the janitor calls it so regions converge without traffic. +func (b *InMemoryBackend) SweepReplication() int { + b.regionGate.Lock("SweepReplication") + defer b.regionGate.Unlock() + + before := b.pendingEvents() + b.pumpDue() + + return max(0, before-b.pendingEvents()) +} + +func (b *InMemoryBackend) pendingEvents() int { + b.mu.RLock("pendingEvents") + defer b.mu.RUnlock() + + n := 0 + + for _, d := range b.databases { + for _, c := range d.Containers { + if c.repl != nil { + n += len(c.repl.events) + } + } + } + + return n +} + +// regionBackend runs the data-plane calls of one request in one region. Calls are serialised on the backend's +// region gate because the region is request state the backend methods read. +type regionBackend struct { + *InMemoryBackend + setToken func(string) + call regionCall +} + +func gated[T any](r *regionBackend, dbID, collID string, fn func() (T, error)) (T, error) { + b := r.InMemoryBackend + + b.regionGate.Lock("regionCall") + defer b.regionGate.Unlock() + + rc := r.call + b.cur.Store(&rc) + + defer b.cur.Store(nil) + + b.pumpDue() + + out, err := fn() + + b.pumpDue() + r.stampToken(dbID, collID) + + return out, err +} + +func (r *regionBackend) stampToken(dbID, collID string) { + if r.setToken == nil { + return + } + + b := r.InMemoryBackend + + b.mu.RLock("stampToken") + defer b.mu.RUnlock() + + if c, err := b.findContainer(dbID, collID); err == nil && c.repl != nil { + r.setToken(b.sessionToken(c, r.call.region)) + } +} + +func (r *regionBackend) CreateDocument(dbID, collID string, body map[string]any, upsert bool) (DocumentInfo, error) { + return gated(r, dbID, collID, func() (DocumentInfo, error) { + return r.InMemoryBackend.CreateDocument(dbID, collID, body, upsert) + }) +} + +func (r *regionBackend) GetDocument(dbID, collID, pk, id string) (DocumentInfo, error) { + return gated(r, dbID, collID, func() (DocumentInfo, error) { + return r.InMemoryBackend.GetDocument(dbID, collID, pk, id) + }) +} + +func (r *regionBackend) ReplaceDocument( + dbID, collID, pk, id string, body map[string]any, ifMatch string, +) (DocumentInfo, error) { + return gated(r, dbID, collID, func() (DocumentInfo, error) { + return r.InMemoryBackend.ReplaceDocument(dbID, collID, pk, id, body, ifMatch) + }) +} + +func (r *regionBackend) PatchDocument( + dbID, collID, pk, id string, req patchRequest, ifMatch string, +) (DocumentInfo, error) { + return gated(r, dbID, collID, func() (DocumentInfo, error) { + return r.InMemoryBackend.PatchDocument(dbID, collID, pk, id, req, ifMatch) + }) +} + +func (r *regionBackend) DeleteDocument(dbID, collID, pk, id, ifMatch string) error { + _, err := gated(r, dbID, collID, func() (struct{}, error) { + return struct{}{}, r.InMemoryBackend.DeleteDocument(dbID, collID, pk, id, ifMatch) + }) + + return err +} + +func (r *regionBackend) ListDocuments(dbID, collID string) ([]DocumentInfo, error) { + return gated(r, dbID, collID, func() ([]DocumentInfo, error) { + return r.InMemoryBackend.ListDocuments(dbID, collID) + }) +} + +func (r *regionBackend) ListDocumentsPaged( + dbID, collID, token string, limit int, +) (page.Page[DocumentInfo], error) { + return gated(r, dbID, collID, func() (page.Page[DocumentInfo], error) { + return r.InMemoryBackend.ListDocumentsPaged(dbID, collID, token, limit) + }) +} + +func (r *regionBackend) RunQuery( + dbID, collID, query string, params []QueryParameter, pk string, +) (QueryResult, error) { + return gated(r, dbID, collID, func() (QueryResult, error) { + return r.InMemoryBackend.RunQuery(dbID, collID, query, params, pk) + }) +} + +func (r *regionBackend) RunQueryOpts( + dbID, collID, query string, params []QueryParameter, pk string, opts QueryOptions, +) (QueryResult, error) { + return gated(r, dbID, collID, func() (QueryResult, error) { + return r.InMemoryBackend.RunQueryOpts(dbID, collID, query, params, pk, opts) + }) +} + +func (r *regionBackend) ExecuteBatch(dbID, collID, pk string, ops []batchOp) ([]BatchResult, bool, error) { + var failed bool + + res, err := gated(r, dbID, collID, func() ([]BatchResult, error) { + out, f, e := r.InMemoryBackend.ExecuteBatch(dbID, collID, pk, ops) + failed = f + + return out, e + }) + + return res, failed, err +} + +func (r *regionBackend) ExecuteSproc(dbID, collID, id, pk string, args []any) (ScriptOutput, error) { + return gated(r, dbID, collID, func() (ScriptOutput, error) { + return r.InMemoryBackend.ExecuteSproc(dbID, collID, id, pk, args) + }) +} + +func (r *regionBackend) runTriggered(dbID, collID string, op docOp) (docOpResult, error) { + return gated(r, dbID, collID, func() (docOpResult, error) { + return r.InMemoryBackend.runTriggered(dbID, collID, op) + }) +} + +func (r *regionBackend) readChangeFeed(dbID, collID string, req changeFeedRequest) (changeFeedPage, error) { + return gated(r, dbID, collID, func() (changeFeedPage, error) { + return r.InMemoryBackend.readChangeFeed(dbID, collID, req) + }) +} + +func (r *regionBackend) ListConflicts(dbID, collID string) ([]ConflictInfo, error) { + return gated(r, dbID, collID, func() ([]ConflictInfo, error) { + return r.InMemoryBackend.ListConflicts(dbID, collID, r.call.region) + }) +} + +func (r *regionBackend) GetConflict(dbID, collID, id string) (ConflictInfo, error) { + return gated(r, dbID, collID, func() (ConflictInfo, error) { + return r.InMemoryBackend.GetConflict(dbID, collID, r.call.region, id) + }) +} + +func (r *regionBackend) DeleteConflict(dbID, collID, id string) error { + _, err := gated(r, dbID, collID, func() (struct{}, error) { + return struct{}{}, r.InMemoryBackend.DeleteConflict(dbID, collID, r.call.region, id) + }) + + return err +} + +// consistencyRank orders the levels strongest first. +func consistencyRank(level string) int { + for i, l := range []string{ + ConsistencyStrong, ConsistencyBoundedStaleness, ConsistencySession, ConsistencyConsistentPrefix, + ConsistencyEventual, + } { + if strings.EqualFold(l, level) { + return i + } + } + + return -1 +} + +func canonicalConsistency(level string) string { + for _, l := range []string{ + ConsistencyStrong, ConsistencyBoundedStaleness, ConsistencySession, ConsistencyConsistentPrefix, + ConsistencyEventual, + } { + if strings.EqualFold(l, level) { + return l + } + } + + return "" +} + +// checkRequestConsistency validates the x-ms-consistency-level override: a request can only weaken the account +// level. +func checkRequestConsistency(requested string, account ConsistencyPolicy) (string, error) { + if requested == "" { + return account.Level, nil + } + + canon := canonicalConsistency(requested) + if canon == "" { + return "", fmt.Errorf("%w: unknown consistency level %q", ErrInvalidConsistency, requested) + } + + if consistencyRank(canon) < consistencyRank(account.Level) { + return "", fmt.Errorf("%w: %s is stronger than the account default %s", ErrInvalidConsistency, canon, + account.Level) + } + + return canon, nil +} + +// ErrInvalidConsistency is returned for an x-ms-consistency-level the account cannot honour. +var ErrInvalidConsistency = errors.New("cosmosdb: invalid consistency level") diff --git a/services/cosmosdb/region_http.go b/services/cosmosdb/region_http.go new file mode 100644 index 0000000000..969094bded --- /dev/null +++ b/services/cosmosdb/region_http.go @@ -0,0 +1,610 @@ +package cosmosdb + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + adminPrefix = "/_gopherstack/cosmosdb/" + headerConsistency = "X-Ms-Consistency-Level" + headerSession = "X-Ms-Session-Token" + subStatusWriteDenied = "3" + subStatusAccountGone = "1008" + subStatusSessionGone = "1002" + opListConflicts = "ListConflicts" + opGetConflict = "GetConflict" + opDeleteConflict = "DeleteConflict" + opQueryConflicts = "QueryConflicts" + conflictsSegment = "conflicts" + resourceTypeDocument = "document" + adminRegionsPath = adminPrefix + "regions" + minStalenessPrefix1 = 10 + minStalenessSecs1 = 5 + minStalenessPrefixN = 100_000 + minStalenessSecsN = 300 +) + +type regionCtxKey struct{} + +// regionOfRequest is the region whose endpoint received the request; "" for the account's global endpoint. +func regionOfRequest(r *http.Request) string { + name, _ := r.Context().Value(regionCtxKey{}).(string) + + return name +} + +// regionListener is the dedicated endpoint of one region. +type regionListener struct { + srv *http.Server + port int +} + +func isWriteRequest(r *http.Request) bool { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return false + case http.MethodPost: + return !isQueryRequest(r) + } + + return true +} + +// guardRegion applies the per-region rules before dispatch: an offline region answers DatabaseAccountNotFound +// (403/1008), a read region refuses writes (403/3 WriteForbidden), and x-ms-consistency-level may only weaken the +// account default (400). handled is true when an error response has been written. +func (h *Handler) guardRegion(c *echo.Context) (bool, error) { + r := c.Request() + regs := h.accountRegions() + + if _, err := checkRequestConsistency( + canonicalConsistency(r.Header.Get(headerConsistency)), + regs.consistency(), + ); err != nil || + (r.Header.Get(headerConsistency) != "" && canonicalConsistency(r.Header.Get(headerConsistency)) == "") { + if err == nil { + err = fmt.Errorf("%w: unknown consistency level %q", ErrInvalidConsistency, r.Header.Get(headerConsistency)) + } + + return true, h.writeError(c, http.StatusBadRequest, "BadRequest", err.Error()) + } + + name := regionOfRequest(r) + if name == "" { + name = regs.home() + } + + reg, _, ok := regs.find(name) + if !ok { + return false, nil + } + + if reg.Offline { + c.Response().Header().Set(headerSubStatus, subStatusAccountGone) + + return true, h.writeError(c, http.StatusForbidden, "Forbidden", + "The database account is not available in region "+reg.Name+".") + } + + if isWriteRequest(r) && !slices.Contains(regs.writers(), reg.Name) { + c.Response().Header().Set(headerSubStatus, subStatusWriteDenied) + + return true, h.writeError( + c, + http.StatusForbidden, + "Forbidden", + "The requested operation cannot be performed in this region: writes go to the write region "+regs.home()+".", + ) + } + + return false, nil +} + +// be returns the storage backend for the request: the plain backend, or one bound to the request's region when the +// account keeps separate copies per region. +func (h *Handler) be(c *echo.Context) StorageBackend { + b, ok := h.Backend.(*InMemoryBackend) + if !ok { + return h.Backend + } + + regs := b.AccountRegions() + if !regs.replicated() { + return h.Backend + } + + r := c.Request() + + region := regionOfRequest(r) + if region == "" { + region = regs.home() + } else if reg, _, found := regs.find(region); found { + region = reg.Name + } + + level, err := checkRequestConsistency(canonicalConsistency(r.Header.Get(headerConsistency)), regs.consistency()) + if err != nil { + level = regs.consistency().Level + } + + rb := ®ionBackend{ + InMemoryBackend: b, + call: regionCall{region: region, level: level}, + setToken: func(t string) { c.Response().Header().Set(headerSession, t) }, + } + + if tok := r.Header.Get(headerSession); tok != "" { + rb.call.token, rb.call.hasToken = b.parseSessionToken(tok) + } + + return rb +} + +// --- listeners --- + +// syncRegionListeners gives each region of a multi-region account its own endpoint and stops the listeners of +// regions that are gone. +func (h *Handler) syncRegionListeners() { + h.srvMu.Lock("syncRegionListeners") + defer h.srvMu.Unlock() + + if h.srv == nil { + return + } + + regs := h.accountRegions() + want := map[string]bool{} + + if len(regs.Regions) > 1 { + for _, reg := range regs.Regions { + want[reg.Name] = true + } + } + + for name, l := range h.regionSrvs { + if !want[name] { + _ = l.srv.Close() + + delete(h.regionSrvs, name) + } + } + + for i, reg := range regs.Regions { + if !want[reg.Name] || h.regionSrvs[reg.Name] != nil { + continue + } + + if l, err := h.startRegionListener(reg.Name, i); err != nil { + logger.Load(h.workerCtx).ErrorContext(h.workerCtx, "cosmosdb: region listener failed", "region", reg.Name, + "error", err) + } else { + h.regionSrvs[reg.Name] = l + } + } +} + +func (h *Handler) startRegionListener(name string, index int) (*regionListener, error) { + port := 0 + if h.RegionPortBase > 0 { + port = h.RegionPortBase + index + } + + var lc net.ListenConfig + + ln, err := lc.Listen(h.workerCtx, "tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return nil, fmt.Errorf("cosmosdb: bind region %s: %w", name, err) + } + + if h.TLS && h.tlsCert != nil { + ln = tls.NewListener(ln, &tls.Config{ + Certificates: []tls.Certificate{*h.tlsCert}, MinVersion: tls.VersionTLS12, NextProtos: []string{"http/1.1"}, + }) + } + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(h.workerCtx))) + e.Any("/*", telemetry.WrapEchoHandler("CosmosDB", h.regionHandler(name), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: cosmosdbReadHeaderTimeout, ReadTimeout: cosmosdbReadTimeout, + WriteTimeout: cosmosdbWriteTimeout, IdleTimeout: cosmosdbIdleTimeout, + } + + go func() { _ = srv.Serve(ln) }() + + p := 0 + if addr, ok := ln.Addr().(*net.TCPAddr); ok { + p = addr.Port + } + + return ®ionListener{srv: srv, port: p}, nil +} + +// regionHandler serves a region's endpoint: the shared handler with the region attached to every request. +func (h *Handler) regionHandler(name string) echo.HandlerFunc { + inner := h.Handler() + + return func(c *echo.Context) error { + r := c.Request() + c.SetRequest(r.WithContext(context.WithValue(r.Context(), regionCtxKey{}, name))) + + return inner(c) + } +} + +// regionPort is the port of a region's endpoint, 0 when it has none. +func (h *Handler) regionPort(name string) int { + h.srvMu.RLock("regionPort") + defer h.srvMu.RUnlock() + + if l := h.regionSrvs[name]; l != nil { + return l.port + } + + return 0 +} + +func (h *Handler) stopRegionListeners() { + h.srvMu.Lock("stopRegionListeners") + srvs := h.regionSrvs + h.regionSrvs = map[string]*regionListener{} + h.srvMu.Unlock() + + for _, l := range srvs { + _ = l.srv.Close() + } +} + +// regionEndpoint is the URL of a region's own endpoint; the request's own host stands in when it has none. +func (h *Handler) regionEndpoint(name, scheme, host string) string { + p := h.regionPort(name) + if p == 0 { + return scheme + "://" + host + "/" + } + + hostname := host + if hn, _, err := net.SplitHostPort(host); err == nil { + hostname = hn + } + + return scheme + "://" + net.JoinHostPort(hostname, strconv.Itoa(p)) + "/" +} + +// locationEntries renders regions as the root resource's location list, each with its own endpoint. +func (h *Handler) locationEntries(regions []AccountRegion, scheme, host string) []map[string]any { + out := make([]map[string]any, 0, len(regions)) + + for _, r := range regions { + out = append(out, map[string]any{ + "name": r.Name, "databaseAccountEndpoint": h.regionEndpoint(r.Name, scheme, host), + }) + } + + return out +} + +// --- admin API --- + +type adminRegion struct { + Name string `json:"name"` + Endpoint string `json:"endpoint,omitempty"` + FailoverPriority int `json:"failoverPriority"` + ReplicationLagMs int `json:"replicationLagMs"` + Offline bool `json:"offline"` + Writable bool `json:"writable"` +} + +type adminTopology struct { + Consistency *ConsistencyPolicy `json:"consistency,omitempty"` + WriteRegion string `json:"writeRegion,omitempty"` + Regions []adminRegion `json:"regions"` + MultiWrite bool `json:"multiWrite"` + AutomaticFailover bool `json:"automaticFailover"` +} + +func (h *Handler) adminBody(r *http.Request) adminTopology { + regs := h.accountRegions() + pol := regs.consistency() + out := adminTopology{ + MultiWrite: regs.MultiWrite, AutomaticFailover: !regs.NoAutoFailover, WriteRegion: regs.home(), + Consistency: &pol, Regions: make([]adminRegion, 0, len(regs.Regions)), + } + + scheme := "http" + if h.TLS { + scheme = "https" + } + + writers := regs.writers() + + for _, reg := range regs.Regions { + out.Regions = append(out.Regions, adminRegion{ + Name: reg.Name, FailoverPriority: reg.FailoverPriority, ReplicationLagMs: reg.ReplicationLagMs, + Offline: reg.Offline, Writable: slices.Contains(writers, reg.Name), + Endpoint: h.regionEndpoint(reg.Name, scheme, r.Host), + }) + } + + return out +} + +// handleAdmin serves the unauthenticated emulator control endpoints under /_gopherstack/cosmosdb/regions: +// +// GET regions the topology +// PUT regions replace it (regions, lag, consistency, multiWrite, automaticFailover) +// POST regions/{name}/offline take a region down; the write region fails over by failover priority +// POST regions/{name}/online bring it back +func (h *Handler) handleAdmin(c *echo.Context) error { + r := c.Request() + path := strings.TrimSuffix(r.URL.Path, "/") + + if path == adminRegionsPath { + switch r.Method { + case http.MethodGet: + return h.writeJSON(c, http.StatusOK, h.adminBody(r)) + case http.MethodPut: + return h.putAdminTopology(c) + } + + return h.methodNotAllowed(c) + } + + rest, ok := strings.CutPrefix(path, adminRegionsPath+"/") + if !ok || r.Method != http.MethodPost { + return h.writeError(c, http.StatusNotFound, "NotFound", "Unknown admin endpoint.") + } + + name, action, _ := strings.Cut(rest, "/") + if action != "offline" && action != "online" { + return h.writeError(c, http.StatusNotFound, "NotFound", "Unknown admin endpoint.") + } + + b, isMem := h.Backend.(*InMemoryBackend) + if !isMem { + return h.writeError(c, http.StatusNotImplemented, "NotImplemented", "The backend has no regions.") + } + + if err := b.SetRegionOffline(name, action == "offline"); err != nil { + return h.writeError(c, http.StatusNotFound, "NotFound", "Unknown region "+name+".") + } + + return h.writeJSON(c, http.StatusOK, h.adminBody(r)) +} + +func (h *Handler) putAdminTopology(c *echo.Context) error { + b, ok := h.Backend.(*InMemoryBackend) + if !ok { + return h.writeError(c, http.StatusNotImplemented, "NotImplemented", "The backend has no regions.") + } + + raw, err := httputils.ReadBody(c.Request()) + if err != nil { + return h.writeError(c, http.StatusInternalServerError, "InternalError", "Failed to read request body.") + } + + var req adminTopology + if jsonErr := json.Unmarshal(raw, &req); jsonErr != nil { + return h.writeError(c, http.StatusBadRequest, "BadRequest", "The input is not valid JSON.") + } + + next := AccountRegions{ + MultiWrite: req.MultiWrite, + NoAutoFailover: !req.AutomaticFailover, + Consistency: req.Consistency, + } + for _, r := range req.Regions { + next.Regions = append(next.Regions, AccountRegion{ + Name: r.Name, + FailoverPriority: r.FailoverPriority, + ReplicationLagMs: r.ReplicationLagMs, + Offline: r.Offline, + }) + } + + next = next.normalized() + if vErr := validateTopology(next); vErr != nil { + return h.writeError(c, http.StatusBadRequest, "BadRequest", vErr.Error()) + } + + b.ConfigureAccountRegions(next) + + return h.writeJSON(c, http.StatusOK, h.adminBody(c.Request())) +} + +// validateTopology applies the account rules: known consistency level, no Strong with multiple write regions +// (Learn, "Consistency level choices"), bounded staleness no tighter than the documented minimums. +func validateTopology(r AccountRegions) error { + for _, reg := range r.Regions { + if reg.ReplicationLagMs < 0 { + return fmt.Errorf("%w: replicationLagMs of %s is negative", ErrInvalidConsistency, reg.Name) + } + } + + if r.Consistency == nil { + return nil + } + + level := canonicalConsistency(r.Consistency.Level) + if level == "" { + return fmt.Errorf("%w: unknown consistency level %q", ErrInvalidConsistency, r.Consistency.Level) + } + + r.Consistency.Level = level + + if level == ConsistencyStrong && r.MultiWrite { + return fmt.Errorf("%w: Strong consistency is not available with multiple write regions", ErrInvalidConsistency) + } + + if level != ConsistencyBoundedStaleness { + return nil + } + + minK, minT := int64(minStalenessPrefix1), minStalenessSecs1 + if len(r.Regions) > 1 { + minK, minT = minStalenessPrefixN, minStalenessSecsN + } + + if (r.Consistency.MaxStalenessPrefix != 0 && r.Consistency.MaxStalenessPrefix < minK) || + (r.Consistency.MaxIntervalInSeconds != 0 && r.Consistency.MaxIntervalInSeconds < minT) { + return fmt.Errorf("%w: bounded staleness needs at least %d versions and %d seconds for this account", + ErrInvalidConsistency, minK, minT) + } + + return nil +} + +// --- conflicts feed --- + +type conflictBackend interface { + ListConflicts(dbID, collID string) ([]ConflictInfo, error) + GetConflict(dbID, collID, id string) (ConflictInfo, error) + DeleteConflict(dbID, collID, id string) error +} + +// conflictBodyOf renders a conflicts-feed entry as the service does: operationType, resourceType, the id of the +// conflicting item and its content as a JSON string. +func (h *Handler) conflictBodyOf(info ConflictInfo) map[string]any { + m := map[string]any{ + "id": info.ID, sysPropRID: info.RID, sysPropSelf: info.Self, sysPropTS: info.Time.Unix(), + sysPropETag: resourceETag(info.Time, info.ID), "operationType": info.Op, "resourceType": resourceTypeDocument, + "resourceId": info.DocRID, "conflictLSN": info.LSN, + } + + if info.Content != nil { + raw, _ := jsonMarshal(h.encodeDocument(*info.Content)) + m["content"] = string(raw) + } + + return m +} + +func (h *Handler) conflictsBackend(c *echo.Context) (conflictBackend, bool) { + cb, ok := h.be(c).(conflictBackend) + if ok { + return cb, true + } + + if mem, isMem := h.Backend.(*InMemoryBackend); isMem { + return plainConflicts{mem}, true + } + + return nil, false +} + +type plainConflicts struct{ b *InMemoryBackend } + +func (p plainConflicts) ListConflicts(dbID, collID string) ([]ConflictInfo, error) { + return p.b.ListConflicts(dbID, collID, "") +} + +func (p plainConflicts) GetConflict(dbID, collID, id string) (ConflictInfo, error) { + return p.b.GetConflict(dbID, collID, "", id) +} + +func (p plainConflicts) DeleteConflict(dbID, collID, id string) error { + return p.b.DeleteConflict(dbID, collID, "", id) +} + +func (h *Handler) handleConflicts(c *echo.Context, dbID, collID string) error { + cb, ok := h.conflictsBackend(c) + if !ok { + return h.methodNotAllowed(c) + } + + r := c.Request() + if r.Method != http.MethodGet && (r.Method != http.MethodPost || !isQueryRequest(r)) { + return h.methodNotAllowed(c) + } + + infos, err := cb.ListConflicts(dbID, collID) + if err != nil { + return h.writeDocumentError(c, err) + } + + bodies := make([]map[string]any, 0, len(infos)) + for _, info := range infos { + bodies = append(bodies, h.conflictBodyOf(info)) + } + + if r.Method == http.MethodPost { + return h.queryResources(c, "Conflicts", bodies) + } + + setCharge(c, ruMetadata) + + return h.writeJSON(c, http.StatusOK, map[string]any{ + sysPropRID: "", "Conflicts": bodies, sysPropCount: len(bodies), + }) +} + +func (h *Handler) handleConflictItem(c *echo.Context, dbID, collID, id string) error { + cb, ok := h.conflictsBackend(c) + if !ok { + return h.methodNotAllowed(c) + } + + switch c.Request().Method { + case http.MethodGet: + info, err := cb.GetConflict(dbID, collID, id) + if err != nil { + return h.writeDocumentError(c, err) + } + + setCharge(c, ruMetadata) + + return h.writeJSON(c, http.StatusOK, h.conflictBodyOf(info)) + case http.MethodDelete: + if err := cb.DeleteConflict(dbID, collID, id); err != nil { + return h.writeDocumentError(c, err) + } + + setCharge(c, ruMetadata) + + return c.NoContent(http.StatusNoContent) + } + + return h.methodNotAllowed(c) +} + +func conflictOperationFor(kind resourceKind, r *http.Request) (string, bool) { + if kind == resourceConflicts { + return collectionOperationFor(r, unknownOperation, opListConflicts, opQueryConflicts), true + } + + if kind == resourceConflictItem { + return itemOperationFor(r.Method, opGetConflict, "", opDeleteConflict), true + } + + return "", false +} + +func (h *Handler) dispatchConflicts(c *echo.Context, kind resourceKind, dbID, collID, id string) (bool, error) { + if kind == resourceConflicts { + return true, h.handleConflicts(c, dbID, collID) + } + + if kind == resourceConflictItem { + return true, h.handleConflictItem(c, dbID, collID, id) + } + + return false, nil +} + +func conflictSupportedOperations() []string { + return []string{opListConflicts, opGetConflict, opDeleteConflict, opQueryConflicts} +} diff --git a/services/cosmosdb/regions.go b/services/cosmosdb/regions.go index 7d9549513f..8ee34658d8 100644 --- a/services/cosmosdb/regions.go +++ b/services/cosmosdb/regions.go @@ -1,26 +1,70 @@ package cosmosdb import ( + "errors" "slices" "strings" + "time" ) -// AccountRegion is one geo-replica of the account; the lowest failover priority is the write region. +// Consistency levels, strongest first (Learn, "Consistency levels in Azure Cosmos DB"). +const ( + ConsistencyStrong = "Strong" + ConsistencyBoundedStaleness = "BoundedStaleness" + ConsistencySession = "Session" + ConsistencyConsistentPrefix = "ConsistentPrefix" + ConsistencyEventual = "Eventual" +) + +// Bounded staleness limits when a policy names none (the service minimums are 10 versions and 5 seconds; the +// defaults here are the Learn portal defaults of 100,000 versions and 300 seconds). +const ( + defaultStalenessPrefix = 100_000 + defaultStalenessSecs = 300 +) + +// minReplicatedRegions is the region count from which an account can hold separate copies. +const minReplicatedRegions = 2 + +// ErrUnknownRegion is returned for an operation naming a region the account does not have. +var ErrUnknownRegion = errors.New("cosmosdb: unknown region") + +// AccountRegion is one geo-replica of the account. The lowest failover priority among online regions is the write +// region; ReplicationLagMs is how long a write elsewhere takes to arrive here. type AccountRegion struct { Name string `json:"name"` FailoverPriority int `json:"failoverPriority"` + ReplicationLagMs int `json:"replicationLagMs,omitempty"` + Offline bool `json:"offline,omitempty"` +} + +// ConsistencyPolicy is the account default consistency (userConsistencyPolicy on the account root). +type ConsistencyPolicy struct { + Level string `json:"defaultConsistencyLevel"` + MaxStalenessPrefix int64 `json:"maxStalenessPrefix,omitempty"` + MaxIntervalInSeconds int `json:"maxIntervalInSeconds,omitempty"` } -// AccountRegions is the account's regional topology. Every region serves the same single in-memory store. +// AccountRegions is the account's regional topology. type AccountRegions struct { - Regions []AccountRegion `json:"regions,omitempty"` - MultiWrite bool `json:"multiWrite,omitempty"` + Consistency *ConsistencyPolicy `json:"consistency,omitempty"` + Write string `json:"write,omitempty"` + Backup string `json:"backup,omitempty"` + Regions []AccountRegion `json:"regions,omitempty"` + MultiWrite bool `json:"multiWrite,omitempty"` + NoAutoFailover bool `json:"noAutoFailover,omitempty"` } // normalized drops blank and duplicate names and orders by failover priority; an empty set means the default // single region. func (r AccountRegions) normalized() AccountRegions { - out := AccountRegions{MultiWrite: r.MultiWrite} + out := AccountRegions{ + MultiWrite: r.MultiWrite, + Consistency: r.Consistency, + NoAutoFailover: r.NoAutoFailover, + Write: r.Write, + Backup: r.Backup, + } seen := map[string]struct{}{} for _, reg := range r.Regions { @@ -34,11 +78,8 @@ func (r AccountRegions) normalized() AccountRegions { } seen[key] = struct{}{} - - out.Regions = append( - out.Regions, - AccountRegion{Name: strings.TrimSpace(reg.Name), FailoverPriority: reg.FailoverPriority}, - ) + reg.Name = strings.TrimSpace(reg.Name) + out.Regions = append(out.Regions, reg) } slices.SortStableFunc(out.Regions, func(a, b AccountRegion) int { return a.FailoverPriority - b.FailoverPriority }) @@ -46,12 +87,187 @@ func (r AccountRegions) normalized() AccountRegions { return out } -// SetAccountRegions replaces the regional topology reported by the account root. +// mergedRuntime carries the runtime settings (lag, offline state, consistency) of next's regions over from r, so +// an ARM topology update that names only regions and priorities keeps them. +func (r AccountRegions) mergedRuntime(next AccountRegions) AccountRegions { + for i, reg := range next.Regions { + for _, old := range r.Regions { + if strings.EqualFold(old.Name, reg.Name) { + next.Regions[i].ReplicationLagMs = old.ReplicationLagMs + next.Regions[i].Offline = old.Offline + + break + } + } + } + + if next.Consistency == nil { + next.Consistency = r.Consistency + } + + next.NoAutoFailover = r.NoAutoFailover + next.Backup = r.Backup + + return next +} + +func (r AccountRegions) consistency() ConsistencyPolicy { + if r.Consistency != nil && r.Consistency.Level != "" { + return *r.Consistency + } + + return ConsistencyPolicy{Level: ConsistencySession} +} + +// home is the write region: the lowest failover priority among online regions. +func (r AccountRegions) home() string { + if reg, _, ok := r.find(r.Write); ok && (!reg.Offline || r.NoAutoFailover) { + return reg.Name + } + + return r.firstOnline() +} + +// firstOnline is the online region with the lowest failover priority. +func (r AccountRegions) firstOnline() string { + for _, reg := range r.Regions { + if !reg.Offline { + return reg.Name + } + } + + if len(r.Regions) > 0 { + return r.Regions[0].Name + } + + return accountRegionName +} + +// withWriteRegion pins the write region to the lowest failover priority among online regions (a topology that was +// just set or whose priorities changed). +func (r AccountRegions) withWriteRegion() AccountRegions { + r.Write = r.firstOnline() + + if len(r.Regions) == 0 { + r.Write = "" + } + + return r +} + +// writers lists the regions that accept writes. +func (r AccountRegions) writers() []string { + if !r.MultiWrite { + return []string{r.home()} + } + + var out []string + + for _, reg := range r.Regions { + if !reg.Offline { + out = append(out, reg.Name) + } + } + + if len(out) == 0 { + return []string{r.home()} + } + + return out +} + +func (r AccountRegions) find(name string) (AccountRegion, int, bool) { + for i, reg := range r.Regions { + if strings.EqualFold(reg.Name, name) { + return reg, i, true + } + } + + return AccountRegion{}, 0, false +} + +func (r AccountRegions) lag(name string) time.Duration { + reg, _, _ := r.find(name) + + return time.Duration(reg.ReplicationLagMs) * time.Millisecond +} + +// replicated reports whether regions hold separate copies: more than one region and either a replication lag or +// multi-region writes. Otherwise every region serves the one store. +func (r AccountRegions) replicated() bool { + if len(r.Regions) < minReplicatedRegions { + return false + } + + if r.MultiWrite { + return true + } + + for _, reg := range r.Regions { + if reg.ReplicationLagMs > 0 { + return true + } + } + + return false +} + +// regionID is the small stable number a region has in a session token: its 1-based position by name. +func (r AccountRegions) regionID(name string) int { + names := make([]string, 0, len(r.Regions)) + for _, reg := range r.Regions { + names = append(names, strings.ToLower(reg.Name)) + } + + slices.Sort(names) + + return slices.Index(names, strings.ToLower(name)) + 1 +} + +// SetAccountRegions replaces the regional topology reported by the account root. Lag, offline state and the +// consistency policy of regions that stay are kept. func (b *InMemoryBackend) SetAccountRegions(r AccountRegions) { b.mu.Lock("SetAccountRegions") - defer b.mu.Unlock() + b.setRegionsLocked(b.regions.mergedRuntime(r.normalized()).withWriteRegion()) + b.mu.Unlock() + b.notifyRegions() +} + +// ConfigureAccountRegions replaces the whole topology including lag, offline state and consistency (the admin API). +func (b *InMemoryBackend) ConfigureAccountRegions(r AccountRegions) { + b.mu.Lock("ConfigureAccountRegions") + b.setRegionsLocked(r.normalized().withWriteRegion()) + b.mu.Unlock() + b.notifyRegions() +} - b.regions = r.normalized() +// SetRegionOffline marks a region offline or online. The write region going offline fails over to the next online +// region by failover priority unless the account has automatic failover disabled. +func (b *InMemoryBackend) SetRegionOffline(name string, offline bool) error { + b.mu.Lock("SetRegionOffline") + + next := b.regions + next.Regions = slices.Clone(b.regions.Regions) + + _, i, ok := next.find(name) + if !ok { + b.mu.Unlock() + + return ErrUnknownRegion + } + + wasHome := strings.EqualFold(b.regionsView().home(), next.Regions[i].Name) + next.Regions[i].Offline = offline + + if offline && wasHome && !next.NoAutoFailover { + next.Write = next.firstOnline() + } + + b.setRegionsLocked(next) + b.mu.Unlock() + b.notifyRegions() + + return nil } // AccountRegions returns the topology, the default single region when none was set. @@ -59,11 +275,19 @@ func (b *InMemoryBackend) AccountRegions() AccountRegions { b.mu.RLock("AccountRegions") defer b.mu.RUnlock() - if len(b.regions.Regions) == 0 { - return AccountRegions{Regions: []AccountRegion{{Name: accountRegionName}}, MultiWrite: b.regions.MultiWrite} + return b.regionsView() +} + +// regionsView is the topology with the default region filled in; the lock must be held. +func (b *InMemoryBackend) regionsView() AccountRegions { + out := b.regions + out.Regions = slices.Clone(b.regions.Regions) + + if len(out.Regions) == 0 { + out.Regions = []AccountRegion{{Name: accountRegionName}} } - return AccountRegions{Regions: slices.Clone(b.regions.Regions), MultiWrite: b.regions.MultiWrite} + return out } // regionProvider is implemented by backends that model the account's regions. @@ -79,12 +303,56 @@ func (h *Handler) accountRegions() AccountRegions { return AccountRegions{Regions: []AccountRegion{{Name: accountRegionName}}} } -// locationEntries renders regions as the root resource's location list; all share the endpoint. -func locationEntries(regions []AccountRegion, endpoint string) []map[string]any { - out := make([]map[string]any, 0, len(regions)) - for _, r := range regions { - out = append(out, map[string]any{"name": r.Name, "databaseAccountEndpoint": endpoint}) +// AccountPolicies are the account settings an ARM account carries into the data plane. +type AccountPolicies struct { + Consistency string + Backup string + MaxStalenessPrefix int64 + MaxIntervalSeconds int + AutomaticFailover bool +} + +// SetAccountPolicies applies the default consistency level, service-managed failover and backup mode. +func (b *InMemoryBackend) SetAccountPolicies(p AccountPolicies) { + b.mu.Lock("SetAccountPolicies") + + next := b.regions + next.Regions = slices.Clone(b.regions.Regions) + next.Consistency = &ConsistencyPolicy{ + Level: canonicalConsistency(p.Consistency), MaxStalenessPrefix: p.MaxStalenessPrefix, + MaxIntervalInSeconds: p.MaxIntervalSeconds, } - return out + if next.Consistency.Level == "" { + next.Consistency.Level = ConsistencySession + } + + next.NoAutoFailover = !p.AutomaticFailover + next.Backup = p.Backup + + b.setRegionsLocked(next) + b.mu.Unlock() + b.notifyRegions() +} + +// Backup modes an account reports (ARM backupPolicy): periodic backup keeps no change history, the continuous tiers +// keep it for 7 or 30 days. +const ( + backupPeriodic = "Periodic" + backupContinuous7 = "Continuous7Days" + backupContinuous = "Continuous30Days" +) + +// changeRetention is how long the all-versions-and-deletes change feed keeps versions: the continuous backup window +// (Learn, "Change feed modes"). Periodic backup keeps none; a standalone emulator without an ARM account defaults to +// the 7-day tier. +func (b *InMemoryBackend) changeRetention() time.Duration { + switch b.regions.Backup { + case backupPeriodic: + return noChangeLog + case backupContinuous: + return continuous30Retention + } + + return defaultChangeRetention } diff --git a/services/cosmosdb/regions_conflicts_test.go b/services/cosmosdb/regions_conflicts_test.go new file mode 100644 index 0000000000..f3d3503624 --- /dev/null +++ b/services/cosmosdb/regions_conflicts_test.go @@ -0,0 +1,478 @@ +package cosmosdb_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +func multiWrite() cosmosdb.AccountRegions { + const lagMs = 5000 + + return cosmosdb.AccountRegions{MultiWrite: true, Regions: []cosmosdb.AccountRegion{ + {Name: eastUS, FailoverPriority: 0, ReplicationLagMs: lagMs}, + {Name: westUS, FailoverPriority: 1, ReplicationLagMs: lagMs}, + }} +} + +func (e *regionEnv) newColl(policy string) { + e.t.Helper() + + body := `{"id":"p","partitionKey":{"paths":["/pk"],"kind":"Hash"}` + if policy != "" { + body += `,"conflictResolutionPolicy":` + policy + } + + require.Equal(e.t, http.StatusCreated, e.do(http.MethodPost, "/dbs/d/colls", nil, body+`}`).status) +} + +func (e *regionEnv) putIn(region, doc string) rawResponse { + e.t.Helper() + + return e.doAt(region, http.MethodPost, "/dbs/d/colls/p/docs", regionHdr(map[string]string{ + "X-Ms-Documentdb-Is-Upsert": "true", + }), doc) +} + +func (e *regionEnv) readIn(region, id string) (int, map[string]any) { + e.t.Helper() + + r := e.doAt(region, http.MethodGet, "/dbs/d/colls/p/docs/"+id, regionHdr(nil), "") + if r.status != http.StatusOK { + return r.status, nil + } + + return r.status, r.object(e.t) +} + +func (e *regionEnv) rankIn(region string) float64 { + e.t.Helper() + + status, doc := e.readIn(region, "x") + require.Equal(e.t, http.StatusOK, status, "x in %s", region) + + rank, _ := doc["rank"].(float64) + + return rank +} + +func TestConflicts_LastWriterWins(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy string + eastDoc string + westDoc string + eastLater bool + wantWinner float64 + }{ + { + name: "custom path: the higher value wins even when written first", + policy: `{"mode":"LastWriterWins","conflictResolutionPath":"/rank"}`, + eastDoc: `{"id":"x","pk":"a","rank":9}`, + westDoc: `{"id":"x","pk":"a","rank":5}`, + wantWinner: 9, + }, + { + name: "custom path: west higher", policy: `{"mode":"LastWriterWins","conflictResolutionPath":"/rank"}`, + eastDoc: `{"id":"x","pk":"a","rank":2}`, westDoc: `{"id":"x","pk":"a","rank":7}`, wantWinner: 7, + }, + { + name: "default path _ts: the later write wins", policy: ``, + eastDoc: `{"id":"x","pk":"a","rank":1}`, westDoc: `{"id":"x","pk":"a","rank":2}`, wantWinner: 2, + }, + { + name: "missing path falls back to _ts", + policy: `{"mode":"LastWriterWins","conflictResolutionPath":"/absent"}`, + eastDoc: `{"id":"x","pk":"a","rank":1}`, + westDoc: `{"id":"x","pk":"a","rank":2}`, + wantWinner: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, multiWrite()) + e.newColl(tt.policy) + + requireWritten(t, e.putIn(eastUS, tt.eastDoc)) + e.clock.advance(2 * time.Second) + requireWritten(t, e.putIn(westUS, tt.westDoc)) + + assert.InDelta(t, jsonRank(t, tt.eastDoc), e.rankIn(eastUS), 0, "each region sees its own write first") + assert.InDelta(t, jsonRank(t, tt.westDoc), e.rankIn(westUS), 0) + + e.clock.advance(10 * time.Second) + + assert.InDelta(t, tt.wantWinner, e.rankIn(eastUS), 0, "all regions converge to one winner") + assert.InDelta(t, tt.wantWinner, e.rankIn(westUS), 0) + + feed := e.doAt(eastUS, http.MethodGet, "/dbs/d/colls/p/conflicts", nil, "") + require.Equal(t, http.StatusOK, feed.status) + assert.Empty(t, feed.object(t)["Conflicts"], "last-writer-wins conflicts never reach the feed") + }) + } +} + +func jsonRank(t *testing.T, doc string) float64 { + t.Helper() + + var m map[string]any + require.NoError(t, json.Unmarshal([]byte(doc), &m)) + + r, _ := m["rank"].(float64) + + return r +} + +func TestConflicts_DeleteWins(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, multiWrite()) + e.newColl(`{"mode":"LastWriterWins","conflictResolutionPath":"/rank"}`) + + requireWritten(t, e.putIn(eastUS, `{"id":"x","pk":"a","rank":1}`)) + e.clock.advance(10 * time.Second) + + del := e.doAt(eastUS, http.MethodDelete, "/dbs/d/colls/p/docs/x", regionHdr(nil), "") + require.Equal(t, http.StatusNoContent, del.status) + requireWritten(t, e.putIn(westUS, `{"id":"x","pk":"a","rank":99}`)) + + e.clock.advance(10 * time.Second) + + for _, region := range []string{eastUS, westUS} { + status, _ := e.readIn(region, "x") + assert.Equal(t, http.StatusNotFound, status, region+": a delete beats a higher-ranked replace") + } +} + +const resolverSproc = `function resolver(incomingItem, existingItem, isTombstone, conflictingItems) { + var collection = getContext().getCollection(); + if (!incomingItem || isTombstone) { return; } + if (existingItem && incomingItem.rank > existingItem.rank) { return; } + if (existingItem) { + collection.replaceDocument(existingItem._self, incomingItem, function (err) { if (err) throw err; }); + } else { + collection.createDocument(collection.getSelfLink(), incomingItem, function (err) { if (err) throw err; }); + } +}` + +func TestConflicts_CustomResolution(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy string + sproc string + wantFeed bool + wantEastRank float64 + wantWestRank float64 + }{ + { + name: "merge procedure picks the lowest rank in every region", + policy: `{"mode":"Custom","conflictResolutionProcedure":"dbs/d/colls/p/sprocs/resolver"}`, + sproc: resolverSproc, + wantEastRank: 3, + wantWestRank: 3, + }, + { + name: "a failing procedure sends the conflict to the feed", + policy: `{"mode":"Custom","conflictResolutionProcedure":"dbs/d/colls/p/sprocs/resolver"}`, + sproc: `function resolver() { throw new Error("cannot merge"); }`, + wantFeed: true, + wantEastRank: 3, + wantWestRank: 8, + }, + { + name: "custom without a procedure uses the feed", policy: `{"mode":"Custom"}`, + wantFeed: true, wantEastRank: 3, wantWestRank: 8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, multiWrite()) + e.newColl(tt.policy) + + if tt.sproc != "" { + require.Equal(t, http.StatusCreated, e.do(http.MethodPost, "/dbs/d/colls/p/sprocs", nil, + scriptJSON(t, map[string]string{"id": "resolver", "body": tt.sproc})).status) + } + + requireWritten(t, e.putIn(eastUS, `{"id":"x","pk":"a","rank":3}`)) + e.clock.advance(time.Second) + requireWritten(t, e.putIn(westUS, `{"id":"x","pk":"a","rank":8}`)) + e.clock.advance(10 * time.Second) + + assert.InDelta(t, tt.wantEastRank, e.rankIn(eastUS), 0) + assert.InDelta(t, tt.wantWestRank, e.rankIn(westUS), 0) + + count := 0 + + for _, region := range []string{eastUS, westUS} { + feed := e.doAt(region, http.MethodGet, "/dbs/d/colls/p/conflicts", nil, "") + require.Equal(t, http.StatusOK, feed.status) + + items, _ := feed.object(t)["Conflicts"].([]any) + count += len(items) + + for _, it := range items { + m := rowObj(t, it) + assert.Equal(t, "document", m["resourceType"]) + assert.Contains(t, []any{"create", "replace"}, m["operationType"]) + assert.Contains(t, m["content"], `"id":"x"`) + + got := e.doAt(region, http.MethodGet, "/dbs/d/colls/p/conflicts/"+m["id"].(string), nil, "") + assert.Equal(t, http.StatusOK, got.status) + + del := e.doAt(region, http.MethodDelete, "/dbs/d/colls/p/conflicts/"+m["id"].(string), nil, "") + assert.Equal(t, http.StatusNoContent, del.status) + assert.Equal(t, http.StatusNotFound, + e.doAt(region, http.MethodGet, "/dbs/d/colls/p/conflicts/"+m["id"].(string), nil, "").status) + } + } + + if tt.wantFeed { + assert.Positive(t, count) + } else { + assert.Zero(t, count, "a successful merge procedure leaves nothing in the feed") + } + }) + } +} + +func TestRegions_AutomaticFailover(t *testing.T) { + t.Parallel() + + topo := cosmosdb.AccountRegions{Regions: []cosmosdb.AccountRegion{ + {Name: eastUS, FailoverPriority: 0}, + {Name: westUS, FailoverPriority: 1}, + {Name: euWest, FailoverPriority: 2}, + }} + e := newRegionEnv(t, topo) + + writeRegion := func() string { + body := e.admin(http.MethodGet, "regions", "").object(t) + + return body["writeRegion"].(string) + } + + require.Equal(t, eastUS, writeRegion()) + requireWritten(t, e.putAt(eastUS, `{"id":"a","pk":"a"}`)) + + off := e.admin(http.MethodPost, "regions/East%20US/offline", "") + require.Equal(t, http.StatusOK, off.status, string(off.body)) + assert.Equal(t, westUS, writeRegion(), "the next failover priority takes over") + + down := e.getAt(eastUS, "a", nil) + assert.Equal(t, http.StatusForbidden, down.status) + assert.Equal(t, "1008", down.header.Get("X-Ms-Substatus")) + + requireWritten(t, e.putAt(westUS, `{"id":"b","pk":"a"}`)) + assert.Equal(t, http.StatusForbidden, e.putAt(euWest, `{"id":"c","pk":"a"}`).status) + + root := e.do(http.MethodGet, "/", nil, "").object(t) + writable, _ := root["writableLocations"].([]any) + require.Len(t, writable, 1) + assert.Equal(t, westUS, rowObj(t, writable[0])["name"]) + + on := e.admin(http.MethodPost, "regions/East%20US/online", "") + require.Equal(t, http.StatusOK, on.status) + assert.Equal(t, westUS, writeRegion(), "a recovered region rejoins as a read region") + assert.Equal(t, http.StatusOK, e.getAt(eastUS, "b", nil).status) + + assert.Equal(t, http.StatusNotFound, e.admin(http.MethodPost, "regions/Nowhere/offline", "").status) + + e.admin(http.MethodPost, "regions/West%20US/offline", "") + assert.Equal(t, eastUS, writeRegion()) +} + +func TestRegions_FailoverDoesNotLoseCommittedWrites(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy cosmosdb.ConsistencyPolicy + wantLost bool + }{ + {name: "strong has nothing in flight", policy: cosmosdb.ConsistencyPolicy{Level: cosmosdb.ConsistencyStrong}}, + { + name: "session write in flight is delivered after recovery", + policy: cosmosdb.ConsistencyPolicy{Level: cosmosdb.ConsistencySession}, + wantLost: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + topo := twoRegions(60_000) + topo.Consistency = &tt.policy + e := newRegionEnv(t, topo) + + requireWritten(t, e.putAt(eastUS, `{"id":"inflight","pk":"a"}`)) + require.Equal(t, http.StatusOK, e.admin(http.MethodPost, "regions/East%20US/offline", "").status) + + status := e.getAt(westUS, "inflight", nil).status + if tt.wantLost { + assert.Equal(t, http.StatusNotFound, status, "the unreplicated write is not in the new write region") + } else { + assert.Equal(t, http.StatusOK, status) + } + + require.Equal(t, http.StatusOK, e.admin(http.MethodPost, "regions/East%20US/online", "").status) + e.clock.advance(2 * time.Minute) + assert.Equal( + t, + http.StatusOK, + e.getAt(westUS, "inflight", nil).status, + "it arrives once the old region is back", + ) + }) + } +} + +func TestRegions_FailoverConflictsGoToTheFeed(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, twoRegions(60_000)) + + requireWritten(t, e.putAt(eastUS, `{"id":"x","pk":"a","v":"old-home"}`)) + require.Equal(t, http.StatusOK, e.admin(http.MethodPost, "regions/East%20US/offline", "").status) + requireWritten(t, e.putAt(westUS, `{"id":"x","pk":"a","v":"new-home"}`)) + + require.Equal(t, http.StatusOK, e.admin(http.MethodPost, "regions/East%20US/online", "").status) + e.clock.advance(2 * time.Minute) + + assert.Equal(t, "new-home", e.getAt(westUS, "x", nil).object(t)["v"]) + + feed := e.doAt(westUS, http.MethodGet, "/dbs/d/colls/c/conflicts", nil, "") + require.Equal(t, http.StatusOK, feed.status) + + items, _ := feed.object(t)["Conflicts"].([]any) + require.Len(t, items, 1, "the write the former write region never replicated surfaces as a conflict") + assert.Contains(t, rowObj(t, items[0])["content"], "old-home") +} + +func TestRegions_AdminTopologyValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + want int + }{ + { + name: "valid", + body: `{"regions":[{"name":"A"},{"name":"B","failoverPriority":1,"replicationLagMs":10}],"automaticFailover":true}`, + want: http.StatusOK, + }, + { + name: "strong with multiple writers", + body: `{"multiWrite":true,"automaticFailover":true,` + + `"consistency":{"defaultConsistencyLevel":"Strong"},"regions":[{"name":"A"},{"name":"B","failoverPriority":1}]}`, + want: http.StatusBadRequest, + }, + { + name: "unknown level", + body: `{"automaticFailover":true,"consistency":{"defaultConsistencyLevel":"Maybe"},"regions":[{"name":"A"}]}`, + want: http.StatusBadRequest, + }, + { + name: "staleness below the multi-region minimum", + body: `{"automaticFailover":true,` + + `"consistency":{"defaultConsistencyLevel":"BoundedStaleness","maxStalenessPrefix":10},` + + `"regions":[{"name":"A"},{"name":"B","failoverPriority":1}]}`, + want: http.StatusBadRequest, + }, + { + name: "staleness at the single-region minimum", + body: `{"automaticFailover":true,` + + `"consistency":{"defaultConsistencyLevel":"BoundedStaleness","maxStalenessPrefix":10,"maxIntervalInSeconds":5},` + + `"regions":[{"name":"A"}]}`, + want: http.StatusOK, + }, + { + name: "negative lag", + body: `{"automaticFailover":true,"regions":[{"name":"A","replicationLagMs":-1}]}`, + want: http.StatusBadRequest, + }, + {name: "not json", body: `{`, want: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newLiveServer(t) + env := ®ionEnv{liveServer: e, clock: &fakeClock{}} + assert.Equal(t, tt.want, env.admin(http.MethodPut, "regions", tt.body).status) + }) + } +} + +func TestRegions_DefaultAccountUnchanged(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + + resp := s.create(`{"id":"d1","pk":"a"}`) + require.Equal(t, http.StatusCreated, resp.status) + assert.Equal(t, "0:0#0", resp.header.Get("X-Ms-Session-Token"), "single region keeps the static token") + + body := s.do(http.MethodGet, "/", nil, "").object(t) + assert.Equal(t, map[string]any{"defaultConsistencyLevel": "Session"}, body["userConsistencyPolicy"]) + + locs, _ := body["readableLocations"].([]any) + require.Len(t, locs, 1) + assert.Equal(t, s.base+"/", rowObj(t, locs[0])["databaseAccountEndpoint"]) +} + +func TestRegions_SnapshotKeepsTopologyAndConflicts(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, multiWrite()) + e.newColl(`{"mode":"Custom"}`) + + requireWritten(t, e.putIn(eastUS, `{"id":"x","pk":"a","rank":3}`)) + e.clock.advance(time.Second) + requireWritten(t, e.putIn(westUS, `{"id":"x","pk":"a","rank":8}`)) + e.clock.advance(10 * time.Second) + status, _ := e.readIn(eastUS, "x") + require.Equal(t, http.StatusOK, status) + + policy := cosmosdb.ConsistencyPolicy{Level: cosmosdb.ConsistencyBoundedStaleness, MaxStalenessPrefix: 7} + topo := e.backend.AccountRegions() + topo.Consistency = &policy + e.backend.ConfigureAccountRegions(topo) + require.NoError(t, e.backend.SetRegionOffline(westUS, true)) + + snap := e.h.Snapshot(t.Context()) + + restored := newLiveServer(t) + require.NoError(t, restored.h.Restore(t.Context(), snap)) + + got := restored.backend.AccountRegions() + assert.True(t, got.MultiWrite) + assert.Equal(t, policy, *got.Consistency) + assert.Equal(t, []cosmosdb.AccountRegion{ + {Name: eastUS, ReplicationLagMs: 5000}, + {Name: westUS, FailoverPriority: 1, ReplicationLagMs: 5000, Offline: true}, + }, got.Regions) + + feed, err := restored.backend.ListConflicts("d", "p", eastUS) + require.NoError(t, err) + assert.Len(t, feed, 1, "the conflicts feed survives a restore") +} diff --git a/services/cosmosdb/regions_replication_test.go b/services/cosmosdb/regions_replication_test.go new file mode 100644 index 0000000000..8eedbf9beb --- /dev/null +++ b/services/cosmosdb/regions_replication_test.go @@ -0,0 +1,462 @@ +package cosmosdb_test + +import ( + "fmt" + "io" + "maps" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +const ( + eastUS = "East US" + westUS = "West US" + euWest = "North Europe" +) + +// regionEnv is a live multi-region account on a fake clock. +type regionEnv struct { + *liveServer + clock *fakeClock +} + +func newRegionEnv(t *testing.T, topo cosmosdb.AccountRegions) *regionEnv { + t.Helper() + + s := newLiveServer(t) + clk := &fakeClock{} + cosmosdb.SetNowFunc(s.backend, clk.now) + s.backend.ConfigureAccountRegions(topo) + + return ®ionEnv{liveServer: s, clock: clk} +} + +func twoRegions(lagMs int) cosmosdb.AccountRegions { + return cosmosdb.AccountRegions{Regions: []cosmosdb.AccountRegion{ + {Name: eastUS, FailoverPriority: 0}, + {Name: westUS, FailoverPriority: 1, ReplicationLagMs: lagMs}, + }} +} + +// endpoint is the base URL of a region's own listener, as the account root advertises it. +func (e *regionEnv) endpoint(region string) string { + e.t.Helper() + + body := e.do(http.MethodGet, "/", nil, "").object(e.t) + + list, _ := body["readableLocations"].([]any) + for _, l := range list { + m := rowObj(e.t, l) + if m["name"] == region { + return strings.TrimSuffix(m["databaseAccountEndpoint"].(string), "/") + } + } + + require.FailNow(e.t, "no endpoint for region", region) + + return "" +} + +func (e *regionEnv) doAt(region, method, path string, headers map[string]string, body string) rawResponse { + e.t.Helper() + + req, err := http.NewRequestWithContext(e.t.Context(), method, e.endpoint(region)+path, strings.NewReader(body)) + require.NoError(e.t, err) + + req.Header.Set("Content-Type", "application/json") + + for k, v := range headers { + req.Header.Set(k, v) + } + + require.NoError(e.t, e.h.SignRequest(req)) + + resp, err := testclient.New(e.t).Do(req) + require.NoError(e.t, err) + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + require.NoError(e.t, err) + + return rawResponse{status: resp.StatusCode, header: resp.Header, body: raw} +} + +func regionHdr(extra map[string]string) map[string]string { + out := map[string]string{"X-Ms-Documentdb-Partitionkey": `["a"]`} + maps.Copy(out, extra) + + return out +} + +func (e *regionEnv) putAt(region, doc string) rawResponse { + e.t.Helper() + + return e.doAt(region, http.MethodPost, "/dbs/d/colls/c/docs", regionHdr(map[string]string{ + "X-Ms-Documentdb-Is-Upsert": "true", + }), doc) +} + +func (e *regionEnv) getAt(region, id string, extra map[string]string) rawResponse { + e.t.Helper() + + return e.doAt(region, http.MethodGet, "/dbs/d/colls/c/docs/"+id, regionHdr(extra), "") +} + +func (e *regionEnv) admin(method, path, body string) rawResponse { + e.t.Helper() + + req, err := http.NewRequestWithContext(e.t.Context(), method, e.base+"/_gopherstack/cosmosdb/"+path, + strings.NewReader(body)) + require.NoError(e.t, err) + + resp, err := testclient.New(e.t).Do(req) + require.NoError(e.t, err) + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + require.NoError(e.t, err) + + return rawResponse{status: resp.StatusCode, header: resp.Header, body: raw} +} + +func TestRegions_EndpointsAreDistinct(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, cosmosdb.AccountRegions{Regions: []cosmosdb.AccountRegion{ + {Name: eastUS}, {Name: westUS, FailoverPriority: 1}, {Name: euWest, FailoverPriority: 2}, + }}) + + body := e.do(http.MethodGet, "/", nil, "").object(t) + endpoints := map[string]string{} + + list, _ := body["readableLocations"].([]any) + for _, l := range list { + m := rowObj(t, l) + endpoints[m["name"].(string)] = m["databaseAccountEndpoint"].(string) + } + + require.Len(t, endpoints, 3) + assert.Len(t, map[string]struct{}{ + endpoints[eastUS]: {}, endpoints[westUS]: {}, endpoints[euWest]: {}, + }, 3, "every region has its own host:port") + + writable, _ := body["writableLocations"].([]any) + require.Len(t, writable, 1) + assert.Equal(t, eastUS, rowObj(t, writable[0])["name"]) + + for region := range endpoints { + root := e.doAt(region, http.MethodGet, "/", nil, "") + assert.Equal(t, http.StatusOK, root.status, region) + } +} + +func TestRegions_WritesGoToTheWriteRegion(t *testing.T) { + t.Parallel() + + tests := []struct { + region string + wantSub string + wantStatus int + }{ + {region: eastUS, wantStatus: http.StatusCreated}, + {region: westUS, wantStatus: http.StatusForbidden, wantSub: "3"}, + } + + for _, tt := range tests { + t.Run(tt.region, func(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, twoRegions(0)) + resp := e.putAt(tt.region, `{"id":"w","pk":"a"}`) + assert.Equal(t, tt.wantStatus, resp.status, string(resp.body)) + assert.Equal(t, tt.wantSub, resp.header.Get("X-Ms-Substatus")) + + read := e.getAt(westUS, "w", nil) + wantRead := http.StatusNotFound + if tt.wantStatus == http.StatusCreated { + wantRead = http.StatusOK + } + + assert.Equal(t, wantRead, read.status, "with no lag the read region sees the write at once") + }) + } +} + +func TestRegions_ReplicationLag(t *testing.T) { + t.Parallel() + + tests := []struct { + check func(t *testing.T, e *regionEnv) + name string + }{ + {name: "new item is missing until the lag elapses", check: func(t *testing.T, e *regionEnv) { + t.Helper() + + requireWritten(t, e.putAt(eastUS, `{"id":"n","pk":"a","v":1}`)) + assert.Equal(t, http.StatusOK, e.getAt(eastUS, "n", nil).status, "the write region reads its own write") + assert.Equal(t, http.StatusNotFound, e.getAt(westUS, "n", nil).status) + + e.clock.advance(1999 * time.Millisecond) + assert.Equal(t, http.StatusNotFound, e.getAt(westUS, "n", nil).status) + + e.clock.advance(time.Millisecond) + assert.Equal(t, http.StatusOK, e.getAt(westUS, "n", nil).status) + }}, + {name: "a stale version is read until the update arrives", check: func(t *testing.T, e *regionEnv) { + t.Helper() + + requireWritten(t, e.putAt(eastUS, `{"id":"n","pk":"a","v":1}`)) + e.clock.advance(2 * time.Second) + requireWritten(t, e.putAt(eastUS, `{"id":"n","pk":"a","v":2}`)) + + assert.InDelta(t, 1, e.getAt(westUS, "n", nil).object(t)["v"], 0) + assert.InDelta(t, 2, e.getAt(eastUS, "n", nil).object(t)["v"], 0) + + e.clock.advance(2 * time.Second) + assert.InDelta(t, 2, e.getAt(westUS, "n", nil).object(t)["v"], 0) + }}, + {name: "a delete reaches the read region late", check: func(t *testing.T, e *regionEnv) { + t.Helper() + + requireWritten(t, e.putAt(eastUS, `{"id":"n","pk":"a"}`)) + e.clock.advance(2 * time.Second) + + del := e.doAt(eastUS, http.MethodDelete, "/dbs/d/colls/c/docs/n", regionHdr(nil), "") + require.Equal(t, http.StatusNoContent, del.status) + assert.Equal(t, http.StatusOK, e.getAt(westUS, "n", nil).status) + + e.clock.advance(2 * time.Second) + assert.Equal(t, http.StatusNotFound, e.getAt(westUS, "n", nil).status) + }}, + {name: "queries and lists see the regional copy", check: func(t *testing.T, e *regionEnv) { + t.Helper() + + requireWritten(t, e.putAt(eastUS, `{"id":"n","pk":"a"}`)) + + list := e.doAt(westUS, http.MethodGet, "/dbs/d/colls/c/docs", nil, "") + assert.Empty(t, list.object(t)["Documents"]) + + q := e.doAt(westUS, http.MethodPost, "/dbs/d/colls/c/docs", map[string]string{ + "X-Ms-Documentdb-Isquery": "true", "Content-Type": "application/query+json", + }, `{"query":"SELECT * FROM c"}`) + assert.Empty(t, q.object(t)["Documents"]) + + e.clock.advance(2 * time.Second) + list = e.doAt(westUS, http.MethodGet, "/dbs/d/colls/c/docs", nil, "") + assert.Len(t, list.object(t)["Documents"], 1) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.check(t, newRegionEnv(t, twoRegions(2000))) + }) + } +} + +func TestRegions_SessionToken(t *testing.T) { + t.Parallel() + + tests := []struct { + headers func(token string) map[string]string + name string + wantSubstat string + advance time.Duration + wantStatus int + }{ + { + name: "own token not yet replicated", + headers: func(tok string) map[string]string { return map[string]string{"X-Ms-Session-Token": tok} }, + wantStatus: http.StatusNotFound, wantSubstat: "1002", + }, + { + name: "own token once replicated", + headers: func(tok string) map[string]string { return map[string]string{"X-Ms-Session-Token": tok} }, + advance: 2 * time.Second, wantStatus: http.StatusOK, + }, + { + name: "eventual override ignores the token", + headers: func(tok string) map[string]string { + return map[string]string{"X-Ms-Session-Token": tok, "X-Ms-Consistency-Level": "Eventual"} + }, + wantStatus: http.StatusNotFound, + }, + { + name: "no token reads like eventual", + headers: func(string) map[string]string { return nil }, + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, twoRegions(2000)) + w := e.putAt(eastUS, `{"id":"s","pk":"a"}`) + requireWritten(t, w) + + token := w.header.Get("X-Ms-Session-Token") + require.True(t, strings.HasPrefix(token, "0:-1#"), token) + + e.clock.advance(tt.advance) + r := e.getAt(westUS, "s", tt.headers(token)) + assert.Equal(t, tt.wantStatus, r.status, string(r.body)) + assert.Equal(t, tt.wantSubstat, r.header.Get("X-Ms-Substatus")) + }) + } +} + +func TestRegions_ConsistencyLevels(t *testing.T) { + t.Parallel() + + const hour = 3_600_000 + + tests := []struct { + name string + policy cosmosdb.ConsistencyPolicy + advance time.Duration + writes int + wantVisible int + }{ + { + name: "strong replicates synchronously", + policy: cosmosdb.ConsistencyPolicy{Level: cosmosdb.ConsistencyStrong}, + writes: 3, + wantVisible: 3, + }, + { + name: "session lags", + policy: cosmosdb.ConsistencyPolicy{Level: cosmosdb.ConsistencySession}, + writes: 3, + wantVisible: 0, + }, + { + name: "bounded staleness caps the lag at the interval", + policy: cosmosdb.ConsistencyPolicy{ + Level: cosmosdb.ConsistencyBoundedStaleness, + MaxIntervalInSeconds: 30, + MaxStalenessPrefix: 100, + }, + writes: 3, + advance: 30 * time.Second, + wantVisible: 3, + }, + { + name: "bounded staleness before the interval", + policy: cosmosdb.ConsistencyPolicy{ + Level: cosmosdb.ConsistencyBoundedStaleness, + MaxIntervalInSeconds: 30, + MaxStalenessPrefix: 100, + }, + writes: 3, + advance: 29 * time.Second, + wantVisible: 0, + }, + { + name: "bounded staleness never trails by more than K versions", + policy: cosmosdb.ConsistencyPolicy{ + Level: cosmosdb.ConsistencyBoundedStaleness, + MaxIntervalInSeconds: 3600, + MaxStalenessPrefix: 2, + }, + writes: 5, + wantVisible: 3, + }, + { + name: "eventual lags", + policy: cosmosdb.ConsistencyPolicy{Level: cosmosdb.ConsistencyEventual}, + writes: 3, + wantVisible: 0, + }, + { + name: "consistent prefix lags", + policy: cosmosdb.ConsistencyPolicy{Level: cosmosdb.ConsistencyConsistentPrefix}, + writes: 3, + wantVisible: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + topo := twoRegions(hour) + topo.Consistency = &tt.policy + e := newRegionEnv(t, topo) + + for i := range tt.writes { + requireWritten(t, e.putAt(eastUS, fmt.Sprintf(`{"id":"i%d","pk":"a"}`, i))) + } + + e.clock.advance(tt.advance) + + list := e.doAt(westUS, http.MethodGet, "/dbs/d/colls/c/docs", nil, "") + docs, _ := list.object(t)["Documents"].([]any) + assert.Len(t, docs, tt.wantVisible) + + for i := range tt.wantVisible { + assert.Equal( + t, + http.StatusOK, + e.getAt(westUS, fmt.Sprintf("i%d", i), nil).status, + "oldest writes arrive first", + ) + } + }) + } +} + +func TestRegions_ConsistencyOverrideMayOnlyWeaken(t *testing.T) { + t.Parallel() + + tests := []struct { + account string + header string + want int + }{ + {account: cosmosdb.ConsistencySession, header: "Eventual", want: http.StatusOK}, + {account: cosmosdb.ConsistencySession, header: "Session", want: http.StatusOK}, + {account: cosmosdb.ConsistencySession, header: "Strong", want: http.StatusBadRequest}, + {account: cosmosdb.ConsistencySession, header: "BoundedStaleness", want: http.StatusBadRequest}, + {account: cosmosdb.ConsistencyEventual, header: "ConsistentPrefix", want: http.StatusBadRequest}, + {account: cosmosdb.ConsistencyStrong, header: "Session", want: http.StatusOK}, + {account: cosmosdb.ConsistencySession, header: "Whatever", want: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.account+"/"+tt.header, func(t *testing.T) { + t.Parallel() + + topo := twoRegions(0) + topo.Consistency = &cosmosdb.ConsistencyPolicy{Level: tt.account} + e := newRegionEnv(t, topo) + + resp := e.do( + http.MethodGet, + "/dbs/d/colls/c/docs", + map[string]string{"X-Ms-Consistency-Level": tt.header}, + "", + ) + assert.Equal(t, tt.want, resp.status, string(resp.body)) + }) + } +} + +func requireWritten(t *testing.T, r rawResponse) { + t.Helper() + + require.Less(t, r.status, http.StatusMultipleChoices, string(r.body)) +} diff --git a/services/cosmosdb/regions_sdk_test.go b/services/cosmosdb/regions_sdk_test.go new file mode 100644 index 0000000000..27025a7fbf --- /dev/null +++ b/services/cosmosdb/regions_sdk_test.go @@ -0,0 +1,95 @@ +package cosmosdb_test + +import ( + "net/http" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +func (e *regionEnv) sdkContainer(preferred ...string) *azcosmos.ContainerClient { + e.t.Helper() + + cred, err := azcosmos.NewKeyCredential(cosmosdb.DefaultMasterKey) + require.NoError(e.t, err) + + client, err := azcosmos.NewClientWithKey(e.base+"/", cred, &azcosmos.ClientOptions{ + PreferredRegions: preferred, + EnableContentResponseOnWrite: true, + Retry: policy.RetryOptions{MaxRetries: -1}, + }) + require.NoError(e.t, err) + + c, err := client.NewContainer("d", "c") + require.NoError(e.t, err) + + return c +} + +func TestSDK_PreferredRegionReadsFromItsOwnReplica(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + preferred []string + wantErr int + }{ + {name: "write region", preferred: []string{eastUS}}, + {name: "default order reads from the first region", preferred: nil}, + {name: "read region lags behind the write", preferred: []string{westUS}, wantErr: http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, twoRegions(5000)) + c := e.sdkContainer(tt.preferred...) + + _, err := c.UpsertItem(t.Context(), azcosmos.NewPartitionKeyString("a"), + []byte(`{"id":"sdk","pk":"a"}`), nil) + require.NoError(t, err, "writes reach the write region whatever the read preference") + + _, err = c.ReadItem(t.Context(), azcosmos.NewPartitionKeyString("a"), "sdk", nil) + if tt.wantErr != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantErr, responseStatus(t, err)) + + e.clock.advance(6 * time.Second) + + _, err = c.ReadItem(t.Context(), azcosmos.NewPartitionKeyString("a"), "sdk", nil) + } + + require.NoError(t, err) + }) + } +} + +func TestSDK_WriteFailsOverWhenTheWriteRegionGoesOffline(t *testing.T) { + t.Parallel() + + e := newRegionEnv(t, twoRegions(0)) + c := e.sdkContainer(eastUS, westUS) + pk := azcosmos.NewPartitionKeyString("a") + + _, err := c.UpsertItem(t.Context(), pk, []byte(`{"id":"before","pk":"a"}`), nil) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, e.admin(http.MethodPost, "regions/East%20US/offline", "").status) + + _, err = c.UpsertItem(t.Context(), pk, []byte(`{"id":"after","pk":"a"}`), nil) + require.NoError(t, err, "the SDK refreshes the account and retries the write in the new write region") + + got := e.getAt(westUS, "after", nil) + assert.Equal(t, http.StatusOK, got.status) + + read, err := c.ReadItem(t.Context(), pk, "before", nil) + require.NoError(t, err, "reads move to the surviving region") + assert.NotEmpty(t, read.Value) +} diff --git a/services/cosmosdb/repl.go b/services/cosmosdb/repl.go new file mode 100644 index 0000000000..5a112c548c --- /dev/null +++ b/services/cosmosdb/repl.go @@ -0,0 +1,462 @@ +package cosmosdb + +import ( + "slices" + "strings" + "time" +) + +// replEvent is one write on its way to one region. +type replEvent struct { + Due time.Time + At time.Time + Doc *storedDocument + Key documentCompositeKey + Dest string + Origin string + Op string + LSN int64 + Base int64 + Stale bool +} + +// tombstone remembers a delete applied in a region, so a concurrent replace arriving later is seen as conflicting +// with it. +type tombstone struct { + lsn int64 +} + +// replState is the per-container replication state of a replicated account: a separate document store per +// non-home region, the writes in flight to each region, and what each region has applied from each origin. The +// home store is the container itself. +type replState struct { + stores map[string]*storedContainer + applied map[string]map[string]int64 + tombs map[string]map[documentCompositeKey]tombstone + home string + events []*replEvent + lsn int64 +} + +func newReplState(home string) *replState { + return &replState{ + stores: map[string]*storedContainer{}, + applied: map[string]map[string]int64{}, + tombs: map[string]map[documentCompositeKey]tombstone{}, + home: home, + } +} + +// copyConfig shares the configuration of src with dst; scripts, policies and the offer are read-only here. +func copyConfig(dst, src *storedContainer) { + dst.Modified, dst.Offer = src.Modified, src.Offer + dst.Triggers, dst.Sprocs, dst.UDFs = src.Triggers, src.Sprocs, src.UDFs + dst.DefaultTTL, dst.ID, dst.PartitionKeyPath, dst.PartitionKeyPaths = src.DefaultTTL, src.ID, src.PartitionKeyPath, + src.PartitionKeyPaths + dst.ConflictResolutionPolicy, dst.IndexingPolicy, dst.UniqueKeyPolicy = src.ConflictResolutionPolicy, + src.IndexingPolicy, src.UniqueKeyPolicy + dst.GeospatialConfig, dst.VectorEmbeddingPolicy, dst.FullTextPolicy = src.GeospatialConfig, + src.VectorEmbeddingPolicy, src.FullTextPolicy + dst.ComputedProperties = src.ComputedProperties + dst.repl = src.repl + dst.keep = src.keep +} + +// shadowStore is a region's copy of the container, seeded from the home store. +func shadowStore(c *storedContainer) *storedContainer { + s := &storedContainer{Documents: make(map[documentCompositeKey]*storedDocument, len(c.Documents))} + copyConfig(s, c) + + for k, d := range c.Documents { + cp := *d + s.Documents[k] = &cp + } + + s.ChangeSeq = c.ChangeSeq + + return s +} + +// container returns the store of region name. +func (r *replState) container(c *storedContainer, name string) *storedContainer { + if strings.EqualFold(name, r.home) { + return c + } + + if s, ok := r.stores[name]; ok { + return s + } + + return c +} + +func (r *replState) appliedFor(region string) map[string]int64 { + m, ok := r.applied[region] + if !ok { + m = map[string]int64{} + r.applied[region] = m + } + + return m +} + +// reconcileRepl brings every container's replication state in line with the topology; the write lock is held. +func (b *InMemoryBackend) reconcileRepl() { + regs := b.regionsView() + + for _, d := range b.databases { + for _, c := range d.Containers { + b.reconcileContainer(d.ID, c, regs) + } + } +} + +func (b *InMemoryBackend) reconcileContainer(dbID string, c *storedContainer, regs AccountRegions) { + if !regs.replicated() { + if c.repl != nil { + b.deliver(dbID, c, regs, true) + c.repl = nil + } + + return + } + + home := regs.home() + + if c.repl == nil { + c.repl = newReplState(home) + c.repl.lsn = maxGLSN(c) + } + + r := c.repl + if !strings.EqualFold(r.home, home) { + b.rehome(c, r, regs, home) + } + + live := map[string]bool{strings.ToLower(home): true} + + for _, reg := range regs.Regions { + live[strings.ToLower(reg.Name)] = true + + if strings.EqualFold(reg.Name, home) { + continue + } + + if _, ok := r.stores[reg.Name]; !ok { + r.stores[reg.Name] = shadowStore(c) + r.applied[reg.Name] = map[string]int64{} + } + } + + for name := range r.stores { + if !live[strings.ToLower(name)] { + delete(r.stores, name) + delete(r.applied, name) + } + } + + r.events = slices.DeleteFunc(r.events, func(e *replEvent) bool { + return !live[strings.ToLower(e.Dest)] || !live[strings.ToLower(e.Origin)] + }) +} + +func maxGLSN(c *storedContainer) int64 { + var m int64 + + for _, d := range c.Documents { + m = max(m, d.GLSN) + } + + return m +} + +// rehome makes the store of region home the container's own: the document state, change log and sequence of the +// two swap, and writes still on their way from the former home are marked as coming from a former write region. +func (b *InMemoryBackend) rehome(c *storedContainer, r *replState, regs AccountRegions, home string) { + old := r.home + + var target *storedContainer + + for name, s := range r.stores { + if strings.EqualFold(name, home) { + target = s + + delete(r.stores, name) + + break + } + } + + if target == nil { + target = shadowStore(c) + } + + c.Documents, target.Documents = target.Documents, c.Documents + c.ChangeLog, target.ChangeLog = target.ChangeLog, c.ChangeLog + c.ChangeSeq, target.ChangeSeq = target.ChangeSeq, c.ChangeSeq + c.sorted.Store(nil) + target.sorted.Store(nil) + copyConfig(target, c) + + r.stores[old] = target + r.home = home + + if _, ok := r.applied[old]; !ok { + r.applied[old] = map[string]int64{} + } + + if !regs.MultiWrite { + for _, e := range r.events { + if strings.EqualFold(e.Origin, old) { + e.Stale = true + } + } + } + + b.budgetEpoch++ +} + +func regionOnline(regs AccountRegions, name string) bool { + reg, _, ok := regs.find(name) + + return ok && !reg.Offline +} + +// deliver applies the writes that have reached their region. force applies everything deliverable regardless of +// its due time. Writes from or to an offline region wait for it. +func (b *InMemoryBackend) deliver(dbID string, c *storedContainer, regs AccountRegions, force bool) { + r := c.repl + if r == nil || len(r.events) == 0 { + return + } + + now := b.now() + + slices.SortStableFunc(r.events, func(x, y *replEvent) int { + if !x.Due.Equal(y.Due) { + return x.Due.Compare(y.Due) + } + + return int(x.LSN - y.LSN) + }) + + pending := r.events + r.events = nil + + var kept []*replEvent + + for _, ev := range pending { + ready := force || !ev.Due.After(now) + if ready && regionOnline(regs, ev.Dest) && regionOnline(regs, ev.Origin) { + b.applyEvent(dbID, c, r, ev, regs, now) + + continue + } + + kept = append(kept, ev) + } + + r.events = b.forceStaleness(dbID, c, r, kept, regs, now) +} + +// forceStaleness enforces the bounded-staleness version bound: a region never trails by more than K writes. +func (b *InMemoryBackend) forceStaleness( + dbID string, c *storedContainer, r *replState, kept []*replEvent, regs AccountRegions, now time.Time, +) []*replEvent { + pol := regs.consistency() + if pol.Level != ConsistencyBoundedStaleness { + return kept + } + + limit := int(pol.MaxStalenessPrefix) + if limit <= 0 { + limit = defaultStalenessPrefix + } + + for _, reg := range regs.Regions { + if !reg.Offline { + kept = b.trimRegion(dbID, c, r, kept, regs, now, reg.Name, limit) + } + } + + return kept +} + +// trimRegion applies a region's oldest waiting writes until at most limit are left. +func (b *InMemoryBackend) trimRegion( + dbID string, c *storedContainer, r *replState, kept []*replEvent, regs AccountRegions, now time.Time, + region string, limit int, +) []*replEvent { + for { + idx, count := waitingFor(kept, regs, region) + if count <= limit || idx < 0 { + return kept + } + + b.applyEvent(dbID, c, r, kept[idx], regs, now) + kept = slices.Delete(kept, idx, idx+1) + } +} + +// waitingFor locates the oldest write waiting for region and counts the ones that could be delivered. +func waitingFor(events []*replEvent, regs AccountRegions, region string) (int, int) { + first, count := -1, 0 + + for i, ev := range events { + if !strings.EqualFold(ev.Dest, region) || !regionOnline(regs, ev.Origin) { + continue + } + + count++ + + if first < 0 { + first = i + } + } + + return first, count +} + +func (b *InMemoryBackend) replTx(dbID string, c, dest *storedContainer, r *replState, region string, now time.Time, + regs AccountRegions, replicating bool, +) *containerTx { + tx := &containerTx{ + b: b, c: dest, dbID: dbID, collID: c.ID, now: now, seq0: dest.ChangeSeq, + region: region, repl: r, regs: regs, replicating: replicating, + } + tx.evLen, tx.lsn0 = len(r.events), r.lsn + + return tx +} + +// applyEvent lands one write in its destination region, resolving it against a concurrent write there. +func (b *InMemoryBackend) applyEvent( + dbID string, c *storedContainer, r *replState, ev *replEvent, regs AccountRegions, now time.Time, +) { + dest := r.container(c, ev.Dest) + if dest != c { + copyConfig(dest, c) + } + + tx := b.replTx(dbID, c, dest, r, ev.Dest, now, regs, true) + + r.appliedFor(ev.Dest)[ev.Origin] = max(r.appliedFor(ev.Dest)[ev.Origin], ev.LSN) + + cur, exists := dest.lookup(ev.Key, now) + tomb, tombed := r.tombs[ev.Dest][ev.Key] + + switch { + case exists && cur.GLSN == ev.LSN: + return + case ev.Doc == nil && !exists: + b.noteTomb(r, ev) + + return + case ev.Doc == nil && cur.GLSN <= ev.Base: + tx.remove(ev.Key) + b.noteTomb(r, ev) + + return + case ev.Doc != nil && !exists && (!tombed || tomb.lsn <= ev.Base): + b.putReplicated(tx, r, ev) + + return + case ev.Doc != nil && exists && cur.GLSN <= ev.Base: + b.putReplicated(tx, r, ev) + + return + } + + b.resolveConflict(dbID, c, dest, r, ev, regs, now, cur, exists) +} + +func (b *InMemoryBackend) noteTomb(r *replState, ev *replEvent) { + m, ok := r.tombs[ev.Dest] + if !ok { + m = map[documentCompositeKey]tombstone{} + r.tombs[ev.Dest] = m + } + + m[ev.Key] = tombstone{lsn: ev.LSN} +} + +func (b *InMemoryBackend) putReplicated(tx *containerTx, r *replState, ev *replEvent) { + cp := *ev.Doc + cp.GLSN, cp.Origin = ev.LSN, ev.Origin + tx.put(ev.Key, &cp) + delete(r.tombs[ev.Dest], ev.Key) +} + +// attachRepl ties a write transaction to the replication state when the account is replicated. +func (b *InMemoryBackend) attachRepl(tx *containerTx) { + rc := b.cur.Load() + if rc == nil || tx.c.repl == nil { + return + } + + tx.repl, tx.region, tx.regs = tx.c.repl, rc.region, b.regionsView() + tx.evLen, tx.lsn0 = len(tx.repl.events), tx.repl.lsn +} + +// emit records a write for replication: it stamps the document with its global sequence number and queues the +// write for every other region, due after that region's replication lag. +func (tx *containerTx) emit(key documentCompositeKey, doc, prev *storedDocument, op string) { + r := tx.repl + if r == nil || tx.replicating { + return + } + + r.lsn++ + + var base int64 + if prev != nil { + base = prev.GLSN + } + + if doc != nil { + doc.GLSN, doc.Origin = r.lsn, tx.region + } else { + tx.b.noteTomb(r, &replEvent{Dest: tx.region, Key: key, LSN: r.lsn}) + } + + r.appliedFor(tx.region)[tx.region] = r.lsn + pol := tx.regs.consistency() + + for _, reg := range tx.regs.Regions { + if strings.EqualFold(reg.Name, tx.region) { + continue + } + + r.events = append(r.events, &replEvent{ + Due: tx.now.Add(effectiveLag(pol, tx.regs.lag(reg.Name))), At: tx.now, Doc: doc, Key: key, + Dest: reg.Name, Origin: tx.region, Op: op, LSN: r.lsn, Base: base, + }) + } +} + +// effectiveLag is the replication delay the account consistency level allows: none for Strong, at most the +// staleness interval for Bounded Staleness. +func effectiveLag(pol ConsistencyPolicy, lag time.Duration) time.Duration { + switch pol.Level { + case ConsistencyStrong: + return 0 + case ConsistencyBoundedStaleness: + secs := pol.MaxIntervalInSeconds + if secs <= 0 { + secs = defaultStalenessSecs + } + + return min(lag, time.Duration(secs)*time.Second) + } + + return lag +} + +func (tx *containerTx) rollbackRepl() { + if tx.repl == nil { + return + } + + tx.repl.events = tx.repl.events[:min(tx.evLen, len(tx.repl.events))] + tx.repl.lsn = tx.lsn0 +} diff --git a/services/cosmosdb/ru.go b/services/cosmosdb/ru.go index 6884c7c19a..29a3c0d73e 100644 --- a/services/cosmosdb/ru.go +++ b/services/cosmosdb/ru.go @@ -8,42 +8,66 @@ import ( "github.com/labstack/echo/v5" ) -// Request-unit model (an approximation of the documented per-operation costs for a 1 KB item with default -// indexing; sizes are the JSON payload length): +// Request-unit model, from the Azure documentation: // -// point read 1 + 0.09 per KB beyond the first -// create/upsert 5.5 + 0.73 per KB beyond the first -// replace 2x create -// delete 5.5 flat -// read feed point-read cost of the returned payload -// query 2.3 + 0.005 per scanned item + 0.1 per KB returned -// metadata 1 flat +// - request-units: a point read of a 1 KB item is 1 RU; strong and bounded staleness reads cost twice as much. +// - optimize-cost-reads-writes: a point read is 1 RU at 1 KB and 10 RU at 100 KB; inserting a 1 KB item without +// indexing is about 5.5 RU; a replace costs twice an insert; a query that returns a thousand 1 KB items costs +// 1000 RU. +// - the same page's query-metrics sample: one retrieved document of 9,963 bytes cost 3.19 RU, which fixes the +// query overhead at 3.19 less the 1.79 RU load of that document, 1.4 RU. +// - partitioning: a query without the partition key pays 2-3 RU for every physical partition (2.5 here). +// +// Cost grows linearly with item size (1 -> 10 RU over 1 -> 100 KB), for writes too. The documents give no per-path +// index cost: the 0.3 RU per indexed property path is a calibration that makes a write cost more the more of the +// item the indexing policy covers; indexing mode none pays only the documented 5.5 RU. Index lookups, query +// predicates and UDFs are listed as cost factors but with no numbers, so they add nothing. +const ( + ruAt100KB = 10.0 + ruKBAtCost = 100.0 +) + const ( - ruReadBase = 1.0 - ruReadPerKB = 0.09 - ruWriteBase = 5.5 - ruWritePerKB = 0.73 - ruReplaceFactor = 2.0 - ruDelete = 5.5 - ruQueryBase = 2.3 - ruQueryPerScanned = 0.005 - ruQueryPerKB = 0.1 - ruMetadata = 1.0 - bytesPerKB = 1024.0 + ruReadBase = 1.0 + ruReadPerKB = (ruAt100KB - ruReadBase) / (ruKBAtCost - 1.0) + ruWriteBase = 5.5 + ruIndexedPath = 0.3 + ruReplaceFactor = 2.0 + ruDelete = ruWriteBase + ruQueryBase = 1.4 + ruCrossPartition = 2.5 + ruStrongReadFactor = 2.0 + ruMetadata = 1.0 + bytesPerKB = 1024.0 ) func kbBeyondFirst(size int) float64 { return max(float64(size)/bytesPerKB-1, 0) } -func readCharge(size int) float64 { return ruReadBase + ruReadPerKB*kbBeyondFirst(size) } -func createCharge(size int) float64 { return ruWriteBase + ruWritePerKB*kbBeyondFirst(size) } -func replaceCharge(size int) float64 { - return ruReplaceFactor * createCharge(size) +// sizeFactor scales a one-KB cost to an item of size bytes. +func sizeFactor(size int) float64 { return 1 + ruReadPerKB*kbBeyondFirst(size) } + +func readCharge(size int) float64 { return ruReadBase * sizeFactor(size) } + +// createCharge is the insert cost of an item of size bytes whose indexing policy covers indexed property paths. +func createCharge(size, indexed int) float64 { + return ruWriteBase*sizeFactor(size) + ruIndexedPath*float64(indexed) +} + +func replaceCharge(size, indexed int) float64 { + return ruReplaceFactor * createCharge(size, indexed) } -func queryCharge(scanned, resultBytes int) float64 { - return ruQueryBase + ruQueryPerScanned*float64(scanned) + ruQueryPerKB*float64(resultBytes)/bytesPerKB +// queryCharge is the cost of one query page: the overhead, the load of the documents it retrieved (loaded, already +// summed from readCharge) and, without a partition key on a multi-partition container, the per-partition check. +func queryCharge(loaded float64, partitions int) float64 { + ru := ruQueryBase + loaded + if partitions > 1 { + ru += ruCrossPartition * float64(partitions) + } + + return ru } // formatCharge renders RUs with at most two decimals, as the service does. @@ -73,3 +97,118 @@ func (h *Handler) writeJSONCharged(c *echo.Context, status int, v any, charge fu return c.Blob(status, "application/json", body) } + +// readFactor is 2 when the request reads at strong or bounded staleness consistency, else 1. +func (h *Handler) readFactor(c *echo.Context) float64 { + pol := h.accountRegions().consistency() + level, err := checkRequestConsistency(canonicalConsistency(c.Request().Header.Get(headerConsistency)), pol) + + if err == nil && (level == ConsistencyStrong || level == ConsistencyBoundedStaleness) { + return ruStrongReadFactor + } + + return 1 +} + +// readCharger prices a read by payload size at the request's consistency level. +func (h *Handler) readCharger(c *echo.Context) func(int) float64 { + f := h.readFactor(c) + + return func(size int) float64 { return f * readCharge(size) } +} + +// feedCharger prices a read feed as one read per returned item. +func (h *Handler) feedCharger(c *echo.Context, values []map[string]any) func(int) float64 { + f := h.readFactor(c) + + return func(int) float64 { + var total float64 + + for _, v := range values { + raw, _ := jsonMarshal(v) + total += readCharge(len(raw)) + } + + return f * max(total, ruReadBase) + } +} + +// indexedPaths counts the property paths of body the container's indexing policy indexes. +func (h *Handler) indexedPaths(dbID, collID string, body map[string]any) int { + info, err := h.Backend.GetContainer(dbID, collID) + if err != nil { + return 0 + } + + return countIndexedPaths(parseIndexingPolicy(info.IndexingPolicy), body) +} + +// countIndexedPaths counts the distinct leaf property paths of body (array positions are "[]") that pol indexes; +// indexing mode none indexes no property. +func countIndexedPaths(pol indexingPolicy, body map[string]any) int { + if strings.EqualFold(pol.IndexingMode, indexModeNone) { + return 0 + } + + seen := map[string]struct{}{} + collectLeafPaths("", body, seen, 0) + + n := 0 + + for p := range seen { + if pol.indexed(p) { + n++ + } + } + + return n +} + +const maxLeafDepth = 32 + +func collectLeafPaths(prefix string, v any, out map[string]struct{}, depth int) { + if depth > maxLeafDepth { + return + } + + switch t := v.(type) { + case map[string]any: + for k, child := range t { + if depth == 0 && strings.HasPrefix(k, "_") { + continue + } + + collectLeafPaths(prefix+"/"+k, child, out, depth+1) + } + case []any: + for _, child := range t { + collectLeafPaths(prefix+"/[]", child, out, depth+1) + } + default: + out[prefix] = struct{}{} + } +} + +// writeCharger prices a create or upsert of body (or, with replace, a replace or patch). +func (h *Handler) writeCharger(dbID, collID string, body map[string]any, replace bool) func(int) float64 { + indexed := h.indexedPaths(dbID, collID, body) + + return func(size int) float64 { + if replace { + return replaceCharge(size, indexed) + } + + return createCharge(size, indexed) + } +} + +// queryPartitions is the number of physical partitions a query has to check: every one unless the partition key +// scopes it. +func (h *Handler) queryPartitions(dbID, collID, scope string) int { + tb, ok := h.Backend.(ThroughputBackend) + if !ok || scope != "" { + return 1 + } + + return tb.ThroughputBudget(dbID, collID).Partitions() +} diff --git a/services/cosmosdb/ru_model_test.go b/services/cosmosdb/ru_model_test.go new file mode 100644 index 0000000000..a69adb14d5 --- /dev/null +++ b/services/cosmosdb/ru_model_test.go @@ -0,0 +1,320 @@ +package cosmosdb_test + +import ( + "fmt" + "maps" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +const ruPK = `["a"]` + +func chargeOf(t *testing.T, rec *httptest.ResponseRecorder) float64 { + t.Helper() + + v, err := strconv.ParseFloat(rec.Header().Get("X-Ms-Request-Charge"), 64) + require.NoError(t, err) + + return v +} + +func ruHandler(t *testing.T, indexing string) *cosmosdb.Handler { + t.Helper() + + h := newTestHandler(t) + createTestDatabase(t, h) + + body := `{"id":"mycoll","partitionKey":{"paths":["/pk"],"kind":"Hash"}` + if indexing != "" { + body += `,"indexingPolicy":` + indexing + } + + rec := doRequest(t, h, http.MethodPost, "/dbs/mydb/colls", nil, []byte(body+`}`)) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + + return h +} + +// putSized stores a document whose point-read response is exactly size bytes. +func putSized(t *testing.T, h *cosmosdb.Handler, id string, size int) { + t.Helper() + + probe := fmt.Sprintf(`{"id":%q,"pk":"a","pad":""}`, id) + require.Equal(t, http.StatusCreated, + doRequest(t, h, http.MethodPost, "/dbs/mydb/colls/mycoll/docs", nil, []byte(probe)).Code) + + empty := doRequest(t, h, http.MethodGet, "/dbs/mydb/colls/mycoll/docs/"+id, map[string]string{ + "X-Ms-Documentdb-Partitionkey": ruPK, + }, nil) + require.Equal(t, http.StatusOK, empty.Code) + + pad := strings.Repeat("x", size-empty.Body.Len()) + body := fmt.Sprintf(`{"id":%q,"pk":"a","pad":%q}`, id, pad) + + rec := doRequest(t, h, http.MethodPut, "/dbs/mydb/colls/mycoll/docs/"+id, map[string]string{ + "X-Ms-Documentdb-Partitionkey": ruPK, + }, []byte(body)) + require.Equal(t, http.StatusOK, rec.Code) +} + +func readDoc(t *testing.T, h *cosmosdb.Handler, id string, extra map[string]string) *httptest.ResponseRecorder { + t.Helper() + + hdr := map[string]string{"X-Ms-Documentdb-Partitionkey": ruPK} + maps.Copy(hdr, extra) + + return doRequest(t, h, http.MethodGet, "/dbs/mydb/colls/mycoll/docs/"+id, hdr, nil) +} + +func TestRU_PointReadMatchesTheDocumentedExamples(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + size int + want float64 + }{ + {name: "1 KB is 1 RU", size: 1024, want: 1}, + {name: "100 KB is 10 RU", size: 100 * 1024, want: 10}, + {name: "small items cost the 1 KB minimum", size: 300, want: 1}, + {name: "50 KB sits between", size: 50 * 1024, want: 1 + 9.0*49/99}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := ruHandler(t, "") + putSized(t, h, "d", tt.size) + + assert.InDelta(t, tt.want, chargeOf(t, readDoc(t, h, "d", nil)), 0.02) + }) + } +} + +func TestRU_StrongAndBoundedStalenessReadsCostTwice(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level string + override string + want float64 + }{ + {name: "session", level: cosmosdb.ConsistencySession, want: 1}, + {name: "eventual", level: cosmosdb.ConsistencyEventual, want: 1}, + {name: "strong", level: cosmosdb.ConsistencyStrong, want: 2}, + {name: "bounded staleness", level: cosmosdb.ConsistencyBoundedStaleness, want: 2}, + { + name: "strong weakened to session per request", + level: cosmosdb.ConsistencyStrong, + override: "Session", + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := ruHandler(t, "") + putSized(t, h, "d", 1024) + + b, ok := h.Backend.(*cosmosdb.InMemoryBackend) + require.True(t, ok) + b.ConfigureAccountRegions(cosmosdb.AccountRegions{ + Consistency: &cosmosdb.ConsistencyPolicy{Level: tt.level}, + Regions: []cosmosdb.AccountRegion{{Name: "East US"}}, + }) + + extra := map[string]string{} + if tt.override != "" { + extra["X-Ms-Consistency-Level"] = tt.override + } + + assert.InDelta(t, tt.want, chargeOf(t, readDoc(t, h, "d", extra)), 0.02) + }) + } +} + +func TestRU_WriteCostFollowsSizeAndIndexing(t *testing.T) { + t.Parallel() + + const none = `{"indexingMode":"none"}` + + const onlyName = `{"indexingMode":"consistent","includedPaths":[{"path":"/name/?"}],"excludedPaths":[{"path":"/*"}]}` + + doc := func(id string) []byte { + return fmt.Appendf(nil, `{"id":%q,"pk":"a","name":"n","a":1,"b":2,"c":{"d":3}}`, id) + } + + create := func(h *cosmosdb.Handler, id string) float64 { + rec := doRequest(t, h, http.MethodPost, "/dbs/mydb/colls/mycoll/docs", nil, doc(id)) + require.Equal(t, http.StatusCreated, rec.Code) + + return chargeOf(t, rec) + } + + noIndex := create(ruHandler(t, none), "x") + partial := create(ruHandler(t, onlyName), "x") + full := create(ruHandler(t, ""), "x") + + assert.InDelta(t, 5.5, noIndex, 0.01, "an item that is not indexed costs the documented 5.5 RU") + assert.Greater(t, partial, noIndex, "an indexed property costs more") + assert.Greater(t, full, partial, "indexing more properties costs more") + assert.InDelta(t, 5.5+0.3*6, full, 0.01, "id, pk, name, a, b and c/d are indexed by default") + + h := ruHandler(t, none) + create(h, "y") + + replace := doRequest(t, h, http.MethodPut, "/dbs/mydb/colls/mycoll/docs/y", + map[string]string{"X-Ms-Documentdb-Partitionkey": ruPK}, doc("y")) + require.Equal(t, http.StatusOK, replace.Code) + assert.InDelta(t, 2*5.5, chargeOf(t, replace), 0.01, "a replace costs twice an insert") + + big := ruHandler(t, none) + putBig := func() float64 { + body := fmt.Sprintf(`{"id":"b","pk":"a","pad":%q}`, strings.Repeat("x", 100*1024)) + rec := doRequest(t, big, http.MethodPost, "/dbs/mydb/colls/mycoll/docs", nil, []byte(body)) + require.Equal(t, http.StatusCreated, rec.Code) + + return chargeOf(t, rec) + }() + + assert.InDelta(t, 5.5*10, putBig, 1, "writes grow with item size like reads (1 -> 10 RU at 100 KB)") +} + +func TestRU_QueryChargeFollowsRetrievedDocuments(t *testing.T) { + t.Parallel() + + query := func(h *cosmosdb.Handler, sql string) float64 { + rec := doRequest(t, h, http.MethodPost, "/dbs/mydb/colls/mycoll/docs", map[string]string{ + "X-Ms-Documentdb-Isquery": "true", "Content-Type": "application/query+json", + }, []byte(`{"query":"`+sql+`"}`)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + return chargeOf(t, rec) + } + + t.Run("one retrieved 10 KB document is the documented 3.19 RU", func(t *testing.T) { + t.Parallel() + + h := ruHandler(t, "") + b, _ := h.Backend.(*cosmosdb.InMemoryBackend) + _, err := b.CreateDocument("mydb", "mycoll", map[string]any{ + "id": "big", "pk": "a", "pad": strings.Repeat("x", 9963-len(`{"id":"big","pk":"a","pad":""}`)), + }, false) + require.NoError(t, err) + + assert.InDelta(t, 3.19, query(h, "SELECT * FROM c"), 0.05) + }) + + t.Run("a thousand 1 KB items cost about a thousand RU", func(t *testing.T) { + t.Parallel() + + h := ruHandler(t, "") + b, _ := h.Backend.(*cosmosdb.InMemoryBackend) + + for i := range 1000 { + id := fmt.Sprintf("i%04d", i) + pad := strings.Repeat("x", 1024-len(fmt.Sprintf(`{"id":%q,"pk":"a","pad":""}`, id))) + _, err := b.CreateDocument("mydb", "mycoll", map[string]any{"id": id, "pk": "a", "pad": pad}, false) + require.NoError(t, err) + } + + assert.InDelta(t, 1000, query(h, "SELECT * FROM c"), 5) + }) + + t.Run("an indexed filter loads only what it keeps", func(t *testing.T) { + t.Parallel() + + h := ruHandler(t, "") + b, _ := h.Backend.(*cosmosdb.InMemoryBackend) + + for i := range 20 { + _, err := b.CreateDocument( + "mydb", + "mycoll", + map[string]any{"id": fmt.Sprintf("i%d", i), "pk": "a", "n": i}, + false, + ) + require.NoError(t, err) + } + + all := query(h, "SELECT * FROM c") + one := query(h, "SELECT * FROM c WHERE c.n = 3") + assert.Less(t, one, all) + assert.InDelta(t, 1.4+1, one, 0.01) + }) + + t.Run("a filter on an excluded path loads every document", func(t *testing.T) { + t.Parallel() + + h := ruHandler( + t, + `{"indexingMode":"consistent","includedPaths":[{"path":"/n/?"}],"excludedPaths":[{"path":"/*"}]}`, + ) + b, _ := h.Backend.(*cosmosdb.InMemoryBackend) + + for i := range 20 { + _, err := b.CreateDocument( + "mydb", + "mycoll", + map[string]any{"id": fmt.Sprintf("i%d", i), "pk": "a", "n": i, "m": i}, + false, + ) + require.NoError(t, err) + } + + rec := doRequest(t, h, http.MethodPost, "/dbs/mydb/colls/mycoll/docs", map[string]string{ + "X-Ms-Documentdb-Isquery": "true", "Content-Type": "application/query+json", + "X-Ms-Documentdb-Query-Enable-Scan": "true", + }, []byte(`{"query":"SELECT * FROM c WHERE c.m = 3"}`)) + require.Equal(t, http.StatusOK, rec.Code) + assert.InDelta(t, 1.4+20, chargeOf(t, rec), 0.05) + }) +} + +func TestRU_CrossPartitionQueriesPayPerPartition(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scope string + want float64 + }{ + {name: "cross partition", want: 1.4 + 1 + 2.5*3}, + {name: "scoped by the partition key", scope: ruPK, want: 1.4 + 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newThroughputServer(t, false, false) + s.mustCreate("/dbs", `{"id":"d"}`, nil) + s.container("c", manualHeader(30000)) + require.Equal(t, http.StatusCreated, s.putPK("c", "k", 0).status) + + hdr := map[string]string{"X-Ms-Documentdb-Isquery": "true", "Content-Type": "application/query+json"} + if tt.scope != "" { + hdr["X-Ms-Documentdb-Partitionkey"] = `["k"]` + } + + resp := s.do(http.MethodPost, "/dbs/d/colls/c/docs", hdr, `{"query":"SELECT * FROM c"}`) + require.Equal(t, http.StatusOK, resp.status) + + got, err := strconv.ParseFloat(resp.header.Get("X-Ms-Request-Charge"), 64) + require.NoError(t, err) + assert.InDelta(t, tt.want, got, 0.1) + }) + } +} diff --git a/services/cosmosdb/script_env.go b/services/cosmosdb/script_env.go index e507fb6ccc..4178b7bc02 100644 --- a/services/cosmosdb/script_env.go +++ b/services/cosmosdb/script_env.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "github.com/dop251/goja" @@ -18,14 +19,15 @@ const ( // scriptEnv is the server-side API surface (getContext) a stored procedure or trigger runs against: one // transaction, one partition. type scriptEnv struct { - vm *goja.Runtime - tx *containerTx - reqObj goja.Value - respObj goja.Value - scope string - logs []string - reqSet bool - respSet bool + reqObj goja.Value + respObj goja.Value + vm *goja.Runtime + tx *containerTx + cbOptions map[string]any + scope string + logs []string + reqSet bool + respSet bool } func newScriptEnv(vm *goja.Runtime, tx *containerTx, scope string) *scriptEnv { @@ -140,7 +142,14 @@ func (e *scriptEnv) invoke(cb goja.Callable, res any, err error) { resVal = toJS(e.vm, res) } - if _, callErr := cb(goja.Undefined(), errVal, resVal, e.vm.NewObject()); callErr != nil { + opts := e.vm.NewObject() + for k, v := range e.cbOptions { + _ = opts.Set(k, v) + } + + e.cbOptions = nil + + if _, callErr := cb(goja.Undefined(), errVal, resVal, opts); callErr != nil { panic(callErr) } } @@ -275,12 +284,84 @@ func (e *scriptEnv) queryDocuments(args []goja.Value) (any, error) { return nil, err } + opts := feedOptions(argAt(args, queryOptionsArg)) + + stmt, err := e.tx.b.parseCached(query) + if err != nil { + return nil, err + } + + scan := QueryOptions{AllowScan: opts.enableScan} + if idxErr := checkQueryIndexing(stmt, e.tx.c.IndexingPolicy, scan, e.tx.b.computedExprs(e.tx.c)); idxErr != nil { + return nil, idxErr + } + res, err := e.tx.b.queryLocked(e.tx.c, e.tx.dbID, e.tx.collID, query, params, e.scope) if err != nil { return nil, err } - return res.Rows, nil + return e.page(res.Rows, opts) +} + +// Server-side FeedOptions (azure-cosmosdb-js-server, Collection.queryDocuments): pageSize defaults to 100, +// continuation resumes an enumeration, enableScan lets a query the indexing policy cannot serve run anyway. +const ( + queryOptionsArg = 2 + defaultScriptPage = 100 + continuationOptions = "continuation" +) + +type scriptFeedOptions struct { + continuation string + pageSize int + enableScan bool +} + +func feedOptions(v goja.Value) scriptFeedOptions { + out := scriptFeedOptions{pageSize: defaultScriptPage} + + obj, ok := v.(*goja.Object) + if !ok { + return out + } + + if n := obj.Get("pageSize"); n != nil && !goja.IsUndefined(n) && n.ToInteger() > 0 { + out.pageSize = int(n.ToInteger()) + } + + if c := obj.Get(continuationOptions); c != nil && !goja.IsUndefined(c) && !goja.IsNull(c) { + out.continuation = c.String() + } + + if s := obj.Get("enableScan"); s != nil && !goja.IsUndefined(s) { + out.enableScan = s.ToBoolean() + } + + return out +} + +// page returns one page of rows and records the continuation the callback receives in its options argument. +func (e *scriptEnv) page(rows []any, opts scriptFeedOptions) ([]any, error) { + start := 0 + + if opts.continuation != "" { + n, err := strconv.Atoi(opts.continuation) + if err != nil || n < 0 || n > len(rows) { + return nil, fmt.Errorf("%w: invalid continuation token", ErrInvalidDocument) + } + + start = n + } + + end := min(start+opts.pageSize, len(rows)) + e.cbOptions = nil + + if end < len(rows) { + e.cbOptions = map[string]any{continuationOptions: strconv.Itoa(end)} + } + + return rows[start:end], nil } // queryArg accepts a query string or a {query, parameters} object. diff --git a/services/cosmosdb/script_exec.go b/services/cosmosdb/script_exec.go index 0040f9ca3f..20c7da7e82 100644 --- a/services/cosmosdb/script_exec.go +++ b/services/cosmosdb/script_exec.go @@ -22,6 +22,10 @@ const sprocOptimisticTries = 4 // failure, not at all. The script runs on a private copy of the container without holding the backend lock and is // committed only if the container was not written meanwhile; after repeated conflicts it runs under the lock. func (b *InMemoryBackend) ExecuteSproc(dbID, collID, id, partitionKey string, args []any) (ScriptOutput, error) { + if b.cur.Load() != nil { + return b.executeSprocLocked(dbID, collID, id, partitionKey, args) + } + for range sprocOptimisticTries { out, done, err := b.trySprocOptimistic(dbID, collID, id, partitionKey, args) if done { @@ -37,7 +41,7 @@ func (b *InMemoryBackend) trySprocOptimistic( ) (ScriptOutput, bool, error) { b.mu.RLock("ExecuteSproc") - tx, err := b.beginTx(dbID, collID) + tx, err := b.beginTxAt(dbID, collID, false) if err != nil { b.mu.RUnlock() @@ -60,6 +64,7 @@ func (b *InMemoryBackend) trySprocOptimistic( script := *s live := tx.c shadow := tx.c.shadowCopy() + shadow.keep = b.changeRetention() b.mu.RUnlock() stx := &containerTx{b: b, c: shadow, dbID: dbID, collID: collID, now: tx.now, seq0: tx.seq0} @@ -68,7 +73,12 @@ func (b *InMemoryBackend) trySprocOptimistic( b.mu.Lock("ExecuteSproc") defer b.mu.Unlock() - if cur, findErr := b.findContainer(dbID, collID); findErr != nil || cur != live || live.ChangeSeq != tx.seq0 { + if cur, findErr := b.findDataContainer( + dbID, + collID, + false, + ); findErr != nil || cur != live || + live.ChangeSeq != tx.seq0 { return ScriptOutput{}, false, nil } @@ -77,6 +87,7 @@ func (b *InMemoryBackend) trySprocOptimistic( } live.Documents, live.ChangeLog, live.ChangeSeq = shadow.Documents, shadow.ChangeLog, shadow.ChangeSeq + live.ChangeFloor = shadow.ChangeFloor live.sorted.Store(nil) return out, true, nil @@ -92,6 +103,7 @@ func (c *storedContainer) shadowCopy() *storedContainer { UniqueKeyPolicy: c.UniqueKeyPolicy, GeospatialConfig: c.GeospatialConfig, VectorEmbeddingPolicy: c.VectorEmbeddingPolicy, FullTextPolicy: c.FullTextPolicy, ComputedProperties: c.ComputedProperties, ChangeLog: slices.Clip(c.ChangeLog), ChangeSeq: c.ChangeSeq, + ChangeFloor: c.ChangeFloor, keep: c.keep, } } diff --git a/services/cosmosdb/script_ops.go b/services/cosmosdb/script_ops.go index 094be7ca2d..06d18cb2d6 100644 --- a/services/cosmosdb/script_ops.go +++ b/services/cosmosdb/script_ops.go @@ -152,7 +152,7 @@ func (h *Handler) methodNotAllowed(c *echo.Context) error { } func (h *Handler) handleScripts(c *echo.Context, kind scriptKind, isItem bool, dbID, collID, id string) error { - sb, ok := h.Backend.(scriptBackend) + sb, ok := h.be(c).(scriptBackend) if !ok { return h.writeError( c, @@ -396,7 +396,7 @@ type triggerRunner interface { // runWithTriggers executes a document write with its pre/post triggers and writes the response. func (h *Handler) runWithTriggers(c *echo.Context, dbID, collID string, op docOp) error { - runner, ok := h.Backend.(triggerRunner) + runner, ok := h.be(c).(triggerRunner) if !ok { return h.writeError(c, http.StatusMethodNotAllowed, "MethodNotAllowed", "The backend has no triggers.") } @@ -428,5 +428,5 @@ func (h *Handler) runWithTriggers(c *echo.Context, dbID, collID string, op docOp payload = res.body } - return h.writeJSONCharged(c, status, payload, replaceCharge) + return h.writeJSONCharged(c, status, payload, h.writeCharger(dbID, collID, res.info.Body, true)) } diff --git a/services/cosmosdb/scripts_test.go b/services/cosmosdb/scripts_test.go index c28e032a66..1264a03bf8 100644 --- a/services/cosmosdb/scripts_test.go +++ b/services/cosmosdb/scripts_test.go @@ -604,8 +604,16 @@ func TestContainer_ConflictResolutionPolicy(t *testing.T) { custom := s.do(http.MethodPut, "/dbs/d/colls/lww", nil, `{"id":"lww","partitionKey":{"paths":["/pk"],"kind":"Hash"},`+ `"conflictResolutionPolicy":{"mode":"Custom","conflictResolutionProcedure":"dbs/d/colls/lww/sprocs/resolve"}}`) - require.Equal(t, http.StatusOK, custom.status) - assert.Equal(t, "Custom", custom.object(t)["conflictResolutionPolicy"].(map[string]any)["mode"]) + assert.Equal(t, http.StatusBadRequest, custom.status, "the policy can only be set when the container is created") + + same := s.do(http.MethodPut, "/dbs/d/colls/lww", nil, + `{"id":"lww","partitionKey":{"paths":["/pk"],"kind":"Hash"},`+ + `"conflictResolutionPolicy":{"mode":"LastWriterWins","conflictResolutionPath":"/v"}}`) + assert.Equal(t, http.StatusOK, same.status, "replacing with the same policy is allowed") + + bad := s.do(http.MethodPost, "/dbs/d/colls", nil, + `{"id":"bad","partitionKey":{"paths":["/pk"],"kind":"Hash"},"conflictResolutionPolicy":{"mode":"Newest"}}`) + assert.Equal(t, http.StatusBadRequest, bad.status) } func TestPersistence_ScriptsAndChangeFeedSurviveRestore(t *testing.T) { @@ -716,3 +724,36 @@ func TestSproc_ConcurrentWriteSameContainerIsSerialized(t *testing.T) { assert.Equal(t, http.StatusOK, s.get(fmt.Sprintf("n%d", i)).status, "each run saw the previous run's write") } } + +func TestSproc_ConcurrentAcrossContainersIsRaceFree(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + require.Equal(t, http.StatusCreated, s.do(http.MethodPost, "/dbs/d/colls", nil, + `{"id":"c2","partitionKey":{"paths":["/pk"],"kind":"Hash"}}`).status) + + body := scriptJSON(t, map[string]string{ + "id": "noop", "body": `function noop(){ getContext().getResponse().setBody(1); }`, + }) + for _, coll := range []string{"c", "c2"} { + require.Equal(t, http.StatusCreated, s.do(http.MethodPost, "/dbs/d/colls/"+coll+"/sprocs", nil, body).status) + } + + headers := map[string]string{"X-Ms-Documentdb-Partitionkey": pkA} + + var wg sync.WaitGroup + + for i := range 32 { + coll := []string{"c", "c2"}[i%2] + + wg.Go(func() { + res := s.do(http.MethodPost, "/dbs/d/colls/"+coll+"/sprocs/noop", headers, "[]") + assert.Equal(t, http.StatusOK, res.status) + }) + wg.Go(func() { + s.do(http.MethodGet, "/dbs/d/colls/"+coll+"/docs/missing", headers, "") + }) + } + + wg.Wait() +} diff --git a/services/cosmosdb/sdk_test.go b/services/cosmosdb/sdk_test.go index 3cec401a1f..9a85b0b09a 100644 --- a/services/cosmosdb/sdk_test.go +++ b/services/cosmosdb/sdk_test.go @@ -88,7 +88,7 @@ func TestSDK_MasterKeyAuthEnforced(t *testing.T) { nil, ) require.NoError(t, err) - assert.InDelta(t, 5.5, float64(resp.RequestCharge), 0.01) + assert.InDelta(t, 6.1, float64(resp.RequestCharge), 0.01) }) } } diff --git a/services/cosmosdb/search_coll.go b/services/cosmosdb/search_coll.go index bd35133da8..faa2c07038 100644 --- a/services/cosmosdb/search_coll.go +++ b/services/cosmosdb/search_coll.go @@ -8,18 +8,25 @@ import ( // queryColl is the container context a query's search functions need: the vector and full-text policies, the // indexed paths, and the corpus statistics BM25 scoring uses. type queryColl struct { - rows queryRows - vectors map[string]vectorEmbedding - vectorIdx map[string]string - ftPaths map[string]struct{} - ftIdx map[string]struct{} - ftStats map[string]*bm25Stats + rows queryRows + vectors map[string]vectorEmbedding + vectorIdx map[string]string + ftPaths map[string]struct{} + ftLang map[string]string + ftIdx map[string]struct{} + ftStats map[string]*bm25Stats + ftDefaultLang string + geometry bool } func (b *InMemoryBackend) newQueryColl(c *storedContainer) *queryColl { q := &queryColl{ - vectors: map[string]vectorEmbedding{}, vectorIdx: map[string]string{}, - ftPaths: map[string]struct{}{}, ftIdx: map[string]struct{}{}, ftStats: map[string]*bm25Stats{}, + vectors: map[string]vectorEmbedding{}, + vectorIdx: map[string]string{}, + ftPaths: map[string]struct{}{}, + ftLang: map[string]string{}, + ftIdx: map[string]struct{}{}, + ftStats: map[string]*bm25Stats{}, } if vp, err := parseVectorPolicy(c.VectorEmbeddingPolicy); err == nil { @@ -29,11 +36,16 @@ func (b *InMemoryBackend) newQueryColl(c *storedContainer) *queryColl { } if fp, err := parseFullTextPolicy(c.FullTextPolicy); err == nil { + q.ftDefaultLang = fp.DefaultLanguage + for _, p := range fp.FullTextPaths { q.ftPaths[p.Path] = struct{}{} + q.ftLang[p.Path] = p.Language } } + q.geometry = geospatialType(c.GeospatialConfig) == geoTypeGeometry + pol := parseIndexingPolicy(c.IndexingPolicy) for _, vi := range pol.VectorIndexes { diff --git a/services/cosmosdb/settings.go b/services/cosmosdb/settings.go index 9d61a8caf7..963c13e2d1 100644 --- a/services/cosmosdb/settings.go +++ b/services/cosmosdb/settings.go @@ -46,10 +46,15 @@ type Settings struct { BurstCapacity bool `json:"burstCapacity" env:"COSMOSDB_BURST_CAPACITY" default:"false" name:"burst-capacity" help:"Let a partition spend up to 300 s of idle RU/s at up to 3000 RU/s before throttling (opt-in; needs --cosmosdb-enforce-throughput)."` //nolint:lll // config struct tags are intentionally verbose // Serverless models a serverless account: throughput cannot be provisioned and containers burst to 5000 RU/s. Serverless bool `json:"serverless" env:"COSMOSDB_SERVERLESS" default:"false" name:"serverless" help:"Model a serverless Cosmos DB account: reject throughput provisioning and cap containers at 5000 RU/s (needs --cosmosdb-enforce-throughput to throttle)."` //nolint:lll // config struct tags are intentionally verbose + // ResourceTokenMaxSeconds is the longest accepted resource-token lifetime (600..86400). + ResourceTokenMaxSeconds int `json:"resourceTokenMaxSeconds" env:"COSMOSDB_RESOURCE_TOKEN_MAX_SECONDS" default:"86400" name:"resource-token-max-seconds" help:"Maximum resource-token lifetime in seconds, 600..86400 (Service quotas: 86400; the 2020 Create Permission page says 18000)."` //nolint:lll // config struct tags are intentionally verbose } // DefaultSettings returns the default Settings. Used when no ConfigProvider // is available at init time (e.g. tests constructing a Provider directly). func DefaultSettings() Settings { - return Settings{Port: DefaultPort, MasterKey: DefaultMasterKey, ValidateAuth: false} + return Settings{ + Port: DefaultPort, MasterKey: DefaultMasterKey, ValidateAuth: false, + ResourceTokenMaxSeconds: maxTokenExpirySeconds, + } } diff --git a/services/cosmosdb/spatial_index.go b/services/cosmosdb/spatial_index.go new file mode 100644 index 0000000000..f8f0008ca6 --- /dev/null +++ b/services/cosmosdb/spatial_index.go @@ -0,0 +1,76 @@ +package cosmosdb + +import ( + "encoding/json" + "fmt" + "slices" + "strings" +) + +// Spatial index types (azcosmos SpatialType, azcosmos v1.5.0 spatial_type.go; Learn "Indexing policies"). +func spatialIndexTypes() []string { return []string{"Point", "Polygon", "MultiPolygon", "LineString"} } + +// spatialIndex is one spatialIndexes entry. BoundingBox is required for a Geometry container (the .NET SpatialPath +// example carries it) and meaningless for Geography. +type spatialIndex struct { + BoundingBox *boundingBox `json:"boundingBox"` + Path string `json:"path"` + Types []string `json:"types"` +} + +type boundingBox struct { + XMin *float64 `json:"xmin"` + YMin *float64 `json:"ymin"` + XMax *float64 `json:"xmax"` + YMax *float64 `json:"ymax"` +} + +func (b boundingBox) valid() bool { + return b.XMin != nil && b.YMin != nil && b.XMax != nil && b.YMax != nil && *b.XMin < *b.XMax && *b.YMin < *b.YMax +} + +// geospatialType is the container's geospatial data type; Geography unless the config says Geometry. +func geospatialType(raw json.RawMessage) string { + var cfg struct { + Type string `json:"type"` + } + + if json.Unmarshal(raw, &cfg) == nil && cfg.Type == geoTypeGeometry { + return geoTypeGeometry + } + + return geoTypeGeography +} + +func spatialPathOK(p string) bool { + return strings.HasPrefix(p, "/") && (strings.HasSuffix(p, "/?") || strings.HasSuffix(p, "/*")) +} + +// validateSpatialIndexes checks the policy's spatial indexes: a path ending in /? or /*, one or more known types, +// and a well-formed bounding box on every index of a Geometry container. +func validateSpatialIndexes(spatial []spatialIndex, geoType string) error { + for _, idx := range spatial { + if !spatialPathOK(idx.Path) { + return fmt.Errorf("%w: spatial index path %q must start with '/' and end with '/?' or '/*'", + ErrInvalidContainerPolicy, idx.Path) + } + + if len(idx.Types) == 0 { + return fmt.Errorf("%w: spatial index %q needs at least one type", ErrInvalidContainerPolicy, idx.Path) + } + + for _, t := range idx.Types { + if !slices.Contains(spatialIndexTypes(), t) { + return fmt.Errorf("%w: spatial index %q has the unknown type %q (use %s)", + ErrInvalidContainerPolicy, idx.Path, t, strings.Join(spatialIndexTypes(), ", ")) + } + } + + if geoType == geoTypeGeometry && (idx.BoundingBox == nil || !idx.BoundingBox.valid()) { + return fmt.Errorf("%w: spatial index %q on a Geometry container needs a boundingBox with "+ + "xmin < xmax and ymin < ymax", ErrInvalidContainerPolicy, idx.Path) + } + } + + return nil +} diff --git a/services/cosmosdb/spatial_index_test.go b/services/cosmosdb/spatial_index_test.go new file mode 100644 index 0000000000..69dafd0203 --- /dev/null +++ b/services/cosmosdb/spatial_index_test.go @@ -0,0 +1,153 @@ +package cosmosdb_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func containerBody(id, indexing, geo string) string { + extra := "" + if indexing != "" { + extra += `,"indexingPolicy":` + indexing + } + + if geo != "" { + extra += `,"geospatialConfig":{"type":"` + geo + `"}` + } + + return fmt.Sprintf(`{"id":%q,"partitionKey":{"paths":["/pk"],"kind":"Hash"}%s}`, id, extra) +} + +func TestSpatialIndex_Validation(t *testing.T) { + t.Parallel() + + const bbox = `"boundingBox":{"xmin":0,"ymin":0,"xmax":10,"ymax":10}` + + policy := func(spatial string) string { + return `{"indexingMode":"consistent","includedPaths":[{"path":"/*"}],"excludedPaths":[],"spatialIndexes":[` + + spatial + `]}` + } + + tests := []struct { + name string + spatial string + geo string + want int + }{ + {name: "geography point and polygon", spatial: `{"path":"/location/*","types":["Point","Polygon"]}`, want: 201}, + { + name: "all four types", + spatial: `{"path":"/g/?","types":["Point","Polygon","LineString","MultiPolygon"]}`, want: 201, + }, + { + name: "geography ignores a bounding box", + spatial: `{"path":"/g/*","types":["Point"],` + bbox + `}`, + want: 201, + }, + {name: "unknown type", spatial: `{"path":"/g/*","types":["Circle"]}`, want: 400}, + {name: "no types", spatial: `{"path":"/g/*","types":[]}`, want: 400}, + {name: "lower-case type", spatial: `{"path":"/g/*","types":["point"]}`, want: 400}, + {name: "path without a terminator", spatial: `{"path":"/g","types":["Point"]}`, want: 400}, + {name: "path without a slash", spatial: `{"path":"g/*","types":["Point"]}`, want: 400}, + { + name: "geometry with a bounding box", + spatial: `{"path":"/g/*","types":["Polygon"],` + bbox + `}`, geo: "Geometry", want: 201, + }, + { + name: "geometry without a bounding box", + spatial: `{"path":"/g/*","types":["Polygon"]}`, + geo: "Geometry", + want: 400, + }, + { + name: "geometry with an inverted box", + spatial: `{"path":"/g/*","types":["Polygon"],"boundingBox":{"xmin":5,"ymin":0,"xmax":1,"ymax":10}}`, + geo: "Geometry", want: 400, + }, + { + name: "geometry with a partial box", + spatial: `{"path":"/g/*","types":["Polygon"],"boundingBox":{"xmin":0,"ymin":0,"xmax":1}}`, + geo: "Geometry", want: 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + resp := s.do(http.MethodPost, "/dbs/d/colls", nil, containerBody("sp", policy(tt.spatial), tt.geo)) + assert.Equal(t, tt.want, resp.status, string(resp.body)) + + if tt.want != http.StatusCreated { + return + } + + got := s.do(http.MethodGet, "/dbs/d/colls/sp", nil, "") + require.Equal(t, http.StatusOK, got.status) + assert.Contains(t, string(got.body), "spatialIndexes") + + bad := policy(`{"path":"/g/*","types":["Circle"]}`) + rep := s.do(http.MethodPut, "/dbs/d/colls/sp", nil, containerBody("sp", bad, tt.geo)) + assert.Equal(t, http.StatusBadRequest, rep.status, "replace validates the same rules") + }) + } +} + +func TestStoredProcedure_QueryHonoursIndexingPolicy(t *testing.T) { + t.Parallel() + + const policy = `{"indexingMode":"consistent","includedPaths":[{"path":"/name/?"}],"excludedPaths":[{"path":"/*"}]}` + + const body = `function f(opts) { + var coll = getContext().getCollection(); + coll.queryDocuments(coll.getSelfLink(), "SELECT * FROM c WHERE c.other >= 0", opts, function (err, docs, o) { + if (err) { throw new Error(err.number + ":" + err.message); } + getContext().getResponse().setBody({n: docs.length, next: o.continuation || null}); + }); + }` + + tests := []struct { + name string + opts string + wantBody string + wantCode int + }{ + {name: "scan needed and not enabled", opts: `{}`, wantCode: 400, wantBody: "allowscanheader"}, + {name: "scan enabled", opts: `{"enableScan":true}`, wantCode: 200, wantBody: `"n":100,"next":"100"`}, + {name: "page size", opts: `{"enableScan":true,"pageSize":30}`, wantCode: 200, wantBody: `"n":30,"next":"30"`}, + { + name: "continuation", opts: `{"enableScan":true,"continuation":"100"}`, + wantCode: 200, wantBody: `"n":20,"next":null`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newLiveServer(t) + require.Equal(t, http.StatusCreated, + s.do(http.MethodPost, "/dbs/d/colls", nil, containerBody("x", policy, "")).status) + + for i := range 120 { + doc := fmt.Sprintf(`{"id":"i%d","pk":"a","name":"n","other":%d}`, i, i) + require.Equal(t, http.StatusCreated, s.do(http.MethodPost, "/dbs/d/colls/x/docs", + map[string]string{"X-Ms-Documentdb-Partitionkey": pkA}, doc).status) + } + + require.Equal(t, http.StatusCreated, s.do(http.MethodPost, "/dbs/d/colls/x/sprocs", nil, + scriptJSON(t, map[string]string{"id": "f", "body": body})).status) + + resp := s.do(http.MethodPost, "/dbs/d/colls/x/sprocs/f", + map[string]string{"X-Ms-Documentdb-Partitionkey": pkA}, "["+tt.opts+"]") + assert.Equal(t, tt.wantCode, resp.status, string(resp.body)) + assert.Contains(t, strings.ReplaceAll(string(resp.body), " ", ""), tt.wantBody) + }) + } +} diff --git a/services/cosmosdb/sql_agg.go b/services/cosmosdb/sql_agg.go index 0abd5331d6..63f0bc35d8 100644 --- a/services/cosmosdb/sql_agg.go +++ b/services/cosmosdb/sql_agg.go @@ -12,6 +12,8 @@ const ( aggAvg = "AVG" aggMin = "MIN" aggMax = "MAX" + // aggCountIf counts the rows for which its argument is true (the full-text search page uses it for facets). + aggCountIf = "COUNTIF" ) // aggAcc folds one aggregate (COUNT/SUM/AVG/MIN/MAX) over a group's values. @@ -34,6 +36,10 @@ func (a *aggAcc) add(v any) { switch a.name { case aggCount: a.count++ + case aggCountIf: + if b, ok := v.(bool); ok && b { + a.count++ + } case aggSum, aggAvg: n, ok := toNumber(v) if !ok { @@ -86,7 +92,7 @@ func (a *aggAcc) addExtreme(v any) { func (a *aggAcc) result() any { switch a.name { - case aggCount: + case aggCount, aggCountIf: return intValue(a.count) case aggSum: switch { diff --git a/services/cosmosdb/sql_eval.go b/services/cosmosdb/sql_eval.go index b4edb67cd9..dc9db26031 100644 --- a/services/cosmosdb/sql_eval.go +++ b/services/cosmosdb/sql_eval.go @@ -22,13 +22,15 @@ func isUndef(v any) bool { // evalCtx carries per-query evaluation state. group is non-nil only while evaluating a GROUP BY output row. type evalCtx struct { - udf *udfRuntime - coll *queryColl - params map[string]any - regex map[string]*regexp.Regexp - now time.Time - group [][]any - grouped bool + now time.Time + udf *udfRuntime + coll *queryColl + params map[string]any + regex map[string]*regexp.Regexp + stats *retrievalStats + group [][]any + geometry bool + grouped bool } func newEvalCtx(params []QueryParameter) *evalCtx { @@ -402,6 +404,8 @@ func (ec *evalCtx) evalCall(n *exprCall, env []any) any { } switch n.kind { + case fnDocumentID: + return documentIDOf(n, env) case fnUDF: return ec.udf.call(n.name, args) case fnVector: diff --git a/services/cosmosdb/sql_exec.go b/services/cosmosdb/sql_exec.go index 5c892113bf..773f1f9f79 100644 --- a/services/cosmosdb/sql_exec.go +++ b/services/cosmosdb/sql_exec.go @@ -71,7 +71,45 @@ func triNot(a sqlTriState) sqlTriState { // QueryResult is the output of one query execution; Scanned counts container rows examined. type QueryResult struct { Rows []any + Visited []*storedDocument + Matched []*storedDocument Scanned int + Loaded float64 +} + +// retrievalStats records which stored documents a query touched, for the request charge. +type retrievalStats struct { + visited map[*storedDocument]struct{} + matched map[*storedDocument]struct{} +} + +func newRetrievalStats() *retrievalStats { + return &retrievalStats{visited: map[*storedDocument]struct{}{}, matched: map[*storedDocument]struct{}{}} +} + +func (s *retrievalStats) visit(r *sqlRow) { + if s != nil && r.doc != nil { + s.visited[r.doc] = struct{}{} + } +} + +func (s *retrievalStats) match(env []any) { + if s == nil || len(env) == 0 { + return + } + + if r, ok := env[0].(*sqlRow); ok && r.doc != nil { + s.matched[r.doc] = struct{}{} + } +} + +func docSet(m map[*storedDocument]struct{}) []*storedDocument { + out := make([]*storedDocument, 0, len(m)) + for d := range m { + out = append(out, d) + } + + return out } // ExecuteQuery parses and runs query against docs. It never panics. @@ -116,12 +154,17 @@ func executeStatementWith( } if coll != nil { + ec.geometry = coll.geometry coll.rows, ec.coll = rs, coll if err := coll.checkStmt(ec, stmt); err != nil { return QueryResult{}, err } } + if _, stored := rs.(storedRows); stored { + ec.stats = newRetrievalStats() + } + rows, err := ec.runStmt(stmt, nil, rs) if err != nil { return QueryResult{}, err @@ -131,7 +174,15 @@ func executeStatementWith( return QueryResult{}, ec.udf.err } - return QueryResult{Rows: rows, Scanned: rs.count()}, nil + res := QueryResult{Rows: rows, Scanned: rs.count()} + if len(stmt.sources) == 0 { + res.Scanned = 0 + } + if ec.stats != nil { + res.Visited, res.Matched = docSet(ec.stats.visited), docSet(ec.stats.matched) + } + + return res, nil } type outRow struct { @@ -269,6 +320,7 @@ func (ec *evalCtx) runStmt(stmt *sqlSelectStmt, outer []any, rows queryRows) ([] } st := &runState{ec: ec, stmt: stmt, lim: lim, nsrc: len(stmt.sources)} + rows = sourceRows(stmt, rows) if stmt.distinct { st.seen = make(map[string]struct{}) } @@ -291,6 +343,8 @@ func (ec *evalCtx) runStmt(stmt *sqlSelectStmt, outer []any, rows queryRows) ([] return true } + ec.stats.match(e) + return st.emit(e) }) } @@ -330,6 +384,8 @@ func (ec *evalCtx) runGrouped(st *runState, env []any, rows queryRows) { return true } + ec.stats.match(e) + key := ec.groupKey(stmt.groupBy, e) g, ok := groups[key] @@ -425,6 +481,7 @@ func (ec *evalCtx) forEachTuple(stmt *sqlSelectStmt, env []any, rows queryRows, for i := range rows.count() { *row = rows.row(i) + ec.stats.visit(row) if !ec.expandSources(stmt, 1, env, fn) { return @@ -487,3 +544,12 @@ func (ec *evalCtx) project(stmt *sqlSelectStmt, env []any, nsrc int) any { return obj } } + +// sourceRows is the container row source of stmt: none for a statement without a FROM clause. +func sourceRows(stmt *sqlSelectStmt, rows queryRows) queryRows { + if len(stmt.sources) == 0 { + return nil + } + + return rows +} diff --git a/services/cosmosdb/sql_funcs.go b/services/cosmosdb/sql_funcs.go index b1e94363cf..7f33040387 100644 --- a/services/cosmosdb/sql_funcs.go +++ b/services/cosmosdb/sql_funcs.go @@ -121,6 +121,8 @@ const ( fnVector fnFullText fnRank + fnMore + fnDocumentID ) // udfNamespace is the prefix of a user-defined function call: udf.name(args). @@ -140,6 +142,8 @@ func builtinKind(name string) fnKind { return fnFullText case biRRF: return fnRank + case biDocumentid: + return fnDocumentID case biAbs, biCeiling, biFloor, @@ -163,9 +167,13 @@ func builtinKind(name string) fnKind { biGetcurrentticks, biGetcurrentticksstatic, biDatetimeadd, biDatetimediff, biDatetimepart, biDatetimetotimestamp, biTimestamptodatetime, biDatetimefromparts, biDatetimetoticks, biTickstodatetime: return fnDate - case aggCount, aggSum, aggAvg, aggMin, aggMax: + case aggCount, aggCountIf, aggSum, aggAvg, aggMin, aggMax: return fnAggregate default: + if moreKind(name) { + return fnMore + } + return fnUnknown } } @@ -195,6 +203,10 @@ func builtinArity(name string) (int, int) { case biLog: return 1, arityPair default: + if lo, hi, ok := moreArity(name); ok { + return lo, hi + } + return 1, 1 } } @@ -212,7 +224,9 @@ func (ec *evalCtx) callBuiltin(kind fnKind, name string, args []any) any { case fnDate: return ec.callDate(name, args) case fnSpatial: - return callSpatial(name, args) + return callSpatial(name, args, ec.geometry) + case fnMore: + return ec.callMore(name, args) default: return undef{} } @@ -839,12 +853,7 @@ func parseDateTime(v any) (time.Time, bool) { return time.Time{}, false } - t, err := time.Parse(time.RFC3339Nano, s) - if err != nil { - return time.Time{}, false - } - - return t.UTC(), true + return parseCosmosDateTime(s) } func dateTimeOp(name string, args []any) any { diff --git a/services/cosmosdb/sql_funcs_more.go b/services/cosmosdb/sql_funcs_more.go new file mode 100644 index 0000000000..4b5bfdc0a5 --- /dev/null +++ b/services/cosmosdb/sql_funcs_more.go @@ -0,0 +1,824 @@ +package cosmosdb + +import ( + "crypto/rand" + "encoding/binary" + "encoding/json" + "math" + "math/big" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "time" +) + +// Built-in functions of the Cosmos DB query language reference (Learn, "Functions") beyond the first set: +// trigonometry, 64-bit integer arithmetic and bit operations, number and date binning, more string and array +// helpers, regular-expression extraction and replacement, and the conditional IIF. +const ( + biAcos = "ACOS" + biAsin = "ASIN" + biAtan = "ATAN" + biAtn2 = "ATN2" + biCos = "COS" + biCot = "COT" + biDegrees = "DEGREES" + biRadians = "RADIANS" + biSin = "SIN" + biTan = "TAN" + biRand = "RAND" + biNumberbin = "NUMBERBIN" + biIntadd = "INTADD" + biIntsub = "INTSUB" + biIntmul = "INTMUL" + biIntdiv = "INTDIV" + biIntmod = "INTMOD" + biIntbitand = "INTBITAND" + biIntbitor = "INTBITOR" + biIntbitxor = "INTBITXOR" + biIntbitnot = "INTBITNOT" + biIntbitleftshift = "INTBITLEFTSHIFT" + biIntbitrightshift = "INTBITRIGHTSHIFT" + biArrayContainsAll = "ARRAY_CONTAINS_ALL" + biArrayContainsAny = "ARRAY_CONTAINS_ANY" + biChoose = "CHOOSE" + biObjecttoarray = "OBJECTTOARRAY" + biLastsubafter = "LASTSUBSTRINGAFTER" + biLastsubbefore = "LASTSUBSTRINGBEFORE" + biSubafter = "SUBSTRINGAFTER" + biSubbefore = "SUBSTRINGBEFORE" + biRegexextract = "REGEXEXTRACT" + biRegexextractall = "REGEXEXTRACTALL" + biRegexreplace = "REGEXREPLACE" + biRegexreplaceall = "REGEXREPLACEALL" + biStringjoin = "STRINGJOIN" + biStringsplit = "STRINGSPLIT" + biDatetimebin = "DATETIMEBIN" + biDocumentid = "DOCUMENTID" + biIif = "IIF" + biIsDatetime = "IS_DATETIME" + biStArea = "ST_AREA" + maxShift = 63 + maxRegexArgs = 4 + maxBinStartYear = 1601 + regexOptionsArg = 2 + regexGroupArg = 3 + maxFractionalDigits = 7 + fracGroup = 7 + monthGroup = 2 + dayGroup = 3 + hourGroup = 4 + minuteGroup = 5 + secondGroup = 6 + randBits = 53 + randShift = 64 - randBits +) + +var cosmosDateTime = regexp.MustCompile( + `^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,7}))?)?Z?$`, +) + +// parseCosmosDateTime accepts the date and time strings IS_DATETIME accepts (Learn, "IS_DATETIME"): a date, +// optionally followed by a T or space and hh:mm:ss with up to seven fractional digits, optionally ending in Z. +func parseCosmosDateTime(s string) (time.Time, bool) { + m := cosmosDateTime.FindStringSubmatch(s) + if m == nil { + return time.Time{}, false + } + + n := func(i int) int { + v, _ := strconv.Atoi(m[i]) + + return v + } + + nanos := 0 + + if m[fracGroup] != "" { + frac := m[fracGroup] + strings.Repeat("0", maxFractionalDigits-len(m[fracGroup])) + ticks, _ := strconv.Atoi(frac) + nanos = ticks * ticksPerNano + } + + year, month, day, hour, minute, second := n( + 1, + ), n( + monthGroup, + ), n( + dayGroup, + ), n( + hourGroup, + ), n( + minuteGroup, + ), n( + secondGroup, + ) + + t := time.Date(year, time.Month(month), day, hour, minute, second, nanos, time.UTC) + if t.Year() != year || int(t.Month()) != month || t.Day() != day || hour > 23 || minute > 59 || second > 59 { + return time.Time{}, false + } + + return t, true +} + +func moreKind(name string) bool { + switch name { + case biAcos, biAsin, biAtan, biAtn2, biCos, biCot, biDegrees, biRadians, biSin, biTan, biRand, biNumberbin, + biIntadd, biIntsub, biIntmul, biIntdiv, biIntmod, biIntbitand, biIntbitor, biIntbitxor, biIntbitnot, + biIntbitleftshift, biIntbitrightshift, biArrayContainsAll, biArrayContainsAny, biChoose, biObjecttoarray, + biLastsubafter, biLastsubbefore, biSubafter, biSubbefore, biRegexextract, biRegexextractall, biRegexreplace, + biRegexreplaceall, biStringjoin, biStringsplit, biDatetimebin, biIif, biIsDatetime, biStArea: + return true + } + + return false +} + +func moreArity(name string) (int, int, bool) { + switch name { + case biRand: + return 0, 0, true + case biAcos, biAsin, biAtan, biCos, biCot, biDegrees, biRadians, biSin, biTan, biIntbitnot, biIsDatetime, biStArea: + return 1, 1, true + case biAtn2, biIntadd, biIntsub, biIntmul, biIntdiv, biIntmod, biIntbitand, biIntbitor, biIntbitxor, + biIntbitleftshift, biIntbitrightshift, biLastsubafter, biLastsubbefore, biSubafter, biSubbefore, + biStringjoin, biStringsplit: + return arityPair, arityPair, true + case biNumberbin: + return 1, arityPair, true + case biIif: + return arityTriple, arityTriple, true + case biArrayContainsAll, biArrayContainsAny, biChoose: + return arityPair, -1, true + case biObjecttoarray: + return 1, arityTriple, true + case biRegexextract, biRegexextractall: + return arityPair, maxRegexArgs, true + case biRegexreplace, biRegexreplaceall: + return arityTriple, maxRegexArgs, true + case biDatetimebin: + return arityPair, arityQuad, true + } + + return 0, 0, false +} + +func (ec *evalCtx) callMore(name string, args []any) any { + switch name { + case biAcos, biAsin, biAtan, biAtn2, biCos, biCot, biDegrees, biRadians, biSin, biTan, biRand, biNumberbin: + return moreMath(name, args) + case biIntadd, biIntsub, biIntmul, biIntdiv, biIntmod, biIntbitand, biIntbitor, biIntbitxor, biIntbitnot, + biIntbitleftshift, biIntbitrightshift: + return intMath(name, args) + case biArrayContainsAll, biArrayContainsAny, biChoose, biObjecttoarray, biIif: + return moreCollection(name, args) + case biRegexextract, biRegexextractall, biRegexreplace, biRegexreplaceall: + return ec.regexOp(name, args) + case biDatetimebin: + return dateTimeBin(args) + case biIsDatetime: + s, ok := args[0].(string) + if !ok { + return false + } + + _, valid := parseCosmosDateTime(s) + + return valid + case biStArea: + return callSpatialArea(args[0], ec.geometry) + default: + return moreString(name, args) + } +} + +func moreMath(name string, args []any) any { + if name == biRand { + return floatResult(randomUnit()) + } + + a, ok := toNumber(args[0]) + if !ok { + return undef{} + } + + x := numFloat(a) + + switch name { + case biAtn2: + b, bok := toNumber(args[1]) + if !bok { + return undef{} + } + + return floatResult(math.Atan2(x, numFloat(b))) + case biNumberbin: + return numberBin(a, args) + default: + return trigonometry(name, x) + } +} + +const degreesPerHalfTurn = 180 + +func trigonometry(name string, x float64) any { + switch name { + case biAcos: + return floatResult(math.Acos(x)) + case biAsin: + return floatResult(math.Asin(x)) + case biAtan: + return floatResult(math.Atan(x)) + case biCos: + return floatResult(math.Cos(x)) + case biSin: + return floatResult(math.Sin(x)) + case biTan: + return floatResult(math.Tan(x)) + case biCot: + if x == 0 { + return undef{} + } + + return floatResult(1 / math.Tan(x)) + case biDegrees: + return floatResult(x * degreesPerHalfTurn / math.Pi) + default: + return floatResult(x * math.Pi / degreesPerHalfTurn) + } +} + +// numberBin rounds a down to a multiple of the bin size (default 1): floor(a / size) * size, so a negative size +// bins upward as the documented example shows. A zero or non-numeric size has no result. +func numberBin(a json.Number, args []any) any { + size := 1.0 + + if len(args) > 1 { + s, ok := toNumber(args[1]) + if !ok { + return undef{} + } + + size = numFloat(s) + } + + if size == 0 { + return undef{} + } + + if ai, aErr := a.Int64(); aErr == nil && size == math.Trunc(size) { + si := int64(size) + q := ai / si + + if ai%si != 0 && (ai < 0) != (si < 0) { + q-- + } + + return json.Number(strconv.FormatInt(q*si, 10)) + } + + return floatResult(math.Floor(numFloat(a)/size) * size) +} + +// int64Arg reads an integral number; a fractional value or a non-number has no integer reading. +func int64Arg(v any) (int64, bool) { + n, ok := toNumber(v) + if !ok { + return 0, false + } + + if i, err := n.Int64(); err == nil { + return i, true + } + + f := numFloat(n) + if f != math.Trunc(f) || math.Abs(f) >= math.MaxInt64 { + return 0, false + } + + return int64(f), true +} + +// intMath implements the INT* functions: both operands must be integers, else (and on overflow or division by +// zero) there is no result, as the documented examples with decimal and zero operands show. +func intMath(name string, args []any) any { + a, ok := int64Arg(args[0]) + if !ok { + return undef{} + } + + if name == biIntbitnot { + return json.Number(strconv.FormatInt(^a, 10)) + } + + b, bok := int64Arg(args[1]) + if !bok { + return undef{} + } + + r, valid := intBinary(name, a, b) + if !valid { + return undef{} + } + + return json.Number(strconv.FormatInt(r, 10)) +} + +func intBinary(name string, a, b int64) (int64, bool) { + switch name { + case biIntadd, biIntsub, biIntmul, biIntdiv, biIntmod: + return intArithmetic(name, a, b) + case biIntbitand: + return a & b, true + case biIntbitor: + return a | b, true + case biIntbitxor: + return a ^ b, true + } + + if b < 0 || b > maxShift { + return 0, false + } + + shift := uint(b & maxShift) + if name == biIntbitleftshift { + return a << shift, true + } + + return a >> shift, true +} + +func intArithmetic(name string, a, b int64) (int64, bool) { + switch name { + case biIntadd: + return addInt64(a, b) + case biIntsub: + return subInt64(a, b) + case biIntmul: + return intMultiply(a, b) + } + + if b == 0 || (a == math.MinInt64 && b == -1) { + return 0, false + } + + if name == biIntdiv { + return a / b, true + } + + return a % b, true +} + +func addInt64(a, b int64) (int64, bool) { + r := a + b + overflowPos := a > 0 && b > 0 && r < 0 + overflowNeg := a < 0 && b < 0 && r >= 0 + + return r, !overflowPos && !overflowNeg +} + +func subInt64(a, b int64) (int64, bool) { + r := a - b + overflowPos := a >= 0 && b < 0 && r < 0 + overflowNeg := a < 0 && b > 0 && r >= 0 + + return r, !overflowPos && !overflowNeg +} + +func intMultiply(a, b int64) (int64, bool) { + r := a * b + if a != 0 && (r/a != b || (a == -1 && b == math.MinInt64)) { + return 0, false + } + + return r, true +} + +func moreCollection(name string, args []any) any { + switch name { + case biIif: + if cond, ok := args[0].(bool); ok && cond { + return args[1] + } + + return args[arityPair] + case biChoose: + i, ok := int64Arg(args[0]) + if !ok || i < 1 || int(i) >= len(args) { + return undef{} + } + + return args[i] + case biObjecttoarray: + return objectToArray(args) + default: + return arrayContainsMany(name == biArrayContainsAll, args) + } +} + +// arrayContainsMany answers ARRAY_CONTAINS_ALL / ARRAY_CONTAINS_ANY. An undefined value to look for makes the +// answer undefined unless a defined value already decides it, as the documented examples show. +func arrayContainsMany(all bool, args []any) any { + arr, ok := args[0].([]any) + if !ok { + return undef{} + } + + sawUndef := false + + for _, want := range args[1:] { + if isUndef(want) { + sawUndef = true + + continue + } + + found := slices.ContainsFunc(arr, func(e any) bool { return deepEqual(e, want) }) + if all && !found { + return false + } + + if !all && found { + return true + } + } + + if sawUndef { + return undef{} + } + + return all +} + +func objectToArray(args []any) any { + obj, ok := args[0].(map[string]any) + if !ok { + return undef{} + } + + kName, vName := "k", "v" + + if len(args) == arityTriple { + k, kok := args[1].(string) + v, vok := args[arityPair].(string) + + if !kok || !vok { + return undef{} + } + + kName, vName = k, v + } else if len(args) != 1 { + return undef{} + } + + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + + sort.Strings(keys) + + out := make([]any, 0, len(keys)) + for _, k := range keys { + out = append(out, map[string]any{kName: k, vName: obj[k]}) + } + + return out +} + +// moreString implements the delimiter helpers, STRINGJOIN and STRINGSPLIT. The pages do not say what the +// substring helpers return when the delimiter is absent; here that has no result. +func moreString(name string, args []any) any { + if name == biStringjoin { + return stringJoin(args) + } + + ss, ok := strArgs(args, arityPair) + if !ok { + return undef{} + } + + s, delim := ss[0], ss[1] + + switch name { + case biStringsplit: + if delim == "" { + return []any{s} + } + + parts := strings.Split(s, delim) + out := make([]any, len(parts)) + + for i, p := range parts { + out[i] = p + } + + return out + case biSubafter: + if _, after, found := strings.Cut(s, delim); found { + return after + } + case biSubbefore: + if before, _, found := strings.Cut(s, delim); found { + return before + } + case biLastsubafter: + if _, after, found := strings.CutLast(s, delim); found { + return after + } + default: + if before, _, found := strings.CutLast(s, delim); found { + return before + } + } + + return undef{} +} + +func stringJoin(args []any) any { + arr, ok := args[0].([]any) + sep, sok := args[1].(string) + + if !ok || !sok { + return undef{} + } + + parts := make([]string, len(arr)) + + for i, e := range arr { + s, isStr := e.(string) + if !isStr { + return undef{} + } + + parts[i] = s + } + + return strings.Join(parts, sep) +} + +// regexOp implements REGEXEXTRACT, REGEXEXTRACTALL, REGEXREPLACE and REGEXREPLACEALL with the i, m, s and x +// options of REGEXMATCH. +func (ec *evalCtx) regexOp(name string, args []any) any { + subject, ok := args[0].(string) + pattern, pok := args[1].(string) + + if !ok || !pok { + return undef{} + } + + replace := name == biRegexreplace || name == biRegexreplaceall + + optIdx := regexOptionsArg + if replace { + optIdx = arityTriple + } + + opts := "" + + if len(args) > optIdx { + o, oOK := args[optIdx].(string) + if !oOK { + return undef{} + } + + opts = o + } + + re := ec.compileRegex(pattern, opts) + if re == nil { + return undef{} + } + + if replace { + repl, rok := args[regexOptionsArg].(string) + if !rok { + return undef{} + } + + return replaceRegex(re, subject, repl, name == biRegexreplaceall) + } + + group := 0 + + if len(args) > regexGroupArg { + g, gok := int64Arg(args[regexGroupArg]) + if !gok || g < 0 || int(g) > re.NumSubexp() { + return undef{} + } + + group = int(g) + } + + return extractRegex(re, subject, group, name == biRegexextractall) +} + +func (ec *evalCtx) compileRegex(pattern, opts string) *regexp.Regexp { + pat := pattern + + if strings.ContainsRune(opts, 'x') { + pat = stripFreeSpacing(pat) + } + + if flags := strings.Map(func(r rune) rune { + if r == 'i' || r == 'm' || r == 's' { + return r + } + + return -1 + }, opts); flags != "" { + pat = "(?" + flags + ")" + pat + } + + if re, ok := ec.regex[pat]; ok { + return re + } + + re, err := regexp.Compile(pat) + if err != nil { + return nil + } + + ec.regex[pat] = re + + return re +} + +func replaceRegex(re *regexp.Regexp, subject, repl string, all bool) any { + if all { + return re.ReplaceAllLiteralString(subject, repl) + } + + loc := re.FindStringIndex(subject) + if loc == nil { + return subject + } + + return subject[:loc[0]] + repl + subject[loc[1]:] +} + +func extractRegex(re *regexp.Regexp, subject string, group int, all bool) any { + if !all { + m := re.FindStringSubmatch(subject) + if m == nil || m[group] == "" && group > 0 && !matchedGroup(re, subject, group) { + return undef{} + } + + return m[group] + } + + out := []any{} + + for _, m := range re.FindAllStringSubmatch(subject, -1) { + out = append(out, m[group]) + } + + return out +} + +func matchedGroup(re *regexp.Regexp, subject string, group int) bool { + idx := re.FindStringSubmatchIndex(subject) + + return idx != nil && idx[2*group] >= 0 +} + +// dateTimeBin rounds a date and time down to a multiple of a bin of the given part, counted from a start date +// (default the Unix epoch): DATETIMEBIN(date, part [, size] [, start]). +func dateTimeBin(args []any) any { + s, ok := args[0].(string) + partName, pok := args[1].(string) + + if !ok || !pok { + return undef{} + } + + t, tok := parseCosmosDateTime(s) + part := parseDatePart(partName) + + size := int64(1) + + if len(args) > arityPair { + v, vok := int64Arg(args[arityPair]) + if !vok || v <= 0 { + return undef{} + } + + size = v + } + + origin := time.Unix(0, 0).UTC() + + if len(args) > arityTriple { + os, osOK := args[arityTriple].(string) + if !osOK { + return undef{} + } + + o, oOK := parseCosmosDateTime(os) + if !oOK || o.Year() < maxBinStartYear { + return undef{} + } + + origin = o + } + + if !tok || part == dpInvalid { + return undef{} + } + + out, valid := binTime(t, part, size, origin) + if !valid { + return undef{} + } + + return out.Format(dateTimeLayout) +} + +func binTime(t time.Time, part datePartKind, size int64, origin time.Time) (time.Time, bool) { + if part == dpYear { + months := int64(t.Year()-origin.Year()) * monthsPerYear + step := size * monthsPerYear + binned := floorDiv(months, step) * step + + return time.Date(origin.Year()+int(binned/monthsPerYear), origin.Month(), 1, 0, 0, 0, 0, time.UTC), true + } + + if part == dpMonth { + months := int64(t.Year()-origin.Year())*monthsPerYear + int64(t.Month()) - int64(origin.Month()) + binned := floorDiv(months, size) * size + + return time.Date(origin.Year(), origin.Month()+time.Month(binned), 1, 0, 0, 0, 0, time.UTC), true + } + + unit := part.duration() + if part == dpDay { + unit = hoursPerDay * time.Hour + } + + if unit == 0 { + return time.Time{}, false + } + + return binByUnit(t, origin, unit, size), true +} + +// binByUnit floors t to a multiple of size units since origin using integer nanoseconds wide enough for the +// 1601 bin start the function allows (a time.Duration only spans 292 years). +func binByUnit(t, origin time.Time, unit time.Duration, size int64) time.Time { + const nanosPerSecond = int64(time.Second) + + ns := func(x time.Time) *big.Int { + v := new(big.Int).Mul(big.NewInt(x.Unix()), big.NewInt(nanosPerSecond)) + + return v.Add(v, big.NewInt(int64(x.Nanosecond()))) + } + + diff := new(big.Int).Sub(ns(t), ns(origin)) + step := new(big.Int).Mul(big.NewInt(size), big.NewInt(int64(unit))) + q, _ := new(big.Int).DivMod(diff, step, new(big.Int)) + + binned := new(big.Int).Mul(q, step) + binned.Add(binned, ns(origin)) + + sec, nano := new(big.Int).DivMod(binned, big.NewInt(nanosPerSecond), new(big.Int)) + + return time.Unix(sec.Int64(), nano.Int64()).UTC() +} + +func floorDiv(a, b int64) int64 { + q := a / b + if a%b != 0 && (a < 0) != (b < 0) { + q-- + } + + return q +} + +// documentIDOf is DOCUMENTID(alias): the internal numeric id of the item, here its change-feed sequence number +// (unique within the container, which is stronger than the per-physical-partition uniqueness the page gives). +func documentIDOf(n *exprCall, env []any) any { + v, ok := n.args[0].(*exprVar) + if !ok || v.idx < 0 || v.idx >= len(env) { + return undef{} + } + + row, isRow := env[v.idx].(*sqlRow) + if !isRow || row.doc == nil { + return undef{} + } + + return json.Number(strconv.FormatInt(row.doc.Seq, 10)) +} + +// randomUnit is a uniform number in [0, 1). +func randomUnit() float64 { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + + return float64(binary.BigEndian.Uint64(b[:])>>randShift) / (1 << randBits) +} diff --git a/services/cosmosdb/sql_functions_doc_test.go b/services/cosmosdb/sql_functions_doc_test.go new file mode 100644 index 0000000000..795506e23b --- /dev/null +++ b/services/cosmosdb/sql_functions_doc_test.go @@ -0,0 +1,351 @@ +package cosmosdb_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +// Each case is an example from the query-language reference (Learn, "Functions"), with the output shown there. +func TestSQLFunctions_DocumentedExamples(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + want string + }{ + {name: "atn2", expr: `ATN2(35.175643, 129.44)`, want: `0.265344532064832`}, + { + name: "numberbin", + expr: `{a: NUMBERBIN(37.752, -100), b: NUMBERBIN(37.752, 10), c: NUMBERBIN(37.752, 1), ` + + `d: NUMBERBIN(37.752, 0), e: NUMBERBIN(37.752, 0.1), f: NUMBERBIN(37.752, 0.01)}`, + want: `{"a":100,"b":30,"c":37,"e":37.7,"f":37.75}`, + }, + {name: "numberbin default size", expr: `NUMBERBIN(-37.5)`, want: `-38`}, + { + name: "intadd", + expr: `{n: INTADD(20, 10), z: INTADD(20, 0), d: INTADD(20, 0.10)}`, + want: `{"n":30,"z":20}`, + }, + { + name: "intdiv", + expr: `{a: INTDIV(10, 2), b: INTDIV(10, -2), c: INTDIV(-10, -2), d: INTDIV(10, 10), e: INTDIV(10, 0), ` + + `f: INTDIV(10, 0.1)}`, + want: `{"a":5,"b":-5,"c":5,"d":1}`, + }, + { + name: "intmod", + expr: `{a: INTMOD(12, 5), b: INTMOD(12, -5), c: INTMOD(-12, -5), d: INTMOD(15, 5), e: INTMOD(12, 0), ` + + `f: INTMOD(12, 0.2)}`, + want: `{"a":2,"b":2,"c":-2,"d":0}`, + }, + { + name: "intmul", + expr: `{a: INTMUL(5, 2), b: INTMUL(5, -2), c: INTMUL(-5, -2), d: INTMUL(5, INTMUL(5, 5)), ` + + `e: INTMUL(5, 0), f: INTMUL(5, 0.5)}`, + want: `{"a":10,"b":-10,"c":10,"d":125,"e":0}`, + }, + {name: "intsub", expr: `{a: INTSUB(20, 10), b: INTSUB(20, 0.5)}`, want: `{"a":10}`}, + { + name: "intbitand", + expr: `{a: INTBITAND(15, 25), b: INTBITAND(15, 0), c: INTBITAND(15, 15), d: INTBITAND(15, 1.5)}`, + want: `{"a":9,"b":0,"c":15}`, + }, + {name: "intbitor", expr: `INTBITOR(15, 25)`, want: `31`}, + {name: "intbitxor", expr: `INTBITXOR(15, 25)`, want: `22`}, + { + name: "intbitnot", + expr: `{a: INTBITNOT(65), b: INTBITNOT(0), c: INTBITNOT(0.1)}`, + want: `{"a":-66,"b":-1}`, + }, + { + name: "intbitleftshift", + expr: `{a: INTBITLEFTSHIFT(16, 4), b: INTBITLEFTSHIFT(16, 0.4)}`, want: `{"a":256}`, + }, + { + name: "intbitrightshift", + expr: `{a: INTBITRIGHTSHIFT(16, 4), b: INTBITRIGHTSHIFT(16, 0.4)}`, want: `{"a":1}`, + }, + {name: "int overflow has no result", expr: `IS_DEFINED(INTMUL(9223372036854775807, 2))`, want: `false`}, + {name: "choose first", expr: `CHOOSE(1, "Vimero", "Hydration", "Pack")`, want: `"Vimero"`}, + { + name: "choose by index", + expr: `{i0: CHOOSE(0, "Mt.", "Hood"), i1: CHOOSE(1, "Mt.", "Hood"), i2: CHOOSE(2, "Mt.", "Hood"), ` + + `i3: CHOOSE(3, "Mt.", "Hood")}`, + want: `{"i1":"Mt.","i2":"Hood"}`, + }, + { + name: "objecttoarray", + expr: `OBJECTTOARRAY({"a": "12345", "b": "67890"})`, + want: `[{"k":"a","v":"12345"},{"k":"b","v":"67890"}]`, + }, + { + name: "objecttoarray custom names", + expr: `OBJECTTOARRAY({"color": "blue"}, "key", "value")`, + want: `[{"key":"color","value":"blue"}]`, + }, + { + name: "array_contains_all", + expr: `{a: ARRAY_CONTAINS_ALL([1, true, "3", [1,2,3]], 1, true, "3", [1,2,3]), ` + + `b: ARRAY_CONTAINS_ALL([1, 2, 3, 4], 2, 3, 4, 5), c: ARRAY_CONTAINS_ALL([1, 2, 3, 4], 1, undefined), ` + + `d: ARRAY_CONTAINS_ALL([1, 2, 3, 4], 5, 6, 7, 8), e: ARRAY_CONTAINS_ALL([], 1, 2, 3), ` + + `f: ARRAY_CONTAINS_ALL([1, 2, 3, 4], 5, undefined)}`, + want: `{"a":true,"b":false,"d":false,"e":false,"f":false}`, + }, + { + name: "array_contains_any", + expr: `{a: ARRAY_CONTAINS_ANY([1, 2, 3], 5, 2), b: ARRAY_CONTAINS_ANY([1, 2, 3], 5, 6), ` + + `c: ARRAY_CONTAINS_ANY([1, 2, 3], 5, undefined)}`, + want: `{"a":true,"b":false}`, + }, + { + name: "substringafter", + expr: `{a: SUBSTRINGAFTER("contoso|north|2026", "|"), b: SUBSTRINGAFTER("bike::touring::frame", "::")}`, + want: `{"a":"north|2026","b":"touring::frame"}`, + }, + {name: "substringbefore", expr: `SUBSTRINGBEFORE("contoso|north|2026", "|")`, want: `"contoso"`}, + { + name: "lastsubstringbefore", + expr: `{a: LASTSUBSTRINGBEFORE("/products/shoes/boots", "/"), b: LASTSUBSTRINGBEFORE("A-B-C", "-")}`, + want: `{"a":"/products/shoes","b":"A-B"}`, + }, + {name: "lastsubstringafter", expr: `LASTSUBSTRINGAFTER("/products/shoes/boots", "/")`, want: `"boots"`}, + { + name: "stringjoin", + expr: `{a: STRINGJOIN(["Iropa", "Mountain", "Bike"], " "), b: STRINGJOIN(["Iropa", "Mountain", "Bike"], ""), ` + + `c: STRINGJOIN(["Iropa", "Mountain", "Bike"], undefined), d: STRINGJOIN(["6", "7", "4", "3"], "A"), ` + + `e: STRINGJOIN(["Adventure", "LT"], "Works")}`, + want: `{"a":"Iropa Mountain Bike","b":"IropaMountainBike","d":"6A7A4A3","e":"AdventureWorksLT"}`, + }, + { + name: "stringsplit", + expr: `{a: STRINGSPLIT("Handlebar", "e"), b: STRINGSPLIT("CARBON_STEEL_BIKE_WHEEL", "_"), ` + + `c: STRINGSPLIT("xenmoun mountain bike", "moun"), d: STRINGSPLIT("AluminumBikeFrame", undefined), ` + + `e: STRINGSPLIT("Helmet", ""), f: STRINGSPLIT("", "")}`, + want: `{"a":["Handl","bar"],"b":["CARBON","STEEL","BIKE","WHEEL"],"c":["xen"," ","tain bike"],` + + `"e":["Helmet"],"f":[""]}`, + }, + { + name: "regexextract", + expr: `{a: REGEXEXTRACT("SKU-93821-BLUE", "[A-Z]+"), b: REGEXEXTRACT("SKU-93821-BLUE", "[0-9]+"), ` + + `c: REGEXEXTRACT("sku-93821-blue sku-48152-red", "sku-([0-9]+)-[a-z]+", "i", 1)}`, + want: `{"a":"SKU","b":"93821","c":"93821"}`, + }, + { + name: "regexextractall", + expr: `{a: REGEXEXTRACTALL("A12-B34-C56", "[0-9]+"), b: REGEXEXTRACTALL("north-east-west", "[a-z]+"), ` + + `c: REGEXEXTRACTALL("sku-93821-blue sku-48152-red", "sku-([0-9]+)-[a-z]+", "i", 1)}`, + want: `{"a":["12","34","56"],"b":["north","east","west"],"c":["93821","48152"]}`, + }, + { + name: "regexreplace", + expr: `{a: REGEXREPLACE("A12-B34-C56", "[0-9]+", "#"), b: REGEXREPLACEALL("A12-B34-C56", "[0-9]+", "#")}`, + want: `{"a":"A#-B34-C56","b":"A#-B#-C#"}`, + }, + { + name: "datetimebin", + expr: `{day: DATETIMEBIN("2021-01-08T18:35:00.0000000", "dd"), ` + + `hour: DATETIMEBIN("2021-01-08T18:35:00.0000000", "hh"), ` + + `sec: DATETIMEBIN("2021-01-08T18:35:00.0000000", "ss"), ` + + `five: DATETIMEBIN("2021-01-08T18:35:00.0000000", "hh", 5), ` + + `seven: DATETIMEBIN("2021-01-08T18:35:00.0000000", "dd", 7), ` + + `win: DATETIMEBIN("2021-01-08T18:35:00.0000000", "dd", 7, "1601-01-01T00:00:00.0000000")}`, + want: `{"day":"2021-01-08T00:00:00.0000000Z","hour":"2021-01-08T18:00:00.0000000Z",` + + `"sec":"2021-01-08T18:35:00.0000000Z","five":"2021-01-08T15:00:00.0000000Z",` + + `"seven":"2021-01-07T00:00:00.0000000Z","win":"2021-01-04T00:00:00.0000000Z"}`, + }, + { + name: "datetimebin invalid", + expr: `{a: IS_DEFINED(DATETIMEBIN("2021-01-08T18:35:00", "xx")), ` + + `b: IS_DEFINED(DATETIMEBIN("2021-01-08", "dd", 0)), ` + + `c: IS_DEFINED(DATETIMEBIN("2021-01-08", "dd", -1)), d: IS_DEFINED(DATETIMEBIN("nope", "dd")), ` + + `e: IS_DEFINED(DATETIMEBIN("2021-01-08", "dd", 1, "1500-01-01T00:00:00"))}`, + want: `{"a":false,"b":false,"c":false,"d":false,"e":false}`, + }, + { + name: "iif", + expr: `{a: IIF(true, 123, 456), b: IIF(false, 123, 456), c: IIF(123, 123, 456), d: IIF("ABC", 123, 456), ` + + `e: IIF([1,2,3], 123, 456), f: IIF({"name": "Alice"}, 123, 456)}`, + want: `{"a":123,"b":456,"c":456,"d":456,"e":456,"f":456}`, + }, + { + name: "is_datetime", + expr: `{c1: IS_DATETIME("2024-12-12"), c2: IS_DATETIME("2024-12-12Z"), c3: IS_DATETIME("2024-12-12 20:21:25"), ` + + `c4: IS_DATETIME("2024-12-12T20:21:25Z"), c5: IS_DATETIME("2024-12-12T20:21:25.123"), ` + + `c6: IS_DATETIME("2024-12-12 20:21:25.123Z"), c7: IS_DATETIME("2024-12-12T20Z"), ` + + `c8: IS_DATETIME("2024-12-12 20:21:25."), c9: IS_DATETIME("2024-13-12"), c10: IS_DATETIME(5)}`, + want: `{"c1":true,"c2":true,"c3":true,"c4":true,"c5":true,"c6":true,"c7":false,"c8":false,"c9":false,"c10":false}`, + }, + { + name: "trigonometry", + expr: `{sin: SIN(0), cos: COS(0), tan: TAN(0), asin: ASIN(1), acos: ACOS(1), atan: ATAN(0), ` + + `bad: IS_DEFINED(ACOS(2)), cot0: IS_DEFINED(COT(0))}`, + want: `{"sin":0,"cos":1,"tan":0,"asin":1.5707963267948966,"acos":0,"atan":0,"bad":false,"cot0":false}`, + }, + { + name: "degrees and radians", + expr: `{d: DEGREES(PI()), r: RADIANS(180)}`, + want: `{"d":180,"r":3.141592653589793}`, + }, + {name: "rand is in range", expr: `RAND() >= 0 AND RAND() < 1`, want: `true`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rows, err := cosmosdb.ExecuteQuery("SELECT VALUE "+tt.expr+" FROM c", nil, docInfos(t, `{"id":"1"}`)) + require.NoError(t, err) + require.Len(t, rows, 1) + + got, err := json.Marshal(rows[0]) + require.NoError(t, err) + assert.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestSQLFunctions_DocumentIDIsPerItem(t *testing.T) { + t.Parallel() + + b := cosmosdb.NewInMemoryBackend() + _, err := b.CreateDatabase("d") + require.NoError(t, err) + _, err = b.CreateContainer("d", cosmosdb.ContainerSpec{ID: "c", PartitionKeyPath: "/pk"}) + require.NoError(t, err) + + for _, id := range []string{"a", "b"} { + _, err = b.CreateDocument("d", "c", map[string]any{"id": id, "pk": "k"}, false) + require.NoError(t, err) + } + + res, err := b.RunQuery("d", "c", "SELECT c.id, DOCUMENTID(c) AS n FROM c ORDER BY c.id", nil, "") + require.NoError(t, err) + require.Len(t, res.Rows, 2) + + first, second := res.Rows[0].(map[string]any), res.Rows[1].(map[string]any) + assert.NotEqual(t, first["n"], second["n"]) +} + +func TestSQL_OrderByWithGroupByIsRejected(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + ok bool + }{ + {name: "group by alone", query: "SELECT c.pk FROM c GROUP BY c.pk", ok: true}, + {name: "order by alone", query: "SELECT c.pk FROM c ORDER BY c.pk", ok: true}, + {name: "both", query: "SELECT c.pk FROM c GROUP BY c.pk ORDER BY c.pk"}, + {name: "both with an aggregate", query: "SELECT c.pk, COUNT(1) AS n FROM c GROUP BY c.pk ORDER BY n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := cosmosdb.ParseQuery(tt.query) + if tt.ok { + require.NoError(t, err) + + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), "'ORDER BY' is not supported in presence of GROUP BY.") + }) + } +} + +const productDoc = `{"id":"1","name":"Remdriel Shoes","price":15,"tags":[{"key":"fabric","value":"leather"},` + + `{"key":"x","value":"y"}],"sizes":[{"key":"5"},{"key":"9"}],"colors":["cobalt","jam"]}` + +// The statements are the SELECT and subquery pages' examples: FROM-less queries, positional names, multi-value +// subqueries as join sources, scalar and ARRAY subqueries. +func TestSQL_DocumentedStatementForms(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + want string + }{ + {name: "static strings", query: `SELECT "Cosmic", "Works"`, want: `[{"$1":"Cosmic","$2":"Works"}]`}, + {name: "static value", query: `SELECT VALUE "Cosmic Works"`, want: `["Cosmic Works"]`}, + { + name: "scalar subqueries", + query: `SELECT (SELECT VALUE 1) AS a, (SELECT VALUE 2) AS b`, + want: `[{"a":1,"b":2}]`, + }, + {name: "undefined value", query: `SELECT VALUE undefined`, want: `[]`}, + {name: "undefined projection", query: `SELECT undefined`, want: `[{}]`}, + {name: "exists of an undefined value", query: `SELECT VALUE EXISTS (SELECT VALUE undefined)`, want: `[false]`}, + {name: "exists of an empty object", query: `SELECT VALUE EXISTS (SELECT undefined)`, want: `[true]`}, + { + name: "join an anonymous multi-value subquery", + query: `SELECT VALUE COUNT(1) FROM products p ` + + `JOIN (SELECT VALUE t FROM t IN p.tags WHERE t.key IN ("fabric","material"))`, + want: `[1]`, + }, + { + name: "evaluate once with a join subquery", + query: `SELECT VALUE {subtotal: p.price, total: totalPrice} FROM products p ` + + `JOIN (SELECT VALUE p.price * 1.25) totalPrice WHERE totalPrice < 22.25`, + want: `[{"subtotal":15,"total":18.75}]`, + }, + { + name: "aggregate join subqueries", + query: `SELECT p.name, colorCount FROM products p JOIN (SELECT VALUE COUNT(1) FROM c IN p.colors) AS colorCount ` + + `WHERE colorCount >= 2`, + want: `[{"name":"Remdriel Shoes","colorCount":2}]`, + }, + { + name: "reference data join", + query: `SELECT s.key, m.unit FROM products p JOIN s IN p.sizes ` + + `JOIN m IN (SELECT VALUE [{size:'5', unit:'cm'}]) WHERE s.key = m.size`, + want: `[{"key":"5","unit":"cm"}]`, + }, + { + name: "array expression after FROM", + query: `SELECT p.name, z.s.key AS sizes FROM products p JOIN z IN (SELECT VALUE ARRAY(SELECT s FROM s IN p.sizes ` + + `WHERE STRINGTONUMBER(s.key) <= 8))`, + want: `[{"name":"Remdriel Shoes","sizes":"5"}]`, + }, + { + name: "filtering scalar subquery", + query: `SELECT p.id, (SELECT p.name WHERE CONTAINS(p.name, "Shoes")).name FROM products p`, + want: `[{"id":"1","name":"Remdriel Shoes"}]`, + }, + { + name: "countif facets", + query: `SELECT COUNTIF(p.price < 25) AS under25, COUNTIF(p.price >= 25) AS over FROM products p`, + want: `[{"under25":1,"over":0}]`, + }, + {name: "select star needs a source", query: `SELECT *`, want: ``}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rows, err := cosmosdb.ExecuteQuery(tt.query, nil, docInfos(t, productDoc)) + if tt.want == "" { + require.Error(t, err) + + return + } + + require.NoError(t, err) + + got, marshalErr := json.Marshal(rows) + require.NoError(t, marshalErr) + assert.JSONEq(t, tt.want, string(got)) + }) + } +} diff --git a/services/cosmosdb/sql_geography.go b/services/cosmosdb/sql_geography.go new file mode 100644 index 0000000000..eb44e41738 --- /dev/null +++ b/services/cosmosdb/sql_geography.go @@ -0,0 +1,353 @@ +package cosmosdb + +import ( + "math" +) + +// geographySpace computes on the WGS 84 earth: geodesic distances on the ellipsoid (Vincenty), edges as great-circle +// arcs, polygon membership by the interior-on-the-left rule (a clockwise ring is the complement of the region it +// encloses, per the geospatial page) and areas on the authalic sphere. Against the Learn examples this gives +// 3.34526974 km for the documented 3.34526982 km distance and 735,970,283.11 m2 for the documented 735,970,283.05. +type geographySpace struct{} + +type vec3 [3]float64 + +func (a vec3) dot(b vec3) float64 { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2] } + +func (a vec3) cross(b vec3) vec3 { + return vec3{a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]} +} + +func (a vec3) scale(k float64) vec3 { return vec3{a[0] * k, a[1] * k, a[2] * k} } + +func (a vec3) sub(b vec3) vec3 { return vec3{a[0] - b[0], a[1] - b[1], a[2] - b[2]} } + +func (a vec3) norm() float64 { return math.Sqrt(a.dot(a)) } + +func (a vec3) unit() vec3 { + n := a.norm() + if n == 0 { + return a + } + + return a.scale(1 / n) +} + +func vecOf(p geoPos) vec3 { + lon, lat := p[0]*degToRad, p[1]*degToRad + + return vec3{math.Cos(lat) * math.Cos(lon), math.Cos(lat) * math.Sin(lon), math.Sin(lat)} +} + +func posOf(v vec3) geoPos { + return geoPos{math.Atan2(v[1], v[0]) / degToRad, math.Asin(math.Max(-1, math.Min(1, v[2]))) / degToRad} +} + +// dist is the geodesic distance in metres on the WGS 84 ellipsoid. +func (geographySpace) dist(a, b geoPos) float64 { + if a == b { + return 0 + } + + if d, ok := vincenty(a, b); ok { + return d + } + + return haversine(a, b) +} + +func haversine(a, b geoPos) float64 { + dLat := (b[1] - a[1]) * degToRad + dLon := (b[0] - a[0]) * degToRad + h := math.Sin(dLat/halfDivisor)*math.Sin(dLat/halfDivisor) + + math.Cos(a[1]*degToRad)*math.Cos(b[1]*degToRad)*math.Sin(dLon/halfDivisor)*math.Sin(dLon/halfDivisor) + + return halfDivisor * meanRadius * math.Asin(math.Min(1, math.Sqrt(h))) +} + +// Coefficients of Vincenty's inverse formulae (Vincenty 1975). +const ( + vincLambdaDiv = 16.0 + vincLambdaK = 4.0 + vincLambdaK2 = 3.0 + vincADiv = 16384.0 + vincAK0 = 4096.0 + vincAK1 = -768.0 + vincAK2 = 320.0 + vincAK3 = -175.0 + vincBDiv = 1024.0 + vincBK0 = 256.0 + vincBK1 = -128.0 + vincBK2 = 74.0 + vincBK3 = -47.0 + vincBQuarter = 4.0 + vincBSixth = 6.0 + vincBSq = 3.0 + vincBSq2 = 4.0 + doubleAngle = 2.0 + minRingPoints = 3 +) + +// vincenty is the inverse geodesic problem on the ellipsoid; ok is false for near-antipodal points where the +// iteration does not converge. +func vincenty(p1, p2 geoPos) (float64, bool) { + b := (1 - wgsF) * wgsA + l := (p2[0] - p1[0]) * degToRad + u1 := math.Atan((1 - wgsF) * math.Tan(p1[1]*degToRad)) + u2 := math.Atan((1 - wgsF) * math.Tan(p2[1]*degToRad)) + sU1, cU1, sU2, cU2 := math.Sin(u1), math.Cos(u1), math.Sin(u2), math.Cos(u2) + lam := l + + for range vincentyIters { + sl, cl := math.Sin(lam), math.Cos(lam) + ss := math.Hypot(cU2*sl, cU1*sU2-sU1*cU2*cl) + + if ss == 0 { + return 0, true + } + + cs := sU1*sU2 + cU1*cU2*cl + sig := math.Atan2(ss, cs) + sa := cU1 * cU2 * sl / ss + c2a := 1 - sa*sa + + c2sm := 0.0 + if c2a != 0 { + c2sm = cs - doubleAngle*sU1*sU2/c2a + } + + c := wgsF / vincLambdaDiv * c2a * (vincLambdaK + wgsF*(vincLambdaK-vincLambdaK2*c2a)) + prev := lam + lam = l + (1-c)*wgsF*sa*(sig+c*ss*(c2sm+c*cs*(-1+doubleAngle*c2sm*c2sm))) + + if math.Abs(lam-prev) < vincentyTol { + u2sq := c2a * (wgsA*wgsA - b*b) / (b * b) + a := 1 + u2sq/vincADiv*(vincAK0+u2sq*(vincAK1+u2sq*(vincAK2+vincAK3*u2sq))) + bb := u2sq / vincBDiv * (vincBK0 + u2sq*(vincBK1+u2sq*(vincBK2+vincBK3*u2sq))) + inner := cs*(-1+doubleAngle*c2sm*c2sm) - + bb/vincBSixth*c2sm*(-vincBSq+vincBSq2*ss*ss)*(-vincBSq+vincBSq2*c2sm*c2sm) + ds := bb * ss * (c2sm + bb/vincBQuarter*inner) + + return b * a * (sig - ds), true + } + } + + return 0, false +} + +func (geographySpace) onSegment(p, a, b geoPos) bool { + return onArc(vecOf(p), vecOf(a), vecOf(b)) +} + +// onArc reports whether p lies on the great-circle arc a-b. +func onArc(p, a, b vec3) bool { + n := a.cross(b) + if n.norm() < arcEpsilon { + return p.sub(a).norm() < geoEpsilon + } + + n = n.unit() + if math.Abs(p.dot(n)) > geoEpsilon { + return false + } + + return a.cross(p).dot(n) >= -geoEpsilon && p.cross(b).dot(n) >= -geoEpsilon +} + +func (sp geographySpace) segsIntersect(s, t [2]geoPos) bool { + a1, a2, b1, b2 := vecOf(s[0]), vecOf(s[1]), vecOf(t[0]), vecOf(t[1]) + + if onArc(a1, b1, b2) || onArc(a2, b1, b2) || onArc(b1, a1, a2) || onArc(b2, a1, a2) { + return true + } + + d := a1.cross(a2).cross(b1.cross(b2)) + if d.norm() < arcEpsilon { + return false + } + + d = d.unit() + + return (onArc(d, a1, a2) && onArc(d, b1, b2)) || (onArc(d.scale(-1), a1, a2) && onArc(d.scale(-1), b1, b2)) +} + +// segsCross reports a proper crossing: the arcs meet at a point interior to both. +func (sp geographySpace) segsCross(s, t [2]geoPos) bool { + a1, a2, b1, b2 := vecOf(s[0]), vecOf(s[1]), vecOf(t[0]), vecOf(t[1]) + + d := a1.cross(a2).cross(b1.cross(b2)) + if d.norm() < arcEpsilon { + return false + } + + d = d.unit() + + for _, c := range []vec3{d, d.scale(-1)} { + if onArc(c, a1, a2) && onArc(c, b1, b2) && !endpoint(c, a1, a2, b1, b2) { + return true + } + } + + return false +} + +func endpoint(c vec3, ends ...vec3) bool { + for _, e := range ends { + if c.sub(e).norm() < geoEpsilon { + return true + } + } + + return false +} + +// segDist is the distance from p to the great-circle arc a-b: the geodesic distance to the arc's closest point. +func (sp geographySpace) segDist(p, a, b geoPos) float64 { + pv, av, bv := vecOf(p), vecOf(a), vecOf(b) + + n := av.cross(bv) + if n.norm() < arcEpsilon { + return sp.dist(p, a) + } + + n = n.unit() + q := pv.sub(n.scale(pv.dot(n))) + + if q.norm() > arcEpsilon { + q = q.unit() + if onArc(q, av, bv) { + return sp.dist(p, posOf(q)) + } + } + + return math.Min(sp.dist(p, a), sp.dist(p, b)) +} + +// ringSignedArea is the signed spherical area of the ring in steradians, normalised to (-2pi, 2pi]: positive for a +// ring that runs counter-clockwise around the smaller region it bounds. +func ringSignedArea(ring []geoPos, lat func(float64) float64) float64 { + n := len(ring) + if n > 1 && ring[0] == ring[n-1] { + n-- + } + + if n < minRingPoints { + return 0 + } + + vs := make([]vec3, n) + for i := range n { + vs[i] = vecOf(geoPos{ring[i][0], lat(ring[i][1])}) + } + + var sum float64 + + for i := 1; i < n-1; i++ { + num := vs[0].dot(vs[i].cross(vs[i+1])) + den := 1 + vs[0].dot(vs[i]) + vs[i].dot(vs[i+1]) + vs[i+1].dot(vs[0]) + sum += doubleAngle * math.Atan2(num, den) + } + + const fourPi = 4 * math.Pi + + sum = math.Mod(sum, fourPi) + + switch { + case sum > doubleAngle*math.Pi: + sum -= fourPi + case sum <= -doubleAngle*math.Pi: + sum += fourPi + } + + return sum +} + +func identityLat(l float64) float64 { return l } + +// windingAround sums the signed azimuth turns of the ring seen from p. +func windingAround(p vec3, ring []geoPos) float64 { + var sum float64 + + tangent := func(v vec3) vec3 { return v.sub(p.scale(v.dot(p))) } + + for i := 1; i < len(ring); i++ { + a, b := tangent(vecOf(ring[i-1])), tangent(vecOf(ring[i])) + if a.norm() < arcEpsilon || b.norm() < arcEpsilon { + continue + } + + sum += math.Atan2(a.cross(b).dot(p), a.dot(b)) + } + + return sum +} + +// leftOf reports whether p is in the region to the left of the ring: the smaller bounded region for a +// counter-clockwise ring, everything else for a clockwise one. +func leftOf(p vec3, ring []geoPos) bool { + turns := math.Round(windingAround(p, ring) / (doubleAngle * math.Pi)) + + if ringSignedArea(ring, identityLat) >= 0 { + return turns == 1 + } + + return turns != -1 +} + +// inPolygon: on a ring boundary, or to the left of every ring (holes run clockwise). +func (sp geographySpace) inPolygon(p geoPos, rings [][]geoPos) bool { + if len(rings) == 0 { + return false + } + + for _, r := range rings { + if onBoundary(sp, p, r) { + return true + } + } + + pv := vecOf(p) + + for _, r := range rings { + if !leftOf(pv, r) { + return false + } + } + + return true +} + +// area is the polygon's area in square metres: each ring's smaller bounded region on the authalic sphere, the +// exterior less the holes. +func (geographySpace) area(rings [][]geoPos) float64 { + rq := authalicRadius() + + var total float64 + + for i, r := range rings { + a := math.Abs(ringSignedArea(r, authalicLatitude)) * rq * rq + if i == 0 { + total += a + } else { + total -= a + } + } + + return math.Max(total, 0) +} + +const wgsE2 = wgsF * (doubleAngle - wgsF) + +func authalicQ(phi float64) float64 { + e := math.Sqrt(wgsE2) + s := math.Sin(phi) + + return (1 - wgsE2) * (s/(1-wgsE2*s*s) - (1/(doubleAngle*e))*math.Log((1-e*s)/(1+e*s))) +} + +func authalicRadius() float64 { return wgsA * math.Sqrt(authalicQ(math.Pi/doubleAngle)/doubleAngle) } + +// authalicLatitude maps a geodetic latitude in degrees to the latitude on the equal-area sphere. +func authalicLatitude(deg float64) float64 { + return math.Asin(authalicQ(deg*degToRad)/authalicQ(math.Pi/doubleAngle)) / degToRad +} diff --git a/services/cosmosdb/sql_parser.go b/services/cosmosdb/sql_parser.go index 75cb6a5726..6b8a6a6eda 100644 --- a/services/cosmosdb/sql_parser.go +++ b/services/cosmosdb/sql_parser.go @@ -24,10 +24,11 @@ import ( const maxQueryDepth = 100 type sqlParser struct { - lx *sqlLexer - agg *bool - cur sqlToken - depth int + lx *sqlLexer + agg *bool + cur sqlToken + depth int + anonSources int } func newSQLParser(s string) *sqlParser { @@ -117,14 +118,14 @@ func (p *sqlParser) parseSelectStmt(sub bool) (*sqlSelectStmt, error) { return nil, err } - if p.cur.typ == sqlTokFrom || !sub { - if _, err := p.expect(sqlTokFrom, "FROM"); err != nil { - return nil, err - } + if p.cur.typ == sqlTokFrom { + p.advance() if err := p.parseFromClause(stmt, sub); err != nil { return nil, err } + } else if stmt.isStar { + return nil, p.errorf("SELECT * is only valid when a FROM clause names a source") } if err := p.parseClauses(stmt); err != nil { @@ -355,6 +356,12 @@ func (p *sqlParser) parseSubSource() (sqlSource, error) { alias := deriveName(x) + if sq, isSub := x.(*exprSubquery); isSub && sq.kind == subScalar { + sq.kind = subArray + p.anonSources++ + alias = "$sub" + strconv.Itoa(p.anonSources) + } + switch p.cur.typ { case sqlTokAs: p.advance() @@ -433,7 +440,15 @@ func (p *sqlParser) parseGroupBy(stmt *sqlSelectStmt) error { } } +// msgOrderByWithGroupBy is the service's text for the unsupported combination (the gocosmos known-issues list quotes +// it from the gateway). +const msgOrderByWithGroupBy = "'ORDER BY' is not supported in presence of GROUP BY." + func (p *sqlParser) parseOrderBy(stmt *sqlSelectStmt) error { + if len(stmt.groupBy) > 0 { + return p.errorf("%s", msgOrderByWithGroupBy) + } + p.advance() if _, err := p.expect(sqlTokBy, "BY"); err != nil { diff --git a/services/cosmosdb/sql_spatial.go b/services/cosmosdb/sql_spatial.go index 2d00eea7b8..26b3454eac 100644 --- a/services/cosmosdb/sql_spatial.go +++ b/services/cosmosdb/sql_spatial.go @@ -3,35 +3,59 @@ package cosmosdb import ( "fmt" "math" + "slices" + "strings" ) const ( - geoPoint = "Point" - geoLineString = "LineString" - geoPolygon = "Polygon" - - earthRadiusMeters = 6371008.8 - degToRad = math.Pi / 180 - minLineVertices = 2 - minRingVertices = 4 - maxLatitude = 90 - maxLongitude = 180 - geoEpsilon = 1e-9 - halfDivisor = 2 + geoPoint = "Point" + geoLineString = "LineString" + geoPolygon = "Polygon" + geoMultiPolygon = "MultiPolygon" + + // WGS 84, the reference system the service uses for geography (Learn, "Geospatial data"). + wgsA = 6378137.0 + wgsF = 1 / 298.257223563 + degToRad = math.Pi / 180 + minLineVertex = 2 + minRingVertex = 4 + maxLatitude = 90 + maxLongitude = 180 + geoEpsilon = 1e-9 + arcEpsilon = 1e-12 + halfDivisor = 2 + vincentyIters = 200 + vincentyTol = 1e-12 + meanRadius = 6371008.8 ) type geoPos [2]float64 -// geoShape is a parsed GeoJSON Point, LineString or Polygon. +// geoShape is a parsed GeoJSON Point, LineString, Polygon or MultiPolygon. A polygon is a list of rings: the +// exterior first, then holes. type geoShape struct { kind string - rings [][]geoPos // Point: one ring of one vertex; LineString: one ring; Polygon: exterior then holes + line []geoPos + polys [][][]geoPos + point geoPos } +func (g geoShape) polygonal() bool { return g.kind == geoPolygon || g.kind == geoMultiPolygon } + func (g geoShape) vertices() []geoPos { + switch g.kind { + case geoPoint: + return []geoPos{g.point} + case geoLineString: + return g.line + } + var out []geoPos - for _, r := range g.rings { - out = append(out, r...) + + for _, poly := range g.polys { + for _, r := range poly { + out = append(out, r...) + } } return out @@ -41,9 +65,19 @@ func (g geoShape) vertices() []geoPos { func (g geoShape) segments() [][2]geoPos { var out [][2]geoPos - for _, r := range g.rings { - for i := 1; i < len(r); i++ { - out = append(out, [2]geoPos{r[i-1], r[i]}) + edges := func(pts []geoPos) { + for i := 1; i < len(pts); i++ { + out = append(out, [2]geoPos{pts[i-1], pts[i]}) + } + } + + if g.kind == geoLineString { + edges(g.line) + } + + for _, poly := range g.polys { + for _, r := range poly { + edges(r) } } @@ -52,7 +86,7 @@ func (g geoShape) segments() [][2]geoPos { func geoPosOf(v any) (geoPos, bool) { arr, ok := v.([]any) - if !ok || len(arr) < minLineVertices { + if !ok || len(arr) < minLineVertex { return geoPos{}, false } @@ -83,6 +117,23 @@ func geoPosList(v any) ([]geoPos, bool) { return out, true } +func geoRings(v any) ([][]geoPos, bool) { + arr, ok := v.([]any) + if !ok { + return nil, false + } + + rings := make([][]geoPos, len(arr)) + + for i, r := range arr { + if rings[i], ok = geoPosList(r); !ok { + return nil, false + } + } + + return rings, true +} + // parseGeoShape decodes the supported GeoJSON geometry types; ok is false for anything else or malformed input. func parseGeoShape(v any) (geoShape, bool) { obj, ok := v.(map[string]any) @@ -97,64 +148,188 @@ func parseGeoShape(v any) (geoShape, bool) { case geoPoint: p, pok := geoPosOf(coords) - return geoShape{kind: kind, rings: [][]geoPos{{p}}}, pok + return geoShape{kind: kind, point: p}, pok case geoLineString: l, lok := geoPosList(coords) - return geoShape{kind: kind, rings: [][]geoPos{l}}, lok + return geoShape{kind: kind, line: l}, lok case geoPolygon: + rings, rok := geoRings(coords) + + return geoShape{kind: kind, polys: [][][]geoPos{rings}}, rok + case geoMultiPolygon: arr, aok := coords.([]any) if !aok { return geoShape{}, false } - rings := make([][]geoPos, len(arr)) + polys := make([][][]geoPos, len(arr)) - for i, r := range arr { - if rings[i], ok = geoPosList(r); !ok { + for i, p := range arr { + if polys[i], ok = geoRings(p); !ok { return geoShape{}, false } } - return geoShape{kind: kind, rings: rings}, true + return geoShape{kind: kind, polys: polys}, true default: return geoShape{}, false } } -func haversine(a, b geoPos) float64 { - dLat := (b[1] - a[1]) * degToRad - dLon := (b[0] - a[0]) * degToRad - h := math.Sin(dLat/halfDivisor)*math.Sin(dLat/halfDivisor) + - math.Cos(a[1]*degToRad)*math.Cos(b[1]*degToRad)*math.Sin(dLon/halfDivisor)*math.Sin(dLon/halfDivisor) +// geoSpace is the geometry a query runs in: geography (WGS 84 sphere and ellipsoid) or the planar coordinate +// system of a Geometry container. +type geoSpace interface { + dist(a, b geoPos) float64 + segDist(p, a, b geoPos) float64 + segsIntersect(s, t [2]geoPos) bool + segsCross(s, t [2]geoPos) bool + onSegment(p, a, b geoPos) bool + inPolygon(p geoPos, rings [][]geoPos) bool + area(rings [][]geoPos) float64 +} + +func spaceFor(geometry bool) geoSpace { + if geometry { + return planarSpace{} + } + + return geographySpace{} +} + +func inAnyPolygon(sp geoSpace, p geoPos, g geoShape) bool { + return slices.ContainsFunc(g.polys, func(poly [][]geoPos) bool { return sp.inPolygon(p, poly) }) +} + +func geoIntersects(sp geoSpace, a, b geoShape) bool { + if b.polygonal() { + for _, v := range a.vertices() { + if inAnyPolygon(sp, v, b) { + return true + } + } + } + + if a.polygonal() { + for _, v := range b.vertices() { + if inAnyPolygon(sp, v, a) { + return true + } + } + } + + return anySegmentsMeet(sp, a, b) +} + +func anySegmentsMeet(sp geoSpace, a, b geoShape) bool { + bs := b.segments() + + for _, s := range a.segments() { + if slices.ContainsFunc(bs, func(t [2]geoPos) bool { return sp.segsIntersect(s, t) }) { + return true + } + } + + for _, s := range a.segments() { + if slices.ContainsFunc(b.vertices(), func(v geoPos) bool { return sp.onSegment(v, s[0], s[1]) }) { + return true + } + } + + for _, t := range bs { + if slices.ContainsFunc(a.vertices(), func(v geoPos) bool { return sp.onSegment(v, t[0], t[1]) }) { + return true + } + } + + return a.kind == geoPoint && b.kind == geoPoint && sp.dist(a.point, b.point) == 0 +} + +func geoWithin(sp geoSpace, a, b geoShape) bool { + if !b.polygonal() { + return a.kind == geoPoint && b.kind == geoPoint && a.point == b.point + } + + for _, v := range a.vertices() { + if !inAnyPolygon(sp, v, b) { + return false + } + } + + for _, s := range a.segments() { + for _, t := range b.segments() { + if sp.segsCross(s, t) { + return false + } + } + } + + return true +} + +func geoDistance(sp geoSpace, a, b geoShape) float64 { + if a.kind == geoPoint && b.kind == geoPoint { + return sp.dist(a.point, b.point) + } + + if geoIntersects(sp, a, b) { + return 0 + } + + return math.Min(minVertexToShape(sp, a, b), minVertexToShape(sp, b, a)) +} + +// minVertexToShape is the least distance from any vertex of from to the vertices and edges of to. +func minVertexToShape(sp geoSpace, from, to geoShape) float64 { + best := math.Inf(1) + segs := to.segments() + + for _, v := range from.vertices() { + for _, w := range to.vertices() { + best = math.Min(best, sp.dist(v, w)) + } + + for _, s := range segs { + best = math.Min(best, sp.segDist(v, s[0], s[1])) + } + } - return halfDivisor * earthRadiusMeters * math.Asin(math.Min(1, math.Sqrt(h))) + return best } -// pointSegmentMeters is the distance from p to segment ab on a local tangent plane around p. -func pointSegmentMeters(p, a, b geoPos) float64 { - cosLat := math.Cos(p[1] * degToRad) - proj := func(q geoPos) (float64, float64) { - return (q[0] - p[0]) * degToRad * earthRadiusMeters * cosLat, (q[1] - p[1]) * degToRad * earthRadiusMeters +// geoArea is the total area of a Polygon or MultiPolygon, zero for points and lines. +func geoArea(sp geoSpace, g geoShape) float64 { + var total float64 + + for _, poly := range g.polys { + total += sp.area(poly) } - ax, ay := proj(a) - bx, by := proj(b) - dx, dy := bx-ax, by-ay + return total +} + +// --- planar (Geometry containers) --- + +type planarSpace struct{} + +func (planarSpace) dist(a, b geoPos) float64 { return math.Hypot(b[0]-a[0], b[1]-a[1]) } + +func (planarSpace) segDist(p, a, b geoPos) float64 { + dx, dy := b[0]-a[0], b[1]-a[1] t := 0.0 if l2 := dx*dx + dy*dy; l2 > 0 { - t = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/l2)) + t = math.Max(0, math.Min(1, ((p[0]-a[0])*dx+(p[1]-a[1])*dy)/l2)) } - return math.Hypot(ax+t*dx, ay+t*dy) + return math.Hypot(p[0]-(a[0]+t*dx), p[1]-(a[1]+t*dy)) } func cross(o, a, b geoPos) float64 { return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]) } -func onSegment(p, a, b geoPos) bool { +func (planarSpace) onSegment(p, a, b geoPos) bool { if math.Abs(cross(a, b, p)) > geoEpsilon { return false } @@ -163,31 +338,28 @@ func onSegment(p, a, b geoPos) bool { p[1] >= math.Min(a[1], b[1])-geoEpsilon && p[1] <= math.Max(a[1], b[1])+geoEpsilon } -func segmentsIntersect(s, t [2]geoPos) bool { - d1, d2 := cross(t[0], t[1], s[0]), cross(t[0], t[1], s[1]) - d3, d4 := cross(s[0], s[1], t[0]), cross(s[0], s[1], t[1]) - - if ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)) { +func (sp planarSpace) segsIntersect(s, t [2]geoPos) bool { + if sp.segsCross(s, t) { return true } - return onSegment(s[0], t[0], t[1]) || onSegment(s[1], t[0], t[1]) || - onSegment(t[0], s[0], s[1]) || onSegment(t[1], s[0], s[1]) + return sp.onSegment(s[0], t[0], t[1]) || sp.onSegment(s[1], t[0], t[1]) || + sp.onSegment(t[0], s[0], s[1]) || sp.onSegment(t[1], s[0], s[1]) } -// segmentsCross reports a proper crossing (an interior point shared), which tangent touching is not. -func segmentsCross(s, t [2]geoPos) bool { +// segsCross reports a proper crossing (an interior point shared), which tangent touching is not. +func (planarSpace) segsCross(s, t [2]geoPos) bool { d1, d2 := cross(t[0], t[1], s[0]), cross(t[0], t[1], s[1]) d3, d4 := cross(s[0], s[1], t[0]), cross(s[0], s[1], t[1]) return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)) } -func inRing(p geoPos, ring []geoPos) bool { +func (sp planarSpace) inRing(p geoPos, ring []geoPos) bool { inside := false for i, j := 0, len(ring)-1; i < len(ring); j, i = i, i+1 { - if onSegment(p, ring[j], ring[i]) { + if sp.onSegment(p, ring[j], ring[i]) { return true } @@ -201,13 +373,13 @@ func inRing(p geoPos, ring []geoPos) bool { } // inPolygon is true for points inside the exterior ring and outside every hole (boundaries count as inside). -func inPolygon(p geoPos, poly geoShape) bool { - if len(poly.rings) == 0 || !inRing(p, poly.rings[0]) { +func (sp planarSpace) inPolygon(p geoPos, rings [][]geoPos) bool { + if len(rings) == 0 || !sp.inRing(p, rings[0]) { return false } - for _, hole := range poly.rings[1:] { - if inRing(p, hole) && !onRingBoundary(p, hole) { + for _, hole := range rings[1:] { + if sp.inRing(p, hole) && !onBoundary(sp, p, hole) { return false } } @@ -215,9 +387,9 @@ func inPolygon(p geoPos, poly geoShape) bool { return true } -func onRingBoundary(p geoPos, ring []geoPos) bool { +func onBoundary(sp geoSpace, p geoPos, ring []geoPos) bool { for i := 1; i < len(ring); i++ { - if onSegment(p, ring[i-1], ring[i]) { + if sp.onSegment(p, ring[i-1], ring[i]) { return true } } @@ -225,111 +397,36 @@ func onRingBoundary(p geoPos, ring []geoPos) bool { return false } -func geoIntersects(a, b geoShape) bool { - if a.kind == geoPolygon { - for _, v := range b.vertices() { - if inPolygon(v, a) { - return true - } - } - } - - if b.kind == geoPolygon { - for _, v := range a.vertices() { - if inPolygon(v, b) { - return true - } - } - } - - return anySegmentsMeet(a, b) -} - -func anySegmentsMeet(a, b geoShape) bool { - as, bs := a.segments(), b.segments() +// area is the exterior's shoelace area less the holes', in squared coordinate units. +func (planarSpace) area(rings [][]geoPos) float64 { + shoelace := func(r []geoPos) float64 { + var s float64 - for _, s := range as { - for _, t := range bs { - if segmentsIntersect(s, t) { - return true - } + for i := 1; i < len(r); i++ { + s += r[i-1][0]*r[i][1] - r[i][0]*r[i-1][1] } - for _, v := range b.vertices() { - if onSegment(v, s[0], s[1]) { - return true - } - } + return math.Abs(s) / halfDivisor } - for _, t := range bs { - for _, v := range a.vertices() { - if onSegment(v, t[0], t[1]) { - return true - } - } - } - - return a.kind == geoPoint && b.kind == geoPoint && haversine(a.rings[0][0], b.rings[0][0]) == 0 -} - -func geoWithin(a, b geoShape) bool { - if b.kind != geoPolygon { - return a.kind == geoPoint && b.kind == geoPoint && a.rings[0][0] == b.rings[0][0] - } + var total float64 - for _, v := range a.vertices() { - if !inPolygon(v, b) { - return false + for i, r := range rings { + if i == 0 { + total += shoelace(r) + } else { + total -= shoelace(r) } } - for _, s := range a.segments() { - for _, t := range b.segments() { - if segmentsCross(s, t) { - return false - } - } - } - - return true + return math.Max(total, 0) } -func geoDistance(a, b geoShape) float64 { - if a.kind == geoPoint && b.kind == geoPoint { - return haversine(a.rings[0][0], b.rings[0][0]) - } +// --- validity --- - if geoIntersects(a, b) { - return 0 - } - - best := math.Inf(1) - best = math.Min(best, minVertexToShape(a, b)) - - return math.Min(best, minVertexToShape(b, a)) -} - -// minVertexToShape is the least distance from any vertex of from to the vertices and edges of to. -func minVertexToShape(from, to geoShape) float64 { - best := math.Inf(1) - segs := to.segments() - - for _, v := range from.vertices() { - for _, w := range to.vertices() { - best = math.Min(best, haversine(v, w)) - } - - for _, s := range segs { - best = math.Min(best, pointSegmentMeters(v, s[0], s[1])) - } - } - - return best -} - -// geoValidity returns "" for a valid GeoJSON geometry, otherwise the reason it is not. -func geoValidity(v any) (string, bool) { +// geoValidity returns "" for a valid GeoJSON geometry, otherwise the reason it is not. Geography coordinates must +// be longitude -180..180 and latitude -90..90; a Geometry container accepts any finite coordinate. +func geoValidity(v any, geometry bool) (string, bool) { obj, ok := v.(map[string]any) if !ok { return "", false @@ -338,7 +435,7 @@ func geoValidity(v any) (string, bool) { kind, _ := obj["type"].(string) switch kind { - case geoPoint, geoLineString, geoPolygon: + case geoPoint, geoLineString, geoPolygon, geoMultiPolygon: default: return "The GeoJSON type of the input is not supported or is missing.", true } @@ -348,26 +445,40 @@ func geoValidity(v any) (string, bool) { return "The coordinates of the GeoJSON input are malformed.", true } - for _, p := range g.vertices() { - if math.Abs(p[1]) > maxLatitude || math.Abs(p[0]) > maxLongitude { - return fmt.Sprintf("Latitude values must be between -90 and 90 degrees. "+ - "Longitude values must be between -180 and 180 degrees. "+ - "Found longitude value '%v' and latitude value '%v'.", p[0], p[1]), true + if !geometry { + for _, p := range g.vertices() { + if reason := rangeReason(p); reason != "" { + return reason, true + } } } return structureReason(g), true } +// rangeReason names each coordinate axis that is out of range, in the service's wording. +func rangeReason(p geoPos) string { + var parts []string + + if math.Abs(p[1]) > maxLatitude { + parts = append(parts, "Latitude values must be between -90 and 90 degrees.") + } + + if math.Abs(p[0]) > maxLongitude { + parts = append(parts, "Longitude values must be between -180 and 180 degrees.") + } + + return strings.Join(parts, " ") +} + func structureReason(g geoShape) string { - switch g.kind { - case geoLineString: - if len(g.rings[0]) < minLineVertices { - return "The LineString input is not valid because it has fewer than two points." - } - case geoPolygon: - for i, r := range g.rings { - if len(r) < minRingVertices { + if g.kind == geoLineString && len(g.line) < minLineVertex { + return "The LineString input is not valid because it has fewer than two points." + } + + for _, poly := range g.polys { + for i, r := range poly { + if len(r) < minRingVertex { return fmt.Sprintf( "The Polygon input is not valid because ring number %d has fewer than four points.", i+1, @@ -384,10 +495,10 @@ func structureReason(g geoShape) string { return "" } -func callSpatial(name string, args []any) any { +func callSpatial(name string, args []any, geometry bool) any { switch name { case biStIsValid, biStIsValidDetailed: - reason, ok := geoValidity(args[0]) + reason, ok := geoValidity(args[0], geometry) if !ok { return undef{} } @@ -409,13 +520,26 @@ func callSpatial(name string, args []any) any { return undef{} } + sp := spaceFor(geometry) + switch name { case biStDistance: - return floatResult(geoDistance(a, b)) + return floatResult(geoDistance(sp, a, b)) case biStWithin: - return geoWithin(a, b) + return geoWithin(sp, a, b) default: - return geoIntersects(a, b) + return geoIntersects(sp, a, b) } } } + +// callSpatialArea is ST_AREA: square metres for geography, squared coordinate units for geometry; points and +// lines have no area. +func callSpatialArea(arg any, geometry bool) any { + g, ok := parseGeoShape(arg) + if !ok { + return undef{} + } + + return floatResult(geoArea(spaceFor(geometry), g)) +} diff --git a/services/cosmosdb/sql_spatial_doc_test.go b/services/cosmosdb/sql_spatial_doc_test.go new file mode 100644 index 0000000000..6387ff30a5 --- /dev/null +++ b/services/cosmosdb/sql_spatial_doc_test.go @@ -0,0 +1,261 @@ +package cosmosdb_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/cosmosdb" +) + +const ( + hqPoint = `{"type":"Point","coordinates":[-122.12826822304672,47.63980239335718]}` + rdPoint = `{"type":"Point","coordinates":[-96.84369,46.81298]}` + refPoint = `{"type":"Point","coordinates":[-122.11758113953535,47.66901087006131]}` + docPolygon = `{"type":"Polygon","coordinates":[[[31.8,-5],[32,-5],[32,-4.7],[31.8,-4.7],[31.8,-5]]]}` + cwPolygon = `{"type":"Polygon","coordinates":[[[31.8,-5],[31.8,-4.7],[32,-4.7],[32,-5],[31.8,-5]]]}` + multiPoly = `{"type":"MultiPolygon","coordinates":[[[[52,12],[53,12],[53,13],[52,13],[52,12]]],` + + `[[[50,0],[51,0],[51,5],[50,5],[50,0]]]]}` +) + +func evalOne(t *testing.T, expr string) any { + t.Helper() + + rows, err := cosmosdb.ExecuteQuery("SELECT VALUE "+expr+" FROM c", nil, docInfos(t, `{"id":"1"}`)) + require.NoError(t, err) + require.Len(t, rows, 1) + + return rows[0] +} + +func asFloat(t *testing.T, v any) float64 { + t.Helper() + + n, ok := v.(json.Number) + require.True(t, ok, "%T", v) + + f, err := n.Float64() + require.NoError(t, err) + + return f +} + +func TestSpatial_GeographyMatchesTheDocumentedExamples(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + want float64 + tol float64 + }{ + { + name: "distance to the headquarters", + expr: `ST_DISTANCE(` + hqPoint + `, ` + refPoint + `)`, + want: 3345.269817267368, + tol: 0.5e-3, + }, + { + name: "distance to the research office", + expr: `ST_DISTANCE(` + rdPoint + `, ` + refPoint + `)`, + want: 1907438.421299902, + tol: 1, + }, + {name: "polygon area in square metres", expr: `ST_AREA(` + docPolygon + `)`, want: 735970283.0522614, tol: 1}, + { + name: "a clockwise ring has the same area", + expr: `ST_AREA(` + cwPolygon + `)`, + want: 735970283.0522614, + tol: 1, + }, + {name: "points and lines have no area", expr: `ST_AREA(` + hqPoint + `)`, want: 0, tol: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.InDelta(t, tt.want, asFloat(t, evalOne(t, tt.expr)), tt.tol) + }) + } +} + +func TestSpatial_GeographySemantics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + want string + }{ + { + name: "invalid latitude reason", + expr: `ST_ISVALIDDETAILED({"type":"Point","coordinates":[133.75682784306348,-184.38876194345323]})`, + want: `{"valid":false,"reason":"Latitude values must be between -90 and 90 degrees."}`, + }, + { + name: "invalid longitude reason", + expr: `ST_ISVALIDDETAILED({"type":"Point","coordinates":[200,10]})`, + want: `{"valid":false,"reason":"Longitude values must be between -180 and 180 degrees."}`, + }, + { + name: "both axes", + expr: `ST_ISVALIDDETAILED({"type":"Point","coordinates":[200,100]})`, + want: `{"valid":false,"reason":"Latitude values must be between -90 and 90 degrees. ` + + `Longitude values must be between -180 and 180 degrees."}`, + }, + { + name: "valid point", + expr: `ST_ISVALIDDETAILED({"type":"Point","coordinates":[-84.38876194345323,33.75682784306348]})`, + want: `{"valid":true}`, + }, + {name: "multipolygon is valid", expr: `ST_ISVALID(` + multiPoly + `)`, want: `true`}, + { + name: "point in the second polygon", + expr: `ST_WITHIN({"type":"Point","coordinates":[50.5,2]}, ` + multiPoly + `)`, + want: `true`, + }, + { + name: "point between the polygons", + expr: `ST_WITHIN({"type":"Point","coordinates":[51.5,2]}, ` + multiPoly + `)`, + want: `false`, + }, + { + name: "intersects a multipolygon", + expr: `ST_INTERSECTS({"type":"LineString","coordinates":[[49,2],[52.5,12.5]]}, ` + multiPoly + `)`, + want: `true`, + }, + { + name: "multipolygon area is the sum", + expr: `ST_AREA(` + multiPoly + + `) > ST_AREA({"type":"Polygon","coordinates":[[[50,0],[51,0],[51,5],[50,5],[50,0]]]})`, + want: `true`, + }, + { + name: "counter-clockwise ring holds its inside", + expr: `ST_WITHIN({"type":"Point","coordinates":[31.9,-4.8]}, ` + docPolygon + `)`, + want: `true`, + }, + { + name: "counter-clockwise ring excludes the outside", + expr: `ST_WITHIN({"type":"Point","coordinates":[40,10]}, ` + docPolygon + `)`, + want: `false`, + }, + { + name: "clockwise ring is the inverse region: inside excluded", + expr: `ST_WITHIN({"type":"Point","coordinates":[31.9,-4.8]}, ` + cwPolygon + `)`, + want: `false`, + }, + { + name: "clockwise ring is the inverse region: outside included", + expr: `ST_WITHIN({"type":"Point","coordinates":[40,10]}, ` + cwPolygon + `)`, + want: `true`, + }, + { + name: "boundary counts as within", + expr: `ST_WITHIN({"type":"Point","coordinates":[31.8,-4.85]}, ` + docPolygon + `)`, + want: `true`, + }, + { + name: "polygon with a hole", + expr: `ST_WITHIN({"type":"Point","coordinates":[1,1]}, {"type":"Polygon","coordinates":[` + + `[[0,0],[4,0],[4,4],[0,4],[0,0]],[[0.5,0.5],[0.5,1.5],[1.5,1.5],[1.5,0.5],[0.5,0.5]]]})`, + want: `false`, + }, + { + name: "outside the hole", + expr: `ST_WITHIN({"type":"Point","coordinates":[3,3]}, {"type":"Polygon","coordinates":[` + + `[[0,0],[4,0],[4,4],[0,4],[0,0]],[[0.5,0.5],[0.5,1.5],[1.5,1.5],[1.5,0.5],[0.5,0.5]]]})`, + want: `true`, + }, + { + name: "a polygon across the antimeridian", + expr: `ST_WITHIN({"type":"Point","coordinates":[-179.5,0]}, {"type":"Polygon","coordinates":[` + + `[[179,-1],[-179,-1],[-179,1],[179,1],[179,-1]]]})`, + want: `true`, + }, + { + name: "distance across the antimeridian is short", + expr: `ST_DISTANCE({"type":"Point","coordinates":[179.9,0]}, {"type":"Point","coordinates":[-179.9,0]}) < 25000`, + want: `true`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := json.Marshal(evalOne(t, tt.expr)) + require.NoError(t, err) + assert.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestSpatial_GeometryContainersUsePlanarMath(t *testing.T) { + t.Parallel() + + tests := []struct { + geo string + expr string + want string + }{ + { + geo: "Geometry", + expr: `ST_DISTANCE({"type":"Point","coordinates":[0,0]},{"type":"Point","coordinates":[3,4]})`, + want: `5`, + }, + { + geo: "Geography", + expr: `ST_DISTANCE({"type":"Point","coordinates":[0,0]},{"type":"Point","coordinates":[3,4]}) > 500000`, + want: `true`, + }, + { + geo: "Geometry", + expr: `ST_AREA({"type":"Polygon","coordinates":[[[0,0],[2,0],[2,3],[0,3],[0,0]]]})`, + want: `6`, + }, + { + geo: "Geometry", + expr: `ST_WITHIN({"type":"Point","coordinates":[1,1]},` + + `{"type":"Polygon","coordinates":[[[0,0],[0,3],[2,3],[2,0],[0,0]]]})`, + want: `true`, + }, + {geo: "Geometry", expr: `ST_ISVALID({"type":"Point","coordinates":[500,500]})`, want: `true`}, + {geo: "Geography", expr: `ST_ISVALID({"type":"Point","coordinates":[500,500]})`, want: `false`}, + { + geo: "Geometry", + expr: `ST_DISTANCE({"type":"Point","coordinates":[5,0]},{"type":"LineString","coordinates":[[0,3],[10,3]]})`, + want: `3`, + }, + } + + for _, tt := range tests { + t.Run(tt.geo+"/"+tt.expr[:20], func(t *testing.T) { + t.Parallel() + + b := cosmosdb.NewInMemoryBackend() + _, err := b.CreateDatabase("d") + require.NoError(t, err) + + _, err = b.CreateContainer("d", cosmosdb.ContainerSpec{ + ID: "c", + PartitionKeyPath: "/pk", + GeospatialConfig: json.RawMessage(`{"type":"` + tt.geo + `"}`), + }) + require.NoError(t, err) + _, err = b.CreateDocument("d", "c", map[string]any{"id": "1", "pk": "k"}, false) + require.NoError(t, err) + + res, err := b.RunQuery("d", "c", "SELECT VALUE "+tt.expr+" FROM c", nil, "") + require.NoError(t, err) + require.Len(t, res.Rows, 1) + + got, err := json.Marshal(res.Rows[0]) + require.NoError(t, err) + assert.JSONEq(t, tt.want, string(got)) + }) + } +} diff --git a/services/cosmosdb/store.go b/services/cosmosdb/store.go index 14ad09effd..699cdad967 100644 --- a/services/cosmosdb/store.go +++ b/services/cosmosdb/store.go @@ -7,6 +7,7 @@ import ( "slices" "sort" "strings" + "sync/atomic" "time" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" @@ -17,26 +18,33 @@ import ( // InMemoryBackend implements StorageBackend using an in-memory map guarded // by a single RWMutex. Shaped after services/azuretable's InMemoryBackend. type InMemoryBackend struct { - tableAPI tableSnapshotter - mu *lockmetrics.RWMutex - databases map[string]*storedDatabase - nowFunc func() time.Time - etagFunc func(time.Time) string - parsed *safemap.Map[string, *sqlSelectStmt] - regions AccountRegions - scriptTimeout time.Duration - budgetEpoch int64 - serverless bool + nextSplitDue time.Time + tableAPI tableSnapshotter + regionsChanged func() + mu *lockmetrics.RWMutex + databases map[string]*storedDatabase + nowFunc func() time.Time + etagFunc func(time.Time) string + parsed *safemap.Map[string, *sqlSelectStmt] + cur atomic.Pointer[regionCall] + regionGate *lockmetrics.RWMutex + regions AccountRegions + budgetEpoch int64 + partitionMaxBytes int64 + scriptTimeout time.Duration + splitDelay time.Duration + serverless bool } // NewInMemoryBackend creates a new empty InMemoryBackend. func NewInMemoryBackend() *InMemoryBackend { return &InMemoryBackend{ - mu: lockmetrics.New("cosmosdb"), - databases: make(map[string]*storedDatabase), - nowFunc: time.Now, - etagFunc: etagFor, - parsed: safemap.New[string, *sqlSelectStmt]("cosmosdb-sql"), + mu: lockmetrics.New("cosmosdb"), + regionGate: lockmetrics.New("cosmosdb.region"), + databases: make(map[string]*storedDatabase), + nowFunc: time.Now, + etagFunc: etagFor, + parsed: safemap.New[string, *sqlSelectStmt]("cosmosdb-sql"), } } @@ -170,6 +178,10 @@ func (b *InMemoryBackend) CreateContainer(dbID string, spec ContainerSpec) (Cont return ContainerInfo{}, polErr } + if crErr := validateConflictPolicy(spec.ConflictResolutionPolicy); crErr != nil { + return ContainerInfo{}, crErr + } + if idxErr := validateSearchIndexes(spec.IndexingPolicy, spec.ContainerPolicies.stored()); idxErr != nil { return ContainerInfo{}, idxErr } @@ -238,6 +250,15 @@ func (b *InMemoryBackend) ReplaceContainer(dbID string, spec ContainerSpec) (Con return ContainerInfo{}, idxErr } + if crErr := validateConflictPolicy(spec.ConflictResolutionPolicy); crErr != nil { + return ContainerInfo{}, crErr + } + + if conflictPolicyChanged(c.ConflictResolutionPolicy, spec.ConflictResolutionPolicy) { + return ContainerInfo{}, fmt.Errorf("%w: conflictResolutionPolicy can only be set when the container is created", + ErrInvalidContainerPolicy) + } + if polErr := c.applyPolicies(spec.ContainerPolicies, true); polErr != nil { return ContainerInfo{}, polErr } @@ -247,10 +268,6 @@ func (b *InMemoryBackend) ReplaceContainer(dbID string, spec ContainerSpec) (Con c.IndexingPolicy = spec.IndexingPolicy } - if len(spec.ConflictResolutionPolicy) > 0 { - c.ConflictResolutionPolicy = spec.ConflictResolutionPolicy - } - c.touch(b.now()) return b.containerInfo(dbID, c), nil @@ -469,7 +486,7 @@ func (b *InMemoryBackend) GetDocument(dbID, containerID, partitionKey, id string b.mu.RLock("GetDocument") defer b.mu.RUnlock() - tx, err := b.beginTx(dbID, containerID) + tx, err := b.beginTxAt(dbID, containerID, true) if err != nil { return DocumentInfo{}, err } @@ -543,7 +560,7 @@ func (b *InMemoryBackend) ListDocuments(dbID, containerID string) ([]DocumentInf b.mu.RLock("ListDocuments") defer b.mu.RUnlock() - c, err := b.findContainer(dbID, containerID) + c, err := b.findDataContainer(dbID, containerID, true) if err != nil { return nil, err } @@ -566,7 +583,7 @@ func (b *InMemoryBackend) ListDocumentsPaged( b.mu.RLock("ListDocumentsPaged") defer b.mu.RUnlock() - c, err := b.findContainer(dbID, containerID) + c, err := b.findDataContainer(dbID, containerID, true) if err != nil { return page.Page[DocumentInfo]{}, err } @@ -584,11 +601,14 @@ func (b *InMemoryBackend) ListDocumentsPaged( // Reset clears all in-memory state. func (b *InMemoryBackend) Reset() { + defer b.notifyRegions() + b.mu.Lock("Reset") defer b.mu.Unlock() b.databases = make(map[string]*storedDatabase) b.regions = AccountRegions{} + b.budgetEpoch++ } type tableSnapshotter interface { diff --git a/services/cosmosdb/table_api.go b/services/cosmosdb/table_api.go index 600c58e4ee..95c390ac54 100644 --- a/services/cosmosdb/table_api.go +++ b/services/cosmosdb/table_api.go @@ -391,12 +391,32 @@ func (h *Handler) createTable(c *echo.Context) error { } func (h *Handler) listTables(c *echo.Context) error { - infos := h.TableBackend.ListTables() r := c.Request() level := tableODataLevelFromAccept(r.Header.Get("Accept")) - values := make([]map[string]any, 0, len(infos)) - for _, ti := range infos { + top, topErr := odatatable.ParseTop(c.QueryParam("$top")) + if topErr != nil { + return h.writeTableError(c, http.StatusBadRequest, "InvalidInput", "The value for $top is invalid.") + } + + if top <= 0 || top > odatatable.MaxPageSize { + top = odatatable.MaxPageSize + } + + start := c.QueryParam("NextTableName") + values := make([]map[string]any, 0) + + for _, ti := range h.TableBackend.ListTables() { + if ti.Name < start { + continue + } + + if len(values) >= top { + c.Response().Header().Set(headerNextTableName, ti.Name) + + break + } + values = append(values, h.tableEntityBody(r.Host, ti.Name, level)) } diff --git a/services/cosmosdb/table_api_ops.go b/services/cosmosdb/table_api_ops.go index 029d4888cb..0ec0968604 100644 --- a/services/cosmosdb/table_api_ops.go +++ b/services/cosmosdb/table_api_ops.go @@ -16,7 +16,18 @@ import ( // the full contract. Mirrors services/azuretable/entity_ops.go's identical // (Handler-bound) encodeEntity wrapper. func (h *Handler) encodeTableEntity(info odatatable.EntityInfo, table, level, selectParam, host string) map[string]any { - return odatatable.EncodeEntity(info, table, level, selectParam, tableServiceEndpoint(host), tableAPIAccountName) + m := odatatable.EncodeEntity(info, table, level, selectParam, tableServiceEndpoint(host), tableAPIAccountName) + + if selected := odatatable.SelectSet(selectParam); selected != nil { + keys := [...]string{odatatable.PartitionKeyProperty, odatatable.RowKeyProperty, odatatable.TimestampProperty} + for _, key := range keys { + if !selected[key] { + delete(m, key) + } + } + } + + return m } func (h *Handler) insertEntity(c *echo.Context, table string) error { @@ -86,23 +97,33 @@ func (h *Handler) getEntity(c *echo.Context, table, partitionKey, rowKey string) func (h *Handler) queryEntities(c *echo.Context, table string) error { r := c.Request() - feed, failure := h.entityFeed(table, r.URL.Query(), r.Header.Get("Accept"), r.Host) + feed, next, failure := h.entityFeed(table, r.URL.Query(), r.Header.Get("Accept"), r.Host) if failure != nil { return h.writeTableError(c, failure.Status, failure.Code, failure.Message) } + for _, kv := range next { + c.Response().Header().Set(kv[0], kv[1]) + } + return h.writeTableJSON(c, http.StatusOK, feed) } -// entityFeed answers an entity-collection query (the $top, $filter and $select of q) as the OData feed body. +// Table service continuation headers and query parameters (Query Entities, "Response headers"). +const ( + headerNextTableName = "X-Ms-Continuation-Nexttablename" +) + +// entityFeed answers an entity-collection query (the $top, $filter and $select of q) as one page of the OData feed +// body, with the continuation headers when more entities match. A page holds at most 1,000 entities. func (h *Handler) entityFeed( table string, q url.Values, accept, host string, -) (map[string]any, *odatatable.BatchFailure) { +) (map[string]any, [][2]string, *odatatable.BatchFailure) { top, topErr := odatatable.ParseTop(q.Get("$top")) if topErr != nil { f := odatatable.NewBatchFailure(http.StatusBadRequest, "InvalidInput", "The value for $top is invalid.") - return nil, &f + return nil, nil, &f } var filter odatatable.Node @@ -112,28 +133,34 @@ func (h *Handler) entityFeed( if parseErr != nil { f := odatatable.NewBatchFailure(http.StatusBadRequest, "InvalidInput", "The specified $filter is invalid.") - return nil, &f + return nil, nil, &f } filter = node } - infos, err := h.TableBackend.QueryEntities(table, filter, top) + page, err := h.TableBackend.QueryEntitiesPage(table, filter, top, q.Get("NextPartitionKey"), q.Get("NextRowKey")) if err != nil { f := odatatable.BatchFailureFor(odatatable.ErrTableNotFound) - return nil, &f + return nil, nil, &f } level := tableODataLevelFromAccept(accept) selectParam := q.Get("$select") - values := make([]map[string]any, 0, len(infos)) - for _, info := range infos { + values := make([]map[string]any, 0, len(page.Entities)) + for _, info := range page.Entities { values = append(values, h.encodeTableEntity(info, table, level, selectParam, host)) } - return map[string]any{"value": values}, nil + var next [][2]string + if page.HasMore { + next = [][2]string{{"x-ms-continuation-NextPartitionKey", page.NextPartitionKey}, + {"x-ms-continuation-NextRowKey", page.NextRowKey}} + } + + return map[string]any{"value": values}, next, nil } func (h *Handler) replaceEntity(c *echo.Context, table, partitionKey, rowKey string) error { diff --git a/services/cosmosdb/table_batch.go b/services/cosmosdb/table_batch.go index 2312236ddc..62d9129d24 100644 --- a/services/cosmosdb/table_batch.go +++ b/services/cosmosdb/table_batch.go @@ -99,7 +99,7 @@ func (h *Handler) writeBatchRetrieve(c *echo.Context, o odatatable.WireBatchOp) func (h *Handler) writeBatchQuery(c *echo.Context, o odatatable.WireBatchOp) error { accept := o.Headers.Get("Accept") - feed, failure := h.entityFeed(o.Table, o.Query, accept, c.Request().Host) + feed, next, failure := h.entityFeed(o.Table, o.Query, accept, c.Request().Host) if failure != nil { body, bodyErr := odatatable.FailureBody(0, *failure, newDocumentID(), time.Now().UTC().Format(time.RFC3339Nano)) if bodyErr != nil { @@ -118,15 +118,14 @@ func (h *Handler) writeBatchQuery(c *echo.Context, o odatatable.WireBatchOp) err return h.writeTableError(c, http.StatusInternalServerError, "InternalError", err.Error()) } + headers := make([][2]string, 0, 1+len(next)) + headers = append(headers, [2]string{ + headerContentType, + "application/json;odata=" + tableODataLevelFromAccept(accept) + ";streaming=true;charset=utf-8", + }) + return h.writeRetrievePart(c, odatatable.BatchResponsePart{ - Status: http.StatusOK, - Headers: [][2]string{ - { - headerContentType, - "application/json;odata=" + tableODataLevelFromAccept(accept) + ";streaming=true;charset=utf-8", - }, - }, - Body: payload, + Status: http.StatusOK, Headers: append(headers, next...), Body: payload, }) } diff --git a/services/cosmosdb/table_paging_test.go b/services/cosmosdb/table_paging_test.go new file mode 100644 index 0000000000..e9c5223cbd --- /dev/null +++ b/services/cosmosdb/table_paging_test.go @@ -0,0 +1,130 @@ +package cosmosdb_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + nextPKHeader = "X-Ms-Continuation-Nextpartitionkey" + nextRKHeader = "X-Ms-Continuation-Nextrowkey" +) + +func TestTableQuery_ContinuationPages(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + wantNext string + entities int + wantFirst int + }{ + {name: "top splits the result", entities: 5, query: "?$top=2", wantFirst: 2, wantNext: "r0002"}, + {name: "a short result has no continuation", entities: 2, query: "?$top=5", wantFirst: 2}, + {name: "a page holds at most 1000", entities: 1002, query: "", wantFirst: 1000, wantNext: "r1000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newBatchTable(t) + + for i := range tt.entities { + body := fmt.Sprintf(`{"PartitionKey":"p","RowKey":"r%04d"}`, i) + require.Equal(t, http.StatusCreated, doRequest(t, h, http.MethodPost, "/t", nil, []byte(body)).Code) + } + + rec := doRequest(t, h, http.MethodGet, "/t()"+tt.query, nil, nil) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Len(t, decodeBody(t, rec)["value"], tt.wantFirst) + + if tt.wantNext == "" { + assert.Empty(t, rec.Header().Get(nextPKHeader)) + + return + } + + assert.Equal(t, "p", rec.Header().Get(nextPKHeader)) + next := rec.Header().Get(nextRKHeader) + assert.Equal(t, tt.wantNext, next) + + rest := doRequest(t, h, http.MethodGet, + "/t()?NextPartitionKey=p&NextRowKey="+next, nil, nil) + require.Equal(t, http.StatusOK, rest.Code) + assert.Len(t, decodeBody(t, rest)["value"], tt.entities-tt.wantFirst) + }) + } +} + +func TestTableBatch_QueryPartCarriesContinuation(t *testing.T) { + t.Parallel() + + h := newBatchTable(t) + + for _, rk := range []string{"a", "b", "c"} { + body := `{"PartitionKey":"p","RowKey":"` + rk + `"}` + require.Equal(t, http.StatusCreated, doRequest(t, h, http.MethodPost, "/t", nil, []byte(body)).Code) + } + + rec := postBatch(t, h, buildRetrieveBatch("t()?$top=2", false)) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "x-ms-continuation-NextPartitionKey: p") + assert.Contains(t, rec.Body.String(), "x-ms-continuation-NextRowKey: c") +} + +func TestTableBatch_SecondChangesetRejected(t *testing.T) { + t.Parallel() + + h := newBatchTable(t) + one := buildBatch(batchOp{method: "POST", path: "t", body: `{"PartitionKey":"p","RowKey":"a"}`}) + two := strings.Replace( + one, + "--"+batchBoundary+"--\r\n", + "--"+batchBoundary+"\r\nContent-Type: multipart/mixed; boundary=cs2\r\n\r\n--cs2--\r\n--"+batchBoundary+"--\r\n", + 1, + ) + + rec := postBatch(t, h, two) + assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + assert.Equal(t, http.StatusNotFound, getEntityStatus(t, h, "a"), "nothing from the batch is applied") +} + +func TestTableAPI_ListTablesPagesAndSelect(t *testing.T) { + t.Parallel() + + h := newTestHandler(t) + + for _, name := range []string{"ta", "tb", "tc"} { + body := `{"TableName":"` + name + `"}` + require.Equal(t, http.StatusCreated, doRequest(t, h, http.MethodPost, "/Tables", nil, []byte(body)).Code) + } + + first := doRequest(t, h, http.MethodGet, "/Tables?$top=2", nil, nil) + require.Equal(t, http.StatusOK, first.Code) + assert.Len(t, decodeBody(t, first)["value"], 2) + assert.Equal(t, "tc", first.Header().Get("X-Ms-Continuation-Nexttablename")) + + rest := doRequest(t, h, http.MethodGet, "/Tables?NextTableName=tc", nil, nil) + assert.Len(t, decodeBody(t, rest)["value"], 1) + assert.Empty(t, rest.Header().Get("X-Ms-Continuation-Nexttablename")) + + require.Equal(t, http.StatusCreated, doRequest(t, h, http.MethodPost, "/ta", nil, + []byte(`{"PartitionKey":"p","RowKey":"r","A":"x","B":"y"}`)).Code) + + sel := doRequest(t, h, http.MethodGet, "/ta()?$select=A", nil, nil) + require.Equal(t, http.StatusOK, sel.Code) + + row, ok := decodeBody(t, sel)["value"].([]any)[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "x", row["A"]) + assert.NotContains(t, row, "PartitionKey") + assert.NotContains(t, row, "RowKey") + assert.NotContains(t, row, "B") +} diff --git a/services/cosmosdb/throttle.go b/services/cosmosdb/throttle.go index e26e7adbca..f2549c75fc 100644 --- a/services/cosmosdb/throttle.go +++ b/services/cosmosdb/throttle.go @@ -16,10 +16,12 @@ const ( headerRetryAfterMS = "X-Ms-Retry-After-Ms" headerSubStatus = "X-Ms-Substatus" subStatusThrottled = "3200" - burstCeilingRUs = 3000.0 - burstWindowSeconds = 300.0 - msPerSecond = 1000.0 - throttledMessage = "Request rate is large. More Request Units may be needed, so no changes were made. " + + // subStatusPKRangeGone is Microsoft.Azure.Cosmos SubStatusCodes.PartitionKeyRangeGone. + subStatusPKRangeGone = "1002" + burstCeilingRUs = 3000.0 + burstWindowSeconds = 300.0 + msPerSecond = 1000.0 + throttledMessage = "Request rate is large. More Request Units may be needed, so no changes were made. " + "Please retry after %d ms." ) @@ -36,9 +38,10 @@ type ruBucket struct { // meterSlot addresses the bucket of one physical partition. type meterSlot struct { - key string - rate float64 - burst bool + key string + rate float64 + weight float64 + burst bool } // ruMeter meters request units per physical partition on an injected clock. @@ -136,15 +139,24 @@ func (m *ruMeter) admit(epoch int64, now time.Time, slots []meterSlot) time.Dura return wait } -// charge splits ru evenly across the partitions the request touched. +// charge splits ru across the partitions the request touched, by the work each did. func (m *ruMeter) charge(epoch int64, now time.Time, slots []meterSlot, ru float64) { m.mu.Lock("charge") defer m.mu.Unlock() m.sync(epoch) - share := ru / float64(len(slots)) + var total float64 for _, s := range slots { + total += s.weight + } + + for _, s := range slots { + share := ru / float64(len(slots)) + if total > 0 { + share = ru * s.weight / total + } + m.bucket(s, now).spend(share, s.burst) } } @@ -158,6 +170,34 @@ func isMetered(kind resourceKind) bool { return kind == resourceDocuments || kind == resourceDocumentItem } +// metadataRUs is the throughput an account has for metadata operations; it cannot be raised (Learn, "Service +// quotas and default limits": maximum throughput supported by an account for metadata operations, 240 RU/s). +const metadataRUs = 240.0 + +// isMetadata reports whether a resource kind is served from the account's metadata partition: databases, +// containers, offers, users, permissions, scripts and the partition key ranges. +func isMetadata(kind resourceKind) bool { + return kind != resourceInvalid && kind != resourceAccountRoot && !isMetered(kind) +} + +// serveMetadata runs next unless the metadata partition is overdrawn, then charges what the response reports. +func (h *Handler) serveMetadata(c *echo.Context, next func() error) error { + var epoch int64 + if tb, ok := h.Backend.(ThroughputBackend); ok { + epoch = tb.ThroughputBudget("", "").Epoch + } + + slots := []meterSlot{{key: "#metadata", rate: metadataRUs, weight: 1}} + if wait := h.meter.admit(epoch, h.now(), slots); wait > 0 { + return h.writeThrottled(c, wait) + } + + err := next() + h.meter.charge(epoch, h.now(), slots, chargeOf(c)) + + return err +} + func chargeOf(c *echo.Context) float64 { ru, err := strconv.ParseFloat(c.Response().Header().Get(headerRequestCharge), 64) if err != nil { @@ -185,6 +225,15 @@ func (h *Handler) serveMetered(c *echo.Context, dbID, collID string, next func() } err := next() + + if len(slots) > 1 { + if shares := tb.RangeShares(dbID, collID); len(shares) == len(slots) { + for i := range slots { + slots[i].weight = shares[i] + } + } + } + h.meter.charge(bud.Epoch, h.now(), slots, chargeOf(c)) return err diff --git a/services/cosmosdb/throughput.go b/services/cosmosdb/throughput.go index 96ec642109..45c91a03bd 100644 --- a/services/cosmosdb/throughput.go +++ b/services/cosmosdb/throughput.go @@ -48,7 +48,8 @@ type ThroughputSpec struct { // storedOffer is the persisted throughput offer of a database (shared) or container (dedicated). type storedOffer struct { - Modified time.Time `json:"Modified"` + Modified time.Time `json:"Modified"` + HighestSeen int `json:"HighestSeen,omitempty"` ThroughputSpec } @@ -58,6 +59,8 @@ type ThroughputInfo struct { OfferRID string ResourceRID string ResourceLink string + Pending bool + Minimum int ThroughputSpec } @@ -70,6 +73,15 @@ func (s ThroughputSpec) MinimumThroughput() int { return manualThroughputMin } +// level is the RU/s the spec provisions: the manual value, or the autoscale maximum. +func (s ThroughputSpec) level() int { + if s.AutoscaleMax > 0 { + return s.AutoscaleMax + } + + return s.Manual +} + // Validate checks the spec against the documented limits. func (s ThroughputSpec) Validate() error { switch { @@ -132,6 +144,8 @@ func offerRIDFor(resourceRID string) string { // offerTarget addresses the offer slot of one database or container. type offerTarget struct { slot *(*storedOffer) + db *storedDatabase + coll *storedContainer kind string rid string link string @@ -142,7 +156,7 @@ func (b *InMemoryBackend) databaseTarget(d *storedDatabase) offerTarget { rid := fakeRID("dbs/" + d.ID) return offerTarget{ - slot: &d.Offer, kind: targetKindDatabase, rid: rid, link: dbsSegment + "/" + rid + "/", key: d.ID, + slot: &d.Offer, db: d, kind: targetKindDatabase, rid: rid, link: dbsSegment + "/" + rid + "/", key: d.ID, } } @@ -151,7 +165,7 @@ func (b *InMemoryBackend) containerTarget(d *storedDatabase, c *storedContainer) rid := fakeRID("dbs/" + d.ID + "/colls/" + c.ID) return offerTarget{ - slot: &c.Offer, kind: targetKindContainer, rid: rid, + slot: &c.Offer, db: d, coll: c, kind: targetKindContainer, rid: rid, link: dbsSegment + "/" + dbRID + "/" + collsSegment + "/" + rid + "/", key: d.ID + budgetKeySeparator + c.ID, } @@ -193,15 +207,30 @@ func (b *InMemoryBackend) allTargets() []offerTarget { return out } -func (t offerTarget) info() ThroughputInfo { +func (b *InMemoryBackend) info(t offerTarget) ThroughputInfo { o := *t.slot return ThroughputInfo{ Modified: o.Modified, OfferRID: offerRIDFor(t.rid), ResourceRID: t.rid, ResourceLink: t.link, - ThroughputSpec: o.ThroughputSpec, + ThroughputSpec: o.ThroughputSpec, Pending: t.pending(), Minimum: b.headerMinimum(t, o.ThroughputSpec), } } +// pending reports whether a scale-up of the offer is still waiting on partition splits. +func (t offerTarget) pending() bool { + if t.coll != nil { + return t.coll.PKLayout != nil && t.coll.PKLayout.pending() + } + + for _, c := range t.db.Containers { + if c.Offer == nil && c.PKLayout != nil && c.PKLayout.pending() { + return true + } + } + + return false +} + // SetServerless marks the account serverless: throughput cannot be provisioned and every container is capped. func (b *InMemoryBackend) SetServerless(v bool) { b.mu.Lock("SetServerless") @@ -245,7 +274,7 @@ func (b *InMemoryBackend) GetThroughput(db, container string) (ThroughputInfo, e return ThroughputInfo{}, ErrThroughputNotFound } - return t.info(), nil + return b.info(t), nil } // SetThroughput creates or replaces the offer of a database (container == "") or container. @@ -266,10 +295,24 @@ func (b *InMemoryBackend) storeOffer(t offerTarget, spec ThroughputSpec) (Throug return ThroughputInfo{}, err } - *t.slot = &storedOffer{ThroughputSpec: spec, Modified: b.now()} + if minimum := b.minimumFor(t, spec); spec.level() < minimum { + return ThroughputInfo{}, fmt.Errorf("%w: the requested throughput %d is below the minimum of %d RU/s "+ + "currently required by the resource", ErrInvalidThroughput, spec.level(), minimum) + } + + b.eachGoverned(t, b.prepareLayout) + + highest := spec.level() + if *t.slot != nil && (*t.slot).HighestSeen > highest { + highest = (*t.slot).HighestSeen + } + + *t.slot = &storedOffer{ThroughputSpec: spec, Modified: b.now(), HighestSeen: highest} b.budgetEpoch++ - return t.info(), nil + b.eachGoverned(t, b.syncLayout) + + return b.info(t), nil } // ListThroughputs returns every provisioned offer. @@ -281,7 +324,7 @@ func (b *InMemoryBackend) ListThroughputs() []ThroughputInfo { for _, t := range b.allTargets() { if *t.slot != nil { - out = append(out, t.info()) + out = append(out, b.info(t)) } } @@ -309,7 +352,7 @@ func (b *InMemoryBackend) GetThroughputByRID(offerRID string) (ThroughputInfo, e return ThroughputInfo{}, err } - return t.info(), nil + return b.info(t), nil } // ReplaceThroughputByRID replaces the offer with the given offer rid. @@ -329,20 +372,35 @@ func (b *InMemoryBackend) ReplaceThroughputByRID(offerRID string, spec Throughpu // offer, the total rate (0 = unlimited), the physical partition count and an epoch that changes whenever an offer // is set, so callers can reset their meters. type RUBudget struct { - Key string - Rate float64 - Epoch int64 - Partitions int + Key string + Ranges []pkSpan + Rate float64 + Epoch int64 } +// Partitions is the number of physical partitions. +func (r RUBudget) Partitions() int { return max(1, len(r.Ranges)) } + // ThroughputBudget reports the RU/s budget governing container coll of db. func (b *InMemoryBackend) ThroughputBudget(db, coll string) RUBudget { + b.settleDue() + b.mu.RLock("ThroughputBudget") defer b.mu.RUnlock() - bud := RUBudget{Partitions: 1} + var bud RUBudget + if d, ok := b.databases[db]; ok { bud = b.budgetOf(d, coll) + + if c, found := d.Containers[coll]; found { + l := b.layoutView(d, c) + bud.Ranges = l.Spans + + if l.pending() { + bud.Rate = min(bud.Rate, float64(len(l.Spans))*partitionMaxRUs) + } + } } bud.Epoch = b.budgetEpoch @@ -357,16 +415,16 @@ func (b *InMemoryBackend) budgetOf(d *storedDatabase, coll string) RUBudget { switch { case b.serverless: - return RUBudget{Key: d.ID + budgetKeySeparator + coll, Rate: serverlessBurstRUs, Partitions: 1} + return RUBudget{Key: d.ID + budgetKeySeparator + coll, Rate: serverlessBurstRUs} case d.Offer != nil: return offerBudget(d.ID, d.Offer) } - return RUBudget{Partitions: 1} + return RUBudget{} } func offerBudget(key string, o *storedOffer) RUBudget { - return RUBudget{Key: key, Rate: o.rate(), Partitions: partitionsForRU(int(o.rate()))} + return RUBudget{Key: key, Rate: o.rate()} } // rate is the enforcement ceiling: autoscale scales instantly within [10% of max, max], so a request can always @@ -388,6 +446,98 @@ type ThroughputBackend interface { GetThroughputByRID(offerRID string) (ThroughputInfo, error) ReplaceThroughputByRID(offerRID string, spec ThroughputSpec) (ThroughputInfo, error) ThroughputBudget(db, coll string) RUBudget + RangeShares(db, coll string) []float64 } var _ ThroughputBackend = (*InMemoryBackend)(nil) + +// eachGoverned applies fn to every container whose layout the offer target governs; the lock must be held. +func (b *InMemoryBackend) eachGoverned(t offerTarget, fn func(*storedDatabase, *storedContainer)) { + if t.coll != nil { + fn(t.db, t.coll) + + return + } + + for _, c := range t.db.Containers { + if c.Offer == nil { + fn(t.db, c) + } + } +} + +// Minimum throughput terms (Learn, "Service quotas and default limits", Minimum throughput limits). +const ( + bytesPerGB = 1 << 30 + sharedContainersFree = 25 + manualPerGBRU = 1 + autoscalePerGBRU = 10 + manualHighestDivisor = 100 + autoscaleHighestDiv = 10 + manualPerExtraContainr = 100 + autoMinRoundUp = 1000 +) + +func ceilDiv(a, b int) int { return (a + b - 1) / b } + +// storageGB is the stored data of the target, rounded up to whole gigabytes; the lock must be held. +func (b *InMemoryBackend) storageGB(t offerTarget) int { + var total int64 + + count := func(c *storedContainer) { + for _, d := range c.Documents { + total += docBytes(d) + } + } + + if t.coll != nil { + count(t.coll) + } else { + for _, c := range t.db.Containers { + if c.Offer == nil { + count(c) + } + } + } + + return int(ceilDiv64(total, bytesPerGB)) +} + +func ceilDiv64(a, b int64) int64 { return (a + b - 1) / b } + +// minimumFor is the lowest throughput the resource can be set to now: the larger of the flat floor, one RU/s (ten +// for autoscale) per stored GB, a hundredth (a tenth) of the highest throughput ever provisioned and, for a shared +// database, the per-container term past 25 containers. +func (b *InMemoryBackend) minimumFor(t offerTarget, spec ThroughputSpec) int { + highest := 0 + if *t.slot != nil { + highest = (*t.slot).HighestSeen + } + + gb := b.storageGB(t) + + extra := 0 + if t.coll == nil { + extra = max(len(t.db.Containers)-sharedContainersFree, 0) + } + + if spec.AutoscaleMax > 0 { + floor := max(autoscaleMaxMin, gb*autoscalePerGBRU, ceilDiv(highest, autoscaleHighestDiv), + autoscaleMaxMin+extra*autoscaleMaxMin) + + return ceilDiv(floor, autoMinRoundUp) * autoMinRoundUp + } + + return max(manualThroughputMin, gb*manualPerGBRU, ceilDiv(highest, manualHighestDivisor), + manualThroughputMin+extra*manualPerExtraContainr) +} + +// headerMinimum is x-ms-cosmos-min-throughput: the lowest manual throughput now allowed, or the floor an +// autoscale resource scales down to (a tenth of its maximum). +func (b *InMemoryBackend) headerMinimum(t offerTarget, spec ThroughputSpec) int { + if spec.AutoscaleMax > 0 { + return spec.MinimumThroughput() + } + + return b.minimumFor(t, spec) +} diff --git a/services/cosmosdb/throughput_test.go b/services/cosmosdb/throughput_test.go index a6708e754a..75d432c8f6 100644 --- a/services/cosmosdb/throughput_test.go +++ b/services/cosmosdb/throughput_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/cosmosdb" ) @@ -73,7 +74,7 @@ func (s *throughputServer) do(method, path string, headers map[string]string, bo req.Header.Set(k, v) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(s.t).Do(req) require.NoError(s.t, err) defer func() { _ = resp.Body.Close() }() @@ -521,3 +522,103 @@ func TestSDK_ThroughputWithAuthValidation(t *testing.T) { v, _ := got.ThroughputProperties.ManualThroughput() assert.EqualValues(t, 800, v) } + +func TestThroughput_MinimumFollowsDocumentedFormula(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(b *cosmosdb.InMemoryBackend) + target string + spec cosmosdb.ThroughputSpec + wantMin int + }{ + { + name: "manual: highest ever provisioned divided by 100", target: "c", + setup: func(b *cosmosdb.InMemoryBackend) { + _, err := b.SetThroughput("d", "c", cosmosdb.ThroughputSpec{Manual: 50_000}) + require.NoError(t, err) + }, + spec: cosmosdb.ThroughputSpec{Manual: 400}, wantMin: 500, + }, + { + name: "autoscale: highest ever divided by 10 rounded up to 1000", target: "c", + setup: func(b *cosmosdb.InMemoryBackend) { + _, err := b.SetThroughput("d", "c", cosmosdb.ThroughputSpec{AutoscaleMax: 55_000}) + require.NoError(t, err) + }, + spec: cosmosdb.ThroughputSpec{AutoscaleMax: 1000}, wantMin: 6000, + }, + { + name: "shared database: 100 RU/s per container past 25", target: "", + setup: func(b *cosmosdb.InMemoryBackend) { + for i := range 29 { + spec := cosmosdb.ContainerSpec{ID: fmt.Sprintf("x%d", i), PartitionKeyPath: "/pk"} + _, err := b.CreateContainer("d", spec) + require.NoError(t, err) + } + }, + spec: cosmosdb.ThroughputSpec{Manual: 400}, wantMin: 900, + }, + { + name: "shared autoscale database: 1000 per container past 25", target: "", + setup: func(b *cosmosdb.InMemoryBackend) { + for i := range 29 { + spec := cosmosdb.ContainerSpec{ID: fmt.Sprintf("x%d", i), PartitionKeyPath: "/pk"} + _, err := b.CreateContainer("d", spec) + require.NoError(t, err) + } + }, + spec: cosmosdb.ThroughputSpec{AutoscaleMax: 1000}, wantMin: 6000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := cosmosdb.NewInMemoryBackend() + _, err := b.CreateDatabase("d") + require.NoError(t, err) + _, err = b.CreateContainer("d", cosmosdb.ContainerSpec{ID: "c", PartitionKeyPath: "/pk"}) + require.NoError(t, err) + tt.setup(b) + + _, err = b.SetThroughput("d", tt.target, tt.spec) + require.ErrorIs(t, err, cosmosdb.ErrInvalidThroughput) + + ok := tt.spec + if ok.AutoscaleMax > 0 { + ok.AutoscaleMax = tt.wantMin + } else { + ok.Manual = tt.wantMin + } + + _, err = b.SetThroughput("d", tt.target, ok) + require.NoError(t, err) + }) + } +} + +func TestThroughput_MetadataOperationsShareTheFixedAccountBudget(t *testing.T) { + t.Parallel() + + s := newThroughputServer(t, true, false) + s.mustCreate("/dbs", `{"id":"d"}`, nil) + + throttled := -1 + + for i := range 400 { + if s.do(http.MethodGet, "/dbs/d", nil, "").status == http.StatusTooManyRequests { + throttled = i + + break + } + } + + require.Positive(t, throttled, "metadata reads draw on the 240 RU/s account budget") + assert.InDelta(t, 240, throttled, 5) + + s.clock.advance(time.Second) + assert.Equal(t, http.StatusOK, s.do(http.MethodGet, "/dbs/d", nil, "").status) +} diff --git a/services/cosmosdb/token.go b/services/cosmosdb/token.go index 61e9c7894d..92bf81bfd0 100644 --- a/services/cosmosdb/token.go +++ b/services/cosmosdb/token.go @@ -21,9 +21,14 @@ const ( headerExpirySeconds = "X-Ms-Documentdb-Expiry-Seconds" - // defaultTokenExpirySeconds and maxTokenExpirySeconds are the documented resource-token lifetime bounds. + // Resource-token lifetime: 1 h default; 10 min minimum and 24 h maximum per "Service quotas" (Per-request limits). + // The Create Permission REST page, last revised 2020, still says 18000 s. defaultTokenExpirySeconds = 3600 - maxTokenExpirySeconds = 18000 + minTokenExpirySeconds = 600 + maxTokenExpirySeconds = 86400 + // MinResourceTokenMaxSeconds and MaxResourceTokenMaxSeconds bound Settings.ResourceTokenMaxSeconds. + MinResourceTokenMaxSeconds = minTokenExpirySeconds + MaxResourceTokenMaxSeconds = maxTokenExpirySeconds ) var ( @@ -31,10 +36,22 @@ var ( ErrInsufficientPermissions = errors.New("cosmosdb: resource token does not grant this operation") // ErrTokenExpired is returned when a resource token is used outside its validity window. ErrTokenExpired = errors.New("cosmosdb: resource token is not valid at the current time") - // ErrInvalidTokenExpiry is returned for an x-ms-documentdb-expiry-seconds outside 1..18000. - ErrInvalidTokenExpiry = errors.New("cosmosdb: x-ms-documentdb-expiry-seconds must be between 1 and 18000") + // ErrInvalidTokenExpiry is returned for an x-ms-documentdb-expiry-seconds outside 600..the configured maximum. + ErrInvalidTokenExpiry = errors.New("cosmosdb: invalid x-ms-documentdb-expiry-seconds") ) +// ErrInvalidResourceTokenMax is returned when --cosmosdb-resource-token-max-seconds is outside 600..86400. +var ErrInvalidResourceTokenMax = errors.New("cosmosdb: resource-token-max-seconds must be between 600 and 86400") + +type tokenExpiryError struct{ maxSeconds int } + +func (e *tokenExpiryError) Error() string { + return fmt.Sprintf("cosmosdb: x-ms-documentdb-expiry-seconds must be between %d and %d", + minTokenExpirySeconds, e.maxSeconds) +} + +func (e *tokenExpiryError) Is(target error) bool { return target == ErrInvalidTokenExpiry } + // tokenClaims is the signed payload of a resource token. It names the permission; scope is read from the live // permission on every request, so deleting or narrowing a permission takes effect immediately. type tokenClaims struct { @@ -117,15 +134,20 @@ func parseResourceToken(masterKey, sig string) (tokenClaims, bool) { } // tokenExpiry reads x-ms-documentdb-expiry-seconds; absent means the one-hour default. -func tokenExpiry(r *http.Request) (time.Duration, error) { +func (h *Handler) tokenExpiry(r *http.Request) (time.Duration, error) { raw := r.Header.Get(headerExpirySeconds) if raw == "" { return defaultTokenExpirySeconds * time.Second, nil } + maxSeconds := h.ResourceTokenMaxSeconds + if maxSeconds == 0 { + maxSeconds = maxTokenExpirySeconds + } + n, err := strconv.Atoi(raw) - if err != nil || n < 1 || n > maxTokenExpirySeconds { - return 0, ErrInvalidTokenExpiry + if err != nil || n < minTokenExpirySeconds || n > maxSeconds { + return 0, &tokenExpiryError{maxSeconds: maxSeconds} } return time.Duration(n) * time.Second, nil @@ -195,26 +217,28 @@ func permissionCovers(p PermissionInfo, r *http.Request) bool { } docPermission := len(segs) == documentLinkSegments + if docPermission && segs[4] != docsSegment { + return scriptPermissionCovers(segs, kind, docID) && partitionAllowed(p, r) + } + + return childCovered(kind, docPermission, docPermission && docID == segs[5], read) && partitionAllowed(p, r) +} +// childCovered reports whether a container (docPermission false) or document permission reaches a request of kind. +func childCovered(kind resourceKind, docPermission, sameDoc, read bool) bool { switch kind { case resourceContainerItem: return !docPermission case resourcePKRanges: return !docPermission && read case resourceDocumentItem: - if docPermission && docID != segs[5] { - return false - } + return !docPermission || sameDoc case resourceDocuments, resourceSprocs, resourceSprocItem, resourceTriggers, resourceTriggerItem, resourceUDFs, resourceUDFItem: - if docPermission { - return false - } + return !docPermission default: return false } - - return partitionAllowed(p, r) } func partitionAllowed(p PermissionInfo, r *http.Request) bool { @@ -238,3 +262,20 @@ func isReadRequest(r *http.Request) bool { return false } } + +// scriptPermissionCovers reports whether a stored procedure, trigger or UDF permission (segs) grants r: the item +// itself and nothing else. Executing a stored procedure is a write, so a Read permission never reaches it. +func scriptPermissionCovers(segs []string, kind resourceKind, itemID string) bool { + var want resourceKind + + switch scriptKind(segs[4]) { + case kindSproc: + want = resourceSprocItem + case kindTrigger: + want = resourceTriggerItem + case kindUDF: + want = resourceUDFItem + } + + return kind == want && itemID == segs[5] +} diff --git a/services/cosmosdb/triggers.go b/services/cosmosdb/triggers.go index 00bd9ab3a1..6b035b20ab 100644 --- a/services/cosmosdb/triggers.go +++ b/services/cosmosdb/triggers.go @@ -1,6 +1,7 @@ package cosmosdb import ( + "encoding/json" "fmt" "strings" ) @@ -97,7 +98,7 @@ func (tx *containerTx) triggerOperation(op docOp, scope string) string { switch op.kind { case batchCreate: return trigOpCreate - case batchReplace: + case batchReplace, batchPatch: return trigOpReplace case batchDelete: return trigOpDelete @@ -149,6 +150,8 @@ func (tx *containerTx) applyDocOp(op docOp, scope string, body map[string]any) ( res.created = !existed case batchReplace: res.info, err = tx.replaceDoc(op.pk, op.id, body, op.ifMatch) + case batchPatch: + res.info, err = tx.patchWithBody(op, body) case batchDelete: res.deleted = true err = tx.deleteDoc(op.pk, op.id, op.ifMatch) @@ -220,3 +223,18 @@ func triggerNames(header string) []string { return out } + +// patchWithBody applies a patch whose request body (the operations, after any pre-trigger edit) is body. +func (tx *containerTx) patchWithBody(op docOp, body map[string]any) (DocumentInfo, error) { + raw, err := json.Marshal(body) + if err != nil { + return DocumentInfo{}, invalidPatchf("the patch body is not valid JSON") + } + + req, err := parsePatchRequest(raw) + if err != nil { + return DocumentInfo{}, err + } + + return tx.patchDoc(op.pk, op.id, req, op.ifMatch) +} diff --git a/services/cosmosdb/ttl.go b/services/cosmosdb/ttl.go index 30128c689e..62ddf3c1b7 100644 --- a/services/cosmosdb/ttl.go +++ b/services/cosmosdb/ttl.go @@ -108,10 +108,11 @@ func (b *InMemoryBackend) SweepExpired() int { } before := removed + c.keep = b.changeRetention() for _, d := range c.sortedDocs() { if !c.alive(d, now) { - c.recordExpired(d) + c.recordExpired(d, now) delete(c.Documents, documentCompositeKey{PartitionKeyJSON: d.PartitionKeyJSON, ID: d.ID}) removed++ diff --git a/services/cosmosdb/users.go b/services/cosmosdb/users.go index c998fb872c..360b211e7c 100644 --- a/services/cosmosdb/users.go +++ b/services/cosmosdb/users.go @@ -346,10 +346,6 @@ func (b *InMemoryBackend) buildPermissionLocked(spec PermissionSpec) (*storedPer return p, nil } - if strings.Count(link, "/")+1 != containerLinkSegments { - return nil, fmt.Errorf("%w: resourcePartitionKey requires a container resource", ErrInvalidPermission) - } - pk, err := parsePartitionKeyHeader(string(spec.PartitionKey)) if err != nil { return nil, fmt.Errorf("%w: resourcePartitionKey is malformed", ErrInvalidPermission) @@ -368,9 +364,12 @@ func (b *InMemoryBackend) resolveLinkLocked(link string) (string, error) { return "", fmt.Errorf("%w: resource must link a container or a document", ErrInvalidPermission) } - badDocSegment := len(segs) == documentLinkSegments && segs[4] != docsSegment - if segs[0] != dbsSegment || segs[2] != collsSegment || badDocSegment { - return "", fmt.Errorf("%w: resource must link a container or a document", ErrInvalidPermission) + _, childOK := collectionKindsFor(segs[len(segs)-2]) + badChild := len(segs) == documentLinkSegments && (!childOK || segs[4] == conflictsSegment) + + if segs[0] != dbsSegment || segs[2] != collsSegment || badChild { + return "", fmt.Errorf("%w: resource must link a container, document, stored procedure, trigger or UDF", + ErrInvalidPermission) } notFound := fmt.Errorf("%w: resource %q does not exist", ErrInvalidPermission, link) @@ -392,6 +391,10 @@ func (b *InMemoryBackend) resolveLinkLocked(link string) (string, error) { return path, nil } + if segs[4] != docsSegment { + return b.resolveScriptLinkLocked(b.databases[dbID].Containers[cID], path, segs[4], segs[5], notFound) + } + for _, doc := range b.databases[dbID].Containers[cID].Documents { if doc.ID == segs[5] || fakeRID(path+"/docs/"+doc.ID) == segs[5] { return path + "/docs/" + doc.ID, nil @@ -414,3 +417,26 @@ func matchLinkSegment[T any](m map[string]*T, seg string, path func(id string) s return "", false } + +// resolveScriptLinkLocked resolves a stored procedure, trigger or UDF link (id or RID) to its id-based path. +func (b *InMemoryBackend) resolveScriptLinkLocked( + c *storedContainer, path, collection, seg string, notFound error, +) (string, error) { + var scripts map[string]*storedScript + + switch scriptKind(collection) { + case kindSproc: + scripts = c.Sprocs + case kindTrigger: + scripts = c.Triggers + case kindUDF: + scripts = c.UDFs + } + + id, ok := matchLinkSegment(scripts, seg, func(id string) string { return path + "/" + collection + "/" + id }) + if !ok { + return "", notFound + } + + return path + "/" + collection + "/" + id, nil +} diff --git a/services/cosmosdb/users_test.go b/services/cosmosdb/users_test.go index 9db2c89928..0e0637f369 100644 --- a/services/cosmosdb/users_test.go +++ b/services/cosmosdb/users_test.go @@ -142,6 +142,8 @@ func TestResourceToken_ScopeMatrix(t *testing.T) { {"allPkA", "All", "dbs/d/colls/c1", "a"}, {"docRead", "Read", "dbs/d/colls/c1/docs/1", ""}, {"docAll", "All", "dbs/d/colls/c1/docs/1", ""}, + {"docPkA", "Read", "dbs/d/colls/c1/docs/1", "a"}, + {"docPkB", "Read", "dbs/d/colls/c1/docs/1", "b"}, } tokens := make(map[string]string, len(perms)) @@ -165,6 +167,10 @@ func TestResourceToken_ScopeMatrix(t *testing.T) { {"read doc own pk", "pkA", http.MethodGet, "/dbs/d/colls/c1/docs/1", "", "pk:a", 200}, {"read doc other pk denied", "pkA", http.MethodGet, "/dbs/d/colls/c1/docs/2", "", "pk:b", 403}, {"read doc no pk header denied", "pkA", http.MethodGet, "/dbs/d/colls/c1/docs/1", "", "", 403}, + {"doc permission for its own key", "docPkA", http.MethodGet, "/dbs/d/colls/c1/docs/1", "", "pk:a", 200}, + {"doc permission for another key", "docPkB", http.MethodGet, "/dbs/d/colls/c1/docs/1", "", "pk:a", 403}, + {"doc permission without a key header", "docPkA", http.MethodGet, "/dbs/d/colls/c1/docs/1", "", "", 403}, + {"doc permission authorizes its key only", "docPkB", http.MethodGet, "/dbs/d/colls/c1/docs/1", "", "pk:b", 404}, {"read granted doc", "docRead", http.MethodGet, "/dbs/d/colls/c1/docs/1", "", "pk:a", 200}, {"read other doc denied", "docRead", http.MethodGet, "/dbs/d/colls/c1/docs/2", "", "pk:b", 403}, {"cross container denied", "readC1", http.MethodGet, "/dbs/d/colls/c2/docs/1", "", "pk:a", 403}, @@ -269,8 +275,8 @@ func TestResourceToken_Expiry(t *testing.T) { {"default expired at an hour", "", 3600 * time.Second, 403}, {"custom valid", "600", 599 * time.Second, 200}, {"custom expired", "600", 600 * time.Second, 403}, - {"max lifetime valid", "18000", 17999 * time.Second, 200}, - {"max lifetime expired", "18000", 5 * time.Hour, 403}, + {"max lifetime valid", "86400", 86399 * time.Second, 200}, + {"max lifetime expired", "86400", 24 * time.Hour, 403}, } for _, tc := range tests { @@ -309,10 +315,12 @@ func TestPermission_ExpiryHeaderValidation(t *testing.T) { }{ {"zero", "0", 400}, {"negative", "-5", 400}, - {"too long", "18001", 400}, + {"below the minimum", "599", 400}, + {"too long", "86401", 400}, {"not a number", "abc", 400}, - {"minimum", "1", 201}, - {"maximum", "18000", 201}, + {"minimum", "600", 201}, + {"former documented maximum", "18001", 201}, + {"maximum", "86400", 201}, } for _, tc := range tests { @@ -329,6 +337,38 @@ func TestPermission_ExpiryHeaderValidation(t *testing.T) { } } +func TestPermission_ExpiryMaxSetting(t *testing.T) { + t.Parallel() + + tests := []struct { + name, value, wantMsg string + max, want int + }{ + {"default accepts 86400", "86400", "", 0, 201}, + {"default rejects 86401", "86401", "between 600 and 86400", 0, 400}, + {"configured accepts its max", "18000", "", 18000, 201}, + {"configured rejects above max", "18001", "between 600 and 18000", 18000, 400}, + {"configured rejects 86400", "86400", "between 600 and 18000", 18000, 400}, + {"configured keeps minimum", "599", "between 600 and 18000", 18000, 400}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newUserEnv(t) + e.h.ResourceTokenMaxSeconds = tc.max + e.seed(t) + + rec := e.admin(t, http.MethodPost, "/dbs/d/users/u/permissions", + `{"id":"p","permissionMode":"Read","resource":"dbs/d/colls/c1"}`, + map[string]string{"X-Ms-Documentdb-Expiry-Seconds": tc.value}) + require.Equal(t, tc.want, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), tc.wantMsg) + }) + } +} + func TestResourceToken_Rejected(t *testing.T) { t.Parallel() @@ -443,8 +483,20 @@ func TestUsersPermissions_CRUD(t *testing.T) { `{"id":"p","permissionMode":"All","resource":"dbs/d/colls/nope"}`, 400}, {"missing document", http.MethodPost, "/dbs/d/users/robert/permissions", `{"id":"p","permissionMode":"All","resource":"dbs/d/colls/c1/docs/nope"}`, 400}, - {"pk on document resource", http.MethodPost, "/dbs/d/users/robert/permissions", - `{"id":"p","permissionMode":"All","resource":"dbs/d/colls/c1/docs/1","resourcePartitionKey":["a"]}`, 400}, + { + "pk on document resource", + http.MethodPost, + "/dbs/d/users/robert/permissions", + `{"id":"pdoc","permissionMode":"All","resource":"dbs/d/colls/c1/docs/1","resourcePartitionKey":["a"]}`, + 201, + }, + { + "pk on document resource is deleted again", + http.MethodDelete, + "/dbs/d/users/robert/permissions/pdoc", + "", + 204, + }, {"malformed pk", http.MethodPost, "/dbs/d/users/robert/permissions", `{"id":"p","permissionMode":"All","resource":"dbs/d/colls/c1","resourcePartitionKey":[[1]]}`, 400}, {"permission for missing user", http.MethodPost, "/dbs/d/users/nobody/permissions", @@ -531,3 +583,78 @@ func TestUsersPermissions_PersistenceRoundTrip(t *testing.T) { assert.Equal(t, 200, restored.withToken(t, tok, http.MethodGet, "/dbs/d/colls/c1/docs/1", "", pkHdr("a")).Code) assert.Equal(t, 403, restored.withToken(t, tok, http.MethodGet, "/dbs/d/colls/c1/docs/2", "", pkHdr("b")).Code) } + +func TestResourceToken_ScriptPermissions(t *testing.T) { + t.Parallel() + + e := newUserEnv(t) + e.seed(t) + + const sprocBody = `{"id":"s","body":"function(){ getContext().getResponse().setBody(1); }"}` + + for _, step := range []struct{ path, body string }{ + {"/dbs/d/colls/c1/sprocs", sprocBody}, + {"/dbs/d/colls/c1/sprocs", `{"id":"other","body":"function(){}"}`}, + {"/dbs/d/colls/c1/udfs", `{"id":"u1","body":"function(x){return x;}"}`}, + {"/dbs/d/colls/c1/triggers", `{"id":"t1","body":"function(){}","triggerOperation":"All","triggerType":"Pre"}`}, + } { + rec := e.admin(t, http.MethodPost, step.path, step.body, nil) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + } + + tokens := map[string]string{} + + for id, p := range map[string]struct{ mode, resource string }{ + "sprocAll": {"All", "dbs/d/colls/c1/sprocs/s"}, + "sprocRead": {"Read", "dbs/d/colls/c1/sprocs/s"}, + "udfRead": {"Read", "dbs/d/colls/c1/udfs/u1"}, + "trigAll": {"All", "dbs/d/colls/c1/triggers/t1"}, + } { + tok, code := e.permission(t, id, p.mode, p.resource, "", nil) + require.Equal(t, http.StatusCreated, code, id) + + tokens[id] = tok + } + + _, code := e.permission(t, "missing", "All", "dbs/d/colls/c1/sprocs/none", "", nil) + assert.Equal(t, http.StatusBadRequest, code) + + cases := []struct { + name, perm, method, path, body string + want int + }{ + {"execute with an All permission", "sprocAll", http.MethodPost, "/dbs/d/colls/c1/sprocs/s", "[]", 200}, + {"a Read permission cannot execute", "sprocRead", http.MethodPost, "/dbs/d/colls/c1/sprocs/s", "[]", 403}, + {"read the granted procedure", "sprocRead", http.MethodGet, "/dbs/d/colls/c1/sprocs/s", "", 200}, + {"another procedure is denied", "sprocAll", http.MethodGet, "/dbs/d/colls/c1/sprocs/other", "", 403}, + {"the procedure list is denied", "sprocAll", http.MethodGet, "/dbs/d/colls/c1/sprocs", "", 403}, + { + "a procedure permission does not reach documents", + "sprocAll", + http.MethodGet, + "/dbs/d/colls/c1/docs/1", + "", + 403, + }, + { + "a procedure permission is not a UDF permission", + "sprocAll", + http.MethodGet, + "/dbs/d/colls/c1/udfs/u1", + "", + 403, + }, + {"read the granted UDF", "udfRead", http.MethodGet, "/dbs/d/colls/c1/udfs/u1", "", 200}, + {"a Read permission cannot delete", "udfRead", http.MethodDelete, "/dbs/d/colls/c1/udfs/u1", "", 403}, + {"delete the granted trigger", "trigAll", http.MethodDelete, "/dbs/d/colls/c1/triggers/t1", "", 204}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + rec := e.withToken(t, tokens[tc.perm], tc.method, tc.path, tc.body, pkHdr("a")) + assert.Equal(t, tc.want, rec.Code, rec.Body.String()) + }) + } +} diff --git a/services/databrew/iam_enforcement_test.go b/services/databrew/iam_enforcement_test.go index 0771e6c56f..d52a43b064 100644 --- a/services/databrew/iam_enforcement_test.go +++ b/services/databrew/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/databrew" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/databrew/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/datasync/iam_enforcement_test.go b/services/datasync/iam_enforcement_test.go index d9c409b2dc..417385b989 100644 --- a/services/datasync/iam_enforcement_test.go +++ b/services/datasync/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/datasync" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -164,7 +165,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/datasync/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/dax/iam_enforcement_test.go b/services/dax/iam_enforcement_test.go index 19d859d435..6b02f28c1e 100644 --- a/services/dax/iam_enforcement_test.go +++ b/services/dax/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/dax" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -166,7 +167,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/dax/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/detective/iam_enforcement_test.go b/services/detective/iam_enforcement_test.go index 00e260a06b..dc6d57db55 100644 --- a/services/detective/iam_enforcement_test.go +++ b/services/detective/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/detective" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/detective/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/directconnect/iam_enforcement_test.go b/services/directconnect/iam_enforcement_test.go index 23f55a920e..8a4861f58f 100644 --- a/services/directconnect/iam_enforcement_test.go +++ b/services/directconnect/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/directconnect" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/directconnect/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/directoryservice/iam_enforcement_test.go b/services/directoryservice/iam_enforcement_test.go index c64d30815a..eec1050e13 100644 --- a/services/directoryservice/iam_enforcement_test.go +++ b/services/directoryservice/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/directoryservice" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ds/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/dlm/iam_enforcement_test.go b/services/dlm/iam_enforcement_test.go index 97b3b4229c..51f1909967 100644 --- a/services/dlm/iam_enforcement_test.go +++ b/services/dlm/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/dlm" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -172,7 +173,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/dlm/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/dms/PARITY.md b/services/dms/PARITY.md index 863350a85a..74e5d28686 100644 --- a/services/dms/PARITY.md +++ b/services/dms/PARITY.md @@ -16,7 +16,7 @@ service: dms # 2026-08-20). Do not repeat cmd/opcensus's mistake when re-auditing. sdk_module: aws-sdk-go-v2/service/databasemigrationservice@v1.75.0 last_audit_commit: 0c1472972 # terraform-coverage pass: S3Settings modeled, Certificate tagging added; prior: f16ac0367fc476ca2ffd1643ed5ef900b9ff0480 -last_audit_date: 2026-10-10 # prior: 2026-09-23 +last_audit_date: 2026-10-31 # prior: 2026-10-10 overall: A # 2026-08-29 (gopherstack-21my, parameter-honoring sweep): audited a coherent slice # of ~44 Filters/pagination-bearing Describe ops (Filters+Marker/MaxRecords or # Filters+NextToken/MaxRecords), not the full 47-op Describe/List surface. Fixed @@ -110,17 +110,17 @@ ops: DescribeEndpoints: {wire: ok, errors: ok, state: ok, persist: ok, note: "FIXED 2026-08-20 -- same 6-field connection-settings fix as CreateEndpoint above (shared epToJSON)"} DeleteEndpoint: {wire: ok, errors: ok, state: ok, persist: ok, note: "rejects delete while referenced by a task"} ModifyEndpoint: {wire: ok, errors: ok, state: ok, persist: ok, note: "EndpointType/EngineName accepted on Modify, validated with the same enum check as Create, and applied. FIXED 2026-07-31 -- same Password fix as CreateEndpoint above. FIXED 2026-10-07 -- engine settings blocks merge per block, ExactSettings drops unspecified blocks. FIXED 2026-08-20 -- CertificateArn/ExtraConnectionAttributes/ServiceAccessRoleArn/SslMode/ExternalTableDefinition (same gap as CreateEndpoint above) now accepted and applied. KmsKeyId is deliberately NOT accepted here -- the real ModifyEndpointInput has no KmsKeyId member (create-only in real AWS); proven unchanged by TestEndpointConnectionSettings_SDKRoundTrip. EndpointIdentifier renames; settings blocks merge by key unless ExactSettings. Proven by TestRealClient_EndpointRenameAndExactSettings."} - TestConnection: {wire: ok, errors: ok, state: ok, persist: ok, note: "records a Connection row, visible via DescribeConnections"} + TestConnection: {wire: ok, errors: ok, state: ok, persist: ok, note: "records a Connection row, visible via DescribeConnections. With dms-engine=docker the endpoint is really contacted (MySQL/MariaDB/PostgreSQL dial plus ping; S3 bucket, Kinesis stream; DynamoDB always reachable) and a failure is recorded as status failed with LastFailureMessage 'Failed to connect to database. ' (types.Connection.LastFailureMessage). Engines the emulator cannot reach, and engine mode off, still report successful. The test is synchronous: real DMS returns status testing first."} DescribeConnections: {wire: ok, errors: ok, state: ok, persist: ok, note: "FIXED 2026-07-31 -- never called dmsPaginate or set Marker on the response, unlike every other Describe op in this service, so MaxRecords/Marker were silently ignored; now paginated like its siblings"} DeleteConnection: {wire: ok, errors: ok, state: ok, persist: ok} CreateReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok, note: "validates source/target endpoint and instance ARNs exist. FIXED this pass -- ReplicationTaskCreationDate was entirely missing from the wire response (epoch-seconds bug class); now emitted via pkgs/awstime.Epoch. FIXED 2026-08-29 (write-only-state sweep) -- CdcStartPosition, CdcStopPosition, and TaskData (real CreateReplicationTaskInput members, api_op_CreateReplicationTask.go, all three also real top-level types.ReplicationTask response members) were entirely unaccepted: a real client's CDC checkpoint positions and task data were silently discarded by encoding/json with no error, and the domain model had no field to store them even if the wire had accepted them. Now accepted, stored, and echoed -- see ReplicationTaskCDCSettings in replication_tasks.go. CdcStartTime is request-only (no matching response field on types.ReplicationTask) and intentionally not modeled."} DescribeReplicationTasks: {wire: ok, errors: ok, state: ok, persist: ok, note: "FIXED 2026-08-29, see CreateReplicationTask above -- same CdcStartPosition/CdcStopPosition/TaskData fix (shared rtToJSON). FIXED 2026-08-29 (wrapper-key/parameter-honoring sweep, gopherstack-21my) -- DescribeReplicationTasksInput documents 5 filter names (replication-task-arn|replication-task-id|migration-type|endpoint-arn|replication-instance-arn, api_op_DescribeReplicationTasks.go); only the first two were honored (via the identifier lookup passed to the backend). migration-type/endpoint-arn/replication-instance-arn were silently ignored -- a client filtering by any of the three got the full unfiltered list back with 200 OK. Now applied as a post-filter against ReplicationTask.MigrationType/Source+TargetEndpointArn/ReplicationInstanceArn. See TestDescribeReplicationTasksFilter_MigrationTypeAndArns (list_filter_params_test.go), real SDK client round trip."} - StartReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok} - StopReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok, note: "rejects stop unless currently running"} + StartReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok, note: "2026-10-31 -- with dms-engine=docker and both endpoints supported the task runs pkgs/dmsengine: starting then running once both endpoints are open, full-load / cdc / full-load-and-cdc honoured, StartReplicationTaskType resume-processing continues from RecoveryCheckpoint (other types start fresh), CdcStartPosition (binlog file:pos, PostgreSQL LSN is ignored, ISO date) and CdcStopPosition (server_time:/commit_time:) consumed. Rejects a replication instance that is not available. Without the engine, or for unsupported engines, state-only (see structural_gaps)."} + StopReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok, note: "rejects stop unless starting/running/stopping. Engine-backed tasks report stopping, then stopped with StopReason 'Stop Reason NORMAL'; a full-load-only task ends by itself with 'Stop Reason FULL_LOAD_ONLY_FINISHED'; a task that errors ends failed with StopReason 'Stop Reason FATAL_ERROR' and LastFailureMessage (all strings from types.ReplicationTask.StopReason, types.go)."} DeleteReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok, note: "rejects delete while running"} ModifyReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok, note: "rejects modify while running. FIXED 2026-08-29, see CreateReplicationTask above -- ModifyReplicationTaskInput also carries CdcStartPosition/CdcStopPosition/TaskData; now accepted and applied (only-overwrite-non-empty semantics, matching every other ModifyReplicationTask field). ReplicationTaskIdentifier renames; CdcStartTime/CdcStartPosition mutual exclusion enforced."} MoveReplicationTask: {wire: ok, errors: ok, state: ok, persist: ok} - ReloadTables: {wire: ok, errors: ok, state: ok, persist: n/a, note: "FIXED this pass -- was a disguised no-op that echoed ReplicationTaskArn without validating anything; now requires TablesToReload, validates ReloadOption enum, 404s on an unknown task, and 400 InvalidResourceStateFault unless the task is currently RUNNING (matches the SDK doc: 'You can only use this operation with a task in the RUNNING state')"} + ReloadTables: {wire: ok, errors: ok, state: ok, persist: n/a, note: "2026-10-31 -- engine-backed tasks re-copy the named TablesToReload (SchemaName/TableName) asynchronously while change capture pauses applying; a table the task does not replicate is ResourceNotFoundFault. FIXED earlier -- was a disguised no-op that echoed ReplicationTaskArn without validating anything; now requires TablesToReload, validates ReloadOption enum, 404s on an unknown task, and 400 InvalidResourceStateFault unless the task is currently RUNNING (matches the SDK doc: 'You can only use this operation with a task in the RUNNING state')"} ReloadReplicationTables: {wire: ok, errors: ok, state: ok, persist: n/a, note: "FIXED this pass -- two bugs: (1) the request field was wrongly named ReplicationTaskArn instead of the real ReplicationConfigArn, silently discarding the client's ARN; (2) it never validated anything. Now requires TablesToReload, validates ReloadOption, 404s on an unknown replication config, and 400s unless the associated Replication is RUNNING"} DescribeReplicationTableStatistics: {wire: ok, errors: ok, state: partial, persist: n/a, note: "FIXED 2026-08-11 -- request/response fields were copy-pasted from the sibling DescribeTableStatistics op (ReplicationTaskArn/TableStatistics) instead of this op's real fields (ReplicationConfigArn/ReplicationTableStatistics); the wrong request field meant the config ARN was silently discarded and the handler queried an arbitrary replication task instead. Now validates the config exists (404 if not) and echoes ReplicationConfigArn. Always returns an empty ReplicationTableStatistics list -- ReplicationConfig carries no TableMappings state in this emulation (see models.go), so per-table stats have no honest backend source; adding fabricated stats would be worse than an accurate empty list. 2026-08-12 (gopherstack-o53q): Filters []types.Filter is now accepted on the wire for shape parity, but deliberately left inert and documented as such -- filtering an always-empty list has no observable effect, and there is no per-table state anywhere in this emulation for a filter to narrow."} DescribeTableStatistics: {wire: ok, errors: ok, state: ok, persist: n/a, note: "FIXED 2026-08-29 (gopherstack-21my) -- unlike its sibling DescribeReplicationTableStatistics (always-empty, inert Filters is correct there), this op DOES have real per-table state (buildTableStatistics derives rows from the task's TableMappings), so its documented Filters (schema-name|table-name|table-state, api_op_DescribeTableStatistics.go) and Marker/MaxRecords pagination were a real, observable gap -- both were declared on the request struct but never read. Now applied. See TestDescribeTableStatisticsFilter (list_filter_params_test.go), real SDK client round trip."} @@ -204,19 +204,27 @@ families: assessment-runs: {status: ok, note: "FIXED this pass (deferred item #3, now resolved) -- StartReplicationTaskAssessmentRun now validates its four required fields and IncludeOnly/Exclude mutual exclusion, then synchronously runs a real (bounded, static-catalog-backed) set of IndividualAssessment checks, all passing. DescribeReplicationTaskIndividualAssessments and DescribeReplicationTaskAssessmentResults are now backed by that real state instead of hardcoded empty lists. Cancel/Delete/DescribeReplicationTaskAssessmentRuns now return the full real ReplicationTaskAssessmentRun wire shape instead of a hand-rolled 4-field map."} gaps: [] items_still_open: + - "dmsengine (2026-10-31, unverified against real DMS, recalled from the DMS User Guide only): the S3 target's CsvNullValue default (NULL), CDC file name pattern (YYYYMMDD-HHMMSSmmm.csv), Kinesis control-record shape under IncludeControlDetails, Kinesis partition key (schema.table) and JSON number/string value encoding, and the ISO/checkpoint forms accepted by CdcStartPosition. The SDK confirms only the settings member names, the TableStatistics and ReplicationTaskStats fields, TableState values and the StopReason strings." + - "dmsengine scope: selection rules support include/exclude/explicit with % wildcards only (no filters, no _ wildcard); transformation rules cover schema/table/column rename, add/remove/replace prefix and suffix, convert-lowercase/uppercase, remove-column (no add-column, include-column, change-data-type, expressions); no object-mapping rules for Kinesis/DynamoDB; BeforeImageSettings, validation, LOB settings, CommitRate batching beyond batch size, parallel-load, CdcMaxBatchInterval, StopTaskCachedChanges*, TaskRecoveryTableEnabled and CreatePkAfterFullLoad are ignored." + - "dmsengine sources and targets: MySQL/MariaDB/Aurora-MySQL and PostgreSQL/Aurora-PostgreSQL sources only (no Oracle, SQL Server, MongoDB, S3 source); SQL targets are the same two families plus S3 (CSV and Parquet), Kinesis and DynamoDB. DDL on the source is not replicated (a column-count change suspends the table with Table error), secrets-manager endpoint credentials are not resolved, TLS is always verified: SslMode require/verify-ca/verify-full trust the endpoint's CertificateArn PEM (ImportCertificate) or the system roots and check the server name, and a failed verification fails TestConnection and the task with an explanatory message (require is not DMS's no-verify mode; verify-ca also checks the host name); PostgreSQL CDC reads test_decoding through pg_logical_slot_get_changes and cannot start from an arbitrary LSN or timestamp (changes since slot creation only), and the slot is dropped when the task is deleted." + - "RDS Postgres docker instances start with wal_level=logical only when the instance parameter group (cluster parameter group for Aurora) sets rds.logical_replication=1 at create time, as real DMS users must; otherwise wal_level=replica and a PostgreSQL CDC task fails with 'wal_level is replica, logical is required'. Enabling it on an existing instance by ModifyDBParameterGroup plus reboot does not recreate the container, so the instance must be created with the group." + - "ENABLE_DMS and DMS_SERVERLESS_* LocalStack aliases are not provided, and serverless ReplicationConfig/StartReplication still move no data (only classic replication tasks run the engine)." - "DescribeApplicableIndividualAssessments: MigrationType/SourceEngineName/TargetEngineName do not narrow the catalog. The SDK doc says a partial set of these three cannot produce a list, but names no error and no per-engine support matrix (the real assessment names live only in the user guide), so narrowing or rejecting would be invention." - "ModifyReplicationInstance.ApplyImmediately/AllowMajorVersionUpgrade are undeclared: the SDK gives neither the maintenance-window application timing, the definition of a 'major' DMS version, nor the error for an unflagged major upgrade." - "DescribeEvents.Duration is undeclared: the pinned SDK doc gives no unit for 'the duration of the events to be listed', so applying a window would be invention." structural_gaps: - - "DescribeDataMigrationsInput.WithoutStatistics: DataMigration.DataMigrationStatistics is never populated because no migration engine ever runs, so there is nothing to omit." + - "DescribeDataMigrationsInput.WithoutStatistics: DataMigration.DataMigrationStatistics is never populated because the engine only drives classic replication tasks, not DMS data migrations, so there is nothing to omit." - "StartReplicationInput.PremigrationAssessmentSettings is validated (JSON object, ResultEncryptionMode enum, IncludeOnly/Exclude exclusive), but the FailOnAssessmentFailure gate never trips: every individual assessment passes since no source/target connectivity exists to check." - - "CdcStartTime on CreateReplicationTask/ModifyReplicationTask/StartReplicationTask: validated against CdcStartPosition but not stored, since types.ReplicationTask has no CdcStartTime member and no CDC engine consumes it. StartReplication echoes CdcStartTime on the Replication." + - "CdcStartTime on CreateReplicationTask/ModifyReplicationTask/StartReplicationTask: validated against CdcStartPosition but not stored, since types.ReplicationTask has no CdcStartTime member and the engine consumes CdcStartPosition only. StartReplication echoes CdcStartTime on the Replication." deferred: [] -leaks: {status: clean, note: "no goroutines, janitors, or timers in this service; all state lives in store.Table/store.Index behind the single lockmetrics.RWMutex. leak_test.go / isolation_test.go pre-existing and passing. Confirmed again this pass -- no new goroutines/tickers/channels were introduced by the assessment-run rework (StartReplicationTaskAssessmentRun completes synchronously)."} +leaks: {status: clean, note: "2026-10-31 -- with dms-engine=docker each running task owns one goroutine (plus a short-lived one per ReloadTables); Handler.Shutdown/Backend.Close cancels and joins them and leak_main_test.go verifies. Without the engine: no goroutines, janitors, or timers; all state lives in store.Table/store.Index behind the single lockmetrics.RWMutex. leak_test.go / isolation_test.go pre-existing and passing. Confirmed again this pass -- no new goroutines/tickers/channels were introduced by the assessment-run rework (StartReplicationTaskAssessmentRun completes synchronously)."} --- ## Notes +### 2026-10-31 replication engine (gopherstack-49qct.76) +New `pkgs/dmsengine` plus `services/dms/engine.go`; enabled by `--dms-engine=docker` / `DMS_ENGINE` (or `--engines=auto|docker`). Design: `Task.Run` lists source tables, applies the table-mapping rules (`Select` + `Plan`), captures the change position before the load for full-load-and-cdc, loads each table (`TargetTablePrepMode` DROP_AND_CREATE default / TRUNCATE_BEFORE_LOAD / DO_NOTHING from `FullLoadSettings`, batched `Load`), then streams changes until cancelled. MySQL/MariaDB capture uses a go-mysql binlog syncer (row events, XID commits, `file:pos` checkpoints); PostgreSQL uses a replication slot and `pg_logical_slot_get_changes` with `test_decoding`. Targets: SQL upsert/update/delete (types mapped across MySQL/PostgreSQL), S3 CSV and Parquet via the in-process S3 backend (`LOAD00000001.csv`, CDC files with I/U/D), Kinesis JSON records `{data, metadata}` via the in-process Kinesis backend, DynamoDB items keyed by the source primary key. Progress flows into `ReplicationTask.TableStats` (DescribeTableStatistics: FullLoadRows, Inserts/Updates/Deletes, TableState Before load / Full load / Table completed / Table error / Table is being reloaded) and `ReplicationTaskStats` (ElapsedTimeMillis on the injectable clock, FullLoadProgressPercent, TablesLoaded/Loading/Queued/Errored, StartDate/StopDate, FullLoad dates). SDK references: `types.ReplicationTaskStats`, `types.TableStatistics`, `types.ReplicationTask.StopReason`, `types.S3Settings`, `types.KinesisSettings` (databasemigrationservice@v1.75.0). `FullLoadSettings.TargetTablePrepMode` is where DMS keeps the prep mode (not `TargetMetadata`). Tests: `engine_test.go` (fake opener, injected clock), `pkgs/dmsengine/*_test.go`, and docker-gated `internal/app/dms_engine_docker_test.go` (MySQL to PostgreSQL with rename rules and CDC, PostgreSQL to MySQL with remove-column and slot cleanup, MySQL to S3 CSV, S3 CDC, Kinesis, DynamoDB). + ### 2026-10-10 realism pass Probed with the AWS CLI. Fixed: error messages drop the doubled code prefix; replication instance identifier (letter first, alphanumeric/hyphen, no `--`, no trailing hyphen, 63 max), class (`dms.` prefix) and `AllocatedStorage` (5-6144) validated; `TableMappings` must be JSON; unissued `Marker`/`NextToken` rejected; StartReplicationTask returns `starting` and the next DescribeReplicationTasks moves it to `running` (starting tasks cannot be deleted/modified). Tests: `request_validation_test.go`. Kept lenient: ARNs still embed the identifier (`rep:ri1`) or a UUID rather than AWS 26-character ids; instances are `available` on creation; TestConnection is `successful` at once. diff --git a/services/dms/README.md b/services/dms/README.md index 9ae963a60c..02d0ccb841 100644 --- a/services/dms/README.md +++ b/services/dms/README.md @@ -1,7 +1,7 @@ # Database Migration Service -**Parity grade: A** · SDK `aws-sdk-go-v2/service/databasemigrationservice@v1.75.0` · last audited 2026-10-10 (`0c1472972`) +**Parity grade: A** · SDK `aws-sdk-go-v2/service/databasemigrationservice@v1.75.0` · last audited 2026-10-31 (`0c1472972`) ## Coverage @@ -9,13 +9,18 @@ | --- | --- | | PARITY entries audited | 97 (96 ok, 1 partial) | | Feature families | 4 (4 ok) | -| Known gaps | 3 | +| Known gaps | 8 | | Structural gaps (can't be emulated) | 3 | | Deferred items | 0 | | Resource leaks | clean | ### Known gaps +- dmsengine (2026-10-31, unverified against real DMS, recalled from the DMS User Guide only): the S3 target's CsvNullValue default (NULL), CDC file name pattern (YYYYMMDD-HHMMSSmmm.csv), Kinesis control-record shape under IncludeControlDetails, Kinesis partition key (schema.table) and JSON number/string value encoding, and the ISO/checkpoint forms accepted by CdcStartPosition. The SDK confirms only the settings member names, the TableStatistics and ReplicationTaskStats fields, TableState values and the StopReason strings. +- dmsengine scope: selection rules support include/exclude/explicit with % wildcards only (no filters, no _ wildcard); transformation rules cover schema/table/column rename, add/remove/replace prefix and suffix, convert-lowercase/uppercase, remove-column (no add-column, include-column, change-data-type, expressions); no object-mapping rules for Kinesis/DynamoDB; BeforeImageSettings, validation, LOB settings, CommitRate batching beyond batch size, parallel-load, CdcMaxBatchInterval, StopTaskCachedChanges*, TaskRecoveryTableEnabled and CreatePkAfterFullLoad are ignored. +- dmsengine sources and targets: MySQL/MariaDB/Aurora-MySQL and PostgreSQL/Aurora-PostgreSQL sources only (no Oracle, SQL Server, MongoDB, S3 source); SQL targets are the same two families plus S3 (CSV and Parquet), Kinesis and DynamoDB. DDL on the source is not replicated (a column-count change suspends the table with Table error), secrets-manager endpoint credentials are not resolved, TLS is always verified: SslMode require/verify-ca/verify-full trust the endpoint's CertificateArn PEM (ImportCertificate) or the system roots and check the server name, and a failed verification fails TestConnection and the task with an explanatory message (require is not DMS's no-verify mode; verify-ca also checks the host name); PostgreSQL CDC reads test_decoding through pg_logical_slot_get_changes and cannot start from an arbitrary LSN or timestamp (changes since slot creation only), and the slot is dropped when the task is deleted. +- RDS Postgres docker instances start with wal_level=logical only when the instance parameter group (cluster parameter group for Aurora) sets rds.logical_replication=1 at create time, as real DMS users must; otherwise wal_level=replica and a PostgreSQL CDC task fails with 'wal_level is replica, logical is required'. Enabling it on an existing instance by ModifyDBParameterGroup plus reboot does not recreate the container, so the instance must be created with the group. +- ENABLE_DMS and DMS_SERVERLESS_* LocalStack aliases are not provided, and serverless ReplicationConfig/StartReplication still move no data (only classic replication tasks run the engine). - DescribeApplicableIndividualAssessments: MigrationType/SourceEngineName/TargetEngineName do not narrow the catalog. The SDK doc says a partial set of these three cannot produce a list, but names no error and no per-engine support matrix (the real assessment names live only in the user guide), so narrowing or rejecting would be invention. - ModifyReplicationInstance.ApplyImmediately/AllowMajorVersionUpgrade are undeclared: the SDK gives neither the maintenance-window application timing, the definition of a 'major' DMS version, nor the error for an unflagged major upgrade. - DescribeEvents.Duration is undeclared: the pinned SDK doc gives no unit for 'the duration of the events to be listed', so applying a window would be invention. @@ -24,9 +29,9 @@ These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. -- DescribeDataMigrationsInput.WithoutStatistics: DataMigration.DataMigrationStatistics is never populated because no migration engine ever runs, so there is nothing to omit. +- DescribeDataMigrationsInput.WithoutStatistics: DataMigration.DataMigrationStatistics is never populated because the engine only drives classic replication tasks, not DMS data migrations, so there is nothing to omit. - StartReplicationInput.PremigrationAssessmentSettings is validated (JSON object, ResultEncryptionMode enum, IncludeOnly/Exclude exclusive), but the FailOnAssessmentFailure gate never trips: every individual assessment passes since no source/target connectivity exists to check. -- CdcStartTime on CreateReplicationTask/ModifyReplicationTask/StartReplicationTask: validated against CdcStartPosition but not stored, since types.ReplicationTask has no CdcStartTime member and no CDC engine consumes it. StartReplication echoes CdcStartTime on the Replication. +- CdcStartTime on CreateReplicationTask/ModifyReplicationTask/StartReplicationTask: validated against CdcStartPosition but not stored, since types.ReplicationTask has no CdcStartTime member and the engine consumes CdcStartPosition only. StartReplication echoes CdcStartTime on the Replication. ## More diff --git a/services/dms/connections.go b/services/dms/connections.go index 6ba8ea18af..c88f788167 100644 --- a/services/dms/connections.go +++ b/services/dms/connections.go @@ -3,20 +3,23 @@ package dms import ( "context" "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" ) -// TestConnection tests a DMS connection and records the result. +// TestConnection tests a DMS connection and records the result. With the engine enabled the endpoint is really +// contacted and a failure carries the driver's own message; engines the emulator cannot reach report success. func (b *InMemoryBackend) TestConnection( ctx context.Context, replicationInstanceArn, endpointArn string, ) (*Connection, error) { b.mu.Lock("TestConnection") - defer b.mu.Unlock() - region := getRegion(ctx, b.region) ri, ok := lookupUnique(b.replicationInstancesByARN, regionKey(region, replicationInstanceArn)) if !ok { + b.mu.Unlock() + return nil, fmt.Errorf( "%w: replication instance %s not found", ErrNotFound, @@ -26,6 +29,8 @@ func (b *InMemoryBackend) TestConnection( ep, ok := lookupUnique(b.endpointsByARN, regionKey(region, endpointArn)) if !ok { + b.mu.Unlock() + return nil, fmt.Errorf("%w: endpoint %s not found", ErrNotFound, endpointArn) } @@ -37,6 +42,31 @@ func (b *InMemoryBackend) TestConnection( Status: statusSuccessful, Region: region, } + + var ( + probe dmsengine.EndpointConfig + opener dmsengine.Opener + source bool + ) + + if b.eng != nil && b.eng.opener != nil && dmsengine.SupportedTarget(ep.EngineName) && + (ep.EndpointType != endpointTypeSource || dmsengine.SupportedSource(ep.EngineName)) { + probe, opener, source = b.endpointConfigLocked(region, ep), b.eng.opener, ep.EndpointType == endpointTypeSource + } + b.mu.Unlock() + + if opener != nil { + tctx, cancel := context.WithTimeout(ctx, connectionTestLimit) + defer cancel() + + if err := dmsengine.TestEndpoint(tctx, opener, probe, source); err != nil { + conn.Status, conn.LastFailureMessage = statusFailed, "Failed to connect to database. "+err.Error() + } + } + + b.mu.Lock("TestConnectionStore") + defer b.mu.Unlock() + b.connections.Put(conn) cp := *conn diff --git a/services/dms/engine.go b/services/dms/engine.go new file mode 100644 index 0000000000..52b3305115 --- /dev/null +++ b/services/dms/engine.go @@ -0,0 +1,436 @@ +package dms + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + statusStopping = "stopping" + statusFailed = "failed" + + eventCategoryFailure = "failure" + connectionTestLimit = 15 * time.Second + slotReleaseLimit = 15 * time.Second + startTypeResume = "resume-processing" +) + +// EngineConfig turns on real data movement for replication tasks. Without it tasks only change state. +type EngineConfig struct { + Opener dmsengine.Opener + // Now is the clock for task statistics; nil uses time.Now. + Now func() time.Time +} + +type engineState struct { + opener dmsengine.Opener + ctx context.Context //nolint:containedctx // lifetime of every task goroutine + cancel context.CancelFunc + runs map[string]*taskRun + wg sync.WaitGroup + // ownOpener marks an opener EnableEngine built, which SetStores may replace. + ownOpener bool +} + +type taskRun struct { + task *dmsengine.Task + cancel context.CancelFunc + done chan struct{} +} + +type runParams struct { + taskARN string + region string + slot string + migration string + mappings string + settings string + cdcStart string + cdcStop string + resume string + sourceEngine string + source dmsengine.EndpointConfig + target dmsengine.EndpointConfig +} + +// EnableEngine switches the backend to real replication; call before serving requests. +func (b *InMemoryBackend) EnableEngine(cfg EngineConfig) { + b.mu.Lock("EnableEngine") + defer b.mu.Unlock() + + if cfg.Now != nil { + b.now = cfg.Now + } + + ctx, cancel := context.WithCancel(context.Background()) + b.eng = &engineState{opener: cfg.Opener, ctx: ctx, cancel: cancel, runs: map[string]*taskRun{}} + + if cfg.Opener == nil { + b.eng.opener, b.eng.ownOpener = dmsengine.NewOpener(dmsengine.Stores{}), true + } +} + +// SetStores gives the default opener the in-process S3, Kinesis and DynamoDB targets can write to. +func (b *InMemoryBackend) SetStores(stores dmsengine.Stores) { + b.mu.Lock("SetStores") + defer b.mu.Unlock() + + if b.eng != nil && b.eng.ownOpener { + b.eng.opener = dmsengine.NewOpener(stores) + } +} + +// Close stops every running replication task and waits for its goroutine. +func (b *InMemoryBackend) Close() { + b.mu.Lock("Close") + eng := b.eng + b.mu.Unlock() + + if eng == nil { + return + } + + eng.cancel() + eng.wg.Wait() +} + +// Shutdown implements service.Shutdowner. +func (h *Handler) Shutdown(context.Context) { h.Backend.Close() } + +func (b *InMemoryBackend) clock() time.Time { + if b.now != nil { + return b.now().UTC() + } + + return time.Now().UTC() +} + +// endpointConfigLocked is endpointConfig plus the PEM of the endpoint's certificate; the caller holds b.mu. +func (b *InMemoryBackend) endpointConfigLocked(region string, ep *Endpoint) dmsengine.EndpointConfig { + cfg := endpointConfig(ep) + + if ep.CertificateArn != "" { + if cert, ok := lookupUnique(b.certificatesByARN, regionKey(region, ep.CertificateArn)); ok { + cfg.CertificatePEM = cert.CertificatePem + } + } + + return cfg +} + +func endpointConfig(ep *Endpoint) dmsengine.EndpointConfig { + cfg := dmsengine.EndpointConfig{ + Engine: ep.EngineName, Server: ep.ServerName, Port: int(ep.Port), Database: ep.DatabaseName, + User: ep.Username, Password: ep.Password, SSLMode: ep.SslMode, S3Settings: ep.S3Settings, + KinesisSettings: ep.EngineSettings["KinesisSettings"], + DynamoDBSettings: ep.EngineSettings["DynamoDbSettings"], + } + + if cfg.Server != "" { + return cfg + } + + for _, key := range []string{"MySQLSettings", "PostgreSQLSettings"} { + raw := ep.EngineSettings[key] + if raw == "" { + continue + } + + var s struct { + ServerName string `json:"ServerName"` + DatabaseName string `json:"DatabaseName"` + Username string `json:"Username"` + Password string `json:"Password"` + Port int `json:"Port"` + } + + if json.Unmarshal([]byte(raw), &s) == nil && s.ServerName != "" { + cfg.Server, cfg.Port, cfg.Database = s.ServerName, s.Port, s.DatabaseName + cfg.User, cfg.Password = s.Username, s.Password + + break + } + } + + return cfg +} + +func slotName(taskARN string) string { + sum := sha256.Sum256([]byte(taskARN)) + + return "dms_" + hex.EncodeToString(sum[:])[:16] +} + +// engineEligible reports whether both endpoints are ones the engine can move data between. +func (b *InMemoryBackend) engineEligible(src, tgt *Endpoint) bool { + return b.eng != nil && b.eng.opener != nil && + dmsengine.SupportedSource(src.EngineName) && dmsengine.SupportedTarget(tgt.EngineName) +} + +func (b *InMemoryBackend) taskEndpoints(region string, rt *ReplicationTask) (*Endpoint, *Endpoint, bool) { + src, ok1 := lookupUnique(b.endpointsByARN, regionKey(region, rt.SourceEndpointArn)) + tgt, ok2 := lookupUnique(b.endpointsByARN, regionKey(region, rt.TargetEndpointArn)) + + return src, tgt, ok1 && ok2 +} + +// launchLocked starts the engine goroutine for rt; the caller holds b.mu and has set the status to starting. +func (b *InMemoryBackend) launchLocked(region string, rt *ReplicationTask, src, tgt *Endpoint, startType string) { + p := runParams{ + taskARN: rt.ReplicationTaskArn, + region: region, + slot: slotName(rt.ReplicationTaskArn), + migration: rt.MigrationType, + mappings: rt.TableMappings, + settings: rt.ReplicationTaskSettings, + cdcStart: rt.CdcStartPosition, + cdcStop: rt.CdcStopPosition, + source: b.endpointConfigLocked( + region, + src, + ), + target: b.endpointConfigLocked(region, tgt), + sourceEngine: src.EngineName, + } + p.source.SlotName = p.slot + + if startType == startTypeResume { + p.resume = rt.RecoveryCheckpoint + } + + ctx, cancel := context.WithCancel(b.eng.ctx) + run := &taskRun{cancel: cancel, done: make(chan struct{})} + b.eng.runs[p.taskARN] = run + b.eng.wg.Go(func() { + defer close(run.done) + defer cancel() + + b.execute(ctx, run, p) + }) +} + +func (b *InMemoryBackend) execute(ctx context.Context, run *taskRun, p runParams) { + res := b.runTask(ctx, run, p) + b.finishRun(ctx, run, p, res) +} + +func (b *InMemoryBackend) runTask(ctx context.Context, run *taskRun, p runParams) dmsengine.Result { + fail := func(err error) dmsengine.Result { + return dmsengine.Result{Reason: dmsengine.StopFatalError, Err: err} + } + + mappings, err := dmsengine.ParseMappings(p.mappings) + if err != nil { + return fail(err) + } + + settings, err := dmsengine.ParseTaskSettings(p.settings) + if err != nil { + return fail(err) + } + + src, err := b.eng.opener.OpenSource(ctx, p.source) + if err != nil { + return fail(fmt.Errorf("source endpoint: %w", err)) + } + defer src.Close() + + tgt, err := b.eng.opener.OpenTarget(ctx, p.target, dmsengine.FamilyOf(p.sourceEngine)) + if err != nil { + return fail(fmt.Errorf("target endpoint: %w", err)) + } + defer tgt.Close() + + task := dmsengine.NewTask(dmsengine.TaskConfig{ + Source: src, Target: tgt, Now: b.clock, MigrationType: p.migration, + CDCStart: p.cdcStart, CDCStop: p.cdcStop, Resume: p.resume, Mappings: mappings, Settings: settings, + SourceFamily: dmsengine.FamilyOf(p.sourceEngine), + OnProgress: func(pr dmsengine.Progress) { b.recordProgress(run, p, pr) }, + }) + + b.mu.Lock("taskReady") + run.task = task + b.mu.Unlock() + + return task.Run(ctx) +} + +func statsFrom(pr dmsengine.Progress) []TableStatistic { + out := make([]TableStatistic, len(pr.Tables)) + for i, t := range pr.Tables { + out[i] = TableStatistic{ + SchemaName: t.Schema, TableName: t.Name, TableState: t.State, FullLoadRows: t.FullLoadRows, + FullLoadErrorRows: t.FullLoadErrorRows, Inserts: t.Inserts, Updates: t.Updates, Deletes: t.Deletes, + FullLoadStartTime: t.FullLoadStart, FullLoadEndTime: t.FullLoadEnd, LastUpdateTime: t.LastUpdate, + FullLoadReloaded: t.FullLoadReloaded, + } + } + + return out +} + +func (b *InMemoryBackend) recordProgress(run *taskRun, p runParams, pr dmsengine.Progress) { + b.mu.Lock("dmsProgress") + defer b.mu.Unlock() + + if b.eng.runs[p.taskARN] != run { + return + } + + rt, ok := lookupUnique(b.replicationTasksByARN, regionKey(p.region, p.taskARN)) + if !ok { + return + } + + if pr.Running && rt.Status == statusStarting { + rt.Status = statusRunning + } + + rt.TableStats = statsFrom(pr) + rt.FullLoadStartDate, rt.FullLoadFinishDate = pr.FullLoadStart, pr.FullLoadFinish + + if pr.Checkpoint != "" { + rt.RecoveryCheckpoint = pr.Checkpoint + } +} + +func (b *InMemoryBackend) finishRun(ctx context.Context, run *taskRun, p runParams, res dmsengine.Result) { + b.mu.Lock("dmsFinish") + defer b.mu.Unlock() + + if b.eng.runs[p.taskARN] != run { + return + } + + delete(b.eng.runs, p.taskARN) + + rt, ok := lookupUnique(b.replicationTasksByARN, regionKey(p.region, p.taskARN)) + if !ok { + return + } + + rt.StopDate, rt.StopReason = b.clock(), res.Reason + + if res.Err != nil { + rt.Status, rt.LastFailureMessage = statusFailed, res.Err.Error() + logger.Load(ctx).WarnContext(ctx, "dms: replication task failed", "task", rt.ReplicationTaskIdentifier, + "error", res.Err) + b.appendEvent(p.region, rt.ReplicationTaskArn, "replication-task", + "Replication task "+rt.ReplicationTaskIdentifier+" failed", []string{eventCategoryFailure}) + + return + } + + rt.Status = statusStopped + b.appendEvent(p.region, rt.ReplicationTaskArn, "replication-task", + "Replication task "+rt.ReplicationTaskIdentifier+" stopped", []string{eventCategoryStateChange}) +} + +// beginStartLocked resets run state for a fresh start. +func (b *InMemoryBackend) beginStartLocked(rt *ReplicationTask, startType string) { + rt.Status = statusStarting + rt.StartDate, rt.StopDate = b.clock(), time.Time{} + rt.StopReason, rt.LastFailureMessage = "", "" + + if startType != startTypeResume { + rt.TableStats, rt.RecoveryCheckpoint = nil, "" + rt.FullLoadStartDate, rt.FullLoadFinishDate = time.Time{}, time.Time{} + } +} + +// stopRunLocked cancels the engine run behind rt, reporting whether one existed. +func (b *InMemoryBackend) stopRunLocked(rt *ReplicationTask) bool { + if b.eng == nil { + return false + } + + run, ok := b.eng.runs[rt.ReplicationTaskArn] + if !ok { + return false + } + + run.cancel() + + return true +} + +func (b *InMemoryBackend) hasRunLocked(arn string) bool { + if b.eng == nil { + return false + } + + _, ok := b.eng.runs[arn] + + return ok +} + +// viewTask copies rt for a response and derives ElapsedTimeMillis. +func (b *InMemoryBackend) viewTask(rt *ReplicationTask) *ReplicationTask { + cp := *rt + + switch { + case rt.StartDate.IsZero(): + case rt.StopDate.IsZero(): + cp.ElapsedTimeMillis = b.clock().Sub(rt.StartDate).Milliseconds() + default: + cp.ElapsedTimeMillis = rt.StopDate.Sub(rt.StartDate).Milliseconds() + } + + return &cp +} + +// releaseSlotAsync drops a PostgreSQL slot the deleted task left on its source. +func (b *InMemoryBackend) releaseSlotAsync(src *Endpoint, taskARN string) { + if b.eng == nil || b.eng.opener == nil || dmsengine.FamilyOf(src.EngineName) != dmsengine.FamilyPostgres { + return + } + + cfg := b.endpointConfigLocked(src.Region, src) + cfg.SlotName = slotName(taskARN) + + b.eng.wg.Go(func() { + ctx, cancel := context.WithTimeout(b.eng.ctx, slotReleaseLimit) + defer cancel() + + _ = b.eng.opener.ReleaseSlot(ctx, cfg) + }) +} + +// reloadRun re-copies tables on a live engine run. The caller must not hold b.mu. +func (b *InMemoryBackend) reloadRun(run *taskRun, task *dmsengine.Task, refs []dmsengine.TableRef) { + b.eng.wg.Go(func() { + ctx, cancel := context.WithCancel(b.eng.ctx) + defer cancel() + + go func() { + select { + case <-run.done: + cancel() + case <-ctx.Done(): + } + }() + + if err := task.Reload(ctx, refs); err != nil && !errors.Is(err, context.Canceled) { + logger.Load(ctx).WarnContext(ctx, "dms: reload tables failed", "error", err) + } + }) +} + +func (e *engineState) runsFor(arn string) (*taskRun, bool) { + if e == nil { + return nil, false + } + + r, ok := e.runs[arn] + + return r, ok +} diff --git a/services/dms/engine_test.go b/services/dms/engine_test.go new file mode 100644 index 0000000000..a3beec4c83 --- /dev/null +++ b/services/dms/engine_test.go @@ -0,0 +1,563 @@ +package dms_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" + "github.com/blackbirdworks/gopherstack/services/dms" +) + +var errUnreachable = errors.New("dial tcp 127.0.0.1:1: connect: connection refused") + +type engineClock struct { + t time.Time + mu sync.Mutex +} + +func (c *engineClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + return c.t +} + +func (c *engineClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.t = c.t.Add(d) +} + +type stubSource struct { + hold chan struct{} + changes chan dmsengine.Change + pingErr error + startAts chan dmsengine.StreamStart + tables []dmsengine.Table +} + +func newStubSource() *stubSource { + return &stubSource{ + hold: make( + chan struct{}, + ), + changes: make(chan dmsengine.Change, 8), + startAts: make(chan dmsengine.StreamStart, 8), + tables: []dmsengine.Table{{Schema: "app", Name: "items", PK: []string{"id"}, Columns: []dmsengine.Column{ + {Name: "id", Native: "int"}, {Name: "name", Native: "text"}, + }}}, + } +} + +func (s *stubSource) Ping(context.Context) error { return s.pingErr } +func (s *stubSource) Tables(context.Context) ([]dmsengine.Table, error) { return s.tables, nil } +func (s *stubSource) Position(context.Context) (string, error) { return "bin.1:4", nil } +func (s *stubSource) Release(context.Context) error { return nil } +func (s *stubSource) Close() error { return nil } + +func (s *stubSource) Scan(ctx context.Context, _ dmsengine.Table, fn func(dmsengine.Row) error) error { + select { + case <-s.hold: + case <-ctx.Done(): + return ctx.Err() + } + + for _, r := range []dmsengine.Row{{"1", "a"}, {"2", "b"}, {"3", "c"}} { + if err := fn(r); err != nil { + return err + } + } + + return nil +} + +func (s *stubSource) Stream( + ctx context.Context, start dmsengine.StreamStart, _ func(string, string) (dmsengine.Table, bool), + fn func(dmsengine.Change) error, +) error { + s.startAts <- start + + for { + select { + case <-ctx.Done(): + return nil + case c := <-s.changes: + if err := fn(c); err != nil { + return err + } + } + } +} + +type stubTarget struct{ pingErr error } + +func (stubTarget) Close() error { return nil } +func (t stubTarget) Ping(context.Context) error { return t.pingErr } +func (stubTarget) Prepare(context.Context, dmsengine.TargetTable, dmsengine.PrepMode) error { + return nil +} +func (stubTarget) Load(context.Context, dmsengine.TargetTable, []dmsengine.Row) error { return nil } +func (stubTarget) EndLoad(context.Context, dmsengine.TargetTable) error { return nil } +func (stubTarget) Apply(context.Context, dmsengine.TargetTable, dmsengine.Change) error { return nil } +func (stubTarget) Commit(context.Context) error { return nil } + +type stubOpener struct { + tgt stubTarget + src *stubSource + released chan string + seen dmsengine.EndpointConfig + mu sync.Mutex +} + +func (o *stubOpener) OpenSource(_ context.Context, ep dmsengine.EndpointConfig) (dmsengine.Source, error) { + o.mu.Lock() + o.seen = ep + o.mu.Unlock() + + return o.src, nil +} + +func (o *stubOpener) OpenTarget(context.Context, dmsengine.EndpointConfig, dmsengine.Family) (dmsengine.Target, error) { + return o.tgt, nil +} + +func (o *stubOpener) ReleaseSlot(_ context.Context, ep dmsengine.EndpointConfig) error { + o.released <- ep.SlotName + + return nil +} + +type engineEnv struct { + b *dms.InMemoryBackend + op *stubOpener + clock *engineClock + task string + src string + tgt string + ri string +} + +const selectApp = `{"rules":[{"rule-type":"selection","rule-id":"1","rule-name":"1", + "object-locator":{"schema-name":"app","table-name":"%"},"rule-action":"include"}]}` + +func newEngineEnv(t *testing.T, srcEngine, migration string) *engineEnv { + t.Helper() + + b := dms.NewInMemoryBackend("123456789012", "us-east-1") + t.Cleanup(b.Close) + + e := &engineEnv{ + b: b, op: &stubOpener{src: newStubSource(), released: make(chan string, 4)}, + clock: &engineClock{t: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + } + b.EnableEngine(dms.EngineConfig{Opener: e.op, Now: e.clock.Now}) + + ctx := t.Context() + ri, err := b.CreateReplicationInstance(ctx, "ri", "dms.t3.micro", "", "", 0, false, false, false, nil, + dms.ReplicationInstanceSettings{}) + require.NoError(t, err) + + src, err := b.CreateEndpoint(ctx, "src", "source", srcEngine, "127.0.0.1", "app", "u", "secret-pass", 3306, nil, + dms.EndpointConnectionSettings{}) + require.NoError(t, err) + + tgt, err := b.CreateEndpoint(ctx, "tgt", "target", "postgres", "127.0.0.1", "app", "u", "secret-pass", 5432, nil, + dms.EndpointConnectionSettings{}) + require.NoError(t, err) + + task, err := b.CreateReplicationTask(ctx, "task", src.EndpointArn, tgt.EndpointArn, ri.ReplicationInstanceArn, + migration, selectApp, "", nil, dms.ReplicationTaskCDCSettings{}) + require.NoError(t, err) + + e.ri, e.src, e.tgt, e.task = ri.ReplicationInstanceArn, src.EndpointArn, tgt.EndpointArn, task.ReplicationTaskArn + + return e +} + +func (e *engineEnv) describe(t *testing.T) *dms.ReplicationTask { + t.Helper() + + tasks, err := e.b.DescribeReplicationTasks(t.Context(), dms.NewIdentifierFilter("replication-task-arn", e.task)) + require.NoError(t, err) + require.Len(t, tasks, 1) + + return tasks[0] +} + +func (e *engineEnv) await(t *testing.T, status string) *dms.ReplicationTask { + t.Helper() + + var got *dms.ReplicationTask + + require.Eventually(t, func() bool { + got = e.describe(t) + + return got.Status == status + }, 10*time.Second, 5*time.Millisecond, "status %s", status) + + return got +} + +func (e *engineEnv) start(t *testing.T, cdc dms.ReplicationTaskCDCSettings) *dms.ReplicationTask { + t.Helper() + + rt, err := e.b.StartReplicationTask(t.Context(), e.task, cdc) + require.NoError(t, err) + + return rt +} + +func TestEngineFullLoadLifecycle(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, "mysql", "full-load") + started := e.start(t, dms.ReplicationTaskCDCSettings{}) + assert.Equal(t, "starting", started.Status) + + running := e.await(t, "running") + assert.False(t, running.StartDate.IsZero()) + assert.Zero(t, running.ElapsedTimeMillis) + + require.Eventually(t, func() bool { + ts := e.describe(t).TableStats + + return len(ts) == 1 && ts[0].TableState == "Full load" + }, 10*time.Second, 5*time.Millisecond) + + e.clock.Advance(90 * time.Second) + assert.EqualValues(t, 90_000, e.describe(t).ElapsedTimeMillis) + + e.clock.Advance(30 * time.Second) + close(e.op.src.hold) + + done := e.await(t, "stopped") + assert.Equal(t, "Stop Reason FULL_LOAD_ONLY_FINISHED", done.StopReason) + assert.EqualValues(t, 120_000, done.ElapsedTimeMillis) + + e.clock.Advance(time.Hour) + assert.EqualValues(t, 120_000, e.describe(t).ElapsedTimeMillis, "elapsed freezes at stop") + + require.Len(t, done.TableStats, 1) + st := done.TableStats[0] + assert.Equal(t, "app", st.SchemaName) + assert.Equal(t, "items", st.TableName) + assert.Equal(t, "Table completed", st.TableState) + assert.EqualValues(t, 3, st.FullLoadRows) + assert.False(t, done.FullLoadFinishDate.IsZero()) +} + +func TestEngineFailureAndRestart(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, "mysql", "full-load") + e.op.src.pingErr = errUnreachable + e.start(t, dms.ReplicationTaskCDCSettings{}) + + failed := e.await(t, "failed") + assert.Equal(t, "Stop Reason FATAL_ERROR", failed.StopReason) + assert.Contains(t, failed.LastFailureMessage, "connection refused") + assert.NotContains(t, failed.LastFailureMessage, "secret-pass") + + e.op.src.pingErr = nil + close(e.op.src.hold) + e.start(t, dms.ReplicationTaskCDCSettings{}) + + done := e.await(t, "stopped") + assert.Empty(t, done.LastFailureMessage) + assert.Equal(t, "Stop Reason FULL_LOAD_ONLY_FINISHED", done.StopReason) +} + +func TestEngineCDCStopAndResume(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, "mysql", "cdc") + e.start(t, dms.ReplicationTaskCDCSettings{CdcStartPosition: "bin.1:100"}) + e.await(t, "running") + + assert.Equal(t, dmsengine.StreamStart{Position: "bin.1:100"}, <-e.op.src.startAts) + + e.op.src.changes <- dmsengine.Change{Commit: true, Position: "bin.1:555"} + require.Eventually(t, func() bool { return e.describe(t).RecoveryCheckpoint == "bin.1:555" }, + 10*time.Second, 5*time.Millisecond) + + stopping, err := e.b.StopReplicationTask(t.Context(), e.task) + require.NoError(t, err) + assert.Equal(t, "stopping", stopping.Status) + + stopped := e.await(t, "stopped") + assert.Equal(t, "Stop Reason NORMAL", stopped.StopReason) + + _, err = e.b.StopReplicationTask(t.Context(), e.task) + require.ErrorIs(t, err, dms.ErrInvalidState) + + e.start(t, dms.ReplicationTaskCDCSettings{StartType: "resume-processing"}) + e.await(t, "running") + assert.Equal(t, dmsengine.StreamStart{Position: "bin.1:555"}, <-e.op.src.startAts) +} + +func TestEngineReloadTables(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, "mysql", "full-load-and-cdc") + _, err := e.b.ReloadTables(t.Context(), e.task, nil) + require.ErrorIs(t, err, dms.ErrInvalidState, "task is not running yet") + + close(e.op.src.hold) + e.start(t, dms.ReplicationTaskCDCSettings{}) + + require.Eventually(t, func() bool { + ts := e.describe(t).TableStats + + return e.describe(t).Status == "running" && len(ts) == 1 && ts[0].TableState == "Table completed" + }, 10*time.Second, 5*time.Millisecond) + + _, err = e.b.ReloadTables(t.Context(), e.task, []dmsengine.TableRef{{Schema: "app", Name: "nope"}}) + require.ErrorIs(t, err, dms.ErrNotFound) + + _, err = e.b.ReloadTables(t.Context(), e.task, []dmsengine.TableRef{{Schema: "app", Name: "items"}}) + require.NoError(t, err) + + require.Eventually(t, func() bool { + ts := e.describe(t).TableStats + + return len(ts) == 1 && ts[0].FullLoadReloaded && ts[0].TableState == "Table completed" + }, 10*time.Second, 5*time.Millisecond) +} + +func TestEngineTestConnection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + srcErr error + tgtErr error + endpoint func(*engineEnv) string + wantStatus string + wantMsg string + }{ + {name: "source ok", endpoint: func(e *engineEnv) string { return e.src }, wantStatus: "successful"}, + { + name: "source unreachable", srcErr: errUnreachable, endpoint: func(e *engineEnv) string { return e.src }, + wantStatus: "failed", wantMsg: "Failed to connect to database. " + errUnreachable.Error(), + }, + { + name: "target unreachable", tgtErr: errUnreachable, endpoint: func(e *engineEnv) string { return e.tgt }, + wantStatus: "failed", wantMsg: errUnreachable.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, "mysql", "full-load") + e.op.src.pingErr, e.op.tgt.pingErr = tt.srcErr, tt.tgtErr + + conn, err := e.b.TestConnection(t.Context(), e.ri, tt.endpoint(e)) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, conn.Status) + assert.Contains(t, conn.LastFailureMessage, tt.wantMsg) + assert.NotContains(t, conn.LastFailureMessage, "secret-pass") + + listed, err := e.b.DescribeConnections(t.Context(), dms.DescribeFilters{}) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, tt.wantStatus, listed[0].Status) + }) + } +} + +func TestEngineFallbackIsStateOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + srcEngine string + engineOn bool + }{ + {name: "engine disabled", engineOn: false, srcEngine: "mysql"}, + {name: "unsupported source engine", engineOn: true, srcEngine: "oracle"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, tt.srcEngine, "full-load") + if !tt.engineOn { + e.b = dms.NewInMemoryBackend("123456789012", "us-east-1") + e = rebuildWithoutEngine(t, e.b) + } + + started := e.start(t, dms.ReplicationTaskCDCSettings{}) + assert.Equal(t, "starting", started.Status) + e.describe(t) + assert.Equal(t, "running", e.describe(t).Status, "state-only tasks flip to running once described") + assert.Empty(t, e.op.src.startAts) + + stopped, err := e.b.StopReplicationTask(t.Context(), e.task) + require.NoError(t, err) + assert.Equal(t, "stopped", stopped.Status) + assert.Equal(t, "Stop Reason NORMAL", stopped.StopReason) + }) + } +} + +func rebuildWithoutEngine(t *testing.T, b *dms.InMemoryBackend) *engineEnv { + t.Helper() + + ctx := t.Context() + ri, err := b.CreateReplicationInstance(ctx, "ri", "dms.t3.micro", "", "", 0, false, false, false, nil, + dms.ReplicationInstanceSettings{}) + require.NoError(t, err) + + src, err := b.CreateEndpoint( + ctx, + "src", + "source", + "mysql", + "127.0.0.1", + "app", + "u", + "p", + 3306, + nil, + dms.EndpointConnectionSettings{}, + ) + require.NoError(t, err) + + tgt, err := b.CreateEndpoint( + ctx, + "tgt", + "target", + "postgres", + "127.0.0.1", + "app", + "u", + "p", + 5432, + nil, + dms.EndpointConnectionSettings{}, + ) + require.NoError(t, err) + + task, err := b.CreateReplicationTask(ctx, "task", src.EndpointArn, tgt.EndpointArn, ri.ReplicationInstanceArn, + "full-load", selectApp, "", nil, dms.ReplicationTaskCDCSettings{}) + require.NoError(t, err) + + return &engineEnv{ + b: b, op: &stubOpener{src: newStubSource()}, clock: &engineClock{t: time.Now()}, + ri: ri.ReplicationInstanceArn, src: src.EndpointArn, tgt: tgt.EndpointArn, task: task.ReplicationTaskArn, + } +} + +func TestEngineSnapshotSettlesRunningTasks(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, "mysql", "cdc") + e.start(t, dms.ReplicationTaskCDCSettings{}) + e.await(t, "running") + + snap := e.b.Snapshot(t.Context()) + + restored := dms.NewInMemoryBackend("123456789012", "us-east-1") + require.NoError(t, restored.Restore(t.Context(), snap)) + + tasks, err := restored.DescribeReplicationTasks(t.Context(), dms.DescribeFilters{}) + require.NoError(t, err) + require.Len(t, tasks, 1) + assert.Equal(t, "stopped", tasks[0].Status) + assert.NotEmpty(t, tasks[0].StopReason) +} + +func TestEngineDeleteReleasesPostgresSlot(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + srcEngine string + wantSlot bool + }{ + {name: "postgres source", srcEngine: "postgres", wantSlot: true}, + {name: "mysql source", srcEngine: "mysql"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, tt.srcEngine, "full-load") + _, err := e.b.DeleteReplicationTask(t.Context(), e.task) + require.NoError(t, err) + + if !tt.wantSlot { + select { + case slot := <-e.op.released: + assert.Failf(t, "unexpected release", "slot %s", slot) + default: + } + + return + } + + select { + case slot := <-e.op.released: + assert.Regexp(t, `^dms_[0-9a-f]{16}$`, slot) + case <-time.After(10 * time.Second): + require.FailNow(t, "slot was not released") + } + }) + } +} + +func TestEngineEndpointCertificateReachesTheEngine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sslMode string + wantPEM string + cert bool + }{ + {name: "require with certificate", sslMode: "require", cert: true, wantPEM: "PEM-DATA"}, + {name: "none with certificate still passes it along", sslMode: "none", cert: true, wantPEM: "PEM-DATA"}, + {name: "require without certificate", sslMode: "require"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, "mysql", "full-load") + settings := dms.EndpointConnectionSettings{SslMode: tt.sslMode} + + if tt.cert { + cert, err := e.b.ImportCertificate(t.Context(), "ca", "PEM-DATA", "", "", nil) + require.NoError(t, err) + + settings.CertificateArn = cert.CertificateArn + } + + ep, err := e.b.CreateEndpoint(t.Context(), "tls-src", "source", "mysql", "db.local", "app", "u", "p", 3306, + nil, settings) + require.NoError(t, err) + + conn, err := e.b.TestConnection(t.Context(), e.ri, ep.EndpointArn) + require.NoError(t, err) + assert.Equal(t, "successful", conn.Status) + + e.op.mu.Lock() + defer e.op.mu.Unlock() + + assert.Equal(t, tt.sslMode, e.op.seen.SSLMode) + assert.Equal(t, tt.wantPEM, e.op.seen.CertificatePEM) + }) + } +} diff --git a/services/dms/handler_replication_tasks.go b/services/dms/handler_replication_tasks.go index 1db81ddd82..c752128118 100644 --- a/services/dms/handler_replication_tasks.go +++ b/services/dms/handler_replication_tasks.go @@ -7,6 +7,7 @@ import ( "sort" "github.com/blackbirdworks/gopherstack/pkgs/awstime" + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" "github.com/blackbirdworks/gopherstack/pkgs/ptrconv" "github.com/blackbirdworks/gopherstack/pkgs/service" ) @@ -196,6 +197,7 @@ func (h *Handler) handleStartReplicationTask( rt, err := h.Backend.StartReplicationTask(ctx, ptrconv.String(in.ReplicationTaskArn), ReplicationTaskCDCSettings{ CdcStartPosition: ptrconv.String(in.CdcStartPosition), CdcStopPosition: ptrconv.String(in.CdcStopPosition), + StartType: taskType, }) if err != nil { return nil, err @@ -243,21 +245,80 @@ func (h *Handler) handleDeleteReplicationTask( } type replicationTaskJSON struct { - ReplicationTaskIdentifier string `json:"ReplicationTaskIdentifier"` - ReplicationTaskArn string `json:"ReplicationTaskArn"` - SourceEndpointArn string `json:"SourceEndpointArn"` - TargetEndpointArn string `json:"TargetEndpointArn"` - ReplicationInstanceArn string `json:"ReplicationInstanceArn"` - MigrationType string `json:"MigrationType"` - TableMappings string `json:"TableMappings,omitempty"` - ReplicationTaskSettings string `json:"ReplicationTaskSettings,omitempty"` - Status string `json:"Status"` - CdcStartPosition string `json:"CdcStartPosition,omitempty"` - CdcStopPosition string `json:"CdcStopPosition,omitempty"` - TaskData string `json:"TaskData,omitempty"` - // ReplicationTaskCreationDate is wire-encoded as epoch seconds - // (awsjson1.1 unixTimestamp format) -- see pkgs/awstime.Epoch. - ReplicationTaskCreationDate float64 `json:"ReplicationTaskCreationDate,omitempty"` + ReplicationTaskStats *replicationTaskStatsJSON `json:"ReplicationTaskStats,omitempty"` + Status string `json:"Status"` + CdcStopPosition string `json:"CdcStopPosition,omitempty"` + TargetEndpointArn string `json:"TargetEndpointArn"` + ReplicationInstanceArn string `json:"ReplicationInstanceArn"` + MigrationType string `json:"MigrationType"` + TableMappings string `json:"TableMappings,omitempty"` + ReplicationTaskSettings string `json:"ReplicationTaskSettings,omitempty"` + ReplicationTaskIdentifier string `json:"ReplicationTaskIdentifier"` + SourceEndpointArn string `json:"SourceEndpointArn"` + TaskData string `json:"TaskData,omitempty"` + CdcStartPosition string `json:"CdcStartPosition,omitempty"` + StopReason string `json:"StopReason,omitempty"` + LastFailureMessage string `json:"LastFailureMessage,omitempty"` + RecoveryCheckpoint string `json:"RecoveryCheckpoint,omitempty"` + ReplicationTaskArn string `json:"ReplicationTaskArn"` + ReplicationTaskStartDate float64 `json:"ReplicationTaskStartDate,omitempty"` + ReplicationTaskCreationDate float64 `json:"ReplicationTaskCreationDate,omitempty"` +} + +const percentComplete = 100 + +// replicationTaskStatsJSON mirrors types.ReplicationTaskStats; dates are epoch seconds. +type replicationTaskStatsJSON struct { + ElapsedTimeMillis int64 `json:"ElapsedTimeMillis"` + FreshStartDate float64 `json:"FreshStartDate,omitempty"` + FullLoadFinishDate float64 `json:"FullLoadFinishDate,omitempty"` + FullLoadStartDate float64 `json:"FullLoadStartDate,omitempty"` + StartDate float64 `json:"StartDate,omitempty"` + StopDate float64 `json:"StopDate,omitempty"` + FullLoadProgressPercent int32 `json:"FullLoadProgressPercent"` + TablesErrored int32 `json:"TablesErrored"` + TablesLoaded int32 `json:"TablesLoaded"` + TablesLoading int32 `json:"TablesLoading"` + TablesQueued int32 `json:"TablesQueued"` +} + +func taskStats(rt *ReplicationTask) *replicationTaskStatsJSON { + if rt.StartDate.IsZero() { + return nil + } + + st := &replicationTaskStatsJSON{ + ElapsedTimeMillis: rt.ElapsedTimeMillis, + FreshStartDate: awstime.Epoch(rt.StartDate), + FullLoadStartDate: awstime.Epoch( + rt.FullLoadStartDate, + ), + FullLoadFinishDate: awstime.Epoch(rt.FullLoadFinishDate), + StartDate: awstime.Epoch(rt.StartDate), + StopDate: awstime.Epoch(rt.StopDate), + } + + for _, ts := range rt.TableStats { + switch ts.TableState { + case "Table completed": + st.TablesLoaded++ + case "Table error": + st.TablesErrored++ + case "Full load", "Table is being reloaded": + st.TablesLoading++ + default: + st.TablesQueued++ + } + } + + switch total := len(rt.TableStats); { + case !rt.FullLoadFinishDate.IsZero(): + st.FullLoadProgressPercent = percentComplete + case total > 0: + st.FullLoadProgressPercent = int32(int(st.TablesLoaded) * percentComplete / total) //nolint:gosec // 0..100 + } + + return st } func rtToJSON(rt *ReplicationTask) replicationTaskJSON { @@ -274,7 +335,12 @@ func rtToJSON(rt *ReplicationTask) replicationTaskJSON { CdcStartPosition: rt.CdcStartPosition, CdcStopPosition: rt.CdcStopPosition, TaskData: rt.TaskData, + StopReason: rt.StopReason, + LastFailureMessage: rt.LastFailureMessage, + RecoveryCheckpoint: rt.RecoveryCheckpoint, ReplicationTaskCreationDate: awstime.Epoch(rt.CreationTime), + ReplicationTaskStartDate: awstime.Epoch(rt.StartDate), + ReplicationTaskStats: taskStats(rt), } } @@ -290,6 +356,31 @@ type tableStatisticJSON struct { ValidationPendingRecords int64 `json:"ValidationPendingRecords"` ValidationFailedRecords int64 `json:"ValidationFailedRecords"` ValidationSuspendedRecords int64 `json:"ValidationSuspendedRecords"` + Inserts int64 `json:"Inserts"` + Updates int64 `json:"Updates"` + Deletes int64 `json:"Deletes"` + Ddls int64 `json:"Ddls"` + AppliedInserts int64 `json:"AppliedInserts"` + AppliedUpdates int64 `json:"AppliedUpdates"` + AppliedDeletes int64 `json:"AppliedDeletes"` + AppliedDdls int64 `json:"AppliedDdls"` + FullLoadReloaded bool `json:"FullLoadReloaded"` + // Times are epoch seconds on the awsjson1.1 wire. + FullLoadStartTime float64 `json:"FullLoadStartTime,omitempty"` + FullLoadEndTime float64 `json:"FullLoadEndTime,omitempty"` + LastUpdateTime float64 `json:"LastUpdateTime,omitempty"` +} + +func statJSON(ts TableStatistic) tableStatisticJSON { + return tableStatisticJSON{ + SchemaName: ts.SchemaName, TableName: ts.TableName, TableState: ts.TableState, + ValidationState: "Not enabled", FullLoadRows: ts.FullLoadRows, FullLoadErrorRows: ts.FullLoadErrorRows, + Inserts: ts.Inserts, Updates: ts.Updates, Deletes: ts.Deletes, + AppliedInserts: ts.Inserts, AppliedUpdates: ts.Updates, AppliedDeletes: ts.Deletes, + FullLoadReloaded: ts.FullLoadReloaded, + FullLoadStartTime: awstime.Epoch(ts.FullLoadStartTime), FullLoadEndTime: awstime.Epoch(ts.FullLoadEndTime), + LastUpdateTime: awstime.Epoch(ts.LastUpdateTime), + } } // tableMappingRule is used to parse table mappings JSON. @@ -411,7 +502,14 @@ func (h *Handler) handleDescribeTableStatistics( }, nil } - all := buildTableStatistics(tasks[0].TableMappings) + var all []tableStatisticJSON + if len(tasks[0].TableStats) > 0 { + for _, ts := range tasks[0].TableStats { + all = append(all, statJSON(ts)) + } + } else { + all = buildTableStatistics(tasks[0].TableMappings) + } df := newDescribeFilters(in.Filters) @@ -563,6 +661,18 @@ type reloadTablesInput struct { TablesToReload []map[string]any `json:"TablesToReload"` } +func tableRefs(raw []map[string]any) []dmsengine.TableRef { + out := make([]dmsengine.TableRef, 0, len(raw)) + + for _, m := range raw { + schema, _ := m["SchemaName"].(string) + table, _ := m["TableName"].(string) + out = append(out, dmsengine.TableRef{Schema: schema, Name: table}) + } + + return out +} + type reloadTablesOutput struct { ReplicationTaskArn string `json:"ReplicationTaskArn"` } @@ -588,7 +698,7 @@ func (h *Handler) handleReloadTables( ) } - rt, err := h.Backend.ReloadTables(ctx, taskArn) + rt, err := h.Backend.ReloadTables(ctx, taskArn, tableRefs(in.TablesToReload)) if err != nil { return nil, err } diff --git a/services/dms/iam_enforcement_test.go b/services/dms/iam_enforcement_test.go index 64a7c717ce..59594fd7cd 100644 --- a/services/dms/iam_enforcement_test.go +++ b/services/dms/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/dms" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/dms/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/dms/models.go b/services/dms/models.go index 0352ce38a8..7e077094ec 100644 --- a/services/dms/models.go +++ b/services/dms/models.go @@ -269,6 +269,32 @@ type ReplicationTask struct { CdcStartPosition string `json:"cdcStartPosition,omitempty"` CdcStopPosition string `json:"cdcStopPosition,omitempty"` TaskData string `json:"taskData,omitempty"` + StopReason string `json:"stopReason,omitempty"` + LastFailureMessage string `json:"lastFailureMessage,omitempty"` + RecoveryCheckpoint string `json:"recoveryCheckpoint,omitempty"` + // Run-state fields below are filled by the replication engine; ElapsedTimeMillis is derived on read. + StartDate time.Time `json:"startDate,omitzero"` + StopDate time.Time `json:"stopDate,omitzero"` + FullLoadStartDate time.Time `json:"fullLoadStartDate,omitzero"` + FullLoadFinishDate time.Time `json:"fullLoadFinishDate,omitzero"` + TableStats []TableStatistic `json:"tableStats,omitempty"` + ElapsedTimeMillis int64 `json:"-"` +} + +// TableStatistic is one DescribeTableStatistics row; names are the source table's. +type TableStatistic struct { + FullLoadStartTime time.Time `json:"fullLoadStartTime,omitzero"` + FullLoadEndTime time.Time `json:"fullLoadEndTime,omitzero"` + LastUpdateTime time.Time `json:"lastUpdateTime,omitzero"` + SchemaName string `json:"schemaName"` + TableName string `json:"tableName"` + TableState string `json:"tableState"` + FullLoadRows int64 `json:"fullLoadRows"` + FullLoadErrorRows int64 `json:"fullLoadErrorRows"` + Inserts int64 `json:"inserts"` + Updates int64 `json:"updates"` + Deletes int64 `json:"deletes"` + FullLoadReloaded bool `json:"fullLoadReloaded,omitempty"` } // Certificate represents a DMS certificate. diff --git a/services/dms/persistence.go b/services/dms/persistence.go index 05d60f47cf..25e0ac2f72 100644 --- a/services/dms/persistence.go +++ b/services/dms/persistence.go @@ -112,6 +112,7 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { } b.reinitTagsLocked(snap.ResourceTags) + b.settleTasksLocked() b.accountID = snap.AccountID b.region = snap.Region @@ -273,3 +274,16 @@ func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapsh func (h *Handler) Restore(ctx context.Context, data []byte) error { return h.Backend.Restore(ctx, data) } + +// settleTasksLocked marks tasks that were mid-run when the snapshot was taken as stopped: no engine run +// survives a restart. +func (b *InMemoryBackend) settleTasksLocked() { + for _, rt := range b.replicationTasks.All() { + if taskActive(rt) { + rt.Status = statusStopped + if rt.StopReason == "" { + rt.StopReason = "Stop Reason RECOVERABLE_ERROR" + } + } + } +} diff --git a/services/dms/provider.go b/services/dms/provider.go index 03a47e6c85..83604b2829 100644 --- a/services/dms/provider.go +++ b/services/dms/provider.go @@ -9,6 +9,14 @@ import ( // ErrNilAppContext is returned when Init is called with a nil AppContext. var ErrNilAppContext = errors.New("DMS provider: nil AppContext") +// EngineModeConfig exposes the DMS engine mode (stub or docker). +type EngineModeConfig interface { + GetDMSEngine() string +} + +// EngineDocker is the engine mode that moves data for real. +const EngineDocker = "docker" + // Provider implements service.Provider for AWS DMS. type Provider struct{} @@ -26,6 +34,10 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { accountID, region := service.AccountRegionOrDefault(ctx) backend := NewInMemoryBackend(accountID, region) + + if ec, ok := ctx.Config.(EngineModeConfig); ok && ec.GetDMSEngine() == EngineDocker { + backend.EnableEngine(EngineConfig{}) + } handler := NewHandler(backend) return handler, nil diff --git a/services/dms/replication_tasks.go b/services/dms/replication_tasks.go index d8b0d0a74a..e5fcc09969 100644 --- a/services/dms/replication_tasks.go +++ b/services/dms/replication_tasks.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/dmsengine" "github.com/blackbirdworks/gopherstack/pkgs/tags" ) @@ -28,6 +29,7 @@ func (b *InMemoryBackend) mustDescribeReplicationTasks(ctx context.Context) []*R type ReplicationTaskCDCSettings struct { CdcStartPosition string CdcStopPosition string + StartType string TaskData string ResourceIdentifier string NewIdentifier string @@ -113,13 +115,12 @@ func (b *InMemoryBackend) CreateReplicationTask( b.tasksByEndpointARN[targetEndpointArn] = make(map[string]struct{}) } b.tasksByEndpointARN[targetEndpointArn][taskARN] = struct{}{} - cp := *rt - return &cp, nil + return b.viewTask(rt), nil } func taskActive(rt *ReplicationTask) bool { - return rt.Status == statusRunning || rt.Status == statusStarting + return rt.Status == statusRunning || rt.Status == statusStarting || rt.Status == statusStopping } // DescribeReplicationTasks returns replication tasks matching filters (valid @@ -142,10 +143,9 @@ func (b *InMemoryBackend) DescribeReplicationTasks( continue } - cp := *rt - result = append(result, &cp) + result = append(result, b.viewTask(rt)) - if rt.Status == statusStarting { + if rt.Status == statusStarting && !b.hasRunLocked(rt.ReplicationTaskArn) { rt.Status = statusRunning } } @@ -175,7 +175,8 @@ func replicationTaskMatchesFilters(rt *ReplicationTask, filters DescribeFilters) return filters.Matches("replication-instance-arn", rt.ReplicationInstanceArn) } -// StartReplicationTask transitions a replication task to running status. +// StartReplicationTask starts a replication task. With the engine enabled and both endpoints supported the task +// moves data; otherwise it only changes state. func (b *InMemoryBackend) StartReplicationTask( ctx context.Context, arnOrID string, cdc ReplicationTaskCDCSettings, ) (*ReplicationTask, error) { @@ -195,7 +196,15 @@ func (b *InMemoryBackend) StartReplicationTask( ) } - rt.Status = statusStarting + region := getRegion(ctx, b.region) + + ri, ok := lookupUnique(b.replicationInstancesByARN, regionKey(region, rt.ReplicationInstanceArn)) + if ok && ri.ReplicationInstanceStatus != statusAvailable { + return nil, fmt.Errorf( + "%w: replication instance %s is %s; it must be available to start a task", + ErrInvalidState, ri.ReplicationInstanceIdentifier, ri.ReplicationInstanceStatus, + ) + } if cdc.CdcStartPosition != "" { rt.CdcStartPosition = cdc.CdcStartPosition @@ -205,17 +214,22 @@ func (b *InMemoryBackend) StartReplicationTask( rt.CdcStopPosition = cdc.CdcStopPosition } + b.beginStartLocked(rt, cdc.StartType) + + if src, tgt, found := b.taskEndpoints(region, rt); found && b.engineEligible(src, tgt) { + b.launchLocked(region, rt, src, tgt, cdc.StartType) + } + b.appendEvent( - getRegion(ctx, b.region), rt.ReplicationTaskArn, "replication-task", + region, rt.ReplicationTaskArn, "replication-task", "Replication task "+rt.ReplicationTaskIdentifier+" started", []string{eventCategoryStateChange}, ) - cp := *rt - return &cp, nil + return b.viewTask(rt), nil } -// StopReplicationTask transitions a replication task to stopped status. -// Real AWS rejects stopping a task that is not currently running. +// StopReplicationTask stops a replication task. Real AWS rejects stopping a task that is not running; an engine +// run reports "stopping" until its goroutine has wound down. func (b *InMemoryBackend) StopReplicationTask(ctx context.Context, arnOrID string) (*ReplicationTask, error) { b.mu.Lock("StopReplicationTask") defer b.mu.Unlock() @@ -234,14 +248,19 @@ func (b *InMemoryBackend) StopReplicationTask(ctx context.Context, arnOrID strin ) } - rt.Status = statusStopped + if b.stopRunLocked(rt) { + rt.Status = statusStopping + + return b.viewTask(rt), nil + } + + rt.Status, rt.StopDate, rt.StopReason = statusStopped, b.clock(), "Stop Reason NORMAL" b.appendEvent( getRegion(ctx, b.region), rt.ReplicationTaskArn, "replication-task", "Replication task "+rt.ReplicationTaskIdentifier+" stopped", []string{eventCategoryStateChange}, ) - cp := *rt - return &cp, nil + return b.viewTask(rt), nil } // DeleteReplicationTask deletes a replication task by ARN or identifier. @@ -261,6 +280,9 @@ func (b *InMemoryBackend) DeleteReplicationTask(ctx context.Context, arnOrID str ) } cp := *rt + if src, _, found := b.taskEndpoints(region, rt); found { + b.releaseSlotAsync(src, rt.ReplicationTaskArn) + } rt.Tags.Close() b.replicationTasks.Delete(regionKey(region, id)) // Remove from reverse instance→tasks index. @@ -393,15 +415,15 @@ func (b *InMemoryBackend) ModifyReplicationTask( rt.TaskData = cdcSettings.TaskData } - cp := *rt - - return &cp, nil + return b.viewTask(rt), nil } -// ReloadTables reloads the target tables of a running replication task with -// source data. Real AWS only permits this while the task is RUNNING, -// otherwise it throws InvalidResourceStateFault. -func (b *InMemoryBackend) ReloadTables(ctx context.Context, arnOrID string) (*ReplicationTask, error) { +// ReloadTables reloads tables of a running replication task from the source. Real AWS only permits this while +// the task is RUNNING, otherwise it throws InvalidResourceStateFault. Engine-backed tasks re-copy the named +// tables asynchronously; an empty refs list reloads nothing. +func (b *InMemoryBackend) ReloadTables( + ctx context.Context, arnOrID string, refs []dmsengine.TableRef, +) (*ReplicationTask, error) { b.mu.Lock("ReloadTables") defer b.mu.Unlock() @@ -419,9 +441,23 @@ func (b *InMemoryBackend) ReloadTables(ctx context.Context, arnOrID string) (*Re ) } - cp := *rt + if run, ok := b.eng.runsFor(rt.ReplicationTaskArn); ok && run.task != nil { + for _, ref := range refs { + if !run.task.Has(ref) { + return nil, fmt.Errorf( + "%w: table %s.%s is not part of task %s", + ErrNotFound, + ref.Schema, + ref.Name, + arnOrID, + ) + } + } - return &cp, nil + b.reloadRun(run, run.task, refs) + } + + return b.viewTask(rt), nil } // MoveReplicationTask moves a replication task to a different instance. diff --git a/services/dms/store.go b/services/dms/store.go index b7d956a778..dea63ef680 100644 --- a/services/dms/store.go +++ b/services/dms/store.go @@ -2,6 +2,7 @@ package dms import ( "context" + "time" "github.com/google/uuid" @@ -121,6 +122,8 @@ type InMemoryBackend struct { // tasksByEndpointARN indexes task ARNs by endpoint ARN (source or target) for O(1) in-use check. tasksByEndpointARN map[string]map[string]struct{} // endpointARN → taskARN set mu *lockmetrics.RWMutex + eng *engineState + now func() time.Time accountID string region string paginationSecret string @@ -165,6 +168,12 @@ func (b *InMemoryBackend) Reset() { b.mu.Lock("Reset") defer b.mu.Unlock() + if b.eng != nil { + for _, run := range b.eng.runs { + run.cancel() + } + } + closeAllTags(b.replicationInstances, func(ri *ReplicationInstance) { ri.Tags.Close() }) closeAllTags(b.endpoints, func(ep *Endpoint) { ep.Tags.Close() }) closeAllTags(b.replicationTasks, func(rt *ReplicationTask) { rt.Tags.Close() }) diff --git a/services/docdb/engine.go b/services/docdb/engine.go index 2cbf9b7057..9e118b2ee6 100644 --- a/services/docdb/engine.go +++ b/services/docdb/engine.go @@ -58,6 +58,7 @@ type EngineRuntime interface { StopContainer(ctx context.Context, containerID string) error StartContainer(ctx context.Context, containerID string) error Exec(ctx context.Context, containerID string, cmd []string) (container.ExecResult, error) + ExecEnv(ctx context.Context, containerID string, cmd, env []string) (container.ExecResult, error) } // PasswordChange describes a master password change inside a running MongoDB container. @@ -627,21 +628,26 @@ func (b *InMemoryBackend) applyMasterPassword(region, id, newPassword string) er return nil } +// changePasswordScript reads credentials from the process env so they never reach argv. +const changePasswordScript = "var a=db.getSiblingDB('admin');" + + "if(!a.auth(_getEnv('GS_USER'),_getEnv('GS_OLD_PW'))){quit(2)}" + + "a.changeUserPassword(_getEnv('GS_USER'),_getEnv('GS_NEW_PW'))" + func execSetPassword(ctx context.Context, rt EngineRuntime, c PasswordChange) error { - user, pass := jsString(c.User), jsString(c.NewPassword) - script := "db.getSiblingDB('admin').changeUserPassword(" + user + "," + pass + ")" - cmd := []string{ - mongoShell, "--quiet", "-u", c.User, "-p", c.OldPassword, - "--authenticationDatabase", "admin", "--eval", script, - } + cmd := []string{mongoShell, "--quiet", "--eval", changePasswordScript} + env := []string{"GS_USER=" + c.User, "GS_OLD_PW=" + c.OldPassword, "GS_NEW_PW=" + c.NewPassword} - res, err := rt.Exec(ctx, c.ContainerID, cmd) + res, err := container.ExecWithEnv(ctx, rt, c.ContainerID, cmd, env) if err != nil { - return fmt.Errorf("exec mongo shell: %w", err) + if errors.Is(err, container.ErrExecUnsupported) { + return fmt.Errorf("exec mongo shell: %w", err) + } + + return errExecFailed } if res.ExitCode != 0 { - return fmt.Errorf("%w: exit %d: %s", errShellFailed, res.ExitCode, strings.TrimSpace(res.Stderr)) + return fmt.Errorf("%w: exit %d", errShellFailed, res.ExitCode) } return nil diff --git a/services/docdb/engine_docker_test.go b/services/docdb/engine_docker_test.go index 14a7b1c7f7..fe697ff42e 100644 --- a/services/docdb/engine_docker_test.go +++ b/services/docdb/engine_docker_test.go @@ -213,3 +213,48 @@ func TestEngineDockerTLSAndSCRAM(t *testing.T) { assert.NotZero(t, shell(append(append([]string{}, secure...), login("s3cretpassword")...)...), "tls refused when disabled") } + +func TestEngineDockerPasswordChange(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + + rt, err := container.NewRuntime(container.Config{}) + if err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + er, ok := rt.(docdb.EngineRuntime) + if !ok { + t.Skip("container runtime cannot exec with env") + } + + if err = rt.Ping(t.Context()); err != nil { + t.Skipf("container daemon unreachable: %v", err) + } + + b := docdb.NewInMemoryBackend("000000000000", "us-east-1") + b.EnableEngine(docdb.EngineConfig{Runtime: &recordingEngineRuntime{EngineRuntime: er}}) + t.Cleanup(b.Close) + + ctx := t.Context() + _, err = b.CreateDBCluster(ctx, "c1", "docdb", "5.0.0", "admin1", "s3cretpassword", + "", "", "", 0, false, false, 0, "", "", nil, nil, nil) + require.NoError(t, err) + + c := waitClusterStatusFor(t, b, "available", dockerEngineWait) + + _, err = b.ModifyDBCluster(ctx, "c1", "", nil, 0, "", "", + &docdb.ModifyDBClusterOptions{MasterUserPassword: "n3wpassword!"}) + require.NoError(t, err) + + login := func(pass string) int { + return mongoShellExit(t, "mongo:5.0", "mongo", "--quiet", "--host", c.Endpoint, "--port", strconv.Itoa(c.Port), + "-u", "admin1", "-p", pass, "--authenticationDatabase", "admin", "--eval", listDBs) + } + + assert.Zero(t, login("n3wpassword!"), "new password accepted") + assert.NotZero(t, login("s3cretpassword"), "old password rejected") +} diff --git a/services/docdb/engine_probe.go b/services/docdb/engine_probe.go index 5b8f5c6d3f..a7806e3bc6 100644 --- a/services/docdb/engine_probe.go +++ b/services/docdb/engine_probe.go @@ -5,7 +5,6 @@ import ( "context" "crypto/tls" "encoding/binary" - "encoding/json" "errors" "fmt" "io" @@ -24,15 +23,10 @@ const ( var ( errShellFailed = errors.New("mongo shell failed") + errExecFailed = errors.New("exec mongo shell failed") errBadReply = errors.New("unexpected mongod reply") ) -func jsString(s string) string { - b, _ := json.Marshal(s) - - return string(b) -} - // isMasterMessage builds an OP_MSG of {isMaster: 1, $db: "admin"}, which mongod 3.6+ answers unauthenticated. func isMasterMessage() []byte { doc := []byte{0, 0, 0, 0, bsonInt32} diff --git a/services/docdb/engine_test.go b/services/docdb/engine_test.go index 000a0d8922..47bfc9afb6 100644 --- a/services/docdb/engine_test.go +++ b/services/docdb/engine_test.go @@ -25,6 +25,7 @@ type fakeEngineRuntime struct { createErr error onCreate func(spec container.Spec) execs [][]string + envs [][]string specs []container.Spec removed []string stopped []string @@ -86,6 +87,25 @@ func (f *fakeEngineRuntime) Exec(_ context.Context, _ string, cmd []string) (con return container.ExecResult{ExitCode: f.execExit, Stderr: "boom"}, nil } +func (f *fakeEngineRuntime) ExecEnv( + _ context.Context, _ string, cmd, env []string, +) (container.ExecResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.execs = append(f.execs, cmd) + f.envs = append(f.envs, env) + + return container.ExecResult{ExitCode: f.execExit, Stderr: "boom s3cretpassword n3wpassword!"}, nil +} + +func (f *fakeEngineRuntime) execEnvs() [][]string { + f.mu.Lock() + defer f.mu.Unlock() + + return append([][]string(nil), f.envs...) +} + func (f *fakeEngineRuntime) snapshot() ([]container.Spec, []string, []string, []string, [][]string) { f.mu.Lock() defer f.mu.Unlock() @@ -427,8 +447,20 @@ func TestEngineModifyMasterPassword(t *testing.T) { execs := rt.execCalls() require.Len(t, execs, 1) assert.Equal(t, "mongo", execs[0][0]) - assert.Contains(t, execs[0], "s3cretpassword") - assert.Contains(t, strings.Join(execs[0], " "), `"n3wpassword!"`) + + argv := strings.Join(execs[0], " ") + assert.NotContains(t, argv, "s3cretpassword") + assert.NotContains(t, argv, "n3wpassword!") + + envs := rt.execEnvs() + require.Len(t, envs, 1) + assert.Contains(t, envs[0], "GS_OLD_PW=s3cretpassword") + assert.Contains(t, envs[0], "GS_NEW_PW=n3wpassword!") + + if err != nil { + assert.NotContains(t, err.Error(), "s3cretpassword") + assert.NotContains(t, err.Error(), "n3wpassword!") + } }) } } diff --git a/services/docdb/handler_sdk_roundtrip_test.go b/services/docdb/handler_sdk_roundtrip_test.go index f249af5257..6d26b52bf5 100644 --- a/services/docdb/handler_sdk_roundtrip_test.go +++ b/services/docdb/handler_sdk_roundtrip_test.go @@ -1,11 +1,9 @@ package docdb_test import ( - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" docdbsdk "github.com/aws/aws-sdk-go-v2/service/docdb" "github.com/aws/aws-sdk-go-v2/service/docdb/types" @@ -13,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/docdb" ) @@ -34,20 +33,11 @@ func newTestDocDBClient(t *testing.T, h *docdb.Handler) *docdbsdk.Client { require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(rtTestRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return docdbsdk.NewFromConfig(cfg, func(o *docdbsdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + return docdbsdk.NewFromConfig(aws.Config{ + Region: rtTestRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/docdb/iam_enforcement_test.go b/services/docdb/iam_enforcement_test.go index 23fb47d572..a9bac97dba 100644 --- a/services/docdb/iam_enforcement_test.go +++ b/services/docdb/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/docdb" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -158,7 +159,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/rds/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/docdb/lifecycle_sdk_test.go b/services/docdb/lifecycle_sdk_test.go index f735fb4828..b12fc6d9bb 100644 --- a/services/docdb/lifecycle_sdk_test.go +++ b/services/docdb/lifecycle_sdk_test.go @@ -2,6 +2,7 @@ package docdb_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -29,40 +30,45 @@ func TestSDK_CreateReportsCreatingUntilDelay(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - backend := docdb.NewInMemoryBackend("123456789012", "us-east-1") - backend.SetLifecycleDelay(tt.delay) - client := newTestDocDBClient(t, docdb.NewHandler(backend)) - ctx := t.Context() + synctest.Test(t, func(t *testing.T) { + backend := docdb.NewInMemoryBackend("123456789012", "us-east-1") + backend.SetLifecycleDelay(tt.delay) + client := newTestDocDBClient(t, docdb.NewHandler(backend)) + ctx := t.Context() - cl, err := client.CreateDBCluster(ctx, &docdbsdk.CreateDBClusterInput{ - DBClusterIdentifier: aws.String("c-1"), Engine: aws.String("docdb"), - MasterUsername: aws.String("admin"), MasterUserPassword: aws.String("password-123"), - }) - require.NoError(t, err) - assert.Equal(t, tt.wantFirst, aws.ToString(cl.DBCluster.Status)) + cl, err := client.CreateDBCluster(ctx, &docdbsdk.CreateDBClusterInput{ + DBClusterIdentifier: aws.String("c-1"), Engine: aws.String("docdb"), + MasterUsername: aws.String("admin"), MasterUserPassword: aws.String("password-123"), + }) + require.NoError(t, err) + assert.Equal(t, tt.wantFirst, aws.ToString(cl.DBCluster.Status)) - inst, err := client.CreateDBInstance(ctx, &docdbsdk.CreateDBInstanceInput{ - DBInstanceIdentifier: aws.String("i-1"), DBInstanceClass: aws.String("db.r5.large"), - Engine: aws.String("docdb"), DBClusterIdentifier: aws.String("c-1"), - }) - require.NoError(t, err) - assert.Equal(t, tt.wantFirst, aws.ToString(inst.DBInstance.DBInstanceStatus)) + inst, err := client.CreateDBInstance(ctx, &docdbsdk.CreateDBInstanceInput{ + DBInstanceIdentifier: aws.String("i-1"), DBInstanceClass: aws.String("db.r5.large"), + Engine: aws.String("docdb"), DBClusterIdentifier: aws.String("c-1"), + }) + require.NoError(t, err) + assert.Equal(t, tt.wantFirst, aws.ToString(inst.DBInstance.DBInstanceStatus)) - waiter := docdbsdk.NewDBInstanceAvailableWaiter(client, func(o *docdbsdk.DBInstanceAvailableWaiterOptions) { - o.MinDelay = 20 * time.Millisecond - o.MaxDelay = 50 * time.Millisecond - }) - require.NoError(t, waiter.Wait(ctx, &docdbsdk.DescribeDBInstancesInput{ - DBInstanceIdentifier: aws.String("i-1"), - }, 10*time.Second)) + waiter := docdbsdk.NewDBInstanceAvailableWaiter( + client, + func(o *docdbsdk.DBInstanceAvailableWaiterOptions) { + o.MinDelay = 20 * time.Millisecond + o.MaxDelay = 50 * time.Millisecond + }, + ) + require.NoError(t, waiter.Wait(ctx, &docdbsdk.DescribeDBInstancesInput{ + DBInstanceIdentifier: aws.String("i-1"), + }, 10*time.Second)) - require.Eventually(t, func() bool { - out, derr := client.DescribeDBClusters(ctx, &docdbsdk.DescribeDBClustersInput{ - DBClusterIdentifier: aws.String("c-1"), - }) + require.Eventually(t, func() bool { + out, derr := client.DescribeDBClusters(ctx, &docdbsdk.DescribeDBClustersInput{ + DBClusterIdentifier: aws.String("c-1"), + }) - return derr == nil && aws.ToString(out.DBClusters[0].Status) == "available" - }, 5*time.Second, 20*time.Millisecond) + return derr == nil && aws.ToString(out.DBClusters[0].Status) == "available" + }, 5*time.Second, 20*time.Millisecond) + }) }) } } diff --git a/services/dynamodb/PARITY.md b/services/dynamodb/PARITY.md index e1e2cbd5ef..a50664afda 100644 --- a/services/dynamodb/PARITY.md +++ b/services/dynamodb/PARITY.md @@ -22,7 +22,7 @@ families: item_crud: {status: ok, note: PROVEN — condition eval, all ReturnValues, ItemCollectionMetrics/LSI 10GB, WCU/RCU formulas. 2026-08-13: GetItem's wire model (models.GetItemInput/GetItemOutput) was silently dropping ReturnConsumedCapacity, ConsistentRead, AttributesToGet on input and ConsumedCapacity on output even though the backend computed everything correctly -- fixed at the wire boundary in models/convert_ops.go, not the backend. Same day, separately: CreateTable dropped SSESpecification/OnDemandThroughput on input (7a2189b06); UpdateTable dropped DeletionProtectionEnabled/TableClass/BillingMode/SSESpecification (7a2189b06); DescribeBackup/DeleteBackup dropped two required SourceTableDetails members (bc2e6285a). 2026-08-14 (this audit): ListBackups' BackupSummary had no BackupSizeBytes field at all, even though CreateBackup/DescribeBackup's BackupDetails already carried it for the same backup via the real per-backup b.SizeBytes -- fixed in models/types.go + backup_ops.go's collectBackupSummaries. 2026-08-14 (gopherstack-lze5): PutItem/UpdateItem/DeleteItem's legacy Expected/ConditionalOperator (conditional-write) and UpdateItem's AttributeUpdates (legacy update) parameters were wire-serialized (confirmed against serializers.go) but declared nowhere in models/types.go, so a legacy client's conditional check or update silently never happened -- 200 OK either way. Fixed by translating them into an equivalent ConditionExpression/UpdateExpression (synthesized #name/:value placeholders) and reusing the exact same evaluator path PutItem/UpdateItem/DeleteItem already use for the modern expression API, rather than a second evaluation engine -- see gaps for the full writeup and citations. legacy_conditional_params_test.go drives the real aws-sdk-go-v2 client and asserts behaviour (blocked writes stay unchanged, ADD/DELETE/PUT actually mutate the item), each hand-verified to fail against unfixed code.} query_scan: {status: ok, note: PROVEN pagination (LastEvaluatedKey w/ base-PK fusion for GSI/LSI, 1MB/Limit); FIXED Select/COUNT omits Items + Select constraint validation. 2026-08-13: ToSDKQueryInput never copied ReturnConsumedCapacity/Select despite models.QueryInput declaring ReturnConsumedCapacity (Select wasn't declared at all); models.ScanInput was missing ReturnConsumedCapacity/ConsistentRead/Select entirely and models.ScanOutput was missing ConsumedCapacity -- all fixed at the wire boundary. A real client could never get ConsumedCapacity from Query/Scan, nor a COUNT-only Scan/Query response, regardless of what it requested. GSI/LSI Query full-scan gap (previously documented here) is FIXED -- see overall. 2026-08-14 (this audit): AttributesToGet (the legacy pre-expression projection parameter, still real and wire-serialized per api_op_Query.go:92/api_op_Scan.go) was declared on neither models.QueryInput nor models.ScanInput, so it was silently dropped by json.Unmarshal regardless of what a client sent. Fixing the wire model alone was not enough: item_ops_query.go's collectQueryPage and item_ops_scan.go's doScan built their Projector from ProjectionExpression only, never falling back to AttributesToGet the way GetItem/BatchGetItem's resolveProjection() already did -- so even a correctly-wired AttributesToGet would have been silently ignored by the projection logic itself. Fixed both layers (models/types.go + convert_ops.go for the wire, item_ops_query.go/item_ops_scan.go for resolveProjection() reuse), and added the AttributesToGet+ProjectionExpression mutual-exclusion validation Query/Scan were missing (validateProjectionParams, already used by GetItem/BatchGetItem). Test: TestQueryScan_AttributesToGet_SurvivesWireConversion (hand-verified to fail against unfixed code: both subtests failed with "AttributesToGet should have excluded 'other'"). 2026-08-14 (gopherstack-yvs8): KeyConditions/QueryFilter (Query) and ScanFilter (Scan), the remaining legacy pre-expression parameters, were declared on neither models.QueryInput nor models.ScanInput (same wire-drop class as AttributesToGet above, and as Expected/AttributeUpdates on item_crud) -- fixed by adding models.LegacyCondition + the KeyConditions/QueryFilter/ScanFilter/ConditionalOperator fields, and translating them (legacy_query_scan.go) into KeyConditionExpression/FilterExpression through the same evaluator paths item_ops_query.go/item_ops_scan.go already use. See gaps for the KeySchema-reordering fix to item_ops_query.go's PK-position-dependent fast path, and citations.} batch: {status: ok, note: FIXED BatchWriteItem duplicate-key validation (was missing; BatchGetItem had it). 2026-08-13: BatchGetItem/BatchWriteItem never called the throttler at all (db.throttler.ConsumeRead/ConsumeWrite) despite ProvisionedThroughputExceededException being a real, documented error for both ops (confirmed against deserializers.go's per-op error switch) -- provisioned-capacity tables never throttled batch calls even though every single-item op did; fixed with per-table charging mirroring the existing single-item formulas, PAY_PER_REQUEST still bypasses. Also: models.BatchGetItemInput/BatchWriteItemInput had no ReturnConsumedCapacity field at all (BatchWriteItemInput also missing ReturnItemCollectionMetrics) -- silently dropped on the wire regardless of client request; fixed in models/convert_ops.go. BatchWriteItemOutput.ItemCollectionMetrics (a real, conditionally-populated SDK field per api_op_BatchWriteItem.go) was never populated by the backend even when requested on an LSI table -- wired using the same per-item SizeEstimateRangeGB formula PutItem/DeleteItem already use. BatchExecuteStatement dropped per-statement ConsistentRead on input, already forwarded correctly once it arrived (fbc2cfe1f).} - transactions: {status: ok, note: FIXED TransactWriteItems Update key-mutation — was NOT validated, silently corrupted pkIndex/pkskIndex (state corruption bug). 2026-07-24: FIXED gopherstack-daa — Put/Update/Delete/ConditionCheck now reject an ExpressionAttributeNames/Values placeholder unused by that item's expression(s), matching plain PutItem/UpdateItem/DeleteItem (checkUnusedExpressionAttributeNames/Values in expressions.go); Update correctly considers both UpdateExpression AND ConditionExpression when deciding "used". Enforced pre-lock in validateTransactWriteItems (transact_validation.go) so it's a plain ValidationException, not wrapped in CancellationReasons — matches AWS request-validation-time semantics. 2026-08-13: TransactWriteItems/TransactGetItems never called the throttler (same gap as BatchWriteItem/BatchGetItem above); fixed with per-table charging. ToSDKTransactWriteItemsInput had a dangling `// ReturnItemCollectionMetrics` comment instead of actually copying the field, so it was always dropped on the wire even though models.TransactWriteItemsInput declared it and TransactWriteItemsOutput.ItemCollectionMetrics (a real SDK field) was never populated by the backend regardless; both fixed.} + transactions: {status: ok, note: FIXED TransactWriteItems Update key-mutation — was NOT validated, silently corrupted pkIndex/pkskIndex (state corruption bug). 2026-07-24: FIXED gopherstack-daa — Put/Update/Delete/ConditionCheck now reject an ExpressionAttributeNames/Values placeholder unused by that item's expression(s), matching plain PutItem/UpdateItem/DeleteItem (checkUnusedExpressionAttributeNames/Values in expressions.go); Update correctly considers both UpdateExpression AND ConditionExpression when deciding "used". Enforced pre-lock in validateTransactWriteItems (transact_validation.go) so it's a plain ValidationException, not wrapped in CancellationReasons — matches AWS request-validation-time semantics. 2026-08-13: TransactWriteItems/TransactGetItems never called the throttler (same gap as BatchWriteItem/BatchGetItem above); fixed with per-table charging. ToSDKTransactWriteItemsInput had a dangling `// ReturnItemCollectionMetrics` comment instead of actually copying the field, so it was always dropped on the wire even though models.TransactWriteItemsInput declared it and TransactWriteItemsOutput.ItemCollectionMetrics (a real SDK field) was never populated by the backend regardless; both fixed. 2026-10-30: transaction conflicts modeled (see 'Transaction conflicts' section below): items of an in-flight TransactWriteItems/TransactGetItems/ExecuteTransaction are tracked, and genuinely overlapping requests get the documented TransactionConflictException / TransactionCanceledException(TransactionConflict) / UnprocessedItems outcome; the chaos effect dynamodb:transaction-conflict forces it.} streams: {status: ok, note: PROVEN shard-iterator sequence clamping, trim-horizon; streamARNIndex now a store.Table, verified Put/Delete key derivation unchanged. 2026-07-24 (gopherstack-exg7): (1) DescribeStream's ShardFilter{Type:CHILD_SHARDS,ShardId} was accepted on the wire but silently ignored — now filters found.streamShards by ParentShardID (parseShardFilter/filterChildShards in streams_ops.go), rejecting unsupported filter Types and a missing ShardId with ValidationException; verified a filter that legitimately matches zero shards returns a real empty Shards list rather than the "stream just enabled" placeholder shard (buildSDKShardsList's synthesizePlaceholder flag). (2) ShardIteratorStore gained a clock-injection seam (now func() time.Time, SetClock/Now) — resolveIterator's expiry check now reads db.iteratorStore.Now() instead of time.Now() directly, so ExpiredIteratorException is exercised end-to-end via GetShardIterator -> advance fake clock -> GetRecords in a test, not just via the pre-existing ExpireAllShardIteratorsForTest backdate-hack. (3) De-duplicated the wire<->SDK AttributeValue conversion functions that were split across streams_ops.go (wire->SDK: toStreamAttributeValue/dispatchStreamType/buildSDKStreamItem/buildSDKRecord) and streams_wire.go (SDK->wire: FromStreamAttributeValue/FromStreamItem) — both directions (and their shared sentinel errors) now live together in streams_wire.go; streams_ops.go keeps only shard/record-management logic.} janitor_ttl: {status: ok, note: PROVEN batched-lock, ctx-cancel, quickselect eviction, ring-buffer compaction} datalayer: {status: ok, note: RE-AUDITED — ce30166a converted db.Tables/Backups/GlobalTables/exports/imports/streamARNIndex from raw maps to pkgs/store.Table+Index (composite key tableKey(region,name), region derived by parsing TableArn via tableRegion()). Verified every insertion site (CreateTable, RestoreTable, CreateGlobalTable replicas, cloneTableSchema, applyOneReplicaTableEntry) builds TableArn with the same region string used as the store key *before* Put, so tableRegion(t) round-trips correctly; TableArn is never mutated post-insert. No stale map-key leaks (tablesByRegion Index auto-empties groups on last delete, unlike the old per-region submap). Persistence snapshot reshaped map->sorted slice + added a schema version gate (old snapshots discarded cleanly on upgrade, matching the sqs/ec2 precedent) — intentional, not a parity bug.} @@ -116,7 +116,9 @@ gaps: [] reach the SDK struct but are never read). Restored byte-identical again; all gates green with both layers in place." items_still_open: + - "Transaction conflicts (2026-10-30): a plain PutItem/UpdateItem/DeleteItem/BatchWriteItem is never registered in flight, so a TransactGetItems overlapping one (documented conflict) only fires through the dynamodb:transaction-conflict chaos effect, not organically; ExecuteTransaction registers each statement's item as it runs (not up front) and its SELECT-only form is not conflict-checked; BatchWriteItem reports conflicting writes as UnprocessedItems by inference (its API reference lists no TransactionConflictException)." - "Vector indexes are modeled (CreateTable/UpdateTable/DescribeTable/Restore*Override/SearchVectors with real COSINE/EUCLIDEAN/DOT_PRODUCT scoring, HASH/INLINE_FILTER schema); VectorCapacity (VectorSearchRequestBytes/VectorWriteRequestBytes) and the per-write VectorIndexes capacity members are not emitted: the pinned SDK doc strings give no byte-accounting formula (re-checked 2026-10-07)." + - "Number precision (2026-10-31, gopherstack-akt8j): N values compare/sort/compute exactly (pkgs/dynamonum). Unverified, recalled only: the three ValidationException texts for >38 significant digits, overflow and underflow; whether DynamoDB rejects (as here) or rounds an arithmetic result needing >38 digits; output is always plain decimal. PartiQL literals still pre-validate with strconv.ParseFloat." structural_gaps: - "ReplicaDescription.ReplicaInaccessibleDateTime / INACCESSIBLE_ENCRYPTION_CREDENTIALS (2026-10-07): set when a replica's customer-managed KMS key becomes unusable; the replica KMSMasterKeyId is stored and echoed but dynamodb holds no live KMS key-state wiring." - "BackupExpiryDateTime applies to SYSTEM backups only; the emulator creates none." @@ -756,3 +758,52 @@ PartiQL (regex-driven) rejected common real-world statements. Fixed in `partiql_ - BatchExecuteStatement per-statement errors use the closed `BatchStatementErrorCodeEnum` (ResourceNotFound, ConditionalCheckFailed, DuplicateItem, ValidationError, ...) instead of the invented `StatementError`. Proof: `TestPartiQLDialect`, `TestBatchExecuteStatementErrorCodes`. Probed and found correct: transactions with cancellation reasons and `ALL_OLD` items, conditional updates, streams GetRecords/iterators, TTL, billing-mode switches, GSI create/delete. + +## 2026-10-30 Transaction conflicts + +Source: Developer Guide "Transaction conflict handling in DynamoDB" and "Isolation levels for DynamoDB transactions" (transaction-apis.html), plus the TransactWriteItems/TransactGetItems/PutItem/UpdateItem/DeleteItem/BatchWriteItem/ExecuteTransaction API references. + +Design: `txn_inflight.go` keeps an in-flight registry (item -> writing transaction; TransactGetItems is checked but never registered) guarded by the backend's single `db.mu`. A transaction registers its items atomically (check-then-mark under one `db.mu` hold) after request validation and idempotency-token handling, stays registered through its prepare/commit (which still run under the per-table locks, so commits stay atomic and isolated), and clears on return. Single-item ops check the registry before taking table locks (no db.mu inside table.mu). No delay and no randomness: conflicts occur only when requests truly overlap. With nothing in flight and no chaos rule, the check is two atomic loads. + +| Documented conflict | Outcome here | +| --- | --- | +| PutItem/UpdateItem/DeleteItem (and ExecuteStatement writes) on an item in an in-flight TransactWriteItems | 400 TransactionConflictException "Transaction is ongoing for the item" | +| TransactWriteItems item in another in-flight TransactWriteItems | TransactionCanceledException, CancellationReasons Code=TransactionConflict at the conflicting entries, None elsewhere; message ends in the bracketed code list | +| TransactGetItems item in an in-flight TransactWriteItems | TransactionCanceledException, TransactionConflict | +| BatchWriteItem write on an item in an in-flight TransactWriteItems | docs are silent on the outcome (see below); returned in UnprocessedItems, rest of the batch written (inferred, see items_still_open) | +| TransactWriteItems overlapping an in-flight TransactGetItems | no conflict: not in the documented list; serializable ordering only (TransactGetItems is not registered in flight) | +| GetItem | never blocked; reads committed state | +| ExecuteTransaction | same as TransactWriteItems: reason TransactionConflict at the failing statement | +| Reused ClientRequestToken while the first call is running | TransactionInProgressException (retried by the SDK as a throttle code) | +| CloudWatch TransactionConflict (TableName) | +1 per failed item-level request | + +Doc evidence (fetched 2026-10-30), developer guide transaction-apis.html#transaction-conflict-handling: "An item within a `TransactWriteItems` request is part of another ongoing `TransactWriteItems` request." and "An item within a `TransactGetItems` request is part of an ongoing `TransactWriteItems`, `BatchWriteItem`, `PutItem`, `UpdateItem`, or `DeleteItem` request." No scenario has a TransactWriteItems conflicting with an ongoing TransactGetItems; API_TransactWriteItems lists only "There is an ongoing `TransactWriteItems` operation that conflicts with a concurrent `TransactWriteItems` request" as a conflict cancellation, and API_TransactGetItems says "There is an ongoing `TransactGetItems` operation that conflicts with a concurrent `PutItem`, `UpdateItem`, `DeleteItem` or `TransactWriteItems` request" (the TGI is the one canceled). Isolation section: "There is serializable isolation ... Between a `TransactWriteItems` operation and a `TransactGetItems` operation." BatchWriteItem: "Although there is serializable isolation between transactional operations, and each individual standard write in a `BatchWriteItem` operation, there is no serializable isolation between the transaction and the `BatchWriteItem` operation as a unit."; the summary table marks BatchWriteItem "NOT Serializable". API_BatchWriteItem lists no TransactionConflictException and documents UnprocessedItems only for "provisioned throughput is exceeded or an internal processing failure". So the BatchWriteItem outcome against an in-flight TransactWriteItems is undocumented; per-item UnprocessedItems is the only reading compatible with its error list. URLs: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html , https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html , .../API_TransactGetItems.html , .../API_BatchWriteItem.html . + +SDK retry: `TransactionConflictException` and `TransactionCanceledException` are not in the SDK's retryable set (docs: "AWS SDKs do not retry the request"); `TransactionInProgressException` is. Proof: `TestTransactionConflict_SDKRetryBehaviour`. + +Chaos: rule `{"effect":"dynamodb:transaction-conflict","operation":"PutItem","target":{"table":"orders","key":{"pk":"a"}},"probability":1,"holdOps":2,"holdMs":500}` on `POST /_gopherstack/chaos/faults` makes matching items behave as held by a phantom in-flight TransactWriteItems. `holdOps`/`holdMs` keep the item held for N further operations / N ms after the first forced conflict. Effect rules are never HTTP faults (`FaultStore.Match` skips them). + +Tests: `txn_conflict_test.go` drives the real SDK client; `db.txnHook` (unexported field, in-package) parks a transaction mid-flight on channels, so overlap is deterministic with no sleeps. Persistence: in-flight state is transient and deliberately not in `backendSnapshot`. + +## 2026-10-31 Exact Number semantics (gopherstack-akt8j) + +N values were compared and computed as float64, so 38-digit values differing in the +last digits compared equal. They now use `pkgs/dynamonum` (normalised decimal: up to +38 significant digits, exact compare, big.Int add/sub with an int64 fast path). + +- Documented (HowItWorks.NamingRulesDataTypes): 38 digits, range 1E-130..9.9999999999999999999999999999999999999E+125, + "leading and trailing zeroes are trimmed", exceeding precision is an exception. +- Fixed sites: expression comparisons/BETWEEN/IN (evaluator and compiled), KeyConditionExpression, + Query/Scan sort-key order and LastEvaluatedKey continuation, SET `+`/`-` and ADD arithmetic, + NS de-dup/ADD/DELETE/contains, M/L nested N equality. N and NS values are canonicalised at + request decode ("1.0" -> "1", "1E+3" -> "1000", "-0" -> "0"), so key strings match too. +- Errors (ValidationException) for >38 digits, overflow, underflow use the wording + "Attempting to store more than 38 significant digits in a Number", + "Number overflow. Attempting to store a number with magnitude larger than supported range", + "Number underflow. Attempting to store a number with magnitude smaller than supported range". + These strings are from memory of real DynamoDB/DynamoDB Local; not in the public docs (unverified verbatim). +- Unverified: whether real DynamoDB rounds rather than rejects an arithmetic result needing >38 digits; + output is always plain decimal (never exponent form). "007"-style leading zeros are still rejected + (existing behaviour). +- Not changed: PartiQL literal paths still pre-validate via ParseFloat. +- Tests: number_precision_sdk_test.go, expr/number_precision_test.go, pkgs/dynamonum. diff --git a/services/dynamodb/README.md b/services/dynamodb/README.md index 914edbd00a..42bf9d7ba6 100644 --- a/services/dynamodb/README.md +++ b/services/dynamodb/README.md @@ -8,14 +8,16 @@ | Metric | Value | | --- | --- | | Feature families | 15 (15 ok) | -| Known gaps | 1 | +| Known gaps | 3 | | Structural gaps (can't be emulated) | 3 | | Deferred items | 2 | | Resource leaks | clean | ### Known gaps +- Transaction conflicts (2026-10-30): a plain PutItem/UpdateItem/DeleteItem/BatchWriteItem is never registered in flight, so a TransactGetItems overlapping one (documented conflict) only fires through the dynamodb:transaction-conflict chaos effect, not organically; ExecuteTransaction registers each statement's item as it runs (not up front) and its SELECT-only form is not conflict-checked; BatchWriteItem reports conflicting writes as UnprocessedItems by inference (its API reference lists no TransactionConflictException). - Vector indexes are modeled (CreateTable/UpdateTable/DescribeTable/Restore*Override/SearchVectors with real COSINE/EUCLIDEAN/DOT_PRODUCT scoring, HASH/INLINE_FILTER schema); VectorCapacity (VectorSearchRequestBytes/VectorWriteRequestBytes) and the per-write VectorIndexes capacity members are not emitted: the pinned SDK doc strings give no byte-accounting formula (re-checked 2026-10-07). +- Number precision (2026-10-31, gopherstack-akt8j): N values compare/sort/compute exactly (pkgs/dynamonum). Unverified, recalled only: the three ValidationException texts for >38 significant digits, overflow and underflow; whether DynamoDB rejects (as here) or rounds an arithmetic result needing >38 digits; output is always plain decimal. PartiQL literals still pre-validate with strconv.ParseFloat. ### Structural gaps diff --git a/services/dynamodb/errors.go b/services/dynamodb/errors.go index 1683023910..5108258c16 100644 --- a/services/dynamodb/errors.go +++ b/services/dynamodb/errors.go @@ -143,6 +143,15 @@ func NewTransactionCanceledException(msg string, reasons []CancellationReason) * } } +// NewTransactionConflictException is the single-item rejection of a PutItem/UpdateItem/DeleteItem that +// overlaps an in-flight TransactWriteItems. +func NewTransactionConflictException(msg string) *Error { + return &Error{ + Type: "com.amazonaws.dynamodb.v20120810#TransactionConflictException", + Message: msg, + } +} + func NewTransactionInProgressException(msg string) *Error { return &Error{ Type: "com.amazonaws.dynamodb.v20120810#TransactionInProgressException", diff --git a/services/dynamodb/execute_transaction.go b/services/dynamodb/execute_transaction.go index f5786a993c..11fd5c7748 100644 --- a/services/dynamodb/execute_transaction.go +++ b/services/dynamodb/execute_transaction.go @@ -67,6 +67,11 @@ func (db *InMemoryDB) executeTransactionOp( // Capture pre-transaction snapshots of all affected tables. snapshots := db.captureExecTxnSnapshots(ctx, tableNames) + txn := db.newTxnHandle("ExecuteTransaction") + defer txn.release() + + ctx = context.WithValue(ctx, txnHandleKey{}, txn) + runner := &partiQLRunner{backend: db} responses := make([]types.ItemResponse, len(input.TransactStatements)) returnCC := input.ReturnConsumedCapacity != "" && @@ -99,11 +104,16 @@ func (db *InMemoryDB) executeTransactionOp( Code: cancellationCodeForErr(execErr), Message: execErr.Error(), } + if reasons[i].Code == cancellationReasonConflict { + reasons[i].Message = txnConflictMessage + } - return nil, NewTransactionCanceledException(txCancelPrefix, reasons) + return nil, NewTransactionCanceledException(txCancelMessage(reasons), reasons) } responses[i] = resp + db.runTxnHook("ExecuteTransaction", i+1) + if returnCC && stmtCC != nil { consumedCapacity[i] = *stmtCC } @@ -439,6 +449,10 @@ func cancellationCodeForErr(err error) string { return "ConditionalCheckFailed" } + if errors.As(err, &wireErr) && strings.Contains(wireErr.Type, "TransactionConflictException") { + return cancellationReasonConflict + } + return "ValidationError" } diff --git a/services/dynamodb/expr/compiled.go b/services/dynamodb/expr/compiled.go new file mode 100644 index 0000000000..e9a0f21d18 --- /dev/null +++ b/services/dynamodb/expr/compiled.go @@ -0,0 +1,281 @@ +package expr + +import "strings" + +// CompiledCondition is a condition bound to one request's attribute maps: placeholder +// values and attribute names are resolved once, then reused across items. Not safe for +// concurrent use. +type CompiledCondition struct { + eval *Evaluator + fn boolFn +} + +type boolFn func(item map[string]any) (bool, error) + +// CompileCondition binds node to names and values. Evaluation matches Evaluator.Evaluate +// on the same node, including errors for placeholders that are reached but undefined. +func CompileCondition(node Node, names map[string]string, values map[string]any) *CompiledCondition { + c := &CompiledCondition{eval: &Evaluator{AttrNames: names, AttrValues: values}} + c.fn = c.eval.compileBool(node) + + return c +} + +// Evaluate reports whether item satisfies the condition. +func (c *CompiledCondition) Evaluate(item map[string]any) (bool, error) { + c.eval.Item = item + + return c.fn(item) +} + +type srcKind uint8 + +const ( + srcNode srcKind = iota + srcConst + srcMissing + srcPath +) + +// src is a pre-resolved operand: a constant placeholder value, a missing placeholder, +// a path with attribute names substituted, or any other node evaluated generically. +type src struct { + raw any + node Node + err error + path []PathElement + str string + op operand + kind srcKind +} + +func (e *Evaluator) compileSrc(n Node) src { + switch v := n.(type) { + case *ValuePlaceholder: + raw, err := e.Evaluate(v) + if err != nil { + return src{kind: srcMissing, err: err} + } + + s := src{kind: srcConst, raw: raw, str: e.toString(raw)} + s.op.unwrapped = e.unwrapAttributeValue(raw) + s.op.num, s.op.isNum = e.parseUnwrappedNumeric(s.op.unwrapped) + + return s + case *PathExpr: + return src{kind: srcPath, path: e.resolvePathNames(v.Elements)} + default: + return src{kind: srcNode, node: n} + } +} + +func (e *Evaluator) resolvePathNames(path []PathElement) []PathElement { + out := make([]PathElement, len(path)) + for i, el := range path { + out[i] = el + if el.Type == ElementKey { + out[i].Name = e.resolveName(el.Name) + } + } + + return out +} + +func (s *src) rawValue(e *Evaluator, item map[string]any) (any, error) { + switch s.kind { + case srcConst: + return s.raw, nil + case srcMissing: + return nil, s.err + case srcPath: + val, _ := e.walkPath(item, s.path) + + return val, nil + default: + return e.Evaluate(s.node) + } +} + +func (s *src) operand(e *Evaluator, item map[string]any) (operand, error) { + switch s.kind { + case srcConst: + return s.op, nil + case srcMissing: + return operand{}, s.err + case srcNode, srcPath: + } + + raw, err := s.rawValue(e, item) + if err != nil { + return operand{}, err + } + + op := operand{unwrapped: e.unwrapAttributeValue(raw)} + op.num, op.isNum = e.parseUnwrappedNumeric(op.unwrapped) + + return op, nil +} + +func (s *src) text(e *Evaluator, item map[string]any) (string, error) { + if s.kind == srcConst { + return s.str, nil + } + + raw, err := s.rawValue(e, item) + if err != nil { + return "", err + } + + return e.toString(raw), nil +} + +func (e *Evaluator) compileBool(node Node) boolFn { + switch n := node.(type) { + case *LogicalExpr: + return e.compileLogical(n) + case *NotExpr: + inner := e.compileBool(n.Expression) + + return func(item map[string]any) (bool, error) { + b, err := inner(item) + if err != nil { + return false, err + } + + return !b, nil + } + case *ComparisonExpr: + if n.Operator != TokenPlus && n.Operator != TokenMinus { + return e.compileComparison(n) + } + case *BetweenExpr: + return e.compileBetween(n) + case *FunctionExpr: + if fn := e.compileFunction(n); fn != nil { + return fn + } + } + + return e.compileGeneric(node) +} + +func (e *Evaluator) compileGeneric(node Node) boolFn { + return func(_ map[string]any) (bool, error) { + v, err := e.Evaluate(node) + if err != nil { + return false, err + } + + b, _ := v.(bool) + + return b, nil + } +} + +func (e *Evaluator) compileLogical(n *LogicalExpr) boolFn { + left, right, op := e.compileBool(n.Left), e.compileBool(n.Right), n.Operator + + return func(item map[string]any) (bool, error) { + l, err := left(item) + if err != nil { + return false, err + } + + if op == TokenAND && !l { + return false, nil + } + + if op == TokenOR && l { + return true, nil + } + + return right(item) + } +} + +func (e *Evaluator) compileComparison(n *ComparisonExpr) boolFn { + l, r, op := e.compileSrc(n.Left), e.compileSrc(n.Right), n.Operator + + return func(item map[string]any) (bool, error) { + lo, err := l.operand(e, item) + if err != nil { + return false, err + } + + ro, err := r.operand(e, item) + if err != nil { + return false, err + } + + return e.compareResolved(lo, op, ro), nil + } +} + +func (e *Evaluator) compileBetween(n *BetweenExpr) boolFn { + v, lo, hi := e.compileSrc(n.Value), e.compileSrc(n.Lower), e.compileSrc(n.Upper) + + return func(item map[string]any) (bool, error) { + val, err := v.operand(e, item) + if err != nil { + return false, err + } + + lower, err := lo.operand(e, item) + if err != nil { + return false, err + } + + upper, err := hi.operand(e, item) + if err != nil { + return false, err + } + + return e.compareResolved(val, TokenGreaterEqual, lower) && + e.compareResolved(val, TokenLessEqual, upper), nil + } +} + +// compileFunction returns nil for functions or argument shapes left to the generic path. +func (e *Evaluator) compileFunction(n *FunctionExpr) boolFn { + switch n.Name { + case funcBeginsWith: + if len(n.Args) != twoArgs { + return nil + } + + path, prefix := e.compileSrc(n.Args[0]), e.compileSrc(n.Args[1]) + + return func(item map[string]any) (bool, error) { + s, err := path.text(e, item) + if err != nil { + return false, err + } + + p, err := prefix.text(e, item) + if err != nil { + return false, err + } + + return strings.HasPrefix(s, p), nil + } + case funcAttributeExists, funcAttributeNotExists: + if len(n.Args) != 1 { + return nil + } + + p, ok := n.Args[0].(*PathExpr) + if !ok { + return nil + } + + path, want := e.resolvePathNames(p.Elements), n.Name == funcAttributeExists + + return func(item map[string]any) (bool, error) { + _, exists := e.walkPath(item, path) + + return exists == want, nil + } + } + + return nil +} diff --git a/services/dynamodb/expr/evaluator.go b/services/dynamodb/expr/evaluator.go index fd7e2ae572..9c899e263d 100644 --- a/services/dynamodb/expr/evaluator.go +++ b/services/dynamodb/expr/evaluator.go @@ -9,6 +9,8 @@ import ( "slices" "strconv" "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" ) // Sentinel errors for evaluator. @@ -79,6 +81,13 @@ var ( ) ) +// DynamoDB function names shared by the lexer, evaluator and compiler. +const ( + funcBeginsWith = "begins_with" + funcAttributeExists = "attribute_exists" + funcAttributeNotExists = "attribute_not_exists" +) + // twoArgs is the expected argument count for two-argument functions. const twoArgs = 2 @@ -99,33 +108,17 @@ type Evaluator struct { // Snapshot, when set, is a read-only pre-update item used instead of copying Item. Snapshot map[string]any - - // operands memoises placeholder values after unwrap and numeric parse; see Memoize. - operands map[string]operand } -// operand is a value placeholder resolved once: the unwrapped value and its numeric parse. +// operand is an unwrapped value with its numeric parse. type operand struct { unwrapped any - num float64 + num dynamonum.Num isNum bool } -// Memoize makes comparisons resolve each :value placeholder once instead of per Evaluate -// call. Only valid while AttrValues is unchanged, and not safe for concurrent use. -func (e *Evaluator) Memoize() { - e.operands = map[string]operand{} -} - // resolveOperand evaluates n and returns its unwrapped value with numeric parse. func (e *Evaluator) resolveOperand(n Node) (operand, error) { - ph, isPlaceholder := n.(*ValuePlaceholder) - if isPlaceholder && e.operands != nil { - if op, ok := e.operands[ph.Name]; ok { - return op, nil - } - } - raw, err := e.Evaluate(n) if err != nil { return operand{}, err @@ -134,10 +127,6 @@ func (e *Evaluator) resolveOperand(n Node) (operand, error) { op := operand{unwrapped: e.unwrapAttributeValue(raw)} op.num, op.isNum = e.parseUnwrappedNumeric(op.unwrapped) - if isPlaceholder && e.operands != nil { - e.operands[ph.Name] = op - } - return op, nil } @@ -242,18 +231,25 @@ func (e *Evaluator) evaluateArithmetic(n *ComparisonExpr) (any, error) { return nil, ErrOperandIncorrectType } - var result float64 + var ( + result dynamonum.Num + opErr error + ) + switch n.Operator { case TokenPlus: - result = leftNum + rightNum + result, opErr = dynamonum.Add(leftNum, rightNum) case TokenMinus: - result = leftNum - rightNum + result, opErr = dynamonum.Sub(leftNum, rightNum) default: return nil, fmt.Errorf("%w: %v", ErrUnsupportedArithmeticOp, n.Operator) } - // Return in DynamoDB attribute value format - return map[string]any{"N": formatDynamoNumber(result)}, nil + if opErr != nil { + return nil, opErr + } + + return map[string]any{"N": result.String()}, nil } func (e *Evaluator) evaluateBetween(n *BetweenExpr) (bool, error) { @@ -296,11 +292,11 @@ func (e *Evaluator) evaluateFunction(n *FunctionExpr) (any, error) { switch n.Name { case "size": return e.evalSizeFunc(n) - case "attribute_exists": + case funcAttributeExists: return e.evalAttributeExistsFunc(n) - case "attribute_not_exists": + case funcAttributeNotExists: return e.evalAttributeNotExistsFunc(n) - case "begins_with": + case funcBeginsWith: return e.evalBeginsWithFunc(n) case "contains": return e.evalContainsFunc(n) @@ -428,7 +424,7 @@ func (e *Evaluator) evalContainsSetOrList(m map[string]any, targetVal any) (bool return containsStringSlice(ss, e.toString(targetVal)), true } if ns, hasNS := m["NS"]; hasNS { - return containsStringSlice(ns, e.toString(targetVal)), true + return containsStringSlice(ns, dynamonum.Canonical(e.toString(targetVal))), true } if bs, hasBS := m["BS"]; hasBS { return e.containsBinarySlice(bs, targetVal), true @@ -612,6 +608,7 @@ func (e *Evaluator) unwrapAttributeValue(v any) any { if !ok { return v } + if val, exists := m["S"]; exists { return val } @@ -701,70 +698,23 @@ func (e *Evaluator) compareResolved(l operand, op TokenType, r operand) bool { } // parseUnwrappedNumeric is parseNumeric for a value unwrapAttributeValue already processed. -func (e *Evaluator) parseUnwrappedNumeric(v any) (float64, bool) { +func (e *Evaluator) parseUnwrappedNumeric(v any) (dynamonum.Num, bool) { switch val := v.(type) { - case float64: - return val, true case string: if !couldBeNumeric(val) { - return 0, false - } - - if f, ok := parseSmallInt(val); ok { - return f, true + return dynamonum.Num{}, false } - f, err := strconv.ParseFloat(val, 64) + x, err := dynamonum.Parse(val) - return f, err == nil + return x, err == nil case nil: - return 0, false + return dynamonum.Num{}, false default: return e.parseNumeric(v) } } -// maxExactDigits keeps parseSmallInt within float64's exact integer range. -const ( - maxExactDigits = 15 - decimalBase = 10 -) - -// parseSmallInt parses an optionally negative run of at most maxExactDigits digits without ParseFloat. -func parseSmallInt(s string) (float64, bool) { - digits := s - neg := false - - if digits != "" && digits[0] == '-' { - neg, digits = true, digits[1:] - } - - if digits == "" || len(digits) > maxExactDigits { - return 0, false - } - - var n int64 - - for i := range len(digits) { - c := digits[i] - if c < '0' || c > '9' { - return 0, false - } - - n = n*decimalBase + int64(c-'0') - } - - if neg { - if n == 0 { - return math.Copysign(0, -1), true - } - - n = -n - } - - return float64(n), true -} - // sameType reports whether a and b have the same dynamic type, using a type // switch instead of fmt.Sprintf("%T",...) to avoid heap allocations. func sameType(a, b any) bool { @@ -798,21 +748,23 @@ func sameType(a, b any) bool { } } -// compareNumbers compares two float64 values with the given operator. -func compareNumbers(lNum, rNum float64, op TokenType) bool { +// compareNumbers compares two exact numbers with the given operator. +func compareNumbers(l, r dynamonum.Num, op TokenType) bool { + c := l.Cmp(r) + switch op { case TokenEqual: - return lNum == rNum + return c == 0 case TokenNotEqual: - return lNum != rNum + return c != 0 case TokenLess: - return lNum < rNum + return c < 0 case TokenLessEqual: - return lNum <= rNum + return c <= 0 case TokenGreater: - return lNum > rNum + return c > 0 case TokenGreaterEqual: - return lNum >= rNum + return c >= 0 default: return false } @@ -875,26 +827,25 @@ func compareStrings(s1, s2 string, op TokenType) bool { } } -func (e *Evaluator) parseNumeric(v any) (float64, bool) { - unwrapped := e.unwrapAttributeValue(v) - switch val := unwrapped.(type) { +func (e *Evaluator) parseNumeric(v any) (dynamonum.Num, bool) { + switch val := e.unwrapAttributeValue(v).(type) { case float64: - return val, true + return dynamonum.FromFloat(val) case int: - return float64(val), true + return dynamonum.FromInt64(int64(val)), true case int64: - return float64(val), true + return dynamonum.FromInt64(val), true case string: - // Skip ParseFloat (allocates on failure) for plainly non-numeric strings. if !couldBeNumeric(val) { - return 0, false - } - if f, parseErr := strconv.ParseFloat(val, 64); parseErr == nil { - return f, true + return dynamonum.Num{}, false } + + x, err := dynamonum.Parse(val) + + return x, err == nil } - return 0, false + return dynamonum.Num{}, false } // couldBeNumeric reports whether s has only decimal-float characters. @@ -915,34 +866,48 @@ func couldBeNumeric(s string) bool { return true } -// formatDynamoNumber formats a float64 as a plain decimal string without -// scientific notation, matching DynamoDB's number representation. -func formatDynamoNumber(f float64) string { - if f == float64(int64(f)) { - return strconv.FormatInt(int64(f), 10) +func (e *Evaluator) getValueAtPath(item map[string]any, path []PathElement) (any, bool) { + if len(path) == 0 { + return nil, false } - return strconv.FormatFloat(f, 'f', -1, 64) + return e.walk(item, path, true) } -func (e *Evaluator) getValueAtPath(item map[string]any, path []PathElement) (any, bool) { +// resolveName substitutes a #name placeholder; unknown placeholders stay literal. +func (e *Evaluator) resolveName(name string) string { + if strings.HasPrefix(name, "#") { + if resolved, ok := e.AttrNames[name]; ok { + return resolved + } + } + + return name +} + +// walkPath follows a path whose names were already resolved by resolvePathNames. +func (e *Evaluator) walkPath(item map[string]any, path []PathElement) (any, bool) { + return e.walk(item, path, false) +} + +func (e *Evaluator) walk(item map[string]any, path []PathElement, resolve bool) (any, bool) { if len(path) == 0 { return nil, false } + current := any(item) for i, elem := range path { - isLast := i == len(path)-1 - var val any - var exists bool + var ( + val any + exists bool + ) - name := elem.Name - if strings.HasPrefix(name, "#") { - if resolved, ok := e.AttrNames[name]; ok { - name = resolved + if elem.Type == ElementKey { + name := elem.Name + if resolve { + name = e.resolveName(name) } - } - if elem.Type == ElementKey { val, exists = e.navigateMap(current, name) } else { val, exists = e.navigateList(current, elem.Index) @@ -951,9 +916,11 @@ func (e *Evaluator) getValueAtPath(item map[string]any, path []PathElement) (any if !exists { return nil, false } - if isLast { + + if i == len(path)-1 { return val, true } + current = e.unwrapAttributeValue(val) } @@ -1346,8 +1313,13 @@ func (e *Evaluator) applyAddNumber(path []PathElement, cur, val any) error { return ErrOperandIncorrectType } valNum, _ := e.parseNumeric(val) - sum := curNum + valNum - updated, err := e.setValueAtPath(e.Item, path, map[string]any{"N": formatDynamoNumber(sum)}) + + sum, err := dynamonum.Add(curNum, valNum) + if err != nil { + return err + } + + updated, err := e.setValueAtPath(e.Item, path, map[string]any{"N": sum.String()}) if err != nil { return err } @@ -1392,6 +1364,10 @@ func (e *Evaluator) addToStringSet( seen := make(map[string]bool, len(merged)) unique := merged[:0] for _, s := range merged { + if setKey == "NS" { + s = dynamonum.Canonical(s) + } + if !seen[s] { seen[s] = true unique = append(unique, s) @@ -1517,9 +1493,14 @@ func (e *Evaluator) deleteNS(path []PathElement, cur any, nsToRemove any) error return ErrCurrentNSValueMustBeSlice } + removeKeys := make([]string, len(toRemove)) + for i, r := range toRemove { + removeKeys[i] = dynamonum.Canonical(r) + } + newSet := make([]string, 0, len(currentSet)) for _, item := range currentSet { - if !slices.Contains(toRemove, item) { + if !slices.Contains(removeKeys, dynamonum.Canonical(item)) { newSet = append(newSet, item) } } diff --git a/services/dynamodb/expr/evaluator_perf_test.go b/services/dynamodb/expr/evaluator_perf_test.go index 3fb12a1695..814f2b4c2e 100644 --- a/services/dynamodb/expr/evaluator_perf_test.go +++ b/services/dynamodb/expr/evaluator_perf_test.go @@ -38,13 +38,12 @@ func BenchmarkEvaluator_FilterItem(b *testing.B) { } }) - b.Run("memoized", func(b *testing.B) { - eval := &expr.Evaluator{Item: item, AttrNames: names, AttrValues: values} - eval.Memoize() + b.Run("compiled", func(b *testing.B) { + compiled := expr.CompileCondition(node, names, values) b.ReportAllocs() for b.Loop() { - if _, evalErr := eval.Evaluate(node); evalErr != nil { + if _, evalErr := compiled.Evaluate(item); evalErr != nil { b.Fatal(evalErr) } } @@ -68,7 +67,7 @@ func TestEvaluator_NumericStringComparison(t *testing.T) { {name: "exponent", left: "1e3", right: "1000", want: true}, {name: "plus sign", left: "+5", right: "5", want: true}, {name: "fifteen digits", left: "999999999999999", right: "999999999999999", want: true}, - {name: "sixteen digits differ", left: "9007199254740993", right: "9007199254740992", want: true}, + {name: "sixteen digits differ", left: "9007199254740993", right: "9007199254740992", want: false}, {name: "different", left: "41", right: "42", want: false}, } @@ -91,7 +90,7 @@ func TestEvaluator_NumericStringComparison(t *testing.T) { } } -func TestEvaluator_MemoizeMatchesFresh(t *testing.T) { +func TestCompiledCondition_MatchesEvaluate(t *testing.T) { t.Parallel() items := []map[string]any{ @@ -99,17 +98,44 @@ func TestEvaluator_MemoizeMatchesFresh(t *testing.T) { {"val": map[string]any{"N": "1500"}, "name": map[string]any{"S": "x"}}, {"val": map[string]any{"S": "1500"}, "name": map[string]any{"S": "b"}}, {"val": map[string]any{"N": "2.5e3"}}, + {"val": map[string]any{"N": "-1500"}, "name": map[string]any{"S": ""}}, + {"val": map[string]any{"N": "12345678901234567890123456789012345678"}}, + {"val": map[string]any{"N": "12345678901234567890123456789012345679"}}, + {"val": map[string]any{"N": "-12345678901234567890123456789012345678"}}, + {"val": map[string]any{"N": "1E+3"}, "name": map[string]any{"S": "xylophone"}}, + {"val": map[string]any{"N": "0.0000000000000000000000000000000000001"}}, {"name": map[string]any{"NULL": true}}, {"val": map[string]any{"BOOL": true}, "name": map[string]any{"B": []byte("x")}}, + { + "m": map[string]any{"M": map[string]any{ + "inner": map[string]any{"N": "7"}, + "l": map[string]any{"L": []any{map[string]any{"S": "p"}, map[string]any{"N": "9"}}}, + }}, + "l": map[string]any{"L": []any{map[string]any{"N": "3"}, map[string]any{"S": "zz"}}}, + "tags": map[string]any{"SS": []any{"a", "b"}}, + "#lit": map[string]any{"N": "11"}, + }, + {"val": map[string]any{"N": "1", "S": "multi"}}, + {"val": map[string]any{"weird": "x"}}, {}, } - names := map[string]string{"#v": "val", "#n": "name"} + names := map[string]string{ + "#v": "val", "#n": "name", "#m": "m", "#i": "inner", "#l": "l", "#t": "tags", + } values := map[string]any{ - ":lo": map[string]any{"N": "1000"}, - ":hi": map[string]any{"N": "2000"}, - ":x": map[string]any{"S": "x"}, - ":null": map[string]any{"NULL": true}, - ":t": map[string]any{"BOOL": true}, + ":lo": map[string]any{"N": "1000"}, + ":hi": map[string]any{"N": "2000"}, + ":x": map[string]any{"S": "x"}, + ":null": map[string]any{"NULL": true}, + ":t": map[string]any{"BOOL": true}, + ":big": map[string]any{"N": "12345678901234567890123456789012345678"}, + ":neg": map[string]any{"N": "-1500"}, + ":exp": map[string]any{"N": "1e3"}, + ":p": map[string]any{"S": "xy"}, + ":empty": map[string]any{"S": ""}, + ":num": map[string]any{"N": "3"}, + ":seven": map[string]any{"N": "7"}, + ":a": map[string]any{"S": "a"}, } tests := []struct { @@ -117,14 +143,54 @@ func TestEvaluator_MemoizeMatchesFresh(t *testing.T) { expr string }{ {"between", "#v BETWEEN :lo AND :hi"}, + {"between_missing_upper", "#v BETWEEN :lo AND :nope"}, {"compare_num", "#v > :lo"}, + {"compare_all_ops", "#v <= :lo OR #v < :lo OR #v >= :hi OR #v = :exp OR #v <> :exp"}, {"compare_str", "#n = :x"}, + {"compare_str_order", "#n >= :x"}, {"not_equal", "#n <> :x"}, {"null", "#n = :null"}, {"bool", "#v = :t"}, {"and_or", "#v < :lo OR (#v >= :hi AND #n <> :x)"}, + {"not", "NOT (#v > :lo)"}, {"missing_placeholder", "#v = :nope"}, + {"missing_placeholder_left", ":nope = #v"}, + {"missing_short_circuit", "#v < :lo AND #v = :nope"}, + {"missing_short_circuit_or", "#v >= :lo OR #v = :nope"}, {"placeholder_both_sides", ":lo < :hi"}, + {"big_eq", "#v = :big"}, + {"big_gt", "#v > :big"}, + {"neg", "#v < :neg"}, + {"exp", "#v = :exp"}, + {"begins_with", "begins_with(#n, :p)"}, + {"begins_with_empty", "begins_with(#n, :empty)"}, + {"begins_with_num_path", "begins_with(#v, :lo)"}, + {"begins_with_missing_prefix", "begins_with(#n, :nope)"}, + {"begins_with_nested", "begins_with(#m.#l[0], :a)"}, + {"begins_with_literal_name", "begins_with(#n, :p) AND begins_with(name, :p)"}, + {"exists", "attribute_exists(#v)"}, + {"not_exists", "attribute_not_exists(#n)"}, + {"exists_nested", "attribute_exists(#m.#i)"}, + {"not_exists_nested", "attribute_not_exists(#m.#i)"}, + {"exists_index", "attribute_exists(#l[1]) AND attribute_not_exists(#l[5])"}, + {"nested_map_cmp", "#m.#i = :seven"}, + {"nested_list_cmp", "#l[0] = :num"}, + {"nested_deep_list", "#m.#l[1] > :num"}, + {"index_on_map", "#m[0] = :num"}, + {"key_on_list", "#l.x = :num"}, + {"unresolved_hash", "#lit = :seven OR #zz = :seven"}, + {"contains_set", "contains(#t, :a)"}, + {"contains_str", "contains(#n, :a)"}, + {"size_cmp", "size(#n) > :num"}, + {"size_missing", "size(#n) > :nope"}, + {"in_list", "#n IN (:x, :a)"}, + {"in_missing", "#n IN (:x, :nope)"}, + {"attribute_type", "attribute_type(#v, :x)"}, + {"fn_wrong_args", "begins_with(#n)"}, + {"bare_placeholder", ":t"}, + {"bare_path", "#v"}, + {"and_bare_placeholder", "#v > :lo AND :t"}, + {"size_left", "size(#n) = size(name)"}, } for _, tt := range tests { @@ -132,20 +198,24 @@ func TestEvaluator_MemoizeMatchesFresh(t *testing.T) { t.Parallel() node, err := expr.NewParser(expr.NewLexer(tt.expr)).ParseCondition() - require.NoError(t, err) + if err != nil { + t.Skipf("parse: %v", err) + } - memo := &expr.Evaluator{AttrNames: names, AttrValues: values} - memo.Memoize() + compiled := expr.CompileCondition(node, names, values) for pass := range 2 { for i, item := range items { fresh := &expr.Evaluator{Item: item, AttrNames: names, AttrValues: values} - want, wantErr := fresh.Evaluate(node) + res, wantErr := fresh.Evaluate(node) + want, _ := res.(bool) + if wantErr != nil { + want = false + } - memo.Item = item - got, gotErr := memo.Evaluate(node) + got, gotErr := compiled.Evaluate(item) - assert.Equal(t, wantErr, gotErr, "pass %d item %d", pass, i) + assert.Equal(t, wantErr != nil, gotErr != nil, "pass %d item %d err", pass, i) assert.Equal(t, want, got, "pass %d item %d", pass, i) } } diff --git a/services/dynamodb/expr/lexer.go b/services/dynamodb/expr/lexer.go index 899751d1db..1840b51e3e 100644 --- a/services/dynamodb/expr/lexer.go +++ b/services/dynamodb/expr/lexer.go @@ -284,11 +284,11 @@ func lookupFunctionKeyword(ident string) (TokenType, bool) { switch ident { case "size": return TokenSize, true - case "attribute_exists": + case funcAttributeExists: return TokenAttributeExists, true - case "attribute_not_exists": + case funcAttributeNotExists: return TokenAttributeNotExists, true - case "begins_with": + case funcBeginsWith: return TokenBeginsWith, true case "contains": return TokenContains, true diff --git a/services/dynamodb/expr/number_precision_test.go b/services/dynamodb/expr/number_precision_test.go new file mode 100644 index 0000000000..c7b69f2d58 --- /dev/null +++ b/services/dynamodb/expr/number_precision_test.go @@ -0,0 +1,66 @@ +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/dynamodb/expr" +) + +func TestNumberPrecision_Conditions(t *testing.T) { + t.Parallel() + + const ( + low = "12345678901234567890123456789012345677" + mid = "12345678901234567890123456789012345678" + high = "12345678901234567890123456789012345679" + ) + + tests := []struct { + name string + expr string + item string + a, b string + want bool + }{ + {name: "eq differs last digit", expr: "n = :a", item: mid, a: high, want: false}, + {name: "eq same", expr: "n = :a", item: mid, a: mid, want: true}, + {name: "ne", expr: "n <> :a", item: mid, a: high, want: true}, + {name: "lt", expr: "n < :a", item: mid, a: high, want: true}, + {name: "gt", expr: "n > :a", item: mid, a: low, want: true}, + {name: "le equal", expr: "n <= :a", item: mid, a: mid, want: true}, + {name: "ge lower", expr: "n >= :a", item: mid, a: high, want: false}, + {name: "between in", expr: "n BETWEEN :a AND :b", item: mid, a: mid, b: high, want: true}, + {name: "between out", expr: "n BETWEEN :a AND :b", item: low, a: mid, b: high, want: false}, + {name: "in miss", expr: "n IN (:a, :b)", item: mid, a: low, b: high, want: false}, + {name: "in hit", expr: "n IN (:a, :b)", item: mid, a: low, b: mid, want: true}, + {name: "exponent", expr: "n = :a", item: "1E+3", a: "1000", want: true}, + {name: "trailing zero", expr: "n = :a", item: "1.0", a: "1", want: true}, + {name: "negative order", expr: "n < :a", item: "-" + high, a: "-" + mid, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + node, err := expr.NewParser(expr.NewLexer(tt.expr)).ParseCondition() + require.NoError(t, err) + + values := map[string]any{ + ":a": map[string]any{"N": tt.a}, + ":b": map[string]any{"N": tt.b}, + } + item := map[string]any{"n": map[string]any{"N": tt.item}} + + got, err := (&expr.Evaluator{Item: item, AttrValues: values}).Evaluate(node) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + + compiled, err := expr.CompileCondition(node, nil, values).Evaluate(item) + require.NoError(t, err) + assert.Equal(t, tt.want, compiled) + }) + } +} diff --git a/services/dynamodb/expressions.go b/services/dynamodb/expressions.go index 0d228df33b..feca0460c5 100644 --- a/services/dynamodb/expressions.go +++ b/services/dynamodb/expressions.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" "github.com/blackbirdworks/gopherstack/services/dynamodb/expr" "github.com/blackbirdworks/gopherstack/services/dynamodb/models" ) @@ -146,6 +147,9 @@ func updateExpressionErrorMessage(err error) string { return "The document path provided in the update expression is invalid for update" case errors.Is(err, expr.ErrOperandIncorrectType): return "An operand in the update expression has an incorrect data type" + case errors.Is(err, dynamonum.ErrOverflow), errors.Is(err, dynamonum.ErrUnderflow), + errors.Is(err, dynamonum.ErrTooManyDigits): + return err.Error() case errors.Is(err, expr.ErrOverlappingDocumentPaths): return "Two document paths overlap with each other; must remove or rewrite one of these paths" default: @@ -282,10 +286,10 @@ func (c *ParsedCondition) Evaluate( } // BoundCondition is a ParsedCondition bound to one request's expression attribute maps, -// resolving each :value placeholder once across many items. Not safe for concurrent use. +// resolving placeholders and attribute names once across many items. Not safe for +// concurrent use. type BoundCondition struct { - eval *expr.Evaluator - node expr.Node + compiled *expr.CompiledCondition } // Bind fixes attrValues and attrNames for repeated Evaluate calls over many items. @@ -294,28 +298,18 @@ func (c *ParsedCondition) Bind(attrValues map[string]any, attrNames map[string]s return &BoundCondition{} } - eval := &expr.Evaluator{AttrNames: attrNames, AttrValues: attrValues} - eval.Memoize() - - return &BoundCondition{eval: eval, node: c.node} + return &BoundCondition{compiled: expr.CompileCondition(c.node, attrNames, attrValues)} } // Evaluate reports whether item satisfies the condition; a zero condition matches everything. func (b *BoundCondition) Evaluate(item map[string]any) bool { - if b.node == nil { + if b.compiled == nil { return true } - b.eval.Item = item - - result, err := b.eval.Evaluate(b.node) - if err != nil { - return false - } - - matched, ok := result.(bool) + matched, err := b.compiled.Evaluate(item) - return ok && matched + return err == nil && matched } // validateEAVTypes checks that expression attribute values are structurally valid diff --git a/services/dynamodb/handler.go b/services/dynamodb/handler.go index c5b0901568..e4e250cea3 100644 --- a/services/dynamodb/handler.go +++ b/services/dynamodb/handler.go @@ -16,6 +16,7 @@ import ( "github.com/labstack/echo/v5" "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/chaos" "github.com/blackbirdworks/gopherstack/pkgs/config" "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -124,6 +125,13 @@ func NewHandler(backend StorageBackend) *DynamoDBHandler { return h } +// SetChaosFaultStore wires the chaos store whose dynamodb:transaction-conflict rules the backend honours. +func (h *DynamoDBHandler) SetChaosFaultStore(store *chaos.FaultStore) { + if db, ok := h.Backend.(*InMemoryDB); ok { + db.SetFaultStore(store) + } +} + // WithJanitor attaches a background janitor to the handler. // The optional janitorTimeout parameter bounds each individual janitor task; // zero (or omitted) disables per-task timeouts. diff --git a/services/dynamodb/iam_enforcement_test.go b/services/dynamodb/iam_enforcement_test.go index a5b1d98743..7a748b2c2c 100644 --- a/services/dynamodb/iam_enforcement_test.go +++ b/services/dynamodb/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/dynamodb" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -173,7 +174,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/dynamodb/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/dynamodb/item_ops.go b/services/dynamodb/item_ops.go index 2470648f89..a04888771a 100644 --- a/services/dynamodb/item_ops.go +++ b/services/dynamodb/item_ops.go @@ -291,6 +291,8 @@ func compareTypedField(typeKey string, leftVal, rightVal any) bool { return compareStringSetField(leftVal, rightVal) case "BS": return compareByteSetField(leftVal, rightVal) + case "N": + return compareNumberField(leftVal, rightVal) default: return compareScalarField(leftVal, rightVal) } @@ -357,6 +359,17 @@ func compareStringSetField(leftVal, rightVal any) bool { return true } +func compareNumberField(leftVal, rightVal any) bool { + l, okL := dynamoattr.ParseNumeric(leftVal) + r, okR := dynamoattr.ParseNumeric(rightVal) + + if okL && okR { + return l.Cmp(r) == 0 + } + + return compareScalarField(leftVal, rightVal) +} + func compareByteSetField(leftVal, rightVal any) bool { b1, ok1 := leftVal.([][]byte) b2, ok2 := rightVal.([][]byte) @@ -487,14 +500,8 @@ func fallbackCompare(v1, v2 any) int { func compareNumbers(v1, v2 any) int { f1, _ := dynamoattr.ParseNumeric(v1) f2, _ := dynamoattr.ParseNumeric(v2) - if f1 < f2 { - return -1 - } - if f1 > f2 { - return 1 - } - return 0 + return f1.Cmp(f2) } func compareStrings(v1, v2 any) int { @@ -761,7 +768,7 @@ func compareSortKeyValues(a, b any, typ string) int { an, _ := dynamoattr.ParseNumeric(a) bn, _ := dynamoattr.ParseNumeric(b) - return cmp.Compare(an, bn) + return an.Cmp(bn) } return cmp.Compare(dynamoattr.ToString(a), dynamoattr.ToString(b)) diff --git a/services/dynamodb/item_ops_batch.go b/services/dynamodb/item_ops_batch.go index bb9c8a6d22..1fe1409282 100644 --- a/services/dynamodb/item_ops_batch.go +++ b/services/dynamodb/item_ops_batch.go @@ -512,19 +512,20 @@ func (db *InMemoryDB) batchWriteItemOp( return nil, err } + unprocessedItems := make(map[string][]types.WriteRequest) + + if db.txn.live.Load() > 0 || db.chaosActive() { + db.dropConflictingBatchWrites(region, input, tables, wireByTable, unprocessedItems) + } + // Split requests per table by size limit before processing. toProcess := make(map[string][]wireWriteRequest, len(wireByTable)) - unprocessedItems := make(map[string][]types.WriteRequest) for tableName, requests := range wireByTable { process, unprocessed := splitWriteRequestsBySize(requests, batchWriteResponseLimit) toProcess[tableName] = process - if len(unprocessed) > 0 { - unprocessedReqs := make([]types.WriteRequest, len(unprocessed)) - for i, wwr := range unprocessed { - unprocessedReqs[i] = wwr.req - } - unprocessedItems[tableName] = unprocessedReqs + for _, wwr := range unprocessed { + unprocessedItems[tableName] = append(unprocessedItems[tableName], wwr.req) } } @@ -553,6 +554,36 @@ func (db *InMemoryDB) batchWriteItemOp( }, nil } +// dropConflictingBatchWrites moves writes that overlap an in-flight transaction out of the batch and into +// unprocessedItems: BatchWriteItem's API reference lists no TransactionConflictException, and each +// write in a batch is isolated individually (Isolation levels for DynamoDB transactions). +func (db *InMemoryDB) dropConflictingBatchWrites( + region string, + input *dynamodb.BatchWriteItemInput, + tables map[string]*Table, + wireByTable map[string][]wireWriteRequest, + unprocessedItems map[string][]types.WriteRequest, +) { + for tableName, requests := range input.RequestItems { + kept, conflicted := db.filterBatchConflicts(region, tableName, tables[tableName], requests) + if len(conflicted) == 0 { + continue + } + + db.emitTransactionConflict(region, tableName, len(conflicted)) + unprocessedItems[tableName] = append(unprocessedItems[tableName], conflicted...) + + if len(kept) == 0 { + delete(wireByTable, tableName) + delete(tables, tableName) + + continue + } + + wireByTable[tableName] = toWireWriteRequests(kept) + } +} + type batchAppliedWrites struct { metrics map[string][]types.ItemCollectionMetrics gsiWCU map[string]map[string]float64 diff --git a/services/dynamodb/item_ops_crud.go b/services/dynamodb/item_ops_crud.go index 50d49a4338..2fa84ada5f 100644 --- a/services/dynamodb/item_ops_crud.go +++ b/services/dynamodb/item_ops_crud.go @@ -53,6 +53,10 @@ func (db *InMemoryDB) putItemOp( return nil, err } + if conflictErr := db.checkSingleWrite(ctx, opPutItem, tableName, table, input.Item); conflictErr != nil { + return nil, conflictErr + } + wire := putWire{item: wireItem, eav: wireEAV} out, globalTableName, region, putErr := db.putItemLocked(ctx, tableName, table, input, wire, itemSize) if putErr != nil { @@ -604,6 +608,10 @@ func (db *InMemoryDB) deleteItemOp( return nil, err } + if conflictErr := db.checkSingleWrite(ctx, opDeleteItem, tableName, table, input.Key); conflictErr != nil { + return nil, conflictErr + } + wireKey := models.FromSDKItem(input.Key) out, globalTableName, region, oldItem, delErr := db.deleteItemLocked(ctx, tableName, table, input, wireKey, wireEAV) @@ -852,6 +860,10 @@ func (db *InMemoryDB) updateItemOp( return nil, err } + if conflictErr := db.checkSingleWrite(ctx, opUpdateItem, tableName, table, input.Key); conflictErr != nil { + return nil, conflictErr + } + wireKey := models.FromSDKItem(input.Key) out, globalTableName, region, updated, outErr := db.updateItemLocked(ctx, tableName, table, input, wireKey, wireEAV) diff --git a/services/dynamodb/item_ops_query.go b/services/dynamodb/item_ops_query.go index 35d4d9cfa3..ebae7c1c01 100644 --- a/services/dynamodb/item_ops_query.go +++ b/services/dynamodb/item_ops_query.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/blackbirdworks/gopherstack/pkgs/dynamoattr" + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/services/dynamodb/models" @@ -546,7 +547,7 @@ func (db *InMemoryDB) filterCandidatesScan( type querySortEntry struct { item map[string]any skStr string - skNum float64 + skNum dynamonum.Num } func (db *InMemoryDB) sortCandidates( @@ -584,7 +585,7 @@ func (db *InMemoryDB) sortCandidates( slices.SortFunc(entries, func(a, b querySortEntry) int { var res int if skType == "N" { - res = cmp.Compare(a.skNum, b.skNum) + res = a.skNum.Cmp(b.skNum) } else { res = cmp.Compare(a.skStr, b.skStr) } diff --git a/services/dynamodb/item_ops_scan.go b/services/dynamodb/item_ops_scan.go index 978774349b..65a79ecf25 100644 --- a/services/dynamodb/item_ops_scan.go +++ b/services/dynamodb/item_ops_scan.go @@ -7,6 +7,7 @@ import ( "slices" "github.com/blackbirdworks/gopherstack/pkgs/dynamoattr" + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/services/dynamodb/models" @@ -287,7 +288,7 @@ func (t *Table) storeScanOrder( type scanKey struct { pk, sk string - pkN, skN float64 + pkN, skN dynamonum.Num } type scanTouch struct { @@ -738,8 +739,8 @@ type scanSortEntry struct { item map[string]any pkStr string skStr string - pkNum float64 - skNum float64 + pkNum dynamonum.Num + skNum dynamonum.Num size int } @@ -774,7 +775,7 @@ func populateScanSortEntry( func compareScanSortEntries(a, b scanSortEntry, pkType, skType string, hasSK bool) int { var pkRes int if pkType == "N" { - pkRes = cmp.Compare(a.pkNum, b.pkNum) + pkRes = a.pkNum.Cmp(b.pkNum) } else { pkRes = cmp.Compare(a.pkStr, b.pkStr) } @@ -784,7 +785,7 @@ func compareScanSortEntries(a, b scanSortEntry, pkType, skType string, hasSK boo if hasSK { if skType == "N" { - return cmp.Compare(a.skNum, b.skNum) + return a.skNum.Cmp(b.skNum) } return cmp.Compare(a.skStr, b.skStr) diff --git a/services/dynamodb/models/convert_attrs.go b/services/dynamodb/models/convert_attrs.go index 4843a12122..a8384dee3c 100644 --- a/services/dynamodb/models/convert_attrs.go +++ b/services/dynamodb/models/convert_attrs.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" "github.com/blackbirdworks/gopherstack/pkgs/ptrconv" "github.com/aws/aws-sdk-go-v2/aws" @@ -110,7 +111,28 @@ func convertNumberSetType(val any) (types.AttributeValue, error) { return nil, err } - return &types.AttributeValueMemberNS{Value: ns}, nil + return &types.AttributeValueMemberNS{Value: canonicalNumbers(ns)}, nil +} + +// canonicalNumbers normalises each number, copying only when one changes. +func canonicalNumbers(ns []string) []string { + out := ns + + for i, n := range ns { + c := dynamonum.Canonical(n) + if c == n { + continue + } + + if &out[0] == &ns[0] { + out = make([]string, len(ns)) + copy(out, ns) + } + + out[i] = c + } + + return out } func convertBinarySetType(val any) (types.AttributeValue, error) { @@ -166,7 +188,7 @@ func ToSDKAttributeValue(v any) (types.AttributeValue, error) { return nil, fmt.Errorf("%w, got %T", ErrInvalidTypeN, val) } - return &types.AttributeValueMemberN{Value: s}, nil + return &types.AttributeValueMemberN{Value: dynamonum.Canonical(s)}, nil case "BOOL": bVal, matched := val.(bool) if !matched { diff --git a/services/dynamodb/number_precision_sdk_test.go b/services/dynamodb/number_precision_sdk_test.go new file mode 100644 index 0000000000..53c03e433a --- /dev/null +++ b/services/dynamodb/number_precision_sdk_test.go @@ -0,0 +1,407 @@ +package dynamodb_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + dynamodbsdk "github.com/aws/aws-sdk-go-v2/service/dynamodb" + dynamodbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/dynamodb" +) + +const ( + num38Low = "12345678901234567890123456789012345677" + num38Mid = "12345678901234567890123456789012345678" + num38High = "12345678901234567890123456789012345679" + num38Max = "99999999999999999999999999999999999999" +) + +func newNumberTable(t *testing.T) *dynamodbsdk.Client { + t.Helper() + + client := newTestDynamoDBClient(t, dynamodb.NewHandler(dynamodb.NewInMemoryDB())) + + _, err := client.CreateTable(t.Context(), &dynamodbsdk.CreateTableInput{ + TableName: aws.String("nums"), + KeySchema: []dynamodbtypes.KeySchemaElement{ + {AttributeName: aws.String("pk"), KeyType: dynamodbtypes.KeyTypeHash}, + {AttributeName: aws.String("sk"), KeyType: dynamodbtypes.KeyTypeRange}, + }, + AttributeDefinitions: []dynamodbtypes.AttributeDefinition{ + {AttributeName: aws.String("pk"), AttributeType: dynamodbtypes.ScalarAttributeTypeS}, + {AttributeName: aws.String("sk"), AttributeType: dynamodbtypes.ScalarAttributeTypeN}, + }, + BillingMode: dynamodbtypes.BillingModePayPerRequest, + }) + require.NoError(t, err) + + for _, sk := range []string{num38Mid, num38High, num38Low} { + putNum(t, client, "p", sk, sk) + } + + return client +} + +func putNum(t *testing.T, c *dynamodbsdk.Client, pk, sk, n string) { + t.Helper() + + _, err := c.PutItem(t.Context(), &dynamodbsdk.PutItemInput{ + TableName: aws.String("nums"), + Item: map[string]dynamodbtypes.AttributeValue{ + "pk": &dynamodbtypes.AttributeValueMemberS{Value: pk}, + "sk": &dynamodbtypes.AttributeValueMemberN{Value: sk}, + "n": &dynamodbtypes.AttributeValueMemberN{Value: n}, + }, + }) + require.NoError(t, err) +} + +func numVals(vals ...string) map[string]dynamodbtypes.AttributeValue { + out := map[string]dynamodbtypes.AttributeValue{":p": &dynamodbtypes.AttributeValueMemberS{Value: "p"}} + for i, v := range vals { + out[":"+string(rune('a'+i))] = &dynamodbtypes.AttributeValueMemberN{Value: v} + } + + return out +} + +func nVals(v string) map[string]dynamodbtypes.AttributeValue { + return map[string]dynamodbtypes.AttributeValue{":a": &dynamodbtypes.AttributeValueMemberN{Value: v}} +} + +func skList(items []map[string]dynamodbtypes.AttributeValue) []string { + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, it["sk"].(*dynamodbtypes.AttributeValueMemberN).Value) + } + + return out +} + +func TestNumberPrecision_KeyConditionAndOrder(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + keyCond string + vals []string + want []string + reverse bool + }{ + {name: "eq", keyCond: "pk = :p AND sk = :a", vals: []string{num38Mid}, want: []string{num38Mid}}, + {name: "gt", keyCond: "pk = :p AND sk > :a", vals: []string{num38Mid}, want: []string{num38High}}, + {name: "ge", keyCond: "pk = :p AND sk >= :a", vals: []string{num38Mid}, want: []string{num38Mid, num38High}}, + {name: "lt", keyCond: "pk = :p AND sk < :a", vals: []string{num38Mid}, want: []string{num38Low}}, + {name: "le", keyCond: "pk = :p AND sk <= :a", vals: []string{num38Mid}, want: []string{num38Low, num38Mid}}, + { + name: "between", keyCond: "pk = :p AND sk BETWEEN :a AND :b", vals: []string{num38Mid, num38High}, + want: []string{num38Mid, num38High}, + }, + {name: "all asc", keyCond: "pk = :p", want: []string{num38Low, num38Mid, num38High}}, + { + name: "all desc", keyCond: "pk = :p", reverse: true, + want: []string{num38High, num38Mid, num38Low}, + }, + { + name: "exponent form", + keyCond: "pk = :p AND sk = :a", + vals: []string{"1.2345678901234567890123456789012345678E+37"}, + want: []string{num38Mid}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + out, err := client.Query(t.Context(), &dynamodbsdk.QueryInput{ + TableName: aws.String("nums"), + KeyConditionExpression: aws.String(tt.keyCond), + ExpressionAttributeValues: numVals(tt.vals...), + ScanIndexForward: aws.Bool(!tt.reverse), + }) + require.NoError(t, err) + assert.Equal(t, tt.want, skList(out.Items)) + }) + } +} + +func TestNumberPrecision_QueryPagination(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + + var ( + got []string + start map[string]dynamodbtypes.AttributeValue + ) + + for range 5 { + out, err := client.Query(t.Context(), &dynamodbsdk.QueryInput{ + TableName: aws.String("nums"), + KeyConditionExpression: aws.String("pk = :p"), + ExpressionAttributeValues: numVals(), + Limit: aws.Int32(1), + ExclusiveStartKey: start, + }) + require.NoError(t, err) + + got = append(got, skList(out.Items)...) + + if start = out.LastEvaluatedKey; start == nil { + break + } + } + + assert.Equal(t, []string{num38Low, num38Mid, num38High}, got) +} + +func TestNumberPrecision_FilterAndCondition(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filter string + vals []string + want []string + }{ + {name: "eq", filter: "n = :a", vals: []string{num38Mid}, want: []string{num38Mid}}, + {name: "ne", filter: "n <> :a", vals: []string{num38Mid}, want: []string{num38Low, num38High}}, + {name: "gt", filter: "n > :a", vals: []string{num38Mid}, want: []string{num38High}}, + {name: "lt", filter: "n < :a", vals: []string{num38Mid}, want: []string{num38Low}}, + { + name: "between", + filter: "n BETWEEN :a AND :b", + vals: []string{num38Mid, num38High}, + want: []string{num38Mid, num38High}, + }, + {name: "in", filter: "n IN (:a, :b)", vals: []string{num38Low, num38High}, want: []string{num38Low, num38High}}, + { + name: "exponent eq", + filter: "n = :a", + vals: []string{"1.2345678901234567890123456789012345678E+37"}, + want: []string{num38Mid}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + out, err := client.Scan(t.Context(), &dynamodbsdk.ScanInput{ + TableName: aws.String("nums"), + FilterExpression: aws.String(tt.filter), + ExpressionAttributeValues: numVals(tt.vals...), + }) + require.NoError(t, err) + + got := skList(out.Items) + assert.ElementsMatch(t, tt.want, got) + }) + } + + t.Run("condition", func(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + key := map[string]dynamodbtypes.AttributeValue{ + "pk": &dynamodbtypes.AttributeValueMemberS{Value: "p"}, + "sk": &dynamodbtypes.AttributeValueMemberN{Value: num38Mid}, + } + _, err := client.DeleteItem(t.Context(), &dynamodbsdk.DeleteItemInput{ + TableName: aws.String("nums"), + Key: key, + ConditionExpression: aws.String("n = :a"), + ExpressionAttributeValues: nVals(num38High), + }) + var ccf *dynamodbtypes.ConditionalCheckFailedException + require.ErrorAs(t, err, &ccf) + + _, err = client.DeleteItem(t.Context(), &dynamodbsdk.DeleteItemInput{ + TableName: aws.String("nums"), + Key: key, + ConditionExpression: aws.String("n = :a"), + ExpressionAttributeValues: nVals(num38Mid), + }) + require.NoError(t, err) + }) +} + +func TestNumberPrecision_Normalisation(t *testing.T) { + t.Parallel() + + tests := []struct{ in, want string }{ + {"1.0", "1"}, {"1.10", "1.1"}, {"1E+3", "1000"}, {"-0", "0"}, {"0.50", "0.5"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + putNum(t, client, "q", "1", tt.in) + + out, err := client.GetItem(t.Context(), &dynamodbsdk.GetItemInput{ + TableName: aws.String("nums"), + Key: map[string]dynamodbtypes.AttributeValue{ + "pk": &dynamodbtypes.AttributeValueMemberS{Value: "q"}, + "sk": &dynamodbtypes.AttributeValueMemberN{Value: "1.0"}, + }, + }) + require.NoError(t, err) + require.NotNil(t, out.Item) + assert.Equal(t, tt.want, out.Item["n"].(*dynamodbtypes.AttributeValueMemberN).Value) + }) + } +} + +func TestNumberPrecision_InvalidNumbers(t *testing.T) { + t.Parallel() + + tests := []struct{ name, in, msg string }{ + {"too many digits", "123456789012345678901234567890123456789", "38 significant digits"}, + {"overflow", "1E+126", "overflow"}, + {"underflow", "1E-131", "underflow"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + _, err := client.PutItem(t.Context(), &dynamodbsdk.PutItemInput{ + TableName: aws.String("nums"), + Item: map[string]dynamodbtypes.AttributeValue{ + "pk": &dynamodbtypes.AttributeValueMemberS{Value: "q"}, + "sk": &dynamodbtypes.AttributeValueMemberN{Value: "1"}, + "n": &dynamodbtypes.AttributeValueMemberN{Value: tt.in}, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "ValidationException") + assert.Contains(t, err.Error(), tt.msg) + }) + } +} + +func TestNumberPrecision_UpdateArithmetic(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + start string + update string + operand string + want string + errMsg string + }{ + {name: "set add", start: num38Mid, update: "SET n = n + :a", operand: "1", want: num38High}, + {name: "set sub", start: num38Mid, update: "SET n = n - :a", operand: "1", want: num38Low}, + {name: "add action", start: num38Mid, update: "ADD n :a", operand: "1", want: num38High}, + {name: "decimal exact", start: "0.1", update: "SET n = n + :a", operand: "0.2", want: "0.3"}, + {name: "cancel to zero", start: "1.5", update: "SET n = n - :a", operand: "1.5", want: "0"}, + {name: "overflow", start: "5E+125", update: "ADD n :a", operand: "5E+125", errMsg: "overflow"}, + {name: "digit limit", start: num38Max, update: "ADD n :a", operand: "0.5", errMsg: "38 significant digits"}, + {name: "underflow", start: "1.5E-130", update: "SET n = n - :a", operand: "1E-130", errMsg: "underflow"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + putNum(t, client, "u", "1", tt.start) + + _, err := client.UpdateItem(t.Context(), &dynamodbsdk.UpdateItemInput{ + TableName: aws.String("nums"), + Key: map[string]dynamodbtypes.AttributeValue{ + "pk": &dynamodbtypes.AttributeValueMemberS{Value: "u"}, + "sk": &dynamodbtypes.AttributeValueMemberN{Value: "1"}, + }, + UpdateExpression: aws.String(tt.update), + ExpressionAttributeValues: map[string]dynamodbtypes.AttributeValue{ + ":a": &dynamodbtypes.AttributeValueMemberN{Value: tt.operand}, + }, + }) + + if tt.errMsg != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + + return + } + + require.NoError(t, err) + + out, err := client.GetItem(t.Context(), &dynamodbsdk.GetItemInput{ + TableName: aws.String("nums"), + Key: map[string]dynamodbtypes.AttributeValue{ + "pk": &dynamodbtypes.AttributeValueMemberS{Value: "u"}, + "sk": &dynamodbtypes.AttributeValueMemberN{Value: "1"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, tt.want, out.Item["n"].(*dynamodbtypes.AttributeValueMemberN).Value) + }) + } +} + +func TestNumberPrecision_NumberSets(t *testing.T) { + t.Parallel() + + client := newNumberTable(t) + key := map[string]dynamodbtypes.AttributeValue{ + "pk": &dynamodbtypes.AttributeValueMemberS{Value: "s"}, + "sk": &dynamodbtypes.AttributeValueMemberN{Value: "1"}, + } + + _, err := client.PutItem(t.Context(), &dynamodbsdk.PutItemInput{ + TableName: aws.String("nums"), + Item: map[string]dynamodbtypes.AttributeValue{ + "pk": key["pk"], "sk": key["sk"], + "ns": &dynamodbtypes.AttributeValueMemberNS{Value: []string{"1", "1.0"}}, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicates") + + _, err = client.PutItem(t.Context(), &dynamodbsdk.PutItemInput{ + TableName: aws.String("nums"), + Item: map[string]dynamodbtypes.AttributeValue{ + "pk": key["pk"], "sk": key["sk"], + "ns": &dynamodbtypes.AttributeValueMemberNS{Value: []string{"1", "2"}}, + }, + }) + require.NoError(t, err) + + _, err = client.UpdateItem(t.Context(), &dynamodbsdk.UpdateItemInput{ + TableName: aws.String("nums"), Key: key, + UpdateExpression: aws.String("ADD ns :a"), + ExpressionAttributeValues: map[string]dynamodbtypes.AttributeValue{ + ":a": &dynamodbtypes.AttributeValueMemberNS{Value: []string{"1.0", "3"}}, + }, + }) + require.NoError(t, err) + + out, err := client.GetItem(t.Context(), &dynamodbsdk.GetItemInput{TableName: aws.String("nums"), Key: key}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"1", "2", "3"}, out.Item["ns"].(*dynamodbtypes.AttributeValueMemberNS).Value) + + _, err = client.UpdateItem(t.Context(), &dynamodbsdk.UpdateItemInput{ + TableName: aws.String("nums"), Key: key, + UpdateExpression: aws.String("DELETE ns :a"), + ExpressionAttributeValues: map[string]dynamodbtypes.AttributeValue{ + ":a": &dynamodbtypes.AttributeValueMemberNS{Value: []string{"2.00"}}, + }, + }) + require.NoError(t, err) + + out, err = client.GetItem(t.Context(), &dynamodbsdk.GetItemInput{TableName: aws.String("nums"), Key: key}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"1", "3"}, out.Item["ns"].(*dynamodbtypes.AttributeValueMemberNS).Value) +} diff --git a/services/dynamodb/store.go b/services/dynamodb/store.go index 75152ec8ad..d9e3dd90f7 100644 --- a/services/dynamodb/store.go +++ b/services/dynamodb/store.go @@ -226,6 +226,9 @@ type InMemoryDB struct { keyInaccessibleSince map[string]time.Time exports *store.Table[storedExport] throttler *Throttler + txn *txnState + txnHook func(txnHookEvent) + clock func() time.Time backups *store.Table[Backup] iteratorStore *ShardIteratorStore mu *lockmetrics.RWMutex @@ -389,6 +392,7 @@ func NewInMemoryDB() *InMemoryDB { accountID: config.DefaultAccountID, mu: lockmetrics.New("ddb"), throttler: NewThrottler(false), + txn: newTxnState(), } registerAllTables(db) @@ -995,6 +999,7 @@ func (db *InMemoryDB) Reset() { db.registry.ResetAll() db.txnTokens = make(map[string]txnTokenRecord) db.txnPending = make(map[string]time.Time) + db.txn.reset() db.fisReplicationPaused = make(map[string]time.Time) db.iteratorStore = NewShardIteratorStore() if db.exprCache != nil { diff --git a/services/dynamodb/transact_ops.go b/services/dynamodb/transact_ops.go index 0e5e2ccac3..cc2b4512e6 100644 --- a/services/dynamodb/transact_ops.go +++ b/services/dynamodb/transact_ops.go @@ -51,6 +51,14 @@ func (db *InMemoryDB) transactWriteItemsOp( } defer cleanupToken() + txn, conflictErr := db.beginTransactWrite(ctx, input.TransactItems) + if conflictErr != nil { + return nil, conflictErr + } + defer txn.release() + + db.runTxnHook(opTransactWriteItems, 0) + tableNames := db.transactTableNames(input.TransactItems) region := getRegionFromContext(ctx, db) @@ -680,6 +688,14 @@ func (db *InMemoryDB) transactGetItemsOp( } sort.Strings(tableNames) + txn, conflictErr := db.beginTransactRead(ctx, input.TransactItems, tableNames) + if conflictErr != nil { + return nil, conflictErr + } + defer txn.release() + + db.runTxnHook(opTransactGetItems, 0) + tables, lockErr := db.lockTablesRead(ctx, tableNames) if lockErr != nil { return nil, lockErr diff --git a/services/dynamodb/txn_conflict_whitebox_test.go b/services/dynamodb/txn_conflict_whitebox_test.go new file mode 100644 index 0000000000..55d45b2603 --- /dev/null +++ b/services/dynamodb/txn_conflict_whitebox_test.go @@ -0,0 +1,948 @@ +package dynamodb + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/ratelimit" + "github.com/aws/aws-sdk-go-v2/aws/retry" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + sdk "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/cwmetric" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" +) + +type conflictMetrics struct { + counts map[string]int + mu sync.Mutex +} + +func (r *conflictMetrics) EmitMetric(p cwmetric.Point) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.counts == nil { + r.counts = map[string]int{} + } + + table := "" + for _, d := range p.Dimensions { + if d.Name == "TableName" { + table = d.Value + } + } + + r.counts[p.Name+"/"+table]++ + + return nil +} + +func (r *conflictMetrics) conflicts() int { + r.mu.Lock() + defer r.mu.Unlock() + + return r.counts["TransactionConflict/tbl"] +} + +type countingTransport struct { + next http.RoundTripper + count *atomic.Int64 +} + +func (c countingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + c.count.Add(1) + + return c.next.RoundTrip(r) +} + +type conflictFixture struct { + db *InMemoryDB + client *sdk.Client + metrics *conflictMetrics + requests *atomic.Int64 + tb testing.TB +} + +func s(v string) types.AttributeValue { return &types.AttributeValueMemberS{Value: v} } + +func key(pk string) map[string]types.AttributeValue { + return map[string]types.AttributeValue{"pk": s(pk)} +} + +func item(pk, v string) map[string]types.AttributeValue { + return map[string]types.AttributeValue{"pk": s(pk), "v": s(v)} +} + +func newConflictFixture(t *testing.T, retryer func() aws.Retryer) *conflictFixture { + t.Helper() + + db := NewInMemoryDB() + db.SetDefaultRegion("us-east-1") + + f := &conflictFixture{tb: t, db: db, metrics: &conflictMetrics{}, requests: &atomic.Int64{}} + db.SetMetricEmitter(f.metrics) + + e := echo.New() + registry := service.NewRegistry() + require.NoError(t, registry.Register(NewHandler(db))) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + hc := testclient.New(t) + hc.Transport = countingTransport{next: hc.Transport, count: f.requests} + + cfg, err := awscfg.LoadDefaultConfig( + t.Context(), + awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + awscfg.WithHTTPClient(hc), + ) + require.NoError(t, err) + + f.client = sdk.NewFromConfig(cfg, func(o *sdk.Options) { + o.BaseEndpoint = aws.String(srv.URL) + o.Retryer = retryer() + }) + + for _, name := range []string{"tbl", "other"} { + _, err = f.client.CreateTable(t.Context(), &sdk.CreateTableInput{ + TableName: aws.String(name), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, + }, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, + }, + BillingMode: types.BillingModePayPerRequest, + }) + require.NoError(t, err) + } + + for _, pk := range []string{"a", "b"} { + _, err = f.client.PutItem(t.Context(), &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item(pk, "old")}) + require.NoError(t, err) + } + + return f +} + +func noRetry() aws.Retryer { return aws.NopRetryer{} } + +// gate parks the first matching in-flight transaction until release is closed. +type gate struct { + entered chan struct{} + release chan struct{} + taken atomic.Bool +} + +func (f *conflictFixture) hold(op string, step int) *gate { + g := &gate{entered: make(chan struct{}), release: make(chan struct{})} + f.db.txnHook = func(ev txnHookEvent) { + if ev.Op == op && ev.Step == step && g.taken.CompareAndSwap(false, true) { + close(g.entered) + <-g.release + } + } + + return g +} + +func (f *conflictFixture) twi(items ...types.TransactWriteItem) *sdk.TransactWriteItemsInput { + return &sdk.TransactWriteItemsInput{TransactItems: items} +} + +func putAction(pk, v string) types.TransactWriteItem { + return types.TransactWriteItem{Put: &types.Put{TableName: aws.String("tbl"), Item: item(pk, v)}} +} + +func tgi(pks ...string) *sdk.TransactGetItemsInput { + in := &sdk.TransactGetItemsInput{} + for _, pk := range pks { + in.TransactItems = append(in.TransactItems, types.TransactGetItem{ + Get: &types.Get{TableName: aws.String("tbl"), Key: key(pk)}, + }) + } + + return in +} + +func reasonCodes(t *testing.T, err error) []string { + t.Helper() + + var canceled *types.TransactionCanceledException + require.ErrorAs(t, err, &canceled) + + codes := make([]string, len(canceled.CancellationReasons)) + for i, r := range canceled.CancellationReasons { + codes[i] = aws.ToString(r.Code) + } + + return codes +} + +func TestTransactionConflict_InFlightWrite(t *testing.T) { + t.Parallel() + + tests := []struct { + probe func(t *testing.T, f *conflictFixture) + name string + }{ + { + name: "put on held item is rejected", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.PutItem( + t.Context(), + &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("a", "x")}, + ) + var conflict *types.TransactionConflictException + require.ErrorAs(t, err, &conflict) + assert.Equal(t, 1, f.metrics.conflicts()) + }, + }, + { + name: "update on held item is rejected", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.UpdateItem(t.Context(), &sdk.UpdateItemInput{ + TableName: aws.String("tbl"), + Key: key("a"), + UpdateExpression: aws.String("SET v = :v"), + ExpressionAttributeValues: map[string]types.AttributeValue{":v": s("x")}, + }) + var conflict *types.TransactionConflictException + require.ErrorAs(t, err, &conflict) + }, + }, + { + name: "delete on held item is rejected", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.DeleteItem( + t.Context(), + &sdk.DeleteItemInput{TableName: aws.String("tbl"), Key: key("a")}, + ) + var conflict *types.TransactionConflictException + require.ErrorAs(t, err, &conflict) + }, + }, + { + name: "put on another item succeeds", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.PutItem( + t.Context(), + &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("b", "x")}, + ) + require.NoError(t, err) + assert.Equal(t, 0, f.metrics.conflicts()) + }, + }, + { + name: "same key in another table succeeds", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.PutItem( + t.Context(), + &sdk.PutItemInput{TableName: aws.String("other"), Item: item("a", "x")}, + ) + require.NoError(t, err) + }, + }, + { + name: "overlapping write transaction is canceled per item", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.TransactWriteItems( + t.Context(), + f.twi(putAction("b", "x"), putAction("a", "x")), + ) + assert.Equal(t, []string{"None", "TransactionConflict"}, reasonCodes(t, err)) + assert.Contains(t, err.Error(), "[None, TransactionConflict]") + assert.Equal(t, 1, f.metrics.conflicts()) + + got, getErr := f.client.GetItem( + t.Context(), + &sdk.GetItemInput{TableName: aws.String("tbl"), Key: key("b")}, + ) + require.NoError(t, getErr) + assert.Equal( + t, + "old", + got.Item["v"].(*types.AttributeValueMemberS).Value, + "canceled transaction must not write", + ) + }, + }, + { + name: "disjoint write transaction succeeds", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.TransactWriteItems(t.Context(), f.twi(putAction("b", "x"))) + require.NoError(t, err) + }, + }, + { + name: "transact get is canceled", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.TransactGetItems(t.Context(), tgi("a", "b")) + assert.Equal(t, []string{"TransactionConflict", "None"}, reasonCodes(t, err)) + }, + }, + { + name: "batch write returns the conflicting write as unprocessed", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + out, err := f.client.BatchWriteItem(t.Context(), &sdk.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{"tbl": { + {PutRequest: &types.PutRequest{Item: item("a", "x")}}, + {PutRequest: &types.PutRequest{Item: item("b", "x")}}, + }}, + }) + require.NoError(t, err) + require.Len(t, out.UnprocessedItems["tbl"], 1) + assert.Equal( + t, + "a", + out.UnprocessedItems["tbl"][0].PutRequest.Item["pk"].(*types.AttributeValueMemberS).Value, + ) + + got, getErr := f.client.GetItem( + t.Context(), + &sdk.GetItemInput{TableName: aws.String("tbl"), Key: key("b")}, + ) + require.NoError(t, getErr) + assert.Equal(t, "x", got.Item["v"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, 1, f.metrics.conflicts()) + }, + }, + { + name: "get item reads committed state and is not blocked", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + got, err := f.client.GetItem( + t.Context(), + &sdk.GetItemInput{TableName: aws.String("tbl"), Key: key("a"), ConsistentRead: aws.Bool(true)}, + ) + require.NoError(t, err) + assert.Equal(t, "old", got.Item["v"].(*types.AttributeValueMemberS).Value) + }, + }, + { + name: "execute transaction statement is canceled", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.ExecuteTransaction(t.Context(), &sdk.ExecuteTransactionInput{ + TransactStatements: []types.ParameterizedStatement{{ + Statement: aws.String(`UPDATE "tbl" SET v = 'x' WHERE pk = 'a'`), + }}, + }) + assert.Equal(t, []string{"TransactionConflict"}, reasonCodes(t, err)) + }, + }, + { + name: "execute statement is rejected like update item", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.ExecuteStatement(t.Context(), &sdk.ExecuteStatementInput{ + Statement: aws.String(`UPDATE "tbl" SET v = 'x' WHERE pk = 'a'`), + }) + var conflict *types.TransactionConflictException + require.ErrorAs(t, err, &conflict) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + g := f.hold(opTransactWriteItems, 0) + + done := make(chan error, 1) + + go func() { + _, err := f.client.TransactWriteItems( + t.Context(), + f.twi(putAction("a", "held"), putAction("c", "held")), + ) + done <- err + }() + + <-g.entered + tt.probe(t, f) + close(g.release) + require.NoError(t, <-done) + + got, err := f.client.GetItem( + t.Context(), + &sdk.GetItemInput{TableName: aws.String("tbl"), Key: key("a"), ConsistentRead: aws.Bool(true)}, + ) + require.NoError(t, err) + assert.Equal(t, "held", got.Item["v"].(*types.AttributeValueMemberS).Value) + + _, err = f.client.PutItem( + t.Context(), + &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("a", "after")}, + ) + require.NoError(t, err, "item must be free once the transaction finishes") + assert.Zero(t, f.db.txn.live.Load()) + }) + } +} + +func TestTransactionConflict_InFlightRead(t *testing.T) { + t.Parallel() + + tests := []struct { + probe func(t *testing.T, f *conflictFixture) + name string + }{ + { + name: "write transaction on the same item proceeds", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.TransactWriteItems(t.Context(), f.twi(putAction("a", "x"))) + require.NoError(t, err) + }, + }, + { + name: "single put is not blocked", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.PutItem( + t.Context(), + &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("a", "x")}, + ) + require.NoError(t, err) + }, + }, + { + name: "another read transaction is not blocked", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.TransactGetItems(t.Context(), tgi("a")) + require.NoError(t, err) + }, + }, + { + name: "write transaction on another item succeeds", + probe: func(t *testing.T, f *conflictFixture) { + t.Helper() + _, err := f.client.TransactWriteItems(t.Context(), f.twi(putAction("c", "x"))) + require.NoError(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + g := f.hold(opTransactGetItems, 0) + + done := make(chan error, 1) + + go func() { + _, err := f.client.TransactGetItems(t.Context(), tgi("a", "b")) + done <- err + }() + + <-g.entered + tt.probe(t, f) + close(g.release) + require.NoError(t, <-done) + assert.Zero(t, f.db.txn.live.Load()) + }) + } +} + +func TestTransactionConflict_ExecuteTransactionHoldsWrittenItems(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + g := f.hold("ExecuteTransaction", 1) + + done := make(chan error, 1) + + go func() { + _, err := f.client.ExecuteTransaction(t.Context(), &sdk.ExecuteTransactionInput{ + TransactStatements: []types.ParameterizedStatement{ + {Statement: aws.String(`UPDATE "tbl" SET v = 'x' WHERE pk = 'a'`)}, + {Statement: aws.String(`UPDATE "tbl" SET v = 'x' WHERE pk = 'b'`)}, + }, + }) + done <- err + }() + + <-g.entered + + _, err := f.client.PutItem(t.Context(), &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("a", "y")}) + var conflict *types.TransactionConflictException + require.ErrorAs(t, err, &conflict) + + _, err = f.client.PutItem(t.Context(), &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("b", "y")}) + require.NoError(t, err, "second statement has not run yet") + + close(g.release) + require.NoError(t, <-done, "a completed single put is not in flight, so statement 2 proceeds") + assert.Zero(t, f.db.txn.live.Load()) +} + +func TestTransactionConflict_TokenInProgress(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + g := f.hold(opTransactWriteItems, 0) + in := f.twi(putAction("a", "held")) + in.ClientRequestToken = aws.String("token-1") + + done := make(chan error, 1) + + go func() { + _, err := f.client.TransactWriteItems(t.Context(), in) + done <- err + }() + + <-g.entered + + _, err := f.client.TransactWriteItems(t.Context(), in) + var inProgress *types.TransactionInProgressException + require.ErrorAs(t, err, &inProgress) + + close(g.release) + require.NoError(t, <-done) + + _, err = f.client.TransactWriteItems(t.Context(), in) + require.NoError(t, err, "same token and request replays successfully once the first call finished") +} + +func TestTransactionConflict_SDKRetryBehaviour(t *testing.T) { + t.Parallel() + + retrying := func() aws.Retryer { + return retry.NewStandard(func(o *retry.StandardOptions) { + o.MaxAttempts = 3 + o.RateLimiter = ratelimit.None + o.Backoff = retry.BackoffDelayerFunc(func(int, error) (time.Duration, error) { return 0, nil }) + }) + } + + tests := []struct { + probe func(f *conflictFixture) error + name string + wantRequests int64 + }{ + { + name: "TransactionConflictException is not retried", + probe: func(f *conflictFixture) error { + _, err := f.client.PutItem( + f.tb.Context(), + &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("a", "x")}, + ) + + return err + }, + wantRequests: 1, + }, + { + name: "TransactionCanceledException is not retried", + probe: func(f *conflictFixture) error { + _, err := f.client.TransactGetItems(f.tb.Context(), tgi("a")) + + return err + }, + wantRequests: 1, + }, + { + name: "TransactionInProgressException is retried", + probe: func(f *conflictFixture) error { + in := f.twi(putAction("a", "x")) + in.ClientRequestToken = aws.String("tok") + _, err := f.client.TransactWriteItems(f.tb.Context(), in) + + return err + }, + wantRequests: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, retrying) + g := f.hold(opTransactWriteItems, 0) + + in := f.twi(putAction("a", "held")) + in.ClientRequestToken = aws.String("tok") + + done := make(chan error, 1) + + go func() { + _, err := f.client.TransactWriteItems(t.Context(), in) + done <- err + }() + + <-g.entered + + before := f.requests.Load() + require.Error(t, tt.probe(f)) + assert.Equal(t, tt.wantRequests, f.requests.Load()-before) + + close(g.release) + require.NoError(t, <-done) + }) + } +} + +func TestTransactionConflict_DefaultIsUnchanged(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + + _, err := f.client.TransactWriteItems(t.Context(), f.twi(putAction("a", "1"), putAction("b", "1"))) + require.NoError(t, err) + + _, err = f.client.PutItem(t.Context(), &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("a", "2")}) + require.NoError(t, err) + + _, err = f.client.TransactGetItems(t.Context(), tgi("a", "b")) + require.NoError(t, err) + + assert.Zero(t, f.metrics.conflicts()) +} + +func effectRule(op string, target *chaos.EffectTarget, p float64, holdOps, holdMs int) chaos.FaultRule { + return chaos.FaultRule{ + Effect: chaos.EffectDynamoDBTransactionConflict, + Operation: op, + Target: target, + Probability: &p, + HoldOps: holdOps, + HoldMs: holdMs, + } +} + +func TestTransactionConflict_ChaosEffect(t *testing.T) { + t.Parallel() + + put := func(f *conflictFixture, table, pk string) error { + _, err := f.client.PutItem(f.tb.Context(), &sdk.PutItemInput{TableName: aws.String(table), Item: item(pk, "x")}) + + return err + } + + tests := []struct { + run func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) + name string + }{ + { + name: "table target conflicts every write to the table only", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules([]chaos.FaultRule{effectRule("", &chaos.EffectTarget{Table: "tbl"}, 1, 0, 0)}) + + var conflict *types.TransactionConflictException + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + require.ErrorAs(t, put(f, "tbl", "b"), &conflict) + require.NoError(t, put(f, "other", "a")) + + _, err := f.client.GetItem(t.Context(), &sdk.GetItemInput{TableName: aws.String("tbl"), Key: key("a")}) + require.NoError(t, err, "GetItem is never blocked") + }, + }, + { + name: "key target conflicts only that item", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules( + []chaos.FaultRule{ + effectRule( + "PutItem", + &chaos.EffectTarget{Table: "tbl", Key: map[string]string{"pk": "a"}}, + 1, + 0, + 0, + ), + }, + ) + + var conflict *types.TransactionConflictException + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + require.NoError(t, put(f, "tbl", "b")) + }, + }, + { + name: "operation scoping leaves other operations alone", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules([]chaos.FaultRule{effectRule("DeleteItem", &chaos.EffectTarget{Table: "tbl"}, 1, 0, 0)}) + + require.NoError(t, put(f, "tbl", "a")) + + _, err := f.client.DeleteItem( + t.Context(), + &sdk.DeleteItemInput{TableName: aws.String("tbl"), Key: key("a")}, + ) + var conflict *types.TransactionConflictException + require.ErrorAs(t, err, &conflict) + }, + }, + { + name: "probability zero never conflicts", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules([]chaos.FaultRule{effectRule("", &chaos.EffectTarget{Table: "tbl"}, 0, 0, 0)}) + + require.NoError(t, put(f, "tbl", "a")) + }, + }, + { + name: "write transaction is canceled for targeted entries", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules( + []chaos.FaultRule{ + effectRule( + "TransactWriteItems", + &chaos.EffectTarget{Table: "tbl", Key: map[string]string{"pk": "b"}}, + 1, + 0, + 0, + ), + }, + ) + + _, err := f.client.TransactWriteItems( + t.Context(), + f.twi(putAction("a", "x"), putAction("b", "x")), + ) + assert.Equal(t, []string{"None", "TransactionConflict"}, reasonCodes(t, err)) + }, + }, + { + name: "transact get is canceled", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules( + []chaos.FaultRule{effectRule("TransactGetItems", &chaos.EffectTarget{Table: "tbl"}, 1, 0, 0)}, + ) + + _, err := f.client.TransactGetItems(t.Context(), tgi("a")) + assert.Equal(t, []string{"TransactionConflict"}, reasonCodes(t, err)) + }, + }, + { + name: "batch write returns targeted writes as unprocessed", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules( + []chaos.FaultRule{ + effectRule( + "BatchWriteItem", + &chaos.EffectTarget{Table: "tbl", Key: map[string]string{"pk": "a"}}, + 1, + 0, + 0, + ), + }, + ) + + out, err := f.client.BatchWriteItem(t.Context(), &sdk.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{"tbl": { + {PutRequest: &types.PutRequest{Item: item("a", "x")}}, + {PutRequest: &types.PutRequest{Item: item("b", "x")}}, + }}, + }) + require.NoError(t, err) + assert.Len(t, out.UnprocessedItems["tbl"], 1) + }, + }, + { + name: "holdOps keeps the item in flight after the rule stops firing", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + target := &chaos.EffectTarget{Table: "tbl"} + fs.SetRules([]chaos.FaultRule{effectRule("", target, 1, 2, 0)}) + + var conflict *types.TransactionConflictException + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + + fs.SetRules([]chaos.FaultRule{effectRule("", target, 0, 2, 0)}) + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + require.NoError(t, put(f, "tbl", "a")) + }, + }, + { + name: "holdMs keeps the item in flight until the injected clock passes the window", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + now := time.Date(2026, 10, 29, 0, 0, 0, 0, time.UTC) + f.db.clock = func() time.Time { return now } + + target := &chaos.EffectTarget{Table: "tbl"} + fs.SetRules([]chaos.FaultRule{effectRule("", target, 1, 0, 500)}) + + var conflict *types.TransactionConflictException + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + + fs.SetRules([]chaos.FaultRule{effectRule("", target, 0, 0, 500)}) + now = now.Add(499 * time.Millisecond) + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + + now = now.Add(2 * time.Millisecond) + require.NoError(t, put(f, "tbl", "a")) + }, + }, + { + name: "clearing the rules restores default behaviour", + run: func(t *testing.T, f *conflictFixture, fs *chaos.FaultStore) { + t.Helper() + fs.SetRules([]chaos.FaultRule{effectRule("", &chaos.EffectTarget{Table: "tbl"}, 1, 0, 0)}) + + var conflict *types.TransactionConflictException + require.ErrorAs(t, put(f, "tbl", "a"), &conflict) + + fs.SetRules(nil) + require.NoError(t, put(f, "tbl", "a")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + + fs := chaos.NewFaultStore() + f.db.SetFaultStore(fs) + + tt.run(t, f, fs) + }) + } +} + +func TestTransactionConflict_ConcurrentWritersStayAtomic(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + + const writers = 16 + + var ( + wg sync.WaitGroup + succeeded atomic.Int64 + canceled atomic.Int64 + ) + + for i := range writers { + wg.Go(func() { + v := string(rune('A' + i)) + _, err := f.client.TransactWriteItems( + t.Context(), + f.twi(putAction("a", v), putAction("b", v)), + ) + + if err == nil { + succeeded.Add(1) + + return + } + + if _, ok := errors.AsType[*types.TransactionCanceledException](err); ok { + canceled.Add(1) + } + }) + } + + wg.Wait() + + assert.Equal(t, int64(writers), succeeded.Load()+canceled.Load()) + assert.Positive(t, succeeded.Load()) + + a, err := f.client.GetItem( + t.Context(), + &sdk.GetItemInput{TableName: aws.String("tbl"), Key: key("a"), ConsistentRead: aws.Bool(true)}, + ) + require.NoError(t, err) + + b, err := f.client.GetItem( + t.Context(), + &sdk.GetItemInput{TableName: aws.String("tbl"), Key: key("b"), ConsistentRead: aws.Bool(true)}, + ) + require.NoError(t, err) + + assert.Equal(t, a.Item["v"], b.Item["v"], "transactions are all-or-nothing and serializable") + assert.Zero(t, f.db.txn.live.Load()) +} + +func TestTransactionConflict_ChaosFaultAPI(t *testing.T) { + t.Parallel() + + f := newConflictFixture(t, noRetry) + fs := chaos.NewFaultStore() + f.db.SetFaultStore(fs) + + e := echo.New() + registry := service.NewRegistry() + chaos.RegisterRoutes(e.Group("/_gopherstack/chaos"), fs, registry) + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + hc := testclient.New(t) + + post := func(body string) int { + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + srv.URL+"/_gopherstack/chaos/faults", + strings.NewReader(body), + ) + require.NoError(t, err) + + resp, err := hc.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + return resp.StatusCode + } + + assert.Equal(t, http.StatusBadRequest, post(`[{"effect":"dynamodb:no-such-effect"}]`)) + assert.Equal( + t, + http.StatusBadRequest, + post(`[{"effect":"dynamodb:transaction-conflict","error":{"code":"X","statusCode":500}}]`), + ) + assert.Equal(t, http.StatusOK, post( + `[{"effect":"dynamodb:transaction-conflict","service":"dynamodb","operation":"PutItem",`+ + `"target":{"table":"tbl","key":{"pk":"a"}},"probability":1,"holdOps":1}]`)) + + _, err := f.client.PutItem(t.Context(), &sdk.PutItemInput{TableName: aws.String("tbl"), Item: item("a", "x")}) + var conflict *types.TransactionConflictException + require.ErrorAs(t, err, &conflict) + + assert.NotEmpty(t, fs.GetActivity()) +} diff --git a/services/dynamodb/txn_inflight.go b/services/dynamodb/txn_inflight.go new file mode 100644 index 0000000000..47b55f18c8 --- /dev/null +++ b/services/dynamodb/txn_inflight.go @@ -0,0 +1,545 @@ +package dynamodb + +import ( + "context" + "strings" + "sync/atomic" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" +) + +// Transaction conflict handling: +// https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html#transaction-conflict-handling +const ( + txnConflictMessage = "Transaction is ongoing for the item" + cancellationReasonConflict = "TransactionConflict" + metricTransactionConflict = "TransactionConflict" + chaosServiceName = "dynamodb" +) + +type itemRef struct { + region string + table string + key string +} + +// inflightItem is guarded by InMemoryDB.mu. +type inflightItem struct { + writer *txnHandle +} + +// txnTarget is one item an operation touches; attrs holds at least its key attributes. +type txnTarget struct { + attrs map[string]types.AttributeValue + ref itemRef + idx int +} + +// txnHandle is one in-flight transaction. refs is guarded by InMemoryDB.mu. +type txnHandle struct { + db *InMemoryDB + op string + refs []itemRef +} + +type txnHandleKey struct{} + +// txnHookEvent is passed to InMemoryDB.txnHook while a transaction is in flight and holds no lock. +type txnHookEvent struct { + Op string + Step int +} + +type chaosHoldKey struct { + ref itemRef +} + +type chaosHold struct { + until time.Time + remaining int +} + +// txnState is the in-flight transaction registry plus chaos conflict state. +// Everything but the atomics is guarded by InMemoryDB.mu. +type txnState struct { + inflight map[itemRef]*inflightItem + holds map[chaosHoldKey]chaosHold + faults atomic.Pointer[chaos.FaultStore] + live atomic.Int64 +} + +func newTxnState() *txnState { + return &txnState{ + inflight: make(map[itemRef]*inflightItem), + holds: make(map[chaosHoldKey]chaosHold), + } +} + +func (s *txnState) reset() { + s.inflight = make(map[itemRef]*inflightItem) + s.holds = make(map[chaosHoldKey]chaosHold) + s.live.Store(0) +} + +// SetFaultStore wires the chaos store that drives dynamodb:transaction-conflict effect rules. +func (db *InMemoryDB) SetFaultStore(fs *chaos.FaultStore) { db.txn.faults.Store(fs) } + +func (db *InMemoryDB) timeNow() time.Time { + if db.clock != nil { + return db.clock() + } + + return time.Now() +} + +func (db *InMemoryDB) runTxnHook(op string, step int) { + if db.txnHook != nil { + db.txnHook(txnHookEvent{Op: op, Step: step}) + } +} + +func (db *InMemoryDB) chaosActive() bool { + fs := db.txn.faults.Load() + + return fs != nil && !fs.Idle() +} + +func txnHandleFrom(ctx context.Context) *txnHandle { + h, _ := ctx.Value(txnHandleKey{}).(*txnHandle) + + return h +} + +func (db *InMemoryDB) newTxnHandle(op string) *txnHandle { + return &txnHandle{db: db, op: op} +} + +// release clears every item the transaction holds. +func (h *txnHandle) release() { + if h == nil { + return + } + + db := h.db + db.mu.Lock("txn.release") + defer db.mu.Unlock() + + for _, ref := range h.refs { + e := db.txn.inflight[ref] + if e == nil { + continue + } + + if e.writer == h { + e.writer = nil + } + + if e.writer == nil { + delete(db.txn.inflight, ref) + } + } + + db.txn.live.Add(-int64(len(h.refs))) + h.refs = nil +} + +// itemKeyString joins an item's primary key values; ok is false when a key attribute is absent. +func itemKeyString(table *Table, attrs map[string]types.AttributeValue) (string, bool) { + pkDef, skDef := getPKAndSK(table.KeySchema) + + pk := BuildKeyStringFromSDK(attrs, pkDef.AttributeName) + if pk == "" { + return "", false + } + + if skDef.AttributeName == "" { + return pk, true + } + + sk := BuildKeyStringFromSDK(attrs, skDef.AttributeName) + if sk == "" { + return "", false + } + + return pk + "\x00" + sk, true +} + +func newTxnTarget(region, tableName string, table *Table, attrs map[string]types.AttributeValue) (txnTarget, bool) { + if table == nil { + return txnTarget{}, false + } + + key, ok := itemKeyString(table, attrs) + if !ok { + return txnTarget{}, false + } + + return txnTarget{ref: itemRef{region: region, table: tableName, key: key}, attrs: attrs}, true +} + +// conflictsLocked reports a genuine overlap with an in-flight transaction. Caller holds db.mu. +func (db *InMemoryDB) conflictsLocked(ref itemRef, self *txnHandle) bool { + e := db.txn.inflight[ref] + if e == nil { + return false + } + + return e.writer != nil && e.writer != self +} + +func chaosTargetMatches(t *chaos.EffectTarget, tg txnTarget) bool { + if t == nil { + return true + } + + if t.Table != "" && t.Table != tg.ref.table { + return false + } + + for name, want := range t.Key { + if BuildKeyStringFromSDK(tg.attrs, name) != want { + return false + } + } + + return true +} + +// forcedConflictLocked applies dynamodb:transaction-conflict effect rules as a phantom in-flight +// TransactWriteItems on the item. Caller holds db.mu. +func (db *InMemoryDB) forcedConflictLocked(op string, tg txnTarget) bool { + fs := db.txn.faults.Load() + if fs == nil || fs.Idle() { + return false + } + + rules := fs.MatchEffects(chaos.EffectDynamoDBTransactionConflict, chaosServiceName, op, tg.ref.region) + for _, r := range rules { + if !chaosTargetMatches(r.Target, tg) { + continue + } + + hk := chaosHoldKey{ref: tg.ref} + if db.consumeHoldLocked(hk) { + recordChaosActivity(fs, r, op, tg, true) + + return true + } + + if !r.ShouldTrigger() { + recordChaosActivity(fs, r, op, tg, false) + + continue + } + + if r.HoldOps > 0 || r.HoldMs > 0 { + db.txn.holds[hk] = chaosHold{ + remaining: r.HoldOps, + until: db.timeNow().Add(time.Duration(r.HoldMs) * time.Millisecond), + } + } + + recordChaosActivity(fs, r, op, tg, true) + + return true + } + + return false +} + +func recordChaosActivity(fs *chaos.FaultStore, r chaos.FaultRule, op string, tg txnTarget, triggered bool) { + ev := chaos.ActivityEvent{ + Timestamp: time.Now(), + Service: chaosServiceName, + Operation: op, + Region: tg.ref.region, + Probability: r.EffectiveProbability(), + Triggered: triggered, + } + if triggered { + ev.FaultApplied = r.Effect + } + + fs.RecordActivity(ev) +} + +// consumeHoldLocked reports whether an earlier forced conflict is still being held on the item. +func (db *InMemoryDB) consumeHoldLocked(hk chaosHoldKey) bool { + h, ok := db.txn.holds[hk] + if !ok { + return false + } + + byOps := h.remaining > 0 + byTime := db.timeNow().Before(h.until) + + if !byOps && !byTime { + delete(db.txn.holds, hk) + + return false + } + + if byOps { + h.remaining-- + if h.remaining == 0 && !byTime { + delete(db.txn.holds, hk) + } else { + db.txn.holds[hk] = h + } + } + + return true +} + +// beginTxn atomically checks targets for conflicts and, when none conflict, marks them in flight. +// It returns the indices of the conflicting targets; on conflict nothing is registered. +func (db *InMemoryDB) beginTxn(h *txnHandle, hold bool, targets []txnTarget) []int { + db.mu.Lock("txn.begin") + defer db.mu.Unlock() + + var conflicts []int + + for _, tg := range targets { + forced := db.forcedConflictLocked(h.op, tg) + if forced || db.conflictsLocked(tg.ref, h) { + conflicts = append(conflicts, tg.idx) + } + } + + if len(conflicts) > 0 { + return conflicts + } + + if hold { + db.markLocked(h, targets) + } + + return nil +} + +func (db *InMemoryDB) markLocked(h *txnHandle, targets []txnTarget) { + for _, tg := range targets { + e := db.txn.inflight[tg.ref] + if e == nil { + e = &inflightItem{} + db.txn.inflight[tg.ref] = e + } + + if e.writer == h { + continue + } + + e.writer = h + + h.refs = append(h.refs, tg.ref) + db.txn.live.Add(1) + } +} + +// checkSingleWrite rejects a PutItem/UpdateItem/DeleteItem that overlaps an in-flight write transaction +// with TransactionConflictException. Inside an ExecuteTransaction (ctx carries its handle) the statement +// is a transaction write and the item is then held by that transaction. +func (db *InMemoryDB) checkSingleWrite( + ctx context.Context, + op, tableName string, + table *Table, + attrs map[string]types.AttributeValue, +) error { + owner := txnHandleFrom(ctx) + if owner == nil && db.txn.live.Load() == 0 && !db.chaosActive() { + return nil + } + + region := getRegionFromContext(ctx, db) + + tg, ok := newTxnTarget(region, tableName, table, attrs) + if !ok { + return nil + } + + opName := op + if owner != nil { + opName = owner.op + } + + db.mu.Lock("txn.checkSingle") + conflict := db.forcedConflictLocked(opName, tg) || db.conflictsLocked(tg.ref, owner) + + if !conflict && owner != nil { + db.markLocked(owner, []txnTarget{tg}) + } + db.mu.Unlock() + + if !conflict { + return nil + } + + db.emitTransactionConflict(region, tableName, 1) + + return NewTransactionConflictException(txnConflictMessage) +} + +// filterBatchConflicts removes from requests the writes that overlap an in-flight transaction. +func (db *InMemoryDB) filterBatchConflicts( + region, tableName string, + table *Table, + requests []types.WriteRequest, +) ([]types.WriteRequest, []types.WriteRequest) { + targets := make([]txnTarget, len(requests)) + valid := make([]bool, len(requests)) + + for i, req := range requests { + var attrs map[string]types.AttributeValue + + switch { + case req.PutRequest != nil: + attrs = req.PutRequest.Item + case req.DeleteRequest != nil: + attrs = req.DeleteRequest.Key + } + + targets[i], valid[i] = newTxnTarget(region, tableName, table, attrs) + } + + var kept, conflicted []types.WriteRequest + + db.mu.Lock("txn.checkBatch") + defer db.mu.Unlock() + + for i, req := range requests { + if valid[i] && (db.forcedConflictLocked(opBatchWriteItem, targets[i]) || + db.conflictsLocked(targets[i].ref, nil)) { + conflicted = append(conflicted, req) + + continue + } + + kept = append(kept, req) + } + + return kept, conflicted +} + +func (db *InMemoryDB) emitTransactionConflict(region, tableName string, n int) { + for range n { + db.metrics.Put(region, ddbMetricNamespace, metricTransactionConflict, ddbUnitCount, 1, ddbTableDim(tableName)) + } +} + +// txCancelMessage is the TransactionCanceledException message: the fixed prefix plus the reason codes. +func txCancelMessage(reasons []CancellationReason) string { + codes := make([]string, len(reasons)) + for i, r := range reasons { + codes[i] = r.Code + } + + return txCancelPrefix + " [" + strings.Join(codes, ", ") + "]" +} + +// conflictCancellation builds the TransactionCanceledException for the conflicting item indices. +func conflictCancellation(n int, conflicts []int) *Error { + reasons := make([]CancellationReason, n) + for i := range reasons { + reasons[i] = CancellationReason{Code: cancellationReasonNone} + } + + for _, i := range conflicts { + reasons[i] = CancellationReason{Code: cancellationReasonConflict, Message: txnConflictMessage} + } + + return NewTransactionCanceledException(txCancelMessage(reasons), reasons) +} + +// beginTransactWrite marks a TransactWriteItems' items in flight; the caller must release the handle. +func (db *InMemoryDB) beginTransactWrite( + ctx context.Context, + items []types.TransactWriteItem, +) (*txnHandle, error) { + region := getRegionFromContext(ctx, db) + + tables, err := db.resolveTransactTablesRLocked(region, db.transactTableNames(items), "TransactWriteItems.begin") + if err != nil { + return nil, err + } + + targets := make([]txnTarget, 0, len(items)) + + for i, ti := range items { + name, attrs := transactWriteItemKey(ti) + + if tg, ok := newTxnTarget(region, name, tables[name], attrs); ok { + tg.idx = i + targets = append(targets, tg) + } + } + + h := db.newTxnHandle(opTransactWriteItems) + if conflicts := db.beginTxn(h, true, targets); len(conflicts) > 0 { + for _, i := range conflicts { + db.emitTransactionConflict(region, transactWriteItemTableName(items[i]), 1) + } + + return nil, conflictCancellation(len(items), conflicts) + } + + return h, nil +} + +func transactWriteItemKey(ti types.TransactWriteItem) (string, map[string]types.AttributeValue) { + switch { + case ti.Put != nil: + return aws.ToString(ti.Put.TableName), ti.Put.Item + case ti.Delete != nil: + return aws.ToString(ti.Delete.TableName), ti.Delete.Key + case ti.Update != nil: + return aws.ToString(ti.Update.TableName), ti.Update.Key + case ti.ConditionCheck != nil: + return aws.ToString(ti.ConditionCheck.TableName), ti.ConditionCheck.Key + } + + return "", nil +} + +// beginTransactRead checks a TransactGetItems' items for conflicts; it holds nothing (caller still releases). +func (db *InMemoryDB) beginTransactRead( + ctx context.Context, + items []types.TransactGetItem, + tableNames []string, +) (*txnHandle, error) { + region := getRegionFromContext(ctx, db) + + tables, err := db.resolveTransactTablesRLocked(region, tableNames, "TransactGetItems.begin") + if err != nil { + return nil, err + } + + targets := make([]txnTarget, 0, len(items)) + + for i, ti := range items { + if ti.Get == nil { + continue + } + + name := aws.ToString(ti.Get.TableName) + + if tg, ok := newTxnTarget(region, name, tables[name], ti.Get.Key); ok { + tg.idx = i + targets = append(targets, tg) + } + } + + h := db.newTxnHandle(opTransactGetItems) + if conflicts := db.beginTxn(h, false, targets); len(conflicts) > 0 { + for _, i := range conflicts { + db.emitTransactionConflict(region, aws.ToString(items[i].Get.TableName), 1) + } + + return nil, conflictCancellation(len(items), conflicts) + } + + return h, nil +} diff --git a/services/dynamodb/validation.go b/services/dynamodb/validation.go index 2e8f7567ed..d925e44751 100644 --- a/services/dynamodb/validation.go +++ b/services/dynamodb/validation.go @@ -1,13 +1,14 @@ package dynamodb import ( + "errors" "fmt" "regexp" - "strconv" "strings" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" "github.com/blackbirdworks/gopherstack/services/dynamodb/models" ) @@ -611,7 +612,7 @@ func validateSetItem(k, t string, item any) error { ), ) } - if _, err := strconv.ParseFloat(s, 64); err != nil { + if err := validateNumberText(s); err != nil { return NewValidationException( fmt.Sprintf("Attribute %s element %s must be a valid number", k, s), ) @@ -628,6 +629,20 @@ func validateSetItem(k, t string, item any) error { return nil } +func validateNumberText(s string) error { + _, err := dynamonum.Parse(s) + + return err +} + +func numberValidationError(k string, err error) error { + if errors.Is(err, dynamonum.ErrInvalid) { + return NewValidationException(fmt.Sprintf("Attribute %s of type N must be a valid number", k)) + } + + return NewValidationException(err.Error()) +} + func validateScalarValue(k, t string, val any) error { switch t { case "S": @@ -639,10 +654,8 @@ func validateScalarValue(k, t string, val any) error { if !ok { return NewValidationException(fmt.Sprintf("Attribute %s of type N must be a string", k)) } - if _, err := strconv.ParseFloat(valStr, 64); err != nil { - return NewValidationException( - fmt.Sprintf("Attribute %s of type N must be a valid number", k), - ) + if err := validateNumberText(valStr); err != nil { + return numberValidationError(k, err) } if err := validateNumberNoLeadingZeros(k, valStr); err != nil { return err @@ -906,7 +919,7 @@ func validateSetNoDuplicates(k string, items []any) error { switch v := item.(type) { case string: - key = v + key = dynamonum.Canonical(v) default: continue } diff --git a/services/dynamodb/wire_decode.go b/services/dynamodb/wire_decode.go index 2dd1af5eac..1ef2aead90 100644 --- a/services/dynamodb/wire_decode.go +++ b/services/dynamodb/wire_decode.go @@ -7,6 +7,8 @@ import ( "strings" "unicode/utf8" + "github.com/blackbirdworks/gopherstack/pkgs/dynamonum" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) @@ -449,7 +451,7 @@ func (r *wireReader) typedValue(typeKey string, depth int) (types.AttributeValue case "N": s, ok := r.str() - return &types.AttributeValueMemberN{Value: s}, ok + return &types.AttributeValueMemberN{Value: dynamonum.Canonical(s)}, ok case typeBOOL: b, ok := r.boolean() @@ -506,6 +508,10 @@ func (r *wireReader) setValue(typeKey string) (types.AttributeValue, bool) { ok := r.elements(func() bool { s, good := r.str() + if typeKey == "NS" { + s = dynamonum.Canonical(s) + } + out = append(out, s) return good diff --git a/services/dynamodbstreams/iam_enforcement_test.go b/services/dynamodbstreams/iam_enforcement_test.go index 29920ed3a4..635f0f959c 100644 --- a/services/dynamodbstreams/iam_enforcement_test.go +++ b/services/dynamodbstreams/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/dynamodb" "github.com/blackbirdworks/gopherstack/services/dynamodbstreams" "github.com/blackbirdworks/gopherstack/services/iam" @@ -164,7 +165,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/dynamodb/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ec2/PARITY.md b/services/ec2/PARITY.md index c6d96ca849..5955844dfe 100644 --- a/services/ec2/PARITY.md +++ b/services/ec2/PARITY.md @@ -574,7 +574,10 @@ families: field is 'returnValue', not 'return' (deserializers.go confirmed)."} gaps: [] items_still_open: - - "RunInstances accepts any syntactically valid InstanceType and any ami-prefixed ImageId: the instance-type catalog is partial and many in-repo callers launch synthetic AMIs, so unknown-but-well-formed values are not rejected." + - "Snapshot and AMI sharing is caller-aware only for resources created or copied by an explicit-account caller (OwnerID = that account); resources owned by the backend account stay visible to every caller, and instances, volumes and other resources are not account-scoped (gopherstack-tnqy.6)." + - "RunInstances (in-memory provider) accepts any syntactically valid InstanceType and any ami-prefixed ImageId: the instance-type catalog is partial and many in-repo callers launch synthetic AMIs, so unknown-but-well-formed values are not rejected. The docker provider rejects an unknown ImageId with InvalidAMIID.NotFound." + - "Docker provider AMI binding: Windows and Bottlerocket seeded AMIs have no Linux container equivalent and run the configured default image; AMI Architecture does not select a platform-specific image (the host platform decides); deregistering an AMI does not remove its committed docker image; CreateImage snapshots the container filesystem only, not attached non-root EBS volumes or EBS snapshots." + - "Docker provider EBS volumes (opt-in): AttachVolume on a running instance copies data in and DetachVolume/terminate copy it back (Docker cannot add a mount to a live container), so runtime-attached data reaches the Docker volume at detach or terminate rather than continuously, the copy-sync registry is in memory (lost on restart), and a volume mounted at launch stays mounted after DetachVolume; the root volume has no Docker volume, so DeleteOnTermination=false on the root device does not retain it; Docker volumes of EBS volumes deleted by fleet or shutdown-behavior termination are reaped on the next Terminate or DeleteVolume call." - "Filters the pinned SDK does not define: DescribeCapacityBlocks 'tags' (doc is only \"the tags assigned\"; checked api_op_DescribeCapacityBlocks.go), DescribeInstanceEventWindows 'instance-tag' (value syntax undocumented in api_op_DescribeInstanceEventWindows.go), DescribeVpcPeeringConnections 'status-message' (no message texts in the SDK or the VPC peering lifecycle page, which names states only)." structural_gaps: - "DescribeInstanceStatus application-status.status filter and InstanceStatus.ApplicationStatus (2026-10-07): derived from live HTTP(S) health-check results; see the DescribeApplicationStatus entry below." @@ -6338,3 +6341,21 @@ CreateVolume without Size and SnapshotId now fails with MissingParameter (api_op `CreateCapacityReservation` with `StartDate` + `CommitmentDuration` + `DeliveryPreference=incremental` models a future-dated reservation: `assessing` (1 minute, emulator constant; AWS publishes no duration) -> `scheduled` -> `active` at `StartDate` (AvailableInstanceCount becomes TotalInstanceCount) -> `expired` at a limited `EndDate`, all derived from the injectable backend clock. Validated per the API reference: start 5-120 days out, `InstanceMatchCriteria=targeted`, CommitmentDuration 1-200000000s, C/M/R/I/T/G families, >= 32 vCPUs (instance-type catalog), no Outpost/PlacementGroup, EndDate not inside the commitment. `CommitmentInfo`, `StartDate`, `OriginalStartDate`, `DeliveryPreference`, `CreateDate`, `EndDate/EndDateType`, `AdjustmentStatus` are returned by Describe. `CancelCapacityReservation` for `scheduled` or `active`-in-commitment needs an active cancellation quote and `ApplyCancellationCharges=commitment-wind-down`, moving to `cancelling` until the commitment end, then `cancelled`. `ModifyCapacityReservation` enforces the documented per-state rules and applies a start-date push-out via `QuoteId` + `AcceptModificationTerms`. `CreateCapacityReservationDateChangeQuote` / `DescribeCapacityReservationDateChangeQuotes` (crmq- ids, taggable): scheduled reservations only, new start later than current and within 30 days of the original start, 24h validity capped 1h before the start date, single use. Not modelled: capacity-driven outcomes (`unsupported`, `failed`, `delayed`) and cancellation charges (cancellation quote terms stay empty). Proof: `capacity_reservation_future_test.go`. + +## 2026-10-29 docker AMI binding, extra flags and EBS volumes (gopherstack-49qct.69) + +Docker-provider mode only; the in-memory provider is unchanged. Conventions are documented in +`examples/ec2-docker/README.md` ("AMIs and Docker images", "Extra docker flags", "EBS volumes"). + +- `RunInstances` starts the image bound to the AMI: `AMIStub.DockerImage` (persisted), else a name-based default for + seeded public AMIs, else `--ec2-docker-image`. Unknown AMI ids fail with `InvalidAMIID.NotFound`. +- `CreateImage` runs `docker commit` into `gopherstack-ec2/:` and a failed commit removes the new AMI. + Local images tagged `gopherstack-ec2/:` are registered as self-owned AMIs; `RegisterImage` accepts + `ImageLocation=docker://`; `CopyImage` copies the binding. +- `GOPHERSTACK_EC2_DOCKER_FLAGS` is parsed into HostConfig fields (no shell); unsupported flags fail startup. +- `GOPHERSTACK_EC2_DOCKER_EBS_VOLUMES=true` maps each EBS volume to a labelled Docker named volume mounted at + `/mnt/ebs/`; launch mappings are real mounts, runtime attach/detach copy data in and out. + +## 2026-10-30 snapshot / AMI sharing by caller (gopherstack-tnqy.6) + +createVolumePermission / launchPermission grants (already stored per grantee) now gate callers with an explicit account: DescribeSnapshots and DescribeImages list owned, shared and public resources only, and honour OwnerIds, RestorableByUserIds and ExecutableUsers (`self`, account ids, `all`); CreateVolume, CopySnapshot, RunInstances and CopyImage on an unshared foreign resource fail with InvalidSnapshotID.NotFound / InvalidAMIID.NotFound; a resource merely shared with the caller yields UnauthorizedOperation (403) on Modify/Describe/ResetSnapshotAttribute and Modify/Describe/ResetImageAttribute. diff --git a/services/ec2/README.md b/services/ec2/README.md index cdccf9f14d..23eff4aa9b 100644 --- a/services/ec2/README.md +++ b/services/ec2/README.md @@ -8,14 +8,17 @@ | Metric | Value | | --- | --- | | Feature families | 22 (22 ok) | -| Known gaps | 2 | +| Known gaps | 5 | | Structural gaps (can't be emulated) | 18 | | Deferred items | 5 | | Resource leaks | ok | ### Known gaps -- RunInstances accepts any syntactically valid InstanceType and any ami-prefixed ImageId: the instance-type catalog is partial and many in-repo callers launch synthetic AMIs, so unknown-but-well-formed values are not rejected. +- Snapshot and AMI sharing is caller-aware only for resources created or copied by an explicit-account caller (OwnerID = that account); resources owned by the backend account stay visible to every caller, and instances, volumes and other resources are not account-scoped (gopherstack-tnqy.6). +- RunInstances (in-memory provider) accepts any syntactically valid InstanceType and any ami-prefixed ImageId: the instance-type catalog is partial and many in-repo callers launch synthetic AMIs, so unknown-but-well-formed values are not rejected. The docker provider rejects an unknown ImageId with InvalidAMIID.NotFound. +- Docker provider AMI binding: Windows and Bottlerocket seeded AMIs have no Linux container equivalent and run the configured default image; AMI Architecture does not select a platform-specific image (the host platform decides); deregistering an AMI does not remove its committed docker image; CreateImage snapshots the container filesystem only, not attached non-root EBS volumes or EBS snapshots. +- Docker provider EBS volumes (opt-in): AttachVolume on a running instance copies data in and DetachVolume/terminate copy it back (Docker cannot add a mount to a live container), so runtime-attached data reaches the Docker volume at detach or terminate rather than continuously, the copy-sync registry is in memory (lost on restart), and a volume mounted at launch stays mounted after DetachVolume; the root volume has no Docker volume, so DeleteOnTermination=false on the root device does not retain it; Docker volumes of EBS volumes deleted by fleet or shutdown-behavior termination are reaped on the next Terminate or DeleteVolume call. - Filters the pinned SDK does not define: DescribeCapacityBlocks 'tags' (doc is only "the tags assigned"; checked api_op_DescribeCapacityBlocks.go), DescribeInstanceEventWindows 'instance-tag' (value syntax undocumented in api_op_DescribeInstanceEventWindows.go), DescribeVpcPeeringConnections 'status-message' (no message texts in the SDK or the VPC peering lifecycle page, which names states only). ### Structural gaps diff --git a/services/ec2/compute.go b/services/ec2/compute.go index 63acac21a7..eaa6be753e 100644 --- a/services/ec2/compute.go +++ b/services/ec2/compute.go @@ -57,6 +57,10 @@ type LaunchRequest struct { VPCCIDR string `json:"vpcCIDR,omitempty"` // PrivateIP is the address allocated from the instance's subnet. PrivateIP string `json:"privateIP,omitempty"` + // DockerImage is the Docker image bound to ImageID; empty means the provider's configured default. + DockerImage string `json:"dockerImage,omitempty"` + // Volumes are the EBS volumes attached at launch (root volume excluded). + Volumes []VolumeAttachSpec `json:"volumes,omitempty"` // PublishPorts are the ports to publish: those opened to the world, or every TCP rule port when enforcing. PublishPorts []PortSpec `json:"publishPorts,omitempty"` } diff --git a/services/ec2/compute_hooks.go b/services/ec2/compute_hooks.go index 326f8ccdae..8053d2a6e1 100644 --- a/services/ec2/compute_hooks.go +++ b/services/ec2/compute_hooks.go @@ -75,6 +75,11 @@ func (h *Handler) launchOnCompute( req.Admit = admitFactory(cb, inst.ID) + req.DockerImage = dockerImageOf(h.Backend, inst.ImageID) + if vb, ok := cb.(volumeBackend); ok { + req.Volumes = vb.LaunchVolumeSpecs(inst.ID) + } + res, err := c.Launch(ctx, req) if err != nil { h.imds.forget(inst.ID) @@ -98,6 +103,17 @@ func (h *Handler) launchOnCompute( } } +func dockerImageOf(b Backend, imageID string) string { + ib, ok := b.(imageBindingBackend) + if !ok { + return "" + } + + ref, _ := ib.DockerImageFor(imageID) + + return ref +} + // networkContextBackend supplies the VPC addressing and security-group ports of an instance. type networkContextBackend interface { ComputeNetworkContext(inst *Instance) (string, []PortSpec) diff --git a/services/ec2/docker_adopt.go b/services/ec2/docker_adopt.go new file mode 100644 index 0000000000..d9ea88e2a2 --- /dev/null +++ b/services/ec2/docker_adopt.go @@ -0,0 +1,177 @@ +package ec2 + +import ( + "context" + + mobyclient "github.com/moby/moby/client" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ec2Service = "ec2" + +// AdoptSpec identifies a restored instance whose container may have survived a crash. +type AdoptSpec struct { + InstanceID string + ProviderID string + ImageID string + VPCID string +} + +// instanceAdopter is implemented by Compute providers that can take over a surviving instance container. +type instanceAdopter interface { + AdoptInstance(ctx context.Context, spec AdoptSpec) (LaunchResult, bool) +} + +// managedInstanceLabels identify an instance container to a restarted gopherstack. A container behind a +// security-group relay carries no resource key: the relay dies with the process, so it is never adopted. +func managedInstanceLabels(req LaunchRequest) map[string]string { + if req.Admit != nil { + return container.ManagedLabels(ec2Service, "") + } + + return container.ManagedLabels(ec2Service, req.InstanceID) +} + +// AdoptInstance takes over the running container a crashed gopherstack left for spec's instance and reports the +// addresses it still has. It declines, removing the container, when it is not the recorded container of the same +// AMI, is not running, or the provider maps EBS volumes (their mounts cannot be rebuilt from the container). +func (d *DockerCompute) AdoptInstance(ctx context.Context, spec AdoptSpec) (LaunchResult, bool) { + claim := d.claim + if claim == nil { + claim = container.ClaimOrphan + } + + oc, ok := claim(ec2Service, spec.InstanceID) + if !ok { + return LaunchResult{}, false + } + + res, adopted := d.adoptable(ctx, spec, oc) + if !adopted { + d.discardOrphan(ctx, oc.ID) + + return LaunchResult{}, false + } + + logger.Load(ctx).InfoContext(ctx, "ec2: adopted instance container from an earlier run", + "instance", spec.InstanceID, "container", oc.ID) + + return res, true +} + +func (d *DockerCompute) adoptable( + ctx context.Context, spec AdoptSpec, oc container.ManagedContainer, +) (LaunchResult, bool) { + if d.EBSEnabled() || oc.ID != spec.ProviderID || oc.Labels[dockerLabelPrefix+"image"] != spec.ImageID { + return LaunchResult{}, false + } + + res, err := d.api.ContainerInspect(ctx, oc.ID, mobyclient.ContainerInspectOptions{}) + if err != nil || res.Container.State == nil || !res.Container.State.Running { + return LaunchResult{}, false + } + + preferred := d.cfg.Network + if spec.VPCID != "" { + preferred = vpcNetworkName(spec.VPCID) + } + + ip, port := extractInspectAddrs(res.Container, preferred) + if ip == "" { + return LaunchResult{}, false + } + + d.reservePort(port) + + return LaunchResult{ + ProviderID: oc.ID, + PrivateIP: ip, + SSHPort: port, + PublishedPorts: publishedOf(res.Container), + }, true +} + +// reservePort marks an adopted container's ssh port as taken when it lies in the configured range. +func (d *DockerCompute) reservePort(port int) { + if port < d.cfg.SSHPortMin || port > d.cfg.SSHPortMax { + return + } + + d.ports.mu.Lock() + defer d.ports.mu.Unlock() + + d.ports.used[port] = struct{}{} +} + +func (d *DockerCompute) discardOrphan(ctx context.Context, id string) { + timeout := stopTimeoutSec + _, _ = d.api.ContainerStop(ctx, id, mobyclient.ContainerStopOptions{Timeout: &timeout}) + _, _ = d.api.ContainerRemove(ctx, id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) +} + +// adoptCandidates lists the restored pending or running instances that were on a provider container. +func (b *InMemoryBackend) adoptCandidates() []AdoptSpec { + b.mu.RLock("adoptCandidates") + defer b.mu.RUnlock() + + var out []AdoptSpec + + for _, inst := range b.instances.All() { + if inst.ProviderID == "" || (inst.State.Name != StateRunning.Name && inst.State.Name != StatePending.Name) { + continue + } + + out = append(out, AdoptSpec{ + InstanceID: inst.ID, ProviderID: inst.ProviderID, ImageID: inst.ImageID, VPCID: inst.VPCID, + }) + } + + return out +} + +func (b *InMemoryBackend) instanceCopy(id string) (Instance, bool) { + b.mu.RLock("instanceCopy") + defer b.mu.RUnlock() + + inst, ok := b.instances.Get(id) + if !ok { + return Instance{}, false + } + + return *inst, true +} + +// adoptComputeInstances reattaches restored running instances to their surviving containers. +func (h *Handler) adoptComputeInstances(ctx context.Context) { + cb, c := h.computeBackend() + + ad, ok := c.(instanceAdopter) + mem, isMem := h.Backend.(*InMemoryBackend) + + if cb == nil || !ok || !isMem { + return + } + + for _, spec := range mem.adoptCandidates() { + res, adopted := ad.AdoptInstance(ctx, spec) + if !adopted { + continue + } + + if err := cb.SetComputeResult(spec.InstanceID, res); err != nil { + logger.Load(ctx).WarnContext(ctx, "ec2: adopted instance result merge failed", + "instance", spec.InstanceID, "error", err) + + continue + } + + h.imdsEndpointFor(ctx, spec.InstanceID) + publishNetworkMetadata(ctx, cb, spec.InstanceID, res) + + if inst, found := mem.instanceCopy(spec.InstanceID); found { + publishComputeMetadata(ctx, cb, &inst) + } + } +} diff --git a/services/ec2/docker_adopt_internal_test.go b/services/ec2/docker_adopt_internal_test.go new file mode 100644 index 0000000000..099434c23a --- /dev/null +++ b/services/ec2/docker_adopt_internal_test.go @@ -0,0 +1,176 @@ +package ec2 + +import ( + "context" + "testing" + + mobycontainer "github.com/moby/moby/api/types/container" + mobyclient "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/sgfilter" +) + +// runningFake reports the fake container as running (or not) on inspect. +type runningFake struct { + *fakeDockerAPI + stopped bool +} + +func (f *runningFake) ContainerInspect( + ctx context.Context, id string, opts mobyclient.ContainerInspectOptions, +) (mobyclient.ContainerInspectResult, error) { + res, err := f.fakeDockerAPI.ContainerInspect(ctx, id, opts) + res.Container.State = &mobycontainer.State{Running: !f.stopped} + + return res, err +} + +func claimOne(c container.ManagedContainer, ok bool) func(string, string) (container.ManagedContainer, bool) { + return func(service, resource string) (container.ManagedContainer, bool) { + if service != "ec2" || resource != "i-adopt" { + return container.ManagedContainer{}, false + } + + return c, ok + } +} + +func TestLaunchStampsIdentityLabels(t *testing.T) { + t.Parallel() + + tests := []struct { + admit func(string, int) sgfilter.Admitter + name string + wantResource string + }{ + {name: "adoptable", wantResource: "i-adopt"}, + {name: "fronted never adopted", admit: func(string, int) sgfilter.Admitter { return nil }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + api := &fakeDockerAPI{containerID: "cid", containerIP: "172.17.0.9"} + dc := newDockerComputeWithAPI(api, DockerComputeConfig{Image: defaultDockerImage}) + + _, err := dc.Launch(t.Context(), LaunchRequest{InstanceID: "i-adopt", ImageID: "ami-1", Admit: tt.admit}) + require.NoError(t, err) + require.Len(t, api.created, 1) + + l := api.created[0].Config.Labels + assert.Equal(t, "true", l[container.LabelManaged]) + assert.Equal(t, container.CurrentIdentity().Owner, l[container.LabelOwner]) + assert.NotEmpty(t, l[container.LabelPID]) + assert.Equal(t, "ec2", l[container.LabelService]) + assert.Equal(t, tt.wantResource, l[container.LabelResource]) + assert.Equal(t, "i-adopt", l[dockerLabelPrefix+"instance"]) + }) + } +} + +func TestAdoptInstance(t *testing.T) { + t.Parallel() + + good := container.ManagedContainer{ + ID: "cid", Running: true, Labels: map[string]string{dockerLabelPrefix + "image": "ami-1"}, + } + spec := AdoptSpec{InstanceID: "i-adopt", ProviderID: "cid", ImageID: "ami-1"} + + tests := []struct { + spec AdoptSpec + name string + claimed container.ManagedContainer + wantRemoved []string + cfg DockerComputeConfig + notClaimed bool + stopped bool + wantAdopt bool + }{ + {name: "matching container", claimed: good, spec: spec, wantAdopt: true}, + {name: "nothing held", spec: spec, notClaimed: true}, + { + name: "different container id", claimed: good, + spec: AdoptSpec{InstanceID: "i-adopt", ProviderID: "other", ImageID: "ami-1"}, + wantRemoved: []string{"cid"}, + }, + { + name: "different ami", claimed: good, + spec: AdoptSpec{InstanceID: "i-adopt", ProviderID: "cid", ImageID: "ami-2"}, + wantRemoved: []string{"cid"}, + }, + {name: "not running", claimed: good, spec: spec, stopped: true, wantRemoved: []string{"cid"}}, + { + name: "ebs mapping cannot be rebuilt", claimed: good, spec: spec, + cfg: DockerComputeConfig{EBSVolumes: true}, wantRemoved: []string{"cid"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerAPI{containerID: "cid", containerIP: "172.17.0.9"} + tt.cfg.Image = defaultDockerImage + + var api dockerAPI = &runningFake{fakeDockerAPI: fake, stopped: tt.stopped} + if tt.cfg.EBSVolumes { + api = newRichDockerAPI(fake) + } + + dc := newDockerComputeWithAPI(api, tt.cfg) + dc.claim = claimOne(tt.claimed, !tt.notClaimed) + + res, ok := dc.AdoptInstance(t.Context(), tt.spec) + + assert.Equal(t, tt.wantAdopt, ok) + assert.ElementsMatch(t, tt.wantRemoved, fake.removed) + + if tt.wantAdopt { + assert.Equal(t, "cid", res.ProviderID) + assert.Equal(t, "172.17.0.9", res.PrivateIP) + } + }) + } +} + +func TestRestoredInstanceReattachedToSurvivingContainer(t *testing.T) { + t.Parallel() + + b1 := NewInMemoryBackend("000000000000", "us-east-1") + insts, err := b1.RunInstances("ami-12345678", "t3.micro", "", 1) + require.NoError(t, err) + + id := insts[0].ID + require.NoError(t, b1.SetComputeResult(id, LaunchResult{ProviderID: "cid", PrivateIP: "10.9.9.9"})) + + snap := b1.Snapshot(t.Context()) + + fake := &fakeDockerAPI{containerID: "cid", containerIP: "172.17.0.9"} + dc := newDockerComputeWithAPI(&runningFake{fakeDockerAPI: fake}, DockerComputeConfig{Image: defaultDockerImage}) + dc.claim = func(service, resource string) (container.ManagedContainer, bool) { + if service == "ec2" && resource == id { + return container.ManagedContainer{ + ID: "cid", Running: true, Labels: map[string]string{dockerLabelPrefix + "image": "ami-12345678"}, + }, true + } + + return container.ManagedContainer{}, false + } + + b2 := NewInMemoryBackend("000000000000", "us-east-1") + b2.WithCompute(dc) + + h := NewHandler(b2) + require.NoError(t, h.Restore(t.Context(), snap)) + + assert.Equal(t, "cid", b2.LookupInstanceProviderID(id), "the instance keeps its container") + got, ok := b2.instanceCopy(id) + require.True(t, ok) + assert.Equal(t, "172.17.0.9", got.PrivateIP, "the address is the container's, as it still is") + assert.Empty(t, fake.removed) + assert.Empty(t, fake.created, "adoption must not launch a second container") +} diff --git a/services/ec2/docker_adopt_live_internal_test.go b/services/ec2/docker_adopt_live_internal_test.go new file mode 100644 index 0000000000..d509adebca --- /dev/null +++ b/services/ec2/docker_adopt_live_internal_test.go @@ -0,0 +1,99 @@ +package ec2 + +import ( + "context" + "os/exec" + "strings" + "testing" + + mobyclient "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +// onlyResource lists just the managed containers whose resource key is id, so a live test never reconciles +// containers it did not create. +type onlyResource struct { + container.ManagedLister + id string +} + +func (o onlyResource) ListManaged(ctx context.Context) ([]container.ManagedContainer, error) { + all, err := o.ManagedLister.ListManaged(ctx) + if err != nil { + return nil, err + } + + var out []container.ManagedContainer + + for _, c := range all { + if strings.EqualFold(c.Labels[container.LabelResource], o.id) { + out = append(out, c) + } + } + + return out, nil +} + +func TestDockerComputeLiveCrashedInstanceAdopted(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not found") + } + + rt, err := container.NewRuntime(container.Config{}) + require.NoError(t, err) + + t.Cleanup(func() { _ = rt.Close() }) + + lister, ok := rt.(container.ManagedLister) + require.True(t, ok) + + dc1, cli := newLiveCompute(t, nil) + + b1 := NewInMemoryBackend("000000000000", "us-east-1") + insts, err := b1.RunInstances("ami-12345678", "t3.micro", "", 1) + require.NoError(t, err) + + id := insts[0].ID + res := launchLive(t, dc1, LaunchRequest{InstanceID: id, ImageID: insts[0].ImageID}) + stale := res + stale.PrivateIP = "10.9.9.9" + require.NoError(t, b1.SetComputeResult(id, stale)) + + snap := b1.Snapshot(t.Context()) + + orphans, err := container.Reconcile(t.Context(), onlyResource{lister, id}, container.CurrentIdentity(), nil) + require.NoError(t, err) + + dc2 := newDockerComputeWithAPI(dc1.api, DockerComputeConfig{Image: liveImage}) + dc2.claim = orphans.Claim + + b2 := NewInMemoryBackend("000000000000", "us-east-1") + b2.WithCompute(dc2) + + h := NewHandler(b2) + require.NoError(t, h.Restore(t.Context(), snap)) + + assert.Equal(t, res.ProviderID, b2.LookupInstanceProviderID(id), "same container, not a replacement") + + got, found := b2.instanceCopy(id) + require.True(t, found) + assert.Equal(t, res.PrivateIP, got.PrivateIP, "the recorded address is refreshed from the adopted container") + + insp, err := cli.ContainerInspect(t.Context(), res.ProviderID, mobyclient.ContainerInspectOptions{}) + require.NoError(t, err) + require.NotNil(t, insp.Container.State) + assert.True(t, insp.Container.State.Running) + + assert.Zero(t, orphans.SweepUnclaimed(t.Context()), "nothing may be left for the sweep") + + list, err := cli.ContainerList(t.Context(), mobyclient.ContainerListOptions{ + All: true, Filters: mobyclient.Filters{}.Add("label", dockerLabelPrefix+"instance="+id), + }) + require.NoError(t, err) + assert.Len(t, list.Items, 1, "exactly one container backs the instance") +} diff --git a/services/ec2/docker_ami_sdk_internal_test.go b/services/ec2/docker_ami_sdk_internal_test.go new file mode 100644 index 0000000000..335c32455c --- /dev/null +++ b/services/ec2/docker_ami_sdk_internal_test.go @@ -0,0 +1,130 @@ +package ec2 + +import ( + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + ec2sdk "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/smithy-go" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +func newInternalSDKClient(t *testing.T, h *Handler) *ec2sdk.Client { + t.Helper() + + e := echo.New() + registry := service.NewRegistry() + require.NoError(t, registry.Register(h)) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + cfg, err := awscfg.LoadDefaultConfig( + t.Context(), + awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + return ec2sdk.NewFromConfig(cfg, func(o *ec2sdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }) +} + +func TestSDKDockerUnknownAMI(t *testing.T) { + t.Parallel() + + h, _, api := newDockerHandler(t, DockerComputeConfig{}) + client := newInternalSDKClient(t, h) + + _, err := client.RunInstances(t.Context(), &ec2sdk.RunInstancesInput{ + ImageId: aws.String("ami-00000000deadbeef0"), MinCount: aws.Int32(1), MaxCount: aws.Int32(1), + }) + + var apiErr smithy.APIError + + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "InvalidAMIID.NotFound", apiErr.ErrorCode()) + assert.Empty(t, api.created) +} + +func TestSDKDockerLocalImageIsAMI(t *testing.T) { + t.Parallel() + + h, _, api := newDockerHandler(t, DockerComputeConfig{Image: "alpine:3.20"}) + client := newInternalSDKClient(t, h) + ctx := t.Context() + + api.images["gopherstack-ec2/web:"+amiDiscover] = "x" + + out, err := client.DescribeImages(ctx, &ec2sdk.DescribeImagesInput{Owners: []string{"self"}}) + require.NoError(t, err) + require.Len(t, out.Images, 1) + assert.Equal(t, amiDiscover, aws.ToString(out.Images[0].ImageId)) + assert.Equal(t, "web", aws.ToString(out.Images[0].Name)) + + run, err := client.RunInstances(ctx, &ec2sdk.RunInstancesInput{ + ImageId: aws.String(amiDiscover), MinCount: aws.Int32(1), MaxCount: aws.Int32(1), + }) + require.NoError(t, err) + require.Len(t, api.created, 1) + assert.Equal(t, "gopherstack-ec2/web:"+amiDiscover, api.created[0].Config.Image) + + img, err := client.CreateImage(ctx, &ec2sdk.CreateImageInput{ + InstanceId: run.Instances[0].InstanceId, Name: aws.String("snap one"), + }) + require.NoError(t, err) + require.Len(t, api.commits, 1) + assert.Equal(t, "gopherstack-ec2/snap-one:"+aws.ToString(img.ImageId), api.commits[0].Reference) + + _, err = client.RunInstances(ctx, &ec2sdk.RunInstancesInput{ + ImageId: img.ImageId, MinCount: aws.Int32(1), MaxCount: aws.Int32(1), + }) + require.NoError(t, err) + assert.Equal(t, api.commits[0].Reference, api.created[1].Config.Image) +} + +func TestSDKDockerEBSVolumes(t *testing.T) { + t.Parallel() + + h, _, api := newDockerHandler(t, DockerComputeConfig{EBSVolumes: true}) + client := newInternalSDKClient(t, h) + ctx := t.Context() + + run, err := client.RunInstances(ctx, &ec2sdk.RunInstancesInput{ + ImageId: aws.String(amiAL2), MinCount: aws.Int32(1), MaxCount: aws.Int32(1), + BlockDeviceMappings: []types.BlockDeviceMapping{{ + DeviceName: aws.String("/dev/sdf"), + Ebs: &types.EbsBlockDevice{VolumeSize: aws.Int32(2), DeleteOnTermination: aws.Bool(false)}, + }}, + }) + require.NoError(t, err) + require.Len(t, api.created[0].HostConfig.Mounts, 1) + assert.Equal(t, "/mnt/ebs/sdf", api.created[0].HostConfig.Mounts[0].Target) + + vol, err := client.CreateVolume(ctx, &ec2sdk.CreateVolumeInput{ + AvailabilityZone: aws.String("us-east-1a"), Size: aws.Int32(1), + }) + require.NoError(t, err) + + _, err = client.AttachVolume(ctx, &ec2sdk.AttachVolumeInput{ + VolumeId: vol.VolumeId, InstanceId: run.Instances[0].InstanceId, Device: aws.String("/dev/sdg"), + }) + require.NoError(t, err) + api.writeFile(richProvider, "/mnt/ebs/sdg/f", []byte("v")) + + _, err = client.DetachVolume(ctx, &ec2sdk.DetachVolumeInput{VolumeId: vol.VolumeId}) + require.NoError(t, err) + assert.Equal(t, []byte("v"), api.files[fakeVolPrefix+aws.ToString(vol.VolumeId)]["/f"]) + + _, err = client.DeleteVolume(ctx, &ec2sdk.DeleteVolumeInput{VolumeId: vol.VolumeId}) + require.NoError(t, err) + assert.NotContains(t, api.volumes, "gopherstack-ebs-"+aws.ToString(vol.VolumeId)) +} diff --git a/services/ec2/docker_compute.go b/services/ec2/docker_compute.go index 8c57e0d8bc..5042ad9a4a 100644 --- a/services/ec2/docker_compute.go +++ b/services/ec2/docker_compute.go @@ -12,10 +12,12 @@ import ( "time" mobycontainer "github.com/moby/moby/api/types/container" + mobymount "github.com/moby/moby/api/types/mount" mobynetwork "github.com/moby/moby/api/types/network" mobyclient "github.com/moby/moby/client" "github.com/blackbirdworks/gopherstack/internal/linklocal" + "github.com/blackbirdworks/gopherstack/pkgs/container" "github.com/blackbirdworks/gopherstack/pkgs/docker" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/registryauth" @@ -41,14 +43,22 @@ type DockerComputeConfig struct { Image string `json:"image,omitempty"` Network string `json:"network,omitempty"` SSHHostIP string `json:"sshHostIP,omitempty"` + // Flags is a restricted docker-run style option string (see parseDockerFlags) applied to every container. + Flags string `json:"flags,omitempty"` // Region is used to render the public DNS name suffix // (e.g. us-east-1 → "compute-1.amazonaws.com"). Region string `json:"region,omitempty"` SSHPortMin int `json:"sshPortMin,omitempty"` SSHPortMax int `json:"sshPortMax,omitempty"` + // EBSVolumes maps EBS volumes to Docker named volumes mounted under /mnt/ebs. + EBSVolumes bool `json:"ebsVolumes,omitempty"` } -var errNoImageTag = errors.New("docker client cannot tag images") +var ( + errNoImageTag = errors.New("docker client cannot tag images") + errNoImageCommit = errors.New("docker client cannot commit images") + errLocalImage = errors.New("docker compute: AMI is bound to a local docker image that does not exist") +) // dockerAPI is the subset of the Docker SDK the DockerCompute uses. // It is an interface so tests can inject a fake. @@ -92,7 +102,12 @@ type DockerCompute struct { fronts *frontRegistry // dialer reaches containers' loopback ports from the relays; nil means a plain net.Dialer. dialer sgfilter.Dialer - cfg DockerComputeConfig + // mounts tracks the EBS volumes each container holds; shared across region views. + mounts *mountRegistry + // claim hands out surviving containers of an earlier run; nil means container.ClaimOrphan. + claim func(service, resource string) (container.ManagedContainer, bool) + cfg DockerComputeConfig + flags dockerFlags } // sshPortPool is shared by every region's view of one Docker daemon so two @@ -105,6 +120,10 @@ type sshPortPool struct { // NewDockerCompute creates a Compute provider backed by the local Docker // daemon. Honours DOCKER_HOST / DOCKER_TLS_VERIFY via mobyclient.FromEnv. func NewDockerCompute(cfg DockerComputeConfig) (*DockerCompute, error) { + if _, err := parseDockerFlags(cfg.Flags); err != nil { + return nil, err + } + cli, err := mobyclient.New(mobyclient.FromEnv) if err != nil { return nil, fmt.Errorf("create docker client: %w", err) @@ -123,12 +142,19 @@ func newDockerComputeWithAPI(api dockerAPI, cfg DockerComputeConfig) *DockerComp cfg.SSHHostIP = defaultSSHHostIP } + flags, _ := parseDockerFlags(cfg.Flags) + if flags.network != "" && cfg.Network == "" { + cfg.Network = flags.network + } + return &DockerCompute{ - api: api, - cfg: cfg, - ports: &sshPortPool{used: make(map[int]struct{})}, - nets: newNetRegistry(), - ll: &linkLocalState{}, + api: api, + cfg: cfg, + flags: flags, + mounts: newMountRegistry(), + ports: &sshPortPool{used: make(map[int]struct{})}, + nets: newNetRegistry(), + ll: &linkLocalState{}, fronts: newFrontRegistry(), } @@ -197,8 +223,9 @@ func mustParsePort(s string) mobynetwork.Port { // Launch implements Compute. func (d *DockerCompute) Launch(ctx context.Context, req LaunchRequest) (LaunchResult, error) { - if err := d.ensureImage(ctx); err != nil { - return LaunchResult{}, err + image, mounts, prepErr := d.prepareLaunch(ctx, req) + if prepErr != nil { + return LaunchResult{}, prepErr } hostPort, err := d.reserveSSHPort() @@ -218,21 +245,7 @@ func (d *DockerCompute) Launch(ctx context.Context, req LaunchRequest) (LaunchRe req.MetadataEndpoint = "" } - entrypoint := buildEntrypointScript(req.AuthorizedKey, req.UserData) - hostCfg, networkCfg, exposed := d.buildContainerConfigs(hostPort, req, vn, hasNet) - createOpts := mobyclient.ContainerCreateOptions{ - Config: &mobycontainer.Config{ - Image: d.cfg.Image, - ExposedPorts: exposed, - Cmd: []string{"/bin/sh", "-c", entrypoint}, - Labels: d.buildLabels(req), - Env: containerEnv(req), - Hostname: dockerHostname(req.InstanceID), - }, - HostConfig: hostCfg, - NetworkingConfig: networkCfg, - Name: "gopherstack-" + req.InstanceID, - } + createOpts := d.createOptions(req, image, mounts, hostPort, vn, hasNet) resp, createErr := d.api.ContainerCreate(ctx, createOpts) if createErr != nil { @@ -241,6 +254,8 @@ func (d *DockerCompute) Launch(ctx context.Context, req LaunchRequest) (LaunchRe return LaunchResult{}, fmt.Errorf("container create: %w", createErr) } + d.recordLaunchMounts(resp.ID, req) + if ll != nil { if attachErr := ll.Attach(ctx, resp.ID, req.InstanceID); attachErr != nil { d.cleanupAfterFailure(ctx, resp.ID, hostPort) @@ -299,6 +314,47 @@ func (d *DockerCompute) Launch(ctx context.Context, req LaunchRequest) (LaunchRe }, nil } +// createOptions assembles the container definition for an instance. +func (d *DockerCompute) createOptions( + req LaunchRequest, image string, mounts []mobymount.Mount, hostPort int, vn vpcNetwork, hasNet bool, +) mobyclient.ContainerCreateOptions { + hostCfg, networkCfg, exposed := d.buildContainerConfigs(hostPort, req, vn, hasNet) + hostCfg.Mounts = append(hostCfg.Mounts, mounts...) + + return mobyclient.ContainerCreateOptions{ + Config: &mobycontainer.Config{ + Image: image, + ExposedPorts: exposed, + Cmd: []string{"/bin/sh", "-c", buildEntrypointScript(req.AuthorizedKey, req.UserData)}, + Labels: d.buildLabels(req), + Env: d.flags.apply(hostCfg, containerEnv(req), exposed), + Hostname: dockerHostname(req.InstanceID), + }, + HostConfig: hostCfg, + NetworkingConfig: networkCfg, + Name: "gopherstack-" + req.InstanceID, + } +} + +// prepareLaunch resolves the image the instance runs, makes it available locally and creates its EBS volumes. +func (d *DockerCompute) prepareLaunch(ctx context.Context, req LaunchRequest) (string, []mobymount.Mount, error) { + image := d.cfg.Image + if req.DockerImage != "" { + image = req.DockerImage + } + + if err := d.ensureImage(ctx, image); err != nil { + return "", nil, err + } + + mounts, err := d.launchMounts(ctx, req) + if err != nil { + return "", nil, err + } + + return image, mounts, nil +} + // syntheticPublicDNS renders an AWS-style public hostname for an instance, // uniquely keyed by the instance ID so the embedded DNS server can register // one entry per launched container. The form mirrors what real EC2 returns: @@ -327,6 +383,8 @@ func (d *DockerCompute) Terminate(ctx context.Context, instanceID, providerID st timeout := stopTimeoutSec frontPort, fronted := d.closeFronts(providerID) + d.syncBackAll(ctx, providerID) + if _, err := d.api.ContainerStop(ctx, providerID, mobyclient.ContainerStopOptions{Timeout: &timeout}); err != nil { log.WarnContext(ctx, "docker stop on terminate", "instance", instanceID, "container", providerID, "error", err) } @@ -379,11 +437,23 @@ func (d *DockerCompute) Stop(ctx context.Context, _, providerID string) error { return nil } -// ensureImage pulls the configured image when not present locally. -// Errors here are non-fatal — Docker create will surface "no such image" -// itself if the pull failed for the wrong reason. -func (d *DockerCompute) ensureImage(ctx context.Context) error { - return registryauth.Pull(ctx, d.cfg.Image, d.pullOnce, registryauth.TaggerFrom(imageTagAdapter{d.api})) +// ensureImage pulls image when not present locally. Images in the gopherstack-ec2/ namespace are +// local-only and are checked, never pulled. +func (d *DockerCompute) ensureImage(ctx context.Context, image string) error { + if strings.HasPrefix(image, localImageRepo) { + ia, ok := d.api.(imageAPI) + if !ok { + return nil + } + + if _, err := ia.ImageInspect(ctx, image); err != nil { + return fmt.Errorf("%w: %s: %w", errLocalImage, image, err) + } + + return nil + } + + return registryauth.Pull(ctx, image, d.pullOnce, registryauth.TaggerFrom(imageTagAdapter{d.api})) } // imageTagAdapter exposes the moby ImageTag call, when the client has one, as a registryauth.ImageTagger. @@ -497,6 +567,7 @@ func (d *DockerCompute) buildLabels(req LaunchRequest) map[string]string { } maps.Copy(labels, d.cfg.Labels) + maps.Copy(labels, managedInstanceLabels(req)) return labels } @@ -600,6 +671,7 @@ func pickHostPort(ports mobynetwork.PortMap) int { } func (d *DockerCompute) cleanupAfterFailure(ctx context.Context, containerID string, port int) { + d.mounts.dropAll(containerID) timeout := stopTimeoutSec _, _ = d.api.ContainerStop(ctx, containerID, mobyclient.ContainerStopOptions{Timeout: &timeout}) _, _ = d.api.ContainerRemove(ctx, containerID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) diff --git a/services/ec2/docker_ebs_fake_internal_test.go b/services/ec2/docker_ebs_fake_internal_test.go new file mode 100644 index 0000000000..3fcb933a8a --- /dev/null +++ b/services/ec2/docker_ebs_fake_internal_test.go @@ -0,0 +1,326 @@ +package ec2 + +import ( + "archive/tar" + "bytes" + "context" + "io" + "slices" + "strconv" + "strings" + "sync" + + mobycontainer "github.com/moby/moby/api/types/container" + mobyimage "github.com/moby/moby/api/types/image" + mobyvolume "github.com/moby/moby/api/types/volume" + mobyclient "github.com/moby/moby/client" +) + +const fakeVolPrefix = "vol:" + +// richDockerAPI adds image, volume and copy support, backed by an in-memory filesystem, to fakeDockerAPI. +type richDockerAPI struct { + commitErr error + copyFromErr error + mountedVol map[string]map[string]string + *fakeDockerAPI + volumes map[string]mobyvolume.Volume + images map[string]string + helperVol map[string]string + files map[string]map[string][]byte + commits []mobyclient.ContainerCommitOptions + execs [][]string + removedVol []string + helperSeq int + rmu sync.Mutex + running bool +} + +func newRichDockerAPI(base *fakeDockerAPI) *richDockerAPI { + return &richDockerAPI{ + fakeDockerAPI: base, + files: map[string]map[string][]byte{}, + helperVol: map[string]string{}, + mountedVol: map[string]map[string]string{}, + volumes: map[string]mobyvolume.Volume{}, + images: map[string]string{}, + running: true, + } +} + +func (r *richDockerAPI) writeFile(store, path string, data []byte) { + if r.files[store] == nil { + r.files[store] = map[string][]byte{} + } + + r.files[store][path] = data +} + +func (r *richDockerAPI) resolve(id, path string) (string, string) { + if v, ok := r.helperVol[id]; ok && strings.HasPrefix(path, ebsHelperDir) { + return fakeVolPrefix + v, strings.TrimPrefix(path, ebsHelperDir) + } + + for target, vol := range r.mountedVol[id] { + if rest, ok := strings.CutPrefix(path, target); ok { + return fakeVolPrefix + vol, rest + } + } + + return id, path +} + +func (r *richDockerAPI) ContainerCreate( + ctx context.Context, opts mobyclient.ContainerCreateOptions, +) (mobyclient.ContainerCreateResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + if opts.HostConfig != nil { + mounts := map[string]string{} + for _, m := range opts.HostConfig.Mounts { + mounts[m.Target] = strings.TrimPrefix(m.Source, ebsVolumePrefix) + } + + if slices.Equal(opts.Config.Cmd, []string{ec2BooleanTrue}) { + r.helperSeq++ + id := "helper-" + strconv.Itoa(r.helperSeq) + r.helperVol[id] = strings.TrimPrefix(opts.HostConfig.Mounts[0].Source, ebsVolumePrefix) + r.images[id] = opts.Config.Image + + return mobyclient.ContainerCreateResult{ID: id}, nil + } + + r.mountedVol[r.fakeDockerAPI.containerID] = mounts + } + + r.images[r.fakeDockerAPI.containerID] = opts.Config.Image + + return r.fakeDockerAPI.ContainerCreate(ctx, opts) +} + +func (r *richDockerAPI) ContainerInspect( + ctx context.Context, id string, opts mobyclient.ContainerInspectOptions, +) (mobyclient.ContainerInspectResult, error) { + res, err := r.fakeDockerAPI.ContainerInspect(ctx, id, opts) + if err != nil { + return res, err + } + + r.rmu.Lock() + defer r.rmu.Unlock() + + res.Container.Config = &mobycontainer.Config{Image: r.images[id]} + res.Container.State = &mobycontainer.State{Running: r.running} + + return res, nil +} + +func (r *richDockerAPI) ContainerRemove( + ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions, +) (mobyclient.ContainerRemoveResult, error) { + r.rmu.Lock() + delete(r.helperVol, id) + r.rmu.Unlock() + + return r.fakeDockerAPI.ContainerRemove(ctx, id, opts) +} + +func (r *richDockerAPI) ContainerCommit( + _ context.Context, _ string, opts mobyclient.ContainerCommitOptions, +) (mobyclient.ContainerCommitResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + if r.commitErr != nil { + return mobyclient.ContainerCommitResult{}, r.commitErr + } + + r.commits = append(r.commits, opts) + r.images[opts.Reference] = "committed" + + return mobyclient.ContainerCommitResult{ID: "sha256:committed"}, nil +} + +func (r *richDockerAPI) ImageList( + _ context.Context, _ mobyclient.ImageListOptions, +) (mobyclient.ImageListResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + var items []mobyimage.Summary + for tag := range r.images { + if strings.HasPrefix(tag, localImageRepo) { + items = append(items, mobyimage.Summary{RepoTags: []string{tag}}) + } + } + + return mobyclient.ImageListResult{Items: items}, nil +} + +func (r *richDockerAPI) ImageInspect( + _ context.Context, ref string, _ ...mobyclient.ImageInspectOption, +) (mobyclient.ImageInspectResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + if _, ok := r.images[ref]; !ok && strings.HasPrefix(ref, localImageRepo) { + return mobyclient.ImageInspectResult{}, errFakeBoom + } + + return mobyclient.ImageInspectResult{}, nil +} + +func (r *richDockerAPI) VolumeCreate( + _ context.Context, opts mobyclient.VolumeCreateOptions, +) (mobyclient.VolumeCreateResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + v := mobyvolume.Volume{Name: opts.Name, Labels: opts.Labels} + if existing, ok := r.volumes[opts.Name]; ok { + v = existing + } + + r.volumes[opts.Name] = v + + return mobyclient.VolumeCreateResult{Volume: v}, nil +} + +func (r *richDockerAPI) VolumeList( + _ context.Context, opts mobyclient.VolumeListOptions, +) (mobyclient.VolumeListResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + var items []mobyvolume.Volume + + for _, v := range r.volumes { + match := true + + for label := range opts.Filters["label"] { + k, val, _ := strings.Cut(label, "=") + if v.Labels[k] != val { + match = false + } + } + + if match { + items = append(items, v) + } + } + + return mobyclient.VolumeListResult{Items: items}, nil +} + +func (r *richDockerAPI) VolumeRemove( + _ context.Context, name string, _ mobyclient.VolumeRemoveOptions, +) (mobyclient.VolumeRemoveResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + delete(r.volumes, name) + delete(r.files, fakeVolPrefix+strings.TrimPrefix(name, ebsVolumePrefix)) + r.removedVol = append(r.removedVol, name) + + return mobyclient.VolumeRemoveResult{}, nil +} + +func (r *richDockerAPI) CopyToContainer( + _ context.Context, id string, opts mobyclient.CopyToContainerOptions, +) (mobyclient.CopyToContainerResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + tr := tar.NewReader(opts.Content) + + for { + hdr, err := tr.Next() + if err == io.EOF { + return mobyclient.CopyToContainerResult{}, nil + } + + if err != nil { + return mobyclient.CopyToContainerResult{}, err + } + + if hdr.Typeflag != tar.TypeReg { + continue + } + + data, _ := io.ReadAll(tr) + store, rel := r.resolve( + id, + strings.TrimSuffix(opts.DestinationPath, "/")+"/"+strings.TrimPrefix(hdr.Name, "./"), + ) + r.writeFile(store, rel, data) + } +} + +func (r *richDockerAPI) CopyFromContainer( + _ context.Context, id string, opts mobyclient.CopyFromContainerOptions, +) (mobyclient.CopyFromContainerResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + if r.copyFromErr != nil && !strings.HasPrefix(id, "helper-") { + return mobyclient.CopyFromContainerResult{}, r.copyFromErr + } + + root := strings.TrimSuffix(opts.SourcePath, "/.") + store, rel := r.resolve(id, root) + + var buf bytes.Buffer + + tw := tar.NewWriter(&buf) + _ = tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: "./", Mode: 0o755}) + + for path, data := range r.files[store] { + if !strings.HasPrefix(path, rel+"/") { + continue + } + + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, Name: "./" + strings.TrimPrefix(path, rel+"/"), Mode: 0o644, Size: int64(len(data)), + }) + _, _ = tw.Write(data) + } + + _ = tw.Close() + + return mobyclient.CopyFromContainerResult{Content: io.NopCloser(&buf)}, nil +} + +func (r *richDockerAPI) ExecCreate( + _ context.Context, _ string, opts mobyclient.ExecCreateOptions, +) (mobyclient.ExecCreateResult, error) { + r.rmu.Lock() + defer r.rmu.Unlock() + + r.execs = append(r.execs, opts.Cmd) + + return mobyclient.ExecCreateResult{ID: "exec-1"}, nil +} + +func (r *richDockerAPI) ExecStart( + context.Context, string, mobyclient.ExecStartOptions, +) (mobyclient.ExecStartResult, error) { + return mobyclient.ExecStartResult{}, nil +} + +func (r *richDockerAPI) ExecInspect( + context.Context, string, mobyclient.ExecInspectOptions, +) (mobyclient.ExecInspectResult, error) { + return mobyclient.ExecInspectResult{}, nil +} + +// ctrFile reads a file from a fake container filesystem. +func (r *richDockerAPI) ctrFile(id, path string) ([]byte, bool) { + r.rmu.Lock() + defer r.rmu.Unlock() + + store, rel := r.resolve(id, path) + data, ok := r.files[store][rel] + + return data, ok +} diff --git a/services/ec2/docker_ebs_live_internal_test.go b/services/ec2/docker_ebs_live_internal_test.go new file mode 100644 index 0000000000..8264ae2c2b --- /dev/null +++ b/services/ec2/docker_ebs_live_internal_test.go @@ -0,0 +1,147 @@ +package ec2 + +import ( + "context" + "crypto/rand" + "encoding/hex" + "testing" + + mobyclient "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func randHex(t *testing.T, n int) string { + t.Helper() + + b := make([]byte, n) + _, err := rand.Read(b) + require.NoError(t, err) + + return hex.EncodeToString(b) +} + +// execExit runs a shell command in a container and returns its exit code. +func execExit(t *testing.T, cli *mobyclient.Client, containerID, script string) int { + t.Helper() + + ex, err := cli.ExecCreate(t.Context(), containerID, mobyclient.ExecCreateOptions{Cmd: []string{"sh", "-c", script}}) + require.NoError(t, err) + + _, err = cli.ExecStart(t.Context(), ex.ID, mobyclient.ExecStartOptions{Detach: true}) + require.NoError(t, err) + + code := -1 + + require.Eventually(t, func() bool { + res, inspectErr := cli.ExecInspect(t.Context(), ex.ID, mobyclient.ExecInspectOptions{}) + if inspectErr != nil || res.Running { + return false + } + + code = res.ExitCode + + return true + }, liveTimeout, liveTick) + + return code +} + +func liveEBSCompute(t *testing.T, instances ...string) (*DockerCompute, *mobyclient.Client, string) { + t.Helper() + + cmds := map[string][]string{} + for _, id := range instances { + cmds["gopherstack-"+id] = []string{"sleep", "600"} + } + + dc, cli := newLiveCompute(t, cmds) + dc.cfg.EBSVolumes = true + owner := "live/" + randHex(t, 4) + + t.Cleanup(func() { + dc.ReapVolumes(context.Background(), owner, map[string]struct{}{}) + }) + + return dc, cli, owner +} + +func TestDockerComputeLiveEBSVolumePersistsAcrossInstances(t *testing.T) { + t.Parallel() + + suffix := randHex(t, 4) + instA, instB := "i-ebsa"+suffix, "i-ebsb"+suffix + dc, cli, owner := liveEBSCompute(t, instA, instB) + vol := VolumeAttachSpec{VolumeID: "vol-live" + suffix, Device: "/dev/sdf", Owner: owner} + + a := launchLive(t, dc, LaunchRequest{InstanceID: instA}) + b := launchLive(t, dc, LaunchRequest{InstanceID: instB}) + + require.NoError(t, dc.AttachEBSVolume(t.Context(), a.ProviderID, vol)) + require.Zero(t, execExit(t, cli, a.ProviderID, "echo persisted > /mnt/ebs/sdf/data.txt")) + + require.NoError(t, dc.DetachEBSVolume(t.Context(), a.ProviderID, vol)) + assert.Zero(t, execExit(t, cli, a.ProviderID, "test ! -e /mnt/ebs/sdf"), "detach clears the device path") + + require.NoError(t, dc.AttachEBSVolume(t.Context(), b.ProviderID, vol)) + assert.Zero(t, execExit(t, cli, b.ProviderID, "grep -q persisted /mnt/ebs/sdf/data.txt"), + "file written on instance A is on the volume attached to instance B") +} + +func TestDockerComputeLiveEBSLaunchMountPersistsAcrossTerminate(t *testing.T) { + t.Parallel() + + suffix := randHex(t, 4) + instA, instB := "i-ebsc"+suffix, "i-ebsd"+suffix + dc, cli, owner := liveEBSCompute(t, instA, instB) + vol := VolumeAttachSpec{VolumeID: "vol-mnt" + suffix, Device: "/dev/sdg", Owner: owner} + + a, err := dc.Launch(t.Context(), LaunchRequest{InstanceID: instA, Volumes: []VolumeAttachSpec{vol}}) + require.NoError(t, err) + require.Zero(t, execExit(t, cli, a.ProviderID, "echo kept > /mnt/ebs/sdg/k.txt")) + require.NoError(t, dc.Terminate(t.Context(), instA, a.ProviderID)) + + b := launchLive(t, dc, LaunchRequest{InstanceID: instB, Volumes: []VolumeAttachSpec{vol}}) + assert.Zero(t, execExit(t, cli, b.ProviderID, "grep -q kept /mnt/ebs/sdg/k.txt"), + "a retained volume's data survives termination and relaunch") +} + +func TestDockerComputeLiveCommitAndLaunchFromLocalImage(t *testing.T) { + t.Parallel() + + suffix := randHex(t, 4) + instA, instB := "i-cmta"+suffix, "i-cmtb"+suffix + dc, cli, _ := liveEBSCompute(t, instA, instB) + amiID := "ami-" + randHex(t, 8) + + a := launchLive(t, dc, LaunchRequest{InstanceID: instA}) + require.Zero(t, execExit(t, cli, a.ProviderID, "echo golden > /marker")) + + ref, err := dc.CommitInstance(t.Context(), a.ProviderID, "live-"+suffix, amiID) + require.NoError(t, err) + + t.Cleanup(func() { + _, _ = cli.ImageRemove(context.Background(), ref, mobyclient.ImageRemoveOptions{Force: true}) + }) + + found, err := dc.DiscoverImages(t.Context()) + require.NoError(t, err) + idx := indexOfAMI(found, amiID) + require.Less(t, idx, len(found)) + assert.Equal(t, "live-"+suffix, found[idx].Name) + assert.Equal(t, ref, found[idx].DockerRef) + assert.Equal(t, amiID, found[idx].AMIID, "the committed image is discovered by its AMI tag") + + b := launchLive(t, dc, LaunchRequest{InstanceID: instB, DockerImage: ref}) + assert.Zero(t, execExit(t, cli, b.ProviderID, "grep -q golden /marker"), "instance runs the committed image") +} + +func indexOfAMI(list []DiscoveredImage, amiID string) int { + for i, d := range list { + if d.AMIID == amiID { + return i + } + } + + return len(list) +} diff --git a/services/ec2/docker_flags.go b/services/ec2/docker_flags.go new file mode 100644 index 0000000000..6115903168 --- /dev/null +++ b/services/ec2/docker_flags.go @@ -0,0 +1,360 @@ +package ec2 + +import ( + "errors" + "fmt" + "math" + "net/netip" + "regexp" + "strconv" + "strings" + "unicode" + + mobycontainer "github.com/moby/moby/api/types/container" + mobynetwork "github.com/moby/moby/api/types/network" +) + +// ErrDockerFlags is returned when GOPHERSTACK_EC2_DOCKER_FLAGS cannot be parsed. +var ErrDockerFlags = errors.New("invalid ec2 docker flags") + +const ( + bytesPerKiB = 1024 + nanoPerCPU = 1_000_000_000 + flagPartsMin = 2 + flagPartsMax = 3 + publishPartsHost = 3 +) + +var ( + capNameRE = regexp.MustCompile(`^[A-Za-z_]+$`) + netNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) +) + +type publishFlag struct { + hostIP string + proto string + hostPort int + containerPort int +} + +// dockerFlags is the validated subset of `docker run` options applied to every launched container. +type dockerFlags struct { + network string + env []string + binds []string + capAdd []string + capDrop []string + publish []publishFlag + memory int64 + nanoCPUs int64 + privileged bool +} + +func flagErr(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrDockerFlags, fmt.Sprintf(format, args...)) +} + +// parseDockerFlags parses a docker-run style option string. Only --privileged, -e/--env, -v/--volume, +// --cap-add, --cap-drop, -m/--memory, --cpus, --network/--net and -p/--publish are accepted. +// The result is applied to the Docker API directly; no shell ever sees the string. +func parseDockerFlags(s string) (dockerFlags, error) { + var f dockerFlags + + words, err := splitFlagWords(s) + if err != nil { + return f, err + } + + for i := 0; i < len(words); i++ { + name, val, hasVal := splitFlagWord(words[i]) + if name == "--privileged" && !hasVal { + f.privileged = true + + continue + } + + if !isValueFlag(name) { + return dockerFlags{}, flagErr("unsupported flag %q (supported: --privileged, -e, -v, --cap-add, "+ + "--cap-drop, --memory, --cpus, --network, -p)", words[i]) + } + + if !hasVal { + i++ + if i >= len(words) { + return dockerFlags{}, flagErr("flag %q needs a value", name) + } + + val = words[i] + } + + if applyErr := f.set(name, val); applyErr != nil { + return dockerFlags{}, applyErr + } + } + + return f, nil +} + +func isValueFlag(name string) bool { + switch name { + case "-e", "--env", "-v", "--volume", "--cap-add", "--cap-drop", "-m", "--memory", + "--cpus", "--network", "--net", "-p", "--publish": + return true + } + + return false +} + +// splitFlagWord splits "--flag=value" (long flags only) into its parts. +func splitFlagWord(w string) (string, string, bool) { + if strings.HasPrefix(w, "--") { + if name, val, ok := strings.Cut(w, "="); ok { + return name, val, true + } + } + + return w, "", false +} + +func (f *dockerFlags) set(name, val string) error { + var err error + + switch name { + case "-e", "--env": + err = f.addEnv(val) + case "-v", "--volume": + err = f.addBind(val) + case "--cap-add": + f.capAdd, err = appendCap(f.capAdd, val) + case "--cap-drop": + f.capDrop, err = appendCap(f.capDrop, val) + case "-m", "--memory": + f.memory, err = parseMemory(val) + case "--cpus": + f.nanoCPUs, err = parseCPUs(val) + case "--network", "--net": + if !netNameRE.MatchString(val) { + err = flagErr("invalid network name %q", val) + } + + f.network = val + default: + err = f.addPublish(val) + } + + return err +} + +func (f *dockerFlags) addEnv(val string) error { + key, _, ok := strings.Cut(val, "=") + if !ok || key == "" { + return flagErr("-e %q must be KEY=VALUE", val) + } + + f.env = append(f.env, val) + + return nil +} + +func (f *dockerFlags) addBind(val string) error { + parts := strings.Split(val, ":") + if len(parts) < flagPartsMin || len(parts) > flagPartsMax { + return flagErr("-v %q must be source:target[:ro|rw]", val) + } + + if parts[0] == "" || !strings.HasPrefix(parts[1], "/") { + return flagErr("-v %q needs a non-empty source and an absolute target path", val) + } + + if len(parts) == flagPartsMax && parts[2] != "ro" && parts[2] != "rw" { + return flagErr("-v %q: mode must be ro or rw", val) + } + + f.binds = append(f.binds, val) + + return nil +} + +func appendCap(list []string, val string) ([]string, error) { + if !capNameRE.MatchString(val) { + return list, flagErr("invalid capability %q", val) + } + + return append(list, strings.ToUpper(val)), nil +} + +func parseMemory(val string) (int64, error) { + v, mult := strings.ToLower(val), int64(1) + + for _, u := range []struct { + suffix string + mult int64 + }{{"b", 1}, {"k", bytesPerKiB}, {"m", bytesPerKiB * bytesPerKiB}, {"g", bytesPerKiB * bytesPerKiB * bytesPerKiB}} { + if trimmed, ok := strings.CutSuffix(v, u.suffix); ok { + v, mult = trimmed, u.mult + + break + } + } + + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n <= 0 || n > math.MaxInt64/mult { + return 0, flagErr("invalid --memory %q (use a positive integer with optional b, k, m or g)", val) + } + + return n * mult, nil +} + +func parseCPUs(val string) (int64, error) { + c, err := strconv.ParseFloat(val, 64) + if err != nil || c <= 0 || c > float64(math.MaxInt32) { + return 0, flagErr("invalid --cpus %q (use a positive number)", val) + } + + return int64(c * nanoPerCPU), nil +} + +func (f *dockerFlags) addPublish(val string) error { + spec, proto, _ := strings.Cut(val, "/") + if proto == "" { + proto = protoTCP + } + + if proto != protoTCP && proto != "udp" { + return flagErr("-p %q: protocol must be tcp or udp", val) + } + + parts := strings.Split(spec, ":") + if len(parts) < flagPartsMin || len(parts) > publishPartsHost { + return flagErr("-p %q must be [hostIP:]hostPort:containerPort[/tcp|udp]", val) + } + + p := publishFlag{proto: proto} + + if len(parts) == publishPartsHost { + if _, err := netip.ParseAddr(parts[0]); err != nil { + return flagErr("-p %q: invalid host IP", val) + } + + p.hostIP, parts = parts[0], parts[1:] + } + + var err error + if p.hostPort, err = parsePortNumber(parts[0]); err != nil { + return flagErr("-p %q: invalid host port", val) + } + + if p.containerPort, err = parsePortNumber(parts[1]); err != nil { + return flagErr("-p %q: invalid container port", val) + } + + if p.containerPort == sshContainerPort && proto == protoTCP { + return flagErr("-p %q: container port 22/tcp is reserved for sshd", val) + } + + f.publish = append(f.publish, p) + + return nil +} + +func parsePortNumber(s string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > maxPort { + return 0, flagErr("port %q out of range", s) + } + + return n, nil +} + +// sshContainerPort is the in-container sshd port. +const sshContainerPort = 22 + +// wordSplitter accumulates shell-like words: whitespace separates, quotes group, backslash escapes. +type wordSplitter struct { + words []string + cur strings.Builder + quote rune + inWord bool + escaped bool +} + +func (w *wordSplitter) feed(r rune) { + switch { + case w.escaped: + w.cur.WriteRune(r) + + w.escaped = false + case r == '\\' && w.quote != '\'': + w.escaped, w.inWord = true, true + case w.quote != 0: + if r == w.quote { + w.quote = 0 + } else { + w.cur.WriteRune(r) + } + case r == '\'' || r == '"': + w.quote, w.inWord = r, true + case unicode.IsSpace(r): + w.flush() + default: + w.cur.WriteRune(r) + + w.inWord = true + } +} + +func (w *wordSplitter) flush() { + if w.inWord { + w.words = append(w.words, w.cur.String()) + w.cur.Reset() + + w.inWord = false + } +} + +// splitFlagWords tokenises s on whitespace, honouring single and double quotes and backslash escapes. +func splitFlagWords(s string) ([]string, error) { + var w wordSplitter + + for _, r := range s { + w.feed(r) + } + + if w.quote != 0 || w.escaped { + return nil, flagErr("unterminated quote or escape in %q", s) + } + + w.flush() + + return w.words, nil +} + +// apply layers the flags onto a container's host config, environment and exposed ports. +func (f *dockerFlags) apply(hc *mobycontainer.HostConfig, env []string, exposed mobynetwork.PortSet) []string { + hc.Privileged = hc.Privileged || f.privileged + hc.Binds = append(hc.Binds, f.binds...) + hc.CapAdd = append(hc.CapAdd, f.capAdd...) + hc.CapDrop = append(hc.CapDrop, f.capDrop...) + + if f.memory > 0 { + hc.Memory = f.memory + } + + if f.nanoCPUs > 0 { + hc.NanoCPUs = f.nanoCPUs + } + + for _, p := range f.publish { + port, err := mobynetwork.ParsePort(fmt.Sprintf("%d/%s", p.containerPort, p.proto)) + if err != nil { + continue + } + + exposed[port] = struct{}{} + hc.PortBindings[port] = append(hc.PortBindings[port], mobynetwork.PortBinding{ + HostIP: parseHostIP(p.hostIP), HostPort: strconv.Itoa(p.hostPort), + }) + } + + return append(env, f.env...) +} diff --git a/services/ec2/docker_flags_internal_test.go b/services/ec2/docker_flags_internal_test.go new file mode 100644 index 0000000000..f86fe22f1c --- /dev/null +++ b/services/ec2/docker_flags_internal_test.go @@ -0,0 +1,152 @@ +package ec2 + +import ( + "testing" + + mobycontainer "github.com/moby/moby/api/types/container" + mobynetwork "github.com/moby/moby/api/types/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDockerFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + check func(t *testing.T, f dockerFlags) + name string + in string + wantErr string + }{ + {name: "empty", in: "", check: func(t *testing.T, f dockerFlags) { + t.Helper() + t.Helper() + assert.Equal(t, dockerFlags{}, f) + }}, + { + name: "privileged_env_volume", + in: `--privileged -e A=1 --env=B=two -v /host:/ctr:ro --volume vol:/data`, + check: func(t *testing.T, f dockerFlags) { + t.Helper() + assert.True(t, f.privileged) + assert.Equal(t, []string{"A=1", "B=two"}, f.env) + assert.Equal(t, []string{"/host:/ctr:ro", "vol:/data"}, f.binds) + }, + }, + { + name: "caps_resources_network", + in: `--cap-add net_admin --cap-drop=MKNOD -m 512m --cpus 1.5 --network=mynet`, + check: func(t *testing.T, f dockerFlags) { + t.Helper() + assert.Equal(t, []string{"NET_ADMIN"}, f.capAdd) + assert.Equal(t, []string{"MKNOD"}, f.capDrop) + assert.Equal(t, int64(512*1024*1024), f.memory) + assert.Equal(t, int64(1_500_000_000), f.nanoCPUs) + assert.Equal(t, "mynet", f.network) + }, + }, + { + name: "publish_forms", + in: `-p 8080:80 -p 127.0.0.1:9000:9000/udp`, + check: func(t *testing.T, f dockerFlags) { + t.Helper() + assert.Equal(t, []publishFlag{ + {hostPort: 8080, containerPort: 80, proto: "tcp"}, + {hostIP: "127.0.0.1", hostPort: 9000, containerPort: 9000, proto: "udp"}, + }, f.publish) + }, + }, + { + name: "quoted_value", + in: `-e "GREETING=hello world" -e 'X=y'`, + check: func(t *testing.T, f dockerFlags) { + t.Helper() + assert.Equal(t, []string{"GREETING=hello world", "X=y"}, f.env) + }, + }, + {name: "unknown_flag", in: "--pid=host", wantErr: "unsupported flag"}, + {name: "unknown_short", in: "-d", wantErr: "unsupported flag"}, + {name: "positional", in: "ubuntu", wantErr: "unsupported flag"}, + {name: "missing_value", in: "-e", wantErr: "needs a value"}, + {name: "env_without_equals", in: "-e FOO", wantErr: "KEY=VALUE"}, + {name: "relative_target", in: "-v /a:rel", wantErr: "absolute target"}, + {name: "bad_volume_mode", in: "-v /a:/b:zz", wantErr: "mode must be ro or rw"}, + {name: "bad_memory", in: "-m lots", wantErr: "invalid --memory"}, + {name: "negative_cpus", in: "--cpus -1", wantErr: "invalid --cpus"}, + {name: "bad_cap", in: "--cap-add 'a;b'", wantErr: "invalid capability"}, + {name: "bad_port", in: "-p 70000:80", wantErr: "invalid host port"}, + {name: "ssh_port_reserved", in: "-p 2222:22", wantErr: "reserved for sshd"}, + {name: "bad_proto", in: "-p 80:80/sctp", wantErr: "protocol must be tcp or udp"}, + {name: "bad_network", in: "--network 'a b;c'", wantErr: "invalid network name"}, + {name: "unterminated_quote", in: `-e "A=1`, wantErr: "unterminated"}, + {name: "privileged_with_value", in: "--privileged=true", wantErr: "unsupported flag"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f, err := parseDockerFlags(tt.in) + if tt.wantErr != "" { + require.ErrorIs(t, err, ErrDockerFlags) + assert.Contains(t, err.Error(), tt.wantErr) + + return + } + + require.NoError(t, err) + tt.check(t, f) + }) + } +} + +func TestDockerFlagsApply(t *testing.T) { + t.Parallel() + + f, err := parseDockerFlags(`--privileged -e A=1 -v /h:/c --cap-add SYS_ADMIN -m 64m --cpus 2 -p 8080:80`) + require.NoError(t, err) + + hc := &mobycontainer.HostConfig{PortBindings: mobynetwork.PortMap{}} + exposed := mobynetwork.PortSet{} + env := f.apply(hc, []string{"BASE=1"}, exposed) + + assert.Equal(t, []string{"BASE=1", "A=1"}, env) + assert.True(t, hc.Privileged) + assert.Equal(t, []string{"/h:/c"}, hc.Binds) + assert.Equal(t, []string{"SYS_ADMIN"}, hc.CapAdd) + assert.Equal(t, int64(64*1024*1024), hc.Memory) + assert.Equal(t, int64(2_000_000_000), hc.NanoCPUs) + + port, perr := mobynetwork.ParsePort("80/tcp") + require.NoError(t, perr) + assert.Contains(t, exposed, port) + require.Len(t, hc.PortBindings[port], 1) + assert.Equal(t, "8080", hc.PortBindings[port][0].HostPort) +} + +func TestNewDockerComputeRejectsBadFlags(t *testing.T) { + t.Parallel() + + _, err := NewDockerCompute(DockerComputeConfig{Flags: "--pid=host"}) + require.ErrorIs(t, err, ErrDockerFlags) +} + +func TestDockerComputeLaunchAppliesFlags(t *testing.T) { + t.Parallel() + + api := &fakeDockerAPI{containerID: "c1", containerIP: "172.17.0.2"} + dc := newDockerComputeWithAPI(api, DockerComputeConfig{ + Flags: `--privileged -e K=V -v /h:/c:ro -m 1g --network flagnet`, + }) + + _, err := dc.Launch(t.Context(), LaunchRequest{InstanceID: "i-1", ImageID: "ami-1"}) + require.NoError(t, err) + require.Len(t, api.created, 1) + + got := api.created[0] + assert.True(t, got.HostConfig.Privileged) + assert.Contains(t, got.Config.Env, "K=V") + assert.Equal(t, []string{"/h:/c:ro"}, got.HostConfig.Binds) + assert.Equal(t, int64(1<<30), got.HostConfig.Memory) + assert.Equal(t, mobycontainer.NetworkMode("flagnet"), got.HostConfig.NetworkMode) +} diff --git a/services/ec2/docker_hooks.go b/services/ec2/docker_hooks.go new file mode 100644 index 0000000000..1e1a85d340 --- /dev/null +++ b/services/ec2/docker_hooks.go @@ -0,0 +1,156 @@ +package ec2 + +import ( + "context" + "fmt" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +// ComputeOwner scopes Docker resources to this backend's account and region. +func (b *InMemoryBackend) ComputeOwner() string { return b.AccountID + "/" + b.Region } + +// LaunchVolumeSpecs returns the non-root EBS volumes attached to an instance. +func (b *InMemoryBackend) LaunchVolumeSpecs(instanceID string) []VolumeAttachSpec { + b.mu.RLock("LaunchVolumeSpecs") + defer b.mu.RUnlock() + + inst, ok := b.instances.Get(instanceID) + if !ok { + return nil + } + + var out []VolumeAttachSpec + + for _, vol := range b.volumes.All() { + att := vol.Attachment + if att == nil || att.InstanceID != instanceID || att.Device == inst.RootDeviceName { + continue + } + + out = append(out, VolumeAttachSpec{VolumeID: vol.ID, Device: att.Device, Owner: b.AccountID + "/" + b.Region}) + } + + return out +} + +// AttachedVolumeSpec returns a volume's attachment and the instance it is attached to. +func (b *InMemoryBackend) AttachedVolumeSpec(volumeID string) (VolumeAttachSpec, string, bool) { + b.mu.RLock("AttachedVolumeSpec") + defer b.mu.RUnlock() + + vol, ok := b.volumes.Get(volumeID) + if !ok || vol.Attachment == nil { + return VolumeAttachSpec{}, "", false + } + + return VolumeAttachSpec{ + VolumeID: volumeID, Device: vol.Attachment.Device, Owner: b.AccountID + "/" + b.Region, + }, vol.Attachment.InstanceID, true +} + +// LiveVolumeIDs returns the ID of every EBS volume that exists. +func (b *InMemoryBackend) LiveVolumeIDs() map[string]struct{} { + b.mu.RLock("LiveVolumeIDs") + defer b.mu.RUnlock() + + out := make(map[string]struct{}, b.volumes.Len()) + for _, vol := range b.volumes.All() { + out[vol.ID] = struct{}{} + } + + return out +} + +// ebsCompute returns the volume-capable provider and backend when EBS mapping is enabled. +func (h *Handler) ebsCompute() (computeCapableBackend, volumeCompute, volumeBackend) { + cb, c := h.computeBackend() + + vc, ok := c.(volumeCompute) + if !ok || !vc.EBSEnabled() { + return nil, nil, nil + } + + vb, ok := h.Backend.(volumeBackend) + if !ok { + return nil, nil, nil + } + + return cb, vc, vb +} + +// attachVolumeOnCompute mirrors an AttachVolume onto the instance's container. +func (h *Handler) attachVolumeOnCompute(ctx context.Context, volumeID string) error { + cb, vc, vb := h.ebsCompute() + if vc == nil { + return nil + } + + spec, instanceID, ok := vb.AttachedVolumeSpec(volumeID) + if !ok { + return nil + } + + providerID := cb.LookupInstanceProviderID(instanceID) + if providerID == "" { + return nil + } + + if err := vc.AttachEBSVolume(ctx, providerID, spec); err != nil { + return fmt.Errorf("attach volume %s to container: %w", volumeID, err) + } + + return nil +} + +// detachVolumeOnCompute writes a volume's container data back before the volume is detached. +func (h *Handler) detachVolumeOnCompute(ctx context.Context, volumeID string) error { + cb, vc, vb := h.ebsCompute() + if vc == nil { + return nil + } + + spec, instanceID, ok := vb.AttachedVolumeSpec(volumeID) + if !ok { + return nil + } + + providerID := cb.LookupInstanceProviderID(instanceID) + if providerID == "" { + return nil + } + + if err := vc.DetachEBSVolume(ctx, providerID, spec); err != nil { + return fmt.Errorf("detach volume %s from container: %w", volumeID, err) + } + + return nil +} + +// reapComputeVolumes removes the Docker volumes of EBS volumes that no longer exist. +func (h *Handler) reapComputeVolumes(ctx context.Context) { + _, vc, vb := h.ebsCompute() + if vc == nil { + return + } + + if removed := vc.ReapVolumes(ctx, vb.ComputeOwner(), vb.LiveVolumeIDs()); len(removed) > 0 { + logger.Load(ctx).InfoContext(ctx, "ec2 removed docker volumes", "volumes", removed) + } +} + +// bindRegisteredImage binds a docker:// ImageLocation to a freshly registered AMI. +func (h *Handler) bindRegisteredImage(imageID, location string) error { + ref, ok := strings.CutPrefix(location, dockerImageLocationPrefix) + if !ok { + return nil + } + + ib, isIB := h.Backend.(imageBindingBackend) + if !isIB { + return nil + } + + return ib.BindDockerImage(imageID, ref) +} diff --git a/services/ec2/docker_images.go b/services/ec2/docker_images.go new file mode 100644 index 0000000000..416ce0629a --- /dev/null +++ b/services/ec2/docker_images.go @@ -0,0 +1,328 @@ +package ec2 + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + mobyclient "github.com/moby/moby/client" +) + +const ( + // localImageRepo is the repository prefix of local Docker images that back AMIs: an image tagged + // "gopherstack-ec2/:" is the AMI named . + localImageRepo = "gopherstack-ec2/" + // dockerImageLocationPrefix marks a RegisterImage ImageLocation as a Docker image reference. + dockerImageLocationPrefix = "docker://" + labelCommittedFrom = dockerLabelPrefix + "committed-from" + maxCommitNameLen = 64 +) + +var ( + amiTagRE = regexp.MustCompile(`^ami-[0-9a-f]{8,}$`) + nameInvalidRE = regexp.MustCompile(`[^a-z0-9._-]+`) +) + +// DiscoveredImage is a local Docker image that follows the AMI tagging convention. +type DiscoveredImage struct { + AMIID string + Name string + DockerRef string + Architecture string +} + +// imageBinder is implemented by Compute providers that run a Docker image per AMI. +type imageBinder interface { + // CommitInstance snapshots the instance's container into a new local image bound to amiID. + CommitInstance(ctx context.Context, providerID, name, amiID string) (string, error) + // DiscoverImages lists local images tagged with the AMI convention. + DiscoverImages(ctx context.Context) ([]DiscoveredImage, error) +} + +// imageBindingBackend is the backend side of AMI to Docker image binding. +type imageBindingBackend interface { + DockerImageFor(imageID string) (string, bool) + BindDockerImage(imageID, ref string) error + RegisterDiscoveredImage(d DiscoveredImage) bool +} + +// seededDockerImage maps a seeded public AMI to the Docker image that best matches its userland. +// Windows and Bottlerocket have no Linux container equivalent and map to "" (the configured default image). +func seededDockerImage(img *AMIStub) string { + name := strings.ToLower(img.Name) + + switch { + case img.Platform == platformWindows, strings.Contains(name, "bottlerocket"), strings.Contains(name, "windows"): + return "" + case strings.Contains(name, "al2023"): + return "amazonlinux:2023" + case strings.Contains(name, "amzn2"), strings.Contains(name, "amazon-eks"): + return "amazonlinux:2" + case strings.Contains(name, "amzn-ami"): + return "amazonlinux:1" + case strings.Contains(name, "focal"), strings.Contains(name, "20.04"): + return "ubuntu:20.04" + case strings.Contains(name, "noble"), strings.Contains(name, "24.04"): + return "ubuntu:24.04" + case strings.Contains(name, "jammy"), strings.Contains(name, "22.04"), strings.Contains(name, "ubuntu"): + return "ubuntu:22.04" + } + + return "" +} + +// DockerImageFor returns the Docker image bound to an AMI ("" when the AMI has none and the provider +// default applies) and whether the AMI exists. +func (b *InMemoryBackend) DockerImageFor(imageID string) (string, bool) { + b.mu.RLock("DockerImageFor") + defer b.mu.RUnlock() + + img := b.lookupImageLocked(imageID) + if img == nil { + return "", false + } + + return dockerRefOf(img), true +} + +func dockerRefOf(img *AMIStub) string { + if img.DockerImage != "" { + return img.DockerImage + } + + return seededDockerImage(img) +} + +// BindDockerImage binds a Docker image reference to a self-owned AMI. +func (b *InMemoryBackend) BindDockerImage(imageID, ref string) error { + if ref == "" || strings.ContainsAny(ref, " \t\r\n") { + return fmt.Errorf("%w: %q is not a Docker image reference", ErrInvalidParameter, ref) + } + + b.mu.Lock("BindDockerImage") + defer b.mu.Unlock() + + img, ok := b.images.Get(imageID) + if !ok { + return fmt.Errorf("%w: %s", ErrImageNotFound, imageID) + } + + img.DockerImage = ref + + return nil +} + +// RegisterDiscoveredImage adds a locally tagged Docker image as a self-owned AMI; false when the AMI id is known. +func (b *InMemoryBackend) RegisterDiscoveredImage(d DiscoveredImage) bool { + b.mu.Lock("RegisterDiscoveredImage") + defer b.mu.Unlock() + + if b.lookupImageLocked(d.AMIID) != nil { + return false + } + + arch := d.Architecture + if arch == "" { + arch = archX8664 + } + + b.images.Put(&AMIStub{ + ImageID: d.AMIID, + Name: d.Name, + Description: "Local Docker image " + d.DockerRef, + Architecture: arch, + RootDeviceName: defaultRootDeviceName, + State: stateAvailable, + OwnerID: b.AccountID, + CreationTime: time.Now().UTC(), + DockerImage: d.DockerRef, + }) + + return true +} + +// refreshDockerImages registers locally tagged Docker images as AMIs; a no-op without an image-capable provider. +func (h *Handler) refreshDockerImages(ctx context.Context) { + ib, ok := h.Backend.(imageBindingBackend) + if !ok { + return + } + + _, c := h.computeBackend() + + binder, ok := c.(imageBinder) + if !ok { + return + } + + found, err := binder.DiscoverImages(ctx) + if err != nil { + return + } + + for _, d := range found { + ib.RegisterDiscoveredImage(d) + } +} + +// requireLaunchableImage rejects an unknown AMI when a docker provider runs the instance. +func (h *Handler) requireLaunchableImage(ctx context.Context, imageID string) error { + ib, ok := h.Backend.(imageBindingBackend) + if !ok { + return nil + } + + _, c := h.computeBackend() + if _, isBinder := c.(imageBinder); !isBinder { + return nil + } + + if _, known := ib.DockerImageFor(imageID); known { + return nil + } + + h.refreshDockerImages(ctx) + + if _, known := ib.DockerImageFor(imageID); !known { + return fmt.Errorf("%w: The image id '[%s]' does not exist", ErrImageNotFound, imageID) + } + + return nil +} + +// commitImageFor snapshots a docker-backed instance into a local image and binds it to the new AMI. +func (h *Handler) commitImageFor(ctx context.Context, instanceID string, image *AMIStub) error { + cb, c := h.computeBackend() + + binder, ok := c.(imageBinder) + if !ok { + return nil + } + + providerID := cb.LookupInstanceProviderID(instanceID) + if providerID == "" { + return nil + } + + ref, err := binder.CommitInstance(ctx, providerID, image.Name, image.ImageID) + if err != nil { + return err + } + + if ib, isIB := h.Backend.(imageBindingBackend); isIB { + return ib.BindDockerImage(image.ImageID, ref) + } + + return nil +} + +// sanitizeImageName turns an AMI name into a valid Docker repository path component. +func sanitizeImageName(name string) string { + n := nameInvalidRE.ReplaceAllString(strings.ToLower(name), "-") + n = strings.Trim(n, "-._") + + if len(n) > maxCommitNameLen { + n = strings.Trim(n[:maxCommitNameLen], "-._") + } + + if n == "" { + n = "image" + } + + return n +} + +func localImageRef(name, amiID string) string { + return localImageRepo + sanitizeImageName(name) + ":" + amiID +} + +// parseLocalImageTag extracts (name, amiID) from "gopherstack-ec2/:". +func parseLocalImageTag(tag string) (string, string, bool) { + rest, ok := strings.CutPrefix(tag, localImageRepo) + if !ok { + return "", "", false + } + + i := strings.LastIndex(rest, ":") + if i <= 0 || !amiTagRE.MatchString(rest[i+1:]) { + return "", "", false + } + + return rest[:i], rest[i+1:], true +} + +// imageAPI is the Docker image surface; a dockerAPI that lacks it keeps the single-image behaviour. +type imageAPI interface { + ContainerCommit( + ctx context.Context, containerID string, opts mobyclient.ContainerCommitOptions, + ) (mobyclient.ContainerCommitResult, error) + ImageList(ctx context.Context, opts mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) + ImageInspect( + ctx context.Context, imageID string, opts ...mobyclient.ImageInspectOption, + ) (mobyclient.ImageInspectResult, error) +} + +// CommitInstance implements imageBinder. +func (d *DockerCompute) CommitInstance(ctx context.Context, providerID, name, amiID string) (string, error) { + ia, ok := d.api.(imageAPI) + if !ok { + return "", errNoImageCommit + } + + ref := localImageRef(name, amiID) + if _, err := ia.ContainerCommit(ctx, providerID, mobyclient.ContainerCommitOptions{ + Reference: ref, + Comment: "gopherstack ec2 CreateImage " + amiID, + }); err != nil { + return "", fmt.Errorf("docker commit %s: %w", providerID, err) + } + + return ref, nil +} + +// DiscoverImages implements imageBinder. +func (d *DockerCompute) DiscoverImages(ctx context.Context) ([]DiscoveredImage, error) { + ia, ok := d.api.(imageAPI) + if !ok { + return nil, nil + } + + list, err := ia.ImageList(ctx, mobyclient.ImageListOptions{ + Filters: mobyclient.Filters{}.Add("reference", localImageRepo+"*"), + }) + if err != nil { + return nil, fmt.Errorf("docker image list: %w", err) + } + + var out []DiscoveredImage + + for _, item := range list.Items { + for _, tag := range item.RepoTags { + name, amiID, match := parseLocalImageTag(tag) + if !match { + continue + } + + out = append(out, DiscoveredImage{ + AMIID: amiID, Name: name, DockerRef: tag, Architecture: d.imageArch(ctx, ia, tag), + }) + } + } + + return out, nil +} + +func (d *DockerCompute) imageArch(ctx context.Context, ia imageAPI, ref string) string { + res, err := ia.ImageInspect(ctx, ref) + if err != nil { + return archX8664 + } + + if res.Architecture == "arm64" { + return archArm64 + } + + return archX8664 +} diff --git a/services/ec2/docker_images_internal_test.go b/services/ec2/docker_images_internal_test.go new file mode 100644 index 0000000000..4acb4676fa --- /dev/null +++ b/services/ec2/docker_images_internal_test.go @@ -0,0 +1,356 @@ +package ec2 + +import ( + "maps" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + amiAL2 = "ami-0c55b159cbfafe1f0" + amiUbuntu22 = "ami-0eb260c4d5475b901" + amiWindows = "ami-09d3b3274b6c5d4aa" + amiAL2023 = "ami-0f713cec7ef8c2ed1" + amiUbuntu24 = "ami-0663e6754638bbff3" + amiUbuntu20 = "ami-057f486f0e508831b" + amiBottle = "ami-0a66ee52e0b6c6cdb" + amiDiscover = "ami-0123456789abcdef0" + richProvider = "ctr-1" +) + +// newDockerHandler returns a handler whose backend launches instances through a DockerCompute over a fake. +func newDockerHandler(t *testing.T, cfg DockerComputeConfig) (*Handler, *InMemoryBackend, *richDockerAPI) { + t.Helper() + + base := &fakeDockerAPI{containerID: richProvider, containerIP: "172.17.0.9"} + api := newRichDockerAPI(base) + b := NewInMemoryBackend("000000000000", "us-east-1") + b.WithCompute(newDockerComputeWithAPI(api, cfg)) + + h := NewHandler(b) + h.AccountID = "000000000000" + + return h, b, api +} + +func runVals(imageID string, extra url.Values) url.Values { + v := url.Values{"ImageId": {imageID}, "InstanceType": {"t3.micro"}, "MinCount": {"1"}, "MaxCount": {"1"}} + maps.Copy(v, extra) + + return v +} + +func TestSeededDockerImageMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + imageID string + want string + wantKnown bool + }{ + {name: "al2", imageID: amiAL2, want: "amazonlinux:2", wantKnown: true}, + {name: "al2023", imageID: amiAL2023, want: "amazonlinux:2023", wantKnown: true}, + {name: "ubuntu_22_04_stub", imageID: amiUbuntu22, want: "ubuntu:22.04", wantKnown: true}, + {name: "ubuntu_20_04", imageID: amiUbuntu20, want: "ubuntu:20.04", wantKnown: true}, + {name: "ubuntu_24_04", imageID: amiUbuntu24, want: "ubuntu:24.04", wantKnown: true}, + {name: "windows_has_no_container", imageID: amiWindows, want: "", wantKnown: true}, + {name: "bottlerocket_has_no_container", imageID: amiBottle, want: "", wantKnown: true}, + {name: "unknown", imageID: "ami-00000000deadbeef0", want: "", wantKnown: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("000000000000", "us-east-1") + got, known := b.DockerImageFor(tt.imageID) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantKnown, known) + }) + } +} + +func TestRunInstancesChoosesImagePerAMI(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + setup func(t *testing.T, h *Handler, api *richDockerAPI) string + name string + wantImage string + }{ + { + name: "seeded_al2023", + setup: func(*testing.T, *Handler, *richDockerAPI) string { return amiAL2023 }, + wantImage: "amazonlinux:2023", + }, + { + name: "seeded_ubuntu", + setup: func(*testing.T, *Handler, *richDockerAPI) string { return amiUbuntu24 }, + wantImage: "ubuntu:24.04", + }, + { + name: "windows_falls_back_to_configured_default", + setup: func(*testing.T, *Handler, *richDockerAPI) string { return amiWindows }, + wantImage: "alpine:3.20", + }, + { + name: "register_image_docker_location", + setup: func(t *testing.T, h *Handler, _ *richDockerAPI) string { + t.Helper() + + resp, err := h.handleRegisterImage(url.Values{ + "Name": {"custom"}, "ImageLocation": {"docker://busybox:1.36"}, + }, "r") + require.NoError(t, err) + + return resp.(*registerImageResponse).ImageID + }, + wantImage: "busybox:1.36", + }, + { + name: "register_image_without_binding_uses_default", + setup: func(t *testing.T, h *Handler, _ *richDockerAPI) string { + t.Helper() + + resp, err := h.handleRegisterImage(url.Values{"Name": {"plain"}}, "r") + require.NoError(t, err) + + return resp.(*registerImageResponse).ImageID + }, + wantImage: "alpine:3.20", + }, + { + name: "copy_image_inherits_binding", + setup: func(t *testing.T, h *Handler, _ *richDockerAPI) string { + t.Helper() + + resp, err := h.handleCopyImage(url.Values{"SourceImageId": {amiUbuntu22}, "Name": {"u-copy"}}, "r") + require.NoError(t, err) + + return resp.(*copyImageResponse).ImageID + }, + wantImage: "ubuntu:22.04", + }, + { + name: "unknown_ami", + setup: func(*testing.T, *Handler, *richDockerAPI) string { return "ami-00000000deadbeef0" }, + wantErr: ErrImageNotFound, + }, + { + name: "discovered_local_image", + setup: func(_ *testing.T, _ *Handler, api *richDockerAPI) string { + api.images["gopherstack-ec2/web:"+amiDiscover] = "x" + + return amiDiscover + }, + wantImage: "gopherstack-ec2/web:" + amiDiscover, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, _, api := newDockerHandler(t, DockerComputeConfig{Image: "alpine:3.20"}) + imageID := tt.setup(t, h, api) + + _, err := h.handleRunInstances(runVals(imageID, nil), "r") + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + assert.Empty(t, api.created) + + return + } + + require.NoError(t, err) + require.Len(t, api.created, 1) + assert.Equal(t, tt.wantImage, api.created[0].Config.Image) + assert.Equal(t, imageID, api.created[0].Config.Labels[dockerLabelPrefix+"image"]) + }) + } +} + +func TestLocalImagesAreNeverPulled(t *testing.T) { + t.Parallel() + + h, _, api := newDockerHandler(t, DockerComputeConfig{}) + api.images["gopherstack-ec2/web:"+amiDiscover] = "x" + + _, err := h.handleRunInstances(runVals(amiDiscover, nil), "r") + require.NoError(t, err) + assert.NotContains(t, api.pulled, "gopherstack-ec2/web:"+amiDiscover) +} + +func TestDiscoveredImagesAppearInDescribeImages(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tag string + wantName string + wantID string + wantSeen bool + }{ + { + name: "convention", + tag: "gopherstack-ec2/web:" + amiDiscover, + wantName: "web", + wantID: amiDiscover, + wantSeen: true, + }, + { + name: "nested_name", tag: "gopherstack-ec2/team/db:ami-0fedcba987654321a", + wantName: "team/db", wantID: "ami-0fedcba987654321a", wantSeen: true, + }, + {name: "non_ami_tag", tag: "gopherstack-ec2/web:latest"}, + {name: "other_repo", tag: "someone/web:" + amiDiscover}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{}) + api.images[tt.tag] = "x" + + _, err := h.handleDescribeImages(url.Values{}, "r") + require.NoError(t, err) + + var found *AMIStub + + for _, img := range b.DescribeImages() { + if img.Name == tt.wantName && tt.wantName != "" { + found = &img + } + } + + if !tt.wantSeen { + assert.Nil(t, found) + + return + } + + require.NotNil(t, found) + assert.Equal(t, tt.wantID, found.ImageID) + assert.Equal(t, "000000000000", found.OwnerID) + assert.Equal(t, tt.tag, found.DockerImage) + }) + } +} + +func TestCreateImageCommitsContainer(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{}) + + _, err := h.handleRunInstances(runVals(amiAL2, nil), "r") + require.NoError(t, err) + + instances := b.DescribeInstances(nil, "") + require.Len(t, instances, 1) + + resp, err := h.handleCreateImage(url.Values{"InstanceId": {instances[0].ID}, "Name": {"My Golden Image"}}, "r") + require.NoError(t, err) + + amiID := resp.(*createImageResponse).ImageID + require.Len(t, api.commits, 1) + assert.Equal(t, "gopherstack-ec2/my-golden-image:"+amiID, api.commits[0].Reference) + + ref, known := b.DockerImageFor(amiID) + assert.True(t, known) + assert.Equal(t, api.commits[0].Reference, ref) + + _, err = h.handleRunInstances(runVals(amiID, nil), "r") + require.NoError(t, err) + require.Len(t, api.created, 2) + assert.Equal(t, ref, api.created[1].Config.Image) +} + +func TestCreateImageCommitFailureRollsBack(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{}) + + _, err := h.handleRunInstances(runVals(amiAL2, nil), "r") + require.NoError(t, err) + + api.commitErr = errFakeBoom + before := len(b.DescribeImages()) + + _, err = h.handleCreateImage(url.Values{"InstanceId": {b.DescribeInstances(nil, "")[0].ID}, "Name": {"x"}}, "r") + require.ErrorIs(t, err, errFakeBoom) + assert.Len(t, b.DescribeImages(), before, "failed commit must not leave an AMI behind") +} + +func TestParseLocalImageTag(t *testing.T) { + t.Parallel() + + tests := []struct { + tag string + wantName string + wantID string + wantOK bool + }{ + { + tag: "gopherstack-ec2/web:ami-0123456789abcdef0", + wantName: "web", + wantID: "ami-0123456789abcdef0", + wantOK: true, + }, + {tag: "gopherstack-ec2/web", wantOK: false}, + {tag: "gopherstack-ec2/:ami-0123456789abcdef0", wantOK: false}, + {tag: "gopherstack-ec2/web:ami-XYZ", wantOK: false}, + {tag: "x/web:ami-0123456789abcdef0", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + t.Parallel() + + name, id, ok := parseLocalImageTag(tt.tag) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantID, id) + }) + } +} + +func TestSanitizeImageName(t *testing.T) { + t.Parallel() + + tests := []struct{ in, want string }{ + {"My Image 1", "my-image-1"}, + {" ", "image"}, + {"a/b", "a-b"}, + {"UPPER_case.v2", "upper_case.v2"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, sanitizeImageName(tt.in)) + }) + } +} + +func TestDockerImageBindingSurvivesSnapshotRestore(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("000000000000", "us-east-1") + img, err := b.RegisterImage("persisted", "", "") + require.NoError(t, err) + require.NoError(t, b.BindDockerImage(img.ImageID, "busybox:1.36")) + + restored := NewInMemoryBackend("000000000000", "us-east-1") + require.NoError(t, restored.Restore(t.Context(), b.Snapshot(t.Context()))) + + ref, known := restored.DockerImageFor(img.ImageID) + assert.True(t, known) + assert.Equal(t, "busybox:1.36", ref) +} diff --git a/services/ec2/docker_ports_internal_test.go b/services/ec2/docker_ports_internal_test.go index df9a344ea0..77626e61f0 100644 --- a/services/ec2/docker_ports_internal_test.go +++ b/services/ec2/docker_ports_internal_test.go @@ -116,14 +116,17 @@ func newVPCDockerHandler(t *testing.T) (*Handler, *fakeNetDockerAPI, *InMemoryBa func runIn(t *testing.T, h *Handler, subnetID string, sgIDs ...string) { t.Helper() + img, err := h.Backend.RegisterImage("plain", "", "") + require.NoError(t, err) + vals := map[string][]string{ - "ImageId": {"ami-1"}, "InstanceType": {"t3.micro"}, "MinCount": {"1"}, "SubnetId": {subnetID}, + "ImageId": {img.ImageID}, "InstanceType": {"t3.micro"}, "MinCount": {"1"}, "SubnetId": {subnetID}, } if len(sgIDs) > 0 { vals["SecurityGroupId.1"] = sgIDs } - _, err := h.handleRunInstances(vals, "req") + _, err = h.handleRunInstances(vals, "req") require.NoError(t, err) } diff --git a/services/ec2/docker_stopstart_live_internal_test.go b/services/ec2/docker_stopstart_live_internal_test.go new file mode 100644 index 0000000000..fe7a9e04c0 --- /dev/null +++ b/services/ec2/docker_stopstart_live_internal_test.go @@ -0,0 +1,30 @@ +package ec2 + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDockerComputeLiveStopStartKeepsFilesystem(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not found") + } + + dc, _ := newLiveCompute(t, nil) + res := launchLive(t, dc, LaunchRequest{InstanceID: "i-stopstart01"}) + + _, err := dockerExec(t.Context(), res.ProviderID, "echo kept > /root/root-volume.txt") + require.NoError(t, err) + + require.NoError(t, dc.Stop(t.Context(), "i-stopstart01", res.ProviderID)) + require.NoError(t, dc.Start(t.Context(), "i-stopstart01", res.ProviderID)) + + got, err := dockerExec(t.Context(), res.ProviderID, "cat /root/root-volume.txt") + require.NoError(t, err) + assert.Equal(t, "kept", got, "the root filesystem must survive stop/start") +} diff --git a/services/ec2/docker_volumes.go b/services/ec2/docker_volumes.go new file mode 100644 index 0000000000..04806024ce --- /dev/null +++ b/services/ec2/docker_volumes.go @@ -0,0 +1,487 @@ +package ec2 + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "fmt" + "io" + "regexp" + "strings" + "sync" + "time" + + mobycontainer "github.com/moby/moby/api/types/container" + mobymount "github.com/moby/moby/api/types/mount" + mobyclient "github.com/moby/moby/client" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + ebsVolumePrefix = "gopherstack-ebs-" + ebsMountRoot = "/mnt/ebs/" + ebsHelperDir = "/vol" + ebsDirMode = 0o777 + ebsExecPollDelay = 100 * time.Millisecond + ebsExecPolls = 50 + labelEBSVolume = dockerLabelPrefix + "volume" + labelEBSOwner = dockerLabelPrefix + "owner" +) + +var ( + errNoVolumeAPI = errors.New("docker compute: docker client has no volume support") + errEBSMountInvalid = errors.New("docker compute: EBS device name has no usable mount path") + deviceUnsafeRE = regexp.MustCompile(`[^A-Za-z0-9_-]+`) +) + +// VolumeAttachSpec is one EBS volume mapped into an instance's container. +type VolumeAttachSpec struct { + VolumeID string `json:"volumeID"` + Device string `json:"device"` + Owner string `json:"owner"` +} + +// volumeAPI is the Docker volume and copy surface; a dockerAPI that lacks it cannot back EBS volumes. +type volumeAPI interface { + VolumeCreate(ctx context.Context, opts mobyclient.VolumeCreateOptions) (mobyclient.VolumeCreateResult, error) + VolumeList(ctx context.Context, opts mobyclient.VolumeListOptions) (mobyclient.VolumeListResult, error) + VolumeRemove( + ctx context.Context, id string, opts mobyclient.VolumeRemoveOptions, + ) (mobyclient.VolumeRemoveResult, error) + CopyToContainer( + ctx context.Context, id string, opts mobyclient.CopyToContainerOptions, + ) (mobyclient.CopyToContainerResult, error) + CopyFromContainer( + ctx context.Context, id string, opts mobyclient.CopyFromContainerOptions, + ) (mobyclient.CopyFromContainerResult, error) + ExecCreate( + ctx context.Context, id string, opts mobyclient.ExecCreateOptions, + ) (mobyclient.ExecCreateResult, error) + ExecStart(ctx context.Context, id string, opts mobyclient.ExecStartOptions) (mobyclient.ExecStartResult, error) + ExecInspect( + ctx context.Context, id string, opts mobyclient.ExecInspectOptions, + ) (mobyclient.ExecInspectResult, error) +} + +// volumeCompute is implemented by Compute providers that map EBS volumes onto Docker volumes. +type volumeCompute interface { + EBSEnabled() bool + AttachEBSVolume(ctx context.Context, providerID string, v VolumeAttachSpec) error + DetachEBSVolume(ctx context.Context, providerID string, v VolumeAttachSpec) error + ReapVolumes(ctx context.Context, owner string, live map[string]struct{}) []string +} + +// volumeBackend is the backend side of EBS to Docker volume mapping. +type volumeBackend interface { + ComputeOwner() string + LaunchVolumeSpecs(instanceID string) []VolumeAttachSpec + AttachedVolumeSpec(volumeID string) (VolumeAttachSpec, string, bool) + LiveVolumeIDs() map[string]struct{} +} + +type mountRec struct { + path string + owner string + // copied volumes were copied into the running container and are copied back on detach or terminate; + // the rest are real Docker mounts created with the container. + copied bool +} + +// mountRegistry tracks which volumes each container holds; shared across region views. +type mountRegistry struct { + byProvider map[string]map[string]mountRec + mu sync.Mutex +} + +func newMountRegistry() *mountRegistry { + return &mountRegistry{byProvider: make(map[string]map[string]mountRec)} +} + +func (m *mountRegistry) put(providerID, volumeID string, rec mountRec) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.byProvider[providerID] == nil { + m.byProvider[providerID] = make(map[string]mountRec) + } + + m.byProvider[providerID][volumeID] = rec +} + +func (m *mountRegistry) get(providerID, volumeID string) (mountRec, bool) { + m.mu.Lock() + defer m.mu.Unlock() + + rec, ok := m.byProvider[providerID][volumeID] + + return rec, ok +} + +func (m *mountRegistry) drop(providerID, volumeID string) { + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.byProvider[providerID], volumeID) +} + +func (m *mountRegistry) dropAll(providerID string) map[string]mountRec { + m.mu.Lock() + defer m.mu.Unlock() + + recs := m.byProvider[providerID] + delete(m.byProvider, providerID) + + return recs +} + +func ebsVolumeName(volumeID string) string { return ebsVolumePrefix + volumeID } + +// ebsMountPath maps a block device name to its in-container mount path: /dev/sdf becomes /mnt/ebs/sdf. +func ebsMountPath(device string) (string, error) { + base := deviceUnsafeRE.ReplaceAllString(strings.TrimPrefix(device, "/dev/"), "_") + if strings.Trim(base, "_") == "" { + return "", fmt.Errorf("%w: %q", errEBSMountInvalid, device) + } + + return ebsMountRoot + base, nil +} + +// EBSEnabled reports whether EBS volumes are mapped to Docker volumes. +func (d *DockerCompute) EBSEnabled() bool { + _, ok := d.api.(volumeAPI) + + return d.cfg.EBSVolumes && ok +} + +func (d *DockerCompute) ensureVolume(ctx context.Context, v VolumeAttachSpec) error { + va, ok := d.api.(volumeAPI) + if !ok { + return errNoVolumeAPI + } + + if _, err := va.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{ + Name: ebsVolumeName(v.VolumeID), + Labels: map[string]string{labelEBSVolume: v.VolumeID, labelEBSOwner: v.Owner}, + }); err != nil { + return fmt.Errorf("docker volume create %s: %w", v.VolumeID, err) + } + + return nil +} + +// launchMounts creates the Docker volumes for a launching instance's EBS volumes and returns their mounts. +func (d *DockerCompute) launchMounts(ctx context.Context, req LaunchRequest) ([]mobymount.Mount, error) { + if !d.EBSEnabled() { + return nil, nil + } + + mounts := make([]mobymount.Mount, 0, len(req.Volumes)) + + for _, v := range req.Volumes { + path, err := ebsMountPath(v.Device) + if err != nil { + logger.Load(ctx).WarnContext(ctx, "ec2 ebs volume not mounted", "volume", v.VolumeID, "error", err) + + continue + } + + if err = d.ensureVolume(ctx, v); err != nil { + return nil, err + } + + mounts = append(mounts, mobymount.Mount{ + Type: mobymount.TypeVolume, + Source: ebsVolumeName(v.VolumeID), + Target: path, + VolumeOptions: &mobymount.VolumeOptions{Labels: map[string]string{labelEBSVolume: v.VolumeID}}, + }) + } + + return mounts, nil +} + +func (d *DockerCompute) recordLaunchMounts(providerID string, req LaunchRequest) { + if !d.EBSEnabled() { + return + } + + for _, v := range req.Volumes { + if path, err := ebsMountPath(v.Device); err == nil { + d.mounts.put(providerID, v.VolumeID, mountRec{path: path, owner: v.Owner}) + } + } +} + +// AttachEBSVolume copies the volume's data into the running container at the device's mount path. +// Docker cannot add a mount to an existing container, so the data is copied back on detach or terminate. +func (d *DockerCompute) AttachEBSVolume(ctx context.Context, providerID string, v VolumeAttachSpec) error { + va, ok := d.api.(volumeAPI) + if !ok || !d.cfg.EBSVolumes { + return nil + } + + if _, held := d.mounts.get(providerID, v.VolumeID); held { + return nil + } + + path, err := ebsMountPath(v.Device) + if err != nil { + return err + } + + if err = d.ensureVolume(ctx, v); err != nil { + return err + } + + err = d.withVolumeHelper(ctx, providerID, v.VolumeID, func(helperID string) error { + return d.copyVolumeIn(ctx, va, helperID, providerID, path) + }) + if err != nil { + return err + } + + d.mounts.put(providerID, v.VolumeID, mountRec{path: path, owner: v.Owner, copied: true}) + + return nil +} + +// DetachEBSVolume writes a copied volume's data back to its Docker volume and clears the container path. +// A volume mounted at launch stays mounted (Docker cannot unmount from a live container); its data is already stored. +func (d *DockerCompute) DetachEBSVolume(ctx context.Context, providerID string, v VolumeAttachSpec) error { + rec, held := d.mounts.get(providerID, v.VolumeID) + if !held { + return nil + } + + if rec.copied { + if err := d.syncBack(ctx, providerID, v.VolumeID, rec); err != nil { + return err + } + + d.clearPath(ctx, providerID, rec.path) + } + + d.mounts.drop(providerID, v.VolumeID) + + return nil +} + +// syncBackAll writes every copied volume of a container back before the container is removed. +func (d *DockerCompute) syncBackAll(ctx context.Context, providerID string) { + log := logger.Load(ctx) + + for volumeID, rec := range d.mounts.dropAll(providerID) { + if !rec.copied { + continue + } + + if err := d.syncBack(ctx, providerID, volumeID, rec); err != nil { + log.WarnContext(ctx, "ec2 ebs volume sync-back failed", "volume", volumeID, "error", err) + } + } +} + +func (d *DockerCompute) syncBack(ctx context.Context, providerID, volumeID string, rec mountRec) error { + va, ok := d.api.(volumeAPI) + if !ok { + return errNoVolumeAPI + } + + from, err := va.CopyFromContainer(ctx, providerID, mobyclient.CopyFromContainerOptions{SourcePath: rec.path + "/."}) + if err != nil { + return fmt.Errorf("copy volume %s out of container: %w", volumeID, err) + } + + var buf bytes.Buffer + + _, copyErr := io.Copy(&buf, from.Content) + _ = from.Content.Close() + + if copyErr != nil { + return fmt.Errorf("read volume %s from container: %w", volumeID, copyErr) + } + + name := ebsVolumeName(volumeID) + if _, err = va.VolumeRemove(ctx, name, mobyclient.VolumeRemoveOptions{Force: true}); err != nil { + return fmt.Errorf("docker volume reset %s: %w", volumeID, err) + } + + if err = d.ensureVolume(ctx, VolumeAttachSpec{VolumeID: volumeID, Owner: rec.owner}); err != nil { + return err + } + + return d.withVolumeHelper(ctx, providerID, volumeID, func(helperID string) error { + if _, cpErr := va.CopyToContainer(ctx, helperID, mobyclient.CopyToContainerOptions{ + DestinationPath: ebsHelperDir, Content: &buf, + }); cpErr != nil { + return fmt.Errorf("copy volume %s into docker volume: %w", volumeID, cpErr) + } + + return nil + }) +} + +// withVolumeHelper runs fn against a created-but-never-started container with the volume mounted at ebsHelperDir. +func (d *DockerCompute) withVolumeHelper( + ctx context.Context, providerID, volumeID string, fn func(helperID string) error, +) error { + image := d.cfg.Image + + if res, err := d.api.ContainerInspect(ctx, providerID, mobyclient.ContainerInspectOptions{}); err == nil && + res.Container.Config != nil && res.Container.Config.Image != "" { + image = res.Container.Config.Image + } + + created, err := d.api.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{ + Config: &mobycontainer.Config{Image: image, Cmd: []string{ec2BooleanTrue}}, + HostConfig: &mobycontainer.HostConfig{Mounts: []mobymount.Mount{{ + Type: mobymount.TypeVolume, Source: ebsVolumeName(volumeID), Target: ebsHelperDir, + }}}, + }) + if err != nil { + return fmt.Errorf("volume helper create: %w", err) + } + + defer func() { + _, _ = d.api.ContainerRemove( + context.WithoutCancel(ctx), created.ID, + mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, + ) + }() + + return fn(created.ID) +} + +func (d *DockerCompute) copyVolumeIn( + ctx context.Context, va volumeAPI, helperID, providerID, path string, +) error { + from, err := va.CopyFromContainer( + ctx, + helperID, + mobyclient.CopyFromContainerOptions{SourcePath: ebsHelperDir + "/."}, + ) + if err != nil { + return fmt.Errorf("read docker volume: %w", err) + } + + defer func() { _ = from.Content.Close() }() + + pr, pw := io.Pipe() + + go func() { pw.CloseWithError(retarUnder(from.Content, pw, strings.TrimPrefix(path, "/"))) }() + + _, err = va.CopyToContainer(ctx, providerID, mobyclient.CopyToContainerOptions{DestinationPath: "/", Content: pr}) + _ = pr.Close() + + if err != nil { + return fmt.Errorf("copy volume into container: %w", err) + } + + return nil +} + +// retarUnder rewrites a tar stream so every entry lives under prefix, creating prefix itself first. +func retarUnder(r io.Reader, w io.Writer, prefix string) error { + tr, tw := tar.NewReader(r), tar.NewWriter(w) + + if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: prefix + "/", Mode: ebsDirMode}); err != nil { + return err + } + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return tw.Close() + } + + if err != nil { + return err + } + + name := strings.TrimPrefix(strings.TrimPrefix(hdr.Name, "./"), "/") + if name == "" || name == "." { + continue + } + + hdr.Name = prefix + "/" + name + + if hdr.Typeflag == tar.TypeLink { + hdr.Linkname = prefix + "/" + strings.TrimPrefix(hdr.Linkname, "./") + } + + if err = tw.WriteHeader(hdr); err != nil { + return err + } + + if _, err = io.CopyN(tw, tr, hdr.Size); err != nil { + return err + } + } +} + +// clearPath empties a detached volume's directory in a running container; best effort. +func (d *DockerCompute) clearPath(ctx context.Context, providerID, path string) { + va, ok := d.api.(volumeAPI) + if !ok { + return + } + + if res, err := d.api.ContainerInspect(ctx, providerID, mobyclient.ContainerInspectOptions{}); err != nil || + res.Container.State == nil || !res.Container.State.Running { + return + } + + ex, err := va.ExecCreate(ctx, providerID, mobyclient.ExecCreateOptions{Cmd: []string{"rm", "-rf", "--", path}}) + if err != nil { + return + } + + if _, err = va.ExecStart(ctx, ex.ID, mobyclient.ExecStartOptions{Detach: true}); err != nil { + return + } + + for range ebsExecPolls { + if res, inspectErr := va.ExecInspect(ctx, ex.ID, mobyclient.ExecInspectOptions{}); inspectErr != nil || + !res.Running { + return + } + + select { + case <-ctx.Done(): + return + case <-time.After(ebsExecPollDelay): + } + } +} + +// ReapVolumes removes the owner's Docker volumes whose EBS volume no longer exists; volumes still mounted stay. +func (d *DockerCompute) ReapVolumes(ctx context.Context, owner string, live map[string]struct{}) []string { + va, ok := d.api.(volumeAPI) + if !ok || !d.cfg.EBSVolumes { + return nil + } + + list, err := va.VolumeList(ctx, mobyclient.VolumeListOptions{ + Filters: mobyclient.Filters{}.Add("label", labelEBSOwner+"="+owner), + }) + if err != nil { + return nil + } + + var removed []string + + for _, vol := range list.Items { + id := vol.Labels[labelEBSVolume] + if _, alive := live[id]; id == "" || alive { + continue + } + + if _, rmErr := va.VolumeRemove(ctx, vol.Name, mobyclient.VolumeRemoveOptions{}); rmErr == nil { + removed = append(removed, id) + } + } + + return removed +} diff --git a/services/ec2/docker_volumes_internal_test.go b/services/ec2/docker_volumes_internal_test.go new file mode 100644 index 0000000000..772fff86d5 --- /dev/null +++ b/services/ec2/docker_volumes_internal_test.go @@ -0,0 +1,323 @@ +package ec2 + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func bdmVals(devices ...string) url.Values { + v := url.Values{} + + for i, d := range devices { + p := "BlockDeviceMapping." + string(rune('1'+i)) + "." + v.Set(p+"DeviceName", d) + v.Set(p+"Ebs.VolumeSize", "4") + v.Set(p+"Ebs.DeleteOnTermination", map[bool]string{true: "true", false: "false"}[i == 0]) + } + + return v +} + +func volumeOnDevice(t *testing.T, b *InMemoryBackend, device string) *Volume { + t.Helper() + + for _, v := range b.DescribeVolumes(nil) { + if v.Attachment != nil && v.Attachment.Device == device { + return v + } + } + + require.FailNow(t, "no volume on device "+device) + + return nil +} + +func TestEBSMountPath(t *testing.T) { + t.Parallel() + + tests := []struct { + device string + want string + wantErr bool + }{ + {device: "/dev/sdf", want: "/mnt/ebs/sdf"}, + {device: "/dev/xvdb", want: "/mnt/ebs/xvdb"}, + {device: "sdg", want: "/mnt/ebs/sdg"}, + {device: "/dev/nvme1n1", want: "/mnt/ebs/nvme1n1"}, + {device: "/dev/../etc", want: "/mnt/ebs/_etc"}, + {device: "/dev/", wantErr: true}, + {device: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.device, func(t *testing.T) { + t.Parallel() + + got, err := ebsMountPath(tt.device) + if tt.wantErr { + require.ErrorIs(t, err, errEBSMountInvalid) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestLaunchMountsBlockDeviceMappings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enabled bool + wantMount bool + }{ + {name: "opt_in_mounts_named_volumes", enabled: true, wantMount: true}, + {name: "default_off_mounts_nothing", enabled: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{EBSVolumes: tt.enabled}) + + _, err := h.handleRunInstances(runVals(amiAL2, bdmVals("/dev/sdf", "/dev/sdg")), "r") + require.NoError(t, err) + require.Len(t, api.created, 1) + + mounts := api.created[0].HostConfig.Mounts + if !tt.wantMount { + assert.Empty(t, mounts) + assert.Empty(t, api.volumes) + + return + } + + sdf, sdg := volumeOnDevice(t, b, "/dev/sdf"), volumeOnDevice(t, b, "/dev/sdg") + require.Len(t, mounts, 2, "root volume must not be mounted") + + got := map[string]string{} + for _, m := range mounts { + got[m.Target] = m.Source + } + + assert.Equal(t, map[string]string{ + "/mnt/ebs/sdf": "gopherstack-ebs-" + sdf.ID, + "/mnt/ebs/sdg": "gopherstack-ebs-" + sdg.ID, + }, got) + + vol := api.volumes["gopherstack-ebs-"+sdf.ID] + assert.Equal(t, sdf.ID, vol.Labels[labelEBSVolume]) + assert.Equal(t, "000000000000/us-east-1", vol.Labels[labelEBSOwner]) + }) + } +} + +func TestDockerVolumesRemovedWithEBSVolume(t *testing.T) { + t.Parallel() + + tests := []struct { + act func(t *testing.T, h *Handler, b *InMemoryBackend, keep *Volume) + name string + wantKeepRemoved bool + }{ + { + name: "terminate_honours_delete_on_termination", + act: func(t *testing.T, h *Handler, b *InMemoryBackend, _ *Volume) { + t.Helper() + + _, err := h.handleTerminateInstances( + url.Values{"InstanceId.1": {b.DescribeInstances(nil, "")[0].ID}}, + "r", + ) + require.NoError(t, err) + }, + }, + { + name: "delete_volume", + wantKeepRemoved: true, + act: func(t *testing.T, h *Handler, b *InMemoryBackend, keep *Volume) { + t.Helper() + + _, err := h.handleTerminateInstances( + url.Values{"InstanceId.1": {b.DescribeInstances(nil, "")[0].ID}}, + "r", + ) + require.NoError(t, err) + + _, err = h.handleDeleteVolume(url.Values{"VolumeId": {keep.ID}}, "r") + require.NoError(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{EBSVolumes: true}) + + _, err := h.handleRunInstances(runVals(amiAL2, bdmVals("/dev/sdf", "/dev/sdg")), "r") + require.NoError(t, err) + + dot, keep := volumeOnDevice(t, b, "/dev/sdf"), volumeOnDevice(t, b, "/dev/sdg") + require.Contains(t, api.volumes, "gopherstack-ebs-"+dot.ID) + require.Contains(t, api.volumes, "gopherstack-ebs-"+keep.ID) + + tt.act(t, h, b, keep) + + assert.NotContains(t, api.volumes, "gopherstack-ebs-"+dot.ID, "DeleteOnTermination volume is removed") + + if tt.wantKeepRemoved { + assert.NotContains(t, api.volumes, "gopherstack-ebs-"+keep.ID, "DeleteVolume removes the docker volume") + } else { + assert.Contains(t, api.volumes, "gopherstack-ebs-"+keep.ID, "retained EBS volume keeps its data") + } + }) + } +} + +func TestRuntimeAttachDetachMovesDataBetweenInstances(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{EBSVolumes: true}) + + _, err := h.handleRunInstances(runVals(amiAL2, nil), "r") + require.NoError(t, err) + + api.fakeDockerAPI.containerID = "ctr-2" + + _, err = h.handleRunInstances(runVals(amiAL2, nil), "r") + require.NoError(t, err) + + instances := b.DescribeInstances(nil, "") + require.Len(t, instances, 2) + + byProvider := map[string]string{} + for _, inst := range instances { + byProvider[inst.ProviderID] = inst.ID + } + + vol, err := b.CreateVolume("us-east-1a", "gp3", 1, "") + require.NoError(t, err) + + volName := "gopherstack-ebs-" + vol.ID + api.writeFile(fakeVolPrefix+vol.ID, "/seed.txt", []byte("seed")) + + attach := func(inst string) { + t.Helper() + + _, aerr := h.handleAttachVolume( + url.Values{"VolumeId": {vol.ID}, "InstanceId": {inst}, "Device": {"/dev/sdf"}}, + "r", + ) + require.NoError(t, aerr) + } + detach := func() { + t.Helper() + + _, derr := h.handleDetachVolume(url.Values{"VolumeId": {vol.ID}}, "r") + require.NoError(t, derr) + } + + attach(byProvider[richProvider]) + got, ok := api.ctrFile(richProvider, "/mnt/ebs/sdf/seed.txt") + require.True(t, ok, "volume contents are visible at the device path") + assert.Equal(t, "seed", string(got)) + assert.Contains(t, api.volumes, volName) + + api.writeFile(richProvider, "/mnt/ebs/sdf/written.txt", []byte("persisted")) + detach() + assert.Contains(t, api.execs, []string{"rm", "-rf", "--", "/mnt/ebs/sdf"}, "container path is cleared on detach") + + stored, ok := api.files[fakeVolPrefix+vol.ID]["/written.txt"] + require.True(t, ok, "data written while attached is stored in the docker volume") + assert.Equal(t, "persisted", string(stored)) + + attach(byProvider["ctr-2"]) + got, ok = api.ctrFile("ctr-2", "/mnt/ebs/sdf/written.txt") + require.True(t, ok, "second instance sees the first instance's file") + assert.Equal(t, "persisted", string(got)) +} + +func TestTerminateSyncsRuntimeAttachedVolumeBack(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{EBSVolumes: true}) + + _, err := h.handleRunInstances(runVals(amiAL2, nil), "r") + require.NoError(t, err) + + inst := b.DescribeInstances(nil, "")[0] + vol, err := b.CreateVolume("us-east-1a", "gp3", 1, "") + require.NoError(t, err) + + _, err = h.handleAttachVolume( + url.Values{"VolumeId": {vol.ID}, "InstanceId": {inst.ID}, "Device": {"/dev/sdh"}}, + "r", + ) + require.NoError(t, err) + + api.writeFile(richProvider, "/mnt/ebs/sdh/db.dat", []byte("rows")) + + _, err = h.handleTerminateInstances(url.Values{"InstanceId.1": {inst.ID}}, "r") + require.NoError(t, err) + + stored, ok := api.files[fakeVolPrefix+vol.ID]["/db.dat"] + require.True(t, ok) + assert.Equal(t, "rows", string(stored)) + assert.Contains(t, api.volumes, "gopherstack-ebs-"+vol.ID, "the surviving EBS volume keeps its docker volume") +} + +func TestDetachFailureKeepsVolumeAttached(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{EBSVolumes: true}) + + _, err := h.handleRunInstances(runVals(amiAL2, nil), "r") + require.NoError(t, err) + + inst := b.DescribeInstances(nil, "")[0] + vol, err := b.CreateVolume("us-east-1a", "gp3", 1, "") + require.NoError(t, err) + + _, err = h.handleAttachVolume( + url.Values{"VolumeId": {vol.ID}, "InstanceId": {inst.ID}, "Device": {"/dev/sdf"}}, + "r", + ) + require.NoError(t, err) + + api.copyFromErr = errFakeBoom + + _, err = h.handleDetachVolume(url.Values{"VolumeId": {vol.ID}}, "r") + require.ErrorIs(t, err, errFakeBoom) + + after := b.DescribeVolumes([]string{vol.ID}) + require.Len(t, after, 1) + assert.NotNil(t, after[0].Attachment, "a failed sync must not detach the EBS volume") +} + +func TestAttachVolumeDisabledLeavesContainerAlone(t *testing.T) { + t.Parallel() + + h, b, api := newDockerHandler(t, DockerComputeConfig{}) + + _, err := h.handleRunInstances(runVals(amiAL2, nil), "r") + require.NoError(t, err) + + vol, err := b.CreateVolume("us-east-1a", "gp3", 1, "") + require.NoError(t, err) + + _, err = h.handleAttachVolume(url.Values{ + "VolumeId": {vol.ID}, "InstanceId": {b.DescribeInstances(nil, "")[0].ID}, "Device": {"/dev/sdf"}, + }, "r") + require.NoError(t, err) + assert.Empty(t, api.volumes) + assert.Empty(t, api.files) +} diff --git a/services/ec2/handler.go b/services/ec2/handler.go index b7fad1e862..bf30ba3761 100644 --- a/services/ec2/handler.go +++ b/services/ec2/handler.go @@ -460,6 +460,11 @@ func (h *Handler) Handler() echo.HandlerFunc { ) } + vals.Del(callerAccountParam) + if account, ok := awsmeta.CallerAccount(ctx); ok { + vals.Set(callerAccountParam, account) + } + log.DebugContext(ctx, "EC2 request", "action", action) resp, opErr := h.dispatch(action, vals, reqID) @@ -906,6 +911,10 @@ func opErrCode(opErr error) (string, int) { return "DryRunOperation", http.StatusPreconditionFailed } + if errors.Is(opErr, ErrUnauthorizedOperation) { + return "UnauthorizedOperation", http.StatusForbidden + } + for _, entry := range errCodeLookup { if errors.Is(opErr, entry.err) { return entry.code, http.StatusBadRequest diff --git a/services/ec2/handler_deepdive_ops.go b/services/ec2/handler_deepdive_ops.go index 86103a4369..f443dbe215 100644 --- a/services/ec2/handler_deepdive_ops.go +++ b/services/ec2/handler_deepdive_ops.go @@ -77,6 +77,14 @@ func (h *Handler) handleCreateImage(vals url.Values, reqID string) (any, error) return nil, err } + h.claimImage(callerOf(vals), image) + + if err = h.commitImageFor(h.svcCtx, vals.Get("InstanceId"), image); err != nil { + _ = h.Backend.DeregisterImage(image.ImageID) + + return nil, err + } + // CreateImageInput.TagSpecifications also accepts ResourceType "snapshot" // (api_op_CreateImage.go:132-140) to tag the per-volume snapshots it // creates, but this backend's CreateImage does not model those snapshots, diff --git a/services/ec2/handler_images.go b/services/ec2/handler_images.go index e00e3e9bc9..b1cc5d76ce 100644 --- a/services/ec2/handler_images.go +++ b/services/ec2/handler_images.go @@ -1,6 +1,7 @@ package ec2 import ( + "cmp" "encoding/xml" "fmt" "net/url" @@ -183,6 +184,10 @@ func (h *Handler) handleModifyImageAttribute(vals url.Values, reqID string) (any attribute := vals.Get("Attribute") value := vals.Get("Value") + if err := h.requireImageOwner(vals, imageID); err != nil { + return nil, err + } + // A real client typically sends the structured Description/ImdsSupport // AttributeValue form (Description.Value=X) rather than the generic // Attribute=description&Value=X pair; awsEc2query_serializeDocumentAttributeValue @@ -266,6 +271,10 @@ func parseLaunchPermissionList(vals url.Values, prefix string) ([]string, bool) func (h *Handler) handleResetImageAttribute(vals url.Values, reqID string) (any, error) { imageID := vals.Get("ImageId") attribute := vals.Get("Attribute") + if err := h.requireImageOwner(vals, imageID); err != nil { + return nil, err + } + if err := h.Backend.ResetImageAttribute(imageID, attribute); err != nil { return nil, err } @@ -435,6 +444,14 @@ func (h *Handler) handleRegisterImage(vals url.Values, reqID string) (any, error return nil, err } + h.claimImage(callerOf(vals), img) + + if err = h.bindRegisteredImage(img.ImageID, vals.Get("ImageLocation")); err != nil { + _ = h.Backend.DeregisterImage(img.ImageID) + + return nil, err + } + virtType := vals.Get("VirtualizationType") if virtType == "" { virtType = "paravirtual" // api_op_RegisterImage.go: "Default: paravirtual" @@ -807,6 +824,10 @@ func (h *Handler) handleDescribeFastLaunchImages(vals url.Values, reqID string) func (h *Handler) handleCopyImage(vals url.Values, reqID string) (any, error) { sourceImageID := vals.Get("SourceImageId") + if err := h.requireImageUsable(vals, sourceImageID); err != nil { + return nil, err + } + image, err := h.Backend.CopyImageEncrypted( sourceImageID, vals.Get("Name"), @@ -818,6 +839,8 @@ func (h *Handler) handleCopyImage(vals url.Values, reqID string) (any, error) { return nil, err } + h.claimImage(callerOf(vals), image) + // Default: your user-defined AMI tags are not copied (ec2@v1.319.1 // api_op_CopyImage.go's CopyImageTags doc comment). if vals.Get("CopyImageTags") == ec2BooleanTrue { @@ -1418,7 +1441,7 @@ func filterImagesByOwner(idFiltered []*AMIStub, owners []string, accountID strin want := make(map[string]struct{}, len(owners)) for _, o := range owners { - if o == "self" { + if o == sharingSelf { o = accountID } want[o] = struct{}{} @@ -1482,20 +1505,32 @@ func (h *Handler) toAMIItem(a *AMIStub, deprecation map[string]string, state str } func (h *Handler) handleDescribeImages(vals url.Values, reqID string) (any, error) { + h.refreshDockerImages(h.svcCtx) + amis := h.Backend.DescribeImages() requested := collectRequestedImageIDs(vals) + candidates := make([]*AMIStub, len(amis)) + for i := range amis { + candidates[i] = &amis[i] + } + + sb := h.sharing() + if sb != nil { + candidates = sb.VisibleImages(callerOf(vals), candidates) + } + // Pre-filter by ID, then apply named EC2 filters (name, architecture, state, etc.). - idFiltered := make([]*AMIStub, 0, len(amis)) + idFiltered := make([]*AMIStub, 0, len(candidates)) found := make(map[string]struct{}, len(requested)) - for i := range amis { + for _, a := range candidates { if len(requested) > 0 { - if _, ok := requested[amis[i].ImageID]; !ok { + if _, ok := requested[a.ImageID]; !ok { continue } - found[amis[i].ImageID] = struct{}{} + found[a.ImageID] = struct{}{} } - idFiltered = append(idFiltered, &amis[i]) + idFiltered = append(idFiltered, a) } if err := firstMissingID(requested, found, ErrImageNotFound); err != nil { @@ -1515,7 +1550,11 @@ func (h *Handler) handleDescribeImages(vals url.Values, reqID string) (any, erro deprecation := h.Backend.ImageDeprecation() includeDeprecated := vals.Get("IncludeDeprecated") == ec2BooleanTrue idFiltered = filterDeprecatedImages(idFiltered, requested, deprecation, includeDeprecated) - idFiltered = filterImagesByOwner(idFiltered, parseMemberList(vals, "Owner"), h.AccountID) + idFiltered = filterImagesByOwner(idFiltered, parseMemberList(vals, "Owner"), cmp.Or(callerOf(vals), h.AccountID)) + + if sb != nil { + idFiltered = sb.NarrowImagesByExecutable(callerOf(vals), idFiltered, parseMemberList(vals, "ExecutableBy")) + } filtered := make([]amiItem, 0, len(idFiltered)) for _, a := range idFiltered { @@ -1704,6 +1743,10 @@ func (h *Handler) handleDescribeImageAttribute(vals url.Values, reqID string) (a return nil, fmt.Errorf("%w: Attribute is required", ErrInvalidParameter) } + if err := h.requireImageOwner(vals, imageID); err != nil { + return nil, err + } + resp := &describeImageAttributeResponse{ Xmlns: ec2XMLNS, RequestID: reqID, diff --git a/services/ec2/handler_instances_lifecycle.go b/services/ec2/handler_instances_lifecycle.go index 3fb0ce497b..db93c9e6bf 100644 --- a/services/ec2/handler_instances_lifecycle.go +++ b/services/ec2/handler_instances_lifecycle.go @@ -331,6 +331,10 @@ func (h *Handler) handleRunInstances(vals url.Values, reqID string) (any, error) return nil, err } + if err := h.requireLaunchableImageFor(vals, imageID); err != nil { + return nil, err + } + minCount, maxCount, err := parseRunInstancesCounts(vals) if err != nil { return nil, err @@ -537,6 +541,7 @@ func (h *Handler) handleTerminateInstances(vals url.Values, reqID string) (any, if c != nil { h.terminateOnCompute(h.svcCtx, cb, c, providerIDs, dnsNames) + h.reapComputeVolumes(h.svcCtx) } for _, ch := range changes { diff --git a/services/ec2/handler_snapshots.go b/services/ec2/handler_snapshots.go index d0bc20f0f9..28584406c8 100644 --- a/services/ec2/handler_snapshots.go +++ b/services/ec2/handler_snapshots.go @@ -91,11 +91,17 @@ func (h *Handler) handleCopySnapshot(vals url.Values, reqID string) (any, error) encrypted := vals.Get("Encrypted") == ec2BooleanTrue kmsKeyID := vals.Get("KmsKeyId") + if err := h.requireSnapshotUsable(vals, sourceID); err != nil { + return nil, err + } + snap, err := h.Backend.CopySnapshot(sourceID, description, encrypted, kmsKeyID) if err != nil { return nil, err } + h.claimSnapshots(callerOf(vals), snap) + tags := parseTagSpecification(vals, resourceTypeSnapshot) if len(tags) > 0 { if err = h.Backend.CreateTags([]string{snap.SnapshotID}, tags); err != nil { @@ -127,6 +133,8 @@ func (h *Handler) handleCreateSnapshots(vals url.Values, reqID string) (any, err return nil, err } + h.claimSnapshots(callerOf(vals), snaps...) + tags := parseTagSpecification(vals, resourceTypeSnapshot) resp := &createSnapshotsResponse{RequestID: reqID} @@ -264,6 +272,10 @@ func (h *Handler) handleModifySnapshotTier(vals url.Values, reqID string) (any, func (h *Handler) handleResetSnapshotAttribute(vals url.Values, reqID string) (any, error) { snapshotID := vals.Get("SnapshotId") + if err := h.requireSnapshotOwner(vals, snapshotID); err != nil { + return nil, err + } + if err := h.Backend.ResetSnapshotAttribute(snapshotID); err != nil { return nil, err } @@ -687,6 +699,8 @@ func (h *Handler) handleCreateSnapshot(vals url.Values, reqID string) (any, erro return nil, err } + h.claimSnapshots(callerOf(vals), snap) + tags := parseTagSpecification(vals, resourceTypeSnapshot) if len(tags) > 0 { if err = h.Backend.CreateTags([]string{snap.SnapshotID}, tags); err != nil { @@ -719,6 +733,18 @@ func (h *Handler) handleDescribeSnapshots(vals url.Values, reqID string) (any, e return nil, err } + if sb := h.sharing(); sb != nil { + snaps = sb.VisibleSnapshots(callerOf(vals), snaps) + + if err = firstMissingSnapshot(ids, snaps); err != nil { + return nil, err + } + + snaps = sb.NarrowSnapshots( + callerOf(vals), snaps, parseMemberList(vals, "Owner"), parseMemberList(vals, "RestorableBy"), + ) + } + filters := parseEC2Filters(vals) snaps = applySnapshotFilters(snaps, filters, h.Backend) @@ -911,6 +937,10 @@ func (h *Handler) handleDescribeSnapshotAttribute(vals url.Values, reqID string) return nil, fmt.Errorf("%w: Attribute is required", ErrInvalidParameter) } + if err := h.requireSnapshotOwner(vals, snapshotID); err != nil { + return nil, err + } + resp := &describeSnapshotAttributeResponse{ Xmlns: ec2XMLNS, RequestID: reqID, @@ -942,6 +972,10 @@ func (h *Handler) handleModifySnapshotAttribute(vals url.Values, reqID string) ( return nil, fmt.Errorf("%w: SnapshotId is required", ErrInvalidParameter) } + if err := h.requireSnapshotOwner(vals, snapshotID); err != nil { + return nil, err + } + addIDs, addPublic := parseLaunchPermissionList(vals, "CreateVolumePermission.Add") removeIDs, removePublic := parseLaunchPermissionList(vals, "CreateVolumePermission.Remove") diff --git a/services/ec2/handler_volumes.go b/services/ec2/handler_volumes.go index 71d0dd7400..402f196a98 100644 --- a/services/ec2/handler_volumes.go +++ b/services/ec2/handler_volumes.go @@ -839,6 +839,10 @@ func (h *Handler) handleCreateVolume(vals url.Values, reqID string) (any, error) return nil, err } + if err = h.requireSnapshotUsable(vals, snapshotID); err != nil { + return nil, err + } + vol, err := h.Backend.CreateVolume(az, volType, size, snapshotID, initRate) if err != nil { return nil, err @@ -925,6 +929,8 @@ func (h *Handler) handleDeleteVolume(vals url.Values, reqID string) (any, error) return nil, err } + h.reapComputeVolumes(h.svcCtx) + return &deleteVolumeResponse{ Xmlns: ec2XMLNS, RequestID: reqID, @@ -955,6 +961,12 @@ func (h *Handler) handleAttachVolume(vals url.Values, reqID string) (any, error) return nil, err } + if err = h.attachVolumeOnCompute(h.svcCtx, volumeID); err != nil { + _, _ = h.Backend.DetachVolume(volumeID, true) + + return nil, err + } + return &attachVolumeResponse{ Xmlns: ec2XMLNS, RequestID: reqID, @@ -976,6 +988,10 @@ func (h *Handler) handleDetachVolume(vals url.Values, reqID string) (any, error) forceStr := vals.Get("Force") force, _ := strconv.ParseBool(forceStr) + if err := h.detachVolumeOnCompute(h.svcCtx, volumeID); err != nil { + return nil, err + } + att, err := h.Backend.DetachVolume(volumeID, force) if err != nil { return nil, err diff --git a/services/ec2/iam_enforcement_test.go b/services/ec2/iam_enforcement_test.go index a37dacff9b..fe2803d7f3 100644 --- a/services/ec2/iam_enforcement_test.go +++ b/services/ec2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/ec2" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ec2/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ec2/images.go b/services/ec2/images.go index 390d515fba..3ef38b2324 100644 --- a/services/ec2/images.go +++ b/services/ec2/images.go @@ -34,6 +34,7 @@ type AMIStub struct { ImdsSupport string `json:"imdsSupport,omitempty"` SourceImageID string `json:"sourceImageID,omitempty"` SriovNetSupport string `json:"sriovNetSupport,omitempty"` + DockerImage string `json:"dockerImage,omitempty"` ProductCodes []string `json:"productCodes,omitempty"` EnaSupport bool `json:"enaSupport,omitempty"` EnaSupportSet bool `json:"enaSupportSet,omitempty"` @@ -899,6 +900,7 @@ func (b *InMemoryBackend) CopyImageEncrypted( SourceImageID: src.ImageID, OwnerID: b.AccountID, CreationTime: time.Now().UTC(), + DockerImage: dockerRefOf(src), } newImage.BlockDeviceMappings = b.copyImageMappingsLocked(src.BlockDeviceMappings, encrypted, kmsKeyID, remoteSnaps) b.images.Put(newImage) diff --git a/services/ec2/persistence.go b/services/ec2/persistence.go index df1503dba3..e8ecdf3b3b 100644 --- a/services/ec2/persistence.go +++ b/services/ec2/persistence.go @@ -478,6 +478,8 @@ func (h *Handler) Restore(ctx context.Context, data []byte) error { return err } + h.adoptComputeInstances(ctx) + return h.peers.Restore( data, func(p *Handler, d []byte) error { return p.Restore(ctx, d) }, diff --git a/services/ec2/regions_internal_test.go b/services/ec2/regions_internal_test.go index 03eccdddf9..a0e859841a 100644 --- a/services/ec2/regions_internal_test.go +++ b/services/ec2/regions_internal_test.go @@ -29,7 +29,7 @@ func runInstance(t *testing.T, h *Handler) *Instance { t.Helper() _, err := h.handleRunInstances(map[string][]string{ - "ImageId": {"ami-1"}, + "ImageId": {amiAL2}, "InstanceType": {"t3.micro"}, "MinCount": {"1"}, }, "req") diff --git a/services/ec2/resource_sharing.go b/services/ec2/resource_sharing.go new file mode 100644 index 0000000000..d99a6d3833 --- /dev/null +++ b/services/ec2/resource_sharing.go @@ -0,0 +1,323 @@ +package ec2 + +import ( + "errors" + "fmt" + "net/url" + "slices" +) + +const ( + sharingSelf = "self" +) + +const callerAccountParam = "X-Gopherstack-Caller-Account" + +// ErrUnauthorizedOperation is returned when a caller that can see a snapshot +// or AMI (shared with it) tries an owner-only operation on it. +var ErrUnauthorizedOperation = errors.New("UnauthorizedOperation") + +var errNotAuthorized = fmt.Errorf("%w: you are not authorized to perform this operation", ErrUnauthorizedOperation) + +// resourceSharingBackend is the caller-aware slice of the backend. Resources +// whose OwnerID is the backend account are treated as unowned and stay open to +// every caller; only resources claimed by an explicit caller account are +// private to it unless shared via createVolumePermission / launchPermission. +type resourceSharingBackend interface { + ClaimSnapshotOwner(caller string, snapshotIDs ...string) + ClaimImageOwner(caller, imageID string) + SnapshotUsableBy(caller, snapshotID string) error + SnapshotOwnedBy(caller, snapshotID string) error + ImageUsableBy(caller, imageID string) error + ImageOwnedBy(caller, imageID string) error + VisibleSnapshots(caller string, snaps []*Snapshot) []*Snapshot + NarrowSnapshots(caller string, snaps []*Snapshot, owners, restorableBy []string) []*Snapshot + VisibleImages(caller string, imgs []*AMIStub) []*AMIStub + NarrowImagesByExecutable(caller string, imgs []*AMIStub, executableBy []string) []*AMIStub +} + +func (h *Handler) sharing() resourceSharingBackend { + sb, _ := h.Backend.(resourceSharingBackend) + + return sb +} + +func callerOf(vals url.Values) string { return vals.Get(callerAccountParam) } + +// claimSnapshots makes caller the owner of freshly created snapshots and +// reflects it on the response copies. +func (h *Handler) claimSnapshots(caller string, snaps ...*Snapshot) { + sb := h.sharing() + if sb == nil || caller == "" { + return + } + + for _, s := range snaps { + sb.ClaimSnapshotOwner(caller, s.SnapshotID) + + if caller != "" { + s.OwnerID = caller + } + } +} + +func (h *Handler) claimImage(caller string, img *AMIStub) { + if sb := h.sharing(); sb != nil && caller != "" { + sb.ClaimImageOwner(caller, img.ImageID) + img.OwnerID = caller + } +} + +func (h *Handler) requireSnapshotUsable(vals url.Values, snapshotID string) error { + if sb := h.sharing(); sb != nil && snapshotID != "" { + return sb.SnapshotUsableBy(callerOf(vals), snapshotID) + } + + return nil +} + +func (h *Handler) requireImageUsable(vals url.Values, imageID string) error { + if sb := h.sharing(); sb != nil && imageID != "" { + return sb.ImageUsableBy(callerOf(vals), imageID) + } + + return nil +} + +func (h *Handler) requireSnapshotOwner(vals url.Values, snapshotID string) error { + if sb := h.sharing(); sb != nil && snapshotID != "" { + return sb.SnapshotOwnedBy(callerOf(vals), snapshotID) + } + + return nil +} + +func (h *Handler) requireImageOwner(vals url.Values, imageID string) error { + if sb := h.sharing(); sb != nil && imageID != "" { + return sb.ImageOwnedBy(callerOf(vals), imageID) + } + + return nil +} + +// ClaimSnapshotOwner records caller as the owner of the snapshots. +func (b *InMemoryBackend) ClaimSnapshotOwner(caller string, snapshotIDs ...string) { + if caller == "" || caller == b.AccountID { + return + } + + b.mu.Lock("ClaimSnapshotOwner") + defer b.mu.Unlock() + + for _, id := range snapshotIDs { + if s, ok := b.snapshots.Get(id); ok { + s.OwnerID = caller + } + } +} + +// ClaimImageOwner records caller as the owner of the AMI. +func (b *InMemoryBackend) ClaimImageOwner(caller, imageID string) { + if caller == "" || caller == b.AccountID { + return + } + + b.mu.Lock("ClaimImageOwner") + defer b.mu.Unlock() + + if img, ok := b.images.Get(imageID); ok { + img.OwnerID = caller + } +} + +func (b *InMemoryBackend) ownsLocked(caller, owner string) bool { + return caller == "" || owner == "" || owner == b.AccountID || owner == caller +} + +func (b *InMemoryBackend) snapshotUsableLocked(caller string, s *Snapshot) bool { + return b.ownsLocked(caller, s.OwnerID) || + b.snapshotCreateVolumePerms[s.SnapshotID][caller] || + b.snapshotCreateVolumePubGroup[s.SnapshotID] +} + +func (b *InMemoryBackend) imageUsableLocked(caller string, img *AMIStub) bool { + return knownImageOwnerAliases[img.OwnerID] || + b.ownsLocked(caller, img.OwnerID) || + b.imageLaunchPermissions[img.ImageID][caller] || + b.imageLaunchPermissionPublic[img.ImageID] +} + +// SnapshotUsableBy returns InvalidSnapshotID.NotFound for a snapshot the +// caller can neither own nor create volumes from; unknown ids pass through. +func (b *InMemoryBackend) SnapshotUsableBy(caller, snapshotID string) error { + b.mu.RLock("SnapshotUsableBy") + defer b.mu.RUnlock() + + if s, ok := b.snapshots.Get(snapshotID); ok && !b.snapshotUsableLocked(caller, s) { + return fmt.Errorf("%w: %s", ErrSnapshotNotFound, snapshotID) + } + + return nil +} + +// SnapshotOwnedBy additionally requires ownership: a snapshot merely shared +// with the caller yields UnauthorizedOperation. +func (b *InMemoryBackend) SnapshotOwnedBy(caller, snapshotID string) error { + b.mu.RLock("SnapshotOwnedBy") + defer b.mu.RUnlock() + + s, ok := b.snapshots.Get(snapshotID) + if !ok { + return nil + } + + switch { + case !b.snapshotUsableLocked(caller, s): + return fmt.Errorf("%w: %s", ErrSnapshotNotFound, snapshotID) + case !b.ownsLocked(caller, s.OwnerID): + return errNotAuthorized + } + + return nil +} + +// ImageUsableBy returns InvalidAMIID.NotFound for an AMI the caller can +// neither own nor launch. +func (b *InMemoryBackend) ImageUsableBy(caller, imageID string) error { + b.mu.RLock("ImageUsableBy") + defer b.mu.RUnlock() + + if img, ok := b.images.Get(imageID); ok && !b.imageUsableLocked(caller, img) { + return fmt.Errorf("%w: The image id '[%s]' does not exist", ErrImageNotFound, imageID) + } + + return nil +} + +// ImageOwnedBy additionally requires ownership (UnauthorizedOperation otherwise). +func (b *InMemoryBackend) ImageOwnedBy(caller, imageID string) error { + b.mu.RLock("ImageOwnedBy") + defer b.mu.RUnlock() + + img, ok := b.images.Get(imageID) + if !ok { + return nil + } + + switch { + case !b.imageUsableLocked(caller, img): + return fmt.Errorf("%w: The image id '[%s]' does not exist", ErrImageNotFound, imageID) + case caller != "" && (knownImageOwnerAliases[img.OwnerID] || !b.ownsLocked(caller, img.OwnerID)): + return errNotAuthorized + } + + return nil +} + +// VisibleSnapshots keeps the snapshots the caller owns, has create-volume +// permission on, or that are public. +func (b *InMemoryBackend) VisibleSnapshots(caller string, snaps []*Snapshot) []*Snapshot { + if caller == "" { + return snaps + } + + b.mu.RLock("VisibleSnapshots") + defer b.mu.RUnlock() + + return slices.DeleteFunc(slices.Clone(snaps), func(s *Snapshot) bool { return !b.snapshotUsableLocked(caller, s) }) +} + +// NarrowSnapshots applies DescribeSnapshots' OwnerIds (sharingSelf, account ids) +// and RestorableByUserIds (sharingSelf, account ids, "all") request members. +func (b *InMemoryBackend) NarrowSnapshots( + caller string, snaps []*Snapshot, owners, restorableBy []string, +) []*Snapshot { + if len(owners) == 0 && len(restorableBy) == 0 { + return snaps + } + + self := caller + if self == "" { + self = b.AccountID + } + + b.mu.RLock("NarrowSnapshots") + defer b.mu.RUnlock() + + return slices.DeleteFunc(slices.Clone(snaps), func(s *Snapshot) bool { + if len(owners) > 0 && !slices.ContainsFunc(owners, func(o string) bool { + return o == s.OwnerID || + (o == sharingSelf && (s.OwnerID == self || (caller == "" && s.OwnerID == b.AccountID))) + }) { + return true + } + + return len(restorableBy) > 0 && !slices.ContainsFunc(restorableBy, func(u string) bool { + if u == sharingSelf { + u = self + } + + return (u == permissionGroupAll && b.snapshotCreateVolumePubGroup[s.SnapshotID]) || + b.snapshotCreateVolumePerms[s.SnapshotID][u] + }) + }) +} + +// VisibleImages keeps the AMIs the caller owns, may launch, or that are public. +func (b *InMemoryBackend) VisibleImages(caller string, imgs []*AMIStub) []*AMIStub { + if caller == "" { + return imgs + } + + b.mu.RLock("VisibleImages") + defer b.mu.RUnlock() + + return slices.DeleteFunc(slices.Clone(imgs), func(a *AMIStub) bool { return !b.imageUsableLocked(caller, a) }) +} + +// NarrowImagesByExecutable applies DescribeImages' ExecutableUsers (sharingSelf, +// account ids, "all") request member. +func (b *InMemoryBackend) NarrowImagesByExecutable(caller string, imgs []*AMIStub, executableBy []string) []*AMIStub { + if len(executableBy) == 0 { + return imgs + } + + self := caller + if self == "" { + self = b.AccountID + } + + b.mu.RLock("NarrowImagesByExecutable") + defer b.mu.RUnlock() + + return slices.DeleteFunc(slices.Clone(imgs), func(a *AMIStub) bool { + return !slices.ContainsFunc(executableBy, func(u string) bool { + if u == sharingSelf { + return a.OwnerID == self || b.imageLaunchPermissions[a.ImageID][self] + } + + public := b.imageLaunchPermissionPublic[a.ImageID] || knownImageOwnerAliases[a.OwnerID] + + return (u == permissionGroupAll && public) || b.imageLaunchPermissions[a.ImageID][u] + }) + }) +} + +// firstMissingSnapshot reports the first requested snapshot id absent from the visible set. +func firstMissingSnapshot(ids []string, visible []*Snapshot) error { + for _, id := range ids { + if !slices.ContainsFunc(visible, func(s *Snapshot) bool { return s.SnapshotID == id }) { + return fmt.Errorf("%w: %s", ErrSnapshotNotFound, id) + } + } + + return nil +} + +func (h *Handler) requireLaunchableImageFor(vals url.Values, imageID string) error { + if err := h.requireImageUsable(vals, imageID); err != nil { + return err + } + + return h.requireLaunchableImage(h.svcCtx, imageID) +} diff --git a/services/ec2/resource_sharing_test.go b/services/ec2/resource_sharing_test.go new file mode 100644 index 0000000000..23bd80151b --- /dev/null +++ b/services/ec2/resource_sharing_test.go @@ -0,0 +1,451 @@ +package ec2_test + +import ( + "net/http/httptest" + "slices" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + ec2sdk "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/ec2" +) + +const ( + shOwner = "111111111111" + shGuest = "222222222222" + shOther = "333333333333" +) + +type sharingStack struct { + clients map[string]*ec2sdk.Client + snapshots map[string]string + images map[string]string +} + +func newSharingStack(t *testing.T) *sharingStack { + t.Helper() + + e := echo.New() + e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + c.SetRequest(req.WithContext(awsmeta.Set(req.Context(), awsmeta.FromRequest(req, "us-east-1")))) + + return next(c) + } + }) + + registry := service.NewRegistry() + require.NoError(t, registry.Register(ec2.NewHandler(ec2.NewInMemoryBackend("000000000000", "us-east-1")))) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + s := &sharingStack{ + clients: map[string]*ec2sdk.Client{}, snapshots: map[string]string{}, images: map[string]string{}, + } + + for _, acct := range []string{shOwner, shGuest, shOther} { + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion("us-east-1"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(acct, "x", "")), + ) + require.NoError(t, err) + + s.clients[acct] = ec2sdk.NewFromConfig(cfg, func(o *ec2sdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }) + } + + s.seed(t) + + return s +} + +func (s *sharingStack) seed(t *testing.T) { + t.Helper() + + owner := s.clients[shOwner] + ctx := t.Context() + + vol, err := owner.CreateVolume( + ctx, + &ec2sdk.CreateVolumeInput{AvailabilityZone: aws.String("us-east-1a"), Size: aws.Int32(8)}, + ) + require.NoError(t, err) + + for _, name := range []string{"shared", "public", "private"} { + snap, serr := owner.CreateSnapshot(ctx, &ec2sdk.CreateSnapshotInput{VolumeId: vol.VolumeId}) + require.NoError(t, serr) + assert.Equal(t, shOwner, aws.ToString(snap.OwnerId)) + s.snapshots[name] = aws.ToString(snap.SnapshotId) + + img, ierr := owner.RegisterImage(ctx, &ec2sdk.RegisterImageInput{ + Name: aws.String("img-" + name), Architecture: types.ArchitectureValuesX8664, + }) + require.NoError(t, ierr) + s.images[name] = aws.ToString(img.ImageId) + } + + _, err = owner.ModifySnapshotAttribute(ctx, &ec2sdk.ModifySnapshotAttributeInput{ + SnapshotId: aws.String(s.snapshots["shared"]), Attribute: types.SnapshotAttributeNameCreateVolumePermission, + CreateVolumePermission: &types.CreateVolumePermissionModifications{ + Add: []types.CreateVolumePermission{{UserId: aws.String(shGuest)}}, + }, + }) + require.NoError(t, err) + + _, err = owner.ModifySnapshotAttribute(ctx, &ec2sdk.ModifySnapshotAttributeInput{ + SnapshotId: aws.String(s.snapshots["public"]), Attribute: types.SnapshotAttributeNameCreateVolumePermission, + CreateVolumePermission: &types.CreateVolumePermissionModifications{ + Add: []types.CreateVolumePermission{{Group: types.PermissionGroupAll}}, + }, + }) + require.NoError(t, err) + + _, err = owner.ModifyImageAttribute(ctx, &ec2sdk.ModifyImageAttributeInput{ + ImageId: aws.String(s.images["shared"]), Attribute: aws.String("launchPermission"), + LaunchPermission: &types.LaunchPermissionModifications{ + Add: []types.LaunchPermission{{UserId: aws.String(shGuest)}}, + }, + }) + require.NoError(t, err) + + _, err = owner.ModifyImageAttribute(ctx, &ec2sdk.ModifyImageAttributeInput{ + ImageId: aws.String(s.images["public"]), Attribute: aws.String("launchPermission"), + LaunchPermission: &types.LaunchPermissionModifications{ + Add: []types.LaunchPermission{{Group: types.PermissionGroupAll}}, + }, + }) + require.NoError(t, err) +} + +func (s *sharingStack) names(ids map[string]string, got []string) []string { + out := make([]string, 0, len(got)) + + for name, id := range ids { + if slices.Contains(got, id) { + out = append(out, name) + } + } + + slices.Sort(out) + + return out +} + +func requireEC2Code(t *testing.T, err error, code string) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), code) +} + +func TestDescribeSnapshotsSharing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caller string + owners []string + restorableBy []string + want []string + }{ + {name: "owner default", caller: shOwner, want: []string{"private", "public", "shared"}}, + {name: "guest default", caller: shGuest, want: []string{"public", "shared"}}, + {name: "other default", caller: shOther, want: []string{"public"}}, + {name: "owner self", caller: shOwner, owners: []string{"self"}, want: []string{"private", "public", "shared"}}, + {name: "guest self", caller: shGuest, owners: []string{"self"}, want: []string{}}, + {name: "guest by owner id", caller: shGuest, owners: []string{shOwner}, want: []string{"public", "shared"}}, + {name: "guest restorable self", caller: shGuest, restorableBy: []string{"self"}, want: []string{"shared"}}, + {name: "owner restorable guest", caller: shOwner, restorableBy: []string{shGuest}, want: []string{"shared"}}, + {name: "other restorable all", caller: shOther, restorableBy: []string{"all"}, want: []string{"public"}}, + {name: "other restorable guest", caller: shOther, restorableBy: []string{shGuest}, want: []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newSharingStack(t) + + out, err := s.clients[tt.caller].DescribeSnapshots(t.Context(), &ec2sdk.DescribeSnapshotsInput{ + OwnerIds: tt.owners, RestorableByUserIds: tt.restorableBy, + }) + require.NoError(t, err) + + got := make([]string, 0, len(out.Snapshots)) + for _, sn := range out.Snapshots { + got = append(got, aws.ToString(sn.SnapshotId)) + } + + assert.Equal(t, tt.want, s.names(s.snapshots, got)) + }) + } +} + +func TestSharedSnapshotAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + op string + caller string + snapshot string + wantCode string + }{ + {name: "describe shared as guest", op: "describe", caller: shGuest, snapshot: "shared"}, + {name: "describe private as guest", op: "describe", caller: shGuest, snapshot: "private", wantCode: "NotFound"}, + {name: "describe shared as other", op: "describe", caller: shOther, snapshot: "shared", wantCode: "NotFound"}, + {name: "volume from shared", op: "volume", caller: shGuest, snapshot: "shared"}, + {name: "volume from public", op: "volume", caller: shOther, snapshot: "public"}, + {name: "volume from private", op: "volume", caller: shGuest, snapshot: "private", wantCode: "NotFound"}, + {name: "volume from shared as other", op: "volume", caller: shOther, snapshot: "shared", wantCode: "NotFound"}, + {name: "volume from private as owner", op: "volume", caller: shOwner, snapshot: "private"}, + {name: "copy shared", op: "copy", caller: shGuest, snapshot: "shared"}, + {name: "copy private", op: "copy", caller: shGuest, snapshot: "private", wantCode: "NotFound"}, + {name: "modify as guest", op: "modify", caller: shGuest, snapshot: "shared", wantCode: "UnauthorizedOperation"}, + {name: "modify as other", op: "modify", caller: shOther, snapshot: "shared", wantCode: "NotFound"}, + {name: "modify as owner", op: "modify", caller: shOwner, snapshot: "shared"}, + { + name: "attribute as guest", + op: "attribute", + caller: shGuest, + snapshot: "shared", + wantCode: "UnauthorizedOperation", + }, + {name: "attribute as owner", op: "attribute", caller: shOwner, snapshot: "shared"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newSharingStack(t) + c := s.clients[tt.caller] + id := aws.String(s.snapshots[tt.snapshot]) + + var err error + + switch tt.op { + case "describe": + _, err = c.DescribeSnapshots(t.Context(), &ec2sdk.DescribeSnapshotsInput{SnapshotIds: []string{*id}}) + case "volume": + _, err = c.CreateVolume(t.Context(), &ec2sdk.CreateVolumeInput{ + AvailabilityZone: aws.String("us-east-1a"), SnapshotId: id, + }) + case "copy": + _, err = c.CopySnapshot(t.Context(), &ec2sdk.CopySnapshotInput{ + SourceSnapshotId: id, SourceRegion: aws.String("us-east-1"), + }) + case "modify": + _, err = c.ModifySnapshotAttribute(t.Context(), &ec2sdk.ModifySnapshotAttributeInput{ + SnapshotId: id, Attribute: types.SnapshotAttributeNameCreateVolumePermission, + CreateVolumePermission: &types.CreateVolumePermissionModifications{ + Add: []types.CreateVolumePermission{{UserId: aws.String(shOther)}}, + }, + }) + default: + _, err = c.DescribeSnapshotAttribute(t.Context(), &ec2sdk.DescribeSnapshotAttributeInput{ + SnapshotId: id, Attribute: types.SnapshotAttributeNameCreateVolumePermission, + }) + } + + if tt.wantCode != "" { + requireEC2Code(t, err, tt.wantCode) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestSnapshotCopyOwnedByCallerAndUnshare(t *testing.T) { + t.Parallel() + + s := newSharingStack(t) + ctx := t.Context() + + cp, err := s.clients[shGuest].CopySnapshot(ctx, &ec2sdk.CopySnapshotInput{ + SourceSnapshotId: aws.String(s.snapshots["shared"]), SourceRegion: aws.String("us-east-1"), + }) + require.NoError(t, err) + + mine, err := s.clients[shGuest].DescribeSnapshots(ctx, &ec2sdk.DescribeSnapshotsInput{OwnerIds: []string{"self"}}) + require.NoError(t, err) + require.Len(t, mine.Snapshots, 1) + assert.Equal(t, aws.ToString(cp.SnapshotId), aws.ToString(mine.Snapshots[0].SnapshotId)) + assert.Equal(t, shGuest, aws.ToString(mine.Snapshots[0].OwnerId)) + + _, err = s.clients[shOther].DescribeSnapshots(ctx, &ec2sdk.DescribeSnapshotsInput{ + SnapshotIds: []string{aws.ToString(cp.SnapshotId)}, + }) + requireEC2Code(t, err, "NotFound") + + _, err = s.clients[shOwner].ModifySnapshotAttribute(ctx, &ec2sdk.ModifySnapshotAttributeInput{ + SnapshotId: aws.String(s.snapshots["shared"]), Attribute: types.SnapshotAttributeNameCreateVolumePermission, + CreateVolumePermission: &types.CreateVolumePermissionModifications{ + Remove: []types.CreateVolumePermission{{UserId: aws.String(shGuest)}}, + }, + }) + require.NoError(t, err) + + _, err = s.clients[shGuest].CreateVolume(ctx, &ec2sdk.CreateVolumeInput{ + AvailabilityZone: aws.String("us-east-1a"), SnapshotId: aws.String(s.snapshots["shared"]), + }) + requireEC2Code(t, err, "NotFound") +} + +func TestDescribeImagesSharing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caller string + owners []string + executable []string + want []string + }{ + { + name: "owner by self", + caller: shOwner, + owners: []string{"self"}, + want: []string{"private", "public", "shared"}, + }, + {name: "guest by owner", caller: shGuest, owners: []string{shOwner}, want: []string{"public", "shared"}}, + {name: "other by owner", caller: shOther, owners: []string{shOwner}, want: []string{"public"}}, + {name: "guest self", caller: shGuest, owners: []string{"self"}, want: []string{}}, + { + name: "guest executable self", caller: shGuest, owners: []string{shOwner}, executable: []string{"self"}, + want: []string{"shared"}, + }, + { + name: "other executable guest", caller: shOther, owners: []string{shOwner}, executable: []string{shGuest}, + want: []string{}, + }, + { + name: "owner executable all", caller: shOwner, owners: []string{shOwner}, executable: []string{"all"}, + want: []string{"public"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newSharingStack(t) + + out, err := s.clients[tt.caller].DescribeImages(t.Context(), &ec2sdk.DescribeImagesInput{ + Owners: tt.owners, ExecutableUsers: tt.executable, + }) + require.NoError(t, err) + + got := make([]string, 0, len(out.Images)) + for _, im := range out.Images { + got = append(got, aws.ToString(im.ImageId)) + } + + assert.Equal(t, tt.want, s.names(s.images, got)) + }) + } +} + +func TestSharedImageAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + op string + caller string + image string + wantCode string + }{ + {name: "describe shared as guest", op: "describe", caller: shGuest, image: "shared"}, + { + name: "describe private as guest", + op: "describe", + caller: shGuest, + image: "private", + wantCode: "InvalidAMIID.NotFound", + }, + { + name: "describe shared as other", + op: "describe", + caller: shOther, + image: "shared", + wantCode: "InvalidAMIID.NotFound", + }, + {name: "run shared", op: "run", caller: shGuest, image: "shared"}, + {name: "run public", op: "run", caller: shOther, image: "public"}, + {name: "run private", op: "run", caller: shGuest, image: "private", wantCode: "InvalidAMIID.NotFound"}, + {name: "run shared as other", op: "run", caller: shOther, image: "shared", wantCode: "InvalidAMIID.NotFound"}, + {name: "run private as owner", op: "run", caller: shOwner, image: "private"}, + {name: "copy shared", op: "copy", caller: shGuest, image: "shared"}, + {name: "copy private", op: "copy", caller: shGuest, image: "private", wantCode: "InvalidAMIID.NotFound"}, + {name: "modify as guest", op: "modify", caller: shGuest, image: "shared", wantCode: "UnauthorizedOperation"}, + {name: "modify as other", op: "modify", caller: shOther, image: "shared", wantCode: "InvalidAMIID.NotFound"}, + {name: "modify as owner", op: "modify", caller: shOwner, image: "shared"}, + { + name: "attribute as guest", + op: "attribute", + caller: shGuest, + image: "shared", + wantCode: "UnauthorizedOperation", + }, + {name: "attribute as owner", op: "attribute", caller: shOwner, image: "shared"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newSharingStack(t) + c := s.clients[tt.caller] + id := aws.String(s.images[tt.image]) + + var err error + + switch tt.op { + case "describe": + _, err = c.DescribeImages(t.Context(), &ec2sdk.DescribeImagesInput{ImageIds: []string{*id}}) + case "run": + _, err = c.RunInstances(t.Context(), &ec2sdk.RunInstancesInput{ + ImageId: id, InstanceType: types.InstanceTypeT3Micro, + MinCount: aws.Int32(1), MaxCount: aws.Int32(1), + }) + case "copy": + _, err = c.CopyImage(t.Context(), &ec2sdk.CopyImageInput{ + SourceImageId: id, SourceRegion: aws.String("us-east-1"), Name: aws.String("copy"), + }) + case "modify": + _, err = c.ModifyImageAttribute(t.Context(), &ec2sdk.ModifyImageAttributeInput{ + ImageId: id, Attribute: aws.String("launchPermission"), + LaunchPermission: &types.LaunchPermissionModifications{ + Add: []types.LaunchPermission{{UserId: aws.String(shOther)}}, + }, + }) + default: + _, err = c.DescribeImageAttribute(t.Context(), &ec2sdk.DescribeImageAttributeInput{ + ImageId: id, Attribute: types.ImageAttributeNameLaunchPermission, + }) + } + + if tt.wantCode != "" { + requireEC2Code(t, err, tt.wantCode) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/services/ecr/iam_enforcement_test.go b/services/ecr/iam_enforcement_test.go index cfde618b92..11d3a18e94 100644 --- a/services/ecr/iam_enforcement_test.go +++ b/services/ecr/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/ecr" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ecr/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ecs/PARITY.md b/services/ecs/PARITY.md index 6c4ecaa662..29dc37a13c 100644 --- a/services/ecs/PARITY.md +++ b/services/ecs/PARITY.md @@ -56,7 +56,7 @@ ops: DeleteAttributes: {wire: ok, errors: ok, state: ok, persist: ok} ListAttributes: {wire: ok, errors: ok, state: ok, persist: ok, note: "FIXED (order-bug sweep): built its response by ranging b.attributes[cluster] (a map[string]*Attribute) directly with no sort, so order was raw Go map order -- can differ between two calls with no mutation in between. AWS documents no order for this op; now sorted (Name, TargetID) for a stable, testable result. Proven by TestECS_ListAttributes_StableOrder."} PutAttributes: {wire: ok, errors: ok, state: ok, persist: ok} - ExecuteCommand: {wire: ok, errors: ok, state: ok, persist: n/a} + ExecuteCommand: {wire: ok, errors: ok, state: ok, persist: ok, note: "Real session: on a RUNNING docker-runtime task with enableExecuteCommand, creates an SSM Session Manager session (target ecs:__) whose data channel runs the command via docker exec; streamUrl points at this server (/ssmmessages/v1/data-channel/). interactive=false is rejected (api_op_ExecuteCommand.go / API_ExecuteCommand: ECS only supports interactive sessions); an unreachable agent is TargetNotConnectedException; container is optional only for single-container tasks; task accepts an ID or ARN. Proof: TestExecuteCommand, TestExecuteCommand_Errors_RealClient, TestContainerSession (ssm)."} GetTaskProtection: {wire: ok, errors: ok, state: ok, persist: ok} UpdateTaskProtection: {wire: ok, errors: ok, state: ok, persist: ok} TagResource: {wire: ok, errors: ok, state: ok, persist: ok, note: "resourceTags side map was NOT in backendSnapshot at all — fixed, see gaps/fixes. Fixed a real bug where TagResource on an Express Gateway Service ARN silently never became visible on Describe or ListTagsForResource (see ExpressGatewayService notes below). FIXED gopherstack-rnka: the identical disconnect for ordinary Service ARNs (Service.Tags was a creation-time-only snapshot, never synced with resourceTags, and RunTask's propagateTags=SERVICE path read the stale snapshot too) is now closed -- see CreateService/UpdateService/DeleteService/DescribeServices notes. Re-checked this pass (wrapper-key sweep) against the sfn TagResource map/array bug class: ecs's TagResourceInput.Tags is []types.Tag, array of {key,value} (api_op_TagResource.go:82, serializers.go:8688-8700), matching this emulator's []Tag{Key,Value} exactly -- genuinely clean, confirmed via a real-client round-trip test (tag_resource_sdk_test.go)."} @@ -73,7 +73,12 @@ ops: families: daemon: {status: ok, note: "Field-diffed for real (previous ledger entries for this family were no-stub-only assessments, not wire-shape diffs). Fixed a real leak: DeleteDaemon never cleaned up daemonRevisions/daemonDeployments rows at all (only the daemons table entry), and the cluster-purge cleanup path (purgeDaemonsLocked) deleted from daemonRevisions by the wrong key (DaemonArn instead of DaemonRevisionArn, a documented-but-never-fixed no-op preserved through a prior mechanical refactor) -- both fixed via a new shared deleteDaemonAncillaryLocked helper. CORRECTED gopherstack-rnka: the prior ledger entry here (2026-07-23) claimed DescribeDaemonOutput.Daemon was flattened -- daemonName/daemonTaskDefinitionArn/capacityProviderArns/tags/etc. living directly on the response instead of nested under CurrentRevisions -- and downgraded this family to partial on that basis. Re-verified against the real types.DaemonDetail shape (ClusterArn/CreatedAt/CurrentRevisions[]DaemonRevisionDetail{Arn,CapacityProviders[]DaemonCapacityProvider{Arn,RunningCount},TotalRunningCount}/DaemonArn/DeploymentArn/Status/UpdatedAt) field-by-field: handler_daemon.go's daemonDetailView/daemonRevisionDetailView/daemonCapacityProviderView already match this exactly, and DO NOT expose daemonName/daemonTaskDefinitionArn/tags/etc. at the top level. Proven with a new real-SDK-client round-trip test (TestECS_DescribeDaemon_SDKRoundTrip_RevisionNesting) rather than trusting the prior note. The 2026-07-23 gap description was inaccurate at the time it was written (the code was already correct); upgraded back to ok. FIXED (order-bug sweep): ListDaemonTaskDefinitions had the same numeric-vs-lexicographic bug as ListTaskDefinitions -- it sorted by the full ARN string ('daemon-task-definition/family:10' < '...:2'), wrong once a family passes revision 9, though unlike ListTaskDefinitions the request's Sort field WAS threaded through and applied (as a post-hoc reversal of the already-wrong order). AWS documents 'by default (ASC), daemon task definitions are listed in ascending order by family name and revision number' (api_op_ListDaemonTaskDefinitions.go). Now sorts by (Family, Revision) with Revision compared numerically before Sort=DESC reverses it. Proven by TestECS_ListDaemonTaskDefinitions_Order. FIXED (value-semantics sweep, gopherstack-uox6): ListDaemons had the same DescribeClusters-shaped bug -- ListDaemonsInput.ClusterArn docs 'If you do not specify a cluster, the default cluster is assumed', but an empty ClusterArn returned daemons from every cluster in the account instead of scoping to 'default'. Fixed by routing through the same resolveCluster helper every other Cluster-defaulting op uses. Proven by TestECS_ListDaemons_OmittedClusterScopesToDefault (fails without the fix). Gap: ListDaemonDeploymentsInput.CreatedAt (a documented time-range filter) is not declared/read at all -- other axis (never-read), not fixed here. FIXED (2026-09-18, gopherstack-xhu2t reqfielddiff tier-1): CreateDaemon.Critical and UpdateDaemon.Critical (documented default true, ecs@v1.96.0 api_op_CreateDaemon.go:86/api_op_UpdateDaemon.go:79) were entirely undeclared -- not on the wire input structs, not on the Daemon/DaemonRevision domain structs, not echoed anywhere. Added Critical to both domain structs (default resolved via resolveDaemonCritical) and threaded it into DescribeDaemonRevisions' daemonRevisionView (the only response shape types.DaemonRevision.Critical actually appears on -- DescribeDaemon's DaemonRevisionDetail has no Critical member). Proven by TestECS_DaemonCritical_DefaultsTrueAndHonoursExplicitFalse (real SDK client: create with Critical omitted decodes true, update with Critical=false decodes false)."} gaps: [] -items_still_open: [] +items_still_open: + - "Docker runtime container definition: firelensConfiguration and awsfirelens log routing (a task using either fails with TaskFailedToStart instead of starting the log router), resourceRequirements (GPU), non-awslogs logConfiguration drivers, ephemeralStorage and task-level cpu/memory cgroups are stored but not applied to the Docker container." + - "Docker runtime volumes: configuredAtLaunch (EBS) volumes fail the task with a TaskFailedToStart stoppedReason instead of mounting; networkMode host/none/awsvpc ENI semantics and pidMode/ipcMode 'task' are not mapped (pidMode/ipcMode 'host' and ipcMode 'none' are)." + - "Docker runtime dependsOn: the stoppedReason text for a failed dependency is unverified (no AWS doc publishes it; the task just moves to STOPPED per ContainerDefinition.startTimeout). Launch is asynchronous (PROVISIONING, PENDING, ACTIVATING, RUNNING); pullStartedAt/pullStoppedAt bracket the whole launch (no per-image pull timing), and PROVISIONING tasks in a restored snapshot are not resumed." + - "ECS Exec: sessions exist only on the docker runtime with the SSM service enabled (otherwise TargetNotConnectedException); the command runs as `sh -c ` through docker exec, and executeCommandConfiguration logging/KMS encryption and the ssmmessages IAM checks are not applied." + - "Docker runtime health: container restarts are applied but restart counts are not reported." structural_gaps: - "Service Connect: ServiceConnectConfiguration is stored and echoed, and ListServicesByNamespace matches on it, but Service Connect proxies and its Cloud Map instance registration are not modeled." - "Cloud Map registration of bridge/host-mode EC2 tasks: container instances have no modeled private IP, so only awsvpc tasks (ENI IP) are registered; tasks carry no placement AZ, so AVAILABILITY_ZONE is a." @@ -86,6 +91,18 @@ leaks: {status: clean, note: "Prior 'found' status was stale documentation -- th ## Notes +### 2026-10-30: EFS volumes, unhealthy-task replacement + +efsVolumeConfiguration volumes on the docker runtime bind-mount the emulator's EFS host storage (services/efs, the same mechanism as Lambda's FileSystemConfig): with authorizationConfig.accessPointId the access point's root directory is mounted (rootDirectory must be omitted or "/", per the ECS EFS volumes guide; the access point must belong to fileSystemId), otherwise rootDirectory under the file system. The access point's POSIX user becomes the user (and secondary groups) of each container that mounts the volume and sets no `user`. transitEncryption and iam are accepted and need no TLS/SigV4 locally. A missing file system/access point, a rootDirectory/access point conflict or an unwired EFS fails the task TaskFailedToStart with stoppedReason "ResourceInitializationError: failed to invoke EFS utils commands to set up EFS volumes: " (the documented ResourceInitializationError text, stopped-task-error-codes / resource-initialization-error; the exact text real ECS emits for a missing access point is unverified). Tests: TestRunTask_EFSVolume, TestResolveVolumeMount, TestECSEFSResolverWiring, docker-gated TestDockerRunnerLiveEFSAccessPoint. + +The reconciler stops RUNNING service tasks whose task healthStatus is UNHEALTHY with stoppedReason "Task failed container health checks" and stopCode ServiceSchedulerInitiated, ignoring them until the service's healthCheckGracePeriodSeconds has elapsed since startedAt (injectable clock), and scaleUp launches the replacement within maximumPercent. Unhealthy tasks do not count as healthy so minimumHealthyPercent does not hold the stop back. Standalone tasks are never replaced. The stoppedReason text is the commonly seen ECS wording and is not in the published docs. Tests: TestStopUnhealthyServiceTasks, TestReconciler_ReplacesUnhealthyServiceTask, docker-gated TestDockerRunnerLiveUnhealthyServiceTaskReplaced. + +dependsOn: the 3-minute default dependency wait matches the ECS agent default ECS_CONTAINER_START_TIMEOUT (3m Linux; github.com/aws/amazon-ecs-agent README) and the 30s stopTimeout default matches ContainerDefinition.stopTimeout; the failed-dependency stoppedReason stays unverified. + +### 2026-10-27: Docker runtime applies container-definition parameters + +createContainer now maps the container definition onto the Docker create and host configs: command/entryPoint, user, workingDirectory, hostname, cpu (CpuShares), memory/memoryReservation (MiB to bytes), ulimits, linuxParameters (capabilities, initProcessEnabled, sharedMemorySize, maxSwap, swappiness, devices, tmpfs), privileged, readonlyRootFilesystem, disableNetworking, dnsServers, dnsSearchDomains, extraHosts, systemControls, dockerSecurityOptions, dockerLabels, interactive/pseudoTerminal, stopTimeout (default 30s) and healthCheck (ECS defaults: interval 30, timeout 5, retries 3). Secrets resolve from Secrets Manager (ARN with :json-key:version-stage:version-id) and SSM (name or parameter ARN) before any container is created; a failure stops the task with stopCode TaskFailedToStart and a ResourceInitializationError stoppedReason. Volumes: host.sourcePath is a bind, dockerVolumeConfiguration a named volume (task scope is removed with the task, shared scope honours autoprovision), an empty host an ephemeral task-scoped volume; mountPoints readOnly and volumesFrom are honoured. dependsOn (START/COMPLETE/SUCCESS/HEALTHY, startTimeout) orders and gates container starts; HEALTHY uses the Docker healthcheck. Containers stop in reverse start order with their stopTimeout. DescribeTasks reports container healthStatus/exitCode/reason (OOM kill) and task healthStatus/stopCode (EssentialContainerExited, UserInitiated, TaskFailedToStart, ServiceSchedulerInitiated); the container restartPolicy restarts exited containers. RegisterTaskDefinition rejects unknown or cyclic dependsOn/volumesFrom, HEALTHY on a container without a healthCheck, mountPoints of undefined volumes and autoprovision outside shared scope. Tests: docker_config_internal_test.go, docker_launch_internal_test.go, docker_task_lifecycle_internal_test.go, TestIntegration_ECS_DockerContainerDefinition (opt-in via GOPHERSTACK_TEST_ECS_DOCKER_ENDPOINT). + ### 2026-10-08: blue/green deployment lifecycle DeploymentConfiguration now carries strategy (ROLLING/BLUE_GREEN/LINEAR/CANARY), bakeTimeInMinutes, lifecycleHooks, @@ -1303,3 +1320,16 @@ Service tasks with serviceRegistries register as Cloud Map instances on RUNNING - Tasks that cannot be placed on a managed-scaling provider stay PROVISIONING (`AwaitingCapacity`) and are placed when capacity appears. The emulated ECS agent registers in-service group instances as container instances (`ManagedBy`) and retires them when they leave the group. - Managed termination protection ENABLED: instances running tasks get scale-in protection, idle ones are unprotected, and desired capacity never drops below the busy count. - Not modeled: instanceWarmupPeriod, the 15-minute scale-in evaluation window, per-instance-type heterogeneity within one group, ENI limits. + +## 2026-10-28 asynchronous task launch and real ECS Exec + +RunTask returns the task PROVISIONING; a tracked goroutine per task (counter plus idle channel under the backend lock; `Close`, `Reset` and `StopTask` cancel it) walks PROVISIONING, PENDING, ACTIVATING (image pull, container create, dependsOn gates) and RUNNING, or STOPPED with `TaskFailedToStart`. createdAt/pullStartedAt/pullStoppedAt/startedAt/connectivityAt are stamped per API_Task; startedAt and connectivityAt are now unset until RUNNING. The service reconciler counts launching tasks, so a PROVISIONING task satisfies the desired count. ExecuteCommand no longer fabricates a `.invalid` stream (see the ExecuteCommand op note). + +## 2026-10-29 (Docker runtime: repositoryCredentials, environmentFiles) + +repositoryCredentials resolves the Secrets Manager secret ({"username","password"}) and pulls with that registry auth; +a missing or malformed secret stops the task with `ResourceInitializationError: unable to pull secrets or registry auth`. +environmentFiles read `.env` objects from the emulator's S3 (VARIABLE=VALUE, `#` comments, values verbatim); the first +file defining a variable wins and `environment` overrides files; an unreadable file stops the task with +`ResourceInitializationError: failed to download env files`. Sources: ECS developer guide "Using non-AWS container +images in Amazon ECS", "Pass environment variables to an Amazon ECS container", "Troubleshooting ResourceInitializationError". diff --git a/services/ecs/README.md b/services/ecs/README.md index 92e596b56a..0fa70d3ba9 100644 --- a/services/ecs/README.md +++ b/services/ecs/README.md @@ -9,11 +9,19 @@ | --- | --- | | PARITY entries audited | 65 (63 ok, 2 partial) | | Feature families | 1 (1 ok) | -| Known gaps | none | +| Known gaps | 5 | | Structural gaps (can't be emulated) | 5 | | Deferred items | 0 | | Resource leaks | clean | +### Known gaps + +- Docker runtime container definition: firelensConfiguration and awsfirelens log routing (a task using either fails with TaskFailedToStart instead of starting the log router), resourceRequirements (GPU), non-awslogs logConfiguration drivers, ephemeralStorage and task-level cpu/memory cgroups are stored but not applied to the Docker container. +- Docker runtime volumes: configuredAtLaunch (EBS) volumes fail the task with a TaskFailedToStart stoppedReason instead of mounting; networkMode host/none/awsvpc ENI semantics and pidMode/ipcMode 'task' are not mapped (pidMode/ipcMode 'host' and ipcMode 'none' are). +- Docker runtime dependsOn: the stoppedReason text for a failed dependency is unverified (no AWS doc publishes it; the task just moves to STOPPED per ContainerDefinition.startTimeout). Launch is asynchronous (PROVISIONING, PENDING, ACTIVATING, RUNNING); pullStartedAt/pullStoppedAt bracket the whole launch (no per-image pull timing), and PROVISIONING tasks in a restored snapshot are not resumed. +- ECS Exec: sessions exist only on the docker runtime with the SSM service enabled (otherwise TargetNotConnectedException); the command runs as `sh -c ` through docker exec, and executeCommandConfiguration logging/KMS encryption and the ssmmessages IAM checks are not applied. +- Docker runtime health: container restarts are applied but restart counts are not reported. + ### Structural gaps These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. diff --git a/services/ecs/container_health_replace.go b/services/ecs/container_health_replace.go new file mode 100644 index 0000000000..d74891a07d --- /dev/null +++ b/services/ecs/container_health_replace.go @@ -0,0 +1,57 @@ +package ecs + +import "time" + +const containerHealthStopReason = "Task failed container health checks" + +// stopUnhealthyServiceTasks stops RUNNING service tasks whose essential-container health is UNHEALTHY once the +// service's healthCheckGracePeriodSeconds has elapsed since the task started; the reconciler then launches the +// replacement within the deployment's maximumPercent. Standalone tasks are never replaced. Unhealthy tasks do not +// count as healthy, so minimumHealthyPercent does not hold the stop back. +func (b *InMemoryBackend) stopUnhealthyServiceTasks() { + type target struct{ cluster, arn string } + + var targets []target + + b.mu.RLock("stopUnhealthyServiceTasks") + + now := b.clock() + + for _, t := range b.tasks.All() { + if t.LastStatus != statusRunning || t.DesiredStatus != statusRunning || t.HealthStatus != healthECSUnhealthy { + continue + } + + svc := b.serviceForTaskLocked(t, clusterKey(t.ClusterArn)) + if svc == nil || svc.Status != statusActive || inHealthGracePeriod(svc, t, now) { + continue + } + + targets = append(targets, target{cluster: t.ClusterArn, arn: t.TaskArn}) + } + + b.mu.RUnlock() + + for _, tg := range targets { + if _, err := b.StopTask(tg.cluster, tg.arn, containerHealthStopReason); err == nil { + b.markServiceSchedulerStop(tg.arn) + } + } +} + +func inHealthGracePeriod(svc *Service, t *Task, now time.Time) bool { + if svc.HealthCheckGracePeriodSeconds == nil || *svc.HealthCheckGracePeriodSeconds <= 0 || t.StartedAt == nil { + return false + } + + return now.Before(t.StartedAt.Add(time.Duration(*svc.HealthCheckGracePeriodSeconds) * time.Second)) +} + +func (b *InMemoryBackend) markServiceSchedulerStop(taskArn string) { + b.mu.Lock("markServiceSchedulerStop") + defer b.mu.Unlock() + + if t, ok := b.tasks.Get(taskArn); ok { + t.StopCode = stopCodeServiceSchedulerInitiated + } +} diff --git a/services/ecs/container_health_replace_internal_test.go b/services/ecs/container_health_replace_internal_test.go new file mode 100644 index 0000000000..982da34f4d --- /dev/null +++ b/services/ecs/container_health_replace_internal_test.go @@ -0,0 +1,136 @@ +package ecs + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStopUnhealthyServiceTasks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + health string + grace int + elapsed time.Duration + standalone bool + wantStopped bool + }{ + {name: "unhealthy_no_grace", health: healthECSUnhealthy, wantStopped: true}, + {name: "unhealthy_inside_grace", health: healthECSUnhealthy, grace: 60, elapsed: 30 * time.Second}, + { + name: "unhealthy_after_grace", + health: healthECSUnhealthy, + grace: 60, + elapsed: 61 * time.Second, + wantStopped: true, + }, + {name: "healthy", health: healthECSHealthy}, + {name: "standalone_not_replaced", health: healthECSUnhealthy, standalone: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("123456789012", "us-east-1", NewNoopRunner()) + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + now := start + b.nowFn = func() time.Time { return now } + + _, err := b.CreateCluster(CreateClusterInput{ClusterName: "c"}) + require.NoError(t, err) + + td, err := b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "hc", + ContainerDefinitions: []ContainerDefinition{{Name: "app", Image: "nginx", Essential: true}}, + }) + require.NoError(t, err) + + grace := tt.grace + _, err = b.CreateService(CreateServiceInput{ + Cluster: "c", ServiceName: "svc", TaskDefinition: td.TaskDefinitionArn, DesiredCount: 0, + HealthCheckGracePeriodSeconds: &grace, + }) + require.NoError(t, err) + + var taskArn string + + if tt.standalone { + tasks, _, rerr := b.RunTask(RunTaskInput{Cluster: "c", TaskDefinition: td.TaskDefinitionArn}) + require.NoError(t, rerr) + + taskArn = tasks[0].TaskArn + } else { + require.NoError(t, b.StartTaskForService("c", "svc", td.TaskDefinitionArn)) + + tasks, _, lerr := b.DescribeTasks("c", nil) + require.NoError(t, lerr) + require.Len(t, tasks, 1) + + taskArn = tasks[0].TaskArn + } + + b.updateContainerHealth(taskArn, "app", tt.health) + + now = start.Add(tt.elapsed) + b.stopUnhealthyServiceTasks() + + got, _, err := b.DescribeTasks("c", []string{taskArn}) + require.NoError(t, err) + require.Len(t, got, 1) + + if !tt.wantStopped { + assert.Equal(t, statusRunning, got[0].DesiredStatus) + + return + } + + assert.Equal(t, statusStopped, got[0].DesiredStatus) + assert.Equal(t, containerHealthStopReason, got[0].StoppedReason) + assert.Equal(t, stopCodeServiceSchedulerInitiated, got[0].StopCode) + }) + } +} + +func TestReconciler_ReplacesUnhealthyServiceTask(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("123456789012", "us-east-1", NewNoopRunner()) + + _, err := b.CreateCluster(CreateClusterInput{ClusterName: "c"}) + require.NoError(t, err) + + td, err := b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "hc", ContainerDefinitions: []ContainerDefinition{{Name: "app", Image: "nginx", Essential: true}}, + }) + require.NoError(t, err) + + _, err = b.CreateService(CreateServiceInput{ + Cluster: "c", ServiceName: "svc", TaskDefinition: td.TaskDefinitionArn, DesiredCount: 1, + }) + require.NoError(t, err) + + rec := NewReconciler(b) + rec.RunOnce(t.Context()) + b.awaitLaunches() + + running, err := b.ListTasksFiltered(ListTasksInput{Cluster: "c", DesiredStatus: statusRunning}) + require.NoError(t, err) + require.Len(t, running, 1) + + first := running[0] + + b.updateContainerHealth(first, "app", healthECSUnhealthy) + + rec.RunOnce(t.Context()) + b.awaitLaunches() + + running, err = b.ListTasksFiltered(ListTasksInput{Cluster: "c", DesiredStatus: statusRunning}) + require.NoError(t, err) + require.Len(t, running, 1) + assert.NotEqual(t, first, running[0], "unhealthy task replaced by a new one") +} diff --git a/services/ecs/container_instances.go b/services/ecs/container_instances.go index bec28f5bc0..77f8f92f82 100644 --- a/services/ecs/container_instances.go +++ b/services/ecs/container_instances.go @@ -231,7 +231,7 @@ func (b *InMemoryBackend) countInstanceTasks(ci *ContainerInstance, clusterName switch t.LastStatus { case statusRunning: running++ - case statusPending, statusProvisioning: + case statusPending, statusProvisioning, statusActivating: pending++ } } diff --git a/services/ecs/deployment_internal_test.go b/services/ecs/deployment_internal_test.go index 4b47ec08ce..77388f34c0 100644 --- a/services/ecs/deployment_internal_test.go +++ b/services/ecs/deployment_internal_test.go @@ -118,6 +118,8 @@ func launchFailingServiceTasks(t *testing.T, b *InMemoryBackend, tdArn string, c }); err != nil { t.Fatalf("RunTask: %v", err) } + + b.awaitLaunches() } func primaryDeployment(t *testing.T, b *InMemoryBackend) Deployment { diff --git a/services/ecs/docker_adopt.go b/services/ecs/docker_adopt.go new file mode 100644 index 0000000000..04857e2c7e --- /dev/null +++ b/services/ecs/docker_adopt.go @@ -0,0 +1,190 @@ +package ecs + +import ( + "context" + "strconv" + "strings" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + labelTaskVolumes = "gopherstack.ecs.volumes" + adoptStopSeconds = 2 +) + +type claimFunc func(service, resource string) (container.ManagedContainer, bool) + +// taskAdopter is implemented by runners that can take over containers a crashed gopherstack left running. +type taskAdopter interface { + AdoptTask(ctx context.Context, task *Task, td *TaskDefinition) bool +} + +func ecsResourceKey(taskArn, containerName string) string { return taskArn + "|" + containerName } + +// managedLabels identify a task container to a restarted gopherstack. Containers behind a security-group relay +// carry no resource key: the relay dies with the process, so they are never adopted. +func managedLabels(l *launch, cd ContainerDefinition, fronted bool) map[string]string { + if fronted { + return container.ManagedLabels("ecs", "") + } + + out := container.ManagedLabels("ecs", ecsResourceKey(l.task.TaskArn, cd.Name)) + if l.plan != nil && len(l.plan.taskVols) > 0 { + out[labelTaskVolumes] = strings.Join(l.plan.taskVols, ",") + } + + return out +} + +// AdoptTask takes over the running containers of task, one per container definition. It is all or nothing: when +// any container is missing, not running or not the expected image, every claimed container is removed and false +// is returned. +func (r *realDockerRunner) AdoptTask(ctx context.Context, task *Task, td *TaskDefinition) bool { + claim := r.claim + if claim == nil { + claim = container.ClaimOrphan + } + + return r.adoptTask(ctx, claim, task, td) +} + +type adoptedContainer struct { + id string + def ContainerDefinition + timeout int +} + +func (r *realDockerRunner) adoptTask(ctx context.Context, claim claimFunc, task *Task, td *TaskDefinition) bool { + order, err := orderContainers(td.ContainerDefinitions) + if err != nil { + return false + } + + var ( + claimed []string + found = make([]adoptedContainer, len(td.ContainerDefinitions)) + vols string + ok = true + ) + + for i, cd := range td.ContainerDefinitions { + oc, hit := claim("ecs", ecsResourceKey(task.TaskArn, cd.Name)) + if !hit { + ok = false + + continue + } + + claimed = append(claimed, oc.ID) + + ac, usable := r.adoptable(ctx, task, i, cd, oc) + if !usable { + ok = false + + continue + } + + found[i] = ac + + if v := oc.Labels[labelTaskVolumes]; v != "" { + vols = v + } + } + + if !ok { + r.discardClaimed(ctx, claimed) + + return false + } + + r.resume(task, td, found, order, vols) + + logger.Load(ctx).InfoContext(ctx, "ecs: adopted task containers from an earlier run", "task", task.TaskArn) + + return true +} + +func (r *realDockerRunner) adoptable( + ctx context.Context, task *Task, idx int, cd ContainerDefinition, oc container.ManagedContainer, +) (adoptedContainer, bool) { + if idx < len(task.Containers) { + if want := task.Containers[idx].RuntimeID; want != "" && want != oc.ID { + return adoptedContainer{}, false + } + } + + resp, err := r.cli.ContainerInspect(ctx, oc.ID) + if err != nil || resp.State == nil || !resp.State.Running || resp.Config == nil || resp.Config.Image != cd.Image { + return adoptedContainer{}, false + } + + timeout := rollbackStopTimeoutSeconds + if resp.Config.StopTimeout != nil { + timeout = *resp.Config.StopTimeout + } + + return adoptedContainer{def: cd, id: oc.ID, timeout: timeout}, true +} + +func (r *realDockerRunner) discardClaimed(ctx context.Context, ids []string) { + timeout := adoptStopSeconds + + for _, id := range ids { + _ = r.cli.ContainerStop(ctx, id, dockertypes.StopOptions{Timeout: &timeout}) + _ = r.cli.ContainerRemove(ctx, id, dockertypes.RemoveOptions{Force: true, RemoveVolumes: true}) + } +} + +// resume registers the adopted containers and restarts exit, health and log watching. Logs resume from now: +// replaying the container's whole history would duplicate what was already forwarded. +func (r *realDockerRunner) resume( + task *Task, td *TaskDefinition, found []adoptedContainer, order []int, vols string, +) { + defOrder := make([]string, len(found)) + startOrder := make([]string, 0, len(found)) + + for i, f := range found { + defOrder[i] = f.id + } + + for _, i := range order { + startOrder = append(startOrder, found[i].id) + } + + r.mu.Lock() + r.containers[task.TaskArn] = defOrder + r.stopOrder[task.TaskArn] = startOrder + + if vols != "" { + r.taskVolumes[task.TaskArn] = strings.Split(vols, ",") + } + + for _, f := range found { + r.stopTimeouts[f.id] = f.timeout + } + + r.mu.Unlock() + + handler := r.taskCompletionHandler() + needsExit := exitDependencies(td.ContainerDefinitions) + since := strconv.FormatInt(r.now().Unix(), 10) + taskID := taskIDFromARN(task.TaskArn) + + for _, f := range found { + if cwLogs := r.cwLogsBackend(); cwLogs != nil { + if group, stream, ok := awslogsContainerTarget(f.def, taskID, f.id); ok { + _ = cwLogs.EnsureLogGroupAndStream(group, stream) + r.forwardContainerLogs(cwLogs, f.id, group, stream, since) + } + } + + if handler != nil || needsExit[f.def.Name] || f.def.RestartPolicy != nil { + r.watchContainerExit(handler, task.TaskArn, f.id, f.def) + } + + r.monitorHealth(task.TaskArn, f.id, f.def) + } +} diff --git a/services/ecs/docker_adopt_internal_test.go b/services/ecs/docker_adopt_internal_test.go new file mode 100644 index 0000000000..df8cb8cb70 --- /dev/null +++ b/services/ecs/docker_adopt_internal_test.go @@ -0,0 +1,279 @@ +package ecs + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +// inspectingFake reports each container's image and running state, which the plain fake leaves empty. +type inspectingFake struct { + *fakeDockerClient + images map[string]string + stopped map[string]bool +} + +func (f *inspectingFake) ContainerInspect(_ context.Context, id string) (dockertypes.InspectResponse, error) { + timeout := 7 + + return dockertypes.InspectResponse{ + ID: id, + State: &dockertypes.State{Running: !f.stopped[id]}, + Config: &dockertypes.Config{Image: f.images[id], StopTimeout: &timeout}, + }, nil +} + +func claimsFrom(held map[string]container.ManagedContainer) claimFunc { + return func(service, resource string) (container.ManagedContainer, bool) { + if service != "ecs" { + return container.ManagedContainer{}, false + } + + c, ok := held[resource] + + return c, ok + } +} + +const adoptTaskArn = "arn:aws:ecs:us-east-1:000000000000:task/c/abc" + +func adoptTaskDef() *TaskDefinition { + return &TaskDefinition{ContainerDefinitions: []ContainerDefinition{ + {Name: "web", Image: "nginx", Essential: true}, + {Name: "side", Image: "busybox", Essential: false}, + }} +} + +func TestAdoptTask(t *testing.T) { + t.Parallel() + + held := func(names ...string) map[string]container.ManagedContainer { + out := map[string]container.ManagedContainer{} + for _, n := range names { + out[ecsResourceKey(adoptTaskArn, n)] = container.ManagedContainer{ + ID: "id-" + n, Running: true, + Labels: map[string]string{labelTaskVolumes: "vol-a,vol-b"}, + } + } + + return out + } + + tests := []struct { + held map[string]container.ManagedContainer + images map[string]string + stopped map[string]bool + name string + wantRemoved []string + wantAdopt bool + }{ + { + name: "all containers present", held: held("web", "side"), + images: map[string]string{"id-web": "nginx", "id-side": "busybox"}, + wantAdopt: true, + }, + { + name: "one container missing", held: held("web"), + images: map[string]string{"id-web": "nginx"}, + wantRemoved: []string{"id-web"}, + }, + { + name: "image changed", held: held("web", "side"), + images: map[string]string{"id-web": "nginx", "id-side": "alpine"}, + wantRemoved: []string{"id-web", "id-side"}, + }, + { + name: "container exited", held: held("web", "side"), + images: map[string]string{"id-web": "nginx", "id-side": "busybox"}, + stopped: map[string]bool{"id-side": true}, + wantRemoved: []string{"id-web", "id-side"}, + }, + {name: "nothing held", held: held()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &inspectingFake{ + fakeDockerClient: &fakeDockerClient{}, images: tt.images, stopped: tt.stopped, + } + r := newDockerRunnerWithClient(t.Context(), fake) + r.claim = claimsFrom(tt.held) + + task := &Task{TaskArn: adoptTaskArn} + got := r.AdoptTask(t.Context(), task, adoptTaskDef()) + + assert.Equal(t, tt.wantAdopt, got) + assert.ElementsMatch(t, tt.wantRemoved, fake.removed) + + if !tt.wantAdopt { + assert.Empty(t, r.ContainerIDs(adoptTaskArn)) + + return + } + + assert.Equal(t, []string{"id-web", "id-side"}, r.ContainerIDs(adoptTaskArn)) + assert.Equal(t, []string{"vol-a", "vol-b"}, r.taskVolumes[adoptTaskArn]) + assert.Equal(t, 7, r.stopTimeouts["id-web"]) + assert.Empty(t, fake.removed) + }) + } +} + +func TestManagedLabelsForTaskContainer(t *testing.T) { + t.Parallel() + + tests := []struct { + plan *volumePlan + name string + wantResource string + wantVolumes string + fronted bool + }{ + {name: "adoptable", plan: &volumePlan{}, wantResource: ecsResourceKey(adoptTaskArn, "web")}, + { + name: "task volumes", plan: &volumePlan{taskVols: []string{"v1", "v2"}}, + wantResource: ecsResourceKey(adoptTaskArn, "web"), wantVolumes: "v1,v2", + }, + {name: "fronted never adopted", plan: &volumePlan{}, fronted: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l := &launch{task: &Task{TaskArn: adoptTaskArn}, plan: tt.plan} + got := managedLabels(l, ContainerDefinition{Name: "web"}, tt.fronted) + + assert.Equal(t, "true", got[container.LabelManaged]) + assert.Equal(t, container.CurrentIdentity().Owner, got[container.LabelOwner]) + assert.Equal(t, "ecs", got[container.LabelService]) + assert.Equal(t, tt.wantResource, got[container.LabelResource]) + assert.Equal(t, tt.wantVolumes, got[labelTaskVolumes]) + }) + } +} + +func TestRunTaskStampsIdentityLabels(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + runner := newDockerRunnerWithClient(t.Context(), fake) + backend := NewInMemoryBackend("000000000000", "us-east-1", runner) + + _, err := backend.CreateCluster(CreateClusterInput{ClusterName: "test"}) + require.NoError(t, err) + + _, err = backend.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "fam", ContainerDefinitions: []ContainerDefinition{{Name: "app", Image: "busybox"}}, + }) + require.NoError(t, err) + + tasks, _, err := backend.RunTask(RunTaskInput{Cluster: "test", TaskDefinition: "fam"}) + require.NoError(t, err) + + tasks = settleTasks(t, backend, tasks) + require.Len(t, tasks, 1) + require.Len(t, fake.creates, 1) + + l := fake.creates[0].cfg.Labels + assert.Equal(t, "true", l[container.LabelManaged]) + assert.Equal(t, "ecs", l[container.LabelService]) + assert.Equal(t, ecsResourceKey(tasks[0].TaskArn, "app"), l[container.LabelResource]) +} + +func TestRestoredTasksAdoptedOrStopped(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantStatus string + adopt bool + }{ + {name: "containers survived", adopt: true, wantStatus: statusRunning}, + {name: "containers lost", adopt: false, wantStatus: statusStopped}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake1 := &fakeDockerClient{} + b1 := NewInMemoryBackend("000000000000", "us-east-1", newDockerRunnerWithClient(t.Context(), fake1)) + + _, err := b1.CreateCluster(CreateClusterInput{ClusterName: "test"}) + require.NoError(t, err) + + _, err = b1.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "fam", ContainerDefinitions: []ContainerDefinition{{Name: "app", Image: "busybox"}}, + }) + require.NoError(t, err) + + tasks, _, err := b1.RunTask(RunTaskInput{Cluster: "test", TaskDefinition: "fam"}) + require.NoError(t, err) + + tasks = settleTasks(t, b1, tasks) + require.Equal(t, statusRunning, tasks[0].LastStatus) + + arn, cid := tasks[0].TaskArn, tasks[0].Containers[0].RuntimeID + require.NotEmpty(t, cid) + + snap := b1.Snapshot(t.Context()) + + fake2 := &inspectingFake{ + fakeDockerClient: &fakeDockerClient{waitResult: make(chan dockertypes.WaitResponse, 1)}, + images: map[string]string{cid: "busybox"}, + } + r2 := newDockerRunnerWithClient(t.Context(), fake2) + + if tt.adopt { + r2.claim = claimsFrom(map[string]container.ManagedContainer{ + ecsResourceKey(arn, "app"): {ID: cid, Running: true}, + }) + } else { + r2.claim = claimsFrom(nil) + } + + b2 := NewInMemoryBackend("000000000000", "us-east-1", r2) + r2.SetTaskCompletionHandler(b2.markTaskStoppedByContainerExit) + require.NoError(t, b2.Restore(t.Context(), snap)) + b2.adoptRestoredTasks(t.Context()) + + got, _, err := b2.DescribeTasks("test", []string{arn}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, tt.wantStatus, got[0].LastStatus) + + if !tt.adopt { + assert.Equal(t, lostTaskReason, got[0].StoppedReason) + + return + } + + assert.Equal(t, []string{cid}, r2.ContainerIDs(arn)) + + require.Eventually(t, func() bool { + fake2.mu.Lock() + defer fake2.mu.Unlock() + + return len(fake2.waitRequestedOn) > 0 + }, 2*time.Second, 10*time.Millisecond, "exit watching must resume") + + fake2.waitResult <- dockertypes.WaitResponse{StatusCode: 4} + + require.Eventually(t, func() bool { + d, _, descErr := b2.DescribeTasks("test", []string{arn}) + require.NoError(t, descErr) + + return d[0].LastStatus == statusStopped + }, 2*time.Second, 10*time.Millisecond, "an adopted container exiting must stop the task") + }) + } +} diff --git a/services/ecs/docker_adopt_live_internal_test.go b/services/ecs/docker_adopt_live_internal_test.go new file mode 100644 index 0000000000..372aaa98b3 --- /dev/null +++ b/services/ecs/docker_adopt_live_internal_test.go @@ -0,0 +1,138 @@ +package ecs + +import ( + "context" + "os/exec" + "strings" + "testing" + + mobyclient "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/internal/dockercompat/client" + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +// onlyResource lists just the managed containers whose resource key starts with prefix, so a live test never +// reconciles containers it did not create. +type onlyResource struct { + container.ManagedLister + prefix string +} + +func (o onlyResource) ListManaged(ctx context.Context) ([]container.ManagedContainer, error) { + all, err := o.ManagedLister.ListManaged(ctx) + if err != nil { + return nil, err + } + + var out []container.ManagedContainer + + for _, c := range all { + if strings.HasPrefix(c.Labels[container.LabelResource], o.prefix) { + out = append(out, c) + } + } + + return out, nil +} + +func TestDockerRunnerLiveCrashedTaskAdopted(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not found") + } + + probe, err := mobyclient.New(mobyclient.FromEnv) + require.NoError(t, err) + + t.Cleanup(func() { _ = probe.Close() }) + + if _, imgErr := probe.ImageInspect(t.Context(), liveImage); imgErr != nil { + t.Skipf("%s not present: %v", liveImage, imgErr) + } + + rt, err := container.NewRuntime(container.Config{}) + require.NoError(t, err) + + t.Cleanup(func() { _ = rt.Close() }) + + lister, ok := rt.(container.ManagedLister) + require.True(t, ok) + + cli, err := client.NewClientWithOpts(client.FromEnv(), client.WithAPIVersionNegotiation()) + require.NoError(t, err) + + t.Cleanup(func() { _ = cli.Close() }) + + crashCtx, crash := context.WithCancel(context.Background()) + r1 := newDockerRunnerWithClient(crashCtx, sleepingClient{cli}) + b1 := NewInMemoryBackend("123456789012", "us-east-1", r1) + r1.SetTaskCompletionHandler(b1.markTaskStoppedByContainerExit) + + _, err = b1.CreateCluster(CreateClusterInput{ClusterName: "adopt"}) + require.NoError(t, err) + + stopTimeout := 1 + + td, err := b1.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "adopt-live", + ContainerDefinitions: []ContainerDefinition{ + {Name: "app", Image: liveImage, Essential: true, StopTimeout: &stopTimeout}, + }, + }) + require.NoError(t, err) + + tasks, _, err := b1.RunTask(RunTaskInput{Cluster: "adopt", TaskDefinition: td.TaskDefinitionArn}) + require.NoError(t, err) + + arn := tasks[0].TaskArn + b1.awaitLaunches() + + ids := r1.ContainerIDs(arn) + require.Len(t, ids, 1) + + t.Cleanup(func() { + _, _ = probe.ContainerRemove( + context.Background(), ids[0], mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, + ) + }) + + snap := b1.Snapshot(t.Context()) + + crash() + + orphans, err := container.Reconcile( + t.Context(), onlyResource{lister, arn}, container.CurrentIdentity(), nil, + ) + require.NoError(t, err) + + r2 := newDockerRunnerWithClient(t.Context(), sleepingClient{cli}) + r2.claim = orphans.Claim + b2 := NewInMemoryBackend("123456789012", "us-east-1", r2) + r2.SetTaskCompletionHandler(b2.markTaskStoppedByContainerExit) + + require.NoError(t, b2.Restore(t.Context(), snap)) + b2.adoptRestoredTasks(t.Context()) + + got, _, err := b2.DescribeTasks("adopt", []string{arn}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, statusRunning, got[0].LastStatus) + assert.Equal(t, ids, r2.ContainerIDs(arn), "the surviving container must be adopted, not replaced") + + insp, err := probe.ContainerInspect(t.Context(), ids[0], mobyclient.ContainerInspectOptions{}) + require.NoError(t, err) + require.NotNil(t, insp.Container.State) + assert.True(t, insp.Container.State.Running) + + assert.Zero(t, orphans.SweepUnclaimed(t.Context()), "nothing may be left for the sweep") + + require.NoError(t, r2.StopTask(&Task{TaskArn: arn})) + + insp, err = probe.ContainerInspect(t.Context(), ids[0], mobyclient.ContainerInspectOptions{}) + require.NoError(t, err) + assert.False(t, insp.Container.State.Running, "StopTask must reach the adopted container") +} diff --git a/services/ecs/docker_async_live_internal_test.go b/services/ecs/docker_async_live_internal_test.go new file mode 100644 index 0000000000..7b56bd1245 --- /dev/null +++ b/services/ecs/docker_async_live_internal_test.go @@ -0,0 +1,91 @@ +package ecs + +import ( + "context" + "os/exec" + "testing" + "time" + + mobyclient "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/internal/dockercompat/client" +) + +func TestDockerRunnerLiveAsyncHealthyDependency(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not found") + } + + probe, err := mobyclient.New(mobyclient.FromEnv) + require.NoError(t, err) + + t.Cleanup(func() { _ = probe.Close() }) + + if _, imgErr := probe.ImageInspect(t.Context(), liveImage); imgErr != nil { + t.Skipf("%s not present: %v", liveImage, imgErr) + } + + cli, err := client.NewClientWithOpts(client.FromEnv(), client.WithAPIVersionNegotiation()) + require.NoError(t, err) + + t.Cleanup(func() { _ = cli.Close() }) + + runner := newDockerRunnerWithClient(context.Background(), sleepingClient{cli}) + runner.pollInterval = 200 * time.Millisecond + b := NewInMemoryBackend("123456789012", "us-east-1", runner) + + _, err = b.CreateCluster(CreateClusterInput{ClusterName: "live"}) + require.NoError(t, err) + + td, err := b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "live-health", + ContainerDefinitions: []ContainerDefinition{ + { + Name: "db", Image: liveImage, Essential: true, + HealthCheck: &HealthCheck{Command: []string{"CMD-SHELL", "true"}, Interval: 5, Timeout: 2, Retries: 1}, + }, + { + Name: "app", Image: liveImage, Essential: true, + DependsOn: []ContainerDependency{{ContainerName: "db", Condition: dependsHealthy}}, + }, + }, + }) + require.NoError(t, err) + + started := time.Now() + + tasks, _, err := b.RunTask(RunTaskInput{Cluster: "live", TaskDefinition: td.TaskDefinitionArn}) + require.NoError(t, err) + require.Len(t, tasks, 1) + + t.Cleanup(func() { + ids := runner.ContainerIDs(tasks[0].TaskArn) + + b.Close() + + for _, id := range ids { + _, _ = probe.ContainerRemove( + context.Background(), id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, + ) + } + }) + + assert.Equal(t, statusProvisioning, tasks[0].LastStatus) + assert.Less(t, time.Since(started), 3*time.Second, "RunTask must not wait for the HEALTHY gate") + + require.Eventually(t, func() bool { + got, _, descErr := b.DescribeTasks("live", []string{tasks[0].TaskArn}) + + return descErr == nil && len(got) == 1 && got[0].LastStatus == statusRunning + }, liveTimeout*2, liveTick) + + got, _, err := b.DescribeTasks("live", []string{tasks[0].TaskArn}) + require.NoError(t, err) + assert.NotNil(t, got[0].StartedAt) + assert.NotNil(t, got[0].PullStoppedAt) + assert.Len(t, runner.ContainerIDs(tasks[0].TaskArn), 2) +} diff --git a/services/ecs/docker_config.go b/services/ecs/docker_config.go new file mode 100644 index 0000000000..9691566e21 --- /dev/null +++ b/services/ecs/docker_config.go @@ -0,0 +1,245 @@ +package ecs + +import ( + "fmt" + "maps" + "net/netip" + "strings" + "time" + + mobycontainer "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" + dockernetwork "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/network" +) + +const ( + mebibyte = int64(1) << 20 + + defaultStopTimeoutSeconds = 30 + + defaultHealthInterval = 30 * time.Second + defaultHealthTimeout = 5 * time.Second + defaultHealthRetries = 3 + + healthCheckNone = "NONE" +) + +// containerInput is everything createContainer needs to map one container definition to Docker. +type containerInput struct { + task *Task + portBindings dockernetwork.PortMap + exposedPorts dockernetwork.PortSet + pidMode string + ipcMode string + env []string + mounts []mount.Mount + volumesFrom []string + cd ContainerDefinition +} + +// buildDockerConfigs maps an ECS container definition onto the Docker create and host configs. +func buildDockerConfigs(in containerInput) (*dockertypes.Config, *dockertypes.HostConfig, error) { + cd := in.cd + + hostCfg := &dockertypes.HostConfig{ + PortBindings: in.portBindings, + Mounts: in.mounts, + VolumesFrom: in.volumesFrom, + Privileged: cd.Privileged, + ReadonlyRootfs: cd.ReadonlyRootFilesystem, + DNSSearch: cd.DNSSearchDomains, + SecurityOpt: cd.DockerSecurityOptions, + PidMode: mobycontainer.PidMode(in.pidMode), + IpcMode: mobycontainer.IpcMode(in.ipcMode), + } + + dns, err := parseDNSServers(cd.DNSServers) + if err != nil { + return nil, nil, err + } + + hostCfg.DNS = dns + + for _, h := range cd.ExtraHosts { + hostCfg.ExtraHosts = append(hostCfg.ExtraHosts, h.Hostname+":"+h.IPAddress) + } + + if len(cd.SystemControls) > 0 { + hostCfg.Sysctls = make(map[string]string, len(cd.SystemControls)) + for _, sc := range cd.SystemControls { + hostCfg.Sysctls[sc.Namespace] = sc.Value + } + } + + applyResourceLimits(hostCfg, cd) + applyLinuxParameters(hostCfg, cd) + + labels := map[string]string{} + maps.Copy(labels, cd.DockerLabels) + + labels["gopherstack.ecs.task"] = in.task.TaskArn + labels["gopherstack.ecs.cluster"] = in.task.ClusterArn + + stopTimeout := defaultStopTimeoutSeconds + if cd.StopTimeout != nil { + stopTimeout = *cd.StopTimeout + } + + cfg := &dockertypes.Config{ + Image: cd.Image, + Env: in.env, + ExposedPorts: in.exposedPorts, + Labels: labels, + Cmd: cd.Command, + Entrypoint: cd.EntryPoint, + User: cd.User, + WorkingDir: cd.WorkingDirectory, + Hostname: cd.Hostname, + NetworkDisabled: cd.DisableNetworking, + OpenStdin: cd.Interactive, + Tty: cd.PseudoTerminal, + StopTimeout: &stopTimeout, + Healthcheck: buildHealthConfig(cd.HealthCheck), + } + + return cfg, hostCfg, nil +} + +func parseDNSServers(servers []string) ([]netip.Addr, error) { + out := make([]netip.Addr, 0, len(servers)) + + for _, s := range servers { + addr, err := netip.ParseAddr(s) + if err != nil { + return nil, fmt.Errorf("invalid dnsServers entry %q: %w", s, err) + } + + out = append(out, addr) + } + + return out, nil +} + +// applyResourceLimits maps cpu (units), memory and memoryReservation (MiB), and ulimits. +func applyResourceLimits(hc *dockertypes.HostConfig, cd ContainerDefinition) { + hc.CPUShares = int64(cd.CPU) + hc.Memory = int64(cd.Memory) * mebibyte + hc.MemoryReservation = int64(cd.MemoryReservation) * mebibyte + + for _, u := range cd.Ulimits { + hc.Ulimits = append(hc.Ulimits, &mobycontainer.Ulimit{ + Name: u.Name, + Soft: int64(u.SoftLimit), + Hard: int64(u.HardLimit), + }) + } +} + +// applyLinuxParameters maps linuxParameters; maxSwap is swap on top of memory, as in ECS. +func applyLinuxParameters(hc *dockertypes.HostConfig, cd ContainerDefinition) { + lp := cd.LinuxParameters + if lp == nil { + return + } + + if lp.Capabilities != nil { + hc.CapAdd = lp.Capabilities.Add + hc.CapDrop = lp.Capabilities.Drop + } + + if lp.InitProcessEnabled != nil { + init := *lp.InitProcessEnabled + hc.Init = &init + } + + if lp.SharedMemorySize != nil { + hc.ShmSize = int64(*lp.SharedMemorySize) * mebibyte + } + + if lp.MaxSwap != nil { + hc.MemorySwap = hc.Memory + int64(*lp.MaxSwap)*mebibyte + } + + if lp.Swappiness != nil { + sw := int64(*lp.Swappiness) + hc.MemorySwappiness = &sw + } + + for _, d := range lp.Devices { + containerPath := d.ContainerPath + if containerPath == "" { + containerPath = d.HostPath + } + + hc.Devices = append(hc.Devices, mobycontainer.DeviceMapping{ + PathOnHost: d.HostPath, + PathInContainer: containerPath, + CgroupPermissions: devicePermissions(d.Permissions), + }) + } + + if len(lp.Tmpfs) > 0 { + hc.Tmpfs = make(map[string]string, len(lp.Tmpfs)) + for _, t := range lp.Tmpfs { + opts := append([]string{fmt.Sprintf("size=%dm", t.Size)}, t.MountOptions...) + hc.Tmpfs[t.ContainerPath] = strings.Join(opts, ",") + } + } +} + +// devicePermissions converts read/write/mknod to Docker's "rwm" letters; empty means all. +func devicePermissions(perms []string) string { + if len(perms) == 0 { + return "rwm" + } + + var b strings.Builder + + for _, p := range perms { + switch strings.ToLower(p) { + case "read": + b.WriteByte('r') + case "write": + b.WriteByte('w') + case "mknod": + b.WriteByte('m') + } + } + + return b.String() +} + +// buildHealthConfig maps an ECS healthCheck (seconds) onto Docker's HEALTHCHECK, using ECS's defaults. +func buildHealthConfig(hc *HealthCheck) *mobycontainer.HealthConfig { + if hc == nil || len(hc.Command) == 0 { + return nil + } + + if hc.Command[0] == healthCheckNone { + return &mobycontainer.HealthConfig{Test: []string{healthCheckNone}} + } + + cfg := &mobycontainer.HealthConfig{ + Test: hc.Command, + Interval: defaultHealthInterval, + Timeout: defaultHealthTimeout, + Retries: defaultHealthRetries, + StartPeriod: time.Duration(hc.StartPeriod) * time.Second, + } + + if hc.Interval > 0 { + cfg.Interval = time.Duration(hc.Interval) * time.Second + } + + if hc.Timeout > 0 { + cfg.Timeout = time.Duration(hc.Timeout) * time.Second + } + + if hc.Retries > 0 { + cfg.Retries = hc.Retries + } + + return cfg +} diff --git a/services/ecs/docker_config_internal_test.go b/services/ecs/docker_config_internal_test.go new file mode 100644 index 0000000000..27b7638efd --- /dev/null +++ b/services/ecs/docker_config_internal_test.go @@ -0,0 +1,301 @@ +package ecs + +import ( + "testing" + "time" + + "github.com/moby/moby/api/types/mount" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" +) + +func TestBuildDockerConfigs(t *testing.T) { + t.Parallel() + + tests := []struct { + check func(t *testing.T, cfg *dockertypes.Config, host *dockertypes.HostConfig) + name string + cd ContainerDefinition + }{ + { + name: "command_entrypoint_user_workdir", + cd: ContainerDefinition{ + Command: []string{"sh", "-c", "echo hi"}, EntryPoint: []string{"/bin/entry"}, + User: "1000:1000", WorkingDirectory: "/srv", Hostname: "web", + }, + check: func(t *testing.T, cfg *dockertypes.Config, _ *dockertypes.HostConfig) { + t.Helper() + + assert.Equal(t, []string{"sh", "-c", "echo hi"}, cfg.Cmd) + assert.Equal(t, []string{"/bin/entry"}, cfg.Entrypoint) + assert.Equal(t, "1000:1000", cfg.User) + assert.Equal(t, "/srv", cfg.WorkingDir) + assert.Equal(t, "web", cfg.Hostname) + }, + }, + { + name: "cpu_memory_limits", + cd: ContainerDefinition{CPU: 512, Memory: 256, MemoryReservation: 128}, + check: func(t *testing.T, _ *dockertypes.Config, host *dockertypes.HostConfig) { + t.Helper() + + assert.EqualValues(t, 512, host.CPUShares) + assert.EqualValues(t, 256<<20, host.Memory) + assert.EqualValues(t, 128<<20, host.MemoryReservation) + }, + }, + { + name: "ulimits", + cd: ContainerDefinition{Ulimits: []Ulimit{{Name: "nofile", SoftLimit: 1024, HardLimit: 4096}}}, + check: func(t *testing.T, _ *dockertypes.Config, host *dockertypes.HostConfig) { + t.Helper() + + require.Len(t, host.Ulimits, 1) + assert.Equal(t, "nofile", host.Ulimits[0].Name) + assert.EqualValues(t, 1024, host.Ulimits[0].Soft) + assert.EqualValues(t, 4096, host.Ulimits[0].Hard) + }, + }, + { + name: "linux_parameters", + cd: ContainerDefinition{ + Memory: 100, + LinuxParameters: &LinuxParameters{ + Capabilities: &KernelCapabilities{Add: []string{"SYS_PTRACE"}, Drop: []string{"NET_RAW"}}, + InitProcessEnabled: new(true), + SharedMemorySize: new(64), + MaxSwap: new(50), + Swappiness: new(10), + Devices: []Device{{HostPath: "/dev/fuse", Permissions: []string{"read", "mknod"}}}, + Tmpfs: []Tmpfs{{ContainerPath: "/run", Size: 16, MountOptions: []string{"noexec"}}}, + }, + }, + check: func(t *testing.T, _ *dockertypes.Config, host *dockertypes.HostConfig) { + t.Helper() + + assert.Equal(t, []string{"SYS_PTRACE"}, host.CapAdd) + assert.Equal(t, []string{"NET_RAW"}, host.CapDrop) + require.NotNil(t, host.Init) + assert.True(t, *host.Init) + assert.EqualValues(t, 64<<20, host.ShmSize) + assert.EqualValues(t, 150<<20, host.MemorySwap) + require.NotNil(t, host.MemorySwappiness) + assert.EqualValues(t, 10, *host.MemorySwappiness) + require.Len(t, host.Devices, 1) + assert.Equal(t, "/dev/fuse", host.Devices[0].PathInContainer) + assert.Equal(t, "rm", host.Devices[0].CgroupPermissions) + assert.Equal(t, map[string]string{"/run": "size=16m,noexec"}, host.Tmpfs) + }, + }, + { + name: "security_and_network", + cd: ContainerDefinition{ + Privileged: true, ReadonlyRootFilesystem: true, DisableNetworking: true, + DNSServers: []string{"1.1.1.1"}, DNSSearchDomains: []string{"corp.local"}, + ExtraHosts: []HostEntry{{Hostname: "db", IPAddress: "10.0.0.5"}}, + SystemControls: []SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}}, + DockerSecurityOptions: []string{"no-new-privileges"}, + }, + check: func(t *testing.T, cfg *dockertypes.Config, host *dockertypes.HostConfig) { + t.Helper() + + assert.True(t, host.Privileged) + assert.True(t, host.ReadonlyRootfs) + assert.True(t, cfg.NetworkDisabled) + require.Len(t, host.DNS, 1) + assert.Equal(t, "1.1.1.1", host.DNS[0].String()) + assert.Equal(t, []string{"corp.local"}, host.DNSSearch) + assert.Equal(t, []string{"db:10.0.0.5"}, host.ExtraHosts) + assert.Equal(t, map[string]string{"net.core.somaxconn": "1024"}, host.Sysctls) + assert.Equal(t, []string{"no-new-privileges"}, host.SecurityOpt) + }, + }, + { + name: "stop_timeout_default", + cd: ContainerDefinition{}, + check: func(t *testing.T, cfg *dockertypes.Config, _ *dockertypes.HostConfig) { + t.Helper() + + require.NotNil(t, cfg.StopTimeout) + assert.Equal(t, 30, *cfg.StopTimeout) + }, + }, + { + name: "stop_timeout_explicit", + cd: ContainerDefinition{StopTimeout: new(5)}, + check: func(t *testing.T, cfg *dockertypes.Config, _ *dockertypes.HostConfig) { + t.Helper() + + require.NotNil(t, cfg.StopTimeout) + assert.Equal(t, 5, *cfg.StopTimeout) + }, + }, + { + name: "healthcheck_defaults", + cd: ContainerDefinition{HealthCheck: &HealthCheck{Command: []string{"CMD-SHELL", "true"}}}, + check: func(t *testing.T, cfg *dockertypes.Config, _ *dockertypes.HostConfig) { + t.Helper() + + require.NotNil(t, cfg.Healthcheck) + assert.Equal(t, []string{"CMD-SHELL", "true"}, cfg.Healthcheck.Test) + assert.Equal(t, 30*time.Second, cfg.Healthcheck.Interval) + assert.Equal(t, 5*time.Second, cfg.Healthcheck.Timeout) + assert.Equal(t, 3, cfg.Healthcheck.Retries) + assert.Zero(t, cfg.Healthcheck.StartPeriod) + }, + }, + { + name: "healthcheck_explicit", + cd: ContainerDefinition{HealthCheck: &HealthCheck{ + Command: []string{"CMD", "ls"}, Interval: 6, Timeout: 3, Retries: 2, StartPeriod: 10, + }}, + check: func(t *testing.T, cfg *dockertypes.Config, _ *dockertypes.HostConfig) { + t.Helper() + + require.NotNil(t, cfg.Healthcheck) + assert.Equal(t, 6*time.Second, cfg.Healthcheck.Interval) + assert.Equal(t, 3*time.Second, cfg.Healthcheck.Timeout) + assert.Equal(t, 2, cfg.Healthcheck.Retries) + assert.Equal(t, 10*time.Second, cfg.Healthcheck.StartPeriod) + }, + }, + { + name: "labels_tty_stdin", + cd: ContainerDefinition{ + DockerLabels: map[string]string{"a": "b"}, + Interactive: true, + PseudoTerminal: true, + }, + check: func(t *testing.T, cfg *dockertypes.Config, _ *dockertypes.HostConfig) { + t.Helper() + + assert.Equal(t, "b", cfg.Labels["a"]) + assert.Equal(t, "arn:task", cfg.Labels["gopherstack.ecs.task"]) + assert.True(t, cfg.OpenStdin) + assert.True(t, cfg.Tty) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, host, err := buildDockerConfigs(containerInput{ + task: &Task{TaskArn: "arn:task", ClusterArn: "arn:cluster"}, + cd: tt.cd, + env: []string{"A=1"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"A=1"}, cfg.Env) + tt.check(t, cfg, host) + }) + } +} + +func TestBuildDockerConfigs_InvalidDNS(t *testing.T) { + t.Parallel() + + _, _, err := buildDockerConfigs(containerInput{ + task: &Task{}, cd: ContainerDefinition{DNSServers: []string{"not-an-ip"}}, + }) + require.Error(t, err) +} + +func TestContainerMounts(t *testing.T) { + t.Parallel() + + plan := &volumePlan{specs: map[string]volumeSpec{ + "data": {mount: mount.Mount{Type: mount.TypeBind, Source: "/host/data"}}, + "tmp": {mount: mount.Mount{Type: mount.TypeVolume, Source: "gopherstack-ecs-t-tmp"}}, + }} + + tests := []struct { + name string + points []MountPoint + want []mount.Mount + wantErr bool + }{ + { + name: "bind_readonly_and_volume", + points: []MountPoint{ + {SourceVolume: "data", ContainerPath: "/d", ReadOnly: true}, + {SourceVolume: "tmp", ContainerPath: "/t"}, + }, + want: []mount.Mount{ + {Type: mount.TypeBind, Source: "/host/data", Target: "/d", ReadOnly: true}, + {Type: mount.TypeVolume, Source: "gopherstack-ecs-t-tmp", Target: "/t"}, + }, + }, + {name: "undefined_volume", points: []MountPoint{{SourceVolume: "nope", ContainerPath: "/x"}}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := containerMounts(ContainerDefinition{Name: "c", MountPoints: tt.points}, plan) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseSecretRef(t *testing.T) { + t.Parallel() + + const sm = "arn:aws:secretsmanager:eu-west-1:123456789012:secret:db-AbCdEf" + + tests := []struct { + name string + in string + want secretRef + fail bool + }{ + {name: "sm_arn", in: sm, want: secretRef{secretsManager: true, region: "eu-west-1", id: sm}}, + { + name: "sm_arn_json_key", + in: sm + ":password::", + want: secretRef{secretsManager: true, region: "eu-west-1", id: sm, jsonKey: "password"}, + }, + { + name: "sm_arn_all_fields", + in: sm + ":password:AWSPREVIOUS:abc123", + want: secretRef{ + secretsManager: true, region: "eu-west-1", id: sm, + jsonKey: "password", versionStage: "AWSPREVIOUS", versionID: "abc123", + }, + }, + { + name: "ssm_arn", in: "arn:aws:ssm:eu-west-1:123456789012:parameter/app/db", + want: secretRef{region: "eu-west-1", id: "/app/db"}, + }, + {name: "ssm_name", in: "/app/db", want: secretRef{region: "us-east-1", id: "/app/db"}}, + {name: "other_arn", in: "arn:aws:s3:::bucket", fail: true}, + {name: "short_arn", in: "arn:aws:secretsmanager", fail: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseSecretRef(tt.in, "us-east-1") + if tt.fail { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/services/ecs/docker_efs_live_internal_test.go b/services/ecs/docker_efs_live_internal_test.go new file mode 100644 index 0000000000..8c1c80ede7 --- /dev/null +++ b/services/ecs/docker_efs_live_internal_test.go @@ -0,0 +1,142 @@ +package ecs + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + + mobyclient "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dockerimage "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/image" + "github.com/blackbirdworks/gopherstack/internal/dockercompat/client" + "github.com/blackbirdworks/gopherstack/services/efs" +) + +type noPullClient struct{ dockerClient } + +func (noPullClient) ImagePull(context.Context, string, dockerimage.PullOptions) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} + +type liveEFS struct{ bk *efs.InMemoryBackend } + +func (l liveEFS) ResolveVolume(_ context.Context, region, fsID, ap, root string) (EFSVolumeMount, error) { + m, err := l.bk.ResolveVolumeMount(region, fsID, ap, root) + if err != nil { + return EFSVolumeMount{}, err + } + + return EFSVolumeMount{ + HostDir: m.HostDir, UID: m.UID, GID: m.GID, SecondaryGIDs: m.SecondaryGIDs, HasPosixUser: m.HasPosixUser, + }, nil +} + +func TestDockerRunnerLiveEFSAccessPoint(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not found") + } + + probe, err := mobyclient.New(mobyclient.FromEnv) + require.NoError(t, err) + + t.Cleanup(func() { _ = probe.Close() }) + + if _, imgErr := probe.ImageInspect(t.Context(), liveImage); imgErr != nil { + t.Skipf("%s not present: %v", liveImage, imgErr) + } + + cli, err := client.NewClientWithOpts(client.FromEnv(), client.WithAPIVersionNegotiation()) + require.NoError(t, err) + + t.Cleanup(func() { _ = cli.Close() }) + + efsBk := efs.NewInMemoryBackend("123456789012", "us-east-1") + efsBk.SetStorageRoot(t.TempDir()) + + fs, err := efsBk.CreateFileSystem(t.Context(), efs.CreateFileSystemRequest{CreationToken: "live-ecs-efs"}) + require.NoError(t, err) + + ap, err := efsBk.CreateAccessPoint(t.Context(), efs.CreateAccessPointRequest{ + FileSystemID: fs.FileSystemID, + PosixUser: &efs.PosixUser{UID: 1234, GID: 5678}, + RootDirectory: &efs.RootDirectory{ + Path: "/shared", + CreationInfo: &efs.CreationInfo{OwnerUID: 1234, OwnerGID: 5678, Permissions: "777"}, + }, + }) + require.NoError(t, err) + + runner := newDockerRunnerWithClient(context.Background(), noPullClient{cli}) + b := NewInMemoryBackend("123456789012", "us-east-1", runner) + b.wireRunnerHooks(runner) + b.SetEFSResolver(liveEFS{efsBk}) + + _, err = b.CreateCluster(CreateClusterInput{ClusterName: "live-efs"}) + require.NoError(t, err) + + t.Cleanup(b.Close) + + vol := Volume{Name: "shared", EFSVolumeConfiguration: &EFSVolumeConfiguration{ + FileSystemID: fs.FileSystemID, TransitEncryption: "ENABLED", + AuthorizationConfig: &EFSAuthorizationConfig{AccessPointID: ap.AccessPointID, IAM: "ENABLED"}, + }} + + run := func(family, script string) *Task { + td, tdErr := b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: family, Volumes: []Volume{vol}, + ContainerDefinitions: []ContainerDefinition{{ + Name: "app", Image: liveImage, Essential: true, Command: []string{"sh", "-c", script}, + MountPoints: []MountPoint{{SourceVolume: "shared", ContainerPath: "/mnt/efs"}}, + }}, + }) + require.NoError(t, tdErr) + + tasks, _, runErr := b.RunTask(RunTaskInput{Cluster: "live-efs", TaskDefinition: td.TaskDefinitionArn}) + require.NoError(t, runErr) + require.Len(t, tasks, 1) + + require.Eventually(t, func() bool { + got, _, dErr := b.DescribeTasks("live-efs", []string{tasks[0].TaskArn}) + + return dErr == nil && len(got) == 1 && got[0].LastStatus == statusStopped + }, liveTimeout*2, liveTick) + + got, _, dErr := b.DescribeTasks("live-efs", []string{tasks[0].TaskArn}) + require.NoError(t, dErr) + + return &got[0] + } + + writer := run("efs-writer", `echo "hello efs" > /mnt/efs/note.txt`) + assert.Equal(t, stopCodeEssentialContainerExited, writer.StopCode) + + m, err := efsBk.ResolveAccessPointMount(ap.AccessPointArn) + require.NoError(t, err) + + body, err := os.ReadFile(filepath.Join(m.HostDir, "note.txt")) + require.NoError(t, err) + assert.Equal(t, "hello efs\n", string(body)) + + info, err := os.Stat(filepath.Join(m.HostDir, "note.txt")) + require.NoError(t, err) + + st, ok := info.Sys().(*syscall.Stat_t) + require.True(t, ok) + assert.EqualValues(t, 1234, st.Uid, "container ran as the access point's POSIX user") + assert.EqualValues(t, 5678, st.Gid) + + run("efs-reader", `cp /mnt/efs/note.txt /mnt/efs/copy.txt`) + + copied, err := os.ReadFile(filepath.Join(m.HostDir, "copy.txt")) + require.NoError(t, err) + assert.Equal(t, "hello efs\n", string(copied), "second task reads what the first wrote") +} diff --git a/services/ecs/docker_inputs.go b/services/ecs/docker_inputs.go new file mode 100644 index 0000000000..de18c2b688 --- /dev/null +++ b/services/ecs/docker_inputs.go @@ -0,0 +1,194 @@ +package ecs + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/registryauth" +) + +const ( + s3ARNParts = 6 + utf8BOM = "\xef\xbb\xbf" + envFileTypeS3 = "s3" + logDriverFirelens = "awsfirelens" +) + +var ( + errRegistryCredentials = errors.New( + "repository credentials secret must be a JSON object with username and password") + errEnvFileReference = errors.New("invalid environment file reference") + errEnvFilesUnavailable = errors.New("s3 backend is not wired") + errFirelensUnsupported = errors.New("firelens log routing is not supported by the Docker runtime") +) + +// EnvFileStore reads the S3 objects named by container environmentFiles. +type EnvFileStore interface { + // ObjectBytes returns the object's body. + ObjectBytes(ctx context.Context, bucket, key string) ([]byte, error) +} + +// SetEnvFileStore wires S3 so container environmentFiles resolve at launch. +func (b *InMemoryBackend) SetEnvFileStore(s EnvFileStore) { + b.mu.Lock("SetEnvFileStore") + defer b.mu.Unlock() + + b.envFiles = s +} + +func (b *InMemoryBackend) envFileStore() EnvFileStore { + b.mu.RLock("envFileStore") + defer b.mu.RUnlock() + + return b.envFiles +} + +// launchInputRunner is implemented by runners that apply repositoryCredentials and environmentFiles. +type launchInputRunner interface { + SetRegistryAuthResolver(fn func(ctx context.Context, cd ContainerDefinition) (string, error)) + SetEnvFileResolver(fn func(ctx context.Context, cd ContainerDefinition) ([]string, error)) +} + +// resolveRegistryAuth returns the base64 registry auth for cd's image, or "" without repositoryCredentials. +func (b *InMemoryBackend) resolveRegistryAuth(ctx context.Context, cd ContainerDefinition) (string, error) { + if cd.RepositoryCredentials == nil || cd.RepositoryCredentials.CredentialsParameter == "" { + return "", nil + } + + param := cd.RepositoryCredentials.CredentialsParameter + + region, id := b.region, param + if strings.HasPrefix(param, "arn:") { + ref, err := parseSecretRef(param, b.region) + if err != nil || !ref.secretsManager { + return "", secretLaunchError(sourceASM, param, errors.Join(errSecretReference, err)) + } + + region, id = ref.region, ref.id + } + + resolver := b.secretsResolver() + if resolver == nil { + return "", secretLaunchError(sourceASM, param, errSecretsUnavailable) + } + + raw, err := resolver.SecretString(ctx, region, id, "", "") + if err != nil { + return "", secretLaunchError(sourceASM, param, err) + } + + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if json.Unmarshal([]byte(raw), &creds) != nil || creds.Username == "" || creds.Password == "" { + return "", secretLaunchError(sourceASM, param, errRegistryCredentials) + } + + return registryauth.EncodeCredential(registryauth.HostOf(cd.Image), creds.Username, creds.Password), nil +} + +// resolveEnvFiles returns KEY=VALUE pairs from cd's environmentFiles; the first file defining a key wins. +func (b *InMemoryBackend) resolveEnvFiles(ctx context.Context, cd ContainerDefinition) ([]string, error) { + if len(cd.EnvironmentFiles) == 0 { + return nil, nil + } + + store := b.envFileStore() + seen := map[string]bool{} + + var out []string + + for _, f := range cd.EnvironmentFiles { + bucket, key, err := parseEnvFileARN(f) + if err != nil { + return nil, envFileLaunchError(f.Value, err) + } + + if store == nil { + return nil, envFileLaunchError(f.Value, errEnvFilesUnavailable) + } + + body, err := store.ObjectBytes(ctx, bucket, key) + if err != nil { + return nil, envFileLaunchError(f.Value, err) + } + + for _, kv := range parseEnvFile(string(body)) { + name, _, _ := strings.Cut(kv, "=") + if seen[name] { + continue + } + + seen[name] = true + + out = append(out, kv) + } + } + + return out, nil +} + +func parseEnvFileARN(f EnvironmentFile) (string, string, error) { + if f.Type != envFileTypeS3 { + return "", "", fmt.Errorf("%w: type %q is not s3", errEnvFileReference, f.Type) + } + + parts := strings.SplitN(f.Value, ":", s3ARNParts) + ok := len(parts) == s3ARNParts && parts[0] == "arn" && parts[2] == envFileTypeS3 + bucket, key, found := strings.Cut(parts[len(parts)-1], "/") + + if !ok || !found || bucket == "" || key == "" { + return "", "", fmt.Errorf("%w: %q is not an S3 object ARN", errEnvFileReference, f.Value) + } + + return bucket, key, nil +} + +// parseEnvFile reads VARIABLE=VALUE lines; values are verbatim (no quote handling) and "#" lines are comments. +func parseEnvFile(body string) []string { + body = strings.TrimPrefix(body, utf8BOM) + + var out []string + + for line := range strings.SplitSeq(body, "\n") { + line = strings.TrimLeft(strings.TrimSuffix(line, "\r"), " \t") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if name, _, ok := strings.Cut(line, "="); ok && name != "" { + out = append(out, line) + } + } + + return out +} + +func envFileLaunchError(arn string, err error) error { + return &taskLaunchError{ + Reason: fmt.Sprintf( + "ResourceInitializationError: failed to download env files: failed to get environment file %s: %v", + arn, err), + Err: err, + } +} + +// checkFirelens rejects task definitions that need FireLens log routing, which the Docker runtime cannot do. +func checkFirelens(defs []ContainerDefinition) error { + for _, cd := range defs { + usesDriver := cd.LogConfiguration != nil && cd.LogConfiguration.LogDriver == logDriverFirelens + if cd.FirelensConfiguration != nil || usesDriver { + return &taskLaunchError{ + Reason: fmt.Sprintf("Task failed to start: container %q: %v", cd.Name, errFirelensUnsupported), + Err: errFirelensUnsupported, + } + } + } + + return nil +} diff --git a/services/ecs/docker_inputs_internal_test.go b/services/ecs/docker_inputs_internal_test.go new file mode 100644 index 0000000000..21d2726c35 --- /dev/null +++ b/services/ecs/docker_inputs_internal_test.go @@ -0,0 +1,262 @@ +package ecs + +import ( + "context" + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeEnvStore struct { + objects map[string]string +} + +func (f fakeEnvStore) ObjectBytes(_ context.Context, bucket, key string) ([]byte, error) { + if v, ok := f.objects[bucket+"/"+key]; ok { + return []byte(v), nil + } + + return nil, errTestMissing +} + +const testSecretARN = "arn:aws:secretsmanager:us-east-1:000000000000:secret:regcreds-AbCdEf" + +func runInputsTask( + t *testing.T, fake *fakeDockerClient, secrets SecretsResolver, store EnvFileStore, cd ContainerDefinition, +) Task { + t.Helper() + + runner := newLaunchRunner(t, fake) + b := NewInMemoryBackend("000000000000", "us-east-1", runner) + b.wireRunnerHooks(runner) + + if secrets != nil { + b.SetSecretsResolver(secrets) + } + + if store != nil { + b.SetEnvFileStore(store) + } + + _, err := b.CreateCluster(CreateClusterInput{ClusterName: "c"}) + require.NoError(t, err) + + _, err = b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "job", ContainerDefinitions: []ContainerDefinition{cd}, + }) + require.NoError(t, err) + + tasks, _, err := b.RunTask(RunTaskInput{Cluster: "c", TaskDefinition: "job"}) + require.NoError(t, err) + + tasks = settleTasks(t, b, tasks) + + return describeOne(t, b, tasks[0].TaskArn) +} + +func TestRepositoryCredentials(t *testing.T) { + t.Parallel() + + tests := []struct { + secrets *fakeSecrets + name string + credParam string + wantReason string + wantUser string + wantPulled bool + }{ + { + name: "auth_from_secret", credParam: testSecretARN, wantPulled: true, wantUser: "bob", + secrets: &fakeSecrets{smValue: `{"username":"bob","password":"hunter2"}`}, + }, + { + name: "secret_name_in_task_region", credParam: "regcreds", wantPulled: true, wantUser: "bob", + secrets: &fakeSecrets{smValue: `{"username":"bob","password":"hunter2"}`}, + }, + { + name: "missing_secret", credParam: testSecretARN, secrets: &fakeSecrets{smErr: errTestSecretNotFound}, + wantReason: "ResourceInitializationError: unable to pull secrets or registry auth", + }, + { + name: "invalid_json", credParam: testSecretARN, secrets: &fakeSecrets{smValue: `not json`}, + wantReason: "ResourceInitializationError: unable to pull secrets or registry auth", + }, + { + name: "missing_password", credParam: testSecretARN, secrets: &fakeSecrets{smValue: `{"username":"bob"}`}, + wantReason: "ResourceInitializationError: unable to pull secrets or registry auth", + }, + { + name: "ssm_arn_rejected", credParam: "arn:aws:ssm:us-east-1:000000000000:parameter/x", + secrets: &fakeSecrets{}, + wantReason: "ResourceInitializationError: unable to pull secrets or registry auth", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + got := runInputsTask(t, fake, tt.secrets, nil, ContainerDefinition{ + Name: "app", Image: "registry.example.com/team/app:1", Essential: true, + RepositoryCredentials: &RepositoryCredentials{CredentialsParameter: tt.credParam}, + }) + + if !tt.wantPulled { + assert.Equal(t, statusStopped, got.LastStatus) + assert.Equal(t, stopCodeTaskFailedToStart, got.StopCode) + assert.Contains(t, got.StoppedReason, tt.wantReason) + assert.NotContains(t, got.StoppedReason, "hunter2") + assert.Empty(t, fake.pulls) + + return + } + + require.Len(t, fake.pulls, 1) + + raw, err := base64.URLEncoding.DecodeString(fake.pulls[0].auth) + require.NoError(t, err) + + var ac map[string]string + + require.NoError(t, json.Unmarshal(raw, &ac)) + assert.Equal(t, tt.wantUser, ac["username"]) + assert.Equal(t, "hunter2", ac["password"]) + assert.Equal(t, "registry.example.com", ac["serveraddress"]) + }) + } +} + +func TestEnvironmentFiles(t *testing.T) { + t.Parallel() + + files := func(vals ...string) []EnvironmentFile { + out := make([]EnvironmentFile, 0, len(vals)) + for _, v := range vals { + out = append(out, EnvironmentFile{Type: "s3", Value: v}) + } + + return out + } + + store := fakeEnvStore{objects: map[string]string{ + "bkt/a.env": "# comment\r\nA=from-a\r\nSHARED=first\r\n\r\nQUOTED=\"x y\"\r\nnoequals\r\n", + "bkt/b.env": "B=from-b\nSHARED=second\nOVERRIDDEN=file\n", + }} + + tests := []struct { + name string + wantReason string + files []EnvironmentFile + wantEnv []string + }{ + { + name: "ordered_first_wins_environment_last", + files: files("arn:aws:s3:::bkt/a.env", "arn:aws:s3:::bkt/b.env"), + wantEnv: []string{ + "A=from-a", "SHARED=first", `QUOTED="x y"`, "B=from-b", "OVERRIDDEN=file", "OVERRIDDEN=env", + }, + }, + { + name: "missing_object", + files: files("arn:aws:s3:::bkt/none.env"), + wantReason: "ResourceInitializationError: failed to download env files", + }, + { + name: "bad_arn", + files: files("bkt/a.env"), + wantReason: "ResourceInitializationError: failed to download env files", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + got := runInputsTask(t, fake, nil, store, ContainerDefinition{ + Name: "app", Image: "busybox", Essential: true, EnvironmentFiles: tt.files, + Environment: []KeyValuePair{{Name: "OVERRIDDEN", Value: "env"}}, + }) + + if tt.wantReason != "" { + assert.Equal(t, statusStopped, got.LastStatus) + assert.Equal(t, stopCodeTaskFailedToStart, got.StopCode) + assert.Contains(t, got.StoppedReason, tt.wantReason) + assert.Empty(t, fake.createdImages()) + + return + } + + env := fake.createAt(0).cfg.Env + assert.Subset(t, env, tt.wantEnv) + assert.Less(t, indexOf(env, "OVERRIDDEN=file"), indexOf(env, "OVERRIDDEN=env")) + assert.NotContains(t, env, "SHARED=second") + assert.NotContains(t, env, "noequals") + }) + } +} + +func indexOf(s []string, v string) int { + for i, x := range s { + if x == v { + return i + } + } + + return -1 +} + +func TestEnvironmentFiles_NoStore(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + got := runInputsTask(t, fake, nil, nil, ContainerDefinition{ + Name: "app", Image: "busybox", Essential: true, + EnvironmentFiles: []EnvironmentFile{{Type: "s3", Value: "arn:aws:s3:::bkt/a.env"}}, + }) + + assert.Equal(t, statusStopped, got.LastStatus) + assert.Contains(t, got.StoppedReason, "failed to download env files") +} + +func TestFirelensFailsDocumented(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cd ContainerDefinition + }{ + { + name: "router_container", + cd: ContainerDefinition{ + Name: "log_router", Image: "fluent/fluent-bit", Essential: true, + FirelensConfiguration: &FirelensConfiguration{Type: "fluentbit"}, + }, + }, + { + name: "awsfirelens_driver", + cd: ContainerDefinition{ + Name: "app", Image: "busybox", Essential: true, + LogConfiguration: &LogConfiguration{LogDriver: "awsfirelens"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + got := runInputsTask(t, fake, nil, nil, tt.cd) + + assert.Equal(t, statusStopped, got.LastStatus) + assert.Equal(t, stopCodeTaskFailedToStart, got.StopCode) + assert.Contains(t, got.StoppedReason, "firelens") + assert.Empty(t, fake.createdImages()) + }) + } +} diff --git a/services/ecs/docker_launch.go b/services/ecs/docker_launch.go new file mode 100644 index 0000000000..e565f90d67 --- /dev/null +++ b/services/ecs/docker_launch.go @@ -0,0 +1,986 @@ +package ecs + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "sync" + "time" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + dependsStart = "START" + dependsComplete = "COMPLETE" + dependsSuccess = "SUCCESS" + dependsHealthy = "HEALTHY" + + defaultPollInterval = time.Second + defaultDependencyTimeout = 3 * time.Minute + + defaultRestartAttemptPeriod = 300 * time.Second + minRestartAttemptPeriod = 60 * time.Second + + rollbackStopTimeoutSeconds = 10 + + healthDockerHealthy = "healthy" + healthDockerUnhealthy = "unhealthy" + healthECSUnhealthy = "UNHEALTHY" + healthECSUnknown = containerHealthStatusUnknown + healthECSHealthy = containerHealthStatusHealthy + + oomReason = "OutOfMemoryError: Container killed due to memory usage" +) + +var ( + errTaskStoppedDuringLaunch = errors.New("task stopped while its containers were starting") + errDependency = errors.New("container dependency not satisfied") + errDependencyCycle = errors.New("circular container dependency") + errUnsupportedCondition = errors.New("unsupported condition") + errNoExitWatcher = errors.New("no exit watcher for container") + errExited = errors.New("exited") + errBecameUnhealthy = errors.New("became unhealthy") +) + +// launchState lets StopTask abort a RunTask that is still starting containers. +type launchState struct { + abort chan struct{} + stop context.CancelFunc + once sync.Once +} + +func (l *launchState) cancel() { + l.once.Do(func() { + close(l.abort) + l.stop() + }) +} + +// containerExit records a watched container's final exit code; done closes once code is set. +type containerExit struct { + done chan struct{} + code int +} + +// launch is the per-RunTask bookkeeping. +type launch struct { + state *launchState + task *Task + plan *volumePlan + ids map[string]string + secretEnv map[string][]string + fileEnv map[string][]string + needsExit map[string]bool + pidMode string + ipcMode string + order []string +} + +// containerOrderer is a depth-first topological sort over dependsOn and volumesFrom edges. +type containerOrderer struct { + defs []ContainerDefinition + index map[string]int + state []int + out []int +} + +const ( + orderVisiting = 1 + orderDone = 2 +) + +func (o *containerOrderer) dependencies(cd ContainerDefinition) ([]string, error) { + names := make([]string, 0, len(cd.DependsOn)+len(cd.VolumesFrom)) + + for _, d := range cd.DependsOn { + names = append(names, d.ContainerName) + } + + for _, v := range cd.VolumesFrom { + names = append(names, v.SourceContainer) + } + + for _, n := range names { + if _, ok := o.index[n]; !ok { + return nil, fmt.Errorf("%w: container %q references undefined container %q", errDependency, cd.Name, n) + } + } + + return names, nil +} + +func (o *containerOrderer) visit(i int) error { + switch o.state[i] { + case orderDone: + return nil + case orderVisiting: + return fmt.Errorf("%w involving %q", errDependencyCycle, o.defs[i].Name) + } + + o.state[i] = orderVisiting + + names, err := o.dependencies(o.defs[i]) + if err != nil { + return err + } + + for _, n := range names { + if err = o.visit(o.index[n]); err != nil { + return err + } + } + + o.state[i] = orderDone + o.out = append(o.out, i) + + return nil +} + +// orderContainers returns the indices of defs sorted so dependsOn targets and volumesFrom sources come first, +// keeping definition order otherwise. +func orderContainers(defs []ContainerDefinition) ([]int, error) { + o := &containerOrderer{ + defs: defs, + index: make(map[string]int, len(defs)), + state: make([]int, len(defs)), + out: make([]int, 0, len(defs)), + } + + for i, cd := range defs { + o.index[cd.Name] = i + } + + for i := range defs { + if err := o.visit(i); err != nil { + return nil, err + } + } + + return o.out, nil +} + +// RunTask starts td's containers in dependency order, gating each on its dependsOn conditions. +func (r *realDockerRunner) RunTask(task *Task, td *TaskDefinition) error { + return r.RunTaskContext(r.svcCtx, task, td) +} + +// RunTaskContext is RunTask with ctx cancelling the launch (pulls, gates, starts) and rolling it back. +func (r *realDockerRunner) RunTaskContext(parent context.Context, task *Task, td *TaskDefinition) error { + ctx, cancel := context.WithCancel(parent) + defer cancel() + + orderIdx, err := orderContainers(td.ContainerDefinitions) + if err != nil { + return &taskLaunchError{Reason: "Task failed to start: " + err.Error(), Err: err} + } + + order := make([]ContainerDefinition, len(orderIdx)) + for pos, i := range orderIdx { + order[pos] = td.ContainerDefinitions[i] + } + + if fireErr := checkFirelens(order); fireErr != nil { + return fireErr + } + + secretEnv, err := r.resolveSecretEnv(ctx, order) + if err != nil { + return err + } + + fileEnv, err := r.resolveFileEnv(ctx, order) + if err != nil { + return err + } + + plan, err := r.resolveVolumes(ctx, task, td) + if _, isLaunch := errors.AsType[*taskLaunchError](err); isLaunch { + return err + } + + if err != nil { + return &taskLaunchError{Reason: "Task failed to start: " + err.Error(), Err: err} + } + + l := &launch{ + task: task, + plan: plan, + ids: make(map[string]string, len(order)), + secretEnv: secretEnv, + fileEnv: fileEnv, + needsExit: exitDependencies(order), + pidMode: hostOrNone(td.PidMode), + ipcMode: hostOrNone(td.IpcMode), + state: &launchState{abort: make(chan struct{}), stop: cancel}, + } + + r.mu.Lock() + r.launching[task.TaskArn] = l.state + r.taskVolumes[task.TaskArn] = plan.taskVols + r.mu.Unlock() + + defer func() { + r.mu.Lock() + delete(r.launching, task.TaskArn) + r.mu.Unlock() + }() + + for _, cd := range order { + if launchErr := r.launchContainer(ctx, l, cd); launchErr != nil { + r.abortLaunch(r.svcCtx, l) + + return launchErr + } + } + + select { + case <-l.state.abort: + r.abortLaunch(r.svcCtx, l) + + return errTaskStoppedDuringLaunch + case <-ctx.Done(): + r.abortLaunch(r.svcCtx, l) + + return fmt.Errorf("%w: %w", errTaskStoppedDuringLaunch, ctx.Err()) + default: + } + + r.mu.Lock() + defer r.mu.Unlock() + + inDefOrder := make([]string, len(order)) + for pos, i := range orderIdx { + inDefOrder[i] = l.order[pos] + } + + r.containers[task.TaskArn] = inDefOrder + + return nil +} + +func hostOrNone(mode string) string { + if mode == "host" || mode == "none" { + return mode + } + + return "" +} + +// exitDependencies returns the containers some other container waits on to exit. +func exitDependencies(defs []ContainerDefinition) map[string]bool { + out := map[string]bool{} + + for _, cd := range defs { + for _, d := range cd.DependsOn { + if d.Condition == dependsComplete || d.Condition == dependsSuccess { + out[d.ContainerName] = true + } + } + } + + return out +} + +func (r *realDockerRunner) resolveSecretEnv( + ctx context.Context, + defs []ContainerDefinition, +) (map[string][]string, error) { + out := make(map[string][]string, len(defs)) + + r.mu.Lock() + lookup := r.secretLookup + r.mu.Unlock() + + for _, cd := range defs { + for _, sec := range cd.Secrets { + if lookup == nil { + return nil, secretLaunchError("asm", sec.ValueFrom, errSecretsUnavailable) + } + + val, err := lookup(ctx, sec.ValueFrom) + if err != nil { + return nil, err + } + + out[cd.Name] = append(out[cd.Name], sec.Name+"="+val) + } + } + + return out, nil +} + +func (r *realDockerRunner) resolveFileEnv( + ctx context.Context, + defs []ContainerDefinition, +) (map[string][]string, error) { + out := make(map[string][]string, len(defs)) + + r.mu.Lock() + lookup := r.envFileLookup + r.mu.Unlock() + + for _, cd := range defs { + if len(cd.EnvironmentFiles) == 0 { + continue + } + + if lookup == nil { + return nil, envFileLaunchError(cd.EnvironmentFiles[0].Value, errEnvFilesUnavailable) + } + + pairs, err := lookup(ctx, cd) + if err != nil { + return nil, err + } + + out[cd.Name] = pairs + } + + return out, nil +} + +func (r *realDockerRunner) pullContainerImage(ctx context.Context, cd ContainerDefinition) error { + auth, err := r.imageAuth(ctx, cd) + if err != nil { + return err + } + + return r.pullImage(ctx, cd.Image, auth) +} + +func (r *realDockerRunner) imageAuth(ctx context.Context, cd ContainerDefinition) (string, error) { + if cd.RepositoryCredentials == nil { + return "", nil + } + + r.mu.Lock() + lookup := r.registryAuth + r.mu.Unlock() + + if lookup == nil { + return "", secretLaunchError(sourceASM, cd.RepositoryCredentials.CredentialsParameter, errSecretsUnavailable) + } + + return lookup(ctx, cd) +} + +// SetRegistryAuthResolver wires the lookup that turns a container's repositoryCredentials into pull auth. +func (r *realDockerRunner) SetRegistryAuthResolver( + fn func(ctx context.Context, cd ContainerDefinition) (string, error), +) { + r.mu.Lock() + defer r.mu.Unlock() + + r.registryAuth = fn +} + +// SetEFSVolumeResolver wires the lookup that resolves efsVolumeConfiguration volumes to host directories. +func (r *realDockerRunner) SetEFSVolumeResolver( + fn func(region string, cfg *EFSVolumeConfiguration) (EFSVolumeMount, error), +) { + r.mu.Lock() + defer r.mu.Unlock() + + r.efsLookup = fn +} + +// SetEnvFileResolver wires the lookup that reads a container's environmentFiles. +func (r *realDockerRunner) SetEnvFileResolver(fn func(ctx context.Context, cd ContainerDefinition) ([]string, error)) { + r.mu.Lock() + defer r.mu.Unlock() + + r.envFileLookup = fn +} + +// launchContainer pulls, gates, creates and starts one container and wires its watchers. +func (r *realDockerRunner) launchContainer(ctx context.Context, l *launch, cd ContainerDefinition) error { + select { + case <-l.state.abort: + return errTaskStoppedDuringLaunch + case <-ctx.Done(): + return fmt.Errorf("%w: %w", errTaskStoppedDuringLaunch, ctx.Err()) + default: + } + + if err := r.pullContainerImage(ctx, cd); err != nil { + return err + } + + if err := r.awaitDependencies(ctx, l, cd); err != nil { + return err + } + + containerID, err := r.createContainer(ctx, l, cd) + if err != nil { + return err + } + + log := logger.Load(ctx) + + if startErr := r.cli.ContainerStart(ctx, containerID, dockertypes.StartOptions{}); startErr != nil { + if rmErr := r.cli.ContainerRemove( + ctx, containerID, dockertypes.RemoveOptions{Force: true, RemoveVolumes: true}, + ); rmErr != nil { + log.WarnContext(ctx, "failed to remove container after start failure", + "containerID", containerID, "error", rmErr) + } + + r.closeFronts(containerID) + + return fmt.Errorf("start container %s: %w", containerID, startErr) + } + + r.recordStarted(l, cd.Name, containerID) + + if frontErr := r.startFronts(containerID); frontErr != nil { + return fmt.Errorf("front container %s: %w", containerID, frontErr) + } + + taskID := taskIDFromARN(l.task.TaskArn) + + if cwLogs := r.cwLogsBackend(); cwLogs != nil { + if group, stream, ok := awslogsContainerTarget(cd, taskID, containerID); ok { + _ = cwLogs.EnsureLogGroupAndStream(group, stream) + r.forwardContainerLogs(cwLogs, containerID, group, stream, "") + } + } + + handler := r.taskCompletionHandler() + if handler != nil || l.needsExit[cd.Name] || cd.RestartPolicy != nil { + r.watchContainerExit(handler, l.task.TaskArn, containerID, cd) + } + + r.monitorHealth(l.task.TaskArn, containerID, cd) + + return nil +} + +func (r *realDockerRunner) recordStarted(l *launch, name, containerID string) { + l.ids[name] = containerID + l.order = append(l.order, containerID) + + r.mu.Lock() + defer r.mu.Unlock() + + r.stopOrder[l.task.TaskArn] = append(r.stopOrder[l.task.TaskArn], containerID) + r.containers[l.task.TaskArn] = append(r.containers[l.task.TaskArn], containerID) +} + +// abortLaunch tears down everything a failed or aborted RunTask started, including task-scoped volumes. +func (r *realDockerRunner) abortLaunch(ctx context.Context, l *launch) { + r.rollbackContainers(ctx, l.order) + + r.mu.Lock() + delete(r.containers, l.task.TaskArn) + delete(r.stopOrder, l.task.TaskArn) + vols := r.taskVolumes[l.task.TaskArn] + delete(r.taskVolumes, l.task.TaskArn) + r.mu.Unlock() + + r.removeVolumes(ctx, vols) + r.forgetContainers(l.order) +} + +// forgetContainers drops per-container runtime state once its task is gone. +func (r *realDockerRunner) forgetContainers(ids []string) { + r.mu.Lock() + defer r.mu.Unlock() + + for _, id := range ids { + delete(r.exits, id) + delete(r.stopTimeouts, id) + } +} + +// SetContainerHealthHandler wires fn to receive Docker health transitions of health-checked containers. +func (r *realDockerRunner) SetContainerHealthHandler(fn func(taskArn, containerName, status string)) { + r.mu.Lock() + defer r.mu.Unlock() + + r.healthHandler = fn +} + +// SetContainerReasonHandler wires fn to receive a container's failure reason (for example an OOM kill). +func (r *realDockerRunner) SetContainerReasonHandler(fn func(taskArn, containerName, reason string)) { + r.mu.Lock() + defer r.mu.Unlock() + + r.reasonHandler = fn +} + +// SetSecretResolver wires the lookup that turns a container secret's valueFrom into its value. +func (r *realDockerRunner) SetSecretResolver(fn func(ctx context.Context, valueFrom string) (string, error)) { + r.mu.Lock() + defer r.mu.Unlock() + + r.secretLookup = fn +} + +func (r *realDockerRunner) removeVolumes(ctx context.Context, names []string) { + for _, name := range names { + if err := r.cli.VolumeRemove(ctx, name); err != nil { + logger.Load(ctx).WarnContext(ctx, "failed to remove task volume", "volume", name, "error", err) + } + } +} + +// createContainer maps cd to a Docker container. +func (r *realDockerRunner) createContainer(ctx context.Context, l *launch, cd ContainerDefinition) (string, error) { + task := l.task + portBindings, exposedPorts := buildPortMappings(cd.PortMappings) + diverted := r.divertPorts(task, cd.PortMappings, portBindings) + + env := append(r.runtimeEnv(task, cd.Name), l.fileEnv[cd.Name]...) + env = append(env, buildEnv(cd.Environment)...) + env = append(env, l.secretEnv[cd.Name]...) + + mounts, err := containerMounts(cd, l.plan) + if err != nil { + releasePending(diverted) + + return "", &taskLaunchError{Reason: "Task failed to start: " + err.Error(), Err: err} + } + + cfg, hostCfg, err := buildDockerConfigs(containerInput{ + task: task, + cd: cd, + portBindings: portBindings, + exposedPorts: exposedPorts, + env: env, + mounts: mounts, + volumesFrom: l.volumesFrom(cd), + pidMode: l.pidMode, + ipcMode: l.ipcMode, + }) + if err != nil { + releasePending(diverted) + + return "", &taskLaunchError{Reason: "Task failed to start: " + err.Error(), Err: err} + } + + applyVolumeIdentity(cfg, hostCfg, cd, l.plan) + maps.Copy(cfg.Labels, managedLabels(l, cd, len(diverted) > 0)) + + resp, err := r.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "") + if err != nil { + releasePending(diverted) + + return "", fmt.Errorf("create container for %s: %w", cd.Image, err) + } + + r.stashFronts(resp.ID, diverted) + + r.mu.Lock() + r.stopTimeouts[resp.ID] = *cfg.StopTimeout + r.mu.Unlock() + + if attachErr := r.attachContainer(ctx, resp.ID); attachErr != nil { + _ = r.cli.ContainerRemove(ctx, resp.ID, dockertypes.RemoveOptions{Force: true, RemoveVolumes: true}) + r.closeFronts(resp.ID) + + return "", fmt.Errorf("attach container %s to link-local network: %w", resp.ID, attachErr) + } + + return resp.ID, nil +} + +// volumesFrom returns the Docker containers whose volumes cd inherits, as "[:ro]". +func (l *launch) volumesFrom(cd ContainerDefinition) []string { + out := make([]string, 0, len(cd.VolumesFrom)) + + for _, v := range cd.VolumesFrom { + spec := l.ids[v.SourceContainer] + if v.ReadOnly { + spec += ":ro" + } + + out = append(out, spec) + } + + return out +} + +// awaitDependencies blocks until every dependsOn condition of cd holds, or fails the launch. +func (r *realDockerRunner) awaitDependencies(ctx context.Context, l *launch, cd ContainerDefinition) error { + if len(cd.DependsOn) == 0 { + return nil + } + + timeout := r.dependencyTimeout + if cd.StartTimeout != nil && *cd.StartTimeout > 0 { + timeout = time.Duration(*cd.StartTimeout) * time.Second + } + + dctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for _, dep := range cd.DependsOn { + id := l.ids[dep.ContainerName] + + var err error + + switch dep.Condition { + case dependsStart: + case dependsComplete: + _, err = r.awaitExit(dctx, l, id) + case dependsSuccess: + err = r.awaitSuccess(dctx, l, id) + case dependsHealthy: + err = r.awaitHealthy(dctx, l, id) + default: + err = fmt.Errorf("%w %q", errUnsupportedCondition, dep.Condition) + } + + if err != nil { + return &taskLaunchError{ + Reason: fmt.Sprintf("Task failed to start: container %q dependency on %q (%s) not satisfied: %v", + cd.Name, dep.ContainerName, dep.Condition, err), + Err: errors.Join(errDependency, err), + } + } + } + + return nil +} + +func (r *realDockerRunner) awaitExit(ctx context.Context, l *launch, id string) (int, error) { + r.mu.Lock() + ex := r.exits[id] + r.mu.Unlock() + + if ex == nil { + return 0, fmt.Errorf("%w %s", errNoExitWatcher, id) + } + + select { + case <-ex.done: + return ex.code, nil + case <-l.state.abort: + return 0, errTaskStoppedDuringLaunch + case <-ctx.Done(): + return 0, fmt.Errorf("timed out waiting for exit: %w", ctx.Err()) + } +} + +func (r *realDockerRunner) awaitSuccess(ctx context.Context, l *launch, id string) error { + code, err := r.awaitExit(ctx, l, id) + if err != nil { + return err + } + + if code != 0 { + return fmt.Errorf("%w with code %d", errExited, code) + } + + return nil +} + +func (r *realDockerRunner) awaitHealthy(ctx context.Context, l *launch, id string) error { + tick := time.NewTicker(r.pollInterval) + defer tick.Stop() + + for { + resp, err := r.cli.ContainerInspect(ctx, id) + if err != nil { + return fmt.Errorf("inspect container %s: %w", id, err) + } + + if st := resp.State; st != nil { + if !st.Running { + return fmt.Errorf("%w with code %d before becoming healthy", errExited, st.ExitCode) + } + + if st.Health != nil { + switch string(st.Health.Status) { + case healthDockerHealthy: + return nil + case healthDockerUnhealthy: + return errBecameUnhealthy + } + } + } + + select { + case <-tick.C: + case <-l.state.abort: + return errTaskStoppedDuringLaunch + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for healthy: %w", ctx.Err()) + } + } +} + +// monitorHealth reports a health-checked container's Docker health status until it stops running. +func (r *realDockerRunner) monitorHealth(taskArn, containerID string, cd ContainerDefinition) { + r.mu.Lock() + report := r.healthHandler + r.mu.Unlock() + + if cd.HealthCheck == nil || report == nil { + return + } + + ctx, cancel := context.WithCancel(r.svcCtx) + + r.mu.Lock() + r.healthCancels[containerID] = cancel + r.mu.Unlock() + + r.waitWG.Go(func() { + defer cancel() + defer func() { + r.mu.Lock() + delete(r.healthCancels, containerID) + r.mu.Unlock() + }() + + tick := time.NewTicker(r.pollInterval) + defer tick.Stop() + + last := "" + + for { + resp, err := r.cli.ContainerInspect(ctx, containerID) + if err != nil || resp.State == nil || !resp.State.Running { + return + } + + if status := ecsHealthStatus(resp.State); status != last { + last = status + report(taskArn, cd.Name, status) + } + + select { + case <-tick.C: + case <-ctx.Done(): + return + } + } + }) +} + +func ecsHealthStatus(st *dockertypes.State) string { + if st.Health == nil { + return healthECSUnknown + } + + switch string(st.Health.Status) { + case healthDockerHealthy: + return healthECSHealthy + case healthDockerUnhealthy: + return healthECSUnhealthy + default: + return healthECSUnknown + } +} + +// watchContainerExit waits for containerID to stop and, unless the wait is cancelled first (see +// cancelContainerWait, called once StopTask/rollback has confirmed the stop), applies cd's restart policy and +// then reports the exit to handler. Never leaked: it ends on exit, cancellation or shutdown. +func (r *realDockerRunner) watchContainerExit( + handler func(taskArn, containerName string, exitCode int), + taskArn, containerID string, + cd ContainerDefinition, +) { + ctx, cancel := context.WithCancel(r.svcCtx) + ex := &containerExit{done: make(chan struct{})} + startedAt := r.now() + + r.mu.Lock() + r.waitCancels[containerID] = cancel + r.exits[containerID] = ex + r.mu.Unlock() + + r.waitWG.Go(func() { + defer cancel() + defer func() { + r.mu.Lock() + delete(r.waitCancels, containerID) + r.mu.Unlock() + }() + + for { + result := r.cli.ContainerWait(ctx, containerID, dockertypes.WaitOptions{}) + + res, err := container.AwaitWait(ctx, result) + switch { + case err == nil: + code := int(res.StatusCode) + if r.restartContainer(ctx, containerID, cd, code, startedAt) { + startedAt = r.now() + + continue + } + + r.finishContainer(ctx, handler, taskArn, containerID, cd.Name, ex, code) + case ctx.Err() == nil: + logger.Load(ctx).WarnContext(ctx, "container wait failed", "containerID", containerID, "error", err) + } + + return + } + }) +} + +func (r *realDockerRunner) finishContainer( + ctx context.Context, + handler func(taskArn, containerName string, exitCode int), + taskArn, containerID, name string, + ex *containerExit, + code int, +) { + r.closeFronts(containerID) + + r.mu.Lock() + reason := r.reasonHandler + r.mu.Unlock() + + if reason != nil { + if resp, err := r.cli.ContainerInspect(ctx, containerID); err == nil && resp.State != nil && + resp.State.OOMKilled { + reason(taskArn, name, oomReason) + } + } + + ex.code = code + close(ex.done) + + if handler != nil { + handler(taskArn, name, code) + } +} + +// restartContainer applies cd.RestartPolicy: ECS restarts an exited container unless its exit code is +// ignored or it ran for less than restartAttemptPeriod. +func (r *realDockerRunner) restartContainer( + ctx context.Context, + containerID string, + cd ContainerDefinition, + code int, + startedAt time.Time, +) bool { + rp := cd.RestartPolicy + if rp == nil || !rp.Enabled || slices.Contains(rp.IgnoredExitCodes, code) { + return false + } + + period := defaultRestartAttemptPeriod + if rp.RestartAttemptPeriod > 0 { + period = max(time.Duration(rp.RestartAttemptPeriod)*time.Second, minRestartAttemptPeriod) + } + + if r.now().Sub(startedAt) < period { + return false + } + + return r.cli.ContainerStart(ctx, containerID, dockertypes.StartOptions{}) == nil +} + +// cancelContainerWait stops the in-flight exit watcher and health monitor for containerID, if any, so a +// self-inflicted stop is not reported as a natural exit. +func (r *realDockerRunner) cancelContainerWait(containerID string) { + r.mu.Lock() + cancel, ok := r.waitCancels[containerID] + hcancel, hok := r.healthCancels[containerID] + r.mu.Unlock() + + if ok { + cancel() + } + + if hok { + hcancel() + } +} + +// StopTask stops the task's containers in reverse start order, giving each its stopTimeout between SIGTERM and +// SIGKILL, and removes the task's containers and task-scoped volumes when it owns any. +func (r *realDockerRunner) StopTask(task *Task) error { + var ( + containerIDs []string + volumes []string + ) + + func() { + r.mu.Lock() + defer r.mu.Unlock() + + if ls := r.launching[task.TaskArn]; ls != nil { + ls.cancel() + } + + containerIDs = append([]string(nil), r.stopOrder[task.TaskArn]...) + if len(containerIDs) == 0 { + containerIDs = append([]string(nil), r.containers[task.TaskArn]...) + } + + volumes = r.taskVolumes[task.TaskArn] + }() + + if len(containerIDs) == 0 { + return nil + } + + slices.Reverse(containerIDs) + + ctx := r.svcCtx + + var ( + errs []error + failed []string + ) + + for _, containerID := range containerIDs { + r.mu.Lock() + timeout, ok := r.stopTimeouts[containerID] + r.mu.Unlock() + + if !ok { + timeout = rollbackStopTimeoutSeconds + } + + if err := r.cli.ContainerStop(ctx, containerID, dockertypes.StopOptions{Timeout: &timeout}); err != nil { + errs = append(errs, fmt.Errorf("stop container %s: %w", containerID, err)) + failed = append(failed, containerID) + + continue + } + + r.cancelLogForwarding(containerID) + r.cancelContainerWait(containerID) + r.closeFronts(containerID) + } + + if len(failed) == 0 && len(volumes) > 0 { + for _, id := range containerIDs { + _ = r.cli.ContainerRemove(ctx, id, dockertypes.RemoveOptions{Force: true, RemoveVolumes: true}) + } + + r.removeVolumes(ctx, volumes) + } + + r.mu.Lock() + defer r.mu.Unlock() + + if len(failed) == 0 { + delete(r.containers, task.TaskArn) + delete(r.stopOrder, task.TaskArn) + delete(r.taskVolumes, task.TaskArn) + + for _, id := range containerIDs { + delete(r.exits, id) + delete(r.stopTimeouts, id) + } + } else { + slices.Reverse(failed) + r.containers[task.TaskArn] = failed + r.stopOrder[task.TaskArn] = failed + } + + return errors.Join(errs...) +} diff --git a/services/ecs/docker_launch_internal_test.go b/services/ecs/docker_launch_internal_test.go new file mode 100644 index 0000000000..8b446c2035 --- /dev/null +++ b/services/ecs/docker_launch_internal_test.go @@ -0,0 +1,714 @@ +package ecs + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + mobycontainer "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" +) + +const launchTaskArn = "arn:aws:ecs:us-east-1:000000000000:task/default/task-1" + +var ( + errTestMissing = errors.New("missing") + errTestParamNotFound = errors.New("ParameterNotFound") + errTestSecretNotFound = errors.New("ResourceNotFoundException") +) + +func fakeID(n int) string { + return "aaaaaaaaaaaa0" + string(rune('0'+n)) +} + +func newLaunchRunner(t *testing.T, fake *fakeDockerClient) *realDockerRunner { + t.Helper() + + r := newDockerRunnerWithClient(t.Context(), fake) + r.pollInterval = time.Millisecond + + return r +} + +func launchTask() *Task { return &Task{TaskArn: launchTaskArn, ClusterArn: "arn:cluster"} } + +func (f *fakeDockerClient) createdImages() []string { + f.mu.Lock() + defer f.mu.Unlock() + + out := make([]string, 0, len(f.creates)) + for _, c := range f.creates { + out = append(out, c.cfg.Image) + } + + return out +} + +func (f *fakeDockerClient) createAt(i int) createCall { + f.mu.Lock() + defer f.mu.Unlock() + + return f.creates[i] +} + +func TestRunTask_Secrets(t *testing.T) { + t.Parallel() + + tests := []struct { + lookup func(ctx context.Context, valueFrom string) (string, error) + name string + wantEnv []string + wantErr bool + wantNoRun bool + }{ + { + name: "secret_appended_after_environment", + lookup: func(_ context.Context, v string) (string, error) { + return "val-of-" + v, nil + }, + wantEnv: []string{"PLAIN=1", "DB_PASS=val-of-arn:x"}, + }, + { + name: "lookup_failure_stops_before_any_container", + lookup: func(context.Context, string) (string, error) { + return "", &taskLaunchError{Reason: "ResourceInitializationError: boom", Err: errTestMissing} + }, + wantErr: true, + wantNoRun: true, + }, + {name: "no_resolver_wired", lookup: nil, wantErr: true, wantNoRun: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + r := newLaunchRunner(t, fake) + r.SetSecretResolver(tt.lookup) + + td := &TaskDefinition{ContainerDefinitions: []ContainerDefinition{{ + Name: "app", Image: "busybox", Essential: true, + Environment: []KeyValuePair{{Name: "PLAIN", Value: "1"}}, + Secrets: []SecretReference{{Name: "DB_PASS", ValueFrom: "arn:x"}}, + }}} + + err := r.RunTask(launchTask(), td) + if tt.wantErr { + var le *taskLaunchError + + require.ErrorAs(t, err, &le) + assert.Contains(t, le.Reason, "ResourceInitializationError") + assert.Empty(t, fake.createdImages()) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantEnv, fake.createAt(0).cfg.Env) + }) + } +} + +type fakeSecrets struct { + smErr error + ssmValues map[string]string + smValue string + smCalls []string + mu sync.Mutex +} + +func (f *fakeSecrets) SecretString(_ context.Context, region, id, stage, version string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.smCalls = append(f.smCalls, region+"|"+id+"|"+stage+"|"+version) + + return f.smValue, f.smErr +} + +func (f *fakeSecrets) ParameterValue(_ context.Context, region, name string) (string, error) { + if v, ok := f.ssmValues[region+"|"+name]; ok { + return v, nil + } + + return "", errTestParamNotFound +} + +func TestResolveSecret(t *testing.T) { + t.Parallel() + + const sm = "arn:aws:secretsmanager:us-east-1:000000000000:secret:db-AbCdEf" + + tests := []struct { + resolver *fakeSecrets + name string + valueFrom string + want string + wantCall string + wantErr string + }{ + { + name: "whole_secret", valueFrom: sm, + resolver: &fakeSecrets{smValue: "plain"}, want: "plain", wantCall: "us-east-1|" + sm + "||", + }, + { + name: "json_key_stage_version", valueFrom: sm + ":password:AWSPREVIOUS:v1", + resolver: &fakeSecrets{smValue: `{"password":"s3cret","n":5}`}, want: "s3cret", + wantCall: "us-east-1|" + sm + "|AWSPREVIOUS|v1", + }, + { + name: "json_key_non_string", valueFrom: sm + ":n::", + resolver: &fakeSecrets{smValue: `{"n":5}`}, want: "5", + }, + { + name: "json_key_missing", valueFrom: sm + ":nope::", + resolver: &fakeSecrets{smValue: `{"n":5}`}, wantErr: "secrets manager", + }, + { + name: "secret_not_found", valueFrom: sm, + resolver: &fakeSecrets{smErr: errTestSecretNotFound}, wantErr: "secrets manager", + }, + { + name: "ssm_name", valueFrom: "/app/db", + resolver: &fakeSecrets{ssmValues: map[string]string{"us-east-1|/app/db": "p"}}, want: "p", + }, + { + name: "ssm_arn_without_slash_fallback", valueFrom: "arn:aws:ssm:us-east-1:000000000000:parameter/plain", + resolver: &fakeSecrets{ssmValues: map[string]string{"us-east-1|plain": "q"}}, want: "q", + }, + { + name: "ssm_missing", valueFrom: "/nope", + resolver: &fakeSecrets{}, wantErr: "ssm", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("000000000000", "us-east-1", NewNoopRunner()) + b.SetSecretsResolver(tt.resolver) + + got, err := b.resolveSecret(t.Context(), tt.valueFrom) + if tt.wantErr != "" { + var le *taskLaunchError + + require.ErrorAs(t, err, &le) + assert.Contains(t, le.Reason, "ResourceInitializationError: unable to pull secrets or registry auth") + assert.Contains(t, le.Reason, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + + if tt.wantCall != "" { + assert.Equal(t, []string{tt.wantCall}, tt.resolver.smCalls) + } + }) + } +} + +func TestResolveSecret_NoResolver(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("000000000000", "us-east-1", NewNoopRunner()) + _, err := b.resolveSecret(t.Context(), "/x") + + var le *taskLaunchError + + require.ErrorAs(t, err, &le) +} + +func TestRunTask_Volumes(t *testing.T) { + t.Parallel() + + taskVol := "gopherstack-ecs-task-1-" + + tests := []struct { + fake *fakeDockerClient + check func(t *testing.T, fake *fakeDockerClient) + name string + volumes []Volume + mounts []MountPoint + wantError bool + }{ + { + name: "bind_host_path_readonly", + volumes: []Volume{{Name: "data", Host: &HostVolumeProperties{SourcePath: "/srv/data"}}}, + mounts: []MountPoint{{SourceVolume: "data", ContainerPath: "/data", ReadOnly: true}}, + check: func(t *testing.T, fake *fakeDockerClient) { + t.Helper() + + assert.Equal(t, []mount.Mount{{ + Type: mount.TypeBind, Source: "/srv/data", Target: "/data", ReadOnly: true, + BindOptions: &mount.BindOptions{CreateMountpoint: true}, + }}, fake.createAt(0).host.Mounts) + }, + }, + { + name: "empty_host_is_task_scoped_ephemeral", + volumes: []Volume{{Name: "scratch"}}, + mounts: []MountPoint{{SourceVolume: "scratch", ContainerPath: "/tmp/s"}}, + check: func(t *testing.T, fake *fakeDockerClient) { + t.Helper() + + assert.Equal(t, []mount.Mount{{ + Type: mount.TypeVolume, Source: taskVol + "scratch", Target: "/tmp/s", + }}, fake.createAt(0).host.Mounts) + }, + }, + { + name: "docker_task_scope_driver_and_labels", + volumes: []Volume{{Name: "d", DockerVolumeConfig: &DockerVolumeConfig{ + Scope: "task", Driver: "local", DriverOpts: map[string]string{"type": "tmpfs"}, + Labels: map[string]string{"k": "v"}, + }}}, + mounts: []MountPoint{{SourceVolume: "d", ContainerPath: "/d"}}, + check: func(t *testing.T, fake *fakeDockerClient) { + t.Helper() + + assert.Equal(t, []mount.Mount{{ + Type: mount.TypeVolume, Source: taskVol + "d", Target: "/d", + VolumeOptions: &mount.VolumeOptions{ + Labels: map[string]string{"k": "v"}, + DriverConfig: &mount.Driver{Name: "local", Options: map[string]string{"type": "tmpfs"}}, + }, + }}, fake.createAt(0).host.Mounts) + }, + }, + { + name: "shared_autoprovision", + volumes: []Volume{{Name: "shared", DockerVolumeConfig: &DockerVolumeConfig{ + Scope: "shared", Autoprovision: new(true), + }}}, + mounts: []MountPoint{{SourceVolume: "shared", ContainerPath: "/s"}}, + check: func(t *testing.T, fake *fakeDockerClient) { + t.Helper() + + assert.Equal(t, "shared", fake.createAt(0).host.Mounts[0].Source) + assert.Empty(t, fake.volumesRemoved) + }, + }, + { + name: "shared_without_autoprovision_must_exist", + volumes: []Volume{{Name: "shared", DockerVolumeConfig: &DockerVolumeConfig{Scope: "shared"}}}, + mounts: []MountPoint{{SourceVolume: "shared", ContainerPath: "/s"}}, + wantError: true, + }, + { + name: "shared_without_autoprovision_existing", + fake: &fakeDockerClient{volumes: map[string]bool{"shared": true}}, + volumes: []Volume{{Name: "shared", DockerVolumeConfig: &DockerVolumeConfig{Scope: "shared"}}}, + mounts: []MountPoint{{SourceVolume: "shared", ContainerPath: "/s"}}, + check: func(t *testing.T, fake *fakeDockerClient) { + t.Helper() + + assert.Equal(t, "shared", fake.createAt(0).host.Mounts[0].Source) + }, + }, + { + name: "efs_rejected", + volumes: []Volume{{Name: "efs", EFSVolumeConfiguration: &EFSVolumeConfiguration{FileSystemID: "fs-1"}}}, + mounts: []MountPoint{{SourceVolume: "efs", ContainerPath: "/e"}}, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := tt.fake + if fake == nil { + fake = &fakeDockerClient{} + } + + r := newLaunchRunner(t, fake) + td := &TaskDefinition{ + Volumes: tt.volumes, + ContainerDefinitions: []ContainerDefinition{ + {Name: "app", Image: "busybox", Essential: true, MountPoints: tt.mounts}, + }, + } + + err := r.RunTask(launchTask(), td) + if tt.wantError { + require.Error(t, err) + assert.Empty(t, fake.createdImages()) + + return + } + + require.NoError(t, err) + tt.check(t, fake) + }) + } +} + +func TestRunTask_VolumesFromOrdersSourceFirst(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + r := newLaunchRunner(t, fake) + td := &TaskDefinition{ContainerDefinitions: []ContainerDefinition{ + { + Name: "consumer", + Image: "consumer", + Essential: true, + VolumesFrom: []VolumeFrom{{SourceContainer: "owner", ReadOnly: true}}, + }, + {Name: "owner", Image: "owner", Essential: true}, + }} + + require.NoError(t, r.RunTask(launchTask(), td)) + assert.Equal(t, []string{"owner", "consumer"}, fake.createdImages()) + assert.Equal(t, []string{fakeID(1) + ":ro"}, fake.createAt(1).host.VolumesFrom) + + ids := r.ContainerIDs(launchTaskArn) + assert.Equal(t, []string{fakeID(2), fakeID(1)}, ids, "ContainerIDs keeps task-definition order") +} + +func TestOrderContainers_Errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + defs []ContainerDefinition + }{ + { + name: "unknown_dependency", + defs: []ContainerDefinition{ + {Name: "a", DependsOn: []ContainerDependency{{ContainerName: "ghost", Condition: "START"}}}, + }, + }, + { + name: "cycle", + defs: []ContainerDefinition{ + {Name: "a", DependsOn: []ContainerDependency{{ContainerName: "b", Condition: "START"}}}, + {Name: "b", DependsOn: []ContainerDependency{{ContainerName: "a", Condition: "START"}}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := orderContainers(tt.defs) + require.Error(t, err) + }) + } +} + +func dep(name, cond string) []ContainerDependency { + return []ContainerDependency{{ContainerName: name, Condition: cond}} +} + +func TestRunTask_DependsOnStart(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + r := newLaunchRunner(t, fake) + td := &TaskDefinition{ContainerDefinitions: []ContainerDefinition{ + {Name: "web", Image: "web", Essential: true, DependsOn: dep("db", dependsStart)}, + {Name: "db", Image: "db", Essential: true}, + }} + + require.NoError(t, r.RunTask(launchTask(), td)) + assert.Equal(t, []string{"db", "web"}, fake.createdImages()) +} + +func TestRunTask_DependsOnSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cond string + exitCode int64 + wantErr bool + }{ + {name: "success_zero", cond: dependsSuccess, exitCode: 0}, + {name: "success_nonzero_fails_task", cond: dependsSuccess, exitCode: 3, wantErr: true}, + {name: "complete_nonzero_ok", cond: dependsComplete, exitCode: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + initExit := make(chan dockertypes.WaitResponse, 1) + fake := &fakeDockerClient{waitByID: map[string]chan dockertypes.WaitResponse{fakeID(1): initExit}} + r := newLaunchRunner(t, fake) + td := &TaskDefinition{ContainerDefinitions: []ContainerDefinition{ + {Name: "main", Image: "main", Essential: true, DependsOn: dep("init", tt.cond)}, + {Name: "init", Image: "init", Essential: false}, + }} + + done := make(chan error, 1) + + go func() { done <- r.RunTask(launchTask(), td) }() + + require.Eventually( + t, + func() bool { return len(fake.createdImages()) == 1 }, + 2*time.Second, + time.Millisecond, + ) + + select { + case <-done: + t.Fatal("RunTask returned before the init container exited") + default: + } + + initExit <- dockertypes.WaitResponse{StatusCode: tt.exitCode} + + err := <-done + if tt.wantErr { + var le *taskLaunchError + + require.ErrorAs(t, err, &le) + assert.Contains(t, le.Reason, "exited with code 3") + assert.Equal(t, []string{"init"}, fake.createdImages(), "dependent container never created") + assert.Empty(t, r.ContainerIDs(launchTaskArn), "failed launch is rolled back") + + return + } + + require.NoError(t, err) + assert.Equal(t, []string{"init", "main"}, fake.createdImages()) + }) + } +} + +func TestRunTask_DependsOnHealthy(t *testing.T) { + t.Parallel() + + healthy := func(status string) *dockertypes.State { + return &dockertypes.State{ + Running: true, + Health: &mobycontainer.Health{Status: mobycontainer.HealthStatus(status)}, + } + } + + tests := []struct { + final *dockertypes.State + name string + wantErr string + }{ + {name: "becomes_healthy", final: healthy("healthy")}, + {name: "becomes_unhealthy", final: healthy("unhealthy"), wantErr: "became unhealthy"}, + {name: "exits", final: &dockertypes.State{Running: false, ExitCode: 2}, wantErr: "exited with code 2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{states: map[string]*dockertypes.State{fakeID(1): healthy("starting")}} + r := newLaunchRunner(t, fake) + td := &TaskDefinition{ContainerDefinitions: []ContainerDefinition{ + {Name: "app", Image: "app", Essential: true, DependsOn: dep("db", dependsHealthy)}, + { + Name: "db", Image: "db", Essential: true, + HealthCheck: &HealthCheck{Command: []string{"CMD-SHELL", "true"}}, + }, + }} + + done := make(chan error, 1) + + go func() { done <- r.RunTask(launchTask(), td) }() + + require.Eventually( + t, + func() bool { return len(fake.createdImages()) == 1 }, + 2*time.Second, + time.Millisecond, + ) + assert.Len(t, fake.createdImages(), 1, "app waits while db is still starting") + + fake.mu.Lock() + fake.states[fakeID(1)] = tt.final + fake.mu.Unlock() + + err := <-done + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, []string{"db", "app"}, fake.createdImages()) + }) + } +} + +func TestRunTask_DependencyTimeout(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{states: map[string]*dockertypes.State{ + fakeID(1): {Running: true, Health: &mobycontainer.Health{Status: "starting"}}, + }} + r := newLaunchRunner(t, fake) + r.dependencyTimeout = 20 * time.Millisecond + + td := &TaskDefinition{ContainerDefinitions: []ContainerDefinition{ + {Name: "app", Image: "app", Essential: true, DependsOn: dep("db", dependsHealthy)}, + {Name: "db", Image: "db", Essential: true, HealthCheck: &HealthCheck{Command: []string{"CMD", "x"}}}, + }} + + err := r.RunTask(launchTask(), td) + + var le *taskLaunchError + + require.ErrorAs(t, err, &le) + assert.Contains(t, le.Reason, "timed out") + assert.Empty(t, r.ContainerIDs(launchTaskArn)) +} + +func TestStopTask_ReverseOrderAndStopTimeout(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + r := newLaunchRunner(t, fake) + td := &TaskDefinition{ + Volumes: []Volume{{Name: "scratch"}}, + ContainerDefinitions: []ContainerDefinition{ + { + Name: "a", + Image: "a", + Essential: true, + MountPoints: []MountPoint{{SourceVolume: "scratch", ContainerPath: "/s"}}, + }, + {Name: "b", Image: "b", Essential: true, StopTimeout: new(7), DependsOn: dep("a", dependsStart)}, + {Name: "c", Image: "c", Essential: true, DependsOn: dep("b", dependsStart)}, + }, + } + + require.NoError(t, r.RunTask(launchTask(), td)) + require.NoError(t, r.StopTask(launchTask())) + + fake.mu.Lock() + defer fake.mu.Unlock() + + assert.Equal(t, []string{fakeID(3), fakeID(2), fakeID(1)}, fake.stopped, "reverse of start order") + assert.Equal(t, map[string]int{fakeID(1): 30, fakeID(2): 7, fakeID(3): 30}, fake.stopTimeouts) + assert.ElementsMatch( + t, + []string{fakeID(1), fakeID(2), fakeID(3)}, + fake.removed, + "containers removed with task volumes", + ) + assert.Equal(t, []string{"gopherstack-ecs-task-1-scratch"}, fake.volumesRemoved) +} + +func TestRunTask_FailureRemovesTaskVolumes(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{startErrOnID: fakeID(2)} + r := newLaunchRunner(t, fake) + td := &TaskDefinition{ + Volumes: []Volume{{Name: "scratch"}}, + ContainerDefinitions: []ContainerDefinition{ + {Name: "a", Image: "a", Essential: true}, + {Name: "b", Image: "b", Essential: true}, + }, + } + + require.Error(t, r.RunTask(launchTask(), td)) + + fake.mu.Lock() + defer fake.mu.Unlock() + + assert.Equal(t, []string{"gopherstack-ecs-task-1-scratch"}, fake.volumesRemoved) +} + +func TestWatchContainerExit_RestartPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + policy *ContainerRestartPolicy + name string + exitCode int64 + ranFor time.Duration + wantStarts int + wantHandler bool + }{ + { + name: "restarts_after_period", policy: &ContainerRestartPolicy{Enabled: true, RestartAttemptPeriod: 60}, + exitCode: 1, ranFor: 61 * time.Second, wantStarts: 2, + }, + { + name: "restarts_on_zero_exit_too", policy: &ContainerRestartPolicy{Enabled: true, RestartAttemptPeriod: 60}, + exitCode: 0, ranFor: 2 * time.Minute, wantStarts: 2, + }, + { + name: "ignored_exit_code", policy: &ContainerRestartPolicy{Enabled: true, IgnoredExitCodes: []int{7}}, + exitCode: 7, ranFor: time.Hour, wantStarts: 1, wantHandler: true, + }, + { + name: "exited_too_early", policy: &ContainerRestartPolicy{Enabled: true, RestartAttemptPeriod: 60}, + exitCode: 1, ranFor: 10 * time.Second, wantStarts: 1, wantHandler: true, + }, + { + name: "disabled", policy: &ContainerRestartPolicy{Enabled: false}, + exitCode: 1, ranFor: time.Hour, wantStarts: 1, wantHandler: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + exit := make(chan dockertypes.WaitResponse, 1) + fake := &fakeDockerClient{waitByID: map[string]chan dockertypes.WaitResponse{fakeID(1): exit}} + r := newLaunchRunner(t, fake) + + var ( + cur atomic.Int64 + handled = make(chan int, 1) + ) + + base := time.Unix(1_700_000_000, 0) + r.now = func() time.Time { return base.Add(time.Duration(cur.Load())) } + r.SetTaskCompletionHandler(func(_, _ string, code int) { handled <- code }) + + td := &TaskDefinition{ContainerDefinitions: []ContainerDefinition{ + {Name: "app", Image: "app", Essential: true, RestartPolicy: tt.policy}, + }} + + require.NoError(t, r.RunTask(launchTask(), td)) + + cur.Store(int64(tt.ranFor)) + exit <- dockertypes.WaitResponse{StatusCode: tt.exitCode} + + if tt.wantHandler { + assert.Equal(t, int(tt.exitCode), <-handled) + } else { + require.Eventually(t, func() bool { + fake.mu.Lock() + defer fake.mu.Unlock() + + return len(fake.started) == tt.wantStarts + }, 2*time.Second, time.Millisecond) + assert.Empty(t, handled, "a restart is not a task-level exit") + } + + fake.mu.Lock() + defer fake.mu.Unlock() + + assert.Len(t, fake.started, tt.wantStarts) + }) + } +} diff --git a/services/ecs/docker_runner.go b/services/ecs/docker_runner.go index b4364b5a3e..4a626b8bad 100644 --- a/services/ecs/docker_runner.go +++ b/services/ecs/docker_runner.go @@ -3,13 +3,13 @@ package ecs import ( "bytes" "context" - "errors" "fmt" "io" "net/netip" "os" "strconv" "sync" + "time" "github.com/moby/moby/api/pkg/stdcopy" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -69,6 +69,9 @@ type dockerClient interface { options dockertypes.WaitOptions, ) dockertypes.WaitResult ContainerStats(ctx context.Context, containerID string) (io.ReadCloser, error) + ContainerInspect(ctx context.Context, containerID string) (dockertypes.InspectResponse, error) + VolumeExists(ctx context.Context, name string) (bool, error) + VolumeRemove(ctx context.Context, name string) error } // NewDockerRunner creates a TaskRunner backed by the local Docker daemon. @@ -91,27 +94,52 @@ func newDockerRunnerWithClient(ctx context.Context, cli dockerClient) *realDocke } return &realDockerRunner{ - cli: cli, - containers: make(map[string][]string), - logCancels: make(map[string]context.CancelFunc), - waitCancels: make(map[string]context.CancelFunc), - fronts: newSGFronts(), - svcCtx: ctx, + cli: cli, + containers: make(map[string][]string), + stopOrder: make(map[string][]string), + taskVolumes: make(map[string][]string), + logCancels: make(map[string]context.CancelFunc), + waitCancels: make(map[string]context.CancelFunc), + healthCancels: make(map[string]context.CancelFunc), + exits: make(map[string]*containerExit), + stopTimeouts: make(map[string]int), + launching: make(map[string]*launchState), + fronts: newSGFronts(), + svcCtx: ctx, + now: time.Now, + pollInterval: defaultPollInterval, + dependencyTimeout: defaultDependencyTimeout, } } // realDockerRunner is a TaskRunner that launches Docker containers. type realDockerRunner struct { containers map[string][]string + stopOrder map[string][]string + taskVolumes map[string][]string logCancels map[string]context.CancelFunc waitCancels map[string]context.CancelFunc + healthCancels map[string]context.CancelFunc + exits map[string]*containerExit + stopTimeouts map[string]int + launching map[string]*launchState cli dockerClient + claim claimFunc cwLogs CWLogsBackend completionHandler func(taskArn, containerName string, exitCode int) + healthHandler func(taskArn, containerName, status string) + reasonHandler func(taskArn, containerName, reason string) + secretLookup func(ctx context.Context, valueFrom string) (string, error) + efsLookup func(region string, cfg *EFSVolumeConfiguration) (EFSVolumeMount, error) + registryAuth func(ctx context.Context, cd ContainerDefinition) (string, error) + envFileLookup func(ctx context.Context, cd ContainerDefinition) ([]string, error) taskEnv func(task *Task, containerName string) []string attach func(ctx context.Context, containerID string) error + now func() time.Time svcCtx context.Context fronts *sgFronts + pollInterval time.Duration + dependencyTimeout time.Duration logWG sync.WaitGroup waitWG sync.WaitGroup mu sync.Mutex @@ -193,88 +221,6 @@ func (r *realDockerRunner) taskCompletionHandler() func(taskArn, containerName s return r.completionHandler } -func (r *realDockerRunner) RunTask(task *Task, td *TaskDefinition) error { - ctx := r.svcCtx - log := logger.Load(ctx) - - // Bound once at call time so a concurrent SetCWLogsBackend doesn't change - // the target mid-task; nil means logs stay unforwarded (cwLogsRunner unwired). - cwLogs := r.cwLogsBackend() - // Bound once at call time for the same reason; nil means no watcher is - // started at all (taskCompletionRunner unwired, e.g. in tests that don't - // need it), so container exits are only observed via an explicit StopTask. - completionHandler := r.taskCompletionHandler() - taskID := taskIDFromARN(task.TaskArn) - - // started accumulates container IDs that were successfully started during - // this call. If any later container fails we roll back by stopping and - // removing everything accumulated so far, keeping RunTask atomic. - var started []string - - for _, cd := range td.ContainerDefinitions { - if err := r.pullImage(ctx, cd.Image); err != nil { - r.rollbackContainers(ctx, started) - - return err - } - - containerID, err := r.createContainer(ctx, task, cd) - if err != nil { - r.rollbackContainers(ctx, started) - - return err - } - - if startErr := r.cli.ContainerStart(ctx, containerID, dockertypes.StartOptions{}); startErr != nil { - // Clean up the just-created container before rolling back the rest. - if rmErr := r.cli.ContainerRemove( - ctx, - containerID, - dockertypes.RemoveOptions{Force: true, RemoveVolumes: true}, - ); rmErr != nil { - log.WarnContext( - ctx, "failed to remove container after start failure", - "containerID", containerID, - "error", rmErr, - ) - } - - r.closeFronts(containerID) - r.rollbackContainers(ctx, started) - - return fmt.Errorf("start container %s: %w", containerID, startErr) - } - - if frontErr := r.startFronts(containerID); frontErr != nil { - started = append(started, containerID) - r.rollbackContainers(ctx, started) - - return fmt.Errorf("front container %s: %w", containerID, frontErr) - } - - started = append(started, containerID) - - if cwLogs != nil { - if group, stream, ok := awslogsContainerTarget(cd, taskID, containerID); ok { - _ = cwLogs.EnsureLogGroupAndStream(group, stream) - r.forwardContainerLogs(cwLogs, containerID, group, stream) - } - } - - if completionHandler != nil { - r.watchContainerExit(completionHandler, task.TaskArn, containerID, cd.Name) - } - } - - // All containers started successfully; register them in the tracking map. - r.mu.Lock() - defer r.mu.Unlock() - - r.containers[task.TaskArn] = append(r.containers[task.TaskArn], started...) - - return nil -} - // rollbackContainers stops and force-removes a set of already-started // containers. Errors are logged but not returned so that all containers are // attempted. This is called on the error path of RunTask to ensure the @@ -322,7 +268,11 @@ func (r *realDockerRunner) rollbackContainers(ctx context.Context, containerIDs } // pullImage pulls a Docker image and drains the response body. -func (r *realDockerRunner) pullImage(ctx context.Context, image string) error { +func (r *realDockerRunner) pullImage(ctx context.Context, image, auth string) error { + if auth != "" { + return r.pullOnce(ctx, image, auth) + } + return registryauth.Pull(ctx, image, r.pullOnce, registryauth.TaggerFrom(r.cli)) } @@ -345,52 +295,6 @@ func (r *realDockerRunner) pullOnce(ctx context.Context, image, auth string) err return nil } -// createContainer creates a Docker container for the given container definition. -func (r *realDockerRunner) createContainer( - ctx context.Context, - task *Task, - cd ContainerDefinition, -) (string, error) { - portBindings, exposedPorts := buildPortMappings(cd.PortMappings) - diverted := r.divertPorts(task, cd.PortMappings, portBindings) - env := append(r.runtimeEnv(task, cd.Name), buildEnv(cd.Environment)...) - - resp, err := r.cli.ContainerCreate( - ctx, - &dockertypes.Config{ - Image: cd.Image, - Env: env, - ExposedPorts: exposedPorts, - Labels: map[string]string{ - "gopherstack.ecs.task": task.TaskArn, - "gopherstack.ecs.cluster": task.ClusterArn, - }, - }, - &dockertypes.HostConfig{ - PortBindings: portBindings, - }, - nil, - nil, - "", - ) - if err != nil { - releasePending(diverted) - - return "", fmt.Errorf("create container for %s: %w", cd.Image, err) - } - - r.stashFronts(resp.ID, diverted) - - if attachErr := r.attachContainer(ctx, resp.ID); attachErr != nil { - _ = r.cli.ContainerRemove(ctx, resp.ID, dockertypes.RemoveOptions{Force: true, RemoveVolumes: true}) - r.closeFronts(resp.ID) - - return "", fmt.Errorf("attach container %s to link-local network: %w", resp.ID, attachErr) - } - - return resp.ID, nil -} - // buildPortMappings converts PortMappings to Moby network.PortMap and network.PortSet. func buildPortMappings(mappings []PortMapping) (dockernetwork.PortMap, dockernetwork.PortSet) { portBindings := dockernetwork.PortMap{} @@ -447,68 +351,12 @@ func (r *realDockerRunner) ContainerIDs(taskArn string) []string { return append([]string(nil), r.containers[taskArn]...) } -func (r *realDockerRunner) StopTask(task *Task) error { - // Snapshot the container IDs while holding the lock but without removing - // the entry yet — we only remove it once all stops have been attempted. - var containerIDs []string - - func() { - r.mu.Lock() - defer r.mu.Unlock() - - containerIDs = append([]string(nil), r.containers[task.TaskArn]...) - }() - - if len(containerIDs) == 0 { - return nil - } - - ctx := r.svcCtx - timeout := 10 - - var ( - errs []error - failed []string - ) - - for _, containerID := range containerIDs { - if err := r.cli.ContainerStop(ctx, containerID, dockertypes.StopOptions{Timeout: &timeout}); err != nil { - errs = append(errs, fmt.Errorf("stop container %s: %w", containerID, err)) - failed = append(failed, containerID) - - continue - } - - // Only cancel once the container is confirmed stopped; a container - // that failed to stop stays tracked for retry and may still be - // producing output worth forwarding, or may yet exit on its own. - r.cancelLogForwarding(containerID) - r.cancelContainerWait(containerID) - r.closeFronts(containerID) - } - - // Update the tracking map: remove the entry entirely on full success, or - // retain only the containers that could not be stopped so callers can retry. - func() { - r.mu.Lock() - defer r.mu.Unlock() - - if len(failed) == 0 { - delete(r.containers, task.TaskArn) - } else { - r.containers[task.TaskArn] = failed - } - }() - - return errors.Join(errs...) -} - // forwardContainerLogs streams a just-started container's stdout/stderr to // CloudWatch Logs, in its own goroutine so RunTask returns without waiting // for the container to exit. The goroutine ends -- and releases its map // entry and logWG slot -- when the log stream reaches EOF (container exit or // removal, in production) or cancelLogForwarding(containerID) is called. -func (r *realDockerRunner) forwardContainerLogs(cwLogs CWLogsBackend, containerID, group, stream string) { +func (r *realDockerRunner) forwardContainerLogs(cwLogs CWLogsBackend, containerID, group, stream, since string) { ctx, cancel := context.WithCancel(r.svcCtx) r.mu.Lock() @@ -527,6 +375,7 @@ func (r *realDockerRunner) forwardContainerLogs(cwLogs CWLogsBackend, containerI ShowStdout: true, ShowStderr: true, Follow: true, + Since: since, }) if err != nil { return @@ -555,58 +404,6 @@ func (r *realDockerRunner) cancelLogForwarding(containerID string) { } } -// watchContainerExit waits for containerID to stop and, unless the wait is -// cancelled first (see cancelContainerWait, called from StopTask/rollback -// once a container is confirmed stopped by an explicit request), reports the -// exit to handler so the owning task can move to STOPPED. Runs in its own -// goroutine so RunTask does not block on it, and terminates on container exit, -// cancellation, or shutdown (ctx derives from r.svcCtx) -- never leaked. -func (r *realDockerRunner) watchContainerExit( - handler func(taskArn, containerName string, exitCode int), - taskArn, containerID, containerName string, -) { - ctx, cancel := context.WithCancel(r.svcCtx) - - r.mu.Lock() - r.waitCancels[containerID] = cancel - r.mu.Unlock() - - r.waitWG.Go(func() { - defer cancel() - defer func() { - r.mu.Lock() - delete(r.waitCancels, containerID) - r.mu.Unlock() - }() - - result := r.cli.ContainerWait(ctx, containerID, dockertypes.WaitOptions{}) - - select { - case res := <-result.Result: - r.closeFronts(containerID) - handler(taskArn, containerName, int(res.StatusCode)) - case err := <-result.Error: - if err != nil && ctx.Err() == nil { - logger.Load(ctx).WarnContext(ctx, "container wait failed", "containerID", containerID, "error", err) - } - case <-ctx.Done(): - } - }) -} - -// cancelContainerWait stops the in-flight exit watcher for containerID, if -// any. Called once a container is confirmed stopped/removed so its watcher -// doesn't report a self-inflicted stop as a natural exit. -func (r *realDockerRunner) cancelContainerWait(containerID string) { - r.mu.Lock() - cancel, ok := r.waitCancels[containerID] - r.mu.Unlock() - - if ok { - cancel() - } -} - // awslogsWriter demultiplexed via stdcopy.StdCopy writes both stdout and // stderr to the same instance: the awslogs driver interleaves both into one // CloudWatch Logs stream, same as the real ECS agent. It splits the combined diff --git a/services/ecs/docker_runner_internal_test.go b/services/ecs/docker_runner_internal_test.go index 02bd995214..f12f215ff3 100644 --- a/services/ecs/docker_runner_internal_test.go +++ b/services/ecs/docker_runner_internal_test.go @@ -32,6 +32,13 @@ var ( // It assigns sequential IDs to created containers and records all operations. type fakeDockerClient struct { containerLogs map[string][]byte + states map[string]*dockertypes.State + waitByID map[string]chan dockertypes.WaitResponse + volumes map[string]bool + creates []createCall + pulls []pullCall + volumesRemoved []string + stopTimeouts map[string]int stats map[string]string envs [][]string waitResult chan dockertypes.WaitResponse @@ -51,14 +58,33 @@ type fakeDockerClient struct { blockLogsUntilCanceled bool } -func (f *fakeDockerClient) ImagePull(_ context.Context, _ string, _ dockerimage.PullOptions) (io.ReadCloser, error) { +// pullCall records one ImagePull request. +type pullCall struct { + image string + auth string +} + +func (f *fakeDockerClient) ImagePull( + _ context.Context, image string, opts dockerimage.PullOptions, +) (io.ReadCloser, error) { + f.mu.Lock() + f.pulls = append(f.pulls, pullCall{image: image, auth: opts.RegistryAuth}) + f.mu.Unlock() + return io.NopCloser(strings.NewReader("")), nil } +// createCall records one ContainerCreate request. +type createCall struct { + cfg *dockertypes.Config + host *dockertypes.HostConfig + id string +} + func (f *fakeDockerClient) ContainerCreate( _ context.Context, cfg *dockertypes.Config, - _ *dockertypes.HostConfig, + host *dockertypes.HostConfig, _ *dockernetwork.NetworkingConfig, _ *ocispec.Platform, _ string, @@ -69,6 +95,7 @@ func (f *fakeDockerClient) ContainerCreate( f.envs = append(f.envs, append([]string(nil), cfg.Env...)) f.nextID++ id := fmt.Sprintf("%s%02d", strings.Repeat("a", 12), f.nextID) + f.creates = append(f.creates, createCall{cfg: cfg, host: host, id: id}) return dockertypes.CreateResponse{ID: id}, nil } @@ -90,10 +117,18 @@ func (f *fakeDockerClient) ContainerStart(_ context.Context, containerID string, return nil } -func (f *fakeDockerClient) ContainerStop(_ context.Context, containerID string, _ dockertypes.StopOptions) error { +func (f *fakeDockerClient) ContainerStop(_ context.Context, containerID string, opts dockertypes.StopOptions) error { f.mu.Lock() defer f.mu.Unlock() + if opts.Timeout != nil { + if f.stopTimeouts == nil { + f.stopTimeouts = map[string]int{} + } + + f.stopTimeouts[containerID] = *opts.Timeout + } + if f.stopErrOnID != "" && containerID == f.stopErrOnID { return errContainerStopFailed } @@ -182,6 +217,9 @@ func (f *fakeDockerClient) ContainerWait( f.mu.Lock() f.waitRequestedOn = append(f.waitRequestedOn, containerID) ch := f.waitResult + if own, ok := f.waitByID[containerID]; ok { + ch = own + } f.mu.Unlock() resultC := make(chan dockertypes.WaitResponse, 1) @@ -191,6 +229,7 @@ func (f *fakeDockerClient) ContainerWait( if ch == nil { <-ctx.Done() f.waitCanceled.Store(true) + errC <- ctx.Err() return } @@ -200,6 +239,7 @@ func (f *fakeDockerClient) ContainerWait( resultC <- res case <-ctx.Done(): f.waitCanceled.Store(true) + errC <- ctx.Err() } }() @@ -555,6 +595,7 @@ func TestDeleteCluster_LeavesNoRunningContainers(t *testing.T) { Cluster: "test-cluster", TaskDefinition: "test", }) + tasks = settleTasks(t, backend, tasks) require.NoError(t, runErr) require.Len(t, tasks, 1) @@ -715,6 +756,7 @@ func TestBackend_RunTask_FailedRunnerSetsSTOPPED(t *testing.T) { TaskDefinition: "fail-task", Count: tt.count, }) + tasks = settleTasks(t, backend, tasks) require.NoError(t, err, "RunTask API should not return an error (runner error is internal)") require.Len(t, tasks, tt.wantTasks) @@ -753,6 +795,7 @@ func TestBackend_StopTask_LockReleasedBeforeDockerCall(t *testing.T) { TaskDefinition: "svc-task", Count: 1, }) + runOut = settleTasks(t, backend, runOut) require.NoError(t, err) require.Len(t, runOut, 1) @@ -807,6 +850,7 @@ func TestDockerRunner_ForwardsAwslogsContainerOutput(t *testing.T) { require.NoError(t, err) tasks, _, err := backend.RunTask(RunTaskInput{Cluster: "test", TaskDefinition: "awslogs-docker"}) + tasks = settleTasks(t, backend, tasks) require.NoError(t, err) require.Len(t, tasks, 1) @@ -882,6 +926,7 @@ func TestDockerRunner_StopTask_CancelsLogForwarding(t *testing.T) { require.NoError(t, err) tasks, _, err := backend.RunTask(RunTaskInput{Cluster: "test", TaskDefinition: "awslogs-block"}) + tasks = settleTasks(t, backend, tasks) require.NoError(t, err) require.Len(t, tasks, 1) @@ -924,6 +969,7 @@ func TestDockerRunner_ContainerExit_MovesTaskToStopped(t *testing.T) { require.NoError(t, err) tasks, _, err := backend.RunTask(RunTaskInput{Cluster: "test", TaskDefinition: "batch-job"}) + tasks = settleTasks(t, backend, tasks) require.NoError(t, err) require.Len(t, tasks, 1) taskArn := tasks[0].TaskArn @@ -976,6 +1022,7 @@ func TestDockerRunner_StopTask_CancelsContainerWait(t *testing.T) { require.NoError(t, err) tasks, _, err := backend.RunTask(RunTaskInput{Cluster: "test", TaskDefinition: "long-runner"}) + tasks = settleTasks(t, backend, tasks) require.NoError(t, err) require.Len(t, tasks, 1) @@ -1033,6 +1080,7 @@ func TestDockerRunner_ContainerExit_EssentialSemantics(t *testing.T) { require.NoError(t, err) tasks, _, err := backend.RunTask(RunTaskInput{Cluster: "test", TaskDefinition: "multi"}) + tasks = settleTasks(t, backend, tasks) require.NoError(t, err) require.Len(t, tasks, 1) taskArn := tasks[0].TaskArn @@ -1066,3 +1114,36 @@ func TestDockerRunner_ContainerExit_EssentialSemantics(t *testing.T) { }) } } + +// ContainerInspect returns the configured state for containerID, or a plain running container. +func (f *fakeDockerClient) ContainerInspect( + _ context.Context, + containerID string, +) (dockertypes.InspectResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if st, ok := f.states[containerID]; ok { + cp := *st + + return dockertypes.InspectResponse{ID: containerID, State: &cp}, nil + } + + return dockertypes.InspectResponse{ID: containerID, State: &dockertypes.State{Running: true}}, nil +} + +func (f *fakeDockerClient) VolumeExists(_ context.Context, name string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.volumes[name], nil +} + +func (f *fakeDockerClient) VolumeRemove(_ context.Context, name string) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.volumesRemoved = append(f.volumesRemoved, name) + + return nil +} diff --git a/services/ecs/docker_secrets.go b/services/ecs/docker_secrets.go new file mode 100644 index 0000000000..0adaa9bf32 --- /dev/null +++ b/services/ecs/docker_secrets.go @@ -0,0 +1,224 @@ +package ecs + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const ( + sourceASM = "asm" + sourceSSM = "ssm" + + secretsManagerARNParts = 7 + secretARNMinParts = 6 +) + +var errSecretReference = errors.New("invalid secret reference") + +// SecretsResolver reads secret values for container "secrets" from Secrets Manager and SSM Parameter Store. +type SecretsResolver interface { + // SecretString returns a secret's string value; empty versionStage and versionID mean AWSCURRENT. + SecretString(ctx context.Context, region, secretID, versionStage, versionID string) (string, error) + // ParameterValue returns a parameter's decrypted value. + ParameterValue(ctx context.Context, region, name string) (string, error) +} + +// SetSecretsResolver wires Secrets Manager and SSM so container secrets resolve at launch. +func (b *InMemoryBackend) SetSecretsResolver(s SecretsResolver) { + b.mu.Lock("SetSecretsResolver") + defer b.mu.Unlock() + + b.secrets = s +} + +func (b *InMemoryBackend) secretsResolver() SecretsResolver { + b.mu.RLock("secretsResolver") + defer b.mu.RUnlock() + + return b.secrets +} + +type secretRef struct { + region string + id string + jsonKey string + versionStage string + versionID string + secretsManager bool +} + +// parseSecretRef understands the valueFrom forms in the ECS task definition parameters docs: a Secrets Manager +// ARN with optional :json-key:version-stage:version-id, an SSM parameter ARN, or a bare SSM parameter name. +func parseSecretRef(valueFrom, defaultRegion string) (secretRef, error) { + if !strings.HasPrefix(valueFrom, "arn:") { + return secretRef{region: defaultRegion, id: valueFrom}, nil + } + + parts := strings.Split(valueFrom, ":") + if len(parts) < secretARNMinParts { + return secretRef{}, fmt.Errorf("%w: %q", errSecretReference, valueFrom) + } + + switch parts[2] { + case "secretsmanager": + if len(parts) < secretsManagerARNParts { + return secretRef{}, fmt.Errorf("%w: %q", errSecretReference, valueFrom) + } + + ref := secretRef{ + secretsManager: true, + region: parts[3], + id: strings.Join(parts[:secretsManagerARNParts], ":"), + } + + fields := parts[secretsManagerARNParts:] + for i, dst := range []*string{&ref.jsonKey, &ref.versionStage, &ref.versionID} { + if i < len(fields) { + *dst = fields[i] + } + } + + return ref, nil + case sourceSSM: + name, ok := strings.CutPrefix(strings.Join(parts[5:], ":"), "parameter") + if !ok { + return secretRef{}, fmt.Errorf("%w: %q", errSecretReference, valueFrom) + } + + return secretRef{region: parts[3], id: name}, nil + default: + return secretRef{}, fmt.Errorf( + "%w: %q is neither a Secrets Manager nor an SSM ARN", + errSecretReference, + valueFrom, + ) + } +} + +// resolveSecret fetches valueFrom; failures use ECS's ResourceInitializationError stoppedReason shape. +func (b *InMemoryBackend) resolveSecret(ctx context.Context, valueFrom string) (string, error) { + ref, err := parseSecretRef(valueFrom, b.region) + if err != nil { + return "", secretLaunchError(sourceASM, valueFrom, err) + } + + resolver := b.secretsResolver() + if resolver == nil { + return "", secretLaunchError(secretSource(ref), valueFrom, errSecretsUnavailable) + } + + if !ref.secretsManager { + val, perr := resolveParameter(ctx, resolver, ref) + if perr != nil { + return "", secretLaunchError(sourceSSM, valueFrom, perr) + } + + return val, nil + } + + val, err := resolver.SecretString(ctx, ref.region, ref.id, ref.versionStage, ref.versionID) + if err != nil { + return "", secretLaunchError(sourceASM, valueFrom, err) + } + + if ref.jsonKey == "" { + return val, nil + } + + extracted, err := jsonSecretField(val, ref.jsonKey) + if err != nil { + return "", secretLaunchError(sourceASM, valueFrom, err) + } + + return extracted, nil +} + +var errSecretsUnavailable = errors.New("secrets backend is not wired") + +func secretSource(ref secretRef) string { + if ref.secretsManager { + return sourceASM + } + + return sourceSSM +} + +// resolveParameter tries the name as written in the ARN, then with a leading slash toggled, since an ARN does +// not say whether the parameter name started with one. +func resolveParameter(ctx context.Context, resolver SecretsResolver, ref secretRef) (string, error) { + val, err := resolver.ParameterValue(ctx, ref.region, ref.id) + if err == nil { + return val, nil + } + + alt, hadSlash := strings.CutPrefix(ref.id, "/") + if !hadSlash { + alt = "/" + ref.id + } + + if alt != "" && alt != ref.id { + if altVal, altErr := resolver.ParameterValue(ctx, ref.region, alt); altErr == nil { + return altVal, nil + } + } + + return "", err +} + +func jsonSecretField(secret, key string) (string, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(secret), &fields); err != nil { + return "", fmt.Errorf("secret value is not a JSON object: %w", err) + } + + raw, ok := fields[key] + if !ok { + return "", fmt.Errorf("%w: json key %q not found in secret", errSecretReference, key) + } + + var s string + if json.Unmarshal(raw, &s) == nil { + return s, nil + } + + return string(raw), nil +} + +func secretLaunchError(source, valueFrom string, err error) error { + return &taskLaunchError{ + Reason: fmt.Sprintf( + "ResourceInitializationError: unable to pull secrets or registry auth: execution resource retrieval "+ + "failed: unable to retrieve secret from %s: service call has been retried 1 time(s): failed to "+ + "fetch secret %s from %s: %v", + source, valueFrom, secretSourceName(source), err), + Err: err, + } +} + +func secretSourceName(source string) string { + if source == sourceASM { + return "secrets manager" + } + + return sourceSSM +} + +// taskLaunchError carries the stoppedReason of a task that failed to start. +type taskLaunchError struct { + Err error + Reason string +} + +func (e *taskLaunchError) Error() string { return e.Reason } + +func (e *taskLaunchError) Unwrap() error { return e.Err } + +const ( + stopCodeUserInitiated = "UserInitiated" + stopCodeEssentialContainerExited = "EssentialContainerExited" + stopCodeTaskFailedToStart = "TaskFailedToStart" + stopCodeServiceSchedulerInitiated = "ServiceSchedulerInitiated" +) diff --git a/services/ecs/docker_sg_internal_test.go b/services/ecs/docker_sg_internal_test.go index c4b4d669d3..7220718715 100644 --- a/services/ecs/docker_sg_internal_test.go +++ b/services/ecs/docker_sg_internal_test.go @@ -75,17 +75,6 @@ func echoes(t *testing.T, addr string) bool { return n == 1 } -func freePort(t *testing.T) int { - t.Helper() - - ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") - require.NoError(t, err) - - defer func() { _ = ln.Close() }() - - return ln.Addr().(*net.TCPAddr).Port -} - func TestRunTaskSecurityGroups(t *testing.T) { t.Parallel() @@ -108,7 +97,7 @@ func TestRunTaskSecurityGroups(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - hostPort := freePort(t) + hostPort := 18080 cli := &bindingCapture{fakeDockerClient: &fakeDockerClient{}} runner := newDockerRunnerWithClient(t.Context(), cli) b := NewInMemoryBackend("000000000000", "us-east-1", runner) diff --git a/services/ecs/docker_task_lifecycle_internal_test.go b/services/ecs/docker_task_lifecycle_internal_test.go new file mode 100644 index 0000000000..964107179f --- /dev/null +++ b/services/ecs/docker_task_lifecycle_internal_test.go @@ -0,0 +1,304 @@ +package ecs + +import ( + "testing" + "time" + + mobycontainer "github.com/moby/moby/api/types/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" +) + +func newLifecycleBackend(t *testing.T, fake *fakeDockerClient, defs []ContainerDefinition) (*InMemoryBackend, []Task) { + t.Helper() + + runner := newLaunchRunner(t, fake) + b := NewInMemoryBackend("000000000000", "us-east-1", runner) + b.wireRunnerHooks(runner) + + _, err := b.CreateCluster(CreateClusterInput{ClusterName: "c"}) + require.NoError(t, err) + + _, err = b.RegisterTaskDefinition(RegisterTaskDefinitionInput{Family: "job", ContainerDefinitions: defs}) + require.NoError(t, err) + + tasks, failures, err := b.RunTask(RunTaskInput{Cluster: "c", TaskDefinition: "job"}) + tasks = settleTasks(t, b, tasks) + require.NoError(t, err) + require.Empty(t, failures) + require.Len(t, tasks, 1) + + return b, tasks +} + +func describeOne(t *testing.T, b *InMemoryBackend, arn string) Task { + t.Helper() + + got, _, err := b.DescribeTasks("c", []string{arn}) + require.NoError(t, err) + require.Len(t, got, 1) + + return got[0] +} + +func containerByName(t *testing.T, task Task, name string) Container { + t.Helper() + + for _, c := range task.Containers { + if c.Name == name { + return c + } + } + + require.Failf(t, "container not found", name) + + return Container{} +} + +func TestDockerTask_EssentialExit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantTask string + wantStopCode string + wantOtherLast string + exitIdx int + exitCode int64 + wantStopped bool + }{ + { + name: "non_essential_exit_keeps_task", + exitIdx: 2, + exitCode: 4, + wantTask: statusRunning, + wantOtherLast: statusRunning, + }, + { + name: "essential_exit_stops_task", exitIdx: 1, exitCode: 9, wantTask: statusStopped, + wantStopCode: stopCodeEssentialContainerExited, wantStopped: true, wantOtherLast: statusStopped, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + exits := map[string]chan dockertypes.WaitResponse{ + fakeID(1): make(chan dockertypes.WaitResponse, 1), + fakeID(2): make(chan dockertypes.WaitResponse, 1), + } + fake := &fakeDockerClient{waitByID: exits} + b, tasks := newLifecycleBackend(t, fake, []ContainerDefinition{ + {Name: "app", Image: "busybox", Essential: true}, + {Name: "side", Image: "busybox", Essential: false}, + }) + arn := tasks[0].TaskArn + + require.Equal(t, statusRunning, describeOne(t, b, arn).LastStatus) + + exits[fakeID(tt.exitIdx)] <- dockertypes.WaitResponse{StatusCode: tt.exitCode} + + require.Eventually(t, func() bool { + c := containerByName(t, describeOne(t, b, arn), map[int]string{1: "app", 2: "side"}[tt.exitIdx]) + + return c.ExitCode != nil + }, 2*time.Second, time.Millisecond) + + got := describeOne(t, b, arn) + exited := containerByName(t, got, map[int]string{1: "app", 2: "side"}[tt.exitIdx]) + assert.Equal(t, int(tt.exitCode), *exited.ExitCode) + assert.Equal(t, tt.wantTask, got.LastStatus) + assert.Equal(t, tt.wantStopCode, got.StopCode) + + if tt.wantStopped { + assert.Equal(t, "Essential container in task exited", got.StoppedReason) + } + + other := map[int]string{1: "side", 2: "app"}[tt.exitIdx] + assert.Equal(t, tt.wantOtherLast, containerByName(t, got, other).LastStatus) + }) + } +} + +func TestDockerTask_HealthStatus(t *testing.T) { + t.Parallel() + + state := func(s string) *dockertypes.State { + return &dockertypes.State{Running: true, Health: &mobycontainer.Health{Status: mobycontainer.HealthStatus(s)}} + } + + tests := []struct { + name string + dockerStatus string + wantContainer string + wantTask string + }{ + {name: "healthy", dockerStatus: "healthy", wantContainer: "HEALTHY", wantTask: "HEALTHY"}, + {name: "unhealthy", dockerStatus: "unhealthy", wantContainer: "UNHEALTHY", wantTask: "UNHEALTHY"}, + { + name: "starting_is_not_fabricated_healthy", + dockerStatus: "starting", + wantContainer: "UNKNOWN", + wantTask: "UNKNOWN", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{states: map[string]*dockertypes.State{fakeID(1): state(tt.dockerStatus)}} + b, tasks := newLifecycleBackend(t, fake, []ContainerDefinition{ + { + Name: "app", Image: "busybox", Essential: true, + HealthCheck: &HealthCheck{Command: []string{"CMD-SHELL", "true"}}, + }, + {Name: "side", Image: "busybox", Essential: false}, + }) + arn := tasks[0].TaskArn + + require.Eventually(t, func() bool { + return describeOne(t, b, arn).HealthStatus == tt.wantTask + }, 2*time.Second, time.Millisecond) + + got := describeOne(t, b, arn) + assert.Equal(t, tt.wantContainer, containerByName(t, got, "app").HealthStatus) + assert.Empty(t, containerByName(t, got, "side").HealthStatus) + }) + } +} + +func TestDockerTask_SecretFailureStopsTask(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + runner := newLaunchRunner(t, fake) + b := NewInMemoryBackend("000000000000", "us-east-1", runner) + b.wireRunnerHooks(runner) + b.SetSecretsResolver(&fakeSecrets{smErr: assert.AnError}) + + _, err := b.CreateCluster(CreateClusterInput{ClusterName: "c"}) + require.NoError(t, err) + + _, err = b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "job", + ContainerDefinitions: []ContainerDefinition{{ + Name: "app", Image: "busybox", Essential: true, + Secrets: []SecretReference{{ + Name: "S", ValueFrom: "arn:aws:secretsmanager:us-east-1:000000000000:secret:missing-AbCdEf", + }}, + }}, + }) + require.NoError(t, err) + + tasks, _, err := b.RunTask(RunTaskInput{Cluster: "c", TaskDefinition: "job"}) + tasks = settleTasks(t, b, tasks) + require.NoError(t, err) + + got := describeOne(t, b, tasks[0].TaskArn) + assert.Equal(t, statusStopped, got.LastStatus) + assert.Equal(t, stopCodeTaskFailedToStart, got.StopCode) + assert.Contains(t, got.StoppedReason, "ResourceInitializationError: unable to pull secrets or registry auth") + assert.Empty(t, fake.createdImages()) +} + +func TestDockerTask_UserStopCode(t *testing.T) { + t.Parallel() + + b, tasks := newLifecycleBackend(t, &fakeDockerClient{}, []ContainerDefinition{ + {Name: "app", Image: "busybox", Essential: true}, + }) + + _, err := b.StopTask("c", tasks[0].TaskArn, "bye") + require.NoError(t, err) + + got := describeOne(t, b, tasks[0].TaskArn) + assert.Equal(t, stopCodeUserInitiated, got.StopCode) + assert.Equal(t, "bye", got.StoppedReason) +} + +func TestRegisterTaskDefinition_ContainerRuntimeValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + defs []ContainerDefinition + volumes []Volume + wantErr bool + }{ + { + name: "valid_healthy_dependency", + defs: []ContainerDefinition{ + {Name: "a", Image: "x", Essential: true, DependsOn: dep("b", dependsHealthy)}, + {Name: "b", Image: "x", Essential: true, HealthCheck: &HealthCheck{Command: []string{"CMD", "x"}}}, + }, + }, + { + name: "healthy_without_healthcheck", + defs: []ContainerDefinition{ + {Name: "a", Image: "x", Essential: true, DependsOn: dep("b", dependsHealthy)}, + {Name: "b", Image: "x", Essential: true}, + }, + wantErr: true, + }, + { + name: "unknown_dependency", + defs: []ContainerDefinition{ + {Name: "a", Image: "x", Essential: true, DependsOn: dep("ghost", dependsStart)}, + }, + wantErr: true, + }, + { + name: "bad_condition", + defs: []ContainerDefinition{{Name: "a", Image: "x", Essential: true, DependsOn: dep("a", "SOON")}}, + wantErr: true, + }, + { + name: "mount_of_undefined_volume", + defs: []ContainerDefinition{ + { + Name: "a", + Image: "x", + Essential: true, + MountPoints: []MountPoint{{SourceVolume: "v", ContainerPath: "/v"}}, + }, + }, + wantErr: true, + }, + { + name: "autoprovision_needs_shared_scope", + defs: []ContainerDefinition{{Name: "a", Image: "x", Essential: true}}, + volumes: []Volume{{Name: "v", DockerVolumeConfig: &DockerVolumeConfig{Autoprovision: new(true)}}}, + wantErr: true, + }, + { + name: "bad_healthcheck_command", + defs: []ContainerDefinition{ + {Name: "a", Image: "x", Essential: true, HealthCheck: &HealthCheck{Command: []string{"true"}}}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("000000000000", "us-east-1", NewNoopRunner()) + _, err := b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "f", ContainerDefinitions: tt.defs, Volumes: tt.volumes, + }) + + if tt.wantErr { + require.ErrorIs(t, err, ErrClient) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/services/ecs/docker_unhealthy_live_internal_test.go b/services/ecs/docker_unhealthy_live_internal_test.go new file mode 100644 index 0000000000..5cd7b9020a --- /dev/null +++ b/services/ecs/docker_unhealthy_live_internal_test.go @@ -0,0 +1,120 @@ +package ecs + +import ( + "context" + "os/exec" + "testing" + "time" + + mobyclient "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/internal/dockercompat/client" +) + +func TestDockerRunnerLiveUnhealthyServiceTaskReplaced(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not found") + } + + probe, err := mobyclient.New(mobyclient.FromEnv) + require.NoError(t, err) + + t.Cleanup(func() { _ = probe.Close() }) + + if _, imgErr := probe.ImageInspect(t.Context(), liveImage); imgErr != nil { + t.Skipf("%s not present: %v", liveImage, imgErr) + } + + cli, err := client.NewClientWithOpts(client.FromEnv(), client.WithAPIVersionNegotiation()) + require.NoError(t, err) + + t.Cleanup(func() { _ = cli.Close() }) + + runner := newDockerRunnerWithClient(context.Background(), sleepingClient{cli}) + runner.pollInterval = 200 * time.Millisecond + b := NewInMemoryBackend("123456789012", "us-east-1", runner) + b.wireRunnerHooks(runner) + + _, err = b.CreateCluster(CreateClusterInput{ClusterName: "live-unhealthy"}) + require.NoError(t, err) + + td, err := b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "live-unhealthy", + ContainerDefinitions: []ContainerDefinition{{ + Name: "app", Image: liveImage, Essential: true, + HealthCheck: &HealthCheck{Command: []string{"CMD-SHELL", "false"}, Interval: 1, Timeout: 2, Retries: 1}, + }}, + }) + require.NoError(t, err) + + _, err = b.CreateService(CreateServiceInput{ + Cluster: "live-unhealthy", ServiceName: "svc", TaskDefinition: td.TaskDefinitionArn, DesiredCount: 1, + }) + require.NoError(t, err) + + t.Cleanup(func() { + all, _, _ := b.DescribeTasks("live-unhealthy", nil) + + ids := make([]string, 0, len(all)) + for _, tk := range all { + ids = append(ids, runner.ContainerIDs(tk.TaskArn)...) + } + + b.Close() + + for _, id := range ids { + _, _ = probe.ContainerRemove( + context.Background(), id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, + ) + } + }) + + rec := NewReconciler(b) + + stoppedUnhealthy := func() []Task { + all, _, descErr := b.DescribeTasks("live-unhealthy", nil) + require.NoError(t, descErr) + + var out []Task + + for _, tk := range all { + if tk.StoppedReason == containerHealthStopReason { + out = append(out, tk) + } + } + + return out + } + + require.Eventually(t, func() bool { + rec.RunOnce(t.Context()) + b.awaitLaunches() + + return len(stoppedUnhealthy()) > 0 + }, liveTimeout*2, liveTick, "unhealthy task stopped by the service scheduler") + + stopped := stoppedUnhealthy()[0] + assert.Equal(t, stopCodeServiceSchedulerInitiated, stopped.StopCode) + + require.Eventually(t, func() bool { + rec.RunOnce(t.Context()) + b.awaitLaunches() + + all, _, descErr := b.DescribeTasks("live-unhealthy", nil) + if descErr != nil { + return false + } + + for _, tk := range all { + if tk.TaskArn != stopped.TaskArn && tk.DesiredStatus == statusRunning { + return true + } + } + + return false + }, liveTimeout*2, liveTick, "replacement task launched") +} diff --git a/services/ecs/docker_volumes.go b/services/ecs/docker_volumes.go new file mode 100644 index 0000000000..5ce2d4e9e8 --- /dev/null +++ b/services/ecs/docker_volumes.go @@ -0,0 +1,187 @@ +package ecs + +import ( + "context" + "errors" + "fmt" + + "github.com/moby/moby/api/types/mount" + + dockertypes "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/container" +) + +const ( + volumeScopeTask = "task" + volumeScopeShared = "shared" +) + +var ( + errUnsupportedVolume = errors.New("unsupported volume") + errUndefinedVolume = errors.New("mounts undefined volume") +) + +// volumeSpec is a task-level volume resolved to its Docker mount source. +type volumeSpec struct { + mount mount.Mount + user string + groups []string +} + +func bindMount(src string) mount.Mount { + return mount.Mount{Type: mount.TypeBind, Source: src} +} + +// volumePlan holds a task's resolved volumes and the Docker volumes to delete when the task ends. +type volumePlan struct { + specs map[string]volumeSpec + taskVols []string +} + +func taskVolumeName(task *Task, volume string) string { + return "gopherstack-ecs-" + taskIDFromARN(task.TaskArn) + "-" + volume +} + +// resolveVolumes maps td.Volumes to Docker mounts: host.sourcePath is a bind, dockerVolumeConfiguration a +// named volume (task scope is deleted with the task), and an empty host an ephemeral task-scoped volume. +func (r *realDockerRunner) resolveVolumes(ctx context.Context, task *Task, td *TaskDefinition) (*volumePlan, error) { + plan := &volumePlan{specs: make(map[string]volumeSpec, len(td.Volumes))} + + for _, v := range td.Volumes { + switch { + case v.EFSVolumeConfiguration != nil: + spec, err := r.efsVolumeSpec(task, v) + if err != nil { + return nil, err + } + + plan.specs[v.Name] = spec + case v.ConfiguredAtLaunch: + return nil, fmt.Errorf("%w: volume %q is configuredAtLaunch", errUnsupportedVolume, v.Name) + case v.DockerVolumeConfig != nil: + spec, err := r.dockerVolumeSpec(ctx, task, v, plan) + if err != nil { + return nil, err + } + + plan.specs[v.Name] = spec + case v.Host != nil && v.Host.SourcePath != "": + plan.specs[v.Name] = volumeSpec{mount: mount.Mount{ + Type: mount.TypeBind, + Source: v.Host.SourcePath, + BindOptions: &mount.BindOptions{CreateMountpoint: true}, + }} + default: + name := taskVolumeName(task, v.Name) + plan.taskVols = append(plan.taskVols, name) + plan.specs[v.Name] = volumeSpec{mount: mount.Mount{Type: mount.TypeVolume, Source: name}} + } + } + + return plan, nil +} + +func (r *realDockerRunner) efsVolumeSpec(task *Task, v Volume) (volumeSpec, error) { + r.mu.Lock() + resolve := r.efsLookup + r.mu.Unlock() + + if resolve == nil { + return volumeSpec{}, &taskLaunchError{ + Reason: fmt.Sprintf("%s: volume %q: %v", efsInitFailure, v.Name, errEFSUnavailable), + Err: errEFSUnavailable, + } + } + + m, err := resolve(regionOfARN(task.TaskArn, ""), v.EFSVolumeConfiguration) + if err != nil { + return volumeSpec{}, &taskLaunchError{Reason: fmt.Sprintf("volume %q: %v", v.Name, err), Err: err} + } + + return efsSpec(m), nil +} + +func (r *realDockerRunner) dockerVolumeSpec( + ctx context.Context, + task *Task, + v Volume, + plan *volumePlan, +) (volumeSpec, error) { + cfg := v.DockerVolumeConfig + name := taskVolumeName(task, v.Name) + + if cfg.Scope == volumeScopeShared { + name = v.Name + + if err := r.requireSharedVolume(ctx, cfg, name); err != nil { + return volumeSpec{}, err + } + } else { + plan.taskVols = append(plan.taskVols, name) + } + + vo := &mount.VolumeOptions{Labels: cfg.Labels} + if cfg.Driver != "" || len(cfg.DriverOpts) > 0 { + vo.DriverConfig = &mount.Driver{Name: cfg.Driver, Options: cfg.DriverOpts} + } + + return volumeSpec{mount: mount.Mount{Type: mount.TypeVolume, Source: name, VolumeOptions: vo}}, nil +} + +// requireSharedVolume fails unless a shared volume without autoprovision already exists. +func (r *realDockerRunner) requireSharedVolume(ctx context.Context, cfg *DockerVolumeConfig, name string) error { + if cfg.Autoprovision != nil && *cfg.Autoprovision { + return nil + } + + exists, err := r.cli.VolumeExists(ctx, name) + if err != nil { + return fmt.Errorf("inspect docker volume %q: %w", name, err) + } + + if !exists { + return fmt.Errorf("%w: shared docker volume %q does not exist and autoprovision is false", + errUnsupportedVolume, name) + } + + return nil +} + +// containerMounts builds cd's mounts from its mountPoints. +func containerMounts(cd ContainerDefinition, plan *volumePlan) ([]mount.Mount, error) { + out := make([]mount.Mount, 0, len(cd.MountPoints)) + + for _, mp := range cd.MountPoints { + spec, ok := plan.specs[mp.SourceVolume] + if !ok { + return nil, fmt.Errorf("container %q %w %q", cd.Name, errUndefinedVolume, mp.SourceVolume) + } + + m := spec.mount + m.Target = mp.ContainerPath + m.ReadOnly = mp.ReadOnly + out = append(out, m) + } + + return out, nil +} + +// applyVolumeIdentity runs a container as an EFS access point's POSIX user unless the definition sets a user. +func applyVolumeIdentity( + cfg *dockertypes.Config, + hc *dockertypes.HostConfig, + cd ContainerDefinition, + plan *volumePlan, +) { + if cd.User != "" { + return + } + + for _, mp := range cd.MountPoints { + if spec := plan.specs[mp.SourceVolume]; spec.user != "" { + cfg.User = spec.user + hc.GroupAdd = append(hc.GroupAdd, spec.groups...) + + return + } + } +} diff --git a/services/ecs/efs_volumes.go b/services/ecs/efs_volumes.go new file mode 100644 index 0000000000..9a7ff7b2ad --- /dev/null +++ b/services/ecs/efs_volumes.go @@ -0,0 +1,94 @@ +package ecs + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" +) + +// efsInitFailure is the documented ResourceInitializationError stoppedReason for EFS volume setup +// (stopped-task error codes: "failed to invoke EFS utils commands to set up EFS volumes"). +const efsInitFailure = "ResourceInitializationError: failed to invoke EFS utils commands to set up EFS volumes" + +var errEFSUnavailable = errors.New("EFS integration is not available") + +// EFSVolumeMount is the host directory backing an efsVolumeConfiguration, plus the access point's POSIX identity. +type EFSVolumeMount struct { + HostDir string + SecondaryGIDs []int64 + UID int64 + GID int64 + HasPosixUser bool +} + +// EFSResolver resolves task-definition EFS volumes against services/efs. +type EFSResolver interface { + ResolveVolume(ctx context.Context, region, fsID, accessPointID, rootDirectory string) (EFSVolumeMount, error) +} + +// efsVolumeRunner is implemented by runners that mount EFS volumes. +type efsVolumeRunner interface { + SetEFSVolumeResolver(fn func(region string, cfg *EFSVolumeConfiguration) (EFSVolumeMount, error)) +} + +// SetEFSResolver wires EFS so efsVolumeConfiguration volumes mount at launch. +func (b *InMemoryBackend) SetEFSResolver(r EFSResolver) { + b.mu.Lock("SetEFSResolver") + defer b.mu.Unlock() + + b.efs = r +} + +func (b *InMemoryBackend) efsResolver() EFSResolver { + b.mu.RLock("efsResolver") + defer b.mu.RUnlock() + + return b.efs +} + +func (b *InMemoryBackend) resolveEFSVolume(region string, cfg *EFSVolumeConfiguration) (EFSVolumeMount, error) { + r := b.efsResolver() + if r == nil { + return EFSVolumeMount{}, fmt.Errorf("%s: %w", efsInitFailure, errEFSUnavailable) + } + + ap, root := "", cfg.RootDirectory + if cfg.AuthorizationConfig != nil { + ap = cfg.AuthorizationConfig.AccessPointID + } + + m, err := r.ResolveVolume(context.Background(), region, cfg.FileSystemID, ap, root) + if err != nil { + return EFSVolumeMount{}, fmt.Errorf("%s: %w", efsInitFailure, err) + } + + return m, nil +} + +// efsSpec turns a resolved EFS mount into a bind volumeSpec; the access point's POSIX user becomes the +// default user of every container mounting the volume that sets none. +func efsSpec(m EFSVolumeMount) volumeSpec { + s := volumeSpec{mount: bindMount(m.HostDir)} + + if m.HasPosixUser { + s.user = strconv.FormatInt(m.UID, 10) + ":" + strconv.FormatInt(m.GID, 10) + + for _, g := range m.SecondaryGIDs { + s.groups = append(s.groups, strconv.FormatInt(g, 10)) + } + } + + return s +} + +func regionOfARN(arn, def string) string { + parts := strings.Split(arn, ":") + const regionIdx = 3 + if len(parts) > regionIdx && parts[regionIdx] != "" { + return parts[regionIdx] + } + + return def +} diff --git a/services/ecs/efs_volumes_internal_test.go b/services/ecs/efs_volumes_internal_test.go new file mode 100644 index 0000000000..5ba38a261e --- /dev/null +++ b/services/ecs/efs_volumes_internal_test.go @@ -0,0 +1,139 @@ +package ecs + +import ( + "context" + "errors" + "testing" + + "github.com/moby/moby/api/types/mount" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errTestNoAccessPoint = errors.New("AccessPointNotFound") + +type fakeEFS struct { + err error + got EFSVolumeConfiguration + reg string + mnt EFSVolumeMount + call int +} + +func (f *fakeEFS) ResolveVolume( + _ context.Context, region, fsID, ap, root string, +) (EFSVolumeMount, error) { + f.call++ + f.reg = region + f.got = EFSVolumeConfiguration{ + FileSystemID: fsID, RootDirectory: root, AuthorizationConfig: &EFSAuthorizationConfig{AccessPointID: ap}, + } + + return f.mnt, f.err +} + +func TestRunTask_EFSVolume(t *testing.T) { + t.Parallel() + + posix := EFSVolumeMount{ + HostDir: "/efs/fs-1/ap", + UID: 1234, + GID: 5678, + SecondaryGIDs: []int64{4321}, + HasPosixUser: true, + } + + tests := []struct { + efs *fakeEFS + cfg *EFSVolumeConfiguration + name string + user string + wantUser string + wantReason string + wantMount string + wantGroups []string + noResolver bool + }{ + { + name: "access_point_posix_user", + efs: &fakeEFS{mnt: posix}, + cfg: &EFSVolumeConfiguration{ + FileSystemID: "fs-1", TransitEncryption: "ENABLED", + AuthorizationConfig: &EFSAuthorizationConfig{AccessPointID: "fsap-1", IAM: "ENABLED"}, + }, + wantUser: "1234:5678", wantGroups: []string{"4321"}, wantMount: "/efs/fs-1/ap", + }, + { + name: "container_user_wins", + efs: &fakeEFS{mnt: posix}, + user: "7:7", + cfg: &EFSVolumeConfiguration{ + FileSystemID: "fs-1", AuthorizationConfig: &EFSAuthorizationConfig{AccessPointID: "fsap-1"}, + }, + wantUser: "7:7", wantMount: "/efs/fs-1/ap", + }, + { + name: "root_directory_no_access_point", + efs: &fakeEFS{mnt: EFSVolumeMount{HostDir: "/efs/fs-1/sub"}}, + cfg: &EFSVolumeConfiguration{FileSystemID: "fs-1", RootDirectory: "/sub"}, wantMount: "/efs/fs-1/sub", + }, + { + name: "missing_access_point", + efs: &fakeEFS{err: errTestNoAccessPoint}, + cfg: &EFSVolumeConfiguration{ + FileSystemID: "fs-1", AuthorizationConfig: &EFSAuthorizationConfig{AccessPointID: "fsap-x"}, + }, + wantReason: efsInitFailure, + }, + { + name: "no_efs_integration", noResolver: true, + cfg: &EFSVolumeConfiguration{FileSystemID: "fs-1"}, + wantReason: efsInitFailure, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fake := &fakeDockerClient{} + r := newLaunchRunner(t, fake) + + if !tt.noResolver { + b := NewInMemoryBackend("123456789012", "us-east-1", NewNoopRunner()) + b.SetEFSResolver(tt.efs) + r.SetEFSVolumeResolver(b.resolveEFSVolume) + } + + td := &TaskDefinition{ + Volumes: []Volume{{Name: "shared", EFSVolumeConfiguration: tt.cfg}}, + ContainerDefinitions: []ContainerDefinition{{ + Name: "app", Image: "busybox", Essential: true, User: tt.user, + MountPoints: []MountPoint{{SourceVolume: "shared", ContainerPath: "/mnt/efs"}}, + }}, + } + + err := r.RunTask(launchTask(), td) + if tt.wantReason != "" { + var le *taskLaunchError + + require.ErrorAs(t, err, &le) + assert.Contains(t, le.Reason, tt.wantReason) + assert.Empty(t, fake.createdImages()) + + return + } + + require.NoError(t, err) + + c := fake.createAt(0) + assert.Equal(t, tt.wantUser, c.cfg.User) + assert.Equal(t, tt.wantGroups, c.host.GroupAdd) + assert.Equal(t, "us-east-1", tt.efs.reg) + require.Len(t, c.host.Mounts, 1) + assert.Equal(t, mount.TypeBind, c.host.Mounts[0].Type) + assert.Equal(t, tt.wantMount, c.host.Mounts[0].Source) + assert.Equal(t, "/mnt/efs", c.host.Mounts[0].Target) + }) + } +} diff --git a/services/ecs/errors.go b/services/ecs/errors.go index 484583d1ca..80281931e2 100644 --- a/services/ecs/errors.go +++ b/services/ecs/errors.go @@ -22,6 +22,8 @@ var ( // AWS ECS reports as a ClientException (for example, malformed container // definitions or an unsupported network mode / launch-type combination). ErrClient = awserr.New("ClientException", awserr.ErrInvalidParameter) + // ErrTargetNotConnected is ExecuteCommand's TargetNotConnectedException: the exec agent cannot be reached. + ErrTargetNotConnected = awserr.New("TargetNotConnectedException", awserr.ErrInvalidParameter) // ErrCapacityProviderInUse is returned when deleting a capacity provider // still associated with a cluster. Uses the ErrAlreadyExists kind (like // ErrDaemonAlreadyExists in daemon.go) since handleError only maps diff --git a/services/ecs/events.go b/services/ecs/events.go index 71d999a947..fa8fb9cc12 100644 --- a/services/ecs/events.go +++ b/services/ecs/events.go @@ -149,7 +149,7 @@ func (b *InMemoryBackend) emitTaskStateLocked(task *Task) { LaunchType: task.LaunchType, Connectivity: task.Connectivity, ConnectivityAt: eventTimePtr(task.ConnectivityAt), - CreatedAt: eventTimePtr(task.StartedAt), + CreatedAt: eventTimePtr(task.CreatedAt), StartedBy: task.StartedBy, StoppedAt: eventTimePtr(task.StoppedAt), StoppedReason: task.StoppedReason, @@ -161,7 +161,7 @@ func (b *InMemoryBackend) emitTaskStateLocked(task *Task) { Containers: make([]eventContainer, 0, len(task.Containers)), } - if task.LastStatus != statusProvisioning && task.LastStatus != statusPending { + if task.StartedAt != nil { d.StartedAt = eventTimePtr(task.StartedAt) } diff --git a/services/ecs/events_internal_test.go b/services/ecs/events_internal_test.go index e8354cb209..d6a29817ca 100644 --- a/services/ecs/events_internal_test.go +++ b/services/ecs/events_internal_test.go @@ -106,7 +106,7 @@ func TestTaskStateChangeEvents(t *testing.T) { { name: "failed_launch", runner: failingRunner{}, run: func(*testing.T, *InMemoryBackend, string) {}, - wantStatus: []string{"PROVISIONING", "PENDING", "STOPPED"}, + wantStatus: []string{"PROVISIONING", "PENDING", "ACTIVATING", "STOPPED"}, wantReason: "container start failed: simulated container start failure", }, { @@ -153,6 +153,7 @@ func TestTaskStateChangeEvents(t *testing.T) { tasks, _, err := b.RunTask(RunTaskInput{ Cluster: "ev", TaskDefinition: tdArn, Group: "family:ev-app", StartedBy: "tester", }) + tasks = settleTasks(t, b, tasks) require.NoError(t, err) require.Len(t, tasks, 1) @@ -194,6 +195,7 @@ func TestTaskStateChangeEventStoppedExitCode(t *testing.T) { b, pub, tdArn := newEventBackend(t, NewNoopRunner()) tasks, _, err := b.RunTask(RunTaskInput{Cluster: "ev", TaskDefinition: tdArn}) + tasks = settleTasks(t, b, tasks) require.NoError(t, err) b.markTaskStoppedByContainerExit(tasks[0].TaskArn, "app", 3) @@ -339,6 +341,7 @@ func TestDeploymentFailedEvent(t *testing.T) { _, _, err = b.RunTask(RunTaskInput{Cluster: "ev", TaskDefinition: tdArn, Group: "service:svc", Count: 3}) require.NoError(t, err) + b.awaitLaunches() evs := pub.ofType(detailTypeDeploymentState) assert.Equal(t, []string{eventDeploymentInProgress, eventDeploymentFailed}, eventNames(evs)) @@ -356,6 +359,7 @@ func TestEventsWithoutPublisher(t *testing.T) { require.NoError(t, err) tasks, _, err := b.RunTask(RunTaskInput{Cluster: "np", TaskDefinition: tdArn}) + tasks = settleTasks(t, b, tasks) require.NoError(t, err) assert.Equal(t, statusRunning, tasks[0].LastStatus) } diff --git a/services/ecs/exec.go b/services/ecs/exec.go new file mode 100644 index 0000000000..5417a206ad --- /dev/null +++ b/services/ecs/exec.go @@ -0,0 +1,185 @@ +package ecs + +import ( + "context" + "fmt" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" +) + +const execNotEnabledMessage = "The execute command failed because execute command was not enabled when the task " + + "was run or the execute command agent isn't running. Wait 30 seconds and try again or run a new task " + + "using the enable-execute-command option." + +type streamBaseKey struct{} + +// WithStreamBase returns ctx carrying the websocket base URL ("wss://host") of the server handling the request. +func WithStreamBase(ctx context.Context, base string) context.Context { + return context.WithValue(ctx, streamBaseKey{}, base) +} + +// ExecSessionRequest asks a Session Manager data channel to run Command in a task container. +type ExecSessionRequest struct { + Region string + Target string + ContainerID string + Command string + StreamBase string +} + +// ExecSessionGrant is the stream a client attaches to. +type ExecSessionGrant struct { + SessionID string + StreamURL string + TokenValue string +} + +// ExecSessionStarter opens ECS Exec sessions; wired to the SSM Session Manager data channel. +type ExecSessionStarter interface { + StartExecSession(ctx context.Context, req ExecSessionRequest) (ExecSessionGrant, error) +} + +// SetExecSessionStarter wires the Session Manager backend ExecuteCommand streams through. +func (b *InMemoryBackend) SetExecSessionStarter(s ExecSessionStarter) { + b.mu.Lock("SetExecSessionStarter") + defer b.mu.Unlock() + + b.execSessions = s +} + +// ExecuteCommand runs command in a container of a RUNNING task that has execute command enabled. +func (b *InMemoryBackend) ExecuteCommand( + cluster, task, container, command string, + interactive bool, +) (*ExecuteCommandOutput, error) { + return b.ExecuteCommandContext(context.Background(), cluster, task, container, command, interactive) +} + +// ExecuteCommandContext is ExecuteCommand with ctx carrying the request's stream base (see WithStreamBase). +func (b *InMemoryBackend) ExecuteCommandContext( + ctx context.Context, + cluster, task, container, command string, + interactive bool, +) (*ExecuteCommandOutput, error) { + if task == "" { + return nil, fmt.Errorf("%w: task is required", ErrInvalidParameter) + } + + if command == "" { + return nil, fmt.Errorf("%w: command is required", ErrInvalidParameter) + } + + if !interactive { + return nil, fmt.Errorf( + "%w: Amazon ECS only supports interactive sessions, so you must specify true for interactive", + ErrInvalidParameter, + ) + } + + clusterName := clusterKey(b.resolveCluster(cluster)) + + target, out, starter, err := b.prepareExec(clusterName, cluster, task, container) + if err != nil { + return nil, err + } + + if starter == nil { + return nil, fmt.Errorf( + "%w: the execute command agent for %s cannot be reached; ECS Exec needs the docker task runtime "+ + "and the SSM Session Manager service", + ErrTargetNotConnected, container, + ) + } + + base, _ := ctx.Value(streamBaseKey{}).(string) + + grant, err := starter.StartExecSession(ctx, ExecSessionRequest{ + Region: b.region, Target: target.name, ContainerID: target.runtimeID, Command: command, StreamBase: base, + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrTargetNotConnected, err) + } + + out.Interactive = true + out.Session = Session(grant) + + return out, nil +} + +type execTarget struct { + name string + runtimeID string +} + +// prepareExec validates the task and container and resolves the runtime container. starter is nil when the task +// is not on a runtime that can exec. +func (b *InMemoryBackend) prepareExec( + clusterName, cluster, task, container string, +) (execTarget, *ExecuteCommandOutput, ExecSessionStarter, error) { + b.mu.RLock("ExecuteCommand") + defer b.mu.RUnlock() + + if !b.clusters.Has(clusterName) { + return execTarget{}, nil, nil, fmt.Errorf("%w: %s", ErrClusterNotFound, cluster) + } + + taskArn := task + if !strings.HasPrefix(task, "arn:") { + taskArn = arn.Build("ecs", b.region, b.accountID, fmt.Sprintf("task/%s/%s", clusterName, task)) + } + + t, ok := b.tasks.Get(taskArn) + if !ok || clusterKey(t.ClusterArn) != clusterName { + return execTarget{}, nil, nil, fmt.Errorf("%w: task %s not found", ErrInvalidParameter, task) + } + + if t.LastStatus != statusRunning { + return execTarget{}, nil, nil, fmt.Errorf("%w: task %s is not in RUNNING state", ErrInvalidParameter, task) + } + + if !t.EnableExecuteCommand { + return execTarget{}, nil, nil, fmt.Errorf("%w: %s", ErrInvalidParameter, execNotEnabledMessage) + } + + c, err := execContainer(t, container) + if err != nil { + return execTarget{}, nil, nil, err + } + + clusterObj, _ := b.clusters.Get(clusterName) + out := &ExecuteCommandOutput{ + ClusterArn: clusterObj.ClusterArn, ContainerArn: c.ContainerArn, ContainerName: c.Name, TaskArn: t.TaskArn, + } + + _, dockerRuntime := b.runner.(interface{ ContainerIDs(taskArn string) []string }) + if !dockerRuntime || b.execSessions == nil || c.RuntimeID == "" { + return execTarget{}, out, nil, nil + } + + name := fmt.Sprintf("ecs:%s_%s_%s", clusterName, taskIDFromARN(t.TaskArn), c.RuntimeID) + + return execTarget{name: name, runtimeID: c.RuntimeID}, out, b.execSessions, nil +} + +// execContainer picks the container to exec in: the named one, or the only one when the name is omitted. +func execContainer(t *Task, name string) (Container, error) { + if name == "" { + if len(t.Containers) != 1 { + return Container{}, fmt.Errorf( + "%w: the container name must be specified for a task with %d containers", + ErrInvalidParameter, len(t.Containers), + ) + } + + return t.Containers[0], nil + } + + for _, c := range t.Containers { + if c.Name == name { + return c, nil + } + } + + return Container{}, fmt.Errorf("%w: container %s not found in task %s", ErrInvalidParameter, name, t.TaskArn) +} diff --git a/services/ecs/exec_honesty_internal_test.go b/services/ecs/exec_honesty_internal_test.go deleted file mode 100644 index da1d722028..0000000000 --- a/services/ecs/exec_honesty_internal_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package ecs - -import ( - "strings" - "testing" -) - -// TestExecuteCommand_StreamURLIsHonest asserts the ExecuteCommand stream URL does -// not masquerade as a real, connectable AWS SSM endpoint. gopherstack has no SSM -// data channel, so the URL must use the reserved non-resolvable ".invalid" TLD -// rather than ssmmessages..amazonaws.com. -func TestExecuteCommand_StreamURLIsHonest(t *testing.T) { - t.Parallel() - - b := newTestBackend() - - tdArn := registerSimpleTaskDef(t, b, "exec-app", "nginx") - if _, err := b.CreateCluster(CreateClusterInput{ClusterName: "ec"}); err != nil { - t.Fatalf("CreateCluster: %v", err) - } - - tasks, _, err := b.RunTask(RunTaskInput{ - Cluster: "ec", - TaskDefinition: tdArn, - Count: 1, - EnableExecuteCommand: true, - }) - if err != nil { - t.Fatalf("RunTask: %v", err) - } - - out, err := b.ExecuteCommand("ec", tasks[0].TaskArn, "app", "/bin/sh", true) - if err != nil { - t.Fatalf("ExecuteCommand: %v", err) - } - - tests := []struct { - name string - substr string - wantFound bool - }{ - {name: "not a real ssmmessages host", substr: "amazonaws.com", wantFound: false}, - {name: "uses non-resolvable invalid TLD", substr: ".invalid", wantFound: true}, - {name: "carries the session id", substr: out.Session.SessionID, wantFound: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := strings.Contains(out.Session.StreamURL, tt.substr) - if got != tt.wantFound { - t.Errorf("StreamURL %q contains %q = %v, want %v", - out.Session.StreamURL, tt.substr, got, tt.wantFound) - } - }) - } - - if out.Session.TokenValue == "" { - t.Error("TokenValue must not be empty") - } -} diff --git a/services/ecs/exec_internal_test.go b/services/ecs/exec_internal_test.go new file mode 100644 index 0000000000..0c5addd56b --- /dev/null +++ b/services/ecs/exec_internal_test.go @@ -0,0 +1,146 @@ +package ecs + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errSessionBackend = errors.New("session backend down") + +// idRunner launches instantly and reports one docker ID per container, like the docker runner. +type idRunner struct{} + +func (idRunner) RunTask(*Task, *TaskDefinition) error { return nil } +func (idRunner) StopTask(*Task) error { return nil } +func (idRunner) ContainerIDs(taskArn string) []string { + return []string{"dockerid-" + taskIDFromARN(taskArn) + "-0", "dockerid-" + taskIDFromARN(taskArn) + "-1"} +} + +type recordingStarter struct { + err error + got []ExecSessionRequest +} + +func (r *recordingStarter) StartExecSession(_ context.Context, req ExecSessionRequest) (ExecSessionGrant, error) { + r.got = append(r.got, req) + if r.err != nil { + return ExecSessionGrant{}, r.err + } + + return ExecSessionGrant{ + SessionID: "ecs-execute-command-1", StreamURL: req.StreamBase + "/s", TokenValue: "tok", + }, nil +} + +func TestExecuteCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + starter *recordingStarter + runner TaskRunner + wantErr error + name string + container string + command string + wantContainer string + wantRuntimeID string + enableExec bool + noInteractive bool + noStarter bool + }{ + { + name: "session", starter: &recordingStarter{}, runner: idRunner{}, enableExec: true, + container: "web", command: "/bin/sh", wantContainer: "web", wantRuntimeID: "1", + }, + { + name: "default_container_not_allowed_with_two", starter: &recordingStarter{}, runner: idRunner{}, + enableExec: true, command: "ls", wantErr: ErrInvalidParameter, + }, + { + name: "unknown_container", starter: &recordingStarter{}, runner: idRunner{}, enableExec: true, + container: "nope", command: "ls", wantErr: ErrInvalidParameter, + }, + { + name: "not_interactive", starter: &recordingStarter{}, runner: idRunner{}, enableExec: true, + container: "web", command: "ls", noInteractive: true, wantErr: ErrInvalidParameter, + }, + { + name: "exec_not_enabled", starter: &recordingStarter{}, runner: idRunner{}, + container: "web", command: "ls", wantErr: ErrInvalidParameter, + }, + { + name: "missing_command", starter: &recordingStarter{}, runner: idRunner{}, enableExec: true, + container: "web", wantErr: ErrInvalidParameter, + }, + { + name: "no_session_backend", runner: idRunner{}, noStarter: true, enableExec: true, + container: "web", command: "ls", wantErr: ErrTargetNotConnected, + }, + { + name: "runtime_cannot_exec", starter: &recordingStarter{}, runner: NewNoopRunner(), enableExec: true, + container: "web", command: "ls", wantErr: ErrTargetNotConnected, + }, + { + name: "session_backend_fails", starter: &recordingStarter{err: errSessionBackend}, runner: idRunner{}, + enableExec: true, container: "web", command: "ls", wantErr: ErrTargetNotConnected, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := NewInMemoryBackend("123456789012", "us-east-1", tt.runner) + if !tt.noStarter { + b.SetExecSessionStarter(tt.starter) + } + + _, err := b.CreateCluster(CreateClusterInput{ClusterName: "x"}) + require.NoError(t, err) + + td, err := b.RegisterTaskDefinition(RegisterTaskDefinitionInput{ + Family: "x", + ContainerDefinitions: []ContainerDefinition{ + {Name: "sidecar", Image: "busybox"}, {Name: "web", Image: "nginx"}, + }, + }) + require.NoError(t, err) + + tasks, _, err := b.RunTask(RunTaskInput{ + Cluster: "x", TaskDefinition: td.TaskDefinitionArn, EnableExecuteCommand: tt.enableExec, + }) + require.NoError(t, err) + + tasks = settleTasks(t, b, tasks) + require.Equal(t, statusRunning, tasks[0].LastStatus) + + ctx := WithStreamBase(t.Context(), "ws://emu:4566") + out, err := b.ExecuteCommandContext(ctx, "x", tasks[0].TaskArn, tt.container, tt.command, !tt.noInteractive) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + assert.Nil(t, out) + + return + } + + require.NoError(t, err) + assert.True(t, out.Interactive) + assert.Equal(t, tt.wantContainer, out.ContainerName) + assert.Equal(t, tasks[0].Containers[1].ContainerArn, out.ContainerArn) + assert.Equal(t, "ecs-execute-command-1", out.Session.SessionID) + assert.Equal(t, "ws://emu:4566/s", out.Session.StreamURL) + + require.Len(t, tt.starter.got, 1) + req := tt.starter.got[0] + assert.Equal(t, "dockerid-"+taskIDFromARN(tasks[0].TaskArn)+"-"+tt.wantRuntimeID, req.ContainerID) + assert.Equal(t, "/bin/sh", req.Command) + assert.Equal(t, "us-east-1", req.Region) + assert.Equal(t, "ecs:x_"+taskIDFromARN(tasks[0].TaskArn)+"_"+req.ContainerID, req.Target) + }) + } +} diff --git a/services/ecs/exec_sdk_test.go b/services/ecs/exec_sdk_test.go new file mode 100644 index 0000000000..8cf1cd1e08 --- /dev/null +++ b/services/ecs/exec_sdk_test.go @@ -0,0 +1,64 @@ +package ecs_test + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + ecssdk "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/stretchr/testify/require" +) + +func TestExecuteCommand_Errors_RealClient(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr any + name string + interactive bool + enableExec bool + }{ + {name: "not_interactive", enableExec: true, wantErr: new(*ecstypes.InvalidParameterException)}, + {name: "exec_not_enabled", interactive: true, wantErr: new(*ecstypes.InvalidParameterException)}, + { + name: "agent_not_connected", interactive: true, enableExec: true, + wantErr: new(*ecstypes.TargetNotConnectedException), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newTestHandler(t) + client := newTestECSClient(t, h) + ctx := t.Context() + + _, err := client.RegisterTaskDefinition(ctx, &ecssdk.RegisterTaskDefinitionInput{ + Family: aws.String("x"), + ContainerDefinitions: []ecstypes.ContainerDefinition{ + {Name: aws.String("app"), Image: aws.String("nginx")}, + }, + }) + require.NoError(t, err) + + run, err := client.RunTask(ctx, &ecssdk.RunTaskInput{ + TaskDefinition: aws.String("x"), EnableExecuteCommand: tt.enableExec, + }) + require.NoError(t, err) + + require.Eventually(t, func() bool { + d, derr := client.DescribeTasks(ctx, &ecssdk.DescribeTasksInput{Tasks: []string{*run.Tasks[0].TaskArn}}) + + return derr == nil && len(d.Tasks) == 1 && aws.ToString(d.Tasks[0].LastStatus) == "RUNNING" + }, 5*time.Second, 5*time.Millisecond) + + _, err = client.ExecuteCommand(ctx, &ecssdk.ExecuteCommandInput{ + Task: run.Tasks[0].TaskArn, Command: aws.String("/bin/sh"), Interactive: tt.interactive, + }) + require.Error(t, err) + require.ErrorAs(t, err, tt.wantErr) + }) + } +} diff --git a/services/ecs/handler.go b/services/ecs/handler.go index 42ce83029e..b5392ba8e1 100644 --- a/services/ecs/handler.go +++ b/services/ecs/handler.go @@ -225,6 +225,8 @@ func (h *Handler) Handler() echo.HandlerFunc { return p.Handler()(c) } + c.SetRequest(c.Request().WithContext(WithStreamBase(c.Request().Context(), requestStreamBase(c.Request())))) + return service.HandleTarget( c, logger.Load(c.Request().Context()), "ECS", "application/x-amz-json-1.1", @@ -463,3 +465,16 @@ func applyNextTokenSlice(items []string, nextToken string, maxResults int) ([]st return p.Data, p.Next } + +// requestStreamBase is the websocket base URL ("ws://host") a client reaches this server at. +func requestStreamBase(r *http.Request) string { + if r.Host == "" { + return "" + } + + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + return "wss://" + r.Host + } + + return "ws://" + r.Host +} diff --git a/services/ecs/handler_task_exec_test.go b/services/ecs/handler_task_exec_test.go index 8b57f057b0..24ab8b04ec 100644 --- a/services/ecs/handler_task_exec_test.go +++ b/services/ecs/handler_task_exec_test.go @@ -268,6 +268,7 @@ func TestECS_ListTasks_TokenChaining(t *testing.T) { } } +// TestECSExec_Basic: with no Session Manager backend wired the agent is unreachable, never a fabricated stream. func TestECSExec_Basic(t *testing.T) { t.Parallel() @@ -298,13 +299,8 @@ func TestECSExec_Basic(t *testing.T) { "command": "/bin/bash", "interactive": true, }) - require.Equal(t, http.StatusOK, execResp.Code) - - var execOut map[string]any - require.NoError(t, json.Unmarshal(execResp.Body.Bytes(), &execOut)) - session := execOut["session"].(map[string]any) - assert.NotEmpty(t, session["sessionId"]) - assert.Equal(t, true, execOut["interactive"]) + require.Equal(t, http.StatusBadRequest, execResp.Code) + assert.Contains(t, execResp.Body.String(), "TargetNotConnectedException") } func TestECSExec_NonInteractive(t *testing.T) { @@ -334,11 +330,9 @@ func TestECSExec_NonInteractive(t *testing.T) { "command": "ls /tmp", "interactive": false, }) - require.Equal(t, http.StatusOK, execResp.Code) - - var execOut map[string]any - require.NoError(t, json.Unmarshal(execResp.Body.Bytes(), &execOut)) - assert.Equal(t, false, execOut["interactive"]) + require.Equal(t, http.StatusBadRequest, execResp.Code) + assert.Contains(t, execResp.Body.String(), "InvalidParameterException") + assert.Contains(t, execResp.Body.String(), "interactive") } func TestTaskProtection_Enable_Disable(t *testing.T) { @@ -443,7 +437,7 @@ func TestECS_ExecuteCommand(t *testing.T) { wantCode int }{ { - name: "execute command on running task", + name: "agent not connected without a session backend", setup: func(h *ecs.Handler) map[string]any { tdArn := registerTestTaskDef(t, h, "exec-task") rec := doECSRequest( @@ -464,12 +458,13 @@ func TestECS_ExecuteCommand(t *testing.T) { taskArn := resp["tasks"].([]any)[0].(map[string]any)["taskArn"].(string) return map[string]any{ - "task": taskArn, - "command": "/bin/sh", - "container": "app", + "task": taskArn, + "command": "/bin/sh", + "container": "app", + "interactive": true, } }, - wantCode: http.StatusOK, + wantCode: http.StatusBadRequest, }, { name: "missing task", @@ -893,6 +888,7 @@ func TestExecuteCommand_RequiresFlag(t *testing.T) { tests := []struct { name string + wantType string enableExec bool wantStatus int }{ @@ -900,11 +896,13 @@ func TestExecuteCommand_RequiresFlag(t *testing.T) { name: "exec disabled — rejected", enableExec: false, wantStatus: http.StatusBadRequest, + wantType: "InvalidParameterException", }, { - name: "exec enabled — accepted", + name: "exec enabled — agent unreachable without a session backend", enableExec: true, - wantStatus: http.StatusOK, + wantStatus: http.StatusBadRequest, + wantType: "TargetNotConnectedException", }, } @@ -930,10 +928,12 @@ func TestExecuteCommand_RequiresFlag(t *testing.T) { taskArn := runOut["tasks"].([]any)[0].(map[string]any)["taskArn"].(string) execResp := doECSRequest(t, h, "ExecuteCommand", map[string]any{ - "task": taskArn, - "command": "/bin/sh", + "task": taskArn, + "command": "/bin/sh", + "interactive": true, }) assert.Equal(t, tc.wantStatus, execResp.Code) + assert.Contains(t, execResp.Body.String(), tc.wantType) }) } } diff --git a/services/ecs/handler_tasks.go b/services/ecs/handler_tasks.go index b6aa27ef76..d9f706d65e 100644 --- a/services/ecs/handler_tasks.go +++ b/services/ecs/handler_tasks.go @@ -355,7 +355,7 @@ type executeCommandInput struct { Task string `json:"task"` Container string `json:"container,omitempty"` Command string `json:"command"` - Interactive bool `json:"interactive,omitempty"` + Interactive bool `json:"interactive"` } type executeCommandOutput struct { @@ -374,16 +374,23 @@ type session struct { } func (h *Handler) handleExecuteCommand( - _ context.Context, + ctx context.Context, in *executeCommandInput, ) (*executeCommandOutput, error) { - out, err := h.Backend.ExecuteCommand( - in.Cluster, - in.Task, - in.Container, - in.Command, - in.Interactive, + var ( + out *ExecuteCommandOutput + err error ) + + if bk, ok := h.Backend.(interface { + ExecuteCommandContext(ctx context.Context, cluster, task, container, command string, interactive bool, + ) (*ExecuteCommandOutput, error) + }); ok { + out, err = bk.ExecuteCommandContext(ctx, in.Cluster, in.Task, in.Container, in.Command, in.Interactive) + } else { + out, err = h.Backend.ExecuteCommand(in.Cluster, in.Task, in.Container, in.Command, in.Interactive) + } + if err != nil { return nil, err } @@ -478,6 +485,8 @@ type taskView struct { DesiredStatus string `json:"desiredStatus"` Connectivity string `json:"connectivity,omitempty"` StoppedReason string `json:"stoppedReason,omitempty"` + StopCode string `json:"stopCode,omitempty"` + HealthStatus string `json:"healthStatus,omitempty"` Group string `json:"group,omitempty"` LaunchType string `json:"launchType,omitempty"` ContainerInstanceArn string `json:"containerInstanceArn,omitempty"` @@ -491,6 +500,9 @@ type taskView struct { StartedAt float64 `json:"startedAt,omitempty"` StoppedAt float64 `json:"stoppedAt,omitempty"` ConnectivityAt float64 `json:"connectivityAt,omitempty"` + CreatedAt float64 `json:"createdAt,omitempty"` + PullStartedAt float64 `json:"pullStartedAt,omitempty"` + PullStoppedAt float64 `json:"pullStoppedAt,omitempty"` EnableExecuteCommand bool `json:"enableExecuteCommand,omitempty"` } @@ -503,6 +515,8 @@ func toTaskView(t Task) taskView { DesiredStatus: t.DesiredStatus, Connectivity: t.Connectivity, StoppedReason: t.StoppedReason, + StopCode: t.StopCode, + HealthStatus: t.HealthStatus, Group: t.Group, LaunchType: t.LaunchType, ContainerInstanceArn: t.ContainerInstanceArn, @@ -569,6 +583,18 @@ func toTaskView(t Task) taskView { v.ConnectivityAt = float64(t.ConnectivityAt.Unix()) } + if t.CreatedAt != nil { + v.CreatedAt = float64(t.CreatedAt.Unix()) + } + + if t.PullStartedAt != nil { + v.PullStartedAt = float64(t.PullStartedAt.Unix()) + } + + if t.PullStoppedAt != nil { + v.PullStoppedAt = float64(t.PullStoppedAt.Unix()) + } + return v } diff --git a/services/ecs/handler_tasks_test.go b/services/ecs/handler_tasks_test.go index 1b7fee1aac..190ee4b97d 100644 --- a/services/ecs/handler_tasks_test.go +++ b/services/ecs/handler_tasks_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -239,8 +240,17 @@ func TestECS_Backend_RunTask_ProvisioningStaysOnRunnerError(t *testing.T) { require.NoError(t, err) require.Len(t, tasks, 1) - // Task must transition to STOPPED when the runner fails rather than staying - // in PROVISIONING forever, which would be a resource leak with wrong semantics. + // The launch runs asynchronously; the task must reach STOPPED rather than stay in PROVISIONING forever. + require.Eventually(t, func() bool { + got, _, descErr := backend.DescribeTasks("", []string{tasks[0].TaskArn}) + + return descErr == nil && len(got) == 1 && got[0].LastStatus == "STOPPED" + }, 5*time.Second, 5*time.Millisecond) + + got, _, err := backend.DescribeTasks("", []string{tasks[0].TaskArn}) + require.NoError(t, err) + + tasks = got assert.Equal(t, "STOPPED", tasks[0].LastStatus) assert.Equal(t, "STOPPED", tasks[0].DesiredStatus) assert.NotNil(t, tasks[0].StoppedAt) diff --git a/services/ecs/iam_enforcement_test.go b/services/ecs/iam_enforcement_test.go index 9e3fe570b5..8ab28ce8fa 100644 --- a/services/ecs/iam_enforcement_test.go +++ b/services/ecs/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/ecs" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ecs/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ecs/launch_async.go b/services/ecs/launch_async.go new file mode 100644 index 0000000000..f6a943aa9e --- /dev/null +++ b/services/ecs/launch_async.go @@ -0,0 +1,321 @@ +package ecs + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +// contextRunner is implemented by runners whose launch can be cancelled through a context. +type contextRunner interface { + RunTaskContext(ctx context.Context, task *Task, td *TaskDefinition) error +} + +// launchTracker counts in-flight launch goroutines. idle is non-nil while n > 0 and closes when n returns to 0. +// All fields are guarded by InMemoryBackend.mu. +type launchTracker struct { + cancels map[string]context.CancelFunc + tasks map[string]*Task + idle chan struct{} + n int + closed bool +} + +func isLaunchingStatus(status string) bool { + return status == statusProvisioning || status == statusPending || status == statusActivating +} + +func (b *InMemoryBackend) clock() time.Time { + if b.nowFn != nil { + return b.nowFn() + } + + return time.Now() +} + +// markRunningLocked moves task to RUNNING and stamps startedAt and connectivityAt. +func (b *InMemoryBackend) markRunningLocked(task *Task, now time.Time) { + task.LastStatus = statusRunning + task.StartedAt = &now + task.Connectivity = connectivityConnected + task.ConnectivityAt = &now + syncContainerStatuses(task, nil) +} + +// launchAsync starts w's launch goroutine; the task stays PROVISIONING until it picks the task up. +func (b *InMemoryBackend) launchAsync(w taskWork, clusterName string) { + ctx, cancel := context.WithCancel(context.Background()) + + b.mu.Lock("launchAsync") + + lt := &b.launches + if lt.closed { + b.mu.Unlock() + cancel() + + return + } + + if lt.cancels == nil { + lt.cancels = make(map[string]context.CancelFunc) + lt.tasks = make(map[string]*Task) + } + + if lt.n == 0 { + lt.idle = make(chan struct{}) + } + + lt.n++ + lt.cancels[w.task.TaskArn] = cancel + lt.tasks[w.task.TaskArn] = w.task + b.mu.Unlock() + + go func() { + defer b.finishLaunch(w.task.TaskArn) + defer cancel() + + b.driveLaunch(ctx, w, clusterName) + }() +} + +func (b *InMemoryBackend) finishLaunch(taskArn string) { + b.mu.Lock("finishLaunch") + defer b.mu.Unlock() + + lt := &b.launches + delete(lt.cancels, taskArn) + delete(lt.tasks, taskArn) + lt.n-- + + if lt.n == 0 { + close(lt.idle) + } +} + +// cancelLaunchLocked cancels taskArn's in-flight launch, if any. +func (b *InMemoryBackend) cancelLaunchLocked(taskArn string) { + if cancel := b.launches.cancels[taskArn]; cancel != nil { + cancel() + } +} + +// cancelLaunches cancels every in-flight launch and returns their tasks and the channel closing when all end. +func (b *InMemoryBackend) cancelLaunches(closing bool) ([]*Task, <-chan struct{}) { + b.mu.Lock("cancelLaunches") + defer b.mu.Unlock() + + lt := &b.launches + if closing { + lt.closed = true + } + + pending := make([]*Task, 0, len(lt.tasks)) + + for arn, cancel := range lt.cancels { + cancel() + + pending = append(pending, lt.tasks[arn]) + } + + if lt.n == 0 { + return pending, nil + } + + return pending, lt.idle +} + +// Close cancels in-flight task launches and waits for them to finish; later launches are ignored. +func (b *InMemoryBackend) Close() { + pending, idle := b.cancelLaunches(true) + if idle == nil { + return + } + + if b.runner != nil { + for _, t := range pending { + _ = b.runner.StopTask(t) + } + } + + <-idle +} + +// stepLaunch moves a launching task from one status to the next. It reports false when the task was stopped, +// reset away or its launch cancelled in the meantime. +func (b *InMemoryBackend) stepLaunch( + ctx context.Context, task *Task, from, to string, mutate func(now time.Time), +) bool { + b.mu.Lock("stepLaunch") + defer b.mu.Unlock() + + if ctx.Err() != nil || task.LastStatus != from || !b.isLiveTaskLocked(task) { + return false + } + + task.LastStatus = to + if mutate != nil { + mutate(b.clock()) + } + + b.emitTaskStateLocked(task) + + return true +} + +func (b *InMemoryBackend) isLiveTaskLocked(task *Task) bool { + cur, ok := b.tasks.Get(task.TaskArn) + + return ok && cur == task +} + +// driveLaunch walks one task PROVISIONING, PENDING, ACTIVATING, RUNNING (or STOPPED on failure). +func (b *InMemoryBackend) driveLaunch(ctx context.Context, w taskWork, clusterName string) { + task := w.task + + if !b.stepLaunch(ctx, task, statusProvisioning, statusPending, nil) { + b.settleCancelled(ctx, task, clusterName) + + return + } + + b.mu.Lock("driveLaunch-runtime") + _, task.runtimeHealth = b.runner.(containerHealthRunner) + b.mu.Unlock() + + activated := b.stepLaunch(ctx, task, statusPending, statusActivating, func(now time.Time) { + task.PullStartedAt = &now + }) + if !activated { + b.settleCancelled(ctx, task, clusterName) + + return + } + + var runErr error + if cr, ok := b.runner.(contextRunner); ok { + runErr = cr.RunTaskContext(ctx, task, w.td) + } else { + runErr = b.runner.RunTask(task, w.td) + } + + b.completeLaunch(ctx, w, clusterName, runErr) +} + +func (b *InMemoryBackend) completeLaunch(ctx context.Context, w taskWork, clusterName string, runErr error) { + task := w.task + superseded := false + + func() { + b.mu.Lock("completeLaunch") + defer b.mu.Unlock() + + if task.LastStatus != statusActivating || !b.isLiveTaskLocked(task) { + superseded = true + + return + } + + if ctx.Err() != nil { + superseded = true + b.stopCancelledLaunchLocked(task, clusterName) + + return + } + + now := b.clock() + task.PullStoppedAt = &now + + if runErr == nil { + b.applyRuntimeContainerIDs(task) + b.markRunningLocked(task, now) + b.emitTaskStateLocked(task) + + if c, _ := b.clusters.Get(clusterName); c != nil { + c.PendingTasksCount-- + c.RunningTasksCount++ + } + + b.registerTaskWithELBv2Locked(task, clusterName) + b.registerTaskWithCloudMapLocked(task, clusterName) + + return + } + + b.failLaunchLocked(task, clusterName, runErr, now) + }() + + if superseded && runErr == nil && b.runner != nil { + if err := b.runner.StopTask(task); err != nil { + logger.Load(ctx).WarnContext(ctx, "failed to clean up superseded task launch", + "task", task.TaskArn, "error", err) + } + } +} + +// failLaunchLocked marks task STOPPED/TaskFailedToStart after its runner failed. +func (b *InMemoryBackend) failLaunchLocked(task *Task, clusterName string, runErr error, now time.Time) { + task.LastStatus = statusStopped + task.DesiredStatus = statusStopped + task.StoppedAt = &now + task.StoppedReason = fmt.Sprintf("container start failed: %v", runErr) + task.StopCode = stopCodeTaskFailedToStart + + if launchErr, ok := errors.AsType[*taskLaunchError](runErr); ok { + task.StoppedReason = launchErr.Reason + } + + exitCode := 1 + syncContainerStatuses(task, &exitCode) + b.emitTaskStateLocked(task) + + if c, _ := b.clusters.Get(clusterName); c != nil { + c.PendingTasksCount-- + } + + b.recordServiceTaskFailureLocked(clusterName, task) +} + +// awaitLaunches blocks until no launch goroutine is in flight. +func (b *InMemoryBackend) awaitLaunches() { + b.mu.RLock("awaitLaunches") + idle := b.launches.idle + busy := b.launches.n > 0 + b.mu.RUnlock() + + if busy { + <-idle + } +} + +// stopCancelledLaunchLocked stops a task whose launch was cancelled by backend shutdown rather than by StopTask. +func (b *InMemoryBackend) stopCancelledLaunchLocked(task *Task, clusterName string) { + now := b.clock() + task.LastStatus = statusStopped + task.DesiredStatus = statusStopped + task.StoppedAt = &now + task.StopCode = stopCodeTaskFailedToStart + task.StoppedReason = "Task launch cancelled: the backend is shutting down" + syncContainerStatuses(task, nil) + b.emitTaskStateLocked(task) + + if c, _ := b.clusters.Get(clusterName); c != nil { + c.PendingTasksCount-- + } +} + +// settleCancelled stops a task whose launch was cancelled before reaching the runtime, if nothing else did. +func (b *InMemoryBackend) settleCancelled(ctx context.Context, task *Task, clusterName string) { + if ctx.Err() == nil { + return + } + + b.mu.Lock("settleCancelled") + defer b.mu.Unlock() + + if b.isLiveTaskLocked(task) && isLaunchingStatus(task.LastStatus) { + b.stopCancelledLaunchLocked(task, clusterName) + } +} diff --git a/services/ecs/launch_async_internal_test.go b/services/ecs/launch_async_internal_test.go new file mode 100644 index 0000000000..47c49ddfa0 --- /dev/null +++ b/services/ecs/launch_async_internal_test.go @@ -0,0 +1,263 @@ +package ecs + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// settleTasks waits for every launch to finish and returns the tasks' current state. +func settleTasks(t *testing.T, b *InMemoryBackend, tasks []Task) []Task { + t.Helper() + b.awaitLaunches() + + out := make([]Task, 0, len(tasks)) + + for _, task := range tasks { + got, _, err := b.DescribeTasks(clusterKey(clusterFromTaskARN(task.TaskArn)), []string{task.TaskArn}) + require.NoError(t, err) + require.Len(t, got, 1) + + out = append(out, got[0]) + } + + return out +} + +// gateRunner is a TaskRunner whose launch blocks on gate, so tests drive the async lifecycle deterministically. +type gateRunner struct { + err error + gate chan struct{} + entered chan struct{} + stopped []string + mu sync.Mutex +} + +func newGateRunner(err error) *gateRunner { + return &gateRunner{err: err, gate: make(chan struct{}), entered: make(chan struct{}, 16)} +} + +func (g *gateRunner) RunTask(task *Task, td *TaskDefinition) error { + return g.RunTaskContext(context.Background(), task, td) +} + +func (g *gateRunner) RunTaskContext(ctx context.Context, _ *Task, _ *TaskDefinition) error { + g.entered <- struct{}{} + + select { + case <-g.gate: + return g.err + case <-ctx.Done(): + return fmt.Errorf("launch cancelled: %w", ctx.Err()) + } +} + +func (g *gateRunner) StopTask(task *Task) error { + g.mu.Lock() + defer g.mu.Unlock() + + g.stopped = append(g.stopped, task.TaskArn) + + return nil +} + +func (g *gateRunner) open() { close(g.gate) } + +func (g *gateRunner) awaitEntered(t *testing.T) { + t.Helper() + + select { + case <-g.entered: + case <-time.After(5 * time.Second): + t.Fatal("launch never reached the runner") + } +} + +// runTaskPromptly fails the test when RunTask blocks on the launch instead of returning. +func runTaskPromptly(t *testing.T, b *InMemoryBackend, in RunTaskInput) []Task { + t.Helper() + + var ( + tasks []Task + err error + ) + + done := make(chan struct{}) + + go func() { + defer close(done) + + tasks, _, err = b.RunTask(in) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("RunTask blocked on the launch") + } + + require.NoError(t, err) + require.Len(t, tasks, 1) + + return tasks +} + +func describeTask(t *testing.T, b *InMemoryBackend, arn string) Task { + t.Helper() + + got, _, err := b.DescribeTasks("ev", []string{arn}) + require.NoError(t, err) + require.Len(t, got, 1) + + return got[0] +} + +func TestAsyncLaunch_Lifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + runErr error + name string + wantStatus string + wantStop string + wantReason string + }{ + {name: "gates_pass", wantStatus: statusRunning}, + { + name: "gate_failure", + runErr: &taskLaunchError{Reason: "Task failed to start: dependency not satisfied"}, + wantStatus: statusStopped, + wantStop: stopCodeTaskFailedToStart, + wantReason: "Task failed to start: dependency not satisfied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + g := newGateRunner(tt.runErr) + b, _, tdArn := newEventBackend(t, g) + t.Cleanup(b.Close) + t.Cleanup(func() { + select { + case <-g.gate: + default: + g.open() + } + }) + + tasks := runTaskPromptly(t, b, RunTaskInput{Cluster: "ev", TaskDefinition: tdArn}) + arn := tasks[0].TaskArn + + assert.Equal(t, statusProvisioning, tasks[0].LastStatus) + assert.Nil(t, tasks[0].StartedAt) + + g.awaitEntered(t) + + mid := describeTask(t, b, arn) + assert.Equal(t, statusActivating, mid.LastStatus) + assert.NotNil(t, mid.CreatedAt) + assert.NotNil(t, mid.PullStartedAt) + assert.Nil(t, mid.PullStoppedAt) + assert.Nil(t, mid.StartedAt) + + g.open() + b.awaitLaunches() + + end := describeTask(t, b, arn) + assert.Equal(t, tt.wantStatus, end.LastStatus) + assert.Equal(t, tt.wantStop, end.StopCode) + assert.Equal(t, tt.wantReason, end.StoppedReason) + assert.NotNil(t, end.PullStoppedAt) + + if tt.wantStatus == statusRunning { + assert.NotNil(t, end.StartedAt) + assert.NotNil(t, end.ConnectivityAt) + assert.Equal(t, connectivityConnected, end.Connectivity) + + return + } + + assert.Nil(t, end.StartedAt) + assert.NotNil(t, end.StoppedAt) + }) + } +} + +func TestAsyncLaunch_Cancellation(t *testing.T) { + t.Parallel() + + tests := []struct { + cancel func(t *testing.T, b *InMemoryBackend, arn string) + name string + }{ + { + name: "stop_task", + cancel: func(t *testing.T, b *InMemoryBackend, arn string) { + t.Helper() + + stopped, err := b.StopTask("ev", arn, "changed my mind") + require.NoError(t, err) + assert.Equal(t, stopCodeUserInitiated, stopped.StopCode) + }, + }, + {name: "close", cancel: func(t *testing.T, b *InMemoryBackend, _ string) { t.Helper(); b.Close() }}, + {name: "reset", cancel: func(t *testing.T, b *InMemoryBackend, _ string) { t.Helper(); b.Reset() }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + g := newGateRunner(nil) + b, _, tdArn := newEventBackend(t, g) + t.Cleanup(g.open) + + arn := runTaskPromptly(t, b, RunTaskInput{Cluster: "ev", TaskDefinition: tdArn})[0].TaskArn + g.awaitEntered(t) + + tt.cancel(t, b, arn) + b.awaitLaunches() + + b.mu.RLock("test") + defer b.mu.RUnlock() + + assert.Zero(t, b.launches.n) + + if task, ok := b.tasks.Get(arn); ok { + assert.Equal(t, statusStopped, task.LastStatus) + assert.Nil(t, task.StartedAt) + } + }) + } +} + +func TestAsyncLaunch_ServiceDoesNotDoubleLaunch(t *testing.T) { + t.Parallel() + + g := newGateRunner(nil) + b, _, tdArn := newEventBackend(t, g) + t.Cleanup(g.open) + t.Cleanup(b.Close) + + _, err := b.CreateService(CreateServiceInput{ + ServiceName: "svc", Cluster: "ev", TaskDefinition: tdArn, DesiredCount: 1, + }) + require.NoError(t, err) + + rec := NewReconciler(b) + + for range 3 { + rec.RunOnce(context.Background()) + } + + arns, err := b.ListTasks("ev") + require.NoError(t, err) + assert.Len(t, arns, 1, "a PROVISIONING task already satisfies the desired count") +} diff --git a/services/ecs/lifecycle.go b/services/ecs/lifecycle.go index f72aae2fc8..be9d25aaff 100644 --- a/services/ecs/lifecycle.go +++ b/services/ecs/lifecycle.go @@ -203,8 +203,7 @@ func (b *InMemoryBackend) advanceStartLocked(task *Task, lc *taskLifecycle, now return false default: - task.LastStatus = statusRunning - syncContainerStatuses(task, nil) + b.markRunningLocked(task, now) b.emitTaskStateLocked(task) if c, _ := b.clusters.Get(lc.clusterName); c != nil { diff --git a/services/ecs/models.go b/services/ecs/models.go index 6adfac09cb..0d0c16d00b 100644 --- a/services/ecs/models.go +++ b/services/ecs/models.go @@ -73,11 +73,21 @@ type HostVolumeProperties struct { // Volume defines a storage volume for a task definition. type Volume struct { Host *HostVolumeProperties `json:"host,omitempty"` + DockerVolumeConfig *DockerVolumeConfig `json:"dockerVolumeConfiguration,omitempty"` EFSVolumeConfiguration *EFSVolumeConfiguration `json:"efsVolumeConfiguration,omitempty"` Name string `json:"name"` ConfiguredAtLaunch bool `json:"configuredAtLaunch,omitempty"` } +// DockerVolumeConfig is a Docker-managed volume (types.DockerVolumeConfiguration). +type DockerVolumeConfig struct { + Autoprovision *bool `json:"autoprovision,omitempty"` + DriverOpts map[string]string `json:"driverOpts,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Driver string `json:"driver,omitempty"` + Scope string `json:"scope,omitempty"` +} + // MountPoint maps a volume into a container. type MountPoint struct { SourceVolume string `json:"sourceVolume,omitempty"` @@ -799,29 +809,102 @@ type ManagedStorageConfiguration struct { // ContainerDefinition represents a container definition in a task definition. type ContainerDefinition struct { - LogConfiguration *LogConfiguration `json:"logConfiguration,omitempty"` - FirelensConfiguration *FirelensConfiguration `json:"firelensConfiguration,omitempty"` - HealthCheck *HealthCheck `json:"healthCheck,omitempty"` - RepositoryCredentials *RepositoryCredentials `json:"repositoryCredentials,omitempty"` - Image string `json:"image"` - Name string `json:"name"` - EntryPoint []string `json:"entryPoint,omitempty"` - Command []string `json:"command,omitempty"` - Environment []KeyValuePair `json:"environment,omitempty"` - Secrets []SecretReference `json:"secrets,omitempty"` - EnvironmentFiles []EnvironmentFile `json:"environmentFiles,omitempty"` - ResourceRequirements []ResourceRequirement `json:"resourceRequirements,omitempty"` - PortMappings []PortMapping `json:"portMappings,omitempty"` - MountPoints []MountPoint `json:"mountPoints,omitempty"` - VolumesFrom []VolumeFrom `json:"volumesFrom,omitempty"` - DependsOn []ContainerDependency `json:"dependsOn,omitempty"` - Memory int `json:"memory,omitempty"` - MemoryReservation int `json:"memoryReservation,omitempty"` - CPU int `json:"cpu,omitempty"` - Essential bool `json:"essential"` - DisableNetworking bool `json:"disableNetworking,omitempty"` - Privileged bool `json:"privileged,omitempty"` - ReadonlyRootFilesystem bool `json:"readonlyRootFilesystem,omitempty"` + LogConfiguration *LogConfiguration `json:"logConfiguration,omitempty"` + StartTimeout *int `json:"startTimeout,omitempty"` + HealthCheck *HealthCheck `json:"healthCheck,omitempty"` + RepositoryCredentials *RepositoryCredentials `json:"repositoryCredentials,omitempty"` + LinuxParameters *LinuxParameters `json:"linuxParameters,omitempty"` + StopTimeout *int `json:"stopTimeout,omitempty"` + DockerLabels map[string]string `json:"dockerLabels,omitempty"` + RestartPolicy *ContainerRestartPolicy `json:"restartPolicy,omitempty"` + FirelensConfiguration *FirelensConfiguration `json:"firelensConfiguration,omitempty"` + User string `json:"user,omitempty"` + Hostname string `json:"hostname,omitempty"` + Name string `json:"name"` + WorkingDirectory string `json:"workingDirectory,omitempty"` + Image string `json:"image"` + Secrets []SecretReference `json:"secrets,omitempty"` + Environment []KeyValuePair `json:"environment,omitempty"` + Ulimits []Ulimit `json:"ulimits,omitempty"` + DNSServers []string `json:"dnsServers,omitempty"` + DNSSearchDomains []string `json:"dnsSearchDomains,omitempty"` + ExtraHosts []HostEntry `json:"extraHosts,omitempty"` + SystemControls []SystemControl `json:"systemControls,omitempty"` + DockerSecurityOptions []string `json:"dockerSecurityOptions,omitempty"` + VolumesFrom []VolumeFrom `json:"volumesFrom,omitempty"` + MountPoints []MountPoint `json:"mountPoints,omitempty"` + PortMappings []PortMapping `json:"portMappings,omitempty"` + ResourceRequirements []ResourceRequirement `json:"resourceRequirements,omitempty"` + EnvironmentFiles []EnvironmentFile `json:"environmentFiles,omitempty"` + DependsOn []ContainerDependency `json:"dependsOn,omitempty"` + Command []string `json:"command,omitempty"` + EntryPoint []string `json:"entryPoint,omitempty"` + Memory int `json:"memory,omitempty"` + MemoryReservation int `json:"memoryReservation,omitempty"` + CPU int `json:"cpu,omitempty"` + Essential bool `json:"essential"` + DisableNetworking bool `json:"disableNetworking,omitempty"` + Privileged bool `json:"privileged,omitempty"` + ReadonlyRootFilesystem bool `json:"readonlyRootFilesystem,omitempty"` + Interactive bool `json:"interactive,omitempty"` + PseudoTerminal bool `json:"pseudoTerminal,omitempty"` +} + +// Ulimit is a container resource limit (types.Ulimit). +type Ulimit struct { + Name string `json:"name"` + SoftLimit int `json:"softLimit"` + HardLimit int `json:"hardLimit"` +} + +// HostEntry is an /etc/hosts entry (types.HostEntry). +type HostEntry struct { + Hostname string `json:"hostname"` + IPAddress string `json:"ipAddress"` +} + +// SystemControl is a namespaced kernel parameter (types.SystemControl). +type SystemControl struct { + Namespace string `json:"namespace"` + Value string `json:"value"` +} + +// ContainerRestartPolicy is the per-container restart policy (types.ContainerRestartPolicy). +type ContainerRestartPolicy struct { + IgnoredExitCodes []int `json:"ignoredExitCodes,omitempty"` + RestartAttemptPeriod int `json:"restartAttemptPeriod,omitempty"` + Enabled bool `json:"enabled"` +} + +// LinuxParameters holds Linux-specific container options (types.LinuxParameters). +type LinuxParameters struct { + Capabilities *KernelCapabilities `json:"capabilities,omitempty"` + InitProcessEnabled *bool `json:"initProcessEnabled,omitempty"` + MaxSwap *int `json:"maxSwap,omitempty"` + SharedMemorySize *int `json:"sharedMemorySize,omitempty"` + Swappiness *int `json:"swappiness,omitempty"` + Devices []Device `json:"devices,omitempty"` + Tmpfs []Tmpfs `json:"tmpfs,omitempty"` +} + +// KernelCapabilities lists Linux capabilities to add or drop. +type KernelCapabilities struct { + Add []string `json:"add,omitempty"` + Drop []string `json:"drop,omitempty"` +} + +// Device maps a host device into a container. +type Device struct { + HostPath string `json:"hostPath"` + ContainerPath string `json:"containerPath,omitempty"` + Permissions []string `json:"permissions,omitempty"` +} + +// Tmpfs is a tmpfs mount (size in MiB). +type Tmpfs struct { + ContainerPath string `json:"containerPath"` + MountOptions []string `json:"mountOptions,omitempty"` + Size int `json:"size"` } // UnmarshalJSON defaults Essential to true when the key is omitted, per @@ -959,20 +1042,25 @@ type Task struct { StartedAt *time.Time `json:"startedAt,omitempty"` StoppedAt *time.Time `json:"stoppedAt,omitempty"` ConnectivityAt *time.Time `json:"connectivityAt,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + PullStartedAt *time.Time `json:"pullStartedAt,omitempty"` + PullStoppedAt *time.Time `json:"pullStoppedAt,omitempty"` Overrides *TaskOverride `json:"overrides,omitempty"` NetworkConfiguration *NetworkConfiguration `json:"networkConfiguration,omitempty"` - TaskArn string `json:"taskArn"` - ClusterArn string `json:"clusterArn"` + Group string `json:"group,omitempty"` + PlatformVersion string `json:"platformVersion,omitempty"` TaskDefinitionArn string `json:"taskDefinitionArn"` LastStatus string `json:"lastStatus"` DesiredStatus string `json:"desiredStatus"` Connectivity string `json:"connectivity,omitempty"` StoppedReason string `json:"stoppedReason,omitempty"` - Group string `json:"group,omitempty"` + StopCode string `json:"stopCode,omitempty"` + HealthStatus string `json:"healthStatus,omitempty"` + TaskArn string `json:"taskArn"` LaunchType string `json:"launchType,omitempty"` ContainerInstanceArn string `json:"containerInstanceArn,omitempty"` StartedBy string `json:"startedBy,omitempty"` - PlatformVersion string `json:"platformVersion,omitempty"` + ClusterArn string `json:"clusterArn"` PlatformFamily string `json:"platformFamily,omitempty"` RuntimeID string `json:"runtimeId,omitempty"` PropagateTags string `json:"propagateTags,omitempty"` @@ -987,10 +1075,11 @@ type Task struct { Tags []Tag `json:"tags,omitempty"` Attachments []TaskAttachment `json:"attachments,omitempty"` Containers []Container `json:"containers,omitempty"` - EnableExecuteCommand bool `json:"enableExecuteCommand,omitempty"` + version int64 + EnableExecuteCommand bool `json:"enableExecuteCommand,omitempty"` // AwaitingCapacity marks a PROVISIONING task waiting for a managed-scaling provider to add instances. AwaitingCapacity bool `json:"awaitingCapacity,omitempty"` - version int64 + runtimeHealth bool } // CreateClusterInput holds input for CreateCluster. diff --git a/services/ecs/persistence.go b/services/ecs/persistence.go index a18ef98444..b70e6306aa 100644 --- a/services/ecs/persistence.go +++ b/services/ecs/persistence.go @@ -252,6 +252,10 @@ func (h *Handler) Restore(ctx context.Context, data []byte) error { return err } + if b, isMem := h.Backend.(*InMemoryBackend); isMem { + b.adoptRestoredTasks(ctx) + } + return h.peers.Restore( data, func(p *Handler, d []byte) error { return p.Restore(ctx, d) }, diff --git a/services/ecs/provider.go b/services/ecs/provider.go index 208ff7e306..e1ab9a2223 100644 --- a/services/ecs/provider.go +++ b/services/ecs/provider.go @@ -11,15 +11,6 @@ import ( // ErrNilAppContext is returned when Provider.Init is called with a nil AppContext. var ErrNilAppContext = errors.New("AppContext is required") -// taskCompletionRunner is implemented by TaskRunners that can report when a -// container they started exits on its own, not via an explicit StopTask call -// (currently only realDockerRunner). Runners that don't implement it -// (noopRunner, test fakes) stay unwired: their tasks only reach STOPPED via -// StopTask. -type taskCompletionRunner interface { - SetTaskCompletionHandler(fn func(taskArn, containerName string, exitCode int)) -} - // Provider implements service.Provider for Amazon ECS. type Provider struct{} @@ -55,21 +46,7 @@ func (p *Provider) Init(appCtx *service.AppContext) (service.Registerable, error backend := NewInMemoryBackend(accountID, region, runner) - if r, ok := runner.(taskCompletionRunner); ok { - r.SetTaskCompletionHandler(backend.markTaskStoppedByContainerExit) - } - - if r, ok := runner.(taskEnvRunner); ok { - r.SetTaskEnvProvider(backend.containerRuntimeEnv) - } - - if r, ok := runner.(containerAttachRunner); ok { - r.SetContainerAttacher(backend.attachLinkLocal) - } - - if r, ok := runner.(admitHookRunner); ok { - r.SetAdmitHook(backend.taskAdmit) - } + backend.wireRunnerHooks(runner) reconciler := NewReconciler(backend) janitor := NewJanitor(backend, 0) diff --git a/services/ecs/reconciler.go b/services/ecs/reconciler.go index 5471a42bce..894303d42a 100644 --- a/services/ecs/reconciler.go +++ b/services/ecs/reconciler.go @@ -102,6 +102,7 @@ func (r *Reconciler) reconcile(ctx context.Context, log *slog.Logger) { // lifecycle states (stop/start pipelines) before reconciling desired counts. r.backend.stepTaskLifecycle(time.Now()) r.backend.stopELBUnhealthyTasks(ctx) + r.backend.stopUnhealthyServiceTasks() r.sampleUtilization(ctx) r.backend.ReconcileCapacity(ctx) diff --git a/services/ecs/regions.go b/services/ecs/regions.go index f6054bce73..4fe2760ea0 100644 --- a/services/ecs/regions.go +++ b/services/ecs/regions.go @@ -45,21 +45,7 @@ func (h *Handler) buildPeer(ctx context.Context, home *InMemoryBackend, region s nb := NewInMemoryBackend(home.accountID, region, runner) nb.inheritWiring(home) - if r, ok := runner.(taskCompletionRunner); ok { - r.SetTaskCompletionHandler(nb.markTaskStoppedByContainerExit) - } - - if r, ok := runner.(taskEnvRunner); ok { - r.SetTaskEnvProvider(nb.containerRuntimeEnv) - } - - if r, ok := runner.(containerAttachRunner); ok { - r.SetContainerAttacher(nb.attachLinkLocal) - } - - if r, ok := runner.(admitHookRunner); ok { - r.SetAdmitHook(nb.taskAdmit) - } + nb.wireRunnerHooks(runner) cwl := home.currentCWLogs() if h.cwLogsFor != nil { @@ -134,7 +120,9 @@ func (b *InMemoryBackend) inheritWiring(home *InMemoryBackend) { b.events = home.events b.credIssuer = home.credIssuer b.endpointURL = home.endpointURL + b.execSessions = home.execSessions b.linkLocal = home.linkLocal + b.secrets = home.secrets b.metrics.Set(home.metrics.Emitter()) inheritEnforcer(b, home) b.stopDelay = home.stopDelay diff --git a/services/ecs/regions_internal_test.go b/services/ecs/regions_internal_test.go index ce40c34b25..1fd085f4da 100644 --- a/services/ecs/regions_internal_test.go +++ b/services/ecs/regions_internal_test.go @@ -41,6 +41,7 @@ func runPeerTask(t *testing.T, peer *InMemoryBackend, def ContainerDefinition) T require.NoError(t, err) tasks, _, err := peer.RunTask(RunTaskInput{Cluster: "c", TaskDefinition: "job"}) + tasks = settleTasks(t, peer, tasks) require.NoError(t, err) require.Len(t, tasks, 1) diff --git a/services/ecs/restore_adopt.go b/services/ecs/restore_adopt.go new file mode 100644 index 0000000000..4ef54fc591 --- /dev/null +++ b/services/ecs/restore_adopt.go @@ -0,0 +1,76 @@ +package ecs + +import ( + "context" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + lostTaskReason = "Task containers were lost when the emulator restarted" + containerLostExitCode = 137 +) + +// adoptRestoredTasks reattaches the runner to the containers of RUNNING tasks restored from a snapshot. A task +// whose containers did not survive is stopped, as the real service stops a task whose containers are gone. +func (b *InMemoryBackend) adoptRestoredTasks(ctx context.Context) { + ad, ok := b.runner.(taskAdopter) + if !ok { + return + } + + type pending struct { + task *Task + td *TaskDefinition + } + + var work []pending + + b.mu.RLock("adoptRestoredTasks") + + for _, t := range b.tasks.All() { + if t.LastStatus != statusRunning { + continue + } + + if td, found := b.taskDefByArn.Get(t.TaskDefinitionArn); found { + work = append(work, pending{task: t, td: td}) + } + } + + b.mu.RUnlock() + + for _, w := range work { + if ad.AdoptTask(ctx, w.task, w.td) { + continue + } + + logger.Load(ctx).WarnContext(ctx, "ecs: restored task lost its containers", "task", w.task.TaskArn) + b.stopLostTask(w.task, w.td) + } +} + +func (b *InMemoryBackend) stopLostTask(task *Task, td *TaskDefinition) { + name := "" + if len(td.ContainerDefinitions) > 0 { + name = td.ContainerDefinitions[0].Name + } + + for _, cd := range td.ContainerDefinitions { + if cd.Essential { + name = cd.Name + + break + } + } + + b.mu.Lock("stopLostTask") + + if task.StoppedReason == "" { + task.StoppedReason = lostTaskReason + } + + b.mu.Unlock() + + b.markTaskStoppedByContainerExit(task.TaskArn, name, containerLostExitCode) +} diff --git a/services/ecs/rolling_deployment.go b/services/ecs/rolling_deployment.go index 431504ace9..16997c0371 100644 --- a/services/ecs/rolling_deployment.go +++ b/services/ecs/rolling_deployment.go @@ -28,7 +28,7 @@ func taskBelongsToDeployment(t *Task, dep *Deployment) bool { func taskIsLive(t *Task) bool { switch t.LastStatus { - case statusRunning, statusPending, statusProvisioning: + case statusRunning, statusPending, statusProvisioning, statusActivating: return true } @@ -234,6 +234,7 @@ func (b *InMemoryBackend) stopServiceTask(clusterName, taskArn, reason string) e task.DesiredStatus = statusStopped task.StoppedAt = &now task.StoppedReason = reason + task.StopCode = stopCodeServiceSchedulerInitiated syncContainerStatuses(task, nil) b.emitTaskStateLocked(task) b.deregisterTaskFromELBv2Locked(task, clusterName) diff --git a/services/ecs/runner_hooks.go b/services/ecs/runner_hooks.go new file mode 100644 index 0000000000..b111b4aeda --- /dev/null +++ b/services/ecs/runner_hooks.go @@ -0,0 +1,132 @@ +package ecs + +import "context" + +// taskCompletionRunner is implemented by TaskRunners that can report when a +// container they started exits on its own, not via an explicit StopTask call +// (currently only realDockerRunner). Runners that don't implement it +// (noopRunner, test fakes) stay unwired: their tasks only reach STOPPED via +// StopTask. +type taskCompletionRunner interface { + SetTaskCompletionHandler(fn func(taskArn, containerName string, exitCode int)) +} + +// containerHealthRunner is implemented by runners that report real container health. +type containerHealthRunner interface { + SetContainerHealthHandler(fn func(taskArn, containerName, status string)) + SetContainerReasonHandler(fn func(taskArn, containerName, reason string)) +} + +// secretRunner is implemented by runners that inject container secrets. +type secretRunner interface { + SetSecretResolver(fn func(ctx context.Context, valueFrom string) (string, error)) +} + +// wireRunnerHooks connects every optional runner capability to this backend. +func (b *InMemoryBackend) wireRunnerHooks(runner TaskRunner) { + if r, ok := runner.(taskCompletionRunner); ok { + r.SetTaskCompletionHandler(b.markTaskStoppedByContainerExit) + } + + if r, ok := runner.(taskEnvRunner); ok { + r.SetTaskEnvProvider(b.containerRuntimeEnv) + } + + if r, ok := runner.(containerAttachRunner); ok { + r.SetContainerAttacher(b.attachLinkLocal) + } + + if r, ok := runner.(admitHookRunner); ok { + r.SetAdmitHook(b.taskAdmit) + } + + if r, ok := runner.(containerHealthRunner); ok { + r.SetContainerHealthHandler(b.updateContainerHealth) + r.SetContainerReasonHandler(b.setContainerReason) + } + + if r, ok := runner.(secretRunner); ok { + r.SetSecretResolver(b.resolveSecret) + } + + if r, ok := runner.(efsVolumeRunner); ok { + r.SetEFSVolumeResolver(b.resolveEFSVolume) + } + + if r, ok := runner.(launchInputRunner); ok { + r.SetRegistryAuthResolver(b.resolveRegistryAuth) + r.SetEnvFileResolver(b.resolveEnvFiles) + } +} + +// updateContainerHealth records a container's runtime health and recomputes the task's. +func (b *InMemoryBackend) updateContainerHealth(taskArn, containerName, status string) { + b.mu.Lock("updateContainerHealth") + defer b.mu.Unlock() + + task, ok := b.tasks.Get(taskArn) + if !ok { + return + } + + for i := range task.Containers { + if task.Containers[i].Name == containerName { + task.Containers[i].HealthStatus = status + } + } + + task.HealthStatus = b.taskHealthLocked(task) + b.emitTaskStateLocked(task) +} + +// setContainerReason records why a container stopped (for example OutOfMemoryError). +func (b *InMemoryBackend) setContainerReason(taskArn, containerName, reason string) { + b.mu.Lock("setContainerReason") + defer b.mu.Unlock() + + task, ok := b.tasks.Get(taskArn) + if !ok { + return + } + + for i := range task.Containers { + if task.Containers[i].Name == containerName { + task.Containers[i].Reason = reason + } + } +} + +// taskHealthLocked is HEALTHY when every essential container with a health check is HEALTHY, UNHEALTHY when +// any is UNHEALTHY, and UNKNOWN otherwise. +func (b *InMemoryBackend) taskHealthLocked(task *Task) string { + essential := map[string]bool{} + + if td, err := b.findTaskDefinitionLocked(task.TaskDefinitionArn); err == nil { + for _, cd := range td.ContainerDefinitions { + essential[cd.Name] = cd.Essential + } + } + + checked, healthy := 0, 0 + + for _, c := range task.Containers { + if !essential[c.Name] || c.HealthStatus == "" { + continue + } + + checked++ + + switch c.HealthStatus { + case healthECSUnhealthy: + return healthECSUnhealthy + case healthECSHealthy: + healthy++ + } + } + + if checked > 0 && healthy == checked { + return healthECSHealthy + } + + return healthECSUnknown +} diff --git a/services/ecs/services.go b/services/ecs/services.go index a8378ebb27..d0d2797d8e 100644 --- a/services/ecs/services.go +++ b/services/ecs/services.go @@ -446,7 +446,7 @@ func (b *InMemoryBackend) enrichService(s *Service, clusterName string) Service case statusRunning: running++ liveTasks = append(liveTasks, t) - case statusPending, statusProvisioning: + case statusPending, statusProvisioning, statusActivating: pending++ liveTasks = append(liveTasks, t) } @@ -775,7 +775,7 @@ func (b *InMemoryBackend) runningTasksByGroupLocked(clusterName string) map[stri counts := make(map[string]int) for _, t := range b.tasksByCluster.Get(clusterName) { - if t.LastStatus == statusRunning { + if t.LastStatus == statusRunning || isLaunchingStatus(t.LastStatus) { counts[t.Group]++ } } @@ -939,6 +939,7 @@ func (b *InMemoryBackend) StopOldestServiceTask(clusterName, serviceName string) oldest.DesiredStatus = statusStopped oldest.StoppedAt = &now oldest.StoppedReason = "service scale-in" + oldest.StopCode = stopCodeServiceSchedulerInitiated syncContainerStatuses(oldest, nil) b.emitTaskStateLocked(oldest) b.deregisterTaskFromELBv2Locked(oldest, clusterName) diff --git a/services/ecs/store.go b/services/ecs/store.go index 09633b6930..56f6299ecd 100644 --- a/services/ecs/store.go +++ b/services/ecs/store.go @@ -20,6 +20,7 @@ const ( statusDeleteInProgress = "DELETE_IN_PROGRESS" statusProvisioning = "PROVISIONING" statusPending = "PENDING" + statusActivating = "ACTIVATING" statusDeactivating = "DEACTIVATING" statusStopping = "STOPPING" statusDeprovisioning = "DEPROVISIONING" @@ -71,6 +72,8 @@ type svcRef struct { // keep new fields grouped with their kind rather than by logical concern. type InMemoryBackend struct { runner TaskRunner + // execSessions opens ECS Exec sessions, guarded by mu. + execSessions ExecSessionStarter // sgEnforcer, when set, fronts awsvpc tasks' published ports with a security-group check. sgEnforcer atomic.Pointer[sgfilter.Enforcer] // cwLogs, when set (see SetCWLogsBackend), makes an awslogs-driver @@ -90,6 +93,9 @@ type InMemoryBackend struct { credIssuer RoleCredentialIssuer endpointURL string linkLocal LinkLocal + secrets SecretsResolver + efs EFSResolver + envFiles EnvFileStore hooksInFlight map[string]struct{} asgResolver AutoScalingGroupResolver asgController AutoScalingGroupController @@ -149,8 +155,12 @@ type InMemoryBackend struct { // are intentionally never populated. serviceRevisions map[string][]*ServiceRevision serviceRevisionsByArn map[string]*ServiceRevision - region string - accountID string + // nowFn overrides time.Now for task launch timestamps (tests). + nowFn func() time.Time + // launches tracks in-flight launch goroutines, guarded by mu. + launches launchTracker + region string + accountID string // clusterDeleteHooks are invoked (outside the backend lock) with each cluster // key removed by DeleteCluster or Purge, so external components such as the // Reconciler can release per-cluster resources and avoid unbounded growth. @@ -199,14 +209,18 @@ func NewInMemoryBackend(accountID, region string, runner TaskRunner) *InMemoryBa // Reset zeroes all backend state for test isolation. func (b *InMemoryBackend) Reset() { + _, idle := b.cancelLaunches(false) + tasks := b.resetLocked() - if b.runner == nil { - return + if b.runner != nil { + for _, t := range tasks { + _ = b.runner.StopTask(t) + } } - for _, t := range tasks { - _ = b.runner.StopTask(t) + if idle != nil { + <-idle } } diff --git a/services/ecs/task_containers.go b/services/ecs/task_containers.go index 494778f7de..7dd0f2ecc6 100644 --- a/services/ecs/task_containers.go +++ b/services/ecs/task_containers.go @@ -139,7 +139,7 @@ func syncContainerStatuses(task *Task, exitCode *int) { task.Containers[i].RuntimeID = newDockerContainerID() } - if task.Containers[i].HealthStatus == containerHealthStatusUnknown { + if !task.runtimeHealth && task.Containers[i].HealthStatus == containerHealthStatusUnknown { task.Containers[i].HealthStatus = containerHealthStatusHealthy } } diff --git a/services/ecs/task_metadata.go b/services/ecs/task_metadata.go index 740c3f8445..49cb6d019c 100644 --- a/services/ecs/task_metadata.go +++ b/services/ecs/task_metadata.go @@ -196,10 +196,10 @@ func containerMetadataFor(task Task, td *TaskDefinition, cd ContainerDefinition, Type: containerTypeNormal, ContainerARN: c.ContainerArn, ExitCode: c.ExitCode, - CreatedAt: timeStr(task.StartedAt), + CreatedAt: timeStr(task.CreatedAt), } - if task.LastStatus != statusProvisioning && task.LastStatus != statusPending { + if task.StartedAt != nil { m.StartedAt = timeStr(task.StartedAt) } diff --git a/services/ecs/task_metadata_test.go b/services/ecs/task_metadata_test.go index 2acaa358da..26149530d9 100644 --- a/services/ecs/task_metadata_test.go +++ b/services/ecs/task_metadata_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/ecs" ) @@ -104,7 +105,7 @@ func (f *metaFixture) get(t *testing.T, path string) (int, map[string]any) { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, f.srv.URL+path, nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ecs/taskdef_runtime_validation.go b/services/ecs/taskdef_runtime_validation.go new file mode 100644 index 0000000000..1c419eea74 --- /dev/null +++ b/services/ecs/taskdef_runtime_validation.go @@ -0,0 +1,88 @@ +package ecs + +import ( + "fmt" + "slices" +) + +// validateContainerRuntime rejects the container wiring real ECS rejects at registration: dependsOn and +// volumesFrom targets that do not exist or form a cycle, unknown conditions, HEALTHY on a container without a +// health check, mount points for undefined volumes, autoprovision outside a shared Docker volume, and a +// healthCheck command that does not start with CMD, CMD-SHELL or NONE. +func validateContainerRuntime(defs []ContainerDefinition, volumes []Volume) error { + if _, err := orderContainers(defs); err != nil { + return fmt.Errorf("%w: %w", ErrClient, err) + } + + byName := make(map[string]ContainerDefinition, len(defs)) + for _, cd := range defs { + byName[cd.Name] = cd + } + + volumeNames := make(map[string]bool, len(volumes)) + for _, v := range volumes { + volumeNames[v.Name] = true + + if err := validateDockerVolume(v); err != nil { + return err + } + } + + for _, cd := range defs { + if err := validateContainerRefs(cd, byName, volumeNames); err != nil { + return err + } + } + + return nil +} + +func validateContainerRefs( + cd ContainerDefinition, + byName map[string]ContainerDefinition, + volumes map[string]bool, +) error { + for _, d := range cd.DependsOn { + if !slices.Contains([]string{dependsStart, dependsComplete, dependsSuccess, dependsHealthy}, d.Condition) { + return fmt.Errorf("%w: container %q dependsOn %q has invalid condition %q", + ErrClient, cd.Name, d.ContainerName, d.Condition) + } + + if d.Condition == dependsHealthy && byName[d.ContainerName].HealthCheck == nil { + return fmt.Errorf("%w: container %q depends on %q being HEALTHY but %q has no healthCheck", + ErrClient, cd.Name, d.ContainerName, d.ContainerName) + } + } + + for _, mp := range cd.MountPoints { + if !volumes[mp.SourceVolume] { + return fmt.Errorf("%w: container %q mounts undefined volume %q", ErrClient, cd.Name, mp.SourceVolume) + } + } + + if hc := cd.HealthCheck; hc != nil { + if len(hc.Command) == 0 || !slices.Contains([]string{"CMD", "CMD-SHELL", healthCheckNone}, hc.Command[0]) { + return fmt.Errorf("%w: container %q healthCheck command must start with CMD, CMD-SHELL or NONE", + ErrClient, cd.Name) + } + } + + return nil +} + +func validateDockerVolume(v Volume) error { + cfg := v.DockerVolumeConfig + if cfg == nil { + return nil + } + + if cfg.Scope != "" && cfg.Scope != volumeScopeTask && cfg.Scope != volumeScopeShared { + return fmt.Errorf("%w: volume %q has invalid scope %q", ErrClient, v.Name, cfg.Scope) + } + + if cfg.Autoprovision != nil && *cfg.Autoprovision && cfg.Scope != volumeScopeShared { + return fmt.Errorf("%w: volume %q sets autoprovision but its scope is not shared", ErrClient, v.Name) + } + + return nil +} diff --git a/services/ecs/taskdef_validation.go b/services/ecs/taskdef_validation.go index 51ac957379..c4379721e5 100644 --- a/services/ecs/taskdef_validation.go +++ b/services/ecs/taskdef_validation.go @@ -45,6 +45,10 @@ func validateRegisterTaskDefinition(input RegisterTaskDefinitionInput) error { return err } + if err := validateContainerRuntime(input.ContainerDefinitions, input.Volumes); err != nil { + return err + } + if err := validateProxyConfiguration(input); err != nil { return err } diff --git a/services/ecs/tasks.go b/services/ecs/tasks.go index 5b8ebd2209..5855d9202d 100644 --- a/services/ecs/tasks.go +++ b/services/ecs/tasks.go @@ -170,8 +170,8 @@ func (b *InMemoryBackend) RunTask(input RunTaskInput) ([]Task, []Failure, error) return tasks, failures, nil } -// startTasksOutsideLock starts containers outside the lock to avoid serializing other -// operations behind potentially slow Docker API calls (image pull, network setup, etc.). +// startTasksOutsideLock hands real-runner tasks to their launch goroutine (RunTask returns while they are +// PROVISIONING) and finishes runner-less tasks inline. func (b *InMemoryBackend) startTasksOutsideLock(work []taskWork) { for _, w := range work { clusterName := clusterKey(clusterFromTaskARN(w.task.TaskArn)) @@ -192,23 +192,7 @@ func (b *InMemoryBackend) startTasksOutsideLock(work []taskWork) { continue } - // Transition PROVISIONING → PENDING before the potentially-slow container - // runtime call, then PENDING → RUNNING/STOPPED based on the result. - b.applyPendingTransition(w.task) - runErr := b.runner.RunTask(w.task, w.td) - b.applyRunnerTransition(w.task, clusterName, runErr) - } -} - -// applyPendingTransition moves a PROVISIONING task to PENDING under the lock. -// Called for tasks with a real container runner, before the runner is invoked. -func (b *InMemoryBackend) applyPendingTransition(task *Task) { - b.mu.Lock("RunTask-setPending") - defer b.mu.Unlock() - - if task.LastStatus == statusProvisioning { - task.LastStatus = statusPending - b.emitTaskStateLocked(task) + b.launchAsync(w, clusterName) } } @@ -239,12 +223,9 @@ func (b *InMemoryBackend) applyNoRunnerTransition(task *Task, clusterName string return } - // Pass through PENDING (resource provisioning complete, container starting). task.LastStatus = statusPending b.emitTaskStateLocked(task) - // Immediately advance to RUNNING since there is no real container to wait for. - task.LastStatus = statusRunning - syncContainerStatuses(task, nil) + b.markRunningLocked(task, b.clock()) b.emitTaskStateLocked(task) if c, _ := b.clusters.Get(clusterName); c != nil { @@ -271,57 +252,6 @@ func (b *InMemoryBackend) applyRuntimeContainerIDs(task *Task) { } } -// applyRunnerTransition transitions a PENDING task to RUNNING or STOPPED -// based on the container runtime result. Must be called without any lock held. -func (b *InMemoryBackend) applyRunnerTransition(task *Task, clusterName string, runErr error) { - b.mu.Lock("RunTask-setRunning") - defer b.mu.Unlock() - - // Only update status if no concurrent operation (e.g. StopTask) has - // already changed the task away from PENDING. A task enters PENDING just - // before the runner call via applyPendingTransition. - if task.LastStatus != statusPending { - return - } - - if runErr == nil { - task.LastStatus = statusRunning - b.applyRuntimeContainerIDs(task) - syncContainerStatuses(task, nil) - b.emitTaskStateLocked(task) - - if c, _ := b.clusters.Get(clusterName); c != nil { - c.PendingTasksCount-- - c.RunningTasksCount++ - } - - b.registerTaskWithELBv2Locked(task, clusterName) - b.registerTaskWithCloudMapLocked(task, clusterName) - - return - } - - // Container start failed — mark STOPPED so the task does not - // remain in PROVISIONING permanently (resource leak + wrong semantics). - now := time.Now() - task.LastStatus = statusStopped - task.DesiredStatus = statusStopped - task.StoppedAt = &now - task.StoppedReason = fmt.Sprintf("container start failed: %v", runErr) - exitCode := 1 - syncContainerStatuses(task, &exitCode) - b.emitTaskStateLocked(task) - - if c, _ := b.clusters.Get(clusterName); c != nil { - c.PendingTasksCount-- - } - - // A failed launch for a service task counts against the deployment circuit - // breaker, which may trip the deployment to FAILED and (if enabled) roll the - // service back to its last stable task definition. - b.recordServiceTaskFailureLocked(clusterName, task) -} - // createTaskEntriesLocked creates task entries in PROVISIONING state. For an // EC2-launch-type task that cannot be placed (no eligible container // instance, or the chosen instance's ports cannot satisfy the task @@ -438,9 +368,7 @@ func newTaskEntryLocked( PlatformVersion: input.PlatformVersion, PropagateTags: input.PropagateTags, Tags: resolvedTags, - StartedAt: &now, - Connectivity: connectivityConnected, - ConnectivityAt: &now, + CreatedAt: &now, Overrides: input.Overrides, NetworkConfiguration: input.NetworkConfiguration, EnableExecuteCommand: input.EnableExecuteCommand, @@ -642,6 +570,8 @@ func (b *InMemoryBackend) StopTask(cluster, taskArn, reason string) (*Task, erro prevStatus := task.LastStatus task.DesiredStatus = statusStopped task.StoppedReason = reason + task.StopCode = stopCodeUserInitiated + b.cancelLaunchLocked(taskArn) // Decrement the cached cluster counters once, as the task leaves its active // state. This is done up front for both the fast and delayed paths so the @@ -650,7 +580,7 @@ func (b *InMemoryBackend) StopTask(cluster, taskArn, reason string) (*Task, erro switch prevStatus { case statusRunning: c.RunningTasksCount-- - case statusProvisioning, statusPending: + case statusProvisioning, statusPending, statusActivating: c.PendingTasksCount-- } } @@ -729,7 +659,7 @@ func (b *InMemoryBackend) StopTask(cluster, taskArn, reason string) (*Task, erro // stopped or mid-transition). func isStoppableStatus(status string) bool { switch status { - case statusRunning, statusPending, statusProvisioning: + case statusRunning, statusPending, statusProvisioning, statusActivating: return true default: return false @@ -777,6 +707,10 @@ func (b *InMemoryBackend) markTaskStoppedByContainerExit(taskArn, containerName task.StoppedReason = "Essential container in task exited" } + if task.StopCode == "" { + task.StopCode = stopCodeEssentialContainerExited + } + syncContainerStatuses(task, nil) setContainerExitCode(task, containerName, exitCode) b.emitTaskStateLocked(task) @@ -785,7 +719,7 @@ func (b *InMemoryBackend) markTaskStoppedByContainerExit(taskArn, containerName switch prevStatus { case statusRunning: c.RunningTasksCount-- - case statusPending, statusProvisioning: + case statusPending, statusProvisioning, statusActivating: c.PendingTasksCount-- } } @@ -996,6 +930,9 @@ func (b *InMemoryBackend) newStartedTaskLocked( TaskDefinitionArn: td.TaskDefinitionArn, LastStatus: statusRunning, DesiredStatus: statusRunning, + CreatedAt: &now, + Connectivity: connectivityConnected, + ConnectivityAt: &now, Group: input.Group, LaunchType: "EC2", ContainerInstanceArn: ciArn, @@ -1110,74 +1047,6 @@ func (b *InMemoryBackend) UpdateTaskProtection( return out, failures, nil } -// ExecuteCommand simulates an ECS Exec session. -func (b *InMemoryBackend) ExecuteCommand( - cluster, task, container, command string, - interactive bool, -) (*ExecuteCommandOutput, error) { - if task == "" { - return nil, fmt.Errorf("%w: task is required", ErrInvalidParameter) - } - - if command == "" { - return nil, fmt.Errorf("%w: command is required", ErrInvalidParameter) - } - - clusterName := clusterKey(b.resolveCluster(cluster)) - - b.mu.RLock("ExecuteCommand") - defer b.mu.RUnlock() - - if !b.clusters.Has(clusterName) { - return nil, fmt.Errorf("%w: %s", ErrClusterNotFound, cluster) - } - - t, ok := b.tasks.Get(task) - if !ok || clusterKey(t.ClusterArn) != clusterName { - return nil, fmt.Errorf("%w: task %s not found", ErrInvalidParameter, task) - } - - if t.LastStatus != statusRunning { - return nil, fmt.Errorf("%w: task %s is not in RUNNING state", ErrInvalidParameter, task) - } - - if !t.EnableExecuteCommand { - return nil, fmt.Errorf( - "%w: ECS Exec is not enabled on task %s; set enableExecuteCommand=true when launching the task", - ErrInvalidParameter, task, - ) - } - - clusterObj, _ := b.clusters.Get(clusterName) - sessionID := uuid.NewString() - - return &ExecuteCommandOutput{ - ClusterArn: clusterObj.ClusterArn, - ContainerArn: arn.Build( - "ecs", - b.region, - b.accountID, - fmt.Sprintf("container/%s", uuid.NewString()), - ), - ContainerName: container, - TaskArn: t.TaskArn, - Interactive: interactive, - Session: Session{ - SessionID: sessionID, - // Honesty signal: gopherstack has no real SSM data channel or ECS Exec - // agent, so this stream URL is deliberately NOT the real - // ssmmessages..amazonaws.com host (which would look connectable - // but lie). It uses the reserved, non-resolvable ".invalid" TLD - // (RFC 6761) so callers cannot mistake it for a live AWS endpoint. - StreamURL: fmt.Sprintf( - "wss://ssm-emulated.gopherstack.invalid/v1/data-channel/%s", - sessionID, - ), - TokenValue: uuid.NewString(), - }, - }, nil -} - // containerExitStopsTaskLocked reports whether containerName's exit stops the task: true for // essential containers, or when the definition is unresolvable or has none. func (b *InMemoryBackend) containerExitStopsTaskLocked(task *Task, containerName string) bool { diff --git a/services/ecs/utilization_internal_test.go b/services/ecs/utilization_internal_test.go index d198518391..36c2f2df55 100644 --- a/services/ecs/utilization_internal_test.go +++ b/services/ecs/utilization_internal_test.go @@ -62,6 +62,7 @@ func TestEmitUtilizationMetrics(t *testing.T) { s := newFargateServiceWithLB(t, b, "svc-usage") require.NoError(t, b.StartTaskForService(s.cluster, s.service, s.tdArn)) + b.awaitLaunches() b.emitUtilizationMetrics(t.Context()) @@ -99,6 +100,7 @@ func TestDockerRunner_TaskUsage(t *testing.T) { s := newFargateServiceWithLB(t, b, "svc-docker-usage") require.NoError(t, b.StartTaskForService(s.cluster, s.service, s.tdArn)) + b.awaitLaunches() tasks, _, err := b.DescribeTasks(s.cluster, nil) require.NoError(t, err) diff --git a/services/efs/handler_file_systems_test.go b/services/efs/handler_file_systems_test.go index 4d6ccc7ee7..d5bb634666 100644 --- a/services/efs/handler_file_systems_test.go +++ b/services/efs/handler_file_systems_test.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" @@ -673,42 +674,46 @@ func TestDeleteFileSystem_RejectedWhenMountTargetsExist(t *testing.T) { func TestFileSystem_CreatingToAvailableLifecycle(t *testing.T) { t.Parallel() - b := efs.NewInMemoryBackend("123456789012", "us-east-1") - efs.SetFSActivationDelay(b, 80*time.Millisecond) - h := efs.NewHandler(b) + synctest.Test(t, func(t *testing.T) { + b := efs.NewInMemoryBackend("123456789012", "us-east-1") + efs.SetFSActivationDelay(b, 80*time.Millisecond) + h := efs.NewHandler(b) - rec := doREST(t, h, http.MethodPost, "/2015-02-01/file-systems", map[string]any{ - "CreationToken": "parity-lifecycle-token", - }) - require.Equal(t, http.StatusCreated, rec.Code) + rec := doREST(t, h, http.MethodPost, "/2015-02-01/file-systems", map[string]any{ + "CreationToken": "parity-lifecycle-token", + }) + require.Equal(t, http.StatusCreated, rec.Code) - var out map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) - assert.Equal(t, "creating", out["LifeCycleState"], - "newly created FS should be in 'creating' state immediately after CreateFileSystem") + assert.Equal(t, "creating", out["LifeCycleState"], + "newly created FS should be in 'creating' state immediately after CreateFileSystem") - fsID := out["FileSystemId"].(string) + fsID := out["FileSystemId"].(string) - require.Eventually(t, func() bool { - descRec := doREST(t, h, http.MethodGet, - "/2015-02-01/file-systems?FileSystemId="+fsID, nil) - if descRec.Code != http.StatusOK { - return false - } + state := func() string { + descRec := doREST(t, h, http.MethodGet, "/2015-02-01/file-systems?FileSystemId="+fsID, nil) + require.Equal(t, http.StatusOK, descRec.Code) - var descOut struct { - FileSystems []struct { - LifeCycleState string `json:"LifeCycleState"` - } `json:"FileSystems"` - } - if err := json.Unmarshal(descRec.Body.Bytes(), &descOut); err != nil || len(descOut.FileSystems) == 0 { - return false + var descOut struct { + FileSystems []struct { + LifeCycleState string `json:"LifeCycleState"` + } `json:"FileSystems"` + } + require.NoError(t, json.Unmarshal(descRec.Body.Bytes(), &descOut)) + require.NotEmpty(t, descOut.FileSystems) + + return descOut.FileSystems[0].LifeCycleState } - return descOut.FileSystems[0].LifeCycleState == "available" - }, 500*time.Millisecond, 20*time.Millisecond, - "FS should transition to 'available' within 500ms") + assert.Equal(t, "creating", state()) + + time.Sleep(80 * time.Millisecond) + synctest.Wait() + + assert.Equal(t, "available", state()) + }) } // TestCreateFileSystem_CreationTokenIdempotency verifies identical params return 409 diff --git a/services/efs/iam_enforcement_test.go b/services/efs/iam_enforcement_test.go index b5705caecf..fa53ded282 100644 --- a/services/efs/iam_enforcement_test.go +++ b/services/efs/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/efs" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/elasticfilesystem/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/efs/storage.go b/services/efs/storage.go index dd74675b2d..4991f3b8c5 100644 --- a/services/efs/storage.go +++ b/services/efs/storage.go @@ -238,3 +238,56 @@ func (b *InMemoryBackend) FileSystemHostDir(region, fsID string) (string, error) return dir, nil } + +// ErrRootDirectoryWithAccessPoint reports a root directory other than "/" combined with an access point. +var ErrRootDirectoryWithAccessPoint = errors.New("rootDirectory must be omitted or / when an access point is used") + +// ErrAccessPointFileSystemMismatch reports an access point that belongs to a different file system. +var ErrAccessPointFileSystemMismatch = errors.New("access point does not belong to the file system") + +// ResolveVolumeMount returns the host directory for an ECS efsVolumeConfiguration: the access point's root +// directory when accessPointID is set (rootDirectory must then be omitted or "/"), else rootDirectory +// under the file system. +func (b *InMemoryBackend) ResolveVolumeMount( + region, fsID, accessPointID, rootDirectory string, +) (AccessPointMount, error) { + if accessPointID == "" { + return b.resolveFileSystemRoot(region, fsID, rootDirectory) + } + + b.mu.RLock("ResolveVolumeMount") + ap, ok := b.accessPoints.Get(regionKey(region, accessPointID)) + b.mu.RUnlock() + + if !ok { + return AccessPointMount{}, fmt.Errorf("%w: %s", ErrAccessPointNotFound, accessPointID) + } + + if ap.FileSystemID != fsID { + return AccessPointMount{}, fmt.Errorf("%w: %s", ErrAccessPointFileSystemMismatch, accessPointID) + } + + if rootDirectory != "" && rootDirectory != "/" { + return AccessPointMount{}, ErrRootDirectoryWithAccessPoint + } + + return b.ResolveAccessPointMount(ap.AccessPointArn) +} + +func (b *InMemoryBackend) resolveFileSystemRoot(region, fsID, rootDirectory string) (AccessPointMount, error) { + fsDir, err := b.FileSystemHostDir(region, fsID) + if err != nil { + return AccessPointMount{}, err + } + + if cherr := os.Chmod(fsDir, rootDirPerm); cherr != nil { + return AccessPointMount{}, fmt.Errorf("chmod file system dir: %w", cherr) + } + + dir, err := ensureRootDirectory(fsDir, &RootDirectory{Path: rootDirectory}) + if err != nil { + return AccessPointMount{}, err + } + + return AccessPointMount{HostDir: dir, FileSystemID: fsID}, nil +} diff --git a/services/efs/volume_mount_test.go b/services/efs/volume_mount_test.go new file mode 100644 index 0000000000..33b0be16a5 --- /dev/null +++ b/services/efs/volume_mount_test.go @@ -0,0 +1,90 @@ +package efs_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/efs" +) + +func TestResolveVolumeMount(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + name string + root string + wantSub string + useAP bool + apOtherFS bool + unknownFS bool + unknownAP bool + wantPosix bool + }{ + {name: "fs_root", wantSub: ""}, + {name: "fs_subdir", root: "/data/x", wantSub: "data/x"}, + {name: "fs_traversal_contained", root: "/../../etc", wantSub: "etc"}, + {name: "unknown_fs", unknownFS: true, wantErr: efs.ErrNotFound}, + {name: "ap_root_dir", useAP: true, wantSub: "ap", wantPosix: true}, + {name: "ap_root_slash", useAP: true, root: "/", wantSub: "ap", wantPosix: true}, + {name: "ap_rejects_root_dir", useAP: true, root: "/x", wantErr: efs.ErrRootDirectoryWithAccessPoint}, + {name: "ap_unknown", useAP: true, unknownAP: true, wantErr: efs.ErrAccessPointNotFound}, + {name: "ap_other_fs", useAP: true, apOtherFS: true, wantErr: efs.ErrAccessPointFileSystemMismatch}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := newTestEFSBackend() + storage := t.TempDir() + b.SetStorageRoot(storage) + + fs, err := b.CreateFileSystem(context.Background(), fsReq("tok-vol-"+tt.name)) + require.NoError(t, err) + + other, err := b.CreateFileSystem(context.Background(), fsReq("tok-vol-other-"+tt.name)) + require.NoError(t, err) + + apFS := fs.FileSystemID + if tt.apOtherFS { + apFS = other.FileSystemID + } + + ap, err := b.CreateAccessPoint(context.Background(), efs.CreateAccessPointRequest{ + FileSystemID: apFS, + RootDirectory: &efs.RootDirectory{Path: "/ap", CreationInfo: &efs.CreationInfo{Permissions: "777"}}, + PosixUser: &efs.PosixUser{UID: 1001, GID: 1002}, + }) + require.NoError(t, err) + + fsID, apID := fs.FileSystemID, "" + if tt.useAP { + apID = ap.AccessPointID + } + + if tt.unknownFS { + fsID = "fs-0000000000000000" + } + + if tt.unknownAP { + apID = "fsap-0000000000000000" + } + + m, err := b.ResolveVolumeMount("us-east-1", fsID, apID, tt.root) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, filepath.Join(storage, "us-east-1", fs.FileSystemID, tt.wantSub), m.HostDir) + assert.Equal(t, tt.wantPosix, m.HasPosixUser) + }) + } +} diff --git a/services/eks/PARITY.md b/services/eks/PARITY.md index d454c66d33..e4ba162acf 100644 --- a/services/eks/PARITY.md +++ b/services/eks/PARITY.md @@ -119,7 +119,7 @@ leaks: {status: clean, note: "worker.Group timers (cluster/nodegroup/fargate/add Default `stub` is unchanged. In `docker` mode CreateCluster starts one privileged `rancher/k3s` container (tag mapped from `version`, 1.29-1.36; unmapped versions -fall back to the 1.32 image; `EKS_K3S_IMAGE` overrides) with the API port published on a +are rejected, see 2026-10-29; `EKS_K3S_IMAGE` overrides) with the API port published on a free host port, mirroring LocalStack's k3s-backed EKS (docs.localstack.cloud/aws/services/eks/). CREATING becomes ACTIVE only once `/cacerts` and an authenticated `/readyz` succeed, otherwise FAILED. @@ -1366,3 +1366,38 @@ FIXED: DescribeAddonVersions and DescribeClusterVersions honour maxResults (1-10 - Token minting (emulator extension, not an AWS API): `POST /_gopherstack/eks//serviceaccount-token` with JSON `{namespace, name, audience | audiences, ttlSeconds, podName}`; defaults `sts.amazonaws.com`, 3600s. Returns `{token, issuer, expirationTimestamp}`. Claims: `iss`, `sub=system:serviceaccount::`, `aud`, `exp`, `iat`, `nbf`, `jti`, `kubernetes.io{namespace,serviceaccount{name,uid},pod{name,uid}}`. - STS `AssumeRoleWithWebIdentity` verifies these tokens against the issuer's keys (see `services/sts/PARITY.md`). Wired by `wireEKSIRSAToSTS` in `internal/app/eks_irsa.go`. - Tests: `irsa_test.go` (`TestMintServiceAccountToken`, `TestOIDCIssuerKeys`, `TestOIDCKeysSurviveRestore`, `TestOIDCEndpoints`, `TestOIDCRouteMatcher`) and `test/integration/eks_irsa_test.go` (SDK, `stscreds.WebIdentityRoleProvider`). + +### 2026-10-29: engine knobs (gopherstack-49qct.74) + +Docker-engine settings (env vars; all invalid values are rejected, never silently ignored): + +- `EKS_K3S_IMAGE`: full image for every cluster (any Kubernetes version accepted). +- `EKS_K3S_IMAGE_TAGS`: `1.31=v1.31.5-k3s1,1.32=...` pins the `rancher/k3s` tag per Kubernetes minor; each tag must be + `v1..x-k3sN` for its own minor. Precedence: `EKS_K3S_IMAGE`, then the pin, then the built-in 1.29-1.36 tags. + A version with none of these (e.g. 1.20, valid in the stub engine) now fails CreateCluster with + `InvalidParameterException` instead of silently running the 1.32 image. +- `EKS_LB_PORTS`: default container ports to publish (`8080,8443`); 1-65535, not 6443, at most 16. +- `EKS_KUBECONFIG` + `EKS_KUBECONFIG_CONTEXT` (default: current-context): existing-cluster provider. No k3s or docker runtime + is used; once ACTIVE, DescribeCluster returns that cluster's `server` and CA (`certificate-authority-data`, or the + `certificate-authority` file read and base64-encoded; relative paths resolve against the kubeconfig's directory). + Every emulated cluster fronts the same external cluster (no per-name namespacing: nothing isolates them), nodegroups/ + add-ons stay metadata-only, the IAM token proxy is not in front, and DeleteCluster never touches the external cluster. + Credentials come from the user's own kubeconfig, not `aws eks get-token`. + +Cluster tags (read at CreateCluster and on Restore; later TagResource edits do not reconfigure a running node; ignored by +the stub engine; an invalid value fails CreateCluster with `InvalidParameterException`): + +- `gopherstack:volume-mount` = `host:container[:ro|rw],...`: bind mounts into the k3s server container. Paths must be + absolute without `..`; container path unique and not `/`; at most 16. Host mounts are off by default: with `EKS_VOLUME_MOUNT_ALLOW` empty the tag fails with + `InvalidParameterException` ("host volume mounts are disabled; set EKS_VOLUME_MOUNT_ALLOW"). `EKS_VOLUME_MOUNT_ALLOW` is a + comma list of absolute host path prefixes (not `/`, no `..`); every mount's host path must exist and, after + `filepath.Clean` + `EvalSymlinks`, lie inside an allowed prefix (also symlink-resolved), so symlink escapes and + sibling-prefix paths are rejected. +- `gopherstack:lb-ports` = `8080,8443`: replaces the `EKS_LB_PORTS` default for the cluster. Each port is published on a + free host port (same bind host as the API port); k3s servicelb is enabled for such clusters (Traefik stays off), so a + `type: LoadBalancer` Service on that port is reachable on the host. Published ports are not an AWS field, so they are + served at `GET /_gopherstack/eks/clusters//ports[?region=]` (`{clusterName, ports:[{host, containerPort, hostPort}]}`). + +Proof: `TestParse*`, `TestDockerCluster_LaunchOptions`, `TestDockerCluster_CreateRejectsInvalidOptions`, +`TestSDK_CreateClusterOptionErrors`, `TestExistingCluster_*` (eks SDK client -> DescribeCluster -> HTTPS call against a +fixture cluster), and the real-k3s `TestDockerCluster_RealK3s` (docker inspect mount, `docker port`). diff --git a/services/eks/cluster_ca.go b/services/eks/cluster_ca.go index eeaf594193..176a11b0fa 100644 --- a/services/eks/cluster_ca.go +++ b/services/eks/cluster_ca.go @@ -1,21 +1,12 @@ package eks import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "math/big" - "time" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" ) -const ( - clusterCAValidity = 10 * 365 * 24 * time.Hour - clusterCASerialBits = 127 -) +const clusterCASerialBits = 127 + +var errNotReady = k3sengine.ErrNotReady // clusterCA is the server CA the emulator pre-provisions into k3s so the probe never has to trust-on-first-use. type clusterCA struct { @@ -24,39 +15,10 @@ type clusterCA struct { } func newClusterCA(name string) (*clusterCA, error) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, fmt.Errorf("generate cluster CA key: %w", err) - } - - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), clusterCASerialBits)) - if err != nil { - return nil, fmt.Errorf("generate cluster CA serial: %w", err) - } - - now := time.Now() - tmpl := &x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: "gopherstack-eks-" + name + "-ca"}, - NotBefore: now.Add(-time.Hour), - NotAfter: now.Add(clusterCAValidity), - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, - BasicConstraintsValid: true, - IsCA: true, - } - - der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) - if err != nil { - return nil, fmt.Errorf("create cluster CA: %w", err) - } - - keyDER, err := x509.MarshalECPrivateKey(key) + ca, err := k3sengine.NewCA("gopherstack-eks-" + name + "-ca") if err != nil { - return nil, fmt.Errorf("marshal cluster CA key: %w", err) + return nil, err } - return &clusterCA{ - certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), - keyPEM: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), - }, nil + return &clusterCA{certPEM: ca.CertPEM, keyPEM: ca.KeyPEM}, nil } diff --git a/services/eks/cluster_docker_test.go b/services/eks/cluster_docker_test.go index ed84c8ea8c..c347b85f3a 100644 --- a/services/eks/cluster_docker_test.go +++ b/services/eks/cluster_docker_test.go @@ -8,6 +8,8 @@ import ( "io" "net/http" "os/exec" + "path/filepath" + "strconv" "strings" "sync" "testing" @@ -84,7 +86,9 @@ func newRealClusterBackend(t *testing.T) (*eks.InMemoryBackend, *recordingCluste rec := &recordingClusterRuntime{Runtime: rt} b := eks.NewInMemoryBackend(t.Context(), "123456789012", "us-east-1") - require.NoError(t, b.EnableClusters(eks.ClusterEngineConfig{Runtime: rec, StartTimeout: k3sStartWait})) + require.NoError(t, b.EnableClusters(eks.ClusterEngineConfig{ + Runtime: rec, StartTimeout: k3sStartWait, VolumeMountAllow: []string{filepath.Dir(t.TempDir())}, + })) t.Cleanup(b.Close) return b, rec @@ -126,7 +130,12 @@ func TestDockerCluster_RealK3s(t *testing.T) { b, rec := newRealClusterBackend(t) - _, err := b.CreateCluster("real", "1.32", "", nil, nil, nil) + mountDir := t.TempDir() + + _, err := b.CreateCluster("real", "1.32", "", nil, nil, map[string]string{ + eks.TagVolumeMount: mountDir + ":/mnt/gopherstack-test:ro", + eks.TagLBPorts: "8080", + }) require.NoError(t, err) require.Eventually(t, func() bool { @@ -151,6 +160,19 @@ func TestDockerCluster_RealK3s(t *testing.T) { require.Len(t, ids, 1) require.True(t, dockerHasContainer(t, ids[0])) + inspect, err := exec.CommandContext(t.Context(), "docker", "inspect", "-f", + "{{range .Mounts}}{{.Source}}:{{.Destination}}:{{.RW}} {{end}}", ids[0]).Output() + require.NoError(t, err) + assert.Contains(t, string(inspect), mountDir+":/mnt/gopherstack-test:false") + + published, err := b.PublishedLBPorts("real") + require.NoError(t, err) + require.Len(t, published, 1) + + dockerPort, err := exec.CommandContext(t.Context(), "docker", "port", ids[0], "8080/tcp").Output() + require.NoError(t, err) + assert.Contains(t, string(dockerPort), ":"+strconv.Itoa(published[0].HostPort)) + _, err = b.DeleteCluster("real") require.NoError(t, err) diff --git a/services/eks/cluster_engine.go b/services/eks/cluster_engine.go index f00aa7363b..80cebfef71 100644 --- a/services/eks/cluster_engine.go +++ b/services/eks/cluster_engine.go @@ -4,21 +4,14 @@ import ( "context" "encoding/base64" "errors" - "fmt" - "io" "log/slog" "net" - "regexp" "strconv" - "strings" "sync" "sync/atomic" "time" - "github.com/google/uuid" - - "github.com/blackbirdworks/gopherstack/pkgs/container" - "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/safemap" ) @@ -31,102 +24,46 @@ const ( const ( // K3sImageRepo is the image repository the cluster engine runs. - K3sImageRepo = "rancher/k3s" + K3sImageRepo = k3sengine.ImageRepo // DefaultClusterToken is the static cluster-admin bearer token, like LocalStack's K3D_CLUSTER_TOKEN default. - DefaultClusterToken = "gopherstack-eks-cluster-token" //nolint:gosec // documented default for a local emulator - - clusterDefaultHost = "127.0.0.1" - clusterStartTimeout = 5 * time.Minute - clusterProbeInterval = time.Second - clusterProbeTimeout = 5 * time.Second - clusterStopTimeout = 30 * time.Second - k3sAPIPort = 6443 - minTokenLen = 16 - majorMinorParts = 2 - firstK3sMinor = 29 - clusterStartCode = "CLUSTER_START_FAILED" - tokenFileName = "/etc/gopherstack-eks-tokens.csv" - envAdminAuth = "GOPHERSTACK_EKS_ADMIN_AUTH" - envHost = "GOPHERSTACK_EKS_HOST" - envCACert = "GOPHERSTACK_EKS_CA_CERT" - envCAKey = "GOPHERSTACK_EKS_CA_KEY" - k3sTLSDir = "/var/lib/rancher/k3s/server/tls" -) + DefaultClusterToken = k3sengine.DefaultToken -// k3sScript writes the token file and the pre-provisioned server CA, then replaces the shell with the k3s server. -const k3sScript = `printf '%s,admin,admin,"system:masters"\n' "$` + envAdminAuth + `" > ` + tokenFileName + - ` && umask 077 && mkdir -p ` + k3sTLSDir + - ` && printf '%s\n' "$` + envCACert + `" > ` + k3sTLSDir + `/server-ca.crt` + - ` && printf '%s\n' "$` + envCAKey + `" > ` + k3sTLSDir + `/server-ca.key` + - ` && unset ` + envCAKey + ` && exec /bin/k3s server --tls-san "$` + envHost + `"` + - ` --disable traefik --disable servicelb --disable metrics-server` + - ` --kube-apiserver-arg=token-auth-file=` + tokenFileName - -// k3sTags are the newest published rancher/k3s tags for Kubernetes 1.29 through 1.36, in minor order. -func k3sTags() [8]string { - return [...]string{ - "v1.29.15-k3s1", "v1.30.14-k3s2", "v1.31.14-k3s1", "v1.32.13-k3s1", - "v1.33.13-k3s1", "v1.34.12-k3s1", "v1.35.9-k3s1", "v1.36.5-k3s1", - } -} + clusterStopTimeout = 30 * time.Second + k3sAPIPort = k3sengine.APIPort + majorMinorParts = 2 + clusterStartCode = "CLUSTER_START_FAILED" +) var ( // ErrInvalidClusterEngineConfig is returned when the cluster engine host or token is unusable. - ErrInvalidClusterEngineConfig = errors.New("invalid EKS cluster engine configuration") + ErrInvalidClusterEngineConfig = k3sengine.ErrInvalidConfig // ErrDefaultTokenExposed is returned when the public default token would be exposed off-host. ErrDefaultTokenExposed = errors.New("EKS_CLUSTER_TOKEN must be set when EKS_CLUSTER_HOST is not loopback") - - tokenPattern = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) - hostPattern = regexp.MustCompile(`^[A-Za-z0-9.:-]+$`) ) // ClusterEngineEnabled reports whether engine selects docker-backed clusters. func ClusterEngineEnabled(engine string) bool { return engine == EngineDocker } // K3sImage maps a Kubernetes minor version to its pinned k3s image; exact is false when it fell back to the default. -func K3sImage(version string) (string, bool) { - if tag, ok := k3sTag(version); ok { - return K3sImageRepo + ":" + tag, true - } - - tag, _ := k3sTag(defaultK8sVersion) - - return K3sImageRepo + ":" + tag, false -} - -func k3sTag(version string) (string, bool) { - parts := strings.Split(version, ".") - if len(parts) < majorMinorParts || parts[0] != "1" { - return "", false - } - - tags := k3sTags() - - minor, err := strconv.Atoi(parts[1]) - if err != nil || minor < firstK3sMinor || minor >= firstK3sMinor+len(tags) { - return "", false - } - - return tags[minor-firstK3sMinor], true -} +func K3sImage(version string) (string, bool) { return k3sengine.Image(version, defaultK8sVersion) } // ClusterRuntime is the slice of container.Runtime the cluster engine needs. -type ClusterRuntime interface { - CreateAndStart(ctx context.Context, spec container.Spec) (string, error) - StopAndRemove(ctx context.Context, containerID string) error -} +type ClusterRuntime = k3sengine.Runtime // ClusterEngineConfig configures docker-backed EKS clusters. type ClusterEngineConfig struct { - Runtime ClusterRuntime - Credentials CredentialResolver - Ports *portalloc.Allocator - Logger *slog.Logger - Probe func(ctx context.Context, addr, token string) ([]byte, error) - Host string - Token string - Image string - StartTimeout time.Duration + Runtime ClusterRuntime + Credentials CredentialResolver + Ports *portalloc.Allocator + Logger *slog.Logger + Probe func(ctx context.Context, addr, token string) ([]byte, error) + Host string + Token string + Image string + ImageTags map[string]string + LBPorts []int + VolumeMountAllow []string + StartTimeout time.Duration } type liveCluster struct { @@ -140,6 +77,9 @@ type liveCluster struct { image string joinToken string ca []byte + mounts []string + lbPorts []int + lbHost []int port int proxyPort int dead atomic.Bool @@ -147,6 +87,7 @@ type liveCluster struct { } type clusterEngine struct { + core *k3sengine.Engine clusters map[string]*liveCluster agents *safemap.Map[string, *agentNode] cfg ClusterEngineConfig @@ -155,26 +96,30 @@ type clusterEngine struct { // EnableClusters switches the backend to docker mode: each CreateCluster starts a single-node k3s container. func (b *InMemoryBackend) EnableClusters(cfg ClusterEngineConfig) error { - if cfg.Host == "" { - cfg.Host = clusterDefaultHost - } + core, err := k3sengine.New(k3sengine.Config{ + Runtime: cfg.Runtime, Ports: cfg.Ports, Logger: cfg.Logger, Probe: cfg.Probe, Prefix: "eks", + Host: cfg.Host, Token: cfg.Token, StartTimeout: cfg.StartTimeout, + }) + if err != nil { + if errors.Is(err, k3sengine.ErrDefaultTokenExposed) { + return ErrDefaultTokenExposed + } - defaulted := cfg.Token == "" - if defaulted { - cfg.Token = DefaultClusterToken + return err } - if !hostPattern.MatchString(cfg.Host) || !tokenPattern.MatchString(cfg.Token) || len(cfg.Token) < minTokenLen { - return fmt.Errorf("%w: host must match %s and token %s (min %d chars)", - ErrInvalidClusterEngineConfig, hostPattern, tokenPattern, minTokenLen) + cfg.Host, cfg.Token = core.Host(), core.Token() + + if err = validateK3sImageTags(cfg.ImageTags); err != nil { + return err } - if defaulted && container.BindHostFor(cfg.Host) != container.LoopbackHost { - return ErrDefaultTokenExposed + if err = validateMountAllow(cfg.VolumeMountAllow); err != nil { + return err } - if cfg.StartTimeout <= 0 { - cfg.StartTimeout = clusterStartTimeout + if err = validateLBPortList(cfg.LBPorts); err != nil { + return err } b.mu.Lock("EnableClusters") @@ -183,6 +128,7 @@ func (b *InMemoryBackend) EnableClusters(cfg ClusterEngineConfig) error { b.credentials = cfg.Credentials b.clusterEng = &clusterEngine{ cfg: cfg, + core: core, clusters: make(map[string]*liveCluster), agents: safemap.New[string, *agentNode]("eks.cluster.agents"), } @@ -190,62 +136,14 @@ func (b *InMemoryBackend) EnableClusters(cfg ClusterEngineConfig) error { return nil } -func (e *clusterEngine) addr(lc *liveCluster) string { - return net.JoinHostPort(e.cfg.Host, strconv.Itoa(lc.port)) -} - -func (e *clusterEngine) image(version string) string { - if e.cfg.Image != "" { - return e.cfg.Image - } +func (e *clusterEngine) addr(lc *liveCluster) string { return e.core.Addr(lc.port) } - img, _ := K3sImage(version) +func (e *clusterEngine) log(ctx context.Context) *slog.Logger { return e.core.Log(ctx) } - return img -} - -func (e *clusterEngine) spec(name string, lc *liveCluster, port int, ca *clusterCA) container.Spec { - return container.Spec{ - Name: "gopherstack-eks-" + name + "-" + uuid.NewString()[:8], - Image: lc.image, - Entrypoint: []string{"/bin/sh", "-c", k3sScript}, - Env: []string{ - envAdminAuth + "=" + e.cfg.Token, envHost + "=" + e.cfg.Host, "K3S_TOKEN=" + lc.joinToken, - envCACert + "=" + string(ca.certPEM), envCAKey + "=" + string(ca.keyPEM), - }, - Ports: []string{ - container.PortSpec(container.BindHostFor(e.cfg.Host), strconv.Itoa(port), strconv.Itoa(k3sAPIPort)), - }, - Tmpfs: []string{"/run", "/var/run"}, - Privileged: true, - } -} - -func (e *clusterEngine) log(ctx context.Context) *slog.Logger { - if e.cfg.Logger != nil { - return e.cfg.Logger - } - - return logger.Load(ctx) -} +func (e *clusterEngine) releasePort(port int) { e.core.ReleasePort(port) } -func (e *clusterEngine) acquirePort(name string) (int, error) { - return portalloc.AcquireFrom(e.cfg.Ports, "eks-"+name) -} - -func (e *clusterEngine) releasePort(port int) { - portalloc.ReleaseTo(e.cfg.Ports, port) -} - -func (e *clusterEngine) remove(containerID string, port int) { - ctx, cancel := context.WithTimeout(context.Background(), clusterStopTimeout) - defer cancel() - - if err := e.cfg.Runtime.StopAndRemove(ctx, containerID); err != nil { - e.log(ctx).WarnContext(ctx, "eks: cluster container removal failed", "container", containerID, "error", err) - } - - e.releasePort(port) +func (e *clusterEngine) remove(containerID string, ports ...int) { + e.core.Remove(containerID, ports...) } func (e *clusterEngine) reap(lcs []*liveCluster) { @@ -268,7 +166,7 @@ func (e *clusterEngine) reap(lcs []*liveCluster) { } if lc.containerID != "" { - e.wg.Go(func() { e.remove(lc.containerID, lc.port) }) + e.wg.Go(func() { e.remove(lc.containerID, append([]int{lc.port}, lc.lbHost...)...) }) } } } @@ -329,65 +227,50 @@ func (b *InMemoryBackend) closeClusters() { e.reap(lcs) e.wg.Wait() e.agents.Close() - - if c, ok := e.cfg.Runtime.(io.Closer); ok { - _ = c.Close() - } + e.core.Close() } // launchClusterLocked registers a pending cluster and starts its container in the background. -func (b *InMemoryBackend) launchClusterLocked(name, version string) { +func (b *InMemoryBackend) launchClusterLocked(name, version string, kv map[string]string) error { e := b.clusterEng if e == nil { - return + return nil + } + + opts, image, err := e.resolveLaunch(version, kv) + if err != nil { + return err } ctx, cancel := context.WithCancel(context.Background()) lc := &liveCluster{ - cancel: cancel, name: name, kick: make(chan struct{}, 1), image: e.image(version), joinToken: randomHex32(), + cancel: cancel, name: name, kick: make(chan struct{}, 1), image: image, joinToken: randomHex32(), + mounts: opts.mounts, lbPorts: opts.lbPorts, } e.clusters[name] = lc e.wg.Go(func() { b.runCluster(ctx, name, version, lc) }) + + return nil } func (b *InMemoryBackend) runCluster(ctx context.Context, name, version string, lc *liveCluster) { e := b.clusterEng - ca, err := newClusterCA(name) + inst, err := e.core.Launch(ctx, + k3sengine.Node{Name: name, Image: lc.image, JoinToken: lc.joinToken, Mounts: lc.mounts, LBPorts: lc.lbPorts}, + func(i *k3sengine.Instance) bool { return b.recordCluster(name, lc, i.ContainerID, i.Port, i.LBHost) }) if err != nil { - b.failCluster(ctx, name, lc, err) - - return - } - - var port int - - id, err := container.CreateAndStartRetry(ctx, e.cfg.Runtime, - func() (container.Spec, error) { - p, perr := e.acquirePort(name) - if perr != nil { - return container.Spec{}, perr - } - - port = p - - return e.spec(name, lc, p, ca), nil - }, - func() { e.releasePort(port) }) - if err != nil { - b.failCluster(ctx, name, lc, err) + if !errors.Is(err, k3sengine.ErrSuperseded) { + b.failCluster(ctx, name, lc, err) + } return } - if !b.recordCluster(name, lc, id, port) { - e.remove(id, port) + ca := &clusterCA{certPEM: inst.CA.CertPEM, keyPEM: inst.CA.KeyPEM} - return - } - - caPEM, err := e.awaitReady(ctx, e.addr(lc), ca.certPEM) + caPEM, err := e.core.AwaitReady(ctx, e.addr(lc), ca.certPEM) if err != nil { b.failCluster(ctx, name, lc, err) @@ -420,7 +303,7 @@ func (b *InMemoryBackend) runCluster(ctx context.Context, name, version string, e.wg.Go(func() { b.runReconciler(ctx, name, version, lc) }) } -func (b *InMemoryBackend) recordCluster(name string, lc *liveCluster, id string, port int) bool { +func (b *InMemoryBackend) recordCluster(name string, lc *liveCluster, id string, port int, lbHost []int) bool { b.mu.Lock("recordCluster") defer b.mu.Unlock() @@ -430,6 +313,7 @@ func (b *InMemoryBackend) recordCluster(name string, lc *liveCluster, id string, lc.containerID = id lc.port = port + lc.lbHost = lbHost return true } @@ -486,41 +370,10 @@ func (b *InMemoryBackend) failCluster(ctx context.Context, name string, lc *live b.clusterEng.reap([]*liveCluster{lc}) } -func (e *clusterEngine) probe(ctx context.Context, addr string, ca []byte) ([]byte, error) { - if e.cfg.Probe != nil { - return e.cfg.Probe(ctx, addr, e.cfg.Token) - } - - return ca, probeK3s(ctx, addr, e.cfg.Token, ca) -} - -func (e *clusterEngine) awaitReady(ctx context.Context, addr string, ca []byte) ([]byte, error) { - ctx, cancel := context.WithTimeout(ctx, e.cfg.StartTimeout) - defer cancel() - - ticker := time.NewTicker(clusterProbeInterval) - defer ticker.Stop() - - for { - pctx, pcancel := context.WithTimeout(ctx, clusterProbeTimeout) - got, err := e.probe(pctx, addr, ca) - - pcancel() - - if err == nil { - return got, nil - } - - select { - case <-ctx.Done(): - return nil, fmt.Errorf("API server not ready: %w", err) - case <-ticker.C: - } - } -} - // applyLiveEndpoint replaces the synthetic endpoint and CA of a ready docker cluster with the real ones. func (b *InMemoryBackend) applyLiveEndpoint(cp *Cluster) { + b.applyExternalEndpoint(cp) + if b.clusterEng == nil { return } @@ -546,7 +399,20 @@ func (b *InMemoryBackend) relaunchClustersLocked() { } cl.Status = statusCreating - b.launchClusterLocked(cl.Name, cl.Version) + + var kv map[string]string + if cl.Tags != nil { + kv = cl.Tags.Clone() + } + + if err := b.launchClusterLocked(cl.Name, cl.Version, kv); err != nil { + cl.Status = statusFailed + b.clusterEng.log(context.Background()).Warn("eks: cluster not relaunched", + "code", clusterStartCode, "cluster", cl.Name, "error", err) + + continue + } + b.resetEngineResourcesLocked(cl.Name) } } diff --git a/services/eks/cluster_engine_test.go b/services/eks/cluster_engine_test.go index 119d78d508..d68c8fd6a8 100644 --- a/services/eks/cluster_engine_test.go +++ b/services/eks/cluster_engine_test.go @@ -272,7 +272,6 @@ func TestDockerCluster_Lifecycle(t *testing.T) { }{ {name: "mapped", version: "1.31", wantImage: "rancher/k3s:v1.31.14-k3s1"}, {name: "default_version", version: "", wantImage: "rancher/k3s:v1.32.13-k3s1"}, - {name: "unmapped_falls_back", version: "1.20", wantImage: "rancher/k3s:v1.32.13-k3s1"}, } for _, tt := range tests { diff --git a/services/eks/cluster_options.go b/services/eks/cluster_options.go new file mode 100644 index 0000000000..a5b66c7303 --- /dev/null +++ b/services/eks/cluster_options.go @@ -0,0 +1,296 @@ +package eks + +import ( + "fmt" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" +) + +const ( + // TagVolumeMount is the cluster tag that bind-mounts host paths into the k3s node container. + TagVolumeMount = "gopherstack:volume-mount" + // TagLBPorts is the cluster tag that publishes load-balancer ports from the k3s node container. + TagLBPorts = "gopherstack:lb-ports" + + maxVolumeMounts = 16 + maxLBPorts = 16 + maxPort = 65535 + mountModeRO = "ro" + mountModeRW = "rw" + mountFieldsMin = 2 + mountFieldsMax = 3 +) + +var k3sTagPattern = regexp.MustCompile(`^v1\.(\d+)\.\d+[-+]k3s\d+$`) + +// launchOptions are the per-cluster container settings resolved from cluster tags and server defaults. +type launchOptions struct { + mounts []string + lbPorts []int +} + +// PublishedPort is a container port published on a host port. +type PublishedPort struct { + Host string `json:"host"` + ContainerPort int `json:"containerPort"` + HostPort int `json:"hostPort"` +} + +func absCleanPath(kind, p string) (string, error) { + if !path.IsAbs(p) { + return "", fmt.Errorf("%w: %s path %q must be absolute", ErrValidation, kind, p) + } + + for seg := range strings.SplitSeq(p, "/") { + if seg == ".." { + return "", fmt.Errorf("%w: %s path %q must not contain '..'", ErrValidation, kind, p) + } + } + + return path.Clean(p), nil +} + +// ParseVolumeMounts parses "host:container[:ro|rw],..." into bind specs. +func ParseVolumeMounts(v string) ([]string, error) { + if strings.TrimSpace(v) == "" { + return nil, nil + } + + items := strings.Split(v, ",") + if len(items) > maxVolumeMounts { + return nil, fmt.Errorf("%w: at most %d volume mounts are allowed", ErrValidation, maxVolumeMounts) + } + + targets := make(map[string]bool, len(items)) + out := make([]string, 0, len(items)) + + for _, item := range items { + parts := strings.Split(strings.TrimSpace(item), ":") + if len(parts) < mountFieldsMin || len(parts) > mountFieldsMax { + return nil, fmt.Errorf("%w: volume mount %q must be host:container[:ro]", ErrValidation, item) + } + + host, err := absCleanPath("host", parts[0]) + if err != nil { + return nil, err + } + + target, err := absCleanPath("container", parts[1]) + if err != nil { + return nil, err + } + + if target == "/" || targets[target] { + return nil, fmt.Errorf("%w: invalid or duplicate container path %q", ErrValidation, parts[1]) + } + + targets[target] = true + bind := host + ":" + target + + if len(parts) == mountFieldsMax { + if parts[2] != mountModeRO && parts[2] != mountModeRW { + return nil, fmt.Errorf("%w: volume mount mode %q must be ro or rw", ErrValidation, parts[2]) + } + + bind += ":" + parts[2] + } + + out = append(out, bind) + } + + return out, nil +} + +// ParseLBPorts parses "8080,8443" into container ports to publish. +func ParseLBPorts(v string) ([]int, error) { + if strings.TrimSpace(v) == "" { + return nil, nil + } + + items := strings.Split(v, ",") + if len(items) > maxLBPorts { + return nil, fmt.Errorf("%w: at most %d load balancer ports are allowed", ErrValidation, maxLBPorts) + } + + seen := make(map[int]bool, len(items)) + out := make([]int, 0, len(items)) + + for _, item := range items { + p, err := strconv.Atoi(strings.TrimSpace(item)) + if err != nil || p < 1 || p > maxPort || p == k3sAPIPort { + return nil, fmt.Errorf("%w: load balancer port %q must be 1-%d and not %d", + ErrValidation, item, maxPort, k3sAPIPort) + } + + if seen[p] { + return nil, fmt.Errorf("%w: duplicate load balancer port %d", ErrValidation, p) + } + + seen[p] = true + out = append(out, p) + } + + return out, nil +} + +// ParseK3sImageTags parses "1.31=v1.31.5-k3s1,1.32=..." into a minor-to-tag pin map. +func ParseK3sImageTags(v string) (map[string]string, error) { + out := make(map[string]string) + + if strings.TrimSpace(v) == "" { + return out, nil + } + + for item := range strings.SplitSeq(v, ",") { + minor, tag, ok := strings.Cut(strings.TrimSpace(item), "=") + if !ok { + return nil, fmt.Errorf("%w: k3s tag pin %q must be =", ErrInvalidClusterEngineConfig, item) + } + + out[minor] = tag + } + + if err := validateK3sImageTags(out); err != nil { + return nil, err + } + + return out, nil +} + +func validateK3sImageTags(tags map[string]string) error { + for minor, tag := range tags { + m := k3sTagPattern.FindStringSubmatch(tag) + if m == nil || "1."+m[1] != minor { + return fmt.Errorf("%w: k3s tag %q is not a v%s.x-k3sN tag", ErrInvalidClusterEngineConfig, tag, minor) + } + + if _, ok := k8sMinor(minor); !ok { + return fmt.Errorf("%w: unsupported Kubernetes minor %q", ErrInvalidClusterEngineConfig, minor) + } + } + + return nil +} + +func validateLBPortList(ports []int) error { + for _, p := range ports { + if p < 1 || p > maxPort || p == k3sAPIPort { + return fmt.Errorf("%w: load balancer port %d out of range", ErrInvalidClusterEngineConfig, p) + } + } + + return nil +} + +// imageFor resolves the k3s image: full override, then per-minor pin, then built-in tags; otherwise unsupported. +func (e *clusterEngine) imageFor(version string) (string, error) { + if e.cfg.Image != "" { + return e.cfg.Image, nil + } + + if key, ok := k3sengine.MinorKey(version); ok { + if tag, pinned := e.cfg.ImageTags[key]; pinned { + return K3sImageRepo + ":" + tag, nil + } + } + + if tag, ok := k3sengine.Tag(version); ok { + return K3sImageRepo + ":" + tag, nil + } + + return "", fmt.Errorf("%w: unsupported Kubernetes version %q for the cluster engine", ErrValidation, version) +} + +// ParseVolumeMountAllow parses a comma list of absolute host path prefixes. +func ParseVolumeMountAllow(v string) ([]string, error) { + var out []string + + for item := range strings.SplitSeq(v, ",") { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + + return out, validateMountAllow(out) +} + +func validateMountAllow(prefixes []string) error { + for _, p := range prefixes { + clean, err := absCleanPath("allowed", p) + if err != nil || clean == "/" { + return fmt.Errorf("%w: volume mount allowlist entry %q must be an absolute path other than /", + ErrInvalidClusterEngineConfig, p) + } + } + + return nil +} + +func insidePrefix(p, prefix string) bool { + return p == prefix || strings.HasPrefix(p, prefix+string(filepath.Separator)) +} + +// checkMountsAllowed requires every host path to resolve (symlinks included) inside an allowed prefix. +func (e *clusterEngine) checkMountsAllowed(mounts []string) error { + if len(mounts) == 0 { + return nil + } + + if len(e.cfg.VolumeMountAllow) == 0 { + return fmt.Errorf("%w: host volume mounts are disabled; set EKS_VOLUME_MOUNT_ALLOW", ErrValidation) + } + + for _, m := range mounts { + host, _, _ := strings.Cut(m, ":") + + resolved, err := filepath.EvalSymlinks(filepath.Clean(host)) + if err != nil || !e.allowedHost(resolved) { + return fmt.Errorf("%w: host path %q is not inside EKS_VOLUME_MOUNT_ALLOW", ErrValidation, host) + } + } + + return nil +} + +func (e *clusterEngine) allowedHost(resolved string) bool { + for _, a := range e.cfg.VolumeMountAllow { + if ra, err := filepath.EvalSymlinks(filepath.Clean(a)); err == nil && insidePrefix(resolved, ra) { + return true + } + } + + return false +} + +// resolveLaunch validates the cluster's tags against the engine and returns its container options. +func (e *clusterEngine) resolveLaunch(version string, kv map[string]string) (launchOptions, string, error) { + img, err := e.imageFor(version) + if err != nil { + return launchOptions{}, "", err + } + + opts := launchOptions{lbPorts: e.cfg.LBPorts} + + if v, ok := kv[TagVolumeMount]; ok { + if opts.mounts, err = ParseVolumeMounts(v); err != nil { + return launchOptions{}, "", err + } + + if err = e.checkMountsAllowed(opts.mounts); err != nil { + return launchOptions{}, "", err + } + } + + if v, ok := kv[TagLBPorts]; ok { + if opts.lbPorts, err = ParseLBPorts(v); err != nil { + return launchOptions{}, "", err + } + } + + return opts, img, nil +} diff --git a/services/eks/cluster_options_test.go b/services/eks/cluster_options_test.go new file mode 100644 index 0000000000..fa8e520a93 --- /dev/null +++ b/services/eks/cluster_options_test.go @@ -0,0 +1,675 @@ +package eks_test + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + ekssdk "github.com/aws/aws-sdk-go-v2/service/eks" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/services/eks" +) + +func TestParseVolumeMounts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want []string + wantErr bool + }{ + {name: "empty", in: ""}, + {name: "rw_default", in: "/data:/mnt/data", want: []string{"/data:/mnt/data"}}, + {name: "ro", in: "/data:/mnt/data:ro", want: []string{"/data:/mnt/data:ro"}}, + {name: "multiple_cleaned", in: "/a//b:/x, /c:/y:rw", want: []string{"/a/b:/x", "/c:/y:rw"}}, + {name: "relative_host", in: "data:/mnt", wantErr: true}, + {name: "relative_container", in: "/data:mnt", wantErr: true}, + {name: "dotdot_host", in: "/data/../etc:/mnt", wantErr: true}, + {name: "dotdot_container", in: "/data:/mnt/..", wantErr: true}, + {name: "bad_mode", in: "/data:/mnt:rx", wantErr: true}, + {name: "missing_container", in: "/data", wantErr: true}, + {name: "too_many_fields", in: "/a:/b:ro:x", wantErr: true}, + {name: "root_target", in: "/data:/", wantErr: true}, + {name: "duplicate_target", in: "/a:/x,/b:/x", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := eks.ParseVolumeMounts(tt.in) + if tt.wantErr { + require.ErrorIs(t, err, eks.ErrValidation) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseLBPorts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want []int + wantErr bool + }{ + {name: "empty", in: ""}, + {name: "two", in: "8080, 8443", want: []int{8080, 8443}}, + {name: "not_a_number", in: "http", wantErr: true}, + {name: "zero", in: "0", wantErr: true}, + {name: "too_big", in: "70000", wantErr: true}, + {name: "api_port", in: "6443", wantErr: true}, + {name: "duplicate", in: "80,80", wantErr: true}, + {name: "trailing_comma", in: "80,", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := eks.ParseLBPorts(tt.in) + if tt.wantErr { + require.ErrorIs(t, err, eks.ErrValidation) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseK3sImageTags(t *testing.T) { + t.Parallel() + + tests := []struct { + want map[string]string + name string + in string + wantErr bool + }{ + {name: "empty", in: "", want: map[string]string{}}, + {name: "one", in: "1.31=v1.31.5-k3s1", want: map[string]string{"1.31": "v1.31.5-k3s1"}}, + {name: "two", in: "1.31=v1.31.5-k3s1,1.32=v1.32.1-k3s2", want: map[string]string{ + "1.31": "v1.31.5-k3s1", "1.32": "v1.32.1-k3s2", + }}, + {name: "minor_mismatch", in: "1.31=v1.32.1-k3s1", wantErr: true}, + {name: "not_a_tag", in: "1.31=latest", wantErr: true}, + {name: "no_equals", in: "v1.31.5-k3s1", wantErr: true}, + {name: "unsupported_minor", in: "1.5=v1.5.1-k3s1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := eks.ParseK3sImageTags(tt.in) + if tt.wantErr { + require.ErrorIs(t, err, eks.ErrInvalidClusterEngineConfig) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDockerCluster_LaunchOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + tags map[string]string + name string + version string + wantImage string + wantMounts []string + wantLBPorts []int + cfg eks.ClusterEngineConfig + wantServLB bool + wantNoVolume bool + }{ + {name: "defaults", version: "1.31", wantImage: "rancher/k3s:v1.31.14-k3s1"}, + { + name: "tag_mount_and_ports", version: "1.31", wantImage: "rancher/k3s:v1.31.14-k3s1", + tags: map[string]string{ + eks.TagVolumeMount: "{d}/src:/mnt/src:ro,{d}/b:/mnt/b", + eks.TagLBPorts: "8080,8443", + }, + wantMounts: []string{"{d}/src:/mnt/src:ro", "{d}/b:/mnt/b"}, wantLBPorts: []int{8080, 8443}, + wantServLB: true, + }, + { + name: "setting_default_ports", version: "1.31", wantImage: "rancher/k3s:v1.31.14-k3s1", + cfg: eks.ClusterEngineConfig{LBPorts: []int{9000}}, + wantLBPorts: []int{9000}, wantServLB: true, + }, + { + name: "tag_overrides_default_ports", version: "1.31", wantImage: "rancher/k3s:v1.31.14-k3s1", + cfg: eks.ClusterEngineConfig{LBPorts: []int{9000}}, + tags: map[string]string{eks.TagLBPorts: "7000"}, + wantLBPorts: []int{7000}, wantServLB: true, + }, + { + name: "pinned_tag", version: "1.31", wantImage: "rancher/k3s:v1.31.1-k3s9", + cfg: eks.ClusterEngineConfig{ImageTags: map[string]string{"1.31": "v1.31.1-k3s9"}}, + }, + { + name: "pin_other_minor_ignored", version: "1.31", wantImage: "rancher/k3s:v1.31.14-k3s1", + cfg: eks.ClusterEngineConfig{ImageTags: map[string]string{"1.32": "v1.32.1-k3s9"}}, + }, + { + name: "full_image_override", version: "1.31", wantImage: "registry.test/k3s:custom", + cfg: eks.ClusterEngineConfig{ + Image: "registry.test/k3s:custom", ImageTags: map[string]string{"1.31": "v1.31.1-k3s9"}, + }, + }, + {name: "override_allows_unmapped", version: "1.20", wantImage: "registry.test/k3s:custom", + cfg: eks.ClusterEngineConfig{Image: "registry.test/k3s:custom"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "src"), 0o750)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "b"), 0o750)) + + tags := map[string]string{} + for k, v := range tt.tags { + tags[k] = strings.ReplaceAll(v, "{d}", dir) + } + + for i, m := range tt.wantMounts { + tt.wantMounts[i] = strings.ReplaceAll(m, "{d}", dir) + } + + rt := &fakeClusterRuntime{} + tt.cfg.Probe = newGatedClusterProbe().probe + tt.cfg.Ports = mustPortAlloc(t) + tt.cfg.VolumeMountAllow = []string{dir} + b := dockerClusterBackend(t, rt, tt.cfg) + + _, err := b.CreateCluster("c1", tt.version, "", nil, nil, tags) + require.NoError(t, err) + waitSpecs(t, rt) + + spec := rt.createdSpecs()[0] + assert.Equal(t, tt.wantImage, spec.Image) + assert.Equal(t, tt.wantMounts, spec.Mounts) + assert.Equal(t, tt.wantServLB, !strings.Contains(spec.Entrypoint[2], "--disable servicelb")) + require.Len(t, spec.Ports, 1+len(tt.wantLBPorts)) + + var published []eks.PublishedPort + + require.Eventually(t, func() bool { + published, err = b.PublishedLBPorts("c1") + + return err == nil && len(published) == len(tt.wantLBPorts) + }, 10*time.Second, 10*time.Millisecond) + + for i, want := range tt.wantLBPorts { + assert.Equal(t, want, published[i].ContainerPort) + assert.Equal(t, "127.0.0.1", published[i].Host) + assert.Equal(t, + container.PortSpec("127.0.0.1", strconv.Itoa(published[i].HostPort), strconv.Itoa(want)), + spec.Ports[1+i]) + } + }) + } +} + +func mustPortAlloc(t *testing.T) *portalloc.Allocator { + t.Helper() + + a, err := portalloc.New(41000, 41400) + require.NoError(t, err) + + return a +} + +func TestEnableClusters_RejectsBadOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg eks.ClusterEngineConfig + }{ + {name: "bad_tag", cfg: eks.ClusterEngineConfig{ImageTags: map[string]string{"1.31": "latest"}}}, + { + name: "tag_minor_mismatch", + cfg: eks.ClusterEngineConfig{ImageTags: map[string]string{"1.31": "v1.30.1-k3s1"}}, + }, + {name: "api_port", cfg: eks.ClusterEngineConfig{LBPorts: []int{6443}}}, + {name: "port_zero", cfg: eks.ClusterEngineConfig{LBPorts: []int{0}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := eks.NewInMemoryBackend(t.Context(), "000000000000", "us-east-1") + tt.cfg.Runtime = &fakeClusterRuntime{} + tt.cfg.Token = testToken + + require.ErrorIs(t, b.EnableClusters(tt.cfg), eks.ErrInvalidClusterEngineConfig) + }) + } +} + +func TestDockerCluster_CreateRejectsInvalidOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + tags map[string]string + name string + version string + }{ + {name: "relative_mount", version: "1.31", tags: map[string]string{eks.TagVolumeMount: "rel:/mnt"}}, + {name: "dotdot_mount", version: "1.31", tags: map[string]string{eks.TagVolumeMount: "/a/../b:/mnt"}}, + {name: "bad_port", version: "1.31", tags: map[string]string{eks.TagLBPorts: "abc"}}, + {name: "unsupported_version", version: "1.20"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := &fakeClusterRuntime{} + b := dockerClusterBackend(t, rt, eks.ClusterEngineConfig{Probe: newGatedClusterProbe().probe}) + + _, err := b.CreateCluster("c1", tt.version, "", nil, nil, tt.tags) + require.ErrorIs(t, err, eks.ErrValidation) + assert.Equal(t, 0, b.ClusterCount()) + assert.Empty(t, rt.createdSpecs()) + }) + } +} + +func TestDockerCluster_StubEngineIgnoresOptionTags(t *testing.T) { + t.Parallel() + + b := eks.NewInMemoryBackend(t.Context(), "000000000000", "us-east-1") + _, err := b.CreateCluster("c1", "1.31", "", nil, nil, map[string]string{eks.TagVolumeMount: "rel:/mnt"}) + require.NoError(t, err) +} + +func TestSDK_CreateClusterOptionErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + tags map[string]string + name string + version string + }{ + {name: "bad_mount", version: "1.31", tags: map[string]string{eks.TagVolumeMount: "rel:/mnt"}}, + {name: "bad_ports", version: "1.31", tags: map[string]string{eks.TagLBPorts: "99999"}}, + {name: "unsupported_version", version: "1.20"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := dockerClusterBackend(t, &fakeClusterRuntime{}, eks.ClusterEngineConfig{ + Probe: newGatedClusterProbe().probe, + }) + client := newTestEKSClient(t, eks.NewHandler(b)) + + _, err := client.CreateCluster(t.Context(), &ekssdk.CreateClusterInput{ + Name: aws.String("c1"), Version: aws.String(tt.version), Tags: tt.tags, + RoleArn: aws.String("arn:aws:iam::123456789012:role/eks-role"), + ResourcesVpcConfig: vpcReq(), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "InvalidParameterException") + }) + } +} + +func TestServePublishedPorts(t *testing.T) { + t.Parallel() + + rt := &fakeClusterRuntime{} + b := dockerClusterBackend(t, rt, eks.ClusterEngineConfig{ + Probe: newGatedClusterProbe().probe, Ports: mustPortAlloc(t), + }) + _, err := b.CreateCluster("c1", "1.31", "", nil, nil, map[string]string{eks.TagLBPorts: "8080"}) + require.NoError(t, err) + + h := eks.NewHandler(b) + e := echo.New() + e.GET("/_gopherstack/eks/clusters/:name/ports", h.ServePublishedPorts) + + tests := []struct { + name string + path string + wantCode int + wantPorts int + }{ + {name: "published", path: "c1", wantCode: http.StatusOK, wantPorts: 1}, + {name: "missing", path: "nope", wantCode: http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Eventually(t, func() bool { + p, _ := b.PublishedLBPorts("c1") + + return len(p) == 1 + }, 10*time.Second, 10*time.Millisecond) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/_gopherstack/eks/clusters/"+tt.path+"/ports", nil)) + require.Equal(t, tt.wantCode, rec.Code) + + if tt.wantCode != http.StatusOK { + return + } + + var body struct { + ClusterName string `json:"clusterName"` + Ports []struct { + ContainerPort int `json:"containerPort"` + HostPort int `json:"hostPort"` + } `json:"ports"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "c1", body.ClusterName) + require.Len(t, body.Ports, tt.wantPorts) + assert.Equal(t, 8080, body.Ports[0].ContainerPort) + assert.Positive(t, body.Ports[0].HostPort) + }) + } +} + +func vpcReq() *ekstypes.VpcConfigRequest { + return &ekstypes.VpcConfigRequest{SubnetIds: []string{"subnet-abc123"}} +} + +func pemEncodeCert(der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func writeKubeconfig(t *testing.T, dir, body string) string { + t.Helper() + + p := filepath.Join(dir, "kubeconfig") + require.NoError(t, os.WriteFile(p, []byte(body), 0o600)) + + return p +} + +func kubeconfigFixture(server, caData, caFile string) string { + ca := "" + if caData != "" { + ca = " certificate-authority-data: " + caData + "\n" + } + + if caFile != "" { + ca = " certificate-authority: " + caFile + "\n" + } + + return `apiVersion: v1 +kind: Config +current-context: primary +clusters: + - name: primary-cluster + cluster: + server: ` + server + ` +` + ca + ` - name: other-cluster + cluster: + server: https://other.example:6443 +contexts: + - name: primary + context: {cluster: primary-cluster, user: u} + - name: other + context: {cluster: other-cluster, user: u} +users: + - name: u + user: {token: t} +` +} + +func TestExistingCluster_Load(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kubeContext string + body string + wantEndpoint string + wantCA string + wantErr bool + }{ + {name: "current_context", body: kubeconfigFixture("https://primary.example:6443", "Q0E=", ""), + wantEndpoint: "https://primary.example:6443", wantCA: "Q0E="}, + {name: "explicit_context", kubeContext: "other", body: kubeconfigFixture("https://p:1", "Q0E=", ""), + wantEndpoint: "https://other.example:6443"}, + { + name: "unknown_context", + kubeContext: "nope", + body: kubeconfigFixture("https://p:1", "Q0E=", ""), + wantErr: true, + }, + {name: "not_yaml", body: ":\n- [", wantErr: true}, + {name: "missing_ca_file", body: kubeconfigFixture("https://p:1", "", "missing.crt"), wantErr: true}, + {name: "relative_ca_file", body: kubeconfigFixture("https://p:1", "", "ca.crt"), + wantEndpoint: "https://p:1", wantCA: base64.StdEncoding.EncodeToString([]byte("PEM"))}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.crt"), []byte("PEM"), 0o600)) + + b := eks.NewInMemoryBackend(t.Context(), "000000000000", "us-east-1") + err := b.EnableExistingCluster(writeKubeconfig(t, dir, tt.body), tt.kubeContext) + + if tt.wantErr { + require.ErrorIs(t, err, eks.ErrInvalidKubeconfig) + + return + } + + require.NoError(t, err) + _, err = b.CreateCluster("c1", "1.31", "", nil, nil, nil) + require.NoError(t, err) + waitStatus(t, b, "ACTIVE") + + d, err := b.DescribeCluster("c1") + require.NoError(t, err) + assert.Equal(t, tt.wantEndpoint, d.Endpoint) + assert.Equal(t, tt.wantCA, d.CertificateAuthority) + }) + } + + t.Run("missing_file", func(t *testing.T) { + t.Parallel() + + b := eks.NewInMemoryBackend(t.Context(), "000000000000", "us-east-1") + require.ErrorIs(t, b.EnableExistingCluster(filepath.Join(t.TempDir(), "none"), ""), eks.ErrInvalidKubeconfig) + }) +} + +func TestExistingCluster_SDKDescribeReachesExternalCluster(t *testing.T) { + t.Parallel() + + ext := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"gitVersion":"v1.31.0"}`)) + })) + t.Cleanup(ext.Close) + + caPEM := string(pemEncodeCert(ext.Certificate().Raw)) + kc := kubeconfigFixture(ext.URL, base64.StdEncoding.EncodeToString([]byte(caPEM)), "") + + b := eks.NewInMemoryBackend(t.Context(), "123456789012", "us-east-1") + require.NoError(t, b.EnableExistingCluster(writeKubeconfig(t, t.TempDir(), kc), "")) + client := newTestEKSClient(t, eks.NewHandler(b)) + + _, err := client.CreateCluster(t.Context(), &ekssdk.CreateClusterInput{ + Name: aws.String("ext"), + RoleArn: aws.String("arn:aws:iam::123456789012:role/eks-role"), + ResourcesVpcConfig: vpcReq(), + }) + require.NoError(t, err) + + var endpoint, ca string + + require.Eventually(t, func() bool { + out, derr := client.DescribeCluster(t.Context(), &ekssdk.DescribeClusterInput{Name: aws.String("ext")}) + require.NoError(t, derr) + + if string(out.Cluster.Status) != "ACTIVE" { + return false + } + + endpoint, ca = aws.ToString(out.Cluster.Endpoint), aws.ToString(out.Cluster.CertificateAuthority.Data) + + return true + }, 30*time.Second, 10*time.Millisecond) + + assert.Equal(t, ext.URL, endpoint) + + get := func() string { + pemBytes, derr := base64.StdEncoding.DecodeString(ca) + require.NoError(t, derr) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(pemBytes)) + + tr := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}} + defer tr.CloseIdleConnections() + + req, rerr := http.NewRequestWithContext(t.Context(), http.MethodGet, endpoint+"/version", nil) + require.NoError(t, rerr) + + resp, rerr := (&http.Client{Transport: tr}).Do(req) + require.NoError(t, rerr) + + defer func() { _ = resp.Body.Close() }() + + body, rerr := io.ReadAll(resp.Body) + require.NoError(t, rerr) + + return string(body) + } + + assert.Contains(t, get(), "v1.31.0") + + _, err = client.DeleteCluster(t.Context(), &ekssdk.DeleteClusterInput{Name: aws.String("ext")}) + require.NoError(t, err) + assert.Contains(t, get(), "v1.31.0", "DeleteCluster must not touch the external cluster") +} + +func TestDockerCluster_VolumeMountAllowlist(t *testing.T) { + t.Parallel() + + tests := []struct { + mount func(t *testing.T, allowed, outside string) string + name string + allow bool + wantErr bool + }{ + {name: "default_off", allow: false, wantErr: true, + mount: func(_ *testing.T, allowed, _ string) string { return allowed + ":/mnt/x" }}, + {name: "allowed", allow: true, + mount: func(_ *testing.T, allowed, _ string) string { return allowed + ":/mnt/x" }}, + {name: "outside_prefix", allow: true, wantErr: true, + mount: func(_ *testing.T, _, outside string) string { return outside + ":/mnt/x" }}, + {name: "missing_path", allow: true, wantErr: true, + mount: func(_ *testing.T, allowed, _ string) string { return allowed + "/nope:/mnt/x" }}, + {name: "symlink_escape", allow: true, wantErr: true, + mount: func(t *testing.T, allowed, outside string) string { + t.Helper() + + link := filepath.Join(allowed, "escape") + require.NoError(t, os.Symlink(outside, link)) + + return link + ":/mnt/x" + }}, + {name: "prefix_sibling", allow: true, wantErr: true, + mount: func(t *testing.T, allowed, _ string) string { + t.Helper() + + sib := allowed + "-sibling" + require.NoError(t, os.Mkdir(sib, 0o750)) + t.Cleanup(func() { _ = os.Remove(sib) }) + + return sib + ":/mnt/x" + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + allowed, outside := t.TempDir(), t.TempDir() + cfg := eks.ClusterEngineConfig{Probe: newGatedClusterProbe().probe} + + if tt.allow { + cfg.VolumeMountAllow = []string{allowed} + } + + rt := &fakeClusterRuntime{} + b := dockerClusterBackend(t, rt, cfg) + + _, err := b.CreateCluster("c1", "1.31", "", nil, nil, map[string]string{ + eks.TagVolumeMount: tt.mount(t, allowed, outside), + }) + if tt.wantErr { + require.ErrorIs(t, err, eks.ErrValidation) + assert.Equal(t, 0, b.ClusterCount()) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestEnableClusters_RejectsBadMountAllow(t *testing.T) { + t.Parallel() + + for _, allow := range [][]string{{"/"}, {"/a/.."}, {"rel"}, {"//"}} { + b := eks.NewInMemoryBackend(t.Context(), "000000000000", "us-east-1") + err := b.EnableClusters(eks.ClusterEngineConfig{ + Runtime: &fakeClusterRuntime{}, Token: testToken, VolumeMountAllow: allow, + }) + require.ErrorIs(t, err, eks.ErrInvalidClusterEngineConfig, allow) + } +} diff --git a/services/eks/clusters.go b/services/eks/clusters.go index 024f02204f..9dbb787b08 100644 --- a/services/eks/clusters.go +++ b/services/eks/clusters.go @@ -197,6 +197,37 @@ func (b *InMemoryBackend) scheduleClusterActivation(name string) { }) } +func (b *InMemoryBackend) checkEngineOptionsLocked(version string, kv map[string]string) error { + if b.clusterEng == nil { + return nil + } + + _, _, err := b.clusterEng.resolveLaunch(version, kv) + + return err +} + +// startClusterLocked launches the cluster container, or schedules the stub activation; a launch failure undoes +// the registration. +func (b *InMemoryBackend) startClusterLocked(c *Cluster, kv map[string]string) error { + if b.clusterEng == nil { + b.scheduleClusterActivation(c.Name) + + return nil + } + + if err := b.launchClusterLocked(c.Name, c.Version, kv); err != nil { + b.clusters.Delete(c.Name) + delete(b.accessPolicies, c.Name) + delete(b.encryptionConfigs, c.Name) + c.Tags.Close() + + return err + } + + return nil +} + // CreateCluster creates a new EKS cluster. func (b *InMemoryBackend) CreateCluster( name, version, roleARN string, @@ -250,15 +281,17 @@ func (b *InMemoryBackend) CreateCluster( version = defaultK8sVersion } + if err := b.checkEngineOptionsLocked(version, kv); err != nil { + return nil, err + } + c := b.newClusterLocked(name, version, roleARN, vpcConfig, networkConfig, kv, opts...) b.clusters.Put(c) b.accessPolicies[name] = make(map[string][]*AccessPolicyAssociation) b.encryptionConfigs[name] = c.EncryptionConfig - if b.clusterEng != nil { - b.launchClusterLocked(name, version) - } else { - b.scheduleClusterActivation(name) + if err := b.startClusterLocked(c, kv); err != nil { + return nil, err } return c.clone(), nil diff --git a/services/eks/external_cluster.go b/services/eks/external_cluster.go new file mode 100644 index 0000000000..23f0b1362e --- /dev/null +++ b/services/eks/external_cluster.go @@ -0,0 +1,159 @@ +package eks + +import ( + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + + "gopkg.in/yaml.v3" +) + +// ErrInvalidKubeconfig is returned when the existing-cluster kubeconfig cannot be used. +var ErrInvalidKubeconfig = errors.New("invalid EKS existing-cluster kubeconfig") + +type externalCluster struct { + endpoint string + ca string +} + +type kubeconfigFile struct { + CurrentContext string `yaml:"current-context"` + Clusters []struct { + Name string `yaml:"name"` + Cluster struct { + Server string `yaml:"server"` + CAData string `yaml:"certificate-authority-data"` + CAFile string `yaml:"certificate-authority"` + } `yaml:"cluster"` + } `yaml:"clusters"` + Contexts []struct { + Name string `yaml:"name"` + Context struct { + Cluster string `yaml:"cluster"` + } `yaml:"context"` + } `yaml:"contexts"` +} + +// loadExternalCluster reads the endpoint and CA of kubeContext (or current-context) from a kubeconfig file. +func loadExternalCluster(kubeconfigPath, kubeContext string) (*externalCluster, error) { + raw, err := os.ReadFile(kubeconfigPath) //nolint:gosec // operator-configured kubeconfig path + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidKubeconfig, err) + } + + var kc kubeconfigFile + if err = yaml.Unmarshal(raw, &kc); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidKubeconfig, err) + } + + if kubeContext == "" { + kubeContext = kc.CurrentContext + } + + clusterName := "" + + for _, c := range kc.Contexts { + if c.Name == kubeContext { + clusterName = c.Context.Cluster + } + } + + if clusterName == "" { + return nil, fmt.Errorf("%w: context %q not found", ErrInvalidKubeconfig, kubeContext) + } + + for _, c := range kc.Clusters { + if c.Name == clusterName { + return externalFromEntry(kubeconfigPath, clusterName, c.Cluster.Server, c.Cluster.CAData, c.Cluster.CAFile) + } + } + + return nil, fmt.Errorf("%w: cluster %q not found", ErrInvalidKubeconfig, clusterName) +} + +func externalFromEntry(kubeconfigPath, name, server, caData, caFile string) (*externalCluster, error) { + if server == "" { + return nil, fmt.Errorf("%w: cluster %q has no server", ErrInvalidKubeconfig, name) + } + + if caData != "" || caFile == "" { + return &externalCluster{endpoint: server, ca: caData}, nil + } + + if !filepath.IsAbs(caFile) { + caFile = filepath.Join(filepath.Dir(kubeconfigPath), caFile) + } + + pem, err := os.ReadFile(caFile) //nolint:gosec // path comes from the operator's kubeconfig + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidKubeconfig, err) + } + + return &externalCluster{endpoint: server, ca: base64.StdEncoding.EncodeToString(pem)}, nil +} + +// EnableExistingCluster fronts the kubeconfig's cluster: DescribeCluster returns its endpoint and CA, no +// container starts, and DeleteCluster leaves the external cluster untouched. +func (b *InMemoryBackend) EnableExistingCluster(kubeconfigPath, kubeContext string) error { + ext, err := loadExternalCluster(kubeconfigPath, kubeContext) + if err != nil { + return err + } + + b.mu.Lock("EnableExistingCluster") + defer b.mu.Unlock() + + b.external = ext + + return nil +} + +func (b *InMemoryBackend) externalProvider() *externalCluster { + b.mu.RLock("externalProvider") + defer b.mu.RUnlock() + + return b.external +} + +func (b *InMemoryBackend) applyExternalEndpoint(cp *Cluster) { + if b.external != nil && cp.Status == statusActive { + cp.Endpoint = b.external.endpoint + cp.CertificateAuthority = b.external.ca + } +} + +// PublishedLBPorts returns the host ports published for the cluster's load-balancer ports. +func (b *InMemoryBackend) PublishedLBPorts(name string) ([]PublishedPort, error) { + b.mu.RLock("PublishedLBPorts") + defer b.mu.RUnlock() + + if _, ok := b.clusters.Get(name); !ok { + return nil, errMsgf(ErrNotFound, "No cluster found for name: %s.", name) + } + + out := []PublishedPort{} + + if b.clusterEng == nil { + return out, nil + } + + lc, ok := b.clusterEng.clusters[name] + if !ok { + return out, nil + } + + host := container.BindHostFor(b.clusterEng.cfg.Host) + if host == container.AllInterfacesHost { + host = b.clusterEng.cfg.Host + } + + for i, h := range lc.lbHost { + out = append(out, PublishedPort{Host: host, ContainerPort: lc.lbPorts[i], HostPort: h}) + } + + return out, nil +} diff --git a/services/eks/iam_enforcement_test.go b/services/eks/iam_enforcement_test.go index 4c95e18185..e005d7f2e4 100644 --- a/services/eks/iam_enforcement_test.go +++ b/services/eks/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/eks" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -174,7 +175,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/eks/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/eks/provider.go b/services/eks/provider.go index e3114f2133..59e8839620 100644 --- a/services/eks/provider.go +++ b/services/eks/provider.go @@ -18,6 +18,35 @@ type EngineConfig interface { } func enableDockerClusters(ctx *service.AppContext, backend *InMemoryBackend) { + if kubeconfig := os.Getenv("EKS_KUBECONFIG"); kubeconfig != "" { + if err := backend.EnableExistingCluster(kubeconfig, os.Getenv("EKS_KUBECONFIG_CONTEXT")); err != nil { + ctx.Logger.Warn("EKS: existing-cluster kubeconfig unusable; clusters stay metadata-only", "error", err) + } + + return + } + + tags, err := ParseK3sImageTags(os.Getenv("EKS_K3S_IMAGE_TAGS")) + if err != nil { + ctx.Logger.Warn("EKS: invalid EKS_K3S_IMAGE_TAGS; clusters stay metadata-only", "error", err) + + return + } + + lbPorts, err := ParseLBPorts(os.Getenv("EKS_LB_PORTS")) + if err != nil { + ctx.Logger.Warn("EKS: invalid EKS_LB_PORTS; clusters stay metadata-only", "error", err) + + return + } + + mountAllow, err := ParseVolumeMountAllow(os.Getenv("EKS_VOLUME_MOUNT_ALLOW")) + if err != nil { + ctx.Logger.Warn("EKS: invalid EKS_VOLUME_MOUNT_ALLOW; clusters stay metadata-only", "error", err) + + return + } + rt, err := container.NewRuntime(container.Config{Logger: ctx.Logger}) if err != nil { ctx.Logger.Warn("EKS: container runtime unavailable; clusters stay metadata-only", "error", err) @@ -32,6 +61,11 @@ func enableDockerClusters(ctx *service.AppContext, backend *InMemoryBackend) { Host: os.Getenv("EKS_CLUSTER_HOST"), Token: os.Getenv("EKS_CLUSTER_TOKEN"), Image: os.Getenv("EKS_K3S_IMAGE"), + + ImageTags: tags, + LBPorts: lbPorts, + + VolumeMountAllow: mountAllow, }) if err != nil { ctx.Logger.Warn("EKS: invalid cluster engine configuration; clusters stay metadata-only", "error", err) diff --git a/services/eks/published_ports.go b/services/eks/published_ports.go new file mode 100644 index 0000000000..aa501e8758 --- /dev/null +++ b/services/eks/published_ports.go @@ -0,0 +1,36 @@ +package eks + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v5" +) + +type publishedPortsResponse struct { + ClusterName string `json:"clusterName"` + Ports []PublishedPort `json:"ports"` +} + +// ServePublishedPorts serves GET /_gopherstack/eks/clusters/:name/ports?region=: the host port behind each +// load-balancer port of a docker-engine cluster. +func (h *Handler) ServePublishedPorts(c *echo.Context) error { + name := c.Param("name") + + region := c.QueryParam("region") + if region == "" { + region = h.Backend.region + } + + ports, err := h.BackendFor(region).PublishedLBPorts(name) + if err != nil { + code := http.StatusInternalServerError + if errors.Is(err, ErrNotFound) { + code = http.StatusNotFound + } + + return c.JSON(code, map[string]string{"message": err.Error()}) + } + + return c.JSON(http.StatusOK, publishedPortsResponse{ClusterName: name, Ports: ports}) +} diff --git a/services/eks/regions.go b/services/eks/regions.go index efefe828e2..c70d8a642a 100644 --- a/services/eks/regions.go +++ b/services/eks/regions.go @@ -20,6 +20,8 @@ func (h *Handler) EnableRegions() { nb := NewInMemoryBackend(home.baseCtx, home.accountID, region) nb.SetIdcApplicationManager(home.idcApplicationManager()) + nb.external = home.externalProvider() + if cfg, on := home.engineConfig(); on { cfg.Runtime = sharedRuntime{cfg.Runtime} _ = nb.EnableClusters(cfg) diff --git a/services/eks/store.go b/services/eks/store.go index 812d66bdec..8f84b3dae4 100644 --- a/services/eks/store.go +++ b/services/eks/store.go @@ -50,6 +50,7 @@ type InMemoryBackend struct { work *worker.Group baseCtx context.Context clusterEng *clusterEngine + external *externalCluster credentials CredentialResolver podIdentityAssumer PodIdentityRoleAssumer idcApps IdcApplicationManager diff --git a/services/eks/update_delete_lifecycle_test.go b/services/eks/update_delete_lifecycle_test.go index 25baba8ea0..49b26abcd4 100644 --- a/services/eks/update_delete_lifecycle_test.go +++ b/services/eks/update_delete_lifecycle_test.go @@ -3,6 +3,7 @@ package eks_test import ( "errors" "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -40,93 +41,97 @@ func waitClusterStatus(t *testing.T, c *ekssdk.Client, name string, want ekstype func TestClusterUpdate_UpdatingThenActive(t *testing.T) { t.Parallel() - c := newLifecycleClient(t, lifecycleTestDelay) - createTestCluster(t, c, "upd") - waitClusterStatus(t, c, "upd", ekstypes.ClusterStatusActive) + synctest.Test(t, func(t *testing.T) { + c := newLifecycleClient(t, lifecycleTestDelay) + createTestCluster(t, c, "upd") + waitClusterStatus(t, c, "upd", ekstypes.ClusterStatusActive) - out, err := c.UpdateClusterVersion(t.Context(), &ekssdk.UpdateClusterVersionInput{ - Name: aws.String("upd"), Version: aws.String("1.33"), - }) - require.NoError(t, err) - assert.NoError(t, uuid.Validate(aws.ToString(out.Update.Id)), "update ids are UUIDs") - assert.Equal(t, ekstypes.UpdateStatusInProgress, out.Update.Status) + out, err := c.UpdateClusterVersion(t.Context(), &ekssdk.UpdateClusterVersionInput{ + Name: aws.String("upd"), Version: aws.String("1.33"), + }) + require.NoError(t, err) + assert.NoError(t, uuid.Validate(aws.ToString(out.Update.Id)), "update ids are UUIDs") + assert.Equal(t, ekstypes.UpdateStatusInProgress, out.Update.Status) - desc, err := c.DescribeCluster(t.Context(), &ekssdk.DescribeClusterInput{Name: aws.String("upd")}) - require.NoError(t, err) - assert.Equal(t, ekstypes.ClusterStatusUpdating, desc.Cluster.Status) - assert.Equal(t, "1.32", aws.ToString(desc.Cluster.Version), "version flips only when the update completes") + desc, err := c.DescribeCluster(t.Context(), &ekssdk.DescribeClusterInput{Name: aws.String("upd")}) + require.NoError(t, err) + assert.Equal(t, ekstypes.ClusterStatusUpdating, desc.Cluster.Status) + assert.Equal(t, "1.32", aws.ToString(desc.Cluster.Version), "version flips only when the update completes") - waitClusterStatus(t, c, "upd", ekstypes.ClusterStatusActive) + waitClusterStatus(t, c, "upd", ekstypes.ClusterStatusActive) - desc, err = c.DescribeCluster(t.Context(), &ekssdk.DescribeClusterInput{Name: aws.String("upd")}) - require.NoError(t, err) - assert.Equal(t, "1.33", aws.ToString(desc.Cluster.Version)) + desc, err = c.DescribeCluster(t.Context(), &ekssdk.DescribeClusterInput{Name: aws.String("upd")}) + require.NoError(t, err) + assert.Equal(t, "1.33", aws.ToString(desc.Cluster.Version)) - upd, err := c.DescribeUpdate(t.Context(), &ekssdk.DescribeUpdateInput{ - Name: aws.String("upd"), UpdateId: out.Update.Id, + upd, err := c.DescribeUpdate(t.Context(), &ekssdk.DescribeUpdateInput{ + Name: aws.String("upd"), UpdateId: out.Update.Id, + }) + require.NoError(t, err) + assert.Equal(t, ekstypes.UpdateStatusSuccessful, upd.Update.Status) }) - require.NoError(t, err) - assert.Equal(t, ekstypes.UpdateStatusSuccessful, upd.Update.Status) } func TestDeletes_DwellInDeletingWhenDelayed(t *testing.T) { t.Parallel() - c := newLifecycleClient(t, lifecycleTestDelay) - createTestCluster(t, c, "del") - waitClusterStatus(t, c, "del", ekstypes.ClusterStatusActive) - - _, err := c.CreateNodegroup(t.Context(), &ekssdk.CreateNodegroupInput{ - ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), - NodeRole: aws.String("arn:aws:iam::123456789012:role/ng"), Subnets: []string{"subnet-abc123"}, - }) - require.NoError(t, err) + synctest.Test(t, func(t *testing.T) { + c := newLifecycleClient(t, lifecycleTestDelay) + createTestCluster(t, c, "del") + waitClusterStatus(t, c, "del", ekstypes.ClusterStatusActive) - require.Eventually(t, func() bool { - out, descErr := c.DescribeNodegroup(t.Context(), &ekssdk.DescribeNodegroupInput{ + _, err := c.CreateNodegroup(t.Context(), &ekssdk.CreateNodegroupInput{ ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), + NodeRole: aws.String("arn:aws:iam::123456789012:role/ng"), Subnets: []string{"subnet-abc123"}, }) + require.NoError(t, err) - return descErr == nil && out.Nodegroup.Status == ekstypes.NodegroupStatusActive - }, 5*time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { + out, descErr := c.DescribeNodegroup(t.Context(), &ekssdk.DescribeNodegroupInput{ + ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), + }) - delNG, err := c.DeleteNodegroup(t.Context(), &ekssdk.DeleteNodegroupInput{ - ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), - }) - require.NoError(t, err) - assert.Equal(t, ekstypes.NodegroupStatusDeleting, delNG.Nodegroup.Status) - - desc, err := c.DescribeNodegroup(t.Context(), &ekssdk.DescribeNodegroupInput{ - ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), - }) - require.NoError(t, err) - assert.Equal(t, ekstypes.NodegroupStatusDeleting, desc.Nodegroup.Status) + return descErr == nil && out.Nodegroup.Status == ekstypes.NodegroupStatusActive + }, 5*time.Second, 5*time.Millisecond) - _, err = c.DeleteCluster(t.Context(), &ekssdk.DeleteClusterInput{Name: aws.String("del")}) - var inUse *ekstypes.ResourceInUseException - require.ErrorAs(t, err, &inUse, "cluster with a deleting nodegroup is still in use") + delNG, err := c.DeleteNodegroup(t.Context(), &ekssdk.DeleteNodegroupInput{ + ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), + }) + require.NoError(t, err) + assert.Equal(t, ekstypes.NodegroupStatusDeleting, delNG.Nodegroup.Status) - require.Eventually(t, func() bool { - _, descErr := c.DescribeNodegroup(t.Context(), &ekssdk.DescribeNodegroupInput{ + desc, err := c.DescribeNodegroup(t.Context(), &ekssdk.DescribeNodegroupInput{ ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), }) - var nf *ekstypes.ResourceNotFoundException + require.NoError(t, err) + assert.Equal(t, ekstypes.NodegroupStatusDeleting, desc.Nodegroup.Status) - return errors.As(descErr, &nf) - }, 5*time.Second, 5*time.Millisecond) + _, err = c.DeleteCluster(t.Context(), &ekssdk.DeleteClusterInput{Name: aws.String("del")}) + var inUse *ekstypes.ResourceInUseException + require.ErrorAs(t, err, &inUse, "cluster with a deleting nodegroup is still in use") - delCluster, err := c.DeleteCluster(t.Context(), &ekssdk.DeleteClusterInput{Name: aws.String("del")}) - require.NoError(t, err) - assert.Equal(t, ekstypes.ClusterStatusDeleting, delCluster.Cluster.Status) + require.Eventually(t, func() bool { + _, descErr := c.DescribeNodegroup(t.Context(), &ekssdk.DescribeNodegroupInput{ + ClusterName: aws.String("del"), NodegroupName: aws.String("ng"), + }) + var nf *ekstypes.ResourceNotFoundException - waitClusterStatus(t, c, "del", ekstypes.ClusterStatusDeleting) + return errors.As(descErr, &nf) + }, 5*time.Second, 5*time.Millisecond) - require.Eventually(t, func() bool { - _, descErr := c.DescribeCluster(t.Context(), &ekssdk.DescribeClusterInput{Name: aws.String("del")}) - var nf *ekstypes.ResourceNotFoundException + delCluster, err := c.DeleteCluster(t.Context(), &ekssdk.DeleteClusterInput{Name: aws.String("del")}) + require.NoError(t, err) + assert.Equal(t, ekstypes.ClusterStatusDeleting, delCluster.Cluster.Status) - return errors.As(descErr, &nf) - }, 5*time.Second, 5*time.Millisecond) + waitClusterStatus(t, c, "del", ekstypes.ClusterStatusDeleting) + + require.Eventually(t, func() bool { + _, descErr := c.DescribeCluster(t.Context(), &ekssdk.DescribeClusterInput{Name: aws.String("del")}) + var nf *ekstypes.ResourceNotFoundException + + return errors.As(descErr, &nf) + }, 5*time.Second, 5*time.Millisecond) + }) } func TestCreateValidation(t *testing.T) { diff --git a/services/eks/wire_maplit_fixes_test.go b/services/eks/wire_maplit_fixes_test.go index 3d053e40cf..b3ddd58b02 100644 --- a/services/eks/wire_maplit_fixes_test.go +++ b/services/eks/wire_maplit_fixes_test.go @@ -1,11 +1,9 @@ package eks_test import ( - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" ekssdk "github.com/aws/aws-sdk-go-v2/service/eks" ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" @@ -13,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/eks" ) @@ -28,20 +27,11 @@ func newTestEKSClient(t *testing.T, h *eks.Handler) *ekssdk.Client { require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion("us-east-1"), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return ekssdk.NewFromConfig(cfg, func(o *ekssdk.Options) { - o.BaseEndpoint = &srv.URL + return ekssdk.NewFromConfig(aws.Config{ + Region: "us-east-1", + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/elasticache/handler_cache_clusters_test.go b/services/elasticache/handler_cache_clusters_test.go index 543eb75231..38508225d7 100644 --- a/services/elasticache/handler_cache_clusters_test.go +++ b/services/elasticache/handler_cache_clusters_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elasticache" ) @@ -691,7 +692,7 @@ func TestHandler_DescribeCacheClusters_MaxRecordsOutOfRange_DoesNotDoubleWrite(t require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -1175,7 +1176,7 @@ func TestCreateCacheCluster_InvalidSnapshotRetentionLimit_StopsBeforeReplication require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -1249,7 +1250,7 @@ func TestModifyCacheCluster_InvalidSnapshotRetentionLimit_LeavesClusterUnchanged require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) diff --git a/services/elasticache/handler_error_test.go b/services/elasticache/handler_error_test.go index 2e0f9a0d1f..cd2511ef49 100644 --- a/services/elasticache/handler_error_test.go +++ b/services/elasticache/handler_error_test.go @@ -25,6 +25,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elasticache" ) @@ -73,7 +74,7 @@ func postAction(t *testing.T, srvURL string, form url.Values) (int, errorEnvelop require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -193,7 +194,7 @@ func TestDescribeListChecked_MaxRecordsOutOfRange_DoesNotDoubleWrite(t *testing. require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -231,7 +232,7 @@ func TestDescribeListChecked_NotFound_DoesNotDoubleWrite(t *testing.T) { require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -334,7 +335,7 @@ func TestHandler_ErrResponseWrittenTranslatesToSuccessTelemetry(t *testing.T) { require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) diff --git a/services/elasticache/handler_new_sdk_fields_test.go b/services/elasticache/handler_new_sdk_fields_test.go index e5f2e509b2..2eeb64deb3 100644 --- a/services/elasticache/handler_new_sdk_fields_test.go +++ b/services/elasticache/handler_new_sdk_fields_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elasticache" ) @@ -53,7 +54,7 @@ func postFormRaw(t *testing.T, srvURL string, form url.Values) string { require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/elasticache/iam_enforcement_test.go b/services/elasticache/iam_enforcement_test.go index b1f0b8c08a..99d8833b5d 100644 --- a/services/elasticache/iam_enforcement_test.go +++ b/services/elasticache/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elasticache" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/elasticache/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/elasticbeanstalk/iam_enforcement_test.go b/services/elasticbeanstalk/iam_enforcement_test.go index 24c58b0db3..b1980ec93e 100644 --- a/services/elasticbeanstalk/iam_enforcement_test.go +++ b/services/elasticbeanstalk/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elasticbeanstalk" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/elasticbeanstalk/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/elasticsearch/iam_enforcement_test.go b/services/elasticsearch/iam_enforcement_test.go index 8d6272ed5f..5a4dfb81ce 100644 --- a/services/elasticsearch/iam_enforcement_test.go +++ b/services/elasticsearch/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elasticsearch" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/es/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/elb/handler_error_status_test.go b/services/elb/handler_error_status_test.go index 44160f8858..79eeb61acb 100644 --- a/services/elb/handler_error_status_test.go +++ b/services/elb/handler_error_status_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elb" ) @@ -25,11 +26,12 @@ import ( // in awsAwsquery_deserializeOpError* funcs, elasticloadbalancing@v1.36.4 // deserializers.go). type statusCapturingTransport struct { + rt http.RoundTripper status int } func (rt *statusCapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := rt.rt.RoundTrip(req) if resp != nil { rt.status = resp.StatusCode } @@ -48,7 +50,7 @@ func newStatusCapturingELBClient(t *testing.T, h *elb.Handler) (*elbsdk.Client, srv := httptest.NewServer(e) t.Cleanup(srv.Close) - transport := &statusCapturingTransport{} + transport := &statusCapturingTransport{rt: testclient.New(t).Transport} cfg, err := awscfg.LoadDefaultConfig( t.Context(), diff --git a/services/elb/iam_enforcement_test.go b/services/elb/iam_enforcement_test.go index d275e551ea..ebe7dca186 100644 --- a/services/elb/iam_enforcement_test.go +++ b/services/elb/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elb" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/elasticloadbalancing/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/elbv2/PARITY.md b/services/elbv2/PARITY.md index 6845370d56..c9a965e2ac 100644 --- a/services/elbv2/PARITY.md +++ b/services/elbv2/PARITY.md @@ -90,7 +90,7 @@ items_still_open: - "Kept lenient for in-repo fixtures: a target group attached to no listener reports healthy (real AWS: unused/Target.NotInUse), an ALB with subnets in one AZ is accepted, and health-check defaults stay 3/3/5 because the SDK docs and observed AWS values disagree." - "LB VpcId/ZoneName from the real subnet needs internal/app/cli.go to give elbEC2ResolverAdapter SubnetVPC(id) and SubnetAZ(id) (same bodies as efsEC2ResolverAdapter); until then they stay vpc-00000000 and index-derived zones." structural_gaps: - - "Data plane has no real listener sockets or TLS: HTTPS listeners are served as plain HTTP on the emulator port (X-Forwarded-Proto reports the listener protocol), the client address is the TCP peer of the emulator, and HTTP/2, gRPC and WebSocket upgrades are not proxied." + - "Default (--elbv2-listeners=routed) data plane has no real listener sockets or TLS: HTTPS listeners are served as plain HTTP on the emulator port and the client address is the TCP peer of the emulator. --elbv2-listeners=real binds a socket per listener (see 2026-10-29 section); HTTP/2, gRPC, mTLS, UDP proxy protocol and IAM-uploaded server certificates (no private key stored) are not served even there." - "GetTrustStoreCaCertificatesBundle/GetTrustStoreRevocationContent return an empty Location: the real value is a presigned URL to an AWS-owned copy of the bundle, which the emulator does not store. The ops still validate TrustStoreNotFound/RevocationIdNotFound." deferred: - "TargetHealth.AnomalyDetection and AdministrativeOverride are never returned: no anomaly-mitigation or zonal-shift subsystem exists to source them, and the default values AWS reports when idle are unverified." @@ -601,3 +601,21 @@ Fixed on the way: the HTTP health probe always hit the target group port instead ## 2026-10-11 reserved ID-pinning tag (gopherstack-cefu7.23) `CreateLoadBalancer` honours the emulator-only `gopherstack:id` tag to pin the ARN suffix (16 lowercase hex digits). The tag is consumed, never stored or listed; a malformed ID returns `ValidationError`, a reused ID `DuplicateLoadBalancerName`, and `AddTags` rejects the key. See docs/pinned-ids.md. + +## 2026-10-29 real listeners (gopherstack-49qct.73) + +`--elbv2-listeners=real` (`ELBV2_LISTENERS`, default `routed`; bind address `--elbv2-listener-host`, default 127.0.0.1) binds a local socket per listener. +Requested port >= 1024 and free binds directly; otherwise a port comes from the shared pool (`ctx.PortAlloc`, ephemeral when nil). The mapping is +`InMemoryBackend.ListenerEndpoint(s)` and `GET /_gopherstack/elbv2/_listeners`. Create/Modify (port or protocol change)/Delete listener, DeleteLoadBalancer, +Restore and Close reconcile sockets; policy and certificate changes apply on the next handshake without a rebind. + +- ALB HTTP/HTTPS run the existing rule engine and target forwarding. HTTPS terminates TLS with ACM certs (`CertificateKeyPairResolver`; imported and + emulator-issued certs both hold a private key). SNI: matching certificates among the listener list win (ECDSA before RSA, then latest expiry), no SNI or + no match uses the default certificate, matches the client cannot use fail the handshake (docs: https-listener-certificates). The default certificate + always takes part in SNI matching, a leniency over the docs (they require AddListenerCertificates). +- SslPolicy derives min/max version and TLS 1.2 suites from `ssl_policy_catalog.go`; suites Go lacks (ECDHE CBC-SHA384, RSA AES256-SHA256) are dropped; + TLS 1.3 suites are not configurable in Go. Default policy ELBSecurityPolicy-2016-08. +- X-Forwarded-For honours `routing.http.xff_header_processing.mode` (append/preserve/remove) and `routing.http.xff_client_port.enabled` on the load + balancer, in routed mode too. HTTPS adds X-Amzn-Tls-Version and X-Amzn-Tls-Cipher-Suite. WebSocket upgrades are tunnelled in both modes. +- NLB TCP, TLS (terminate, then plain or TLS to the target), UDP (120 s flow idle) and TCP_UDP proxy to the forward action's target group. TLS ALPN policy + is honoured. `proxy_protocol_v2.enabled` prepends a PROXY v2 header (no TLVs) on TCP; not on UDP. NLB listener rules (source-ip) and TLS session resumption are not modelled. diff --git a/services/elbv2/README.md b/services/elbv2/README.md index 90ce7f2900..0708076db7 100644 --- a/services/elbv2/README.md +++ b/services/elbv2/README.md @@ -23,7 +23,7 @@ These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. -- Data plane has no real listener sockets or TLS: HTTPS listeners are served as plain HTTP on the emulator port (X-Forwarded-Proto reports the listener protocol), the client address is the TCP peer of the emulator, and HTTP/2, gRPC and WebSocket upgrades are not proxied. +- Default (--elbv2-listeners=routed) data plane has no real listener sockets or TLS: HTTPS listeners are served as plain HTTP on the emulator port and the client address is the TCP peer of the emulator. --elbv2-listeners=real binds a socket per listener (see 2026-10-29 section); HTTP/2, gRPC, mTLS, UDP proxy protocol and IAM-uploaded server certificates (no private key stored) are not served even there. - GetTrustStoreCaCertificatesBundle/GetTrustStoreRevocationContent return an empty Location: the real value is a presigned URL to an AWS-owned copy of the bundle, which the emulator does not store. The ops still validate TrustStoreNotFound/RevocationIdNotFound. ### Deferred diff --git a/services/elbv2/crossservice.go b/services/elbv2/crossservice.go index c5917b32d7..8754c5c134 100644 --- a/services/elbv2/crossservice.go +++ b/services/elbv2/crossservice.go @@ -41,3 +41,9 @@ type SubnetPlacementResolver interface { SubnetVPC(id string) string SubnetAZ(id string) string } + +// CertificateKeyPairResolver is the optional CertificateResolver extension real listeners use to +// terminate TLS: it returns the PEM certificate (with chain) and private key behind a certificate ARN. +type CertificateKeyPairResolver interface { + CertificateKeyPair(certARN string) (certPEM, keyPEM []byte, ok bool) +} diff --git a/services/elbv2/dataplane.go b/services/elbv2/dataplane.go index fab0db574c..b4a32fd47b 100644 --- a/services/elbv2/dataplane.go +++ b/services/elbv2/dataplane.go @@ -2,6 +2,7 @@ package elbv2 import ( "context" + "encoding/json" "net" "net/http" "net/netip" @@ -17,18 +18,19 @@ import ( ) const ( - dataPlanePrefix = "/_gopherstack/elbv2/" - dataPlaneHostMarker = ".elb." - dataPlaneLocalSuffix = ".elb.localhost" - dataPlaneAWSSuffix = ".elb.amazonaws.com" - listenerPortHeader = "X-Gopherstack-Listener-Port" - defaultIdleTimeout = 60 * time.Second - idleTimeoutAttr = "idle_timeout.timeout_seconds" - defaultListenerPort = 80 - operationDataPlane = "DataPlaneRequest" - msgNoTargets = "503 Service Temporarily Unavailable" - msgBadGateway = "502 Bad Gateway" - msgNotImplemented = "501 Not Implemented" + dataPlanePrefix = "/_gopherstack/elbv2/" + listenersEndpointPath = dataPlanePrefix + "_listeners" + dataPlaneHostMarker = ".elb." + dataPlaneLocalSuffix = ".elb.localhost" + dataPlaneAWSSuffix = ".elb.amazonaws.com" + listenerPortHeader = "X-Gopherstack-Listener-Port" + defaultIdleTimeout = 60 * time.Second + idleTimeoutAttr = "idle_timeout.timeout_seconds" + defaultListenerPort = 80 + operationDataPlane = "DataPlaneRequest" + msgNoTargets = "503 Service Temporarily Unavailable" + msgBadGateway = "502 Bad Gateway" + msgNotImplemented = "501 Not Implemented" ) // LambdaInvoker invokes a Lambda function synchronously for lambda target groups. @@ -139,6 +141,8 @@ type dpRequest struct { path string rawPath string host string + // clientPort is the TCP peer port of the client, 0 when unknown. + clientPort uint16 } func (h *Handler) serveDataPlane(c *echo.Context) error { @@ -148,6 +152,12 @@ func (h *Handler) serveDataPlane(c *echo.Context) error { } func (h *Handler) handleDataPlane(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == listenersEndpointPath { + h.serveListenerEndpoints(w) + + return + } + lb, be, path, rawPath := h.resolveLB(r) if lb == nil { writeDPError(w, http.StatusNotFound, "404 Not Found: no such load balancer") @@ -177,8 +187,9 @@ func (h *Handler) handleDataPlane(w http.ResponseWriter, r *http.Request) { req := &dpRequest{ r: r, be: be, lb: lb, listener: l, path: path, rawPath: rawPath, - host: hostWithoutPort(r.Host), client: clientAddr(r), + host: hostWithoutPort(r.Host), } + req.client, req.clientPort = clientAddrPort(r) h.route(w, req) } @@ -275,3 +286,19 @@ func writeDPError(w http.ResponseWriter, status int, msg string) { _, _ = w.Write([]byte("" + msg + "

" + msg + "

")) } + +// serveListenerEndpoints lists real-listener host endpoints across regions (the port mapping). +func (h *Handler) serveListenerEndpoints(w http.ResponseWriter) { + out := []ListenerEndpoint{} + + for _, be := range h.allBackends() { + if ib, ok := be.(*InMemoryBackend); ok { + out = append(out, ib.ListenerEndpoints()...) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].ListenerArn < out[j].ListenerArn }) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} diff --git a/services/elbv2/dataplane_forward.go b/services/elbv2/dataplane_forward.go index 71a93eb198..3bf4d6013e 100644 --- a/services/elbv2/dataplane_forward.go +++ b/services/elbv2/dataplane_forward.go @@ -255,6 +255,12 @@ func (h *Handler) proxyTo(w http.ResponseWriter, req *dpRequest, tg *TargetGroup scheme = "https" } + if isUpgradeRequest(req.r) { + h.proxyUpgrade(w, req, ap, scheme, sticky, tg) + + return + } + target := scheme + "://" + ap.String() + req.rawPath if q := req.r.URL.RawQuery; q != "" { target += "?" + q @@ -330,12 +336,7 @@ func copyHeaders(dst, src http.Header) { } func setForwardHeaders(h http.Header, req *dpRequest) { - ip := req.client.String() - if prior := h.Get("X-Forwarded-For"); prior != "" { - ip = prior + ", " + ip - } - - h.Set("X-Forwarded-For", ip) + applyXFF(h, req) h.Set("X-Forwarded-Proto", listenerScheme(req.listener)) h.Set("X-Forwarded-Port", strconv.Itoa(int(req.listener.Port))) h.Set(traceHeader, nextTraceID(h.Get(traceHeader))) diff --git a/services/elbv2/dataplane_lambda.go b/services/elbv2/dataplane_lambda.go index 9a387c9259..c3e6dda871 100644 --- a/services/elbv2/dataplane_lambda.go +++ b/services/elbv2/dataplane_lambda.go @@ -3,14 +3,19 @@ package elbv2 import ( "encoding/base64" "encoding/json" - "io" + "errors" "mime" "net/http" "strconv" "strings" "unicode/utf8" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" ) +// maxLambdaTargetBodyBytes is the 1 MB request body limit for ALB Lambda targets. +const maxLambdaTargetBodyBytes int64 = 1 << 20 + const lambdaMultiValueAttr = "lambda.multi_value_headers.enabled" type albEvent struct { @@ -50,8 +55,14 @@ func (h *Handler) serveLambda(w http.ResponseWriter, req *dpRequest, tg *TargetG return } - body, err := io.ReadAll(req.r.Body) + body, err := httputils.ReadBodyLimit(req.r, maxLambdaTargetBodyBytes) if err != nil { + if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge { + writeDPError(w, http.StatusRequestEntityTooLarge, "413 Request Entity Too Large") + + return + } + writeDPError(w, http.StatusBadGateway, msgBadGateway) return diff --git a/services/elbv2/dataplane_upgrade_test.go b/services/elbv2/dataplane_upgrade_test.go new file mode 100644 index 0000000000..175b6d83b1 --- /dev/null +++ b/services/elbv2/dataplane_upgrade_test.go @@ -0,0 +1,153 @@ +package elbv2_test + +import ( + "bufio" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + elbv2sdk "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDataPlane_WebSocketUpgradeEcho(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + }{ + {name: "upgrade echoes through routed data plane", path: "/ws"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + tg := f.tg(t, "tg-ws", elbv2types.TargetTypeEnumIp, "") + f.register(t, tg, wsTarget(t)) + f.listener(t, forwardAction(tg)) + + conn, err := net.Dial("tcp", f.srvAddr) + require.NoError(t, err) + + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = io.WriteString(conn, "GET /_gopherstack/elbv2/"+f.lbName+tt.path+" HTTP/1.1\r\nHost: "+f.srvAddr+ + "\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\n"+ + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n") + require.NoError(t, err) + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + + _, err = io.WriteString(conn, "frame") + require.NoError(t, err) + + buf := make([]byte, len("frame")) + _, err = io.ReadFull(br, buf) + require.NoError(t, err) + assert.Equal(t, "frame", string(buf)) + }) + } +} + +func TestDataPlane_XFFProcessingModes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode string + wantXFF string + wantAbsen bool + }{ + {name: "preserve", mode: "preserve", wantXFF: "10.0.0.9"}, + {name: "remove", mode: "remove", wantAbsen: true}, + {name: "append", mode: "append", wantXFF: "10.0.0.9, 127.0.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + echo := newHeaderEcho(t) + tg := f.tg(t, "tg-xff", elbv2types.TargetTypeEnumIp, "") + f.register(t, tg, echo.addr()) + f.listener(t, forwardAction(tg)) + + _, err := f.client.ModifyLoadBalancerAttributes(t.Context(), &elbv2sdk.ModifyLoadBalancerAttributesInput{ + LoadBalancerArn: aws.String(f.lbArn), + Attributes: []elbv2types.LoadBalancerAttribute{ + {Key: aws.String("routing.http.xff_header_processing.mode"), Value: aws.String(tt.mode)}, + }, + }) + require.NoError(t, err) + + got := f.do(t, dpReq{path: "/x", headers: map[string]string{"X-Forwarded-For": "10.0.0.9"}}) + require.Equal(t, http.StatusOK, got.status) + + lines := headerLines(got.body) + if tt.wantAbsen { + assert.NotContains(t, got.body, "X-Forwarded-For") + + return + } + + assert.Equal(t, tt.wantXFF, lines["X-Forwarded-For"]) + }) + } +} + +func TestDataPlane_UpgradeNon101Passthrough(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + upgrade string + wantBody string + wantStatus int + }{ + {name: "target rejects upgrade", upgrade: "tls/1.0", wantBody: "no upgrade", wantStatus: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + tg := f.tg(t, "tg-no101", elbv2types.TargetTypeEnumIp, "") + f.register(t, tg, wsTarget(t)) + f.listener(t, forwardAction(tg)) + + conn, err := net.Dial("tcp", f.srvAddr) + require.NoError(t, err) + + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = io.WriteString(conn, "GET /_gopherstack/elbv2/"+f.lbName+"/x HTTP/1.1\r\nHost: "+f.srvAddr+ + "\r\nConnection: Upgrade\r\nUpgrade: "+tt.upgrade+"\r\n\r\n") + require.NoError(t, err) + + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, resp.StatusCode) + assert.Contains(t, string(body), tt.wantBody) + }) + } +} diff --git a/services/elbv2/handler.go b/services/elbv2/handler.go index 43119f858c..d682a2d3a0 100644 --- a/services/elbv2/handler.go +++ b/services/elbv2/handler.go @@ -46,6 +46,10 @@ func NewHandler(backend StorageBackend) *Handler { h := &Handler{Backend: backend, dp: newDataPlane()} h.dispatchTable = h.buildDispatchTable() + if b, ok := backend.(*InMemoryBackend); ok { + b.attachHandler(h) + } + return h } diff --git a/services/elbv2/iam_enforcement_test.go b/services/elbv2/iam_enforcement_test.go index 39fc4638ca..37d000fed1 100644 --- a/services/elbv2/iam_enforcement_test.go +++ b/services/elbv2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/elbv2" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -157,7 +158,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/elasticloadbalancing/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/elbv2/lambda_body_limit_test.go b/services/elbv2/lambda_body_limit_test.go new file mode 100644 index 0000000000..4c43ce4413 --- /dev/null +++ b/services/elbv2/lambda_body_limit_test.go @@ -0,0 +1,40 @@ +package elbv2_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDataPlane_LambdaBodyLimit(t *testing.T) { + t.Parallel() + + const limit = 1 << 20 + + tests := []struct { + name string + size int + wantStatus int + }{ + {name: "at limit", size: limit, wantStatus: http.StatusOK}, + {name: "over limit", size: limit + 1, wantStatus: http.StatusRequestEntityTooLarge}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv := &fakeInvoker{response: `{"statusCode":200,"body":"ok"}`} + f, _ := lambdaFixture(t, false, inv) + + got := f.do(t, dpReq{ + method: http.MethodPost, path: "/", + headers: map[string]string{"Content-Type": "text/plain"}, + body: strings.Repeat("a", tc.size), + }) + assert.Equal(t, tc.wantStatus, got.status) + }) + } +} diff --git a/services/elbv2/listener_http.go b/services/elbv2/listener_http.go new file mode 100644 index 0000000000..276767e732 --- /dev/null +++ b/services/elbv2/listener_http.go @@ -0,0 +1,171 @@ +package elbv2 + +import ( + "context" + "crypto/tls" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/netip" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + readHeaderTimeout = 30 * time.Second + tlsVersionHeader = "X-Amzn-Tls-Version" + tlsCipherHeader = "X-Amzn-Tls-Cipher-Suite" + xffModeAttr = "routing.http.xff_header_processing.mode" + xffClientPortAttr = "routing.http.xff_client_port.enabled" +) + +// serveHTTPListener starts an HTTP(S) server for an ALB listener. Callers must hold b.mu. +func (b *InMemoryBackend) serveHTTPListener(bl *boundListener, h *Handler, arn string, secure bool) { + srv := &http.Server{ + ReadHeaderTimeout: readHeaderTimeout, + ErrorLog: slog.NewLogLogger(logger.Load(bl.ctx).Handler(), slog.LevelDebug), + BaseContext: func(net.Listener) context.Context { return bl.ctx }, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l, lb, ok := b.listenerView(arn) + if !ok { + writeDPError(w, http.StatusServiceUnavailable, msgNoTargets) + + return + } + + h.serveListenerHTTP(w, r, b, lb, l) + }), + } + + ln := bl.tcp + if secure { + ln = tls.NewListener(ln, &tls.Config{GetConfigForClient: b.clientConfig(arn, false)}) + } + + bl.srv = srv + + bl.wg.Go(func() { + _ = srv.Serve(ln) + }) +} + +func (h *Handler) serveListenerHTTP( + w http.ResponseWriter, + r *http.Request, + be StorageBackend, + lb *LoadBalancer, + l *Listener, +) { + if r.TLS != nil { + r.Header.Set(tlsVersionHeader, tlsVersionName(r.TLS.Version)) + r.Header.Set(tlsCipherHeader, opensslCipherName(r.TLS.CipherSuite)) + } + + raw := r.URL.EscapedPath() + if raw == "" { + raw = "/" + } + + ip, port := clientAddrPort(r) + h.route(w, &dpRequest{ + r: r, be: be, lb: lb, listener: l, path: r.URL.Path, rawPath: raw, + host: hostWithoutPort(r.Host), client: ip, clientPort: port, + }) +} + +func clientAddrPort(r *http.Request) (netip.Addr, uint16) { + ap, err := netip.ParseAddrPort(r.RemoteAddr) + if err != nil { + return clientAddr(r), 0 + } + + return ap.Addr().Unmap(), ap.Port() +} + +// xffValue renders the client entry appended to X-Forwarded-For per routing.http.xff_client_port.enabled. +func xffValue(req *dpRequest) string { + ip := req.client.String() + + if req.lb == nil || req.lb.Attributes[xffClientPortAttr] != attrValueTrue || req.clientPort == 0 { + return ip + } + + if req.client.Is6() { + ip = "[" + ip + "]" + } + + return ip + ":" + strconv.Itoa(int(req.clientPort)) +} + +func applyXFF(h http.Header, req *dpRequest) { + switch req.lb.Attributes[xffModeAttr] { + case "preserve": + return + case "remove": + h.Del("X-Forwarded-For") + + return + } + + v := xffValue(req) + if prior := h.Get("X-Forwarded-For"); prior != "" { + v = prior + ", " + v + } + + h.Set("X-Forwarded-For", v) +} + +func isUpgradeRequest(r *http.Request) bool { + if r.Header.Get("Upgrade") == "" { + return false + } + + for _, v := range r.Header.Values("Connection") { + for tok := range strings.SplitSeq(v, ",") { + if strings.EqualFold(strings.TrimSpace(tok), "upgrade") { + return true + } + } + } + + return false +} + +// proxyUpgrade forwards a connection upgrade (WebSocket) to the target via httputil.ReverseProxy, +// which switches to a raw pipe on a 101. +func (h *Handler) proxyUpgrade( + w http.ResponseWriter, + req *dpRequest, + ap netip.AddrPort, + scheme string, + sticky string, + tg *TargetGroup, +) { + rp := &httputil.ReverseProxy{ + Transport: h.dp.client.Transport, + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme = scheme + pr.Out.URL.Host = ap.String() + pr.Out.Host = pr.In.Host + pr.Out.Header["X-Forwarded-For"] = slices.Clone(pr.In.Header.Values("X-Forwarded-For")) + setForwardHeaders(pr.Out.Header, req) + }, + ModifyResponse: func(resp *http.Response) error { + if sticky != "" && resp.StatusCode == http.StatusSwitchingProtocols { + resp.Header.Add("Set-Cookie", stickyCookie(tg, sticky)) + } + + return nil + }, + ErrorHandler: func(ew http.ResponseWriter, _ *http.Request, _ error) { + writeDPError(ew, http.StatusBadGateway, msgBadGateway) + }, + } + + rp.ServeHTTP(w, req.r) +} diff --git a/services/elbv2/listener_l4.go b/services/elbv2/listener_l4.go new file mode 100644 index 0000000000..4a11a93503 --- /dev/null +++ b/services/elbv2/listener_l4.go @@ -0,0 +1,318 @@ +package elbv2 + +import ( + "context" + "crypto/tls" + "encoding/binary" + "io" + "net" + "net/http" + "net/netip" + "sync" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + udpIdleTimeout = 120 * time.Second + udpMaxDatagram = 64 * 1024 + tlsHandshakeLimit = 10 * time.Second + proxyProtocolAttr = "proxy_protocol_v2.enabled" + ppv2CmdProxy = 0x21 + ppv2FamTCP4 = 0x11 + ppv2FamTCP6 = 0x21 + ppv2IPv4AddrBytes = 4 + ppv2IPv6AddrBytes = 16 + ppv2PortBytes = 2 + ppv2SignatureBytes = 12 +) + +var ppv2Signature = [ppv2SignatureBytes]byte{ //nolint:gochecknoglobals // fixed wire constant + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, +} + +type closeWriter interface{ CloseWrite() error } + +// pipeConns copies both directions until each side finishes or ctx is cancelled, then closes both. +func pipeConns(ctx context.Context, client net.Conn, clientR io.Reader, target net.Conn, targetR io.Reader) { + finished := make(chan struct{}) + watcherDone := make(chan struct{}) + + go func() { + defer close(watcherDone) + + select { + case <-ctx.Done(): + _ = client.Close() + _ = target.Close() + case <-finished: + } + }() + + var wg sync.WaitGroup + + cp := func(dst net.Conn, src io.Reader) { + _, _ = io.Copy(dst, src) + + if cw, ok := dst.(closeWriter); ok { + _ = cw.CloseWrite() + } + } + + wg.Go(func() { cp(target, clientR) }) + wg.Go(func() { cp(client, targetR) }) + + wg.Wait() + close(finished) + <-watcherDone + + _ = client.Close() + _ = target.Close() +} + +// serveL4Listener starts the TCP/TLS accept loop and UDP relay for an NLB listener. Callers must hold b.mu. +func (b *InMemoryBackend) serveL4Listener(bl *boundListener, h *Handler, arn string) { + if bl.tcp != nil { + ln := bl.tcp + if bl.endpoint.Protocol == protoTLS { + ln = tls.NewListener(ln, &tls.Config{GetConfigForClient: b.clientConfig(arn, true)}) + } + + bl.wg.Go(func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + + bl.wg.Go(func() { h.serveTCPConn(bl.ctx, b, arn, c) }) + } + }) + } + + if bl.udp != nil { + bl.wg.Go(func() { + h.serveUDP(bl, b, arn) + }) + } +} + +// pickL4Target resolves the forward action's target group and one routable target address. +func (h *Handler) pickL4Target( + be StorageBackend, + l *Listener, + lb *LoadBalancer, + client netip.Addr, +) (*TargetGroup, netip.AddrPort, bool) { + var tuples []TargetGroupTuple + + for _, a := range l.DefaultActions { + if a.Type == actionTypeForward { + tuples = forwardTuples(a) + + break + } + } + + tgArn := pickWeightedGroup(tuples) + if tgArn == "" { + return nil, netip.AddrPort{}, false + } + + groups, err := be.DescribeTargetGroups([]string{tgArn}, nil, "") + if err != nil || len(groups) == 0 { + return nil, netip.AddrPort{}, false + } + + health, err := be.DescribeTargetHealth(tgArn) + if err != nil { + return nil, netip.AddrPort{}, false + } + + cands := candidateTargets(health) + if len(cands) == 0 { + return nil, netip.AddrPort{}, false + } + + tg := &groups[0] + t, _ := h.dp.chooseTarget(tg, cands, &dpRequest{ + r: &http.Request{Header: http.Header{}}, client: client, lb: lb, listener: l, + }) + + ap, ok := h.resolveAddr(tg, t) + + return tg, ap, ok +} + +func (h *Handler) serveTCPConn(ctx context.Context, b *InMemoryBackend, arn string, c net.Conn) { + defer c.Close() + + if tc, ok := c.(*tls.Conn); ok { + hctx, cancel := context.WithTimeout(ctx, tlsHandshakeLimit) + err := tc.HandshakeContext(hctx) + + cancel() + + if err != nil { + return + } + } + + l, lb, ok := b.listenerView(arn) + if !ok { + return + } + + client := netip.Addr{} + if ap, err := netip.ParseAddrPort(c.RemoteAddr().String()); err == nil { + client = ap.Addr().Unmap() + } + + tg, ap, ok := h.pickL4Target(b, l, lb, client) + if !ok { + return + } + + d := net.Dialer{Timeout: dialTimeout} + + tconn, err := d.DialContext(ctx, "tcp", ap.String()) + if err != nil { + logger.Load(ctx).DebugContext(ctx, "elbv2: nlb target dial failed", "target", ap.String(), "error", err) + + return + } + + defer tconn.Close() + + if tg.TargetGroupAttributes[proxyProtocolAttr] == attrValueTrue { + if _, err = tconn.Write(proxyProtocolV2Header(c.RemoteAddr(), c.LocalAddr(), l.Port)); err != nil { + return + } + } + + if tg.Protocol == protoTLS { + tc := tls.Client(tconn, targetTLSConfig()) + if err = tc.HandshakeContext(ctx); err != nil { + return + } + + tconn = tc + } + + pipeConns(ctx, c, c, tconn, tconn) +} + +// proxyProtocolV2Header encodes a PROXY v2 header (no TLVs) for a TCP connection. +func proxyProtocolV2Header(src, dst net.Addr, listenerPort int32) []byte { + sa, _ := netip.ParseAddrPort(src.String()) + da, _ := netip.ParseAddrPort(dst.String()) + dstPort := uint16(listenerPort) //nolint:gosec // validated 1-65535 + + sIP, dIP := sa.Addr().Unmap(), da.Addr().Unmap() + + hdr := append([]byte(nil), ppv2Signature[:]...) + hdr = append(hdr, ppv2CmdProxy) + + if sIP.Is4() && dIP.Is4() { + hdr = append(hdr, ppv2FamTCP4) + hdr = binary.BigEndian.AppendUint16(hdr, 2*ppv2IPv4AddrBytes+2*ppv2PortBytes) + s, d := sIP.As4(), dIP.As4() + hdr = append(hdr, s[:]...) + hdr = append(hdr, d[:]...) + } else { + hdr = append(hdr, ppv2FamTCP6) + hdr = binary.BigEndian.AppendUint16(hdr, 2*ppv2IPv6AddrBytes+2*ppv2PortBytes) + s, d := sIP.As16(), dIP.As16() + hdr = append(hdr, s[:]...) + hdr = append(hdr, d[:]...) + } + + hdr = binary.BigEndian.AppendUint16(hdr, sa.Port()) + + return binary.BigEndian.AppendUint16(hdr, dstPort) +} + +type udpSession struct{ conn net.Conn } + +// serveUDP relays datagrams per client address to a target chosen when the flow starts. +func (h *Handler) serveUDP(bl *boundListener, b *InMemoryBackend, arn string) { + var sessions sync.Map + + buf := make([]byte, udpMaxDatagram) + + for { + n, caddr, err := bl.udp.ReadFrom(buf) + if err != nil { + return + } + + key := caddr.String() + + v, ok := sessions.Load(key) + if !ok { + sess := h.newUDPSession(bl, b, arn, caddr, key, &sessions) + if sess == nil { + continue + } + + v = sess + } + + if s, isSess := v.(*udpSession); isSess { + _, _ = s.conn.Write(buf[:n]) + } + } +} + +func (h *Handler) newUDPSession( + bl *boundListener, b *InMemoryBackend, arn string, caddr net.Addr, key string, sessions *sync.Map, +) *udpSession { + l, lb, ok := b.listenerView(arn) + if !ok { + return nil + } + + client := netip.Addr{} + if ap, err := netip.ParseAddrPort(caddr.String()); err == nil { + client = ap.Addr().Unmap() + } + + _, ap, ok := h.pickL4Target(b, l, lb, client) + if !ok { + return nil + } + + d := net.Dialer{Timeout: dialTimeout} + + conn, err := d.DialContext(bl.ctx, "udp", ap.String()) + if err != nil { + return nil + } + + sess := &udpSession{conn: conn} + sessions.Store(key, sess) + + bl.wg.Go(func() { + defer conn.Close() + defer sessions.Delete(key) + defer context.AfterFunc(bl.ctx, func() { _ = conn.Close() })() + + buf := make([]byte, udpMaxDatagram) + + for { + _ = conn.SetReadDeadline(time.Now().Add(udpIdleTimeout)) + + n, rerr := conn.Read(buf) + if rerr != nil { + return + } + + if _, werr := bl.udp.WriteTo(buf[:n], caddr); werr != nil { + return + } + } + }) + + return sess +} diff --git a/services/elbv2/listener_real_fixture_test.go b/services/elbv2/listener_real_fixture_test.go new file mode 100644 index 0000000000..17baac56fc --- /dev/null +++ b/services/elbv2/listener_real_fixture_test.go @@ -0,0 +1,189 @@ +package elbv2_test + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "net" + "net/http" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + elbv2sdk "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/devtls" + "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +type pemPair struct{ cert, key []byte } + +type realCertResolver struct{ certs map[string]pemPair } + +func (r *realCertResolver) ResolveCertificate(arn string) bool { + _, ok := r.certs[arn] + + return ok +} +func (r *realCertResolver) AddInUseBy(string, string) {} +func (r *realCertResolver) RemoveInUseBy(string, string) {} + +func (r *realCertResolver) CertificateKeyPair(arn string) ([]byte, []byte, bool) { + p, ok := r.certs[arn] + + return p.cert, p.key, ok +} + +type realFixture struct { + be *elbv2.InMemoryBackend + client *elbv2sdk.Client + ca *devtls.CA + res *realCertResolver +} + +func newRealFixture(t *testing.T) *realFixture { + t.Helper() + + ca, err := devtls.NewCA() + require.NoError(t, err) + + res := &realCertResolver{certs: map[string]pemPair{}} + be := elbv2.NewInMemoryBackend("123456789012", "us-east-1") + t.Cleanup(be.Close) + be.SetCertificateResolver(res) + be.EnableRealListeners(elbv2.RealListenerConfig{}) + + h := elbv2.NewHandler(be) + client, _ := newTestELBv2ClientURL(t, h) + + return &realFixture{be: be, client: client, ca: ca, res: res} +} + +// cert issues a CA-signed certificate for hosts and registers it under a fresh ARN. +func (f *realFixture) cert(t *testing.T, name string, hosts ...string) string { + t.Helper() + + c, err := f.ca.Issue(hosts...) + require.NoError(t, err) + + keyDER, err := x509.MarshalPKCS8PrivateKey(c.PrivateKey) + require.NoError(t, err) + + arn := "arn:aws:acm:us-east-1:123456789012:certificate/" + name + f.res.certs[arn] = pemPair{ + cert: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Certificate[0]}), + key: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + } + + return arn +} + +func (f *realFixture) lb(t *testing.T, name string, typ elbv2types.LoadBalancerTypeEnum) string { + t.Helper() + + out, err := f.client.CreateLoadBalancer(t.Context(), &elbv2sdk.CreateLoadBalancerInput{ + Name: aws.String(name), Type: typ, + }) + require.NoError(t, err) + + return aws.ToString(out.LoadBalancers[0].LoadBalancerArn) +} + +func (f *realFixture) tg(t *testing.T, name string, proto elbv2types.ProtocolEnum, target string) string { + t.Helper() + + out, err := f.client.CreateTargetGroup(t.Context(), &elbv2sdk.CreateTargetGroupInput{ + Name: aws.String(name), TargetType: elbv2types.TargetTypeEnumIp, Protocol: proto, + Port: aws.Int32(80), VpcId: aws.String("vpc-1"), + }) + require.NoError(t, err) + + arn := aws.ToString(out.TargetGroups[0].TargetGroupArn) + host, port, err := net.SplitHostPort(target) + require.NoError(t, err) + + p, err := strconv.Atoi(port) + require.NoError(t, err) + + _, err = f.client.RegisterTargets(t.Context(), &elbv2sdk.RegisterTargetsInput{ + TargetGroupArn: aws.String(arn), + Targets: []elbv2types.TargetDescription{{Id: aws.String(host), Port: aws.Int32(int32(p))}}, + }) + require.NoError(t, err) + + return arn +} + +func (f *realFixture) attrs(t *testing.T, arn string, kv map[string]string) { + t.Helper() + + if len(kv) == 0 { + return + } + + var attrs []elbv2types.LoadBalancerAttribute + for k, v := range kv { + attrs = append(attrs, elbv2types.LoadBalancerAttribute{Key: aws.String(k), Value: aws.String(v)}) + } + + _, err := f.client.ModifyLoadBalancerAttributes(t.Context(), &elbv2sdk.ModifyLoadBalancerAttributesInput{ + LoadBalancerArn: aws.String(arn), Attributes: attrs, + }) + require.NoError(t, err) +} + +type listenerOpts struct { + policy string + certs []string + alpn []string +} + +func (f *realFixture) listener( + t *testing.T, lbArn string, proto elbv2types.ProtocolEnum, port int32, tgArn string, o listenerOpts, +) string { + t.Helper() + + in := &elbv2sdk.CreateListenerInput{ + LoadBalancerArn: aws.String(lbArn), Protocol: proto, Port: aws.Int32(port), + DefaultActions: []elbv2types.Action{forwardAction(tgArn)}, + } + if o.policy != "" { + in.SslPolicy = aws.String(o.policy) + } + + for _, c := range o.certs { + in.Certificates = append(in.Certificates, elbv2types.Certificate{CertificateArn: aws.String(c)}) + } + + in.AlpnPolicy = o.alpn + + out, err := f.client.CreateListener(t.Context(), in) + require.NoError(t, err) + + return aws.ToString(out.Listeners[0].ListenerArn) +} + +func (f *realFixture) addr(t *testing.T, listenerArn string) string { + t.Helper() + + ep, ok := f.be.ListenerEndpoint(listenerArn) + require.True(t, ok, "listener must be bound") + + return ep.Address +} + +func (f *realFixture) httpClient(t *testing.T, serverName string, maxVersion uint16) *http.Client { + t.Helper() + + tr := &http.Transport{ + DisableKeepAlives: true, + TLSClientConfig: &tls.Config{ + RootCAs: f.ca.Pool(), ServerName: serverName, MinVersion: tls.VersionTLS12, MaxVersion: maxVersion, + }, + } + t.Cleanup(tr.CloseIdleConnections) + + return &http.Client{Transport: tr} +} diff --git a/services/elbv2/listener_real_test.go b/services/elbv2/listener_real_test.go new file mode 100644 index 0000000000..2600bcbe6d --- /dev/null +++ b/services/elbv2/listener_real_test.go @@ -0,0 +1,740 @@ +package elbv2_test + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + elbv2sdk "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +const ( + policyTLS12 = "ELBSecurityPolicy-TLS13-1-2-2021-06" + policyTLS13 = "ELBSecurityPolicy-TLS13-1-3-2021-06" + policyOld = "ELBSecurityPolicy-2016-08" +) + +type headerEcho struct { + srv *httptest.Server +} + +// newHeaderEcho replies with the request headers it saw as "Name: value" lines. +func newHeaderEcho(t *testing.T) *headerEcho { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for k, vs := range r.Header { + for _, v := range vs { + _, _ = w.Write([]byte(k + ": " + v + "\n")) + } + } + })) + t.Cleanup(srv.Close) + + return &headerEcho{srv: srv} +} + +func (e *headerEcho) addr() string { return e.srv.Listener.Addr().String() } + +func headerLines(body string) map[string]string { + out := map[string]string{} + + for line := range strings.SplitSeq(body, "\n") { + if k, v, ok := strings.Cut(line, ": "); ok { + out[k] = v + } + } + + return out +} + +func getBody(t *testing.T, c *http.Client, url string, hdr map[string]string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) + require.NoError(t, err) + + for k, v := range hdr { + req.Header.Set(k, v) + } + + resp, err := c.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(b) +} + +func TestRealListener_HTTPForwardHeaders(t *testing.T) { + t.Parallel() + + tests := []struct { + attrs map[string]string + name string + priorXFF string + wantXFF string + wantPrefix string + wantNoXFF bool + }{ + {name: "append default", wantXFF: "127.0.0.1"}, + {name: "append existing", priorXFF: "10.0.0.9", wantXFF: "10.0.0.9, 127.0.0.1"}, + { + name: "preserve", priorXFF: "10.0.0.9", wantXFF: "10.0.0.9", + attrs: map[string]string{"routing.http.xff_header_processing.mode": "preserve"}, + }, + { + name: "remove", priorXFF: "10.0.0.9", wantNoXFF: true, + attrs: map[string]string{"routing.http.xff_header_processing.mode": "remove"}, + }, + { + name: "client port", wantPrefix: "127.0.0.1:", + attrs: map[string]string{"routing.http.xff_client_port.enabled": "true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + echo := newHeaderEcho(t) + lb := f.lb(t, "alb-http", elbv2types.LoadBalancerTypeEnumApplication) + tg := f.tg(t, "tg-http", elbv2types.ProtocolEnumHttp, echo.addr()) + f.attrs(t, lb, tt.attrs) + l := f.listener(t, lb, elbv2types.ProtocolEnumHttp, 80, tg, listenerOpts{}) + + hdr := map[string]string{} + if tt.priorXFF != "" { + hdr["X-Forwarded-For"] = tt.priorXFF + } + + status, body := getBody(t, f.httpClient(t, "", 0), "http://"+f.addr(t, l)+"/x?y=1", hdr) + require.Equal(t, http.StatusOK, status) + + got := headerLines(body) + assert.Equal(t, "http", got["X-Forwarded-Proto"]) + assert.Equal(t, "80", got["X-Forwarded-Port"], "port is the listener port, not the host port") + assert.NotEmpty(t, got["X-Amzn-Trace-Id"]) + + switch { + case tt.wantNoXFF: + assert.NotContains(t, got, "X-Forwarded-For") + case tt.wantPrefix != "": + assert.True(t, strings.HasPrefix(got["X-Forwarded-For"], tt.wantPrefix), got["X-Forwarded-For"]) + default: + assert.Equal(t, tt.wantXFF, got["X-Forwarded-For"]) + } + }) + } +} + +func TestRealListener_HTTPSTerminationAndSNI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + serverName string + wantDNS string + }{ + {name: "sni selects additional", serverName: "b.example.test", wantDNS: "b.example.test"}, + {name: "sni selects default", serverName: "a.example.test", wantDNS: "a.example.test"}, + {name: "no sni uses default", serverName: "", wantDNS: "a.example.test"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + echo := newHeaderEcho(t) + lb := f.lb(t, "alb-https", elbv2types.LoadBalancerTypeEnumApplication) + tg := f.tg(t, "tg-https", elbv2types.ProtocolEnumHttp, echo.addr()) + certA := f.cert(t, "a", "a.example.test", "127.0.0.1") + certB := f.cert(t, "b", "b.example.test") + l := f.listener( + t, + lb, + elbv2types.ProtocolEnumHttps, + 443, + tg, + listenerOpts{certs: []string{certA}, policy: policyTLS12}, + ) + + _, err := f.client.AddListenerCertificates(t.Context(), &elbv2sdk.AddListenerCertificatesInput{ + ListenerArn: aws.String(l), + Certificates: []elbv2types.Certificate{{CertificateArn: aws.String(certB)}}, + }) + require.NoError(t, err) + + addr := f.addr(t, l) + host, port, err := net.SplitHostPort(addr) + require.NoError(t, err) + + url := "https://" + addr + "/p" + name := tt.serverName + + if name == "" { + name = host + } + + if tt.serverName != "" { + url = "https://" + net.JoinHostPort(tt.serverName, port) + "/p" + } + + c := f.httpClient(t, name, 0) + tr, ok := c.Transport.(*http.Transport) + require.True(t, ok) + + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + + return d.DialContext(ctx, "tcp", addr) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) + require.NoError(t, err) + + resp, err := c.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + require.NotNil(t, resp.TLS) + assert.Contains(t, resp.TLS.PeerCertificates[0].DNSNames, tt.wantDNS) + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + got := headerLines(string(b)) + assert.Equal(t, "https", got["X-Forwarded-Proto"]) + assert.Equal(t, "443", got["X-Forwarded-Port"]) + assert.Equal(t, "TLSv1.3", got["X-Amzn-Tls-Version"]) + assert.NotEmpty(t, got["X-Amzn-Tls-Cipher-Suite"]) + }) + } +} + +func TestRealListener_SecurityPolicyVersions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy string + maxVersion uint16 + wantErr bool + }{ + {name: "tls13 only rejects tls12", policy: policyTLS13, maxVersion: tls.VersionTLS12, wantErr: true}, + {name: "tls13 only accepts tls13", policy: policyTLS13, maxVersion: tls.VersionTLS13}, + {name: "1-2 policy accepts tls12", policy: policyTLS12, maxVersion: tls.VersionTLS12}, + {name: "2016-08 accepts tls12", policy: policyOld, maxVersion: tls.VersionTLS12}, + {name: "default policy accepts tls12", policy: "", maxVersion: tls.VersionTLS12}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + echo := newHeaderEcho(t) + lb := f.lb(t, "alb-pol", elbv2types.LoadBalancerTypeEnumApplication) + tg := f.tg(t, "tg-pol", elbv2types.ProtocolEnumHttp, echo.addr()) + cert := f.cert(t, "pol", "127.0.0.1") + l := f.listener( + t, + lb, + elbv2types.ProtocolEnumHttps, + 443, + tg, + listenerOpts{certs: []string{cert}, policy: tt.policy}, + ) + + conn, err := tls.Dial("tcp", f.addr(t, l), &tls.Config{ + RootCAs: f.ca.Pool(), ServerName: "127.0.0.1", MinVersion: tls.VersionTLS12, MaxVersion: tt.maxVersion, + }) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + require.NoError(t, conn.Close()) + }) + } +} + +// wsTarget accepts an HTTP upgrade and then echoes raw bytes. +func wsTarget(t *testing.T) string { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "no upgrade", http.StatusBadRequest) + + return + } + + conn, rw, err := http.NewResponseController(w).Hijack() + if err != nil { + return + } + + defer conn.Close() + + _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n") + _ = rw.Flush() + _, _ = io.Copy(conn, rw) + })) + t.Cleanup(srv.Close) + + return srv.Listener.Addr().String() +} + +func TestRealListener_WebSocketEcho(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + proto elbv2types.ProtocolEnum + port int32 + }{ + {name: "ws over http", proto: elbv2types.ProtocolEnumHttp, port: 80}, + {name: "wss over https", proto: elbv2types.ProtocolEnumHttps, port: 443}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + lb := f.lb(t, "alb-ws", elbv2types.LoadBalancerTypeEnumApplication) + tg := f.tg(t, "tg-ws", elbv2types.ProtocolEnumHttp, wsTarget(t)) + + opts := listenerOpts{} + if tt.proto == elbv2types.ProtocolEnumHttps { + opts.certs = []string{f.cert(t, "ws", "127.0.0.1")} + } + + l := f.listener(t, lb, tt.proto, tt.port, tg, opts) + + var ( + conn net.Conn + err error + ) + + if tt.proto == elbv2types.ProtocolEnumHttps { + conn, err = tls.Dial("tcp", f.addr(t, l), &tls.Config{ + RootCAs: f.ca.Pool(), ServerName: "127.0.0.1", MinVersion: tls.VersionTLS12, + }) + } else { + conn, err = net.Dial("tcp", f.addr(t, l)) + } + + require.NoError(t, err) + + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = io.WriteString(conn, "GET /ws HTTP/1.1\r\nHost: example.test\r\nConnection: Upgrade\r\n"+ + "Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n") + require.NoError(t, err) + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + + _, err = io.WriteString(conn, "ping-frame") + require.NoError(t, err) + + buf := make([]byte, len("ping-frame")) + _, err = io.ReadFull(br, buf) + require.NoError(t, err) + assert.Equal(t, "ping-frame", string(buf)) + }) + } +} + +func tcpEcho(t *testing.T, onConn func(net.Conn) io.Reader) string { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + var wg sync.WaitGroup + + t.Cleanup(func() { _ = ln.Close(); wg.Wait() }) + + wg.Go(func() { + for { + c, aerr := ln.Accept() + if aerr != nil { + return + } + + wg.Go(func() { + defer c.Close() + + r := io.Reader(c) + if onConn != nil { + r = onConn(c) + } + + _, _ = io.Copy(c, r) + }) + } + }) + + return ln.Addr().String() +} + +func TestRealListener_NLBTCPEcho(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tls bool + proxyProtocol bool + }{ + {name: "tcp"}, + {name: "tls", tls: true}, + {name: "tcp proxy protocol v2", proxyProtocol: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + + ppHeader := make(chan string, 1) + + target := tcpEcho(t, func(c net.Conn) io.Reader { + if !tt.proxyProtocol { + return c + } + + hdr := make([]byte, 16+12) + if _, err := io.ReadFull(c, hdr); err != nil { + return c + } + + ppHeader <- fmt.Sprintf("%x|%x|%d|%s:%d|%d", hdr[12], hdr[13], binary.BigEndian.Uint16(hdr[14:16]), + net.IP(hdr[16:20]), binary.BigEndian.Uint16(hdr[24:26]), binary.BigEndian.Uint16(hdr[26:28])) + + return c + }) + + lb := f.lb(t, "nlb-tcp", elbv2types.LoadBalancerTypeEnumNetwork) + tgProto := elbv2types.ProtocolEnumTcp + tg := f.tg(t, "tg-tcp", tgProto, target) + + if tt.proxyProtocol { + _, err := f.client.ModifyTargetGroupAttributes(t.Context(), &elbv2sdk.ModifyTargetGroupAttributesInput{ + TargetGroupArn: aws.String(tg), + Attributes: []elbv2types.TargetGroupAttribute{ + {Key: aws.String("proxy_protocol_v2.enabled"), Value: aws.String("true")}, + }, + }) + require.NoError(t, err) + } + + proto := elbv2types.ProtocolEnumTcp + opts := listenerOpts{} + + if tt.tls { + proto = elbv2types.ProtocolEnumTls + opts = listenerOpts{certs: []string{f.cert(t, "nlb", "127.0.0.1")}, policy: policyTLS12} + } + + l := f.listener(t, lb, proto, 9000, tg, opts) + + var ( + conn net.Conn + err error + ) + + if tt.tls { + conn, err = tls.Dial("tcp", f.addr(t, l), &tls.Config{ + RootCAs: f.ca.Pool(), ServerName: "127.0.0.1", MinVersion: tls.VersionTLS12, + }) + } else { + conn, err = net.Dial("tcp", f.addr(t, l)) + } + + require.NoError(t, err) + + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = io.WriteString(conn, "hello-nlb") + require.NoError(t, err) + + buf := make([]byte, len("hello-nlb")) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + assert.Equal(t, "hello-nlb", string(buf)) + + if tt.proxyProtocol { + got := <-ppHeader + assert.True(t, strings.HasPrefix(got, "21|11|12|127.0.0.1:"), got) + assert.True(t, strings.HasSuffix(got, "|9000"), "destination port is the listener port: %s", got) + } + }) + } +} + +func TestRealListener_NLBUDPEcho(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + + done := make(chan struct{}) + + go func() { + defer close(done) + + buf := make([]byte, 1500) + + for { + n, addr, rerr := pc.ReadFrom(buf) + if rerr != nil { + return + } + + _, _ = pc.WriteTo(buf[:n], addr) + } + }() + + t.Cleanup(func() { _ = pc.Close(); <-done }) + + lb := f.lb(t, "nlb-udp", elbv2types.LoadBalancerTypeEnumNetwork) + tg := f.tg(t, "tg-udp", elbv2types.ProtocolEnumUdp, pc.LocalAddr().String()) + l := f.listener(t, lb, elbv2types.ProtocolEnumUdp, 5353, tg, listenerOpts{}) + + conn, err := net.Dial("udp", f.addr(t, l)) + require.NoError(t, err) + + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = conn.Write([]byte("dgram")) + require.NoError(t, err) + + buf := make([]byte, 16) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "dgram", string(buf[:n])) +} + +func unreachable(t *testing.T, addr string) { + t.Helper() + + c, err := net.DialTimeout("tcp", addr, time.Second) + if err == nil { + _ = c.Close() + } + + require.Error(t, err, "socket %s should be closed", addr) +} + +func TestRealListener_Lifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + act func(t *testing.T, f *realFixture, lb, l string) + name string + }{ + {name: "delete listener", act: func(t *testing.T, f *realFixture, _, l string) { + t.Helper() + + _, err := f.client.DeleteListener(t.Context(), &elbv2sdk.DeleteListenerInput{ListenerArn: aws.String(l)}) + require.NoError(t, err) + }}, + {name: "delete load balancer", act: func(t *testing.T, f *realFixture, lb, _ string) { + t.Helper() + + _, err := f.client.DeleteLoadBalancer( + t.Context(), + &elbv2sdk.DeleteLoadBalancerInput{LoadBalancerArn: aws.String(lb)}, + ) + require.NoError(t, err) + }}, + {name: "backend close", act: func(t *testing.T, f *realFixture, _, _ string) { + t.Helper() + + f.be.Close() + }}, + {name: "modify port rebinds", act: func(t *testing.T, f *realFixture, _, l string) { + t.Helper() + + _, err := f.client.ModifyListener(t.Context(), &elbv2sdk.ModifyListenerInput{ + ListenerArn: aws.String(l), Port: aws.Int32(8081), + }) + require.NoError(t, err) + + ep, ok := f.be.ListenerEndpoint(l) + require.True(t, ok) + assert.Equal(t, int32(8081), ep.Port) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + echo := newHeaderEcho(t) + lb := f.lb(t, "alb-life", elbv2types.LoadBalancerTypeEnumApplication) + tg := f.tg(t, "tg-life", elbv2types.ProtocolEnumHttp, echo.addr()) + l := f.listener(t, lb, elbv2types.ProtocolEnumHttp, 80, tg, listenerOpts{}) + addr := f.addr(t, l) + + status, _ := getBody(t, f.httpClient(t, "", 0), "http://"+addr+"/", nil) + require.Equal(t, http.StatusOK, status) + + tt.act(t, f, lb, l) + unreachable(t, addr) + }) + } +} + +func TestRealListener_PortMapping(t *testing.T) { + t.Parallel() + + free, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + freePort := int32(free.Addr().(*net.TCPAddr).Port) //nolint:forcetypeassert // test listener is TCP + require.NoError(t, free.Close()) + + tests := []struct { + name string + port int32 + wantMapped bool + }{ + {name: "unprivileged free port binds directly", port: freePort}, + {name: "privileged port maps to pool", port: 80, wantMapped: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + echo := newHeaderEcho(t) + lb := f.lb(t, "alb-map", elbv2types.LoadBalancerTypeEnumApplication) + tg := f.tg(t, "tg-map", elbv2types.ProtocolEnumHttp, echo.addr()) + l := f.listener(t, lb, elbv2types.ProtocolEnumHttp, tt.port, tg, listenerOpts{}) + + ep, ok := f.be.ListenerEndpoint(l) + require.True(t, ok) + assert.Equal(t, tt.port, ep.Port) + assert.Equal(t, tt.wantMapped, int32(ep.HostPort) != tt.port) + assert.Len(t, f.be.ListenerEndpoints(), 1) + }) + } +} + +func TestRealListener_DisabledByDefault(t *testing.T) { + t.Parallel() + + f := newDPFixture(t) + tg := f.tg(t, "tg-off", elbv2types.TargetTypeEnumIp, "") + l := f.listener(t, forwardAction(tg)) + + be, ok := f.h.Backend.(*elbv2.InMemoryBackend) + require.True(t, ok) + + _, bound := be.ListenerEndpoint(l) + assert.False(t, bound) + assert.Empty(t, be.ListenerEndpoints()) +} + +func TestRealListener_DeleteDuringConnectionStorm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + proto elbv2types.ProtocolEnum + }{ + {name: "tcp", proto: elbv2types.ProtocolEnumTcp}, + {name: "udp", proto: elbv2types.ProtocolEnumUdp}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newRealFixture(t) + lb := f.lb(t, "nlb-storm", elbv2types.LoadBalancerTypeEnumNetwork) + target := tcpEcho(t, nil) + + tgProto := elbv2types.ProtocolEnumTcp + if tt.proto == elbv2types.ProtocolEnumUdp { + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = pc.Close() }) + + target, tgProto = pc.LocalAddr().String(), elbv2types.ProtocolEnumUdp + } + + tg := f.tg(t, "tg-storm", tgProto, target) + l := f.listener(t, lb, tt.proto, 9100, tg, listenerOpts{}) + addr := f.addr(t, l) + network := "tcp" + + if tt.proto == elbv2types.ProtocolEnumUdp { + network = "udp" + } + + var wg sync.WaitGroup + + for range 8 { + wg.Go(func() { + for range 2000 { + c, err := net.Dial(network, addr) + if err != nil { + return + } + + _, _ = c.Write([]byte("x")) + _ = c.Close() + } + }) + } + + _, err := f.client.DeleteListener(t.Context(), &elbv2sdk.DeleteListenerInput{ListenerArn: aws.String(l)}) + require.NoError(t, err) + + if network == "tcp" { + unreachable(t, addr) + } + + wg.Wait() + }) + } +} diff --git a/services/elbv2/listener_runtime.go b/services/elbv2/listener_runtime.go new file mode 100644 index 0000000000..125120066f --- /dev/null +++ b/services/elbv2/listener_runtime.go @@ -0,0 +1,414 @@ +package elbv2 + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "sync" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const ( + // ListenerModeReal binds a real local socket per listener. + ListenerModeReal = "real" + // ListenerModeRouted keeps the path/host-routed data plane. + ListenerModeRouted = "routed" + + defaultListenerHost = "127.0.0.1" + // privilegedPortLimit: below this the requested port is never tried directly. + privilegedPortLimit = 1024 + bindAttempts = 16 +) + +var errNoHandler = errors.New("no handler attached to backend") + +// RealListenerConfig opts a backend into binding a real socket per listener. +type RealListenerConfig struct { + // Ports supplies host ports when the requested one is privileged or taken; nil uses ephemeral ports. + Ports *portalloc.Allocator + // Host is the bind address, default 127.0.0.1. + Host string +} + +// ListenerEndpoint is where a bound listener is reachable on the host. +type ListenerEndpoint struct { + ListenerArn string `json:"listenerArn"` + LoadBalancerArn string `json:"loadBalancerArn"` + Protocol string `json:"protocol"` + Address string `json:"address"` + Port int32 `json:"port"` + HostPort int `json:"hostPort"` +} + +type realListeners struct { + cfg *RealListenerConfig + handler *Handler + bound map[string]*boundListener + closed bool +} + +type boundListener struct { + ctx context.Context + cancel context.CancelFunc + tcp net.Listener + udp net.PacketConn + srv httpServer + sig string + endpoint ListenerEndpoint + poolPort int + wg sync.WaitGroup +} + +type httpServer interface{ Close() error } + +func (bl *boundListener) stop(alloc *portalloc.Allocator) { + bl.cancel() + + if bl.srv != nil { + _ = bl.srv.Close() + } + + if bl.tcp != nil { + _ = bl.tcp.Close() + } + + if bl.udp != nil { + _ = bl.udp.Close() + } + + bl.wg.Wait() + portalloc.ReleaseTo(alloc, bl.poolPort) +} + +// EnableRealListeners switches the backend to real-listener mode. Call before serving traffic. +func (b *InMemoryBackend) EnableRealListeners(cfg RealListenerConfig) { + b.mu.Lock("EnableRealListeners") + defer b.mu.Unlock() + + if cfg.Host == "" { + cfg.Host = defaultListenerHost + } + + b.rl.cfg = &cfg +} + +// ListenerEndpoints returns the host endpoint of every bound listener. +func (b *InMemoryBackend) ListenerEndpoints() []ListenerEndpoint { + b.mu.RLock("ListenerEndpoints") + defer b.mu.RUnlock() + + out := make([]ListenerEndpoint, 0, len(b.rl.bound)) + for _, bl := range b.rl.bound { + out = append(out, bl.endpoint) + } + + return out +} + +// ListenerEndpoint returns the host endpoint of one bound listener. +func (b *InMemoryBackend) ListenerEndpoint(listenerArn string) (ListenerEndpoint, bool) { + b.mu.RLock("ListenerEndpoint") + defer b.mu.RUnlock() + + bl, ok := b.rl.bound[listenerArn] + if !ok { + return ListenerEndpoint{}, false + } + + return bl.endpoint, true +} + +func (b *InMemoryBackend) attachHandler(h *Handler) { + b.mu.Lock("attachHandler") + defer b.mu.Unlock() + + b.rl.handler = h +} + +func isRealProtocol(proto string) bool { + switch proto { + case protoHTTP, protoHTTPS, protoTCP, protoTLS, protoUDP, protoTCPUDP: + return true + } + + return false +} + +func listenerSig(l *Listener) string { + return l.Protocol + "|" + strconv.Itoa(int(l.Port)) +} + +// syncListeners reconciles bound sockets with the listener table. Callers must not hold b.mu. +func (b *InMemoryBackend) syncListeners() { + b.mu.Lock("syncListeners") + + cfg := b.rl.cfg + if cfg == nil || b.rl.closed { + b.mu.Unlock() + + return + } + + want := make(map[string]*Listener) + + for _, l := range b.listeners.All() { + if isRealProtocol(l.Protocol) { + want[l.ListenerArn] = l + } + } + + var stale []*boundListener + + for arn, bl := range b.rl.bound { + if l, ok := want[arn]; !ok || bl.sig != listenerSig(l) { + stale = append(stale, bl) + + delete(b.rl.bound, arn) + } + } + + b.mu.Unlock() + + for _, bl := range stale { + bl.stop(cfg.Ports) + } + + b.bindMissing(cfg) +} + +func (b *InMemoryBackend) bindMissing(cfg *RealListenerConfig) { + b.mu.Lock("bindMissing") + defer b.mu.Unlock() + + if b.rl.closed { + return + } + + for _, l := range b.listeners.All() { + if !isRealProtocol(l.Protocol) { + continue + } + + if _, ok := b.rl.bound[l.ListenerArn]; ok { + continue + } + + bl, err := b.bindListener(cfg, l) + if err != nil { + logger.Load(context.Background()).Warn("elbv2: cannot bind listener", + "listener", l.ListenerArn, "error", err) + + continue + } + + b.rl.bound[l.ListenerArn] = bl + } +} + +func (b *InMemoryBackend) closeRealListeners() { + b.mu.Lock("closeRealListeners") + + b.rl.closed = true + cfg := b.rl.cfg + all := make([]*boundListener, 0, len(b.rl.bound)) + + for arn, bl := range b.rl.bound { + all = append(all, bl) + + delete(b.rl.bound, arn) + } + + b.mu.Unlock() + + var alloc *portalloc.Allocator + if cfg != nil { + alloc = cfg.Ports + } + + for _, bl := range all { + bl.stop(alloc) + } +} + +type socketPair struct { + tcp net.Listener + udp net.PacketConn + port int + poolPort int +} + +func listenOn(host string, port int, wantTCP, wantUDP bool) (net.Listener, net.PacketConn, error) { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + + var ( + tcp net.Listener + udp net.PacketConn + err error + lc net.ListenConfig + ) + + if wantTCP { + tcp, err = lc.Listen(context.Background(), "tcp", addr) + if err != nil { + return nil, nil, fmt.Errorf("listen tcp %s: %w", addr, err) + } + } + + if wantUDP { + udp, err = lc.ListenPacket(context.Background(), "udp", addr) + if err != nil { + if tcp != nil { + _ = tcp.Close() + } + + return nil, nil, fmt.Errorf("listen udp %s: %w", addr, err) + } + } + + return tcp, udp, nil +} + +func tcpPort(tcp net.Listener, udp net.PacketConn) int { + if tcp != nil { + if a, ok := tcp.Addr().(*net.TCPAddr); ok { + return a.Port + } + } + + if a, ok := udp.LocalAddr().(*net.UDPAddr); ok { + return a.Port + } + + return 0 +} + +// bindSockets tries the requested port first (unless privileged) and falls back to the port pool. +func bindSockets(cfg *RealListenerConfig, l *Listener, label string) (socketPair, error) { + wantTCP := l.Protocol != protoUDP + wantUDP := l.Protocol == protoUDP || l.Protocol == protoTCPUDP + + if l.Port >= privilegedPortLimit { + tcp, udp, err := listenOn(cfg.Host, int(l.Port), wantTCP, wantUDP) + if err == nil { + return socketPair{tcp: tcp, udp: udp, port: tcpPort(tcp, udp)}, nil + } + } + + var lastErr error + + for range bindAttempts { + p, err := portalloc.AcquireFrom(cfg.Ports, label) + if err != nil { + return socketPair{}, fmt.Errorf("acquire host port: %w", err) + } + + tcp, udp, err := listenOn(cfg.Host, p, wantTCP, wantUDP) + if err == nil { + return socketPair{tcp: tcp, udp: udp, port: tcpPort(tcp, udp), poolPort: p}, nil + } + + portalloc.ReleaseTo(cfg.Ports, p) + + lastErr = err + } + + return socketPair{}, lastErr +} + +// bindListener binds sockets for l and starts serving. Callers must hold b.mu. +func (b *InMemoryBackend) bindListener(cfg *RealListenerConfig, l *Listener) (*boundListener, error) { + h := b.rl.handler + if h == nil { + return nil, errNoHandler + } + + sp, err := bindSockets(cfg, l, "elbv2-listener:"+l.ListenerArn) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + bl := &boundListener{ + ctx: ctx, cancel: cancel, tcp: sp.tcp, udp: sp.udp, poolPort: sp.poolPort, sig: listenerSig(l), + endpoint: ListenerEndpoint{ + ListenerArn: l.ListenerArn, LoadBalancerArn: l.LoadBalancerArn, Protocol: l.Protocol, + Port: l.Port, HostPort: sp.port, Address: net.JoinHostPort(cfg.Host, strconv.Itoa(sp.port)), + }, + } + + switch l.Protocol { + case protoHTTP, protoHTTPS: + b.serveHTTPListener(bl, h, l.ListenerArn, l.Protocol == protoHTTPS) + default: + b.serveL4Listener(bl, h, l.ListenerArn) + } + + logger.Load(context.Background()).Info("elbv2: listener bound", + "listener", l.ListenerArn, "protocol", l.Protocol, "port", l.Port, "address", bl.endpoint.Address) + + return bl, nil +} + +// listenerView returns copies of the listener and its load balancer. +func (b *InMemoryBackend) listenerView(arn string) (*Listener, *LoadBalancer, bool) { + b.mu.RLock("listenerView") + defer b.mu.RUnlock() + + l, ok := b.listeners.Get(arn) + if !ok { + return nil, nil, false + } + + lb, ok := b.loadBalancers.Get(l.LoadBalancerArn) + if !ok { + return nil, nil, false + } + + lc, lbc := *l, *lb + + return &lc, &lbc, true +} + +// CreateListener creates a listener and binds its socket in real-listener mode. +func (b *InMemoryBackend) CreateListener(input CreateListenerInput) (*Listener, error) { + l, err := b.createListener(input) + b.syncListeners() + + return l, err +} + +// DeleteListener deletes a listener and closes its socket. +func (b *InMemoryBackend) DeleteListener(listenerArn string) error { + err := b.deleteListener(listenerArn) + b.syncListeners() + + return err +} + +// ModifyListener updates a listener and rebinds its socket when protocol or port changed. +func (b *InMemoryBackend) ModifyListener(input ModifyListenerInput) (*Listener, error) { + l, err := b.modifyListener(input) + b.syncListeners() + + return l, err +} + +// DeleteLoadBalancer deletes a load balancer and closes its listeners' sockets. +func (b *InMemoryBackend) DeleteLoadBalancer(lbArn string) error { + err := b.deleteLoadBalancer(lbArn) + b.syncListeners() + + return err +} + +// Restore loads a snapshot and rebinds sockets for the restored listeners. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + err := b.restore(ctx, data) + b.syncListeners() + + return err +} diff --git a/services/elbv2/listener_tls.go b/services/elbv2/listener_tls.go new file mode 100644 index 0000000000..6bb55b4b7c --- /dev/null +++ b/services/elbv2/listener_tls.go @@ -0,0 +1,300 @@ +package elbv2 + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "slices" + "strings" +) + +var ( + errNoCertificate = errors.New("no usable certificate for listener") + errNoMatchingServer = errors.New("no compatible certificate for requested server name") +) + +const ( + alpnHTTP11 = "http/1.1" + alpnHTTP10 = "http/1.0" + alpnH2 = "h2" +) + +const defaultSSLPolicy = "ELBSecurityPolicy-2016-08" + +// cipherTable pairs a policy-table OpenSSL cipher name with its IANA name. Suites Go does not +// implement (ECDHE CBC-SHA384, RSA AES256-SHA256) are absent and dropped from the policy. +const cipherTable = ` +ECDHE-ECDSA-AES128-GCM-SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 +ECDHE-RSA-AES128-GCM-SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 +ECDHE-ECDSA-AES256-GCM-SHA384 TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 +ECDHE-RSA-AES256-GCM-SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 +ECDHE-ECDSA-AES128-SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 +ECDHE-RSA-AES128-SHA256 TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 +ECDHE-ECDSA-AES128-SHA TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA +ECDHE-RSA-AES128-SHA TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA +ECDHE-ECDSA-AES256-SHA TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA +ECDHE-RSA-AES256-SHA TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA +AES128-GCM-SHA256 TLS_RSA_WITH_AES_128_GCM_SHA256 +AES256-GCM-SHA384 TLS_RSA_WITH_AES_256_GCM_SHA384 +AES128-SHA256 TLS_RSA_WITH_AES_128_CBC_SHA256 +AES128-SHA TLS_RSA_WITH_AES_128_CBC_SHA +AES256-SHA TLS_RSA_WITH_AES_256_CBC_SHA +` + +func goCipherSuite(name string) (uint16, bool) { + for line := range strings.Lines(cipherTable) { + f := strings.Fields(line) + if len(f) != 2 || f[0] != name { + continue + } + + for _, list := range [][]*tls.CipherSuite{tls.CipherSuites(), tls.InsecureCipherSuites()} { + for _, s := range list { + if s.Name == f[1] { + return s.ID, true + } + } + } + } + + return 0, false +} + +func tlsVersionOf(proto string) uint16 { + switch proto { + case "TLSv1.3": + return tls.VersionTLS13 + case "TLSv1.2": + return tls.VersionTLS12 + case "TLSv1.1": + return tls.VersionTLS11 + case "TLSv1": + return tls.VersionTLS10 + } + + return 0 +} + +type policyTLS struct { + ciphers []uint16 + min uint16 + max uint16 +} + +// policyTLSFor derives min/max protocol and TLS 1.2 suites from the catalog entry for name. +func policyTLSFor(name string) policyTLS { + if name == "" { + name = defaultSSLPolicy + } + + groups := sslCipherGroups() + + for _, spec := range sslPolicySpecs() { + if spec.name != name { + continue + } + + p := policyTLS{min: tls.VersionTLS13} + + for _, proto := range spec.protocols { + v := tlsVersionOf(proto) + p.min = min(p.min, v) + p.max = max(p.max, v) + } + + for _, c := range groups[spec.ciphers] { + if id, ok := goCipherSuite(c); ok { + p.ciphers = append(p.ciphers, id) + } + } + + return p + } + + return policyTLS{min: tls.VersionTLS12, max: tls.VersionTLS13} +} + +// opensslCipherName reports the policy-table spelling of a negotiated suite, as ALB's X-Amzn-Tls-Cipher-Suite does. +func opensslCipherName(id uint16) string { + for _, g := range sslCipherGroups() { + for _, n := range g { + if gid, ok := goCipherSuite(n); ok && gid == id { + return n + } + } + } + + return tls.CipherSuiteName(id) +} + +func tlsVersionName(v uint16) string { + switch v { + case tls.VersionTLS13: + return "TLSv1.3" + case tls.VersionTLS12: + return "TLSv1.2" + case tls.VersionTLS11: + return "TLSv1.1" + case tls.VersionTLS10: + return "TLSv1" + } + + return "" +} + +func alpnProtocols(policy []string) []string { + if len(policy) == 0 { + return nil + } + + switch policy[0] { + case "HTTP1Only": + return []string{alpnHTTP11, alpnHTTP10} + case "HTTP2Only": + return []string{alpnH2} + case "HTTP2Optional": + return []string{alpnHTTP11, alpnHTTP10, alpnH2} + case "HTTP2Preferred": + return []string{alpnH2, alpnHTTP11, alpnHTTP10} + } + + return nil +} + +func loadKeyPair(r CertificateResolver, arn string) (*tls.Certificate, error) { + kp, ok := r.(CertificateKeyPairResolver) + if !ok { + return nil, fmt.Errorf("%w: resolver exposes no key material", errNoCertificate) + } + + certPEM, keyPEM, found := kp.CertificateKeyPair(arn) + if !found { + return nil, fmt.Errorf("%w: %s", errNoCertificate, arn) + } + + c, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("%w: %s: %w", errNoCertificate, arn, err) + } + + c.Leaf, err = x509.ParseCertificate(c.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("%w: %s: %w", errNoCertificate, arn, err) + } + + return &c, nil +} + +// selectCertificate follows the documented ALB algorithm: a SNI match among the certificate list wins +// (ECDSA over RSA, then latest expiry); no SNI or no match falls back to the default certificate. +func selectCertificate( + certs []*tls.Certificate, def *tls.Certificate, hello *tls.ClientHelloInfo, +) (*tls.Certificate, error) { + if hello.ServerName != "" { + if matched := matchingCerts(certs, hello.ServerName); len(matched) > 0 { + return bestCompatible(matched, hello) + } + } + + if def == nil { + return nil, errNoCertificate + } + + return def, nil +} + +func matchingCerts(certs []*tls.Certificate, serverName string) []*tls.Certificate { + return slices.DeleteFunc(slices.Clone(certs), func(c *tls.Certificate) bool { + return c.Leaf.VerifyHostname(serverName) != nil + }) +} + +func bestCompatible(matched []*tls.Certificate, hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + usable := slices.DeleteFunc(matched, func(c *tls.Certificate) bool { return hello.SupportsCertificate(c) != nil }) + if len(usable) == 0 { + return nil, errNoMatchingServer + } + + slices.SortStableFunc(usable, func(a, b *tls.Certificate) int { + if ea, eb := isECDSA(a), isECDSA(b); ea != eb { + if ea { + return -1 + } + + return 1 + } + + return b.Leaf.NotAfter.Compare(a.Leaf.NotAfter) + }) + + return usable[0], nil +} + +func isECDSA(c *tls.Certificate) bool { return c.Leaf.PublicKeyAlgorithm == x509.ECDSA } + +// clientConfig returns a GetConfigForClient that reads the listener's current policy and certificates +// on every handshake, so ModifyListener/AddListenerCertificates take effect without a rebind. +func (b *InMemoryBackend) clientConfig(listenerArn string, alpn bool) func(*tls.ClientHelloInfo) (*tls.Config, error) { + return func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + l, _, ok := b.listenerView(listenerArn) + if !ok { + return nil, errNoCertificate + } + + b.mu.RLock("clientConfig") + resolver := b.certResolver + b.mu.RUnlock() + + if resolver == nil { + return nil, errNoCertificate + } + + var ( + certs []*tls.Certificate + def *tls.Certificate + ) + + for i, c := range l.Certificates { + kp, err := loadKeyPair(resolver, c.CertificateArn) + if err != nil { + continue + } + + certs = append(certs, kp) + + if c.IsDefault || (def == nil && i == 0) { + def = kp + } + } + + pick, err := selectCertificate(certs, def, hello) + if err != nil { + return nil, err + } + + pol := policyTLSFor(l.SSLPolicy) + cfg := &tls.Config{ + Certificates: []tls.Certificate{*pick}, + MinVersion: pol.min, + MaxVersion: pol.max, + CipherSuites: pol.ciphers, + } + + if protos := alpnProtocols(l.AlpnPolicy); alpn && hasOverlap(protos, hello.SupportedProtos) { + cfg.NextProtos = protos + } + + return cfg, nil + } +} + +func hasOverlap(a, b []string) bool { + for _, x := range a { + if slices.Contains(b, x) { + return true + } + } + + return false +} diff --git a/services/elbv2/listeners.go b/services/elbv2/listeners.go index 85eb7a69b3..afcf1957d2 100644 --- a/services/elbv2/listeners.go +++ b/services/elbv2/listeners.go @@ -97,8 +97,8 @@ func checkDuplicateListenerPort(candidates []*Listener, port int32) error { return nil } -// CreateListener creates a new listener on a load balancer. -func (b *InMemoryBackend) CreateListener(input CreateListenerInput) (*Listener, error) { +// createListener creates a new listener on a load balancer. +func (b *InMemoryBackend) createListener(input CreateListenerInput) (*Listener, error) { b.mu.Lock("CreateListener") defer b.mu.Unlock() @@ -300,8 +300,8 @@ func checkAllListenerArnsFound(arns []string, result []Listener) error { return nil } -// DeleteListener deletes a listener by ARN. -func (b *InMemoryBackend) DeleteListener(listenerArn string) error { +// deleteListener deletes a listener by ARN. +func (b *InMemoryBackend) deleteListener(listenerArn string) error { b.mu.Lock("DeleteListener") defer b.mu.Unlock() @@ -327,7 +327,7 @@ func (b *InMemoryBackend) DeleteListener(listenerArn string) error { } // ModifyListener updates the properties of an existing listener. -func (b *InMemoryBackend) ModifyListener(input ModifyListenerInput) (*Listener, error) { +func (b *InMemoryBackend) modifyListener(input ModifyListenerInput) (*Listener, error) { b.mu.Lock("ModifyListener") defer b.mu.Unlock() diff --git a/services/elbv2/load_balancers.go b/services/elbv2/load_balancers.go index b7b08f2b22..20757b57f2 100644 --- a/services/elbv2/load_balancers.go +++ b/services/elbv2/load_balancers.go @@ -536,8 +536,8 @@ func sortLoadBalancersByName(result []LoadBalancer) { }) } -// DeleteLoadBalancer deletes a load balancer by ARN. -func (b *InMemoryBackend) DeleteLoadBalancer(lbArn string) error { +// deleteLoadBalancer deletes a load balancer by ARN. +func (b *InMemoryBackend) deleteLoadBalancer(lbArn string) error { b.mu.Lock("DeleteLoadBalancer") defer b.mu.Unlock() diff --git a/services/elbv2/persistence.go b/services/elbv2/persistence.go index 44fc78d98e..62217465f4 100644 --- a/services/elbv2/persistence.go +++ b/services/elbv2/persistence.go @@ -80,9 +80,8 @@ func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { return persistence.MarshalSnapshot(ctx, "elbv2", snap) } -// Restore loads backend state from a JSON snapshot. -// It implements persistence.Persistable. -func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { +// restore loads backend state from a JSON snapshot. +func (b *InMemoryBackend) restore(ctx context.Context, data []byte) error { var snap backendSnapshot if err := persistence.UnmarshalSnapshot(ctx, "elbv2", data, &snap); err != nil { diff --git a/services/elbv2/provider.go b/services/elbv2/provider.go index 92c431c3e8..0a301cc74b 100644 --- a/services/elbv2/provider.go +++ b/services/elbv2/provider.go @@ -10,6 +10,12 @@ type Provider struct{} // Name returns the logical name of the provider. func (p *Provider) Name() string { return "ELBv2" } +// ListenerModeConfig exposes the --elbv2-listeners setting. +type ListenerModeConfig interface { + GetELBv2Listeners() string + GetELBv2ListenerHost() string +} + // Init initializes the ELBv2 backend and handler. // //nolint:ireturn,nolintlint // architecturally required to return interface @@ -17,6 +23,10 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { accountID, region := service.AccountRegionOrDefault(ctx) backend := NewInMemoryBackend(accountID, region) + if lc, ok := ctx.Config.(ListenerModeConfig); ok && lc.GetELBv2Listeners() == ListenerModeReal { + backend.EnableRealListeners(RealListenerConfig{Ports: ctx.PortAlloc, Host: lc.GetELBv2ListenerHost()}) + } + handler := NewHandler(backend) handler.EnableRegions() diff --git a/services/elbv2/regions.go b/services/elbv2/regions.go index 7e9e301eb5..c37356cf50 100644 --- a/services/elbv2/regions.go +++ b/services/elbv2/regions.go @@ -56,4 +56,5 @@ func (b *InMemoryBackend) inheritWiring(home *InMemoryBackend) { b.ec2Resolver = home.ec2Resolver b.certResolver = home.certResolver + b.rl.cfg = home.rl.cfg } diff --git a/services/elbv2/store.go b/services/elbv2/store.go index 410495a6a8..75811fcf6d 100644 --- a/services/elbv2/store.go +++ b/services/elbv2/store.go @@ -34,6 +34,7 @@ type InMemoryBackend struct { targetReadyAt map[string]map[string]time.Time // tgArn → targetKey → readyAt (initial→healthy) targetDrainingUntil map[string]map[string]time.Time // tgArn → targetKey → drainExpiresAt mu *lockmetrics.RWMutex + rl realListeners stopCh chan struct{} // healthDone is closed by runHealthReconciler when it returns, so Close // can join it and guarantee the goroutine is gone before returning. @@ -61,6 +62,7 @@ func NewInMemoryBackend(accountID, region string) *InMemoryBackend { targetDrainingUntil: make(map[string]map[string]time.Time), stopCh: make(chan struct{}), healthDone: make(chan struct{}), + rl: realListeners{bound: make(map[string]*boundListener)}, } registerAllTables(b) @@ -94,6 +96,8 @@ func (b *InMemoryBackend) SetCertificateResolver(r CertificateResolver) { // Close stops the background health reconciler, waiting for it to exit so // no goroutine outlives the backend. Safe to call more than once. func (b *InMemoryBackend) Close() { + b.closeRealListeners() + select { case <-b.stopCh: default: diff --git a/services/emr/iam_enforcement_test.go b/services/emr/iam_enforcement_test.go index a451c61f7c..c67d8819c1 100644 --- a/services/emr/iam_enforcement_test.go +++ b/services/emr/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/emr" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/elasticmapreduce/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/emrserverless/iam_enforcement_test.go b/services/emrserverless/iam_enforcement_test.go index 8163f9d82b..e6c6d7b1b9 100644 --- a/services/emrserverless/iam_enforcement_test.go +++ b/services/emrserverless/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/emrserverless" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/emr-serverless/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/eventbridge/PARITY.md b/services/eventbridge/PARITY.md index d4b94fd8a6..d7772ff694 100644 --- a/services/eventbridge/PARITY.md +++ b/services/eventbridge/PARITY.md @@ -121,8 +121,8 @@ families: archives_replays_connections_api_destinations_endpoints: {status: ok, note: "Previously 'deferred, spot-checked only'. Field-diffed this sweep against aws-sdk-go-v2/service/eventbridge's api_op_*.go Input/Output structs and types.go for Archive, Connection (+ ConnectionAuthResponseParameters/CreateConnectionAuthRequestParameters/UpdateConnectionAuthRequestParameters), ApiDestination, Endpoint (+ RoutingConfig/FailoverConfig/Primary/Secondary/EndpointEventBus), Replay, and ReplayDestination. Found and fixed real bugs: DescribeEndpoint/ListEndpoints and DescribeReplay/ListReplays response-side epoch-seconds bug, Replay missing Destination/Description, ReplayDestination missing FilterArns (an over-delivery correctness bug, not just a missing echo field), StartReplayInput request-side epoch-seconds bug. Connections and API destinations were already correct field-for-field (auth masking, all CRUD output shapes) except the KMS/private-API-connectivity extras noted per-op above and in items_still_open."} gaps: [] items_still_open: - - "Kept lenient: ScheduleExpression on a custom event bus (real AWS allows it only on the default bus; test/terraform/fixtures/eventbridge/success.tf relies on it) and CreateConnection AuthParameters that do not match AuthorizationType (the SDK names no error for it)." - - "Invalid-pattern Reason strings other than scalar-value, unknown matcher type and Filter-is-not-an-object (e.g. the exact wording for malformed JSON) are modeled from memory of real AWS, not pinned by the SDK." + - "Kept lenient: CreateConnection AuthParameters that do not match AuthorizationType (the SDK names no error for it)." + - "Invalid-pattern Reason strings now follow aws/event-ruler JsonRuleCompiler.java (barf() calls, e.g. :195 Filter is not an object, :254/:300 Empty objects, :338/:352/:357 $or, :417 Match value, :421 Empty arrays, :521 anything-but, :532, :681, :909 exists). Still unverified: the malformed-JSON reason (emulator says 'Invalid JSON'; real AWS surfaces Jackson parser text plus a source/line location suffix that event-ruler itself does not define), and the exact ScheduleExpression-on-custom-bus message 'ScheduleExpression is supported only on the default event bus.' (restriction is documented in the terraform-provider-aws cloudwatch_event_rule docs, wording found in no primary source). A root-level empty pattern {} is still accepted." structural_gaps: - "Inspector is not a deliverable rule target: the current EventBridge targets guide no longer lists it, Inspector Classic (assessment templates) is retired, and the repo has no classic backend; Inspector2 has no StartAssessmentRun-style API. Such ARNs log 'unsupported target ARN type'." - "ThrottledRules and PutEventsApproximateThrottledCount are never emitted: the emulator applies no invocation or PutEvents throttle limits. The AWS/Events docs list no API-destination metrics." @@ -1184,5 +1184,5 @@ SSM Automation for an unknown document still succeeds in the SSM backend (synthe - Pattern matcher values are validated at PutRule/TestEventPattern time (InvalidEventPatternException): prefix/suffix take a string or equals-ignore-case object, exists a boolean, cidr a valid CIDR, wildcard no consecutive `**`, numeric one or two operator/number pairs forming a range. An array of objects in the event now matches a nested-object pattern when any element matches. Tests: TestEventPatternMatcherValueValidation, TestEventPatternMatrix. - DeleteRule rejects a rule that still has targets (ValidationException); PutTargets reports a target setting more than one of Input/InputPath/InputTransformer in FailedEntries; StartReplay requires the destination to be the archive's source bus (Destination.Arn doc); CreateApiDestination requires the connection to exist (ResourceNotFoundException). Tests: TestDeleteRuleWithTargets, TestPutTargetsRejectsMultipleInputSources, TestStartReplayDestinationMustBeArchiveSourceBus, TestCreateAPIDestination_UnknownConnection. -- Kept lenient: schedule expressions on custom buses (test/terraform/fixtures/eventbridge/success.tf relies on it; real AWS allows them only on the default bus), CreateConnection with auth parameters that do not match the authorization type. +- Kept lenient: CreateConnection with auth parameters that do not match the authorization type. - Verified clean: all documented operators, input transformers (JSON template quoting), cross-bus targets, archives and replays, API destination delivery with header/query parameters. diff --git a/services/eventbridge/README.md b/services/eventbridge/README.md index 1847b55730..61fa4b85da 100644 --- a/services/eventbridge/README.md +++ b/services/eventbridge/README.md @@ -16,8 +16,8 @@ ### Known gaps -- Kept lenient: ScheduleExpression on a custom event bus (real AWS allows it only on the default bus; test/terraform/fixtures/eventbridge/success.tf relies on it) and CreateConnection AuthParameters that do not match AuthorizationType (the SDK names no error for it). -- Invalid-pattern Reason strings other than scalar-value, unknown matcher type and Filter-is-not-an-object (e.g. the exact wording for malformed JSON) are modeled from memory of real AWS, not pinned by the SDK. +- Kept lenient: CreateConnection AuthParameters that do not match AuthorizationType (the SDK names no error for it). +- Invalid-pattern Reason strings now follow aws/event-ruler JsonRuleCompiler.java (barf() calls, e.g. :195 Filter is not an object, :254/:300 Empty objects, :338/:352/:357 $or, :417 Match value, :421 Empty arrays, :521 anything-but, :532, :681, :909 exists). Still unverified: the malformed-JSON reason (emulator says 'Invalid JSON'; real AWS surfaces Jackson parser text plus a source/line location suffix that event-ruler itself does not define), and the exact ScheduleExpression-on-custom-bus message 'ScheduleExpression is supported only on the default event bus.' (restriction is documented in the terraform-provider-aws cloudwatch_event_rule docs, wording found in no primary source). A root-level empty pattern {} is still accepted. ### Structural gaps diff --git a/services/eventbridge/iam_enforcement_test.go b/services/eventbridge/iam_enforcement_test.go index db2f7932ef..7fab976f9c 100644 --- a/services/eventbridge/iam_enforcement_test.go +++ b/services/eventbridge/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/eventbridge" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/events/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/eventbridge/pattern.go b/services/eventbridge/pattern.go index 35ac139ec5..79861834ff 100644 --- a/services/eventbridge/pattern.go +++ b/services/eventbridge/pattern.go @@ -11,6 +11,15 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/eventpattern" ) +const ( + anythingButMixedMsg = "Inside anything but list, either all values are number or string, " + + "mixed type is not supported" + minOrAlternatives = 2 +) + +// reasonErr builds an event-ruler parse reason, passed through verbatim to the wire. +func reasonErr(reason string) error { return fmt.Errorf("%w: %s", ErrInvalidParameter, reason) } + type compiledPattern struct { pattern map[string]any sourceExactValues []string @@ -63,6 +72,10 @@ func validatePatternObject(pattern map[string]any) error { switch v := val.(type) { case map[string]any: + if len(v) == 0 { + return fmt.Errorf("%w: Empty objects are not allowed", ErrInvalidParameter) + } + if err := validatePatternObject(v); err != nil { return err } @@ -82,13 +95,17 @@ func validatePatternObject(pattern map[string]any) error { func validateOrCombinator(val any) error { alts, ok := val.([]any) if !ok { - return fmt.Errorf("%w: $or must be an array", ErrInvalidParameter) + return reasonErr("It must be an Array followed with $or.") + } + + if len(alts) < minOrAlternatives { + return reasonErr("There must have at least 2 Objects in $or relationship.") } for _, alt := range alts { subPat, isMap := alt.(map[string]any) if !isMap { - return fmt.Errorf("%w: $or elements must be objects", ErrInvalidParameter) + return reasonErr("Only JSON object is allowed in array of $or relationship.") } if err := validatePatternObject(subPat); err != nil { @@ -101,6 +118,10 @@ func validateOrCombinator(val any) error { // validateMatcherArray validates a pattern array for a single field. func validateMatcherArray(field string, matchers []any) error { + if len(matchers) == 0 { + return fmt.Errorf("%w: Empty arrays are not allowed", ErrInvalidParameter) + } + for _, m := range matchers { switch mv := m.(type) { case string, float64, bool, nil: @@ -110,7 +131,7 @@ func validateMatcherArray(field string, matchers []any) error { return err } default: - return fmt.Errorf("%w: matcher for field %q has unsupported type", ErrInvalidParameter, field) + return fmt.Errorf("%w: Match value must be String, number, true, false, or null", ErrInvalidParameter) } } @@ -125,7 +146,7 @@ func validateMatcherObject(field string, m map[string]any) error { } if key == "anything-but" { - if err := validateAnythingButValue(field, val); err != nil { + if err := validateAnythingButValue(val); err != nil { return err } @@ -147,30 +168,33 @@ func validateMatcherObject(field string, m map[string]any) error { // only numbers." (eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but). // A map or array element would decode to a non-comparable Go type and panic // matchAnythingBut's containment check, so this is also the fix for that. -func validateAnythingButValue(field string, v any) error { +func validateAnythingButValue(v any) error { switch ab := v.(type) { case string, float64: return nil case map[string]any: - return validateAnythingButObject(field, ab) + return validateAnythingButObject(ab) case []any: + var strs, nums int + for _, elem := range ab { switch elem.(type) { - case string, float64: + case string: + strs++ + case float64: + nums++ default: - return fmt.Errorf( - "%w: anything-but list for field %q must contain only strings or only numbers", - ErrInvalidParameter, field, - ) + return reasonErr(anythingButMixedMsg) } } + if strs > 0 && nums > 0 { + return reasonErr(anythingButMixedMsg) + } + return nil default: - return fmt.Errorf( - "%w: anything-but value for field %q must be a string, number, list, or matcher object", - ErrInvalidParameter, field, - ) + return reasonErr("Value of anything-but must be an array or single string/number value.") } } @@ -178,13 +202,13 @@ func validateAnythingButValue(field string, v any) error { // matcher (e.g. {"anything-but": {"prefix": "init"}}). Must match the keys // matchAnythingButObject recognizes, so an unrecognized key is rejected at // compile time instead of silently never matching. -func validateAnythingButObject(field string, ab map[string]any) error { +func validateAnythingButObject(ab map[string]any) error { for key := range ab { if key == matcherNumeric || slices.Contains(anythingButStringMatcherKeys(), key) { continue } - return fmt.Errorf("%w: unknown anything-but matcher %q for field %q", ErrInvalidParameter, key, field) + return reasonErr("Unsupported anything-but pattern: " + key) } return nil diff --git a/services/eventbridge/pattern_reason_test.go b/services/eventbridge/pattern_reason_test.go new file mode 100644 index 0000000000..f317f10b58 --- /dev/null +++ b/services/eventbridge/pattern_reason_test.go @@ -0,0 +1,46 @@ +package eventbridge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/eventbridge" +) + +// Messages follow aws/event-ruler JsonRuleCompiler.java. +func TestPutRule_PatternReasons(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + want string + }{ + {"empty_object", `{"detail":{}}`, "Empty objects are not allowed"}, + {"empty_array", `{"source":[]}`, "Empty arrays are not allowed"}, + {"bad_match_value", `{"source":[[1]]}`, "Match value must be String, number, true, false, or null"}, + {"or_not_array", `{"$or":{"a":[1]}}`, "It must be an Array followed with $or."}, + {"or_one_element", `{"$or":[{"a":[1]}]}`, "There must have at least 2 Objects in $or relationship."}, + {"or_non_object", `{"$or":[1,2]}`, "Only JSON object is allowed in array of $or relationship."}, + {"anything_but_object", `{"a":[{"anything-but":{"bogus":"x"}}]}`, "Unsupported anything-but pattern: bogus"}, + { + "anything_but_bool", `{"a":[{"anything-but":true}]}`, + "Value of anything-but must be an array or single string/number value.", + }, + {"anything_but_mixed", `{"a":[{"anything-but":["x",1]}]}`, "mixed type is not supported"}, + {"exists_bad", `{"a":[{"exists":"x"}]}`, "exists match pattern must be either true or false."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := newBackend().PutRule(context.Background(), eventbridge.PutRuleInput{Name: "r", EventPattern: tt.pattern}) + require.ErrorIs(t, err, eventbridge.ErrInvalidEventPattern) + require.ErrorContains(t, err, "Event pattern is not valid. Reason: ") + require.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/services/eventbridge/pattern_validation.go b/services/eventbridge/pattern_validation.go index 92096b30bd..83c5eda0b3 100644 --- a/services/eventbridge/pattern_validation.go +++ b/services/eventbridge/pattern_validation.go @@ -28,7 +28,7 @@ func validateMatcherValue(field, key string, val any) error { return requireString(field, key, val) case "exists": if _, ok := val.(bool); !ok { - return fmt.Errorf("%w: exists match pattern must be either true or false", ErrInvalidParameter) + return reasonErr("exists match pattern must be either true or false.") } case "cidr": return validateCIDRMatcher(val) diff --git a/services/eventbridge/provider.go b/services/eventbridge/provider.go index c74244365a..33282f7cf6 100644 --- a/services/eventbridge/provider.go +++ b/services/eventbridge/provider.go @@ -10,6 +10,11 @@ import ( // ErrNilAppContext is returned by Init when called with a nil AppContext. var ErrNilAppContext = errors.New("nil AppContext passed to EventBridge provider Init") +// ConfigProvider extracts the EventBridge settings from the AppContext config. +type ConfigProvider interface { + GetEventBridgeSettings() Settings +} + // Provider implements service.Provider for the EventBridge service. type Provider struct{} @@ -33,6 +38,10 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { backend = NewInMemoryBackend() } + if cp, ok := ctx.Config.(ConfigProvider); ok { + backend.SetSettings(cp.GetEventBridgeSettings()) + } + handler := NewHandler(backend) // Attach the scheduled-rules scheduler so it runs as a BackgroundWorker. diff --git a/services/eventbridge/put_rule_schedule_bus_sdk_test.go b/services/eventbridge/put_rule_schedule_bus_sdk_test.go new file mode 100644 index 0000000000..69e852daa4 --- /dev/null +++ b/services/eventbridge/put_rule_schedule_bus_sdk_test.go @@ -0,0 +1,59 @@ +package eventbridge_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + ebsdk "github.com/aws/aws-sdk-go-v2/service/eventbridge" + "github.com/aws/smithy-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/eventbridge" +) + +func TestPutRule_ScheduleOnCustomBus_SDK(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + bus string + lenient bool + wantFail bool + }{ + {name: "default bus implicit", bus: "", wantFail: false}, + {name: "default bus explicit", bus: "default", wantFail: false}, + {name: "custom bus rejected", bus: "custom", wantFail: true}, + {name: "custom bus lenient opt-in", bus: "custom", lenient: true, wantFail: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := newBackend() + b.SetSettings(eventbridge.Settings{AllowScheduleOnCustomBus: tt.lenient}) + client := newTestEventBridgeClient(t, eventbridge.NewHandler(b)) + + _, err := client.CreateEventBus(t.Context(), &ebsdk.CreateEventBusInput{Name: aws.String("custom")}) + require.NoError(t, err) + + in := &ebsdk.PutRuleInput{Name: aws.String("r"), ScheduleExpression: aws.String("rate(5 minutes)")} + if tt.bus != "" { + in.EventBusName = aws.String(tt.bus) + } + + _, err = client.PutRule(t.Context(), in) + if !tt.wantFail { + require.NoError(t, err) + + return + } + + var apiErr smithy.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "ValidationException", apiErr.ErrorCode()) + assert.Equal(t, "ScheduleExpression is supported only on the default event bus.", apiErr.ErrorMessage()) + }) + } +} diff --git a/services/eventbridge/rules.go b/services/eventbridge/rules.go index 76ef99f068..fb40d36d9b 100644 --- a/services/eventbridge/rules.go +++ b/services/eventbridge/rules.go @@ -50,13 +50,6 @@ func validatePutRuleInput(input PutRuleInput) error { ) } - if input.EventPattern != "" && input.ScheduleExpression != "" { - return fmt.Errorf( - "%w: ScheduleExpression and EventPattern are mutually exclusive", - ErrInvalidParameter, - ) - } - if input.EventPattern == "" && input.ScheduleExpression == "" { return fmt.Errorf( "%w: either EventPattern or ScheduleExpression must be provided", @@ -117,6 +110,10 @@ func (b *InMemoryBackend) PutRule(ctx context.Context, input PutRuleInput) (*Rul busKey := ebBusKey(busName) + if input.ScheduleExpression != "" && busKey != defaultEventBusName && !b.settings.AllowScheduleOnCustomBus { + return nil, awsErrorf(ErrInvalidParameter, "ScheduleExpression is supported only on the default event bus.") + } + b.mu.Lock("PutRule") defer b.mu.Unlock() diff --git a/services/eventbridge/rules_test.go b/services/eventbridge/rules_test.go index 1ad55bee57..c2fc00348e 100644 --- a/services/eventbridge/rules_test.go +++ b/services/eventbridge/rules_test.go @@ -367,18 +367,6 @@ func TestRule_NameTooLong(t *testing.T) { require.ErrorIs(t, err, eventbridge.ErrInvalidParameter) } -func TestRule_MutuallyExclusivePatternAndSchedule(t *testing.T) { - t.Parallel() - b := newBackend() - - _, err := b.PutRule(context.Background(), eventbridge.PutRuleInput{ - Name: "bad-rule", - EventPattern: `{"source":["x"]}`, - ScheduleExpression: "rate(1 minute)", - }) - require.ErrorIs(t, err, eventbridge.ErrInvalidParameter) -} - func TestRule_RequiresPatternOrSchedule(t *testing.T) { t.Parallel() b := newBackend() @@ -413,13 +401,13 @@ func TestPutRule_MutualExclusion(t *testing.T) { input eventbridge.PutRuleInput }{ { - name: "both set rejects", + name: "both set allowed", input: eventbridge.PutRuleInput{ Name: "r", ScheduleExpression: "rate(1 minute)", EventPattern: `{"source":["x"]}`, }, - wantErr: eventbridge.ErrInvalidParameter, + wantErr: nil, }, { name: "neither set rejects", diff --git a/services/eventbridge/settings.go b/services/eventbridge/settings.go new file mode 100644 index 0000000000..66d6219042 --- /dev/null +++ b/services/eventbridge/settings.go @@ -0,0 +1,11 @@ +package eventbridge + +// Settings holds service-level configuration for the EventBridge backend. +type Settings struct { + // AllowScheduleOnCustomBus lets PutRule accept a ScheduleExpression on a + // non-default event bus. Real AWS rejects it, so the default is false. + AllowScheduleOnCustomBus bool `json:"allow_schedule_on_custom_bus" name:"allow-schedule-on-custom-bus" env:"EVENTBRIDGE_ALLOW_SCHEDULE_ON_CUSTOM_BUS" default:"false" help:"Accept scheduled rules on custom event buses (real AWS allows them only on the default bus)."` //nolint:lll // Kong struct tag makes this line long +} + +// SetSettings applies s to the backend; call before serving requests. +func (b *InMemoryBackend) SetSettings(s Settings) { b.settings = s } diff --git a/services/eventbridge/store.go b/services/eventbridge/store.go index b70334e156..9e3774437d 100644 --- a/services/eventbridge/store.go +++ b/services/eventbridge/store.go @@ -384,6 +384,7 @@ type InMemoryBackend struct { deliveryTimeout time.Duration tableMu sync.Mutex closing atomic.Bool + settings Settings } // NewInMemoryBackend creates a new InMemoryBackend with default configuration. diff --git a/services/firehose/iam_enforcement_test.go b/services/firehose/iam_enforcement_test.go index d25db7ed58..3ff314f035 100644 --- a/services/firehose/iam_enforcement_test.go +++ b/services/firehose/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/firehose" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/firehose/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/fis/iam_enforcement_test.go b/services/fis/iam_enforcement_test.go index 29c55736f6..63f3c2a09c 100644 --- a/services/fis/iam_enforcement_test.go +++ b/services/fis/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/fis" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -176,7 +177,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/fis/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/fis/wire_field_test.go b/services/fis/wire_field_test.go index 48304ef9df..85315a0c61 100644 --- a/services/fis/wire_field_test.go +++ b/services/fis/wire_field_test.go @@ -2,7 +2,6 @@ package fis_test import ( "encoding/json" - "net/http" "net/http/httptest" "testing" @@ -16,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/fis" ) @@ -224,7 +224,7 @@ func TestFISListOps_NarrowSummaryParity(t *testing.T) { func assertNoRawKey(t *testing.T, url, listKey, itemKey string) { t.Helper() - resp, err := http.Get(url) + resp, err := testclient.New(t).Get(url) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) diff --git a/services/forecast/iam_enforcement_test.go b/services/forecast/iam_enforcement_test.go index 4807b18b56..47942986c2 100644 --- a/services/forecast/iam_enforcement_test.go +++ b/services/forecast/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/forecast" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/forecast/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/fsx/file_system_realism_test.go b/services/fsx/file_system_realism_test.go index c2430ff3ae..fa05da7099 100644 --- a/services/fsx/file_system_realism_test.go +++ b/services/fsx/file_system_realism_test.go @@ -2,6 +2,7 @@ package fsx_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -274,29 +275,31 @@ func TestLifecycleDelay_CreatingThenAvailable(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - b, client := newRealismClient(t) - b.SetLifecycleDelay(tt.delay) + synctest.Test(t, func(t *testing.T) { + b, client := newRealismClient(t) + b.SetLifecycleDelay(tt.delay) - fs := createTestLustreFS(t, client) - assert.Equal(t, tt.wantFirst, fs.FileSystem.Lifecycle) + fs := createTestLustreFS(t, client) + assert.Equal(t, tt.wantFirst, fs.FileSystem.Lifecycle) - bk, err := client.CreateBackup( - t.Context(), - &fsxsdk.CreateBackupInput{FileSystemId: fs.FileSystem.FileSystemId}, - ) - require.NoError(t, err) + bk, err := client.CreateBackup( + t.Context(), + &fsxsdk.CreateBackupInput{FileSystemId: fs.FileSystem.FileSystemId}, + ) + require.NoError(t, err) - want := types.BackupLifecycle(tt.wantFirst) - assert.Equal(t, want, bk.Backup.Lifecycle) + want := types.BackupLifecycle(tt.wantFirst) + assert.Equal(t, want, bk.Backup.Lifecycle) - require.Eventually(t, func() bool { - out, descErr := client.DescribeFileSystems(t.Context(), &fsxsdk.DescribeFileSystemsInput{ - FileSystemIds: []string{aws.ToString(fs.FileSystem.FileSystemId)}, - }) + require.Eventually(t, func() bool { + out, descErr := client.DescribeFileSystems(t.Context(), &fsxsdk.DescribeFileSystemsInput{ + FileSystemIds: []string{aws.ToString(fs.FileSystem.FileSystemId)}, + }) - return descErr == nil && len(out.FileSystems) == 1 && - out.FileSystems[0].Lifecycle == types.FileSystemLifecycleAvailable - }, 5*time.Second, 20*time.Millisecond) + return descErr == nil && len(out.FileSystems) == 1 && + out.FileSystems[0].Lifecycle == types.FileSystemLifecycleAvailable + }, 5*time.Second, 20*time.Millisecond) + }) }) } } diff --git a/services/fsx/handler_create_tags_test.go b/services/fsx/handler_create_tags_test.go index 0089fa71de..2f4bbc88ec 100644 --- a/services/fsx/handler_create_tags_test.go +++ b/services/fsx/handler_create_tags_test.go @@ -1,11 +1,9 @@ package fsx_test import ( - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" fsxsdk "github.com/aws/aws-sdk-go-v2/service/fsx" "github.com/aws/aws-sdk-go-v2/service/fsx/types" @@ -13,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/fsx" ) @@ -33,20 +32,11 @@ func newTestFSxClient(t *testing.T, h *fsx.Handler) *fsxsdk.Client { require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(tagsRTRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return fsxsdk.NewFromConfig(cfg, func(o *fsxsdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + return fsxsdk.NewFromConfig(aws.Config{ + Region: tagsRTRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/fsx/iam_enforcement_test.go b/services/fsx/iam_enforcement_test.go index e662308216..b21b6ffbcd 100644 --- a/services/fsx/iam_enforcement_test.go +++ b/services/fsx/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/fsx" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/fsx/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/gcpapigateway/PARITY.md b/services/gcpapigateway/PARITY.md new file mode 100644 index 0000000000..dbbe141d49 --- /dev/null +++ b/services/gcpapigateway/PARITY.md @@ -0,0 +1,70 @@ +--- +service: gcpapigateway +sdk_module: cloud.google.com/go/apigateway@v1.17.0 +last_audit_commit: 8b70851a3 +last_audit_date: 2026-10-30 +overall: B +# Per-op or per-op-family status. Values: ok | partial | gap | deferred. +# wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. +ops: + CreateApi: {wire: ok, errors: partial, state: ok, persist: ok, note: "Global location only; api_id is required. managed_service is generated as {api}-{hash}.apigateway.{project}.cloud.goog unless supplied. The returned operation runs on the backend clock (default 2s) and the API reads CREATING until it ends. The id charset, the AlreadyExists text and the 'locations/global only' rejection are not verified against production."} + GetApi: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} + ListApis: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Paged through pkgs/page; order_by name, create_time or update_time (optionally desc); filter is rejected."} + UpdateApi: {wire: ok, errors: partial, state: ok, persist: ok, note: "update_mask display_name, labels or *; managed_service is immutable. An empty mask updates display_name and labels."} + DeleteApi: {wire: ok, errors: partial, state: ok, persist: ok, note: "FAILED_PRECONDITION while the API has configs (code and text unverified). The API reads DELETING until the operation ends."} + CreateApiConfig: {wire: ok, errors: partial, state: ok, persist: ok, note: "Exactly one openapi_documents entry (YAML or JSON bytes); grpc_services and managed_service_configs are rejected. An invalid document leaves the config FAILED and finishes the operation with INVALID_ARGUMENT 'Cannot convert to service config. ...' plus OperationMetadata.diagnostics. Only one config per API may be CREATING at a time (FAILED_PRECONDITION). gateway_service_account must be an IAM service account email or a projects/ name. service_config_id is {config}-{hash}."} + GetApiConfig: {wire: ok, errors: ok, state: ok, persist: n/a, note: "view BASIC (default) omits openapi_documents; FULL returns them."} + ListApiConfigs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Paged; BASIC view."} + UpdateApiConfig: {wire: ok, errors: ok, state: ok, persist: ok, note: "display_name and labels only; the document and service account are immutable."} + DeleteApiConfig: {wire: ok, errors: partial, state: ok, persist: ok, note: "FAILED_PRECONDITION while a gateway serves the config (code unverified)."} + CreateGateway: {wire: ok, errors: partial, state: ok, persist: ok, note: "Regional: us-central1, us-east1, us-east4, us-west2-4, europe-west1-2, asia-northeast1, australia-southeast1 (the quickstart list). api_config must exist and not be FAILED or DELETING. default_hostname is {gateway}-{18 chars}.{region code}.gateway.dev (standard format per docs.cloud.google.com/api-gateway/docs/deploying-api: my-gateway-a12bcd345e67f89g0h.uc.gateway.dev); model-routing gateways (x-google-model-router, unsupported) use a second format gateway-{12 digits}.{region}.gateway.dev or, if created before 2026-09-03, run.app; the hash and region codes other than uc are derived. effective_streaming_mode mirrors streaming_mode."} + GetGateway: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} + ListGateways: {wire: ok, errors: ok, state: ok, persist: n/a, note: "locations/- spans regions."} + UpdateGateway: {wire: ok, errors: partial, state: ok, persist: ok, note: "display_name, labels, api_config; streaming_mode is immutable. The gateway reads UPDATING while the operation runs and keeps serving."} + DeleteGateway: {wire: ok, errors: ok, state: ok, persist: ok, note: "DELETING gateways stop serving."} + GetLocation: {wire: ok, errors: ok, state: ok, persist: n/a, note: "global plus the gateway regions."} + ListLocations: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Paged; filter is rejected."} + GetIamPolicy: {wire: ok, errors: ok, state: ok, persist: ok, note: "APIs, API configs and gateways (the REST API has these verbs; the Go client has no method for them, so they are exercised over gRPC IAMPolicy and REST)."} + SetIamPolicy: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces the policy; a stale etag is ABORTED."} + TestIamPermissions: {wire: ok, errors: ok, state: partial, persist: n/a, note: "Reports every requested apigateway.* permission as granted: no caller is authenticated."} + GetOperation: {wire: ok, errors: ok, state: ok, persist: ok, note: "google.longrunning.Operations on projects/*/locations/*/operations/*. Operations finish on the backend clock; metadata carries target, verb, api_version v1 and diagnostics."} + ListOperations: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Paged; filter is rejected."} + WaitOperation: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Waits up to min(remaining time, timeout) on a real timer."} + CancelOperation: {wire: ok, errors: ok, state: partial, persist: ok, note: "A running create is rolled back, a running delete is undone, a running update keeps its change; the operation ends with CANCELLED. Cancelling a finished operation succeeds without effect."} + DeleteOperation: {wire: ok, errors: partial, state: ok, persist: ok, note: "A running operation is FAILED_PRECONDITION (unverified)."} +families: + openapi_validation: {status: partial, note: "Swagger 2.0 and OpenAPI 3.0.x/3.1.x (openapi-overview). 2.0 uses x-google-backend objects (snake_case), securityDefinitions x-google-issuer/-jwks_uri/-audiences, x-google-management and operation x-google-quota.metricCosts. 3.x uses the oasv3-extensions grammar: required x-google-api-management with backends (name -> address, jwtAudience, disableAuth, pathTranslation, deadline, protocol) plus metrics and quota.limits keyed by name (limit values = per project per minute), x-google-backend as a backend NAME at top level or per operation (an object is rejected), x-google-quota as a metric -> cost map (operation replaces top level), securitySchemes x-google-auth {issuer, jwksUri, audiences, jwtLocations{header|query, valuePrefix}}. Also checked: version, paths, responses, path_translation enum, deadline 0-3600 s, protocol, jwt_audience xor disable_auth, security references, apiKey name/in, unique issuers, metric displayName <= 40, ambiguous templates. Every operation needs an effective backend: documented for 3.x (oasv3-extensions: x-google-backend \"Required. You must set this extension for API Gateway.\"; x-google-api-management and its backends are \"Required\"); for 2.0 the oasv2 page never says (only address inside the block is Required), so the emulator keeps the rule there. See the 2026-10-30 verification section."} + dataplane_routing: {status: ok, note: "Requests whose Host is a gateway's default_hostname, or whose path starts /_apigateway/{project}/{location}/{gateway}/, are served on the same listener (default 8146). Paths match the document templates below basePath ({name} fills one segment, literals win). Errors are the ESPv2 local-reply JSON {code,message}: an undefined path is 404 \"The current request is not defined by this API.\" (deny_all.go); a defined template with another method is 405 \"The current request is matched to the defined url template \\\"{basePath+template}\\\" but its http method is not allowed\" (deny_invalid_method.go)."} + path_translation: {status: ok, note: "From oasv2-extensions: top-level default APPEND_PATH_TO_ADDRESS appends the original request path to the address path; per-operation x-google-backend defaults to CONSTANT_ADDRESS, which sends to the address and turns path parameters into query parameters (/hello/{name} -> ?name=world). Request query parameters are kept. deadline defaults to 15 s (504 on expiry)."} + api_key: {status: partial, note: "api_key definitions are enforced: key from the query parameter named by the definition, key, api_key (ESPv2 util.go) or the x-api-key header. Missing key is 401 with the ESPv2 text \"UNAUTHENTICATED: Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API.\". The emulator has no API Keys or Service Usage service, so any non-empty key is accepted: ESPv2 answers an unknown key 400 \"INVALID_ARGUMENT: API key not valid. Please pass a valid API key.\" and a project without the API enabled 403 \"PERMISSION_DENIED: API {service} is not enabled for the project.\"; neither can occur here."} + jwt: {status: ok, note: "Issuers, JWKS URIs and audiences (default audience https://{managed service}) are enforced; token from the jwtLocations of the scheme (3.x) or, by default, Authorization Bearer, X-Goog-Iap-Jwt-Assertion or access_token; custom locations replace the defaults (ESPv2 jwt_locations_test). RS/PS/ES 256-512 only, exp required, nbf checked on the backend clock, JWK sets and kid-to-X.509 maps, keys cached 5 minutes. Failure bodies are {code,message} with Envoy jwt_verify_lib status strings: 401 Jwt is missing / Jwt is expired / Jwt not yet valid / Jwt issuer is not configured / Jwt verification fails / Jwks remote fetch is failed / Jwks doesn't have any valid public key / Jwt is not in the form of Header.Payload.Signature with two dots and 3 sections; 403 Audiences in Jwt are not allowed. The payload reaches the backend base64url-encoded in X-Apigateway-Api-Userinfo; a client copy is dropped. Authorization moves to X-Forwarded-Authorization unless disable_auth (ESPv2 backend_auth_disable_auth_test)."} + quota: {status: partial, note: "Metrics and limits apply to the whole API and the most recently created config governs, not the gateway's own config (oasv2/oasv3 extension pages); a cost metric the newest config no longer declares is 500 {code:500,message:\"Failed to call Service Control Quota.\"} (troubleshoot page). Counters are per project per minute on the backend clock, all-or-nothing, in memory, not persisted. Operations without x-google-quota are unlimited. Excess is 429 \"RESOURCE_EXHAUSTED: Quota exceeded for quota metric ...\"; the status prefix is ESPv2, the detail text after it comes from Service Control and is recalled, not verified."} + cors: {status: ok, note: "allowCors (2.0 x-google-endpoints entry named after the host; 3.x servers[].x-google-endpoint) adds an OPTIONS route per path template that has none, proxied to the first operation backend with no security or quota, as ESPv2 ProxyCORSGenerator and service_info.go do; a declared OPTIONS operation wins. Without it OPTIONS is 405. No CORS response headers are added by the gateway (ESPv2 cors_preset is a deployment flag, not an API Gateway setting)."} + backends: {status: ok, note: "HTTP(S) backends through one pooled client: TLS is always verified, redirects are returned, the dialer checks the resolved peer address and refuses anything outside 127.0.0.0/8 and ::1 unless --gcp-apigateway-allow-external-backends is set; the same rule covers remote JWKS fetches. Cloud Run and Cloud Functions serving URLs (*.localhost) are mapped to loopback, so they reach the emulated services without a separate interface. Refused destinations are 502, connect errors 503."} + routing_isolation: {status: ok, note: "Own gRPC server with the REST transcoder and the gateway route bound in StartWorker to --gcp-apigateway-port / GCP_APIGATEWAY_PORT (default 8146, fails fast if taken); never registered on the shared AWS router. cli.go reserves the port in the shared PortAlloc pool."} + auth: {status: partial, note: "No control-plane request is authenticated or authorised."} + observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; RPC failures are logged at debug. No dashboard page."} +items_still_open: + - "The Cloud Run / Functions gateway-to-backend identity token (Authorization: Bearer ID token, jwt_audience) is not minted: the original Authorization header is moved to X-Forwarded-Authorization and the backend gets none." + - "API keys are not validated against any project or key store (no API Keys or Service Usage emulator); any non-empty key passes, so the 400 invalid-key and 403 API-not-enabled answers cannot occur." + - "gRPC API configs (grpc_services, managed_service_configs), more than one OpenAPI document per config, h2 and WebSocket backends, x-google-allow, x-google-parameter, x-google-model-router, x-google-mcp-tool, AI and MCP blocks, and the 2.0 jwt locations extension are not supported." + - "Whether OpenAPI 2.0 documents without any x-google-backend are rejected at config creation is undocumented (oasv2 page: only address is Required inside the block); the emulator rejects them as it does 3.x. The 3.x docs example gives address without a scheme (myapp.run.app), the emulator requires http(s). The 3.x quota limit unit is undocumented; limits are taken as per project per minute." + - "Data-plane texts not in a primary source: the detail after 429 RESOURCE_EXHAUSTED (from Service Control), 502 for refused destinations (emulator-only), the Gateway-not-found and not-ready answers, and the Envoy JWT messages for header/payload parse failures (jwt_verify_lib has distinct strings; only the three-section case is distinguished)." + - "Control-plane error codes and texts (AlreadyExists, FAILED_PRECONDITION for in-use resources, id charset, the global-only rule for APIs and configs, the single CREATING config rule) have no primary text: googleapis apigateway.proto only says ids 'must be unique within scope of the parent resource'." + - "No dashboard page or proxy." +leaks: {status: clean, note: "The only background resources are the grpc.Server and its serve goroutine, joined by Shutdown, and the pooled backend client, closed there."} +--- + +Initial implementation (2026-10-30): google.cloud.apigateway.v1 ApiGatewayService, Locations, IAMPolicy and Operations verified against cloud.google.com/go/apigateway v1.17.0 through the official client in the package tests; the data plane is verified against httptest backends and JWKS servers. + +Verification pass (2026-10-30) against primary sources: + +- docs.cloud.google.com/api-gateway/docs/openapi-overview: "API Gateway supports the following OpenAPI versions" 2.0, 3.0.x, 3.1.x; "supports all patch versions of 3.0 and 3.1". +- docs.cloud.google.com/api-gateway/docs/oasv3-extensions: x-google-backend "Required. You must set this extension for API Gateway."; x-google-api-management "Required", backends "Yes"; x-google-backend is a backend name; defaults APPEND_PATH_TO_ADDRESS (top level) and CONSTANT_ADDRESS (operation); deadline default 15 s, max 3600 s; x-google-auth lives in a Security Scheme Object (issuer, jwksUri, audiences, jwtLocations with header/query/valuePrefix); x-google-quota is a metric-to-cost map; "metrics and quotas apply to the whole API, not one API config". +- docs.cloud.google.com/api-gateway/docs/oasv2-extensions: "API Gateway applies the metrics and quota that you define here to the entire API rather than to an individual API config."; "If the address field is missing, path_translation will remain unspecified and it won't occur." +- docs.cloud.google.com/api-gateway/docs/troubleshoot: 500 {"code":500,"message":"Failed to call Service Control Quota."} when a quota metric no longer exists for the API. +- docs.cloud.google.com/api-gateway/docs/authenticate-api-keys: API Gateway "rejects requests unless the API key was generated in your Google Cloud project"; keys from other projects work if the API is enabled there. +- docs.cloud.google.com/api-gateway/docs/get-started-cloud-run: config creation "may take several minutes" and "up to ten minutes" for complex configs. The emulator's 2 s create and 1 s update/delete are a deliberate choice for tests (Timing is configurable), not an observation. +- github.com/GoogleCloudPlatform/esp-v2 (API Gateway's data plane): src/go/configgenerator/routegen/deny_all.go and deny_invalid_method.go (404 and 405 bodies), filtergen/http_connection_manager.go (local replies as {"code":..,"message":..}), tests/integration_test jwt_*, service_control_check_fail_test (400/403 key texts), service_control_quota_test (429 "RESOURCE_EXHAUSTED: "), backend_auth_disable_auth_test, jwt_locations_test, util.go (key query names key and api_key). +- github.com/google/jwt_verify_lib src/status.cc: the JWT status strings listed under api jwt. +- googleapis/googleapis google/cloud/apigateway/v1/apigateway.proto: resource patterns, openapi_documents vs grpc_services exclusivity, gateway_service_account as email. diff --git a/services/gcpapigateway/README.md b/services/gcpapigateway/README.md new file mode 100644 index 0000000000..5ee5ec3f5f --- /dev/null +++ b/services/gcpapigateway/README.md @@ -0,0 +1,29 @@ + +# API Gateway + +**Parity grade: B** · SDK `cloud.google.com/go/apigateway@v1.17.0` · last audited 2026-10-30 (`8b70851a3`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 25 (15 ok, 10 partial) | +| Feature families | 11 (6 ok, 5 partial) | +| Known gaps | 7 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Known gaps + +- The Cloud Run / Functions gateway-to-backend identity token (Authorization: Bearer ID token, jwt_audience) is not minted: the original Authorization header is moved to X-Forwarded-Authorization and the backend gets none. +- API keys are not validated against any project or key store (no API Keys or Service Usage emulator); any non-empty key passes, so the 400 invalid-key and 403 API-not-enabled answers cannot occur. +- gRPC API configs (grpc_services, managed_service_configs), more than one OpenAPI document per config, h2 and WebSocket backends, x-google-allow, x-google-parameter, x-google-model-router, x-google-mcp-tool, AI and MCP blocks, and the 2.0 jwt locations extension are not supported. +- Whether OpenAPI 2.0 documents without any x-google-backend are rejected at config creation is undocumented (oasv2 page: only address is Required inside the block); the emulator rejects them as it does 3.x. The 3.x docs example gives address without a scheme (myapp.run.app), the emulator requires http(s). The 3.x quota limit unit is undocumented; limits are taken as per project per minute. +- Data-plane texts not in a primary source: the detail after 429 RESOURCE_EXHAUSTED (from Service Control), 502 for refused destinations (emulator-only), the Gateway-not-found and not-ready answers, and the Envoy JWT messages for header/payload parse failures (jwt_verify_lib has distinct strings; only the three-section case is distinguished). +- Control-plane error codes and texts (AlreadyExists, FAILED_PRECONDITION for in-use resources, id charset, the global-only rule for APIs and configs, the single CREATING config rule) have no primary text: googleapis apigateway.proto only says ids 'must be unique within scope of the parent resource'. +- No dashboard page or proxy. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/gcpapigateway/apis.go b/services/gcpapigateway/apis.go new file mode 100644 index 0000000000..c401db9b17 --- /dev/null +++ b/services/gcpapigateway/apis.go @@ -0,0 +1,241 @@ +package gcpapigateway + +import ( + "crypto/sha256" + "encoding/base32" + "strings" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const hashLen = 18 + +// idHash is a stable lowercase base32 digest used in generated service and host names. +func idHash(n int, parts ...string) string { + sum := sha256.Sum256([]byte(strings.Join(parts, "/"))) + + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]))[:n] +} + +func (b *InMemoryBackend) apiViewLocked(r *apiRec) *apigatewaypb.Api { + out := clone(r.pb) + out.State = apigatewaypb.Api_State(r.life.state(b.now(), stateActive)) + + return out +} + +func (b *InMemoryBackend) liveAPILocked(name string) (*apiRec, bool) { + r, ok := b.apis[name] + if !ok || r.life.gone(b.now()) { + return nil, false + } + + return r, true +} + +// CreateAPI creates an API in the global location. +func (b *InMemoryBackend) CreateAPI(in *apigatewaypb.CreateApiRequest) (*longrunningpb.Operation, error) { + project, location, err := parseParent(in.GetParent()) + if err != nil { + return nil, err + } + + if location != globalLocation { + return nil, errInvalid("APIs are global resources: use projects/%s/locations/global.", project) + } + + if err = checkID("api_id", in.GetApiId()); err != nil { + return nil, err + } + + src := in.GetApi() + if err = validateLabels(src.GetLabels()); err != nil { + return nil, err + } + + b.mu.Lock("CreateApi") + defer b.mu.Unlock() + + b.sweepLocked() + + name := apiName(project, in.GetApiId()) + if _, ok := b.apis[name]; ok { + return nil, errExists("Resource '%s' already exists.", name) + } + + managed := src.GetManagedService() + if managed == "" { + managed = in.GetApiId() + "-" + idHash(hashLen, name) + ".apigateway." + project + ".cloud.goog" + } + + now := b.ts() + pb := &apigatewaypb.Api{ + Name: name, CreateTime: now, UpdateTime: now, Labels: src.GetLabels(), DisplayName: src.GetDisplayName(), + ManagedService: managed, State: apigatewaypb.Api_ACTIVE, + } + rec := &apiRec{pb: pb, life: life{Until: b.now().Add(b.timing.Create), Transient: stateCreating}} + b.apis[name] = rec + + return b.finishLocked(opSpec{ + project: project, location: location, target: name, verb: verbCreate, dur: b.timing.Create, + resp: clone(pb), + }) +} + +func (b *InMemoryBackend) finishLocked(s opSpec) (*longrunningpb.Operation, error) { + o, err := b.newOpLocked(s) + if err != nil { + return nil, err + } + + return b.opViewLocked(o), nil +} + +// GetAPI returns one API. +func (b *InMemoryBackend) GetAPI(name string) (*apigatewaypb.Api, error) { + if _, _, err := parseAPIName(name); err != nil { + return nil, err + } + + b.mu.RLock("GetApi") + defer b.mu.RUnlock() + + r, ok := b.liveAPILocked(name) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", name) + } + + return b.apiViewLocked(r), nil +} + +// ListApis lists the APIs of a project. +func (b *InMemoryBackend) ListApis(in *apigatewaypb.ListApisRequest) (*apigatewaypb.ListApisResponse, error) { + project, location, err := parseParent(in.GetParent()) + if err != nil { + return nil, err + } + + if location != globalLocation && location != "-" { + return nil, errInvalid("APIs are global resources: use projects/%s/locations/global.", project) + } + + b.mu.RLock("ListApis") + + var all []*apigatewaypb.Api + + for _, r := range b.apis { + if strings.HasPrefix(r.pb.GetName(), "projects/"+project+"/") && !r.life.gone(b.now()) { + all = append(all, b.apiViewLocked(r)) + } + } + + b.mu.RUnlock() + + pg, err := paginate(all, listArgs{ + filter: in.GetFilter(), orderBy: in.GetOrderBy(), pageToken: in.GetPageToken(), pageSize: in.GetPageSize(), + }, listKeys[*apigatewaypb.Api]{ + name: (*apigatewaypb.Api).GetName, + created: func(a *apigatewaypb.Api) time.Time { return a.GetCreateTime().AsTime() }, + updated: func(a *apigatewaypb.Api) time.Time { return a.GetUpdateTime().AsTime() }, + }) + if err != nil { + return nil, err + } + + return &apigatewaypb.ListApisResponse{Apis: pg.Data, NextPageToken: pg.Next}, nil +} + +// UpdateAPI updates the display name and labels of an API. +func (b *InMemoryBackend) UpdateAPI(in *apigatewaypb.UpdateApiRequest) (*longrunningpb.Operation, error) { + src := in.GetApi() + + project, _, err := parseAPIName(src.GetName()) + if err != nil { + return nil, err + } + + if err = validateLabels(src.GetLabels()); err != nil { + return nil, err + } + + b.mu.Lock("UpdateApi") + defer b.mu.Unlock() + + b.sweepLocked() + + rec, ok := b.liveAPILocked(src.GetName()) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", src.GetName()) + } + + next := clone(rec.pb) + + for _, p := range maskPaths(in.GetUpdateMask().GetPaths(), maskDisplay, maskLabels) { + switch p { + case maskAll: + next.DisplayName, next.Labels = src.GetDisplayName(), src.GetLabels() + case maskDisplay, maskDisplayCamel: + next.DisplayName = src.GetDisplayName() + case maskLabels: + next.Labels = src.GetLabels() + case "managed_service", "managedService": + if src.GetManagedService() != rec.pb.GetManagedService() { + return nil, errInvalid("managed_service is immutable.") + } + default: + return nil, errInvalid("Unsupported update_mask path %q for Api.", p) + } + } + + next.UpdateTime = timestamppb.New(b.now()) + rec.pb = next + rec.life.Until, rec.life.Transient = b.now().Add(b.timing.Update), stateUpdating + + return b.finishLocked(opSpec{ + project: project, location: globalLocation, target: next.GetName(), verb: verbUpdate, + dur: b.timing.Update, resp: clone(next), + }) +} + +// DeleteAPI deletes an API that has no API configs left. +func (b *InMemoryBackend) DeleteAPI(in *apigatewaypb.DeleteApiRequest) (*longrunningpb.Operation, error) { + project, _, err := parseAPIName(in.GetName()) + if err != nil { + return nil, err + } + + b.mu.Lock("DeleteApi") + defer b.mu.Unlock() + + b.sweepLocked() + + rec, ok := b.liveAPILocked(in.GetName()) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", in.GetName()) + } + + if rec.life.deleting() { + return nil, errPrecondition("Resource '%s' is already being deleted.", in.GetName()) + } + + for _, c := range b.configs { + if strings.HasPrefix(c.pb.GetName(), in.GetName()+"/") && !c.life.gone(b.now()) { + return nil, errPrecondition("API '%s' still has API configs; delete them first.", in.GetName()) + } + } + + op, err := b.finishLocked(opSpec{ + project: project, location: globalLocation, target: in.GetName(), verb: verbDelete, + dur: b.timing.Delete, resp: emptyResp(), + }) + if err != nil { + return nil, err + } + + rec.life.DeleteAt, rec.life.DeleteOp = b.now().Add(b.timing.Delete), op.GetName() + + return op, nil +} diff --git a/services/gcpapigateway/auth.go b/services/gcpapigateway/auth.go new file mode 100644 index 0000000000..87c6616941 --- /dev/null +++ b/services/gcpapigateway/auth.go @@ -0,0 +1,504 @@ +package gcpapigateway + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "slices" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + jwksMaxBytes = 1 << 20 + jwksFetchLimit = 10 * time.Second + jwksTTL = 5 * time.Minute + bearerPrefix = "Bearer " + msgJWTFails = "Jwt verification fails" + msgJWTFormat = "Jwt is not in the form of Header.Payload.Signature with two dots and 3 sections" + msgJWKSFetch = "Jwks remote fetch is failed" + msgJWKSNoKeys = "Jwks doesn't have any valid public key" + iapHeader = "X-Goog-Iap-Jwt-Assertion" + jwtParts = 3 +) + +var ( + errNoKey = errors.New("no matching signing key") + errKeyFormat = errors.New("unsupported key") + errJWKS = errors.New("jwks endpoint answered") +) + +// authError is a rejected data-plane request. +type authError struct { + msg string + status int +} + +type jwksEntry struct { + expires time.Time + keys map[string]crypto.PublicKey +} + +type jwksCache struct { + mu *lockmetrics.RWMutex + entries map[string]*jwksEntry +} + +func newJWKSCache() *jwksCache { + return &jwksCache{mu: lockmetrics.New("gcpapigateway.jwks"), entries: map[string]*jwksEntry{}} +} + +func (c *jwksCache) reset() { + c.mu.Lock("reset") + defer c.mu.Unlock() + + c.entries = map[string]*jwksEntry{} +} + +func (c *jwksCache) get(uri string, now time.Time) (map[string]crypto.PublicKey, bool) { + c.mu.RLock("get") + defer c.mu.RUnlock() + + e, ok := c.entries[uri] + if !ok || !now.Before(e.expires) { + return nil, false + } + + return e.keys, true +} + +func (c *jwksCache) put(uri string, keys map[string]crypto.PublicKey, expires time.Time) { + c.mu.Lock("put") + defer c.mu.Unlock() + + c.entries[uri] = &jwksEntry{keys: keys, expires: expires} +} + +func (b *InMemoryBackend) fetchKeys(ctx context.Context, uri string) (map[string]crypto.PublicKey, error) { + now := b.now() + if keys, ok := b.jwks.get(uri, now); ok { + return keys, nil + } + + ctx, cancel := context.WithTimeout(ctx, jwksFetchLimit) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + resp, err := b.client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %d", errJWKS, resp.StatusCode) + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, jwksMaxBytes)) + if err != nil { + return nil, err + } + + keys := parseKeySet(raw) + b.jwks.put(uri, keys, now.Add(jwksTTL)) + + return keys, nil +} + +type jwk struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// parseKeySet reads a JWK set or a map of key id to X.509 PEM certificate. +func parseKeySet(raw []byte) map[string]crypto.PublicKey { + keys := map[string]crypto.PublicKey{} + + var set struct { + Keys []jwk `json:"keys"` + } + + if json.Unmarshal(raw, &set) == nil && len(set.Keys) > 0 { + for _, k := range set.Keys { + if pub, err := k.public(); err == nil { + keys[k.Kid] = pub + } + } + + return keys + } + + var certs map[string]string + if json.Unmarshal(raw, &certs) != nil { + return keys + } + + for kid, p := range certs { + blk, _ := pem.Decode([]byte(p)) + if blk == nil { + continue + } + + if cert, err := x509.ParseCertificate(blk.Bytes); err == nil { + keys[kid] = cert.PublicKey + } + } + + return keys +} + +func b64(s string) (*big.Int, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")) + if err != nil { + return nil, err + } + + return new(big.Int).SetBytes(raw), nil +} + +func (k jwk) public() (crypto.PublicKey, error) { + switch k.Kty { + case "RSA": + return k.rsaKey() + case "EC": + return k.ecKey() + default: + return nil, errKeyFormat + } +} + +func (k jwk) rsaKey() (crypto.PublicKey, error) { + n, err := b64(k.N) + if err != nil { + return nil, err + } + + e, err := b64(k.E) + if err != nil || !e.IsInt64() || e.Int64() < 3 || e.Int64() > 1<<31-1 { + return nil, errKeyFormat + } + + return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil +} + +func (k jwk) ecKey() (crypto.PublicKey, error) { + var curve elliptic.Curve + + switch k.Crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, errKeyFormat + } + + x, err := b64(k.X) + if err != nil { + return nil, err + } + + y, err := b64(k.Y) + if err != nil { + return nil, err + } + + size := (curve.Params().BitSize + 7) / 8 //nolint:mnd // bits to bytes + if x.BitLen() > size*8 || y.BitLen() > size*8 { + return nil, errKeyFormat + } + + point := make([]byte, 1+2*size) + point[0] = 4 + x.FillBytes(point[1 : 1+size]) + y.FillBytes(point[1+size:]) + + return ecdsa.ParseUncompressedPublicKey(curve, point) +} + +func checkKeyAlg(k crypto.PublicKey, t *jwt.Token) (crypto.PublicKey, error) { + alg := t.Method.Alg() + + switch k.(type) { + case *rsa.PublicKey: + if strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS") { + return k, nil + } + case *ecdsa.PublicKey: + if strings.HasPrefix(alg, "ES") { + return k, nil + } + } + + return nil, errKeyFormat +} + +// tokenOf reads the JWT from the definitions' jwtLocations; a definition without any uses the defaults +// (Authorization Bearer, X-Goog-Iap-Jwt-Assertion, access_token). +func tokenOf(r *http.Request, defs []*secDef) string { + for _, d := range defs { + if len(d.Locations) == 0 { + if t := defaultToken(r); t != "" { + return t + } + + continue + } + + for _, l := range d.Locations { + if t := locationToken(r, l); t != "" { + return t + } + } + } + + return "" +} + +func defaultToken(r *http.Request) string { + if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), bearerPrefix); ok { + return strings.TrimSpace(v) + } + + if v := r.Header.Get(iapHeader); v != "" { + return v + } + + return r.URL.Query().Get("access_token") +} + +func locationToken(r *http.Request, l jwtLocation) string { + if l.Query != "" { + return r.URL.Query().Get(l.Query) + } + + v, ok := strings.CutPrefix(r.Header.Get(l.Header), l.Prefix) + if !ok { + return "" + } + + return strings.TrimSpace(v) +} + +func apiKeyOf(r *http.Request, d *secDef) string { + q := r.URL.Query() + + candidates := []string{q.Get("key"), r.Header.Get("X-Api-Key")} + if d.KeyIn == inQuery { + candidates = append([]string{q.Get(d.KeyName)}, candidates...) + } else { + candidates = append([]string{r.Header.Get(d.KeyName)}, candidates...) + } + + for _, c := range candidates { + if c != "" { + return c + } + } + + return "" +} + +func claimAudiences(v any) []string { + switch t := v.(type) { + case string: + return []string{t} + case []any: + var out []string + + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + + return out + default: + return nil + } +} + +//nolint:gochecknoglobals // immutable allow-list +var allowedAlgs = []string{"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"} + +// issuerDef picks the definition matching the token's issuer; a definition without an issuer matches any. +func issuerDef(defs []*secDef, token string) (*secDef, *authError) { + unverified := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(token, unverified); err != nil { + msg := msgJWTFails + if strings.Count(token, ".") != jwtParts-1 { + msg = msgJWTFormat + } + + return nil, &authError{status: http.StatusUnauthorized, msg: msg} + } + + iss, _ := unverified["iss"].(string) + + idx := slices.IndexFunc(defs, func(d *secDef) bool { return d.Issuer == iss }) + if idx < 0 { + idx = slices.IndexFunc(defs, func(d *secDef) bool { return d.Issuer == "" }) + } + + if idx < 0 { + return nil, &authError{status: http.StatusUnauthorized, msg: "Jwt issuer is not configured"} + } + + return defs[idx], nil +} + +// verifyJWT checks token against the JWT definitions of one security requirement. +func (b *InMemoryBackend) verifyJWT( + ctx context.Context, defs []*secDef, managed, token string, +) (jwt.MapClaims, *authError) { + if token == "" { + return nil, &authError{status: http.StatusUnauthorized, msg: "Jwt is missing"} + } + + def, aerr := issuerDef(defs, token) + if aerr != nil { + return nil, aerr + } + + keys, err := b.fetchKeys(ctx, def.JWKSURI) + if err != nil { + return nil, &authError{status: http.StatusUnauthorized, msg: msgJWKSFetch} + } + + if len(keys) == 0 { + return nil, &authError{status: http.StatusUnauthorized, msg: msgJWKSNoKeys} + } + + claims := jwt.MapClaims{} + opts := []jwt.ParserOption{jwt.WithValidMethods(allowedAlgs), jwt.WithTimeFunc(b.now), jwt.WithExpirationRequired()} + + if def.Issuer != "" { + opts = append(opts, jwt.WithIssuer(def.Issuer)) + } + + parser := jwt.NewParser(opts...) + + _, err = parser.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + if k, ok := keys[kid]; ok { + return checkKeyAlg(k, t) + } + + if kid == "" && len(keys) == 1 { + for _, k := range keys { + return checkKeyAlg(k, t) + } + } + + return nil, errNoKey + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, &authError{status: http.StatusUnauthorized, msg: "Jwt is expired"} + } + + if errors.Is(err, jwt.ErrTokenNotValidYet) { + return nil, &authError{status: http.StatusUnauthorized, msg: "Jwt not yet valid"} + } + + return nil, &authError{status: http.StatusUnauthorized, msg: msgJWTFails} + } + + want := def.Audiences + if len(want) == 0 { + want = []string{"https://" + managed} + } + + if !slices.ContainsFunc(claimAudiences(claims["aud"]), func(a string) bool { return slices.Contains(want, a) }) { + return nil, &authError{status: http.StatusForbidden, msg: "Audiences in Jwt are not allowed"} + } + + return claims, nil +} + +const missingKeyMsg = "UNAUTHENTICATED: Method doesn't allow unregistered callers (callers without established " + + "identity). Please use API Key or other form of API consumer identity to call this API." + +// checkRequirement verifies every definition of one security requirement. +func (b *InMemoryBackend) checkRequirement( + r *http.Request, sv *served, names []string, +) (jwt.MapClaims, *authError) { + var ( + jwtDefs []*secDef + claims jwt.MapClaims + ) + + for _, n := range names { + d := sv.spec.Defs[n] + + if d.Kind == kindJWT { + jwtDefs = append(jwtDefs, d) + + continue + } + + if apiKeyOf(r, d) == "" { + return nil, &authError{status: http.StatusUnauthorized, msg: missingKeyMsg} + } + } + + if len(jwtDefs) > 0 { + var aerr *authError + + claims, aerr = b.verifyJWT(r.Context(), jwtDefs, sv.managed, tokenOf(r, jwtDefs)) + if aerr != nil { + return nil, aerr + } + } + + return claims, nil +} + +// authorize applies a route's security: any one requirement must hold; an empty requirement allows anonymous calls. +func (b *InMemoryBackend) authorize(r *http.Request, sv *served, rt *route) (jwt.MapClaims, *authError) { + if len(rt.Security) == 0 { + return nil, nil + } + + var firstErr *authError + + for _, names := range rt.Security { + claims, aerr := b.checkRequirement(r, sv, names) + if aerr == nil { + return claims, nil + } + + if firstErr == nil { + firstErr = aerr + } + } + + return nil, firstErr +} diff --git a/services/gcpapigateway/backend.go b/services/gcpapigateway/backend.go new file mode 100644 index 0000000000..bceb48fba4 --- /dev/null +++ b/services/gcpapigateway/backend.go @@ -0,0 +1,64 @@ +package gcpapigateway + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "syscall" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" +) + +const ( + dialerKeepAlive = 30 * time.Second + localhostSuffix = ".localhost" + loopbackIP = "127.0.0.1" +) + +// ErrDestinationNotPermitted is returned when a backend or JWKS host resolves outside loopback. +var ErrDestinationNotPermitted = errors.New( + "gcpapigateway: destination is outside loopback and external backends are not allowed", +) + +// newBackendClient builds the data-plane HTTP client. Redirects are returned to the caller, TLS is always +// verified, and the dialer refuses non-loopback peers unless external backends are allowed. Cloud Run and +// Functions *.localhost serving URLs are sent to the loopback interface. +func (b *InMemoryBackend) newBackendClient() *http.Client { + d := &net.Dialer{ + KeepAlive: dialerKeepAlive, + Control: func(_, address string, _ syscall.RawConn) error { + if b.externalAllowed() { + return nil + } + + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + + if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() { + return ErrDestinationNotPermitted + } + + return nil + }, + } + + tr := httpclient.NewTransport() + tr.Proxy = nil + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + if host, port, err := net.SplitHostPort(addr); err == nil && strings.HasSuffix(host, localhostSuffix) { + addr = net.JoinHostPort(loopbackIP, port) + } + + return d.DialContext(ctx, network, addr) + } + + return &http.Client{ + Transport: tr, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } +} diff --git a/services/gcpapigateway/configs.go b/services/gcpapigateway/configs.go new file mode 100644 index 0000000000..db831e4d12 --- /dev/null +++ b/services/gcpapigateway/configs.go @@ -0,0 +1,277 @@ +package gcpapigateway + +import ( + "strings" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const specFailMsg = "Cannot convert to service config." + +func (b *InMemoryBackend) liveConfigLocked(name string) (*configRec, bool) { + r, ok := b.configs[name] + if !ok || r.life.gone(b.now()) { + return nil, false + } + + return r, true +} + +// configViewLocked returns the config as clients see it; the OpenAPI contents only come with the full view. +func (b *InMemoryBackend) configViewLocked(r *configRec, full bool) *apigatewaypb.ApiConfig { + out := clone(r.pb) + out.State = apigatewaypb.ApiConfig_State(r.life.state(b.now(), int32(r.pb.GetState()))) + + if !full { + out.OpenapiDocuments = nil + } + + return out +} + +func validateConfigInput(src *apigatewaypb.ApiConfig) error { + if err := validateLabels(src.GetLabels()); err != nil { + return err + } + + if sa := src.GetGatewayServiceAccount(); sa != "" && !validServiceAccount(sa) { + return errInvalid("Invalid gateway_service_account %q: want a service account email or resource name.", sa) + } + + if len(src.GetGrpcServices()) > 0 || len(src.GetManagedServiceConfigs()) > 0 { + return errInvalid("gRPC service definitions are not supported by the emulator; use openapi_documents.") + } + + docs := src.GetOpenapiDocuments() + if len(docs) == 0 { + return errInvalid("openapi_documents is required.") + } + + if len(docs) > 1 { + return errInvalid("Exactly one OpenAPI document is supported per API config.") + } + + if docs[0].GetDocument().GetPath() == "" || len(docs[0].GetDocument().GetContents()) == 0 { + return errInvalid("openapi_documents[0].document needs a path and contents.") + } + + return nil +} + +// CreateAPIConfig creates an API config; an invalid OpenAPI document leaves it FAILED with the operation failed. +func (b *InMemoryBackend) CreateAPIConfig(in *apigatewaypb.CreateApiConfigRequest) (*longrunningpb.Operation, error) { + project, api, err := parseAPIName(in.GetParent()) + if err != nil { + return nil, err + } + + if err = checkID("api_config_id", in.GetApiConfigId()); err != nil { + return nil, err + } + + src := in.GetApiConfig() + if err = validateConfigInput(src); err != nil { + return nil, err + } + + b.mu.Lock("CreateApiConfig") + defer b.mu.Unlock() + + b.sweepLocked() + + if _, ok := b.liveAPILocked(in.GetParent()); !ok { + return nil, errNotFound("Resource '%s' was not found.", in.GetParent()) + } + + name := configName(project, api, in.GetApiConfigId()) + if _, ok := b.configs[name]; ok { + return nil, errExists("Resource '%s' already exists.", name) + } + + now := b.now() + + for _, c := range b.configs { + if strings.HasPrefix(c.pb.GetName(), in.GetParent()+"/") && !c.life.deleting() && now.Before(c.life.Until) && + c.life.Transient == stateCreating { + return nil, errPrecondition("Only one API config at a time may be created for API '%s'.", in.GetParent()) + } + } + + spec, diags := compileSpec(src.GetOpenapiDocuments()[0].GetDocument().GetContents()) + + pb := &apigatewaypb.ApiConfig{ + Name: name, CreateTime: b.ts(), UpdateTime: b.ts(), Labels: src.GetLabels(), + DisplayName: src.GetDisplayName(), GatewayServiceAccount: src.GetGatewayServiceAccount(), + ServiceConfigId: in.GetApiConfigId() + "-" + idHash(hashLen, name), State: apigatewaypb.ApiConfig_ACTIVE, + OpenapiDocuments: src.GetOpenapiDocuments(), + } + rec := &configRec{pb: pb, spec: spec, life: life{Until: now.Add(b.timing.Create), Transient: stateCreating}} + + s := opSpec{project: project, location: globalLocation, target: name, verb: verbCreate, dur: b.timing.Create} + + if len(diags) > 0 { + pb.State, rec.spec = apigatewaypb.ApiConfig_FAILED, nil + s.failMsg, s.failCode, s.diags = specFailMsg+" "+diags[0].Message, codes.InvalidArgument, diags + } else { + resp := clone(pb) + resp.OpenapiDocuments = nil + s.resp = resp + } + + b.configs[name] = rec + + return b.finishLocked(s) +} + +// GetAPIConfig returns one API config; the full view includes the OpenAPI documents. +func (b *InMemoryBackend) GetAPIConfig(in *apigatewaypb.GetApiConfigRequest) (*apigatewaypb.ApiConfig, error) { + if _, err := parseConfigName(in.GetName()); err != nil { + return nil, err + } + + b.mu.RLock("GetApiConfig") + defer b.mu.RUnlock() + + r, ok := b.liveConfigLocked(in.GetName()) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", in.GetName()) + } + + return b.configViewLocked(r, in.GetView() == apigatewaypb.GetApiConfigRequest_FULL), nil +} + +// ListAPIConfigs lists the configs of an API. +func (b *InMemoryBackend) ListAPIConfigs(in *apigatewaypb.ListApiConfigsRequest) ( + *apigatewaypb.ListApiConfigsResponse, error, +) { + if _, _, err := parseAPIName(in.GetParent()); err != nil { + return nil, err + } + + b.mu.RLock("ListApiConfigs") + + if _, ok := b.liveAPILocked(in.GetParent()); !ok { + b.mu.RUnlock() + + return nil, errNotFound("Resource '%s' was not found.", in.GetParent()) + } + + var all []*apigatewaypb.ApiConfig + + for _, r := range b.configs { + if strings.HasPrefix(r.pb.GetName(), in.GetParent()+"/") && !r.life.gone(b.now()) { + all = append(all, b.configViewLocked(r, false)) + } + } + + b.mu.RUnlock() + + pg, err := paginate(all, listArgs{ + filter: in.GetFilter(), orderBy: in.GetOrderBy(), pageToken: in.GetPageToken(), pageSize: in.GetPageSize(), + }, listKeys[*apigatewaypb.ApiConfig]{ + name: (*apigatewaypb.ApiConfig).GetName, + created: func(a *apigatewaypb.ApiConfig) time.Time { return a.GetCreateTime().AsTime() }, + updated: func(a *apigatewaypb.ApiConfig) time.Time { return a.GetUpdateTime().AsTime() }, + }) + if err != nil { + return nil, err + } + + return &apigatewaypb.ListApiConfigsResponse{ApiConfigs: pg.Data, NextPageToken: pg.Next}, nil +} + +// UpdateAPIConfig updates the display name and labels of an API config. +func (b *InMemoryBackend) UpdateAPIConfig(in *apigatewaypb.UpdateApiConfigRequest) (*longrunningpb.Operation, error) { + src := in.GetApiConfig() + + project, err := parseConfigName(src.GetName()) + if err != nil { + return nil, err + } + + if err = validateLabels(src.GetLabels()); err != nil { + return nil, err + } + + b.mu.Lock("UpdateApiConfig") + defer b.mu.Unlock() + + b.sweepLocked() + + rec, ok := b.liveConfigLocked(src.GetName()) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", src.GetName()) + } + + next := clone(rec.pb) + + for _, p := range maskPaths(in.GetUpdateMask().GetPaths(), maskDisplay, maskLabels) { + switch p { + case maskAll: + next.DisplayName, next.Labels = src.GetDisplayName(), src.GetLabels() + case maskDisplay, maskDisplayCamel: + next.DisplayName = src.GetDisplayName() + case maskLabels: + next.Labels = src.GetLabels() + default: + return nil, errInvalid("Unsupported update_mask path %q for ApiConfig: the document and service account "+ + "are immutable.", p) + } + } + + next.UpdateTime = timestamppb.New(b.now()) + rec.pb = next + rec.life.Until, rec.life.Transient = b.now().Add(b.timing.Update), stateUpdating + + resp := clone(next) + resp.OpenapiDocuments = nil + + return b.finishLocked(opSpec{ + project: project, location: globalLocation, target: next.GetName(), verb: verbUpdate, + dur: b.timing.Update, resp: resp, + }) +} + +// DeleteAPIConfig deletes an API config that no gateway serves. +func (b *InMemoryBackend) DeleteAPIConfig(in *apigatewaypb.DeleteApiConfigRequest) (*longrunningpb.Operation, error) { + project, err := parseConfigName(in.GetName()) + if err != nil { + return nil, err + } + + b.mu.Lock("DeleteApiConfig") + defer b.mu.Unlock() + + b.sweepLocked() + + rec, ok := b.liveConfigLocked(in.GetName()) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", in.GetName()) + } + + if rec.life.deleting() { + return nil, errPrecondition("Resource '%s' is already being deleted.", in.GetName()) + } + + for _, g := range b.gateways { + if g.pb.GetApiConfig() == in.GetName() && !g.life.gone(b.now()) { + return nil, errPrecondition("API config '%s' is in use by gateway '%s'.", in.GetName(), g.pb.GetName()) + } + } + + op, err := b.finishLocked(opSpec{ + project: project, location: globalLocation, target: in.GetName(), verb: verbDelete, + dur: b.timing.Delete, resp: emptyResp(), + }) + if err != nil { + return nil, err + } + + rec.life.DeleteAt, rec.life.DeleteOp = b.now().Add(b.timing.Delete), op.GetName() + + return op, nil +} diff --git a/services/gcpapigateway/control_test.go b/services/gcpapigateway/control_test.go new file mode 100644 index 0000000000..3f930e57be --- /dev/null +++ b/services/gcpapigateway/control_test.go @@ -0,0 +1,539 @@ +package gcpapigateway_test + +import ( + "testing" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/cloud/location" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + "github.com/blackbirdworks/gopherstack/services/gcpapigateway" +) + +func helloSpec(backend string) string { + return opSpec(backend, "", " /hello:\n get:\n"+okResponses) +} + +func TestAPICRUD(t *testing.T) { + t.Parallel() + + e := newEnv(t) + api := e.createAPI(t, apiID) + + assert.Equal(t, parent+"/apis/"+apiID, api.GetName()) + assert.Equal(t, apigatewaypb.Api_ACTIVE, api.GetState()) + assert.Regexp(t, `^my-api-[a-z2-7]{18}\.apigateway\.test-project\.cloud\.goog$`, api.GetManagedService()) + + got, err := e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: api.GetName()}) + require.NoError(t, err) + assert.Equal(t, api.GetManagedService(), got.GetManagedService()) + + op, err := e.client.UpdateApi(t.Context(), &apigatewaypb.UpdateApiRequest{ + Api: &apigatewaypb.Api{ + Name: api.GetName(), + DisplayName: "renamed", + Labels: map[string]string{"env": "dev"}, + }, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name", "labels"}}, + }) + require.NoError(t, err) + + updated, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, "renamed", updated.GetDisplayName()) + assert.Equal(t, map[string]string{"env": "dev"}, updated.GetLabels()) + + e.createAPI(t, "second") + + it := e.client.ListApis(t.Context(), &apigatewaypb.ListApisRequest{Parent: parent, PageSize: 1}) + + var names []string + + for { + a, nerr := it.Next() + if nerr != nil { + break + } + + names = append(names, a.GetName()) + } + + assert.Equal(t, []string{parent + "/apis/" + apiID, parent + "/apis/second"}, names) + + del, err := e.client.DeleteApi(t.Context(), &apigatewaypb.DeleteApiRequest{Name: api.GetName()}) + require.NoError(t, err) + require.NoError(t, del.Wait(t.Context())) + + _, err = e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: api.GetName()}) + assert.Equal(t, codes.NotFound, status.Code(err)) +} + +func TestAPIErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + req *apigatewaypb.CreateApiRequest + name string + want codes.Code + }{ + {&apigatewaypb.CreateApiRequest{Parent: parent, ApiId: "Bad_ID"}, "bad id", codes.InvalidArgument}, + {&apigatewaypb.CreateApiRequest{Parent: parent}, "missing id", codes.InvalidArgument}, + { + &apigatewaypb.CreateApiRequest{Parent: "projects/p/locations/us-central1", ApiId: "x"}, + "not global", + codes.InvalidArgument, + }, + {&apigatewaypb.CreateApiRequest{Parent: "nope", ApiId: "x"}, "bad parent", codes.InvalidArgument}, + {&apigatewaypb.CreateApiRequest{Parent: parent, ApiId: "dup"}, "duplicate", codes.AlreadyExists}, + {&apigatewaypb.CreateApiRequest{ + Parent: parent, ApiId: "lab", Api: &apigatewaypb.Api{Labels: map[string]string{"Bad": "x"}}, + }, "bad label", codes.InvalidArgument}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, "dup") + + _, err := e.client.CreateApi(t.Context(), tt.req) + assert.Equal(t, tt.want, status.Code(err), "%v", err) + }) + } +} + +func TestConfigLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, apiID) + + cfg := e.createConfig(t, "cfg-1", helloSpec("https://backend.example")) + assert.Equal(t, apigatewaypb.ApiConfig_ACTIVE, cfg.GetState()) + assert.Equal(t, parent+"/apis/"+apiID+"/configs/cfg-1", cfg.GetName()) + assert.NotEmpty(t, cfg.GetServiceConfigId()) + + basic, err := e.client.GetApiConfig(t.Context(), &apigatewaypb.GetApiConfigRequest{Name: cfg.GetName()}) + require.NoError(t, err) + assert.Empty(t, basic.GetOpenapiDocuments()) + + full, err := e.client.GetApiConfig(t.Context(), &apigatewaypb.GetApiConfigRequest{ + Name: cfg.GetName(), View: apigatewaypb.GetApiConfigRequest_FULL, + }) + require.NoError(t, err) + require.Len(t, full.GetOpenapiDocuments(), 1) + assert.Contains(t, string(full.GetOpenapiDocuments()[0].GetDocument().GetContents()), "swagger") + + op, err := e.client.UpdateApiConfig(t.Context(), &apigatewaypb.UpdateApiConfigRequest{ + ApiConfig: &apigatewaypb.ApiConfig{Name: cfg.GetName(), DisplayName: "v1"}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name"}}, + }) + require.NoError(t, err) + + upd, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, "v1", upd.GetDisplayName()) + + _, err = e.client.UpdateApiConfig(t.Context(), &apigatewaypb.UpdateApiConfigRequest{ + ApiConfig: &apigatewaypb.ApiConfig{Name: cfg.GetName()}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"openapi_documents"}}, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + gw := e.createGateway(t, "gw", cfg.GetName()) + assert.Equal(t, apigatewaypb.Gateway_ACTIVE, gw.GetState()) + + _, err = e.client.DeleteApiConfig(t.Context(), &apigatewaypb.DeleteApiConfigRequest{Name: cfg.GetName()}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err), "config used by gateway") + + _, err = e.client.DeleteApi(t.Context(), &apigatewaypb.DeleteApiRequest{Name: parent + "/apis/" + apiID}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err), "api still has configs") + + dg, err := e.client.DeleteGateway(t.Context(), &apigatewaypb.DeleteGatewayRequest{Name: gw.GetName()}) + require.NoError(t, err) + require.NoError(t, dg.Wait(t.Context())) + + dc, err := e.client.DeleteApiConfig(t.Context(), &apigatewaypb.DeleteApiConfigRequest{Name: cfg.GetName()}) + require.NoError(t, err) + require.NoError(t, dc.Wait(t.Context())) + + da, err := e.client.DeleteApi(t.Context(), &apigatewaypb.DeleteApiRequest{Name: parent + "/apis/" + apiID}) + require.NoError(t, err) + require.NoError(t, da.Wait(t.Context())) +} + +func TestConfigInputErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + mut func(*apigatewaypb.CreateApiConfigRequest) + name string + want codes.Code + }{ + { + func(r *apigatewaypb.CreateApiConfigRequest) { r.ApiConfig.OpenapiDocuments = nil }, + "no document", + codes.InvalidArgument, + }, + {func(r *apigatewaypb.CreateApiConfigRequest) { + doc := r.GetApiConfig().GetOpenapiDocuments()[0] + r.ApiConfig.OpenapiDocuments = []*apigatewaypb.ApiConfig_OpenApiDocument{doc, doc} + }, "two documents", codes.InvalidArgument}, + { + func(r *apigatewaypb.CreateApiConfigRequest) { r.ApiConfig.GatewayServiceAccount = "nope" }, + "bad service account", + codes.InvalidArgument, + }, + {func(r *apigatewaypb.CreateApiConfigRequest) { + r.ApiConfig.GrpcServices = []*apigatewaypb.ApiConfig_GrpcServiceDefinition{{}} + }, "grpc", codes.InvalidArgument}, + { + func(r *apigatewaypb.CreateApiConfigRequest) { r.Parent = parent + "/apis/ghost" }, + "missing api", + codes.NotFound, + }, + {func(r *apigatewaypb.CreateApiConfigRequest) { r.ApiConfigId = "" }, "no id", codes.InvalidArgument}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, apiID) + + req := configRequest("cfg", helloSpec("https://backend.example")) + tt.mut(req) + + _, err := e.client.CreateApiConfig(t.Context(), req) + assert.Equal(t, tt.want, status.Code(err), "%v", err) + }) + } + + t.Run("service account email accepted", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, apiID) + + req := configRequest("cfg", helloSpec("https://backend.example")) + req.ApiConfig.GatewayServiceAccount = "gw@" + project + ".iam.gserviceaccount.com" + + op, err := e.client.CreateApiConfig(t.Context(), req) + require.NoError(t, err) + + cfg, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, req.GetApiConfig().GetGatewayServiceAccount(), cfg.GetGatewayServiceAccount()) + }) +} + +const ( + backendAddr = "https://backend.example" + getA = " /a:\n get:\n" + okResponses + metricM = "x-google-management:\n metrics:\n - name: m\n valueType: INT64\n" +) + +func jwtDef(name, issuerURL, jwks string) string { + d := " " + name + ":\n type: oauth2\n x-google-issuer: " + issuerURL + "\n" + if jwks != "" { + d += " x-google-jwks_uri: " + jwks + "\n" + } + + return d +} + +func quotaLimit(unit string) string { + return " quota:\n limits:\n - name: l\n metric: m\n unit: \"" + unit + + "\"\n values:\n STANDARD: 1\n" +} + +func TestOpenAPIValidation(t *testing.T) { + t.Parallel() + + const ( + secHdr = "securityDefinitions:\n" + keyDef = " api_key:\n type: apiKey\n in: query\n" + ) + + tests := []struct { + name string + doc string + want string + }{ + {"not yaml", "{{{", "not valid YAML"}, + {"wrong version", "swagger: \"1.2\"\npaths: {}\n", "Unsupported Swagger version"}, + {"no version", "paths: {}\n", "Unsupported document"}, + {"no paths", opSpec(backendAddr, "", " {}\n"), "no paths"}, + {"no backend", opSpec("", "", getA), "no x-google-backend"}, + {"backend no scheme", opSpec("backend.example", "", getA), "http or https"}, + {"bad translation", opSpec(backendAddr+"\n path_translation: SIDEWAYS", "", getA), "path_translation"}, + {"deadline too big", opSpec(backendAddr+"\n deadline: 9999", "", getA), "deadline"}, + { + "audience and disable auth", + opSpec(backendAddr+"\n jwt_audience: x\n disable_auth: true", "", getA), + "Only one of", + }, + {"missing responses", opSpec(backendAddr, "", " /a:\n get: {}\n"), "responses"}, + { + "undefined security", + opSpec(backendAddr, "", " /a:\n get:\n security:\n - nope: []\n"+okResponses), + "not defined", + }, + {"jwt without jwks", opSpec(backendAddr, secHdr+jwtDef("j", "i", ""), getA), "x-google-jwks_uri"}, + { + "duplicate issuer", + opSpec(backendAddr, secHdr+jwtDef("a", "i", "https://x/k")+jwtDef("b", "i", "https://x/k"), getA), + "already used", + }, + {"api key without name", opSpec(backendAddr, secHdr+keyDef, getA), "needs a name"}, + { + "ambiguous templates", + opSpec(backendAddr, "", " /a/{x}:\n get:\n"+okResponses+" /a/{y}:\n get:\n"+okResponses), + "ambiguous", + }, + {"partial template", opSpec(backendAddr, "", " /a/pre{x}:\n get:\n"+okResponses), "whole segment"}, + { + "quota undefined metric", + opSpec( + backendAddr, + "", + " /a:\n get:\n x-google-quota:\n metricCosts:\n m: 1\n"+okResponses, + ), + "undefined metric", + }, + { + "quota bad unit", + opSpec(backendAddr, metricM+" metricKind: DELTA\n"+quotaLimit("1/s/{project}"), getA), + "unit", + }, + {"metric kind", opSpec(backendAddr, metricM+" metricKind: GAUGE\n", getA), "DELTA"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, apiID) + + op, err := e.client.CreateApiConfig(t.Context(), configRequest("bad", tt.doc)) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + assert.Contains(t, err.Error(), tt.want) + + meta, merr := op.Metadata() + require.NoError(t, merr) + assert.NotEmpty(t, meta.GetDiagnostics()) + + cfg, gerr := e.client.GetApiConfig(t.Context(), &apigatewaypb.GetApiConfigRequest{ + Name: parent + "/apis/" + apiID + "/configs/bad", + }) + require.NoError(t, gerr) + assert.Equal(t, apigatewaypb.ApiConfig_FAILED, cfg.GetState()) + + _, gerr = e.client.CreateGateway(t.Context(), gatewayRequest("gw", gwParen, cfg.GetName())) + assert.Equal(t, codes.FailedPrecondition, status.Code(gerr), "a failed config cannot be served") + }) + } +} + +func TestGatewayLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, apiID) + + cfg := e.createConfig(t, "cfg-1", helloSpec("https://b.example")) + cfg2 := e.createConfig(t, "cfg-2", helloSpec("https://b.example")) + gw := e.createGateway(t, "my-gw", cfg.GetName()) + + assert.Equal(t, gwParen+"/gateways/my-gw", gw.GetName()) + assert.Regexp(t, `^my-gw-[a-z2-7]{18}\.uc\.gateway\.dev$`, gw.GetDefaultHostname()) + assert.Equal(t, apigatewaypb.Gateway_EFFECTIVE_STREAMING_MODE_DISABLED, gw.GetEffectiveStreamingMode()) + + op, err := e.client.UpdateGateway(t.Context(), &apigatewaypb.UpdateGatewayRequest{ + Gateway: &apigatewaypb.Gateway{Name: gw.GetName(), ApiConfig: cfg2.GetName()}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"api_config"}}, + }) + require.NoError(t, err) + + upd, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, cfg2.GetName(), upd.GetApiConfig()) + assert.Equal(t, gw.GetDefaultHostname(), upd.GetDefaultHostname()) + + it := e.client.ListGateways( + t.Context(), + &apigatewaypb.ListGatewaysRequest{Parent: "projects/" + project + "/locations/-"}, + ) + g, err := it.Next() + require.NoError(t, err) + assert.Equal(t, gw.GetName(), g.GetName()) + + tests := []struct { + req *apigatewaypb.CreateGatewayRequest + name string + want codes.Code + }{ + {gatewayRequest("x", parent, cfg.GetName()), "global location", codes.InvalidArgument}, + {gatewayRequest("x", gwParen, ""), "no config", codes.InvalidArgument}, + {gatewayRequest("x", gwParen, parent+"/apis/a/configs/ghost"), "missing config", codes.NotFound}, + {gatewayRequest("my-gw", gwParen, cfg.GetName()), "duplicate", codes.AlreadyExists}, + {gatewayRequest("X", gwParen, cfg.GetName()), "bad id", codes.InvalidArgument}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, cerr := e.client.CreateGateway(t.Context(), tt.req) + assert.Equal(t, tt.want, status.Code(cerr), "%v", cerr) + }) + } +} + +func TestOperationLifecycleOnClock(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.handler.Backend.SetTiming(gcpapigateway.Timing{ + Create: 10 * time.Second, Update: 5 * time.Second, Delete: 3 * time.Second, + }) + + op, err := e.client.CreateApi(t.Context(), &apigatewaypb.CreateApiRequest{Parent: parent, ApiId: apiID}) + require.NoError(t, err) + assert.False(t, op.Done()) + + name := parent + "/apis/" + apiID + api, err := e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: name}) + require.NoError(t, err) + assert.Equal(t, apigatewaypb.Api_CREATING, api.GetState()) + + e.clock.Advance(10 * time.Second) + + api, err = e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: name}) + require.NoError(t, err) + assert.Equal(t, apigatewaypb.Api_ACTIVE, api.GetState()) + + done, err := op.Poll(t.Context()) + require.NoError(t, err) + assert.Equal(t, name, done.GetName()) + + del, err := e.client.DeleteApi(t.Context(), &apigatewaypb.DeleteApiRequest{Name: name}) + require.NoError(t, err) + + api, err = e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: name}) + require.NoError(t, err) + assert.Equal(t, apigatewaypb.Api_DELETING, api.GetState()) + + e.clock.Advance(3 * time.Second) + + _, err = e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: name}) + assert.Equal(t, codes.NotFound, status.Code(err)) + require.NoError(t, del.Wait(t.Context())) +} + +func TestOperationsAPI(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.handler.Backend.SetTiming(gcpapigateway.Timing{Create: time.Minute}) + + op, err := e.client.CreateApi(t.Context(), &apigatewaypb.CreateApiRequest{Parent: parent, ApiId: apiID}) + require.NoError(t, err) + + lro := e.client.LROClient + opName := op.Name() + assert.Contains(t, opName, "projects/"+project+"/locations/global/operations/operation-") + + got, err := lro.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: opName}) + require.NoError(t, err) + assert.False(t, got.GetDone()) + + list, err := lro.ListOperations(t.Context(), &longrunningpb.ListOperationsRequest{Name: parent}).Next() + require.NoError(t, err) + assert.Equal(t, opName, list.GetName()) + + _, err = lro.WaitOperation( + t.Context(), + &longrunningpb.WaitOperationRequest{Name: "projects/p/locations/global/operations/ghost"}, + ) + assert.Equal(t, codes.NotFound, status.Code(err)) + + running := lro.DeleteOperation(t.Context(), &longrunningpb.DeleteOperationRequest{Name: opName}) + assert.Equal(t, codes.FailedPrecondition, status.Code(running), "running operation") + + require.NoError(t, lro.CancelOperation(t.Context(), &longrunningpb.CancelOperationRequest{Name: opName})) + + cancelled, err := lro.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: opName}) + require.NoError(t, err) + assert.True(t, cancelled.GetDone()) + assert.Equal(t, int32(codes.Canceled), cancelled.GetError().GetCode()) + + _, err = e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: parent + "/apis/" + apiID}) + assert.Equal(t, codes.NotFound, status.Code(err), "cancelled create is rolled back") + + require.NoError(t, lro.DeleteOperation(t.Context(), &longrunningpb.DeleteOperationRequest{Name: opName})) + assert.Equal( + t, + codes.NotFound, + status.Code(lro.DeleteOperation(t.Context(), &longrunningpb.DeleteOperationRequest{Name: opName})), + ) +} + +func TestIAMAndLocations(t *testing.T) { + t.Parallel() + + e := newEnv(t) + gw := e.deploy(t, helloSpec("https://b.example")) + apiName := parent + "/apis/" + apiID + iam := iampb.NewIAMPolicyClient(e.conn) + loc := location.NewLocationsClient(e.conn) + + for _, res := range []string{apiName, apiName + "/configs/cfg-1", gw.GetName()} { + t.Run(res, func(t *testing.T) { + t.Parallel() + + pol, err := iam.GetIamPolicy(t.Context(), &iampb.GetIamPolicyRequest{Resource: res}) + require.NoError(t, err) + assert.Empty(t, pol.GetBindings()) + + set, err := iam.SetIamPolicy(t.Context(), &iampb.SetIamPolicyRequest{ + Resource: res, Policy: &iampb.Policy{Bindings: []*iampb.Binding{ + {Role: "roles/apigateway.viewer", Members: []string{"user:a@example.com"}}, + }}, + }) + require.NoError(t, err) + require.Len(t, set.GetBindings(), 1) + + again, err := iam.GetIamPolicy(t.Context(), &iampb.GetIamPolicyRequest{Resource: res}) + require.NoError(t, err) + assert.Equal(t, "roles/apigateway.viewer", again.GetBindings()[0].GetRole()) + + perms, err := iam.TestIamPermissions(t.Context(), &iampb.TestIamPermissionsRequest{ + Resource: res, Permissions: []string{"apigateway.gateways.get", "storage.objects.get"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"apigateway.gateways.get"}, perms.GetPermissions()) + }) + } + + _, err := iam.GetIamPolicy(t.Context(), &iampb.GetIamPolicyRequest{Resource: apiName + "x"}) + require.Error(t, err) + + locs, err := loc.ListLocations(t.Context(), &location.ListLocationsRequest{Name: "projects/" + project}) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(locs.GetLocations()), 2) +} diff --git a/services/gcpapigateway/cors_test.go b/services/gcpapigateway/cors_test.go new file mode 100644 index 0000000000..7c0c1d99db --- /dev/null +++ b/services/gcpapigateway/cors_test.go @@ -0,0 +1,135 @@ +package gcpapigateway_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const corsHost = "api.endpoints.proj.cloud.goog" + +func corsTop(allow string) string { + return "host: " + corsHost + "\nx-google-endpoints:\n- name: " + corsHost + "\n allowCors: " + allow + "\n" +} + +func TestAllowCorsOpenAPI2(t *testing.T) { + t.Parallel() + + const keyDef = "securityDefinitions:\n api_key:\n type: apiKey\n name: key\n in: query\n" + + locked := " /items/{id}:\n" + op("get", " security:\n - api_key: []\n") + + tests := []struct { + name string + top string + paths string + method string + target string + wantMethod string + wantStatus int + }{ + { + name: "allowCors proxies preflight to the backend", top: corsTop("True"), paths: locked, + method: http.MethodOptions, target: "/items/7", wantStatus: 200, wantMethod: http.MethodOptions, + }, + { + name: "preflight skips the API key requirement", top: keyDef + corsTop("true"), paths: locked, + method: http.MethodOptions, target: "/items/7", wantStatus: 200, wantMethod: http.MethodOptions, + }, + { + name: "allowCors false rejects OPTIONS", top: corsTop("false"), paths: locked, + method: http.MethodOptions, target: "/items/7", wantStatus: 405, + }, + { + name: "no extension rejects OPTIONS", top: "", paths: locked, + method: http.MethodOptions, target: "/items/7", wantStatus: 405, + }, + { + name: "endpoint name that is not the host does not enable CORS", + top: "host: " + corsHost + "\nx-google-endpoints:\n- name: other.cloud.goog\n allowCors: true\n", + paths: locked, + method: http.MethodOptions, target: "/items/7", wantStatus: 405, + }, + { + name: "undefined path stays 404", top: corsTop("true"), paths: locked, + method: http.MethodOptions, target: "/nope", wantStatus: 404, + }, + { + name: "a declared OPTIONS operation wins", top: corsTop("true"), + paths: " /items:\n" + op("get", "") + op("options", " security:\n - api_key: []\n"), + method: http.MethodOptions, target: "/items?key=k", wantStatus: 200, wantMethod: http.MethodOptions, + }, + { + name: "other methods are unaffected", top: corsTop("true"), paths: locked, + method: http.MethodDelete, target: "/items/7", wantStatus: 405, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if strings.Contains(tt.paths, "api_key") && !strings.Contains(tt.top, "securityDefinitions") { + tt.top = keyDef + tt.top + } + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, tt.top, tt.paths)) + + got := e.call(t, gw, tt.method, tt.target, nil) + require.Equal(t, tt.wantStatus, got.status, got.raw) + + if tt.wantMethod != "" { + assert.Equal(t, tt.wantMethod, got.body["method"]) + } + }) + } +} + +func TestAllowCorsOpenAPI3(t *testing.T) { + t.Parallel() + + const server = "servers:\n- url: https://api.example.com\n x-google-endpoint:" + + tests := []struct { + name string + servers string + wantStatus int + }{ + {"allowCors true", server + "\n allowCors: true\n", 200}, + {"allowCors false", server + "\n allowCors: false\n", 405}, + {"default is false", server + " {}\n", 405}, + {"no servers entry", "", 405}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, spec3(mgmt3(be.URL, ""), tt.servers+"x-google-backend: be\n", + " /hello:\n"+op("get", ""))) + + got := e.call(t, gw, http.MethodOptions, "/hello", nil) + require.Equal(t, tt.wantStatus, got.status, got.raw) + + if tt.wantStatus == http.StatusOK { + assert.Equal(t, http.MethodOptions, got.body["method"]) + } + }) + } +} + +func TestDefaultHostnameShape(t *testing.T) { + t.Parallel() + + e := newEnv(t) + gw := e.deploy(t, opSpec("http://127.0.0.1:1", "", " /h:\n"+op("get", ""))) + + assert.Regexp(t, `^my-gw-[a-z0-9]{18}\.uc\.gateway\.dev$`, gw.GetDefaultHostname()) +} diff --git a/services/gcpapigateway/dataplane.go b/services/gcpapigateway/dataplane.go new file mode 100644 index 0000000000..cf425f8731 --- /dev/null +++ b/services/gcpapigateway/dataplane.go @@ -0,0 +1,338 @@ +package gcpapigateway + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + // PathPrefix fronts the path-style data-plane URL: /_apigateway/{project}/{location}/{gateway}/{path}. + PathPrefix = "/_apigateway/" + userinfoHeader = "X-Apigateway-Api-Userinfo" + forwardedAuth = "X-Forwarded-Authorization" + pathStyleSegment = 4 +) + +//nolint:gochecknoglobals // immutable lookup table +var hopHeaders = map[string]bool{ + "Connection": true, "Keep-Alive": true, "Proxy-Authenticate": true, "Proxy-Authorization": true, + "Te": true, "Trailer": true, "Transfer-Encoding": true, "Upgrade": true, "Proxy-Connection": true, +} + +// served is a gateway resolved for one data-plane request. +type served struct { + spec *compiledSpec + metrics map[string]bool + limits map[string]quotaLimit + project string + managed string + path string +} + +// MatchesDataPlane reports whether a request addresses a gateway, by default hostname or by path prefix. +func (b *InMemoryBackend) MatchesDataPlane(r *http.Request) bool { + if strings.HasPrefix(r.URL.Path, PathPrefix) { + return true + } + + host := hostOnly(r.Host) + if !strings.HasSuffix(host, hostSuffix) { + return false + } + + b.mu.RLock("MatchesDataPlane") + defer b.mu.RUnlock() + + for _, g := range b.gateways { + if g.pb.GetDefaultHostname() == host { + return true + } + } + + return false +} + +func (b *InMemoryBackend) gatewayForLocked(r *http.Request) (*gatewayRec, string) { + if rest, ok := strings.CutPrefix(r.URL.Path, PathPrefix); ok { + parts := strings.SplitN(rest, "/", pathStyleSegment) + if len(parts) < pathStyleSegment-1 { + return nil, "" + } + + p := "" + if len(parts) == pathStyleSegment { + p = parts[3] + } + + return b.gateways[childName(parts[0], parts[1], kindGateways, parts[2])], "/" + p + } + + host := hostOnly(r.Host) + + for _, g := range b.gateways { + if g.pb.GetDefaultHostname() == host { + return g, r.URL.Path + } + } + + return nil, "" +} + +func (b *InMemoryBackend) resolve(r *http.Request) (*served, *authError) { + b.mu.RLock("resolve") + defer b.mu.RUnlock() + + g, p := b.gatewayForLocked(r) + if g == nil || g.life.gone(b.now()) || g.life.deleting() { + return nil, &authError{status: http.StatusNotFound, msg: "Gateway not found."} + } + + if g.life.state(b.now(), stateActive) == stateCreating { + return nil, &authError{status: http.StatusServiceUnavailable, msg: "Gateway is not ready."} + } + + c, ok := b.liveConfigLocked(g.pb.GetApiConfig()) + if !ok || c.spec == nil { + return nil, &authError{status: http.StatusServiceUnavailable, msg: "Gateway API config is not available."} + } + + project, _, _ := parseGatewayName(g.pb.GetName()) + managed := "" + + if api, found := b.apis[apiOfConfig(c.pb.GetName())]; found { + managed = api.pb.GetManagedService() + } + + sv := &served{ + spec: c.spec, project: project, managed: managed, path: p, metrics: c.spec.Metrics, limits: c.spec.Limits, + } + if latest := b.latestSpecLocked(apiOfConfig(c.pb.GetName())); latest != nil { + sv.metrics, sv.limits = latest.Metrics, latest.Limits + } + + return sv, nil +} + +// latestSpecLocked returns the spec of the API's newest usable config: metrics and quota apply to the whole API, +// and the most recently declared definition governs. +func (b *InMemoryBackend) latestSpecLocked(api string) *compiledSpec { + var ( + best *compiledSpec + at time.Time + ) + + for name, c := range b.configs { + if c.spec == nil || apiOfConfig(name) != api || c.life.gone(b.now()) || c.life.deleting() { + continue + } + + if t := c.pb.GetCreateTime().AsTime(); best == nil || t.After(at) { + best, at = c.spec, t + } + } + + return best +} + +func apiOfConfig(config string) string { + api, _, _ := strings.Cut(config, "/"+kindConfigs+"/") + + return api +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"code": status, "message": msg}) +} + +// DataPlane serves requests to gateways. +func (b *InMemoryBackend) DataPlane() http.Handler { return http.HandlerFunc(b.serveGateway) } + +func (b *InMemoryBackend) serveGateway(w http.ResponseWriter, r *http.Request) { + sv, aerr := b.resolve(r) + if aerr != nil { + writeErr(w, aerr.status, aerr.msg) + + return + } + + rt, vars, other := sv.spec.match(r.Method, sv.path) + if rt == nil { + if other != "" { + writeErr(w, http.StatusMethodNotAllowed, fmt.Sprintf( + "The current request is matched to the defined url template \"%s\" but its http method is not allowed", + other)) + + return + } + + writeErr(w, http.StatusNotFound, "The current request is not defined by this API.") + + return + } + + claims, aerr := b.authorize(r, sv, rt) + if aerr != nil { + writeErr(w, aerr.status, aerr.msg) + + return + } + + d, missing := b.consume(sv, rt) + if missing { + writeErr(w, http.StatusInternalServerError, "Failed to call Service Control Quota.") + + return + } + + if d != nil { + writeErr(w, http.StatusTooManyRequests, fmt.Sprintf( + "RESOURCE_EXHAUSTED: Quota exceeded for quota metric '%s' and limit '%s' of service '%s' for consumer "+ + "'project:%s'.", d.metric, d.limit, sv.managed, sv.project)) + + return + } + + b.forward(w, r, sv, rt, vars, claims) +} + +func backendURL(be *backendCfg, reqPath, rawQuery string, vars map[string]string) (*url.URL, error) { + u, err := url.Parse(be.Address) + if err != nil { + return nil, err + } + + q := url.Values{} + maps.Copy(q, u.Query()) + + if be.Translation == translateAppend { + u.Path = strings.TrimRight(u.Path, "/") + reqPath + } else { + for _, k := range sortedVars(vars) { + q.Add(k, vars[k]) + } + } + + for k, vs := range mustQuery(rawQuery) { + q[k] = append(q[k], vs...) + } + + u.RawPath, u.RawQuery = "", q.Encode() + + return u, nil +} + +func sortedVars(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys +} + +func mustQuery(raw string) url.Values { + v, _ := url.ParseQuery(raw) + + return v +} + +func (b *InMemoryBackend) forward( + w http.ResponseWriter, r *http.Request, sv *served, rt *route, vars map[string]string, claims jwt.MapClaims, +) { + be := rt.Backend + + u, err := backendURL(be, sv.path, r.URL.RawQuery, vars) + if err != nil { + writeErr(w, http.StatusBadGateway, "Invalid backend address.") + + return + } + + ctx, cancel := context.WithTimeout(r.Context(), be.Deadline) + defer cancel() + + out, err := http.NewRequestWithContext(ctx, r.Method, u.String(), r.Body) + if err != nil { + writeErr(w, http.StatusBadGateway, "Invalid backend request.") + + return + } + + out.ContentLength = r.ContentLength + copyHeaders(out.Header, r.Header) + out.Header.Del(userinfoHeader) + + if !be.DisableAuth { + if a := r.Header.Get("Authorization"); a != "" { + out.Header.Set(forwardedAuth, a) + } + + out.Header.Del("Authorization") + } + + if claims != nil { + if raw, mErr := json.Marshal(claims); mErr == nil { + out.Header.Set(userinfoHeader, base64.RawURLEncoding.EncodeToString(raw)) + } + } + + if host, _, sErr := net.SplitHostPort(r.RemoteAddr); sErr == nil { + out.Header.Add("X-Forwarded-For", host) + } + + resp, err := b.client.Do(out) //nolint:gosec // G704 loopback-only by default; guarded dialer + if err != nil { + writeBackendErr(ctx, w, err) + + return + } + + defer resp.Body.Close() + + for k, vs := range resp.Header { + if !hopHeaders[http.CanonicalHeaderKey(k)] { + w.Header()[k] = vs + } + } + + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func copyHeaders(dst, src http.Header) { + for k, vs := range src { + if hopHeaders[k] || k == "Host" || k == "Content-Length" { + continue + } + + dst[k] = slices.Clone(vs) + } +} + +func writeBackendErr(ctx context.Context, w http.ResponseWriter, err error) { + switch { + case errors.Is(ctx.Err(), context.DeadlineExceeded): + writeErr(w, http.StatusGatewayTimeout, "upstream request timeout") + case errors.Is(err, ErrDestinationNotPermitted): + writeErr(w, http.StatusBadGateway, "backend destination is not permitted") + default: + writeErr(w, http.StatusServiceUnavailable, "upstream connect error or disconnect/reset before headers") + } +} diff --git a/services/gcpapigateway/dataplane_test.go b/services/gcpapigateway/dataplane_test.go new file mode 100644 index 0000000000..347bda3c71 --- /dev/null +++ b/services/gcpapigateway/dataplane_test.go @@ -0,0 +1,257 @@ +package gcpapigateway_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func op(method, extra string) string { + return " " + method + ":\n" + extra + okResponses +} + +func TestRoutingAndPathTranslation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + root string + top string + paths string + method string + target string + wantPath string + wantQuery string + wantMsg string + wantCode int + }{ + { + name: "append is the root default", root: "BE/base", + paths: " /hello/{name}:\n" + op("get", ""), + method: "GET", target: "/hello/world?x=1", wantPath: "/base/hello/world", wantQuery: "x=1", wantCode: 200, + }, + { + name: "constant address moves path params to the query", + root: "BE/fn\n path_translation: CONSTANT_ADDRESS", + paths: " /hello/{name}:\n" + op("get", ""), + method: "GET", target: "/hello/world", wantPath: "/fn", wantQuery: "name=world", wantCode: 200, + }, + { + name: "operation backend defaults to constant address", root: "BE/root", + paths: " /a/{id}:\n" + op("get", " x-google-backend:\n address: BE/op\n"), + method: "GET", target: "/a/7?q=1", wantPath: "/op", wantQuery: "id=7&q=1", wantCode: 200, + }, + { + name: "operation backend can append", root: "BE/root", + paths: " /a/{id}:\n" + op("get", " x-google-backend:\n address: BE/op\n"+ + " path_translation: APPEND_PATH_TO_ADDRESS\n"), + method: "GET", target: "/a/7", wantPath: "/op/a/7", wantCode: 200, + }, + { + name: "base path is part of the appended path", root: "BE/base", top: "basePath: /v1\n", + paths: " /hello:\n" + op("get", ""), + method: "GET", target: "/v1/hello", wantPath: "/base/v1/hello", wantCode: 200, + }, + { + name: "literal beats template", root: "BE", top: "", + paths: " /items/{id}:\n" + op("get", "") + " /items/special:\n" + op("get", ""), + method: "GET", target: "/items/special", wantPath: "/items/special", wantCode: 200, + }, + { + name: "template matches other values", root: "BE", top: "", + paths: " /items/{id}:\n" + op("get", "") + " /items/special:\n" + op("get", ""), + method: "GET", target: "/items/42", wantPath: "/items/42", wantCode: 200, + }, + { + name: "undefined path", root: "BE", paths: " /hello:\n" + op("get", ""), + method: "GET", target: "/nope", wantCode: 404, + wantMsg: "The current request is not defined by this API.", + }, + { + name: "undefined method", + root: "BE", + paths: " /hello:\n" + op("get", ""), + method: "POST", + target: "/hello", + wantCode: 405, + wantMsg: `The current request is matched to the defined url template "/hello" but its http method is not allowed`, + }, + { + name: "undefined method keeps the base path in the template", root: "BE", top: "basePath: /v1\n", + paths: " /items/{id}:\n" + op("get", ""), + method: "DELETE", target: "/v1/items/3", wantCode: 405, + wantMsg: `The current request is matched to the defined url template "/v1/items/{id}" ` + + `but its http method is not allowed`, + }, + { + name: "base path mismatch", root: "BE", top: "basePath: /v1\n", paths: " /hello:\n" + op("get", ""), + method: "GET", target: "/hello", wantCode: 404, + wantMsg: "The current request is not defined by this API.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, strings.ReplaceAll(opSpec(tt.root, tt.top, tt.paths), "BE", be.URL)) + + got := e.call(t, gw, tt.method, tt.target, nil) + require.Equal(t, tt.wantCode, got.status, got.raw) + + if tt.wantCode != http.StatusOK { + assert.EqualValues(t, tt.wantCode, got.body["code"]) + assert.Equal(t, tt.wantMsg, got.body["message"]) + + return + } + + assert.Equal(t, tt.wantPath, got.body["path"]) + assert.Equal(t, tt.wantQuery, got.body["query"]) + }) + } +} + +func TestPathStyleURLAndGatewayResolution(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + _ = e.deploy(t, opSpec(be.URL, "", " /hello:\n"+op("get", ""))) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, + "http://"+e.handler.Addr()+"/_apigateway/"+project+"/"+region+"/my-gw/hello", nil) + require.NoError(t, err) + + got := e.do(t, req) + require.Equal(t, http.StatusOK, got.status, got.raw) + assert.Equal(t, "/hello", got.body["path"]) + + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, + "http://"+e.handler.Addr()+"/_apigateway/"+project+"/"+region+"/ghost/hello", nil) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, e.do(t, req).status) + + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+e.handler.Addr()+"/hello", nil) + require.NoError(t, err) + req.Host = "other-abc.uc.gateway.dev" + assert.Equal(t, http.StatusNotFound, e.do(t, req).status, "control-plane REST handles unknown hosts") +} + +func TestForwardedHeadersAndBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + backendOpts string + wantAuth string + wantFwdAuth string + }{ + {"authorization is replaced", "", "", "Bearer user-token"}, + {"disable auth keeps authorization", " disable_auth: true\n", "Bearer user-token", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL+"\n"+strings.TrimRight(tt.backendOpts, "\n"), "", + " /echo:\n"+op("post", ""))) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://"+e.handler.Addr()+"/echo", + strings.NewReader("payload")) + require.NoError(t, err) + + req.Host = gw.GetDefaultHostname() + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("X-Apigateway-Api-Userinfo", "forged") + + got := e.do(t, req) + require.Equal(t, http.StatusOK, got.status, got.raw) + + hdr, ok := got.body["headers"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "payload", got.body["body"]) + assert.Equal(t, tt.wantAuth, hdr["Authorization"]) + assert.Equal(t, tt.wantFwdAuth, hdr["X-Forwarded-Authorization"]) + assert.Empty(t, hdr["X-Apigateway-Api-Userinfo"], "client supplied userinfo is dropped") + }) + } +} + +func TestCloudRunLocalhostBackend(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + port := be.URL[strings.LastIndex(be.URL, ":")+1:] + + e := newEnv(t) + gw := e.deploy(t, opSpec("http://svc-abc123-uc.localhost:"+port, "", " /hello:\n"+op("get", ""))) + + got := e.call(t, gw, http.MethodGet, "/hello", nil) + require.Equal(t, http.StatusOK, got.status, got.raw) + assert.Equal(t, "/hello", got.body["path"]) +} + +func TestExternalBackendRefusedByDefault(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantCodes []int + allow bool + }{ + {name: "refused by default", allow: false, wantCodes: []int{http.StatusBadGateway}}, + { + name: "opt in dials out", allow: true, + wantCodes: []int{http.StatusServiceUnavailable, http.StatusGatewayTimeout}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.handler.Backend.SetAllowExternal(tt.allow) + + gw := e.deploy(t, opSpec("http://192.0.2.1:9\n deadline: 0.2", "", " /hello:\n"+op("get", ""))) + + got := e.call(t, gw, http.MethodGet, "/hello", nil) + assert.Contains(t, tt.wantCodes, got.status, got.raw) + }) + } +} + +func TestBackendTimeout(t *testing.T) { + t.Parallel() + + e := newEnv(t) + gw := e.deploy(t, opSpec("http://127.0.0.1:1\n deadline: 0.5", "", " /hello:\n"+op("get", ""))) + + got := e.call(t, gw, http.MethodGet, "/hello", nil) + assert.Equal(t, http.StatusServiceUnavailable, got.status, "connection refused is a connect error") +} + +func TestGatewayNotServing(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, "", " /hello:\n"+op("get", ""))) + + require.Equal(t, http.StatusOK, e.call(t, gw, http.MethodGet, "/hello", nil).status) + + delOp, err := e.client.DeleteGateway(t.Context(), deleteGatewayRequest(gw.GetName())) + require.NoError(t, err) + require.NoError(t, delOp.Wait(t.Context())) + + assert.Equal(t, http.StatusNotFound, e.call(t, gw, http.MethodGet, "/hello", nil).status) +} diff --git a/services/gcpapigateway/errors.go b/services/gcpapigateway/errors.go new file mode 100644 index 0000000000..7301242ed3 --- /dev/null +++ b/services/gcpapigateway/errors.go @@ -0,0 +1,24 @@ +package gcpapigateway + +import ( + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func errNotFound(format string, a ...any) error { + return status.Error(codes.NotFound, fmt.Sprintf(format, a...)) +} + +func errExists(format string, a ...any) error { + return status.Error(codes.AlreadyExists, fmt.Sprintf(format, a...)) +} + +func errInvalid(format string, a ...any) error { + return status.Error(codes.InvalidArgument, fmt.Sprintf(format, a...)) +} + +func errPrecondition(format string, a ...any) error { + return status.Error(codes.FailedPrecondition, fmt.Sprintf(format, a...)) +} diff --git a/services/gcpapigateway/gateways.go b/services/gcpapigateway/gateways.go new file mode 100644 index 0000000000..e1d0b2f716 --- /dev/null +++ b/services/gcpapigateway/gateways.go @@ -0,0 +1,273 @@ +package gcpapigateway + +import ( + "strings" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const hostSuffix = ".gateway.dev" + +// regionCodes are the short region labels of gateway hostnames. +// +//nolint:gochecknoglobals // immutable lookup table +var regionCodes = map[string]string{ + "us-central1": "uc", "us-east1": "ue", "us-east4": "ue", "us-west2": "uw", "us-west3": "uw", "us-west4": "uw", + "europe-west1": "ew", "europe-west2": "ew", "asia-northeast1": "an", "australia-southeast1": "as", +} + +func defaultHostname(id, region, name string) string { + return id + "-" + idHash(hashLen, name) + "." + regionCodes[region] + hostSuffix +} + +func (b *InMemoryBackend) liveGatewayLocked(name string) (*gatewayRec, bool) { + r, ok := b.gateways[name] + if !ok || r.life.gone(b.now()) { + return nil, false + } + + return r, true +} + +func (b *InMemoryBackend) gatewayViewLocked(r *gatewayRec) *apigatewaypb.Gateway { + out := clone(r.pb) + out.State = apigatewaypb.Gateway_State(r.life.state(b.now(), stateActive)) + + return out +} + +// usableConfigLocked checks that a gateway can serve the config. +func (b *InMemoryBackend) usableConfigLocked(name string) error { + if _, err := parseConfigName(name); err != nil { + return errInvalid("Invalid api_config %q: %v", name, err) + } + + c, ok := b.liveConfigLocked(name) + if !ok { + return errNotFound("Resource '%s' was not found.", name) + } + + if c.life.deleting() || c.pb.GetState() == apigatewaypb.ApiConfig_FAILED { + return errPrecondition("API config '%s' is %s and cannot be served.", name, + apigatewaypb.ApiConfig_State(c.life.state(b.now(), int32(c.pb.GetState())))) + } + + return nil +} + +// CreateGateway creates a gateway in a region. +func (b *InMemoryBackend) CreateGateway(in *apigatewaypb.CreateGatewayRequest) (*longrunningpb.Operation, error) { + project, region, err := parseParent(in.GetParent()) + if err != nil { + return nil, err + } + + if !knownRegion(region) { + return nil, errInvalid("Gateways are not available in location %q.", region) + } + + if err = checkID("gateway_id", in.GetGatewayId()); err != nil { + return nil, err + } + + src := in.GetGateway() + if err = validateLabels(src.GetLabels()); err != nil { + return nil, err + } + + if src.GetApiConfig() == "" { + return nil, errInvalid("gateway.api_config is required.") + } + + b.mu.Lock("CreateGateway") + defer b.mu.Unlock() + + b.sweepLocked() + + name := childName(project, region, kindGateways, in.GetGatewayId()) + if _, ok := b.gateways[name]; ok { + return nil, errExists("Resource '%s' already exists.", name) + } + + if err = b.usableConfigLocked(src.GetApiConfig()); err != nil { + return nil, err + } + + effective := apigatewaypb.Gateway_EFFECTIVE_STREAMING_MODE_DISABLED + if src.GetStreamingMode() == apigatewaypb.Gateway_STREAMING_MODE_ENABLED { + effective = apigatewaypb.Gateway_EFFECTIVE_STREAMING_MODE_ENABLED + } + + pb := &apigatewaypb.Gateway{ + Name: name, CreateTime: b.ts(), UpdateTime: b.ts(), Labels: src.GetLabels(), + DisplayName: src.GetDisplayName(), ApiConfig: src.GetApiConfig(), State: apigatewaypb.Gateway_ACTIVE, + DefaultHostname: defaultHostname(in.GetGatewayId(), region, name), StreamingMode: src.GetStreamingMode(), + EffectiveStreamingMode: effective, + } + b.gateways[name] = &gatewayRec{pb: pb, life: life{Until: b.now().Add(b.timing.Create), Transient: stateCreating}} + + return b.finishLocked(opSpec{ + project: project, location: region, target: name, verb: verbCreate, dur: b.timing.Create, resp: clone(pb), + }) +} + +// GetGateway returns one gateway. +func (b *InMemoryBackend) GetGateway(name string) (*apigatewaypb.Gateway, error) { + if _, _, err := parseGatewayName(name); err != nil { + return nil, err + } + + b.mu.RLock("GetGateway") + defer b.mu.RUnlock() + + r, ok := b.liveGatewayLocked(name) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", name) + } + + return b.gatewayViewLocked(r), nil +} + +// ListGateways lists the gateways of a location, or of every location for "-". +func (b *InMemoryBackend) ListGateways(in *apigatewaypb.ListGatewaysRequest) ( + *apigatewaypb.ListGatewaysResponse, error, +) { + project, location, err := parseParent(in.GetParent()) + if err != nil { + return nil, err + } + + b.mu.RLock("ListGateways") + + var all []*apigatewaypb.Gateway + + for _, r := range b.gateways { + p, l, _ := parseGatewayName(r.pb.GetName()) + if p == project && (location == "-" || l == location) && !r.life.gone(b.now()) { + all = append(all, b.gatewayViewLocked(r)) + } + } + + b.mu.RUnlock() + + pg, err := paginate(all, listArgs{ + filter: in.GetFilter(), orderBy: in.GetOrderBy(), pageToken: in.GetPageToken(), pageSize: in.GetPageSize(), + }, listKeys[*apigatewaypb.Gateway]{ + name: (*apigatewaypb.Gateway).GetName, + created: func(a *apigatewaypb.Gateway) time.Time { return a.GetCreateTime().AsTime() }, + updated: func(a *apigatewaypb.Gateway) time.Time { return a.GetUpdateTime().AsTime() }, + }) + if err != nil { + return nil, err + } + + return &apigatewaypb.ListGatewaysResponse{Gateways: pg.Data, NextPageToken: pg.Next}, nil +} + +// UpdateGateway updates the display name, labels and API config of a gateway. +func (b *InMemoryBackend) UpdateGateway(in *apigatewaypb.UpdateGatewayRequest) (*longrunningpb.Operation, error) { + src := in.GetGateway() + + project, region, err := parseGatewayName(src.GetName()) + if err != nil { + return nil, err + } + + if err = validateLabels(src.GetLabels()); err != nil { + return nil, err + } + + b.mu.Lock("UpdateGateway") + defer b.mu.Unlock() + + b.sweepLocked() + + rec, ok := b.liveGatewayLocked(src.GetName()) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", src.GetName()) + } + + next := clone(rec.pb) + + for _, p := range maskPaths(in.GetUpdateMask().GetPaths(), maskDisplay, maskLabels, maskAPIConfig) { + switch p { + case maskAll: + next.DisplayName, next.Labels = src.GetDisplayName(), src.GetLabels() + if src.GetApiConfig() != "" { + next.ApiConfig = src.GetApiConfig() + } + case maskDisplay, maskDisplayCamel: + next.DisplayName = src.GetDisplayName() + case maskLabels: + next.Labels = src.GetLabels() + case maskAPIConfig, "apiConfig": + next.ApiConfig = src.GetApiConfig() + case "streaming_mode", "streamingMode": + if src.GetStreamingMode() != rec.pb.GetStreamingMode() { + return nil, errInvalid("streaming_mode is immutable.") + } + default: + return nil, errInvalid("Unsupported update_mask path %q for Gateway.", p) + } + } + + if next.GetApiConfig() != rec.pb.GetApiConfig() { + if err = b.usableConfigLocked(next.GetApiConfig()); err != nil { + return nil, err + } + } + + next.UpdateTime = timestamppb.New(b.now()) + rec.pb = next + rec.life.Until, rec.life.Transient = b.now().Add(b.timing.Update), stateUpdating + + return b.finishLocked(opSpec{ + project: project, location: region, target: next.GetName(), verb: verbUpdate, + dur: b.timing.Update, resp: clone(next), + }) +} + +// DeleteGateway deletes a gateway. +func (b *InMemoryBackend) DeleteGateway(in *apigatewaypb.DeleteGatewayRequest) (*longrunningpb.Operation, error) { + project, region, err := parseGatewayName(in.GetName()) + if err != nil { + return nil, err + } + + b.mu.Lock("DeleteGateway") + defer b.mu.Unlock() + + b.sweepLocked() + + rec, ok := b.liveGatewayLocked(in.GetName()) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", in.GetName()) + } + + if rec.life.deleting() { + return nil, errPrecondition("Resource '%s' is already being deleted.", in.GetName()) + } + + op, err := b.finishLocked(opSpec{ + project: project, location: region, target: in.GetName(), verb: verbDelete, + dur: b.timing.Delete, resp: emptyResp(), + }) + if err != nil { + return nil, err + } + + rec.life.DeleteAt, rec.life.DeleteOp = b.now().Add(b.timing.Delete), op.GetName() + + return op, nil +} + +func hostOnly(h string) string { + if i := strings.LastIndexByte(h, ':'); i >= 0 && !strings.Contains(h[i:], "]") { + return strings.ToLower(h[:i]) + } + + return strings.ToLower(h) +} diff --git a/services/gcpapigateway/grpc.go b/services/gcpapigateway/grpc.go new file mode 100644 index 0000000000..704889de79 --- /dev/null +++ b/services/gcpapigateway/grpc.go @@ -0,0 +1,182 @@ +package gcpapigateway + +import ( + "context" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/genproto/googleapis/cloud/location" + "google.golang.org/protobuf/types/known/emptypb" +) + +type apiGatewayServer struct { + apigatewaypb.UnimplementedApiGatewayServiceServer + b *InMemoryBackend +} + +func (s *apiGatewayServer) ListGateways( + _ context.Context, in *apigatewaypb.ListGatewaysRequest, +) (*apigatewaypb.ListGatewaysResponse, error) { + return s.b.ListGateways(in) +} + +func (s *apiGatewayServer) GetGateway( + _ context.Context, in *apigatewaypb.GetGatewayRequest, +) (*apigatewaypb.Gateway, error) { + return s.b.GetGateway(in.GetName()) +} + +func (s *apiGatewayServer) CreateGateway( + _ context.Context, in *apigatewaypb.CreateGatewayRequest, +) (*longrunningpb.Operation, error) { + return s.b.CreateGateway(in) +} + +func (s *apiGatewayServer) UpdateGateway( + _ context.Context, in *apigatewaypb.UpdateGatewayRequest, +) (*longrunningpb.Operation, error) { + return s.b.UpdateGateway(in) +} + +func (s *apiGatewayServer) DeleteGateway( + _ context.Context, in *apigatewaypb.DeleteGatewayRequest, +) (*longrunningpb.Operation, error) { + return s.b.DeleteGateway(in) +} + +func (s *apiGatewayServer) ListApis( + _ context.Context, in *apigatewaypb.ListApisRequest, +) (*apigatewaypb.ListApisResponse, error) { + return s.b.ListApis(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) GetApi(_ context.Context, in *apigatewaypb.GetApiRequest) (*apigatewaypb.Api, error) { + return s.b.GetAPI(in.GetName()) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) CreateApi( + _ context.Context, in *apigatewaypb.CreateApiRequest, +) (*longrunningpb.Operation, error) { + return s.b.CreateAPI(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) UpdateApi( + _ context.Context, in *apigatewaypb.UpdateApiRequest, +) (*longrunningpb.Operation, error) { + return s.b.UpdateAPI(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) DeleteApi( + _ context.Context, in *apigatewaypb.DeleteApiRequest, +) (*longrunningpb.Operation, error) { + return s.b.DeleteAPI(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) ListApiConfigs( + _ context.Context, in *apigatewaypb.ListApiConfigsRequest, +) (*apigatewaypb.ListApiConfigsResponse, error) { + return s.b.ListAPIConfigs(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) GetApiConfig( + _ context.Context, in *apigatewaypb.GetApiConfigRequest, +) (*apigatewaypb.ApiConfig, error) { + return s.b.GetAPIConfig(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) CreateApiConfig( + _ context.Context, in *apigatewaypb.CreateApiConfigRequest, +) (*longrunningpb.Operation, error) { + return s.b.CreateAPIConfig(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) UpdateApiConfig( + _ context.Context, in *apigatewaypb.UpdateApiConfigRequest, +) (*longrunningpb.Operation, error) { + return s.b.UpdateAPIConfig(in) +} + +//nolint:revive,staticcheck,nolintlint // method name fixed by the generated gRPC interface +func (s *apiGatewayServer) DeleteApiConfig( + _ context.Context, in *apigatewaypb.DeleteApiConfigRequest, +) (*longrunningpb.Operation, error) { + return s.b.DeleteAPIConfig(in) +} + +type iamServer struct { + iampb.UnimplementedIAMPolicyServer + b *InMemoryBackend +} + +func (s *iamServer) GetIamPolicy(_ context.Context, in *iampb.GetIamPolicyRequest) (*iampb.Policy, error) { + return s.b.GetIamPolicy(in.GetResource(), in.GetOptions().GetRequestedPolicyVersion()) +} + +func (s *iamServer) SetIamPolicy(_ context.Context, in *iampb.SetIamPolicyRequest) (*iampb.Policy, error) { + return s.b.SetIamPolicy(in) +} + +func (s *iamServer) TestIamPermissions( + _ context.Context, in *iampb.TestIamPermissionsRequest, +) (*iampb.TestIamPermissionsResponse, error) { + return s.b.TestIamPermissions(in) +} + +type locationsServer struct { + location.UnimplementedLocationsServer + b *InMemoryBackend +} + +func (s locationsServer) ListLocations( + _ context.Context, in *location.ListLocationsRequest, +) (*location.ListLocationsResponse, error) { + return s.b.ListLocations(in) +} + +func (s locationsServer) GetLocation(_ context.Context, in *location.GetLocationRequest) (*location.Location, error) { + return s.b.GetLocation(in.GetName()) +} + +type operationsServer struct { + longrunningpb.UnimplementedOperationsServer + b *InMemoryBackend +} + +func (s *operationsServer) GetOperation( + _ context.Context, in *longrunningpb.GetOperationRequest, +) (*longrunningpb.Operation, error) { + return s.b.GetOperation(in.GetName()) +} + +func (s *operationsServer) WaitOperation( + ctx context.Context, in *longrunningpb.WaitOperationRequest, +) (*longrunningpb.Operation, error) { + return s.b.WaitOperation(ctx, in) +} + +func (s *operationsServer) ListOperations( + _ context.Context, in *longrunningpb.ListOperationsRequest, +) (*longrunningpb.ListOperationsResponse, error) { + return s.b.ListOperations(in) +} + +func (s *operationsServer) DeleteOperation( + _ context.Context, in *longrunningpb.DeleteOperationRequest, +) (*emptypb.Empty, error) { + return s.b.DeleteOperation(in.GetName()) +} + +func (s *operationsServer) CancelOperation( + _ context.Context, in *longrunningpb.CancelOperationRequest, +) (*emptypb.Empty, error) { + return s.b.CancelOperation(in.GetName()) +} diff --git a/services/gcpapigateway/handler.go b/services/gcpapigateway/handler.go new file mode 100644 index 0000000000..c783f6d859 --- /dev/null +++ b/services/gcpapigateway/handler.go @@ -0,0 +1,190 @@ +package gcpapigateway + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/labstack/echo/v5" + "google.golang.org/genproto/googleapis/cloud/location" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const gracefulStopWait = 2 * time.Second + +// Handler serves the API Gateway control plane (gRPC and REST) and the gateway data plane on a dedicated listener. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *grpc.Server + wg *sync.WaitGroup + addr string + Port int +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, + Port: DefaultPort, + srvMu: lockmetrics.New("gcpapigateway.server"), + wg: &sync.WaitGroup{}, + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "GCPAPIGateway" } + +// GetSupportedOperations lists the supported gRPC methods. +func (h *Handler) GetSupportedOperations() []string { + return []string{ + "CreateApi", "GetApi", "ListApis", "UpdateApi", "DeleteApi", + "CreateApiConfig", "GetApiConfig", "ListApiConfigs", "UpdateApiConfig", "DeleteApiConfig", + "CreateGateway", "GetGateway", "ListGateways", "UpdateGateway", "DeleteGateway", + "GetLocation", "ListLocations", "GetIamPolicy", "SetIamPolicy", "TestIamPermissions", + "GetOperation", "ListOperations", "DeleteOperation", "CancelOperation", "WaitOperation", + } +} + +// RouteMatcher never matches: API Gateway runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation is unused: requests never reach the shared router. +func (h *Handler) ExtractOperation(*echo.Context) string { return "Unknown" } + +// ExtractResource is unused: requests never reach the shared router. +func (h *Handler) ExtractResource(*echo.Context) string { return "" } + +// Addr returns the bound listener address, or "" before StartWorker. +func (h *Handler) Addr() string { + h.srvMu.RLock("Addr") + defer h.srvMu.RUnlock() + + return h.addr +} + +// Reset clears all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Handler returns a 404 handler; the real API is on the dedicated port. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + return c.String(http.StatusNotFound, "gcpapigateway serves gRPC, REST and gateways on its dedicated port") + } +} + +type debugLogger interface { + DebugContext(ctx context.Context, msg string, args ...any) +} + +func logUnary(log debugLogger) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { + resp, err := next(ctx, req) + if err != nil && status.Code(err) != codes.OK { + log.DebugContext(ctx, "gcpapigateway: rpc failed", "method", info.FullMethod, "error", err) + } + + return resp, err + } +} + +func (h *Handler) registerServers(srv *grpc.Server) { + apigatewaypb.RegisterApiGatewayServiceServer(srv, &apiGatewayServer{b: h.Backend}) + iampb.RegisterIAMPolicyServer(srv, &iamServer{b: h.Backend}) + location.RegisterLocationsServer(srv, locationsServer{b: h.Backend}) + longrunningpb.RegisterOperationsServer(srv, &operationsServer{b: h.Backend}) +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + var lc net.ListenConfig + + listener, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + if err != nil { + return fmt.Errorf("gcpapigateway: bind port %d: %w", h.Port, err) + } + + workerCtx := logger.WithWorker(ctx, "gcpapigateway", "listener") + log := logger.Load(workerCtx) + + srv := gcpenforce.NewServer(ctx, "apigateway.googleapis.com", grpc.UnaryInterceptor(logUnary(log))) + h.registerServers(srv) + + h.srvMu.Lock("StartWorker") + h.srv, h.addr = srv, listener.Addr().String() + h.srvMu.Unlock() + + log.InfoContext(workerCtx, "gcpapigateway: starting dedicated listener", "port", h.Port) + + gateway := gcprest.Extra{MatchRequest: h.Backend.MatchesDataPlane, Handler: h.Backend.DataPlane()} + + h.wg.Go(func() { + if serveErr := gcprest.ServeWithExtras(srv, listener, nil, gateway); serveErr != nil && + !errors.Is(serveErr, grpc.ErrServerStopped) { + log.ErrorContext(workerCtx, "gcpapigateway: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener, forcing a close after a short grace period. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv := h.srv + h.srv = nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + done := make(chan struct{}) + + go func() { + srv.GracefulStop() + close(done) + }() + + grace := time.NewTimer(gracefulStopWait) + defer grace.Stop() + + select { + case <-done: + case <-grace.C: + srv.Stop() + <-done + case <-ctx.Done(): + srv.Stop() + <-done + } + + h.wg.Wait() + h.Backend.CloseIdleConnections() +} diff --git a/services/gcpapigateway/helpers_test.go b/services/gcpapigateway/helpers_test.go new file mode 100644 index 0000000000..f582c07536 --- /dev/null +++ b/services/gcpapigateway/helpers_test.go @@ -0,0 +1,238 @@ +package gcpapigateway_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + apigateway "cloud.google.com/go/apigateway/apiv1" + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "github.com/stretchr/testify/require" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + "github.com/blackbirdworks/gopherstack/services/gcpapigateway" +) + +const ( + project = "test-project" + region = "us-central1" + apiID = "my-api" + parent = "projects/" + project + "/locations/global" + gwParen = "projects/" + project + "/locations/" + region +) + +type fakeClock struct{ ns atomic.Int64 } + +func newClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } + +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + handler *gcpapigateway.Handler + clock *fakeClock + client *apigateway.Client + conn *grpc.ClientConn + http *http.Client +} + +func newEnv(t *testing.T) *env { + t.Helper() + + clk := newClock() + b := gcpapigateway.NewInMemoryBackend() + b.SetClock(clk.Now) + b.SetTiming(gcpapigateway.Timing{}) + + h := gcpapigateway.NewHandler(b) + h.Port = 0 + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + conn, err := grpc.NewClient(h.Addr(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + client, err := apigateway.NewClient(t.Context(), option.WithGRPCConn(conn)) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + return &env{handler: h, clock: clk, client: client, conn: conn, http: testclient.New(t)} +} + +func (e *env) createAPI(t *testing.T, id string) *apigatewaypb.Api { + t.Helper() + + op, err := e.client.CreateApi(t.Context(), &apigatewaypb.CreateApiRequest{ + Parent: parent, ApiId: id, Api: &apigatewaypb.Api{DisplayName: id}, + }) + require.NoError(t, err) + + api, err := op.Wait(t.Context()) + require.NoError(t, err) + + return api +} + +func configRequest(id, doc string) *apigatewaypb.CreateApiConfigRequest { + return &apigatewaypb.CreateApiConfigRequest{ + Parent: parent + "/apis/" + apiID, ApiConfigId: id, + ApiConfig: &apigatewaypb.ApiConfig{OpenapiDocuments: []*apigatewaypb.ApiConfig_OpenApiDocument{{ + Document: &apigatewaypb.ApiConfig_File{Path: "openapi.yaml", Contents: []byte(doc)}, + }}}, + } +} + +func (e *env) createConfig(t *testing.T, id, doc string) *apigatewaypb.ApiConfig { + t.Helper() + + op, err := e.client.CreateApiConfig(t.Context(), configRequest(id, doc)) + require.NoError(t, err) + + cfg, err := op.Wait(t.Context()) + require.NoError(t, err) + + return cfg +} + +func (e *env) createGateway(t *testing.T, id, cfg string) *apigatewaypb.Gateway { + t.Helper() + + op, err := e.client.CreateGateway(t.Context(), &apigatewaypb.CreateGatewayRequest{ + Parent: gwParen, GatewayId: id, Gateway: &apigatewaypb.Gateway{ApiConfig: cfg}, + }) + require.NoError(t, err) + + gw, err := op.Wait(t.Context()) + require.NoError(t, err) + + return gw +} + +// deploy creates an API, one config from doc and a gateway serving it. +func (e *env) deploy(t *testing.T, doc string) *apigatewaypb.Gateway { + t.Helper() + + e.createAPI(t, apiID) + + cfg := e.createConfig(t, "cfg-1", doc) + + return e.createGateway(t, "my-gw", cfg.GetName()) +} + +type reply struct { + body map[string]any + raw string + status int +} + +// call sends a request to the gateway by default hostname. +func (e *env) call(t *testing.T, gw *apigatewaypb.Gateway, method, target string, hdr map[string]string) reply { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, "http://"+e.handler.Addr()+target, nil) + require.NoError(t, err) + + req.Host = gw.GetDefaultHostname() + + for k, v := range hdr { + req.Header.Set(k, v) + } + + return e.do(t, req) +} + +func (e *env) do(t *testing.T, req *http.Request) reply { + t.Helper() + + resp, err := e.http.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + r := reply{status: resp.StatusCode, raw: string(raw)} + _ = json.Unmarshal(raw, &r.body) + + return r +} + +// echoBackend answers every request with a JSON description of what it received. +func echoBackend(t *testing.T) *httptest.Server { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hdr := map[string]string{} + for _, k := range []string{"Authorization", "X-Forwarded-Authorization", "X-Apigateway-Api-Userinfo"} { + hdr[k] = r.Header.Get(k) + } + + body, _ := io.ReadAll(r.Body) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Backend", "echo") + _ = json.NewEncoder(w).Encode(map[string]any{ + "method": r.Method, "path": r.URL.Path, "query": r.URL.RawQuery, "headers": hdr, "body": string(body), + }) + })) + t.Cleanup(srv.Close) + + return srv +} + +const specHeader = "swagger: \"2.0\"\ninfo:\n title: test\n version: \"1.0\"\n" + +// opSpec renders a Swagger document with the given root backend address and extra top-level and path YAML. +func opSpec(root, top, paths string) string { + var sb strings.Builder + + sb.WriteString(specHeader) + + if root != "" { + sb.WriteString("x-google-backend:\n address: " + root + "\n") + } + + sb.WriteString(top) + sb.WriteString("paths:\n") + sb.WriteString(paths) + + return sb.String() +} + +const okResponses = " responses:\n \"200\":\n description: ok\n" + +func deleteGatewayRequest(name string) *apigatewaypb.DeleteGatewayRequest { + return &apigatewaypb.DeleteGatewayRequest{Name: name} +} + +func (e *env) managedService(t *testing.T) string { + t.Helper() + + api, err := e.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: parent + "/apis/" + apiID}) + require.NoError(t, err) + + return api.GetManagedService() +} + +func gatewayRequest(id, parentName, cfg string) *apigatewaypb.CreateGatewayRequest { + return &apigatewaypb.CreateGatewayRequest{ + Parent: parentName, GatewayId: id, Gateway: &apigatewaypb.Gateway{ApiConfig: cfg}, + } +} diff --git a/services/gcpapigateway/iam.go b/services/gcpapigateway/iam.go new file mode 100644 index 0000000000..b066755756 --- /dev/null +++ b/services/gcpapigateway/iam.go @@ -0,0 +1,115 @@ +package gcpapigateway + +import ( + "encoding/binary" + "strings" + + "cloud.google.com/go/iam/apiv1/iampb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpiampolicy" +) + +const ( + policyEtagLen = 8 + permPrefix = "apigateway." +) + +func (b *InMemoryBackend) nextEtag() []byte { + b.seq++ + raw := make([]byte, policyEtagLen) + binary.BigEndian.PutUint64(raw, b.seq) + + return raw +} + +// policySlot returns the policy pointer of an API, API config or gateway. +func (b *InMemoryBackend) policySlot(resource string) (**iampb.Policy, error) { + switch { + case strings.Contains(resource, "/"+kindConfigs+"/"): + if _, err := parseConfigName(resource); err != nil { + return nil, err + } + + rec, ok := b.liveConfigLocked(resource) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", resource) + } + + return &rec.policy, nil + case strings.Contains(resource, "/"+kindGateways+"/"): + if _, _, err := parseGatewayName(resource); err != nil { + return nil, err + } + + rec, ok := b.liveGatewayLocked(resource) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", resource) + } + + return &rec.policy, nil + case strings.Contains(resource, "/"+kindApis+"/"): + if _, _, err := parseAPIName(resource); err != nil { + return nil, err + } + + rec, ok := b.liveAPILocked(resource) + if !ok { + return nil, errNotFound("Resource '%s' was not found.", resource) + } + + return &rec.policy, nil + default: + return nil, errInvalid("IAM policies are available for APIs, API configs and gateways, not %q.", resource) + } +} + +// GetIamPolicy returns the policy of a resource at the requested version. +func (b *InMemoryBackend) GetIamPolicy(resource string, version int32) (*iampb.Policy, error) { + b.mu.RLock("GetIamPolicy") + defer b.mu.RUnlock() + + slot, err := b.policySlot(resource) + if err != nil { + return nil, err + } + + return gcpiampolicy.View(*slot, version), nil +} + +// SetIamPolicy replaces a policy; a stale etag aborts the call. +func (b *InMemoryBackend) SetIamPolicy(in *iampb.SetIamPolicyRequest) (*iampb.Policy, error) { + if err := gcpiampolicy.Validate(in.GetPolicy()); err != nil { + return nil, err + } + + b.mu.Lock("SetIamPolicy") + defer b.mu.Unlock() + + slot, err := b.policySlot(in.GetResource()) + if err != nil { + return nil, err + } + + next, err := gcpiampolicy.Apply(*slot, in.GetPolicy(), b.nextEtag) + if err != nil { + return nil, err + } + + *slot = next + + return gcpiampolicy.SetView(next), nil +} + +// TestIamPermissions reports the requested API Gateway permissions as granted: the emulator authorises all callers. +func (b *InMemoryBackend) TestIamPermissions( + in *iampb.TestIamPermissionsRequest, +) (*iampb.TestIamPermissionsResponse, error) { + b.mu.RLock("TestIamPermissions") + defer b.mu.RUnlock() + + if _, err := b.policySlot(in.GetResource()); err != nil { + return nil, err + } + + return &iampb.TestIamPermissionsResponse{Permissions: gcpiampolicy.Granted(in.GetPermissions(), permPrefix)}, nil +} diff --git a/services/gcpapigateway/inproc.go b/services/gcpapigateway/inproc.go new file mode 100644 index 0000000000..53e22ca642 --- /dev/null +++ b/services/gcpapigateway/inproc.go @@ -0,0 +1,56 @@ +package gcpapigateway + +import ( + "slices" + "strings" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" +) + +// API returns the control-plane server for in-process callers such as the dashboard proxy. +func (h *Handler) API() apigatewaypb.ApiGatewayServiceServer { return &apiGatewayServer{b: h.Backend} } + +// OperationsAPI returns the long-running operations server for in-process callers. +func (h *Handler) OperationsAPI() longrunningpb.OperationsServer { + return &operationsServer{b: h.Backend} +} + +// ProjectIDs lists the projects that own at least one API, config, gateway or operation, sorted. +func (b *InMemoryBackend) ProjectIDs() []string { + b.mu.RLock("ProjectIDs") + defer b.mu.RUnlock() + + seen := map[string]bool{} + add := func(name string) { + if rest, ok := strings.CutPrefix(name, "projects/"); ok { + id, _, _ := strings.Cut(rest, "/") + seen[id] = true + } + } + + for n := range b.apis { + add(n) + } + + for n := range b.configs { + add(n) + } + + for n := range b.gateways { + add(n) + } + + for n := range b.ops { + add(n) + } + + ids := make([]string, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + + slices.Sort(ids) + + return ids +} diff --git a/services/gcpapigateway/leak_main_test.go b/services/gcpapigateway/leak_main_test.go new file mode 100644 index 0000000000..c5f073f839 --- /dev/null +++ b/services/gcpapigateway/leak_main_test.go @@ -0,0 +1,11 @@ +package gcpapigateway_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + testleak.VerifyTestMain(m) +} diff --git a/services/gcpapigateway/list.go b/services/gcpapigateway/list.go new file mode 100644 index 0000000000..0778b46f46 --- /dev/null +++ b/services/gcpapigateway/list.go @@ -0,0 +1,80 @@ +package gcpapigateway + +import ( + "cmp" + "slices" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +type listArgs struct { + filter string + orderBy string + pageToken string + pageSize int32 +} + +type listKeys[T any] struct { + name func(T) string + created func(T) time.Time + updated func(T) time.Time +} + +func paginate[T any](items []T, a listArgs, keys listKeys[T]) (page.Page[T], error) { + if a.filter != "" { + return page.Page[T]{}, errInvalid("The filter parameter is not supported.") + } + + if a.pageSize < 0 { + return page.Page[T]{}, errInvalid("page_size must not be negative.") + } + + field, desc := "name", false + + if f := strings.Fields(a.orderBy); len(f) > 0 { + field = f[0] + + switch { + case len(f) == 1: + case len(f) == 2 && strings.EqualFold(f[1], "desc"): + desc = true + case len(f) == 2 && strings.EqualFold(f[1], "asc"): + default: + return page.Page[T]{}, errInvalid("Invalid order_by %q.", a.orderBy) + } + } + + var less func(a, b T) int + + switch field { + case "name": + less = func(x, y T) int { return cmp.Compare(keys.name(x), keys.name(y)) } + case "create_time": + less = func(x, y T) int { return keys.created(x).Compare(keys.created(y)) } + case "update_time": + less = func(x, y T) int { return keys.updated(x).Compare(keys.updated(y)) } + default: + return page.Page[T]{}, errInvalid("Cannot order by %q: use name, create_time or update_time.", field) + } + + slices.SortStableFunc(items, func(x, y T) int { + c := less(x, y) + if desc { + return -c + } + + return c + }) + + return page.New(items, a.pageToken, int(a.pageSize), defaultListPage), nil +} + +func maskPaths(paths []string, defaults ...string) []string { + if len(paths) == 0 { + return defaults + } + + return paths +} diff --git a/services/gcpapigateway/locations.go b/services/gcpapigateway/locations.go new file mode 100644 index 0000000000..d7def1a09c --- /dev/null +++ b/services/gcpapigateway/locations.go @@ -0,0 +1,60 @@ +package gcpapigateway + +import ( + "cmp" + "slices" + + "google.golang.org/genproto/googleapis/cloud/location" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +func locationIDs() []string { return append([]string{globalLocation}, gatewayRegions...) } + +func buildLocation(project, id string) *location.Location { + return &location.Location{ + Name: locationName(project, id), LocationId: id, DisplayName: id, + Labels: map[string]string{"cloud.googleapis.com/region": id}, + } +} + +// GetLocation returns the global location or a gateway region. +func (b *InMemoryBackend) GetLocation(name string) (*location.Location, error) { + project, loc, err := parseParent(name) + if err != nil { + return nil, err + } + + if !slices.Contains(locationIDs(), loc) { + return nil, errNotFound("Location %q not found.", name) + } + + return buildLocation(project, loc), nil +} + +// ListLocations lists the global location and the gateway regions. +func (b *InMemoryBackend) ListLocations(in *location.ListLocationsRequest) (*location.ListLocationsResponse, error) { + ids, ok := parseRes(in.GetName(), "projects") + if !ok { + return nil, errInvalid("Invalid name %q: want projects/{project}.", in.GetName()) + } + + if in.GetFilter() != "" { + return nil, errInvalid("filter is not supported for locations.") + } + + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + all := make([]*location.Location, 0, len(locationIDs())) + for _, l := range locationIDs() { + all = append(all, buildLocation(ids[0], l)) + } + + slices.SortFunc(all, func(a, c *location.Location) int { return cmp.Compare(a.GetName(), c.GetName()) }) + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &location.ListLocationsResponse{Locations: pg.Data, NextPageToken: pg.Next}, nil +} diff --git a/services/gcpapigateway/names.go b/services/gcpapigateway/names.go new file mode 100644 index 0000000000..9de4f20f73 --- /dev/null +++ b/services/gcpapigateway/names.go @@ -0,0 +1,146 @@ +package gcpapigateway + +import ( + "regexp" + "slices" + "strings" +) + +const ( + kindApis = "apis" + kindConfigs = "configs" + kindGateways = "gateways" + kindOperations = "operations" + + globalLocation = "global" + maxLabels = 64 + maskDisplay = "display_name" + maskDisplayCamel = "displayName" + maskLabels = "labels" + maskAPIConfig = "api_config" + maskAll = "*" +) + +var ( + idRe = regexp.MustCompile(`^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$`) + labelKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,62}$`) + labelValRe = regexp.MustCompile(`^[a-z0-9_-]{0,63}$`) + saEmailRe = regexp.MustCompile(`^[^@/\s]+@[^@/\s]+\.iam\.gserviceaccount\.com$`) +) + +// gatewayRegions are the regions a gateway can be created in. +// +//nolint:gochecknoglobals // immutable lookup table +var gatewayRegions = []string{ + "asia-northeast1", "australia-southeast1", "europe-west1", "europe-west2", "us-central1", "us-east1", + "us-east4", "us-west2", "us-west3", "us-west4", +} + +func parseRes(name string, kinds ...string) ([]string, bool) { + p := strings.Split(name, "/") + if len(p) != 2*len(kinds) { + return nil, false + } + + ids := make([]string, len(kinds)) + + for i, k := range kinds { + if p[2*i] != k || p[2*i+1] == "" { + return nil, false + } + + ids[i] = p[2*i+1] + } + + return ids, true +} + +func parseParent(name string) (string, string, error) { + ids, ok := parseRes(name, "projects", "locations") + if !ok { + return "", "", errInvalid("Invalid parent %q: want projects/{project}/locations/{location}.", name) + } + + return ids[0], ids[1], nil +} + +// parseGatewayName returns the project and location of "projects/{p}/locations/{l}/gateways/{id}". +func parseGatewayName(name string) (string, string, error) { + ids, ok := parseRes(name, "projects", "locations", kindGateways) + if !ok { + return "", "", errInvalid( + "Invalid name %q: want projects/{project}/locations/{location}/gateways/{id}.", name) + } + + return ids[0], ids[1], nil +} + +// parseConfigName returns the project of an API config name. +func parseConfigName(name string) (string, error) { + ids, ok := parseRes(name, "projects", "locations", kindApis, kindConfigs) + if !ok || ids[1] != globalLocation { + return "", errInvalid( + "Invalid name %q: want projects/{project}/locations/global/apis/{api}/configs/{config}.", name) + } + + return ids[0], nil +} + +func parseAPIName(name string) (string, string, error) { + ids, ok := parseRes(name, "projects", "locations", kindApis) + if !ok || ids[1] != globalLocation { + return "", "", errInvalid("Invalid name %q: want projects/{project}/locations/global/apis/{api}.", name) + } + + return ids[0], ids[2], nil +} + +func locationName(project, location string) string { + return "projects/" + project + "/locations/" + location +} + +func childName(project, location, kind, id string) string { + return locationName(project, location) + "/" + kind + "/" + id +} + +func apiName(project, api string) string { return childName(project, globalLocation, kindApis, api) } + +func configName(project, api, config string) string { + return apiName(project, api) + "/" + kindConfigs + "/" + config +} + +func checkID(field, id string) error { + if id == "" { + return errInvalid("%s is required.", field) + } + + if !idRe.MatchString(id) { + return errInvalid("Invalid %s %q: must match %s.", field, id, idRe.String()) + } + + return nil +} + +func validateLabels(labels map[string]string) error { + if len(labels) > maxLabels { + return errInvalid("At most %d labels are allowed.", maxLabels) + } + + for k, v := range labels { + if !labelKeyRe.MatchString(k) { + return errInvalid("Invalid label key %q.", k) + } + + if !labelValRe.MatchString(v) { + return errInvalid("Invalid value %q for label %q.", v, k) + } + } + + return nil +} + +func knownRegion(r string) bool { return slices.Contains(gatewayRegions, r) } + +func validServiceAccount(sa string) bool { + return saEmailRe.MatchString(sa) || strings.HasPrefix(sa, "projects/") && strings.Count(sa, "/") >= 3 +} diff --git a/services/gcpapigateway/openapi3_test.go b/services/gcpapigateway/openapi3_test.go new file mode 100644 index 0000000000..cd35c78132 --- /dev/null +++ b/services/gcpapigateway/openapi3_test.go @@ -0,0 +1,275 @@ +package gcpapigateway_test + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" +) + +const spec3Header = "openapi: 3.0.4\ninfo:\n title: t\n version: \"1\"\n" + +const quota3 = " metrics:\n reads:\n displayName: Reads\n quota:\n limits:\n reads-limit:\n" + + " metric: reads\n values: 2\n" + +func mgmt3(address, extra string) string { + return "x-google-api-management:\n backends:\n be:\n address: " + address + "\n" + extra +} + +func spec3(mgmt, top, paths string) string { + return spec3Header + mgmt + top + "paths:\n" + paths +} + +func TestOpenAPI3Grammar(t *testing.T) { + t.Parallel() + + const be = "https://b.example" + + hello := " /hello:\n" + op("get", "") + named := " /hello:\n" + op("get", " x-google-backend: be\n") + + tests := []struct { + name string + doc string + want string + }{ + {name: "top level backend", doc: spec3(mgmt3(be, ""), "x-google-backend: be\n", hello)}, + {name: "operation backend only", doc: spec3(mgmt3(be, ""), "", named)}, + { + name: "metrics and quota", + doc: spec3(mgmt3(be, quota3), "x-google-backend: be\nx-google-quota:\n reads: 1\n", hello), + }, + { + name: "no management block", + doc: spec3("", "x-google-backend: be\n", hello), + want: "x-google-api-management is required", + }, + { + name: "no backends", + doc: spec3("x-google-api-management:\n metrics: {}\n", "x-google-backend: be\n", hello), + want: "x-google-api-management.backends is required", + }, + { + name: "no effective backend", + doc: spec3(mgmt3(be, ""), "", hello), + want: "Operation has no x-google-backend", + }, + { + name: "undefined backend name", + doc: spec3(mgmt3(be, ""), "x-google-backend: nope\n", hello), + want: `Backend "nope" is not defined`, + }, + { + name: "backend object is not the 3.x form", + doc: spec3(mgmt3(be, ""), "x-google-backend:\n address: https://b.example\n", hello), + want: "must name a backend", + }, + { + name: "quota refers to undefined metric", + doc: spec3( + mgmt3(be, ""), + "x-google-quota:\n ghosts: 1\n", + " /hello:\n"+op("get", " x-google-backend: be\n"), + ), + want: "undefined metric", + }, + { + name: "quota limit value", + doc: spec3(mgmt3(be, strings.Replace(quota3, "values: 2", "values: -1", 1)), + "x-google-backend: be\n", hello), + want: "non-negative integer", + }, + { + name: "issuer scheme without x-google-auth", + doc: spec3( + mgmt3(be, ""), + "x-google-backend: be\ncomponents:\n securitySchemes:\n j:\n type: oauth2\n"+ + " x-google-issuer: iss\nsecurity:\n - j: []\n", + hello, + ), + want: "unsupported", + }, + { + name: "jwt location needs one of header and query", + doc: spec3( + mgmt3(be, ""), + "x-google-backend: be\ncomponents:\n securitySchemes:\n j:\n type: oauth2\n"+ + " x-google-auth:\n issuer: iss\n jwksUri: https://k.example/jwks\n"+ + " jwtLocations:\n - valuePrefix: p\n", + hello, + ), + want: "Exactly one of header and query", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, apiID) + + cfgOp, err := e.client.CreateApiConfig(t.Context(), configRequest("c", tt.doc)) + require.NoError(t, err) + + cfg, err := cfgOp.Wait(t.Context()) + if tt.want == "" { + require.NoError(t, err) + assert.Equal(t, apigatewaypb.ApiConfig_ACTIVE, cfg.GetState()) + + return + } + + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +func TestOpenAPI3DataPlane(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + hello := " /hello/{name}:\n" + op("get", "") + + e := newEnv(t) + gw := e.deploy(t, spec3( + mgmt3(be.URL+"/fn", " pathTranslation: CONSTANT_ADDRESS\n"), + "x-google-backend: be\n", hello)) + + got := e.call(t, gw, http.MethodGet, "/hello/world", nil) + require.Equal(t, http.StatusOK, got.status, got.raw) + assert.Equal(t, "/fn", got.body["path"], "pathTranslation is the 3.x spelling") + assert.Equal(t, "name=world", got.body["query"]) +} + +func TestOpenAPI3JWTLocations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hdr map[string]string + path string + want int + }{ + {name: "custom header with prefix", hdr: map[string]string{"X-Token": "tk "}, path: "/hello", want: 200}, + {name: "custom query", path: "/hello?jwt=", want: 200}, + { + name: "default bearer is replaced", + hdr: map[string]string{"Authorization": "Bearer "}, + path: "/hello", + want: 401, + }, + {name: "wrong prefix", hdr: map[string]string{"X-Token": "other "}, path: "/hello", want: 401}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newSigner(t) + be := echoBackend(t) + e := newEnv(t) + now := e.clock.Now() + + doc := spec3(mgmt3(be.URL, ""), "x-google-backend: be\ncomponents:\n securitySchemes:\n j:\n"+ + " type: oauth2\n x-google-auth:\n issuer: "+issuer+"\n jwksUri: "+s.jwks.URL+"\n"+ + " audiences:\n - aud-1\n jwtLocations:\n - header: X-Token\n"+ + " valuePrefix: \"tk \"\n - query: jwt\nsecurity:\n - j: []\n", " /hello:\n"+op("get", "")) + gw := e.deploy(t, doc) + + tok := s.rsaToken(t, jwt.MapClaims{"iss": issuer, "aud": "aud-1", "exp": now.Add(time.Hour).Unix()}) + + hdr := map[string]string{} + for k, v := range tt.hdr { + hdr[k] = v + tok + } + + got := e.call(t, gw, http.MethodGet, tt.path+tokenIf(tt.path, tok), hdr) + assert.Equal(t, tt.want, got.status, got.raw) + + if tt.want == http.StatusUnauthorized { + assert.EqualValues(t, "Jwt is missing", got.body["message"]) + } + }) + } +} + +func tokenIf(path, tok string) string { + if strings.HasSuffix(path, "=") { + return tok + } + + return "" +} + +func TestOpenAPI3Quota(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, spec3(mgmt3(be.URL, quota3), "x-google-backend: be\nx-google-quota:\n reads: 1\n", + " /top:\n"+op("get", "")+" /dear:\n"+op("get", " x-google-quota:\n reads: 2\n"))) + + codes := func(target string, n int) []int { + out := make([]int, 0, n) + for range n { + out = append(out, e.call(t, gw, http.MethodGet, target, nil).status) + } + + return out + } + + assert.Equal(t, []int{200, 200, 429}, codes("/top", 3), "the top-level cost of 1 against a limit of 2") + + e.clock.Advance(time.Minute) + assert.Equal(t, []int{200, 429}, codes("/dear", 2), "an operation cost replaces the top-level one") +} + +const quota2 = "x-google-management:\n metrics:\n - name: reads\n displayName: Reads\n" + + " valueType: INT64\n" + + " metricKind: DELTA\n quota:\n limits:\n - name: reads-limit\n metric: reads\n" + + " unit: \"1/min/{project}\"\n values:\n STANDARD: %d\n" + +func TestQuotaFollowsLatestConfig(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + paths := " /read:\n" + op("get", " x-google-quota:\n metricCosts:\n reads: 1\n") + e := newEnv(t) + e.createAPI(t, apiID) + + first := e.createConfig(t, "cfg-1", opSpec(be.URL, strings.Replace(quota2, "%d", "1", 1), paths)) + gw := e.createGateway(t, "my-gw", first.GetName()) + + statuses := func(n int) []int { + out := make([]int, 0, n) + for range n { + out = append(out, e.call(t, gw, http.MethodGet, "/read", nil).status) + } + + return out + } + + assert.Equal(t, []int{200, 429}, statuses(2), "the gateway's own config limits while it is the latest") + + e.clock.Advance(time.Minute) + e.createConfig(t, "cfg-2", opSpec(be.URL, strings.Replace(quota2, "%d", "3", 1), paths)) + assert.Equal(t, []int{200, 200, 200, 429}, statuses(4), "the newest config's limit governs the whole API") + + e.clock.Advance(time.Minute) + e.createConfig(t, "cfg-3", opSpec(be.URL, "", " /other:\n"+op("get", ""))) + + got := e.call(t, gw, http.MethodGet, "/read", nil) + assert.Equal(t, http.StatusInternalServerError, got.status, got.raw) + assert.EqualValues(t, "Failed to call Service Control Quota.", got.body["message"]) +} diff --git a/services/gcpapigateway/ops.go b/services/gcpapigateway/ops.go new file mode 100644 index 0000000000..f650edb24b --- /dev/null +++ b/services/gcpapigateway/ops.go @@ -0,0 +1,316 @@ +package gcpapigateway + +import ( + "context" + "slices" + "strings" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + apiVersion = "v1" + defaultListPage = 100 + verbCreate = "create" + verbUpdate = "update" + verbDelete = "delete" +) + +type diagnostic struct { + Location string `json:"location"` + Message string `json:"message"` +} + +type opRec struct { + Created time.Time `json:"created"` + EndAt time.Time `json:"endAt"` + CancelledAt time.Time `json:"cancelledAt"` + Name string `json:"name"` + Target string `json:"target"` + Verb string `json:"verb"` + FailMsg string `json:"failMsg,omitempty"` + Resp []byte `json:"resp,omitempty"` + Diags []diagnostic `json:"diagnostics,omitempty"` + FailCode int32 `json:"failCode,omitempty"` + CancelRequested bool `json:"cancelRequested,omitempty"` +} + +func (o *opRec) cancelled() bool { return !o.CancelledAt.IsZero() } + +func (o *opRec) done(now time.Time) bool { return o.cancelled() || !now.Before(o.EndAt) } + +type opSpec struct { + resp proto.Message + failMsg string + target string + verb string + project string + location string + diags []diagnostic + dur time.Duration + failCode codes.Code +} + +// newOpLocked records an operation that finishes dur from now with resp, or with a failure when failMsg is set. +func (b *InMemoryBackend) newOpLocked(s opSpec) (*opRec, error) { + now := b.now() + o := &opRec{ + Name: childName(s.project, s.location, kindOperations, "operation-"+uuid.NewString()), + Target: s.target, + Verb: s.verb, + Created: now, + EndAt: now.Add(s.dur), + Diags: s.diags, + } + + if s.failMsg != "" { + o.FailMsg, o.FailCode = s.failMsg, int32(s.failCode) //nolint:gosec // codes are small + } else if s.resp != nil { + a, err := anypb.New(s.resp) + if err != nil { + return nil, status.Errorf(codes.Internal, "record operation response: %v", err) + } + + raw, err := proto.Marshal(a) + if err != nil { + return nil, status.Errorf(codes.Internal, "record operation response: %v", err) + } + + o.Resp = raw + } + + b.ops[o.Name] = o + + return o, nil +} + +func (b *InMemoryBackend) opViewLocked(o *opRec) *longrunningpb.Operation { + now := b.now() + done := o.done(now) + + meta := &apigatewaypb.OperationMetadata{ + CreateTime: timestamppb.New(o.Created), Target: o.Target, Verb: o.Verb, ApiVersion: apiVersion, + RequestedCancellation: o.CancelRequested, + } + + for _, d := range o.Diags { + meta.Diagnostics = append(meta.Diagnostics, &apigatewaypb.OperationMetadata_Diagnostic{ + Location: d.Location, Message: d.Message, + }) + } + + op := &longrunningpb.Operation{Name: o.Name} + + if done { + end := o.EndAt + if o.cancelled() { + end = o.CancelledAt + } + + meta.EndTime = timestamppb.New(end) + op.Done = true + + switch { + case o.cancelled(): + meta.StatusMessage = "Operation cancelled." + op.Result = &longrunningpb.Operation_Error{ + Error: status.New(codes.Canceled, "Operation cancelled.").Proto(), + } + case o.FailMsg != "": + meta.StatusMessage = o.FailMsg + op.Result = &longrunningpb.Operation_Error{ + Error: status.New(codes.Code(o.FailCode), o.FailMsg).Proto(), //nolint:gosec // stored from a codes.Code + } + default: + resp := &anypb.Any{} + if len(o.Resp) > 0 && proto.Unmarshal(o.Resp, resp) == nil { + op.Result = &longrunningpb.Operation_Response{Response: resp} + } + } + } + + if a, err := anypb.New(meta); err == nil { + op.Metadata = a + } + + return op +} + +// GetOperation returns one operation. +func (b *InMemoryBackend) GetOperation(name string) (*longrunningpb.Operation, error) { + b.mu.RLock("GetOperation") + defer b.mu.RUnlock() + + o, ok := b.ops[name] + if !ok { + return nil, errNotFound("Operation %q not found.", name) + } + + return b.opViewLocked(o), nil +} + +// WaitOperation returns once the operation is done or the timeout passes, whichever comes first. +func (b *InMemoryBackend) WaitOperation(ctx context.Context, in *longrunningpb.WaitOperationRequest) ( + *longrunningpb.Operation, error, +) { + b.mu.RLock("WaitOperation") + + o, ok := b.ops[in.GetName()] + if !ok { + b.mu.RUnlock() + + return nil, errNotFound("Operation %q not found.", in.GetName()) + } + + wait := o.EndAt.Sub(b.now()) + if o.done(b.now()) { + wait = 0 + } + + b.mu.RUnlock() + + if in.GetTimeout() != nil { + wait = min(wait, in.GetTimeout().AsDuration()) + } + + if wait > 0 { + t := time.NewTimer(wait) + defer t.Stop() + + select { + case <-t.C: + case <-ctx.Done(): + } + } + + return b.GetOperation(in.GetName()) +} + +// ListOperations lists the operations of one location, ordered by name. +func (b *InMemoryBackend) ListOperations(in *longrunningpb.ListOperationsRequest) ( + *longrunningpb.ListOperationsResponse, error, +) { + if in.GetFilter() != "" { + return nil, errInvalid("ListOperations filter is not supported.") + } + + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + if _, _, err := parseParent(strings.TrimSuffix(in.GetName(), "/")); err != nil { + return nil, err + } + + prefix := strings.TrimSuffix(in.GetName(), "/") + "/" + kindOperations + "/" + + b.mu.RLock("ListOperations") + + names := make([]string, 0, len(b.ops)) + + for n := range b.ops { + if strings.HasPrefix(n, prefix) { + names = append(names, n) + } + } + + slices.Sort(names) + + all := make([]*longrunningpb.Operation, 0, len(names)) + for _, n := range names { + all = append(all, b.opViewLocked(b.ops[n])) + } + + b.mu.RUnlock() + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &longrunningpb.ListOperationsResponse{Operations: pg.Data, NextPageToken: pg.Next}, nil +} + +// DeleteOperation removes a finished operation. +func (b *InMemoryBackend) DeleteOperation(name string) (*emptypb.Empty, error) { + b.mu.Lock("DeleteOperation") + defer b.mu.Unlock() + + o, ok := b.ops[name] + if !ok { + return nil, errNotFound("Operation %q not found.", name) + } + + if !o.done(b.now()) { + return nil, errPrecondition("Operation %q is still running.", name) + } + + delete(b.ops, name) + + return &emptypb.Empty{}, nil +} + +// CancelOperation cancels a running operation: a create is rolled back, a delete is undone and an update keeps +// its change. Cancelling a finished operation has no effect. +func (b *InMemoryBackend) CancelOperation(name string) (*emptypb.Empty, error) { + b.mu.Lock("CancelOperation") + defer b.mu.Unlock() + + o, ok := b.ops[name] + if !ok { + return nil, errNotFound("Operation %q not found.", name) + } + + now := b.now() + if o.done(now) { + return &emptypb.Empty{}, nil + } + + o.CancelRequested, o.CancelledAt = true, now + b.rollbackLocked(o, now) + + return &emptypb.Empty{}, nil +} + +func (b *InMemoryBackend) rollbackLocked(o *opRec, now time.Time) { + l := b.lifeOf(o.Target) + if l == nil { + return + } + + switch o.Verb { + case verbCreate: + l.DeleteAt = now + case verbDelete: + l.DeleteAt, l.DeleteOp = time.Time{}, "" + case verbUpdate: + l.Until = now + } +} + +// lifeOf returns the lifecycle of the resource a target names. +func (b *InMemoryBackend) lifeOf(target string) *life { + if r, ok := b.apis[target]; ok { + return &r.life + } + + if r, ok := b.configs[target]; ok { + return &r.life + } + + if r, ok := b.gateways[target]; ok { + return &r.life + } + + return nil +} + +func emptyResp() proto.Message { return &emptypb.Empty{} } diff --git a/services/gcpapigateway/persistence.go b/services/gcpapigateway/persistence.go new file mode 100644 index 0000000000..10278b5993 --- /dev/null +++ b/services/gcpapigateway/persistence.go @@ -0,0 +1,201 @@ +package gcpapigateway + +import ( + "context" + "encoding/json" + "fmt" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/iam/apiv1/iampb" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the snapshot types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type recSnap struct { + Resource json.RawMessage `json:"resource"` + Policy json.RawMessage `json:"policy,omitempty"` + Life life `json:"life"` +} + +type backendSnapshot struct { + Apis map[string]recSnap `json:"apis"` + Configs map[string]recSnap `json:"configs"` + Gateways map[string]recSnap `json:"gateways"` + Ops map[string]*opRec `json:"operations"` + Version int `json:"version"` + Seq uint64 `json:"seq"` +} + +func marshalPB(m proto.Message) json.RawMessage { + if m == nil || !m.ProtoReflect().IsValid() { + return nil + } + + raw, err := protojson.Marshal(m) + if err != nil { + return nil + } + + return raw +} + +func unmarshalPB(raw []byte, m proto.Message) error { + return protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(raw, m) +} + +func restorePolicy(raw json.RawMessage) (*iampb.Policy, error) { + if len(raw) == 0 { + return nil, nil //nolint:nilnil // no policy stored + } + + p := &iampb.Policy{} + if err := unmarshalPB(raw, p); err != nil { + return nil, err + } + + return p, nil +} + +// Snapshot serialises APIs, API configs, gateways, policies and operations. Quota counters and cached signing +// keys are not kept. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + snap := backendSnapshot{ + Version: snapshotVersion, Seq: b.seq, Ops: b.ops, + Apis: make(map[string]recSnap, len(b.apis)), + Configs: make(map[string]recSnap, len(b.configs)), + Gateways: make(map[string]recSnap, len(b.gateways)), + } + + for n, r := range b.apis { + snap.Apis[n] = recSnap{Resource: marshalPB(r.pb), Policy: marshalPB(r.policy), Life: r.life} + } + + for n, r := range b.configs { + snap.Configs[n] = recSnap{Resource: marshalPB(r.pb), Policy: marshalPB(r.policy), Life: r.life} + } + + for n, r := range b.gateways { + snap.Gateways[n] = recSnap{Resource: marshalPB(r.pb), Policy: marshalPB(r.policy), Life: r.life} + } + + return persistence.MarshalSnapshot(ctx, "gcpapigateway", snap) +} + +type restored struct { + apis map[string]*apiRec + configs map[string]*configRec + gateways map[string]*gatewayRec +} + +func restoreAll(snap *backendSnapshot) (*restored, error) { + out := &restored{ + apis: make(map[string]*apiRec, len(snap.Apis)), + configs: make(map[string]*configRec, len(snap.Configs)), + gateways: make(map[string]*gatewayRec, len(snap.Gateways)), + } + + for n, s := range snap.Apis { + pb := &apigatewaypb.Api{} + if err := unmarshalPB(s.Resource, pb); err != nil { + return nil, fmt.Errorf("api %q: %w", n, err) + } + + pol, err := restorePolicy(s.Policy) + if err != nil { + return nil, fmt.Errorf("api %q policy: %w", n, err) + } + + out.apis[n] = &apiRec{pb: pb, policy: pol, life: s.Life} + } + + for n, s := range snap.Configs { + pb := &apigatewaypb.ApiConfig{} + if err := unmarshalPB(s.Resource, pb); err != nil { + return nil, fmt.Errorf("api config %q: %w", n, err) + } + + pol, err := restorePolicy(s.Policy) + if err != nil { + return nil, fmt.Errorf("api config %q policy: %w", n, err) + } + + rec := &configRec{pb: pb, policy: pol, life: s.Life} + if pb.GetState() != apigatewaypb.ApiConfig_FAILED && len(pb.GetOpenapiDocuments()) > 0 { + rec.spec, _ = compileSpec(pb.GetOpenapiDocuments()[0].GetDocument().GetContents()) + } + + out.configs[n] = rec + } + + for n, s := range snap.Gateways { + pb := &apigatewaypb.Gateway{} + if err := unmarshalPB(s.Resource, pb); err != nil { + return nil, fmt.Errorf("gateway %q: %w", n, err) + } + + pol, err := restorePolicy(s.Policy) + if err != nil { + return nil, fmt.Errorf("gateway %q policy: %w", n, err) + } + + out.gateways[n] = &gatewayRec{pb: pb, policy: pol, life: s.Life} + } + + return out, nil +} + +// Restore loads a snapshot, replacing all state. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "gcpapigateway", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "gcpapigateway: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + r, err := restoreAll(&snap) + if err != nil { + return fmt.Errorf("gcpapigateway: restore: %w", err) + } + + if snap.Ops == nil { + snap.Ops = map[string]*opRec{} + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.apis, b.configs, b.gateways, b.ops, b.seq = r.apis, r.configs, r.gateways, snap.Ops, snap.Seq + b.quota = map[string]*quotaCounter{} + b.jwks.reset() + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("gcpapigateway: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/gcpapigateway/persistence_test.go b/services/gcpapigateway/persistence_test.go new file mode 100644 index 0000000000..f1cdd0fca3 --- /dev/null +++ b/services/gcpapigateway/persistence_test.go @@ -0,0 +1,106 @@ +package gcpapigateway_test + +import ( + "net/http" + "testing" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/gcpapigateway" +) + +func TestSnapshotRestoreRoundTrip(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + src := newEnv(t) + gw := src.deploy(t, opSpec(be.URL, "", " /hello:\n"+op("get", ""))) + apiName := parent + "/apis/" + apiID + + iam := iampb.NewIAMPolicyClient(src.conn) + _, err := iam.SetIamPolicy(t.Context(), &iampb.SetIamPolicyRequest{ + Resource: gw.GetName(), Policy: &iampb.Policy{Bindings: []*iampb.Binding{ + {Role: "roles/apigateway.admin", Members: []string{"user:a@example.com"}}, + }}, + }) + require.NoError(t, err) + + src.handler.Backend.SetTiming(gcpapigateway.Timing{Create: time.Minute}) + + pending, err := src.client.CreateApi(t.Context(), &apigatewaypb.CreateApiRequest{Parent: parent, ApiId: "pending"}) + require.NoError(t, err) + + snap := src.handler.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + dst := newEnv(t) + require.NoError(t, dst.handler.Restore(t.Context(), snap)) + + got, err := dst.client.GetApi(t.Context(), &apigatewaypb.GetApiRequest{Name: apiName}) + require.NoError(t, err) + assert.Equal(t, src.managedService(t), got.GetManagedService()) + + gotGW, err := dst.client.GetGateway(t.Context(), &apigatewaypb.GetGatewayRequest{Name: gw.GetName()}) + require.NoError(t, err) + assert.Equal(t, gw.GetDefaultHostname(), gotGW.GetDefaultHostname()) + + full, err := dst.client.GetApiConfig(t.Context(), &apigatewaypb.GetApiConfigRequest{ + Name: gw.GetApiConfig(), View: apigatewaypb.GetApiConfigRequest_FULL, + }) + require.NoError(t, err) + assert.Len(t, full.GetOpenapiDocuments(), 1) + + pol, err := iampb.NewIAMPolicyClient(dst.conn). + GetIamPolicy(t.Context(), &iampb.GetIamPolicyRequest{Resource: gw.GetName()}) + require.NoError(t, err) + require.Len(t, pol.GetBindings(), 1) + + served := dst.call(t, gotGW, http.MethodGet, "/hello", nil) + assert.Equal(t, http.StatusOK, served.status, "the restored config routes again") + + op, err := dst.client.LROClient.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: pending.Name()}) + require.NoError(t, err) + assert.False(t, op.GetDone(), "a running operation stays running") + + dst.clock.Advance(2 * time.Minute) + + op, err = dst.client.LROClient.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: pending.Name()}) + require.NoError(t, err) + assert.True(t, op.GetDone()) +} + +func TestRestoreRejectsCorruptAndStaleSnapshots(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createAPI(t, apiID) + + require.Error(t, e.handler.Restore(t.Context(), []byte("not json"))) + + snap := e.handler.Snapshot(t.Context()) + stale := []byte(string(snap)) + stale = replaceOnce(stale, `"version":1`, `"version":999`) + + require.NoError(t, e.handler.Restore(t.Context(), stale)) + + it := e.client.ListApis(t.Context(), &apigatewaypb.ListApisRequest{Parent: parent}) + _, err := it.Next() + assert.Error(t, err, "an incompatible snapshot starts empty") +} + +func replaceOnce(b []byte, old, repl string) []byte { + s := string(b) + + for i := 0; i+len(old) <= len(s); i++ { + if s[i:i+len(old)] == old { + return []byte(s[:i] + repl + s[i+len(old):]) + } + } + + return b +} diff --git a/services/gcpapigateway/provider.go b/services/gcpapigateway/provider.go new file mode 100644 index 0000000000..f576dc68e6 --- /dev/null +++ b/services/gcpapigateway/provider.go @@ -0,0 +1,44 @@ +package gcpapigateway + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("gcpapigateway: nil app context") + +// ConfigProvider extracts API Gateway configuration from the AppContext Config. +type ConfigProvider interface { + GetGCPAPIGatewaySettings() Settings +} + +// Provider implements service.Provider for API Gateway. It serves gRPC, REST and the gateway data plane on +// its own listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "GCPAPIGateway" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetGCPAPIGatewaySettings() + } + + backend := NewInMemoryBackend() + backend.SetAllowExternal(settings.AllowExternalBackend) + + handler := NewHandler(backend) + handler.Port = settings.Port + + return handler, nil +} diff --git a/services/gcpapigateway/quota.go b/services/gcpapigateway/quota.go new file mode 100644 index 0000000000..87a71e08e6 --- /dev/null +++ b/services/gcpapigateway/quota.go @@ -0,0 +1,70 @@ +package gcpapigateway + +import ( + "slices" + "time" +) + +type quotaCounter struct { + Window int64 + Used int64 +} + +type quotaDenial struct { + metric string + limit string +} + +// consume charges a request's metric costs against the per-minute project limits, all or nothing. missing reports +// a cost metric the API's latest config no longer declares. +func (b *InMemoryBackend) consume(sv *served, rt *route) (*quotaDenial, bool) { + if len(rt.Costs) == 0 { + return nil, false + } + + for m := range rt.Costs { + if !sv.metrics[m] { + return nil, true + } + } + + project := sv.project + + b.mu.Lock("consume") + defer b.mu.Unlock() + + window := b.now().Truncate(time.Minute).Unix() + + metrics := make([]string, 0, len(rt.Costs)) + for m := range rt.Costs { + if _, limited := sv.limits[m]; limited { + metrics = append(metrics, m) + } + } + + slices.Sort(metrics) + + counter := func(m string) *quotaCounter { + key := project + "|" + m + + c, ok := b.quota[key] + if !ok || c.Window != window { + c = "aCounter{Window: window} + b.quota[key] = c + } + + return c + } + + for _, m := range metrics { + if counter(m).Used+rt.Costs[m] > sv.limits[m].PerMin { + return "aDenial{metric: m, limit: sv.limits[m].Name}, false + } + } + + for _, m := range metrics { + counter(m).Used += rt.Costs[m] + } + + return nil, false +} diff --git a/services/gcpapigateway/rest_test.go b/services/gcpapigateway/rest_test.go new file mode 100644 index 0000000000..1d2abf2831 --- /dev/null +++ b/services/gcpapigateway/rest_test.go @@ -0,0 +1,99 @@ +package gcpapigateway_test + +import ( + "net/http" + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/gcprest/resttest" +) + +func TestRESTFlow(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + const ( + apiPath = "/v1/projects/test-project/locations/global/apis/rest-api" + gwPath = "/v1/projects/test-project/locations/us-central1/gateways/rest-gw" + spec = "c3dhZ2dlcjogIjIuMCIKaW5mbzoKICB0aXRsZTogdAogIHZlcnNpb246ICIxIgp4LWdvb2dsZS1iYWNrZW5kOgogIG" + + "FkZHJlc3M6IGh0dHBzOi8vYi5leGFtcGxlCnBhdGhzOgogIC9oZWxsbzoKICAgIGdldDoKICAgICAgcmVzcG9uc2Vz" + + "OgogICAgICAgICIyMDAiOgogICAgICAgICAgZGVzY3JpcHRpb246IG9rCg==" + ) + + resttest.Run(t, e.handler.Addr(), []resttest.Step{ + { + Name: "create api", Method: http.MethodPost, + Path: "/v1/projects/test-project/locations/global/apis?apiId=rest-api", Body: `{"displayName":"REST"}`, + Status: 200, Contains: []string{`"done":true`, `"managedService"`, `OperationMetadata`}, + }, + { + Name: "get api", Method: http.MethodGet, Path: apiPath, + Status: 200, Contains: []string{`"displayName":"REST"`, `"state":"ACTIVE"`}, + }, + { + Name: "create config", Method: http.MethodPost, Path: apiPath + "/configs?apiConfigId=rest-cfg", + Body: `{"openapiDocuments":[{"document":{"path":"openapi.yaml","contents":"` + spec + `"}}]}`, + Status: 200, Contains: []string{`"done":true`, `rest-cfg`}, + }, + { + Name: "create gateway", Method: http.MethodPost, + Path: "/v1/projects/test-project/locations/us-central1/gateways?gatewayId=rest-gw", + Body: `{"apiConfig":"projects/test-project/locations/global/apis/rest-api/configs/rest-cfg"}`, + Status: 200, Contains: []string{`"done":true`, `.uc.gateway.dev`}, + }, + { + Name: "full config view", Method: http.MethodGet, Path: apiPath + "/configs/rest-cfg?view=FULL", + Status: 200, Contains: []string{`"openapiDocuments"`, spec}, + }, + { + Name: "patch gateway", Method: http.MethodPatch, Path: gwPath + "?updateMask=labels", + Body: `{"labels":{"a":"b"}}`, + Status: 200, Contains: []string{`"done":true`, `"a":"b"`}, + }, + { + Name: "set iam", Method: http.MethodPost, Path: gwPath + ":setIamPolicy", + Body: `{"policy":{"bindings":[{"role":"roles/apigateway.viewer","members":["user:a@example.com"]}]}}`, + Status: 200, Contains: []string{`roles/apigateway.viewer`}, + }, + { + Name: "get iam", Method: http.MethodPost, Path: gwPath + ":getIamPolicy", Body: `{}`, + Status: 200, Contains: []string{`user:a@example.com`}, + }, + { + Name: "test iam", + Method: http.MethodPost, + Path: gwPath + ":testIamPermissions", + Body: `{"permissions":["apigateway.gateways.get"]}`, + Status: 200, + Contains: []string{`apigateway.gateways.get`}, + }, + { + Name: "list operations", + Method: http.MethodGet, + Path: "/v1/projects/test-project/locations/global/operations", + Status: 200, + Contains: []string{`"operations"`, `operation-`}, + }, + { + Name: "list locations", Method: http.MethodGet, Path: "/v1/projects/test-project/locations", + Status: 200, Contains: []string{`"locationId":"global"`, `"locationId":"us-central1"`}, + }, + { + Name: "missing api", Method: http.MethodGet, Path: apiPath + "-ghost", + Status: 404, Contains: []string{`"status":"NOT_FOUND"`}, + }, + { + Name: "invalid api id", Method: http.MethodPost, + Path: "/v1/projects/test-project/locations/global/apis?apiId=Bad_Id", Body: `{}`, + Status: 400, Contains: []string{`"status":"INVALID_ARGUMENT"`}, + }, + { + Name: "config in use", Method: http.MethodDelete, Path: apiPath + "/configs/rest-cfg", + Status: 400, Contains: []string{`"status":"FAILED_PRECONDITION"`}, + }, + { + Name: "delete gateway", Method: http.MethodDelete, Path: gwPath, + Status: 200, Contains: []string{`"done":true`}, + }, + }) +} diff --git a/services/gcpapigateway/security_test.go b/services/gcpapigateway/security_test.go new file mode 100644 index 0000000000..f7949bc4e5 --- /dev/null +++ b/services/gcpapigateway/security_test.go @@ -0,0 +1,492 @@ +package gcpapigateway_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "maps" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + issuer = "https://issuer.example.com" + otherIs = "https://other.example.com" +) + +func TestAPIKeySecurity(t *testing.T) { + t.Parallel() + + const top = "securityDefinitions:\n api_key:\n type: apiKey\n name: key\n in: query\n" + + paths := " /locked:\n" + op("get", " security:\n - api_key: []\n") + " /open:\n" + op("get", "") + + tests := []struct { + hdr map[string]string + name string + target string + want int + }{ + {name: "missing key", target: "/locked", want: http.StatusUnauthorized}, + {name: "empty key", target: "/locked?key=", want: http.StatusUnauthorized}, + {name: "query key", target: "/locked?key=abc", want: http.StatusOK}, + {name: "header key", target: "/locked", hdr: map[string]string{"x-api-key": "abc"}, want: http.StatusOK}, + {name: "unsecured path", target: "/open", want: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, top, paths)) + + got := e.call(t, gw, http.MethodGet, tt.target, tt.hdr) + assert.Equal(t, tt.want, got.status, got.raw) + + if tt.want == http.StatusUnauthorized { + assert.EqualValues(t, "UNAUTHENTICATED: Method doesn't allow unregistered callers (callers without "+ + "established identity). Please use API Key or other form of API consumer identity to call this API.", + got.body["message"]) + } + }) + } +} + +func TestRootSecurityAndAnonymousOverride(t *testing.T) { + t.Parallel() + + const top = "securityDefinitions:\n api_key:\n type: apiKey\n name: key\n in: query\n" + + "security:\n - api_key: []\n" + + paths := " /locked:\n" + op("get", "") + " /anon:\n" + op("get", " security: []\n") + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, top, paths)) + + assert.Equal(t, http.StatusUnauthorized, e.call(t, gw, http.MethodGet, "/locked", nil).status) + assert.Equal(t, http.StatusOK, e.call(t, gw, http.MethodGet, "/anon", nil).status) +} + +type signer struct { + rsa *rsa.PrivateKey + ec *ecdsa.PrivateKey + jwks *httptest.Server +} + +var sharedRSA = sync.OnceValue(func() *rsa.PrivateKey { //nolint:gochecknoglobals // expensive key shared by tests + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + + return k +}) + +func b64u(b *big.Int) string { return base64.RawURLEncoding.EncodeToString(b.Bytes()) } + +func newSigner(t *testing.T) *signer { + t.Helper() + + ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + point, err := ec.PublicKey.Bytes() + require.NoError(t, err) + + s := &signer{rsa: sharedRSA(), ec: ec} + s.jwks = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{ + {"kty": "RSA", "kid": "rsa-1", "n": b64u(s.rsa.N), "e": b64u(big.NewInt(int64(s.rsa.E)))}, + { + "kty": "EC", + "kid": "ec-1", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(point[1:33]), + "y": base64.RawURLEncoding.EncodeToString(point[33:]), + }, + }}) + })) + t.Cleanup(s.jwks.Close) + + return s +} + +func (s *signer) token(t *testing.T, method jwt.SigningMethod, kid string, key any, claims jwt.MapClaims) string { + t.Helper() + + tok := jwt.NewWithClaims(method, claims) + tok.Header["kid"] = kid + + signed, err := tok.SignedString(key) + require.NoError(t, err) + + return signed +} + +func (s *signer) rsaToken(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + + return s.token(t, jwt.SigningMethodRS256, "rsa-1", s.rsa, claims) +} + +func jwtSpec(jwksURI, audiences string) string { + def := "securityDefinitions:\n jwt:\n type: oauth2\n flow: implicit\n authorizationUrl: \"\"\n" + + " x-google-issuer: " + issuer + "\n x-google-jwks_uri: " + jwksURI + "\n" + if audiences != "" { + def += " x-google-audiences: " + audiences + "\n" + } + + return def + "security:\n - jwt: []\n" +} + +func TestJWTSecurity(t *testing.T) { + t.Parallel() + + now := newClock().Now() + good := func(extra jwt.MapClaims) jwt.MapClaims { + c := jwt.MapClaims{ + "iss": issuer, "sub": "user-1", "aud": "https://AUD", "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), "email": "u@example.com", + } + maps.Copy(c, extra) + + return c + } + + tests := []struct { + build func(*testing.T, *signer, string) (hdr map[string]string, target string) + name string + aud string + msg string + want int + }{ + { + name: "valid rs256", aud: "my-audience", want: 200, + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + return bearer(s.rsaToken(t, good(jwt.MapClaims{"aud": "my-audience"}))), "/hello" + }, + }, + { + name: "valid es256 with audience list", aud: "a1, a2", want: 200, + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + tok := s.token( + t, + jwt.SigningMethodES256, + "ec-1", + s.ec, + good(jwt.MapClaims{"aud": []string{"zzz", "a2"}}), + ) + + return bearer(tok), "/hello" + }, + }, + { + name: "default audience is the managed service", want: 200, + build: func(t *testing.T, s *signer, managed string) (map[string]string, string) { + t.Helper() + + return bearer(s.rsaToken(t, good(jwt.MapClaims{"aud": "https://" + managed}))), "/hello" + }, + }, + { + name: "access_token query parameter", want: 200, + build: func(t *testing.T, s *signer, managed string) (map[string]string, string) { + t.Helper() + + return nil, "/hello?access_token=" + s.rsaToken(t, good(jwt.MapClaims{"aud": "https://" + managed})) + }, + }, + { + name: "missing token", want: 401, msg: "Jwt is missing", + build: func(*testing.T, *signer, string) (map[string]string, string) { return nil, "/hello" }, + }, + { + name: "garbage token", + want: 401, + msg: "Jwt verification fails", + build: func(*testing.T, *signer, string) (map[string]string, string) { return bearer("not.a.jwt"), "/hello" }, + }, + { + name: "token without dots", want: 401, + msg: "Jwt is not in the form of Header.Payload.Signature with two dots and 3 sections", + build: func(*testing.T, *signer, string) (map[string]string, string) { + return bearer("garbage"), "/hello" + }, + }, + { + name: "not yet valid", aud: "x", want: 401, msg: "Jwt not yet valid", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + return bearer( + s.rsaToken(t, good(jwt.MapClaims{"aud": "x", "nbf": now.Add(time.Hour).Unix()})), + ), "/hello" + }, + }, + { + name: "expired", aud: "x", want: 401, msg: "Jwt is expired", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + return bearer( + s.rsaToken(t, good(jwt.MapClaims{"aud": "x", "exp": now.Add(-time.Hour).Unix()})), + ), "/hello" + }, + }, + { + name: "no exp", aud: "x", want: 401, msg: "Jwt verification fails", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + c := good(jwt.MapClaims{"aud": "x"}) + delete(c, "exp") + + return bearer(s.rsaToken(t, c)), "/hello" + }, + }, + { + name: "unknown issuer", aud: "x", want: 401, msg: "issuer is not configured", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + return bearer(s.rsaToken(t, good(jwt.MapClaims{"aud": "x", "iss": otherIs}))), "/hello" + }, + }, + { + name: "audience mismatch", aud: "mine", want: 403, msg: "Audiences in Jwt are not allowed", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + return bearer(s.rsaToken(t, good(jwt.MapClaims{"aud": "theirs"}))), "/hello" + }, + }, + { + name: "unknown kid", aud: "x", want: 401, msg: "Jwt verification fails", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + tok := s.token(t, jwt.SigningMethodRS256, "other", s.rsa, good(jwt.MapClaims{"aud": "x"})) + + return bearer(tok), "/hello" + }, + }, + { + name: "signed with another key", aud: "x", want: 401, msg: "Jwt verification fails", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + other, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + return bearer( + s.token(t, jwt.SigningMethodRS256, "rsa-1", other, good(jwt.MapClaims{"aud": "x"})), + ), "/hello" + }, + }, + { + name: "hs256 forged with the public key", aud: "x", want: 401, msg: "Jwt verification fails", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + tok := s.token(t, jwt.SigningMethodHS256, "rsa-1", s.rsa.N.Bytes(), good(jwt.MapClaims{"aud": "x"})) + + return bearer(tok), "/hello" + }, + }, + { + name: "alg none", aud: "x", want: 401, msg: "Jwt verification fails", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + tok := s.token( + t, + jwt.SigningMethodNone, + "rsa-1", + jwt.UnsafeAllowNoneSignatureType, + good(jwt.MapClaims{"aud": "x"}), + ) + + return bearer(tok), "/hello" + }, + }, + { + name: "es256 header with rsa key id", aud: "x", want: 401, msg: "Jwt verification fails", + build: func(t *testing.T, s *signer, _ string) (map[string]string, string) { + t.Helper() + + tok := s.token(t, jwt.SigningMethodES256, "rsa-1", s.ec, good(jwt.MapClaims{"aud": "x"})) + + return bearer(tok), "/hello" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newSigner(t) + be := echoBackend(t) + e := newEnv(t) + e.clock.Advance(0) + + e.createAPI(t, apiID) + + managed := e.managedService(t) + cfg := e.createConfig(t, "cfg-1", opSpec(be.URL, jwtSpec(s.jwks.URL, tt.aud), " /hello:\n"+op("get", ""))) + gw := e.createGateway(t, "my-gw", cfg.GetName()) + + hdr, target := tt.build(t, s, managed) + got := e.call(t, gw, http.MethodGet, target, hdr) + require.Equal(t, tt.want, got.status, got.raw) + + if tt.msg != "" { + assert.Contains(t, got.raw, tt.msg) + } + }) + } +} + +func bearer(tok string) map[string]string { return map[string]string{"Authorization": "Bearer " + tok} } + +func TestJWTUserinfoForwarded(t *testing.T) { + t.Parallel() + + s := newSigner(t) + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, jwtSpec(s.jwks.URL, "aud-1"), " /hello:\n"+op("get", ""))) + + tok := s.rsaToken(t, jwt.MapClaims{ + "iss": issuer, + "sub": "user-1", + "aud": "aud-1", + "exp": e.clock.Now().Add(time.Hour).Unix(), + "email": "u@example.com", + }) + + got := e.call(t, gw, http.MethodGet, "/hello", map[string]string{ + "Authorization": "Bearer " + tok, "X-Apigateway-Api-Userinfo": "forged", + }) + require.Equal(t, http.StatusOK, got.status, got.raw) + + hdr, ok := got.body["headers"].(map[string]any) + require.True(t, ok) + + raw, err := base64.RawURLEncoding.DecodeString(fmt.Sprint(hdr["X-Apigateway-Api-Userinfo"])) + require.NoError(t, err) + + var claims map[string]any + require.NoError(t, json.Unmarshal(raw, &claims)) + assert.Equal(t, "user-1", claims["sub"]) + assert.Equal(t, "u@example.com", claims["email"]) + assert.Equal(t, "Bearer "+tok, hdr["X-Forwarded-Authorization"]) + assert.Empty(t, hdr["Authorization"]) +} + +func TestJWTExpiryFollowsClock(t *testing.T) { + t.Parallel() + + s := newSigner(t) + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, jwtSpec(s.jwks.URL, "aud-1"), " /hello:\n"+op("get", ""))) + + tok := s.rsaToken(t, jwt.MapClaims{ + "iss": issuer, "aud": "aud-1", "exp": e.clock.Now().Add(time.Minute).Unix(), + }) + + assert.Equal(t, http.StatusOK, e.call(t, gw, http.MethodGet, "/hello", bearer(tok)).status) + + e.clock.Advance(2 * time.Minute) + assert.Equal(t, http.StatusUnauthorized, e.call(t, gw, http.MethodGet, "/hello", bearer(tok)).status) +} + +func TestJWKSFetchRefusedOutsideLoopback(t *testing.T) { + t.Parallel() + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, jwtSpec("http://192.0.2.1:9/keys", "aud-1"), " /hello:\n"+op("get", ""))) + + tok := (&signer{rsa: sharedRSA()}).rsaToken(t, jwt.MapClaims{ + "iss": issuer, "aud": "aud-1", "exp": e.clock.Now().Add(time.Hour).Unix(), + }) + + got := e.call(t, gw, http.MethodGet, "/hello", bearer(tok)) + assert.Equal(t, http.StatusUnauthorized, got.status, "a JWKS URI outside loopback cannot be fetched") + assert.EqualValues(t, "Jwks remote fetch is failed", got.body["message"]) +} + +func TestJWKSWithoutUsableKeys(t *testing.T) { + t.Parallel() + + empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"keys":[]}`)) + })) + t.Cleanup(empty.Close) + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, jwtSpec(empty.URL, "aud-1"), " /hello:\n"+op("get", ""))) + + tok := (&signer{rsa: sharedRSA()}).rsaToken(t, jwt.MapClaims{ + "iss": issuer, "aud": "aud-1", "exp": e.clock.Now().Add(time.Hour).Unix(), + }) + + got := e.call(t, gw, http.MethodGet, "/hello", bearer(tok)) + assert.Equal(t, http.StatusUnauthorized, got.status) + assert.EqualValues(t, "Jwks doesn't have any valid public key", got.body["message"]) +} + +func TestQuotaPerMinute(t *testing.T) { + t.Parallel() + + const top = "x-google-management:\n metrics:\n - name: reads\n displayName: Reads\n valueType: INT64\n" + + " metricKind: DELTA\n quota:\n limits:\n - name: reads-limit\n metric: reads\n" + + " unit: \"1/min/{project}\"\n values:\n STANDARD: 5\n" + + paths := " /read:\n" + op("get", " x-google-quota:\n metricCosts:\n reads: 2\n") + + " /free:\n" + op("get", "") + + be := echoBackend(t) + e := newEnv(t) + gw := e.deploy(t, opSpec(be.URL, top, paths)) + + statuses := func(n int, target string) []int { + out := make([]int, 0, n) + for range n { + out = append(out, e.call(t, gw, http.MethodGet, target, nil).status) + } + + return out + } + + assert.Equal(t, []int{200, 200, 429, 429}, statuses(4, "/read"), "cost 2 against 5 per minute") + + denied := e.call(t, gw, http.MethodGet, "/read", nil) + assert.Contains(t, denied.raw, "RESOURCE_EXHAUSTED: Quota exceeded for quota metric 'reads'") + assert.Equal(t, []int{200, 200, 200}, statuses(3, "/free"), "operations without a cost are not limited") + + e.clock.Advance(time.Minute) + assert.Equal(t, []int{200, 200, 429}, statuses(3, "/read"), "the window resets each minute") +} diff --git a/services/gcpapigateway/settings.go b/services/gcpapigateway/settings.go new file mode 100644 index 0000000000..405c54643e --- /dev/null +++ b/services/gcpapigateway/settings.go @@ -0,0 +1,15 @@ +package gcpapigateway + +// DefaultPort is the dedicated API Gateway listener's default TCP port: control plane (gRPC and REST) and data plane. +const DefaultPort = 8146 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.GCPAPIGateway. +type Settings struct { + Port int `json:"port" env:"GCP_APIGATEWAY_PORT" default:"8146" name:"port" help:"Fixed TCP port for the dedicated API Gateway listener (control plane and gateway data plane); startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + AllowExternalBackend bool `json:"allowExternalBackend" env:"GCP_APIGATEWAY_ALLOW_EXTERNAL_BACKENDS" default:"false" name:"allow-external-backends" help:"Let gateway backends and remote JWKS URIs reach hosts outside loopback; by default the data plane only dials 127.0.0.0/8 and ::1 (Cloud Run and Functions *.localhost URLs included)."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { + return Settings{Port: DefaultPort} +} diff --git a/services/gcpapigateway/spec.go b/services/gcpapigateway/spec.go new file mode 100644 index 0000000000..3648506db5 --- /dev/null +++ b/services/gcpapigateway/spec.go @@ -0,0 +1,846 @@ +package gcpapigateway + +import ( + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + translateAppend = "APPEND_PATH_TO_ADDRESS" + translateConstant = "CONSTANT_ADDRESS" + defaultDeadline = 15 * time.Second + maxDeadlineSec = 3600 + quotaUnit = "1/min/{project}" + maxMetricName = 40 + kindAPIKey = "apiKey" + kindJWT = "jwt" + kindOther = "other" + keyIn = "in" + inQuery = "query" + inHeader = "header" + keyBackend = "x-google-backend" + keyResponses = "responses" +) + +//nolint:gochecknoglobals // immutable lookup table +var httpMethods = []string{"get", "put", "post", "delete", "options", "head", "patch"} + +type seg struct { + lit string + param string +} + +type backendCfg struct { + Address string + Translation string + Deadline time.Duration + DisableAuth bool +} + +type secDef struct { + Name string + Kind string + KeyName string + KeyIn string + Issuer string + JWKSURI string + Audiences []string + Locations []jwtLocation +} + +type jwtLocation struct { + Header string + Query string + Prefix string +} + +type quotaLimit struct { + Name string + Metric string + PerMin int64 +} + +type route struct { + Backend *backendCfg + Costs map[string]int64 + Method string + Template string + Segs []seg + Security [][]string +} + +type compiledSpec struct { + Defs map[string]*secDef + Metrics map[string]bool + Limits map[string]quotaLimit + BasePath string + Routes []*route +} + +type compiler struct { + doc map[string]any + defs map[string]*secDef + metrics map[string]bool + limits map[string]quotaLimit + rootQ map[string]int64 + diags []diagnostic + rootSec [][]string + v3 bool +} + +func asMap(v any) map[string]any { + switch m := v.(type) { + case map[string]any: + return m + case map[any]any: + out := make(map[string]any, len(m)) + for k, e := range m { + out[fmt.Sprint(k)] = e + } + + return out + default: + return nil + } +} + +func asList(v any) []any { + l, _ := v.([]any) + + return l +} + +func str(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok { + return s + } + } + + return "" +} + +func num(v any) (float64, bool) { + switch n := v.(type) { + case int: + return float64(n), true + case int64: + return float64(n), true + case float64: + return n, true + case uint64: + return float64(n), true + default: + return 0, false + } +} + +func (c *compiler) failf(loc, format string, a ...any) { + c.diags = append(c.diags, diagnostic{Location: loc, Message: fmt.Sprintf(format, a...)}) +} + +// compileSpec parses and validates an API config's OpenAPI document (YAML or JSON). +func compileSpec(raw []byte) (*compiledSpec, []diagnostic) { + var doc map[string]any + if err := yaml.Unmarshal(raw, &doc); err != nil || doc == nil { + return nil, []diagnostic{{Message: "Unable to parse the OpenAPI document: it is not valid YAML or JSON."}} + } + + c := &compiler{doc: doc, defs: map[string]*secDef{}, metrics: map[string]bool{}, limits: map[string]quotaLimit{}} + + if !c.version() { + return nil, c.diags + } + + base := c.basePath() + c.securityDefs() + c.management() + c.rootSecurity() + c.rootQuota() + + root := c.rootBackend() + routes := c.paths(root) + + if c.allowCors() { + routes = withCORSRoutes(routes) + } + + if len(c.diags) > 0 { + return nil, c.diags + } + + return &compiledSpec{Defs: c.defs, Metrics: c.metrics, Limits: c.limits, BasePath: base, Routes: routes}, nil +} + +func (c *compiler) version() bool { + if s, ok := c.doc["swagger"]; ok { + if n, isNum := num(s); (isNum && n == 2) || fmt.Sprint(s) == "2.0" { + return true + } + + c.failf("swagger", "Unsupported Swagger version %v: use \"2.0\".", s) + + return false + } + + if s, ok := c.doc["openapi"].(string); ok && (strings.HasPrefix(s, "3.0") || strings.HasPrefix(s, "3.1")) { + c.v3 = true + + return true + } + + c.failf("", "Unsupported document: expected an OpenAPI 2.0 (swagger: \"2.0\") or 3.x (openapi: 3.0.x or 3.1.x) "+ + "specification.") + + return false +} + +func (c *compiler) basePath() string { + var base string + + if c.v3 { + servers := asList(c.doc["servers"]) + if len(servers) > 0 { + if u, err := url.Parse(str(asMap(servers[0]), "url")); err == nil { + base = u.Path + } + } + } else { + base = str(c.doc, "basePath") + } + + if base != "" && !strings.HasPrefix(base, "/") { + c.failf("basePath", "basePath %q must start with '/'.", base) + } + + return strings.TrimRight(base, "/") +} + +// allowCors reads x-google-endpoints (2.0, the entry named after the host) or the servers x-google-endpoint (3.x). +func (c *compiler) allowCors() bool { + if c.v3 { + for _, sv := range asList(c.doc["servers"]) { + if b, _ := asMap(asMap(sv)["x-google-endpoint"])["allowCors"].(bool); b { + return true + } + } + + return false + } + + host := str(c.doc, "host") + + for _, e := range asList(c.doc["x-google-endpoints"]) { + em := asMap(e) + if b, _ := em["allowCors"].(bool); b && str(em, "name") == host { + return true + } + } + + return false +} + +// withCORSRoutes adds, as ESPv2 does for allowCors, an OPTIONS route proxied to the backend of the first operation +// of every path template without one; these routes carry no security or quota. +func withCORSRoutes(routes []*route) []*route { + hasOptions := map[string]bool{} + + for _, r := range routes { + if r.Method == http.MethodOptions { + hasOptions[normalised(r.Segs)] = true + } + } + + out := routes + + for _, r := range routes { + key := normalised(r.Segs) + if r.Method == http.MethodOptions || hasOptions[key] || r.Segs == nil { + continue + } + + hasOptions[key] = true + out = append(out, &route{Backend: r.Backend, Method: http.MethodOptions, Template: r.Template, Segs: r.Segs}) + } + + return out +} + +func (c *compiler) securityDefs() { + defs := asMap(c.doc["securityDefinitions"]) + if c.v3 { + defs = asMap(asMap(c.doc["components"])["securitySchemes"]) + } + + issuers := map[string]string{} + + for _, name := range sortedKeys(defs) { + m := asMap(defs[name]) + loc := "securityDefinitions." + name + d := &secDef{Name: name, Kind: kindOther} + c.defs[name] = d + + switch { + case str(m, "type") == "apiKey": + d.Kind, d.KeyName, d.KeyIn = kindAPIKey, str(m, "name"), str(m, keyIn) + if d.KeyName == "" || (d.KeyIn != inQuery && d.KeyIn != inHeader) { + c.failf(loc, "apiKey security definition %q needs a name and 'in' of query or header.", name) + } + case c.v3 && asMap(m["x-google-auth"]) != nil: + c.authScheme(loc, d, asMap(m["x-google-auth"]), issuers) + case !c.v3 && str(m, "x-google-issuer") != "": + d.Kind, d.Issuer, d.JWKSURI = kindJWT, str(m, "x-google-issuer"), str(m, "x-google-jwks_uri") + d.Audiences = audiences(m["x-google-audiences"]) + + if !httpURL(d.JWKSURI) { + c.failf(loc, "x-google-jwks_uri is required and must be an http or https URL.") + } + + if prev, dup := issuers[d.Issuer]; dup { + c.failf(loc, "x-google-issuer %q is already used by security definition %q.", d.Issuer, prev) + } + + issuers[d.Issuer] = name + } + } +} + +// authScheme reads an OpenAPI 3.x x-google-auth extension of a security scheme. +func (c *compiler) authScheme(loc string, d *secDef, m map[string]any, issuers map[string]string) { + d.Kind, d.Issuer, d.JWKSURI = kindJWT, str(m, "issuer"), str(m, "jwksUri") + d.Audiences = audiences(m["audiences"]) + + if !httpURL(d.JWKSURI) { + c.failf(loc, "x-google-auth.jwksUri is required and must be an http or https URL.") + } + + if d.Issuer != "" { + if prev, dup := issuers[d.Issuer]; dup { + c.failf(loc, "x-google-auth.issuer %q is already used by security scheme %q.", d.Issuer, prev) + } + + issuers[d.Issuer] = d.Name + } + + for i, e := range asList(m["jwtLocations"]) { + em := asMap(e) + l := jwtLocation{Header: str(em, "header"), Query: str(em, "query"), Prefix: str(em, "valuePrefix")} + + switch { + case (l.Header == "") == (l.Query == ""): + c.failf(fmt.Sprintf("%s.jwtLocations[%d]", loc, i), "Exactly one of header and query is required.") + case l.Prefix != "" && l.Header == "": + c.failf(fmt.Sprintf("%s.jwtLocations[%d]", loc, i), "valuePrefix is only valid with header.") + default: + d.Locations = append(d.Locations, l) + } + } +} + +func httpURL(s string) bool { + u, err := url.Parse(s) + + return s != "" && err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} + +func audiences(v any) []string { + switch t := v.(type) { + case string: + var out []string + + for a := range strings.SplitSeq(t, ",") { + if a = strings.TrimSpace(a); a != "" { + out = append(out, a) + } + } + + return out + case []any: + var out []string + for _, a := range t { + out = append(out, fmt.Sprint(a)) + } + + return out + default: + return nil + } +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys +} + +func (c *compiler) management() { + if c.v3 { + c.management3() + + return + } + + mg, ok := c.doc["x-google-management"] + if !ok { + return + } + + m := asMap(mg) + + for i, e := range asList(m["metrics"]) { + em := asMap(e) + loc := fmt.Sprintf("x-google-management.metrics[%d]", i) + name := str(em, "name") + + switch { + case name == "": + c.failf(loc, "Metric name is required.") + case len(str(em, "displayName")) > maxMetricName: + c.failf(loc, "Metric displayName must be at most %d characters.", maxMetricName) + case str(em, "valueType") != "INT64": + c.failf(loc, "Metric %q: valueType must be INT64.", name) + case str(em, "metricKind") != "DELTA": + c.failf(loc, "Metric %q: metricKind must be DELTA.", name) + default: + c.metrics[name] = true + } + } + + limits := asList(asMap(m["quota"])["limits"]) + for i, e := range limits { + c.limit(fmt.Sprintf("x-google-management.quota.limits[%d]", i), asMap(e)) + } +} + +// management3 reads x-google-api-management: metrics and quota limits keyed by name, and the named backends. +func (c *compiler) management3() { + const loc = "x-google-api-management" + + m := asMap(c.doc[loc]) + if m == nil { + c.failf(loc, "x-google-api-management is required for OpenAPI 3.x documents.") + + return + } + + if len(asMap(m["backends"])) == 0 { + c.failf(loc+".backends", "x-google-api-management.backends is required.") + } + + for _, name := range sortedKeys(asMap(m["metrics"])) { + if len(str(asMap(asMap(m["metrics"])[name]), "displayName")) > maxMetricName { + c.failf(loc+".metrics."+name, "Metric displayName must be at most %d characters.", maxMetricName) + + continue + } + + c.metrics[name] = true + } + + limits := asMap(asMap(m["quota"])["limits"]) + for _, name := range sortedKeys(limits) { + lm := asMap(limits[name]) + metric := str(lm, "metric") + lloc := loc + ".quota.limits." + name + + n, ok := num(lm["values"]) + + switch { + case !c.metrics[metric]: + c.failf(lloc, "Quota limit %q refers to undefined metric %q.", name, metric) + case !ok || n < 0 || n != float64(int64(n)): + c.failf(lloc, "Quota limit %q: values must be a non-negative integer.", name) + default: + c.limits[metric] = quotaLimit{Name: name, Metric: metric, PerMin: int64(n)} + } + } +} + +func (c *compiler) limit(loc string, m map[string]any) { + name, metric := str(m, "name"), str(m, "metric") + + switch { + case name == "" || metric == "": + c.failf(loc, "Quota limits need a name and a metric.") + + return + case !c.metrics[metric]: + c.failf(loc, "Quota limit %q refers to undefined metric %q.", name, metric) + + return + case str(m, "unit") != quotaUnit: + c.failf(loc, "Quota limit %q: only the unit %q is supported.", name, quotaUnit) + + return + } + + n, ok := num(asMap(m["values"])["STANDARD"]) + if !ok || n < 0 || n != float64(int64(n)) { + c.failf(loc, "Quota limit %q: values.STANDARD must be a non-negative integer.", name) + + return + } + + c.limits[metric] = quotaLimit{Name: name, Metric: metric, PerMin: int64(n)} +} + +// rootQuota reads the top-level OpenAPI 3.x x-google-quota, which operations may override. +func (c *compiler) rootQuota() { + if q, ok := c.doc["x-google-quota"]; ok && c.v3 { + c.rootQ = c.costs("x-google-quota", q) + } +} + +// costs reads an OpenAPI 3.x x-google-quota map of metric to per-request cost. +func (c *compiler) costs(loc string, q any) map[string]int64 { + out := map[string]int64{} + m := asMap(q) + + for _, metric := range sortedKeys(m) { + n, isNum := num(m[metric]) + + switch { + case !c.metrics[metric]: + c.failf(loc, "x-google-quota refers to undefined metric %q.", metric) + case !isNum || n < 0 || n != float64(int64(n)): + c.failf(loc, "x-google-quota[%q] must be a non-negative integer.", metric) + default: + out[metric] = int64(n) + } + } + + return out +} + +func (c *compiler) rootSecurity() { + if sec, ok := c.doc["security"]; ok { + c.rootSec = c.requirements("security", sec) + } +} + +func (c *compiler) requirements(loc string, v any) [][]string { + list := asList(v) + out := make([][]string, 0, len(list)) + + for _, e := range list { + req := []string{} + + for _, name := range sortedKeys(asMap(e)) { + d, ok := c.defs[name] + + switch { + case !ok: + c.failf(loc, "Security requirement %q is not defined in the security definitions.", name) + case d.Kind == kindOther: + c.failf( + loc, + "Security definition %q is unsupported: use an apiKey or an x-google-issuer definition.", + name, + ) + default: + req = append(req, name) + } + } + + out = append(out, req) + } + + return out +} + +// rootBackend returns the top-level x-google-backend, or nil. +func (c *compiler) rootBackend() *backendCfg { + v, ok := c.doc[keyBackend] + if !ok { + return nil + } + + return c.backend(keyBackend, v, translateAppend) +} + +func (c *compiler) backend(loc string, v any, defTranslation string) *backendCfg { + m := asMap(v) + + if c.v3 { + name, ok := v.(string) + if !ok { + c.failf(loc, "x-google-backend must name a backend defined in x-google-api-management.backends.") + + return nil + } + + m = asMap(asMap(asMap(c.doc["x-google-api-management"])["backends"])[name]) + if m == nil { + c.failf(loc, "Backend %q is not defined in x-google-api-management.backends.", name) + + return nil + } + } + + if m == nil { + c.failf(loc, "x-google-backend must be an object.") + + return nil + } + + b := &backendCfg{Address: str(m, "address"), Translation: defTranslation, Deadline: defaultDeadline} + + if !httpURL(b.Address) { + c.failf(loc, "x-google-backend.address is required and must be an http or https URL.") + } + + if t := str(m, c.field("path_translation", "pathTranslation")); t != "" { + if t != translateAppend && t != translateConstant { + c.failf(loc, "Unsupported path_translation %q: use %s or %s.", t, translateAppend, translateConstant) + } + + b.Translation = t + } + + c.backendLimits(loc, m, b) + + return b +} + +// field picks the OpenAPI 2.0 (snake_case) or 3.x (camelCase) spelling of an extension field. +func (c *compiler) field(snake, camel string) string { + if c.v3 { + return camel + } + + return snake +} + +func (c *compiler) backendLimits(loc string, m map[string]any, b *backendCfg) { + if d, ok := m["deadline"]; ok { + n, isNum := num(d) + if !isNum || n < 0 || n > maxDeadlineSec { + c.failf(loc, "x-google-backend.deadline must be between 0 and %d seconds.", maxDeadlineSec) + } else if n > 0 { + b.Deadline = time.Duration(n * float64(time.Second)) + } + } + + if p := str(m, "protocol"); p != "" && p != "http/1.1" && p != "h2" { + c.failf(loc, "Unsupported x-google-backend.protocol %q: use http/1.1 or h2.", p) + } + + b.DisableAuth, _ = m[c.field("disable_auth", "disableAuth")].(bool) + + if _, aud := m[c.field("jwt_audience", "jwtAudience")]; aud && b.DisableAuth { + c.failf(loc, "Only one of jwt_audience and disable_auth may be set.") + } +} + +func (c *compiler) paths(root *backendCfg) []*route { + paths := asMap(c.doc["paths"]) + if len(paths) == 0 { + c.failf("paths", "The document defines no paths.") + + return nil + } + + var routes []*route + + seen := map[string]string{} + + for _, p := range sortedKeys(paths) { + item := asMap(paths[p]) + segs, ok := c.template(p) + + for _, method := range httpMethods { + opv, has := item[method] + if !has { + continue + } + + loc := "paths." + p + "." + method + r := c.operation(loc, asMap(opv), root) + r.Method, r.Template, r.Segs = strings.ToUpper(method), p, segs + + if ok { + key := r.Method + " " + normalised(segs) + if prev, dup := seen[key]; dup { + c.failf(loc, "Path %q is ambiguous with %q.", p, prev) + } + + seen[key] = p + } + + routes = append(routes, r) + } + } + + if len(routes) == 0 { + c.failf("paths", "The document defines no operations.") + } + + return routes +} + +func (c *compiler) operation(loc string, op map[string]any, root *backendCfg) *route { + r := &route{Backend: root} + + if _, ok := op[keyResponses]; !ok { + c.failf(loc, "Operation is missing the required 'responses' field.") + } + + if bv, ok := op[keyBackend]; ok { + r.Backend = c.backend(loc+"."+keyBackend, bv, translateConstant) + } else if root == nil { + c.failf(loc, "Operation has no x-google-backend and the document defines none at the top level.") + } + + r.Security = c.rootSec + if sec, ok := op["security"]; ok { + r.Security = c.requirements(loc+".security", sec) + } + + if c.v3 { + r.Costs = c.rootQ + if q, ok := op["x-google-quota"]; ok { + r.Costs = c.costs(loc+".x-google-quota", q) + } + + return r + } + + if q, ok := op["x-google-quota"]; ok { + r.Costs = map[string]int64{} + costs := asMap(asMap(q)["metricCosts"]) + + for _, metric := range sortedKeys(costs) { + n, isNum := num(costs[metric]) + + switch { + case !c.metrics[metric]: + c.failf(loc+".x-google-quota", "metricCosts refers to undefined metric %q.", metric) + case !isNum || n < 0 || n != float64(int64(n)): + c.failf(loc+".x-google-quota", "metricCosts[%q] must be a non-negative integer.", metric) + default: + r.Costs[metric] = int64(n) + } + } + } + + return r +} + +func (c *compiler) template(p string) ([]seg, bool) { + if !strings.HasPrefix(p, "/") { + c.failf("paths."+p, "Path %q must start with '/'.", p) + + return nil, false + } + + var segs []seg + + for s := range strings.SplitSeq(strings.Trim(p, "/"), "/") { + switch { + case strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") && !strings.ContainsAny(s[1:len(s)-1], "{}=*"): + segs = append(segs, seg{param: s[1 : len(s)-1]}) + case strings.ContainsAny(s, "{}"): + c.failf("paths."+p, "Path %q: a template variable must fill a whole segment as {name}.", p) + + return nil, false + default: + segs = append(segs, seg{lit: s}) + } + } + + return segs, true +} + +func normalised(segs []seg) string { + parts := make([]string, len(segs)) + + for i, s := range segs { + if s.param != "" { + parts[i] = "{}" + } else { + parts[i] = s.lit + } + } + + return strings.Join(parts, "/") +} + +// match finds the route for a request method and path, with the path template variables. When the path fits a +// template but no operation has the method, it returns that template (BasePath included) for the 405 text. +func (s *compiledSpec) match(method, p string) (*route, map[string]string, string) { + rest, ok := strings.CutPrefix(p, s.BasePath) + if !ok || (rest != "" && !strings.HasPrefix(rest, "/")) { + return nil, nil, "" + } + + parts := strings.Split(strings.Trim(rest, "/"), "/") + + var ( + best *route + bestVars map[string]string + bestLits = -1 + other string + ) + + for _, r := range s.Routes { + vars, lits, fits := r.fit(parts) + if !fits { + continue + } + + if r.Method != method { + if other == "" { + other = s.BasePath + r.Template + } + + continue + } + + if lits > bestLits { + best, bestVars, bestLits = r, vars, lits + } + } + + if best != nil { + other = "" + } + + return best, bestVars, other +} + +func (r *route) fit(parts []string) (map[string]string, int, bool) { + if len(parts) != len(r.Segs) { + return nil, 0, false + } + + vars := map[string]string{} + lits := 0 + + for i, sg := range r.Segs { + switch { + case sg.param != "": + if parts[i] == "" { + return nil, 0, false + } + + vars[sg.param] = parts[i] + case sg.lit == parts[i]: + lits++ + default: + return nil, 0, false + } + } + + return vars, lits, true +} diff --git a/services/gcpapigateway/store.go b/services/gcpapigateway/store.go new file mode 100644 index 0000000000..a527c0bebc --- /dev/null +++ b/services/gcpapigateway/store.go @@ -0,0 +1,185 @@ +package gcpapigateway + +import ( + "net/http" + "sync/atomic" + "time" + + "cloud.google.com/go/apigateway/apiv1/apigatewaypb" + "cloud.google.com/go/iam/apiv1/iampb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +// Resource states shared by Api, ApiConfig and Gateway (the enums use the same numbers). +const ( + stateCreating int32 = 1 + stateActive int32 = 2 + stateFailed int32 = 3 + stateDeleting int32 = 4 + stateUpdating int32 = 5 +) + +const defaultCreate = 2 * time.Second + +// Timing controls how long create, update and delete operations take on the backend clock. +type Timing struct { + Create time.Duration + Update time.Duration + Delete time.Duration +} + +// DefaultTiming returns the operation durations used unless a test overrides them. +func DefaultTiming() Timing { + return Timing{Create: defaultCreate, Update: time.Second, Delete: time.Second} +} + +// life tracks the transient state of a resource on the backend clock. +type life struct { + Until time.Time `json:"until"` + DeleteAt time.Time `json:"deleteAt"` + DeleteOp string `json:"deleteOp,omitempty"` + Transient int32 `json:"transient"` +} + +func (l *life) gone(now time.Time) bool { return !l.DeleteAt.IsZero() && !now.Before(l.DeleteAt) } + +func (l *life) deleting() bool { return !l.DeleteAt.IsZero() } + +func (l *life) state(now time.Time, final int32) int32 { + switch { + case l.deleting(): + return stateDeleting + case now.Before(l.Until): + return l.Transient + default: + return final + } +} + +type apiRec struct { + pb *apigatewaypb.Api + policy *iampb.Policy + life life +} + +type configRec struct { + pb *apigatewaypb.ApiConfig + policy *iampb.Policy + spec *compiledSpec + life life +} + +type gatewayRec struct { + pb *apigatewaypb.Gateway + policy *iampb.Policy + life life +} + +// InMemoryBackend holds all API Gateway state under one lock. +type InMemoryBackend struct { + clock atomic.Pointer[func() time.Time] + client *http.Client + mu *lockmetrics.RWMutex + apis map[string]*apiRec + configs map[string]*configRec + gateways map[string]*gatewayRec + ops map[string]*opRec + quota map[string]*quotaCounter + jwks *jwksCache + timing Timing + allowExternal bool + seq uint64 +} + +// NewInMemoryBackend creates an empty backend on the wall clock. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{ + mu: lockmetrics.New("gcpapigateway"), + apis: map[string]*apiRec{}, + configs: map[string]*configRec{}, + gateways: map[string]*gatewayRec{}, + ops: map[string]*opRec{}, + quota: map[string]*quotaCounter{}, + jwks: newJWKSCache(), + timing: DefaultTiming(), + } + b.SetClock(time.Now) + b.client = b.newBackendClient() + + return b +} + +// SetClock replaces the clock lifecycle states, quotas and token validation derive from. +func (b *InMemoryBackend) SetClock(now func() time.Time) { b.clock.Store(&now) } + +func (b *InMemoryBackend) now() time.Time { return (*b.clock.Load())().UTC() } + +// SetTiming replaces the operation durations. +func (b *InMemoryBackend) SetTiming(t Timing) { + b.mu.Lock("SetTiming") + defer b.mu.Unlock() + + b.timing = t +} + +// SetAllowExternal lets gateway backends and remote JWKS URIs reach hosts outside loopback. +func (b *InMemoryBackend) SetAllowExternal(allow bool) { + b.mu.Lock("SetAllowExternal") + defer b.mu.Unlock() + + b.allowExternal = allow +} + +func (b *InMemoryBackend) externalAllowed() bool { + b.mu.RLock("externalAllowed") + defer b.mu.RUnlock() + + return b.allowExternal +} + +func (b *InMemoryBackend) ts() *timestamppb.Timestamp { return timestamppb.New(b.now()) } + +func clone[T proto.Message](m T) T { + out, _ := proto.Clone(m).(T) + + return out +} + +// sweepLocked drops resources whose delete operation has run its course. +func (b *InMemoryBackend) sweepLocked() { + now := b.now() + + for k, r := range b.apis { + if r.life.gone(now) { + delete(b.apis, k) + } + } + + for k, r := range b.configs { + if r.life.gone(now) { + delete(b.configs, k) + } + } + + for k, r := range b.gateways { + if r.life.gone(now) { + delete(b.gateways, k) + } + } +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + defer b.mu.Unlock() + + b.apis, b.configs, b.gateways = map[string]*apiRec{}, map[string]*configRec{}, map[string]*gatewayRec{} + b.ops, b.quota = map[string]*opRec{}, map[string]*quotaCounter{} + b.jwks.reset() +} + +// CloseIdleConnections releases pooled backend connections. +func (b *InMemoryBackend) CloseIdleConnections() { b.client.CloseIdleConnections() } diff --git a/services/gcpartifactregistry/handler.go b/services/gcpartifactregistry/handler.go index 5911decd8c..b9a7522b9d 100644 --- a/services/gcpartifactregistry/handler.go +++ b/services/gcpartifactregistry/handler.go @@ -19,6 +19,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -135,7 +136,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpartifactregistry", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "artifactregistry.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) h.registerServers(srv) h.srvMu.Lock("StartWorker") diff --git a/services/gcpartifactregistry/hosts_test.go b/services/gcpartifactregistry/hosts_test.go index e3cb19ecfa..4c8613fd81 100644 --- a/services/gcpartifactregistry/hosts_test.go +++ b/services/gcpartifactregistry/hosts_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -32,7 +34,7 @@ func (e *env) callHost(t *testing.T, host, method, path, ctype string, body []by } client := &http.Client{ - Transport: http.DefaultTransport.(*http.Transport).Clone(), + Transport: httpclient.NewTransport(), CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } diff --git a/services/gcpartifactregistry/pkgfmt_test.go b/services/gcpartifactregistry/pkgfmt_test.go index 6ef638f932..2c6edfd9a6 100644 --- a/services/gcpartifactregistry/pkgfmt_test.go +++ b/services/gcpartifactregistry/pkgfmt_test.go @@ -16,6 +16,8 @@ import ( "strings" "testing" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,7 +45,7 @@ func (e *env) call(t *testing.T, method, path, ctype string, body []byte) httpRe } client := &http.Client{ - Transport: http.DefaultTransport.(*http.Transport).Clone(), + Transport: httpclient.NewTransport(), CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } diff --git a/services/gcpbatch/PARITY.md b/services/gcpbatch/PARITY.md new file mode 100644 index 0000000000..55709dd4cf --- /dev/null +++ b/services/gcpbatch/PARITY.md @@ -0,0 +1,53 @@ +--- +service: gcpbatch +sdk_module: cloud.google.com/go/batch@v1.24.1 +last_audit_commit: b0763c598 +last_audit_date: 2026-10-30 +overall: B +# Per-op or per-op-family status. Values: ok | partial | gap | deferred. +# wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. +ops: + CreateJob: {wire: ok, errors: partial, state: ok, persist: ok, note: "Returns the Job directly (the SDK method returns *batchpb.Job, not an operation). Exactly one task group (googleapis job.proto: 'Only one TaskGroup is supported now'); task_count defaults to 1 and is the length of task_environments when that is set; parallelism defaults to task_count and must be 1 for IN_ORDER; max_retry_count [0,10]; priority [0,100); runnables need one of container (image_uri), script (text or path) or barrier; lifecycle_policies at most one; secret_variables must name projects/*/secrets/*/versions/*; encrypted_variables (KMS) are rejected; allocation_policy labels, allowed_locations (regions/ or zones/) and service account email are validated and stored. job_id is ^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$ (CreateJobRequest comment: 63 chars, lowercase start, lowercase/digits/'-', no leading or trailing '-'); an empty id is generated as job-{8 hex}. request_id must be a non-zero UUID and a repeat returns the same job. output-only fields (name, uid, status, times) are ignored. Error codes for validation failures and AlreadyExists are not verified against production."} + GetJob: {wire: ok, errors: ok, state: ok, persist: n/a, note: "status.state, status_events (STATUS_CHANGED, 'Job state is set from X to Y for job NAME.'), task_groups counts keyed by group0 with TaskStatus state names, run_duration."} + ListJobs: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Paged through pkgs/page; locations/- spans locations. filter supports status.state=STATE and labels.KEY=VALUE joined by AND (the full AIP-160 grammar is not implemented); order_by name, create_time, update_time with optional desc."} + DeleteJob: {wire: ok, errors: partial, state: ok, persist: ok, note: "Long-running operation with OperationMetadata (verb delete, api_version v1) and an Empty response. The job reads DELETION_IN_PROGRESS for Timing.Delete on the backend clock, running containers are stopped, then it is gone. A second delete is FAILED_PRECONDITION (unverified). reason is ignored."} + CancelJob: {wire: ok, errors: partial, state: ok, persist: ok, note: "Long-running operation with a CancelJobResponse. The job reads CANCELLATION_IN_PROGRESS then CANCELLED after Timing.Cancel; unfinished tasks end UNEXECUTED (pending) or FAILED (running). Cancelling a finished or already-cancelling job is FAILED_PRECONDITION (unverified)."} + GetTask: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Name {job}/taskGroups/group0/tasks/{index}; status.state and status_events carrying task_execution.exit_code on terminal events."} + ListTasks: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Paged; filter State= as documented in ListTasksRequest."} + GetLocation: {wire: ok, errors: ok, state: ok, persist: n/a, note: "A fixed list of public regions; any well-formed location is accepted for jobs."} + ListLocations: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Paged; filter is rejected."} + GetOperation: {wire: ok, errors: ok, state: ok, persist: ok, note: "projects/*/locations/*/operations/*; operations finish on the backend clock."} + ListOperations: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Paged; filter is rejected."} + WaitOperation: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Waits up to min(remaining time, timeout)."} + DeleteOperation: {wire: ok, errors: partial, state: ok, persist: ok, note: "A running operation is FAILED_PRECONDITION (unverified)."} + CancelOperation: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Not served (gRPC UNIMPLEMENTED): delete and cancel operations cannot themselves be cancelled."} +families: + emulator_mode_state_only: {status: ok, note: "Default engine 'none': no container runs. A job is QUEUED, SCHEDULED after Timing.Schedule (1 s), RUNNING after Timing.Start (2 s) and SUCCEEDED when every task finished; tasks run in waves of parallelism, each for Timing.TaskRun (2 s), PENDING until their wave then ASSIGNED and RUNNING together and SUCCEEDED with exit code 0. Progress is derived from the injectable clock on every API call, so tests drive it with a fake clock. Jobs never fail in this mode."} + emulator_mode_docker: {status: ok, note: "--gcp-batch-engine=docker (GCP_BATCH_ENGINE) runs each task in the container runtime used by Cloud Build and Cloud Run: runnables run in order; a non-zero exit fails the task and later runnables are skipped unless always_run; ignore_exit_status keeps the task going; background runnables are started, not awaited, and stopped when the foreground runnables finish; runnable timeout and task max_run_duration end the work with exit code 50005 (troubleshooting page: 'A task or runnable exceeded its time limit'). A task is retried while attempts < max_retry_count; lifecycle_policies follow the TaskSpec comment: RETRY_TASK retries only the listed exit codes, FAIL_TASK fails on them and retries the rest, no policy retries any non-zero exit. A task may not start more than parallelism at a time; IN_ORDER stops launching after the first failure and leaves the rest UNEXECUTED, other policies let remaining tasks finish. A job is FAILED when any task failed. Container runnables map to image_uri, commands (Cmd), entrypoint, volumes (bind specs) and block_external_network (network none); script text runs through the --gcp-batch-script-image container, script path is its entrypoint."} + environment: {status: ok, note: "Layers, lowest first: task_spec.environments (deprecated), task_spec.environment, task_environments[index], runnable.environment (runnable overrides, per the Runnable.environment comment). Within a layer a secret_variables entry overrides variables. secret_variables are read from the Secret Manager emulator when the runnable starts; a missing or unreadable secret fails the task and the message names the secret, never its value. BATCH_TASK_INDEX and BATCH_TASK_COUNT are set last (TaskGroup.task_environments comment). BATCH_HOSTS_FILE is set when require_hosts_file is true (TaskGroup comment) to /etc/cloudbatch-taskgroup-hosts, a file with one emulated host name per task."} + volumes: {status: partial, note: "task_spec.volumes (nfs, gcs, device_name) are validated (absolute mount_path, a source set) and stored. In docker mode each gets a shared local scratch directory bind-mounted at mount_path in every runnable of the job; nothing is synchronised with Cloud Storage, NFS or a disk."} + logs_policy: {status: partial, note: "CLOUD_LOGGING sends container stdout and stderr to the Cloud Logging emulator when it is wired (log batch_task_logs, resource generic_task, labels job_uid and task_id; stderr is severity ERROR). DESTINATION_UNSPECIFIED (the default, 'logs are not preserved') and PATH write nothing; PATH is stored and validated (logs_path required)."} + allocation_policy: {status: partial, note: "Validated and returned unchanged; no VM is created, so machine_type, accelerators, disks, network and placement have no effect and JobStatus.task_groups.instances is empty."} + routing_isolation: {status: ok, note: "Own gRPC server with the REST transcoder bound in StartWorker to --gcp-batch-port / GCP_BATCH_PORT (default 8147, fails fast if taken); never registered on the shared AWS router. cli.go reserves the port in the shared PortAlloc pool."} + auth: {status: partial, note: "No request is authenticated or authorised and no IAM permission is checked."} + observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; RPC failures are logged at debug. No dashboard page."} +items_still_open: + - "Barrier runnables are accepted and stored but are not synchronised across tasks: they are skipped, so tasks do not wait for each other." + - "Compute Engine behaviour is not emulated: no VMs, so allocation_policy has no effect, instances stay empty, tasks are never preempted (exit codes 50001 to 50004 and 50006 cannot occur) and task_count_per_node, permissive_ssh and run_as_non_root are stored only. The 50005 exit code for exceeded run time is from the troubleshooting page." + - "Predefined environment variables other than BATCH_TASK_INDEX, BATCH_TASK_COUNT and BATCH_HOSTS_FILE (for example the retry attempt number or job identity) are not set: the primary pages fetched did not list them. The hosts file content (one name per task) is not documented." + - "Container runnable options, username and password (registry credentials) and enable_image_streaming are stored but ignored; script runnables run in a container image rather than on the VM host, so host paths a script path refers to must exist in that image." + - "Task status event texts other than the documented 'Task state is updated from X to Y' prefix, the TASK_RETRY event type and the retry description are emulator wording; the task event type names and the initial job events are not in a primary source." + - "Job.notifications are stored but no Pub/Sub message is sent." + - "Validation and state-conflict error codes and texts (AlreadyExists, FAILED_PRECONDITION for a repeated delete or cancel, the task_count upper bound of 100000 enforced here) have no primary text; the REST filter grammar of ListJobs is a subset." + - "No IAM policy methods, CancelOperation, dashboard page or proxy." + - "In docker mode a job that was RUNNING when the emulator restarts is marked FAILED on restore (its containers are gone) instead of being resumed." +leaks: {status: clean, note: "Job runners, the per-attempt timer goroutines and container supervisors are tracked by a WaitGroup that Shutdown, Reset and Restore join; containers are removed after each runnable and scratch directories after each job."} +--- + +Initial implementation (2026-10-30): google.cloud.batch.v1 BatchService, Locations and Operations verified against cloud.google.com/go/batch v1.24.1 through the official client in the package tests; docker mode is exercised against a fake runtime in unit tests and a real container runtime in TestDocker*. + +Primary sources used: + +- cloud.google.com/go/batch@v1.24.1/apiv1/batchpb comments: CreateJobRequest.job_id and request_id, TaskGroup (task_count, parallelism, IN_ORDER, task_environments, BATCH_TASK_INDEX, BATCH_TASK_COUNT, BATCH_HOSTS_FILE), TaskSpec (max_retry_count, lifecycle_policies, max_run_duration), Runnable (ignore_exit_status, background, always_run, timeout), LifecyclePolicy actions, ListTasksRequest.filter. +- docs.cloud.google.com/batch/docs/reference/rest/v1/projects.locations.jobs: one TaskGroup, priority [0, 100), labels with goog- and google- reserved, logsPolicy default DESTINATION_UNSPECIFIED (logs not preserved), maxRetryCount [0, 10], parallelism default min(taskCount, job limit). +- docs.cloud.google.com/batch/docs/troubleshooting: exit code 50005 for a task or runnable over its time limit; the 'Task state is updated from PRE-STATE to FAILED on zones/ZONE/instances/ID' event wording. diff --git a/services/gcpbatch/README.md b/services/gcpbatch/README.md new file mode 100644 index 0000000000..5152db826b --- /dev/null +++ b/services/gcpbatch/README.md @@ -0,0 +1,31 @@ + +# Google Cloud Batch + +**Parity grade: B** · SDK `cloud.google.com/go/batch@v1.24.1` · last audited 2026-10-30 (`b0763c598`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 14 (8 ok, 5 partial, 1 gap) | +| Feature families | 9 (4 ok, 5 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Known gaps + +- Barrier runnables are accepted and stored but are not synchronised across tasks: they are skipped, so tasks do not wait for each other. +- Compute Engine behaviour is not emulated: no VMs, so allocation_policy has no effect, instances stay empty, tasks are never preempted (exit codes 50001 to 50004 and 50006 cannot occur) and task_count_per_node, permissive_ssh and run_as_non_root are stored only. The 50005 exit code for exceeded run time is from the troubleshooting page. +- Predefined environment variables other than BATCH_TASK_INDEX, BATCH_TASK_COUNT and BATCH_HOSTS_FILE (for example the retry attempt number or job identity) are not set: the primary pages fetched did not list them. The hosts file content (one name per task) is not documented. +- Container runnable options, username and password (registry credentials) and enable_image_streaming are stored but ignored; script runnables run in a container image rather than on the VM host, so host paths a script path refers to must exist in that image. +- Task status event texts other than the documented 'Task state is updated from X to Y' prefix, the TASK_RETRY event type and the retry description are emulator wording; the task event type names and the initial job events are not in a primary source. +- Job.notifications are stored but no Pub/Sub message is sent. +- Validation and state-conflict error codes and texts (AlreadyExists, FAILED_PRECONDITION for a repeated delete or cancel, the task_count upper bound of 100000 enforced here) have no primary text; the REST filter grammar of ListJobs is a subset. +- No IAM policy methods, CancelOperation, dashboard page or proxy. +- In docker mode a job that was RUNNING when the emulator restarts is marked FAILED on restore (its containers are gone) instead of being resumed. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/gcpbatch/backend.go b/services/gcpbatch/backend.go new file mode 100644 index 0000000000..196de6bdc0 --- /dev/null +++ b/services/gcpbatch/backend.go @@ -0,0 +1,188 @@ +package gcpbatch + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + defaultListPage = 100 + apiVersion = "v1" + verbDelete = "delete" + verbCancel = "cancel" + defaultStart = 2 * time.Second +) + +// Runtime is the slice of the container runtime a task needs. The runtime must also implement +// container.ExitWaiter; it should implement container.LogStreamer (Cloud Logging output). +type Runtime interface { + CreateAndStart(ctx context.Context, spec container.Spec) (string, error) + StopAndRemove(ctx context.Context, containerID string) error +} + +// SecretResolver reads a Secret Manager secret version ("projects/p/secrets/s/versions/v"). +type SecretResolver interface { + AccessSecret(versionName string) ([]byte, error) +} + +// Services are the emulator backends a job reads from and writes to; absent ones stay nil. +type Services struct { + Secrets SecretResolver + Logs gcpserve.LogSink +} + +// Timing sets how long a state-only job takes on the backend clock. Every task runs for TaskRun and at most +// parallelism tasks run at once. +type Timing struct { + Schedule time.Duration + Start time.Duration + TaskRun time.Duration + Cancel time.Duration + Delete time.Duration +} + +// DefaultTiming returns the durations used unless a test overrides them. +func DefaultTiming() Timing { + return Timing{ + Schedule: time.Second, Start: defaultStart, TaskRun: defaultStart, + Cancel: time.Second, Delete: time.Second, + } +} + +// timerFunc starts a timer; the channel fires after d and stop cancels it. +type timerFunc func(d time.Duration) (<-chan time.Time, func() bool) + +func realTimer(d time.Duration) (<-chan time.Time, func() bool) { + t := time.NewTimer(d) + + return t.C, t.Stop +} + +// InMemoryBackend holds all Batch state under one lock. +type InMemoryBackend struct { + clock atomic.Pointer[func() time.Time] + timer atomic.Pointer[timerFunc] + mu *lockmetrics.RWMutex + jobs map[string]*jobRec + ops map[string]*opRec + requests map[string]string + cancels map[string]context.CancelFunc + wg *sync.WaitGroup + rt Runtime + ctx context.Context //nolint:containedctx // parent of every job runner + svc Services + script string + timing Timing +} + +// NewInMemoryBackend creates an empty state-only backend on the wall clock. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{ + mu: lockmetrics.New("gcpbatch"), + jobs: map[string]*jobRec{}, + ops: map[string]*opRec{}, + requests: map[string]string{}, + cancels: map[string]context.CancelFunc{}, + wg: &sync.WaitGroup{}, + ctx: context.Background(), + script: DefaultScriptImage, + timing: DefaultTiming(), + } + b.SetClock(time.Now) + b.SetTimer(realTimer) + + return b +} + +// SetClock replaces the clock lifecycle states and timestamps derive from. +func (b *InMemoryBackend) SetClock(now func() time.Time) { b.clock.Store(&now) } + +// SetTimer replaces the timer behind max_run_duration and runnable timeouts. +func (b *InMemoryBackend) SetTimer(t func(d time.Duration) (<-chan time.Time, func() bool)) { + f := timerFunc(t) + b.timer.Store(&f) +} + +func (b *InMemoryBackend) now() time.Time { return (*b.clock.Load())().UTC() } + +func (b *InMemoryBackend) startTimer(d time.Duration) (<-chan time.Time, func() bool) { + return (*b.timer.Load())(d) +} + +// SetTiming replaces the state-only durations. +func (b *InMemoryBackend) SetTiming(t Timing) { + b.mu.Lock("SetTiming") + defer b.mu.Unlock() + + b.timing = t +} + +// SetContext sets the parent context of job runners; cancelling it stops them. +func (b *InMemoryBackend) SetContext(ctx context.Context) { + b.mu.Lock("SetContext") + defer b.mu.Unlock() + + b.ctx = ctx +} + +// SetRuntime turns the container engine on: new jobs run their runnables as containers. +func (b *InMemoryBackend) SetRuntime(rt Runtime, scriptImage string) { + b.mu.Lock("SetRuntime") + defer b.mu.Unlock() + + b.rt = rt + if scriptImage != "" { + b.script = scriptImage + } +} + +// SetServices connects Secret Manager and Cloud Logging. +func (b *InMemoryBackend) SetServices(s Services) { + b.mu.Lock("SetServices") + defer b.mu.Unlock() + + b.svc = s +} + +// WaitIdle blocks until every job runner has finished. +func (b *InMemoryBackend) WaitIdle() { b.wg.Wait() } + +// Close cancels every running job and waits for the runners to stop. +func (b *InMemoryBackend) Close() { + b.mu.Lock("Close") + + for _, c := range b.cancels { + c() + } + + b.mu.Unlock() + b.wg.Wait() +} + +func (b *InMemoryBackend) ts() *timestamppb.Timestamp { return timestamppb.New(b.now()) } + +func clone[T proto.Message](m T) T { + out, _ := proto.Clone(m).(T) + + return out +} + +// Reset stops every runner and clears all state. +func (b *InMemoryBackend) Reset() { + b.Close() + + b.mu.Lock("Reset") + defer b.mu.Unlock() + + b.jobs, b.ops = map[string]*jobRec{}, map[string]*opRec{} + b.requests, b.cancels = map[string]string{}, map[string]context.CancelFunc{} +} diff --git a/services/gcpbatch/docker_test.go b/services/gcpbatch/docker_test.go new file mode 100644 index 0000000000..cd4aad7c61 --- /dev/null +++ b/services/gcpbatch/docker_test.go @@ -0,0 +1,99 @@ +package gcpbatch_test + +import ( + "log/slog" + "testing" + + "cloud.google.com/go/batch/apiv1/batchpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +const dockerImage = "alpine:3.20" + +func dockerRuntime(t *testing.T) container.Runtime { + t.Helper() + + if testing.Short() { + t.Skip("docker-backed Batch tests are skipped in short mode") + } + + rt, err := container.NewRuntime(container.Config{Logger: slog.Default()}) + if err != nil { + t.Skipf("no container runtime: %v", err) + } + + t.Cleanup(func() { _ = rt.Close() }) + + if err = rt.Ping(t.Context()); err != nil { + t.Skipf("container runtime is not reachable: %v", err) + } + + if err = rt.PullImage(t.Context(), dockerImage); err != nil { + t.Skipf("cannot pull %s: %v", dockerImage, err) + } + + return rt +} + +func TestDockerTwoTaskJob(t *testing.T) { + t.Parallel() + + rt := dockerRuntime(t) + sink := &fakeLogs{} + e := newEnv(t, func(b *gcpbatch.InMemoryBackend) { b.SetRuntime(rt, dockerImage) }, + withServices(gcpbatch.Services{Logs: sink, Secrets: fakeSecrets{"projects/p/secrets/s/versions/1": "s3cret"}})) + + script := &batchpb.Runnable{Executable: &batchpb.Runnable_Script_{Script: &batchpb.Runnable_Script{ + Command: &batchpb.Runnable_Script_Text{ + Text: "#!/bin/sh\necho \"script $BATCH_TASK_INDEX/$BATCH_TASK_COUNT secret=${SECRET}\"\n", + }, + }}} + script.Environment = &batchpb.Environment{ + SecretVariables: map[string]string{"SECRET": "projects/p/secrets/s/versions/1"}, + } + + job := basicJob(2, + containerRunnable(dockerImage, "sh", "-c", `echo "task $BATCH_TASK_INDEX of $BATCH_TASK_COUNT"`), + script, + ) + e.create(t, "docker", job) + + got := e.finish(t, "docker") + require.Equal(t, batchpb.JobStatus_SUCCEEDED, got.GetStatus().GetState()) + assert.Equal(t, []string{"SUCCEEDED", "SUCCEEDED"}, taskStates(e.tasks(t, "docker"))) + + assert.Equal(t, []string{ + "script 0/2 secret=s3cret", "script 1/2 secret=s3cret", "task 0 of 2", "task 1 of 2", + }, sink.texts()) +} + +func TestDockerFailingTaskRetries(t *testing.T) { + t.Parallel() + + rt := dockerRuntime(t) + e := newEnv(t, func(b *gcpbatch.InMemoryBackend) { b.SetRuntime(rt, dockerImage) }) + + job := basicJob(1, containerRunnable(dockerImage, "sh", "-c", "exit 7")) + job.TaskGroups[0].TaskSpec.MaxRetryCount = 1 + e.create(t, "dockerfail", job) + + got := e.finish(t, "dockerfail") + assert.Equal(t, batchpb.JobStatus_FAILED, got.GetStatus().GetState()) + + evs := e.tasks(t, "dockerfail")[0].GetStatus().GetStatusEvents() + assert.Equal(t, int32(7), evs[len(evs)-1].GetTaskExecution().GetExitCode()) + + retries := 0 + + for _, ev := range evs { + if ev.GetType() == "TASK_RETRY" { + retries++ + } + } + + assert.Equal(t, 1, retries) +} diff --git a/services/gcpbatch/engine.go b/services/gcpbatch/engine.go new file mode 100644 index 0000000000..ce7d0abe5a --- /dev/null +++ b/services/gcpbatch/engine.go @@ -0,0 +1,536 @@ +package gcpbatch + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" +) + +const ( + cleanupBudget = 15 * time.Second + logDrainLimit = 10 * time.Second + scratchMode = 0o755 + hostsMode = 0o644 + scriptWrapper = `printf '%s' "$1" > /tmp/batch-script && chmod +x /tmp/batch-script && exec /tmp/batch-script` + exitStartError = 1 +) + +// execEnv is what a job runner needs, captured when the job starts. +type execEnv struct { + rt Runtime + svc Services + script string + scratch string + hosts string + job string + project string + location string + vols []string + logs bool + count int +} + +func (b *InMemoryBackend) startRunnerLocked(r *jobRec) { + ctx, cancel := context.WithCancel(b.ctx) + b.cancels[r.pb.GetName()] = cancel + + project, location, _ := parseJobName(r.pb.GetName()) + ex := &execEnv{ + rt: b.rt, svc: b.svc, script: b.script, job: r.pb.GetName(), project: project, location: location, + count: len(r.Tasks), logs: r.pb.GetLogsPolicy().GetDestination() == batchpb.LogsPolicy_CLOUD_LOGGING, + } + g := clone(r.group()) + par := r.parallelism() + + b.wg.Go(func() { + defer cancel() + b.runJob(ctx, ex, g, par) + }) +} + +func (b *InMemoryBackend) stopRunnerLocked(name string) { + if c, ok := b.cancels[name]; ok { + c() + delete(b.cancels, name) + } +} + +// withJob runs f on the job under the write lock unless the job is gone. +func (b *InMemoryBackend) withJob(name string, f func(r *jobRec)) { + b.mu.Lock("withJob") + defer b.mu.Unlock() + + if r, ok := b.jobs[name]; ok { + f(r) + } +} + +func (b *InMemoryBackend) taskEvent(ex *execEnv, i int, to int32, exit *int32, note string) { + b.withJob(ex.job, func(r *jobRec) { r.setTask(i, to, b.now(), exit, note) }) +} + +func (b *InMemoryBackend) runJob(ctx context.Context, ex *execEnv, g *batchpb.TaskGroup, par int) { + b.withJob(ex.job, func(r *jobRec) { + now := b.now() + r.setPhase(jobScheduled, now) + r.setPhase(jobRunning, now) + }) + + defer func() { _ = os.RemoveAll(ex.scratch) }() + + if err := b.prepare(ex, g); err != nil { + b.failJob(ex, err.Error()) + + return + } + + var ( + failed atomic.Bool + wg sync.WaitGroup + ) + + sem := make(chan struct{}, par) + inOrder := g.GetSchedulingPolicy() == batchpb.TaskGroup_IN_ORDER + +launch: + for i := range ex.count { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + break launch + } + + if inOrder && failed.Load() { + <-sem + + break + } + + wg.Go(func() { + defer func() { <-sem }() + + if !b.runTask(ctx, ex, g, i) { + failed.Store(true) + } + }) + } + + wg.Wait() + b.finishJob(ctx, ex) +} + +func (b *InMemoryBackend) finishJob(ctx context.Context, ex *execEnv) { + b.withJob(ex.job, func(r *jobRec) { + if r.interrupted() || ctx.Err() != nil { + return + } + + now, failed := b.now(), false + + for i, t := range r.Tasks { + if t.State == taskPending || t.State == taskAssigned { + r.setTask(i, taskUnexecuted, now, nil, "an earlier task failed") + } + + failed = failed || t.State == taskFailed + } + + if failed { + r.setPhase(jobFailed, now) + } else { + r.setPhase(jobSucceeded, now) + } + }) +} + +func (b *InMemoryBackend) failJob(ex *execEnv, note string) { + b.withJob(ex.job, func(r *jobRec) { + now := b.now() + for i := range r.Tasks { + r.setTask(i, taskFailed, now, nil, note) + } + + r.setPhase(jobFailed, now) + }) +} + +// prepare creates the scratch directories behind the task spec's volumes and the hosts file. +func (b *InMemoryBackend) prepare(ex *execEnv, g *batchpb.TaskGroup) error { + vols := g.GetTaskSpec().GetVolumes() + if len(vols) == 0 && !g.GetRequireHostsFile() { + return nil + } + + dir, err := os.MkdirTemp("", "gcpbatch-") + if err != nil { + return fmt.Errorf("create scratch dir: %w", err) + } + + ex.scratch = dir + + for i, v := range vols { + p := filepath.Join(dir, fmt.Sprintf("vol%d", i)) + if err = os.MkdirAll(p, scratchMode); err != nil { + return fmt.Errorf("create volume dir: %w", err) + } + + if err = os.Chmod(p, scratchMode|0o022); err != nil { + return fmt.Errorf("chmod volume dir: %w", err) + } + + ex.vols = append(ex.vols, p+":"+v.GetMountPath()) + } + + if g.GetRequireHostsFile() { + var sb strings.Builder + for i := range ex.count { + sb.WriteString("batch-" + ex.job[strings.LastIndex(ex.job, "/")+1:] + "-" + itoa(i) + "\n") + } + + ex.hosts = filepath.Join(dir, "hosts") + if err = os.WriteFile(ex.hosts, []byte(sb.String()), hostsMode); err != nil { + return fmt.Errorf("write hosts file: %w", err) + } + } + + return nil +} + +// runTask runs one task through its attempts and reports whether it succeeded. +func (b *InMemoryBackend) runTask(ctx context.Context, ex *execEnv, g *batchpb.TaskGroup, i int) bool { + b.taskEvent(ex, i, taskAssigned, nil, "") + b.taskEvent(ex, i, taskRunning, nil, "") + + spec := g.GetTaskSpec() + + for attempt := int32(0); ; attempt++ { + exit, note, aborted := b.runAttempt(ctx, ex, g, i) + if aborted { + return false + } + + code := int32(exit) //nolint:gosec // exit statuses fit in int32 + + if exit == 0 { + b.taskEvent(ex, i, taskSucceeded, &code, "") + + return true + } + + if !shouldRetry(spec, exit, attempt) { + b.taskEvent(ex, i, taskFailed, &code, note) + + return false + } + + b.withJob(ex.job, func(r *jobRec) { + t := r.Tasks[i] + t.Attempts++ + t.Events = append(t.Events, eventRec{ + Time: b.now(), Type: eventRetry, Exit: &code, + Desc: fmt.Sprintf("Task attempt %d failed with exit code %d; retrying (%d of %d).", + attempt+1, exit, attempt+1, spec.GetMaxRetryCount()), + }) + }) + } +} + +// shouldRetry applies the task's lifecycle policy: RETRY_TASK retries only the listed exit codes, FAIL_TASK fails +// on them and retries the rest; with no policy any non-zero exit is retried. Retries stop at max_retry_count. +func shouldRetry(spec *batchpb.TaskSpec, exit int, attempt int32) bool { + if attempt >= spec.GetMaxRetryCount() { + return false + } + + if len(spec.GetLifecyclePolicies()) == 0 { + return true + } + + p := spec.GetLifecyclePolicies()[0] + match := false + + for _, c := range p.GetActionCondition().GetExitCodes() { + match = match || int(c) == exit + } + + if p.GetAction() == batchpb.LifecyclePolicy_RETRY_TASK { + return match + } + + return !match +} + +func (b *InMemoryBackend) deadline( + ctx context.Context, d time.Duration, fired *atomic.Bool, +) (context.Context, context.CancelFunc) { + ctx2, cancel := context.WithCancel(ctx) + if d <= 0 { + return ctx2, cancel + } + + ch, stop := b.startTimer(d) + + go func() { + defer stop() + + select { + case <-ch: + fired.Store(true) + cancel() + case <-ctx2.Done(): + } + }() + + return ctx2, cancel +} + +// attemptState tracks one attempt across its runnables. +type attemptState struct { + note string + bg []*proc + exit int + failed bool +} + +// runAttempt runs the task's runnables once. aborted means the job was cancelled, deleted or shut down. +func (b *InMemoryBackend) runAttempt( + ctx context.Context, ex *execEnv, g *batchpb.TaskGroup, i int, +) (int, string, bool) { + var timedOut atomic.Bool + + actx, cancel := b.deadline(ctx, g.GetTaskSpec().GetMaxRunDuration().AsDuration(), &timedOut) + defer cancel() + + st := &attemptState{} + defer func() { stopAll(st.bg) }() + + for n, r := range g.GetTaskSpec().GetRunnables() { + if ctx.Err() != nil { + return 0, "", true + } + + if _, isBarrier := r.GetExecutable().(*batchpb.Runnable_Barrier_); isBarrier { + continue + } + + if (st.failed || actx.Err() != nil) && !r.GetAlwaysRun() { + continue + } + + b.step(actx, ex, g, i, n, r, &timedOut, st) + } + + if ctx.Err() != nil { + return 0, "", true + } + + if timedOut.Load() && !st.failed { + return exitMaxRuntime, "task exceeded max_run_duration", false + } + + return st.exit, st.note, false +} + +// step runs one runnable and folds its outcome into the attempt. +func (b *InMemoryBackend) step( + ctx context.Context, ex *execEnv, g *batchpb.TaskGroup, i, n int, r *batchpb.Runnable, + timedOut *atomic.Bool, st *attemptState, +) { + p, code, msg := b.launch(ctx, ex, g, i, n, r) + if p != nil && r.GetBackground() { + st.bg = append(st.bg, p) + + return + } + + if p != nil { + code, msg = p.wait(ctx) + } + + if timedOut.Load() && code != 0 { + code, msg = exitMaxRuntime, "task or runnable exceeded its time limit" + } + + if code != 0 && !r.GetIgnoreExitStatus() && !st.failed { + st.exit, st.note, st.failed = code, msg, true + } +} + +// proc is a started runnable container. +type proc struct { + cancel context.CancelFunc + done chan struct{} + rt Runtime + id string + note string + exit int + fired atomic.Bool +} + +func stopAll(ps []*proc) { + for _, p := range ps { + p.cancel() + <-p.done + } +} + +// wait blocks until the container exits and removes it. A runnable timeout reports exit 50005. +func (p *proc) wait(ctx context.Context) (int, string) { + select { + case <-p.done: + case <-ctx.Done(): + p.cancel() + <-p.done + } + + if p.fired.Load() { + return exitMaxRuntime, "runnable exceeded its timeout" + } + + return p.exit, p.note +} + +// launch starts one runnable. It returns the process, or an exit code and note when it could not start. +func (b *InMemoryBackend) launch( + ctx context.Context, ex *execEnv, g *batchpb.TaskGroup, i, n int, r *batchpb.Runnable, +) (*proc, int, string) { + vars, err := b.resolveEnv(ex.svc, envLayers(g, i, r)) + if err != nil { + return nil, exitStartError, err.Error() + } + + predefined(g, i, ex.count, vars) + + spec, err := b.runnableSpec(ex, r, vars) + if err != nil { + return nil, exitStartError, err.Error() + } + + p := &proc{done: make(chan struct{}), rt: ex.rt} + rctx, cancel := b.deadline(ctx, r.GetTimeout().AsDuration(), &p.fired) + p.cancel = cancel + + id, err := ex.rt.CreateAndStart(rctx, spec) + if err != nil { + cancel() + + return nil, exitStartError, fmt.Sprintf("start runnable %d: %v", n, err) + } + + p.id = id + + go p.supervise(rctx, ex, i) + + return p, 0, "" +} + +// supervise follows the container's logs, waits for it to exit and removes it. +func (p *proc) supervise(ctx context.Context, ex *execEnv, task int) { + defer close(p.done) + defer p.remove() + + logDone := make(chan struct{}) + + go func() { + defer close(logDone) + + p.streamLogs(ctx, ex, task) + }() + + w, ok := p.rt.(container.ExitWaiter) + if !ok { + p.exit, p.note = exitStartError, container.ErrWaitUnsupported.Error() + + return + } + + code, err := w.WaitExit(ctx, p.id) + p.exit = code + + if err != nil { + p.exit, p.note = max(code, exitStartError), err.Error() + } + + t := time.NewTimer(logDrainLimit) + defer t.Stop() + + select { + case <-logDone: + case <-t.C: + } +} + +func (p *proc) remove() { + ctx, cancel := context.WithTimeout(context.Background(), cleanupBudget) + defer cancel() + + _ = p.rt.StopAndRemove(ctx, p.id) +} + +func (p *proc) streamLogs(ctx context.Context, ex *execEnv, task int) { + if !ex.logs || ex.svc.Logs == nil { + return + } + + sink := taskLogSink{inner: ex.svc.Logs, project: ex.project} + target := gcpserve.LogTarget{ + Project: ex.project, + Resource: taskLogResource(ex), + Labels: map[string]string{"job_uid": ex.job[strings.LastIndex(ex.job, "/")+1:], "task_id": itoa(task)}, + } + + _ = gcpserve.StreamLogs(ctx, p.rt, p.id, target, sink) +} + +func (b *InMemoryBackend) runnableSpec( + ex *execEnv, + r *batchpb.Runnable, + vars map[string]string, +) (container.Spec, error) { + mounts := append([]string(nil), ex.vols...) + if ex.hosts != "" { + mounts = append(mounts, ex.hosts+":"+HostsFilePath+":ro") + } + + spec := container.Spec{ + Env: envList(vars), Mounts: mounts, + Labels: map[string]string{"gopherstack.gcpbatch.job": ex.job}, + } + + switch x := r.GetExecutable().(type) { + case *batchpb.Runnable_Container_: + c := x.Container + spec.Image, spec.Cmd = c.GetImageUri(), c.GetCommands() + spec.Mounts = append(spec.Mounts, c.GetVolumes()...) + + if c.GetEntrypoint() != "" { + spec.Entrypoint = []string{c.GetEntrypoint()} + } + + if c.GetBlockExternalNetwork() { + spec.Network = "none" + } + case *batchpb.Runnable_Script_: + spec.Image = ex.script + + if p := x.Script.GetPath(); p != "" { + spec.Entrypoint = []string{p} + } else { + spec.Entrypoint = []string{"/bin/sh", "-c", scriptWrapper, "sh", x.Script.GetText()} + } + default: + return spec, errUnsupportedRunnable + } + + return spec, nil +} diff --git a/services/gcpbatch/engine_test.go b/services/gcpbatch/engine_test.go new file mode 100644 index 0000000000..ce4e8aa661 --- /dev/null +++ b/services/gcpbatch/engine_test.go @@ -0,0 +1,471 @@ +package gcpbatch_test + +import ( + "context" + "io" + "strconv" + "sync/atomic" + "testing" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +func (e *env) finish(t *testing.T, id string) *batchpb.Job { + t.Helper() + e.backend.WaitIdle() + + return e.get(t, id) +} + +func taskStates(tasks []*batchpb.Task) []string { + out := make([]string, 0, len(tasks)) + for _, task := range tasks { + out = append(out, task.GetStatus().GetState().String()) + } + + return out +} + +func TestEngineTaskEnvironment(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{"ok:1": exitsOK()}) + e := newEnv(t, withRuntime(rt)) + + job := basicJob(3) + g := job.GetTaskGroups()[0] + g.TaskSpec.Environment = &batchpb.Environment{Variables: map[string]string{"A": "spec", "B": "spec"}} + g.TaskEnvironments = []*batchpb.Environment{ + {Variables: map[string]string{"B": "t0"}}, nil, {Variables: map[string]string{"B": "t2"}}, + } + g.TaskCount = 99 + g.TaskSpec.Runnables[0].Environment = &batchpb.Environment{Variables: map[string]string{"C": "run"}} + g.TaskSpec.Runnables[0].Executable = &batchpb.Runnable_Container_{Container: &batchpb.Runnable_Container{ + ImageUri: "ok:1", Entrypoint: "/bin/run", Commands: []string{"a", "b"}, Volumes: []string{"/h:/c:ro"}, + BlockExternalNetwork: true, + }} + g.RequireHostsFile = true + + created := e.create(t, "envs", job) + assert.Equal(t, int64(3), created.GetTaskGroups()[0].GetTaskCount()) + + got := e.finish(t, "envs") + assert.Equal(t, batchpb.JobStatus_SUCCEEDED, got.GetStatus().GetState()) + + specs := rt.specsFor("ok:1") + require.Len(t, specs, 3) + + byIndex := map[string]container.Spec{} + for _, s := range specs { + byIndex[envOf(s, "BATCH_TASK_INDEX")] = s + } + + require.Len(t, byIndex, 3) + + for idx, wantB := range map[string]string{"0": "t0", "1": "spec", "2": "t2"} { + s := byIndex[idx] + assert.Equal(t, "3", envOf(s, "BATCH_TASK_COUNT"), idx) + assert.Equal(t, wantB, envOf(s, "B"), idx) + assert.Equal(t, "spec", envOf(s, "A"), idx) + assert.Equal(t, "run", envOf(s, "C"), idx) + assert.Equal(t, gcpbatch.HostsFilePath, envOf(s, "BATCH_HOSTS_FILE"), idx) + assert.Equal(t, []string{"/bin/run"}, s.Entrypoint) + assert.Equal(t, []string{"a", "b"}, s.Cmd) + assert.Equal(t, "none", s.Network) + assert.Contains(t, s.Mounts, "/h:/c:ro") + } +} + +func TestEngineSecretVariables(t *testing.T) { + t.Parallel() + + const ref = "projects/p/secrets/db/versions/1" + + tests := []struct { + secrets fakeSecrets + name string + want string + state batchpb.JobStatus_State + }{ + {name: "resolved", secrets: fakeSecrets{ref: "hunter2"}, want: "hunter2", state: batchpb.JobStatus_SUCCEEDED}, + {name: "missing secret", secrets: fakeSecrets{}, state: batchpb.JobStatus_FAILED}, + {name: "no secret manager", state: batchpb.JobStatus_FAILED}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{"ok:1": exitsOK()}) + svc := gcpbatch.Services{} + + if tt.secrets != nil { + svc.Secrets = tt.secrets + } + + e := newEnv(t, withRuntime(rt), withServices(svc)) + job := basicJob(1) + job.TaskGroups[0].TaskSpec.Environment = &batchpb.Environment{ + SecretVariables: map[string]string{"DB_PASSWORD": ref}, + } + e.create(t, "sec", job) + + got := e.finish(t, "sec") + assert.Equal(t, tt.state, got.GetStatus().GetState()) + + if tt.want != "" { + assert.Equal(t, tt.want, envOf(rt.specsFor("ok:1")[0], "DB_PASSWORD")) + } + + for _, task := range e.tasks(t, "sec") { + for _, ev := range task.GetStatus().GetStatusEvents() { + assert.NotContains(t, ev.GetDescription(), "hunter2") + } + } + }) + } +} + +func TestEngineRetriesAndLifecyclePolicies(t *testing.T) { + t.Parallel() + + policy := func(a batchpb.LifecyclePolicy_Action, codes ...int32) []*batchpb.LifecyclePolicy { + return []*batchpb.LifecyclePolicy{{ + Action: a, ActionCondition: &batchpb.LifecyclePolicy_ActionCondition{ExitCodes: codes}, + }} + } + + tests := []struct { + name string + policies []*batchpb.LifecyclePolicy + exit int + failFor int32 + retries int32 + wantRuns int32 + want batchpb.TaskStatus_State + }{ + {name: "no retries", exit: 3, failFor: 99, wantRuns: 1, want: batchpb.TaskStatus_FAILED}, + {name: "retry then succeed", exit: 3, failFor: 2, retries: 3, wantRuns: 3, want: batchpb.TaskStatus_SUCCEEDED}, + {name: "retries exhausted", exit: 3, failFor: 99, retries: 2, wantRuns: 3, want: batchpb.TaskStatus_FAILED}, + { + name: "retry matching code", exit: 50001, failFor: 1, retries: 2, wantRuns: 2, + want: batchpb.TaskStatus_SUCCEEDED, + policies: policy(batchpb.LifecyclePolicy_RETRY_TASK, 50001), + }, + { + name: "retry policy ignores other code", exit: 3, failFor: 99, retries: 5, wantRuns: 1, + want: batchpb.TaskStatus_FAILED, + policies: policy(batchpb.LifecyclePolicy_RETRY_TASK, 50001), + }, + { + name: "fail on matching code", exit: 3, failFor: 99, retries: 5, wantRuns: 1, + want: batchpb.TaskStatus_FAILED, + policies: policy(batchpb.LifecyclePolicy_FAIL_TASK, 3), + }, + { + name: "fail policy retries other codes", exit: 4, failFor: 1, retries: 5, wantRuns: 2, + want: batchpb.TaskStatus_SUCCEEDED, + policies: policy(batchpb.LifecyclePolicy_FAIL_TASK, 3), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var runs atomic.Int32 + + rt := newFakeRuntime(map[string]behavior{ + "flaky:1": func(context.Context, container.Spec, io.Writer) int { + if runs.Add(1) <= tt.failFor { + return tt.exit + } + + return 0 + }, + }) + e := newEnv(t, withRuntime(rt)) + job := basicJob(1, containerRunnable("flaky:1")) + job.TaskGroups[0].TaskSpec.MaxRetryCount = tt.retries + job.TaskGroups[0].TaskSpec.LifecyclePolicies = tt.policies + e.create(t, "retry", job) + + got := e.finish(t, "retry") + assert.Equal(t, tt.wantRuns, runs.Load()) + + task := e.tasks(t, "retry")[0] + assert.Equal(t, tt.want, task.GetStatus().GetState()) + + wantJob := batchpb.JobStatus_SUCCEEDED + if tt.want == batchpb.TaskStatus_FAILED { + wantJob = batchpb.JobStatus_FAILED + } + + assert.Equal(t, wantJob, got.GetStatus().GetState()) + + evs := task.GetStatus().GetStatusEvents() + last := evs[len(evs)-1] + assert.Equal(t, tt.want.String() != "SUCCEEDED", last.GetTaskExecution().GetExitCode() != 0) + }) + } +} + +func TestEngineRunnableSemantics(t *testing.T) { + t.Parallel() + + var order atomic.Int32 + + seen := map[string]int32{} + record := func(name string, code int) behavior { + return func(context.Context, container.Spec, io.Writer) int { + seen[name] = order.Add(1) + + return code + } + } + + rt := newFakeRuntime(map[string]behavior{ + "bad:1": record("bad", 7), "after:1": record("after", 0), "always:1": record("always", 0), + "ignored:1": record("ignored", 9), "bgsvc:1": blocks(), + }) + e := newEnv(t, withRuntime(rt)) + + bg := containerRunnable("bgsvc:1") + bg.Background = true + + ignored := containerRunnable("ignored:1") + ignored.IgnoreExitStatus = true + + always := containerRunnable("always:1") + always.AlwaysRun = true + + e.create(t, "sem", basicJob(1, bg, ignored, containerRunnable("bad:1"), containerRunnable("after:1"), always)) + + got := e.finish(t, "sem") + assert.Equal(t, batchpb.JobStatus_FAILED, got.GetStatus().GetState()) + + mu := seen + assert.Contains(t, mu, "ignored") + assert.Contains(t, mu, "bad") + assert.Contains(t, mu, "always") + assert.NotContains(t, mu, "after") + assert.Greater(t, mu["always"], mu["bad"]) + + evs := e.tasks(t, "sem")[0].GetStatus().GetStatusEvents() + assert.Equal(t, int32(7), evs[len(evs)-1].GetTaskExecution().GetExitCode()) +} + +func TestEngineBackgroundRunnableDoesNotFailTask(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{"bg:1": blocks(), "ok:1": exitsOK()}) + e := newEnv(t, withRuntime(rt)) + + bg := containerRunnable("bg:1") + bg.Background = true + e.create(t, "bg", basicJob(2, bg, containerRunnable("ok:1"))) + + got := e.finish(t, "bg") + assert.Equal(t, batchpb.JobStatus_SUCCEEDED, got.GetStatus().GetState()) + assert.Equal(t, []string{"SUCCEEDED", "SUCCEEDED"}, taskStates(e.tasks(t, "bg"))) +} + +func TestEngineScriptRunnables(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{"script:img": exitsOK()}) + e := newEnv(t, withRuntime(rt)) + + text := &batchpb.Runnable{Executable: &batchpb.Runnable_Script_{Script: &batchpb.Runnable_Script{ + Command: &batchpb.Runnable_Script_Text{Text: "echo $BATCH_TASK_INDEX"}, + }}} + path := &batchpb.Runnable{Executable: &batchpb.Runnable_Script_{Script: &batchpb.Runnable_Script{ + Command: &batchpb.Runnable_Script_Path{Path: "/opt/run.sh"}, + }}} + e.create(t, "scripts", basicJob(1, text, path)) + + assert.Equal(t, batchpb.JobStatus_SUCCEEDED, e.finish(t, "scripts").GetStatus().GetState()) + + specs := rt.specsFor("script:img") + require.Len(t, specs, 2) + assert.Equal(t, "echo $BATCH_TASK_INDEX", specs[0].Entrypoint[len(specs[0].Entrypoint)-1]) + assert.Equal(t, []string{"/opt/run.sh"}, specs[1].Entrypoint) +} + +func TestEngineInOrderStopsAfterFailure(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{ + "x:1": func(_ context.Context, s container.Spec, _ io.Writer) int { + if envOf(s, "BATCH_TASK_INDEX") == "1" { + return 2 + } + + return 0 + }, + }) + e := newEnv(t, withRuntime(rt)) + job := basicJob(4, containerRunnable("x:1")) + job.TaskGroups[0].SchedulingPolicy = batchpb.TaskGroup_IN_ORDER + e.create(t, "ordered", job) + + got := e.finish(t, "ordered") + assert.Equal(t, batchpb.JobStatus_FAILED, got.GetStatus().GetState()) + assert.Equal(t, []string{"SUCCEEDED", "FAILED", "UNEXECUTED", "UNEXECUTED"}, taskStates(e.tasks(t, "ordered"))) +} + +func TestEngineParallelFailureLetsOthersFinish(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{ + "x:1": func(_ context.Context, s container.Spec, _ io.Writer) int { + n, _ := strconv.Atoi(envOf(s, "BATCH_TASK_INDEX")) + + return n % 2 + }, + }) + e := newEnv(t, withRuntime(rt)) + e.create(t, "par", basicJob(4, containerRunnable("x:1"))) + + got := e.finish(t, "par") + assert.Equal(t, batchpb.JobStatus_FAILED, got.GetStatus().GetState()) + assert.Equal(t, []string{"SUCCEEDED", "FAILED", "SUCCEEDED", "FAILED"}, taskStates(e.tasks(t, "par"))) + assert.Equal( + t, + map[string]int64{"SUCCEEDED": 2, "FAILED": 2}, + got.GetStatus().GetTaskGroups()["group0"].GetCounts(), + ) +} + +func TestEngineMaxRunDuration(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{"slow:1": blocks()}) + e := newEnv(t, withRuntime(rt), withTimer(func(time.Duration) (<-chan time.Time, func() bool) { + ch := make(chan time.Time, 1) + ch <- time.Time{} + + return ch, func() bool { return true } + })) + job := basicJob(1, containerRunnable("slow:1")) + job.TaskGroups[0].TaskSpec.MaxRunDuration = durationpb.New(time.Minute) + e.create(t, "slow", job) + + got := e.finish(t, "slow") + assert.Equal(t, batchpb.JobStatus_FAILED, got.GetStatus().GetState()) + + evs := e.tasks(t, "slow")[0].GetStatus().GetStatusEvents() + assert.Equal(t, int32(50005), evs[len(evs)-1].GetTaskExecution().GetExitCode()) +} + +func TestEngineRunnableTimeout(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{"slow:1": blocks(), "ok:1": exitsOK()}) + e := newEnv(t, withRuntime(rt), withTimer(func(time.Duration) (<-chan time.Time, func() bool) { + ch := make(chan time.Time, 1) + ch <- time.Time{} + + return ch, func() bool { return true } + })) + slow := containerRunnable("slow:1") + slow.Timeout = durationpb.New(time.Second) + e.create(t, "rto", basicJob(1, slow, containerRunnable("ok:1"))) + + got := e.finish(t, "rto") + assert.Equal(t, batchpb.JobStatus_FAILED, got.GetStatus().GetState()) + assert.Empty(t, rt.specsFor("ok:1")) + + evs := e.tasks(t, "rto")[0].GetStatus().GetStatusEvents() + assert.Equal(t, int32(50005), evs[len(evs)-1].GetTaskExecution().GetExitCode()) +} + +func TestEngineStartFailureFailsTask(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(nil) + e := newEnv(t, withRuntime(rt)) + e.create(t, "nostart", basicJob(1, containerRunnable("missing:1"))) + + got := e.finish(t, "nostart") + assert.Equal(t, batchpb.JobStatus_FAILED, got.GetStatus().GetState()) +} + +func TestEngineCancelStopsRunningTasks(t *testing.T) { + t.Parallel() + + started := make(chan struct{}, 1) + rt := newFakeRuntime(map[string]behavior{ + "hang:1": func(ctx context.Context, _ container.Spec, _ io.Writer) int { + started <- struct{}{} + <-ctx.Done() + + return 137 + }, + }) + e := newEnv(t, withRuntime(rt)) + e.create(t, "stop", basicJob(2, containerRunnable("hang:1"))) + <-started + + _, err := e.client.CancelJob(t.Context(), &batchpb.CancelJobRequest{Name: parent + "/jobs/stop"}) + require.NoError(t, err) + + got := e.finish(t, "stop") + assert.Equal(t, batchpb.JobStatus_CANCELLED, got.GetStatus().GetState()) + + for _, s := range taskStates(e.tasks(t, "stop")) { + assert.Contains(t, []string{"FAILED", "UNEXECUTED"}, s) + } +} + +func TestEngineCloudLogging(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{ + "say:1": func(_ context.Context, s container.Spec, out io.Writer) int { + _, _ = io.WriteString(out, "hello "+envOf(s, "BATCH_TASK_INDEX")+"\n") + + return 0 + }, + }) + + tests := []struct { + name string + logs *batchpb.LogsPolicy + wantLog []string + }{ + { + name: "cloud logging", + logs: &batchpb.LogsPolicy{Destination: batchpb.LogsPolicy_CLOUD_LOGGING}, + wantLog: []string{"hello 0", "hello 1"}, + }, + {name: "unspecified", logs: nil}, + {name: "path", logs: &batchpb.LogsPolicy{Destination: batchpb.LogsPolicy_PATH, LogsPath: "/tmp/x"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + sink := &fakeLogs{} + e := newEnv(t, withRuntime(rt), withServices(gcpbatch.Services{Logs: sink})) + job := basicJob(2, containerRunnable("say:1")) + job.LogsPolicy = tt.logs + e.create(t, "logs", job) + e.finish(t, "logs") + + assert.ElementsMatch(t, tt.wantLog, sink.texts()) + + for _, en := range sink.entries { + assert.Equal(t, "projects/"+project+"/logs/batch_task_logs", en.GetLogName()) + } + }) + } +} diff --git a/services/gcpbatch/env.go b/services/gcpbatch/env.go new file mode 100644 index 0000000000..ecdba065f9 --- /dev/null +++ b/services/gcpbatch/env.go @@ -0,0 +1,80 @@ +package gcpbatch + +import ( + "errors" + "fmt" + "maps" + "slices" + "strconv" + + "cloud.google.com/go/batch/apiv1/batchpb" +) + +const ( + envTaskIndex = "BATCH_TASK_INDEX" + envTaskCount = "BATCH_TASK_COUNT" + envHostsFile = "BATCH_HOSTS_FILE" + // HostsFilePath is where BATCH_HOSTS_FILE points when require_hosts_file is set. + HostsFilePath = "/etc/cloudbatch-taskgroup-hosts" +) + +var errNoSecrets = errors.New("secret manager is not available to this job") + +// envLayers returns the environments that apply to task i, lowest precedence first: task spec, then the task's +// own task_environments entry, then the runnable's. +func envLayers(g *batchpb.TaskGroup, i int, r *batchpb.Runnable) []*batchpb.Environment { + spec := g.GetTaskSpec() + deprecated := spec.GetEnvironments() //nolint:staticcheck // still honoured, below task_spec.environment + layers := []*batchpb.Environment{{Variables: deprecated}, spec.GetEnvironment()} + + if envs := g.GetTaskEnvironments(); i < len(envs) { + layers = append(layers, envs[i]) + } + + return append(layers, r.GetEnvironment()) +} + +// resolveEnv flattens the layers into variables, reading secret_variables from Secret Manager. Within a layer a +// secret variable overrides a plain one of the same name. Error text names the secret, never its value. +func (b *InMemoryBackend) resolveEnv(svc Services, layers []*batchpb.Environment) (map[string]string, error) { + out := map[string]string{} + + for _, l := range layers { + maps.Copy(out, l.GetVariables()) + + for _, name := range slices.Sorted(maps.Keys(l.GetSecretVariables())) { + ref := l.GetSecretVariables()[name] + if svc.Secrets == nil { + return nil, fmt.Errorf("%w (variable %s)", errNoSecrets, name) + } + + v, err := svc.Secrets.AccessSecret(ref) + if err != nil { + return nil, fmt.Errorf("access secret %s for variable %s: %w", ref, name, err) + } + + out[name] = string(v) + } + } + + return out, nil +} + +// predefined are the variables Batch sets on every runnable of a task. +func predefined(g *batchpb.TaskGroup, i, count int, vars map[string]string) { + vars[envTaskIndex] = strconv.Itoa(i) + vars[envTaskCount] = strconv.Itoa(count) + + if g.GetRequireHostsFile() { + vars[envHostsFile] = HostsFilePath + } +} + +func envList(vars map[string]string) []string { + out := make([]string, 0, len(vars)) + for _, k := range slices.Sorted(maps.Keys(vars)) { + out = append(out, k+"="+vars[k]) + } + + return out +} diff --git a/services/gcpbatch/errors.go b/services/gcpbatch/errors.go new file mode 100644 index 0000000000..f58e893d47 --- /dev/null +++ b/services/gcpbatch/errors.go @@ -0,0 +1,24 @@ +package gcpbatch + +import ( + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func errNotFound(format string, a ...any) error { + return status.Error(codes.NotFound, fmt.Sprintf(format, a...)) +} + +func errExists(format string, a ...any) error { + return status.Error(codes.AlreadyExists, fmt.Sprintf(format, a...)) +} + +func errInvalid(format string, a ...any) error { + return status.Error(codes.InvalidArgument, fmt.Sprintf(format, a...)) +} + +func errPrecondition(format string, a ...any) error { + return status.Error(codes.FailedPrecondition, fmt.Sprintf(format, a...)) +} diff --git a/services/gcpbatch/grpc.go b/services/gcpbatch/grpc.go new file mode 100644 index 0000000000..ed33b65033 --- /dev/null +++ b/services/gcpbatch/grpc.go @@ -0,0 +1,94 @@ +package gcpbatch + +import ( + "context" + + "cloud.google.com/go/batch/apiv1/batchpb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/genproto/googleapis/cloud/location" + "google.golang.org/protobuf/types/known/emptypb" +) + +type batchServer struct { + batchpb.UnimplementedBatchServiceServer + b *InMemoryBackend +} + +func (s *batchServer) CreateJob(_ context.Context, in *batchpb.CreateJobRequest) (*batchpb.Job, error) { + return s.b.CreateJob(in) +} + +func (s *batchServer) GetJob(_ context.Context, in *batchpb.GetJobRequest) (*batchpb.Job, error) { + return s.b.GetJob(in.GetName()) +} + +func (s *batchServer) DeleteJob( + _ context.Context, in *batchpb.DeleteJobRequest, +) (*longrunningpb.Operation, error) { + return s.b.DeleteJob(in) +} + +func (s *batchServer) CancelJob( + _ context.Context, in *batchpb.CancelJobRequest, +) (*longrunningpb.Operation, error) { + return s.b.CancelJob(in) +} + +func (s *batchServer) ListJobs( + _ context.Context, in *batchpb.ListJobsRequest, +) (*batchpb.ListJobsResponse, error) { + return s.b.ListJobs(in) +} + +func (s *batchServer) GetTask(_ context.Context, in *batchpb.GetTaskRequest) (*batchpb.Task, error) { + return s.b.GetTask(in.GetName()) +} + +func (s *batchServer) ListTasks( + _ context.Context, in *batchpb.ListTasksRequest, +) (*batchpb.ListTasksResponse, error) { + return s.b.ListTasks(in) +} + +type locationsServer struct { + location.UnimplementedLocationsServer +} + +func (locationsServer) ListLocations( + _ context.Context, in *location.ListLocationsRequest, +) (*location.ListLocationsResponse, error) { + return listLocations(in) +} + +func (locationsServer) GetLocation(_ context.Context, in *location.GetLocationRequest) (*location.Location, error) { + return getLocation(in.GetName()) +} + +type operationsServer struct { + longrunningpb.UnimplementedOperationsServer + b *InMemoryBackend +} + +func (s *operationsServer) GetOperation( + _ context.Context, in *longrunningpb.GetOperationRequest, +) (*longrunningpb.Operation, error) { + return s.b.GetOperation(in.GetName()) +} + +func (s *operationsServer) WaitOperation( + ctx context.Context, in *longrunningpb.WaitOperationRequest, +) (*longrunningpb.Operation, error) { + return s.b.WaitOperation(ctx, in) +} + +func (s *operationsServer) ListOperations( + _ context.Context, in *longrunningpb.ListOperationsRequest, +) (*longrunningpb.ListOperationsResponse, error) { + return s.b.ListOperations(in) +} + +func (s *operationsServer) DeleteOperation( + _ context.Context, in *longrunningpb.DeleteOperationRequest, +) (*emptypb.Empty, error) { + return s.b.DeleteOperation(in.GetName()) +} diff --git a/services/gcpbatch/handler.go b/services/gcpbatch/handler.go new file mode 100644 index 0000000000..38a4bcb0f3 --- /dev/null +++ b/services/gcpbatch/handler.go @@ -0,0 +1,183 @@ +package gcpbatch + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/labstack/echo/v5" + "google.golang.org/genproto/googleapis/cloud/location" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const gracefulStopWait = 2 * time.Second + +// Handler serves the Batch API (gRPC and REST) on a dedicated listener. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *grpc.Server + wg *sync.WaitGroup + addr string + Port int +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, + Port: DefaultPort, + srvMu: lockmetrics.New("gcpbatch.server"), + wg: &sync.WaitGroup{}, + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "GCPBatch" } + +// GetSupportedOperations lists the supported gRPC methods. +func (h *Handler) GetSupportedOperations() []string { + return []string{ + "CreateJob", "GetJob", "ListJobs", "DeleteJob", "CancelJob", "GetTask", "ListTasks", + "GetLocation", "ListLocations", "GetOperation", "ListOperations", "DeleteOperation", "WaitOperation", + } +} + +// RouteMatcher never matches: Batch runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation is unused: requests never reach the shared router. +func (h *Handler) ExtractOperation(*echo.Context) string { return "Unknown" } + +// ExtractResource is unused: requests never reach the shared router. +func (h *Handler) ExtractResource(*echo.Context) string { return "" } + +// Addr returns the bound listener address, or "" before StartWorker. +func (h *Handler) Addr() string { + h.srvMu.RLock("Addr") + defer h.srvMu.RUnlock() + + return h.addr +} + +// Reset clears all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Handler returns a 404 handler; the real API is on the dedicated port. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + return c.String(http.StatusNotFound, "gcpbatch serves gRPC and REST on its dedicated port") + } +} + +type debugLogger interface { + DebugContext(ctx context.Context, msg string, args ...any) +} + +func logUnary(log debugLogger) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { + resp, err := next(ctx, req) + if err != nil && status.Code(err) != codes.OK { + log.DebugContext(ctx, "gcpbatch: rpc failed", "method", info.FullMethod, "error", err) + } + + return resp, err + } +} + +func (h *Handler) registerServers(srv *grpc.Server) { + batchpb.RegisterBatchServiceServer(srv, &batchServer{b: h.Backend}) + location.RegisterLocationsServer(srv, locationsServer{}) + longrunningpb.RegisterOperationsServer(srv, &operationsServer{b: h.Backend}) +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + var lc net.ListenConfig + + listener, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + if err != nil { + return fmt.Errorf("gcpbatch: bind port %d: %w", h.Port, err) + } + + workerCtx := logger.WithWorker(ctx, "gcpbatch", "listener") + log := logger.Load(workerCtx) + + srv := gcpenforce.NewServer(ctx, "batch.googleapis.com", grpc.UnaryInterceptor(logUnary(log))) + h.registerServers(srv) + + h.srvMu.Lock("StartWorker") + h.srv, h.addr = srv, listener.Addr().String() + h.srvMu.Unlock() + + log.InfoContext(workerCtx, "gcpbatch: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := gcprest.Serve(srv, listener); serveErr != nil && + !errors.Is(serveErr, grpc.ErrServerStopped) { + log.ErrorContext(workerCtx, "gcpbatch: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener, forcing a close after a short grace period. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv := h.srv + h.srv = nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + done := make(chan struct{}) + + go func() { + srv.GracefulStop() + close(done) + }() + + grace := time.NewTimer(gracefulStopWait) + defer grace.Stop() + + select { + case <-done: + case <-grace.C: + srv.Stop() + <-done + case <-ctx.Done(): + srv.Stop() + <-done + } + + h.wg.Wait() + h.Backend.Close() +} diff --git a/services/gcpbatch/helpers_test.go b/services/gcpbatch/helpers_test.go new file mode 100644 index 0000000000..7aefab598e --- /dev/null +++ b/services/gcpbatch/helpers_test.go @@ -0,0 +1,326 @@ +package gcpbatch_test + +import ( + "bytes" + "context" + "io" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + batch "cloud.google.com/go/batch/apiv1" + "cloud.google.com/go/batch/apiv1/batchpb" + "cloud.google.com/go/logging/apiv2/loggingpb" + "github.com/stretchr/testify/require" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +const ( + project = "test-project" + region = "us-central1" + parent = "projects/" + project + "/locations/" + region +) + +type fakeClock struct{ ns atomic.Int64 } + +func newClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } + +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *gcpbatch.InMemoryBackend + clock *fakeClock + client *batch.Client +} + +type option2 func(*gcpbatch.InMemoryBackend) + +func newEnv(t *testing.T, opts ...option2) *env { + t.Helper() + + clk := newClock() + b := gcpbatch.NewInMemoryBackend() + b.SetClock(clk.Now) + b.SetTiming(gcpbatch.Timing{}) + + for _, o := range opts { + o(b) + } + + h := gcpbatch.NewHandler(b) + h.Port = 0 + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + conn, err := grpc.NewClient(h.Addr(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + client, err := batch.NewClient(t.Context(), option.WithGRPCConn(conn)) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + return &env{backend: b, clock: clk, client: client} +} + +func withTiming(tm gcpbatch.Timing) option2 { + return func(b *gcpbatch.InMemoryBackend) { b.SetTiming(tm) } +} + +func withRuntime(rt gcpbatch.Runtime) option2 { + return func(b *gcpbatch.InMemoryBackend) { b.SetRuntime(rt, "script:img") } +} + +func withServices(s gcpbatch.Services) option2 { + return func(b *gcpbatch.InMemoryBackend) { b.SetServices(s) } +} + +func withTimer(f func(time.Duration) (<-chan time.Time, func() bool)) option2 { + return func(b *gcpbatch.InMemoryBackend) { b.SetTimer(f) } +} + +func containerRunnable(image string, cmds ...string) *batchpb.Runnable { + return &batchpb.Runnable{Executable: &batchpb.Runnable_Container_{ + Container: &batchpb.Runnable_Container{ImageUri: image, Commands: cmds}, + }} +} + +func basicJob(count int64, rs ...*batchpb.Runnable) *batchpb.Job { + if len(rs) == 0 { + rs = []*batchpb.Runnable{containerRunnable("ok:1")} + } + + return &batchpb.Job{ + TaskGroups: []*batchpb.TaskGroup{{ + TaskCount: count, TaskSpec: &batchpb.TaskSpec{Runnables: rs}, + }}, + LogsPolicy: &batchpb.LogsPolicy{Destination: batchpb.LogsPolicy_CLOUD_LOGGING}, + } +} + +func (e *env) create(t *testing.T, id string, job *batchpb.Job) *batchpb.Job { + t.Helper() + + got, err := e.client.CreateJob(t.Context(), &batchpb.CreateJobRequest{Parent: parent, JobId: id, Job: job}) + require.NoError(t, err) + + return got +} + +func (e *env) get(t *testing.T, id string) *batchpb.Job { + t.Helper() + + got, err := e.client.GetJob(t.Context(), &batchpb.GetJobRequest{Name: parent + "/jobs/" + id}) + require.NoError(t, err) + + return got +} + +func (e *env) tasks(t *testing.T, id string) []*batchpb.Task { + t.Helper() + + it := e.client.ListTasks( + t.Context(), + &batchpb.ListTasksRequest{Parent: parent + "/jobs/" + id + "/taskGroups/group0"}, + ) + + var out []*batchpb.Task + + for { + task, err := it.Next() + if err != nil { + break + } + + out = append(out, task) + } + + return out +} + +// behavior is what a fake container does; its return value is the exit code. +type behavior func(ctx context.Context, spec container.Spec, out io.Writer) int + +type fakeBox struct { + exit chan int + done chan struct{} + buf bytes.Buffer + mu sync.Mutex +} + +func (b *fakeBox) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.Write(p) +} + +// fakeRuntime runs containers in-process, keyed by image name. +type fakeRuntime struct { + behaviors map[string]behavior + boxes map[string]*fakeBox + specs []container.Spec + removed int + mu sync.Mutex + next int +} + +func newFakeRuntime(b map[string]behavior) *fakeRuntime { + return &fakeRuntime{behaviors: b, boxes: map[string]*fakeBox{}} +} + +func (f *fakeRuntime) CreateAndStart(ctx context.Context, spec container.Spec) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + run, ok := f.behaviors[spec.Image] + if !ok { + return "", io.ErrUnexpectedEOF + } + + f.next++ + id := "fake-" + string(rune('a'+f.next%26)) + string(rune('a'+f.next/26)) + box := &fakeBox{exit: make(chan int, 1), done: make(chan struct{})} + f.boxes[id] = box + f.specs = append(f.specs, spec) + + go func() { + code := run(ctx, spec, box) + close(box.done) + box.exit <- code + }() + + return id, nil +} + +func (f *fakeRuntime) StopAndRemove(context.Context, string) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.removed++ + + return nil +} + +func (f *fakeRuntime) box(id string) *fakeBox { + f.mu.Lock() + defer f.mu.Unlock() + + return f.boxes[id] +} + +func (f *fakeRuntime) WaitExit(ctx context.Context, id string) (int, error) { + select { + case code := <-f.box(id).exit: + return code, nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (f *fakeRuntime) StreamLogs(ctx context.Context, id string, stdout, _ io.Writer) error { + box := f.box(id) + + select { + case <-box.done: + case <-ctx.Done(): + } + + box.mu.Lock() + defer box.mu.Unlock() + + _, err := stdout.Write(box.buf.Bytes()) + + return err +} + +func (f *fakeRuntime) specsFor(image string) []container.Spec { + f.mu.Lock() + defer f.mu.Unlock() + + var out []container.Spec + + for _, s := range f.specs { + if s.Image == image { + out = append(out, s) + } + } + + return out +} + +func envOf(spec container.Spec, key string) string { + for _, kv := range spec.Env { + if v, ok := strings.CutPrefix(kv, key+"="); ok { + return v + } + } + + return "" +} + +func exitsOK() behavior { + return func(context.Context, container.Spec, io.Writer) int { return 0 } +} + +func blocks() behavior { + return func(ctx context.Context, _ container.Spec, _ io.Writer) int { + <-ctx.Done() + + return 137 + } +} + +type fakeSecrets map[string]string + +func (f fakeSecrets) AccessSecret(name string) ([]byte, error) { + v, ok := f[name] + if !ok { + return nil, io.ErrUnexpectedEOF + } + + return []byte(v), nil +} + +type fakeLogs struct { + entries []*loggingpb.LogEntry + mu sync.Mutex +} + +func (l *fakeLogs) WriteLogEntries(_ context.Context, req *loggingpb.WriteLogEntriesRequest) error { + l.mu.Lock() + defer l.mu.Unlock() + + l.entries = append(l.entries, req.GetEntries()...) + + return nil +} + +func (l *fakeLogs) texts() []string { + l.mu.Lock() + defer l.mu.Unlock() + + out := make([]string, 0, len(l.entries)) + for _, e := range l.entries { + out = append(out, e.GetTextPayload()) + } + + slices.Sort(out) + + return out +} diff --git a/services/gcpbatch/inproc.go b/services/gcpbatch/inproc.go new file mode 100644 index 0000000000..8ac2453ac5 --- /dev/null +++ b/services/gcpbatch/inproc.go @@ -0,0 +1,41 @@ +package gcpbatch + +import ( + "slices" + "strings" + + "cloud.google.com/go/batch/apiv1/batchpb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" +) + +// API returns the Batch service server for in-process callers. +func (h *Handler) API() batchpb.BatchServiceServer { return &batchServer{b: h.Backend} } + +// OperationsAPI returns the long-running operations server for in-process callers. +func (h *Handler) OperationsAPI() longrunningpb.OperationsServer { + return &operationsServer{b: h.Backend} +} + +// ProjectIDs lists the projects that own at least one job, sorted. +func (b *InMemoryBackend) ProjectIDs() []string { + b.mu.RLock("ProjectIDs") + defer b.mu.RUnlock() + + seen := map[string]bool{} + + for n := range b.jobs { + if rest, ok := strings.CutPrefix(n, "projects/"); ok { + id, _, _ := strings.Cut(rest, "/") + seen[id] = true + } + } + + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + + slices.Sort(out) + + return out +} diff --git a/services/gcpbatch/jobs.go b/services/gcpbatch/jobs.go new file mode 100644 index 0000000000..a853b9e728 --- /dev/null +++ b/services/gcpbatch/jobs.go @@ -0,0 +1,341 @@ +package gcpbatch + +import ( + "strings" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/google/uuid" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" +) + +const ( + maxTaskCount = 100000 + idHexLen = 8 + zeroUUID = "00000000-0000-0000-0000-000000000000" +) + +func (r *jobRec) setPhase(to int32, at time.Time) { + from := r.Phase + r.Phase = to + r.Events = append(r.Events, eventRec{ + Time: at, Type: eventStatusChanged, + Desc: "Job state is set from " + stateName(from) + " to " + stateName(to) + " for job " + r.pb.GetName() + ".", + }) + + switch to { + case jobRunning: + r.StartedAt = at + case jobSucceeded, jobFailed, jobCancelled, jobCancelling, jobDeleting: + if r.EndedAt.IsZero() && !r.StartedAt.IsZero() { + r.EndedAt = at + } + } +} + +// setTask moves a task to a new state unless it already finished. +func (r *jobRec) setTask(i int, to int32, at time.Time, exit *int32, note string) { + t := r.Tasks[i] + if t.terminal() { + return + } + + desc := "Task state is updated from " + taskStateName(t.State) + " to " + taskStateName(to) + if note != "" { + desc += ": " + note + } + + t.State = to + t.Events = append(t.Events, eventRec{Time: at, Type: eventStatusChanged, Desc: desc, Exit: exit}) +} + +// interrupt ends every unfinished task because the job was cancelled or deleted. +func (r *jobRec) interrupt(at time.Time) { + for i, t := range r.Tasks { + switch t.State { + case taskPending, taskAssigned: + r.setTask(i, taskUnexecuted, at, nil, "job "+strings.ToLower(stateName(r.Phase))) + case taskRunning: + r.setTask(i, taskFailed, at, nil, "task stopped because the job was cancelled or deleted") + } + } +} + +func (r *jobRec) parallelism() int { + p := r.Parallel + if p <= 0 || p > int64(len(r.Tasks)) { + p = int64(len(r.Tasks)) + } + + return int(max(p, 1)) +} + +// advanceStateOnly applies the planned timeline of a job that runs no containers. +func (r *jobRec) advanceStateOnly(now time.Time) { + if r.Phase == jobQueued && !now.Before(r.ScheduledAt) { + r.setPhase(jobScheduled, r.ScheduledAt) + } + + if r.Phase == jobScheduled && !now.Before(r.RunningAt) { + r.setPhase(jobRunning, r.RunningAt) + } + + if r.Phase != jobRunning { + return + } + + par, run := r.parallelism(), time.Duration(r.TaskRun) + last, done := r.RunningAt, 0 + + for i := range r.Tasks { + start := r.RunningAt.Add(time.Duration(i/par) * run) + end := start.Add(run) + + if !now.Before(start) { + r.setTask(i, taskAssigned, start, nil, "") + r.setTask(i, taskRunning, start, nil, "") + } + + if !now.Before(end) { + zero := int32(0) + r.setTask(i, taskSucceeded, end, &zero, "") + + last = end + done++ + } + } + + if done == len(r.Tasks) { + r.setPhase(jobSucceeded, last) + } +} + +// advanceLocked settles cancel, delete and state-only progress on the clock. +func (b *InMemoryBackend) advanceLocked() { + now := b.now() + + for name, r := range b.jobs { + switch { + case r.Deleting: + if !now.Before(r.DeleteDone) { + delete(b.jobs, name) + } + case r.Cancelled: + if r.Phase != jobCancelled && !now.Before(r.CancelDone) { + r.setPhase(jobCancelled, r.CancelDone) + } + case !r.Engine && !r.finished(): + r.advanceStateOnly(now) + } + } +} + +func (b *InMemoryBackend) liveJobLocked(name string) (*jobRec, error) { + if _, _, err := parseJobName(name); err != nil { + return nil, err + } + + r, ok := b.jobs[name] + if !ok { + return nil, errNotFound("Job %q not found.", name) + } + + return r, nil +} + +func newJobID() string { return awsid.NewN("job-", idHexLen) } + +func (b *InMemoryBackend) buildJobLocked(name string, src *batchpb.Job) *jobRec { + pb := clone(src) + now := b.ts() + pb.Name, pb.CreateTime, pb.UpdateTime, pb.Status = name, now, now, nil + pb.Uid = name[strings.LastIndex(name, "/")+1:] + "-" + uuid.NewString() + + g := pb.GetTaskGroups()[0] + g.Name = groupFullName(name) + + if n := len(g.GetTaskEnvironments()); n > 0 { + g.TaskCount = int64(n) + } + + if g.GetTaskCount() == 0 { + g.TaskCount = 1 + } + + if g.GetSchedulingPolicy() == batchpb.TaskGroup_IN_ORDER { + g.Parallelism = 1 + } else if g.GetParallelism() == 0 { + g.Parallelism = g.GetTaskCount() + } + + g.Parallelism = min(g.GetParallelism(), g.GetTaskCount()) + + created := b.now() + r := &jobRec{ + pb: pb, Phase: jobQueued, Parallel: g.GetParallelism(), Engine: b.rt != nil, + Tasks: make([]*taskRec, g.GetTaskCount()), + ScheduledAt: created.Add(b.timing.Schedule), + TaskRun: int64(b.timing.TaskRun), + } + r.RunningAt = r.ScheduledAt.Add(b.timing.Start) + + for i := range r.Tasks { + r.Tasks[i] = &taskRec{State: taskPending} + } + + return r +} + +// validateCreate checks a CreateJobRequest and returns the project, location and (possibly generated) id. +func validateCreate(in *batchpb.CreateJobRequest) (string, string, string, error) { + project, location, err := parseParent(in.GetParent()) + if err != nil { + return "", "", "", err + } + + if location == anyLocation { + return "", "", "", errInvalid("Invalid parent %q: a concrete location is required.", in.GetParent()) + } + + id := in.GetJobId() + if id == "" { + id = newJobID() + } else if !jobIDRe.MatchString(id) { + return "", "", "", errInvalid("Invalid job_id %q: must match %s.", id, jobIDRe.String()) + } + + if rid := in.GetRequestId(); rid != "" && (!requestRe.MatchString(rid) || rid == zeroUUID) { + return "", "", "", errInvalid("request_id must be a valid non-zero UUID.") + } + + if err = validateJob(in.GetJob()); err != nil { + return "", "", "", err + } + + if in.GetJob().GetTaskGroups()[0].GetTaskCount() > maxTaskCount { + return "", "", "", errInvalid("task_count must be at most %d.", maxTaskCount) + } + + return project, location, id, nil +} + +// CreateJob validates and records a job. It returns the job without a long-running operation, as the API does. +func (b *InMemoryBackend) CreateJob(in *batchpb.CreateJobRequest) (*batchpb.Job, error) { + project, location, id, err := validateCreate(in) + if err != nil { + return nil, err + } + + b.mu.Lock("CreateJob") + defer b.mu.Unlock() + + b.advanceLocked() + + name := jobName(project, location, id) + reqKey := in.GetParent() + "|" + in.GetRequestId() + + if in.GetRequestId() != "" { + if r, live := b.jobs[b.requests[reqKey]]; live { + return r.view(b.now()), nil + } + } + + if _, ok := b.jobs[name]; ok { + return nil, errExists("Job %q already exists.", name) + } + + r := b.buildJobLocked(name, in.GetJob()) + b.jobs[name] = r + + if in.GetRequestId() != "" { + b.requests[reqKey] = name + } + + if r.Engine { + b.startRunnerLocked(r) + } + + b.advanceLocked() + + return r.view(b.now()), nil +} + +// GetJob returns one job. +func (b *InMemoryBackend) GetJob(name string) (*batchpb.Job, error) { + b.mu.Lock("GetJob") + defer b.mu.Unlock() + + b.advanceLocked() + + r, err := b.liveJobLocked(name) + if err != nil { + return nil, err + } + + return r.view(b.now()), nil +} + +// DeleteJob starts deletion: the job reads DELETION_IN_PROGRESS until the operation ends, then is gone. +func (b *InMemoryBackend) DeleteJob(in *batchpb.DeleteJobRequest) (*longrunningpb.Operation, error) { + b.mu.Lock("DeleteJob") + defer b.mu.Unlock() + + b.advanceLocked() + + r, err := b.liveJobLocked(in.GetName()) + if err != nil { + return nil, err + } + + if r.Deleting { + return nil, errPrecondition("Job %q is already being deleted.", in.GetName()) + } + + now := b.now() + b.stopRunnerLocked(in.GetName()) + r.interrupt(now) + r.Deleting = true + r.DeleteDone = now.Add(b.timing.Delete) + r.setPhase(jobDeleting, now) + + project, location, _ := parseJobName(in.GetName()) + + return b.newOpLocked(opSpec{ + project: project, location: location, target: in.GetName(), verb: verbDelete, + end: r.DeleteDone, resp: &emptypb.Empty{}, + }) +} + +// CancelJob starts cancellation: the job reads CANCELLATION_IN_PROGRESS, then CANCELLED. +func (b *InMemoryBackend) CancelJob(in *batchpb.CancelJobRequest) (*longrunningpb.Operation, error) { + b.mu.Lock("CancelJob") + defer b.mu.Unlock() + + b.advanceLocked() + + r, err := b.liveJobLocked(in.GetName()) + if err != nil { + return nil, err + } + + if r.finished() || r.interrupted() { + return nil, errPrecondition("Job %q is in state %s and cannot be cancelled.", in.GetName(), stateName(r.Phase)) + } + + now := b.now() + b.stopRunnerLocked(in.GetName()) + r.interrupt(now) + r.Cancelled = true + r.CancelDone = now.Add(b.timing.Cancel) + r.setPhase(jobCancelling, now) + + project, location, _ := parseJobName(in.GetName()) + + return b.newOpLocked(opSpec{ + project: project, location: location, target: in.GetName(), verb: verbCancel, + end: r.CancelDone, resp: &batchpb.CancelJobResponse{}, + }) +} diff --git a/services/gcpbatch/jobs_test.go b/services/gcpbatch/jobs_test.go new file mode 100644 index 0000000000..eaaa7ee62f --- /dev/null +++ b/services/gcpbatch/jobs_test.go @@ -0,0 +1,480 @@ +package gcpbatch_test + +import ( + "strings" + "testing" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +func TestCreateJobValidation(t *testing.T) { + t.Parallel() + + mod := func(f func(j *batchpb.Job)) *batchpb.Job { + j := basicJob(1) + f(j) + + return j + } + + tests := []struct { + job *batchpb.Job + name string + id string + extra string + want codes.Code + }{ + {name: "valid", job: basicJob(2), id: "ok-1", want: codes.OK}, + {name: "no task group", job: &batchpb.Job{}, id: "a", want: codes.InvalidArgument}, + {name: "two task groups", job: mod(func(j *batchpb.Job) { + j.TaskGroups = append(j.GetTaskGroups(), j.GetTaskGroups()[0]) + }), id: "a", want: codes.InvalidArgument, extra: "Only one TaskGroup"}, + {name: "no runnables", job: mod(func(j *batchpb.Job) { + j.TaskGroups[0].TaskSpec.Runnables = nil + }), id: "a", want: codes.InvalidArgument}, + { + name: "container without image", + job: basicJob(1, containerRunnable("")), + id: "a", + want: codes.InvalidArgument, + }, + {name: "empty script", job: basicJob(1, &batchpb.Runnable{Executable: &batchpb.Runnable_Script_{ + Script: &batchpb.Runnable_Script{}, + }}), id: "a", want: codes.InvalidArgument}, + {name: "retry out of range", job: mod(func(j *batchpb.Job) { + j.TaskGroups[0].TaskSpec.MaxRetryCount = 11 + }), id: "a", want: codes.InvalidArgument}, + {name: "in order parallelism", job: mod(func(j *batchpb.Job) { + j.TaskGroups[0].SchedulingPolicy = batchpb.TaskGroup_IN_ORDER + j.TaskGroups[0].Parallelism = 2 + }), id: "a", want: codes.InvalidArgument}, + {name: "bad secret ref", job: mod(func(j *batchpb.Job) { + j.TaskGroups[0].TaskSpec.Environment = &batchpb.Environment{SecretVariables: map[string]string{"A": "nope"}} + }), id: "a", want: codes.InvalidArgument}, + {name: "bad policy", job: mod(func(j *batchpb.Job) { + j.TaskGroups[0].TaskSpec.LifecyclePolicies = []*batchpb.LifecyclePolicy{{ + Action: batchpb.LifecyclePolicy_RETRY_TASK, + }} + }), id: "a", want: codes.InvalidArgument}, + {name: "priority", job: mod(func(j *batchpb.Job) { j.Priority = 100 }), id: "a", want: codes.InvalidArgument}, + {name: "reserved label", job: mod(func(j *batchpb.Job) { + j.Labels = map[string]string{"goog-x": "y"} + }), id: "a", want: codes.InvalidArgument}, + {name: "path logs without path", job: mod(func(j *batchpb.Job) { + j.LogsPolicy = &batchpb.LogsPolicy{Destination: batchpb.LogsPolicy_PATH} + }), id: "a", want: codes.InvalidArgument}, + {name: "bad allocation location", job: mod(func(j *batchpb.Job) { + j.AllocationPolicy = &batchpb.AllocationPolicy{Location: &batchpb.AllocationPolicy_LocationPolicy{ + AllowedLocations: []string{"us-central1"}, + }} + }), id: "a", want: codes.InvalidArgument}, + {name: "bad id", job: basicJob(1), id: "Bad_ID", want: codes.InvalidArgument}, + {name: "id ends with dash", job: basicJob(1), id: "abc-", want: codes.InvalidArgument}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.client.CreateJob( + t.Context(), + &batchpb.CreateJobRequest{Parent: parent, JobId: tt.id, Job: tt.job}, + ) + assert.Equal(t, tt.want, status.Code(err)) + + if tt.extra != "" { + assert.Contains(t, err.Error(), tt.extra) + } + }) + } +} + +func TestCreateJobDefaultsAndIdentity(t *testing.T) { + t.Parallel() + + e := newEnv(t, withTiming(gcpbatch.Timing{Schedule: time.Minute})) + job := basicJob(4) + job.Labels = map[string]string{"team": "data"} + job.Name, job.Uid = "ignored", "ignored" + got := e.create(t, "defaults", job) + + assert.Equal(t, parent+"/jobs/defaults", got.GetName()) + assert.True(t, strings.HasPrefix(got.GetUid(), "defaults-")) + assert.Equal(t, parent+"/jobs/defaults/taskGroups/group0", got.GetTaskGroups()[0].GetName()) + assert.Equal(t, int64(4), got.GetTaskGroups()[0].GetParallelism()) + assert.Equal(t, batchpb.JobStatus_QUEUED, got.GetStatus().GetState()) + assert.Equal(t, map[string]int64{"PENDING": 4}, got.GetStatus().GetTaskGroups()["group0"].GetCounts()) + assert.NotNil(t, got.GetCreateTime()) + + gen, err := e.client.CreateJob(t.Context(), &batchpb.CreateJobRequest{Parent: parent, Job: basicJob(1)}) + require.NoError(t, err) + assert.Contains(t, gen.GetName(), parent+"/jobs/job-") + + _, err = e.client.CreateJob( + t.Context(), + &batchpb.CreateJobRequest{Parent: parent, JobId: "defaults", Job: basicJob(1)}, + ) + assert.Equal(t, codes.AlreadyExists, status.Code(err)) + + _, err = e.client.GetJob(t.Context(), &batchpb.GetJobRequest{Name: parent + "/jobs/missing"}) + assert.Equal(t, codes.NotFound, status.Code(err)) + + _, err = e.client.GetJob(t.Context(), &batchpb.GetJobRequest{Name: "bogus"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) +} + +func TestCreateJobRequestIDIdempotent(t *testing.T) { + t.Parallel() + + e := newEnv(t) + req := &batchpb.CreateJobRequest{ + Parent: parent, JobId: "idem", Job: basicJob(1), RequestId: "6f1c1d6e-3b0e-4f5e-8f55-0c6d3b2b1a10", + } + + first, err := e.client.CreateJob(t.Context(), req) + require.NoError(t, err) + + second, err := e.client.CreateJob(t.Context(), req) + require.NoError(t, err) + assert.Equal(t, first.GetUid(), second.GetUid()) + + req.RequestId = "not-a-uuid" + _, err = e.client.CreateJob(t.Context(), req) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) +} + +func TestStateOnlyProgression(t *testing.T) { + t.Parallel() + + e := newEnv(t, withTiming(gcpbatch.Timing{ + Schedule: 10 * time.Second, Start: 20 * time.Second, TaskRun: 30 * time.Second, + })) + job := basicJob(3) + job.TaskGroups[0].Parallelism = 2 + e.create(t, "prog", job) + + counts := func() map[string]int64 { + return e.get(t, "prog").GetStatus().GetTaskGroups()["group0"].GetCounts() + } + + steps := []struct { + counts map[string]int64 + state batchpb.JobStatus_State + after time.Duration + }{ + {state: batchpb.JobStatus_QUEUED, counts: map[string]int64{"PENDING": 3}}, + {state: batchpb.JobStatus_SCHEDULED, after: 10 * time.Second, counts: map[string]int64{"PENDING": 3}}, + { + state: batchpb.JobStatus_RUNNING, + after: 20 * time.Second, + counts: map[string]int64{"RUNNING": 2, "PENDING": 1}, + }, + { + state: batchpb.JobStatus_RUNNING, + after: 30 * time.Second, + counts: map[string]int64{"SUCCEEDED": 2, "RUNNING": 1}, + }, + {state: batchpb.JobStatus_SUCCEEDED, after: 30 * time.Second, counts: map[string]int64{"SUCCEEDED": 3}}, + } + + for _, s := range steps { + e.clock.Advance(s.after) + + got := e.get(t, "prog") + assert.Equal(t, s.state, got.GetStatus().GetState(), s.state.String()) + assert.Equal(t, s.counts, counts(), s.state.String()) + } + + final := e.get(t, "prog") + assert.Equal(t, 60*time.Second, final.GetStatus().GetRunDuration().AsDuration()) + + changes := make([]string, 0, len(final.GetStatus().GetStatusEvents())) + for _, ev := range final.GetStatus().GetStatusEvents() { + assert.Equal(t, "STATUS_CHANGED", ev.GetType()) + changes = append(changes, ev.GetDescription()) + } + + assert.Equal(t, []string{ + "Job state is set from QUEUED to SCHEDULED for job " + parent + "/jobs/prog.", + "Job state is set from SCHEDULED to RUNNING for job " + parent + "/jobs/prog.", + "Job state is set from RUNNING to SUCCEEDED for job " + parent + "/jobs/prog.", + }, changes) + + for _, task := range e.tasks(t, "prog") { + assert.Equal(t, batchpb.TaskStatus_SUCCEEDED, task.GetStatus().GetState()) + last := task.GetStatus().GetStatusEvents() + assert.Equal(t, int32(0), last[len(last)-1].GetTaskExecution().GetExitCode()) + } +} + +func TestTasksListAndGet(t *testing.T) { + t.Parallel() + + e := newEnv(t, withTiming(gcpbatch.Timing{Start: time.Second, TaskRun: time.Minute})) + job := basicJob(3) + job.TaskGroups[0].Parallelism = 1 + e.create(t, "tl", job) + e.clock.Advance(time.Second) + + group := parent + "/jobs/tl/taskGroups/group0" + + tests := []struct { + name string + filter string + want []string + code codes.Code + }{ + {name: "all", want: []string{"RUNNING", "PENDING", "PENDING"}}, + {name: "running", filter: "State=RUNNING", want: []string{"RUNNING"}}, + {name: "pending", filter: "State=PENDING", want: []string{"PENDING", "PENDING"}}, + {name: "bad filter", filter: "foo", code: codes.InvalidArgument}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + it := e.client.ListTasks(t.Context(), &batchpb.ListTasksRequest{Parent: group, Filter: tt.filter}) + + var got []string + + var err error + + for { + var task *batchpb.Task + + task, err = it.Next() + if err != nil { + break + } + + got = append(got, task.GetStatus().GetState().String()) + } + + if tt.code != codes.OK { + assert.Equal(t, tt.code, status.Code(err)) + + return + } + + assert.Equal(t, tt.want, got) + }) + } + + task, err := e.client.GetTask(t.Context(), &batchpb.GetTaskRequest{Name: group + "/tasks/0"}) + require.NoError(t, err) + assert.Equal(t, batchpb.TaskStatus_RUNNING, task.GetStatus().GetState()) + + missing := []string{group + "/tasks/9", group + "/tasks/x", parent + "/jobs/none/taskGroups/group0/tasks/0"} + + for _, name := range missing { + _, err = e.client.GetTask(t.Context(), &batchpb.GetTaskRequest{Name: name}) + assert.Equal(t, codes.NotFound, status.Code(err), name) + } + + it := e.client.ListTasks(t.Context(), &batchpb.ListTasksRequest{Parent: parent + "/jobs/tl/taskGroups/other"}) + _, err = it.Next() + assert.Equal(t, codes.NotFound, status.Code(err)) +} + +func TestListJobsFilterOrderPage(t *testing.T) { + t.Parallel() + + e := newEnv(t, withTiming(gcpbatch.Timing{Schedule: time.Hour})) + + for _, id := range []string{"c-job", "a-job", "b-job"} { + j := basicJob(1) + j.Labels = map[string]string{"env": "prod"} + + if id == "b-job" { + j.Labels["env"] = "dev" + } + + e.create(t, id, j) + e.clock.Advance(time.Second) + } + + list := func(req *batchpb.ListJobsRequest) ([]string, error) { + req.Parent = parent + it := e.client.ListJobs(t.Context(), req) + + var names []string + + for { + j, err := it.Next() + if err != nil { + if err.Error() != "no more items in iterator" { + return names, err + } + + return names, nil + } + + names = append(names, j.GetName()[len(parent)+len("/jobs/"):]) + } + } + + tests := []struct { + name string + req *batchpb.ListJobsRequest + want []string + code codes.Code + }{ + {name: "by name", req: &batchpb.ListJobsRequest{}, want: []string{"a-job", "b-job", "c-job"}}, + { + name: "create time desc", + req: &batchpb.ListJobsRequest{OrderBy: "create_time desc"}, + want: []string{"b-job", "a-job", "c-job"}, + }, + { + name: "label filter", + req: &batchpb.ListJobsRequest{Filter: "labels.env=prod"}, + want: []string{"a-job", "c-job"}, + }, + { + name: "state filter", + req: &batchpb.ListJobsRequest{Filter: `status.state="QUEUED" AND labels.env=dev`}, + want: []string{"b-job"}, + }, + {name: "no match", req: &batchpb.ListJobsRequest{Filter: `status.state="RUNNING"`}, want: nil}, + {name: "bad filter", req: &batchpb.ListJobsRequest{Filter: "bogus"}, code: codes.InvalidArgument}, + {name: "bad state", req: &batchpb.ListJobsRequest{Filter: "status.state=NOPE"}, code: codes.InvalidArgument}, + {name: "bad order", req: &batchpb.ListJobsRequest{OrderBy: "color"}, code: codes.InvalidArgument}, + {name: "page size", req: &batchpb.ListJobsRequest{PageSize: 1}, want: []string{"a-job", "b-job", "c-job"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := list(tt.req) + if tt.code != codes.OK { + assert.Equal(t, tt.code, status.Code(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + every := e.client.ListJobs(t.Context(), &batchpb.ListJobsRequest{Parent: "projects/" + project + "/locations/-"}) + n := 0 + + for { + if _, err := every.Next(); err != nil { + break + } + + n++ + } + + assert.Equal(t, 3, n) +} + +func TestCancelJob(t *testing.T) { + t.Parallel() + + e := newEnv(t, withTiming(gcpbatch.Timing{ + Schedule: time.Second, Start: time.Second, TaskRun: time.Minute, Cancel: 5 * time.Second, + })) + e.create(t, "cx", basicJob(2)) + e.clock.Advance(2 * time.Second) + + op, err := e.client.CancelJob(t.Context(), &batchpb.CancelJobRequest{Name: parent + "/jobs/cx"}) + require.NoError(t, err) + assert.False(t, op.Done()) + assert.Equal(t, batchpb.JobStatus_CANCELLATION_IN_PROGRESS, e.get(t, "cx").GetStatus().GetState()) + + _, err = e.client.CancelJob(t.Context(), &batchpb.CancelJobRequest{Name: parent + "/jobs/cx"}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + + e.clock.Advance(5 * time.Second) + + got := e.get(t, "cx") + assert.Equal(t, batchpb.JobStatus_CANCELLED, got.GetStatus().GetState()) + + for _, task := range e.tasks(t, "cx") { + assert.Contains( + t, + []batchpb.TaskStatus_State{batchpb.TaskStatus_FAILED, batchpb.TaskStatus_UNEXECUTED}, + task.GetStatus().GetState(), + ) + } + + resp, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.NotNil(t, resp) + + _, err = e.client.CancelJob(t.Context(), &batchpb.CancelJobRequest{Name: parent + "/jobs/cx"}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) +} + +func TestDeleteJob(t *testing.T) { + t.Parallel() + + e := newEnv(t, withTiming(gcpbatch.Timing{Delete: 10 * time.Second, Schedule: time.Hour})) + e.create(t, "del", basicJob(1)) + + op, err := e.client.DeleteJob(t.Context(), &batchpb.DeleteJobRequest{Name: parent + "/jobs/del"}) + require.NoError(t, err) + assert.False(t, op.Done()) + assert.Equal(t, batchpb.JobStatus_DELETION_IN_PROGRESS, e.get(t, "del").GetStatus().GetState()) + + _, err = e.client.DeleteJob(t.Context(), &batchpb.DeleteJobRequest{Name: parent + "/jobs/del"}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + + e.clock.Advance(10 * time.Second) + require.NoError(t, op.Wait(t.Context())) + + _, err = e.client.GetJob(t.Context(), &batchpb.GetJobRequest{Name: parent + "/jobs/del"}) + assert.Equal(t, codes.NotFound, status.Code(err)) + + _, err = e.client.DeleteJob(t.Context(), &batchpb.DeleteJobRequest{Name: parent + "/jobs/del"}) + assert.Equal(t, codes.NotFound, status.Code(err)) + + e.create(t, "del", basicJob(1)) +} + +func TestStoredSpecRoundTrip(t *testing.T) { + t.Parallel() + + e := newEnv(t) + job := basicJob(1, &batchpb.Runnable{ + DisplayName: "scr", IgnoreExitStatus: true, + Executable: &batchpb.Runnable_Script_{Script: &batchpb.Runnable_Script{ + Command: &batchpb.Runnable_Script_Text{Text: "echo hi"}, + }}, + }) + spec := job.GetTaskGroups()[0].GetTaskSpec() + spec.MaxRunDuration = durationpb.New(time.Hour) + spec.ComputeResource = &batchpb.ComputeResource{CpuMilli: 500, MemoryMib: 256} + spec.MaxRetryCount = 3 + job.AllocationPolicy = &batchpb.AllocationPolicy{ + ServiceAccount: &batchpb.ServiceAccount{Email: "sa@p.iam.gserviceaccount.com"}, + Instances: []*batchpb.AllocationPolicy_InstancePolicyOrTemplate{{ + PolicyTemplate: &batchpb.AllocationPolicy_InstancePolicyOrTemplate_Policy{ + Policy: &batchpb.AllocationPolicy_InstancePolicy{MachineType: "e2-standard-4"}, + }, + }}, + } + e.create(t, "rt", job) + + got := e.get(t, "rt") + gs := got.GetTaskGroups()[0].GetTaskSpec() + assert.Equal(t, "echo hi", gs.GetRunnables()[0].GetScript().GetText()) + assert.Equal(t, time.Hour, gs.GetMaxRunDuration().AsDuration()) + assert.Equal(t, int64(500), gs.GetComputeResource().GetCpuMilli()) + assert.Equal(t, "e2-standard-4", got.GetAllocationPolicy().GetInstances()[0].GetPolicy().GetMachineType()) + assert.Equal(t, "sa@p.iam.gserviceaccount.com", got.GetAllocationPolicy().GetServiceAccount().GetEmail()) +} diff --git a/services/gcpbatch/leak_main_test.go b/services/gcpbatch/leak_main_test.go new file mode 100644 index 0000000000..33461d0ba7 --- /dev/null +++ b/services/gcpbatch/leak_main_test.go @@ -0,0 +1,11 @@ +package gcpbatch_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + testleak.VerifyTestMain(m) +} diff --git a/services/gcpbatch/list.go b/services/gcpbatch/list.go new file mode 100644 index 0000000000..1819a875eb --- /dev/null +++ b/services/gcpbatch/list.go @@ -0,0 +1,152 @@ +package gcpbatch + +import ( + "cmp" + "slices" + "strings" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + fieldState = "status.state" + labelPrefix = "labels." +) + +type clause struct{ field, value string } + +// parseFilter reads clauses joined by AND: status.state=X or labels.key=value, optionally quoted. +func parseFilter(f string) ([]clause, error) { + var out []clause + + for raw := range strings.SplitSeq(f, " AND ") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + field, val, ok := strings.Cut(raw, "=") + field, val = strings.TrimSpace(field), strings.Trim(strings.TrimSpace(val), `"`) + + if !ok || (field != fieldState && !strings.HasPrefix(field, labelPrefix)) { + return nil, errInvalid("Invalid filter %q: use status.state=STATE or labels.KEY=VALUE joined by AND.", f) + } + + if field == fieldState { + if _, known := batchpb.JobStatus_State_value[val]; !known { + return nil, errInvalid("Invalid filter %q: unknown state %q.", f, val) + } + } + + out = append(out, clause{field, val}) + } + + return out, nil +} + +func (c clause) match(j *batchpb.Job) bool { + if c.field == fieldState { + return j.GetStatus().GetState().String() == c.value + } + + v, ok := j.GetLabels()[strings.TrimPrefix(c.field, labelPrefix)] + + return ok && v == c.value +} + +func sortJobs(items []*batchpb.Job, orderBy string) error { + field, desc := "name", false + + if f := strings.Fields(orderBy); len(f) > 0 { + field = f[0] + + switch { + case len(f) == 1: + case len(f) == 2 && strings.EqualFold(f[1], "desc"): + desc = true + case len(f) == 2 && strings.EqualFold(f[1], "asc"): + default: + return errInvalid("Invalid order_by %q.", orderBy) + } + } + + var key func(x *batchpb.Job) time.Time + + switch field { + case "name": + case "create_time": + key = func(x *batchpb.Job) time.Time { return x.GetCreateTime().AsTime() } + case "update_time": + key = func(x *batchpb.Job) time.Time { return x.GetUpdateTime().AsTime() } + default: + return errInvalid("Cannot order by %q: use name, create_time or update_time.", field) + } + + slices.SortStableFunc(items, func(x, y *batchpb.Job) int { + c := cmp.Compare(x.GetName(), y.GetName()) + if key != nil { + c = key(x).Compare(key(y)) + } + + if desc { + return -c + } + + return c + }) + + return nil +} + +// ListJobs lists the jobs of a location, or of every location for "-". +func (b *InMemoryBackend) ListJobs(in *batchpb.ListJobsRequest) (*batchpb.ListJobsResponse, error) { + project, location, err := parseParent(in.GetParent()) + if err != nil { + return nil, err + } + + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + clauses, err := parseFilter(in.GetFilter()) + if err != nil { + return nil, err + } + + prefix := locationName(project, location) + "/" + kindJobs + "/" + if location == anyLocation { + prefix = "projects/" + project + "/locations/" + } + + b.mu.Lock("ListJobs") + b.advanceLocked() + + var all []*batchpb.Job + + for n, r := range b.jobs { + if !strings.HasPrefix(n, prefix) || (location == anyLocation && !strings.Contains(n, "/"+kindJobs+"/")) { + continue + } + + v := r.view(b.now()) + if slices.ContainsFunc(clauses, func(c clause) bool { return !c.match(v) }) { + continue + } + + all = append(all, v) + } + + b.mu.Unlock() + + if err = sortJobs(all, in.GetOrderBy()); err != nil { + return nil, err + } + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &batchpb.ListJobsResponse{Jobs: pg.Data, NextPageToken: pg.Next}, nil +} diff --git a/services/gcpbatch/locations.go b/services/gcpbatch/locations.go new file mode 100644 index 0000000000..e17da74c5a --- /dev/null +++ b/services/gcpbatch/locations.go @@ -0,0 +1,53 @@ +package gcpbatch + +import ( + "slices" + + "google.golang.org/genproto/googleapis/cloud/location" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +func buildLocation(project, id string) *location.Location { + return &location.Location{ + Name: locationName(project, id), LocationId: id, DisplayName: id, + Labels: map[string]string{"cloud.googleapis.com/region": id}, + } +} + +func getLocation(name string) (*location.Location, error) { + project, loc, err := parseParent(name) + if err != nil { + return nil, err + } + + if !slices.Contains(regions, loc) { + return nil, errNotFound("Location %q not found.", name) + } + + return buildLocation(project, loc), nil +} + +func listLocations(in *location.ListLocationsRequest) (*location.ListLocationsResponse, error) { + ids, ok := parseRes(in.GetName(), "projects") + if !ok { + return nil, errInvalid("Invalid name %q: want projects/{project}.", in.GetName()) + } + + if in.GetFilter() != "" { + return nil, errInvalid("filter is not supported for locations.") + } + + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + all := make([]*location.Location, 0, len(regions)) + for _, l := range regions { + all = append(all, buildLocation(ids[0], l)) + } + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &location.ListLocationsResponse{Locations: pg.Data, NextPageToken: pg.Next}, nil +} diff --git a/services/gcpbatch/logs.go b/services/gcpbatch/logs.go new file mode 100644 index 0000000000..4984495642 --- /dev/null +++ b/services/gcpbatch/logs.go @@ -0,0 +1,39 @@ +package gcpbatch + +import ( + "context" + "errors" + + "cloud.google.com/go/logging/apiv2/loggingpb" + "google.golang.org/genproto/googleapis/api/monitoredres" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" +) + +const taskLogID = "batch_task_logs" + +var errUnsupportedRunnable = errors.New("runnable is neither a container nor a script") + +// taskLogSink files container output under the Batch task log instead of the Cloud Run log id. +type taskLogSink struct { + inner gcpserve.LogSink + project string +} + +func (s taskLogSink) WriteLogEntries(ctx context.Context, req *loggingpb.WriteLogEntriesRequest) error { + for _, e := range req.GetEntries() { + e.LogName = gcpserve.LogName(s.project, taskLogID) + } + + return s.inner.WriteLogEntries(ctx, req) //nolint:wrapcheck // sink errors are dropped by the caller +} + +func taskLogResource(ex *execEnv) *monitoredres.MonitoredResource { + return &monitoredres.MonitoredResource{ + Type: "generic_task", + Labels: map[string]string{ + "project_id": ex.project, "location": ex.location, + "job": ex.job[len(locationName(ex.project, ex.location))+len("/"+kindJobs+"/"):], + }, + } +} diff --git a/services/gcpbatch/model.go b/services/gcpbatch/model.go new file mode 100644 index 0000000000..ac82a00101 --- /dev/null +++ b/services/gcpbatch/model.go @@ -0,0 +1,146 @@ +package gcpbatch + +import ( + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + jobQueued = int32(batchpb.JobStatus_QUEUED) + jobScheduled = int32(batchpb.JobStatus_SCHEDULED) + jobRunning = int32(batchpb.JobStatus_RUNNING) + jobSucceeded = int32(batchpb.JobStatus_SUCCEEDED) + jobFailed = int32(batchpb.JobStatus_FAILED) + jobDeleting = int32(batchpb.JobStatus_DELETION_IN_PROGRESS) + jobCancelling = int32(batchpb.JobStatus_CANCELLATION_IN_PROGRESS) + jobCancelled = int32(batchpb.JobStatus_CANCELLED) + + taskPending = int32(batchpb.TaskStatus_PENDING) + taskAssigned = int32(batchpb.TaskStatus_ASSIGNED) + taskRunning = int32(batchpb.TaskStatus_RUNNING) + taskFailed = int32(batchpb.TaskStatus_FAILED) + taskSucceeded = int32(batchpb.TaskStatus_SUCCEEDED) + taskUnexecuted = int32(batchpb.TaskStatus_UNEXECUTED) + + eventStatusChanged = "STATUS_CHANGED" + eventRetry = "TASK_RETRY" +) + +// eventRec is one status event of a job or task. +type eventRec struct { + Time time.Time `json:"time"` + Exit *int32 `json:"exit,omitempty"` + Type string `json:"type"` + Desc string `json:"desc"` +} + +type taskRec struct { + Events []eventRec `json:"events,omitempty"` + State int32 `json:"state"` + Attempts int32 `json:"attempts,omitempty"` +} + +func (t *taskRec) terminal() bool { + return t.State == taskFailed || t.State == taskSucceeded || t.State == taskUnexecuted +} + +// jobRec is a job with its materialised status. Planned times drive state-only progression. +type jobRec struct { + EndedAt time.Time `json:"endedAt"` + CancelDone time.Time `json:"cancelDone"` + DeleteDone time.Time `json:"deleteDone"` + ScheduledAt time.Time `json:"scheduledAt"` + RunningAt time.Time `json:"runningAt"` + StartedAt time.Time `json:"startedAt"` + pb *batchpb.Job + Events []eventRec `json:"events"` + Tasks []*taskRec `json:"tasks"` + TaskRun int64 `json:"taskRun"` + Parallel int64 `json:"parallel"` + Phase int32 `json:"phase"` + Engine bool `json:"engine"` + Cancelled bool `json:"cancelled"` + Deleting bool `json:"deleting"` +} + +func (r *jobRec) interrupted() bool { return r.Cancelled || r.Deleting } + +func (r *jobRec) finished() bool { + return r.Phase == jobSucceeded || r.Phase == jobFailed || r.Phase == jobCancelled +} + +func (r *jobRec) group() *batchpb.TaskGroup { return r.pb.GetTaskGroups()[0] } + +func stateName(s int32) string { return batchpb.JobStatus_State(s).String() } + +func taskStateName(s int32) string { return batchpb.TaskStatus_State(s).String() } + +func eventPB(e eventRec, withExec bool) *batchpb.StatusEvent { + out := &batchpb.StatusEvent{Type: e.Type, Description: e.Desc, EventTime: timestamppb.New(e.Time)} + if withExec && e.Exit != nil { + out.TaskExecution = &batchpb.TaskExecution{ExitCode: *e.Exit} + } + + return out +} + +func eventsPB(es []eventRec, withExec bool) []*batchpb.StatusEvent { + out := make([]*batchpb.StatusEvent, 0, len(es)) + for _, e := range es { + out = append(out, eventPB(e, withExec)) + } + + return out +} + +func (r *jobRec) counts() map[string]int64 { + c := map[string]int64{} + for _, t := range r.Tasks { + c[taskStateName(t.State)]++ + } + + return c +} + +// view renders the job as the API returns it. +func (r *jobRec) view(now time.Time) *batchpb.Job { + out := clone(r.pb) + st := &batchpb.JobStatus{ + State: batchpb.JobStatus_State(r.Phase), + StatusEvents: eventsPB(r.Events, false), + TaskGroups: map[string]*batchpb.JobStatus_TaskGroupStatus{ + groupName: {Counts: r.counts()}, + }, + } + + if !r.StartedAt.IsZero() { + end := now + if !r.EndedAt.IsZero() { + end = r.EndedAt + } + + st.RunDuration = durationpb.New(max(end.Sub(r.StartedAt), 0)) + } + + out.Status = st + + if n := len(r.Events); n > 0 { + out.UpdateTime = timestamppb.New(r.Events[n-1].Time) + } + + return out +} + +func (r *jobRec) taskView(i int) *batchpb.Task { + t := r.Tasks[i] + + return &batchpb.Task{ + Name: taskName(r.pb.GetName(), i), + Status: &batchpb.TaskStatus{ + State: batchpb.TaskStatus_State(t.State), StatusEvents: eventsPB(t.Events, true), + }, + } +} diff --git a/services/gcpbatch/names.go b/services/gcpbatch/names.go new file mode 100644 index 0000000000..36c0c58345 --- /dev/null +++ b/services/gcpbatch/names.go @@ -0,0 +1,127 @@ +package gcpbatch + +import ( + "regexp" + "strconv" + "strings" +) + +const ( + kindJobs = "jobs" + kindGroups = "taskGroups" + kindTasks = "tasks" + kindOperations = "operations" + anyLocation = "-" + groupName = "group0" + maxLabels = 64 + maxPriority = 100 +) + +var ( + jobIDRe = regexp.MustCompile(`^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$`) + locationRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + labelKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,62}$`) + labelValRe = regexp.MustCompile(`^[a-z0-9_-]{0,63}$`) + envNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + secretRe = regexp.MustCompile(`^projects/[^/]+/secrets/[^/]+/versions/[^/]+$`) + saEmailRe = regexp.MustCompile(`^[^@/\s]+@[^@/\s]+\.iam\.gserviceaccount\.com$`) + requestRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +) + +// regions are the locations Batch lists; jobs may target any of them. +// +//nolint:gochecknoglobals // immutable lookup table +var regions = []string{ + "asia-east1", "asia-east2", "asia-northeast1", "asia-northeast2", "asia-northeast3", "asia-south1", + "asia-southeast1", "asia-southeast2", "australia-southeast1", "europe-north1", "europe-west1", + "europe-west2", "europe-west3", "europe-west4", "europe-west6", "northamerica-northeast1", + "southamerica-east1", "us-central1", "us-east1", "us-east4", "us-west1", "us-west2", "us-west3", "us-west4", +} + +func parseRes(name string, kinds ...string) ([]string, bool) { + p := strings.Split(name, "/") + if len(p) != 2*len(kinds) { + return nil, false + } + + ids := make([]string, len(kinds)) + + for i, k := range kinds { + if p[2*i] != k || p[2*i+1] == "" { + return nil, false + } + + ids[i] = p[2*i+1] + } + + return ids, true +} + +func parseParent(name string) (string, string, error) { + ids, ok := parseRes(name, "projects", "locations") + if !ok || (ids[1] != anyLocation && !locationRe.MatchString(ids[1])) { + return "", "", errInvalid("Invalid parent %q: want projects/{project}/locations/{location}.", name) + } + + return ids[0], ids[1], nil +} + +func parseJobName(name string) (string, string, error) { + ids, ok := parseRes(name, "projects", "locations", kindJobs) + if !ok { + return "", "", errInvalid("Invalid name %q: want projects/{project}/locations/{location}/jobs/{job}.", name) + } + + return ids[0], ids[1], nil +} + +// parseGroupName returns the job name of "{job}/taskGroups/{group}" and the group id. +func parseGroupName(name string) (string, string, error) { + i := strings.Index(name, "/"+kindGroups+"/") + if i < 0 { + return "", "", errInvalid("Invalid parent %q: want {job}/taskGroups/{task_group}.", name) + } + + job, group := name[:i], name[i+len(kindGroups)+2:] + if _, _, err := parseJobName(job); err != nil || group == "" || strings.Contains(group, "/") { + return "", "", errInvalid("Invalid parent %q: want {job}/taskGroups/{task_group}.", name) + } + + return job, group, nil +} + +// parseTaskName returns the job name, group id and task index of ".../taskGroups/{g}/tasks/{n}". +func parseTaskName(name string) (string, string, int, error) { + i := strings.LastIndex(name, "/"+kindTasks+"/") + if i < 0 { + return "", "", 0, errInvalid("Invalid name %q: want {job}/taskGroups/{task_group}/tasks/{task}.", name) + } + + job, group, err := parseGroupName(name[:i]) + if err != nil { + return "", "", 0, errInvalid("Invalid name %q: want {job}/taskGroups/{task_group}/tasks/{task}.", name) + } + + n, convErr := strconv.Atoi(name[i+len(kindTasks)+2:]) + if convErr != nil || n < 0 { + return "", "", 0, errNotFound("Task %q not found.", name) + } + + return job, group, n, nil +} + +func locationName(project, location string) string { + return "projects/" + project + "/locations/" + location +} + +func jobName(project, location, id string) string { + return locationName(project, location) + "/" + kindJobs + "/" + id +} + +func groupFullName(job string) string { return job + "/" + kindGroups + "/" + groupName } + +func taskName(job string, idx int) string { + return groupFullName(job) + "/" + kindTasks + "/" + strconv.Itoa(idx) +} + +func itoa(i int) string { return strconv.Itoa(i) } diff --git a/services/gcpbatch/ops.go b/services/gcpbatch/ops.go new file mode 100644 index 0000000000..29c05c0791 --- /dev/null +++ b/services/gcpbatch/ops.go @@ -0,0 +1,199 @@ +package gcpbatch + +import ( + "context" + "slices" + "strings" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +type opRec struct { + Created time.Time `json:"created"` + EndAt time.Time `json:"endAt"` + Name string `json:"name"` + Target string `json:"target"` + Verb string `json:"verb"` + Resp []byte `json:"resp,omitempty"` +} + +func (o *opRec) done(now time.Time) bool { return !now.Before(o.EndAt) } + +type opSpec struct { + end time.Time + resp proto.Message + target string + verb string + project string + location string +} + +func (b *InMemoryBackend) newOpLocked(s opSpec) (*longrunningpb.Operation, error) { + o := &opRec{ + Name: locationName(s.project, s.location) + "/" + kindOperations + "/operation-" + uuid.NewString(), + Target: s.target, + Verb: s.verb, + Created: b.now(), + EndAt: s.end, + } + + a, err := anypb.New(s.resp) + if err != nil { + return nil, status.Errorf(codes.Internal, "record operation response: %v", err) + } + + if o.Resp, err = proto.Marshal(a); err != nil { + return nil, status.Errorf(codes.Internal, "record operation response: %v", err) + } + + b.ops[o.Name] = o + + return b.opViewLocked(o), nil +} + +func (b *InMemoryBackend) opViewLocked(o *opRec) *longrunningpb.Operation { + now := b.now() + meta := &batchpb.OperationMetadata{ + CreateTime: timestamppb.New(o.Created), Target: o.Target, Verb: o.Verb, ApiVersion: apiVersion, + } + op := &longrunningpb.Operation{Name: o.Name} + + if o.done(now) { + meta.EndTime = timestamppb.New(o.EndAt) + op.Done = true + + resp := &anypb.Any{} + if proto.Unmarshal(o.Resp, resp) == nil { + op.Result = &longrunningpb.Operation_Response{Response: resp} + } + } + + if a, err := anypb.New(meta); err == nil { + op.Metadata = a + } + + return op +} + +// GetOperation returns one operation. +func (b *InMemoryBackend) GetOperation(name string) (*longrunningpb.Operation, error) { + b.mu.Lock("GetOperation") + defer b.mu.Unlock() + + b.advanceLocked() + + o, ok := b.ops[name] + if !ok { + return nil, errNotFound("Operation %q not found.", name) + } + + return b.opViewLocked(o), nil +} + +// WaitOperation returns once the operation is done or the timeout passes, whichever comes first. +func (b *InMemoryBackend) WaitOperation(ctx context.Context, in *longrunningpb.WaitOperationRequest) ( + *longrunningpb.Operation, error, +) { + b.mu.RLock("WaitOperation") + o, ok := b.ops[in.GetName()] + + var wait time.Duration + if ok { + wait = max(o.EndAt.Sub(b.now()), 0) + } + + b.mu.RUnlock() + + if !ok { + return nil, errNotFound("Operation %q not found.", in.GetName()) + } + + if in.GetTimeout() != nil { + wait = min(wait, in.GetTimeout().AsDuration()) + } + + if wait > 0 { + t := time.NewTimer(wait) + defer t.Stop() + + select { + case <-t.C: + case <-ctx.Done(): + } + } + + return b.GetOperation(in.GetName()) +} + +// ListOperations lists the operations of one location, ordered by name. +func (b *InMemoryBackend) ListOperations(in *longrunningpb.ListOperationsRequest) ( + *longrunningpb.ListOperationsResponse, error, +) { + if in.GetFilter() != "" { + return nil, errInvalid("ListOperations filter is not supported.") + } + + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + if _, _, err := parseParent(strings.TrimSuffix(in.GetName(), "/")); err != nil { + return nil, err + } + + prefix := strings.TrimSuffix(in.GetName(), "/") + "/" + kindOperations + "/" + + b.mu.Lock("ListOperations") + b.advanceLocked() + + names := make([]string, 0, len(b.ops)) + + for n := range b.ops { + if strings.HasPrefix(n, prefix) { + names = append(names, n) + } + } + + slices.Sort(names) + + all := make([]*longrunningpb.Operation, 0, len(names)) + for _, n := range names { + all = append(all, b.opViewLocked(b.ops[n])) + } + + b.mu.Unlock() + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &longrunningpb.ListOperationsResponse{Operations: pg.Data, NextPageToken: pg.Next}, nil +} + +// DeleteOperation removes a finished operation. +func (b *InMemoryBackend) DeleteOperation(name string) (*emptypb.Empty, error) { + b.mu.Lock("DeleteOperation") + defer b.mu.Unlock() + + o, ok := b.ops[name] + if !ok { + return nil, errNotFound("Operation %q not found.", name) + } + + if !o.done(b.now()) { + return nil, errPrecondition("Operation %q is still running.", name) + } + + delete(b.ops, name) + + return &emptypb.Empty{}, nil +} diff --git a/services/gcpbatch/persistence.go b/services/gcpbatch/persistence.go new file mode 100644 index 0000000000..ca47826643 --- /dev/null +++ b/services/gcpbatch/persistence.go @@ -0,0 +1,131 @@ +package gcpbatch + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the snapshot types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type jobSnap struct { + Rec *jobRec `json:"rec"` + Job json.RawMessage `json:"job"` +} + +type backendSnapshot struct { + Jobs map[string]jobSnap `json:"jobs"` + Ops map[string]*opRec `json:"operations"` + Requests map[string]string `json:"requests"` + Version int `json:"version"` +} + +// Snapshot serialises jobs, tasks, operations and request ids. Running containers are not kept. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + snap := backendSnapshot{ + Version: snapshotVersion, Ops: b.ops, Requests: b.requests, + Jobs: make(map[string]jobSnap, len(b.jobs)), + } + + for n, r := range b.jobs { + raw, err := protojson.Marshal(r.pb) + if err != nil { + continue + } + + snap.Jobs[n] = jobSnap{Job: raw, Rec: r} + } + + return persistence.MarshalSnapshot(ctx, "gcpbatch", snap) +} + +// abandon fails the jobs a restart interrupted: their containers are gone. +func (r *jobRec) abandon(at time.Time) { + if !r.Engine || r.finished() || r.interrupted() { + return + } + + for i := range r.Tasks { + r.setTask(i, taskFailed, at, nil, "the emulator restarted while the task was running") + } + + r.setPhase(jobFailed, at) +} + +// Restore loads a snapshot, replacing all state. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "gcpbatch", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "gcpbatch: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + jobs := make(map[string]*jobRec, len(snap.Jobs)) + + for n, s := range snap.Jobs { + pb := &batchpb.Job{} + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(s.Job, pb); err != nil { + return fmt.Errorf("gcpbatch: restore job %q: %w", n, err) + } + + if s.Rec == nil { + continue + } + + s.Rec.pb = pb + jobs[n] = s.Rec + } + + if snap.Ops == nil { + snap.Ops = map[string]*opRec{} + } + + if snap.Requests == nil { + snap.Requests = map[string]string{} + } + + b.Close() + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + now := b.now() + for _, r := range jobs { + r.abandon(now) + } + + b.jobs, b.ops, b.requests, b.cancels = jobs, snap.Ops, snap.Requests, map[string]context.CancelFunc{} + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("gcpbatch: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/gcpbatch/persistence_test.go b/services/gcpbatch/persistence_test.go new file mode 100644 index 0000000000..0bacf8146d --- /dev/null +++ b/services/gcpbatch/persistence_test.go @@ -0,0 +1,60 @@ +package gcpbatch_test + +import ( + "testing" + "time" + + "cloud.google.com/go/batch/apiv1/batchpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/services/gcpbatch" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + tm := gcpbatch.Timing{Schedule: time.Second, Start: time.Second, TaskRun: time.Minute, Delete: time.Hour} + src := newEnv(t, withTiming(tm)) + src.create(t, "keep", basicJob(2)) + src.create(t, "gone", basicJob(1)) + src.clock.Advance(3 * time.Second) + + _, err := src.client.DeleteJob(t.Context(), &batchpb.DeleteJobRequest{Name: parent + "/jobs/gone"}) + require.NoError(t, err) + + want := src.get(t, "keep") + snap := src.backend.Snapshot(t.Context()) + + dst := newEnv(t, withTiming(tm)) + dst.clock.Advance(3 * time.Second) + require.NoError(t, dst.backend.Restore(t.Context(), snap)) + + got := dst.get(t, "keep") + assert.True(t, proto.Equal(want, got), "job survives a restore") + assert.Equal(t, batchpb.JobStatus_DELETION_IN_PROGRESS, dst.get(t, "gone").GetStatus().GetState()) + assert.Len(t, dst.tasks(t, "keep"), 2) + + dst.clock.Advance(time.Minute) + assert.Equal(t, int64(2), dst.get(t, "keep").GetStatus().GetTaskGroups()["group0"].GetCounts()["SUCCEEDED"]) +} + +func TestRestoreFailsInterruptedEngineJobs(t *testing.T) { + t.Parallel() + + rt := newFakeRuntime(map[string]behavior{"hang:1": blocks()}) + src := newEnv(t, withRuntime(rt)) + src.create(t, "run", basicJob(1, containerRunnable("hang:1"))) + + require.Eventually(t, func() bool { + return src.get(t, "run").GetStatus().GetState() == batchpb.JobStatus_RUNNING + }, time.Second*5, time.Millisecond*5) + + snap := src.backend.Snapshot(t.Context()) + + dst := newEnv(t) + require.NoError(t, dst.backend.Restore(t.Context(), snap)) + assert.Equal(t, batchpb.JobStatus_FAILED, dst.get(t, "run").GetStatus().GetState()) + assert.Equal(t, []string{"FAILED"}, taskStates(dst.tasks(t, "run"))) +} diff --git a/services/gcpbatch/provider.go b/services/gcpbatch/provider.go new file mode 100644 index 0000000000..bd96b97a59 --- /dev/null +++ b/services/gcpbatch/provider.go @@ -0,0 +1,69 @@ +package gcpbatch + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("gcpbatch: nil app context") + +// ConfigProvider extracts Batch configuration from the AppContext Config. +type ConfigProvider interface { + GetGCPBatchSettings() Settings +} + +// Provider implements service.Provider for Batch. It serves gRPC, REST and the gateway data plane on +// its own listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "GCPBatch" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetGCPBatchSettings() + } + + backend := NewInMemoryBackend() + + if ctx.JanitorCtx != nil { + backend.SetContext(ctx.JanitorCtx) + } + + enableEngine(ctx, backend, settings) + + handler := NewHandler(backend) + handler.Port = settings.Port + + return handler, nil +} + +func enableEngine(ctx *service.AppContext, backend *InMemoryBackend, settings Settings) { + if settings.Engine != EngineDocker { + return + } + + rt, err := container.NewRuntime(container.Config{Logger: ctx.Logger}) + if err != nil { + ctx.Logger.Warn( + "gcpbatch: container runtime unavailable; jobs progress on the clock without running", + "error", + err, + ) + + return + } + + backend.SetRuntime(rt, settings.ScriptImage) +} diff --git a/services/gcpbatch/settings.go b/services/gcpbatch/settings.go new file mode 100644 index 0000000000..4a54291897 --- /dev/null +++ b/services/gcpbatch/settings.go @@ -0,0 +1,24 @@ +package gcpbatch + +const ( + // DefaultPort is the dedicated gRPC and REST listener's default TCP port. + DefaultPort = 8147 + // EngineNone records jobs and progresses them on the clock without running any container. + EngineNone = "none" + // EngineDocker runs every task's runnables as containers. + EngineDocker = "docker" + // DefaultScriptImage runs script runnables, which Batch executes on the VM host. + DefaultScriptImage = "debian:bookworm-slim" +) + +// Settings holds service-level configuration, embedded in the root CLI command as CLI.GCPBatch. +type Settings struct { + Engine string `json:"engine" env:"GCP_BATCH_ENGINE" default:"none" name:"engine" enum:"none,docker" help:"Task execution: none (jobs progress QUEUED, SCHEDULED, RUNNING, SUCCEEDED on the clock without running anything) or docker (run each task's runnables as containers)."` //nolint:lll // config struct tags are intentionally verbose + ScriptImage string `json:"scriptImage" env:"GCP_BATCH_SCRIPT_IMAGE" default:"debian:bookworm-slim" name:"script-image" help:"Container image that executes script runnables when the docker engine is on (Batch runs them on the VM host)."` //nolint:lll // config struct tags are intentionally verbose + Port int `json:"port" env:"GCP_BATCH_PORT" default:"8147" name:"port" help:"Fixed TCP port for the dedicated Batch gRPC and REST listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { + return Settings{Engine: EngineNone, Port: DefaultPort, ScriptImage: DefaultScriptImage} +} diff --git a/services/gcpbatch/tasks.go b/services/gcpbatch/tasks.go new file mode 100644 index 0000000000..868d5f4150 --- /dev/null +++ b/services/gcpbatch/tasks.go @@ -0,0 +1,108 @@ +package gcpbatch + +import ( + "strings" + + "cloud.google.com/go/batch/apiv1/batchpb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const filterStatePrefix = "state=" + +func taskFilter(f string) (int32, bool, error) { + f = strings.TrimSpace(f) + if f == "" { + return 0, false, nil + } + + v, ok := strings.CutPrefix(strings.ReplaceAll(f, " ", ""), filterStatePrefix) + if !ok { + v, ok = strings.CutPrefix(strings.ReplaceAll(f, " ", ""), "State=") + } + + st, known := batchpb.TaskStatus_State_value[strings.Trim(v, `"`)] + if !ok || !known { + return 0, false, errInvalid("Invalid filter %q: use State=TASK_STATE, for example State=RUNNING.", f) + } + + return st, true, nil +} + +func (b *InMemoryBackend) groupLocked(parent string) (*jobRec, error) { + job, group, err := parseGroupName(parent) + if err != nil { + return nil, err + } + + r, err := b.liveJobLocked(job) + if err != nil { + return nil, err + } + + if group != groupName { + return nil, errNotFound("TaskGroup %q not found.", parent) + } + + return r, nil +} + +// ListTasks lists the tasks of a task group, optionally filtered by State=.... +func (b *InMemoryBackend) ListTasks(in *batchpb.ListTasksRequest) (*batchpb.ListTasksResponse, error) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + want, filtered, err := taskFilter(in.GetFilter()) + if err != nil { + return nil, err + } + + b.mu.Lock("ListTasks") + b.advanceLocked() + + r, err := b.groupLocked(in.GetParent()) + if err != nil { + b.mu.Unlock() + + return nil, err + } + + all := make([]*batchpb.Task, 0, len(r.Tasks)) + + for i, t := range r.Tasks { + if !filtered || t.State == want { + all = append(all, r.taskView(i)) + } + } + + b.mu.Unlock() + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &batchpb.ListTasksResponse{Tasks: pg.Data, NextPageToken: pg.Next}, nil +} + +// GetTask returns one task. +func (b *InMemoryBackend) GetTask(name string) (*batchpb.Task, error) { + job, group, idx, err := parseTaskName(name) + if err != nil { + return nil, err + } + + b.mu.Lock("GetTask") + defer b.mu.Unlock() + + b.advanceLocked() + + r, err := b.liveJobLocked(job) + if err != nil { + return nil, err + } + + if group != groupName || idx >= len(r.Tasks) { + return nil, errNotFound("Task %q not found.", name) + } + + return r.taskView(idx), nil +} diff --git a/services/gcpbatch/validate.go b/services/gcpbatch/validate.go new file mode 100644 index 0000000000..a0b0f10866 --- /dev/null +++ b/services/gcpbatch/validate.go @@ -0,0 +1,260 @@ +package gcpbatch + +import ( + "strings" + + "cloud.google.com/go/batch/apiv1/batchpb" +) + +const ( + maxRetries = 10 + maxExitCode = 255 + exitMaxRuntime = 50005 + minReserved = 50001 + maxReserved = 50006 +) + +func validateLabels(what string, labels map[string]string) error { + if len(labels) > maxLabels { + return errInvalid("At most %d %s are allowed.", maxLabels, what) + } + + for k, v := range labels { + if !labelKeyRe.MatchString(k) { + return errInvalid("Invalid %s key %q.", what, k) + } + + if strings.HasPrefix(k, "goog-") || strings.HasPrefix(k, "google-") { + return errInvalid("%s key %q uses a reserved prefix.", what, k) + } + + if !labelValRe.MatchString(v) { + return errInvalid("Invalid value %q for %s %q.", v, what, k) + } + } + + return nil +} + +func validateEnv(where string, e *batchpb.Environment) error { + if e == nil { + return nil + } + + if e.GetEncryptedVariables() != nil { + return errInvalid("%s: encrypted_variables are not supported; use secret_variables.", where) + } + + for k := range e.GetVariables() { + if !envNameRe.MatchString(k) { + return errInvalid("%s: invalid environment variable name %q.", where, k) + } + } + + for k, v := range e.GetSecretVariables() { + if !envNameRe.MatchString(k) { + return errInvalid("%s: invalid environment variable name %q.", where, k) + } + + if !secretRe.MatchString(v) { + return errInvalid( + "%s: secret variable %q must name projects/{project}/secrets/{secret}/versions/{version}.", + where, + k, + ) + } + } + + return nil +} + +func validateRunnable(i int, r *batchpb.Runnable) error { + where := "runnables[" + itoa(i) + "]" + + switch x := r.GetExecutable().(type) { + case *batchpb.Runnable_Container_: + if x.Container.GetImageUri() == "" { + return errInvalid("%s.container.image_uri is required.", where) + } + case *batchpb.Runnable_Script_: + if x.Script.GetText() == "" && x.Script.GetPath() == "" { + return errInvalid("%s.script needs text or path.", where) + } + case *batchpb.Runnable_Barrier_: + default: + return errInvalid("%s: one of container, script or barrier is required.", where) + } + + if r.GetTimeout() != nil && r.GetTimeout().AsDuration() < 0 { + return errInvalid("%s.timeout must not be negative.", where) + } + + if r.GetBackground() && r.GetAlwaysRun() { + return errInvalid("%s: background and always_run cannot both be set.", where) + } + + return validateEnv(where+".environment", r.GetEnvironment()) +} + +func validatePolicies(ps []*batchpb.LifecyclePolicy) error { + if len(ps) > 1 { + return errInvalid("Only one lifecycle policy is supported.") + } + + for _, p := range ps { + if p.GetAction() == batchpb.LifecyclePolicy_ACTION_UNSPECIFIED { + return errInvalid("lifecycle_policies.action is required.") + } + + codes := p.GetActionCondition().GetExitCodes() + if len(codes) == 0 { + return errInvalid("lifecycle_policies.action_condition.exit_codes is required.") + } + + for _, c := range codes { + if c < 0 || (c > maxExitCode && (c < minReserved || c > maxReserved)) { + return errInvalid("Invalid lifecycle policy exit code %d.", c) + } + } + } + + return nil +} + +func validateVolumes(vs []*batchpb.Volume) error { + for i, v := range vs { + if v.GetMountPath() == "" || !strings.HasPrefix(v.GetMountPath(), "/") { + return errInvalid("volumes[%d].mount_path must be an absolute path.", i) + } + + if v.GetSource() == nil { + return errInvalid("volumes[%d]: one of nfs, gcs or device_name is required.", i) + } + } + + return nil +} + +func validateSpec(s *batchpb.TaskSpec) error { + if s == nil { + return errInvalid("task_groups[0].task_spec is required.") + } + + if len(s.GetRunnables()) == 0 { + return errInvalid("task_spec.runnables must contain at least one runnable.") + } + + for i, r := range s.GetRunnables() { + if err := validateRunnable(i, r); err != nil { + return err + } + } + + if s.GetMaxRetryCount() < 0 || s.GetMaxRetryCount() > maxRetries { + return errInvalid("task_spec.max_retry_count must be in [0, %d].", maxRetries) + } + + if s.GetMaxRunDuration() != nil && s.GetMaxRunDuration().AsDuration() < 0 { + return errInvalid("task_spec.max_run_duration must not be negative.") + } + + if c := s.GetComputeResource(); c.GetCpuMilli() < 0 || c.GetMemoryMib() < 0 || c.GetBootDiskMib() < 0 { + return errInvalid("task_spec.compute_resource values must not be negative.") + } + + if err := validatePolicies(s.GetLifecyclePolicies()); err != nil { + return err + } + + if err := validateVolumes(s.GetVolumes()); err != nil { + return err + } + + return validateEnv("task_spec.environment", s.GetEnvironment()) +} + +func validateGroup(g *batchpb.TaskGroup) error { + if err := validateSpec(g.GetTaskSpec()); err != nil { + return err + } + + if g.GetTaskCount() < 0 || g.GetParallelism() < 0 { + return errInvalid("task_count and parallelism must not be negative.") + } + + if g.GetSchedulingPolicy() == batchpb.TaskGroup_IN_ORDER && g.GetParallelism() > 1 { + return errInvalid("parallelism must be 1 when scheduling_policy is IN_ORDER.") + } + + for i, e := range g.GetTaskEnvironments() { + if err := validateEnv("task_environments["+itoa(i)+"]", e); err != nil { + return err + } + } + + return nil +} + +func validateAllocation(a *batchpb.AllocationPolicy) error { + if a == nil { + return nil + } + + if err := validateLabels("allocation_policy label", a.GetLabels()); err != nil { + return err + } + + for _, l := range a.GetLocation().GetAllowedLocations() { + if !strings.HasPrefix(l, "regions/") && !strings.HasPrefix(l, "zones/") { + return errInvalid( + "allocation_policy.location.allowed_locations entry %q must be regions/... or zones/....", + l, + ) + } + } + + if sa := a.GetServiceAccount().GetEmail(); sa != "" && !saEmailRe.MatchString(sa) { + return errInvalid("allocation_policy.service_account.email %q is not a service account email.", sa) + } + + return nil +} + +func validateLogs(p *batchpb.LogsPolicy) error { + if p.GetDestination() == batchpb.LogsPolicy_PATH && p.GetLogsPath() == "" { + return errInvalid("logs_policy.logs_path is required when destination is PATH.") + } + + return nil +} + +func validateJob(j *batchpb.Job) error { + if j == nil { + return errInvalid("job is required.") + } + + switch n := len(j.GetTaskGroups()); { + case n == 0: + return errInvalid("job.task_groups is required.") + case n > 1: + return errInvalid("Only one TaskGroup is supported now.") + } + + if j.GetPriority() < 0 || j.GetPriority() >= maxPriority { + return errInvalid("job.priority must be in [0, %d).", maxPriority) + } + + if err := validateLabels("label", j.GetLabels()); err != nil { + return err + } + + if err := validateGroup(j.GetTaskGroups()[0]); err != nil { + return err + } + + if err := validateAllocation(j.GetAllocationPolicy()); err != nil { + return err + } + + return validateLogs(j.GetLogsPolicy()) +} diff --git a/services/gcpbigquery/handler.go b/services/gcpbigquery/handler.go index a72272e9e1..c880a3bed4 100644 --- a/services/gcpbigquery/handler.go +++ b/services/gcpbigquery/handler.go @@ -15,6 +15,7 @@ import ( "github.com/labstack/echo/v5" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/service" @@ -474,6 +475,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Use(gcpenforce.EchoMiddleware(ctx, gcpenforce.Target{Host: "bigquery.googleapis.com"})) e.Any("/*", telemetry.WrapEchoHandler("GCPBigQuery", h.Handler(), h)) srv := &http.Server{ diff --git a/services/gcpbigquery/sdk_more_test.go b/services/gcpbigquery/sdk_more_test.go index fe8e994188..978b32aab4 100644 --- a/services/gcpbigquery/sdk_more_test.go +++ b/services/gcpbigquery/sdk_more_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/api/googleapi" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestViewsAndDDL(t *testing.T) { @@ -279,7 +281,7 @@ func TestRoutingAndErrors(t *testing.T) { req, err := http.NewRequestWithContext(t.Context(), tt.method, env.url+tt.path, nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/gcpbigtable/handler.go b/services/gcpbigtable/handler.go index ab609984da..d2a731e3e5 100644 --- a/services/gcpbigtable/handler.go +++ b/services/gcpbigtable/handler.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -150,7 +151,8 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpbigtable", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer( + srv := gcpenforce.NewServer( + ctx, "bigtable.googleapis.com", grpc.UnaryInterceptor(logUnary(log)), grpc.StreamInterceptor(logStream(log)), grpc.MaxRecvMsgSize(serverMaxMessage), diff --git a/services/gcpcloudbuild/handler.go b/services/gcpcloudbuild/handler.go index f85097d6ca..4b790fdd0c 100644 --- a/services/gcpcloudbuild/handler.go +++ b/services/gcpcloudbuild/handler.go @@ -18,6 +18,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -138,7 +139,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpcloudbuild", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "cloudbuild.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) h.registerServers(srv) h.srvMu.Lock("StartWorker") diff --git a/services/gcpcloudtasks/dispatch.go b/services/gcpcloudtasks/dispatch.go index 57ac128438..41dd9163ca 100644 --- a/services/gcpcloudtasks/dispatch.go +++ b/services/gcpcloudtasks/dispatch.go @@ -17,6 +17,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" "github.com/blackbirdworks/gopherstack/pkgs/gcpdispatch" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/logger" ) @@ -168,6 +169,7 @@ func (b *InMemoryBackend) deliver(ctx context.Context, j job) result { } req.Header = j.out.header.Clone() + gcpserve.MarkInternal(req) resp, err := j.client.Do(req) if err != nil { diff --git a/services/gcpcloudtasks/handler.go b/services/gcpcloudtasks/handler.go index 4e5b8959a9..08246f3ae2 100644 --- a/services/gcpcloudtasks/handler.go +++ b/services/gcpcloudtasks/handler.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -145,7 +146,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpcloudtasks", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "cloudtasks.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) cloudtaskspb.RegisterCloudTasksServer(srv, &server{b: h.Backend}) longrunningpb.RegisterOperationsServer(srv, h.Backend.ops) diff --git a/services/gcpcompute/PARITY.md b/services/gcpcompute/PARITY.md index b497c4c313..181914723e 100644 --- a/services/gcpcompute/PARITY.md +++ b/services/gcpcompute/PARITY.md @@ -7,12 +7,16 @@ overall: B # Per-op or per-op-family status. Values: ok | partial | gap | deferred. # wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. ops: - Networks.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "autoCreateSubnetworks (omitted means auto mode), routingConfig (REGIONAL or GLOBAL), mtu 1300-8896 (default 1460), networkFirewallPolicyEnforcementOrder. Auto mode creates one subnetwork per supported region named like the network from the documented auto ranges (us-central1 is 10.128.0.0/20); every network gets a deletable default-route-* internet route. Legacy IPv4Range networks are 400. Returns a global Operation."} - Networks.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: "subnetworks is derived from the stored subnetworks; selfLinkWithId carries the numeric id."} + Networks.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "autoCreateSubnetworks (omitted means auto mode), routingConfig (REGIONAL or GLOBAL), mtu 1300-8896 (default 1460), networkFirewallPolicyEnforcementOrder. Auto mode creates one subnetwork per supported region named like the network from the documented auto ranges (us-central1 is 10.128.0.0/20); every network gets a deletable default-route-* internet route and one SUBNET route per subnetwork range (see Routes.Insert). Legacy IPv4Range networks are 400. Returns a global Operation."} + Networks.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: "subnetworks is derived from the stored subnetworks, peerings[] carries state/stateDetails/peerMtu computed from the peer side; selfLinkWithId carries the numeric id."} Networks.List: {wire: ok, errors: ok, state: ok, persist: n/a, note: "filter, orderBy, maxResults, pageToken."} Networks.Patch: {wire: ok, errors: partial, state: ok, persist: ok, note: "Only routingConfig.routingMode, mtu, description and networkFirewallPolicyEnforcementOrder are updatable; any other field is 400."} Networks.SwitchToCustomMode: {wire: ok, errors: ok, state: ok, persist: ok, note: "Auto to custom is one-way; the auto subnetworks stay and then block network deletion like user subnetworks."} - Networks.Delete: {wire: ok, errors: partial, state: ok, persist: ok, note: "400 resourceInUseByAnotherResource while a firewall, user route, non-auto subnetwork, router or instance references it. Auto subnetworks and system routes are removed with the network."} + Networks.AddPeering: {wire: ok, errors: partial, state: ok, persist: ok, note: "networkPeering or the legacy name/peerNetwork/autoCreateRoutes. Defaults exchangeSubnetRoutes true, exportSubnetRoutesWithPublicIp true, importSubnetRoutesWithPublicIp false, stackType IPV4_ONLY, updateStrategy INDEPENDENT (CONSENSUS is 400). The peer network must exist; a self peering, a duplicate name, a second peering to the same network, more than 25 peerings and any subnet range overlap between the two networks (two auto mode networks always overlap) are 400. state is ACTIVE only while the peer network holds a peering back to this network, otherwise INACTIVE. Field semantics are from the SDK doc comments; the error texts, the stateDetails strings and the 25 limit are recalled, not verified."} + Networks.RemovePeering: {wire: ok, errors: partial, state: ok, persist: ok, note: "Removes by name; the other side turns INACTIVE. An unknown name is 400 (code unverified)."} + Networks.UpdatePeering: {wire: ok, errors: partial, state: ok, persist: ok, note: "PATCH. Only exportCustomRoutes and importCustomRoutes change (SDK doc comment); a differing value for any other field is 400."} + Networks.ListPeeringRoutes: {wire: ok, errors: ok, state: ok, persist: ok, note: "Derived from the live state of ACTIVE peerings. INCOMING lists the peer network subnet ranges (SUBNET_PEERING_ROUTE) and, when the peer exports custom routes, its static non-gateway routes (STATIC_PEERING_ROUTE, priority from the route); imported follows the local exchangeSubnetRoutes and importCustomRoutes. OUTGOING lists what this network exports, with imported following the peer side. direction defaults to INCOMING; no dynamic routes exist. Filter, orderBy and paging use the shared pipeline."} + Networks.Delete: {wire: ok, errors: partial, state: ok, persist: ok, note: "400 resourceInUseByAnotherResource while a firewall, user route, non-auto subnetwork, router or instance references it, and while the network has a peering (remove the peerings first). Auto subnetworks and system routes are removed with the network."} Subnetworks.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "ipCidrRange must be an IPv4 network address in a private range (RFC 1918, 100.64/10 and the other documented non-routable blocks), /8 to /29, and may not overlap any primary or secondary range in the network. secondaryIpRanges are validated and stored; manual subnetworks are rejected in auto mode networks. gatewayAddress is the first host."} Subnetworks.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} Subnetworks.List: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} @@ -27,10 +31,25 @@ ops: Firewalls.Patch: {wire: ok, errors: ok, state: ok, persist: ok, note: "JSON merge into the stored rule, then the full validation again; name and network are immutable."} Firewalls.Update: {wire: ok, errors: ok, state: ok, persist: ok, note: "PUT replaces the rule."} Firewalls.Delete: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} - Routes.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "destRange CIDR, priority default 1000, tags, and exactly one of nextHopGateway (default-internet-gateway only), nextHopIp or nextHopInstance (must exist). VPN tunnel, ILB, hub, peering and network next hops are 400."} + Routes.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "destRange CIDR, priority default 1000, tags, and exactly one of nextHopGateway (default-internet-gateway only), nextHopIp or nextHopInstance (must exist). VPN tunnel, ILB, hub, peering and network next hops are 400. System subnet routes are generated by the subnetworks, not inserted."} Routes.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} - Routes.List: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} - Routes.Delete: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} + Routes.List: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Includes the system routes: the internet route and one default-route-<16 hex> SUBNET route per subnetwork primary and secondary range (nextHopNetwork, priority 0, routeStatus ACTIVE, description Default local route to the subnetwork .). Peering routes are only listed by networks.listPeeringRoutes."} + Routes.Delete: {wire: ok, errors: partial, state: ok, persist: ok, note: "SUBNET routes cannot be deleted (400; the message is unverified); they follow the subnetwork."} + SecurityPolicies.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "Global, type CLOUD_ARMOR (default), CLOUD_ARMOR_EDGE or CLOUD_ARMOR_NETWORK. Without rules a default rule is created (priority 2147483647, allow, match SRC_IPS_V1 srcIpRanges *, description Default rule, higher priority overrides it). With rules the default rule must be present and match everything. Rules: priority 0-2147483647 and unique, action allow, deny(403|404|502), rate_based_ban, throttle or redirect with the matching rateLimitOptions or redirectOptions, match exactly one of versionedExpr SRC_IPS_V1 (config.srcIpRanges up to 10 CIDRs, addresses or *) and expr (CEL text, only balanced parentheses and quotes are checked). Operation type is compute.securityPolicies.insert. Rules, enums and limits are from the SDK doc comments; the 400 message texts are chosen here."} + SecurityPolicies.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Also list. Rules come back ordered by priority with priority and preview always present."} + SecurityPolicies.Patch: {wire: ok, errors: partial, state: ok, persist: ok, note: "JSON merge of description, advancedOptionsConfig, adaptiveProtectionConfig and the other policy-level fields; rules, type and labels are not touched (use the rule methods and setLabels). updateMask clears named fields absent from the body. A stale fingerprint is 412."} + SecurityPolicies.AddRule: {wire: ok, errors: partial, state: ok, persist: ok, note: "validateOnly validates without storing. A duplicate priority or a second default rule is 400."} + SecurityPolicies.GetRule: {wire: ok, errors: ok, state: ok, persist: n/a, note: "By priority query parameter; an unknown priority is 404."} + SecurityPolicies.PatchRule: {wire: ok, errors: partial, state: ok, persist: ok, note: "Merges the body into the rule at the priority query parameter (the body priority is ignored); updateMask clears absent fields. The merged rule is fully re-validated."} + SecurityPolicies.RemoveRule: {wire: ok, errors: partial, state: ok, persist: ok, note: "The default rule (2147483647) cannot be removed (400)."} + SecurityPolicies.SetLabels: {wire: ok, errors: ok, state: ok, persist: ok, note: "labelFingerprint mismatch is 412."} + SecurityPolicies.Delete: {wire: ok, errors: ok, state: ok, persist: ok, note: "400 resourceInUseByAnotherResource while a backend service attaches it."} + BackendServices.SetSecurityPolicy: {wire: ok, errors: partial, state: ok, persist: ok, note: "Global EXTERNAL and EXTERNAL_MANAGED services only, CLOUD_ARMOR policies only; an empty securityPolicy detaches. The securityPolicy field is also validated on insert, patch and update. STATE ONLY: the emulator has no HTTP load balancer data plane, so rules are never evaluated and SRC_IPS_V1 allow/deny is not enforced."} + ResourcePolicies.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "Regional; exactly one of snapshotSchedulePolicy (hourly hoursInCycle dividing 24, daily, weekly with unique days; startTime resolves to 00/04/08/12/16/20:00 UTC and the 13:00-5 offset form from the SDK doc is accepted and normalised; retentionPolicy maxRetentionDays >= 1, onSourceDiskDelete defaults to KEEP_AUTO_SNAPSHOTS; snapshotProperties labels and at most one storage location), instanceSchedulePolicy (vmStartSchedule or vmStopSchedule as a five field unix-cron, IANA timeZone default UTC, RFC 3339 start and expiration), groupPlacementPolicy, diskConsistencyGroupPolicy or workloadPolicy. status is READY. The duration PT1H and the cron checks are recalled, not verified. Nothing executes: no snapshot is taken and no instance is started or stopped by a schedule."} + ResourcePolicies.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Also list, aggregatedList and patch (JSON merge, re-validated; the policy type cannot change)."} + ResourcePolicies.Delete: {wire: ok, errors: ok, state: ok, persist: ok, note: "400 resourceInUseByAnotherResource while a disk or instance references it."} + Disks.AddResourcePolicies: {wire: ok, errors: partial, state: ok, persist: ok, note: "Also removeResourcePolicies and resourcePolicies on insert. Snapshot schedule and disk consistency group policies only, in the disk region, at most one per kind; attaching twice or removing an unattached policy is 400."} + Instances.AddResourcePolicies: {wire: ok, errors: partial, state: ok, persist: ok, note: "Also removeResourcePolicies and resourcePolicies on insert. Instance schedule, group placement and workload policies only, in the instance region, at most one per kind."} Addresses.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "Regional and global. EXTERNAL addresses come from a per-region (global: own) 34.x pool and are unique across addresses, instance NAT IPs and router NAT IPs; a requested address must be free. Regional INTERNAL addresses are allocated from the subnetwork's usable range (first two and last two addresses are reserved) or validated when given. IPv6, global INTERNAL and prefix-length addresses are 400."} Addresses.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: "status RESERVED or IN_USE and users are derived from the instances and router NATs that reference the address."} Addresses.List: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} @@ -53,6 +72,7 @@ ops: Disks.SetLabels: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} Disks.CreateSnapshot: {wire: ok, errors: ok, state: ok, persist: ok, note: "Zonal operation targeting the disk, like the real API; creates a READY global snapshot (no data is copied). storageLocations defaults to the multi-region of the disk's zone."} Disks.Delete: {wire: ok, errors: ok, state: ok, persist: ok, note: "400 resourceInUseByAnotherResource while attached."} + Instances.Update: {wire: ok, errors: partial, state: ok, persist: ok, note: "PUT. Only keys present in the body are considered (absent keys are kept, not cleared). Unchanged values are ignored. NO_EFFECT: description, deletionProtection, labels, metadata, tags and scheduling without a provisioning model, termination action or node affinity change; RESTART: canIpForward, shieldedInstanceConfig, displayDevice, minCpuPlatform, serviceAccounts and the restricted scheduling fields (a RUNNING instance restarts through PROVISIONING and STAGING, a stopped one stays stopped); any other changed field is 400. minimalAction raises the action, mostDisruptiveAllowedAction below the needed action is 400; both accept NO_EFFECT, REFRESH, RESTART (SDK doc). Stale fingerprint, labelFingerprint and metadata or tags fingerprints are 412. The field classification is a model, not verified against the service; REFRESH is never required."} Snapshots.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} Snapshots.List: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} Snapshots.SetLabels: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} @@ -80,6 +100,15 @@ ops: Instances.AddAccessConfig: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} Instances.DeleteAccessConfig: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} Instances.Delete: {wire: ok, errors: ok, state: ok, persist: ok, note: "Removes autoDelete disks; deletionProtection is 400."} + Instances.SetScheduling: {wire: ok, errors: partial, state: ok, persist: ok, note: "TERMINATED only (SDK doc comment); the body replaces the options with the normal defaults and the preemptible and spot rules."} + Instances.SetServiceAccount: {wire: ok, errors: partial, state: ok, persist: ok, note: "TERMINATED only; email must look like an address; an empty email clears the service accounts (the default service account is not modelled). Stopped-only is from the Compute docs, recalled."} + Instances.SetMinCpuPlatform: {wire: ok, errors: partial, state: ok, persist: ok, note: "TERMINATED only (SDK doc comment); the value must be Automatic or one of the Intel and AMD platforms in a fixed list (recalled)."} + Instances.UpdateShieldedInstanceConfig: {wire: ok, errors: partial, state: ok, persist: ok, note: "PATCH, TERMINATED only (SDK doc comment); JSON merge of enableSecureBoot, enableVtpm, enableIntegrityMonitoring. There is no setShieldedInstanceConfig method in the v1 API."} + Instances.SetShieldedInstanceIntegrityPolicy: {wire: ok, errors: partial, state: ok, persist: ok, note: "PATCH, RUNNING only (SDK doc comment); stores updateAutoLearnPolicy."} + Instances.Suspend: {wire: ok, errors: partial, state: ok, persist: ok, note: "RUNNING only (otherwise 400 resourceNotReady); SUSPENDING for the stop delay, then SUSPENDED on the injectable clock; sets lastSuspendedTimestamp, keeps IPs and disks. Spot, preemptible and GPU instances are 400. The error texts and the restrictions are recalled."} + Instances.Resume: {wire: ok, errors: partial, state: ok, persist: ok, note: "SUSPENDED only; the instance goes PROVISIONING, STAGING, RUNNING. There is no RESUMING instance status in the API enum. start on a suspended instance is 400 (use resume); stop works."} + Instances.GetSerialPortOutput: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Routed as instances/{name}/serialPort. port 1-4, start >= 0; no virtual machine runs, so contents is empty with start and next 0 and selfLink .../serialPort. No boot log is fabricated."} + Instances.GetGuestAttributes: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Always 404 notFound: there is no guest agent or metadata server to write attributes (the real not-set response is unverified)."} InstanceTemplates.Insert: {wire: ok, errors: partial, state: ok, persist: ok, note: "Global and regional (RegionInstanceTemplates). properties needs machineType (catalog or custom) and disks; labels, metadata, tags and scheduling are validated and normalized. sourceInstance is 400. Templates are immutable."} InstanceTemplates.Get: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Global and regional; also list and aggregatedList."} InstanceTemplates.Delete: {wire: ok, errors: ok, state: ok, persist: ok, note: "400 resourceInUseByAnotherResource while an instance group manager version references it."} @@ -146,14 +175,14 @@ families: auth: {status: partial, note: "No request is authenticated or authorised."} observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; requests go through telemetry. No dashboard page."} items_still_open: - - "Load balancing: target pools, target instances, service attachments, legacy HTTP health checks, regional SSL policies, regional TCP/SSL certificates managed by Certificate Manager, certificate maps, security policies, backend service patch fields such as outlier detection are stored without effect, urlMaps.invalidateCache and forwardingRules.patch are not routed, and managed certificates never leave PROVISIONING. No traffic is forwarded and nothing is probed (see BackendServices.GetHealth). Backend bucket existence is not checked against gcpstorage." + - "Load balancing: target pools, target instances, service attachments, legacy HTTP health checks, regional SSL policies, regional TCP/SSL certificates managed by Certificate Manager, certificate maps, backend service patch fields such as outlier detection are stored without effect, urlMaps.invalidateCache and forwardingRules.patch are not routed, and managed certificates never leave PROVISIONING. No traffic is forwarded and nothing is probed (see BackendServices.GetHealth). Backend bucket existence is not checked against gcpstorage. Cloud Armor security policies are state only: there is no HTTP load balancer data plane, so SRC_IPS_V1 and CEL rules are never evaluated and nothing is allowed or denied; aggregatedList, listPreconfiguredExpressionSets, regional and organization policies, associations, edge/backend bucket attachment and adaptive protection behaviour are not modelled." - "Instance groups: instance health is never probed (health checks are state only, so autoHealingPolicies are stored and validated but no instance is ever repaired, instanceHealth and lastAttempt stay empty); utilization-based autoscaling has no metric source; updatePolicy pacing (maxSurge, maxUnavailable, minimalAction, replacementMethod), stateful policy, per-instance configs, resize requests, createInstances, applyUpdatesToInstances, start/stop/suspend/resume instances and listErrors are not implemented; instance creation errors fail the whole call instead of surfacing as group errors." - "Instance templates cannot be created from sourceInstance and do not support regional-only fields beyond the stored properties; GRPC_WITH_TLS health checks and regional composite health checks are not modelled." - - "Regional disks, instantSnapshots, machine images, reservations, resource policies, VPN gateways/tunnels, interconnects, network peerings (addPeering/removePeering), shared VPC, security policies and licenses are not routed (404)." - - "Instances: update (PUT), updateNetworkInterface, setScheduling, setServiceAccount, setMinCpuPlatform, setShieldedInstanceConfig, suspend/resume, getSerialPortOutput, getGuestAttributes, bulkInsert, listReferrers and simulateMaintenanceEvent are not implemented; local SSD (SCRATCH) disks, GPUs, IPv6 and alias IP ranges are rejected or stored without effect." - - "System subnet routes (default-route-* per subnetwork) are not generated; only the 0.0.0.0/0 internet route is created with a network. Routes cannot use VPN tunnel, ILB, hub, peering or network next hops." + - "Regional disks, instantSnapshots, machine images, reservations, VPN gateways/tunnels, interconnects, shared VPC and licenses are not routed (404). Network peering has no CONSENSUS update strategy, requestRemovePeering or cancelRequestRemovePeering, stores the public-IP subnet route flags without effect (all emulator subnets are private or special-use ranges), and peer networks must exist in the emulator; peering routes are not added to routes.list. Resource policies are state only: schedules never take snapshots or start/stop instances, resourceStatus.instanceSchedulePolicy run times are not computed and status never becomes EXPIRED." + - "Instances: updateNetworkInterface, updateAccessConfig, updateDisplayDevice, setName, setMachineResources, setDiskAutoDelete, bulkInsert, listReferrers and simulateMaintenanceEvent are not implemented; local SSD (SCRATCH) disks, GPUs, IPv6 and alias IP ranges are rejected or stored without effect. instances.update uses a modelled field classification (see Instances.Update); getSerialPortOutput returns empty output and getGuestAttributes is always 404 because no guest runs." + - "Routes cannot use VPN tunnel, ILB, hub, peering or network next hops, and there are no dynamic (BGP) routes. The default-route- name is derived from the subnetwork self link rather than random; the real hash input is unknown." - "Global INTERNAL addresses (VPC peering ranges), IPv6 addresses and prefix-length reservations are rejected." - - "Error message texts for state conflicts (resourceNotReady on setMachineType/reset/detach), resourceInUseByAnotherResource wording, required-field and validation messages, and the firewall rule for ingress without sources were chosen without a production reference." + - "Error message texts for state conflicts (resourceNotReady on setMachineType/reset/detach/suspend/resume/setScheduling), peering overlap and duplicate errors, subnet route delete, resourceInUseByAnotherResource wording, required-field and validation messages, and the firewall rule for ingress without sources were chosen without a production reference." - "Only a fixed set of 15 regions, 46 zones, 9 image families, e2/n2/n1-standard machine types and 3 persistent disk types exist; image point releases are pinned, region quotas and Project usage quotas are not modelled, and the status/progress timing is a fixed model." - "No dashboard page, and the emulator does not enforce IAM or return permission errors." --- diff --git a/services/gcpcompute/README.md b/services/gcpcompute/README.md index df80eb036f..88150abcd8 100644 --- a/services/gcpcompute/README.md +++ b/services/gcpcompute/README.md @@ -14,14 +14,14 @@ ### Known gaps -- Load balancing: target pools, target instances, service attachments, legacy HTTP health checks, regional SSL policies, regional TCP/SSL certificates managed by Certificate Manager, certificate maps, security policies, backend service patch fields such as outlier detection are stored without effect, urlMaps.invalidateCache and forwardingRules.patch are not routed, and managed certificates never leave PROVISIONING. No traffic is forwarded and nothing is probed (see BackendServices.GetHealth). Backend bucket existence is not checked against gcpstorage. +- Load balancing: target pools, target instances, service attachments, legacy HTTP health checks, regional SSL policies, regional TCP/SSL certificates managed by Certificate Manager, certificate maps, backend service patch fields such as outlier detection are stored without effect, urlMaps.invalidateCache and forwardingRules.patch are not routed, and managed certificates never leave PROVISIONING. No traffic is forwarded and nothing is probed (see BackendServices.GetHealth). Backend bucket existence is not checked against gcpstorage. Cloud Armor security policies are state only: there is no HTTP load balancer data plane, so SRC_IPS_V1 and CEL rules are never evaluated and nothing is allowed or denied; aggregatedList, listPreconfiguredExpressionSets, regional and organization policies, associations, edge/backend bucket attachment and adaptive protection behaviour are not modelled. - Instance groups: instance health is never probed (health checks are state only, so autoHealingPolicies are stored and validated but no instance is ever repaired, instanceHealth and lastAttempt stay empty); utilization-based autoscaling has no metric source; updatePolicy pacing (maxSurge, maxUnavailable, minimalAction, replacementMethod), stateful policy, per-instance configs, resize requests, createInstances, applyUpdatesToInstances, start/stop/suspend/resume instances and listErrors are not implemented; instance creation errors fail the whole call instead of surfacing as group errors. - Instance templates cannot be created from sourceInstance and do not support regional-only fields beyond the stored properties; GRPC_WITH_TLS health checks and regional composite health checks are not modelled. -- Regional disks, instantSnapshots, machine images, reservations, resource policies, VPN gateways/tunnels, interconnects, network peerings (addPeering/removePeering), shared VPC, security policies and licenses are not routed (404). -- Instances: update (PUT), updateNetworkInterface, setScheduling, setServiceAccount, setMinCpuPlatform, setShieldedInstanceConfig, suspend/resume, getSerialPortOutput, getGuestAttributes, bulkInsert, listReferrers and simulateMaintenanceEvent are not implemented; local SSD (SCRATCH) disks, GPUs, IPv6 and alias IP ranges are rejected or stored without effect. -- System subnet routes (default-route-* per subnetwork) are not generated; only the 0.0.0.0/0 internet route is created with a network. Routes cannot use VPN tunnel, ILB, hub, peering or network next hops. +- Regional disks, instantSnapshots, machine images, reservations, VPN gateways/tunnels, interconnects, shared VPC and licenses are not routed (404). Network peering has no CONSENSUS update strategy, requestRemovePeering or cancelRequestRemovePeering, stores the public-IP subnet route flags without effect (all emulator subnets are private or special-use ranges), and peer networks must exist in the emulator; peering routes are not added to routes.list. Resource policies are state only: schedules never take snapshots or start/stop instances, resourceStatus.instanceSchedulePolicy run times are not computed and status never becomes EXPIRED. +- Instances: updateNetworkInterface, updateAccessConfig, updateDisplayDevice, setName, setMachineResources, setDiskAutoDelete, bulkInsert, listReferrers and simulateMaintenanceEvent are not implemented; local SSD (SCRATCH) disks, GPUs, IPv6 and alias IP ranges are rejected or stored without effect. instances.update uses a modelled field classification (see Instances.Update); getSerialPortOutput returns empty output and getGuestAttributes is always 404 because no guest runs. +- Routes cannot use VPN tunnel, ILB, hub, peering or network next hops, and there are no dynamic (BGP) routes. The default-route- name is derived from the subnetwork self link rather than random; the real hash input is unknown. - Global INTERNAL addresses (VPC peering ranges), IPv6 addresses and prefix-length reservations are rejected. -- Error message texts for state conflicts (resourceNotReady on setMachineType/reset/detach), resourceInUseByAnotherResource wording, required-field and validation messages, and the firewall rule for ingress without sources were chosen without a production reference. +- Error message texts for state conflicts (resourceNotReady on setMachineType/reset/detach/suspend/resume/setScheduling), peering overlap and duplicate errors, subnet route delete, resourceInUseByAnotherResource wording, required-field and validation messages, and the firewall rule for ingress without sources were chosen without a production reference. - Only a fixed set of 15 regions, 46 zones, 9 image families, e2/n2/n1-standard machine types and 3 persistent disk types exist; image point releases are pinned, region quotas and Project usage quotas are not modelled, and the status/progress timing is a fixed model. - No dashboard page, and the emulator does not enforce IAM or return permission errors. diff --git a/services/gcpcompute/catalog.go b/services/gcpcompute/catalog.go index a80a87788f..a454b2de86 100644 --- a/services/gcpcompute/catalog.go +++ b/services/gcpcompute/catalog.go @@ -51,7 +51,7 @@ var regionCatalog = []regionSpec{ //nolint:gochecknoglobals // read-only lookup var cpuPlatforms = []string{ //nolint:gochecknoglobals // read-only lookup table platBroadwell, - "Intel Cascade Lake", + platCascade, "Intel Skylake", "AMD Rome", } diff --git a/services/gcpcompute/consts.go b/services/gcpcompute/consts.go index 2b61f9eb43..35bfba2be2 100644 --- a/services/gcpcompute/consts.go +++ b/services/gcpcompute/consts.go @@ -17,3 +17,25 @@ const ( dateUbuntu = "2026-01-08" dateRocky = "2026-01-12" ) + +const ( + platCascade = "Intel Cascade Lake" + fieldPriority = "Priority" + provSpot = "SPOT" + keyAll = "ALL" + keyHTTPCookie = "HTTP_COOKIE" + keyHTTPHeader = "HTTP_HEADER" + stratUnspec = "UNSPECIFIED" + kindRoute = "compute#route" + jsonName = "name" + jsonSelfLink = "selfLink" + jsonLabels = "labels" + jsonUpdateStrt = "updateStrategy" + jsonKind = "kind" + jsonDesc = "description" + jsonCreated = "creationTimestamp" + jsonFingerp = "fingerprint" + jsonStackType = "stackType" + verbAddResPols = "addResourcePolicies" + verbDelResPols = "removeResourcePolicies" +) diff --git a/services/gcpcompute/disk.go b/services/gcpcompute/disk.go index 5a72a5cafe..0e6581781d 100644 --- a/services/gcpcompute/disk.go +++ b/services/gcpcompute/disk.go @@ -122,7 +122,8 @@ func (b *InMemoryBackend) createDiskLocked(project, zone string, in *computeapi. return nil, err } - if apiErr := validLabels(in.Labels); apiErr != nil { + policies, apiErr := b.diskLabelsAndPoliciesLocked(project, zone, in) + if apiErr != nil { return nil, apiErr } @@ -162,6 +163,7 @@ func (b *InMemoryBackend) createDiskLocked(project, zone string, in *computeapi. LabelFingerprint: labelFP(in.Labels), PhysicalBlockSizeBytes: physBlockBytes, Architecture: archX86, + ResourcePolicies: policies, } if src.image != nil { @@ -399,3 +401,12 @@ func (b *InMemoryBackend) setSnapshotLabels(project, name string, req labelsRequ b.timing.Done, ), nil } + +func (b *InMemoryBackend) diskLabelsAndPoliciesLocked(project, zone string, in *computeapi.Disk) ([]string, *APIError) { + if err := validLabels(in.Labels); err != nil { + return nil, err + } + + return b.resolveResPoliciesLocked( + project, regionOfZone(zone), in.ResourcePolicies, "resource.resourcePolicies", diskPolicyKinds()) +} diff --git a/services/gcpcompute/firewall.go b/services/gcpcompute/firewall.go index c35fcd27a3..2a0a42d635 100644 --- a/services/gcpcompute/firewall.go +++ b/services/gcpcompute/firewall.go @@ -204,7 +204,7 @@ func (b *InMemoryBackend) insertFirewall(project string, in *computeapi.Firewall f.Direction = dirIngress } - if f.Priority == 0 && !hasForced(in.ForceSendFields, "Priority") { + if f.Priority == 0 && !hasForced(in.ForceSendFields, fieldPriority) { f.Priority = defaultFirewallPrio } @@ -249,7 +249,7 @@ func (b *InMemoryBackend) getFirewall(project, name string) (*computeapi.Firewal func viewFirewall(f *computeapi.Firewall) *computeapi.Firewall { out := clone(f) - out.ForceSendFields = []string{"Disabled", "Priority"} + out.ForceSendFields = []string{"Disabled", fieldPriority} return out } @@ -319,7 +319,7 @@ func (b *InMemoryBackend) updateFirewall( next.Direction = cur.Direction } - if next.Priority == 0 && !hasForced(next.ForceSendFields, "Priority") { + if next.Priority == 0 && !hasForced(next.ForceSendFields, fieldPriority) { next.Priority = defaultFirewallPrio } diff --git a/services/gcpcompute/handler.go b/services/gcpcompute/handler.go index ba0ba14bbd..3af0e8daa2 100644 --- a/services/gcpcompute/handler.go +++ b/services/gcpcompute/handler.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "net/url" "sort" @@ -15,8 +14,10 @@ import ( "github.com/labstack/echo/v5" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -64,6 +65,8 @@ func NewHandler(backend *InMemoryBackend) *Handler { h.registerInstanceOps() h.registerCatalogOps() h.registerGroupOps() + h.registerSecurityOps() + h.registerResourcePolicyOps() return h } @@ -127,10 +130,13 @@ func (h *Handler) Reset() { h.Backend.Reset() } var readVerbs = map[string]bool{ //nolint:gochecknoglobals // read-only lookup table verbList: true, verbAggList: true, verbGet: true, "getRouterStatus": true, "getFromFamily": true, + "listPeeringRoutes": true, "getRule": true, "serialPort": true, "getGuestAttributes": true, } var verbMethods = map[string]string{ //nolint:gochecknoglobals // read-only lookup table verbInsert: http.MethodPost, verbDelete: http.MethodDelete, verbPatch: http.MethodPatch, verbUpdate: http.MethodPut, + "updatePeering": http.MethodPatch, "updateShieldedInstanceConfig": http.MethodPatch, + "setShieldedInstanceIntegrityPolicy": http.MethodPatch, } func methodFor(verb string) string { @@ -274,15 +280,16 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpcompute", "listener") log := logger.Load(workerCtx) - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { return fmt.Errorf("gcpcompute: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(log)) + e.Use(gcpenforce.EchoMiddleware(ctx, gcpenforce.Target{Host: "compute.googleapis.com"})) e.Any("/*", telemetry.WrapEchoHandler("GCPCompute", h.Handler(), h)) srv := &http.Server{ diff --git a/services/gcpcompute/handler_test.go b/services/gcpcompute/handler_test.go index 9d92da7f40..156a64e5a2 100644 --- a/services/gcpcompute/handler_test.go +++ b/services/gcpcompute/handler_test.go @@ -1,7 +1,6 @@ package gcpcompute_test import ( - "net" "net/http" "strconv" "testing" @@ -11,21 +10,10 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/gcpcompute" ) -func freePort(t *testing.T) int { - t.Helper() - - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - port := l.Addr().(*net.TCPAddr).Port - require.NoError(t, l.Close()) - - return port -} - type fakeConfig struct{ port int } func (f fakeConfig) GetGCPComputeSettings() gcpcompute.Settings { @@ -90,12 +78,13 @@ func TestStartWorkerServesAndConflicts(t *testing.T) { t.Parallel() first := gcpcompute.NewHandler(gcpcompute.NewInMemoryBackend()) - first.Port = freePort(t) + first.Port = 0 require.NoError(t, first.StartWorker(t.Context())) t.Cleanup(func() { first.Shutdown(t.Context()) }) require.NotEmpty(t, first.Addr()) - resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(first.Port) + "/compute/v1/projects/p/global/networks") + resp, err := testclient.New(t). + Get("http://127.0.0.1:" + strconv.Itoa(first.Port) + "/compute/v1/projects/p/global/networks") require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/services/gcpcompute/helpers_test.go b/services/gcpcompute/helpers_test.go index fbd712ce95..ea5c94363f 100644 --- a/services/gcpcompute/helpers_test.go +++ b/services/gcpcompute/helpers_test.go @@ -17,6 +17,7 @@ import ( "google.golang.org/api/googleapi" "google.golang.org/api/option" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/gcpcompute" ) @@ -224,7 +225,7 @@ func rawDo(t *testing.T, e *env, method, path, body string) (int, string) { require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/gcpcompute/instance.go b/services/gcpcompute/instance.go index 07beb57a3a..88179c4506 100644 --- a/services/gcpcompute/instance.go +++ b/services/gcpcompute/instance.go @@ -46,6 +46,12 @@ func (r *instRec) status(now time.Time, t Timing) string { } return stTerminated + case phaseSuspend: + if el < t.Stop { + return stSuspending + } + + return stSuspended } return stRunning @@ -56,7 +62,7 @@ func instRunning(r *instRec, now time.Time, t Timing) bool { return r.status(now func cpuPlatformFor(machine string) string { switch { case strings.HasPrefix(machine, "n2-"): - return "Intel Cascade Lake" + return platCascade case strings.HasPrefix(machine, "n1-"): return "Intel Haswell" } @@ -215,7 +221,7 @@ func defaultScheduling(in *computeapi.Scheduling) (*computeapi.Scheduling, *APIE *s = *clone(in) } - spot := s.Preemptible || s.ProvisioningModel == "SPOT" + spot := s.Preemptible || s.ProvisioningModel == provSpot if spot && s.AutomaticRestart != nil && *s.AutomaticRestart { return nil, errInvalid( @@ -227,7 +233,7 @@ func defaultScheduling(in *computeapi.Scheduling) (*computeapi.Scheduling, *APIE if s.ProvisioningModel == "" { s.ProvisioningModel = tierStandard if s.Preemptible { - s.ProvisioningModel = "SPOT" + s.ProvisioningModel = provSpot } } @@ -758,6 +764,12 @@ func (b *InMemoryBackend) insertInstanceLocked( return nil, err } + policies, err := b.resolveResPoliciesLocked( + project, regionOfZone(zone), in.ResourcePolicies, "resource.resourcePolicies", instancePolicyKinds()) + if err != nil { + return nil, err + } + nics, err := b.buildNICsLocked(project, zone, in) if err != nil { return nil, err @@ -773,6 +785,7 @@ func (b *InMemoryBackend) insertInstanceLocked( } inst := b.assembleInstanceLocked(project, zone, in, machine, sched, nics, disks) + inst.ResourcePolicies = policies b.instances[key(project, zone, in.Name)] = &instRec{Inst: inst, Phase: phaseStart, Since: b.now().UTC()} return inst, nil @@ -932,6 +945,11 @@ func (b *InMemoryBackend) startInstance(project, zone, name string) (*computeapi return nil, err } + if st := r.status(b.now().UTC(), b.timing); st == stSuspended || st == stSuspending { + return nil, errNotReady(relPath(project, scopeZone, zone, "instances", name), + "the instance is suspended; use instances.resume") + } + if r.status(b.now().UTC(), b.timing) != stTerminated { return b.newOpLocked("start", instTarget(project, zone, r.Inst), b.timing.Pending), nil } diff --git a/services/gcpcompute/instance_ops.go b/services/gcpcompute/instance_ops.go new file mode 100644 index 0000000000..90e7f86487 --- /dev/null +++ b/services/gcpcompute/instance_ops.go @@ -0,0 +1,593 @@ +package gcpcompute + +import ( + "reflect" + "regexp" + "slices" + "strconv" + + computeapi "google.golang.org/api/compute/v1" +) + +const ( + phaseSuspend = "suspend" + stSuspending = "SUSPENDING" + stSuspended = "SUSPENDED" + actNoEffect = "NO_EFFECT" + actRefresh = "REFRESH" + actRestart = "RESTART" + maxSerial = 4 +) + +var ( + emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + actionOrder = []string{actNoEffect, actRefresh, actRestart} //nolint:gochecknoglobals // lookup + minCPUPlatforms = []string{ //nolint:gochecknoglobals // read-only lookup table + "Automatic", "Intel Sandy Bridge", "Intel Ivy Bridge", "Intel Haswell", "Intel Broadwell", "Intel Skylake", + platCascade, "Intel Ice Lake", "Intel Sapphire Rapids", "AMD Rome", "AMD Milan", "AMD Genoa", + } + updateSkip = map[string]bool{ //nolint:gochecknoglobals // read-only lookup table + jsonKind: true, "id": true, jsonSelfLink: true, "selfLinkWithId": true, jsonCreated: true, "status": true, + "statusMessage": true, "cpuPlatform": true, "lastStartTimestamp": true, "lastStopTimestamp": true, + "lastSuspendedTimestamp": true, "zone": true, "resourceStatus": true, "satisfiesPzi": true, + "satisfiesPzs": true, "startRestricted": true, jsonFingerp: true, "labelFingerprint": true, + } + updateNoEffect = map[string]bool{ //nolint:gochecknoglobals // read-only lookup table + jsonDesc: true, "deletionProtection": true, jsonLabels: true, "metadata": true, "tags": true, + } + updateRestart = map[string]bool{ //nolint:gochecknoglobals // read-only lookup table + "canIpForward": true, "shieldedInstanceConfig": true, "displayDevice": true, "minCpuPlatform": true, + "serviceAccounts": true, + } +) + +type updateOpts struct { + minimal string + mostDisruptive string +} + +type serviceAccountRequest struct { + Email string `json:"email"` + Scopes []string `json:"scopes"` +} + +type minCPURequest struct { + MinCPUPlatform string `json:"minCpuPlatform"` +} + +func (b *InMemoryBackend) requireStoppedLocked(r *instRec, project, zone, what string) *APIError { + if r.status(b.now().UTC(), b.timing) != stTerminated { + return errNotReady( + relPath(project, scopeZone, zone, "instances", r.Inst.Name), + "the instance must be TERMINATED to change its "+what, + ) + } + + return nil +} + +func (b *InMemoryBackend) setInstanceScheduling( + project, zone, name string, in *computeapi.Scheduling, +) (*computeapi.Operation, error) { + b.mu.Lock("SetScheduling") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + next, derr := defaultScheduling(in) + if derr != nil { + return nil, derr + } + + if err = b.requireStoppedLocked(r, project, zone, "scheduling options"); err != nil { + return nil, err + } + + r.Inst.Scheduling = next + b.touchLocked(r) + + return b.newOpLocked("setScheduling", instTarget(project, zone, r.Inst), b.timing.Done), nil +} + +func schedulingNeedsStop(cur, next *computeapi.Scheduling) bool { + if cur == nil { + return false + } + + return cur.Preemptible != next.Preemptible || cur.ProvisioningModel != next.ProvisioningModel || + cur.InstanceTerminationAction != next.InstanceTerminationAction || cur.MinNodeCpus != next.MinNodeCpus || + !reflect.DeepEqual(cur.NodeAffinities, next.NodeAffinities) +} + +func validServiceAccounts(email string, scopes []string) ([]*computeapi.ServiceAccount, *APIError) { + if email == "" { + if len(scopes) > 0 { + return nil, errRequired("email") + } + + return nil, nil + } + + if !emailRE.MatchString(email) { + return nil, errField("email", email, "Must be a service account email address.") + } + + for _, s := range scopes { + if s == "" { + return nil, errField("scopes", s, "Scopes cannot be empty.") + } + } + + return []*computeapi.ServiceAccount{{Email: email, Scopes: scopes}}, nil +} + +func (b *InMemoryBackend) setInstanceServiceAccount( + project, zone, name string, in *serviceAccountRequest, +) (*computeapi.Operation, error) { + b.mu.Lock("SetServiceAccount") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + if err = b.requireStoppedLocked(r, project, zone, "service account"); err != nil { + return nil, err + } + + accounts, aerr := validServiceAccounts(in.Email, in.Scopes) + if aerr != nil { + return nil, aerr + } + + r.Inst.ServiceAccounts = accounts + b.touchLocked(r) + + return b.newOpLocked("setServiceAccount", instTarget(project, zone, r.Inst), b.timing.Done), nil +} + +func (b *InMemoryBackend) setInstanceMinCPUPlatform( + project, zone, name, platform string, +) (*computeapi.Operation, error) { + b.mu.Lock("SetMinCpuPlatform") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + if err = b.requireStoppedLocked(r, project, zone, "minimum CPU platform"); err != nil { + return nil, err + } + + if !slices.Contains(minCPUPlatforms, platform) { + return nil, errField("minCpuPlatform", platform, "Not a supported CPU platform.") + } + + r.Inst.MinCpuPlatform = platform + b.touchLocked(r) + + return b.newOpLocked("setMinCpuPlatform", instTarget(project, zone, r.Inst), b.timing.Done), nil +} + +func (b *InMemoryBackend) updateInstanceShielded( + project, zone, name string, in *computeapi.ShieldedInstanceConfig, raw map[string]any, +) (*computeapi.Operation, error) { + b.mu.Lock("UpdateShieldedInstanceConfig") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + if err = b.requireStoppedLocked(r, project, zone, "shielded instance configuration"); err != nil { + return nil, err + } + + cfg := clone(r.Inst.ShieldedInstanceConfig) + if cfg == nil { + cfg = &computeapi.ShieldedInstanceConfig{} + } + + if _, ok := raw["enableSecureBoot"]; ok { + cfg.EnableSecureBoot = in.EnableSecureBoot + } + + if _, ok := raw["enableVtpm"]; ok { + cfg.EnableVtpm = in.EnableVtpm + } + + if _, ok := raw["enableIntegrityMonitoring"]; ok { + cfg.EnableIntegrityMonitoring = in.EnableIntegrityMonitoring + } + + r.Inst.ShieldedInstanceConfig = cfg + b.touchLocked(r) + + return b.newOpLocked("updateShieldedInstanceConfig", instTarget(project, zone, r.Inst), b.timing.Done), nil +} + +func (b *InMemoryBackend) setInstanceIntegrityPolicy( + project, zone, name string, in *computeapi.ShieldedInstanceIntegrityPolicy, +) (*computeapi.Operation, error) { + b.mu.Lock("SetShieldedInstanceIntegrityPolicy") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + if r.status(b.now().UTC(), b.timing) != stRunning { + return nil, errNotReady(relPath(project, scopeZone, zone, "instances", name), + "the instance must be RUNNING to change its integrity policy") + } + + r.Inst.ShieldedInstanceIntegrityPolicy = &computeapi.ShieldedInstanceIntegrityPolicy{ + UpdateAutoLearnPolicy: in.UpdateAutoLearnPolicy, + } + b.touchLocked(r) + + return b.newOpLocked("setShieldedInstanceIntegrityPolicy", instTarget(project, zone, r.Inst), b.timing.Done), nil +} + +func (b *InMemoryBackend) suspendInstance(project, zone, name string) (*computeapi.Operation, error) { + b.mu.Lock("SuspendInstance") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + path := relPath(project, scopeZone, zone, "instances", name) + + if r.status(b.now().UTC(), b.timing) != stRunning { + return nil, errNotReady(path, "the instance must be RUNNING to be suspended") + } + + if s := r.Inst.Scheduling; s != nil && (s.Preemptible || s.ProvisioningModel == provSpot) { + return nil, errInvalid("Invalid value for field 'resource.scheduling': Spot and preemptible instances " + + "cannot be suspended.") + } + + if len(r.Inst.GuestAccelerators) > 0 { + return nil, errInvalid("Invalid value for field 'resource.guestAccelerators': instances with GPUs " + + "cannot be suspended.") + } + + now := b.now().UTC() + r.Phase, r.Since = phaseSuspend, now + r.Inst.LastSuspendedTimestamp = now.Format(timeLayout) + b.touchLocked(r) + + return b.newOpLocked("suspend", instTarget(project, zone, r.Inst), b.timing.Stop), nil +} + +func (b *InMemoryBackend) resumeInstance(project, zone, name string) (*computeapi.Operation, error) { + b.mu.Lock("ResumeInstance") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + if r.status(b.now().UTC(), b.timing) != stSuspended { + return nil, errNotReady(relPath(project, scopeZone, zone, "instances", name), + "the instance must be SUSPENDED to be resumed") + } + + now := b.now().UTC() + r.Phase, r.Since = phaseStart, now + r.Inst.LastStartTimestamp = now.Format(timeLayout) + b.touchLocked(r) + + return b.newOpLocked("resume", instTarget(project, zone, r.Inst), b.timing.Stage), nil +} + +func (b *InMemoryBackend) serialPortOutput( + project, zone, name string, port, start int64, +) (*computeapi.SerialPortOutput, error) { + b.mu.RLock("GetSerialPortOutput") + defer b.mu.RUnlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + if port < 1 || port > maxSerial { + return nil, errField("port", strconv.FormatInt(port, 10), "Must be between 1 and 4.") + } + + if start < 0 { + return nil, errField("start", strconv.FormatInt(start, 10), "Must not be negative.") + } + + return &computeapi.SerialPortOutput{ + Kind: "compute#serialPortOutput", SelfLink: r.Inst.SelfLink + "/serialPort", + ForceSendFields: []string{"Start", "Next"}, + }, nil +} + +func (b *InMemoryBackend) guestAttributes(project, zone, name string) (*computeapi.GuestAttributes, error) { + b.mu.RLock("GetGuestAttributes") + defer b.mu.RUnlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + return nil, errNotFound(trimURL(r.Inst.SelfLink) + "/guestAttributes") +} + +func actionOf(raw, field string) (string, *APIError) { + if raw == "" { + return "", nil + } + + if !slices.Contains(actionOrder, raw) { + return "", errField(field, raw, "Must be NO_EFFECT, REFRESH or RESTART.") + } + + return raw, nil +} + +func (b *InMemoryBackend) updateInstance( + project, zone, name string, in *computeapi.Instance, raw map[string]any, o updateOpts, +) (*computeapi.Operation, error) { + b.mu.Lock("UpdateInstance") + defer b.mu.Unlock() + + r, err := b.getInstLocked(project, zone, name) + if err != nil { + return nil, err + } + + minimal, aerr := actionOf(o.minimal, "minimalAction") + if aerr != nil { + return nil, aerr + } + + most, aerr := actionOf(o.mostDisruptive, "mostDisruptiveAllowedAction") + if aerr != nil { + return nil, aerr + } + + next := clone(r.Inst) + + need, aerr := b.applyInstanceUpdate(r.Inst, next, in, raw) + if aerr != nil { + return nil, aerr + } + + if actionRank(minimal) > actionRank(need) { + need = minimal + } + + if most != "" && actionRank(need) > actionRank(most) { + return nil, errField("mostDisruptiveAllowedAction", most, + "The update requires the "+need+" action, which is more disruptive than allowed.") + } + + restart := need == actRestart && r.status(b.now().UTC(), b.timing) == stRunning + + r.Inst = next + b.touchLocked(r) + + done := b.timing.Done + + if restart { + now := b.now().UTC() + r.Phase, r.Since = phaseStart, now + r.Inst.LastStopTimestamp, r.Inst.LastStartTimestamp = now.Format(timeLayout), now.Format(timeLayout) + done = b.timing.Stage + } + + return b.newOpLocked("update", instTarget(project, zone, r.Inst), done), nil +} + +// applyInstanceUpdate copies the changed, updatable fields of in onto next and returns the least disruptive action. +func (b *InMemoryBackend) applyInstanceUpdate( + cur, next, in *computeapi.Instance, raw map[string]any, +) (string, *APIError) { + if fp, ok := raw[jsonFingerp].(string); ok && fp != "" && fp != cur.Fingerprint { + return "", errPrecondition("Supplied fingerprint does not match current fingerprint.") + } + + curDoc := toDoc(cur) + need := actNoEffect + + keys := make([]string, 0, len(raw)) + for k := range raw { + keys = append(keys, k) + } + + slices.Sort(keys) + + for _, k := range keys { + if updateSkip[k] || reflect.DeepEqual(raw[k], curDoc[k]) { + continue + } + + act, err := b.updateField(cur, next, in, raw, k) + if err != nil { + return "", err + } + + if actionRank(act) > actionRank(need) { + need = act + } + } + + if lf, ok := raw["labelFingerprint"].(string); ok && lf != "" && lf != cur.LabelFingerprint { + if _, labelsGiven := raw[jsonLabels]; labelsGiven { + return "", errPrecondition("%s", labelsStaleDetail) + } + } + + return need, nil +} + +func (b *InMemoryBackend) updateField( + cur, next, in *computeapi.Instance, raw map[string]any, k string, +) (string, *APIError) { + switch { + case k == "machineType" && lastSegment(in.MachineType) == lastSegment(cur.MachineType): + return actNoEffect, nil + case !updateNoEffect[k] && !updateRestart[k] && k != "scheduling": + return "", errField("resource."+k, "", "This field cannot be changed by instances.update.") + } + + act := actNoEffect + if updateRestart[k] { + act = actRestart + } + + switch k { + case jsonDesc: + next.Description = in.Description + case "deletionProtection": + next.DeletionProtection = in.DeletionProtection + case "canIpForward": + next.CanIpForward = in.CanIpForward + case "displayDevice": + next.DisplayDevice = in.DisplayDevice + default: + return updateComplexField(k, act, cur, next, in, raw) + } + + return act, nil +} + +func updateComplexField( + k, act string, cur, next, in *computeapi.Instance, raw map[string]any, +) (string, *APIError) { + switch k { + case jsonLabels: + return act, applyUpdateLabels(next, in, raw) + case "metadata": + return act, applyUpdateMetadata(cur, next, in) + case "tags": + return act, applyUpdateTags(cur, next, in) + case "scheduling": + return applyUpdateScheduling(cur, next, in) + } + + return act, applyUpdateRestartField(k, next, in) +} + +func applyUpdateLabels(next, in *computeapi.Instance, raw map[string]any) *APIError { + req := labelsRequest{Labels: in.Labels, LabelFingerprint: next.LabelFingerprint} + if lf, ok := raw["labelFingerprint"].(string); ok && lf != "" { + req.LabelFingerprint = lf + } + + labels, fp, err := applyLabels(next.Labels, next.LabelFingerprint, req) + if err != nil { + return err + } + + next.Labels, next.LabelFingerprint = labels, fp + + return nil +} + +func applyUpdateMetadata(cur, next, in *computeapi.Instance) *APIError { + if in.Metadata == nil { + return nil + } + + if in.Metadata.Fingerprint != "" && in.Metadata.Fingerprint != cur.Metadata.Fingerprint { + return errPrecondition("Supplied fingerprint does not match current metadata fingerprint.") + } + + if err := validMetadata(in.Metadata); err != nil { + return err + } + + next.Metadata = normalizeMetadata(in.Metadata) + + return nil +} + +func applyUpdateTags(cur, next, in *computeapi.Instance) *APIError { + if in.Tags == nil { + return nil + } + + if in.Tags.Fingerprint != "" && in.Tags.Fingerprint != cur.Tags.Fingerprint { + return errPrecondition("Supplied fingerprint does not match current tags fingerprint.") + } + + if err := validTags("tags.items", in.Tags.Items); err != nil { + return err + } + + next.Tags = &computeapi.Tags{Items: in.Tags.Items, Fingerprint: tagsFP(in.Tags.Items)} + + return nil +} + +func applyUpdateScheduling(cur, next, in *computeapi.Instance) (string, *APIError) { + s, err := defaultScheduling(in.Scheduling) + if err != nil { + return "", err + } + + next.Scheduling = s + + if schedulingNeedsStop(cur.Scheduling, s) { + return actRestart, nil + } + + return actNoEffect, nil +} + +func applyUpdateRestartField(k string, next, in *computeapi.Instance) *APIError { + switch k { + case "shieldedInstanceConfig": + if in.ShieldedInstanceConfig == nil { + return errRequired("shieldedInstanceConfig") + } + + next.ShieldedInstanceConfig = in.ShieldedInstanceConfig + case "minCpuPlatform": + if !slices.Contains(minCPUPlatforms, in.MinCpuPlatform) { + return errField("minCpuPlatform", in.MinCpuPlatform, "Not a supported CPU platform.") + } + + next.MinCpuPlatform = in.MinCpuPlatform + case "serviceAccounts": + if len(in.ServiceAccounts) > 1 { + return errField("serviceAccounts", "", "Only one service account per instance is supported.") + } + + var email string + + var scopes []string + + if len(in.ServiceAccounts) == 1 { + email, scopes = in.ServiceAccounts[0].Email, in.ServiceAccounts[0].Scopes + } + + accounts, err := validServiceAccounts(email, scopes) + if err != nil { + return err + } + + next.ServiceAccounts = accounts + } + + return nil +} + +func actionRank(a string) int { return slices.Index(actionOrder, a) } diff --git a/services/gcpcompute/instance_ops_test.go b/services/gcpcompute/instance_ops_test.go new file mode 100644 index 0000000000..91eab6508a --- /dev/null +++ b/services/gcpcompute/instance_ops_test.go @@ -0,0 +1,577 @@ +package gcpcompute_test + +import ( + "net/http" + "testing" + "time" + + compute "cloud.google.com/go/compute/apiv1" + "cloud.google.com/go/compute/apiv1/computepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func (e *env) runningVM(t *testing.T) { + t.Helper() + e.baseNet(t) + e.vm(t, vmSpec("vm1")) +} + +func (e *env) stopVM(t *testing.T) { + t.Helper() + + op, err := e.instances(t). + Stop(t.Context(), &computepb.StopInstanceRequest{Project: project, Zone: zone, Instance: "vm1"}) + e.done(t, op, err) + require.Equal(t, stTerm, e.getVM(t, "vm1").GetStatus()) +} + +func (e *env) update(t *testing.T, name string, res *computepb.Instance, minimal, most string) error { + t.Helper() + + req := &computepb.UpdateInstanceRequest{Project: project, Zone: zone, Instance: name, InstanceResource: res} + if minimal != "" { + req.MinimalAction = &minimal + } + + if most != "" { + req.MostDisruptiveAllowedAction = &most + } + + _, err := e.instances(t).Update(t.Context(), req) + + return err +} + +func TestInstanceSuspendResume(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.runningVM(t) + timing := gcpcomputeTiming() + + assertAPIError(t, e.resumeErr(t, "vm1"), http.StatusBadRequest, "resourceNotReady") + + op, err := e.instances(t). + Suspend(t.Context(), &computepb.SuspendInstanceRequest{Project: project, Zone: zone, Instance: "vm1"}) + require.NoError(t, err) + assert.Equal(t, "suspend", op.Proto().GetOperationType()) + assert.Equal(t, "SUSPENDING", e.getVM(t, "vm1").GetStatus()) + assertAPIError(t, e.resumeErr(t, "vm1"), http.StatusBadRequest, "resourceNotReady") + + e.clock.Advance(timing.Stop) + assert.Equal(t, "SUSPENDED", e.getVM(t, "vm1").GetStatus()) + assert.NotEmpty(t, e.getVM(t, "vm1").GetLastSuspendedTimestamp()) + + _, err = e.instances(t). + Start(t.Context(), &computepb.StartInstanceRequest{Project: project, Zone: zone, Instance: "vm1"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceNotReady") + _, err = e.instances(t). + Suspend(t.Context(), &computepb.SuspendInstanceRequest{Project: project, Zone: zone, Instance: "vm1"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceNotReady") + + require.NoError(t, e.resumeErr(t, "vm1")) + + for _, step := range []struct { + status string + after time.Duration + }{ + {"PROVISIONING", 0}, + {"STAGING", timing.Provision}, + {"RUNNING", timing.Stage - timing.Provision}, + } { + e.clock.Advance(step.after) + assert.Equal(t, step.status, e.getVM(t, "vm1").GetStatus()) + } +} + +func (e *env) resumeErr(t *testing.T, name string) error { + t.Helper() + + op, err := e.instances(t). + Resume(t.Context(), &computepb.ResumeInstanceRequest{Project: project, Zone: zone, Instance: name}) + if err != nil { + return err + } + + _ = op + + return nil +} + +func TestInstanceSuspendPreconditions(t *testing.T) { + t.Parallel() + + tests := []struct { + prep func(t *testing.T, e *env) + name string + spot bool + }{ + {name: "terminated", prep: func(t *testing.T, e *env) { t.Helper(); e.stopVM(t) }}, + {name: "spot", spot: true, prep: func(*testing.T, *env) {}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.baseNet(t) + + spec := vmSpec("vm1") + if tt.spot { + spec.Scheduling = &computepb.Scheduling{ + ProvisioningModel: s("SPOT"), + InstanceTerminationAction: s("STOP"), + } + } + + e.vm(t, spec) + tt.prep(t, e) + + _, err := e.instances(t). + Suspend(t.Context(), &computepb.SuspendInstanceRequest{Project: project, Zone: zone, Instance: "vm1"}) + assertAPIError(t, err, http.StatusBadRequest, "") + }) + } +} + +func TestInstanceStoppedOnlyMutations(t *testing.T) { + t.Parallel() + + type call func(t *testing.T, e *env) error + + do := func(t *testing.T, e *env, mk func(c *compute.InstancesClient) (*compute.Operation, error)) error { + t.Helper() + + op, err := mk(e.instances(t)) + + return e.finish(t, op, err) + } + + tests := []struct { + call call + verify func(t *testing.T, i *computepb.Instance) + name string + }{ + { + name: "service account", + call: func(t *testing.T, e *env) error { + t.Helper() + + return do(t, e, func(c *compute.InstancesClient) (*compute.Operation, error) { + return c.SetServiceAccount(t.Context(), &computepb.SetServiceAccountInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + InstancesSetServiceAccountRequestResource: &computepb.InstancesSetServiceAccountRequest{ + Email: s( + "sa@proj.iam.gserviceaccount.com", + ), Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + }, + }) + }) + }, + verify: func(t *testing.T, i *computepb.Instance) { + t.Helper() + require.Len(t, i.GetServiceAccounts(), 1) + assert.Equal(t, "sa@proj.iam.gserviceaccount.com", i.GetServiceAccounts()[0].GetEmail()) + }, + }, + { + name: "min cpu platform", + call: func(t *testing.T, e *env) error { + t.Helper() + + return do(t, e, func(c *compute.InstancesClient) (*compute.Operation, error) { + return c.SetMinCpuPlatform(t.Context(), &computepb.SetMinCpuPlatformInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + InstancesSetMinCpuPlatformRequestResource: &computepb.InstancesSetMinCpuPlatformRequest{ + MinCpuPlatform: s("Intel Skylake"), + }, + }) + }) + }, + verify: func(t *testing.T, i *computepb.Instance) { + t.Helper() + assert.Equal(t, "Intel Skylake", i.GetMinCpuPlatform()) + }, + }, + { + name: "shielded config", + call: func(t *testing.T, e *env) error { + t.Helper() + + return do(t, e, func(c *compute.InstancesClient) (*compute.Operation, error) { + return c.UpdateShieldedInstanceConfig( + t.Context(), + &computepb.UpdateShieldedInstanceConfigInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + ShieldedInstanceConfigResource: &computepb.ShieldedInstanceConfig{ + EnableSecureBoot: new(true), + EnableVtpm: new(true), + }, + }, + ) + }) + }, + verify: func(t *testing.T, i *computepb.Instance) { + t.Helper() + assert.True(t, i.GetShieldedInstanceConfig().GetEnableSecureBoot()) + assert.True( + t, + i.GetShieldedInstanceConfig().GetEnableIntegrityMonitoring(), + "PATCH keeps unspecified fields", + ) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.runningVM(t) + + assertAPIError(t, tt.call(t, e), http.StatusBadRequest, "resourceNotReady") + + e.stopVM(t) + require.NoError(t, tt.call(t, e)) + tt.verify(t, e.getVM(t, "vm1")) + }) + } +} + +func TestInstanceStoppedOnlyValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.runningVM(t) + e.stopVM(t) + + c := e.instances(t) + + _, err := c.SetServiceAccount(t.Context(), &computepb.SetServiceAccountInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + InstancesSetServiceAccountRequestResource: &computepb.InstancesSetServiceAccountRequest{ + Email: s("not-an-email"), + }, + }) + assertAPIError(t, err, http.StatusBadRequest, "invalid") + + _, err = c.SetMinCpuPlatform(t.Context(), &computepb.SetMinCpuPlatformInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + InstancesSetMinCpuPlatformRequestResource: &computepb.InstancesSetMinCpuPlatformRequest{ + MinCpuPlatform: s("Quantum 9000"), + }, + }) + assertAPIError(t, err, http.StatusBadRequest, "invalid") + + _, err = c.SetServiceAccount(t.Context(), &computepb.SetServiceAccountInstanceRequest{ + Project: project, Zone: zone, Instance: "ghost", + InstancesSetServiceAccountRequestResource: &computepb.InstancesSetServiceAccountRequest{}, + }) + assertAPIError(t, err, http.StatusNotFound, "notFound") +} + +func TestInstanceSetScheduling(t *testing.T) { + t.Parallel() + + set := func(t *testing.T, e *env, sc *computepb.Scheduling) error { + t.Helper() + + op, err := e.instances(t).SetScheduling(t.Context(), &computepb.SetSchedulingInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", SchedulingResource: sc, + }) + + return e.finish(t, op, err) + } + + e := newEnv(t) + e.runningVM(t) + + spot := &computepb.Scheduling{ProvisioningModel: s("SPOT"), InstanceTerminationAction: s("STOP")} + assertAPIError(t, set(t, e, spot), http.StatusBadRequest, "resourceNotReady") + assertAPIError( + t, + set(t, e, &computepb.Scheduling{AutomaticRestart: new(false)}), + http.StatusBadRequest, + "resourceNotReady", + ) + + e.stopVM(t) + require.NoError( + t, + set(t, e, &computepb.Scheduling{AutomaticRestart: new(false), OnHostMaintenance: s("TERMINATE")}), + ) + + sc := e.getVM(t, "vm1").GetScheduling() + assert.False(t, sc.GetAutomaticRestart()) + assert.Equal(t, "TERMINATE", sc.GetOnHostMaintenance()) + + require.NoError(t, set(t, e, spot)) + assert.Equal(t, "SPOT", e.getVM(t, "vm1").GetScheduling().GetProvisioningModel()) + assertAPIError( + t, + set(t, e, &computepb.Scheduling{ProvisioningModel: s("SPOT"), AutomaticRestart: new(true)}), + http.StatusBadRequest, + "invalid", + ) +} + +func TestInstanceUpdate(t *testing.T) { + t.Parallel() + + tests := []struct { + res *computepb.Instance + verify func(t *testing.T, i *computepb.Instance) + name string + minimal string + most string + status string + errToken string + errCode int + stopped bool + }{ + { + name: "description no effect", res: &computepb.Instance{Description: s("new")}, status: stRunning, + verify: func(t *testing.T, i *computepb.Instance) { + t.Helper() + assert.Equal(t, "new", i.GetDescription()) + }, + }, + { + name: "deletion protection", res: &computepb.Instance{DeletionProtection: new(true)}, status: stRunning, + verify: func(t *testing.T, i *computepb.Instance) { t.Helper(); assert.True(t, i.GetDeletionProtection()) }, + }, + { + name: "can ip forward restarts", res: &computepb.Instance{CanIpForward: new(true)}, status: "PROVISIONING", + verify: func(t *testing.T, i *computepb.Instance) { t.Helper(); assert.True(t, i.GetCanIpForward()) }, + }, + { + name: "minimal restart on description", + res: &computepb.Instance{Description: s("d")}, + minimal: "RESTART", + status: "PROVISIONING", + }, + { + name: "minimal refresh keeps running", + res: &computepb.Instance{Description: s("d")}, + minimal: "REFRESH", + status: stRunning, + }, + { + name: "most disruptive allows", + res: &computepb.Instance{CanIpForward: new(true)}, + most: "RESTART", + status: "PROVISIONING", + }, + { + name: "most disruptive too low", res: &computepb.Instance{CanIpForward: new(true)}, most: "NO_EFFECT", + status: stRunning, errCode: http.StatusBadRequest, errToken: "invalid", + }, + { + name: "minimal above most", + res: &computepb.Instance{Description: s("d")}, + minimal: "RESTART", + most: "REFRESH", + status: stRunning, errCode: http.StatusBadRequest, errToken: "invalid", + }, + { + name: "bad minimal", + res: &computepb.Instance{}, + minimal: "REBOOT", + status: stRunning, + errCode: http.StatusBadRequest, + errToken: "invalid", + }, + { + name: "bad most", + res: &computepb.Instance{}, + most: "REBOOT", + status: stRunning, + errCode: http.StatusBadRequest, + errToken: "invalid", + }, + { + name: "immutable machine type", + res: &computepb.Instance{MachineType: s("zones/" + zone + "/machineTypes/e2-standard-4")}, + status: stRunning, errCode: http.StatusBadRequest, errToken: "invalid", + }, + { + name: "same machine type is fine", + res: &computepb.Instance{MachineType: s(e2Medium), Description: s("x")}, + status: stRunning, + }, + { + name: "stale fingerprint", + res: &computepb.Instance{Fingerprint: s("stale")}, + status: stRunning, + errCode: http.StatusPreconditionFailed, + errToken: "conditionNotMet", + }, + { + name: "stale label fingerprint", + res: &computepb.Instance{Labels: map[string]string{"a": "b"}, LabelFingerprint: s("stale")}, + status: stRunning, errCode: http.StatusPreconditionFailed, errToken: "conditionNotMet", + }, + { + name: "labels", res: &computepb.Instance{Labels: map[string]string{"env": "x"}}, status: stRunning, + verify: func(t *testing.T, i *computepb.Instance) { + t.Helper() + assert.Equal(t, map[string]string{"env": "x"}, i.GetLabels()) + }, + }, + { + name: "restart change while stopped does not start", + res: &computepb.Instance{CanIpForward: new(true)}, + stopped: true, + status: stTerm, + verify: func(t *testing.T, i *computepb.Instance) { t.Helper(); assert.True(t, i.GetCanIpForward()) }, + }, + { + name: "bad platform", res: &computepb.Instance{MinCpuPlatform: s("Nope")}, status: stRunning, + errCode: http.StatusBadRequest, errToken: "invalid", + }, + { + name: "scheduling restart", status: "PROVISIONING", + res: &computepb.Instance{ + Scheduling: &computepb.Scheduling{ProvisioningModel: s("SPOT"), InstanceTerminationAction: s("STOP")}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.runningVM(t) + + if tt.stopped { + e.stopVM(t) + } + + before := e.getVM(t, "vm1") + err := e.update(t, "vm1", tt.res, tt.minimal, tt.most) + + if tt.errCode != 0 { + assertAPIError(t, err, tt.errCode, tt.errToken) + assert.Equal(t, before.GetFingerprint(), e.getVM(t, "vm1").GetFingerprint()) + } else { + require.NoError(t, err) + assert.NotEqual(t, before.GetFingerprint(), e.getVM(t, "vm1").GetFingerprint()) + } + + got := e.getVM(t, "vm1") + assert.Equal(t, tt.status, got.GetStatus()) + + if tt.verify != nil && tt.errCode == 0 { + tt.verify(t, got) + } + }) + } +} + +func TestInstanceUpdateRestartCompletes(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.runningVM(t) + require.NoError(t, e.update(t, "vm1", &computepb.Instance{CanIpForward: new(true)}, "", "")) + assert.Equal(t, "PROVISIONING", e.getVM(t, "vm1").GetStatus()) + + e.settle() + got := e.getVM(t, "vm1") + assert.Equal(t, stRunning, got.GetStatus()) + assert.NotEmpty(t, got.GetLastStopTimestamp()) +} + +func TestInstanceIntegrityPolicy(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.runningVM(t) + + set := func() error { + op, err := e.instances(t). + SetShieldedInstanceIntegrityPolicy(t.Context(), &computepb.SetShieldedInstanceIntegrityPolicyInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + ShieldedInstanceIntegrityPolicyResource: &computepb.ShieldedInstanceIntegrityPolicy{ + UpdateAutoLearnPolicy: new(true), + }, + }) + + return e.finish(t, op, err) + } + + require.NoError(t, set()) + assert.True(t, e.getVM(t, "vm1").GetShieldedInstanceIntegrityPolicy().GetUpdateAutoLearnPolicy()) + + e.stopVM(t) + assertAPIError(t, set(), http.StatusBadRequest, "resourceNotReady") +} + +func TestInstanceSerialPortAndGuestAttributes(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.runningVM(t) + + tests := []struct { + port *int32 + name string + errCode int + }{ + {name: "default port"}, + {name: "port 4", port: proto.Int32(4)}, + {name: "port 0", port: proto.Int32(0), errCode: http.StatusBadRequest}, + {name: "port 5", port: proto.Int32(5), errCode: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + out, err := e.instances(t).GetSerialPortOutput(t.Context(), &computepb.GetSerialPortOutputInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", Port: tt.port, + }) + + if tt.errCode != 0 { + assertAPIError(t, err, tt.errCode, "invalid") + + return + } + + require.NoError(t, err) + assert.Equal(t, "compute#serialPortOutput", out.GetKind()) + assert.Empty(t, out.GetContents()) + assert.Zero(t, out.GetStart()) + assert.Zero(t, out.GetNext()) + assert.Contains(t, out.GetSelfLink(), "/instances/vm1/serialPort") + }) + } + + t.Run("missing instance", func(t *testing.T) { + t.Parallel() + + _, err := e.instances(t).GetSerialPortOutput(t.Context(), &computepb.GetSerialPortOutputInstanceRequest{ + Project: project, Zone: zone, Instance: "ghost", + }) + assertAPIError(t, err, http.StatusNotFound, "notFound") + }) + + t.Run("guest attributes", func(t *testing.T) { + t.Parallel() + + _, err := e.instances(t).GetGuestAttributes(t.Context(), &computepb.GetGuestAttributesInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", QueryPath: s("hello/"), + }) + assertAPIError(t, err, http.StatusNotFound, "notFound") + + _, err = e.instances(t).GetGuestAttributes(t.Context(), &computepb.GetGuestAttributesInstanceRequest{ + Project: project, Zone: zone, Instance: "ghost", + }) + assertAPIError(t, err, http.StatusNotFound, "notFound") + }) +} diff --git a/services/gcpcompute/lb_backend.go b/services/gcpcompute/lb_backend.go index bde080bbc8..188949f265 100644 --- a/services/gcpcompute/lb_backend.go +++ b/services/gcpcompute/lb_backend.go @@ -76,6 +76,10 @@ func bsSpec() lbSpec[computeapi.BackendService] { out = append(out, be.Group) } + if v.SecurityPolicy != "" { + out = append(out, v.SecurityPolicy) + } + return out }, } @@ -159,6 +163,10 @@ func prepBackendService(b *InMemoryBackend, c lbCtx, in *computeapi.BackendServi return err } + if err = b.resolveBSSecurityPolicyLocked(c, in); err != nil { + return err + } + return b.prepBackendsLocked(c, in, raw) } diff --git a/services/gcpcompute/network.go b/services/gcpcompute/network.go index d6b2d1c319..90249b5190 100644 --- a/services/gcpcompute/network.go +++ b/services/gcpcompute/network.go @@ -38,6 +38,11 @@ func (b *InMemoryBackend) networkViewLocked(project string, n *computeapi.Networ sort.Strings(out.Subnetworks) + out.Peerings = b.peeringsViewLocked(n) + if len(out.Peerings) == 0 { + out.Peerings = nil + } + return out } @@ -82,7 +87,7 @@ func (b *InMemoryBackend) systemRouteLocked(project string, n *computeapi.Networ name := sysRoutePrefix + hex.EncodeToString(buf[:]) b.routes[key(project, name)] = &computeapi.Route{ - Kind: "compute#route", Id: newID(), CreationTimestamp: b.stamp(), Name: name, Network: n.SelfLink, + Kind: kindRoute, Id: newID(), CreationTimestamp: b.stamp(), Name: name, Network: n.SelfLink, Description: "Default route to the Internet.", DestRange: "0.0.0.0/0", Priority: defaultRoutePrio, NextHopGateway: globalLink(project, "gateways", defaultGateway), SelfLink: globalLink(project, "routes", name), } @@ -152,6 +157,7 @@ func (b *InMemoryBackend) insertNetwork( } b.systemRouteLocked(project, n) + b.syncSubnetRoutesLocked(project, n) return b.newOpLocked("insert", netTarget(project, n), b.timing.Done), nil } @@ -184,6 +190,10 @@ func (b *InMemoryBackend) listNetworks(project string) []entry { } func (b *InMemoryBackend) networkUserLocked(project string, n *computeapi.Network) string { + if len(n.Peerings) > 0 { + return trimURL(n.Peerings[0].Network) + } + if by := b.networkRefsLocked(project, n); by != "" { return by } @@ -228,7 +238,7 @@ func (b *InMemoryBackend) networkRefsLocked(project string, n *computeapi.Networ } func isSystemRoute(r *computeapi.Route) bool { - return strings.HasPrefix(r.Name, sysRoutePrefix) && r.NextHopGateway != "" + return isSubnetRoute(r) || strings.HasPrefix(r.Name, sysRoutePrefix) && r.NextHopGateway != "" } func (b *InMemoryBackend) deleteNetwork(project, name string) (*computeapi.Operation, error) { @@ -262,9 +272,10 @@ func (b *InMemoryBackend) deleteNetwork(project, name string) (*computeapi.Opera } var networkPatchable = map[string]bool{ //nolint:gochecknoglobals // read-only lookup table - "name": true, "routingConfig": true, "mtu": true, "description": true, - "networkFirewallPolicyEnforcementOrder": true, "kind": true, "id": true, "selfLink": true, - "creationTimestamp": true, "autoCreateSubnetworks": true, "subnetworks": true, "selfLinkWithId": true, + jsonName: true, "routingConfig": true, "mtu": true, jsonDesc: true, + "networkFirewallPolicyEnforcementOrder": true, jsonKind: true, "id": true, jsonSelfLink: true, + jsonCreated: true, "autoCreateSubnetworks": true, "subnetworks": true, "selfLinkWithId": true, + "peerings": true, } func (b *InMemoryBackend) patchNetwork( diff --git a/services/gcpcompute/networks_test.go b/services/gcpcompute/networks_test.go index 148cb8ddbd..91537fc3d0 100644 --- a/services/gcpcompute/networks_test.go +++ b/services/gcpcompute/networks_test.go @@ -1,6 +1,7 @@ package gcpcompute_test import ( + "errors" "net/http" "testing" "time" @@ -55,10 +56,16 @@ func TestAutoNetworkLifecycle(t *testing.T) { assert.Equal(t, "10.128.0.1", sub.GetGatewayAddress()) routes := e.routes(t).List(t.Context(), &computepb.ListRoutesRequest{Project: project}) - r, err := routes.Next() - require.NoError(t, err) - assert.Equal(t, "0.0.0.0/0", r.GetDestRange()) - assert.Contains(t, r.GetName(), "default-route-") + dests := map[string]string{} + + for r, rerr := routes.Next(); !errors.Is(rerr, iterator.Done); r, rerr = routes.Next() { + require.NoError(t, rerr) + assert.Contains(t, r.GetName(), "default-route-") + dests[r.GetDestRange()] = r.GetRouteType() + } + + assert.Contains(t, dests, "0.0.0.0/0") + assert.Equal(t, "SUBNET", dests["10.128.0.0/20"]) op, err = nets.Delete(t.Context(), &computepb.DeleteNetworkRequest{Project: project, Network: "auto-net"}) e.done(t, op, err) @@ -70,7 +77,7 @@ func TestAutoNetworkLifecycle(t *testing.T) { Get(t.Context(), &computepb.GetSubnetworkRequest{Project: project, Region: region, Subnetwork: "auto-net"}) assertAPIError(t, err, http.StatusNotFound, "notFound") - _, err = routes.Next() + _, err = e.routes(t).List(t.Context(), &computepb.ListRoutesRequest{Project: project}).Next() assert.ErrorIs(t, err, iterator.Done) } diff --git a/services/gcpcompute/ops_instance.go b/services/gcpcompute/ops_instance.go index 16015a67d9..7227cccbe5 100644 --- a/services/gcpcompute/ops_instance.go +++ b/services/gcpcompute/ops_instance.go @@ -1,6 +1,9 @@ package gcpcompute import ( + "net/url" + "strconv" + computeapi "google.golang.org/api/compute/v1" ) @@ -55,6 +58,7 @@ func (h *Handler) registerInstanceOps() { } h.registerInstanceMutations() + h.registerInstanceStateOps() } func (h *Handler) registerInstanceMutations() { @@ -134,3 +138,94 @@ func (h *Handler) registerInstanceMutations() { ) }) } + +func (h *Handler) registerInstanceStateOps() { + z := scopeZone + h.reg("instances", z, verbUpdate, func(h *Handler, rq *request) (any, error) { + in, raw, err := decode[computeapi.Instance](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.updateInstance(rq.rt.project, rq.rt.loc, rq.rt.name, in, raw, updateOpts{ + minimal: rq.q.Get("minimalAction"), mostDisruptive: rq.q.Get("mostDisruptiveAllowedAction"), + })) + }) + h.reg("instances", z, "setScheduling", func(h *Handler, rq *request) (any, error) { + in, _, err := decode[computeapi.Scheduling](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.setInstanceScheduling(rq.rt.project, rq.rt.loc, rq.rt.name, in)) + }) + h.reg("instances", z, "setServiceAccount", func(h *Handler, rq *request) (any, error) { + in, _, err := decode[serviceAccountRequest](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.setInstanceServiceAccount(rq.rt.project, rq.rt.loc, rq.rt.name, in)) + }) + h.reg("instances", z, "setMinCpuPlatform", func(h *Handler, rq *request) (any, error) { + in, _, err := decode[minCPURequest](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.setInstanceMinCPUPlatform(rq.rt.project, rq.rt.loc, rq.rt.name, in.MinCPUPlatform)) + }) + h.reg("instances", z, "updateShieldedInstanceConfig", func(h *Handler, rq *request) (any, error) { + in, raw, err := decode[computeapi.ShieldedInstanceConfig](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.updateInstanceShielded(rq.rt.project, rq.rt.loc, rq.rt.name, in, raw)) + }) + h.reg("instances", z, "setShieldedInstanceIntegrityPolicy", func(h *Handler, rq *request) (any, error) { + in, _, err := decode[computeapi.ShieldedInstanceIntegrityPolicy](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.setInstanceIntegrityPolicy(rq.rt.project, rq.rt.loc, rq.rt.name, in)) + }) + h.reg("instances", z, "suspend", func(h *Handler, rq *request) (any, error) { + return opResult(h.Backend.suspendInstance(rq.rt.project, rq.rt.loc, rq.rt.name)) + }) + h.reg("instances", z, "resume", func(h *Handler, rq *request) (any, error) { + return opResult(h.Backend.resumeInstance(rq.rt.project, rq.rt.loc, rq.rt.name)) + }) + h.reg("instances", z, "serialPort", func(h *Handler, rq *request) (any, error) { + port, start, err := serialArgs(rq.q) + if err != nil { + return nil, err + } + + return h.Backend.serialPortOutput(rq.rt.project, rq.rt.loc, rq.rt.name, port, start) + }) + h.reg("instances", z, "getGuestAttributes", func(h *Handler, rq *request) (any, error) { + return h.Backend.guestAttributes(rq.rt.project, rq.rt.loc, rq.rt.name) + }) +} + +func serialArgs(q url.Values) (int64, int64, *APIError) { + port, start := int64(1), int64(0) + + for name, dst := range map[string]*int64{"port": &port, "start": &start} { + v := q.Get(name) + if v == "" { + continue + } + + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, 0, errField(name, v, "") + } + + *dst = n + } + + return port, start, nil +} diff --git a/services/gcpcompute/ops_network.go b/services/gcpcompute/ops_network.go index 9a10d2ea9a..93cf0f0e68 100644 --- a/services/gcpcompute/ops_network.go +++ b/services/gcpcompute/ops_network.go @@ -17,6 +17,7 @@ func (h *Handler) registerNetworkOps() { h.registerSubnetOps() h.registerFirewallOps() h.registerRouteOps() + h.registerPeeringOps() } func (h *Handler) registerVPCOps() { diff --git a/services/gcpcompute/ops_secpolicy.go b/services/gcpcompute/ops_secpolicy.go new file mode 100644 index 0000000000..215254d45f --- /dev/null +++ b/services/gcpcompute/ops_secpolicy.go @@ -0,0 +1,269 @@ +package gcpcompute + +import ( + "slices" + "strconv" + "strings" + + computeapi "google.golang.org/api/compute/v1" +) + +var secPolicyPatchDrop = []string{ //nolint:gochecknoglobals // read-only lookup table + "rules", "type", jsonLabels, "labelFingerprint", "associations", "id", jsonKind, jsonSelfLink, jsonCreated, + "region", "parent", "shortName", +} + +func rulePriority(q map[string][]string) (int64, *APIError) { + vals := q["priority"] + if len(vals) == 0 { + return 0, nil + } + + p, err := strconv.ParseInt(vals[0], 10, 64) + if err != nil { + return 0, errField("priority", vals[0], "") + } + + return p, nil +} + +func ruleIndex(p *computeapi.SecurityPolicy, prio int64) (int, *APIError) { + i := slices.IndexFunc(p.Rules, func(r *computeapi.SecurityPolicyRule) bool { return r.Priority == prio }) + if i < 0 { + return 0, errNotFound(trimURL(p.SelfLink) + "/rules/" + strconv.FormatInt(prio, 10)) + } + + return i, nil +} + +// maskedBody returns the body keys a patch applies; names in the updateMask but absent from the body are cleared. +func maskedBody(raw map[string]any, mask string, drop []string) map[string]any { + out := map[string]any{} + + for k, v := range raw { + if !slices.Contains(drop, k) { + out[k] = v + } + } + + for path := range strings.SplitSeq(mask, ",") { + top, _, _ := strings.Cut(strings.TrimSpace(path), ".") + if _, set := raw[top]; top != "" && !set && !slices.Contains(drop, top) { + out[top] = nil + } + } + + return out +} + +func (b *InMemoryBackend) mutateSecPolicy( + c lbCtx, name, verb string, edit func(cur *computeapi.SecurityPolicy) (*computeapi.SecurityPolicy, *APIError), +) (*computeapi.Operation, error) { + return lbMutate(b, secPolicySpec(), c, name, verb, nil, edit) +} + +func (b *InMemoryBackend) validateSecPolicyEdit( + c lbCtx, name string, edit func(cur *computeapi.SecurityPolicy) (*computeapi.SecurityPolicy, *APIError), +) (*computeapi.Operation, error) { + b.mu.Lock("ValidateSecurityPolicy") + defer b.mu.Unlock() + + cur, ok := b.secPolicies[key(c.project, scopeGlobal, name)] + if !ok { + return nil, errNotFound(relPath(c.project, scopeGlobal, "", collSecPolicy, name)) + } + + next, err := edit(clone(cur)) + if err == nil { + err = prepSecurityPolicy(b, c, next, nil) + } + + if err != nil { + return nil, err + } + + return b.newOpLocked( + opPrefix+collSecPolicy+".addRule", + lbTargetOf(c, collSecPolicy, name, cur.Id), + b.timing.Done, + ), nil +} + +func (b *InMemoryBackend) addSecPolicyRule( + c lbCtx, name string, rule *computeapi.SecurityPolicyRule, validateOnly bool, +) (*computeapi.Operation, error) { + edit := func(cur *computeapi.SecurityPolicy) (*computeapi.SecurityPolicy, *APIError) { + cur.Rules = append(cur.Rules, clone(rule)) + + return cur, nil + } + + if validateOnly { + return b.validateSecPolicyEdit(c, name, edit) + } + + return b.mutateSecPolicy(c, name, "addRule", edit) +} + +func (b *InMemoryBackend) getSecPolicyRule(c lbCtx, name string, prio int64) (*computeapi.SecurityPolicyRule, error) { + p, err := lbGet(b, secPolicySpec(), c, name) + if err != nil { + return nil, err + } + + i, ierr := ruleIndex(p, prio) + if ierr != nil { + return nil, ierr + } + + return p.Rules[i], nil +} + +func (b *InMemoryBackend) patchSecPolicyRule( + c lbCtx, name string, prio int64, raw map[string]any, mask string, +) (*computeapi.Operation, error) { + return b.mutateSecPolicy( + c, + name, + "patchRule", + func(cur *computeapi.SecurityPolicy) (*computeapi.SecurityPolicy, *APIError) { + i, err := ruleIndex(cur, prio) + if err != nil { + return nil, err + } + + next, merr := mergeInto(cur.Rules[i], maskedBody(raw, mask, []string{"priority", jsonKind})) + if merr != nil { + return nil, merr + } + + next.Priority = prio + cur.Rules[i] = next + + return cur, nil + }, + ) +} + +func (b *InMemoryBackend) removeSecPolicyRule(c lbCtx, name string, prio int64) (*computeapi.Operation, error) { + return b.mutateSecPolicy( + c, + name, + "removeRule", + func(cur *computeapi.SecurityPolicy) (*computeapi.SecurityPolicy, *APIError) { + i, err := ruleIndex(cur, prio) + if err != nil { + return nil, err + } + + if prio == defaultRulePrio { + return nil, errField("priority", strconv.FormatInt(prio, 10), "The default rule cannot be removed.") + } + + cur.Rules = slices.Delete(cur.Rules, i, i+1) + + return cur, nil + }, + ) +} + +func (b *InMemoryBackend) patchSecPolicy( + c lbCtx, name string, raw map[string]any, mask string, +) (*computeapi.Operation, error) { + return b.mutateSecPolicy( + c, + name, + "patch", + func(cur *computeapi.SecurityPolicy) (*computeapi.SecurityPolicy, *APIError) { + if fp, ok := raw[jsonFingerp].(string); ok && fp != "" && fp != cur.Fingerprint { + return nil, errPrecondition("Supplied fingerprint does not match current fingerprint.") + } + + return mergeInto(cur, maskedBody(raw, mask, append([]string{jsonFingerp, jsonName}, secPolicyPatchDrop...))) + }, + ) +} + +func (b *InMemoryBackend) setSecPolicyLabels(c lbCtx, name string, req labelsRequest) (*computeapi.Operation, error) { + return b.mutateSecPolicy( + c, + name, + "setLabels", + func(cur *computeapi.SecurityPolicy) (*computeapi.SecurityPolicy, *APIError) { + labels, fp, err := applyLabels(cur.Labels, cur.LabelFingerprint, req) + if err != nil { + return nil, err + } + + cur.Labels, cur.LabelFingerprint = labels, fp + + return cur, nil + }, + ) +} + +func (h *Handler) registerSecurityOps() { + g := scopeGlobal + sp := secPolicySpec() + + registerLB(h, sp, lbOpts[computeapi.SecurityPolicy]{scopes: []string{g}}) + + h.reg(collSecPolicy, g, verbPatch, func(h *Handler, rq *request) (any, error) { + _, raw, err := decode[computeapi.SecurityPolicy](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.patchSecPolicy(ctxOf(rq), rq.rt.name, raw, rq.q.Get("updateMask"))) + }) + h.reg(collSecPolicy, g, "addRule", func(h *Handler, rq *request) (any, error) { + in, _, err := decode[computeapi.SecurityPolicyRule](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.addSecPolicyRule(ctxOf(rq), rq.rt.name, in, rq.q.Get("validateOnly") == "true")) + }) + h.reg(collSecPolicy, g, "getRule", func(h *Handler, rq *request) (any, error) { + prio, err := rulePriority(rq.q) + if err != nil { + return nil, err + } + + return h.Backend.getSecPolicyRule(ctxOf(rq), rq.rt.name, prio) + }) + h.reg(collSecPolicy, g, "patchRule", func(h *Handler, rq *request) (any, error) { + _, raw, err := decode[computeapi.SecurityPolicyRule](rq) + if err != nil { + return nil, err + } + + prio, perr := rulePriority(rq.q) + if perr != nil { + return nil, perr + } + + return opResult(h.Backend.patchSecPolicyRule(ctxOf(rq), rq.rt.name, prio, raw, rq.q.Get("updateMask"))) + }) + h.reg(collSecPolicy, g, "removeRule", func(h *Handler, rq *request) (any, error) { + prio, err := rulePriority(rq.q) + if err != nil { + return nil, err + } + + return opResult(h.Backend.removeSecPolicyRule(ctxOf(rq), rq.rt.name, prio)) + }) + h.reg(collSecPolicy, g, "setLabels", func(h *Handler, rq *request) (any, error) { + in, _, err := decode[labelsRequest](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.setSecPolicyLabels(ctxOf(rq), rq.rt.name, *in)) + }) + + regSetter(h, bsSpec(), []string{g}, "setSecurityPolicy", + func(cur *computeapi.BackendService, r *computeapi.SecurityPolicyReference) { + cur.SecurityPolicy = r.SecurityPolicy + }) +} diff --git a/services/gcpcompute/peering.go b/services/gcpcompute/peering.go new file mode 100644 index 0000000000..62e471e53f --- /dev/null +++ b/services/gcpcompute/peering.go @@ -0,0 +1,496 @@ +package gcpcompute + +import ( + "net/netip" + "slices" + "sort" + "strings" + + computeapi "google.golang.org/api/compute/v1" +) + +const ( + peerActive = "ACTIVE" + peerInactive = "INACTIVE" + maxPeerings = 25 + stackIPv4Only = "IPV4_ONLY" + stackIPv4IPv6 = "IPV4_IPV6" + stratIndep = "INDEPENDENT" + dirIncoming = "INCOMING" + dirOutgoing = "OUTGOING" + peerRouteSub = "SUBNET_PEERING_ROUTE" + peerRouteStat = "STATIC_PEERING_ROUTE" + peerDetailOn = "Connected." + peerDetailWait = "Waiting for the peer network to create the matching peering." +) + +type addPeeringRequest struct { + NetworkPeering *computeapi.NetworkPeering `json:"networkPeering"` + Name string `json:"name"` + PeerNetwork string `json:"peerNetwork"` + AutoCreateRoutes bool `json:"autoCreateRoutes"` +} + +type updatePeeringRequest struct { + NetworkPeering *computeapi.NetworkPeering `json:"networkPeering"` +} + +type removePeeringRequest struct { + Name string `json:"name"` +} + +func (b *InMemoryBackend) networkBySelfLinkLocked(self string) *computeapi.Network { + for _, n := range b.networks { + if n.SelfLink == self { + return n + } + } + + return nil +} + +func peeringTo(n *computeapi.Network, peerSelf string) *computeapi.NetworkPeering { + for _, p := range n.Peerings { + if p.Network == peerSelf { + return p + } + } + + return nil +} + +// peerSideLocked returns the peer network and its matching peering, or nils when absent. +func (b *InMemoryBackend) peerSideLocked(n *computeapi.Network, p *computeapi.NetworkPeering) ( + *computeapi.Network, *computeapi.NetworkPeering, +) { + peer := b.networkBySelfLinkLocked(p.Network) + if peer == nil { + return nil, nil + } + + return peer, peeringTo(peer, n.SelfLink) +} + +func (b *InMemoryBackend) activePeersLocked(n *computeapi.Network) []*computeapi.Network { + var out []*computeapi.Network + + for _, p := range n.Peerings { + if peer, back := b.peerSideLocked(n, p); back != nil { + out = append(out, peer) + } + } + + return out +} + +func (b *InMemoryBackend) peeringsViewLocked(n *computeapi.Network) []*computeapi.NetworkPeering { + out := make([]*computeapi.NetworkPeering, 0, len(n.Peerings)) + + for _, p := range n.Peerings { + v := clone(p) + v.ForceSendFields = []string{"ExchangeSubnetRoutes", "ExportCustomRoutes", "ImportCustomRoutes"} + + peer, back := b.peerSideLocked(n, p) + if peer != nil { + v.PeerMtu = peer.Mtu + } + + if back != nil { + v.State, v.StateDetails = peerActive, peerDetailOn + } else { + v.State, v.StateDetails = peerInactive, peerDetailWait + } + + out = append(out, v) + } + + return out +} + +func (b *InMemoryBackend) networkRangesWithOwnerLocked(n *computeapi.Network) map[netip.Prefix]string { + out := map[netip.Prefix]string{} + + for _, s := range b.subnets { + if s.Network != n.SelfLink { + continue + } + + if p, err := netip.ParsePrefix(s.IpCidrRange); err == nil { + out[p] = trimURL(s.SelfLink) + } + + for _, sec := range s.SecondaryIpRanges { + if p, err := netip.ParsePrefix(sec.IpCidrRange); err == nil { + out[p] = trimURL(s.SelfLink) + } + } + } + + return out +} + +func (b *InMemoryBackend) peerOverlapLocked(local, peer *computeapi.Network) *APIError { + mine, theirs := b.networkRangesWithOwnerLocked(local), b.networkRangesWithOwnerLocked(peer) + keys := make([]netip.Prefix, 0, len(mine)) + + for p := range mine { + keys = append(keys, p) + } + + slices.SortFunc(keys, func(a, c netip.Prefix) int { return strings.Compare(a.String(), c.String()) }) + + for _, lp := range keys { + for tp, owner := range theirs { + if lp.Overlaps(tp) { + return errInvalid( + "An IP range in the peer network (%s) overlaps with an IP range in the local network (%s) "+ + "allocated by resource (%s).", tp, lp, owner) + } + } + } + + return nil +} + +func (b *InMemoryBackend) buildPeeringLocked( + project string, + n *computeapi.Network, + req *addPeeringRequest, + raw map[string]any, +) (*computeapi.NetworkPeering, *computeapi.Network, *APIError) { + p := req.NetworkPeering + nested, _ := raw["networkPeering"].(map[string]any) + + if p == nil { + p = &computeapi.NetworkPeering{ + Name: req.Name, Network: req.PeerNetwork, ExchangeSubnetRoutes: true, + } + if _, ok := raw["autoCreateRoutes"]; ok { + p.ExchangeSubnetRoutes = req.AutoCreateRoutes + } + + nested = map[string]any{} + } else { + p = clone(p) + if _, ok := nested["exchangeSubnetRoutes"]; !ok { + p.ExchangeSubnetRoutes = true + } + } + + if err := validName("networkPeering.name", p.Name); err != nil { + return nil, nil, err + } + + if p.Network == "" { + return nil, nil, errRequired("networkPeering.network") + } + + if _, ok := nested["exportSubnetRoutesWithPublicIp"]; !ok { + p.ExportSubnetRoutesWithPublicIp = true + } + + r, err := refGlobal(p.Network, "networks", project) + if err != nil { + return nil, nil, errField("networkPeering.network", p.Network, "The URL is malformed.") + } + + peer, perr := b.getNetworkLocked(r.project, r.name) + if perr != nil { + return nil, nil, perr + } + + if peer.SelfLink == n.SelfLink { + return nil, nil, errField("networkPeering.network", p.Network, "A network cannot be peered with itself.") + } + + if verr := validPeeringEnums(p); verr != nil { + return nil, nil, verr + } + + p.Network = peer.SelfLink + p.AutoCreateRoutes = p.ExchangeSubnetRoutes + p.State, p.StateDetails, p.PeerMtu, p.ConnectionStatus = "", "", 0, nil + + return p, peer, nil +} + +func validPeeringEnums(p *computeapi.NetworkPeering) *APIError { + switch p.StackType { + case "", stackIPv4Only, stackIPv4IPv6: + default: + return errField("networkPeering.stackType", p.StackType, "Must be IPV4_ONLY or IPV4_IPV6.") + } + + switch p.UpdateStrategy { + case "", stratUnspec, stratIndep: + case "CONSENSUS": + return errField("networkPeering.updateStrategy", p.UpdateStrategy, + "CONSENSUS peerings are not supported by this emulator.") + default: + return errField("networkPeering.updateStrategy", p.UpdateStrategy, "") + } + + if p.StackType == "" { + p.StackType = stackIPv4Only + } + + p.UpdateStrategy = stratIndep + + return nil +} + +func (b *InMemoryBackend) addPeering( + project, name string, + req *addPeeringRequest, + raw map[string]any, +) (*computeapi.Operation, error) { + b.mu.Lock("AddPeering") + defer b.mu.Unlock() + + n, err := b.getNetworkLocked(project, name) + if err != nil { + return nil, err + } + + p, peer, err := b.buildPeeringLocked(project, n, req, raw) + if err != nil { + return nil, err + } + + if len(n.Peerings) >= maxPeerings { + return nil, errInvalid("Quota 'PEERINGS_PER_NETWORK' exceeded. Limit: %d.", maxPeerings) + } + + for _, have := range n.Peerings { + if have.Name == p.Name { + return nil, errInvalid("Invalid value for field 'networkPeering.name': '%s'. A peering with this name "+ + "already exists in the network.", p.Name) + } + + if have.Network == p.Network { + return nil, errInvalid("A peering from network '%s' to network '%s' already exists.", n.Name, peer.Name) + } + } + + if err = b.peerOverlapLocked(n, peer); err != nil { + return nil, err + } + + n.Peerings = append(n.Peerings, p) + + return b.newOpLocked("addPeering", netTarget(project, n), b.timing.Done), nil +} + +func (b *InMemoryBackend) removePeering(project, name, peering string) (*computeapi.Operation, error) { + b.mu.Lock("RemovePeering") + defer b.mu.Unlock() + + n, err := b.getNetworkLocked(project, name) + if err != nil { + return nil, err + } + + i := slices.IndexFunc(n.Peerings, func(p *computeapi.NetworkPeering) bool { return p.Name == peering }) + if i < 0 { + return nil, errField("name", peering, "Peering not found in the network.") + } + + n.Peerings = slices.Delete(n.Peerings, i, i+1) + + return b.newOpLocked("removePeering", netTarget(project, n), b.timing.Done), nil +} + +var peeringImmutable = []string{ //nolint:gochecknoglobals // read-only lookup table + "exchangeSubnetRoutes", "autoCreateRoutes", "exportSubnetRoutesWithPublicIp", + "importSubnetRoutesWithPublicIp", jsonStackType, jsonUpdateStrt, +} + +func (b *InMemoryBackend) updatePeering( + project, name string, + req *updatePeeringRequest, + raw map[string]any, +) (*computeapi.Operation, error) { + b.mu.Lock("UpdatePeering") + defer b.mu.Unlock() + + n, err := b.getNetworkLocked(project, name) + if err != nil { + return nil, err + } + + if req.NetworkPeering == nil { + return nil, errRequired("networkPeering") + } + + in := req.NetworkPeering + nested, _ := raw["networkPeering"].(map[string]any) + + i := slices.IndexFunc(n.Peerings, func(p *computeapi.NetworkPeering) bool { return p.Name == in.Name }) + if i < 0 { + return nil, errField("networkPeering.name", in.Name, "Peering not found in the network.") + } + + cur := n.Peerings[i] + want := map[string]any{ + "exchangeSubnetRoutes": cur.ExchangeSubnetRoutes, "autoCreateRoutes": cur.AutoCreateRoutes, + "exportSubnetRoutesWithPublicIp": cur.ExportSubnetRoutesWithPublicIp, + "importSubnetRoutesWithPublicIp": cur.ImportSubnetRoutesWithPublicIp, + jsonStackType: cur.StackType, jsonUpdateStrt: cur.UpdateStrategy, + } + + for _, k := range peeringImmutable { + if v, ok := nested[k]; ok && v != want[k] && (k != jsonUpdateStrt || v != stratUnspec) { + return nil, errField( + "networkPeering."+k, + "", + "Only exportCustomRoutes and importCustomRoutes can be updated.", + ) + } + } + + if _, ok := nested["exportCustomRoutes"]; ok { + cur.ExportCustomRoutes = in.ExportCustomRoutes + } + + if _, ok := nested["importCustomRoutes"]; ok { + cur.ImportCustomRoutes = in.ImportCustomRoutes + } + + return b.newOpLocked("updatePeering", netTarget(project, n), b.timing.Done), nil +} + +// customRoutesLocked returns the static routes a peering can carry, without system and gateway routes. +func (b *InMemoryBackend) customRoutesLocked(n *computeapi.Network) []*computeapi.Route { + var out []*computeapi.Route + + for _, r := range b.routes { + if r.Network == n.SelfLink && !isSystemRoute(r) && r.NextHopGateway == "" { + out = append(out, r) + } + } + + return out +} + +func (b *InMemoryBackend) exportedRoutesLocked( + from *computeapi.Network, + exp *computeapi.NetworkPeering, + subnetImported, customImported bool, +) []*computeapi.ExchangedPeeringRoute { + var out []*computeapi.ExchangedPeeringRoute + + if exp.ExchangeSubnetRoutes { + ranges := make([]string, 0, len(b.subnets)) + + for p := range b.networkRangesWithOwnerLocked(from) { + ranges = append(ranges, p.String()) + } + + sort.Strings(ranges) + + for _, cidr := range ranges { + out = append( + out, + &computeapi.ExchangedPeeringRoute{DestRange: cidr, Type: peerRouteSub, Imported: subnetImported}, + ) + } + } + + if exp.ExportCustomRoutes { + customs := b.customRoutesLocked(from) + sort.Slice(customs, func(i, j int) bool { return customs[i].Name < customs[j].Name }) + + for _, r := range customs { + out = append(out, &computeapi.ExchangedPeeringRoute{ + DestRange: r.DestRange, Type: peerRouteStat, Priority: r.Priority, Imported: customImported, + }) + } + } + + return out +} + +func (b *InMemoryBackend) listPeeringRoutes(project, name, peering, direction string) ([]entry, error) { + b.mu.RLock("ListPeeringRoutes") + defer b.mu.RUnlock() + + n, err := b.getNetworkLocked(project, name) + if err != nil { + return nil, err + } + + switch direction { + case "", dirIncoming, dirOutgoing: + default: + return nil, errField("direction", direction, "Must be INCOMING or OUTGOING.") + } + + if peering != "" && !slices.ContainsFunc(n.Peerings, func(p *computeapi.NetworkPeering) bool { + return p.Name == peering + }) { + return nil, errField("peeringName", peering, "Peering not found in the network.") + } + + var out []entry + + for _, p := range n.Peerings { + if peering != "" && p.Name != peering { + continue + } + + peer, back := b.peerSideLocked(n, p) + if back == nil { + continue + } + + var routes []*computeapi.ExchangedPeeringRoute + if direction == dirOutgoing { + routes = b.exportedRoutesLocked(n, p, back.ExchangeSubnetRoutes, back.ImportCustomRoutes) + } else { + routes = b.exportedRoutesLocked(peer, back, p.ExchangeSubnetRoutes, p.ImportCustomRoutes) + } + + for _, r := range routes { + out = append(out, entry{scope: scopeGlobal, item: r}) + } + } + + return out, nil +} + +func (h *Handler) registerPeeringOps() { + g := scopeGlobal + h.reg("networks", g, "addPeering", func(h *Handler, rq *request) (any, error) { + in, raw, err := decode[addPeeringRequest](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.addPeering(rq.rt.project, rq.rt.name, in, raw)) + }) + h.reg("networks", g, "removePeering", func(h *Handler, rq *request) (any, error) { + in, _, err := decode[removePeeringRequest](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.removePeering(rq.rt.project, rq.rt.name, in.Name)) + }) + h.reg("networks", g, "updatePeering", func(h *Handler, rq *request) (any, error) { + in, raw, err := decode[updatePeeringRequest](rq) + if err != nil { + return nil, err + } + + return opResult(h.Backend.updatePeering(rq.rt.project, rq.rt.name, in, raw)) + }) + h.reg("networks", g, "listPeeringRoutes", func(h *Handler, rq *request) (any, error) { + entries, err := h.Backend.listPeeringRoutes( + rq.rt.project, rq.rt.name, rq.q.Get("peeringName"), rq.q.Get("direction")) + if err != nil { + return nil, err + } + + return listBody(rq, "compute#exchangedPeeringRoutesList", entries) + }) +} diff --git a/services/gcpcompute/peering_test.go b/services/gcpcompute/peering_test.go new file mode 100644 index 0000000000..f27ef5249a --- /dev/null +++ b/services/gcpcompute/peering_test.go @@ -0,0 +1,308 @@ +package gcpcompute_test + +import ( + "errors" + "net/http" + "testing" + + "cloud.google.com/go/compute/apiv1/computepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/iterator" +) + +func netRef(name string) string { return "projects/" + project + "/global/networks/" + name } + +// peerNet creates a custom-mode network with one subnetwork in the given range. +func (e *env) peerNet(t *testing.T, name, cidr string) { + t.Helper() + e.network(t, name, false) + + op, err := e.subnets(t).Insert(t.Context(), &computepb.InsertSubnetworkRequest{ + Project: project, Region: region, SubnetworkResource: &computepb.Subnetwork{ + Name: s(name + "-sub"), Network: s(netRef(name)), IpCidrRange: s(cidr), + }, + }) + e.done(t, op, err) +} + +func (e *env) addPeering(t *testing.T, net, name, peer string, mod func(*computepb.NetworkPeering)) error { + t.Helper() + + p := &computepb.NetworkPeering{Name: s(name), Network: s(netRef(peer)), ExchangeSubnetRoutes: new(true)} + if mod != nil { + mod(p) + } + + op, err := e.networks(t).AddPeering(t.Context(), &computepb.AddPeeringNetworkRequest{ + Project: project, Network: net, + NetworksAddPeeringRequestResource: &computepb.NetworksAddPeeringRequest{NetworkPeering: p}, + }) + + return e.finish(t, op, err) +} + +func (e *env) peerings(t *testing.T, net string) map[string]*computepb.NetworkPeering { + t.Helper() + + n, err := e.networks(t).Get(t.Context(), &computepb.GetNetworkRequest{Project: project, Network: net}) + require.NoError(t, err) + + out := map[string]*computepb.NetworkPeering{} + for _, p := range n.GetPeerings() { + out[p.GetName()] = p + } + + return out +} + +func (e *env) peeringRoutes(t *testing.T, net, peering, direction string) map[string]*computepb.ExchangedPeeringRoute { + t.Helper() + + it := e.networks(t).ListPeeringRoutes(t.Context(), &computepb.ListPeeringRoutesNetworksRequest{ + Project: project, Network: net, PeeringName: &peering, Direction: &direction, + }) + out := map[string]*computepb.ExchangedPeeringRoute{} + + for { + r, err := it.Next() + if errors.Is(err, iterator.Done) { + return out + } + + require.NoError(t, err) + out[r.GetDestRange()] = r + } +} + +func TestPeeringActivation(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.peerNet(t, "a", "10.1.0.0/24") + e.peerNet(t, "b", "10.2.0.0/24") + + require.NoError(t, e.addPeering(t, "a", "a-to-b", "b", nil)) + + p := e.peerings(t, "a")["a-to-b"] + require.NotNil(t, p) + assert.Equal(t, "INACTIVE", p.GetState()) + assert.NotEmpty(t, p.GetStateDetails()) + assert.True(t, p.GetExchangeSubnetRoutes()) + assert.True(t, p.GetExportSubnetRoutesWithPublicIp()) + assert.False(t, p.GetImportCustomRoutes()) + assert.Equal(t, "IPV4_ONLY", p.GetStackType()) + assert.Equal(t, "INDEPENDENT", p.GetUpdateStrategy()) + assert.Equal(t, int32(1460), p.GetPeerMtu()) + assert.Contains(t, p.GetNetwork(), "/global/networks/b") + assert.Empty(t, e.peerings(t, "b")) + + require.NoError(t, e.addPeering(t, "b", "b-to-a", "a", nil)) + + assert.Equal(t, "ACTIVE", e.peerings(t, "a")["a-to-b"].GetState()) + assert.Equal(t, "ACTIVE", e.peerings(t, "b")["b-to-a"].GetState()) + assert.Equal(t, "Connected.", e.peerings(t, "b")["b-to-a"].GetStateDetails()) + + op, err := e.networks(t).RemovePeering(t.Context(), &computepb.RemovePeeringNetworkRequest{ + Project: project, Network: "b", + NetworksRemovePeeringRequestResource: &computepb.NetworksRemovePeeringRequest{Name: s("b-to-a")}, + }) + require.NoError(t, e.finish(t, op, err)) + assert.Empty(t, e.peerings(t, "b")) + assert.Equal(t, "INACTIVE", e.peerings(t, "a")["a-to-b"].GetState()) +} + +func TestPeeringAddErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + net string + pname string + peer string + reason string + status int + }{ + {name: "overlap", net: "a", pname: "p", peer: "c", status: http.StatusBadRequest, reason: "invalid"}, + {name: "self", net: "a", pname: "p", peer: "a", status: http.StatusBadRequest, reason: "invalid"}, + {name: "duplicate name", net: "a", pname: "first", peer: "d", status: http.StatusBadRequest, reason: "invalid"}, + {name: "duplicate peer", net: "a", pname: "again", peer: "b", status: http.StatusBadRequest, reason: "invalid"}, + {name: "missing peer", net: "a", pname: "p", peer: "ghost", status: http.StatusNotFound, reason: "notFound"}, + {name: "bad name", net: "a", pname: "Bad_Name", peer: "d", status: http.StatusBadRequest, reason: "invalid"}, + {name: "missing network", net: "ghost", pname: "p", peer: "d", status: http.StatusNotFound, reason: "notFound"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.peerNet(t, "a", "10.1.0.0/24") + e.peerNet(t, "b", "10.2.0.0/24") + e.peerNet(t, "c", "10.1.0.0/24") + e.peerNet(t, "d", "10.4.0.0/24") + require.NoError(t, e.addPeering(t, "a", "first", "b", nil)) + + assertAPIError(t, e.addPeering(t, tt.net, tt.pname, tt.peer, nil), tt.status, tt.reason) + }) + } +} + +func TestPeeringAutoModeNetworksOverlap(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.network(t, "auto1", true) + e.network(t, "auto2", true) + + err := e.addPeering(t, "auto1", "p", "auto2", nil) + assertAPIError(t, err, http.StatusBadRequest, "invalid") + assert.Contains(t, err.Error(), "overlaps with an IP range in the local network") +} + +func TestPeeringRouteExchange(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.peerNet(t, "a", "10.1.0.0/24") + e.peerNet(t, "b", "10.2.0.0/24") + + op, err := e.routes(t). + Insert(t.Context(), &computepb.InsertRouteRequest{Project: project, RouteResource: &computepb.Route{ + Name: s("b-custom"), Network: s(netRef("b")), DestRange: s("192.168.50.0/24"), NextHopIp: s("10.2.0.5"), + Priority: new(uint32(500)), + }}) + e.done(t, op, err) + + require.NoError(t, e.addPeering(t, "a", "a-to-b", "b", nil)) + assert.Empty(t, e.peeringRoutes(t, "a", "a-to-b", "INCOMING"), "inactive peering exchanges nothing") + + require.NoError(t, e.addPeering(t, "b", "b-to-a", "a", nil)) + + in := e.peeringRoutes(t, "a", "a-to-b", "INCOMING") + require.Contains(t, in, "10.2.0.0/24") + assert.Equal(t, "SUBNET_PEERING_ROUTE", in["10.2.0.0/24"].GetType()) + assert.True(t, in["10.2.0.0/24"].GetImported()) + assert.NotContains(t, in, "192.168.50.0/24", "custom routes need exportCustomRoutes") + assert.NotContains(t, in, "0.0.0.0/0") + + out := e.peeringRoutes(t, "a", "a-to-b", "OUTGOING") + assert.Contains(t, out, "10.1.0.0/24") + assert.NotContains(t, out, "10.2.0.0/24") + + update := func(net, peering string, export, imp *bool) { + t.Helper() + + upOp, uerr := e.networks(t).UpdatePeering(t.Context(), &computepb.UpdatePeeringNetworkRequest{ + Project: project, Network: net, + NetworksUpdatePeeringRequestResource: &computepb.NetworksUpdatePeeringRequest{ + NetworkPeering: &computepb.NetworkPeering{ + Name: s(peering), + ExportCustomRoutes: export, + ImportCustomRoutes: imp, + }, + }, + }) + require.NoError(t, e.finish(t, upOp, uerr)) + } + + update("b", "b-to-a", new(true), nil) + + in = e.peeringRoutes(t, "a", "a-to-b", "INCOMING") + require.Contains(t, in, "192.168.50.0/24") + assert.Equal(t, "STATIC_PEERING_ROUTE", in["192.168.50.0/24"].GetType()) + assert.Equal(t, uint32(500), in["192.168.50.0/24"].GetPriority()) + assert.False(t, in["192.168.50.0/24"].GetImported(), "importCustomRoutes is off locally") + + update("a", "a-to-b", nil, new(true)) + + in = e.peeringRoutes(t, "a", "a-to-b", "INCOMING") + assert.True(t, in["192.168.50.0/24"].GetImported()) + assert.True(t, e.peerings(t, "a")["a-to-b"].GetImportCustomRoutes()) + assert.True(t, e.peerings(t, "b")["b-to-a"].GetExportCustomRoutes()) + + update("b", "b-to-a", new(false), nil) + assert.NotContains(t, e.peeringRoutes(t, "a", "a-to-b", "INCOMING"), "192.168.50.0/24") +} + +func TestPeeringUpdateAndRemoveErrors(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.peerNet(t, "a", "10.1.0.0/24") + e.peerNet(t, "b", "10.2.0.0/24") + require.NoError(t, e.addPeering(t, "a", "a-to-b", "b", nil)) + + updates := []struct { + p *computepb.NetworkPeering + name string + }{ + {name: "stack type", p: &computepb.NetworkPeering{Name: s("a-to-b"), StackType: s("IPV4_IPV6")}}, + {name: "exchange routes", p: &computepb.NetworkPeering{Name: s("a-to-b"), ExchangeSubnetRoutes: new(false)}}, + {name: "unknown peering", p: &computepb.NetworkPeering{Name: s("nope"), ExportCustomRoutes: new(true)}}, + } + + for _, tt := range updates { + t.Run("update "+tt.name, func(t *testing.T) { + t.Parallel() + + op, err := e.networks(t).UpdatePeering(t.Context(), &computepb.UpdatePeeringNetworkRequest{ + Project: project, Network: "a", + NetworksUpdatePeeringRequestResource: &computepb.NetworksUpdatePeeringRequest{NetworkPeering: tt.p}, + }) + assertAPIError(t, e.finish(t, op, err), http.StatusBadRequest, "invalid") + }) + } + + t.Run("remove unknown", func(t *testing.T) { + t.Parallel() + + op, err := e.networks(t).RemovePeering(t.Context(), &computepb.RemovePeeringNetworkRequest{ + Project: project, Network: "a", + NetworksRemovePeeringRequestResource: &computepb.NetworksRemovePeeringRequest{Name: s("nope")}, + }) + assertAPIError(t, e.finish(t, op, err), http.StatusBadRequest, "invalid") + }) + + t.Run("list unknown peering", func(t *testing.T) { + t.Parallel() + + _, err := e.networks(t).ListPeeringRoutes(t.Context(), &computepb.ListPeeringRoutesNetworksRequest{ + Project: project, Network: "a", PeeringName: s("nope"), + }).Next() + assertAPIError(t, err, http.StatusBadRequest, "invalid") + }) +} + +func TestPeeringBlocksNetworkDeleteAndOverlappingSubnets(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.peerNet(t, "a", "10.1.0.0/24") + e.peerNet(t, "b", "10.2.0.0/24") + require.NoError(t, e.addPeering(t, "a", "a-to-b", "b", nil)) + require.NoError(t, e.addPeering(t, "b", "b-to-a", "a", nil)) + + insertSub := func(name, cidr string) error { + op, err := e.subnets(t).Insert(t.Context(), &computepb.InsertSubnetworkRequest{ + Project: project, Region: region, SubnetworkResource: &computepb.Subnetwork{ + Name: s(name), Network: s(netRef("a")), IpCidrRange: s(cidr), + }, + }) + + return e.finish(t, op, err) + } + + assertAPIError(t, insertSub("clash", "10.2.0.0/25"), http.StatusBadRequest, "invalid") + require.NoError(t, insertSub("fine", "10.3.0.0/24")) + + _, err := e.networks(t).Delete(t.Context(), &computepb.DeleteNetworkRequest{Project: project, Network: "a"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceInUseByAnotherResource") + + op, err := e.networks(t).RemovePeering(t.Context(), &computepb.RemovePeeringNetworkRequest{ + Project: project, Network: "a", + NetworksRemovePeeringRequestResource: &computepb.NetworksRemovePeeringRequest{Name: s("a-to-b")}, + }) + require.NoError(t, e.finish(t, op, err)) + require.NoError(t, insertSub("now-fine", "10.2.0.128/25")) +} diff --git a/services/gcpcompute/peers.go b/services/gcpcompute/peers.go new file mode 100644 index 0000000000..d485f65cd9 --- /dev/null +++ b/services/gcpcompute/peers.go @@ -0,0 +1,64 @@ +package gcpcompute + +import "strings" + +// NetworkExists reports whether the VPC network exists in project. +func (b *InMemoryBackend) NetworkExists(project, network string) bool { + b.mu.RLock("NetworkExists") + defer b.mu.RUnlock() + + _, ok := b.networks[key(project, network)] + + return ok +} + +// SubnetworkExists reports whether the subnetwork exists in the region of project. +func (b *InMemoryBackend) SubnetworkExists(project, region, subnetwork string) bool { + b.mu.RLock("SubnetworkExists") + defer b.mu.RUnlock() + + _, ok := b.subnets[key(project, region, subnetwork)] + + return ok +} + +// SecondaryRange returns the CIDR of a subnetwork's named secondary range. +func (b *InMemoryBackend) SecondaryRange(project, region, subnetwork, rangeName string) (string, bool) { + b.mu.RLock("SecondaryRange") + defer b.mu.RUnlock() + + s, ok := b.subnets[key(project, region, subnetwork)] + if !ok { + return "", false + } + + for _, r := range s.SecondaryIpRanges { + if r.RangeName == rangeName { + return r.IpCidrRange, true + } + } + + return "", false +} + +// MachineTypeExists reports whether a predefined or custom machine type name is valid. +func (b *InMemoryBackend) MachineTypeExists(machineType string) bool { + if _, ok := findMachine(machineType); ok { + return true + } + + _, tail, found := strings.Cut(machineType, "custom-") + if !found { + return false + } + + family := strings.TrimSuffix(strings.TrimSuffix(machineType, "custom-"+tail), "-") + switch family { + case "", "n1", "n2", "n2d", "e2": + _, _, ok := customMachine("custom-" + tail) + + return ok + default: + return false + } +} diff --git a/services/gcpcompute/persistence.go b/services/gcpcompute/persistence.go index 000f126ab6..0703ea85c8 100644 --- a/services/gcpcompute/persistence.go +++ b/services/gcpcompute/persistence.go @@ -42,6 +42,8 @@ type backendSnapshot struct { FwdRules map[string]*computeapi.ForwardingRule `json:"forwardingRules"` NEGs map[string]*computeapi.NetworkEndpointGroup `json:"networkEndpointGroups"` NEGEndpoints map[string][]*computeapi.NetworkEndpoint `json:"networkEndpoints"` + SecPolicies map[string]*computeapi.SecurityPolicy `json:"securityPolicies"` + ResPolicies map[string]*computeapi.ResourcePolicy `json:"resourcePolicies"` Ops map[string]*opRec `json:"ops"` Projects map[string]*computeapi.Metadata `json:"projects"` Version int `json:"version"` @@ -60,7 +62,7 @@ func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { BackendServices: b.backendSvcs, BackendBuckets: b.backendBuckets, URLMaps: b.urlMaps, HTTPProxies: b.httpProxies, HTTPSProxies: b.httpsProxies, TCPProxies: b.tcpProxies, SSLProxies: b.sslProxies, SSLCerts: b.sslCerts, SSLPolicies: b.sslPolicies, FwdRules: b.fwdRules, - NEGs: b.negs, NEGEndpoints: b.negEndpoints, + NEGs: b.negs, NEGEndpoints: b.negEndpoints, SecPolicies: b.secPolicies, ResPolicies: b.resPolicies, }) } @@ -110,7 +112,8 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { copyNonNil(b.httpsProxies, snap.HTTPSProxies), copyNonNil(b.tcpProxies, snap.TCPProxies), copyNonNil(b.sslProxies, snap.SSLProxies), copyNonNil(b.sslCerts, snap.SSLCerts), copyNonNil(b.sslPolicies, snap.SSLPolicies), copyNonNil(b.fwdRules, snap.FwdRules), - copyNonNil(b.negs, snap.NEGs), + copyNonNil(b.negs, snap.NEGs), copyNonNil(b.secPolicies, snap.SecPolicies), + copyNonNil(b.resPolicies, snap.ResPolicies), } { if err != nil { b.resetMaps() diff --git a/services/gcpcompute/persistence_test.go b/services/gcpcompute/persistence_test.go index 0146de5558..2ae7aa13c6 100644 --- a/services/gcpcompute/persistence_test.go +++ b/services/gcpcompute/persistence_test.go @@ -143,6 +143,8 @@ func TestRestoreRejectsBadSnapshots(t *testing.T) { }{ {"null instance", `{"instances":{"p/z/x":null},"version":1}`, true}, {"null network", `{"networks":{"p/x":null},"version":1}`, true}, + {"null security policy", `{"securityPolicies":{"p/global/x":null},"version":1}`, true}, + {"null resource policy", `{"resourcePolicies":{"p/r/x":null},"version":1}`, true}, {"instance without body", `{"instances":{"p/z/x":{"phase":"start"}},"version":1}`, true}, {"router without body", `{"routers":{"p/r/x":{}},"version":1}`, true}, {"operation without body", `{"ops":{"k":{}},"version":1}`, true}, diff --git a/services/gcpcompute/policies_persistence_test.go b/services/gcpcompute/policies_persistence_test.go new file mode 100644 index 0000000000..f8dfcb230d --- /dev/null +++ b/services/gcpcompute/policies_persistence_test.go @@ -0,0 +1,102 @@ +package gcpcompute_test + +import ( + "net/http" + "testing" + "time" + + compute "cloud.google.com/go/compute/apiv1" + "cloud.google.com/go/compute/apiv1/computepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPolicyAndPeeringStateSurvivesRestore(t *testing.T) { + t.Parallel() + + src := newEnv(t) + src.peerNet(t, "a", "10.1.0.0/24") + src.peerNet(t, "b", "10.2.0.0/24") + require.NoError( + t, + src.addPeering(t, "a", "a-to-b", "b", func(p *computepb.NetworkPeering) { p.ExportCustomRoutes = new(true) }), + ) + require.NoError(t, src.addPeering(t, "b", "b-to-a", "a", nil)) + + src.lbStack(t) + require.NoError(t, src.policy(t, &computepb.SecurityPolicy{ + Name: s( + "pol", + ), + Rules: []*computepb.SecurityPolicyRule{ + srcRule(10, "deny(403)", "198.51.100.0/24"), srcRule(defaultPrio, "allow", "*"), + }, + })) + + bsc := src.backendServices(t) + op, err := bsc.SetSecurityPolicy(t.Context(), &computepb.SetSecurityPolicyBackendServiceRequest{ + Project: project, BackendService: "bs", + SecurityPolicyReferenceResource: &computepb.SecurityPolicyReference{ + SecurityPolicy: s("global/securityPolicies/pol"), + }, + }) + src.done(t, op, err) + + src.disk(t, "d1") + require.NoError(t, src.resPolicy(t, named(daily("00:00"), "snap"))) + require.NoError(t, src.changeDisk(t, "d1", true, rpRef("snap"))) + + src.baseNetVM(t) + require.NoError(t, src.resPolicy(t, named(instanceSchedule("0 8 * * *"), "sched"))) + require.NoError(t, src.changeVM(t, true, rpRef("sched"))) + + suspend, err := src.instances(t). + Suspend(t.Context(), &computepb.SuspendInstanceRequest{Project: project, Zone: zone, Instance: "vm1"}) + src.done(t, suspend, err) + + data := src.backend.Snapshot(t.Context()) + + dst := newEnv(t) + require.NoError(t, dst.backend.Restore(t.Context(), data)) + dst.clock.Advance(src.clock.Now().Sub(dst.clock.Now()) + time.Minute) + + assert.Equal(t, "ACTIVE", dst.peerings(t, "a")["a-to-b"].GetState()) + assert.True(t, dst.peerings(t, "a")["a-to-b"].GetExportCustomRoutes()) + assert.Contains(t, dst.peeringRoutes(t, "b", "b-to-a", "INCOMING"), "10.1.0.0/24") + + p := dst.getPolicy(t) + require.Len(t, p.GetRules(), 2) + assert.Equal(t, int32(10), p.GetRules()[0].GetPriority()) + assert.Equal(t, "deny(403)", p.GetRules()[0].GetAction()) + + bs, err := dst.backendServices(t). + Get(t.Context(), &computepb.GetBackendServiceRequest{Project: project, BackendService: "bs"}) + require.NoError(t, err) + assert.Contains(t, bs.GetSecurityPolicy(), "/securityPolicies/pol") + + _, err = dst.armor(t). + Delete(t.Context(), &computepb.DeleteSecurityPolicyRequest{Project: project, SecurityPolicy: "pol"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceInUseByAnotherResource") + + _, err = dst.resPolicies(t). + Delete(t.Context(), &computepb.DeleteResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "snap"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceInUseByAnotherResource") + + vm := dst.getVM(t, "vm1") + assert.Equal(t, "SUSPENDED", vm.GetStatus()) + assert.Len(t, vm.GetResourcePolicies(), 1) + + assert.Contains(t, dst.routeList(t), "10.1.0.0/24") + assert.Equal(t, "SUBNET", dst.routeList(t)["10.2.0.0/24"].GetRouteType()) +} + +func (e *env) backendServices(t *testing.T) *compute.BackendServicesClient { + t.Helper() + + return client(t, e, compute.NewBackendServicesRESTClient) +} + +func (e *env) baseNetVM(t *testing.T) { + t.Helper() + e.runningVM(t) +} diff --git a/services/gcpcompute/respolicy.go b/services/gcpcompute/respolicy.go new file mode 100644 index 0000000000..1f7b5000dd --- /dev/null +++ b/services/gcpcompute/respolicy.go @@ -0,0 +1,375 @@ +package gcpcompute + +import ( + "regexp" + "strconv" + "time" + + computeapi "google.golang.org/api/compute/v1" +) + +const ( + collResPolicy = "resourcePolicies" + resPolicyReady = "READY" + defDuration = "PT1H" + keepSnapshots = "KEEP_AUTO_SNAPSHOTS" + hoursPerDay = 24 +) + +var ( + hhmmRE = regexp.MustCompile(`^(\d{2}):(\d{2})(?:([+-]\d{1,2}))?$`) + cronFieldRE = regexp.MustCompile(`^[0-9A-Za-z*,/\-?]+$`) +) + +const ( + snapStartStep = 4 + daysPerWeek = 7 +) + +func validWeekday(d string) bool { + switch d { + case "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY": + return true + } + + return false +} + +func validOnDiskDelete(v string) bool { + switch v { + case "APPLY_RETENTION_POLICY", keepSnapshots, "UNSPECIFIED_ON_SOURCE_DISK_DELETE": + return true + } + + return false +} + +func resPolicySpec() lbSpec[computeapi.ResourcePolicy] { + return lbSpec[computeapi.ResourcePolicy]{ + coll: collResPolicy, kind: "compute#resourcePolicy", noun: "resource_policy", + m: func(b *InMemoryBackend) map[string]*computeapi.ResourcePolicy { return b.resPolicies }, + meta: func(v *computeapi.ResourcePolicy) lbMeta { + return lbMeta{ + name: &v.Name, kind: &v.Kind, created: &v.CreationTimestamp, self: &v.SelfLink, + region: &v.Region, id: &v.Id, + } + }, + prep: prepResourcePolicy, + } +} + +func resPolicyKind(p *computeapi.ResourcePolicy) string { + switch { + case p.SnapshotSchedulePolicy != nil: + return "snapshotSchedulePolicy" + case p.InstanceSchedulePolicy != nil: + return "instanceSchedulePolicy" + case p.GroupPlacementPolicy != nil: + return "groupPlacementPolicy" + case p.DiskConsistencyGroupPolicy != nil: + return "diskConsistencyGroupPolicy" + case p.WorkloadPolicy != nil: + return "workloadPolicy" + } + + return "" +} + +func prepResourcePolicy(_ *InMemoryBackend, _ lbCtx, in *computeapi.ResourcePolicy, _ map[string]any) *APIError { + n := 0 + + for _, set := range []bool{ + in.SnapshotSchedulePolicy != nil, in.InstanceSchedulePolicy != nil, in.GroupPlacementPolicy != nil, + in.DiskConsistencyGroupPolicy != nil, in.WorkloadPolicy != nil, + } { + if set { + n++ + } + } + + if n != 1 { + return errInvalid("Invalid value for field 'resource': exactly one of snapshotSchedulePolicy, " + + "instanceSchedulePolicy, groupPlacementPolicy, diskConsistencyGroupPolicy or workloadPolicy must be set.") + } + + in.Status = resPolicyReady + + switch { + case in.SnapshotSchedulePolicy != nil: + return validSnapshotPolicy(in.SnapshotSchedulePolicy) + case in.InstanceSchedulePolicy != nil: + return validInstanceSchedule(in.InstanceSchedulePolicy) + case in.GroupPlacementPolicy != nil: + return validGroupPlacement(in.GroupPlacementPolicy) + } + + return nil +} + +// utcStart parses HH:MM with an optional hour offset (13:00-5) and returns the UTC hour and minute. +func utcStart(field, raw string) (int, int, *APIError) { + m := hhmmRE.FindStringSubmatch(raw) + if m == nil { + return 0, 0, errField(field, raw, "Must be in the format HH:MM.") + } + + h, _ := strconv.Atoi(m[1]) + mi, _ := strconv.Atoi(m[2]) + + if h > hoursPerDay-1 || mi > 59 { + return 0, 0, errField(field, raw, "Must be in the format HH:MM.") + } + + if m[3] != "" { + off, _ := strconv.Atoi(m[3]) + h = ((h+off)%hoursPerDay + hoursPerDay) % hoursPerDay + } + + return h, mi, nil +} + +func normSnapshotStart(field string, start *string) *APIError { + h, mi, err := utcStart(field, *start) + if err != nil { + return err + } + + if h%snapStartStep != 0 || mi != 0 { + return errField(field, *start, "Must resolve to one of 00:00, 04:00, 08:00, 12:00, 16:00 or 20:00 UTC.") + } + + *start = formatHHMM(h) + + return nil +} + +func formatHHMM(h int) string { return pad2(h) + ":00" } + +func pad2(n int) string { + if n < 10 { //nolint:mnd // two-digit padding + return "0" + strconv.Itoa(n) + } + + return strconv.Itoa(n) +} + +func validSnapshotPolicy(sp *computeapi.ResourcePolicySnapshotSchedulePolicy) *APIError { + const f = "resource.snapshotSchedulePolicy" + + sc := sp.Schedule + if sc == nil { + return errRequired(f + ".schedule") + } + + set := 0 + + for _, ok := range []bool{sc.DailySchedule != nil, sc.HourlySchedule != nil, sc.WeeklySchedule != nil} { + if ok { + set++ + } + } + + if set != 1 { + return errInvalid("Invalid value for field '%s.schedule': exactly one of dailySchedule, hourlySchedule "+ + "and weeklySchedule must be set.", f) + } + + if err := validCycle(sc, f+".schedule"); err != nil { + return err + } + + if r := sp.RetentionPolicy; r != nil { + if r.MaxRetentionDays < 1 { + return errField(f+".retentionPolicy.maxRetentionDays", strconv.FormatInt(r.MaxRetentionDays, 10), + "Must be at least 1.") + } + + if r.OnSourceDiskDelete == "" { + r.OnSourceDiskDelete = keepSnapshots + } + + if !validOnDiskDelete(r.OnSourceDiskDelete) { + return errField(f+".retentionPolicy.onSourceDiskDelete", r.OnSourceDiskDelete, "") + } + } + + if sprops := sp.SnapshotProperties; sprops != nil { + if err := validLabels(sprops.Labels); err != nil { + return err + } + + if len(sprops.StorageLocations) > 1 { + return errField(f+".snapshotProperties.storageLocations", "", "At most one storage location is allowed.") + } + } + + return nil +} + +func validCycle(sc *computeapi.ResourcePolicySnapshotSchedulePolicySchedule, f string) *APIError { + switch { + case sc.DailySchedule != nil: + d := sc.DailySchedule + if d.DaysInCycle < 1 { + return errField(f+".dailySchedule.daysInCycle", strconv.FormatInt(d.DaysInCycle, 10), "Must be at least 1.") + } + + d.Duration = defDuration + + return normSnapshotStart(f+".dailySchedule.startTime", &d.StartTime) + case sc.HourlySchedule != nil: + hs := sc.HourlySchedule + if hs.HoursInCycle < 1 || hs.HoursInCycle > hoursPerDay || hoursPerDay%hs.HoursInCycle != 0 { + return errField(f+".hourlySchedule.hoursInCycle", strconv.FormatInt(hs.HoursInCycle, 10), + "Must be a divisor of 24.") + } + + h, mi, err := utcStart(f+".hourlySchedule.startTime", hs.StartTime) + if err != nil { + return err + } + + if mi != 0 { + return errField(f+".hourlySchedule.startTime", hs.StartTime, "Minutes must be 00.") + } + + hs.StartTime, hs.Duration = formatHHMM(h), defDuration + + return nil + } + + return validWeekly(sc.WeeklySchedule, f+".weeklySchedule") +} + +func validWeekly(w *computeapi.ResourcePolicyWeeklyCycle, f string) *APIError { + if len(w.DayOfWeeks) < 1 || len(w.DayOfWeeks) > daysPerWeek { + return errField(f+".dayOfWeeks", "", "Between 1 and 7 days are required.") + } + + seen := map[string]bool{} + + for _, d := range w.DayOfWeeks { + if !validWeekday(d.Day) || seen[d.Day] { + return errField(f+".dayOfWeeks.day", d.Day, "Must be a unique day from MONDAY to SUNDAY.") + } + + seen[d.Day] = true + d.Duration = defDuration + + if err := normSnapshotStart(f+".dayOfWeeks.startTime", &d.StartTime); err != nil { + return err + } + } + + return nil +} + +func validCron(field, expr string) *APIError { + fields := 0 + + for _, tok := range splitFields(expr) { + if !cronFieldRE.MatchString(tok) { + return errField(field, expr, "Not a valid unix-cron expression.") + } + + fields++ + } + + if fields != 5 { //nolint:mnd // minute hour day-of-month month day-of-week + return errField(field, expr, "A unix-cron expression has five fields.") + } + + return nil +} + +func splitFields(s string) []string { + var out []string + + cur := "" + + for _, c := range s { + if c == ' ' || c == '\t' { + if cur != "" { + out, cur = append(out, cur), "" + } + + continue + } + + cur += string(c) + } + + if cur != "" { + out = append(out, cur) + } + + return out +} + +func validInstanceSchedule(is *computeapi.ResourcePolicyInstanceSchedulePolicy) *APIError { + const f = "resource.instanceSchedulePolicy" + + if is.VmStartSchedule == nil && is.VmStopSchedule == nil { + return errInvalid("Invalid value for field '%s': at least one of vmStartSchedule and vmStopSchedule "+ + "must be set.", f) + } + + for name, s := range map[string]*computeapi.ResourcePolicyInstanceSchedulePolicySchedule{ + "vmStartSchedule": is.VmStartSchedule, "vmStopSchedule": is.VmStopSchedule, + } { + if s == nil { + continue + } + + if err := validCron(f+"."+name+".schedule", s.Schedule); err != nil { + return err + } + } + + if is.TimeZone == "" { + is.TimeZone = "UTC" + } + + if _, err := time.LoadLocation(is.TimeZone); err != nil { + return errField(f+".timeZone", is.TimeZone, "Must be a time zone name from the tz database.") + } + + var start, exp time.Time + + for _, t := range []struct { + dst *time.Time + val string + key string + }{{&start, is.StartTime, "startTime"}, {&exp, is.ExpirationTime, "expirationTime"}} { + if t.val == "" { + continue + } + + v, err := time.Parse(time.RFC3339, t.val) + if err != nil { + return errField(f+"."+t.key, t.val, "Must be an RFC3339 timestamp.") + } + + *t.dst = v + } + + if !start.IsZero() && !exp.IsZero() && !exp.After(start) { + return errField(f+".expirationTime", is.ExpirationTime, "Must be after startTime.") + } + + return nil +} + +func validGroupPlacement(g *computeapi.ResourcePolicyGroupPlacementPolicy) *APIError { + switch g.Collocation { + case "", "COLLOCATED", "UNSPECIFIED_COLLOCATION": + default: + return errField("resource.groupPlacementPolicy.collocation", g.Collocation, "") + } + + if g.VmCount < 0 || g.AvailabilityDomainCount < 0 { + return errInvalid("Invalid value for field 'resource.groupPlacementPolicy': counts cannot be negative.") + } + + return nil +} diff --git a/services/gcpcompute/respolicy_attach.go b/services/gcpcompute/respolicy_attach.go new file mode 100644 index 0000000000..8a2771af8f --- /dev/null +++ b/services/gcpcompute/respolicy_attach.go @@ -0,0 +1,237 @@ +package gcpcompute + +import ( + "slices" + + computeapi "google.golang.org/api/compute/v1" +) + +func diskPolicyKinds() []string { + return []string{"snapshotSchedulePolicy", "diskConsistencyGroupPolicy"} +} + +func instancePolicyKinds() []string { + return []string{"instanceSchedulePolicy", "groupPlacementPolicy", "workloadPolicy"} +} + +type resPoliciesRequest struct { + ResourcePolicies []string `json:"resourcePolicies"` +} + +// resolveResPoliciesLocked resolves refs to regional resource policies of the allowed kinds, one per kind. +func (b *InMemoryBackend) resolveResPoliciesLocked( + project, region string, refs []string, field string, allowed []string, +) ([]string, *APIError) { + c := lbCtx{project: project, scope: scopeRegion, loc: region} + out := make([]string, 0, len(refs)) + kinds := map[string]bool{} + + for _, raw := range refs { + self, err := lbResolve(b, resPolicySpec(), c, field, raw) + if err != nil { + return nil, err + } + + kind := resPolicyKind(lbFind(b, resPolicySpec(), self)) + if !slices.Contains(allowed, kind) { + return nil, errField(field, raw, "A resource policy of this type cannot be attached to this resource.") + } + + if kinds[kind] { + return nil, errField(field, raw, "At most one resource policy of this type can be attached.") + } + + kinds[kind] = true + + if slices.Contains(out, self) { + return nil, errField(field, raw, "Duplicate reference.") + } + + out = append(out, self) + } + + return out, nil +} + +func (b *InMemoryBackend) addResPoliciesLocked( + project, region string, have, add []string, allowed []string, +) ([]string, *APIError) { + if len(add) == 0 { + return nil, errRequired("resourcePolicies") + } + + merged, err := b.resolveResPoliciesLocked(project, region, add, "resourcePolicies", allowed) + if err != nil { + return nil, err + } + + for _, self := range merged { + if slices.Contains(have, self) { + return nil, errInvalid("The resource policy '%s' is already attached to the resource.", trimURL(self)) + } + } + + return b.resolveResPoliciesLocked( + project, + region, + append(slices.Clone(have), merged...), + "resourcePolicies", + allowed, + ) +} + +func (b *InMemoryBackend) removeResPoliciesLocked(project, region string, have, drop []string) ([]string, *APIError) { + if len(drop) == 0 { + return nil, errRequired("resourcePolicies") + } + + c := lbCtx{project: project, scope: scopeRegion, loc: region} + out := slices.Clone(have) + + for _, raw := range drop { + self, err := lbResolve(b, resPolicySpec(), c, "resourcePolicies", raw) + if err != nil { + return nil, err + } + + i := slices.Index(out, self) + if i < 0 { + return nil, errInvalid("The resource policy '%s' is not attached to the resource.", trimURL(self)) + } + + out = slices.Delete(out, i, i+1) + } + + return out, nil +} + +func (b *InMemoryBackend) changeDiskPolicies( + project, zone, name, verb string, refs []string, +) (*computeapi.Operation, error) { + b.mu.Lock("DiskResourcePolicies") + defer b.mu.Unlock() + + d, ok := b.disks[key(project, zone, name)] + if !ok { + return nil, errNotFound(relPath(project, scopeZone, zone, "disks", name)) + } + + var ( + next []string + err *APIError + ) + + if verb == verbAddResPols { + next, err = b.addResPoliciesLocked(project, regionOfZone(zone), d.ResourcePolicies, refs, diskPolicyKinds()) + } else { + next, err = b.removeResPoliciesLocked(project, regionOfZone(zone), d.ResourcePolicies, refs) + } + + if err != nil { + return nil, err + } + + d.ResourcePolicies = next + + return b.newOpLocked(verb, diskTarget(project, zone, d), b.timing.Done), nil +} + +func (b *InMemoryBackend) changeInstancePolicies( + project, zone, name, verb string, refs []string, +) (*computeapi.Operation, error) { + b.mu.Lock("InstanceResourcePolicies") + defer b.mu.Unlock() + + r, aerr := b.getInstLocked(project, zone, name) + if aerr != nil { + return nil, aerr + } + + var ( + next []string + err *APIError + ) + + if verb == verbAddResPols { + next, err = b.addResPoliciesLocked( + project, + regionOfZone(zone), + r.Inst.ResourcePolicies, + refs, + instancePolicyKinds(), + ) + } else { + next, err = b.removeResPoliciesLocked(project, regionOfZone(zone), r.Inst.ResourcePolicies, refs) + } + + if err != nil { + return nil, err + } + + r.Inst.ResourcePolicies = next + b.touchLocked(r) + + return b.newOpLocked(verb, instTarget(project, zone, r.Inst), b.timing.Done), nil +} + +// resPolicyUserLocked names a disk or instance that references the policy, or "". +func (b *InMemoryBackend) resPolicyUserLocked(self string) string { + for _, d := range b.disks { + if slices.Contains(d.ResourcePolicies, self) { + return trimURL(d.SelfLink) + } + } + + for _, r := range b.instances { + if slices.Contains(r.Inst.ResourcePolicies, self) { + return trimURL(r.Inst.SelfLink) + } + } + + return "" +} + +func (h *Handler) registerResourcePolicyOps() { + r := scopeRegion + registerLB(h, resPolicySpec(), lbOpts[computeapi.ResourcePolicy]{ + scopes: []string{r}, agg: regionScopes(), patch: true, + del: func(b *InMemoryBackend, c lbCtx, name string) (*computeapi.Operation, error) { + return lbDelete( + b, + resPolicySpec(), + c, + name, + func(b *InMemoryBackend, v *computeapi.ResourcePolicy) *APIError { + if by := b.resPolicyUserLocked(v.SelfLink); by != "" { + return errInUse("resource_policy", trimURL(v.SelfLink), by) + } + + return nil + }, + ) + }, + }) + + for _, verb := range []string{verbAddResPols, verbDelResPols} { + h.reg("disks", scopeZone, verb, func(h *Handler, rq *request) (any, error) { + in, _, err := decode[resPoliciesRequest](rq) + if err != nil { + return nil, err + } + + return opResult( + h.Backend.changeDiskPolicies(rq.rt.project, rq.rt.loc, rq.rt.name, verb, in.ResourcePolicies), + ) + }) + h.reg("instances", scopeZone, verb, func(h *Handler, rq *request) (any, error) { + in, _, err := decode[resPoliciesRequest](rq) + if err != nil { + return nil, err + } + + return opResult( + h.Backend.changeInstancePolicies(rq.rt.project, rq.rt.loc, rq.rt.name, verb, in.ResourcePolicies), + ) + }) + } +} diff --git a/services/gcpcompute/respolicy_test.go b/services/gcpcompute/respolicy_test.go new file mode 100644 index 0000000000..666332105c --- /dev/null +++ b/services/gcpcompute/respolicy_test.go @@ -0,0 +1,352 @@ +package gcpcompute_test + +import ( + "net/http" + "testing" + + compute "cloud.google.com/go/compute/apiv1" + "cloud.google.com/go/compute/apiv1/computepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func rpRef(name string) string { + return "projects/" + project + "/regions/" + region + "/resourcePolicies/" + name +} + +func (e *env) resPolicies(t *testing.T) *compute.ResourcePoliciesClient { + t.Helper() + + return client(t, e, compute.NewResourcePoliciesRESTClient) +} + +func (e *env) resPolicy(t *testing.T, p *computepb.ResourcePolicy) error { + t.Helper() + + op, err := e.resPolicies(t).Insert(t.Context(), &computepb.InsertResourcePolicyRequest{ + Project: project, Region: region, ResourcePolicyResource: p, + }) + + return e.finish(t, op, err) +} + +func daily(start string) *computepb.ResourcePolicy { + return &computepb.ResourcePolicy{SnapshotSchedulePolicy: &computepb.ResourcePolicySnapshotSchedulePolicy{ + Schedule: &computepb.ResourcePolicySnapshotSchedulePolicySchedule{ + DailySchedule: &computepb.ResourcePolicyDailyCycle{DaysInCycle: proto.Int32(1), StartTime: s(start)}, + }, + RetentionPolicy: &computepb.ResourcePolicySnapshotSchedulePolicyRetentionPolicy{ + MaxRetentionDays: proto.Int32(7), + }, + }} +} + +func instanceSchedule(cron string) *computepb.ResourcePolicy { + return &computepb.ResourcePolicy{InstanceSchedulePolicy: &computepb.ResourcePolicyInstanceSchedulePolicy{ + VmStartSchedule: &computepb.ResourcePolicyInstanceSchedulePolicySchedule{Schedule: s(cron)}, TimeZone: s("UTC"), + }} +} + +func named(p *computepb.ResourcePolicy, name string) *computepb.ResourcePolicy { + p.Name = s(name) + + return p +} + +func TestResourcePolicyCRUD(t *testing.T) { + t.Parallel() + + e := newEnv(t) + require.NoError(t, e.resPolicy(t, named(daily("13:00-5"), "snap"))) + require.NoError(t, e.resPolicy(t, named(instanceSchedule("0 8 * * 1-5"), "sched"))) + + p, err := e.resPolicies(t). + Get(t.Context(), &computepb.GetResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "snap"}) + require.NoError(t, err) + assert.Equal(t, "READY", p.GetStatus()) + assert.Equal(t, "compute#resourcePolicy", p.GetKind()) + assert.Contains(t, p.GetRegion(), "/regions/"+region) + assert.Contains(t, p.GetSelfLink(), "/regions/"+region+"/resourcePolicies/snap") + assert.Equal(t, "08:00", p.GetSnapshotSchedulePolicy().GetSchedule().GetDailySchedule().GetStartTime()) + assert.Equal(t, "KEEP_AUTO_SNAPSHOTS", p.GetSnapshotSchedulePolicy().GetRetentionPolicy().GetOnSourceDiskDelete()) + + assertAPIError(t, e.resPolicy(t, named(daily("00:00"), "snap")), http.StatusConflict, "alreadyExists") + + count := 0 + it := e.resPolicies(t).List(t.Context(), &computepb.ListResourcePoliciesRequest{Project: project, Region: region}) + + for _, err = it.Next(); err == nil; _, err = it.Next() { + count++ + } + + assert.Equal(t, 2, count) + + op, err := e.resPolicies(t).Patch(t.Context(), &computepb.PatchResourcePolicyRequest{ + Project: project, Region: region, ResourcePolicy: "sched", + ResourcePolicyResource: &computepb.ResourcePolicy{ + InstanceSchedulePolicy: &computepb.ResourcePolicyInstanceSchedulePolicy{ + VmStopSchedule: &computepb.ResourcePolicyInstanceSchedulePolicySchedule{Schedule: s("0 20 * * *")}, + }, + }, + }) + require.NoError(t, e.finish(t, op, err)) + + p, err = e.resPolicies(t). + Get(t.Context(), &computepb.GetResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "sched"}) + require.NoError(t, err) + assert.Equal(t, "0 20 * * *", p.GetInstanceSchedulePolicy().GetVmStopSchedule().GetSchedule()) + assert.Equal(t, "0 8 * * 1-5", p.GetInstanceSchedulePolicy().GetVmStartSchedule().GetSchedule()) + + op, err = e.resPolicies(t). + Delete(t.Context(), &computepb.DeleteResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "snap"}) + require.NoError(t, e.finish(t, op, err)) + + _, err = e.resPolicies(t). + Get(t.Context(), &computepb.GetResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "snap"}) + assertAPIError(t, err, http.StatusNotFound, "notFound") +} + +func TestResourcePolicyValidation(t *testing.T) { + t.Parallel() + + weekly := func(day, start string) *computepb.ResourcePolicy { + return &computepb.ResourcePolicy{SnapshotSchedulePolicy: &computepb.ResourcePolicySnapshotSchedulePolicy{ + Schedule: &computepb.ResourcePolicySnapshotSchedulePolicySchedule{ + WeeklySchedule: &computepb.ResourcePolicyWeeklyCycle{ + DayOfWeeks: []*computepb.ResourcePolicyWeeklyCycleDayOfWeek{ + {Day: s(day), StartTime: s(start)}, + }, + }, + }, + }} + } + hourly := func(n int32) *computepb.ResourcePolicy { + return &computepb.ResourcePolicy{SnapshotSchedulePolicy: &computepb.ResourcePolicySnapshotSchedulePolicy{ + Schedule: &computepb.ResourcePolicySnapshotSchedulePolicySchedule{ + HourlySchedule: &computepb.ResourcePolicyHourlyCycle{ + HoursInCycle: new(n), + StartTime: s("04:00"), + }, + }, + }} + } + + tests := []struct { + pol *computepb.ResourcePolicy + name string + valid bool + }{ + {name: "weekly", valid: true, pol: weekly("MONDAY", "08:00")}, + {name: "hourly divisor", valid: true, pol: hourly(6)}, + {name: "hourly not divisor", pol: hourly(5)}, + {name: "weekly bad day", pol: weekly("FUNDAY", "08:00")}, + {name: "weekly bad start", pol: weekly("MONDAY", "09:00")}, + {name: "daily bad start", pol: daily("10:00")}, + {name: "no type", pol: &computepb.ResourcePolicy{}}, + {name: "two types", pol: &computepb.ResourcePolicy{ + SnapshotSchedulePolicy: daily("00:00").GetSnapshotSchedulePolicy(), + InstanceSchedulePolicy: instanceSchedule("0 8 * * *").GetInstanceSchedulePolicy(), + }}, + { + name: "snapshot without schedule", + pol: &computepb.ResourcePolicy{SnapshotSchedulePolicy: &computepb.ResourcePolicySnapshotSchedulePolicy{}}, + }, + {name: "bad cron", pol: instanceSchedule("0 8 * *")}, + {name: "bad cron chars", pol: instanceSchedule("0 8 * * $")}, + { + name: "no schedule", + pol: &computepb.ResourcePolicy{InstanceSchedulePolicy: &computepb.ResourcePolicyInstanceSchedulePolicy{}}, + }, + { + name: "bad time zone", + pol: &computepb.ResourcePolicy{InstanceSchedulePolicy: &computepb.ResourcePolicyInstanceSchedulePolicy{ + VmStopSchedule: &computepb.ResourcePolicyInstanceSchedulePolicySchedule{ + Schedule: s("0 8 * * *"), + }, TimeZone: s("Mars/Base"), + }}, + }, + { + name: "placement", + valid: true, + pol: &computepb.ResourcePolicy{GroupPlacementPolicy: &computepb.ResourcePolicyGroupPlacementPolicy{ + Collocation: s("COLLOCATED"), VmCount: proto.Int32(2), + }}, + }, + { + name: "placement bad collocation", + pol: &computepb.ResourcePolicy{GroupPlacementPolicy: &computepb.ResourcePolicyGroupPlacementPolicy{ + Collocation: s("SPREAD"), + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + err := e.resPolicy(t, named(tt.pol, "pol")) + + if tt.valid { + require.NoError(t, err) + + return + } + + assertAPIError(t, err, http.StatusBadRequest, "") + }) + } +} + +func (e *env) disk(t *testing.T, name string) { + t.Helper() + + op, err := e.disks(t).Insert(t.Context(), &computepb.InsertDiskRequest{ + Project: project, Zone: zone, DiskResource: &computepb.Disk{Name: s(name), SizeGb: proto.Int64(10)}, + }) + e.done(t, op, err) +} + +func (e *env) changeDisk(t *testing.T, disk string, add bool, refs ...string) error { + t.Helper() + + var ( + op *compute.Operation + err error + ) + + if add { + op, err = e.disks(t).AddResourcePolicies(t.Context(), &computepb.AddResourcePoliciesDiskRequest{ + Project: project, Zone: zone, Disk: disk, + DisksAddResourcePoliciesRequestResource: &computepb.DisksAddResourcePoliciesRequest{ResourcePolicies: refs}, + }) + } else { + op, err = e.disks(t).RemoveResourcePolicies(t.Context(), &computepb.RemoveResourcePoliciesDiskRequest{ + Project: project, Zone: zone, Disk: disk, + DisksRemoveResourcePoliciesRequestResource: &computepb.DisksRemoveResourcePoliciesRequest{ + ResourcePolicies: refs, + }, + }) + } + + return e.finish(t, op, err) +} + +func (e *env) changeVM(t *testing.T, add bool, refs ...string) error { + t.Helper() + + var ( + op *compute.Operation + err error + ) + + if add { + op, err = e.instances(t).AddResourcePolicies(t.Context(), &computepb.AddResourcePoliciesInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + InstancesAddResourcePoliciesRequestResource: &computepb.InstancesAddResourcePoliciesRequest{ + ResourcePolicies: refs, + }, + }) + } else { + op, err = e.instances(t).RemoveResourcePolicies(t.Context(), &computepb.RemoveResourcePoliciesInstanceRequest{ + Project: project, Zone: zone, Instance: "vm1", + InstancesRemoveResourcePoliciesRequestResource: &computepb.InstancesRemoveResourcePoliciesRequest{ + ResourcePolicies: refs, + }, + }) + } + + return e.finish(t, op, err) +} + +func TestResourcePolicyAttachToDisk(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.disk(t, "d1") + require.NoError(t, e.resPolicy(t, named(daily("00:00"), "snap"))) + require.NoError(t, e.resPolicy(t, named(daily("04:00"), "snap2"))) + require.NoError(t, e.resPolicy(t, named(instanceSchedule("0 8 * * *"), "sched"))) + + getDisk := func() *computepb.Disk { + d, err := e.disks(t).Get(t.Context(), &computepb.GetDiskRequest{Project: project, Zone: zone, Disk: "d1"}) + require.NoError(t, err) + + return d + } + + assertAPIError(t, e.changeDisk(t, "d1", true, rpRef("sched")), http.StatusBadRequest, "invalid") + assertAPIError(t, e.changeDisk(t, "d1", true, rpRef("ghost")), http.StatusNotFound, "notFound") + assertAPIError(t, e.changeDisk(t, "ghost", true, rpRef("snap")), http.StatusNotFound, "notFound") + assertAPIError(t, e.changeDisk(t, "d1", false, rpRef("snap")), http.StatusBadRequest, "invalid") + + require.NoError(t, e.changeDisk(t, "d1", true, rpRef("snap"))) + assert.Equal(t, []string{"https://www.googleapis.com/compute/v1/" + rpRef("snap")}, getDisk().GetResourcePolicies()) + + assertAPIError(t, e.changeDisk(t, "d1", true, rpRef("snap")), http.StatusBadRequest, "invalid") + assertAPIError(t, e.changeDisk(t, "d1", true, rpRef("snap2")), http.StatusBadRequest, "invalid") + + _, err := e.resPolicies(t). + Delete(t.Context(), &computepb.DeleteResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "snap"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceInUseByAnotherResource") + + require.NoError(t, e.changeDisk(t, "d1", false, rpRef("snap"))) + assert.Empty(t, getDisk().GetResourcePolicies()) + + op, err := e.resPolicies(t). + Delete(t.Context(), &computepb.DeleteResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "snap"}) + require.NoError(t, e.finish(t, op, err)) + + t.Run("create time", func(t *testing.T) { + t.Parallel() + + cop, cerr := e.disks(t).Insert(t.Context(), &computepb.InsertDiskRequest{ + Project: project, Zone: zone, DiskResource: &computepb.Disk{ + Name: s("d2"), SizeGb: proto.Int64(10), ResourcePolicies: []string{rpRef("snap2")}, + }, + }) + require.NoError(t, e.finish(t, cop, cerr)) + + d, gerr := e.disks(t).Get(t.Context(), &computepb.GetDiskRequest{Project: project, Zone: zone, Disk: "d2"}) + require.NoError(t, gerr) + assert.Len(t, d.GetResourcePolicies(), 1) + + _, ierr := e.disks(t).Insert(t.Context(), &computepb.InsertDiskRequest{ + Project: project, Zone: zone, DiskResource: &computepb.Disk{ + Name: s("d3"), SizeGb: proto.Int64(10), ResourcePolicies: []string{rpRef("ghost")}, + }, + }) + assertAPIError(t, ierr, http.StatusNotFound, "notFound") + }) +} + +func TestResourcePolicyAttachToInstance(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.baseNet(t) + e.vm(t, vmSpec("vm1")) + require.NoError(t, e.resPolicy(t, named(daily("00:00"), "snap"))) + require.NoError(t, e.resPolicy(t, named(instanceSchedule("0 8 * * *"), "sched"))) + require.NoError(t, e.resPolicy(t, named(&computepb.ResourcePolicy{ + GroupPlacementPolicy: &computepb.ResourcePolicyGroupPlacementPolicy{Collocation: s("COLLOCATED")}, + }, "place"))) + + assertAPIError(t, e.changeVM(t, true, rpRef("snap")), http.StatusBadRequest, "invalid") + require.NoError(t, e.changeVM(t, true, rpRef("sched"), rpRef("place"))) + assert.Len(t, e.getVM(t, "vm1").GetResourcePolicies(), 2) + + _, err := e.resPolicies(t). + Delete(t.Context(), &computepb.DeleteResourcePolicyRequest{Project: project, Region: region, ResourcePolicy: "sched"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceInUseByAnotherResource") + assertAPIError(t, e.changeVM(t, true, rpRef("sched")), http.StatusBadRequest, "invalid") + + require.NoError(t, e.changeVM(t, false, rpRef("sched"))) + assert.Len(t, e.getVM(t, "vm1").GetResourcePolicies(), 1) + assertAPIError(t, e.changeVM(t, false, rpRef("sched")), http.StatusBadRequest, "invalid") + + spec := vmSpec("vm2") + spec.ResourcePolicies = []string{rpRef("sched")} + assert.Len(t, e.vm(t, spec).GetResourcePolicies(), 1) +} diff --git a/services/gcpcompute/secpolicy.go b/services/gcpcompute/secpolicy.go new file mode 100644 index 0000000000..795a71c73e --- /dev/null +++ b/services/gcpcompute/secpolicy.go @@ -0,0 +1,477 @@ +package gcpcompute + +import ( + "net/netip" + "regexp" + "slices" + "strconv" + "strings" + + computeapi "google.golang.org/api/compute/v1" +) + +const ( + collSecPolicy = "securityPolicies" + spTypeArmor = "CLOUD_ARMOR" + spTypeEdge = "CLOUD_ARMOR_EDGE" + spTypeNetwork = "CLOUD_ARMOR_NETWORK" + defaultRulePrio = 2147483647 + versionedSrcIPs = "SRC_IPS_V1" + maxSrcIPRanges = 10 + maxKeyConfigs = 3 + wildcard = "*" + defaultRuleDesc = "Default rule, higher priority overrides it" + actionAllow = "allow" + actionRateBan = "rate_based_ban" + actionThrottle = "throttle" + actionRedirect = "redirect" + ruleKind = "compute#securityPolicyRule" + redirectExternal = "EXTERNAL_302" + redirectRecaptch = "GOOGLE_RECAPTCHA" +) + +var ( + denyActionRE = regexp.MustCompile(`^deny\((403|404|502)\)$`) + exceedDenyRE = regexp.MustCompile(`^deny\((403|404|429|502)\)$`) +) + +func validSecPolicyType(v string) bool { + return v == spTypeArmor || v == spTypeEdge || v == spTypeNetwork +} + +func validJSONParsing(v string) bool { + return v == "DISABLED" || v == "STANDARD" || v == "STANDARD_WITH_GRAPHQL" +} + +func validLogLevel(v string) bool { return v == "NORMAL" || v == "VERBOSE" } + +func validEnforceKeyName(v string) bool { + switch v { + case keyAll, "ASN", keyHTTPCookie, keyHTTPHeader, "HTTP_PATH", "IP", "REGION_CODE", "SNI", + "TLS_JA3_FINGERPRINT", "TLS_JA4_FINGERPRINT", "USER_IP", "XFF_IP": + return true + } + + return false +} + +func secPolicySpec() lbSpec[computeapi.SecurityPolicy] { + return lbSpec[computeapi.SecurityPolicy]{ + coll: collSecPolicy, kind: "compute#securityPolicy", noun: "security_policy", + m: func(b *InMemoryBackend) map[string]*computeapi.SecurityPolicy { return b.secPolicies }, + meta: func(v *computeapi.SecurityPolicy) lbMeta { + return lbMeta{ + name: &v.Name, kind: &v.Kind, created: &v.CreationTimestamp, self: &v.SelfLink, + fp: &v.Fingerprint, id: &v.Id, + } + }, + prep: prepSecurityPolicy, + view: func(v *computeapi.SecurityPolicy) { + for _, r := range v.Rules { + r.ForceSendFields = []string{fieldPriority, "Preview"} + } + }, + } +} + +func defaultRule(policyType string) *computeapi.SecurityPolicyRule { + r := &computeapi.SecurityPolicyRule{ + Kind: ruleKind, Action: actionAllow, Priority: defaultRulePrio, Description: defaultRuleDesc, + } + if policyType == spTypeNetwork { + r.NetworkMatch = &computeapi.SecurityPolicyRuleNetworkMatcher{} + } else { + r.Match = &computeapi.SecurityPolicyRuleMatcher{ + VersionedExpr: versionedSrcIPs, + Config: &computeapi.SecurityPolicyRuleMatcherConfig{SrcIpRanges: []string{wildcard}}, + } + } + + return r +} + +func prepSecurityPolicy(_ *InMemoryBackend, _ lbCtx, in *computeapi.SecurityPolicy, _ map[string]any) *APIError { + if in.Type == "" { + in.Type = spTypeArmor + } + + if !validSecPolicyType(in.Type) { + return errField("resource.type", in.Type, "Must be CLOUD_ARMOR, CLOUD_ARMOR_EDGE or CLOUD_ARMOR_NETWORK.") + } + + if err := validLabels(in.Labels); err != nil { + return err + } + + if in.LabelFingerprint == "" { + in.LabelFingerprint = labelFP(in.Labels) + } + + if err := validSecurityPolicyOptions(in); err != nil { + return err + } + + if len(in.Rules) == 0 { + in.Rules = []*computeapi.SecurityPolicyRule{defaultRule(in.Type)} + } + + seen := map[int64]bool{} + + for i, r := range in.Rules { + if err := validateRule(in.Type, r, "resource.rules["+strconv.Itoa(i)+"]"); err != nil { + return err + } + + if seen[r.Priority] { + return errInvalid("Invalid value for field 'resource.rules[%d].priority': '%d'. "+ + "Multiple rules have the same priority.", i, r.Priority) + } + + seen[r.Priority] = true + } + + if !seen[defaultRulePrio] { + return errInvalid("Invalid value for field 'resource.rules': the policy must contain a default rule " + + "with priority 2147483647 that matches everything.") + } + + slices.SortFunc(in.Rules, func(a, b *computeapi.SecurityPolicyRule) int { + switch { + case a.Priority < b.Priority: + return -1 + case a.Priority > b.Priority: + return 1 + } + + return 0 + }) + + return nil +} + +func validSecurityPolicyOptions(in *computeapi.SecurityPolicy) *APIError { + if o := in.AdvancedOptionsConfig; o != nil { + if o.JsonParsing != "" && !validJSONParsing(o.JsonParsing) { + return errField("resource.advancedOptionsConfig.jsonParsing", o.JsonParsing, "") + } + + if o.LogLevel != "" && !validLogLevel(o.LogLevel) { + return errField("resource.advancedOptionsConfig.logLevel", o.LogLevel, "") + } + + switch strings.ToUpper(o.RequestBodyInspectionSize) { + case "", "8KB", "16KB", "32KB", "48KB", "64KB": + default: + return errField("resource.advancedOptionsConfig.requestBodyInspectionSize", + o.RequestBodyInspectionSize, "Must be 8KB, 16KB, 32KB, 48KB or 64KB.") + } + } + + return nil +} + +func validateRule(policyType string, r *computeapi.SecurityPolicyRule, field string) *APIError { + if r == nil { + return errRequired(field) + } + + r.Kind = ruleKind + + if r.Priority < 0 || r.Priority > defaultRulePrio { + return errField(field+".priority", strconv.FormatInt(r.Priority, 10), + "Priority must be between 0 and 2147483647.") + } + + if err := validateRuleAction(r, field); err != nil { + return err + } + + if err := validateRuleMatch(policyType, r, field); err != nil { + return err + } + + if r.Priority == defaultRulePrio { + return validateDefaultRule(policyType, r, field) + } + + return nil +} + +func validateRuleAction(r *computeapi.SecurityPolicyRule, field string) *APIError { + switch { + case r.Action == "": + return errRequired(field + ".action") + case r.Action == actionAllow, denyActionRE.MatchString(r.Action): + case r.Action == actionRateBan, r.Action == actionThrottle, r.Action == actionRedirect: + default: + return errField(field+".action", r.Action, + "Must be allow, deny(403), deny(404), deny(502), rate_based_ban, throttle or redirect.") + } + + limited := r.Action == actionRateBan || r.Action == actionThrottle + if limited != (r.RateLimitOptions != nil) { + if limited { + return errRequired(field + ".rateLimitOptions") + } + + return errField(field+".rateLimitOptions", "", "Only valid with the rate_based_ban and throttle actions.") + } + + if (r.Action == actionRedirect) != (r.RedirectOptions != nil) { + if r.Action == actionRedirect { + return errRequired(field + ".redirectOptions") + } + + return errField(field+".redirectOptions", "", "Only valid with the redirect action.") + } + + if r.RedirectOptions != nil { + if err := validRedirect(r.RedirectOptions, field+".redirectOptions"); err != nil { + return err + } + } + + if r.RateLimitOptions != nil { + return validRateLimit(r.Action, r.RateLimitOptions, field+".rateLimitOptions") + } + + return nil +} + +func validRedirect(o *computeapi.SecurityPolicyRuleRedirectOptions, field string) *APIError { + switch o.Type { + case redirectExternal: + if o.Target == "" { + return errRequired(field + ".target") + } + case redirectRecaptch: + if o.Target != "" { + return errField(field+".target", o.Target, "Cannot be specified for GOOGLE_RECAPTCHA.") + } + default: + return errField(field+".type", o.Type, "Must be EXTERNAL_302 or GOOGLE_RECAPTCHA.") + } + + return nil +} + +func validRateLimit(action string, o *computeapi.SecurityPolicyRuleRateLimitOptions, field string) *APIError { + if o.RateLimitThreshold == nil || o.RateLimitThreshold.Count < 1 || o.RateLimitThreshold.IntervalSec < 1 { + return errField(field+".rateLimitThreshold", "", "count and intervalSec must be positive.") + } + + if o.ConformAction == "" { + o.ConformAction = actionAllow + } + + if o.ConformAction != actionAllow { + return errField(field+".conformAction", o.ConformAction, "The only valid value is allow.") + } + + if o.ExceedAction == "" { + return errRequired(field + ".exceedAction") + } + + if o.ExceedAction != actionRedirect && !exceedDenyRE.MatchString(o.ExceedAction) { + return errField( + field+".exceedAction", + o.ExceedAction, + "Must be deny(403), deny(404), deny(429), deny(502) or redirect.", + ) + } + + if (o.ExceedAction == actionRedirect) != (o.ExceedRedirectOptions != nil) { + return errField( + field+".exceedRedirectOptions", + "", + "Required with, and only valid for, the redirect exceed action.", + ) + } + + if o.ExceedRedirectOptions != nil { + if err := validRedirect(o.ExceedRedirectOptions, field+".exceedRedirectOptions"); err != nil { + return err + } + } + + if action != actionRateBan && (o.BanDurationSec != 0 || o.BanThreshold != nil) { + return errField( + field+".banDurationSec", + "", + "banDurationSec and banThreshold are only valid with rate_based_ban.", + ) + } + + return validEnforceKey(o, field) +} + +func validEnforceKey(o *computeapi.SecurityPolicyRuleRateLimitOptions, field string) *APIError { + if len(o.EnforceOnKeyConfigs) > 0 && o.EnforceOnKey != "" { + return errField(field+".enforceOnKey", o.EnforceOnKey, "Cannot be combined with enforceOnKeyConfigs.") + } + + if len(o.EnforceOnKeyConfigs) > maxKeyConfigs { + return errField(field+".enforceOnKeyConfigs", "", "At most 3 configs are allowed.") + } + + if o.EnforceOnKey != "" && !validEnforceKeyName(o.EnforceOnKey) { + return errField(field+".enforceOnKey", o.EnforceOnKey, "") + } + + named := o.EnforceOnKey == keyHTTPHeader || o.EnforceOnKey == keyHTTPCookie + if o.EnforceOnKeyName != "" && !named { + return errField(field+".enforceOnKeyName", o.EnforceOnKeyName, "Only valid for HTTP_HEADER and HTTP_COOKIE.") + } + + return nil +} + +func validateRuleMatch(policyType string, r *computeapi.SecurityPolicyRule, field string) *APIError { + if policyType == spTypeNetwork { + if r.Match != nil { + return errField(field+".match", "", "Network security policies use networkMatch.") + } + + if r.NetworkMatch == nil { + r.NetworkMatch = &computeapi.SecurityPolicyRuleNetworkMatcher{} + } + + return validSrcRanges(r.NetworkMatch.SrcIpRanges, field+".networkMatch.srcIpRanges") + } + + if r.NetworkMatch != nil { + return errField(field+".networkMatch", "", "Only valid in CLOUD_ARMOR_NETWORK policies.") + } + + m := r.Match + if m == nil { + return errRequired(field + ".match") + } + + hasExpr := m.Expr != nil + if hasExpr == (m.VersionedExpr != "") { + return errInvalid( + "Invalid value for field '%s.match': exactly one of versionedExpr and expr must be set.", + field, + ) + } + + if hasExpr { + return validExpr(m.Expr, field+".match.expr") + } + + if m.VersionedExpr != versionedSrcIPs { + return errField(field+".match.versionedExpr", m.VersionedExpr, "Must be SRC_IPS_V1.") + } + + if m.Config == nil { + return errRequired(field + ".match.config") + } + + return validSrcRanges(m.Config.SrcIpRanges, field+".match.config.srcIpRanges") +} + +func validSrcRanges(ranges []string, field string) *APIError { + if len(ranges) > maxSrcIPRanges { + return errField(field, "", "At most 10 source IP ranges are allowed.") + } + + for _, s := range ranges { + if s == wildcard { + continue + } + + if _, err := netip.ParsePrefix(s); err == nil { + continue + } + + if _, err := netip.ParseAddr(s); err != nil { + return errField(field, s, "Not a valid IP address range.") + } + } + + return nil +} + +func validExpr(e *computeapi.Expr, field string) *APIError { + expr := strings.TrimSpace(e.Expression) + if expr == "" { + return errRequired(field + ".expression") + } + + depth, quote := 0, rune(0) + + for _, c := range expr { + switch { + case quote != 0: + if c == quote { + quote = 0 + } + case c == '"' || c == '\'': + quote = c + case c == '(': + depth++ + case c == ')': + depth-- + } + + if depth < 0 { + break + } + } + + if depth != 0 || quote != 0 { + return errField(field+".expression", e.Expression, "Syntax error in the expression.") + } + + return nil +} + +func validateDefaultRule(policyType string, r *computeapi.SecurityPolicyRule, field string) *APIError { + if policyType == spTypeNetwork { + return nil + } + + m := r.Match + if m.VersionedExpr != versionedSrcIPs || m.Config == nil || + !slices.Equal(m.Config.SrcIpRanges, []string{wildcard}) { + return errField( + field+".match", + "", + "The default rule (priority 2147483647) must match all source IP ranges (*).", + ) + } + + return nil +} + +// resolveBSSecurityPolicyLocked validates a backend service's Cloud Armor attachment and stores its selfLink. +func (b *InMemoryBackend) resolveBSSecurityPolicyLocked(c lbCtx, bs *computeapi.BackendService) *APIError { + if bs.SecurityPolicy == "" { + return nil + } + + if c.scope != scopeGlobal { + return errField("resource.securityPolicy", bs.SecurityPolicy, + "Regional backend services use regional security policies, which are not supported.") + } + + if bs.LoadBalancingScheme != schemeExternal && bs.LoadBalancingScheme != schemeExternalManaged { + return errField("resource.securityPolicy", bs.SecurityPolicy, + "Security policies attach only to EXTERNAL and EXTERNAL_MANAGED backend services.") + } + + self, err := lbResolve(b, secPolicySpec(), c, "resource.securityPolicy", bs.SecurityPolicy) + if err != nil { + return err + } + + if p := lbFind(b, secPolicySpec(), self); p != nil && p.Type != spTypeArmor { + return errField("resource.securityPolicy", bs.SecurityPolicy, + "Only CLOUD_ARMOR security policies attach to backend services.") + } + + bs.SecurityPolicy = self + + return nil +} diff --git a/services/gcpcompute/secpolicy_test.go b/services/gcpcompute/secpolicy_test.go new file mode 100644 index 0000000000..dfe39f7e34 --- /dev/null +++ b/services/gcpcompute/secpolicy_test.go @@ -0,0 +1,429 @@ +package gcpcompute_test + +import ( + "net/http" + "strings" + "testing" + + compute "cloud.google.com/go/compute/apiv1" + "cloud.google.com/go/compute/apiv1/computepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +const defaultPrio = int32(2147483647) + +func (e *env) armor(t *testing.T) *compute.SecurityPoliciesClient { + t.Helper() + + return client(t, e, compute.NewSecurityPoliciesRESTClient) +} + +func (e *env) policy(t *testing.T, p *computepb.SecurityPolicy) error { + t.Helper() + + op, err := e.armor(t). + Insert(t.Context(), &computepb.InsertSecurityPolicyRequest{Project: project, SecurityPolicyResource: p}) + + return e.finish(t, op, err) +} + +func (e *env) getPolicy(t *testing.T) *computepb.SecurityPolicy { + t.Helper() + + p, err := e.armor(t).Get(t.Context(), &computepb.GetSecurityPolicyRequest{Project: project, SecurityPolicy: "pol"}) + require.NoError(t, err) + + return p +} + +func srcRule(prio int32, action string, ranges ...string) *computepb.SecurityPolicyRule { + return &computepb.SecurityPolicyRule{ + Priority: new(prio), Action: s(action), Match: &computepb.SecurityPolicyRuleMatcher{ + VersionedExpr: s("SRC_IPS_V1"), Config: &computepb.SecurityPolicyRuleMatcherConfig{SrcIpRanges: ranges}, + }, + } +} + +func (e *env) ruleOp(t *testing.T, prio int32, verb string, rule *computepb.SecurityPolicyRule) error { + t.Helper() + + c := e.armor(t) + + var ( + op *compute.Operation + err error + ) + + switch verb { + case "add": + op, err = c.AddRule(t.Context(), &computepb.AddRuleSecurityPolicyRequest{ + Project: project, SecurityPolicy: "pol", SecurityPolicyRuleResource: rule, + }) + case "patch": + op, err = c.PatchRule(t.Context(), &computepb.PatchRuleSecurityPolicyRequest{ + Project: project, SecurityPolicy: "pol", Priority: new(prio), SecurityPolicyRuleResource: rule, + }) + default: + op, err = c.RemoveRule(t.Context(), &computepb.RemoveRuleSecurityPolicyRequest{ + Project: project, SecurityPolicy: "pol", Priority: new(prio), + }) + } + + return e.finish(t, op, err) +} + +func TestSecurityPolicyDefaults(t *testing.T) { + t.Parallel() + + e := newEnv(t) + op, err := e.armor(t).Insert(t.Context(), &computepb.InsertSecurityPolicyRequest{ + Project: project, SecurityPolicyResource: &computepb.SecurityPolicy{Name: s("pol"), Description: s("d")}, + }) + require.NoError(t, err) + assert.Equal(t, "compute.securityPolicies.insert", op.Proto().GetOperationType()) + e.done(t, op, nil) + + p := e.getPolicy(t) + assert.Equal(t, "CLOUD_ARMOR", p.GetType()) + assert.Equal(t, "compute#securityPolicy", p.GetKind()) + assert.Contains(t, p.GetSelfLink(), "/global/securityPolicies/pol") + assert.NotEmpty(t, p.GetFingerprint()) + require.Len(t, p.GetRules(), 1) + + r := p.GetRules()[0] + assert.Equal(t, defaultPrio, r.GetPriority()) + assert.Equal(t, "allow", r.GetAction()) + assert.Equal(t, "SRC_IPS_V1", r.GetMatch().GetVersionedExpr()) + assert.Equal(t, []string{"*"}, r.GetMatch().GetConfig().GetSrcIpRanges()) + assert.Equal(t, "Default rule, higher priority overrides it", r.GetDescription()) + assert.Equal(t, "compute#securityPolicyRule", r.GetKind()) + assert.False(t, r.GetPreview()) + + assertAPIError(t, e.policy(t, &computepb.SecurityPolicy{Name: s("pol")}), http.StatusConflict, "alreadyExists") + + op, err = e.armor(t). + Delete(t.Context(), &computepb.DeleteSecurityPolicyRequest{Project: project, SecurityPolicy: "pol"}) + e.done(t, op, err) + + _, err = e.armor(t).Get(t.Context(), &computepb.GetSecurityPolicyRequest{Project: project, SecurityPolicy: "pol"}) + assertAPIError(t, err, http.StatusNotFound, "notFound") +} + +func TestSecurityPolicyInsertValidation(t *testing.T) { + t.Parallel() + + deflt := srcRule(defaultPrio, "allow", "*") + tooMany := make([]string, 11) + + for i := range tooMany { + tooMany[i] = "10.0.0.1" + } + + tests := []struct { + pol *computepb.SecurityPolicy + name string + valid bool + }{ + {name: "valid rules", valid: true, pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + srcRule(100, "deny(403)", "10.0.0.0/8", "192.0.2.1", "2001:db8::/32"), deflt, + }}}, + {name: "cel expr", valid: true, pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + {Priority: new(int32(5)), Action: s("deny(404)"), Match: &computepb.SecurityPolicyRuleMatcher{ + Expr: &computepb.Expr{Expression: s("origin.region_code == 'US' && !(request.path.matches('/x'))")}, + }}, deflt, + }}}, + {name: "rate limit", valid: true, pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + { + Priority: new(int32(7)), Action: s("throttle"), + Match: srcRule(7, "x", "*").GetMatch(), + RateLimitOptions: &computepb.SecurityPolicyRuleRateLimitOptions{ + RateLimitThreshold: &computepb.SecurityPolicyRuleRateLimitOptionsThreshold{ + Count: proto.Int32(10), IntervalSec: proto.Int32(60), + }, + ExceedAction: s("deny(429)"), EnforceOnKey: s("IP"), + }, + }, deflt, + }}}, + { + name: "missing default", + pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{srcRule(1, "allow", "*")}}, + }, + {name: "duplicate priority", pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + srcRule(1, "allow", "*"), srcRule(1, "allow", "*"), deflt, + }}}, + { + name: "bad action", + pol: &computepb.SecurityPolicy{ + Rules: []*computepb.SecurityPolicyRule{srcRule(1, "deny(500)", "*"), deflt}, + }, + }, + { + name: "bad range", + pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{srcRule(1, "allow", "nope"), deflt}}, + }, + { + name: "too many ranges", + pol: &computepb.SecurityPolicy{ + Rules: []*computepb.SecurityPolicyRule{srcRule(1, "allow", tooMany...), deflt}, + }, + }, + {name: "unknown versioned expr", pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + {Priority: new(int32(1)), Action: s("allow"), Match: &computepb.SecurityPolicyRuleMatcher{ + VersionedExpr: s( + "SRC_IPS_V2", + ), Config: &computepb.SecurityPolicyRuleMatcherConfig{SrcIpRanges: []string{"*"}}, + }}, deflt, + }}}, + {name: "expr and versioned", pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + {Priority: new(int32(1)), Action: s("allow"), Match: &computepb.SecurityPolicyRuleMatcher{ + VersionedExpr: s("SRC_IPS_V1"), Expr: &computepb.Expr{Expression: s("true")}, + Config: &computepb.SecurityPolicyRuleMatcherConfig{SrcIpRanges: []string{"*"}}, + }}, deflt, + }}}, + {name: "unbalanced expr", pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + {Priority: new(int32(1)), Action: s("allow"), Match: &computepb.SecurityPolicyRuleMatcher{ + Expr: &computepb.Expr{Expression: s("origin.ip == '1.2.3.4' && (a")}, + }}, deflt, + }}}, + {name: "missing match", pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + {Priority: new(int32(1)), Action: s("allow")}, deflt, + }}}, + {name: "throttle without options", pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + srcRule(1, "throttle", "*"), deflt, + }}}, + {name: "options without throttle", pol: &computepb.SecurityPolicy{Rules: []*computepb.SecurityPolicyRule{ + { + Priority: new(int32(1)), Action: s("allow"), Match: srcRule(1, "x", "*").GetMatch(), + RateLimitOptions: &computepb.SecurityPolicyRuleRateLimitOptions{}, + }, deflt, + }}}, + { + name: "default not match all", + pol: &computepb.SecurityPolicy{ + Rules: []*computepb.SecurityPolicyRule{srcRule(defaultPrio, "allow", "10.0.0.0/8")}, + }, + }, + {name: "bad type", pol: &computepb.SecurityPolicy{Type: s("CLOUD_ARMOR_INTERNAL_SERVICE")}}, + { + name: "bad json parsing", + pol: &computepb.SecurityPolicy{ + AdvancedOptionsConfig: &computepb.SecurityPolicyAdvancedOptionsConfig{JsonParsing: s("BOGUS")}, + }, + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + tt.pol.Name = s("p" + strings.Repeat("x", i)) + err := e.policy(t, tt.pol) + + if tt.valid { + require.NoError(t, err) + + return + } + + assertAPIError(t, err, http.StatusBadRequest, "") + }) + } +} + +func TestSecurityPolicyRules(t *testing.T) { + t.Parallel() + + e := newEnv(t) + require.NoError(t, e.policy(t, &computepb.SecurityPolicy{Name: s("pol")})) + + fp := e.getPolicy(t).GetFingerprint() + + require.NoError(t, e.ruleOp(t, 0, "add", srcRule(1000, "deny(403)", "198.51.100.0/24"))) + require.NoError(t, e.ruleOp(t, 0, "add", srcRule(0, "allow", "203.0.113.7"))) + + assertAPIError(t, e.ruleOp(t, 0, "add", srcRule(1000, "allow", "*")), http.StatusBadRequest, "invalid") + assertAPIError(t, e.ruleOp(t, 0, "add", srcRule(defaultPrio, "deny(403)", "*")), http.StatusBadRequest, "invalid") + assertAPIError(t, e.ruleOp(t, 0, "add", srcRule(-5, "allow", "*")), http.StatusBadRequest, "invalid") + + p := e.getPolicy(t) + assert.NotEqual(t, fp, p.GetFingerprint()) + require.Len(t, p.GetRules(), 3) + assert.Equal( + t, + []int32{0, 1000, defaultPrio}, + []int32{p.GetRules()[0].GetPriority(), p.GetRules()[1].GetPriority(), p.GetRules()[2].GetPriority()}, + ) + + got, err := e.armor(t).GetRule(t.Context(), &computepb.GetRuleSecurityPolicyRequest{ + Project: project, SecurityPolicy: "pol", Priority: new(int32(1000)), + }) + require.NoError(t, err) + assert.Equal(t, "deny(403)", got.GetAction()) + assert.Equal(t, []string{"198.51.100.0/24"}, got.GetMatch().GetConfig().GetSrcIpRanges()) + + _, err = e.armor(t).GetRule(t.Context(), &computepb.GetRuleSecurityPolicyRequest{ + Project: project, SecurityPolicy: "pol", Priority: new(int32(77)), + }) + assertAPIError(t, err, http.StatusNotFound, "notFound") + + patch := &computepb.SecurityPolicyRule{Action: s("deny(404)"), Description: s("patched"), Preview: new(true)} + require.NoError(t, e.ruleOp(t, 1000, "patch", patch)) + + got, err = e.armor(t).GetRule(t.Context(), &computepb.GetRuleSecurityPolicyRequest{ + Project: project, SecurityPolicy: "pol", Priority: new(int32(1000)), + }) + require.NoError(t, err) + assert.Equal(t, "deny(404)", got.GetAction()) + assert.Equal(t, "patched", got.GetDescription()) + assert.True(t, got.GetPreview()) + assert.Equal( + t, + []string{"198.51.100.0/24"}, + got.GetMatch().GetConfig().GetSrcIpRanges(), + "unspecified fields are kept", + ) + + assertAPIError( + t, + e.ruleOp(t, 1000, "patch", &computepb.SecurityPolicyRule{Action: s("bogus")}), + http.StatusBadRequest, + "invalid", + ) + assertAPIError(t, e.ruleOp(t, 55, "patch", patch), http.StatusNotFound, "notFound") + assertAPIError(t, e.ruleOp(t, defaultPrio, "remove", nil), http.StatusBadRequest, "invalid") + assertAPIError(t, e.ruleOp(t, 55, "remove", nil), http.StatusNotFound, "notFound") + require.NoError(t, e.ruleOp(t, 1000, "remove", nil)) + assert.Len(t, e.getPolicy(t).GetRules(), 2) +} + +func TestSecurityPolicyPatchAndLabels(t *testing.T) { + t.Parallel() + + e := newEnv(t) + require.NoError(t, e.policy(t, &computepb.SecurityPolicy{Name: s("pol"), Type: s("CLOUD_ARMOR_EDGE")})) + + p := e.getPolicy(t) + assert.Equal(t, "CLOUD_ARMOR_EDGE", p.GetType()) + + patch := func(body *computepb.SecurityPolicy) error { + op, err := e.armor(t).Patch(t.Context(), &computepb.PatchSecurityPolicyRequest{ + Project: project, SecurityPolicy: "pol", SecurityPolicyResource: body, + }) + + return e.finish(t, op, err) + } + + require.NoError(t, patch(&computepb.SecurityPolicy{ + Description: s("new"), + AdvancedOptionsConfig: &computepb.SecurityPolicyAdvancedOptionsConfig{ + JsonParsing: s("STANDARD"), + LogLevel: s("VERBOSE"), + }, + Type: s("CLOUD_ARMOR"), + Rules: []*computepb.SecurityPolicyRule{srcRule(1, "deny(403)", "*")}, + })) + + after := e.getPolicy(t) + assert.Equal(t, "new", after.GetDescription()) + assert.Equal(t, "VERBOSE", after.GetAdvancedOptionsConfig().GetLogLevel()) + assert.Equal(t, "CLOUD_ARMOR_EDGE", after.GetType(), "type is fixed at creation") + assert.Len(t, after.GetRules(), 1, "patch does not touch rules") + assert.NotEqual(t, p.GetFingerprint(), after.GetFingerprint()) + + assertAPIError( + t, + patch(&computepb.SecurityPolicy{Fingerprint: s("stale")}), + http.StatusPreconditionFailed, + "conditionNotMet", + ) + assertAPIError( + t, + patch( + &computepb.SecurityPolicy{ + AdvancedOptionsConfig: &computepb.SecurityPolicyAdvancedOptionsConfig{LogLevel: s("LOUD")}, + }, + ), + http.StatusBadRequest, + "invalid", + ) + + setLabels := func(fp string, labels map[string]string) error { + op, err := e.armor(t).SetLabels(t.Context(), &computepb.SetLabelsSecurityPolicyRequest{ + Project: project, Resource: "pol", + GlobalSetLabelsRequestResource: &computepb.GlobalSetLabelsRequest{Labels: labels, LabelFingerprint: s(fp)}, + }) + + return e.finish(t, op, err) + } + + require.NoError(t, setLabels(after.GetLabelFingerprint(), map[string]string{"team": "net"})) + assert.Equal(t, map[string]string{"team": "net"}, e.getPolicy(t).GetLabels()) + assertAPIError( + t, + setLabels(after.GetLabelFingerprint(), map[string]string{"x": "y"}), + http.StatusPreconditionFailed, + "conditionNotMet", + ) +} + +func TestSecurityPolicyBackendServiceAttachment(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.lbStack(t) + require.NoError(t, e.policy(t, &computepb.SecurityPolicy{Name: s("pol")})) + require.NoError(t, e.policy(t, &computepb.SecurityPolicy{Name: s("edge"), Type: s("CLOUD_ARMOR_EDGE")})) + + bs := e.backendServices(t) + setPolicy := func(ref string) error { + op, err := bs.SetSecurityPolicy(t.Context(), &computepb.SetSecurityPolicyBackendServiceRequest{ + Project: project, BackendService: "bs", + SecurityPolicyReferenceResource: &computepb.SecurityPolicyReference{SecurityPolicy: s(ref)}, + }) + + return e.finish(t, op, err) + } + + assertAPIError(t, setPolicy("global/securityPolicies/ghost"), http.StatusNotFound, "notFound") + assertAPIError(t, setPolicy("global/securityPolicies/edge"), http.StatusBadRequest, "invalid") + require.NoError(t, setPolicy("global/securityPolicies/pol")) + + got, err := bs.Get(t.Context(), &computepb.GetBackendServiceRequest{Project: project, BackendService: "bs"}) + require.NoError(t, err) + assert.Contains(t, got.GetSecurityPolicy(), "/global/securityPolicies/pol") + + _, err = e.armor(t). + Delete(t.Context(), &computepb.DeleteSecurityPolicyRequest{Project: project, SecurityPolicy: "pol"}) + assertAPIError(t, err, http.StatusBadRequest, "resourceInUseByAnotherResource") + + require.NoError(t, setPolicy("")) + + got, err = bs.Get(t.Context(), &computepb.GetBackendServiceRequest{Project: project, BackendService: "bs"}) + require.NoError(t, err) + assert.Empty(t, got.GetSecurityPolicy()) + + op, err := e.armor(t). + Delete(t.Context(), &computepb.DeleteSecurityPolicyRequest{Project: project, SecurityPolicy: "pol"}) + e.done(t, op, err) + + t.Run("insert time attachment", func(t *testing.T) { + t.Parallel() + + ee := newEnv(t) + ee.lbHC(t) + require.NoError(t, ee.policy(t, &computepb.SecurityPolicy{Name: s("pol")})) + require.NoError( + t, + ee.lbBS(t, &computepb.BackendService{Name: s("with"), SecurityPolicy: s("global/securityPolicies/pol")}), + ) + assertAPIError( + t, + ee.lbBS(t, &computepb.BackendService{Name: s("bad"), SecurityPolicy: s("global/securityPolicies/ghost")}), + http.StatusNotFound, + "notFound", + ) + }) +} diff --git a/services/gcpcompute/store.go b/services/gcpcompute/store.go index 8c3955a58f..21070115dc 100644 --- a/services/gcpcompute/store.go +++ b/services/gcpcompute/store.go @@ -108,6 +108,8 @@ type InMemoryBackend struct { fwdRules map[string]*computeapi.ForwardingRule negs map[string]*computeapi.NetworkEndpointGroup negEndpoints map[string][]*computeapi.NetworkEndpoint + secPolicies map[string]*computeapi.SecurityPolicy + resPolicies map[string]*computeapi.ResourcePolicy ops map[string]*opRec projects map[string]*computeapi.Metadata timing Timing @@ -177,6 +179,8 @@ func (b *InMemoryBackend) resetMaps() { b.fwdRules = map[string]*computeapi.ForwardingRule{} b.negs = map[string]*computeapi.NetworkEndpointGroup{} b.negEndpoints = map[string][]*computeapi.NetworkEndpoint{} + b.secPolicies = map[string]*computeapi.SecurityPolicy{} + b.resPolicies = map[string]*computeapi.ResourcePolicy{} b.ops = map[string]*opRec{} b.projects = map[string]*computeapi.Metadata{} } diff --git a/services/gcpcompute/subnet.go b/services/gcpcompute/subnet.go index 0577979a4d..79b6213434 100644 --- a/services/gcpcompute/subnet.go +++ b/services/gcpcompute/subnet.go @@ -51,6 +51,14 @@ func (b *InMemoryBackend) networkRangesLocked(network, skip string) []netip.Pref } } + if n := b.networkBySelfLinkLocked(network); n != nil { + for _, peer := range b.activePeersLocked(n) { + for p := range b.networkRangesWithOwnerLocked(peer) { + out = append(out, p) + } + } + } + return out } @@ -159,6 +167,7 @@ func (b *InMemoryBackend) insertSubnet( } b.subnets[key(project, region, in.Name)] = s + b.syncSubnetRoutesLocked(project, n) return b.newOpLocked("insert", subnetTarget(project, region, s), b.timing.Done), nil } @@ -242,6 +251,7 @@ func (b *InMemoryBackend) deleteSubnet(project, region, name string) (*computeap } delete(b.subnets, key(project, region, name)) + b.syncNetworkRoutesBySelfLinkLocked(project, s.Network) return b.newOpLocked("delete", subnetTarget(project, region, s), b.timing.Done), nil } @@ -274,6 +284,7 @@ func (b *InMemoryBackend) expandSubnet(project, region, name, cidr string) (*com } s.IpCidrRange, s.GatewayAddress, s.Fingerprint = next.String(), gateway(next).String(), newFingerprint() + b.syncNetworkRoutesBySelfLinkLocked(project, s.Network) return b.newOpLocked("expandIpCidrRange", subnetTarget(project, region, s), b.timing.Done), nil } @@ -296,10 +307,10 @@ func (b *InMemoryBackend) setSubnetPrivateGoogleAccess( } var subnetPatchable = map[string]bool{ //nolint:gochecknoglobals // read-only lookup table - "name": true, collNetwork: true, "ipCidrRange": true, "secondaryIpRanges": true, "privateIpGoogleAccess": true, - "enableFlowLogs": true, "logConfig": true, "description": true, "fingerprint": true, "region": true, - "kind": true, "id": true, "selfLink": true, "creationTimestamp": true, "gatewayAddress": true, "purpose": true, - "stackType": true, "privateIpv6GoogleAccess": true, + jsonName: true, collNetwork: true, "ipCidrRange": true, "secondaryIpRanges": true, "privateIpGoogleAccess": true, + "enableFlowLogs": true, "logConfig": true, jsonDesc: true, "fingerprint": true, "region": true, + jsonKind: true, "id": true, jsonSelfLink: true, jsonCreated: true, "gatewayAddress": true, "purpose": true, + jsonStackType: true, "privateIpv6GoogleAccess": true, } func (b *InMemoryBackend) patchSubnet( @@ -352,6 +363,7 @@ func (b *InMemoryBackend) patchSubnet( } s.Fingerprint = newFingerprint() + b.syncNetworkRoutesBySelfLinkLocked(project, s.Network) return b.newOpLocked("patch", subnetTarget(project, region, s), b.timing.Done), nil } diff --git a/services/gcpcompute/subnetroute.go b/services/gcpcompute/subnetroute.go new file mode 100644 index 0000000000..f326f5e8a7 --- /dev/null +++ b/services/gcpcompute/subnetroute.go @@ -0,0 +1,84 @@ +package gcpcompute + +import ( + "crypto/sha256" + "encoding/hex" + + computeapi "google.golang.org/api/compute/v1" +) + +const ( + routeTypeSubnet = "SUBNET" + routeActive = "ACTIVE" +) + +func isSubnetRoute(r *computeapi.Route) bool { return r.RouteType == routeTypeSubnet } + +// subnetRouteName derives a stable default-route-<16 hex> name per subnetwork range. +func subnetRouteName(subnetSelf, rangeName string) string { + sum := sha256.Sum256([]byte(subnetSelf + "|" + rangeName)) + + return sysRoutePrefix + hex.EncodeToString(sum[:8]) +} + +func subnetRouteDesc(cidr string) string { + return "Default local route to the subnetwork " + cidr + "." +} + +// syncSubnetRoutesLocked makes the network's subnet routes match its subnetworks: one per primary and secondary range. +func (b *InMemoryBackend) syncSubnetRoutesLocked(project string, n *computeapi.Network) { + want := map[string]string{} + + for _, s := range b.subnets { + if s.Network != n.SelfLink { + continue + } + + want[subnetRouteName(s.SelfLink, "")] = s.IpCidrRange + + for _, sec := range s.SecondaryIpRanges { + want[subnetRouteName(s.SelfLink, sec.RangeName)] = sec.IpCidrRange + } + } + + for k, r := range b.routes { + if r.Network != n.SelfLink || !isSubnetRoute(r) { + continue + } + + if cidr, ok := want[r.Name]; !ok { + delete(b.routes, k) + } else if r.DestRange != cidr { + r.DestRange, r.Description = cidr, subnetRouteDesc(cidr) + } + } + + for name, cidr := range want { + if _, ok := b.routes[key(project, name)]; ok { + continue + } + + b.routes[key(project, name)] = &computeapi.Route{ + Kind: kindRoute, Id: newID(), CreationTimestamp: b.stamp(), Name: name, Network: n.SelfLink, + Description: subnetRouteDesc(cidr), DestRange: cidr, NextHopNetwork: n.SelfLink, + SelfLink: globalLink(project, "routes", name), RouteType: routeTypeSubnet, RouteStatus: routeActive, + } + } +} + +func (b *InMemoryBackend) syncNetworkRoutesBySelfLinkLocked(project, network string) { + for _, n := range b.networks { + if n.SelfLink == network { + b.syncSubnetRoutesLocked(project, n) + + return + } + } +} + +func viewRoute(r *computeapi.Route) *computeapi.Route { + out := clone(r) + out.ForceSendFields = []string{fieldPriority} + + return out +} diff --git a/services/gcpcompute/subnetroutes_test.go b/services/gcpcompute/subnetroutes_test.go new file mode 100644 index 0000000000..1e549770e6 --- /dev/null +++ b/services/gcpcompute/subnetroutes_test.go @@ -0,0 +1,115 @@ +package gcpcompute_test + +import ( + "errors" + "net/http" + "strings" + "testing" + + "cloud.google.com/go/compute/apiv1/computepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/iterator" +) + +func (e *env) routeList(t *testing.T) map[string]*computepb.Route { + t.Helper() + + it := e.routes(t).List(t.Context(), &computepb.ListRoutesRequest{Project: project}) + out := map[string]*computepb.Route{} + + for { + r, err := it.Next() + if errors.Is(err, iterator.Done) { + return out + } + + require.NoError(t, err) + out[r.GetDestRange()] = r + } +} + +func TestSubnetRoutesFollowSubnetworkLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.network(t, "vpc", false) + e.subnet(t, "sub", "10.10.0.0/24") + + r := e.routeList(t)["10.10.0.0/24"] + require.NotNil(t, r) + assert.True(t, strings.HasPrefix(r.GetName(), "default-route-")) + assert.Len(t, r.GetName(), len("default-route-")+16) + assert.Equal(t, "SUBNET", r.GetRouteType()) + assert.Equal(t, "ACTIVE", r.GetRouteStatus()) + assert.Equal(t, "Default local route to the subnetwork 10.10.0.0/24.", r.GetDescription()) + assert.Contains(t, r.GetNextHopNetwork(), "/global/networks/vpc") + assert.Contains(t, r.GetNetwork(), "/global/networks/vpc") + assert.Zero(t, r.GetPriority()) + assert.Empty(t, r.GetNextHopGateway()) + + name := r.GetName() + + op, err := e.subnets(t).ExpandIpCidrRange(t.Context(), &computepb.ExpandIpCidrRangeSubnetworkRequest{ + Project: project, Region: region, Subnetwork: "sub", + SubnetworksExpandIpCidrRangeRequestResource: &computepb.SubnetworksExpandIpCidrRangeRequest{ + IpCidrRange: s("10.10.0.0/23"), + }, + }) + e.done(t, op, err) + + routes := e.routeList(t) + assert.NotContains(t, routes, "10.10.0.0/24") + require.Contains(t, routes, "10.10.0.0/23") + assert.Equal(t, name, routes["10.10.0.0/23"].GetName(), "the route keeps its name when the range grows") + + op, err = e.subnets(t).Patch(t.Context(), &computepb.PatchSubnetworkRequest{ + Project: project, Region: region, Subnetwork: "sub", SubnetworkResource: &computepb.Subnetwork{ + SecondaryIpRanges: []*computepb.SubnetworkSecondaryRange{ + {RangeName: s("pods"), IpCidrRange: s("192.168.0.0/24")}, + }, + }, + }) + e.done(t, op, err) + + routes = e.routeList(t) + require.Contains(t, routes, "192.168.0.0/24") + assert.Equal(t, "SUBNET", routes["192.168.0.0/24"].GetRouteType()) + + _, err = e.routes(t).Delete(t.Context(), &computepb.DeleteRouteRequest{Project: project, Route: name}) + assertAPIError(t, err, http.StatusBadRequest, "invalid") + + op, err = e.subnets(t). + Delete(t.Context(), &computepb.DeleteSubnetworkRequest{Project: project, Region: region, Subnetwork: "sub"}) + e.done(t, op, err) + + routes = e.routeList(t) + assert.NotContains(t, routes, "10.10.0.0/23") + assert.NotContains(t, routes, "192.168.0.0/24") + assert.Contains(t, routes, "0.0.0.0/0", "the internet route stays") + + op, err = e.networks(t).Delete(t.Context(), &computepb.DeleteNetworkRequest{Project: project, Network: "vpc"}) + e.done(t, op, err) + assert.Empty(t, e.routeList(t)) +} + +func TestAutoNetworkHasSubnetRoutePerRegion(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.network(t, "auto", true) + + n, err := e.networks(t).Get(t.Context(), &computepb.GetNetworkRequest{Project: project, Network: "auto"}) + require.NoError(t, err) + + subnetRoutes := 0 + + for _, r := range e.routeList(t) { + if r.GetRouteType() == "SUBNET" { + subnetRoutes++ + } + } + + assert.Equal(t, len(n.GetSubnetworks()), subnetRoutes) + assert.Positive(t, subnetRoutes) +} diff --git a/services/gcpcompute/vpcroute.go b/services/gcpcompute/vpcroute.go index 47aef71e42..2c026c4fb2 100644 --- a/services/gcpcompute/vpcroute.go +++ b/services/gcpcompute/vpcroute.go @@ -127,7 +127,7 @@ func (b *InMemoryBackend) insertRoute(project string, in *computeapi.Route) (*co } r := &computeapi.Route{ - Kind: "compute#route", Id: newID(), CreationTimestamp: b.stamp(), Name: in.Name, Description: in.Description, + Kind: kindRoute, Id: newID(), CreationTimestamp: b.stamp(), Name: in.Name, Description: in.Description, Network: n.SelfLink, DestRange: in.DestRange, Priority: in.Priority, Tags: in.Tags, SelfLink: globalLink(project, "routes", in.Name), RouteType: "STATIC", } @@ -154,7 +154,7 @@ func (b *InMemoryBackend) getRoute(project, name string) (*computeapi.Route, err return nil, errNotFound(relPath(project, scopeGlobal, "", "routes", name)) } - return clone(r), nil + return viewRoute(r), nil } func (b *InMemoryBackend) listRoutes(project string) []entry { @@ -165,7 +165,7 @@ func (b *InMemoryBackend) listRoutes(project string) []entry { for k, r := range b.routes { if strings.HasPrefix(k, project+"/") { - out = append(out, entry{scope: scopeGlobal, item: clone(r)}) + out = append(out, entry{scope: scopeGlobal, item: viewRoute(r)}) } } @@ -181,6 +181,11 @@ func (b *InMemoryBackend) deleteRoute(project, name string) (*computeapi.Operati return nil, errNotFound(relPath(project, scopeGlobal, "", "routes", name)) } + if isSubnetRoute(r) { + return nil, errField("route", name, + "System-generated subnet routes cannot be deleted; delete or change the subnetwork instead.") + } + delete(b.routes, key(project, name)) return b.newOpLocked("delete", routeTarget(project, r), b.timing.Done), nil diff --git a/services/gcpcontainer/PARITY.md b/services/gcpcontainer/PARITY.md new file mode 100644 index 0000000000..debbfa1b68 --- /dev/null +++ b/services/gcpcontainer/PARITY.md @@ -0,0 +1,54 @@ +--- +service: gcpcontainer +sdk_module: cloud.google.com/go/container@v1.56.0 +last_audit_commit: 86ab2e0a7 +last_audit_date: 2026-10-29 +overall: B +ops: + ClusterManager.CreateCluster: {wire: ok, errors: partial, state: ok, persist: ok, note: "Zonal and regional, Standard and Autopilot. Validates name (40 chars, lowercase letters/digits/hyphens), location, labels, release channel and version, node pools (machine type, disk, image type, taints, labels, autoscaling, max pods), ipAllocationPolicy (pod /8-/21 and services /16-/27 ranges, '/N' size requests auto-allocated, secondary range names resolved against gcpcompute), private cluster master /28, authorized networks and maintenance policy. Network and subnetwork are checked against gcpcompute when wired ('default' always exists). Returns a RUNNING CREATE_CLUSTER operation."} + ClusterManager.GetCluster: {wire: ok, errors: ok, state: ok, persist: n/a, note: "status, node counts, instanceGroupUrls and etag are derived at read time from the operations on the injectable clock."} + ClusterManager.ListClusters: {wire: ok, errors: ok, state: ok, persist: n/a, note: "'-' lists every location."} + ClusterManager.UpdateCluster: {wire: ok, errors: partial, state: ok, persist: ok, note: "Desired* fields map onto the same-named Cluster or NetworkConfig field; master/node version, node pool autoscaling and image type, locations, cluster autoscaling, logging/monitoring service, addons and authorized networks have dedicated handling. Fields that cannot be honoured are 400, never ignored."} + ClusterManager.DeleteCluster: {wire: ok, errors: ok, state: ok, persist: ok, note: "Allowed while provisioning (the create operation is aborted); STOPPING until the DELETE_CLUSTER operation ends."} + ClusterManager.SetLabels: {wire: ok, errors: partial, state: ok, persist: ok, note: "labelFingerprint required and checked."} + ClusterManager.SetMasterAuth: {wire: ok, errors: partial, state: ok, persist: ok, note: "SET_PASSWORD, GENERATE_PASSWORD, SET_USERNAME store basic-auth credentials on masterAuth; they are not enforced and do not reach a docker-engine API server."} + ClusterManager.SetLegacyAbac: {wire: ok, errors: ok, state: ok, persist: ok, note: "Rejected for Autopilot."} + ClusterManager.SetNetworkPolicy: {wire: ok, errors: partial, state: ok, persist: ok, note: "CALICO only; the network policy addon must be enabled first. Stored, not enforced."} + ClusterManager.SetMaintenancePolicy: {wire: ok, errors: partial, state: ok, persist: ok, note: "resourceVersion is required and checked; daily, recurring and exclusion windows are validated. The 48h-per-32-days availability rule is not enforced."} + ClusterManager.StartIPRotation: {wire: ok, errors: partial, state: ok, persist: ok, note: "Endpoint changes and, with rotateCredentials, a new CA is issued. Rejected with the docker engine."} + ClusterManager.CompleteIPRotation: {wire: ok, errors: partial, state: ok, persist: ok, note: "FAILED_PRECONDITION when no rotation is in progress."} + ClusterManager.SetLoggingService: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} + ClusterManager.SetMonitoringService: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} + ClusterManager.SetAddonsConfig: {wire: ok, errors: ok, state: ok, persist: ok, note: "Each addon section in the request replaces the stored one."} + ClusterManager.SetLocations: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} + ClusterManager.UpdateMaster: {wire: ok, errors: ok, state: ok, persist: ok, note: "Upgrade only."} + ClusterManager.CreateNodePool: {wire: ok, errors: partial, state: ok, persist: ok, note: "Not allowed on Autopilot. Pool operations on different pools run concurrently; cluster-wide operations conflict with everything."} + ClusterManager.GetNodePool: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} + ClusterManager.ListNodePools: {wire: ok, errors: ok, state: ok, persist: n/a, note: ""} + ClusterManager.UpdateNodePool: {wire: ok, errors: partial, state: ok, persist: ok, note: "Version, image type, machine type, disk, locations, tags, taints, labels, resource labels, upgrade settings and any request field with a same-named NodeConfig field; others are 400. Etag checked."} + ClusterManager.DeleteNodePool: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} + ClusterManager.SetNodePoolSize: {wire: ok, errors: ok, state: ok, persist: ok, note: "Rejected while autoscaling is enabled."} + ClusterManager.SetNodePoolAutoscaling: {wire: ok, errors: ok, state: ok, persist: ok, note: "Per-zone and total limits are exclusive; the size is clamped into the limits."} + ClusterManager.SetNodePoolManagement: {wire: ok, errors: ok, state: ok, persist: ok, note: "Auto-upgrade cannot be disabled on a release channel."} + ClusterManager.RollbackNodePoolUpgrade: {wire: ok, errors: ok, state: ok, persist: ok, note: "Restores the version before the last upgrade."} + ClusterManager.GetOperation: {wire: ok, errors: ok, state: ok, persist: ok, note: "container.v1 Operation resource (not google.longrunning); RUNNING until the injectable clock passes its end, or until the k3s API server answers with the docker engine."} + ClusterManager.ListOperations: {wire: ok, errors: ok, state: ok, persist: ok, note: ""} + ClusterManager.GetServerConfig: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Documented representative catalog: four release channels with default, valid and upgrade-target versions, valid master and node versions, image types."} + ClusterManager.CancelOperation: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Unimplemented (UNIMPLEMENTED)."} + ClusterManager.GetJSONWebKeys: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Unimplemented (UNIMPLEMENTED)."} + ClusterManager.ListUsableSubnetworks: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Unimplemented (UNIMPLEMENTED)."} + ClusterManager.CheckAutopilotCompatibility: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Unimplemented (UNIMPLEMENTED)."} + ClusterManager.FetchClusterUpgradeInfo: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Unimplemented (UNIMPLEMENTED); also FetchNodePoolUpgradeInfo, CompleteNodePoolUpgrade and CompleteControlPlaneUpgrade."} +families: + operations_clock: {status: ok, note: "Statuses of clusters, node pools and operations are derived from timestamps on an injectable clock (InMemoryBackend.SetClock, SetTiming): cluster PROVISIONING, RECONCILING, STOPPING, RUNNING."} + docker_engine: {status: ok, note: "GKE_ENGINE=docker (or --engines) starts one single-node k3s container per cluster through the shared pkgs/k3sengine (also used by EKS). Endpoint is host:port, masterAuth.clusterCaCertificate the serving CA, bearer token GKE_CLUSTER_TOKEN (default documented token). Node pools stay metadata."} + routing_isolation: {status: ok, note: "Own gRPC and REST listener on --gcp-container-port / GCP_CONTAINER_PORT (default 8144), reserved in the shared PortAlloc pool, never on the shared AWS router."} + persistence: {status: ok, note: "Clusters, node pools (with sizes and previous versions), rotation state and operations are in backendSnapshot; docker-engine clusters are started again on restore."} + auth: {status: partial, note: "No request is authenticated or authorised."} +items_still_open: + - "The control plane is metadata: without GKE_ENGINE=docker the endpoint is a documentation-range address with a real but unused CA, and node pools never run nodes (the docker engine runs one k3s node regardless of pool sizes). Instance groups named in instanceGroupUrls are not created in gcpcompute." + - "Unimplemented RPCs: CancelOperation, GetJSONWebKeys, ListUsableSubnetworks, CheckAutopilotCompatibility, FetchClusterUpgradeInfo, FetchNodePoolUpgradeInfo, CompleteNodePoolUpgrade, CompleteControlPlaneUpgrade." + - "Version catalog and region/zone lists are a fixed representative set; Autopilot being regional-only, error message texts and the PreconditionFailure/BadRequest/ResourceInfo detail payloads were chosen without a production reference (only the cluster name rules were checked against Google docs)." + - "createSubnetwork is not honoured (no subnet is created in gcpcompute), pod/service ranges of other clusters are only avoided for auto-allocation, and quotas are not modelled." + - "No dashboard page, and the emulator does not enforce IAM or return permission errors." +--- diff --git a/services/gcpcontainer/README.md b/services/gcpcontainer/README.md new file mode 100644 index 0000000000..f0b8e53a18 --- /dev/null +++ b/services/gcpcontainer/README.md @@ -0,0 +1,26 @@ + +# Kubernetes Engine + +**Parity grade: B** · SDK `cloud.google.com/go/container@v1.56.0` · last audited 2026-10-29 (`86ab2e0a7`) + +## Coverage + +| Metric | Value | +| --- | --- | +| Feature families | 5 (4 ok, 1 partial) | +| Known gaps | 5 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- The control plane is metadata: without GKE_ENGINE=docker the endpoint is a documentation-range address with a real but unused CA, and node pools never run nodes (the docker engine runs one k3s node regardless of pool sizes). Instance groups named in instanceGroupUrls are not created in gcpcompute. +- Unimplemented RPCs: CancelOperation, GetJSONWebKeys, ListUsableSubnetworks, CheckAutopilotCompatibility, FetchClusterUpgradeInfo, FetchNodePoolUpgradeInfo, CompleteNodePoolUpgrade, CompleteControlPlaneUpgrade. +- Version catalog and region/zone lists are a fixed representative set; Autopilot being regional-only, error message texts and the PreconditionFailure/BadRequest/ResourceInfo detail payloads were chosen without a production reference (only the cluster name rules were checked against Google docs). +- createSubnetwork is not honoured (no subnet is created in gcpcompute), pod/service ranges of other clusters are only avoided for auto-allocation, and quotas are not modelled. +- No dashboard page, and the emulator does not enforce IAM or return permission errors. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/gcpcontainer/actions_test.go b/services/gcpcontainer/actions_test.go new file mode 100644 index 0000000000..382551d179 --- /dev/null +++ b/services/gcpcontainer/actions_test.go @@ -0,0 +1,612 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "testing" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestSetLabels(t *testing.T) { + t.Parallel() + + tests := []struct { + labels map[string]string + name string + wantMsg string + want codes.Code + stale bool + noFP bool + }{ + {name: "ok", labels: map[string]string{"team": "a"}}, + {name: "clear", labels: map[string]string{}}, + { + name: "stale fingerprint", + labels: map[string]string{"x": "y"}, + stale: true, + want: codes.FailedPrecondition, + wantMsg: "stale", + }, + { + name: "missing fingerprint", + labels: map[string]string{"x": "y"}, + noFP: true, + want: codes.InvalidArgument, + wantMsg: "fingerprint", + }, + {name: "bad key", labels: map[string]string{"Bad": "y"}, want: codes.InvalidArgument, wantMsg: "label key"}, + {name: "bad value", labels: map[string]string{"k": "UPPER"}, want: codes.InvalidArgument, wantMsg: "label"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + before := e.ready( + t, + zone, + &containerpb.Cluster{Name: "l", InitialNodeCount: 1, ResourceLabels: map[string]string{"a": "b"}}, + ) + + fp := before.GetLabelFingerprint() + + switch { + case tt.stale: + fp = "AAAA" + case tt.noFP: + fp = "" + } + + op, err := e.gc.SetLabels(t.Context(), &containerpb.SetLabelsRequest{ + Name: clusterPath(zone, "l"), ResourceLabels: tt.labels, LabelFingerprint: fp, + }) + if tt.want != codes.OK { + assertCode(t, tt.want, err, tt.wantMsg) + + return + } + + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_LABELS, op.GetOperationType()) + assert.Equal(t, containerpb.Cluster_RECONCILING, e.getCluster(t, zone, "l").GetStatus()) + + e.settle() + + after := e.getCluster(t, zone, "l") + assert.Equal(t, containerpb.Cluster_RUNNING, after.GetStatus()) + assert.Len(t, after.GetResourceLabels(), len(tt.labels)) + assert.NotEqual(t, before.GetLabelFingerprint(), after.GetLabelFingerprint()) + }) + } +} + +func TestSetMasterAuth(t *testing.T) { + t.Parallel() + + tests := []struct { + update *containerpb.MasterAuth + check func(t *testing.T, ma *containerpb.MasterAuth) + name string + wantMsg string + action containerpb.SetMasterAuthRequest_Action + want codes.Code + }{ + { + name: "set password", action: containerpb.SetMasterAuthRequest_SET_PASSWORD, + update: &containerpb.MasterAuth{Password: "0123456789abcdef"}, + check: func(t *testing.T, ma *containerpb.MasterAuth) { + t.Helper() + assert.Equal(t, "0123456789abcdef", ma.GetPassword()) + assert.Equal(t, "admin", ma.GetUsername()) + }, + }, + { + name: "short password", action: containerpb.SetMasterAuthRequest_SET_PASSWORD, + update: &containerpb.MasterAuth{Password: "short"}, want: codes.InvalidArgument, wantMsg: "Password", + }, + { + name: "generate", action: containerpb.SetMasterAuthRequest_GENERATE_PASSWORD, + check: func(t *testing.T, ma *containerpb.MasterAuth) { + t.Helper() + assert.Len(t, ma.GetPassword(), 24) + }, + }, + { + name: "set username", action: containerpb.SetMasterAuthRequest_SET_USERNAME, + update: &containerpb.MasterAuth{Username: "root"}, + check: func(t *testing.T, ma *containerpb.MasterAuth) { + t.Helper() + assert.Equal(t, "root", ma.GetUsername()) + }, + }, + { + name: "empty username", action: containerpb.SetMasterAuthRequest_SET_USERNAME, + want: codes.InvalidArgument, wantMsg: "Username", + }, + { + name: "unknown action", + action: containerpb.SetMasterAuthRequest_UNKNOWN, + want: codes.InvalidArgument, + wantMsg: "Action", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("m")) + + op, err := e.gc.SetMasterAuth(t.Context(), &containerpb.SetMasterAuthRequest{ + Name: clusterPath(zone, "m"), Action: tt.action, Update: tt.update, + }) + if tt.want != codes.OK { + assertCode(t, tt.want, err, tt.wantMsg) + + return + } + + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_MASTER_AUTH, op.GetOperationType()) + + e.settle() + tt.check(t, e.getCluster(t, zone, "m").GetMasterAuth()) + }) + } +} + +func TestLegacyAbacAndNetworkPolicy(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("n")) + name := clusterPath(zone, "n") + + _, err := e.gc.SetLegacyAbac(t.Context(), &containerpb.SetLegacyAbacRequest{Name: name, Enabled: true}) + require.NoError(t, err) + e.settle() + assert.True(t, e.getCluster(t, zone, "n").GetLegacyAbac().GetEnabled()) + + _, err = e.gc.SetNetworkPolicy(t.Context(), &containerpb.SetNetworkPolicyRequest{ + Name: name, + NetworkPolicy: &containerpb.NetworkPolicy{Enabled: true, Provider: containerpb.NetworkPolicy_CALICO}, + }) + assertCode(t, codes.FailedPrecondition, err, "addon must be enabled") + + _, err = e.gc.SetAddonsConfig(t.Context(), &containerpb.SetAddonsConfigRequest{ + Name: name, + AddonsConfig: &containerpb.AddonsConfig{NetworkPolicyConfig: &containerpb.NetworkPolicyConfig{Disabled: false}}, + }) + require.NoError(t, err) + e.settle() + + _, err = e.gc.SetNetworkPolicy(t.Context(), &containerpb.SetNetworkPolicyRequest{ + Name: name, NetworkPolicy: &containerpb.NetworkPolicy{Enabled: true}, + }) + assertCode(t, codes.InvalidArgument, err, "CALICO") + + _, err = e.gc.SetNetworkPolicy(t.Context(), &containerpb.SetNetworkPolicyRequest{Name: name}) + assertCode(t, codes.InvalidArgument, err, "must be specified") + + op, err := e.gc.SetNetworkPolicy(t.Context(), &containerpb.SetNetworkPolicyRequest{ + Name: name, + NetworkPolicy: &containerpb.NetworkPolicy{Enabled: true, Provider: containerpb.NetworkPolicy_CALICO}, + }) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_NETWORK_POLICY, op.GetOperationType()) + e.settle() + assert.True(t, e.getCluster(t, zone, "n").GetNetworkPolicy().GetEnabled()) + + _, err = e.gc.SetAddonsConfig(t.Context(), &containerpb.SetAddonsConfigRequest{ + Name: name, + AddonsConfig: &containerpb.AddonsConfig{NetworkPolicyConfig: &containerpb.NetworkPolicyConfig{Disabled: true}}, + }) + assertCode(t, codes.FailedPrecondition, err, "cannot be disabled") + + cl := e.getCluster(t, zone, "n") + assert.False(t, cl.GetAddonsConfig().GetHttpLoadBalancing().GetDisabled()) +} + +func TestSetMaintenancePolicy(t *testing.T) { + t.Parallel() + + start := time.Date(2026, 11, 1, 3, 0, 0, 0, time.UTC) + recurring := &containerpb.MaintenanceWindow{Policy: &containerpb.MaintenanceWindow_RecurringWindow{ + RecurringWindow: &containerpb.RecurringTimeWindow{ + Window: &containerpb.TimeWindow{ + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(start.Add(4 * time.Hour)), + }, + Recurrence: "FREQ=WEEKLY;BYDAY=SA", + }, + }} + + tests := []struct { + window *containerpb.MaintenanceWindow + name string + version string + wantMsg string + want codes.Code + }{ + { + name: "daily", + window: &containerpb.MaintenanceWindow{Policy: &containerpb.MaintenanceWindow_DailyMaintenanceWindow{ + DailyMaintenanceWindow: &containerpb.DailyMaintenanceWindow{StartTime: "03:00"}, + }}, + }, + {name: "recurring", window: recurring}, + { + name: "bad daily", + window: &containerpb.MaintenanceWindow{Policy: &containerpb.MaintenanceWindow_DailyMaintenanceWindow{ + DailyMaintenanceWindow: &containerpb.DailyMaintenanceWindow{StartTime: "25:99"}, + }}, + want: codes.InvalidArgument, + wantMsg: "HH:MM", + }, + { + name: "recurring without rule", want: codes.InvalidArgument, wantMsg: "RRULE", + window: &containerpb.MaintenanceWindow{Policy: &containerpb.MaintenanceWindow_RecurringWindow{ + RecurringWindow: &containerpb.RecurringTimeWindow{ + Window: &containerpb.TimeWindow{ + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(start.Add(time.Hour)), + }, + }, + }}, + }, + { + name: "stale version", + window: recurring, + version: "deadbeef", + want: codes.FailedPrecondition, + wantMsg: "stale", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + before := e.ready(t, zone, stdCluster("mw")) + rv := before.GetMaintenancePolicy().GetResourceVersion() + + if tt.version != "" { + rv = tt.version + } + + op, err := e.gc.SetMaintenancePolicy(t.Context(), &containerpb.SetMaintenancePolicyRequest{ + Name: clusterPath( + zone, + "mw", + ), + MaintenancePolicy: &containerpb.MaintenancePolicy{Window: tt.window, ResourceVersion: rv}, + }) + if tt.want != codes.OK { + assertCode(t, tt.want, err, tt.wantMsg) + + return + } + + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_MAINTENANCE_POLICY, op.GetOperationType()) + e.settle() + + after := e.getCluster(t, zone, "mw").GetMaintenancePolicy() + assert.NotNil(t, after.GetWindow()) + assert.NotEqual(t, before.GetMaintenancePolicy().GetResourceVersion(), after.GetResourceVersion()) + }) + } + + t.Run("missing version", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("mw")) + + _, err := e.gc.SetMaintenancePolicy(t.Context(), &containerpb.SetMaintenancePolicyRequest{ + Name: clusterPath(zone, "mw"), MaintenancePolicy: &containerpb.MaintenancePolicy{Window: recurring}, + }) + assertCode(t, codes.InvalidArgument, err, "Resource version") + }) +} + +func TestIPRotation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + credentials bool + }{ + {"ip only", false}, + {"with credentials", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + before := e.ready(t, zone, stdCluster("rot")) + name := clusterPath(zone, "rot") + + _, err := e.gc.CompleteIPRotation(t.Context(), &containerpb.CompleteIPRotationRequest{Name: name}) + assertCode(t, codes.FailedPrecondition, err, "not in progress") + + _, err = e.gc.StartIPRotation( + t.Context(), + &containerpb.StartIPRotationRequest{Name: name, RotateCredentials: tt.credentials}, + ) + require.NoError(t, err) + e.settle() + + mid := e.getCluster(t, zone, "rot") + assert.NotEqual(t, before.GetEndpoint(), mid.GetEndpoint()) + assert.Equal( + t, + tt.credentials, + before.GetMasterAuth().GetClusterCaCertificate() != mid.GetMasterAuth().GetClusterCaCertificate(), + ) + + _, err = e.gc.StartIPRotation(t.Context(), &containerpb.StartIPRotationRequest{Name: name}) + assertCode(t, codes.FailedPrecondition, err, "already in progress") + + op, err := e.gc.CompleteIPRotation(t.Context(), &containerpb.CompleteIPRotationRequest{Name: name}) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_UPDATE_CLUSTER, op.GetOperationType()) + e.settle() + + _, err = e.gc.CompleteIPRotation(t.Context(), &containerpb.CompleteIPRotationRequest{Name: name}) + assertCode(t, codes.FailedPrecondition, err, "not in progress") + assert.Equal(t, mid.GetEndpoint(), e.getCluster(t, zone, "rot").GetEndpoint()) + }) + } +} + +func TestLoggingMonitoringLocations(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready( + t, + region, + &containerpb.Cluster{Name: "svc", NodePools: []*containerpb.NodePool{{Name: "p", InitialNodeCount: 1}}}, + ) + name := clusterPath(region, "svc") + + _, err := e.gc.SetLoggingService( + t.Context(), + &containerpb.SetLoggingServiceRequest{Name: name, LoggingService: "bogus"}, + ) + assertCode(t, codes.InvalidArgument, err, "Invalid service") + + _, err = e.gc.SetLoggingService( + t.Context(), + &containerpb.SetLoggingServiceRequest{Name: name, LoggingService: "none"}, + ) + require.NoError(t, err) + e.settle() + + _, err = e.gc.SetMonitoringService( + t.Context(), + &containerpb.SetMonitoringServiceRequest{Name: name, MonitoringService: "monitoring.googleapis.com"}, + ) + require.NoError(t, err) + e.settle() + + got := e.getCluster(t, region, "svc") + assert.Equal(t, "none", got.GetLoggingService()) + assert.Equal(t, "monitoring.googleapis.com", got.GetMonitoringService()) + + _, err = e.gc.SetLocations( + t.Context(), + &containerpb.SetLocationsRequest{Name: name, Locations: []string{"europe-west1-b"}}, + ) + assertCode(t, codes.InvalidArgument, err, "not a zone of") + + _, err = e.gc.SetLocations(t.Context(), &containerpb.SetLocationsRequest{Name: name}) + assertCode(t, codes.InvalidArgument, err, "At least one") + + _, err = e.gc.SetLocations( + t.Context(), + &containerpb.SetLocationsRequest{Name: name, Locations: []string{"us-central1-b", "us-central1-f"}}, + ) + require.NoError(t, err) + e.settle() + + got = e.getCluster(t, region, "svc") + assert.Equal(t, []string{"us-central1-b", "us-central1-f"}, got.GetLocations()) + assert.Equal(t, got.GetLocations(), got.GetNodePools()[0].GetLocations()) + assert.Equal(t, int32(2), got.GetCurrentNodeCount()) +} + +func TestUpdateCluster(t *testing.T) { + t.Parallel() + + tests := []struct { + update *containerpb.ClusterUpdate + check func(t *testing.T, c *containerpb.Cluster) + name string + wantMsg string + wantOp containerpb.Operation_Type + want codes.Code + }{ + { + name: "monitoring", update: &containerpb.ClusterUpdate{DesiredMonitoringService: "none"}, + wantOp: containerpb.Operation_UPDATE_CLUSTER, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, "none", c.GetMonitoringService()) + }, + }, + { + name: "generic message field", wantOp: containerpb.Operation_UPDATE_CLUSTER, + update: &containerpb.ClusterUpdate{DesiredBinaryAuthorization: &containerpb.BinaryAuthorization{ + EvaluationMode: containerpb.BinaryAuthorization_PROJECT_SINGLETON_POLICY_ENFORCE, + }}, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal( + t, + containerpb.BinaryAuthorization_PROJECT_SINGLETON_POLICY_ENFORCE, + c.GetBinaryAuthorization().GetEvaluationMode(), + ) + }, + }, + { + name: "network config enum", wantOp: containerpb.Operation_UPDATE_CLUSTER, + update: &containerpb.ClusterUpdate{DesiredDatapathProvider: containerpb.DatapathProvider_ADVANCED_DATAPATH}, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal( + t, + containerpb.DatapathProvider_ADVANCED_DATAPATH, + c.GetNetworkConfig().GetDatapathProvider(), + ) + }, + }, + { + name: "intra node visibility", + wantOp: containerpb.Operation_UPDATE_CLUSTER, + update: &containerpb.ClusterUpdate{ + DesiredIntraNodeVisibilityConfig: &containerpb.IntraNodeVisibilityConfig{Enabled: true}, + }, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.True(t, c.GetNetworkConfig().GetEnableIntraNodeVisibility()) + }, + }, + { + name: "release channel", + wantOp: containerpb.Operation_UPDATE_CLUSTER, + update: &containerpb.ClusterUpdate{ + DesiredReleaseChannel: &containerpb.ReleaseChannel{Channel: containerpb.ReleaseChannel_RAPID}, + }, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, containerpb.ReleaseChannel_RAPID, c.GetReleaseChannel().GetChannel()) + }, + }, + { + name: "authorized networks", + wantOp: containerpb.Operation_UPDATE_CLUSTER, + update: &containerpb.ClusterUpdate{ + DesiredMasterAuthorizedNetworksConfig: &containerpb.MasterAuthorizedNetworksConfig{ + Enabled: true, + CidrBlocks: []*containerpb.MasterAuthorizedNetworksConfig_CidrBlock{ + {DisplayName: "o", CidrBlock: "203.0.113.0/24"}, + }, + }, + }, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal( + t, + "203.0.113.0/24", + c.GetMasterAuthorizedNetworksConfig().GetCidrBlocks()[0].GetCidrBlock(), + ) + }, + }, + { + name: "bad authorized network", + want: codes.InvalidArgument, + wantMsg: "Invalid CIDR", + update: &containerpb.ClusterUpdate{ + DesiredMasterAuthorizedNetworksConfig: &containerpb.MasterAuthorizedNetworksConfig{ + CidrBlocks: []*containerpb.MasterAuthorizedNetworksConfig_CidrBlock{{CidrBlock: "nope"}}, + }, + }, + }, + { + name: "master upgrade", wantOp: containerpb.Operation_UPGRADE_MASTER, + update: &containerpb.ClusterUpdate{DesiredMasterVersion: "latest"}, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, "1.35.1-gke.1000000", c.GetCurrentMasterVersion()) + assert.NotEqual(t, c.GetInitialClusterVersion(), c.GetCurrentMasterVersion()) + }, + }, + { + name: "master downgrade", want: codes.InvalidArgument, wantMsg: "downgrades", + update: &containerpb.ClusterUpdate{DesiredMasterVersion: "1.33.7-gke.1044000"}, + }, + { + name: "unknown master version", want: codes.InvalidArgument, wantMsg: "unsupported", + update: &containerpb.ClusterUpdate{DesiredMasterVersion: "0.1"}, + }, + { + name: "unsupported field", + want: codes.InvalidArgument, + wantMsg: "not supported by the emulator", + update: &containerpb.ClusterUpdate{ + DesiredGcfsConfig: &containerpb.GcfsConfig{Enabled: true}, + DesiredImage: "x", + }, + }, + {name: "empty", update: &containerpb.ClusterUpdate{}, want: codes.InvalidArgument, wantMsg: "No update"}, + { + name: "stale etag", want: codes.FailedPrecondition, wantMsg: "etag", + update: &containerpb.ClusterUpdate{Etag: "stale", DesiredMonitoringService: "none"}, + }, + { + name: "private endpoint without private nodes", want: codes.FailedPrecondition, wantMsg: "private cluster", + update: &containerpb.ClusterUpdate{DesiredEnablePrivateEndpoint: new(true)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready( + t, + zone, + &containerpb.Cluster{Name: "u", InitialNodeCount: 1, InitialClusterVersion: "1.34.2-gke.1008000"}, + ) + + op, err := e.gc.UpdateCluster( + t.Context(), + &containerpb.UpdateClusterRequest{Name: clusterPath(zone, "u"), Update: tt.update}, + ) + if tt.want != codes.OK { + assertCode(t, tt.want, err, tt.wantMsg) + + assert.Equal(t, containerpb.Cluster_RUNNING, e.getCluster(t, zone, "u").GetStatus()) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOp, op.GetOperationType()) + assert.Equal(t, containerpb.Cluster_RECONCILING, e.getCluster(t, zone, "u").GetStatus()) + e.settle() + tt.check(t, e.getCluster(t, zone, "u")) + }) + } +} + +func TestUpdateClusterCurrentEtag(t *testing.T) { + t.Parallel() + + e := newEnv(t) + c := e.ready(t, zone, stdCluster("et")) + + _, err := e.gc.UpdateCluster(t.Context(), &containerpb.UpdateClusterRequest{ + Name: clusterPath( + zone, + "et", + ), + Update: &containerpb.ClusterUpdate{Etag: c.GetEtag(), DesiredMonitoringService: "none"}, + }) + require.NoError(t, err) + e.settle() + assert.NotEqual(t, c.GetEtag(), e.getCluster(t, zone, "et").GetEtag()) +} diff --git a/services/gcpcontainer/cluster_ops.go b/services/gcpcontainer/cluster_ops.go new file mode 100644 index 0000000000..5f5734ed55 --- /dev/null +++ b/services/gcpcontainer/cluster_ops.go @@ -0,0 +1,502 @@ +//nolint:staticcheck // legacy request and cluster fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "slices" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" +) + +const ( + generatedPasswordBytes = 12 + defaultAdminUser = "admin" +) + +// clusterTarget names the cluster an action applies to. +type clusterTarget struct { + name string + project string + zone string + cluster string +} + +// mutateCluster runs apply against a copy of the cluster and commits it with a new operation of type typ. +// apply may also edit the record's own fields, but only after every check has passed. +func (b *InMemoryBackend) mutateCluster( + t clusterTarget, typ containerpb.Operation_Type, apply func(c *clusterRec, cl *containerpb.Cluster) error, +) (*containerpb.Operation, error) { + r, err := resolveCluster(t.name, t.project, t.zone, t.cluster) + if err != nil { + return nil, err + } + + b.mu.Lock("mutateCluster") + defer b.mu.Unlock() + + b.sweepLocked() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + if err = b.conflictLocked(r, ""); err != nil { + return nil, err + } + + cl := clone(c.Cluster) + if err = apply(c, cl); err != nil { + return nil, err + } + + c.Cluster = cl + o := b.newOpLocked(r, typ, kindClusterOp, cl.GetSelfLink(), b.timing.Mutate, false) + + return b.opViewLocked(o), nil +} + +// SetLabels replaces the cluster's resource labels. +func (b *InMemoryBackend) SetLabels(req *containerpb.SetLabelsRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_SET_LABELS, func(_ *clusterRec, cl *containerpb.Cluster) error { + if req.GetLabelFingerprint() == "" { + return errInvalid("label_fingerprint", "Label fingerprint must be specified.") + } + + if err := validLabels("resource_labels", req.GetResourceLabels()); err != nil { + return err + } + + if req.GetLabelFingerprint() != cl.GetLabelFingerprint() { + return errPrecondition( + cl.GetSelfLink(), + "Label fingerprint %q is stale; the cluster's labels have changed.", + req.GetLabelFingerprint(), + ) + } + + cl.ResourceLabels = req.GetResourceLabels() + cl.LabelFingerprint = fingerprint(cl.GetResourceLabels()) + + return nil + }) +} + +func randomPassword() string { + buf := make([]byte, generatedPasswordBytes) + _, _ = rand.Read(buf) + + return hex.EncodeToString(buf) +} + +// SetMasterAuth changes the cluster's basic-auth username or password. +func (b *InMemoryBackend) SetMasterAuth(req *containerpb.SetMasterAuthRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster( + t, + containerpb.Operation_SET_MASTER_AUTH, + func(_ *clusterRec, cl *containerpb.Cluster) error { + cl.MasterAuth = firstPtr(cl.GetMasterAuth(), &containerpb.MasterAuth{}) + ma := cl.GetMasterAuth() + + switch req.GetAction() { + case containerpb.SetMasterAuthRequest_SET_PASSWORD: + if err := validPassword( + req.GetUpdate().GetPassword(), + ); err != nil { + return err + } + + ma.Password = req.GetUpdate(). + GetPassword() + //nolint:staticcheck // deprecated field kept for wire compatibility + ma.Username = firstNonEmpty(ma.GetUsername(), req.GetUpdate().GetUsername(), defaultAdminUser) + case containerpb.SetMasterAuthRequest_GENERATE_PASSWORD: + ma.Password = randomPassword() + ma.Username = firstNonEmpty(ma.GetUsername(), defaultAdminUser) + case containerpb.SetMasterAuthRequest_SET_USERNAME: + if req.GetUpdate(). + GetUsername() == + "" { + return errInvalid("update.username", "Username must be specified.") + } + + ma.Username = req.GetUpdate(). + GetUsername() + //nolint:staticcheck // deprecated field kept for wire compatibility + case containerpb.SetMasterAuthRequest_UNKNOWN: + return errInvalid("action", "Action must be one of SET_PASSWORD, GENERATE_PASSWORD or SET_USERNAME.") + } + + return nil + }, + ) +} + +// SetLegacyAbac enables or disables legacy ABAC authorization. +func (b *InMemoryBackend) SetLegacyAbac(req *containerpb.SetLegacyAbacRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPDATE_CLUSTER, func(_ *clusterRec, cl *containerpb.Cluster) error { + if req.GetEnabled() && cl.GetAutopilot().GetEnabled() { + return errInvalid("enabled", "Legacy ABAC cannot be enabled on Autopilot clusters.") + } + + cl.LegacyAbac = &containerpb.LegacyAbac{Enabled: req.GetEnabled()} + + return nil + }) +} + +// SetNetworkPolicy enables or disables Calico network policy enforcement. +func (b *InMemoryBackend) SetNetworkPolicy(req *containerpb.SetNetworkPolicyRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster( + t, + containerpb.Operation_SET_NETWORK_POLICY, + func(_ *clusterRec, cl *containerpb.Cluster) error { + np := req.GetNetworkPolicy() + if np == nil { + return errInvalid("network_policy", "Network policy must be specified.") + } + + if np.GetEnabled() { + switch { + case cl.GetAutopilot().GetEnabled(): + return errInvalid("network_policy", "Network policy cannot be enabled on Autopilot clusters.") + case np.GetProvider() != containerpb.NetworkPolicy_CALICO: + return errInvalid("network_policy.provider", "Network policy provider must be CALICO.") + case cl.GetAddonsConfig().GetNetworkPolicyConfig().GetDisabled(): + return errPrecondition( + cl.GetSelfLink(), + "The network policy addon must be enabled before network policy is enabled.", + ) + } + } + + cl.NetworkPolicy = clone(np) + + return nil + }, + ) +} + +// SetMaintenancePolicy replaces the cluster's maintenance policy. +func (b *InMemoryBackend) SetMaintenancePolicy( + req *containerpb.SetMaintenancePolicyRequest, +) (*containerpb.Operation, error) { + t := clusterTarget{req.GetName(), req.GetProjectId(), req.GetZone(), req.GetClusterId()} + + return b.mutateCluster( + t, + containerpb.Operation_SET_MAINTENANCE_POLICY, + func(_ *clusterRec, cl *containerpb.Cluster) error { + mp := req.GetMaintenancePolicy() + if mp == nil { + return errInvalid("maintenance_policy", "Maintenance policy must be specified.") + } + + if mp.GetResourceVersion() == "" { + return errInvalid( + "maintenance_policy.resource_version", + "Resource version must be specified; read it from the cluster.", + ) + } + + if mp.GetResourceVersion() != cl.GetMaintenancePolicy().GetResourceVersion() { + return errPrecondition( + cl.GetSelfLink(), + "The maintenance policy resource version %q is stale.", + mp.GetResourceVersion(), + ) + } + + if err := validMaintenance(mp); err != nil { + return err + } + + cl.MaintenancePolicy = withPolicyVersion(clone(mp)) + + return nil + }, + ) +} + +// StartIPRotation begins an IP (and optionally credential) rotation. +func (b *InMemoryBackend) StartIPRotation(req *containerpb.StartIPRotationRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPDATE_CLUSTER, func(c *clusterRec, cl *containerpb.Cluster) error { + switch { + case b.engine != nil: + return errPrecondition( + cl.GetSelfLink(), + "IP rotation is not supported by clusters backed by the docker engine.", + ) + case c.Rotation != nil: + return errPrecondition(cl.GetSelfLink(), "IP rotation is already in progress.") + } + + c.Rotation = &rotationRec{OldEndpoint: cl.GetEndpoint(), Credentials: req.GetRotateCredentials()} + b.seq++ + cl.Endpoint = documentationAddr(b.seq) + + if req.GetRotateCredentials() { + ca, err := k3sengine.NewCA("gopherstack-gke-" + cl.GetName() + "-ca") + if err != nil { + c.Rotation = nil + + return err + } + + cl.MasterAuth.ClusterCaCertificate = base64.StdEncoding.EncodeToString(ca.CertPEM) + } + + return nil + }) +} + +// CompleteIPRotation finishes a rotation started by StartIPRotation. +func (b *InMemoryBackend) CompleteIPRotation( + req *containerpb.CompleteIPRotationRequest, +) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPDATE_CLUSTER, func(c *clusterRec, cl *containerpb.Cluster) error { + if c.Rotation == nil { + return errPrecondition(cl.GetSelfLink(), "IP rotation is not in progress.") + } + + c.Rotation = nil + + return nil + }) +} + +func validService(v string, allowed ...string) error { + if slices.Contains(allowed, v) { + return nil + } + + return errInvalid("service", "Invalid service %q; want one of %v.", v, allowed) +} + +// SetLoggingService selects the logging backend. +func (b *InMemoryBackend) SetLoggingService(req *containerpb.SetLoggingServiceRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPDATE_CLUSTER, func(_ *clusterRec, cl *containerpb.Cluster) error { + if err := validService( + req.GetLoggingService(), + "logging.googleapis.com/kubernetes", + "logging.googleapis.com", + "none", + ); err != nil { + return err + } + + cl.LoggingService = req.GetLoggingService() + + return nil + }) +} + +// SetMonitoringService selects the monitoring backend. +func (b *InMemoryBackend) SetMonitoringService( + req *containerpb.SetMonitoringServiceRequest, +) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPDATE_CLUSTER, func(_ *clusterRec, cl *containerpb.Cluster) error { + if err := validService( + req.GetMonitoringService(), + "monitoring.googleapis.com/kubernetes", + "monitoring.googleapis.com", + "none", + ); err != nil { + return err + } + + cl.MonitoringService = req.GetMonitoringService() + + return nil + }) +} + +func mergeAddons(cl *containerpb.Cluster, in *containerpb.AddonsConfig) error { + if in == nil { + return errInvalid("addons_config", "Addons config must be specified.") + } + + merged := clone(cl.GetAddonsConfig()) + + in.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + merged.ProtoReflect().Set(fd, v) + + return true + }) + + if merged.GetNetworkPolicyConfig().GetDisabled() && cl.GetNetworkPolicy().GetEnabled() { + return errPrecondition( + cl.GetSelfLink(), + "The network policy addon cannot be disabled while network policy is enabled.", + ) + } + + cl.AddonsConfig = merged + + return nil +} + +// SetAddonsConfig replaces each addon section named in the request into the cluster. +func (b *InMemoryBackend) SetAddonsConfig(req *containerpb.SetAddonsConfigRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPDATE_CLUSTER, func(_ *clusterRec, cl *containerpb.Cluster) error { + return mergeAddons(cl, req.GetAddonsConfig()) + }) +} + +// SetLocations changes the zones the cluster's nodes run in. +func (b *InMemoryBackend) SetLocations(req *containerpb.SetLocationsRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPDATE_CLUSTER, func(c *clusterRec, cl *containerpb.Cluster) error { + return b.setLocations(c, cl, req.GetLocations()) + }) +} + +func (b *InMemoryBackend) setLocations(c *clusterRec, cl *containerpb.Cluster, want []string) error { + if len(want) == 0 { + return errInvalid("locations", "At least one location must be specified.") + } + + if err := checkLocations(cl.GetLocation(), want); err != nil { + return err + } + + zones := clusterZones(cl.GetLocation(), want) + old := cl.GetLocations() + + for _, p := range c.Pools { + if equalStrings(p.Pool.GetLocations(), old) { + p.Pool.Locations = zones + p.Pool.InstanceGroupUrls = groupURLs(c.Project, zones, cl.GetName(), p.Pool.GetName()) + } + } + + cl.Locations = zones + + return nil +} + +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 +} + +// UpdateMaster upgrades the cluster's control plane. +func (b *InMemoryBackend) UpdateMaster(req *containerpb.UpdateMasterRequest) (*containerpb.Operation, error) { + t := clusterTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + } + + return b.mutateCluster(t, containerpb.Operation_UPGRADE_MASTER, func(_ *clusterRec, cl *containerpb.Cluster) error { + return upgradeMaster(cl, req.GetMasterVersion()) + }) +} + +func upgradeMaster(cl *containerpb.Cluster, want string) error { + if want == "" { + return errInvalid("master_version", "Master version must be specified.") + } + + v, err := resolveMasterVersion(want, cl.GetReleaseChannel().GetChannel()) + if err != nil { + return err + } + + if compareVersions(v, cl.GetCurrentMasterVersion()) < 0 { + return errInvalid( + "master_version", + "Master version %s is older than the current version %s; downgrades are not supported.", + v, + cl.GetCurrentMasterVersion(), + ) + } + + cl.CurrentMasterVersion = v + + return nil +} diff --git a/services/gcpcontainer/clusters.go b/services/gcpcontainer/clusters.go new file mode 100644 index 0000000000..176690756e --- /dev/null +++ b/services/gcpcontainer/clusters.go @@ -0,0 +1,669 @@ +//nolint:staticcheck // legacy request and cluster fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "encoding/base64" + "fmt" + "net/netip" + "slices" + "strings" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" +) + +const ( + defaultNetwork = "default" + loggingService = "logging.googleapis.com/kubernetes" + monitoringService = "monitoring.googleapis.com/kubernetes" + nodeCIDRSize = 24 + routesNodeCIDR = 24 + privateEndpointIP = 2 +) + +type clusterPlan struct { + in *containerpb.Cluster + ref ref + version string + zones []string + channel containerpb.ReleaseChannel_Channel + autopilot bool +} + +func (b *InMemoryBackend) planCluster(project, location string, in *containerpb.Cluster) (*clusterPlan, error) { + if in == nil { + return nil, errInvalid("cluster", "Cluster must be specified.") + } + + if err := validName("cluster.name", in.GetName()); err != nil { + return nil, err + } + + if !validLocation(location) { + return nil, errInvalid("parent", "Location %q is not a valid zone or region.", location) + } + + p := &clusterPlan{in: in, ref: ref{project: project, location: location, cluster: in.GetName()}} + p.autopilot = in.GetAutopilot().GetEnabled() + p.channel = in.GetReleaseChannel().GetChannel() + + if p.autopilot { + if err := checkAutopilotInput(in, location); err != nil { + return nil, err + } + + if p.channel == containerpb.ReleaseChannel_UNSPECIFIED { + p.channel = containerpb.ReleaseChannel_REGULAR + } + } + + var err error + if p.version, err = resolveMasterVersion(in.GetInitialClusterVersion(), p.channel); err != nil { + return nil, err + } + + p.zones = clusterZones(location, in.GetLocations()) + if err = checkLocations(location, in.GetLocations()); err != nil { + return nil, err + } + + return p, validLabels("cluster.resource_labels", in.GetResourceLabels()) +} + +func clusterZones(location string, want []string) []string { + if len(want) > 0 { + out := slices.Clone(want) + slices.Sort(out) + + return slices.Compact(out) + } + + if isZone(location) { + return []string{location} + } + + return defaultZones(location) +} + +func checkLocations(location string, want []string) error { + region := regionOf(location) + + for _, z := range want { + if !isZone(z) || regionOf(z) != region { + return errInvalid("cluster.locations", "Location %q is not a zone of %s.", z, region) + } + } + + if isZone(location) && len(want) > 0 && !slices.Contains(want, location) { + return errInvalid("cluster.locations", "Locations must include the cluster zone %s.", location) + } + + return nil +} + +func checkAutopilotInput(in *containerpb.Cluster, location string) error { + switch { + case !isRegion(location): + return errInvalid("parent", "Autopilot clusters must be regional; %q is a zone.", location) + case len(in.GetNodePools()) > 0, + in.GetInitialNodeCount() != 0, + in.GetNodeConfig() != nil: + return errInvalid( + "cluster.node_pools", + "Autopilot clusters manage their own nodes; node pools and node configuration cannot be specified.", + ) + case in.GetLegacyAbac().GetEnabled(): + return errInvalid("cluster.legacy_abac", "Legacy ABAC cannot be enabled on Autopilot clusters.") + case in.GetNetworkPolicy().GetEnabled(): + return errInvalid("cluster.network_policy", "Network policy cannot be enabled on Autopilot clusters.") + } + + return nil +} + +type netSpec struct { + network string + subnetwork string + netPath string + subPath string +} + +func shortName(s string) string { + if _, after, ok := strings.CutLast(s, "/"); ok { + return after + } + + return s +} + +// pathProject extracts the project of a `projects/p/...` resource path, or def. +func pathProject(path, def string) string { + rest, ok := strings.CutPrefix(path, "projects/") + if !ok { + return def + } + + p, _, _ := strings.Cut(rest, "/") + + return p +} + +func (b *InMemoryBackend) resolveNet(project, region string, in *containerpb.Cluster) (netSpec, error) { + rawNet := firstNonEmpty(in.GetNetwork(), in.GetNetworkConfig().GetNetwork(), defaultNetwork) + rawSub := firstNonEmpty(in.GetSubnetwork(), in.GetNetworkConfig().GetSubnetwork()) + n := netSpec{network: shortName(rawNet), subnetwork: shortName(rawSub)} + netProject := pathProject(rawNet, project) + subProject := pathProject(rawSub, project) + + if b.compute != nil { + if n.network != defaultNetwork && !b.compute.NetworkExists(netProject, n.network) { + return n, errInvalid("cluster.network", "Network %q does not exist.", n.network) + } + + if n.subnetwork != "" && !(n.network == defaultNetwork && n.subnetwork == defaultNetwork) && + !b.compute.SubnetworkExists(subProject, region, n.subnetwork) { + return n, errInvalid( + "cluster.subnetwork", + "Subnetwork %q does not exist in region %s.", + n.subnetwork, + region, + ) + } + } + + if n.subnetwork == "" { + n.subnetwork = n.network + } + + n.netPath = "projects/" + netProject + "/global/networks/" + n.network + n.subPath = "projects/" + subProject + "/regions/" + region + "/subnetworks/" + n.subnetwork + + return n, nil +} + +// CreateCluster creates a cluster and its node pools and returns the CREATE_CLUSTER operation. +func (b *InMemoryBackend) CreateCluster(req *containerpb.CreateClusterRequest) (*containerpb.Operation, error) { + project, location, err := resolveLocation( + req.GetParent(), + req.GetProjectId(), + req.GetZone(), + ) + if err != nil { + return nil, err + } + + b.mu.Lock("CreateCluster") + defer b.mu.Unlock() + + b.sweepLocked() + + plan, err := b.planCluster(project, location, req.GetCluster()) + if err != nil { + return nil, err + } + + if _, exists := b.clusters[plan.ref.clusterKey()]; exists { + return nil, errExists(plan.ref.clusterName()) + } + + rec, err := b.buildClusterLocked(plan) + if err != nil { + return nil, err + } + + manual := b.engine != nil + o := b.newOpLocked( + plan.ref, + containerpb.Operation_CREATE_CLUSTER, + kindCreate, + rec.Cluster.GetSelfLink(), + b.timing.Create, + manual, + ) + b.clusters[plan.ref.clusterKey()] = rec + + if manual { + b.launchLocked(plan.ref, rec, o) + } + + return b.opViewLocked(o), nil +} + +func (b *InMemoryBackend) buildClusterLocked(p *clusterPlan) (*clusterRec, error) { + in := p.in + + net, err := b.resolveNet(p.ref.project, regionOf(p.ref.location), in) + if err != nil { + return nil, err + } + + rng, err := b.chooseRanges(p.ref.project, regionOf(p.ref.location), net.subnetwork, in) + if err != nil { + return nil, err + } + + cl := b.baseCluster(p, net, rng) + + if err = b.applyAccessInput(cl, in); err != nil { + return nil, err + } + + rec := &clusterRec{Project: p.ref.project, Cluster: cl} + + if err = b.addInitialPools(rec, p); err != nil { + return nil, err + } + + if err = b.stubIdentity(cl, p.ref.cluster); err != nil { + return nil, err + } + + cl.Etag = "" + + return rec, nil +} + +func (b *InMemoryBackend) baseCluster(p *clusterPlan, n netSpec, r ranges) *containerpb.Cluster { + cl := clone(p.in) + cl.NodePools = nil + cl.NodeConfig = nil + cl.Id = uuid.NewString() + cl.SelfLink = clusterSelfLink(p.ref.project, p.ref.location, p.ref.cluster) + cl.Location, cl.Zone = p.ref.location, p.ref.location + cl.CreateTime = rfc3339(b.now()) + cl.Status, cl.StatusMessage, cl.Conditions, cl.ExpireTime = containerpb.Cluster_PROVISIONING, "", nil, "" + cl.InitialClusterVersion, cl.CurrentMasterVersion, cl.CurrentNodeVersion = p.version, p.version, p.version + cl.Locations = p.zones + cl.Network, cl.Subnetwork = n.network, n.subnetwork + cl.LoggingService = firstNonEmpty(cl.GetLoggingService(), loggingService) + cl.MonitoringService = firstNonEmpty(cl.GetMonitoringService(), monitoringService) + cl.ClusterIpv4Cidr, cl.ServicesIpv4Cidr = r.pod.String(), r.services.String() + cl.NodeIpv4CidrSize = nodeCIDRSize + cl.LabelFingerprint = fingerprint(cl.GetResourceLabels()) + cl.LegacyAbac = firstPtr(cl.GetLegacyAbac(), &containerpb.LegacyAbac{}) + cl.ShieldedNodes = firstPtr(cl.GetShieldedNodes(), &containerpb.ShieldedNodes{Enabled: true}) + cl.DefaultMaxPodsConstraint = firstPtr( + cl.GetDefaultMaxPodsConstraint(), + &containerpb.MaxPodsConstraint{MaxPodsPerNode: defaultPodsMax}, + ) + cl.AddonsConfig = withAddonDefaults(cl.GetAddonsConfig()) + cl.LoggingConfig = firstPtr(cl.GetLoggingConfig(), defaultLoggingConfig()) + cl.MonitoringConfig = firstPtr(cl.GetMonitoringConfig(), defaultMonitoringConfig()) + cl.MaintenancePolicy = withPolicyVersion(cl.GetMaintenancePolicy()) + cl.NetworkConfig = withNetworkConfig(cl.GetNetworkConfig(), n, p.autopilot) + cl.IpAllocationPolicy = withIPPolicy(cl.GetIpAllocationPolicy(), n, r) + + if p.channel != containerpb.ReleaseChannel_UNSPECIFIED { + cl.ReleaseChannel = &containerpb.ReleaseChannel{Channel: p.channel} + } + + if p.autopilot { + cl.Autopilot = &containerpb.Autopilot{Enabled: true} + cl.VerticalPodAutoscaling = firstPtr( + cl.GetVerticalPodAutoscaling(), + &containerpb.VerticalPodAutoscaling{Enabled: true}, + ) + cl.WorkloadIdentityConfig = firstPtr(cl.GetWorkloadIdentityConfig(), + &containerpb.WorkloadIdentityConfig{WorkloadPool: p.ref.project + ".svc.id.goog"}) + } + + return cl +} + +func firstPtr[T any](cur, def *T) *T { + if cur != nil { + return cur + } + + return def +} + +func withAddonDefaults(a *containerpb.AddonsConfig) *containerpb.AddonsConfig { + a = firstPtr(a, &containerpb.AddonsConfig{}) + a.HttpLoadBalancing = firstPtr(a.GetHttpLoadBalancing(), &containerpb.HttpLoadBalancing{}) + a.HorizontalPodAutoscaling = firstPtr(a.GetHorizontalPodAutoscaling(), &containerpb.HorizontalPodAutoscaling{}) + a.NetworkPolicyConfig = firstPtr(a.GetNetworkPolicyConfig(), &containerpb.NetworkPolicyConfig{Disabled: true}) + + return a +} + +func defaultLoggingConfig() *containerpb.LoggingConfig { + return &containerpb.LoggingConfig{ComponentConfig: &containerpb.LoggingComponentConfig{ + EnableComponents: []containerpb.LoggingComponentConfig_Component{ + containerpb.LoggingComponentConfig_SYSTEM_COMPONENTS, containerpb.LoggingComponentConfig_WORKLOADS, + }, + }} +} + +func defaultMonitoringConfig() *containerpb.MonitoringConfig { + return &containerpb.MonitoringConfig{ + ComponentConfig: &containerpb.MonitoringComponentConfig{ + EnableComponents: []containerpb.MonitoringComponentConfig_Component{ + containerpb.MonitoringComponentConfig_SYSTEM_COMPONENTS, + }, + }, + ManagedPrometheusConfig: &containerpb.ManagedPrometheusConfig{Enabled: true}, + } +} + +func withPolicyVersion(m *containerpb.MaintenancePolicy) *containerpb.MaintenancePolicy { + m = firstPtr(m, &containerpb.MaintenancePolicy{}) + m.ResourceVersion = etagOf(&containerpb.MaintenancePolicy{Window: m.GetWindow()}) + + return m +} + +func withNetworkConfig(nc *containerpb.NetworkConfig, n netSpec, autopilot bool) *containerpb.NetworkConfig { + nc = firstPtr(nc, &containerpb.NetworkConfig{}) + nc.Network, nc.Subnetwork = n.netPath, n.subPath + + if nc.GetDatapathProvider() == containerpb.DatapathProvider_DATAPATH_PROVIDER_UNSPECIFIED { + nc.DatapathProvider = containerpb.DatapathProvider_LEGACY_DATAPATH + if autopilot { + nc.DatapathProvider = containerpb.DatapathProvider_ADVANCED_DATAPATH + } + } + + return nc +} + +func withIPPolicy(ip *containerpb.IPAllocationPolicy, n netSpec, r ranges) *containerpb.IPAllocationPolicy { + ip = firstPtr(ip, &containerpb.IPAllocationPolicy{UseIpAliases: true}) + if ip.GetUseRoutes() { + return ip + } + + ip.UseIpAliases = true + ip.SubnetworkName = firstNonEmpty(ip.GetSubnetworkName(), n.subnetwork) + ip.ClusterIpv4Cidr, ip.ServicesIpv4Cidr = r.pod.String(), r.services.String() + ip.ClusterIpv4CidrBlock, ip.ServicesIpv4CidrBlock = r.pod.String(), r.services.String() + + if ip.GetStackType() == containerpb.StackType_STACK_TYPE_UNSPECIFIED { + ip.StackType = containerpb.StackType_IPV4 + } + + return ip +} + +// applyAccessInput validates the master-access settings of a create request. +func (b *InMemoryBackend) applyAccessInput(cl, in *containerpb.Cluster) error { + if err := validMasterAuthNets( + in.GetMasterAuthorizedNetworksConfig(), + ); err != nil { + return err + } + + if err := validMaintenance(in.GetMaintenancePolicy()); err != nil { + return err + } + + if pw := in.GetMasterAuth(). + GetPassword(); pw != "" { + if err := validPassword(pw); err != nil { + return err + } + } + + return b.applyPrivate(cl, in) +} + +func (b *InMemoryBackend) applyPrivate(cl, in *containerpb.Cluster) error { + pc := in.GetPrivateClusterConfig() + if pc == nil { + return nil + } + + cl.PrivateClusterConfig = clone(pc) + + if !pc.GetEnablePrivateNodes() { + if pc.GetEnablePrivateEndpoint() { + return errInvalid("cluster.private_cluster_config", "A private endpoint requires private nodes.") + } + + return nil + } + + blk, err := parseCIDR( + "cluster.private_cluster_config.master_ipv4_cidr_block", + pc.GetMasterIpv4CidrBlock(), + masterPrefix, + masterPrefix, + ) + if err != nil { + return err + } + + if pod, perr := netip.ParsePrefix(cl.GetClusterIpv4Cidr()); perr == nil && pod.Overlaps(blk) { + return errInvalid( + "cluster.private_cluster_config.master_ipv4_cidr_block", + "The master range %s overlaps the pod range %s.", + blk, + pod, + ) + } + + cl.PrivateClusterConfig.PrivateEndpoint = u32ToAddr( + addrToU32(blk.Addr()) + privateEndpointIP, + ).String() + //nolint:staticcheck // deprecated field kept for wire compatibility + + return nil +} + +// addInitialPools creates the legacy default pool, the requested pools, or the Autopilot-managed pool. +func (b *InMemoryBackend) addInitialPools(rec *clusterRec, p *clusterPlan) error { + in := p.in + pc := poolCtx{ + ref: p.ref, + version: p.version, + zones: p.zones, + autoUpdate: p.channel != containerpb.ReleaseChannel_UNSPECIFIED, + } + + if p.autopilot { + rec.Pools = []*poolRec{autopilotPool(pc)} + + return nil + } + + pools := in.GetNodePools() + if len(pools) == 0 { + if in.GetInitialNodeCount() == 0 { + return errInvalid( + "cluster.initial_node_count", + "Either node_pools or initial_node_count must be specified.", + ) + } + + pools = []*containerpb.NodePool{ + { + Name: defaultPoolName, + Config: in.GetNodeConfig(), + InitialNodeCount: in.GetInitialNodeCount(), + }, + } + rec.Cluster.NodeConfig = withNodeDefaults( + in.GetNodeConfig(), + ) + } + + seen := map[string]bool{} + + for _, np := range pools { + pr, err := b.buildPoolLocked(pc, np) + if err != nil { + return err + } + + if seen[pr.Pool.GetName()] { + return errInvalid("cluster.node_pools", "Duplicate node pool name %q.", pr.Pool.GetName()) + } + + seen[pr.Pool.GetName()] = true + rec.Pools = append(rec.Pools, pr) + } + + return nil +} + +func autopilotPool(pc poolCtx) *poolRec { + pc.ref.pool = defaultPoolName + + return &poolRec{Pool: &containerpb.NodePool{ + Name: defaultPoolName, Config: withNodeDefaults(nil), Version: pc.version, Locations: pc.zones, + SelfLink: poolSelfLink(pc.ref), + InstanceGroupUrls: groupURLs(pc.ref.project, pc.zones, pc.ref.cluster, defaultPoolName), + Autoscaling: &containerpb.NodePoolAutoscaling{Enabled: true, Autoprovisioned: true}, + Management: &containerpb.NodeManagement{AutoUpgrade: true, AutoRepair: true}, + MaxPodsConstraint: &containerpb.MaxPodsConstraint{MaxPodsPerNode: defaultPodsMax}, + PodIpv4CidrSize: podPrefix(defaultPodsMax), + }} +} + +// stubIdentity gives the cluster a synthetic endpoint and a real CA certificate; the docker engine replaces both. +func (b *InMemoryBackend) stubIdentity(cl *containerpb.Cluster, name string) error { + ca, err := k3sengine.NewCA("gopherstack-gke-" + name + "-ca") + if err != nil { + return fmt.Errorf("cluster CA: %w", err) + } + + b.seq++ + cl.Endpoint = documentationAddr(b.seq) + cl.MasterAuth = firstPtr(cl.GetMasterAuth(), &containerpb.MasterAuth{}) + cl.MasterAuth.ClusterCaCertificate = base64.StdEncoding.EncodeToString(ca.CertPEM) + cl.MasterAuth.ClientCertificateConfig = firstPtr( + cl.GetMasterAuth().GetClientCertificateConfig(), + &containerpb.ClientCertificateConfig{}, + ) + + return nil +} + +// documentationAddr hands out addresses from the RFC 5737 documentation ranges. +func documentationAddr(n uint64) string { + bases := []string{"203.0.113.", "198.51.100.", "192.0.2."} + const hosts = 253 + + i := (n - 1) % (hosts * uint64(len(bases))) + + return fmt.Sprintf("%s%d", bases[i/hosts], i%hosts+1) +} + +// GetCluster returns one cluster. +func (b *InMemoryBackend) GetCluster(req *containerpb.GetClusterRequest) (*containerpb.Cluster, error) { + r, err := resolveCluster( + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + ) + if err != nil { + return nil, err + } + + b.mu.RLock("GetCluster") + defer b.mu.RUnlock() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + return b.clusterViewLocked(c), nil +} + +func (b *InMemoryBackend) liveClusterLocked(r ref) (*clusterRec, error) { + c, ok := b.clusters[r.clusterKey()] + if !ok || (c.DeleteOp != "" && b.deleteDoneLocked(r.clusterKey(), c.DeleteOp)) { + return nil, errNotFound(r.clusterName()) + } + + return c, nil +} + +// ListClusters lists the clusters of a location, or of every location for "-". +func (b *InMemoryBackend) ListClusters( + req *containerpb.ListClustersRequest, +) (*containerpb.ListClustersResponse, error) { + project, location, err := resolveLocation( + req.GetParent(), + req.GetProjectId(), + req.GetZone(), + ) + if err != nil { + return nil, err + } + + if location != wildcard && !validLocation(location) { + return nil, errNotFound(locationPath(project, location)) + } + + b.mu.RLock("ListClusters") + defer b.mu.RUnlock() + + resp := &containerpb.ListClustersResponse{} + + for k, c := range b.clusters { + if c.Project != project || (location != wildcard && c.Cluster.GetLocation() != location) { + continue + } + + if c.DeleteOp != "" && b.deleteDoneLocked(k, c.DeleteOp) { + continue + } + + resp.Clusters = append(resp.Clusters, b.clusterViewLocked(c)) + } + + slices.SortFunc(resp.GetClusters(), func(a, c *containerpb.Cluster) int { + return strings.Compare(a.GetSelfLink(), c.GetSelfLink()) + }) + + return resp, nil +} + +// DeleteCluster starts deletion and returns the DELETE_CLUSTER operation. +func (b *InMemoryBackend) DeleteCluster(req *containerpb.DeleteClusterRequest) (*containerpb.Operation, error) { + r, err := resolveCluster( + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + ) + if err != nil { + return nil, err + } + + b.mu.Lock("DeleteCluster") + defer b.mu.Unlock() + + b.sweepLocked() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + if c.DeleteOp != "" { + return nil, errBusy(r.clusterName(), c.DeleteOp) + } + + for _, o := range b.activeOpsLocked(r.project, r.location, r.cluster) { + b.finishOpLocked(o, "") + } + + o := b.newOpLocked( + r, + containerpb.Operation_DELETE_CLUSTER, + kindDelete, + c.Cluster.GetSelfLink(), + b.timing.Delete, + false, + ) + c.DeleteOp = o.Op.GetName() + b.reapLocked(r.clusterKey()) + + return b.opViewLocked(o), nil +} diff --git a/services/gcpcontainer/clusters_test.go b/services/gcpcontainer/clusters_test.go new file mode 100644 index 0000000000..3841ad1dc4 --- /dev/null +++ b/services/gcpcontainer/clusters_test.go @@ -0,0 +1,477 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/netip" + "strings" + "testing" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" +) + +func TestClusterLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t) + op := e.create(t, zone, &containerpb.Cluster{ + Name: "dev", InitialNodeCount: 2, Description: "d", ResourceLabels: map[string]string{"env": "dev"}, + NodeConfig: &containerpb.NodeConfig{MachineType: "n2-standard-4", DiskSizeGb: 50}, + }) + + assert.True(t, strings.HasPrefix(op.GetName(), "operation-")) + assert.Equal(t, containerpb.Operation_CREATE_CLUSTER, op.GetOperationType()) + assert.Equal(t, containerpb.Operation_RUNNING, op.GetStatus()) + assert.Equal(t, zone, op.GetZone()) + assert.Equal(t, zone, op.GetLocation()) + assert.Equal(t, "https://container.googleapis.com/v1/"+zoneLoc+"/operations/"+op.GetName(), op.GetSelfLink()) + assert.Equal(t, "https://container.googleapis.com/v1/"+clusterPath(zone, "dev"), op.GetTargetLink()) + assert.NotEmpty(t, op.GetStartTime()) + + c := e.getCluster(t, zone, "dev") + assert.Equal(t, containerpb.Cluster_PROVISIONING, c.GetStatus()) + assert.Equal(t, "dev", c.GetName()) + assert.Equal(t, zone, c.GetLocation()) + assert.Equal(t, zone, c.GetZone()) + assert.Equal(t, op.GetTargetLink(), c.GetSelfLink()) + assert.Equal(t, "default", c.GetNetwork()) + assert.Equal(t, "default", c.GetSubnetwork()) + assert.Equal(t, "projects/"+project+"/global/networks/default", c.GetNetworkConfig().GetNetwork()) + assert.Equal(t, "2026-10-26T12:00:00+00:00", c.GetCreateTime()) + assert.NotEmpty(t, c.GetId()) + assert.NotEmpty(t, c.GetEndpoint()) + assert.Equal(t, []string{zone}, c.GetLocations()) + assert.Equal(t, int32(2), c.GetCurrentNodeCount()) + assert.Equal(t, "logging.googleapis.com/kubernetes", c.GetLoggingService()) + assert.Equal(t, "monitoring.googleapis.com/kubernetes", c.GetMonitoringService()) + assert.Equal(t, c.GetCurrentMasterVersion(), c.GetInitialClusterVersion()) + assert.Contains(t, c.GetCurrentMasterVersion(), "-gke.") + assert.Equal(t, map[string]string{"env": "dev"}, c.GetResourceLabels()) + assert.NotEmpty(t, c.GetLabelFingerprint()) + assert.NotEmpty(t, c.GetEtag()) + assert.True(t, c.GetShieldedNodes().GetEnabled()) + assert.NotEmpty(t, c.GetMaintenancePolicy().GetResourceVersion()) + + block, _ := pem.Decode(mustB64(t, c.GetMasterAuth().GetClusterCaCertificate())) + require.NotNil(t, block) + _, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + ip := c.GetIpAllocationPolicy() + assert.True(t, ip.GetUseIpAliases()) + + pod := netip.MustParsePrefix(ip.GetClusterIpv4CidrBlock()) + svc := netip.MustParsePrefix(ip.GetServicesIpv4CidrBlock()) + assert.Equal(t, 14, pod.Bits()) + assert.Equal(t, 20, svc.Bits()) + assert.False(t, pod.Overlaps(svc)) + assert.Equal(t, ip.GetClusterIpv4CidrBlock(), c.GetClusterIpv4Cidr()) + + require.Len(t, c.GetNodePools(), 1) + + np := c.GetNodePools()[0] + assert.Equal(t, "default-pool", np.GetName()) + assert.Equal(t, containerpb.NodePool_PROVISIONING, np.GetStatus()) + assert.Equal(t, "n2-standard-4", np.GetConfig().GetMachineType()) + assert.Equal(t, int32(50), np.GetConfig().GetDiskSizeGb()) + assert.Equal(t, "COS_CONTAINERD", np.GetConfig().GetImageType()) + assert.Equal(t, c.GetCurrentMasterVersion(), np.GetVersion()) + assert.True(t, np.GetManagement().GetAutoRepair()) + assert.Len(t, np.GetInstanceGroupUrls(), 1) + assert.Equal(t, int64(110), np.GetMaxPodsConstraint().GetMaxPodsPerNode()) + assert.Equal(t, int32(24), np.GetPodIpv4CidrSize()) + assert.Equal(t, np.GetInstanceGroupUrls(), c.GetInstanceGroupUrls()) + + assert.Equal(t, containerpb.Operation_RUNNING, e.getOp(t, op).GetStatus()) + + e.clock.Advance(599 * time.Millisecond) + assert.Equal(t, containerpb.Cluster_PROVISIONING, e.getCluster(t, zone, "dev").GetStatus()) + + e.clock.Advance(time.Millisecond) + + done := e.getOp(t, op) + assert.Equal(t, containerpb.Operation_DONE, done.GetStatus()) + assert.Equal(t, "2026-10-26T12:00:00.6Z", done.GetEndTime()) + assert.Nil(t, done.GetError()) + + c = e.getCluster(t, zone, "dev") + assert.Equal(t, containerpb.Cluster_RUNNING, c.GetStatus()) + assert.Equal(t, containerpb.NodePool_RUNNING, c.GetNodePools()[0].GetStatus()) + + list, err := e.gc.ListClusters( + t.Context(), + &containerpb.ListClustersRequest{Parent: "projects/" + project + "/locations/-"}, + ) + require.NoError(t, err) + require.Len(t, list.GetClusters(), 1) + + delOp, err := e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "dev")}) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_DELETE_CLUSTER, delOp.GetOperationType()) + assert.Equal(t, containerpb.Cluster_STOPPING, e.getCluster(t, zone, "dev").GetStatus()) + + _, err = e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "dev")}) + assertCode(t, codes.FailedPrecondition, err, "incompatible operation "+delOp.GetName()) + + e.settle() + + _, err = e.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: clusterPath(zone, "dev")}) + assertCode(t, codes.NotFound, err, "Not found: "+clusterPath(zone, "dev")) + + list, err = e.gc.ListClusters(t.Context(), &containerpb.ListClustersRequest{Parent: zoneLoc}) + require.NoError(t, err) + assert.Empty(t, list.GetClusters()) + + ops, err := e.gc.ListOperations(t.Context(), &containerpb.ListOperationsRequest{Parent: zoneLoc}) + require.NoError(t, err) + require.Len(t, ops.GetOperations(), 2) + assert.Equal(t, containerpb.Operation_DONE, ops.GetOperations()[1].GetStatus()) +} + +func mustB64(t *testing.T, s string) []byte { + t.Helper() + + raw, err := base64.StdEncoding.DecodeString(s) + require.NoError(t, err) + + return raw +} + +func TestCreateClusterValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + cluster *containerpb.Cluster + name string + loc string + want string + }{ + {&containerpb.Cluster{Name: "Bad_Name", InitialNodeCount: 1}, "bad name", zone, "Bad_Name"}, + {&containerpb.Cluster{InitialNodeCount: 1}, "no name", zone, "must be specified"}, + {&containerpb.Cluster{Name: "a"}, "no pools", zone, "node_pools or initial_node_count"}, + { + &containerpb.Cluster{Name: "a", InitialNodeCount: 1}, + "bad location", + "mars-central1-a", + "not a valid zone or region", + }, + { + &containerpb.Cluster{Name: "a", InitialNodeCount: 1, InitialClusterVersion: "9.9"}, + "bad version", + zone, + "unsupported", + }, + { + &containerpb.Cluster{ + Name: "a", InitialNodeCount: 1, InitialClusterVersion: "1.35.1", + ReleaseChannel: &containerpb.ReleaseChannel{Channel: containerpb.ReleaseChannel_STABLE}, + }, "version outside channel", zone, "STABLE", + }, + { + &containerpb.Cluster{Name: "a", InitialNodeCount: 1, ResourceLabels: map[string]string{"Bad": "x"}}, + "bad label", zone, "Invalid label key", + }, + { + &containerpb.Cluster{Name: "a", InitialNodeCount: 1, IpAllocationPolicy: &containerpb.IPAllocationPolicy{ + ClusterIpv4CidrBlock: "10.0.0.0/14", ServicesIpv4CidrBlock: "10.0.0.0/20", + }}, "overlapping ranges", zone, "overlaps", + }, + { + &containerpb.Cluster{Name: "a", InitialNodeCount: 1, IpAllocationPolicy: &containerpb.IPAllocationPolicy{ + ClusterIpv4CidrBlock: "10.0.0.1/14", + }}, "host bits", zone, "Invalid CIDR", + }, + { + &containerpb.Cluster{Name: "a", InitialNodeCount: 1, IpAllocationPolicy: &containerpb.IPAllocationPolicy{ + ClusterIpv4CidrBlock: "/30", + }}, "pod range too small", zone, "between /8 and /21", + }, + { + &containerpb.Cluster{Name: "a", InitialNodeCount: 1, NodeConfig: &containerpb.NodeConfig{DiskSizeGb: 1}}, + "disk too small", zone, "Disk size", + }, + { + &containerpb.Cluster{ + Name: "a", + InitialNodeCount: 1, + NodeConfig: &containerpb.NodeConfig{Spot: true, Preemptible: true}, + }, + "spot and preemptible", + zone, + "cannot both be set", + }, + { + &containerpb.Cluster{ + Name: "a", + InitialNodeCount: 1, + NodeConfig: &containerpb.NodeConfig{MachineType: "Bad Type"}, + }, + "bad machine type", + zone, + "Invalid machine type", + }, + { + &containerpb.Cluster{Name: "a", NodePools: []*containerpb.NodePool{ + {Name: "p", InitialNodeCount: 1}, {Name: "p", InitialNodeCount: 1}, + }}, "duplicate pool", zone, "Duplicate node pool", + }, + { + &containerpb.Cluster{Name: "a", Locations: []string{"europe-west1-b"}, InitialNodeCount: 1}, + "foreign zone", zone, "not a zone of", + }, + { + &containerpb.Cluster{ + Name: "a", + InitialNodeCount: 1, + PrivateClusterConfig: &containerpb.PrivateClusterConfig{EnablePrivateNodes: true}, + }, + "private without master range", + zone, + "master_ipv4_cidr_block", + }, + { + &containerpb.Cluster{Name: "a", Autopilot: &containerpb.Autopilot{Enabled: true}}, + "zonal autopilot", zone, "must be regional", + }, + { + &containerpb.Cluster{Name: "a", Autopilot: &containerpb.Autopilot{Enabled: true}, InitialNodeCount: 3}, + "autopilot with nodes", region, "manage their own nodes", + }, + { + &containerpb.Cluster{Name: "a", NodePools: []*containerpb.NodePool{{Name: "p"}}}, + "pool without nodes", zone, "greater than 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.gc.CreateCluster(t.Context(), &containerpb.CreateClusterRequest{ + Parent: "projects/" + project + "/locations/" + tt.loc, Cluster: tt.cluster, + }) + assertCode(t, codes.InvalidArgument, err, tt.want) + assert.True(t, hasDetail[*errdetails.BadRequest](err), "want BadRequest detail") + }) + } +} + +func TestClusterNotFoundAndExists(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("dup")) + + _, err := e.gc.CreateCluster( + t.Context(), + &containerpb.CreateClusterRequest{Parent: zoneLoc, Cluster: stdCluster("dup")}, + ) + assertCode(t, codes.AlreadyExists, err, "Already exists: "+clusterPath(zone, "dup")) + assert.True(t, hasDetail[*errdetails.ResourceInfo](err)) + + e.ready(t, "us-east1-b", stdCluster("dup")) + + missing := clusterPath(zone, "missing") + _, err = e.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: missing}) + assertCode(t, codes.NotFound, err, "Not found: "+missing) + assert.True(t, hasDetail[*errdetails.ResourceInfo](err)) + + _, err = e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: missing}) + assertCode(t, codes.NotFound, err, "") + + _, err = e.gc.UpdateCluster(t.Context(), &containerpb.UpdateClusterRequest{ + Name: missing, Update: &containerpb.ClusterUpdate{DesiredMonitoringService: "none"}, + }) + assertCode(t, codes.NotFound, err, "") + + _, err = e.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: "garbage"}) + assertCode(t, codes.InvalidArgument, err, "Invalid cluster name") + + _, err = e.gc.GetOperation(t.Context(), &containerpb.GetOperationRequest{Name: zoneLoc + "/operations/nope"}) + assertCode(t, codes.NotFound, err, "") + + _, err = e.gc.ListClusters( + t.Context(), + &containerpb.ListClustersRequest{Parent: "projects/" + project + "/locations/mars-1"}, + ) + assertCode(t, codes.NotFound, err, "") +} + +func TestLegacyRequestFields(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + _, err := e.gc.CreateCluster(t.Context(), &containerpb.CreateClusterRequest{ + ProjectId: project, Zone: zone, Cluster: stdCluster("legacy"), + }) + require.NoError(t, err) + e.settle() + + got, err := e.gc.GetCluster( + t.Context(), + &containerpb.GetClusterRequest{ProjectId: project, Zone: zone, ClusterId: "legacy"}, + ) + require.NoError(t, err) + assert.Equal(t, containerpb.Cluster_RUNNING, got.GetStatus()) + + list, err := e.gc.ListClusters(t.Context(), &containerpb.ListClustersRequest{ProjectId: project, Zone: zone}) + require.NoError(t, err) + assert.Len(t, list.GetClusters(), 1) + + _, err = e.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{ProjectId: project, Zone: zone}) + assertCode(t, codes.InvalidArgument, err, "Cluster ID") + + _, err = e.gc.DeleteCluster( + t.Context(), + &containerpb.DeleteClusterRequest{ProjectId: project, Zone: zone, ClusterId: "legacy"}, + ) + require.NoError(t, err) +} + +func TestRegionalCluster(t *testing.T) { + t.Parallel() + + e := newEnv(t) + c := e.ready(t, region, &containerpb.Cluster{ + Name: "reg", NodePools: []*containerpb.NodePool{{Name: "p", InitialNodeCount: 2}}, + }) + + assert.Equal(t, []string{"us-central1-a", "us-central1-b", "us-central1-c"}, c.GetLocations()) + assert.Equal(t, region, c.GetLocation()) + assert.Equal(t, int32(6), c.GetCurrentNodeCount()) + assert.Len(t, c.GetInstanceGroupUrls(), 3) + assert.Equal(t, c.GetLocations(), c.GetNodePools()[0].GetLocations()) + + sub := e.ready(t, region, &containerpb.Cluster{ + Name: "two-zone", Locations: []string{"us-central1-b", "us-central1-a"}, + NodePools: []*containerpb.NodePool{{Name: "p", InitialNodeCount: 3}}, + }) + assert.Equal(t, []string{"us-central1-a", "us-central1-b"}, sub.GetLocations()) + assert.Equal(t, int32(6), sub.GetCurrentNodeCount()) +} + +func TestAutopilotCluster(t *testing.T) { + t.Parallel() + + e := newEnv(t) + c := e.ready(t, region, &containerpb.Cluster{Name: "ap", Autopilot: &containerpb.Autopilot{Enabled: true}}) + + assert.True(t, c.GetAutopilot().GetEnabled()) + assert.Equal(t, containerpb.ReleaseChannel_REGULAR, c.GetReleaseChannel().GetChannel()) + assert.Equal(t, containerpb.DatapathProvider_ADVANCED_DATAPATH, c.GetNetworkConfig().GetDatapathProvider()) + assert.Equal(t, project+".svc.id.goog", c.GetWorkloadIdentityConfig().GetWorkloadPool()) + require.Len(t, c.GetNodePools(), 1) + + _, err := e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: clusterPath(region, "ap"), NodePool: &containerpb.NodePool{Name: "x", InitialNodeCount: 1}, + }) + assertCode(t, codes.FailedPrecondition, err, "Autopilot") + + _, err = e.gc.DeleteNodePool( + t.Context(), + &containerpb.DeleteNodePoolRequest{Name: poolPath(region, "ap", "default-pool")}, + ) + assertCode(t, codes.FailedPrecondition, err, "managed by Google") + + _, err = e.gc.SetLegacyAbac( + t.Context(), + &containerpb.SetLegacyAbacRequest{Name: clusterPath(region, "ap"), Enabled: true}, + ) + assertCode(t, codes.InvalidArgument, err, "Autopilot") +} + +func TestReleaseChannelVersions(t *testing.T) { + t.Parallel() + + cfg, err := newEnv(t).gc.GetServerConfig(t.Context(), &containerpb.GetServerConfigRequest{Name: zoneLoc}) + require.NoError(t, err) + + for _, ch := range cfg.GetChannels() { + t.Run(ch.GetChannel().String(), func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + def := e.ready(t, zone, &containerpb.Cluster{ + Name: "def", InitialNodeCount: 1, ReleaseChannel: &containerpb.ReleaseChannel{Channel: ch.GetChannel()}, + }) + assert.Equal(t, ch.GetDefaultVersion(), def.GetCurrentMasterVersion()) + assert.Equal(t, ch.GetChannel(), def.GetReleaseChannel().GetChannel()) + assert.True(t, def.GetNodePools()[0].GetManagement().GetAutoUpgrade()) + + latest := e.ready(t, zone, &containerpb.Cluster{ + Name: "latest", InitialNodeCount: 1, InitialClusterVersion: "latest", + ReleaseChannel: &containerpb.ReleaseChannel{Channel: ch.GetChannel()}, + }) + assert.Equal(t, ch.GetValidVersions()[0], latest.GetCurrentMasterVersion()) + + last := ch.GetValidVersions()[len(ch.GetValidVersions())-1] + pinned := e.ready(t, zone, &containerpb.Cluster{ + Name: "pinned", InitialNodeCount: 1, InitialClusterVersion: last, + ReleaseChannel: &containerpb.ReleaseChannel{Channel: ch.GetChannel()}, + }) + assert.Equal(t, last, pinned.GetCurrentMasterVersion()) + }) + } +} + +func TestVersionPrefixResolution(t *testing.T) { + t.Parallel() + + e := newEnv(t) + c := e.ready(t, zone, &containerpb.Cluster{Name: "minor", InitialNodeCount: 1, InitialClusterVersion: "1.33"}) + assert.True(t, strings.HasPrefix(c.GetCurrentMasterVersion(), "1.33."), c.GetCurrentMasterVersion()) +} + +func TestIncompatibleOperations(t *testing.T) { + t.Parallel() + + e := newEnv(t) + create := e.create(t, zone, stdCluster("busy")) + + fp := e.getCluster(t, zone, "busy").GetLabelFingerprint() + _, err := e.gc.SetLabels(t.Context(), &containerpb.SetLabelsRequest{ + Name: clusterPath(zone, "busy"), ResourceLabels: map[string]string{"a": "b"}, LabelFingerprint: fp, + }) + assertCode(t, codes.FailedPrecondition, err, "incompatible operation "+create.GetName()) + assert.True(t, hasDetail[*errdetails.PreconditionFailure](err)) + + _, err = e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: clusterPath(zone, "busy"), NodePool: &containerpb.NodePool{Name: "x", InitialNodeCount: 1}, + }) + assertCode(t, codes.FailedPrecondition, err, "incompatible operation") + + e.settle() + + _, err = e.gc.SetLabels(t.Context(), &containerpb.SetLabelsRequest{ + Name: clusterPath(zone, "busy"), ResourceLabels: map[string]string{"a": "b"}, LabelFingerprint: fp, + }) + require.NoError(t, err) +} + +func TestDeleteWhileProvisioningAbortsCreate(t *testing.T) { + t.Parallel() + + e := newEnv(t) + create := e.create(t, zone, stdCluster("abort")) + + _, err := e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "abort")}) + require.NoError(t, err) + + assert.Equal(t, containerpb.Operation_DONE, e.getOp(t, create).GetStatus()) + assert.Equal(t, containerpb.Cluster_STOPPING, e.getCluster(t, zone, "abort").GetStatus()) + + e.settle() + + _, err = e.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: clusterPath(zone, "abort")}) + assertCode(t, codes.NotFound, err, "") +} diff --git a/services/gcpcontainer/compute_test.go b/services/gcpcontainer/compute_test.go new file mode 100644 index 0000000000..7d01fc0192 --- /dev/null +++ b/services/gcpcontainer/compute_test.go @@ -0,0 +1,200 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "net/http/httptest" + "testing" + + compute "cloud.google.com/go/compute/apiv1" + "cloud.google.com/go/compute/apiv1/computepb" + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/option" + "google.golang.org/grpc/codes" + + "github.com/blackbirdworks/gopherstack/services/gcpcompute" + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +func computeEnv(t *testing.T) *env { + t.Helper() + + cb := gcpcompute.NewInMemoryBackend() + e := echo.New() + e.Any("/*", gcpcompute.NewHandler(cb).Handler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + opts := []option.ClientOption{option.WithEndpoint(srv.URL), option.WithoutAuthentication()} + + nets, err := compute.NewNetworksRESTClient(t.Context(), opts...) + require.NoError(t, err) + t.Cleanup(func() { _ = nets.Close() }) + + _, err = nets.Insert(t.Context(), &computepb.InsertNetworkRequest{ + Project: project, NetworkResource: &computepb.Network{Name: new("vpc"), AutoCreateSubnetworks: new(false)}, + }) + require.NoError(t, err) + + subs, err := compute.NewSubnetworksRESTClient(t.Context(), opts...) + require.NoError(t, err) + t.Cleanup(func() { _ = subs.Close() }) + + _, err = subs.Insert(t.Context(), &computepb.InsertSubnetworkRequest{ + Project: project, Region: region, SubnetworkResource: &computepb.Subnetwork{ + Name: new( + "sub", + ), + Network: new("projects/" + project + "/global/networks/vpc"), + IpCidrRange: new("10.10.0.0/20"), + SecondaryIpRanges: []*computepb.SubnetworkSecondaryRange{ + {RangeName: new("pods"), IpCidrRange: new("172.20.0.0/16")}, + {RangeName: new("svcs"), IpCidrRange: new("172.21.0.0/24")}, + }, + }, + }) + require.NoError(t, err) + + b := gcpcontainer.NewInMemoryBackend() + b.SetCompute(cb) + + return newEnvWith(t, b) +} + +func TestClusterNetworkValidationAgainstCompute(t *testing.T) { + t.Parallel() + + tests := []struct { + cluster *containerpb.Cluster + check func(t *testing.T, c *containerpb.Cluster) + name string + want string + }{ + { + name: "default network needs no compute network", + cluster: &containerpb.Cluster{Name: "d", InitialNodeCount: 1}, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, "default", c.GetNetwork()) + }, + }, + { + name: "vpc and subnet", cluster: &containerpb.Cluster{ + Name: "v", InitialNodeCount: 1, Network: "vpc", Subnetwork: "sub", + }, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, "projects/"+project+"/global/networks/vpc", c.GetNetworkConfig().GetNetwork()) + assert.Equal( + t, + "projects/"+project+"/regions/"+region+"/subnetworks/sub", + c.GetNetworkConfig().GetSubnetwork(), + ) + }, + }, + { + name: "full resource paths", cluster: &containerpb.Cluster{ + Name: "p", InitialNodeCount: 1, + Network: "projects/" + project + "/global/networks/vpc", + Subnetwork: "projects/" + project + "/regions/" + region + "/subnetworks/sub", + }, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, "vpc", c.GetNetwork()) + assert.Equal(t, "sub", c.GetSubnetwork()) + }, + }, + { + name: "secondary ranges", cluster: &containerpb.Cluster{ + Name: "s", InitialNodeCount: 1, Network: "vpc", Subnetwork: "sub", + IpAllocationPolicy: &containerpb.IPAllocationPolicy{ + UseIpAliases: true, ClusterSecondaryRangeName: "pods", ServicesSecondaryRangeName: "svcs", + }, + }, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, "172.20.0.0/16", c.GetIpAllocationPolicy().GetClusterIpv4CidrBlock()) + assert.Equal(t, "172.21.0.0/24", c.GetIpAllocationPolicy().GetServicesIpv4CidrBlock()) + assert.Equal(t, "pods", c.GetIpAllocationPolicy().GetClusterSecondaryRangeName()) + }, + }, + { + name: "missing network", want: "Network \"nope\" does not exist", + cluster: &containerpb.Cluster{Name: "n", InitialNodeCount: 1, Network: "nope"}, + }, + { + name: "missing subnet", want: "Subnetwork \"nope\" does not exist", + cluster: &containerpb.Cluster{Name: "n", InitialNodeCount: 1, Network: "vpc", Subnetwork: "nope"}, + }, + { + name: "missing secondary range", want: "Secondary range \"ghost\" does not exist", + cluster: &containerpb.Cluster{ + Name: "n", InitialNodeCount: 1, Network: "vpc", Subnetwork: "sub", + IpAllocationPolicy: &containerpb.IPAllocationPolicy{ClusterSecondaryRangeName: "ghost"}, + }, + }, + { + name: "unknown machine type", + want: "does not exist", + cluster: &containerpb.Cluster{ + Name: "n", + InitialNodeCount: 1, + NodeConfig: &containerpb.NodeConfig{MachineType: "z9-huge"}, + }, + }, + { + name: "custom machine type", cluster: &containerpb.Cluster{ + Name: "c", InitialNodeCount: 1, NodeConfig: &containerpb.NodeConfig{MachineType: "e2-custom-2-4096"}, + }, + check: func(t *testing.T, c *containerpb.Cluster) { + t.Helper() + assert.Equal(t, "e2-custom-2-4096", c.GetNodePools()[0].GetConfig().GetMachineType()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := computeEnv(t) + + _, err := e.gc.CreateCluster( + t.Context(), + &containerpb.CreateClusterRequest{Parent: zoneLoc, Cluster: tt.cluster}, + ) + if tt.want != "" { + assertCode(t, codes.InvalidArgument, err, tt.want) + + return + } + + require.NoError(t, err) + tt.check(t, e.getCluster(t, zone, tt.cluster.GetName())) + }) + } +} + +func TestNodePoolMachineTypeAgainstCompute(t *testing.T) { + t.Parallel() + + e := computeEnv(t) + e.ready(t, zone, stdCluster("mt")) + + _, err := e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: clusterPath(zone, "mt"), NodePool: &containerpb.NodePool{ + Name: "big", InitialNodeCount: 1, Config: &containerpb.NodeConfig{MachineType: "n2-standard-64"}, + }, + }) + require.NoError(t, err) + + _, err = e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: clusterPath(zone, "mt"), NodePool: &containerpb.NodePool{ + Name: "bad", InitialNodeCount: 1, Config: &containerpb.NodeConfig{MachineType: "n2-standard-3"}, + }, + }) + assertCode(t, codes.InvalidArgument, err, "does not exist") +} diff --git a/services/gcpcontainer/docker_test.go b/services/gcpcontainer/docker_test.go new file mode 100644 index 0000000000..7c40428f14 --- /dev/null +++ b/services/gcpcontainer/docker_test.go @@ -0,0 +1,167 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "io" + "net/http" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +const k3sStartWait = 6 * time.Minute + +type recordingRuntime struct { + container.Runtime + ids []string + mu sync.Mutex +} + +func (r *recordingRuntime) CreateAndStart(ctx context.Context, spec container.Spec) (string, error) { + id, err := r.Runtime.CreateAndStart(ctx, spec) + if err == nil { + r.mu.Lock() + r.ids = append(r.ids, id) + r.mu.Unlock() + } + + return id, err +} + +func (r *recordingRuntime) containerIDs() []string { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]string(nil), r.ids...) +} + +func dockerHasContainer(t *testing.T, id string) bool { + t.Helper() + + out, err := exec.CommandContext(t.Context(), "docker", "ps", "-aq", "--no-trunc", "--filter", "id="+id).Output() + require.NoError(t, err) + + return strings.TrimSpace(string(out)) != "" +} + +func kubeGet(t *testing.T, endpoint, caB64, token, path string) (int, string) { + t.Helper() + + pemBytes, err := base64.StdEncoding.DecodeString(caB64) + require.NoError(t, err) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(pemBytes), "masterAuth.clusterCaCertificate must be base64 PEM") + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + DisableKeepAlives: true, + } + defer tr.CloseIdleConnections() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://"+endpoint+path, nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := (&http.Client{Transport: tr}).Do(req) + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(body) +} + +// TestDockerClusterRealK3s starts a real k3s container and talks to its API server with the CA the emulator +// returns. It skips when no usable container runtime is present. +func TestDockerClusterRealK3s(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + + rt, err := container.NewRuntime(container.Config{}) + if err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + if err = rt.Ping(t.Context()); err != nil { + t.Skipf("container daemon unreachable: %v", err) + } + + image, _ := k3sengine.Image("1.34", "1.34") + + probe, err := rt.CreateAndStart( + t.Context(), + container.Spec{Image: image, Entrypoint: []string{"/bin/true"}, Privileged: true}, + ) + if err != nil { + t.Skipf("privileged containers unavailable: %v", err) + } + + _ = rt.StopAndRemove(t.Context(), probe) + + rec := &recordingRuntime{Runtime: rt} + b := gcpcontainer.NewInMemoryBackend() + require.NoError(t, b.EnableEngine(gcpcontainer.EngineConfig{Runtime: rec, StartTimeout: k3sStartWait})) + + e := newEnvWith(t, b) + op := e.create(t, zone, &containerpb.Cluster{Name: "real", InitialNodeCount: 1, InitialClusterVersion: "1.34"}) + + require.Eventually(t, func() bool { + c := e.getCluster(t, zone, "real") + require.NotEqual(t, containerpb.Cluster_ERROR, c.GetStatus(), c.GetStatusMessage()) + + return e.getOp(t, op).GetStatus() == containerpb.Operation_DONE + }, k3sStartWait, time.Second) + + c := e.getCluster(t, zone, "real") + require.Equal(t, containerpb.Cluster_RUNNING, c.GetStatus()) + require.True(t, strings.HasPrefix(c.GetEndpoint(), "127.0.0.1:"), c.GetEndpoint()) + + code, body := kubeGet( + t, + c.GetEndpoint(), + c.GetMasterAuth().GetClusterCaCertificate(), + b.EngineToken(), + "/api/v1/namespaces", + ) + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, body, "kube-system") + + code, _ = kubeGet( + t, + c.GetEndpoint(), + c.GetMasterAuth().GetClusterCaCertificate(), + "wrong-token", + "/api/v1/namespaces", + ) + assert.Equal(t, http.StatusUnauthorized, code) + + ids := rec.containerIDs() + require.Len(t, ids, 1) + require.True(t, dockerHasContainer(t, ids[0])) + + _, err = e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "real")}) + require.NoError(t, err) + + require.Eventually(t, func() bool { return !dockerHasContainer(t, ids[0]) }, 2*time.Minute, time.Second) +} diff --git a/services/gcpcontainer/engine.go b/services/gcpcontainer/engine.go new file mode 100644 index 0000000000..63e8677e70 --- /dev/null +++ b/services/gcpcontainer/engine.go @@ -0,0 +1,264 @@ +package gcpcontainer + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "log/slog" + "sync" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const ( + joinTokenBytes = 16 + engineFallback = "1.34" + engineStartCode = "CLUSTER_START_FAILED" +) + +// EngineConfig configures docker-backed clusters: each CreateCluster starts a single-node k3s container. +type EngineConfig struct { + Runtime k3sengine.Runtime + Ports *portalloc.Allocator + Logger *slog.Logger + Probe func(ctx context.Context, addr, token string) ([]byte, error) + Host string + Token string + Image string + StartTimeout time.Duration +} + +type liveCluster struct { + cancel context.CancelFunc + containerID string + lbHost []int + port int +} + +type clusterEngine struct { + core *k3sengine.Engine + live map[string]*liveCluster + image string + wg sync.WaitGroup +} + +// EnableEngine switches the backend to docker mode. +func (b *InMemoryBackend) EnableEngine(cfg EngineConfig) error { + core, err := k3sengine.New(k3sengine.Config{ + Runtime: cfg.Runtime, Ports: cfg.Ports, Logger: cfg.Logger, Probe: cfg.Probe, Prefix: "gke", + Host: cfg.Host, Token: cfg.Token, StartTimeout: cfg.StartTimeout, + }) + if err != nil { + return err + } + + b.mu.Lock("EnableEngine") + defer b.mu.Unlock() + + b.engine = &clusterEngine{core: core, live: map[string]*liveCluster{}, image: cfg.Image} + + return nil +} + +// EngineToken is the cluster-admin bearer token of docker-engine clusters; empty without the engine. +func (b *InMemoryBackend) EngineToken() string { + b.mu.RLock("EngineToken") + defer b.mu.RUnlock() + + if b.engine == nil { + return "" + } + + return b.engine.core.Token() +} + +func (e *clusterEngine) imageFor(version string) string { + if e.image != "" { + return e.image + } + + img, _ := k3sengine.Image(version, engineFallback) + + return img +} + +func (e *clusterEngine) reap(lcs []*liveCluster) { + for _, lc := range lcs { + lc.cancel() + + if lc.containerID != "" { + e.wg.Go(func() { e.core.Remove(lc.containerID, append([]int{lc.port}, lc.lbHost...)...) }) + } + } +} + +func (b *InMemoryBackend) detachAllLocked() []*liveCluster { + if b.engine == nil { + return nil + } + + out := make([]*liveCluster, 0, len(b.engine.live)) + for k, lc := range b.engine.live { + out = append(out, lc) + delete(b.engine.live, k) + } + + return out +} + +// reapLocked releases the engine cluster of key; container removal runs in the background. +func (b *InMemoryBackend) reapLocked(key string) { + if b.engine == nil { + return + } + + if lc, ok := b.engine.live[key]; ok { + delete(b.engine.live, key) + b.engine.reap([]*liveCluster{lc}) + } +} + +func (b *InMemoryBackend) closeEngine() { + b.mu.Lock("closeEngine") + e := b.engine + lcs := b.detachAllLocked() + b.mu.Unlock() + + if e == nil { + return + } + + e.reap(lcs) + e.wg.Wait() + e.core.Close() +} + +func joinToken() string { + buf := make([]byte, joinTokenBytes) + _, _ = rand.Read(buf) + + return hex.EncodeToString(buf) +} + +// launchLocked registers the cluster with the engine and starts its container in the background. +func (b *InMemoryBackend) launchLocked(r ref, rec *clusterRec, o *opRec) { + e := b.engine + key := r.clusterKey() + ctx, cancel := context.WithCancel(b.runCtx) + lc := &liveCluster{cancel: cancel} + e.live[key] = lc + image := e.imageFor(rec.Cluster.GetCurrentMasterVersion()) + opName := o.Op.GetName() + + b.wg.Go(func() { b.runEngine(ctx, r, lc, image, opName) }) +} + +func (b *InMemoryBackend) runEngine(ctx context.Context, r ref, lc *liveCluster, image, opName string) { + e := b.engine + key := r.clusterKey() + + inst, err := e.core.Launch(ctx, k3sengine.Node{Name: r.cluster, Image: image, JoinToken: joinToken()}, + func(i *k3sengine.Instance) bool { return b.recordLive(key, lc, i) }) + if err != nil { + if !errors.Is(err, k3sengine.ErrSuperseded) { + b.failEngine(ctx, r, lc, opName, err) + } + + return + } + + addr := e.core.Addr(inst.Port) + + ca, err := e.core.AwaitReady(ctx, addr, inst.CA.CertPEM) + if err != nil { + b.failEngine(ctx, r, lc, opName, err) + + return + } + + b.readyEngine(r, lc, addr, ca, opName) +} + +func (b *InMemoryBackend) recordLive(key string, lc *liveCluster, i *k3sengine.Instance) bool { + b.mu.Lock("recordLive") + defer b.mu.Unlock() + + if b.engine.live[key] != lc { + return false + } + + lc.containerID, lc.port, lc.lbHost = i.ContainerID, i.Port, i.LBHost + + return true +} + +func (b *InMemoryBackend) readyEngine(r ref, lc *liveCluster, addr string, ca []byte, opName string) { + b.mu.Lock("readyEngine") + defer b.mu.Unlock() + + c, ok := b.clusters[r.clusterKey()] + if !ok || b.engine.live[r.clusterKey()] != lc { + return + } + + c.Cluster.Endpoint = addr + c.Cluster.MasterAuth.ClusterCaCertificate = base64.StdEncoding.EncodeToString(ca) + + if o, found := b.ops[opKey(r.project, r.location, opName)]; found { + b.finishOpLocked(o, "") + } +} + +// failEngine marks a still-wanted cluster ERROR and releases its container. +func (b *InMemoryBackend) failEngine(ctx context.Context, r ref, lc *liveCluster, opName string, cause error) { + b.mu.Lock("failEngine") + + key := r.clusterKey() + c, ok := b.clusters[key] + + if !ok || b.engine.live[key] != lc { + b.mu.Unlock() + + return + } + + delete(b.engine.live, key) + + msg := "Cluster failed to start: " + cause.Error() + c.Failure = msg + + if o, found := b.ops[opKey(r.project, r.location, opName)]; found { + b.finishOpLocked(o, msg) + } + + e := b.engine + b.mu.Unlock() + + e.core.Log(ctx).WarnContext(ctx, "gcpcontainer: cluster failed to start", + "code", engineStartCode, "cluster", r.clusterName(), "error", cause) + e.reap([]*liveCluster{lc}) +} + +// relaunchLocked starts a fresh engine cluster for every restored cluster. +func (b *InMemoryBackend) relaunchLocked() { + if b.engine == nil { + return + } + + for _, c := range b.clusters { + if c.DeleteOp != "" { + continue + } + + r := ref{project: c.Project, location: c.Cluster.GetLocation(), cluster: c.Cluster.GetName()} + o := b.newOpLocked(r, containerpb.Operation_REPAIR_CLUSTER, kindCreate, c.Cluster.GetSelfLink(), 0, true) + c.Failure = "" + b.launchLocked(r, c, o) + } +} diff --git a/services/gcpcontainer/engine_test.go b/services/gcpcontainer/engine_test.go new file mode 100644 index 0000000000..a356a032cb --- /dev/null +++ b/services/gcpcontainer/engine_test.go @@ -0,0 +1,365 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "sync" + "testing" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +const ( + testToken = "unit-test-cluster-token-0123" + testCA = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" + engineMax = 10 * time.Second + engineGap = 5 * time.Millisecond +) + +var errRuntimeDown = errors.New("runtime down") + +type fakeRuntime struct { + failErr error + specs []container.Spec + removed []string + mu sync.Mutex +} + +func (f *fakeRuntime) CreateAndStart(_ context.Context, spec container.Spec) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if f.failErr != nil { + return "", f.failErr + } + + f.specs = append(f.specs, spec) + + return "ctr-" + spec.Name, nil +} + +func (f *fakeRuntime) StopAndRemove(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.removed = append(f.removed, id) + + return nil +} + +func (f *fakeRuntime) created() []container.Spec { + f.mu.Lock() + defer f.mu.Unlock() + + return append([]container.Spec(nil), f.specs...) +} + +func (f *fakeRuntime) removedIDs() []string { + f.mu.Lock() + defer f.mu.Unlock() + + return append([]string(nil), f.removed...) +} + +type gate struct { + err error + open chan struct{} + mu sync.Mutex +} + +func newGate() *gate { return &gate{open: make(chan struct{})} } + +func (g *gate) release() { close(g.open) } + +func (g *gate) probe(ctx context.Context, _, _ string) ([]byte, error) { + g.mu.Lock() + err := g.err + g.mu.Unlock() + + if err != nil { + return nil, err + } + + select { + case <-g.open: + return []byte(testCA), nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +type engineEnv struct { + *env + + rt *fakeRuntime + gate *gate + ports *portalloc.Allocator +} + +func newEngineEnv(t *testing.T, mod func(cfg *gcpcontainer.EngineConfig, rt *fakeRuntime)) *engineEnv { + t.Helper() + + ports, err := portalloc.New(41000, 41100) + require.NoError(t, err) + + rt, g := &fakeRuntime{}, newGate() + cfg := gcpcontainer.EngineConfig{ + Runtime: rt, + Ports: ports, + Probe: g.probe, + Token: testToken, + StartTimeout: engineMax, + } + + if mod != nil { + mod(&cfg, rt) + } + + b := gcpcontainer.NewInMemoryBackend() + require.NoError(t, b.EnableEngine(cfg)) + + return &engineEnv{env: newEnvWith(t, b), rt: rt, gate: g, ports: ports} +} + +func (e *engineEnv) opDone(t *testing.T, op *containerpb.Operation) *containerpb.Operation { + t.Helper() + + var got *containerpb.Operation + + require.Eventually(t, func() bool { + got = e.getOp(t, op) + + return got.GetStatus() == containerpb.Operation_DONE + }, engineMax, engineGap) + + return got +} + +func TestEngineClusterLifecycle(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, nil) + op := e.create( + t, + zone, + &containerpb.Cluster{Name: "real", InitialNodeCount: 1, InitialClusterVersion: "1.34.2-gke.1008000"}, + ) + + require.Eventually(t, func() bool { return len(e.rt.created()) == 1 }, engineMax, engineGap) + + spec := e.rt.created()[0] + assert.True(t, strings.HasPrefix(spec.Image, k3sengine.ImageRepo+":v1.34."), spec.Image) + assert.True(t, spec.Privileged) + assert.Contains(t, spec.Env, "GOPHERSTACK_EKS_ADMIN_AUTH="+testToken) + assert.Contains(t, spec.Name, "gopherstack-gke-real-") + require.Len(t, spec.Ports, 1) + + e.settle() + assert.Equal( + t, + containerpb.Operation_RUNNING, + e.getOp(t, op).GetStatus(), + "the operation ends when the API server answers, not on the clock", + ) + assert.Equal(t, containerpb.Cluster_PROVISIONING, e.getCluster(t, zone, "real").GetStatus()) + + e.gate.release() + done := e.opDone(t, op) + assert.Nil(t, done.GetError()) + + c := e.getCluster(t, zone, "real") + assert.Equal(t, containerpb.Cluster_RUNNING, c.GetStatus()) + assert.Regexp(t, `^127\.0\.0\.1:41\d{3}$`, c.GetEndpoint()) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(testCA)), c.GetMasterAuth().GetClusterCaCertificate()) + assert.Equal(t, testToken, e.b.EngineToken()) + + port := strings.TrimPrefix(c.GetEndpoint(), "127.0.0.1:") + assert.Contains(t, spec.Ports[0], port) + + _, err := e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "real")}) + require.NoError(t, err) + + require.Eventually(t, func() bool { return len(e.rt.removedIDs()) == 1 }, engineMax, engineGap) + assert.Equal(t, "ctr-"+spec.Name, e.rt.removedIDs()[0]) + require.Eventually(t, func() bool { return !e.ports.IsAllocated(portOf(t, c.GetEndpoint())) }, engineMax, engineGap) +} + +func portOf(t *testing.T, endpoint string) int { + t.Helper() + + _, p, ok := strings.Cut(endpoint, ":") + require.True(t, ok) + + var n int + + for _, ch := range p { + n = n*10 + int(ch-'0') + } + + return n +} + +func TestEngineDeleteWhileProvisioning(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, nil) + create := e.create(t, zone, stdCluster("early")) + + require.Eventually(t, func() bool { return len(e.rt.created()) == 1 }, engineMax, engineGap) + + _, err := e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "early")}) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_DONE, e.getOp(t, create).GetStatus(), "the pending create is aborted") + + require.Eventually(t, func() bool { return len(e.rt.removedIDs()) == 1 }, engineMax, engineGap) +} + +func TestEngineStartFailures(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(cfg *gcpcontainer.EngineConfig, rt *fakeRuntime) + name string + want string + }{ + { + name: "container start", want: "runtime down", + mod: func(_ *gcpcontainer.EngineConfig, rt *fakeRuntime) { rt.failErr = errRuntimeDown }, + }, + { + name: "api server never ready", want: "API server not ready", + mod: func(cfg *gcpcontainer.EngineConfig, _ *fakeRuntime) { + cfg.StartTimeout = 20 * time.Millisecond + cfg.Probe = func(context.Context, string, string) ([]byte, error) { return nil, errRuntimeDown } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, tt.mod) + op := e.create(t, zone, stdCluster("bad")) + + done := e.opDone(t, op) + assert.Contains(t, done.GetStatusMessage(), tt.want) + assert.Equal(t, int32(codes.Internal), done.GetError().GetCode()) + + c := e.getCluster(t, zone, "bad") + assert.Equal(t, containerpb.Cluster_ERROR, c.GetStatus()) + assert.Contains(t, c.GetStatusMessage(), tt.want) + assert.Equal(t, containerpb.NodePool_ERROR, c.GetNodePools()[0].GetStatus()) + + _, err := e.gc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "bad")}) + require.NoError(t, err) + }) + } +} + +func TestEngineRejectsIPRotation(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, nil) + e.gate.release() + op := e.create(t, zone, stdCluster("rot")) + e.opDone(t, op) + + _, err := e.gc.StartIPRotation(t.Context(), &containerpb.StartIPRotationRequest{Name: clusterPath(zone, "rot")}) + assertCode(t, codes.FailedPrecondition, err, "docker engine") +} + +func TestEngineConfigValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + want error + name string + cfg gcpcontainer.EngineConfig + }{ + { + name: "bad host", + cfg: gcpcontainer.EngineConfig{Host: "bad host!", Token: testToken}, + want: k3sengine.ErrInvalidConfig, + }, + {name: "short token", cfg: gcpcontainer.EngineConfig{Token: "short"}, want: k3sengine.ErrInvalidConfig}, + { + name: "token charset", + cfg: gcpcontainer.EngineConfig{Token: "has spaces in it......"}, + want: k3sengine.ErrInvalidConfig, + }, + { + name: "default token off host", + cfg: gcpcontainer.EngineConfig{Host: "10.1.2.3"}, + want: k3sengine.ErrDefaultTokenExposed, + }, + {name: "explicit token off host", cfg: gcpcontainer.EngineConfig{Host: "10.1.2.3", Token: testToken}}, + {name: "loopback default token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.cfg.Runtime = &fakeRuntime{} + b := gcpcontainer.NewInMemoryBackend() + t.Cleanup(b.Close) + + err := b.EnableEngine(tt.cfg) + if tt.want == nil { + require.NoError(t, err) + + return + } + + require.ErrorIs(t, err, tt.want) + }) + } +} + +func TestEngineImageOverride(t *testing.T) { + t.Parallel() + + e := newEngineEnv( + t, + func(cfg *gcpcontainer.EngineConfig, _ *fakeRuntime) { cfg.Image = "registry.test/k3s:custom" }, + ) + e.create(t, zone, stdCluster("img")) + + require.Eventually(t, func() bool { return len(e.rt.created()) == 1 }, engineMax, engineGap) + assert.Equal(t, "registry.test/k3s:custom", e.rt.created()[0].Image) +} + +func TestEngineRestoreRelaunches(t *testing.T) { + t.Parallel() + + src := newEngineEnv(t, nil) + src.gate.release() + src.opDone(t, src.create(t, zone, stdCluster("again"))) + + data := src.h.Snapshot(t.Context()) + + dst := newEngineEnv(t, nil) + dst.clock.Advance(src.clock.Now().Sub(dst.clock.Now())) + require.NoError(t, dst.h.Restore(t.Context(), data)) + + require.Eventually(t, func() bool { return len(dst.rt.created()) == 1 }, engineMax, engineGap) + assert.Equal(t, containerpb.Cluster_PROVISIONING, dst.getCluster(t, zone, "again").GetStatus()) + + dst.gate.release() + require.Eventually(t, func() bool { + return dst.getCluster(t, zone, "again").GetStatus() == containerpb.Cluster_RUNNING + }, engineMax, engineGap) +} diff --git a/services/gcpcontainer/errors.go b/services/gcpcontainer/errors.go new file mode 100644 index 0000000000..35ae67d722 --- /dev/null +++ b/services/gcpcontainer/errors.go @@ -0,0 +1,55 @@ +package gcpcontainer + +import ( + "fmt" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/runtime/protoiface" +) + +func withDetails(code codes.Code, msg string, details ...protoiface.MessageV1) error { + st := status.New(code, msg) + if withD, err := st.WithDetails(details...); err == nil { + st = withD + } + + return st.Err() +} + +func errNotFound(name string) error { + return withDetails(codes.NotFound, fmt.Sprintf("Not found: %s.", name), + &errdetails.ResourceInfo{ResourceName: name}) +} + +func errExists(name string) error { + return withDetails(codes.AlreadyExists, fmt.Sprintf("Already exists: %s.", name), + &errdetails.ResourceInfo{ResourceName: name}) +} + +func errInvalid(field, format string, a ...any) error { + msg := fmt.Sprintf(format, a...) + + return withDetails(codes.InvalidArgument, msg, + &errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{ + {Field: field, Description: msg}, + }}) +} + +func errPrecondition(subject, format string, a ...any) error { + msg := fmt.Sprintf(format, a...) + + return withDetails(codes.FailedPrecondition, msg, + &errdetails.PreconditionFailure{Violations: []*errdetails.PreconditionFailure_Violation{ + {Type: "STATE", Subject: subject, Description: msg}, + }}) +} + +func errBusy(subject, op string) error { + return errPrecondition(subject, "Cluster is running incompatible operation %s.", op) +} + +func errUnsupported(field string) error { + return errInvalid(field, "Update of %s is not supported by the emulator.", field) +} diff --git a/services/gcpcontainer/grpc.go b/services/gcpcontainer/grpc.go new file mode 100644 index 0000000000..a92c058b6e --- /dev/null +++ b/services/gcpcontainer/grpc.go @@ -0,0 +1,215 @@ +package gcpcontainer + +import ( + "context" + + "cloud.google.com/go/container/apiv1/containerpb" +) + +type clusterManager struct { + containerpb.UnimplementedClusterManagerServer + b *InMemoryBackend +} + +func (s *clusterManager) CreateCluster( + _ context.Context, + in *containerpb.CreateClusterRequest, +) (*containerpb.Operation, error) { + return s.b.CreateCluster(in) +} + +func (s *clusterManager) GetCluster( + _ context.Context, + in *containerpb.GetClusterRequest, +) (*containerpb.Cluster, error) { + return s.b.GetCluster(in) +} + +func (s *clusterManager) ListClusters( + _ context.Context, + in *containerpb.ListClustersRequest, +) (*containerpb.ListClustersResponse, error) { + return s.b.ListClusters(in) +} + +func (s *clusterManager) UpdateCluster( + _ context.Context, + in *containerpb.UpdateClusterRequest, +) (*containerpb.Operation, error) { + return s.b.UpdateCluster(in) +} + +func (s *clusterManager) DeleteCluster( + _ context.Context, + in *containerpb.DeleteClusterRequest, +) (*containerpb.Operation, error) { + return s.b.DeleteCluster(in) +} + +func (s *clusterManager) SetLabels( + _ context.Context, + in *containerpb.SetLabelsRequest, +) (*containerpb.Operation, error) { + return s.b.SetLabels(in) +} + +func (s *clusterManager) SetMasterAuth( + _ context.Context, + in *containerpb.SetMasterAuthRequest, +) (*containerpb.Operation, error) { + return s.b.SetMasterAuth(in) +} + +func (s *clusterManager) SetLegacyAbac( + _ context.Context, + in *containerpb.SetLegacyAbacRequest, +) (*containerpb.Operation, error) { + return s.b.SetLegacyAbac(in) +} + +func (s *clusterManager) SetNetworkPolicy( + _ context.Context, + in *containerpb.SetNetworkPolicyRequest, +) (*containerpb.Operation, error) { + return s.b.SetNetworkPolicy(in) +} + +func (s *clusterManager) SetMaintenancePolicy( + _ context.Context, + in *containerpb.SetMaintenancePolicyRequest, +) (*containerpb.Operation, error) { + return s.b.SetMaintenancePolicy(in) +} + +func (s *clusterManager) StartIPRotation( + _ context.Context, + in *containerpb.StartIPRotationRequest, +) (*containerpb.Operation, error) { + return s.b.StartIPRotation(in) +} + +func (s *clusterManager) CompleteIPRotation( + _ context.Context, + in *containerpb.CompleteIPRotationRequest, +) (*containerpb.Operation, error) { + return s.b.CompleteIPRotation(in) +} + +func (s *clusterManager) SetLoggingService( + _ context.Context, + in *containerpb.SetLoggingServiceRequest, +) (*containerpb.Operation, error) { + return s.b.SetLoggingService(in) +} + +func (s *clusterManager) SetMonitoringService( + _ context.Context, + in *containerpb.SetMonitoringServiceRequest, +) (*containerpb.Operation, error) { + return s.b.SetMonitoringService(in) +} + +func (s *clusterManager) SetAddonsConfig( + _ context.Context, + in *containerpb.SetAddonsConfigRequest, +) (*containerpb.Operation, error) { + return s.b.SetAddonsConfig(in) +} + +func (s *clusterManager) SetLocations( + _ context.Context, + in *containerpb.SetLocationsRequest, +) (*containerpb.Operation, error) { + return s.b.SetLocations(in) +} + +func (s *clusterManager) UpdateMaster( + _ context.Context, + in *containerpb.UpdateMasterRequest, +) (*containerpb.Operation, error) { + return s.b.UpdateMaster(in) +} + +func (s *clusterManager) CreateNodePool( + _ context.Context, + in *containerpb.CreateNodePoolRequest, +) (*containerpb.Operation, error) { + return s.b.CreateNodePool(in) +} + +func (s *clusterManager) GetNodePool( + _ context.Context, + in *containerpb.GetNodePoolRequest, +) (*containerpb.NodePool, error) { + return s.b.GetNodePool(in) +} + +func (s *clusterManager) ListNodePools( + _ context.Context, + in *containerpb.ListNodePoolsRequest, +) (*containerpb.ListNodePoolsResponse, error) { + return s.b.ListNodePools(in) +} + +func (s *clusterManager) UpdateNodePool( + _ context.Context, + in *containerpb.UpdateNodePoolRequest, +) (*containerpb.Operation, error) { + return s.b.UpdateNodePool(in) +} + +func (s *clusterManager) DeleteNodePool( + _ context.Context, + in *containerpb.DeleteNodePoolRequest, +) (*containerpb.Operation, error) { + return s.b.DeleteNodePool(in) +} + +func (s *clusterManager) SetNodePoolSize( + _ context.Context, + in *containerpb.SetNodePoolSizeRequest, +) (*containerpb.Operation, error) { + return s.b.SetNodePoolSize(in) +} + +func (s *clusterManager) SetNodePoolAutoscaling( + _ context.Context, + in *containerpb.SetNodePoolAutoscalingRequest, +) (*containerpb.Operation, error) { + return s.b.SetNodePoolAutoscaling(in) +} + +func (s *clusterManager) SetNodePoolManagement( + _ context.Context, + in *containerpb.SetNodePoolManagementRequest, +) (*containerpb.Operation, error) { + return s.b.SetNodePoolManagement(in) +} + +func (s *clusterManager) RollbackNodePoolUpgrade( + _ context.Context, + in *containerpb.RollbackNodePoolUpgradeRequest, +) (*containerpb.Operation, error) { + return s.b.RollbackNodePoolUpgrade(in) +} + +func (s *clusterManager) GetOperation( + _ context.Context, + in *containerpb.GetOperationRequest, +) (*containerpb.Operation, error) { + return s.b.GetOperation(in) +} + +func (s *clusterManager) ListOperations( + _ context.Context, + in *containerpb.ListOperationsRequest, +) (*containerpb.ListOperationsResponse, error) { + return s.b.ListOperations(in) +} + +func (s *clusterManager) GetServerConfig( + _ context.Context, + in *containerpb.GetServerConfigRequest, +) (*containerpb.ServerConfig, error) { + return s.b.GetServerConfig(in) +} diff --git a/services/gcpcontainer/handler.go b/services/gcpcontainer/handler.go new file mode 100644 index 0000000000..37b5aa5a6e --- /dev/null +++ b/services/gcpcontainer/handler.go @@ -0,0 +1,180 @@ +package gcpcontainer + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/labstack/echo/v5" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const gracefulStopWait = 2 * time.Second + +// Handler serves the Kubernetes Engine API on a dedicated gRPC and REST listener. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *grpc.Server + wg *sync.WaitGroup + addr string + Port int +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, + Port: DefaultPort, + srvMu: lockmetrics.New("gcpcontainer.server"), + wg: &sync.WaitGroup{}, + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "GCPContainer" } + +// GetSupportedOperations lists the supported gRPC methods. +func (h *Handler) GetSupportedOperations() []string { + return []string{ + "CreateCluster", "GetCluster", "ListClusters", "UpdateCluster", "DeleteCluster", "SetLabels", + "SetMasterAuth", "SetLegacyAbac", "SetNetworkPolicy", "SetMaintenancePolicy", "StartIPRotation", + "CompleteIPRotation", "SetLoggingService", "SetMonitoringService", "SetAddonsConfig", "SetLocations", + "UpdateMaster", "CreateNodePool", "GetNodePool", "ListNodePools", "UpdateNodePool", "DeleteNodePool", + "SetNodePoolSize", "SetNodePoolAutoscaling", "SetNodePoolManagement", "RollbackNodePoolUpgrade", + "GetOperation", "ListOperations", "GetServerConfig", + } +} + +// RouteMatcher never matches: Kubernetes Engine runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation is unused: requests never reach the shared router. +func (h *Handler) ExtractOperation(*echo.Context) string { return "Unknown" } + +// ExtractResource is unused: requests never reach the shared router. +func (h *Handler) ExtractResource(*echo.Context) string { return "" } + +// Addr returns the bound listener address, or "" before StartWorker. +func (h *Handler) Addr() string { + h.srvMu.RLock("Addr") + defer h.srvMu.RUnlock() + + return h.addr +} + +// Reset clears all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Handler returns a 404 handler; the real API is on the dedicated port. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + return c.String(http.StatusNotFound, "gcpcontainer serves gRPC and REST on its dedicated port") + } +} + +type debugLogger interface { + DebugContext(ctx context.Context, msg string, args ...any) +} + +func logUnary(log debugLogger) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { + resp, err := next(ctx, req) + if err != nil && status.Code(err) != codes.OK { + log.DebugContext(ctx, "gcpcontainer: rpc failed", "method", info.FullMethod, "error", err) + } + + return resp, err + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + var lc net.ListenConfig + + listener, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + if err != nil { + return fmt.Errorf("gcpcontainer: bind port %d: %w", h.Port, err) + } + + workerCtx := logger.WithWorker(ctx, "gcpcontainer", "listener") + log := logger.Load(workerCtx) + + srv := gcpenforce.NewServer(ctx, "container.googleapis.com", grpc.UnaryInterceptor(logUnary(log))) + containerpb.RegisterClusterManagerServer(srv, &clusterManager{b: h.Backend}) + + h.srvMu.Lock("StartWorker") + h.srv, h.addr = srv, listener.Addr().String() + h.srvMu.Unlock() + + log.InfoContext(workerCtx, "gcpcontainer: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := gcprest.Serve(srv, listener); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) { + log.ErrorContext(workerCtx, "gcpcontainer: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener, forcing a close after a short grace period, then releases engine clusters. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv := h.srv + h.srv = nil + h.srvMu.Unlock() + + if srv != nil { + stopServer(ctx, srv) + h.wg.Wait() + } + + h.Backend.Close() +} + +func stopServer(ctx context.Context, srv *grpc.Server) { + done := make(chan struct{}) + + go func() { + srv.GracefulStop() + close(done) + }() + + grace := time.NewTimer(gracefulStopWait) + defer grace.Stop() + + select { + case <-done: + case <-grace.C: + srv.Stop() + <-done + case <-ctx.Done(): + srv.Stop() + <-done + } +} diff --git a/services/gcpcontainer/helpers_test.go b/services/gcpcontainer/helpers_test.go new file mode 100644 index 0000000000..caa3136ef6 --- /dev/null +++ b/services/gcpcontainer/helpers_test.go @@ -0,0 +1,220 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + + container "cloud.google.com/go/container/apiv1" + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/googleapis/gax-go/v2/apierror" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +const ( + project = "test-project" + zone = "us-central1-a" + region = "us-central1" + zoneLoc = "projects/" + project + "/locations/" + zone + regionLc = "projects/" + project + "/locations/" + region +) + +type fakeClock struct { + t time.Time + mu sync.Mutex +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + return c.t +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.t = c.t.Add(d) +} + +type env struct { + h *gcpcontainer.Handler + b *gcpcontainer.InMemoryBackend + clock *fakeClock + gc *container.ClusterManagerClient + rc *container.ClusterManagerClient +} + +func newEnvWith(t *testing.T, b *gcpcontainer.InMemoryBackend) *env { + t.Helper() + + clock := &fakeClock{t: time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)} + b.SetClock(clock.Now) + + h := gcpcontainer.NewHandler(b) + h.Port = 0 + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + conn, err := grpc.NewClient(h.Addr(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + + gc, err := container.NewClusterManagerClient(t.Context(), + option.WithGRPCConn(conn), option.WithoutAuthentication()) + require.NoError(t, err) + t.Cleanup(func() { _ = gc.Close() }) + + rc, err := container.NewClusterManagerRESTClient( + t.Context(), + option.WithEndpoint( + "http://"+h.Addr(), + ), + option.WithoutAuthentication(), + option.WithHTTPClient(testclient.New(t)), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = rc.Close() }) + + return &env{h: h, b: b, clock: clock, gc: gc, rc: rc} +} + +func newEnv(t *testing.T) *env { + t.Helper() + + return newEnvWith(t, gcpcontainer.NewInMemoryBackend()) +} + +func (e *env) settle() { e.clock.Advance(time.Minute) } + +func clusterPath(loc, name string) string { + return "projects/" + project + "/locations/" + loc + "/clusters/" + name +} + +func poolPath(loc, cluster, pool string) string { + return clusterPath(loc, cluster) + "/nodePools/" + pool +} + +func (e *env) create(t *testing.T, loc string, cl *containerpb.Cluster) *containerpb.Operation { + t.Helper() + + op, err := e.gc.CreateCluster(t.Context(), &containerpb.CreateClusterRequest{ + Parent: "projects/" + project + "/locations/" + loc, Cluster: cl, + }) + require.NoError(t, err) + + return op +} + +func stdCluster(name string) *containerpb.Cluster { + return &containerpb.Cluster{Name: name, InitialNodeCount: 2} +} + +func (e *env) ready(t *testing.T, loc string, cl *containerpb.Cluster) *containerpb.Cluster { + t.Helper() + + e.create(t, loc, cl) + e.settle() + + got, err := e.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: clusterPath(loc, cl.GetName())}) + require.NoError(t, err) + require.Equal(t, containerpb.Cluster_RUNNING, got.GetStatus()) + + return got +} + +func (e *env) getCluster(t *testing.T, loc, name string) *containerpb.Cluster { + t.Helper() + + got, err := e.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: clusterPath(loc, name)}) + require.NoError(t, err) + + return got +} + +func (e *env) getPool(t *testing.T, loc, cluster, pool string) *containerpb.NodePool { + t.Helper() + + got, err := e.gc.GetNodePool(t.Context(), &containerpb.GetNodePoolRequest{Name: poolPath(loc, cluster, pool)}) + require.NoError(t, err) + + return got +} + +func (e *env) getOp(t *testing.T, op *containerpb.Operation) *containerpb.Operation { + t.Helper() + + got, err := e.gc.GetOperation(t.Context(), &containerpb.GetOperationRequest{ + Name: zoneLoc + "/operations/" + op.GetName(), + }) + require.NoError(t, err) + + return got +} + +// code reads the gRPC code of a gRPC or REST client error. +func code(err error) codes.Code { + if err == nil { + return codes.OK + } + + if ae, ok := errors.AsType[*apierror.APIError](err); ok { + switch ae.HTTPCode() { + case http.StatusNotFound: + return codes.NotFound + case http.StatusConflict: + return codes.AlreadyExists + case http.StatusBadRequest: + return codes.InvalidArgument + case http.StatusPreconditionFailed: + return codes.FailedPrecondition + } + + if st := ae.GRPCStatus(); st != nil { + return st.Code() + } + } + + return status.Code(err) +} + +func assertCode(t *testing.T, want codes.Code, err error, msgContains string) { + t.Helper() + + require.Error(t, err) + assert.Equal(t, want, code(err), err.Error()) + + if msgContains != "" { + assert.Contains(t, err.Error(), msgContains) + } +} + +func hasDetail[T any](err error) bool { + st, ok := status.FromError(err) + if !ok { + return false + } + + for _, d := range st.Details() { + if _, is := d.(T); is { + return true + } + } + + return false +} diff --git a/services/gcpcontainer/inproc.go b/services/gcpcontainer/inproc.go new file mode 100644 index 0000000000..5bc8ad8f56 --- /dev/null +++ b/services/gcpcontainer/inproc.go @@ -0,0 +1,25 @@ +package gcpcontainer + +// NodePoolSizes reports the current node count per zone of each node pool of a cluster, by pool name. The API +// itself exposes the count only through the cluster's totals. +func (b *InMemoryBackend) NodePoolSizes(clusterName string) (map[string]int32, error) { + r, err := resolveCluster(clusterName, "", "", "") + if err != nil { + return nil, err + } + + b.mu.RLock("NodePoolSizes") + defer b.mu.RUnlock() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + out := make(map[string]int32, len(c.Pools)) + for _, p := range c.Pools { + out[p.Pool.GetName()] = p.Size + } + + return out, nil +} diff --git a/services/gcpcontainer/leak_main_test.go b/services/gcpcontainer/leak_main_test.go new file mode 100644 index 0000000000..e1b348c7f4 --- /dev/null +++ b/services/gcpcontainer/leak_main_test.go @@ -0,0 +1,11 @@ +package gcpcontainer_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + testleak.VerifyTestMain(m) +} diff --git a/services/gcpcontainer/names.go b/services/gcpcontainer/names.go new file mode 100644 index 0000000000..d5915d9af2 --- /dev/null +++ b/services/gcpcontainer/names.go @@ -0,0 +1,237 @@ +package gcpcontainer + +import ( + "regexp" + "slices" + "strings" +) + +const ( + apiHost = "https://container.googleapis.com/v1" + computeV = "https://www.googleapis.com/compute/v1" + + kindProjects = "projects" + kindLocations = "locations" + kindClusters = "clusters" + kindNodePools = "nodePools" + kindOps = "operations" + + wildcard = "-" + + maxNameLen = 40 +) + +var nameRE = regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`) + +// ref identifies a cluster, node pool or operation. project and location are always set. +type ref struct { + project string + location string + cluster string + pool string +} + +func (r ref) clusterKey() string { return r.project + "/" + r.location + "/" + r.cluster } + +func (r ref) clusterName() string { + return kindProjects + "/" + r.project + "/" + kindLocations + "/" + r.location + "/" + kindClusters + "/" + r.cluster +} + +func (r ref) poolName() string { return r.clusterName() + "/" + kindNodePools + "/" + r.pool } + +func locationPath(project, location string) string { + return kindProjects + "/" + project + "/" + kindLocations + "/" + location +} + +func clusterSelfLink(project, location, cluster string) string { + return apiHost + "/" + locationPath(project, location) + "/" + kindClusters + "/" + cluster +} + +func parseRes(name string, kinds ...string) ([]string, bool) { + p := strings.Split(name, "/") + if len(p) != 2*len(kinds) { + return nil, false + } + + ids := make([]string, len(kinds)) + + for i, k := range kinds { + if p[2*i] != k || p[2*i+1] == "" { + return nil, false + } + + ids[i] = p[2*i+1] + } + + return ids, true +} + +// resolveLocation reads a parent given as `projects/*/locations/*` or as the legacy project_id and zone fields. +func resolveLocation(parent, project, zone string) (string, string, error) { + if parent != "" { + ids, ok := parseRes(parent, kindProjects, kindLocations) + if !ok { + return "", "", errInvalid( + "parent", + "Invalid parent %q: want projects/{project}/locations/{location}.", + parent, + ) + } + + return ids[0], ids[1], nil + } + + if project == "" { + return "", "", errInvalid("project_id", "Project ID must be specified.") + } + + if zone == "" { + return "", "", errInvalid("zone", "Location must be specified.") + } + + return project, zone, nil +} + +func resolveCluster(name, project, zone, cluster string) (ref, error) { + if name != "" { + ids, ok := parseRes(name, kindProjects, kindLocations, kindClusters) + if !ok { + return ref{}, errInvalid( + "name", + "Invalid cluster name %q: want projects/{project}/locations/{location}/clusters/{cluster}.", + name, + ) + } + + return ref{project: ids[0], location: ids[1], cluster: ids[2]}, nil + } + + p, l, err := resolveLocation("", project, zone) + if err != nil { + return ref{}, err + } + + if cluster == "" { + return ref{}, errInvalid("cluster_id", "Cluster ID must be specified.") + } + + return ref{project: p, location: l, cluster: cluster}, nil +} + +func resolvePool(name, project, zone, cluster, pool string) (ref, error) { + if name != "" { + ids, ok := parseRes(name, kindProjects, kindLocations, kindClusters, kindNodePools) + if !ok { + return ref{}, errInvalid( + "name", + "Invalid node pool name %q: want projects/{project}/locations/{location}/clusters/{cluster}/nodePools/{nodePool}.", + name, + ) + } + + return ref{project: ids[0], location: ids[1], cluster: ids[2], pool: ids[3]}, nil + } + + r, err := resolveCluster("", project, zone, cluster) + if err != nil { + return ref{}, err + } + + if pool == "" { + return ref{}, errInvalid("node_pool_id", "Node pool ID must be specified.") + } + + r.pool = pool + + return r, nil +} + +func resolveOperation(name, project, zone, op string) (ref, string, error) { + if name != "" { + ids, ok := parseRes(name, kindProjects, kindLocations, kindOps) + if !ok { + return ref{}, "", errInvalid( + "name", + "Invalid operation name %q: want projects/{project}/locations/{location}/operations/{operation}.", + name, + ) + } + + return ref{project: ids[0], location: ids[1]}, ids[2], nil + } + + p, l, err := resolveLocation("", project, zone) + if err != nil { + return ref{}, "", err + } + + if op == "" { + return ref{}, "", errInvalid("operation_id", "Operation ID must be specified.") + } + + return ref{project: p, location: l}, op, nil +} + +func validName(field, name string) error { + if name == "" { + return errInvalid(field, "%s must be specified.", field) + } + + if len(name) > maxNameLen || !nameRE.MatchString(name) { + return errInvalid( + field, + "Invalid value for field %q: %q must be 1-%d lowercase letters, digits or hyphens, "+ + "start with a letter and not end with a hyphen.", + field, + name, + maxNameLen, + ) + } + + return nil +} + +// regions are the regions Kubernetes Engine clusters can be created in. +func regions() []string { + return []string{ + "africa-south1", "asia-east1", "asia-east2", "asia-northeast1", "asia-northeast2", "asia-northeast3", + "asia-south1", "asia-south2", "asia-southeast1", "asia-southeast2", "australia-southeast1", + "australia-southeast2", "europe-central2", "europe-north1", "europe-southwest1", "europe-west1", + "europe-west2", "europe-west3", "europe-west4", "europe-west6", "europe-west8", "europe-west9", + "europe-west10", "europe-west12", "me-central1", "me-central2", "me-west1", "northamerica-northeast1", + "northamerica-northeast2", "northamerica-south1", "southamerica-east1", "southamerica-west1", + "us-central1", "us-east1", "us-east4", "us-east5", "us-south1", "us-west1", "us-west2", "us-west3", + "us-west4", + } +} + +var zoneSuffixes = []string{"a", "b", "c", "d", "f"} //nolint:gochecknoglobals // immutable catalog + +func isRegion(l string) bool { return slices.Contains(regions(), l) } + +func isZone(l string) bool { + region, suffix, ok := cutLast(l, "-") + + return ok && isRegion(region) && slices.Contains(zoneSuffixes, suffix) +} + +func cutLast(s, sep string) (string, string, bool) { + return strings.CutLast(s, sep) +} + +func validLocation(l string) bool { return isRegion(l) || isZone(l) } + +func regionOf(location string) string { + if isZone(location) { + r, _, _ := cutLast(location, "-") + + return r + } + + return location +} + +// defaultZones are the node locations of a regional cluster that does not name any. +func defaultZones(region string) []string { + return []string{region + "-a", region + "-b", region + "-c"} +} diff --git a/services/gcpcontainer/nodepools.go b/services/gcpcontainer/nodepools.go new file mode 100644 index 0000000000..810db06023 --- /dev/null +++ b/services/gcpcontainer/nodepools.go @@ -0,0 +1,533 @@ +//nolint:staticcheck // legacy request and operation fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "slices" + "strings" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/protobuf/reflect/protoreflect" +) + +type poolTarget struct { + name string + project string + zone string + cluster string + pool string +} + +// mutatePool runs apply on a node pool and records an operation of type typ for it. +func (b *InMemoryBackend) mutatePool( + t poolTarget, typ containerpb.Operation_Type, apply func(c *clusterRec, cl *containerpb.Cluster, pr *poolRec) error, +) (*containerpb.Operation, error) { + r, err := resolvePool(t.name, t.project, t.zone, t.cluster, t.pool) + if err != nil { + return nil, err + } + + b.mu.Lock("mutatePool") + defer b.mu.Unlock() + + b.sweepLocked() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + if c.Cluster.GetAutopilot().GetEnabled() { + return nil, errPrecondition(c.Cluster.GetSelfLink(), "Node pools of Autopilot clusters are managed by Google.") + } + + pr, ok := c.pool(r.pool) + if !ok { + return nil, errNotFound(r.poolName()) + } + + if err = b.conflictLocked(r, r.pool); err != nil { + return nil, err + } + + saved := clonePools(c.Pools) + cl := clone(c.Cluster) + + if err = apply(c, cl, pr); err != nil { + c.Pools = saved + + return nil, err + } + + c.Cluster = cl + + dur := b.timing.Mutate + if typ == containerpb.Operation_DELETE_NODE_POOL { + dur = b.timing.Delete + } + + o := b.newOpLocked(r, typ, kindPoolOp, poolSelfLink(r), dur, false) + if typ == containerpb.Operation_DELETE_NODE_POOL { + pr.DeleteOp = o.Op.GetName() + } + + return b.opViewLocked(o), nil +} + +func resolveClusterParent(parent, project, zone, cluster string) (ref, error) { + if parent == "" { + return resolveCluster("", project, zone, cluster) + } + + ids, ok := parseRes(parent, kindProjects, kindLocations, kindClusters) + if !ok { + return ref{}, errInvalid( + "parent", + "Invalid parent %q: want projects/{project}/locations/{location}/clusters/{cluster}.", + parent, + ) + } + + return ref{project: ids[0], location: ids[1], cluster: ids[2]}, nil +} + +// CreateNodePool adds a node pool to a cluster. +func (b *InMemoryBackend) CreateNodePool(req *containerpb.CreateNodePoolRequest) (*containerpb.Operation, error) { + r, err := resolveClusterParent( + req.GetParent(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + ) + if err != nil { + return nil, err + } + + if req.GetNodePool() == nil { + return nil, errInvalid("node_pool", "Node pool must be specified.") + } + + b.mu.Lock("CreateNodePool") + defer b.mu.Unlock() + + b.sweepLocked() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + if c.Cluster.GetAutopilot().GetEnabled() { + return nil, errPrecondition(c.Cluster.GetSelfLink(), "Autopilot clusters do not support creating node pools.") + } + + pc := poolCtx{ + ref: r, + version: c.Cluster.GetCurrentMasterVersion(), + zones: c.Cluster.GetLocations(), + autoUpdate: c.Cluster.GetReleaseChannel(). + GetChannel() != + containerpb.ReleaseChannel_UNSPECIFIED, + } + + pr, err := b.buildPoolLocked(pc, req.GetNodePool()) + if err != nil { + return nil, err + } + + r.pool = pr.Pool.GetName() + if _, exists := c.pool(r.pool); exists { + return nil, errExists(r.poolName()) + } + + if err = b.conflictLocked(r, r.pool); err != nil { + return nil, err + } + + c.Pools = append(c.Pools, pr) + o := b.newOpLocked(r, containerpb.Operation_CREATE_NODE_POOL, kindPoolOp, poolSelfLink(r), b.timing.Create, false) + + return b.opViewLocked(o), nil +} + +// GetNodePool returns one node pool. +func (b *InMemoryBackend) GetNodePool(req *containerpb.GetNodePoolRequest) (*containerpb.NodePool, error) { + r, err := resolvePool( + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + req.GetNodePoolId(), + ) + if err != nil { + return nil, err + } + + b.mu.RLock("GetNodePool") + defer b.mu.RUnlock() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + pr, ok := c.pool(r.pool) + if !ok || (pr.DeleteOp != "" && b.deleteDoneLocked(r.clusterKey(), pr.DeleteOp)) { + return nil, errNotFound(r.poolName()) + } + + return b.poolViewLocked(c, pr), nil +} + +// ListNodePools lists a cluster's node pools. +func (b *InMemoryBackend) ListNodePools( + req *containerpb.ListNodePoolsRequest, +) (*containerpb.ListNodePoolsResponse, error) { + r, err := resolveClusterParent( + req.GetParent(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + ) + if err != nil { + return nil, err + } + + b.mu.RLock("ListNodePools") + defer b.mu.RUnlock() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + resp := &containerpb.ListNodePoolsResponse{} + + for _, pr := range c.Pools { + if pr.DeleteOp == "" || !b.deleteDoneLocked(r.clusterKey(), pr.DeleteOp) { + resp.NodePools = append(resp.NodePools, b.poolViewLocked(c, pr)) + } + } + + return resp, nil +} + +// DeleteNodePool starts deletion of a node pool. +func (b *InMemoryBackend) DeleteNodePool(req *containerpb.DeleteNodePoolRequest) (*containerpb.Operation, error) { + t := poolTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + req.GetNodePoolId(), + } + + return b.mutatePool( + t, + containerpb.Operation_DELETE_NODE_POOL, + func(_ *clusterRec, _ *containerpb.Cluster, _ *poolRec) error { + return nil + }, + ) +} + +// SetNodePoolSize resizes a node pool. +func (b *InMemoryBackend) SetNodePoolSize(req *containerpb.SetNodePoolSizeRequest) (*containerpb.Operation, error) { + t := poolTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + req.GetNodePoolId(), + } + + return b.mutatePool( + t, + containerpb.Operation_SET_NODE_POOL_SIZE, + func(_ *clusterRec, _ *containerpb.Cluster, pr *poolRec) error { + if n := req.GetNodeCount(); n < 0 || n > maxNodeCount { + return errInvalid("node_count", "Node count must be between 0 and %d.", maxNodeCount) + } + + if pr.Pool.GetAutoscaling().GetEnabled() { + return errPrecondition( + pr.Pool.GetSelfLink(), + "Node pool %s has autoscaling enabled; disable it before resizing.", + pr.Pool.GetName(), + ) + } + + pr.Size = req.GetNodeCount() + + return nil + }, + ) +} + +func setPoolAutoscaling(pr *poolRec, a *containerpb.NodePoolAutoscaling) error { + if a == nil { + return errInvalid("autoscaling", "Autoscaling must be specified.") + } + + if err := validAutoscaling(a); err != nil { + return err + } + + pr.Pool.Autoscaling = clone(a) + + if a.GetEnabled() { + zones := int32(max(len(pr.Pool.GetLocations()), 1)) //nolint:gosec // at most a handful of zones + lo, hi := a.GetMinNodeCount(), a.GetMaxNodeCount() + + if a.GetTotalMaxNodeCount() > 0 { + lo, hi = (a.GetTotalMinNodeCount()+zones-1)/zones, a.GetTotalMaxNodeCount()/zones + } + + pr.Size = min(max(pr.Size, lo), max(hi, lo)) + } + + return nil +} + +// SetNodePoolAutoscaling configures a node pool's autoscaler. +func (b *InMemoryBackend) SetNodePoolAutoscaling( + req *containerpb.SetNodePoolAutoscalingRequest, +) (*containerpb.Operation, error) { + t := poolTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + req.GetNodePoolId(), + } + + return b.mutatePool( + t, + containerpb.Operation_UPDATE_CLUSTER, + func(_ *clusterRec, _ *containerpb.Cluster, pr *poolRec) error { + return setPoolAutoscaling(pr, req.GetAutoscaling()) + }, + ) +} + +// SetNodePoolManagement sets auto-upgrade and auto-repair. +func (b *InMemoryBackend) SetNodePoolManagement( + req *containerpb.SetNodePoolManagementRequest, +) (*containerpb.Operation, error) { + t := poolTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + req.GetNodePoolId(), + } + + return b.mutatePool( + t, + containerpb.Operation_SET_NODE_POOL_MANAGEMENT, + func(_ *clusterRec, cl *containerpb.Cluster, pr *poolRec) error { + m := req.GetManagement() + if m == nil { + return errInvalid("management", "Management must be specified.") + } + + if !m.GetAutoUpgrade() && + cl.GetReleaseChannel(). + GetChannel() != + containerpb.ReleaseChannel_UNSPECIFIED { + return errInvalid( + "management.auto_upgrade", + "Auto-upgrade cannot be disabled for node pools of a cluster on a release channel.", + ) + } + + pr.Pool.Management = clone(m) + + return nil + }, + ) +} + +// RollbackNodePoolUpgrade restores the version a node pool had before its last upgrade. +func (b *InMemoryBackend) RollbackNodePoolUpgrade( + req *containerpb.RollbackNodePoolUpgradeRequest, +) (*containerpb.Operation, error) { + t := poolTarget{ + req.GetName(), + req.GetProjectId(), + req.GetZone(), + req.GetClusterId(), + req.GetNodePoolId(), + } + + return b.mutatePool( + t, + containerpb.Operation_UPGRADE_NODES, + func(_ *clusterRec, _ *containerpb.Cluster, pr *poolRec) error { + if pr.PrevVersion == "" { + return errPrecondition( + pr.Pool.GetSelfLink(), + "Node pool %s has no upgrade to roll back.", + pr.Pool.GetName(), + ) + } + + pr.Pool.Version, pr.PrevVersion = pr.PrevVersion, "" + + return nil + }, + ) +} + +// poolFields are the UpdateNodePoolRequest fields handled explicitly; every other populated field must map onto a +// NodeConfig field of the same name and type. +var poolFields = map[string]bool{ //nolint:gochecknoglobals // immutable catalog + "project_id": true, "zone": true, "cluster_id": true, "node_pool_id": true, "name": true, fieldEtag: true, + "node_version": true, "image_type": true, "locations": true, "tags": true, "taints": true, "labels": true, + "resource_labels": true, "upgrade_settings": true, "machine_type": true, "disk_type": true, "disk_size_gb": true, + "node_network_config": true, +} + +// targetFields identify the node pool or guard the request; they change nothing. +var targetFields = map[string]bool{ //nolint:gochecknoglobals // immutable catalog + "project_id": true, "zone": true, "cluster_id": true, "node_pool_id": true, "name": true, fieldEtag: true, +} + +// UpdateNodePool changes node pool settings. +func (b *InMemoryBackend) UpdateNodePool(req *containerpb.UpdateNodePoolRequest) (*containerpb.Operation, error) { + t := poolTarget{req.GetName(), req.GetProjectId(), req.GetZone(), req.GetClusterId(), req.GetNodePoolId()} + + typ := containerpb.Operation_UPDATE_CLUSTER + if req.GetNodeVersion() != "" { + typ = containerpb.Operation_UPGRADE_NODES + } + + return b.mutatePool(t, typ, func(c *clusterRec, cl *containerpb.Cluster, pr *poolRec) error { + if e := req.GetEtag(); e != "" && e != etagOf(pr.Pool) { + return errPrecondition(pr.Pool.GetSelfLink(), "The etag %q does not match the node pool's current etag.", e) + } + + if err := b.applyPoolRequest(c, cl, pr, req); err != nil { + return err + } + + return validNodeConfig(b.compute, pr.Pool.GetConfig()) + }) +} + +func (b *InMemoryBackend) applyPoolRequest( + c *clusterRec, + cl *containerpb.Cluster, + pr *poolRec, + req *containerpb.UpdateNodePoolRequest, +) error { + changes := 0 + + req.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, _ protoreflect.Value) bool { + if !targetFields[string(fd.Name())] { + changes++ + } + + return true + }) + + if changes == 0 { + return errInvalid("update", "No update was specified.") + } + + if err := applyPoolConfig(pr.Pool.GetConfig(), req); err != nil { + return err + } + + if v := req.GetNodeVersion(); v != "" { + if err := upgradeNodes(pr, v, cl.GetCurrentMasterVersion()); err != nil { + return err + } + } + + if len(req.GetLocations()) > 0 { + zones, err := poolZones(req.GetLocations(), cl.GetLocations()) + if err != nil { + return err + } + + pr.Pool.Locations = zones + pr.Pool.InstanceGroupUrls = groupURLs(c.Project, zones, cl.GetName(), pr.Pool.GetName()) + } + + if us := req.GetUpgradeSettings(); us != nil { + pr.Pool.UpgradeSettings = clone(us) + } + + if nn := req.GetNodeNetworkConfig(); nn != nil { + pr.Pool.NetworkConfig = clone(nn) + } + + return applyGenericPool(pr.Pool.GetConfig(), req) +} + +func applyPoolConfig(nc *containerpb.NodeConfig, req *containerpb.UpdateNodePoolRequest) error { + if t := req.GetImageType(); t != "" { + if !validImageType(t) { + return errInvalid("image_type", "Image type %q is not supported.", t) + } + + nc.ImageType = t + } + + nc.MachineType = firstNonEmpty(req.GetMachineType(), nc.GetMachineType()) + nc.DiskType = firstNonEmpty(req.GetDiskType(), nc.GetDiskType()) + + if d := req.GetDiskSizeGb(); d != 0 { + nc.DiskSizeGb = int32(d) //nolint:gosec // range-checked by validNodeConfig afterwards + } + + if req.GetTags() != nil { + nc.Tags = slices.Clone(req.GetTags().GetTags()) + } + + if req.GetTaints() != nil { + nc.Taints = cloneTaints(req.GetTaints().GetTaints()) + } + + if req.GetLabels() != nil { + nc.Labels = req.GetLabels().GetLabels() + } + + if req.GetResourceLabels() != nil { + nc.ResourceLabels = req.GetResourceLabels().GetLabels() + } + + return nil +} + +func cloneTaints(in []*containerpb.NodeTaint) []*containerpb.NodeTaint { + out := make([]*containerpb.NodeTaint, len(in)) + for i, t := range in { + out[i] = clone(t) + } + + return out +} + +func applyGenericPool(nc *containerpb.NodeConfig, req *containerpb.UpdateNodePoolRequest) error { + var err error + + req.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + name := string(fd.Name()) + if poolFields[name] { + return true + } + + if !fieldAccepts(nc.ProtoReflect(), name, fd) { + err = errUnsupported(strings.TrimSpace(name)) + + return false + } + + set(nc.ProtoReflect(), name, fd, v) + + return true + }) + + return err +} diff --git a/services/gcpcontainer/nodepools_test.go b/services/gcpcontainer/nodepools_test.go new file mode 100644 index 0000000000..3875f58e3a --- /dev/null +++ b/services/gcpcontainer/nodepools_test.go @@ -0,0 +1,512 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "testing" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestNodePoolLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("np")) + parentName := clusterPath(zone, "np") + + op, err := e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: parentName, + NodePool: &containerpb.NodePool{ + Name: "workers", InitialNodeCount: 3, + Config: &containerpb.NodeConfig{ + MachineType: "e2-standard-4", + Labels: map[string]string{"role": "worker"}, + Taints: []*containerpb.NodeTaint{ + {Key: "dedicated", Value: "gpu", Effect: containerpb.NodeTaint_NO_SCHEDULE}, + }, + Tags: []string{"web"}, + Spot: true, + }, + }, + }) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_CREATE_NODE_POOL, op.GetOperationType()) + assert.Equal(t, "https://container.googleapis.com/v1/"+poolPath(zone, "np", "workers"), op.GetTargetLink()) + + assert.Equal(t, containerpb.NodePool_PROVISIONING, e.getPool(t, zone, "np", "workers").GetStatus()) + assert.Equal(t, containerpb.NodePool_RUNNING, e.getPool(t, zone, "np", "default-pool").GetStatus()) + assert.Equal(t, containerpb.Cluster_RECONCILING, e.getCluster(t, zone, "np").GetStatus()) + + _, err = e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: parentName, NodePool: &containerpb.NodePool{Name: "other", InitialNodeCount: 1}, + }) + require.NoError(t, err, "pool operations on different pools run side by side") + + e.settle() + + p := e.getPool(t, zone, "np", "workers") + assert.Equal(t, containerpb.NodePool_RUNNING, p.GetStatus()) + assert.Equal(t, "e2-standard-4", p.GetConfig().GetMachineType()) + assert.Equal(t, map[string]string{"role": "worker"}, p.GetConfig().GetLabels()) + assert.True(t, p.GetConfig().GetSpot()) + + list, err := e.gc.ListNodePools(t.Context(), &containerpb.ListNodePoolsRequest{Parent: parentName}) + require.NoError(t, err) + assert.Len(t, list.GetNodePools(), 3) + + c := e.getCluster(t, zone, "np") + assert.Equal(t, int32(2+3+1), c.GetCurrentNodeCount()) + + _, err = e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: parentName, NodePool: &containerpb.NodePool{Name: "workers", InitialNodeCount: 1}, + }) + assertCode(t, codes.AlreadyExists, err, poolPath(zone, "np", "workers")) + + del, err := e.gc.DeleteNodePool( + t.Context(), + &containerpb.DeleteNodePoolRequest{Name: poolPath(zone, "np", "workers")}, + ) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_DELETE_NODE_POOL, del.GetOperationType()) + assert.Equal(t, containerpb.NodePool_STOPPING, e.getPool(t, zone, "np", "workers").GetStatus()) + + e.settle() + + _, err = e.gc.GetNodePool(t.Context(), &containerpb.GetNodePoolRequest{Name: poolPath(zone, "np", "workers")}) + assertCode(t, codes.NotFound, err, "") + + list, err = e.gc.ListNodePools( + t.Context(), + &containerpb.ListNodePoolsRequest{ProjectId: project, Zone: zone, ClusterId: "np"}, + ) + require.NoError(t, err) + assert.Len(t, list.GetNodePools(), 2) + + _, err = e.gc.GetNodePool(t.Context(), &containerpb.GetNodePoolRequest{ + ProjectId: project, Zone: zone, ClusterId: "np", NodePoolId: "default-pool", + }) + require.NoError(t, err) +} + +func TestNodePoolValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + pool *containerpb.NodePool + name string + want string + }{ + {&containerpb.NodePool{Name: "Bad", InitialNodeCount: 1}, "bad name", "Bad"}, + {&containerpb.NodePool{Name: "p"}, "zero nodes", "greater than 0"}, + {&containerpb.NodePool{Name: "p", InitialNodeCount: 5000}, "too many nodes", "between 0 and"}, + { + &containerpb.NodePool{Name: "p", InitialNodeCount: 1, Locations: []string{"us-central1-b"}}, + "foreign location", "not one of the cluster locations", + }, + { + &containerpb.NodePool{Name: "p", InitialNodeCount: 1, Version: "1.10.0"}, + "unknown version", "unsupported", + }, + { + &containerpb.NodePool{Name: "p", InitialNodeCount: 1, Version: "1.29.15-gke.1039000"}, + "version skew", "at most 2 minor", + }, + { + &containerpb.NodePool{ + Name: "p", + InitialNodeCount: 1, + Autoscaling: &containerpb.NodePoolAutoscaling{Enabled: true, MinNodeCount: 3, MaxNodeCount: 1}, + }, + "min above max", + "Maximum node count must not be less", + }, + { + &containerpb.NodePool{ + Name: "p", + InitialNodeCount: 1, + MaxPodsConstraint: &containerpb.MaxPodsConstraint{MaxPodsPerNode: 4}, + }, + "too few pods", + "Max pods per node", + }, + { + &containerpb.NodePool{Name: "p", InitialNodeCount: 1, Config: &containerpb.NodeConfig{ + Taints: []*containerpb.NodeTaint{{Key: "k", Value: "v"}}, + }}, "taint without effect", "must have an effect", + }, + { + &containerpb.NodePool{ + Name: "p", + InitialNodeCount: 1, + Config: &containerpb.NodeConfig{ImageType: "WINDOZE"}, + }, + "image type", + "not supported", + }, + { + &containerpb.NodePool{Name: "p", InitialNodeCount: 1, Config: &containerpb.NodeConfig{DiskType: "tape"}}, + "disk type", "Invalid disk type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready( + t, + zone, + &containerpb.Cluster{Name: "v", InitialNodeCount: 1, InitialClusterVersion: "1.34.2-gke.1008000"}, + ) + + _, err := e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: clusterPath(zone, "v"), NodePool: tt.pool, + }) + assertCode(t, codes.InvalidArgument, err, tt.want) + }) + } +} + +func TestSetNodePoolSizeAndAutoscaling(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready( + t, + region, + &containerpb.Cluster{Name: "sz", NodePools: []*containerpb.NodePool{{Name: "p", InitialNodeCount: 1}}}, + ) + pool := poolPath(region, "sz", "p") + + op, err := e.gc.SetNodePoolSize(t.Context(), &containerpb.SetNodePoolSizeRequest{Name: pool, NodeCount: 4}) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_NODE_POOL_SIZE, op.GetOperationType()) + assert.Equal(t, containerpb.NodePool_RECONCILING, e.getPool(t, region, "sz", "p").GetStatus()) + + _, err = e.gc.SetNodePoolSize(t.Context(), &containerpb.SetNodePoolSizeRequest{Name: pool, NodeCount: 5}) + assertCode(t, codes.FailedPrecondition, err, "incompatible operation "+op.GetName()) + + e.settle() + assert.Equal(t, int32(12), e.getCluster(t, region, "sz").GetCurrentNodeCount()) + + _, err = e.gc.SetNodePoolSize(t.Context(), &containerpb.SetNodePoolSizeRequest{Name: pool, NodeCount: -1}) + assertCode(t, codes.InvalidArgument, err, "Node count") + + bad := []*containerpb.NodePoolAutoscaling{ + {Enabled: false, MaxNodeCount: 3}, + {Enabled: true}, + {Enabled: true, MinNodeCount: -1, MaxNodeCount: 3}, + {Enabled: true, MinNodeCount: 5, MaxNodeCount: 3}, + {Enabled: true, MaxNodeCount: 5000}, + {Enabled: true, MaxNodeCount: 3, TotalMaxNodeCount: 6}, + } + for _, a := range bad { + _, err = e.gc.SetNodePoolAutoscaling( + t.Context(), + &containerpb.SetNodePoolAutoscalingRequest{Name: pool, Autoscaling: a}, + ) + assertCode(t, codes.InvalidArgument, err, "") + } + + _, err = e.gc.SetNodePoolAutoscaling(t.Context(), &containerpb.SetNodePoolAutoscalingRequest{Name: pool}) + assertCode(t, codes.InvalidArgument, err, "must be specified") + + _, err = e.gc.SetNodePoolAutoscaling(t.Context(), &containerpb.SetNodePoolAutoscalingRequest{ + Name: pool, Autoscaling: &containerpb.NodePoolAutoscaling{Enabled: true, MinNodeCount: 5, MaxNodeCount: 9}, + }) + require.NoError(t, err) + e.settle() + + p := e.getPool(t, region, "sz", "p") + assert.True(t, p.GetAutoscaling().GetEnabled()) + assert.Equal(t, int32(9), p.GetAutoscaling().GetMaxNodeCount()) + assert.Equal( + t, + int32(15), + e.getCluster(t, region, "sz").GetCurrentNodeCount(), + "size clamps up to the autoscaler minimum in every zone", + ) + + _, err = e.gc.SetNodePoolSize(t.Context(), &containerpb.SetNodePoolSizeRequest{Name: pool, NodeCount: 2}) + assertCode(t, codes.FailedPrecondition, err, "autoscaling enabled") + + _, err = e.gc.UpdateCluster(t.Context(), &containerpb.UpdateClusterRequest{ + Name: clusterPath(region, "sz"), + Update: &containerpb.ClusterUpdate{ + DesiredNodePoolId: "p", + DesiredNodePoolAutoscaling: &containerpb.NodePoolAutoscaling{ + Enabled: true, + MinNodeCount: 1, + MaxNodeCount: 2, + }, + }, + }) + require.NoError(t, err) + e.settle() + assert.Equal(t, int32(2), e.getPool(t, region, "sz", "p").GetAutoscaling().GetMaxNodeCount()) +} + +func TestSetNodePoolManagement(t *testing.T) { + t.Parallel() + + tests := []struct { + mgmt *containerpb.NodeManagement + name string + wantMsg string + channel containerpb.ReleaseChannel_Channel + want codes.Code + wantRepair bool + }{ + {name: "no channel", mgmt: &containerpb.NodeManagement{AutoRepair: true}, wantRepair: true}, + {name: "off", mgmt: &containerpb.NodeManagement{}}, + { + name: "channel needs upgrade", + channel: containerpb.ReleaseChannel_REGULAR, + mgmt: &containerpb.NodeManagement{ + AutoRepair: true, + }, + want: codes.InvalidArgument, + wantMsg: "release channel", + }, + { + name: "channel ok", channel: containerpb.ReleaseChannel_REGULAR, wantRepair: true, + mgmt: &containerpb.NodeManagement{AutoUpgrade: true, AutoRepair: true}, + }, + {name: "nil", want: codes.InvalidArgument, wantMsg: "must be specified"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + cl := stdCluster("mg") + if tt.channel != containerpb.ReleaseChannel_UNSPECIFIED { + cl.ReleaseChannel = &containerpb.ReleaseChannel{Channel: tt.channel} + } + + e.ready(t, zone, cl) + + op, err := e.gc.SetNodePoolManagement(t.Context(), &containerpb.SetNodePoolManagementRequest{ + Name: poolPath(zone, "mg", "default-pool"), Management: tt.mgmt, + }) + if tt.want != codes.OK { + assertCode(t, tt.want, err, tt.wantMsg) + + return + } + + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_NODE_POOL_MANAGEMENT, op.GetOperationType()) + e.settle() + assert.Equal(t, tt.wantRepair, e.getPool(t, zone, "mg", "default-pool").GetManagement().GetAutoRepair()) + }) + } +} + +func TestNodeUpgradeAndRollback(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, &containerpb.Cluster{Name: "up", InitialNodeCount: 1, InitialClusterVersion: "1.34.2-gke.1008000"}) + pool := poolPath(zone, "up", "default-pool") + + _, err := e.gc.RollbackNodePoolUpgrade(t.Context(), &containerpb.RollbackNodePoolUpgradeRequest{Name: pool}) + assertCode(t, codes.FailedPrecondition, err, "no upgrade to roll back") + + _, err = e.gc.UpdateNodePool( + t.Context(), + &containerpb.UpdateNodePoolRequest{Name: pool, NodeVersion: "1.35.0-gke.1233000"}, + ) + assertCode(t, codes.InvalidArgument, err, "at most 2 minor") + + _, err = e.gc.UpdateCluster(t.Context(), &containerpb.UpdateClusterRequest{ + Name: clusterPath(zone, "up"), Update: &containerpb.ClusterUpdate{DesiredMasterVersion: "1.35.1-gke.1000000"}, + }) + require.NoError(t, err) + e.settle() + + op, err := e.gc.UpdateNodePool(t.Context(), &containerpb.UpdateNodePoolRequest{Name: pool, NodeVersion: "latest"}) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_UPGRADE_NODES, op.GetOperationType()) + assert.Equal(t, containerpb.NodePool_RECONCILING, e.getPool(t, zone, "up", "default-pool").GetStatus()) + e.settle() + + assert.Equal(t, "1.35.1-gke.1000000", e.getPool(t, zone, "up", "default-pool").GetVersion()) + assert.Equal(t, "1.35.1-gke.1000000", e.getCluster(t, zone, "up").GetCurrentNodeVersion()) + + _, err = e.gc.UpdateNodePool( + t.Context(), + &containerpb.UpdateNodePoolRequest{Name: pool, NodeVersion: "1.34.2-gke.1008000"}, + ) + assertCode(t, codes.InvalidArgument, err, "older than") + + rb, err := e.gc.RollbackNodePoolUpgrade(t.Context(), &containerpb.RollbackNodePoolUpgradeRequest{Name: pool}) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_UPGRADE_NODES, rb.GetOperationType()) + e.settle() + assert.Equal(t, "1.34.2-gke.1008000", e.getPool(t, zone, "up", "default-pool").GetVersion()) + + _, err = e.gc.RollbackNodePoolUpgrade(t.Context(), &containerpb.RollbackNodePoolUpgradeRequest{Name: pool}) + assertCode(t, codes.FailedPrecondition, err, "no upgrade to roll back") + + _, err = e.gc.UpdateCluster(t.Context(), &containerpb.UpdateClusterRequest{ + Name: clusterPath(zone, "up"), Update: &containerpb.ClusterUpdate{DesiredNodeVersion: "1.35.1-gke.1000000"}, + }) + require.NoError(t, err) + e.settle() + assert.Equal(t, "1.35.1-gke.1000000", e.getPool(t, zone, "up", "default-pool").GetVersion()) +} + +func TestUpdateNodePool(t *testing.T) { + t.Parallel() + + tests := []struct { + req *containerpb.UpdateNodePoolRequest + check func(t *testing.T, p *containerpb.NodePool) + name string + wantMsg string + want codes.Code + }{ + { + name: "labels taints tags", req: &containerpb.UpdateNodePoolRequest{ + Labels: &containerpb.NodeLabels{Labels: map[string]string{"a": "b"}}, + Taints: &containerpb.NodeTaints{ + Taints: []*containerpb.NodeTaint{{Key: "k", Value: "v", Effect: containerpb.NodeTaint_NO_EXECUTE}}, + }, + Tags: &containerpb.NetworkTags{Tags: []string{"t1", "t2"}}, + }, + check: func(t *testing.T, p *containerpb.NodePool) { + t.Helper() + assert.Equal(t, map[string]string{"a": "b"}, p.GetConfig().GetLabels()) + assert.Equal(t, containerpb.NodeTaint_NO_EXECUTE, p.GetConfig().GetTaints()[0].GetEffect()) + assert.Equal(t, []string{"t1", "t2"}, p.GetConfig().GetTags()) + }, + }, + { + name: "machine type and disk", + req: &containerpb.UpdateNodePoolRequest{MachineType: "n2-standard-8", DiskSizeGb: 200, DiskType: "pd-ssd"}, + check: func(t *testing.T, p *containerpb.NodePool) { + t.Helper() + assert.Equal(t, "n2-standard-8", p.GetConfig().GetMachineType()) + assert.Equal(t, int32(200), p.GetConfig().GetDiskSizeGb()) + assert.Equal(t, "pd-ssd", p.GetConfig().GetDiskType()) + }, + }, + { + name: "upgrade settings and kubelet", req: &containerpb.UpdateNodePoolRequest{ + UpgradeSettings: &containerpb.NodePool_UpgradeSettings{MaxSurge: 3}, + KubeletConfig: &containerpb.NodeKubeletConfig{CpuManagerPolicy: "static"}, + }, + check: func(t *testing.T, p *containerpb.NodePool) { + t.Helper() + assert.Equal(t, int32(3), p.GetUpgradeSettings().GetMaxSurge()) + assert.Equal(t, "static", p.GetConfig().GetKubeletConfig().GetCpuManagerPolicy()) + }, + }, + { + name: "image type", req: &containerpb.UpdateNodePoolRequest{ImageType: "UBUNTU_CONTAINERD"}, + check: func(t *testing.T, p *containerpb.NodePool) { + t.Helper() + assert.Equal(t, "UBUNTU_CONTAINERD", p.GetConfig().GetImageType()) + }, + }, + { + name: "bad image type", + req: &containerpb.UpdateNodePoolRequest{ImageType: "x"}, + want: codes.InvalidArgument, + wantMsg: "not supported", + }, + { + name: "bad machine type", + req: &containerpb.UpdateNodePoolRequest{MachineType: "Not A Type"}, + want: codes.InvalidArgument, + wantMsg: "machine type", + }, + { + name: "bad label", + req: &containerpb.UpdateNodePoolRequest{ + Labels: &containerpb.NodeLabels{Labels: map[string]string{"-x": "y"}}, + }, + want: codes.InvalidArgument, + wantMsg: "label", + }, + { + name: "unsupported", + req: &containerpb.UpdateNodePoolRequest{Image: "custom"}, + want: codes.InvalidArgument, + wantMsg: "not supported by the emulator", + }, + { + name: "stale etag", + req: &containerpb.UpdateNodePoolRequest{Etag: "stale", MachineType: "n2-standard-2"}, + want: codes.FailedPrecondition, + wantMsg: "etag", + }, + {name: "empty", req: &containerpb.UpdateNodePoolRequest{}, want: codes.InvalidArgument, wantMsg: "No update"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("un")) + tt.req.Name = poolPath(zone, "un", "default-pool") + + _, err := e.gc.UpdateNodePool(t.Context(), tt.req) + if tt.want != codes.OK { + assertCode(t, tt.want, err, tt.wantMsg) + assert.Equal(t, containerpb.NodePool_RUNNING, e.getPool(t, zone, "un", "default-pool").GetStatus()) + + return + } + + require.NoError(t, err) + e.settle() + tt.check(t, e.getPool(t, zone, "un", "default-pool")) + }) + } +} + +func TestNodePoolEtag(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("et")) + + p := e.getPool(t, zone, "et", "default-pool") + _, err := e.gc.UpdateNodePool(t.Context(), &containerpb.UpdateNodePoolRequest{ + Name: poolPath(zone, "et", "default-pool"), Etag: p.GetEtag(), MachineType: "n2-standard-2", + }) + require.NoError(t, err) + e.settle() + assert.NotEqual(t, p.GetEtag(), e.getPool(t, zone, "et", "default-pool").GetEtag()) +} + +func TestNodePoolNotFound(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("nf")) + missing := poolPath(zone, "nf", "ghost") + + _, err := e.gc.GetNodePool(t.Context(), &containerpb.GetNodePoolRequest{Name: missing}) + assertCode(t, codes.NotFound, err, missing) + + _, err = e.gc.DeleteNodePool(t.Context(), &containerpb.DeleteNodePoolRequest{Name: missing}) + assertCode(t, codes.NotFound, err, "") + + _, err = e.gc.SetNodePoolSize(t.Context(), &containerpb.SetNodePoolSizeRequest{Name: missing, NodeCount: 1}) + assertCode(t, codes.NotFound, err, "") + + _, err = e.gc.ListNodePools(t.Context(), &containerpb.ListNodePoolsRequest{Parent: clusterPath(zone, "ghost")}) + assertCode(t, codes.NotFound, err, "") + + _, err = e.gc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{Parent: clusterPath(zone, "nf")}) + assertCode(t, codes.InvalidArgument, err, "Node pool must be specified") +} diff --git a/services/gcpcontainer/ops.go b/services/gcpcontainer/ops.go new file mode 100644 index 0000000000..28bc0297c3 --- /dev/null +++ b/services/gcpcontainer/ops.go @@ -0,0 +1,184 @@ +//nolint:staticcheck // legacy request and operation fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "crypto/rand" + "encoding/hex" + "slices" + "strconv" + "strings" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const timeLayout = time.RFC3339Nano + +func (b *InMemoryBackend) opName() string { + var suffix [4]byte + + _, _ = rand.Read(suffix[:]) + + return "operation-" + strconv.FormatInt(b.now().UnixMilli(), 10) + "-" + hex.EncodeToString(suffix[:]) +} + +// newOpLocked records an operation running until dur has passed on the backend clock, or until finishOpLocked +// when manual. +func (b *InMemoryBackend) newOpLocked( + r ref, typ containerpb.Operation_Type, kind opKind, target string, dur time.Duration, manual bool, +) *opRec { + now := b.now().UTC() + name := b.opName() + selfLink := apiHost + "/" + locationPath(r.project, r.location) + "/" + kindOps + "/" + name + + o := &opRec{ + Op: &containerpb.Operation{ + Name: name, Zone: r.location, Location: r.location, OperationType: typ, + Status: containerpb.Operation_RUNNING, SelfLink: selfLink, TargetLink: target, + StartTime: now.Format(timeLayout), + }, + EndAt: now.Add(dur), Project: r.project, Location: r.location, Cluster: r.cluster, Pool: r.pool, + Kind: kind, Manual: manual, + } + b.ops[opKey(r.project, r.location, name)] = o + + return o +} + +func opKey(project, location, name string) string { return project + "/" + location + "/" + name } + +func (b *InMemoryBackend) opDoneLocked(o *opRec) bool { + return o.Finished || (!o.Manual && !b.now().Before(o.EndAt)) +} + +// finishOpLocked ends an operation now, recording failure when non-empty. +func (b *InMemoryBackend) finishOpLocked(o *opRec, failure string) { + if o.Finished { + return + } + + o.Finished = true + o.Failure = failure + o.EndAt = b.now().UTC() +} + +func (b *InMemoryBackend) opViewLocked(o *opRec) *containerpb.Operation { + op := clone(o.Op) + if !b.opDoneLocked(o) { + return op + } + + op.Status = containerpb.Operation_DONE + op.EndTime = o.EndAt.UTC().Format(timeLayout) + + if o.Failure != "" { + op.StatusMessage = o.Failure + op.Error = status.New(codes.Internal, o.Failure).Proto() + } + + return op +} + +func (b *InMemoryBackend) activeOpsLocked(project, location, cluster string) []*opRec { + var out []*opRec + + for _, o := range b.ops { + if o.Project == project && o.Location == location && o.Cluster == cluster && !b.opDoneLocked(o) { + out = append(out, o) + } + } + + slices.SortFunc(out, func(a, c *opRec) int { return a.EndAt.Compare(c.EndAt) }) + + return out +} + +// conflictLocked rejects an operation that cannot run next to the cluster's running ones. A node pool operation +// (pool != "") only conflicts with cluster-wide operations and operations on the same pool. +func (b *InMemoryBackend) conflictLocked(r ref, pool string) error { + for _, o := range b.activeOpsLocked(r.project, r.location, r.cluster) { + if o.Kind == kindPoolOp && pool != "" && o.Pool != pool { + continue + } + + return errBusy(r.clusterName(), o.Op.GetName()) + } + + return nil +} + +// sweepLocked drops clusters and node pools whose delete operation has finished. +func (b *InMemoryBackend) sweepLocked() { + for k, c := range b.clusters { + if c.DeleteOp != "" && b.deleteDoneLocked(k, c.DeleteOp) { + delete(b.clusters, k) + + continue + } + + c.Pools = slices.DeleteFunc(c.Pools, func(p *poolRec) bool { + return p.DeleteOp != "" && b.deleteDoneLocked(k, p.DeleteOp) + }) + } +} + +func (b *InMemoryBackend) deleteDoneLocked(clusterKey, opName string) bool { + for _, o := range b.ops { + if o.Op.GetName() == opName && o.Project+"/"+o.Location+"/"+o.Cluster == clusterKey { + return b.opDoneLocked(o) + } + } + + return true +} + +// GetOperation returns one operation. +func (b *InMemoryBackend) GetOperation(req *containerpb.GetOperationRequest) (*containerpb.Operation, error) { + r, name, err := resolveOperation(req.GetName(), req.GetProjectId(), req.GetZone(), req.GetOperationId()) + if err != nil { + return nil, err + } + + b.mu.RLock("GetOperation") + defer b.mu.RUnlock() + + o, ok := b.ops[opKey(r.project, r.location, name)] + if !ok { + return nil, errNotFound(locationPath(r.project, r.location) + "/" + kindOps + "/" + name) + } + + return b.opViewLocked(o), nil +} + +// ListOperations lists the operations of a location, or of every location for "-". +func (b *InMemoryBackend) ListOperations( + req *containerpb.ListOperationsRequest, +) (*containerpb.ListOperationsResponse, error) { + project, location, err := resolveLocation(req.GetParent(), req.GetProjectId(), req.GetZone()) + if err != nil { + return nil, err + } + + b.mu.RLock("ListOperations") + defer b.mu.RUnlock() + + resp := &containerpb.ListOperationsResponse{} + + for _, o := range b.ops { + if o.Project == project && (location == wildcard || o.Location == location) { + resp.Operations = append(resp.Operations, b.opViewLocked(o)) + } + } + + slices.SortFunc(resp.GetOperations(), func(a, c *containerpb.Operation) int { + if d := strings.Compare(a.GetStartTime(), c.GetStartTime()); d != 0 { + return d + } + + return strings.Compare(a.GetName(), c.GetName()) + }) + + return resp, nil +} diff --git a/services/gcpcontainer/persistence.go b/services/gcpcontainer/persistence.go new file mode 100644 index 0000000000..d65acfa1d8 --- /dev/null +++ b/services/gcpcontainer/persistence.go @@ -0,0 +1,210 @@ +package gcpcontainer + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the snapshot types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type poolSnap struct { + PrevVersion string `json:"prevVersion"` + DeleteOp string `json:"deleteOp"` + Pool json.RawMessage `json:"pool"` + Size int32 `json:"size"` +} + +type clusterSnap struct { + Rotation *rotationRec `json:"rotation,omitempty"` + Cluster json.RawMessage `json:"cluster"` + Project string `json:"project"` + Failure string `json:"failure"` + DeleteOp string `json:"deleteOp"` + Pools []poolSnap `json:"pools"` +} + +type opSnap struct { + EndAt time.Time `json:"endAt"` + Project string `json:"project"` + Location string `json:"location"` + Cluster string `json:"cluster"` + Pool string `json:"pool"` + Failure string `json:"failure"` + Op json.RawMessage `json:"op"` + Kind opKind `json:"kind"` + Manual bool `json:"manual"` + Finished bool `json:"finished"` +} + +type backendSnapshot struct { + Clusters map[string]clusterSnap `json:"clusters"` + Ops map[string]opSnap `json:"operations"` + Version int `json:"version"` + Seq uint64 `json:"seq"` +} + +func marshalPB(m proto.Message) json.RawMessage { + raw, err := protojson.Marshal(m) + if err != nil { + return nil + } + + return raw +} + +func unmarshalPB(raw []byte, m proto.Message) error { + return protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(raw, m) +} + +// Snapshot serialises clusters, node pools and operations. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + snap := backendSnapshot{ + Version: snapshotVersion, Seq: b.seq, + Clusters: make(map[string]clusterSnap, len(b.clusters)), Ops: make(map[string]opSnap, len(b.ops)), + } + + for k, c := range b.clusters { + cs := clusterSnap{ + Project: c.Project, + Cluster: marshalPB(c.Cluster), + Rotation: c.Rotation, + Failure: c.Failure, + DeleteOp: c.DeleteOp, + } + + for _, p := range c.Pools { + cs.Pools = append(cs.Pools, poolSnap{ + Pool: marshalPB(p.Pool), PrevVersion: p.PrevVersion, DeleteOp: p.DeleteOp, Size: p.Size, + }) + } + + snap.Clusters[k] = cs + } + + for k, o := range b.ops { + snap.Ops[k] = opSnap{ + Op: marshalPB(o.Op), EndAt: o.EndAt, Project: o.Project, Location: o.Location, Cluster: o.Cluster, + Pool: o.Pool, Failure: o.Failure, Kind: o.Kind, Manual: o.Manual, Finished: o.Finished, + } + } + + return persistence.MarshalSnapshot(ctx, "gcpcontainer", snap) +} + +func restoreClusters(in map[string]clusterSnap) (map[string]*clusterRec, error) { + out := make(map[string]*clusterRec, len(in)) + + for k, cs := range in { + cl := &containerpb.Cluster{} + if err := unmarshalPB(cs.Cluster, cl); err != nil { + return nil, fmt.Errorf("cluster %q: %w", k, err) + } + + rec := &clusterRec{ + Project: cs.Project, Cluster: cl, Rotation: cs.Rotation, Failure: cs.Failure, DeleteOp: cs.DeleteOp, + } + + for _, ps := range cs.Pools { + np := &containerpb.NodePool{} + if err := unmarshalPB(ps.Pool, np); err != nil { + return nil, fmt.Errorf("cluster %q node pool: %w", k, err) + } + + rec.Pools = append( + rec.Pools, + &poolRec{Pool: np, PrevVersion: ps.PrevVersion, DeleteOp: ps.DeleteOp, Size: ps.Size}, + ) + } + + out[k] = rec + } + + return out, nil +} + +func restoreOps(in map[string]opSnap) (map[string]*opRec, error) { + out := make(map[string]*opRec, len(in)) + + for k, os := range in { + op := &containerpb.Operation{} + if err := unmarshalPB(os.Op, op); err != nil { + return nil, fmt.Errorf("operation %q: %w", k, err) + } + + out[k] = &opRec{ + Op: op, EndAt: os.EndAt, Project: os.Project, Location: os.Location, Cluster: os.Cluster, Pool: os.Pool, + Failure: os.Failure, Kind: os.Kind, Manual: os.Manual, Finished: os.Finished, + } + } + + return out, nil +} + +// Restore loads a snapshot, replacing all state. Engine clusters are started again from scratch. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "gcpcontainer", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "gcpcontainer: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + clusters, err := restoreClusters(snap.Clusters) + if err != nil { + return fmt.Errorf("gcpcontainer: restore: %w", err) + } + + ops, err := restoreOps(snap.Ops) + if err != nil { + return fmt.Errorf("gcpcontainer: restore: %w", err) + } + + b.stop() + b.wg.Wait() + + b.mu.Lock("Restore") + lcs := b.detachAllLocked() + b.clusters, b.ops, b.seq = clusters, ops, snap.Seq + b.runCtx, b.stop = newRunCtx() + b.relaunchLocked() + e := b.engine + b.mu.Unlock() + + if e != nil { + e.reap(lcs) + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("gcpcontainer: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/gcpcontainer/persistence_test.go b/services/gcpcontainer/persistence_test.go new file mode 100644 index 0000000000..dd3e215968 --- /dev/null +++ b/services/gcpcontainer/persistence_test.go @@ -0,0 +1,119 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "testing" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/services/gcpcontainer" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + src := newEnv(t) + src.ready(t, zone, &containerpb.Cluster{ + Name: "keep", InitialNodeCount: 2, InitialClusterVersion: "1.34.2-gke.1008000", + ResourceLabels: map[string]string{"k": "v"}, + }) + src.ready(t, region, &containerpb.Cluster{Name: "ap", Autopilot: &containerpb.Autopilot{Enabled: true}}) + + _, err := src.gc.UpdateCluster(t.Context(), &containerpb.UpdateClusterRequest{ + Name: clusterPath(zone, "keep"), Update: &containerpb.ClusterUpdate{DesiredMasterVersion: "1.35.1-gke.1000000"}, + }) + require.NoError(t, err) + src.settle() + + _, err = src.gc.UpdateNodePool(t.Context(), &containerpb.UpdateNodePoolRequest{ + Name: poolPath(zone, "keep", "default-pool"), NodeVersion: "latest", + }) + require.NoError(t, err) + src.settle() + + _, err = src.gc.StartIPRotation( + t.Context(), + &containerpb.StartIPRotationRequest{Name: clusterPath(zone, "keep"), RotateCredentials: true}, + ) + require.NoError(t, err) + + inflight, err := src.gc.DeleteCluster( + t.Context(), + &containerpb.DeleteClusterRequest{Name: clusterPath(region, "ap")}, + ) + require.NoError(t, err) + + data := src.h.Snapshot(t.Context()) + require.NotEmpty(t, data) + + dst := newEnv(t) + dst.clock.Advance(src.clock.Now().Sub(dst.clock.Now())) + require.NoError(t, dst.h.Restore(t.Context(), data)) + + want := src.getCluster(t, zone, "keep") + got := dst.getCluster(t, zone, "keep") + assert.True(t, proto.Equal(want, got), "cluster survives a restore") + assert.Equal(t, containerpb.Cluster_RECONCILING, got.GetStatus(), "in-flight operations stay in flight") + + ops, err := dst.gc.GetOperation( + t.Context(), + &containerpb.GetOperationRequest{Name: regionLc + "/operations/" + inflight.GetName()}, + ) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_DELETE_CLUSTER, ops.GetOperationType()) + + assert.Equal(t, containerpb.Cluster_STOPPING, dst.getCluster(t, region, "ap").GetStatus()) + + dst.settle() + assert.Equal(t, containerpb.Cluster_RUNNING, dst.getCluster(t, zone, "keep").GetStatus()) + + _, err = dst.gc.CompleteIPRotation( + t.Context(), + &containerpb.CompleteIPRotationRequest{Name: clusterPath(zone, "keep")}, + ) + require.NoError(t, err, "rotation state is restored") + + _, err = dst.gc.RollbackNodePoolUpgrade(t.Context(), &containerpb.RollbackNodePoolUpgradeRequest{ + Name: poolPath(zone, "keep", "default-pool"), + }) + assertCode(t, codes.FailedPrecondition, err, "") + + dst.settle() + + _, err = dst.gc.RollbackNodePoolUpgrade(t.Context(), &containerpb.RollbackNodePoolUpgradeRequest{ + Name: poolPath(zone, "keep", "default-pool"), + }) + require.NoError(t, err, "previous node version is restored") + + dst.settle() + + _, err = dst.gc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: clusterPath(region, "ap")}) + assertCode(t, codes.NotFound, err, "") +} + +func TestRestoreRejectsGarbage(t *testing.T) { + t.Parallel() + + b := gcpcontainer.NewInMemoryBackend() + require.Error(t, b.Restore(t.Context(), []byte("not json"))) +} + +func TestReset(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.ready(t, zone, stdCluster("gone")) + e.h.Reset() + + list, err := e.gc.ListClusters(t.Context(), &containerpb.ListClustersRequest{Parent: zoneLoc}) + require.NoError(t, err) + assert.Empty(t, list.GetClusters()) + + ops, err := e.gc.ListOperations(t.Context(), &containerpb.ListOperationsRequest{Parent: zoneLoc}) + require.NoError(t, err) + assert.Empty(t, ops.GetOperations()) +} diff --git a/services/gcpcontainer/pools.go b/services/gcpcontainer/pools.go new file mode 100644 index 0000000000..dc8b85ab20 --- /dev/null +++ b/services/gcpcontainer/pools.go @@ -0,0 +1,210 @@ +//nolint:staticcheck // legacy cluster fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "slices" + + "cloud.google.com/go/container/apiv1/containerpb" +) + +const ( + defaultMachineType = "e2-medium" + defaultDiskType = "pd-balanced" + defaultPoolName = "default-pool" + defaultMaxSurge = 1 + groupHashBytes = 4 +) + +func defaultScopes() []string { + return []string{ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/trace.append", + } +} + +func withNodeDefaults(in *containerpb.NodeConfig) *containerpb.NodeConfig { + nc := clone(in) + if nc == nil { + nc = &containerpb.NodeConfig{} + } + + if nc.GetMachineType() == "" { + nc.MachineType = defaultMachineType + } + + if nc.GetDiskSizeGb() == 0 { + nc.DiskSizeGb = defaultDiskGb + } + + if nc.GetDiskType() == "" { + nc.DiskType = defaultDiskType + } + + if nc.GetImageType() == "" { + nc.ImageType = defaultImageType + } + + if len(nc.GetOauthScopes()) == 0 { + nc.OauthScopes = defaultScopes() + } + + if nc.GetServiceAccount() == "" { + nc.ServiceAccount = "default" + } + + if nc.Metadata == nil { + nc.Metadata = map[string]string{"disable-legacy-endpoints": "true"} + } + + if nc.GetShieldedInstanceConfig() == nil { + nc.ShieldedInstanceConfig = &containerpb.ShieldedInstanceConfig{EnableIntegrityMonitoring: true} + } + + return nc +} + +func groupURL(project, zone, cluster, pool string) string { + sum := sha256.Sum256([]byte(project + "/" + zone + "/" + cluster + "/" + pool)) + + return fmt.Sprintf("%s/projects/%s/zones/%s/instanceGroupManagers/gke-%s-%s-%s-grp", + computeV, project, zone, cluster, pool, hex.EncodeToString(sum[:groupHashBytes])) +} + +func groupURLs(project string, zones []string, cluster, pool string) []string { + out := make([]string, 0, len(zones)) + for _, z := range zones { + out = append(out, groupURL(project, z, cluster, pool)) + } + + return out +} + +func poolSelfLink(r ref) string { return apiHost + "/" + r.poolName() } + +type poolCtx struct { + ref ref + version string + zones []string + autoUpdate bool +} + +// buildPoolLocked validates in and returns the node pool record created for it. +func (b *InMemoryBackend) buildPoolLocked(pc poolCtx, in *containerpb.NodePool) (*poolRec, error) { + name := firstNonEmpty(in.GetName(), defaultPoolName) + if err := validName("node_pool.name", name); err != nil { + return nil, err + } + + if err := b.validatePoolInput(in); err != nil { + return nil, err + } + + version, err := resolveNodeVersion(in.GetVersion(), pc.version) + if err != nil { + return nil, err + } + + zones, err := poolZones(in.GetLocations(), pc.zones) + if err != nil { + return nil, err + } + + pc.ref.pool = name + p := clone(in) + p.Name = name + p.Config = withNodeDefaults(in.GetConfig()) + p.Version = version + p.Locations = zones + p.SelfLink = poolSelfLink(pc.ref) + p.InstanceGroupUrls = groupURLs(pc.ref.project, zones, pc.ref.cluster, name) + p.Status = containerpb.NodePool_PROVISIONING + p.Etag = "" + p.Conditions = nil + p.StatusMessage = "" + + if p.GetManagement() == nil { + p.Management = &containerpb.NodeManagement{AutoUpgrade: true, AutoRepair: true} + } + + if pc.autoUpdate { + p.Management.AutoUpgrade = true + } + + if p.GetMaxPodsConstraint() == nil { + p.MaxPodsConstraint = &containerpb.MaxPodsConstraint{MaxPodsPerNode: defaultPodsMax} + } + + p.PodIpv4CidrSize = podPrefix(p.GetMaxPodsConstraint().GetMaxPodsPerNode()) + + if p.GetUpgradeSettings() == nil { + strategy := containerpb.NodePoolUpdateStrategy_SURGE + p.UpgradeSettings = &containerpb.NodePool_UpgradeSettings{ + MaxSurge: defaultMaxSurge, Strategy: &strategy, + } + } + + if p.GetAutoscaling() == nil { + p.Autoscaling = &containerpb.NodePoolAutoscaling{} + } + + return &poolRec{Pool: p, Size: in.GetInitialNodeCount()}, nil +} + +func (b *InMemoryBackend) validatePoolInput(in *containerpb.NodePool) error { + if n := in.GetInitialNodeCount(); n < 0 || n > maxNodeCount { + return errInvalid("node_pool.initial_node_count", "Initial node count must be between 0 and %d.", maxNodeCount) + } + + if err := validAutoscaling(in.GetAutoscaling()); err != nil { + return err + } + + if in.GetInitialNodeCount() == 0 && !in.GetAutoscaling().GetEnabled() { + return errInvalid("node_pool.initial_node_count", "Initial node count must be greater than 0.") + } + + if err := validMaxPods("node_pool.max_pods_constraint", in.GetMaxPodsConstraint()); err != nil { + return err + } + + return validNodeConfig(b.compute, in.GetConfig()) +} + +func poolZones(want, clusterZones []string) ([]string, error) { + if len(want) == 0 { + return slices.Clone(clusterZones), nil + } + + for _, z := range want { + if !slices.Contains(clusterZones, z) { + return nil, errInvalid( + "node_pool.locations", + "Node pool location %q is not one of the cluster locations %v.", + z, + clusterZones, + ) + } + } + + out := slices.Clone(want) + slices.Sort(out) + + return slices.Compact(out), nil +} + +func (c *clusterRec) pool(name string) (*poolRec, bool) { + for _, p := range c.Pools { + if p.Pool.GetName() == name { + return p, true + } + } + + return nil, false +} diff --git a/services/gcpcontainer/provider.go b/services/gcpcontainer/provider.go new file mode 100644 index 0000000000..3fd27a7f1b --- /dev/null +++ b/services/gcpcontainer/provider.go @@ -0,0 +1,75 @@ +package gcpcontainer + +import ( + "errors" + "os" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("gcpcontainer: nil app context") + +// ConfigProvider extracts Kubernetes Engine configuration from the AppContext Config. +type ConfigProvider interface { + GetGCPContainerSettings() Settings +} + +// EngineConfigProvider exposes the engine mode (stub or docker). +type EngineConfigProvider interface { + GetGCPContainerEngine() string +} + +// Provider implements service.Provider for Kubernetes Engine. It serves gRPC and REST on its own listener +// (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "GCPContainer" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetGCPContainerSettings() + } + + backend := NewInMemoryBackend() + if ec, ok := ctx.Config.(EngineConfigProvider); ok && ec.GetGCPContainerEngine() == EngineDocker { + enableDockerEngine(ctx, backend) + } + + handler := NewHandler(backend) + handler.Port = settings.Port + + return handler, nil +} + +func enableDockerEngine(ctx *service.AppContext, backend *InMemoryBackend) { + rt, err := container.NewRuntime(container.Config{Logger: ctx.Logger}) + if err != nil { + ctx.Logger.Warn("GKE: container runtime unavailable; clusters stay metadata-only", "error", err) + + return + } + + err = backend.EnableEngine(EngineConfig{ + Runtime: rt, + Ports: ctx.PortAlloc, + Logger: ctx.Logger, + Host: os.Getenv("GKE_CLUSTER_HOST"), + Token: os.Getenv("GKE_CLUSTER_TOKEN"), + Image: os.Getenv("GKE_K3S_IMAGE"), + }) + if err != nil { + ctx.Logger.Warn("GKE: invalid cluster engine configuration; clusters stay metadata-only", "error", err) + _ = rt.Close() + } +} diff --git a/services/gcpcontainer/ranges.go b/services/gcpcontainer/ranges.go new file mode 100644 index 0000000000..14aadff98c --- /dev/null +++ b/services/gcpcontainer/ranges.go @@ -0,0 +1,209 @@ +//nolint:staticcheck // legacy cluster fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "encoding/binary" + "net/netip" + "strconv" + "strings" + + "cloud.google.com/go/container/apiv1/containerpb" +) + +const ( + defaultPodBits = 14 + defaultSvcBits = 20 + ipv4Bits = 32 +) + +type ranges struct { + pod netip.Prefix + services netip.Prefix + podName string + svcName string +} + +func addrToU32(a netip.Addr) uint32 { + b := a.As4() + + return binary.BigEndian.Uint32(b[:]) +} + +func u32ToAddr(n uint32) netip.Addr { + var b [4]byte + + binary.BigEndian.PutUint32(b[:], n) + + return netip.AddrFrom4(b) +} + +// allocBlock returns the first bits-sized block in [from, to) that overlaps none of used. +func allocBlock(from, to string, bits int, used []netip.Prefix) (netip.Prefix, bool) { + lo, hi := addrToU32(netip.MustParseAddr(from)), addrToU32(netip.MustParseAddr(to)) + size := uint32(1) << (ipv4Bits - bits) + + for a := lo; a+size-1 < hi && a >= lo; a += size { + p := netip.PrefixFrom(u32ToAddr(a), bits) + + clash := false + + for _, u := range used { + if u.Overlaps(p) { + clash = true + + break + } + } + + if !clash { + return p, true + } + } + + return netip.Prefix{}, false +} + +// usedRangesLocked are the pod and service ranges of the project's clusters. +func (b *InMemoryBackend) usedRangesLocked(project string) []netip.Prefix { + var out []netip.Prefix + + for _, c := range b.clusters { + if c.Project != project { + continue + } + + ip := c.Cluster.GetIpAllocationPolicy() + + blocks := []string{ip.GetClusterIpv4CidrBlock(), ip.GetServicesIpv4CidrBlock(), c.Cluster.GetClusterIpv4Cidr()} + + for _, s := range blocks { + if p, err := netip.ParsePrefix(s); err == nil { + out = append(out, p) + } + } + } + + return out +} + +// wantBits reads a "/N" size request; ok is false for an explicit CIDR or an empty value. +func wantBits(s string) (int, bool) { + rest, ok := strings.CutPrefix(s, "/") + if !ok { + return 0, false + } + + n, err := strconv.Atoi(rest) + + return n, err == nil +} + +func (b *InMemoryBackend) chooseRanges(project, region, subnet string, in *containerpb.Cluster) (ranges, error) { + ip := in.GetIpAllocationPolicy() + r := ranges{podName: ip.GetClusterSecondaryRangeName(), svcName: ip.GetServicesSecondaryRangeName()} + pod := firstNonEmpty(ip.GetClusterIpv4CidrBlock(), ip.GetClusterIpv4Cidr(), in.GetClusterIpv4Cidr()) + svc := firstNonEmpty(ip.GetServicesIpv4CidrBlock(), ip.GetServicesIpv4Cidr()) + + var err error + + if pod, err = b.secondary(project, region, subnet, r.podName, pod, "cluster_secondary_range_name"); err != nil { + return r, err + } + + if svc, err = b.secondary(project, region, subnet, r.svcName, svc, "services_secondary_range_name"); err != nil { + return r, err + } + + used := b.usedRangesLocked(project) + + if r.pod, err = pickRange( + pod, + defaultPodBits, + minPodPrefix, + maxPodPrefix, + "10.0.0.0", + "10.128.0.0", + used, + "cluster_ipv4_cidr_block", + ); err != nil { + return r, err + } + + used = append(used, r.pod) + + if r.services, err = pickRange( + svc, + defaultSvcBits, + minSvcPrefix, + maxSvcPrefix, + "34.118.224.0", + "34.120.0.0", + used, + "services_ipv4_cidr_block", + ); err != nil { + return r, err + } + + if r.pod.Overlaps(r.services) { + return r, errInvalid( + "ip_allocation_policy", + "The pod range %s overlaps the services range %s.", + r.pod, + r.services, + ) + } + + return r, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + + return "" +} + +func (b *InMemoryBackend) secondary(project, region, subnet, rangeName, explicit, field string) (string, error) { + if rangeName == "" || b.compute == nil { + return explicit, nil + } + + cidr, ok := b.compute.SecondaryRange(project, region, subnet, rangeName) + if !ok { + return "", errInvalid(field, "Secondary range %q does not exist in subnetwork %q.", rangeName, subnet) + } + + return cidr, nil +} + +func pickRange( + want string, + defBits, minBits, maxBits int, + from, to string, + used []netip.Prefix, + field string, +) (netip.Prefix, error) { + bits := defBits + + if n, ok := wantBits(want); ok { + if n < minBits || n > maxBits { + return netip.Prefix{}, errInvalid(field, "CIDR size /%d must be between /%d and /%d.", n, minBits, maxBits) + } + + bits, want = n, "" + } + + if want != "" { + return parseCIDR(field, want, minBits, maxBits) + } + + p, ok := allocBlock(from, to, bits, used) + if !ok { + return netip.Prefix{}, errInvalid(field, "No free /%d range is available.", bits) + } + + return p, nil +} diff --git a/services/gcpcontainer/rest_test.go b/services/gcpcontainer/rest_test.go new file mode 100644 index 0000000000..ca4c1aa1b4 --- /dev/null +++ b/services/gcpcontainer/rest_test.go @@ -0,0 +1,195 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" +) + +func TestRESTClusterAndNodePoolLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + op, err := e.rc.CreateCluster(t.Context(), &containerpb.CreateClusterRequest{ + Parent: zoneLoc, Cluster: &containerpb.Cluster{ + Name: "rest", InitialNodeCount: 2, ResourceLabels: map[string]string{"via": "rest"}, + NodeConfig: &containerpb.NodeConfig{MachineType: "n2-standard-2"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_CREATE_CLUSTER, op.GetOperationType()) + assert.Equal(t, containerpb.Operation_RUNNING, op.GetStatus()) + + _, err = e.rc.CreateCluster(t.Context(), &containerpb.CreateClusterRequest{ + Parent: zoneLoc, Cluster: &containerpb.Cluster{Name: "rest", InitialNodeCount: 1}, + }) + assertCode(t, codes.AlreadyExists, err, "") + + e.settle() + + got, err := e.rc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: clusterPath(zone, "rest")}) + require.NoError(t, err) + assert.Equal(t, containerpb.Cluster_RUNNING, got.GetStatus()) + assert.Equal(t, map[string]string{"via": "rest"}, got.GetResourceLabels()) + assert.Equal(t, "n2-standard-2", got.GetNodePools()[0].GetConfig().GetMachineType()) + + _, err = e.rc.GetCluster(t.Context(), &containerpb.GetClusterRequest{Name: clusterPath(zone, "ghost")}) + assertCode(t, codes.NotFound, err, "") + + pop, err := e.rc.CreateNodePool(t.Context(), &containerpb.CreateNodePoolRequest{ + Parent: clusterPath(zone, "rest"), NodePool: &containerpb.NodePool{Name: "extra", InitialNodeCount: 1}, + }) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_CREATE_NODE_POOL, pop.GetOperationType()) + e.settle() + + sz, err := e.rc.SetNodePoolSize( + t.Context(), + &containerpb.SetNodePoolSizeRequest{Name: poolPath(zone, "rest", "extra"), NodeCount: 4}, + ) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_NODE_POOL_SIZE, sz.GetOperationType()) + e.settle() + + list, err := e.rc.ListNodePools(t.Context(), &containerpb.ListNodePoolsRequest{Parent: clusterPath(zone, "rest")}) + require.NoError(t, err) + assert.Len(t, list.GetNodePools(), 2) + + lb, err := e.rc.SetLabels(t.Context(), &containerpb.SetLabelsRequest{ + Name: clusterPath( + zone, + "rest", + ), + ResourceLabels: map[string]string{"x": "y"}, + LabelFingerprint: got.GetLabelFingerprint(), + }) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_SET_LABELS, lb.GetOperationType()) + e.settle() + + up, err := e.rc.UpdateCluster(t.Context(), &containerpb.UpdateClusterRequest{ + Name: clusterPath(zone, "rest"), Update: &containerpb.ClusterUpdate{DesiredMonitoringService: "none"}, + }) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_UPDATE_CLUSTER, up.GetOperationType()) + e.settle() + + fetched, err := e.rc.GetOperation(t.Context(), &containerpb.GetOperationRequest{ + Name: zoneLoc + "/operations/" + up.GetName(), + }) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_DONE, fetched.GetStatus()) + + ops, err := e.rc.ListOperations(t.Context(), &containerpb.ListOperationsRequest{Parent: zoneLoc}) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(ops.GetOperations()), 5) + + cfg, err := e.rc.GetServerConfig(t.Context(), &containerpb.GetServerConfigRequest{Name: zoneLoc}) + require.NoError(t, err) + assert.Len(t, cfg.GetChannels(), 4) + + del, err := e.rc.DeleteCluster(t.Context(), &containerpb.DeleteClusterRequest{Name: clusterPath(zone, "rest")}) + require.NoError(t, err) + assert.Equal(t, containerpb.Operation_DELETE_CLUSTER, del.GetOperationType()) + e.settle() + + listC, err := e.rc.ListClusters(t.Context(), &containerpb.ListClustersRequest{Parent: zoneLoc}) + require.NoError(t, err) + assert.Empty(t, listC.GetClusters()) +} + +func (e *env) raw(t *testing.T, method, path string, body any) (int, map[string]any) { + t.Helper() + + var rdr io.Reader + + if body != nil { + raw, err := json.Marshal(body) + require.NoError(t, err) + + rdr = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(t.Context(), method, "http://"+e.h.Addr()+path, rdr) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + + out := map[string]any{} + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + if len(raw) > 0 { + require.NoError(t, json.Unmarshal(raw, &out), string(raw)) + } + + return resp.StatusCode, out +} + +func TestRESTWireShape(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + status, op := e.raw(t, http.MethodPost, "/v1/projects/"+project+"/zones/"+zone+"/clusters", map[string]any{ + "cluster": map[string]any{"name": "wire", "initialNodeCount": 1}, + }) + require.Equal(t, http.StatusOK, status, op) + assert.Equal(t, "CREATE_CLUSTER", op["operationType"]) + assert.Equal(t, "RUNNING", op["status"]) + assert.Equal(t, zone, op["zone"]) + assert.Contains(t, op["selfLink"], "/operations/") + + e.settle() + + status, cl := e.raw(t, http.MethodGet, "/v1/projects/"+project+"/zones/"+zone+"/clusters/wire", nil) + require.Equal(t, http.StatusOK, status, cl) + assert.Equal(t, "RUNNING", cl["status"]) + assert.Equal(t, "wire", cl["name"]) + assert.InDelta(t, 1, cl["currentNodeCount"], 0, "int32 counts are JSON numbers") + + pools, ok := cl["nodePools"].([]any) + require.True(t, ok) + + pool, ok := pools[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "default-pool", pool["name"]) + + status, list := e.raw(t, http.MethodGet, "/v1/projects/"+project+"/locations/-/clusters", nil) + require.Equal(t, http.StatusOK, status) + assert.Len(t, list["clusters"], 1) + + status, errBody := e.raw(t, http.MethodGet, "/v1/projects/"+project+"/zones/"+zone+"/clusters/ghost", nil) + assert.Equal(t, http.StatusNotFound, status) + + errObj, ok := errBody["error"].(map[string]any) + require.True(t, ok, errBody) + assert.Equal(t, "NOT_FOUND", errObj["status"]) + assert.Contains(t, errObj["message"], "Not found: ") + + status, errBody = e.raw(t, http.MethodPost, "/v1/projects/"+project+"/zones/"+zone+"/clusters", map[string]any{ + "cluster": map[string]any{"name": "BAD"}, + }) + assert.Equal(t, http.StatusBadRequest, status) + + errObj, ok = errBody["error"].(map[string]any) + require.True(t, ok, errBody) + assert.Equal(t, "INVALID_ARGUMENT", errObj["status"]) + assert.NotEmpty(t, errObj["details"]) +} diff --git a/services/gcpcontainer/serverconfig_test.go b/services/gcpcontainer/serverconfig_test.go new file mode 100644 index 0000000000..c29894a116 --- /dev/null +++ b/services/gcpcontainer/serverconfig_test.go @@ -0,0 +1,70 @@ +//nolint:staticcheck // the API still serves fields the proto marks deprecated +package gcpcontainer_test + +import ( + "testing" + + "cloud.google.com/go/container/apiv1/containerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestGetServerConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + req *containerpb.GetServerConfigRequest + name string + want codes.Code + }{ + {&containerpb.GetServerConfigRequest{Name: zoneLoc}, "zone", codes.OK}, + {&containerpb.GetServerConfigRequest{Name: regionLc}, "region", codes.OK}, + {&containerpb.GetServerConfigRequest{ProjectId: project, Zone: zone}, "legacy fields", codes.OK}, + {&containerpb.GetServerConfigRequest{Name: "projects/p/locations/moon-1"}, "unknown location", codes.NotFound}, + {&containerpb.GetServerConfigRequest{Name: "garbage"}, "bad name", codes.InvalidArgument}, + {&containerpb.GetServerConfigRequest{}, "empty", codes.InvalidArgument}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + cfg, err := e.gc.GetServerConfig(t.Context(), tt.req) + if tt.want != codes.OK { + assertCode(t, tt.want, err, "") + + return + } + + require.NoError(t, err) + assert.Equal(t, "COS_CONTAINERD", cfg.GetDefaultImageType()) + assert.Contains(t, cfg.GetValidImageTypes(), "UBUNTU_CONTAINERD") + assert.Contains(t, cfg.GetValidMasterVersions(), cfg.GetDefaultClusterVersion()) + assert.Equal(t, "1.35.1-gke.1000000", cfg.GetValidMasterVersions()[0], "newest first") + + for _, v := range cfg.GetValidMasterVersions() { + assert.Contains(t, cfg.GetValidNodeVersions(), v) + } + + require.Len(t, cfg.GetChannels(), 4) + + seen := make(map[containerpb.ReleaseChannel_Channel]bool) + + for _, ch := range cfg.GetChannels() { + seen[ch.GetChannel()] = true + + assert.Contains(t, ch.GetValidVersions(), ch.GetDefaultVersion()) + assert.Contains(t, ch.GetValidVersions(), ch.GetUpgradeTargetVersion()) + + for _, v := range ch.GetValidVersions() { + assert.Contains(t, cfg.GetValidMasterVersions(), v) + } + } + + assert.Len(t, seen, 4) + }) + } +} diff --git a/services/gcpcontainer/settings.go b/services/gcpcontainer/settings.go new file mode 100644 index 0000000000..71b6f56534 --- /dev/null +++ b/services/gcpcontainer/settings.go @@ -0,0 +1,20 @@ +package gcpcontainer + +// DefaultPort is the dedicated Kubernetes Engine gRPC and REST listener's default TCP port. +const DefaultPort = 8144 + +// Engine modes (GKE_ENGINE). +const ( + EngineStub = "stub" + EngineDocker = "docker" +) + +// Settings holds service-level configuration, embedded in the root CLI as CLI.GCPContainer. +type Settings struct { + Port int `json:"port" env:"GCP_CONTAINER_PORT" default:"8144" name:"port" help:"Fixed TCP port for the dedicated Kubernetes Engine gRPC and REST listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { + return Settings{Port: DefaultPort} +} diff --git a/services/gcpcontainer/store.go b/services/gcpcontainer/store.go new file mode 100644 index 0000000000..1225d2d4a2 --- /dev/null +++ b/services/gcpcontainer/store.go @@ -0,0 +1,166 @@ +package gcpcontainer + +import ( + "context" + "sync" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + defCreate = 600 * time.Millisecond + defMutate = 300 * time.Millisecond + defDelete = 400 * time.Millisecond +) + +// Timing controls how long operations take on the backend clock. +type Timing struct { + Create time.Duration + Mutate time.Duration + Delete time.Duration +} + +// DefaultTiming returns the operation durations used unless a test overrides them. +func DefaultTiming() Timing { + return Timing{Create: defCreate, Mutate: defMutate, Delete: defDelete} +} + +// Compute resolves the networks and machine types of the Compute Engine emulator, so clusters can be checked +// against them. +type Compute interface { + NetworkExists(project, network string) bool + SubnetworkExists(project, region, subnetwork string) bool + SecondaryRange(project, region, subnetwork, rangeName string) (string, bool) + MachineTypeExists(machineType string) bool +} + +type poolRec struct { + Pool *containerpb.NodePool + PrevVersion string + DeleteOp string + Size int32 +} + +type rotationRec struct { + OldEndpoint string + Credentials bool +} + +type clusterRec struct { + Project string + Cluster *containerpb.Cluster + Rotation *rotationRec + Failure string + DeleteOp string + Pools []*poolRec +} + +type opKind int + +const ( + kindCreate opKind = iota + kindDelete + kindClusterOp + kindPoolOp +) + +type opRec struct { + Op *containerpb.Operation + EndAt time.Time + Project string + Location string + Cluster string + Pool string + Failure string + Kind opKind + Manual bool + Finished bool +} + +// InMemoryBackend holds all Kubernetes Engine state under one lock. +type InMemoryBackend struct { + now func() time.Time + compute Compute + engine *clusterEngine + mu *lockmetrics.RWMutex + wg *sync.WaitGroup + runCtx context.Context //nolint:containedctx // engine goroutines outlive any single request + stop context.CancelFunc + clusters map[string]*clusterRec + ops map[string]*opRec + timing Timing + seq uint64 +} + +// NewInMemoryBackend creates an empty backend on the wall clock. +func NewInMemoryBackend() *InMemoryBackend { + ctx, stop := context.WithCancel(context.Background()) + + return &InMemoryBackend{ + now: time.Now, mu: lockmetrics.New("gcpcontainer"), wg: &sync.WaitGroup{}, runCtx: ctx, stop: stop, + clusters: map[string]*clusterRec{}, ops: map[string]*opRec{}, timing: DefaultTiming(), + } +} + +// SetClock replaces the clock statuses and operations are derived from; tests inject a fake clock here. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + + b.now = now +} + +// SetTiming replaces the operation durations. +func (b *InMemoryBackend) SetTiming(t Timing) { + b.mu.Lock("SetTiming") + defer b.mu.Unlock() + + b.timing = t +} + +// SetCompute connects clusters to the Compute Engine emulator's networks and machine types; nil disconnects. +func (b *InMemoryBackend) SetCompute(c Compute) { + b.mu.Lock("SetCompute") + defer b.mu.Unlock() + + b.compute = c +} + +func newRunCtx() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) +} + +func clone[T proto.Message](m T) T { + out, _ := proto.Clone(m).(T) + + return out +} + +// Close stops engine goroutines and releases cluster containers. +func (b *InMemoryBackend) Close() { + b.stop() + b.wg.Wait() + b.closeEngine() +} + +// Reset clears all state and tears down engine clusters. +func (b *InMemoryBackend) Reset() { + b.stop() + b.wg.Wait() + + b.mu.Lock("Reset") + lcs := b.detachAllLocked() + b.clusters = map[string]*clusterRec{} + b.ops = map[string]*opRec{} + b.runCtx, b.stop = newRunCtx() + e := b.engine + b.mu.Unlock() + + if e != nil { + e.reap(lcs) + } +} diff --git a/services/gcpcontainer/update.go b/services/gcpcontainer/update.go new file mode 100644 index 0000000000..54eeb81292 --- /dev/null +++ b/services/gcpcontainer/update.go @@ -0,0 +1,420 @@ +//nolint:staticcheck // legacy cluster fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "strings" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +const allPools = "-" + +type updatePlan struct { + nodeAutoscaling *containerpb.NodePoolAutoscaling + poolID string + nodeVersion string + imageType string + masterVersion string + locations []string + hasNodeVersion bool + hasMaster bool + hasAutoscaling bool + hasImage bool + hasLocations bool +} + +// UpdateCluster applies the Desired* fields of a ClusterUpdate. Every field with a matching cluster field is +// copied onto it; fields the emulator cannot honour are rejected rather than ignored. +func (b *InMemoryBackend) UpdateCluster(req *containerpb.UpdateClusterRequest) (*containerpb.Operation, error) { + r, err := resolveCluster(req.GetName(), req.GetProjectId(), req.GetZone(), req.GetClusterId()) + if err != nil { + return nil, err + } + + u := req.GetUpdate() + if u == nil || !u.ProtoReflect().IsValid() || proto.Equal(u, &containerpb.ClusterUpdate{}) { + return nil, errInvalid("update", "No update was specified.") + } + + b.mu.Lock("UpdateCluster") + defer b.mu.Unlock() + + b.sweepLocked() + + c, err := b.liveClusterLocked(r) + if err != nil { + return nil, err + } + + pool := "" + if id := u.GetDesiredNodePoolId(); id != "" && id != allPools { + pool = id + } + + if err = b.conflictLocked(r, pool); err != nil { + return nil, err + } + + tmp := clone(c.Cluster) + saved := clonePools(c.Pools) + + plan, err := collectUpdate(c, tmp, u) + if err != nil { + return nil, err + } + + typ, err := b.applyPlan(c, tmp, plan) + if err != nil { + c.Pools = saved + + return nil, err + } + + c.Cluster = tmp + kind := kindClusterOp + + if pool != "" { + kind, r.pool = kindPoolOp, pool + } + + o := b.newOpLocked(r, typ, kind, tmp.GetSelfLink(), b.timing.Mutate, false) + + return b.opViewLocked(o), nil +} + +func collectUpdate(c *clusterRec, tmp *containerpb.Cluster, u *containerpb.ClusterUpdate) (*updatePlan, error) { + p := &updatePlan{} + + var err error + + u.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + err = p.take(c, tmp, fd, v) + + return err == nil + }) + + return p, err +} + +const fieldEtag = "etag" + +// takePlan records the fields that act on node pools, versions and locations; it reports whether name was one. +func (p *updatePlan) takePlan(name string, v protoreflect.Value) bool { + switch name { + case "desired_node_pool_id": + p.poolID = v.String() + case "desired_node_version": + p.nodeVersion, p.hasNodeVersion = v.String(), true + case "desired_master_version": + p.masterVersion, p.hasMaster = v.String(), true + case "desired_image_type": + p.imageType, p.hasImage = v.String(), true + case "desired_node_pool_autoscaling": + p.nodeAutoscaling, p.hasAutoscaling = msgOf[*containerpb.NodePoolAutoscaling](v), true + case "desired_locations": + p.hasLocations = true + + for i := range v.List().Len() { + p.locations = append(p.locations, v.List().Get(i).String()) + } + default: + return false + } + + return true +} + +// msgOf returns a copy of the message held by v. +func msgOf[T proto.Message](v protoreflect.Value) T { + out, _ := proto.Clone(v.Message().Interface()).(T) + + return out +} + +func (p *updatePlan) take( + c *clusterRec, + tmp *containerpb.Cluster, + fd protoreflect.FieldDescriptor, + v protoreflect.Value, +) error { + name := string(fd.Name()) + if p.takePlan(name, v) { + return nil + } + + switch name { + case fieldEtag: + if v.String() != etagOf(c.Cluster) { + return errPrecondition( + tmp.GetSelfLink(), + "The etag %q does not match the cluster's current etag.", + v.String(), + ) + } + case "desired_logging_service": + return setService( + &tmp.LoggingService, + v.String(), + "logging.googleapis.com/kubernetes", + "logging.googleapis.com", + "none", + ) + case "desired_monitoring_service": + return setService( + &tmp.MonitoringService, + v.String(), + "monitoring.googleapis.com/kubernetes", + "monitoring.googleapis.com", + "none", + ) + case "desired_addons_config": + return mergeAddons(tmp, msgOf[*containerpb.AddonsConfig](v)) + case "desired_cluster_autoscaling": + return setClusterAutoscaling(tmp, msgOf[*containerpb.ClusterAutoscaling](v)) + case "desired_master_authorized_networks_config": + return setAuthNets(tmp, msgOf[*containerpb.MasterAuthorizedNetworksConfig](v)) + case "desired_enable_private_endpoint", "desired_default_enable_private_nodes": + return setPrivate(tmp, name, v.Bool()) + default: + return setGeneric(tmp, fd, v) + } + + return nil +} + +func setService(dst *string, v string, allowed ...string) error { + if err := validService(v, allowed...); err != nil { + return err + } + + *dst = v + + return nil +} + +func setClusterAutoscaling(cl *containerpb.Cluster, a *containerpb.ClusterAutoscaling) error { + for _, rl := range a.GetResourceLimits() { + if rl.GetResourceType() == "" || rl.GetMaximum() < rl.GetMinimum() || rl.GetMinimum() < 0 { + return errInvalid( + "desired_cluster_autoscaling.resource_limits", + "Invalid resource limit for %q.", + rl.GetResourceType(), + ) + } + } + + cl.Autoscaling = a + + return nil +} + +func setAuthNets(cl *containerpb.Cluster, m *containerpb.MasterAuthorizedNetworksConfig) error { + if err := validMasterAuthNets(m); err != nil { + return err + } + + cl.MasterAuthorizedNetworksConfig = m + + return nil +} + +func setPrivate(cl *containerpb.Cluster, name string, v bool) error { + if !cl.GetPrivateClusterConfig().GetEnablePrivateNodes() { + return errPrecondition(cl.GetSelfLink(), "%s requires a private cluster.", strings.TrimPrefix(name, "desired_")) + } + + if name == "desired_enable_private_endpoint" { + cl.PrivateClusterConfig.EnablePrivateEndpoint = v + } else { + cl.PrivateClusterConfig.EnablePrivateNodes = v + } + + return nil +} + +// setGeneric copies a ClusterUpdate field onto the cluster field (or NetworkConfig field) of the same name. +func setGeneric(cl *containerpb.Cluster, fd protoreflect.FieldDescriptor, v protoreflect.Value) error { + name := string(fd.Name()) + target := strings.TrimPrefix(name, "desired_") + + if name == "desired_intra_node_visibility_config" || name == "desired_l4_ilb_subsetting_config" { + return setNetworkFlag(cl, name, v) + } + + if !fieldAccepts(cl.ProtoReflect(), target, fd) && !fieldAccepts(netConfig(cl).ProtoReflect(), target, fd) { + return errUnsupported(name) + } + + dst := cl.ProtoReflect() + if !fieldAccepts(dst, target, fd) { + dst = cl.GetNetworkConfig().ProtoReflect() + } + + set(dst, target, fd, v) + + return nil +} + +func netConfig(cl *containerpb.Cluster) *containerpb.NetworkConfig { + cl.NetworkConfig = firstPtr(cl.GetNetworkConfig(), &containerpb.NetworkConfig{}) + + return cl.GetNetworkConfig() +} + +func setNetworkFlag(cl *containerpb.Cluster, name string, v protoreflect.Value) error { + nc := netConfig(cl) + on := v.Message().Get(v.Message().Descriptor().Fields().ByName("enabled")).Bool() + + if name == "desired_intra_node_visibility_config" { + nc.EnableIntraNodeVisibility = on + } else { + nc.EnableL4IlbSubsetting = on + } + + return nil +} + +func fieldAccepts(m protoreflect.Message, name string, src protoreflect.FieldDescriptor) bool { + dst := m.Descriptor().Fields().ByName(protoreflect.Name(name)) + if dst == nil || dst.Kind() != src.Kind() || dst.IsList() != src.IsList() || dst.IsMap() != src.IsMap() { + return false + } + + switch src.Kind() { + case protoreflect.MessageKind, protoreflect.GroupKind: + return dst.Message().FullName() == src.Message().FullName() + case protoreflect.EnumKind: + return dst.Enum().FullName() == src.Enum().FullName() + default: + return true + } +} + +func set(m protoreflect.Message, name string, src protoreflect.FieldDescriptor, v protoreflect.Value) { + dst := m.Descriptor().Fields().ByName(protoreflect.Name(name)) + + if src.Kind() == protoreflect.MessageKind && !src.IsList() && !src.IsMap() { + m.Set(dst, protoreflect.ValueOfMessage(proto.Clone(v.Message().Interface()).ProtoReflect())) + + return + } + + m.Set(dst, v) +} + +// applyPlan applies the node-pool and version parts of an update and reports the operation type. +func (b *InMemoryBackend) applyPlan( + c *clusterRec, + tmp *containerpb.Cluster, + p *updatePlan, +) (containerpb.Operation_Type, error) { + typ := containerpb.Operation_UPDATE_CLUSTER + + if p.hasLocations { + if err := b.setLocations(c, tmp, p.locations); err != nil { + return typ, err + } + } + + if p.hasMaster { + if err := upgradeMaster(tmp, p.masterVersion); err != nil { + return typ, err + } + + typ = containerpb.Operation_UPGRADE_MASTER + } + + if p.hasNodeVersion || p.hasImage || p.hasAutoscaling { + targets, err := c.targetPools(p.poolID) + if err != nil { + return typ, err + } + + for _, pr := range targets { + if err = b.applyPoolUpdate(c, tmp, pr, p); err != nil { + return typ, err + } + } + + if p.hasNodeVersion && !p.hasMaster { + typ = containerpb.Operation_UPGRADE_NODES + } + } + + return typ, nil +} + +func (c *clusterRec) targetPools(id string) ([]*poolRec, error) { + if id == "" || id == allPools { + return c.Pools, nil + } + + pr, ok := c.pool(id) + if !ok { + return nil, errNotFound(id) + } + + return []*poolRec{pr}, nil +} + +func (b *InMemoryBackend) applyPoolUpdate(_ *clusterRec, cl *containerpb.Cluster, pr *poolRec, p *updatePlan) error { + if cl.GetAutopilot().GetEnabled() { + return errPrecondition(cl.GetSelfLink(), "Node pools of Autopilot clusters are managed by Google.") + } + + if p.hasNodeVersion { + if err := upgradeNodes(pr, p.nodeVersion, cl.GetCurrentMasterVersion()); err != nil { + return err + } + } + + if p.hasImage { + if !validImageType(p.imageType) { + return errInvalid("desired_image_type", "Image type %q is not supported.", p.imageType) + } + + pr.Pool.Config.ImageType = p.imageType + } + + if p.hasAutoscaling { + return setPoolAutoscaling(pr, p.nodeAutoscaling) + } + + return nil +} + +func upgradeNodes(pr *poolRec, want, master string) error { + v, err := resolveNodeVersion(firstNonEmpty(want, master), master) + if err != nil { + return err + } + + if compareVersions(v, pr.Pool.GetVersion()) < 0 { + return errInvalid( + "node_version", + "Node version %s is older than the current version %s.", + v, + pr.Pool.GetVersion(), + ) + } + + if v != pr.Pool.GetVersion() { + pr.PrevVersion, pr.Pool.Version = pr.Pool.GetVersion(), v + } + + return nil +} + +func clonePools(in []*poolRec) []*poolRec { + out := make([]*poolRec, len(in)) + + for i, p := range in { + cp := *p + cp.Pool = clone(p.Pool) + out[i] = &cp + } + + return out +} diff --git a/services/gcpcontainer/validate.go b/services/gcpcontainer/validate.go new file mode 100644 index 0000000000..84dd999d7f --- /dev/null +++ b/services/gcpcontainer/validate.go @@ -0,0 +1,355 @@ +package gcpcontainer + +import ( + "crypto/sha256" + "encoding/base64" + "maps" + "net/netip" + "regexp" + "slices" + "strings" + "time" + + "cloud.google.com/go/container/apiv1/containerpb" +) + +const ( + maxLabels = 64 + maxNodeCount = 1000 + minDiskGb = 10 + maxDiskGb = 65536 + defaultDiskGb = 100 + defaultPodsMax = 110 + maxAuthNets = 50 + minPodsMax = 8 + maxPodsMax = 256 + minPodPrefix = 8 + maxPodPrefix = 21 + minSvcPrefix = 16 + maxSvcPrefix = 27 + masterPrefix = 28 + minPassword = 16 + maxPassword = 32 +) + +var ( + labelKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,62}$`) + labelValueRE = regexp.MustCompile(`^[a-z0-9_-]{0,63}$`) + machineRE = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)+$`) + k8sLabelRE = regexp.MustCompile( + `^([A-Za-z0-9]([-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?/)?[A-Za-z0-9]([-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?$`, + ) + k8sValueRE = regexp.MustCompile(`^([A-Za-z0-9]([-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?)?$`) + clockRE = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) +) + +func validLabels(field string, labels map[string]string) error { + if len(labels) > maxLabels { + return errInvalid(field, "At most %d labels are allowed.", maxLabels) + } + + for k, v := range labels { + if !labelKeyRE.MatchString(k) { + return errInvalid( + field, + "Invalid label key %q: start with a lowercase letter, then at most 62 lowercase letters, "+ + "digits, underscores or hyphens.", + k, + ) + } + + if !labelValueRE.MatchString(v) { + return errInvalid( + field, + "Invalid value %q for label %q: use at most 63 lowercase letters, digits, underscores or hyphens.", + v, + k, + ) + } + } + + return nil +} + +func validNodeLabels(labels map[string]string) error { + for k, v := range labels { + if !k8sLabelRE.MatchString(k) || !k8sValueRE.MatchString(v) { + return errInvalid("node_config.labels", "Invalid Kubernetes label %q=%q.", k, v) + } + } + + return nil +} + +func validTaints(taints []*containerpb.NodeTaint) error { + for _, t := range taints { + if !k8sLabelRE.MatchString(t.GetKey()) || !k8sValueRE.MatchString(t.GetValue()) { + return errInvalid("node_config.taints", "Invalid taint %q=%q.", t.GetKey(), t.GetValue()) + } + + if t.GetEffect() == containerpb.NodeTaint_EFFECT_UNSPECIFIED { + return errInvalid("node_config.taints", "Taint %q must have an effect.", t.GetKey()) + } + } + + return nil +} + +func parseCIDR(field, s string, minBits, maxBits int) (netip.Prefix, error) { + p, err := netip.ParsePrefix(s) + if err != nil || !p.Addr().Is4() || p.Masked() != p { + return netip.Prefix{}, errInvalid(field, "Invalid CIDR block %q: want an IPv4 network such as 10.0.0.0/14.", s) + } + + if p.Bits() < minBits || p.Bits() > maxBits { + return netip.Prefix{}, errInvalid( + field, + "CIDR block %q must have a prefix length between /%d and /%d.", + s, + minBits, + maxBits, + ) + } + + return p, nil +} + +func validMachineType(c Compute, mt string) error { + if mt == "" { + return nil + } + + if c != nil { + if !c.MachineTypeExists(mt) { + return errInvalid("node_config.machine_type", "Machine type %q does not exist.", mt) + } + + return nil + } + + if !machineRE.MatchString(mt) { + return errInvalid("node_config.machine_type", "Invalid machine type %q.", mt) + } + + return nil +} + +func validDiskType(t string) bool { + switch t { + case "pd-standard", "pd-balanced", "pd-ssd": + return true + default: + return strings.HasPrefix(t, "hyperdisk-") + } +} + +func validImageType(t string) bool { + for _, v := range validImageTypes() { + if strings.EqualFold(v, t) { + return true + } + } + + return false +} + +func validAutoscaling(a *containerpb.NodePoolAutoscaling) error { + if a == nil { + return nil + } + + perZone := a.GetMinNodeCount() != 0 || a.GetMaxNodeCount() != 0 + total := a.GetTotalMinNodeCount() != 0 || a.GetTotalMaxNodeCount() != 0 + + if !a.GetEnabled() { + if perZone || total { + return errInvalid("autoscaling", "Autoscaling limits can only be set when autoscaling is enabled.") + } + + return nil + } + + if perZone && total { + return errInvalid("autoscaling", "Per-zone and total node limits cannot both be set.") + } + + return validAutoscalingLimits(a, perZone || total) +} + +func validAutoscalingLimits(a *containerpb.NodePoolAutoscaling, limited bool) error { + top := max(a.GetMaxNodeCount(), a.GetTotalMaxNodeCount()) + + switch { + case a.GetMinNodeCount() < 0 || a.GetTotalMinNodeCount() < 0: + return errInvalid("autoscaling", "Minimum node count must not be negative.") + case !limited || top < 1: + return errInvalid("autoscaling", "Maximum node count must be greater than 0.") + case a.GetMaxNodeCount() < a.GetMinNodeCount() || a.GetTotalMaxNodeCount() < a.GetTotalMinNodeCount(): + return errInvalid("autoscaling", "Maximum node count must not be less than the minimum node count.") + case top > maxNodeCount: + return errInvalid("autoscaling", "Maximum node count must not exceed %d.", maxNodeCount) + } + + return nil +} + +func validNodeConfig(c Compute, nc *containerpb.NodeConfig) error { + if err := validMachineType(c, nc.GetMachineType()); err != nil { + return err + } + + if d := int64(nc.GetDiskSizeGb()); d != 0 && (d < minDiskGb || d > maxDiskGb) { + return errInvalid("node_config.disk_size_gb", "Disk size must be between %d and %d GB.", minDiskGb, maxDiskGb) + } + + if t := nc.GetDiskType(); t != "" && !validDiskType(t) { + return errInvalid("node_config.disk_type", "Invalid disk type %q.", t) + } + + if t := nc.GetImageType(); t != "" && !validImageType(t) { + return errInvalid("node_config.image_type", "Image type %q is not supported.", t) + } + + if nc.GetSpot() && nc.GetPreemptible() { + return errInvalid("node_config.spot", "Spot and preemptible cannot both be set.") + } + + if nc.GetLocalSsdCount() < 0 { + return errInvalid("node_config.local_ssd_count", "Local SSD count must not be negative.") + } + + if err := validNodeLabels(nc.GetLabels()); err != nil { + return err + } + + if err := validLabels("node_config.resource_labels", nc.GetResourceLabels()); err != nil { + return err + } + + return validTaints(nc.GetTaints()) +} + +// podPrefixes maps the largest max-pods-per-node a per-node pod range of that size can serve. +var podPrefixes = []struct { //nolint:gochecknoglobals // immutable catalog + maxPods int64 + prefix int32 +}{{8, 28}, {16, 27}, {32, 26}, {64, 25}, {128, 24}} + +const widePodPrefix = 23 + +// podPrefix maps max pods per node to the per-node pod range size. +func podPrefix(maxPods int64) int32 { + for _, p := range podPrefixes { + if maxPods <= p.maxPods { + return p.prefix + } + } + + return widePodPrefix +} + +func validMaxPods(field string, c *containerpb.MaxPodsConstraint) error { + if c == nil { + return nil + } + + if n := c.GetMaxPodsPerNode(); n != 0 && (n < minPodsMax || n > maxPodsMax) { + return errInvalid(field, "Max pods per node must be between %d and %d.", minPodsMax, maxPodsMax) + } + + return nil +} + +func validMaintenance(m *containerpb.MaintenancePolicy) error { + w := m.GetWindow() + if w == nil { + return nil + } + + if d := w.GetDailyMaintenanceWindow(); d != nil && !clockRE.MatchString(d.GetStartTime()) { + return errInvalid("maintenance_policy.window.daily_maintenance_window.start_time", + "Invalid start time %q: want HH:MM in UTC.", d.GetStartTime()) + } + + if r := w.GetRecurringWindow(); r != nil { + if err := validRecurring(r); err != nil { + return err + } + } + + for id, ex := range w.GetMaintenanceExclusions() { + win := ex.GetStartTime() != nil && ex.GetEndTime() != nil + if !win || !ex.GetEndTime().AsTime().After(ex.GetStartTime().AsTime()) { + return errInvalid("maintenance_policy.window.maintenance_exclusions", + "Maintenance exclusion %q must end after it starts.", id) + } + } + + return nil +} + +func validRecurring(r *containerpb.RecurringTimeWindow) error { + win := r.GetWindow() + if win == nil || win.GetStartTime() == nil || win.GetEndTime() == nil { + return errInvalid("maintenance_policy.window.recurring_window.window", "Start and end times must be set.") + } + + if !win.GetEndTime().AsTime().After(win.GetStartTime().AsTime()) { + return errInvalid("maintenance_policy.window.recurring_window.window", "End time must be after start time.") + } + + if r.GetRecurrence() == "" || !strings.Contains(r.GetRecurrence(), "FREQ=") { + return errInvalid( + "maintenance_policy.window.recurring_window.recurrence", + "Recurrence must be an RFC 5545 RRULE.", + ) + } + + return nil +} + +func validMasterAuthNets(m *containerpb.MasterAuthorizedNetworksConfig) error { + if len(m.GetCidrBlocks()) > maxAuthNets { + return errInvalid( + "master_authorized_networks_config", + "At most %d authorized networks are allowed.", + maxAuthNets, + ) + } + + for _, blk := range m.GetCidrBlocks() { + p, err := netip.ParsePrefix(blk.GetCidrBlock()) + if err != nil || !p.Addr().Is4() { + return errInvalid( + "master_authorized_networks_config.cidr_blocks", + "Invalid CIDR block %q.", + blk.GetCidrBlock(), + ) + } + } + + return nil +} + +func validPassword(p string) error { + if len(p) < minPassword || len(p) > maxPassword { + return errInvalid("update.password", "Password must be between %d and %d characters.", minPassword, maxPassword) + } + + return nil +} + +func rfc3339(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05+00:00") } + +// fingerprint identifies a label set so concurrent SetLabels calls can be detected. +func fingerprint(labels map[string]string) string { + var sb strings.Builder + + for _, k := range slices.Sorted(maps.Keys(labels)) { + sb.WriteString(k + "=" + labels[k] + ";") + } + + sum := sha256.Sum256([]byte(sb.String())) + + return base64.StdEncoding.EncodeToString(sum[:etagBytes]) +} diff --git a/services/gcpcontainer/versions.go b/services/gcpcontainer/versions.go new file mode 100644 index 0000000000..5dc76156f4 --- /dev/null +++ b/services/gcpcontainer/versions.go @@ -0,0 +1,227 @@ +//nolint:staticcheck // legacy cluster fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "cmp" + "fmt" + "slices" + "strconv" + "strings" + + "cloud.google.com/go/container/apiv1/containerpb" +) + +const ( + v1343 = "1.34.3-gke.1051000" + v1337 = "1.33.7-gke.1044000" + v13211 = "1.32.11-gke.1090000" + + versionParts = 3 + latestVersion = "latest" + maxNodeSkew = 2 + defaultImageType = "COS_CONTAINERD" +) + +type channelSpec struct { + def string + target string + versions []string + channel containerpb.ReleaseChannel_Channel +} + +// channels is a documented representative version catalog per release channel, newest first. The emulator +// does not track Google's rollout; the shape (channel -> default, valid and upgrade-target versions) matches the API. +func channels() []channelSpec { + return []channelSpec{ + { + channel: containerpb.ReleaseChannel_RAPID, def: "1.35.0-gke.1233000", target: "1.35.1-gke.1000000", + versions: []string{"1.35.1-gke.1000000", "1.35.0-gke.1233000", v1343}, + }, + { + channel: containerpb.ReleaseChannel_REGULAR, def: "1.34.2-gke.1008000", target: v1343, + versions: []string{v1343, "1.34.2-gke.1008000", v1337}, + }, + { + channel: containerpb.ReleaseChannel_STABLE, def: "1.33.6-gke.1021000", target: v1337, + versions: []string{v1337, "1.33.6-gke.1021000", v13211}, + }, + { + channel: containerpb.ReleaseChannel_EXTENDED, def: "1.32.10-gke.1049000", target: v13211, + versions: []string{v13211, "1.32.10-gke.1049000", "1.31.14-gke.1084000"}, + }, + } +} + +func channelByEnum(c containerpb.ReleaseChannel_Channel) (channelSpec, bool) { + for _, s := range channels() { + if s.channel == c { + return s, true + } + } + + return channelSpec{}, false +} + +func defaultVersion() string { + s, _ := channelByEnum(containerpb.ReleaseChannel_REGULAR) + + return s.def +} + +// versionKey orders "1.M.P-gke.B" versions. +func versionKey(v string) [4]int { + var k [4]int + + core, build, _ := strings.Cut(v, "-gke.") + for i, p := range strings.Split(core, ".") { + if i < versionParts { + k[i], _ = strconv.Atoi(p) + } + } + + k[3], _ = strconv.Atoi(build) + + return k +} + +func compareVersions(a, b string) int { + ka, kb := versionKey(a), versionKey(b) + + return slices.Compare(ka[:], kb[:]) +} + +func sortedDesc(in []string) []string { + out := slices.Clone(in) + slices.SortFunc(out, func(a, b string) int { return -compareVersions(a, b) }) + + return slices.Compact(out) +} + +func validMasterVersions() []string { + var all []string + for _, s := range channels() { + all = append(all, s.versions...) + } + + return sortedDesc(all) +} + +func validNodeVersions() []string { + return sortedDesc(append(validMasterVersions(), "1.30.14-gke.1050000", "1.29.15-gke.1039000")) +} + +func validImageTypes() []string { + return []string{"COS_CONTAINERD", "UBUNTU_CONTAINERD", "WINDOWS_LTSC_CONTAINERD", "WINDOWS_SAC_CONTAINERD"} +} + +// pickVersion resolves a request against candidates (newest first): "", "latest", an exact version or a +// "1.M" / "1.M.P" prefix. +func pickVersion(req string, candidates []string, def string) (string, bool) { + switch req { + case "": + return def, def != "" + case latestVersion: + return candidates[0], true + } + + if slices.Contains(candidates, req) { + return req, true + } + + for _, c := range candidates { + if strings.HasPrefix(c, req+".") || strings.HasPrefix(c, req+"-") { + return c, true + } + } + + return "", false +} + +func resolveMasterVersion(req string, channel containerpb.ReleaseChannel_Channel) (string, error) { + if spec, ok := channelByEnum(channel); ok { + v, found := pickVersion(req, spec.versions, spec.def) + if !found { + return "", errInvalid("initial_cluster_version", + "Master version %q is unsupported in the %s release channel.", req, channel) + } + + return v, nil + } + + v, found := pickVersion(req, validMasterVersions(), defaultVersion()) + if !found { + return "", errInvalid("initial_cluster_version", "Master version %q is unsupported.", req) + } + + return v, nil +} + +func resolveNodeVersion(req, master string) (string, error) { + if req == "" { + return master, nil + } + + v, found := pickVersion(req, validNodeVersions(), "") + if !found { + return "", errInvalid("version", "Node version %q is unsupported.", req) + } + + if err := checkSkew(v, master); err != nil { + return "", err + } + + return v, nil +} + +func minor(v string) int { return versionKey(v)[1] } + +func checkSkew(node, master string) error { + if d := minor(master) - minor(node); d < 0 || d > maxNodeSkew { + return errInvalid( + "version", + "Node version %s must be the master version %s or at most %d minor versions older.", + node, + master, + maxNodeSkew, + ) + } + + return nil +} + +func serverConfig() *containerpb.ServerConfig { + cfg := &containerpb.ServerConfig{ + DefaultClusterVersion: defaultVersion(), + ValidNodeVersions: validNodeVersions(), + DefaultImageType: defaultImageType, + ValidImageTypes: validImageTypes(), + ValidMasterVersions: validMasterVersions(), + } + + for _, s := range channels() { + cfg.Channels = append(cfg.Channels, &containerpb.ServerConfig_ReleaseChannelConfig{ + Channel: s.channel, DefaultVersion: s.def, ValidVersions: slices.Clone(s.versions), + UpgradeTargetVersion: s.target, + }) + } + + slices.SortFunc(cfg.GetChannels(), func(a, b *containerpb.ServerConfig_ReleaseChannelConfig) int { + return cmp.Compare(a.GetChannel(), b.GetChannel()) + }) + + return cfg +} + +// GetServerConfig returns the supported versions and image types for a location. +func (b *InMemoryBackend) GetServerConfig(req *containerpb.GetServerConfigRequest) (*containerpb.ServerConfig, error) { + project, location, err := resolveLocation(req.GetName(), req.GetProjectId(), req.GetZone()) + if err != nil { + return nil, err + } + + if !validLocation(location) { + return nil, errNotFound(fmt.Sprintf("projects/%s/locations/%s", project, location)) + } + + return serverConfig(), nil +} diff --git a/services/gcpcontainer/views.go b/services/gcpcontainer/views.go new file mode 100644 index 0000000000..9074774eb9 --- /dev/null +++ b/services/gcpcontainer/views.go @@ -0,0 +1,119 @@ +//nolint:staticcheck // legacy cluster fields are deprecated in the proto but still served +package gcpcontainer + +import ( + "crypto/sha256" + "encoding/hex" + "slices" + + "cloud.google.com/go/container/apiv1/containerpb" + "google.golang.org/protobuf/proto" +) + +const ( + etagBytes = 8 + maxZones = 16 +) + +func etagOf(m proto.Message) string { + raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(m) + if err != nil { + return "" + } + + sum := sha256.Sum256(raw) + + return hex.EncodeToString(sum[:etagBytes]) +} + +func (b *InMemoryBackend) poolStatusLocked(c *clusterRec, p *poolRec, active []*opRec) containerpb.NodePool_Status { + if p.DeleteOp != "" || c.DeleteOp != "" { + return containerpb.NodePool_STOPPING + } + + if c.Failure != "" { + return containerpb.NodePool_ERROR + } + + st := containerpb.NodePool_RUNNING + + for _, o := range active { + switch { + case o.Kind == kindCreate, o.Kind == kindPoolOp && o.Pool == p.Pool.GetName() && + o.Op.GetOperationType() == containerpb.Operation_CREATE_NODE_POOL: + return containerpb.NodePool_PROVISIONING + case o.Kind == kindClusterOp && o.Op.GetOperationType() == containerpb.Operation_UPGRADE_NODES, + o.Kind == kindPoolOp && o.Pool == p.Pool.GetName(): + st = containerpb.NodePool_RECONCILING + } + } + + return st +} + +func (b *InMemoryBackend) clusterStatusLocked(c *clusterRec, active []*opRec) containerpb.Cluster_Status { + switch { + case c.DeleteOp != "": + return containerpb.Cluster_STOPPING + case c.Failure != "": + return containerpb.Cluster_ERROR + } + + for _, o := range active { + if o.Kind == kindCreate { + return containerpb.Cluster_PROVISIONING + } + } + + if len(active) > 0 { + return containerpb.Cluster_RECONCILING + } + + return containerpb.Cluster_RUNNING +} + +func poolView(p *poolRec, st containerpb.NodePool_Status) *containerpb.NodePool { + v := clone(p.Pool) + v.Status = st + v.Etag = etagOf(p.Pool) + + return v +} + +func (b *InMemoryBackend) poolViewLocked(c *clusterRec, p *poolRec) *containerpb.NodePool { + active := b.activeOpsLocked(c.Project, c.Cluster.GetLocation(), c.Cluster.GetName()) + + return poolView(p, b.poolStatusLocked(c, p, active)) +} + +func (b *InMemoryBackend) clusterViewLocked(c *clusterRec) *containerpb.Cluster { + active := b.activeOpsLocked(c.Project, c.Cluster.GetLocation(), c.Cluster.GetName()) + v := clone(c.Cluster) + v.Status = b.clusterStatusLocked(c, active) + v.StatusMessage = c.Failure + v.Etag = etagOf(c.Cluster) + v.NodePools = nil + v.InstanceGroupUrls = nil + v.CurrentNodeCount = 0 + + oldest := "" + + for _, p := range c.Pools { + v.NodePools = append(v.NodePools, poolView(p, b.poolStatusLocked(c, p, active))) + v.InstanceGroupUrls = append(v.InstanceGroupUrls, p.Pool.GetInstanceGroupUrls()...) + zones := min(max(len(p.Pool.GetLocations()), 1), maxZones) + v.CurrentNodeCount += p.Size * int32(zones) + + if oldest == "" || compareVersions(p.Pool.GetVersion(), oldest) < 0 { + oldest = p.Pool.GetVersion() + } + } + + if oldest != "" { + v.CurrentNodeVersion = oldest + } + + slices.Sort(v.GetInstanceGroupUrls()) + + return v +} diff --git a/services/gcpdatastore/PARITY.md b/services/gcpdatastore/PARITY.md new file mode 100644 index 0000000000..6e45fb560c --- /dev/null +++ b/services/gcpdatastore/PARITY.md @@ -0,0 +1,41 @@ +--- +service: gcpdatastore +sdk_module: cloud.google.com/go/datastore@v1.25.0 +last_audit_commit: 70ee58098 +last_audit_date: 2026-10-26 +overall: B +# Per-op or per-op-family status. Values: ok | partial | gap | deferred. +# wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. +ops: + Lookup: {wire: ok, errors: ok, state: ok, persist: ok, note: "Found, missing and (never) deferred results with version, create_time and update_time; at most 1000 keys; strong and eventual reads see the latest commit; read_options.transaction reads the transaction's snapshot and registers the keys for conflict detection; new_transaction begins one and returns its id; read_time reads history up to one hour back and rejects future or older times with INVALID_ARGUMENT; property_mask projects results."} + RunQuery: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Structured queries: one kind or kindless (ancestor and __key__ filters only), property filters = < <= > >= != IN NOT_IN, HAS_ANCESTOR, key and dotted embedded-entity paths, nested AND/OR, multi-value array semantics, unindexed values never match, orders (entities missing a sorted property are omitted), projection (one row per array element combination), keys-only, distinct_on, offset and limit, start and end cursors, 300-entity batches with NOT_FINISHED and the matching end cursor, MORE_RESULTS_AFTER_LIMIT and AFTER_CURSOR, skipped_results and skipped_cursor. __kind__ and __namespace__ metadata queries. GQL with named and positional bindings, literals gated on allow_literals, AND/OR/parentheses, IN/NOT IN/CONTAINS/HAS ANCESTOR/IS NULL, KEY/BLOB/DATETIME literals, ORDER BY, LIMIT/OFFSET and START/END CURSOR; the parsed query is echoed in the response. Value ordering follows the documented type order (null, integers and timestamps, booleans, blobs, strings, doubles, geo points, keys); inequalities only match values of the same type."} + RunAggregationQuery: {wire: ok, errors: ok, state: ok, persist: n/a, note: "count (with up_to), sum and avg over a nested query or GQL (AGGREGATE ... OVER and SELECT COUNT(*) shorthand); 1-5 aggregations, unique aliases, default aliases property_N counted over unnamed aggregations; sum is an integer until a double or overflow, avg is null without numeric values. Transactional aggregation registers the query scope for conflict detection."} + BeginTransaction: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Read-write and read-only (optionally at a read_time); 270 s lifetime and 60 s idle expiry give INVALID_ARGUMENT 'The referenced transaction has expired or is no longer valid.' Open transactions are not persisted."} + Commit: {wire: ok, errors: ok, state: ok, persist: ok, note: "insert (ALREADY_EXISTS), update (NOT_FOUND), upsert and delete with per-mutation version, create_time and update_time; incomplete keys get allocated ids returned in the result; property_mask; property transforms (REQUEST_TIME, increment, maximum, minimum, append_missing_elements, remove_all_from_array, max 500) with transform_results; base_version and update_time preconditions with conflict_resolution_strategy (SERVER_VALUE keeps the server entity and reports conflict_detected, FAIL aborts the commit with ABORTED); TRANSACTIONAL needs a transaction or a single-use read-write one, NON_TRANSACTIONAL forbids two mutations of one entity. All mutations apply atomically or not at all. Optimistic concurrency: a commit is ABORTED ('too much contention on these datastore entities. please try again.') when an entity it read, an entity it writes, or any query scope it ran was changed by a commit after its snapshot. index_updates counts two entries per indexed value written or replaced."} + Rollback: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Unknown ids are INVALID_ARGUMENT; rolling back a transaction that already ended (committed, aborted or rolled back) is a no-op because the Go client rolls back after an ABORTED commit."} + AllocateIds: {wire: ok, errors: ok, state: ok, persist: ok, note: "Incomplete keys only; random unique ids up to 16 digits that auto-allocation never reuses; allocated and reserved ids are persisted."} + ReserveIds: {wire: ok, errors: ok, state: ok, persist: ok, note: "Complete keys only; numeric ids are excluded from later auto-allocation."} + CreateIndex: {wire: ok, errors: partial, state: ok, persist: ok, note: "Validates kind, ancestor mode, property names and directions; duplicates are ALREADY_EXISTS; limit 200 per project. The index is READY at once and the returned operation is already done (gcplro)."} + DeleteIndex: {wire: ok, errors: ok, state: ok, persist: ok, note: "NOT_FOUND for unknown ids; the operation response is the index in DELETING state."} + GetIndex: {wire: ok, errors: ok, state: ok, persist: n/a, note: "NOT_FOUND for unknown ids."} + ListIndexes: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Paged through pkgs/page in creation order (default 100); a filter is rejected with INVALID_ARGUMENT."} + GetOperation: {wire: ok, errors: ok, state: ok, persist: ok, note: "google.longrunning.Operations via pkgs/gcplro on projects/*/operations/*."} +families: + composite_indexes: {status: partial, note: "With enforcement on (default; --gcp-datastore-enforce-indexes=false disables it) a query fails FAILED_PRECONDITION 'no matching index found. recommended index is: ...' when the documented rules say built-in indexes cannot serve it: ancestor with an inequality, an inequality with equality filters, a descending __key__ sort, several sort orders, or a filter combined with a sort other than the inequality property itself. A matching index is one for the kind (ALL_ANCESTORS for ancestor queries) whose properties, after removing the equality properties, equal the query's sort list with directions. Projection queries are never rejected; the docs do not say precisely when they need an index, and OR queries are checked as a whole rather than per disjunct."} + concurrency: {status: partial, note: "Optimistic only (the documented 'optimistic' mode): conflicts abort at commit, nothing blocks. The default 'pessimistic' mode would make a competing transaction wait for locks. Entity-group limits (25 groups, 1 write/s) of legacy mode are not modelled. Reads inside a transaction do not see its own buffered writes, as documented."} + data_model: {status: ok, note: "Keys are (project, database, namespace) partitions with ancestor paths; ids must be positive, names and kinds up to 1500 bytes, reserved __x__ names are rejected on write, paths up to 100 elements. Entities up to 1,048,572 bytes, indexed strings and blobs up to 1500 bytes, unindexed up to 1,000,000, nesting depth 20, no array in array, meaning 18 rejected, timestamps truncated to microseconds. exclude_from_indexes is honoured per value."} + history: {status: ok, note: "Every commit keeps entity versions for one hour so read_time and transaction snapshots work; versions are the commit time in microseconds and strictly increase."} + persistence: {status: partial, note: "Current entities with version and timestamps, allocated ids, indexes, operations and the last commit time are snapshotted; version history and open transactions are not."} + routing_isolation: {status: ok, note: "Own gRPC server with the REST transcoder (gcprest) bound in StartWorker to --gcp-datastore-port / GCP_DATASTORE_PORT (default 8143, fails fast if taken); never registered on the shared AWS router. The Go client reaches it with DATASTORE_EMULATOR_HOST=localhost:8143 over plaintext gRPC. cli.go reserves the port in the shared PortAlloc pool."} + auth: {status: partial, note: "No request is authenticated or authorised."} + observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; RPC failures are logged at debug. No dashboard page."} +items_still_open: + - "ExportEntities and ImportEntities are not served (they need a Cloud Storage bucket target); the admin service answers UNIMPLEMENTED for them." + - "Query explain (explain_options), vector search (find_nearest), __property__ metadata queries are not supported; explain and find_nearest return UNIMPLEMENTED, and a property_mask is applied to full entities only." + - "Pessimistic concurrency, point-in-time recovery beyond one hour, multiple named databases' lifecycle API and TTL policies are not modelled; the database_id field only partitions data." + - "Composite index rules cover only the cases the documentation states; the index build is instantaneous (no CREATING state) and the exact wording of FAILED_PRECONDITION and ABORTED messages was chosen without a production reference." + - "There is no dashboard page." +leaks: {status: clean, note: "Background resources are the grpc.Server and its serve goroutines, joined by Shutdown."} +--- + +Initial implementation (2026-10-26): google.datastore.v1 Datastore and google.datastore.admin.v1 index management verified against cloud.google.com/go/datastore v1.25.0 through the official client and raw datastorepb clients in the package tests, including REST/JSON through the shared transcoder. diff --git a/services/gcpdatastore/README.md b/services/gcpdatastore/README.md new file mode 100644 index 0000000000..0c698742d8 --- /dev/null +++ b/services/gcpdatastore/README.md @@ -0,0 +1,27 @@ + +# Cloud Datastore + +**Parity grade: B** · SDK `cloud.google.com/go/datastore@v1.25.0` · last audited 2026-10-26 (`70ee58098`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 13 (10 ok, 3 partial) | +| Feature families | 8 (3 ok, 5 partial) | +| Known gaps | 5 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Known gaps + +- ExportEntities and ImportEntities are not served (they need a Cloud Storage bucket target); the admin service answers UNIMPLEMENTED for them. +- Query explain (explain_options), vector search (find_nearest), __property__ metadata queries are not supported; explain and find_nearest return UNIMPLEMENTED, and a property_mask is applied to full entities only. +- Pessimistic concurrency, point-in-time recovery beyond one hour, multiple named databases' lifecycle API and TTL policies are not modelled; the database_id field only partitions data. +- Composite index rules cover only the cases the documentation states; the index build is instantaneous (no CREATING state) and the exact wording of FAILED_PRECONDITION and ABORTED messages was chosen without a production reference. +- There is no dashboard page. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/gcpdatastore/admin.go b/services/gcpdatastore/admin.go new file mode 100644 index 0000000000..4b24e8297d --- /dev/null +++ b/services/gcpdatastore/admin.go @@ -0,0 +1,243 @@ +package gcpdatastore + +import ( + "context" + "slices" + "strconv" + "strings" + "time" + + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcplro" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + indexIDBase = 1_000_000_000_000_000_000 + defaultIdxPage = 100 + maxIndexProps = 100 + maxCompositeIdx = 200 +) + +type adminServer struct { + adminpb.UnimplementedDatastoreAdminServer + b *InMemoryBackend +} + +func (s *adminServer) CreateIndex( + _ context.Context, in *adminpb.CreateIndexRequest, +) (*longrunningpb.Operation, error) { + return s.b.CreateIndex(in) +} + +func (s *adminServer) DeleteIndex( + _ context.Context, in *adminpb.DeleteIndexRequest, +) (*longrunningpb.Operation, error) { + return s.b.DeleteIndex(in) +} + +func (s *adminServer) GetIndex(_ context.Context, in *adminpb.GetIndexRequest) (*adminpb.Index, error) { + return s.b.GetIndex(in) +} + +func (s *adminServer) ListIndexes( + _ context.Context, in *adminpb.ListIndexesRequest, +) (*adminpb.ListIndexesResponse, error) { + return s.b.ListIndexes(in) +} + +func indexKey(project, id string) string { return project + "/" + id } + +func cloneIndex(i *adminpb.Index) *adminpb.Index { + out, _ := proto.Clone(i).(*adminpb.Index) + + return out +} + +func validateIndex(project string, idx *adminpb.Index) error { + if project == "" { + return errInvalid("Missing required project ID.") + } + + if idx == nil || idx.GetKind() == "" { + return errInvalid("An index requires a kind.") + } + + if idx.GetAncestor() == adminpb.Index_ANCESTOR_MODE_UNSPECIFIED { + return errInvalid("An index requires an ancestor mode (NONE or ALL_ANCESTORS).") + } + + if len(idx.GetProperties()) == 0 || len(idx.GetProperties()) > maxIndexProps { + return errInvalid("An index needs between 1 and %d properties.", maxIndexProps) + } + + seen := map[string]struct{}{} + + for _, p := range idx.GetProperties() { + if p.GetName() == "" || p.GetDirection() == adminpb.Index_DIRECTION_UNSPECIFIED { + return errInvalid("Every index property needs a name and a direction.") + } + + if _, dup := seen[p.GetName()]; dup { + return errInvalid("Index property %q is listed more than once.", p.GetName()) + } + + seen[p.GetName()] = struct{}{} + } + + return nil +} + +func sameDefinition(a, b *adminpb.Index) bool { + if a.GetKind() != b.GetKind() || a.GetAncestor() != b.GetAncestor() || + len(a.GetProperties()) != len(b.GetProperties()) { + return false + } + + for i, p := range a.GetProperties() { + if p.GetName() != b.GetProperties()[i].GetName() || p.GetDirection() != b.GetProperties()[i].GetDirection() { + return false + } + } + + return true +} + +func (b *InMemoryBackend) indexOp( + project string, op adminpb.OperationType, idx *adminpb.Index, +) (*longrunningpb.Operation, error) { + now := timestamppb.New(b.wallLocked()) + meta := &adminpb.IndexOperationMetadata{ + Common: &adminpb.CommonMetadata{ + StartTime: now, EndTime: now, OperationType: op, State: adminpb.CommonMetadata_SUCCESSFUL, + }, + IndexId: idx.GetIndexId(), + } + + name := "projects/" + project + "/operations/" + strconv.FormatInt(time.Now().UnixNano(), 36) + idx.GetIndexId() + + o, err := gcplro.Done(name, meta, idx) + if err != nil { + return nil, errInvalid("%v", err) + } + + return b.ops.Put(o), nil +} + +// CreateIndex registers a composite index; it is READY immediately. +func (b *InMemoryBackend) CreateIndex(in *adminpb.CreateIndexRequest) (*longrunningpb.Operation, error) { + if err := validateIndex(in.GetProjectId(), in.GetIndex()); err != nil { + return nil, err + } + + b.mu.Lock("CreateIndex") + defer b.mu.Unlock() + + n := 0 + + for _, ex := range b.indexes { + if ex.GetProjectId() != in.GetProjectId() { + continue + } + + n++ + + if sameDefinition(ex, in.GetIndex()) { + return nil, errExists("Index already exists: %s.", ex.GetIndexId()) + } + } + + if n >= maxCompositeIdx { + return nil, errPrecondition("The project already has the maximum of %d composite indexes.", maxCompositeIdx) + } + + b.nextIndex++ + + idx := cloneIndex(in.GetIndex()) + idx.ProjectId = in.GetProjectId() + idx.IndexId = strconv.FormatInt(indexIDBase+b.nextIndex, 10) + idx.State = adminpb.Index_READY + b.indexes[indexKey(idx.GetProjectId(), idx.GetIndexId())] = idx + + return b.indexOp(idx.GetProjectId(), adminpb.OperationType_CREATE_INDEX, cloneIndex(idx)) +} + +// DeleteIndex removes a composite index. +func (b *InMemoryBackend) DeleteIndex(in *adminpb.DeleteIndexRequest) (*longrunningpb.Operation, error) { + if in.GetProjectId() == "" || in.GetIndexId() == "" { + return nil, errInvalid("project_id and index_id are required.") + } + + b.mu.Lock("DeleteIndex") + defer b.mu.Unlock() + + k := indexKey(in.GetProjectId(), in.GetIndexId()) + + idx, ok := b.indexes[k] + if !ok { + return nil, errNotFound("Index %s not found.", in.GetIndexId()) + } + + delete(b.indexes, k) + + out := cloneIndex(idx) + out.State = adminpb.Index_DELETING + + return b.indexOp(in.GetProjectId(), adminpb.OperationType_DELETE_INDEX, out) +} + +// GetIndex returns one composite index. +func (b *InMemoryBackend) GetIndex(in *adminpb.GetIndexRequest) (*adminpb.Index, error) { + b.mu.RLock("GetIndex") + defer b.mu.RUnlock() + + idx, ok := b.indexes[indexKey(in.GetProjectId(), in.GetIndexId())] + if !ok { + return nil, errNotFound("Index %s not found.", in.GetIndexId()) + } + + return cloneIndex(idx), nil +} + +// ListIndexes pages through a project's composite indexes in creation order. +func (b *InMemoryBackend) ListIndexes(in *adminpb.ListIndexesRequest) (*adminpb.ListIndexesResponse, error) { + if in.GetProjectId() == "" { + return nil, errInvalid("Missing required project ID.") + } + + if in.GetFilter() != "" { + return nil, errInvalid("ListIndexes filter is not supported.") + } + + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + if in.GetPageToken() != "" { + if err := page.ValidateToken(in.GetPageToken()); err != nil { + return nil, errInvalid("Invalid page token.") + } + } + + b.mu.RLock("ListIndexes") + + var all []*adminpb.Index + + for _, idx := range b.indexes { + if idx.GetProjectId() == in.GetProjectId() { + all = append(all, cloneIndex(idx)) + } + } + + b.mu.RUnlock() + + slices.SortFunc(all, func(x, y *adminpb.Index) int { return strings.Compare(x.GetIndexId(), y.GetIndexId()) }) + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultIdxPage) + + return &adminpb.ListIndexesResponse{Indexes: pg.Data, NextPageToken: pg.Next}, nil +} diff --git a/services/gcpdatastore/aggregate.go b/services/gcpdatastore/aggregate.go new file mode 100644 index 0000000000..9c76fa8fb6 --- /dev/null +++ b/services/gcpdatastore/aggregate.go @@ -0,0 +1,234 @@ +package gcpdatastore + +import ( + "strconv" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const maxAggregations = 5 + +const ( + aggCount = "count" + aggSum = "sum" + aggAvg = "avg" +) + +type aggSpec struct { + alias string + kind string + prop string + upTo int64 + hasUp bool +} + +func aggSpecs(aggs []*datastorepb.AggregationQuery_Aggregation) ([]aggSpec, error) { + if len(aggs) == 0 || len(aggs) > maxAggregations { + return nil, errInvalid("An aggregation query needs between 1 and %d aggregations.", maxAggregations) + } + + seen := map[string]struct{}{} + unnamed := 0 + out := make([]aggSpec, 0, len(aggs)) + + for _, a := range aggs { + var s aggSpec + + switch op := a.GetOperator().(type) { + case *datastorepb.AggregationQuery_Aggregation_Count_: + s.kind = aggCount + + if op.Count.GetUpTo() != nil { + if op.Count.GetUpTo().GetValue() < 0 { + return nil, errInvalid("COUNT_UP_TO must be non-negative.") + } + + s.upTo, s.hasUp = op.Count.GetUpTo().GetValue(), true + } + case *datastorepb.AggregationQuery_Aggregation_Sum_: + s.kind, s.prop = aggSum, op.Sum.GetProperty().GetName() + case *datastorepb.AggregationQuery_Aggregation_Avg_: + s.kind, s.prop = aggAvg, op.Avg.GetProperty().GetName() + default: + return nil, errInvalid("An aggregation requires an operator.") + } + + if s.kind != aggCount && s.prop == "" { + return nil, errInvalid("The %s aggregation requires a property.", s.kind) + } + + s.alias = a.GetAlias() + if s.alias == "" { + unnamed++ + s.alias = "property_" + strconv.Itoa(unnamed) + } + + if _, dup := seen[s.alias]; dup { + return nil, errInvalid("Aggregation alias %q is used more than once.", s.alias) + } + + seen[s.alias] = struct{}{} + + out = append(out, s) + } + + return out, nil +} + +func (s aggSpec) compute(rows []*row) *datastorepb.Value { + if s.kind == aggCount { + n := int64(len(rows)) + if s.hasUp { + n = min(n, s.upTo) + } + + return intValue(n) + } + + var ( + isum int64 + fsum float64 + count int64 + float bool + overflo bool + ) + + for _, r := range rows { + v, ok := numericProp(r.ent, s.prop) + if !ok { + continue + } + + count++ + + if d, isD := v.GetValueType().(*datastorepb.Value_DoubleValue); isD { + float = true + fsum += d.DoubleValue + + continue + } + + n := v.GetIntegerValue() + sum, over := addChecked(isum, n) + overflo = overflo || over + isum = sum + fsum += float64(n) + } + + if s.kind == aggAvg { + if count == 0 { + return nullValue() + } + + return doubleValue(fsum / float64(count)) + } + + if float || overflo { + return doubleValue(fsum) + } + + return intValue(isum) +} + +func numericProp(e *datastorepb.Entity, path string) (*datastorepb.Value, bool) { + if v, ok := e.GetProperties()[path]; ok { + return v, isNumber(v) + } + + vals := propValues(e, path) + if len(vals) == 1 && isNumber(vals[0]) { + return vals[0], true + } + + return nil, false +} + +// RunAggregationQuery computes count, sum and avg over a query. +func (b *InMemoryBackend) RunAggregationQuery( + in *datastorepb.RunAggregationQueryRequest, +) (*datastorepb.RunAggregationQueryResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + if in.GetExplainOptions() != nil { + return nil, errUnimplemented("Query explain is not supported.") + } + + part, err := queryPartition(req, in.GetPartitionId()) + if err != nil { + return nil, err + } + + aq, err := resolveAggregation(in) + if err != nil { + return nil, err + } + + specs, err := aggSpecs(aq.GetAggregations()) + if err != nil { + return nil, err + } + + nested := aq.GetNestedQuery() + if nested == nil { + return nil, errInvalid("An aggregation query requires a nested query.") + } + + plan, err := buildPlan(part, nested) + if err != nil { + return nil, err + } + + plan.proj, plan.keysOnly, plan.unbounded = nil, false, true + + resp := &datastorepb.RunAggregationQueryResponse{Query: aq} + + err = b.read(in.GetReadOptions(), req, func(rv *readView) error { + res, qerr := b.execQuery(plan, rv) + if qerr != nil { + return qerr + } + + props := make(map[string]*datastorepb.Value, len(specs)) + for _, s := range specs { + props[s.alias] = s.compute(res.rows) + } + + resp.Batch = &datastorepb.AggregationResultBatch{ + AggregationResults: []*datastorepb.AggregationResult{{AggregateProperties: props}}, + MoreResults: datastorepb.QueryResultBatch_NO_MORE_RESULTS, + ReadTime: timestamppb.New(rv.at), + } + resp.Transaction = rv.newTxnID + + return nil + }) + if err != nil { + return nil, err + } + + return resp, nil +} + +func resolveAggregation(in *datastorepb.RunAggregationQueryRequest) (*datastorepb.AggregationQuery, error) { + switch qt := in.GetQueryType().(type) { + case *datastorepb.RunAggregationQueryRequest_AggregationQuery: + return qt.AggregationQuery, nil + case *datastorepb.RunAggregationQueryRequest_GqlQuery: + g, err := parseGQL(qt.GqlQuery) + if err != nil { + return nil, err + } + + if g.agg == nil { + return nil, errInvalid("The GQL query is not an aggregation query.") + } + + return g.agg, nil + default: + return nil, errInvalid("An aggregation_query or gql_query is required.") + } +} diff --git a/services/gcpdatastore/commit.go b/services/gcpdatastore/commit.go new file mode 100644 index 0000000000..c29c3cdb65 --- /dev/null +++ b/services/gcpdatastore/commit.go @@ -0,0 +1,494 @@ +package gcpdatastore + +import ( + "time" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const maxTransforms = 500 + +// wstate is the working copy of one entity while a commit's mutations are applied in order. +type wstate struct { + key *datastorepb.Key + ent *datastorepb.Entity + old *datastorepb.Entity + created time.Time + updated time.Time + version int64 + dirty bool +} + +type commitCtx struct { + b *InMemoryBackend + req partition + at time.Time + work map[string]*wstate + order []string + version int64 + strict bool +} + +func (c *commitCtx) load(ks string, key *datastorepb.Key) *wstate { + if w, ok := c.work[ks]; ok { + return w + } + + w := &wstate{key: key} + + if rec := c.b.ents[ks]; rec != nil { + if ver := rec.cur(); ver != nil { + w.ent, w.old = ver.ent, ver.ent + w.created, w.updated, w.version = ver.created, ver.at, ver.version + } + } + + c.work[ks] = w + c.order = append(c.order, ks) + + return w +} + +// Commit applies mutations atomically, optionally inside a transaction. +func (b *InMemoryBackend) Commit(in *datastorepb.CommitRequest) (*datastorepb.CommitResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + mode := in.GetMode() + if mode == datastorepb.CommitRequest_MODE_UNSPECIFIED { + mode = datastorepb.CommitRequest_TRANSACTIONAL + } + + b.mu.Lock("Commit") + defer b.mu.Unlock() + + b.sweepLocked(b.wallLocked()) + + txn, err := b.commitTxnLocked(in, mode, req) + if err != nil { + return nil, err + } + + if txn != nil && len(in.GetMutations()) > 0 { + if err = b.checkConflictLocked(txn, in.GetMutations(), req); err != nil { + b.endTxnLocked(txnKey(in)) + + return nil, err + } + } + + c := &commitCtx{ + b: b, req: req, work: map[string]*wstate{}, + strict: mode == datastorepb.CommitRequest_NON_TRANSACTIONAL, + } + c.at = b.wallLocked() + + if !c.at.After(b.lastCommit) { + c.at = b.lastCommit.Add(time.Microsecond) + } + + c.version = c.at.UnixMicro() + + results := make([]*datastorepb.MutationResult, 0, len(in.GetMutations())) + + for _, m := range in.GetMutations() { + r, merr := c.apply(m) + if merr != nil { + if txn != nil { + b.endTxnLocked(txnKey(in)) + } + + return nil, merr + } + + results = append(results, r) + } + + if txn != nil { + b.endTxnLocked(txnKey(in)) + } + + resp := &datastorepb.CommitResponse{MutationResults: results} + + if len(c.order) > 0 && c.anyDirty() { + b.lastCommit = c.at + resp.IndexUpdates = clampInt32(c.persist()) + } + + resp.CommitTime = timestamppb.New(c.at) + + return resp, nil +} + +func txnKey(in *datastorepb.CommitRequest) string { + if t := in.GetTransaction(); t != nil { + return string(t) + } + + return "" +} + +func (b *InMemoryBackend) commitTxnLocked( + in *datastorepb.CommitRequest, mode datastorepb.CommitRequest_Mode, req partition, +) (*txnRec, error) { + if mode == datastorepb.CommitRequest_NON_TRANSACTIONAL { + if in.GetTransactionSelector() != nil { + return nil, errInvalid("A transaction must not be specified for a non-transactional commit.") + } + + return nil, nil //nolint:nilnil // no transaction for non-transactional commits + } + + switch sel := in.GetTransactionSelector().(type) { + case *datastorepb.CommitRequest_Transaction: + t, err := b.txnLocked(sel.Transaction, req) + if err != nil { + return nil, err + } + + if t.readOnly && len(in.GetMutations()) > 0 { + b.endTxnLocked(string(sel.Transaction)) + + return nil, errInvalid("Cannot modify entities in a read-only transaction.") + } + + return t, nil + case *datastorepb.CommitRequest_SingleUseTransaction: + if sel.SingleUseTransaction.GetReadOnly() != nil { + return nil, errInvalid("A single-use transaction must be read-write.") + } + + return nil, nil //nolint:nilnil // single-use transactions have no prior reads to validate + default: + return nil, errInvalid("A transaction must be specified for a transactional commit.") + } +} + +// checkConflictLocked aborts when anything the transaction read or writes changed after its snapshot. +func (b *InMemoryBackend) checkConflictLocked( + t *txnRec, muts []*datastorepb.Mutation, req partition, +) error { + writes := map[string]struct{}{} + + for _, m := range muts { + var k *datastorepb.Key + + switch op := m.GetOperation().(type) { + case *datastorepb.Mutation_Insert: + k = op.Insert.GetKey() + case *datastorepb.Mutation_Update: + k = op.Update.GetKey() + case *datastorepb.Mutation_Upsert: + k = op.Upsert.GetKey() + case *datastorepb.Mutation_Delete: + k = op.Delete + } + + if nk, _, err := normKey(k, req, true, false); err == nil && isComplete(nk) { + writes[keyString(nk)] = struct{}{} + } + } + + for _, ch := range b.changes { + if !ch.at.After(t.readTime) { + continue + } + + if _, ok := t.reads[ch.ks]; ok { + return errContention() + } + + if _, ok := writes[ch.ks]; ok { + return errContention() + } + + for _, q := range t.queries { + if q.covers(ch.key) { + return errContention() + } + } + } + + return nil +} + +func (c *commitCtx) anyDirty() bool { + for _, w := range c.work { + if w.dirty { + return true + } + } + + return false +} + +// persist writes the dirty working states as new versions and returns the index entry count. +func (c *commitCtx) persist() int { + b := c.b + idx := 0 + + for _, ks := range c.order { + w := c.work[ks] + if !w.dirty { + continue + } + + rec := b.ents[ks] + if rec == nil { + rec = &entRec{key: w.key} + b.ents[ks] = rec + } + + if w.ent == nil { + rec.vers = append(rec.vers, entVer{at: c.at, version: c.version}) + } else { + rec.vers = append(rec.vers, entVer{at: c.at, created: w.created, ent: w.ent, version: c.version}) + } + + if e, ok := lastElem(w.key).GetIdType().(*datastorepb.Key_PathElement_Id); ok { + b.reserveIDLocked(keyPartition(w.key), e.Id) + } + + b.changes = append(b.changes, change{at: c.at, key: w.key, ks: ks}) + idx += countIndexed(w.ent) + countIndexed(w.old) + } + + return idx +} + +func (c *commitCtx) apply(m *datastorepb.Mutation) (*datastorepb.MutationResult, error) { + if m.GetConflictResolutionStrategy() != datastorepb.Mutation_STRATEGY_UNSPECIFIED && + m.GetConflictDetectionStrategy() == nil { + return nil, errInvalid("conflict_resolution_strategy requires a conflict detection strategy.") + } + + var ( + ent *datastorepb.Entity + del *datastorepb.Key + ) + + switch op := m.GetOperation().(type) { + case *datastorepb.Mutation_Insert: + ent = op.Insert + case *datastorepb.Mutation_Update: + ent = op.Update + case *datastorepb.Mutation_Upsert: + ent = op.Upsert + case *datastorepb.Mutation_Delete: + del = op.Delete + default: + return nil, errInvalid("Mutation has no operation set.") + } + + if del != nil { + if len(m.GetPropertyTransforms()) > 0 { + return nil, errInvalid("Property transforms are not allowed on delete.") + } + + return c.applyDelete(m, del) + } + + return c.applyWrite(m, ent) +} + +func (c *commitCtx) conflict(m *datastorepb.Mutation, w *wstate) (bool, error) { + var hit bool + + switch d := m.GetConflictDetectionStrategy().(type) { + case *datastorepb.Mutation_BaseVersion: + hit = w.version != d.BaseVersion + case *datastorepb.Mutation_UpdateTime: + hit = w.ent == nil || !w.updated.Equal(d.UpdateTime.AsTime()) + default: + return false, nil + } + + if hit && m.GetConflictResolutionStrategy() == datastorepb.Mutation_FAIL { + return true, errContention() + } + + return hit, nil +} + +func (c *commitCtx) skipped(w *wstate) *datastorepb.MutationResult { + r := &datastorepb.MutationResult{ConflictDetected: true, Version: w.version} + if w.ent != nil { + r.CreateTime, r.UpdateTime = timestamppb.New(w.created), timestamppb.New(w.updated) + } + + return r +} + +func (c *commitCtx) applyDelete(m *datastorepb.Mutation, k *datastorepb.Key) (*datastorepb.MutationResult, error) { + key, _, err := normKey(k, c.req, false, true) + if err != nil { + return nil, err + } + + ks := keyString(key) + if err = c.touch(ks); err != nil { + return nil, err + } + + w := c.load(ks, key) + + if hit, cerr := c.conflict(m, w); cerr != nil { + return nil, cerr + } else if hit { + return c.skipped(w), nil + } + + if w.ent != nil { + w.ent, w.dirty = nil, true + } + + w.version = c.version + + return &datastorepb.MutationResult{Version: c.version}, nil +} + +func (c *commitCtx) touch(ks string) error { + if _, seen := c.work[ks]; seen && c.strict { + return errInvalid("A non-transactional commit may not contain two mutations of the same entity.") + } + + return nil +} + +// writeTarget validates a write mutation's entity and resolves its key, allocating an id for incomplete keys. +func (c *commitCtx) writeTarget( + m *datastorepb.Mutation, e *datastorepb.Entity, update bool, +) (*datastorepb.Key, *datastorepb.Entity, *datastorepb.MutationResult, error) { + key, part, err := normKey(e.GetKey(), c.req, !update, true) + if err != nil { + return nil, nil, nil, err + } + + if len(m.GetPropertyTransforms()) > maxTransforms { + return nil, nil, nil, errInvalid("A mutation may have at most %d property transforms.", maxTransforms) + } + + prepared, err := prepareEntity(e, c.req) + if err != nil { + return nil, nil, nil, err + } + + res := &datastorepb.MutationResult{} + + if !isComplete(key) { + id, aerr := c.b.allocIDLocked(part) + if aerr != nil { + return nil, nil, nil, aerr + } + + lastElem(key).IdType = &datastorepb.Key_PathElement_Id{Id: id} + res.Key = key + } + + return key, prepared, res, nil +} + +func (c *commitCtx) applyWrite(m *datastorepb.Mutation, e *datastorepb.Entity) (*datastorepb.MutationResult, error) { + _, isInsert := m.GetOperation().(*datastorepb.Mutation_Insert) + _, isUpdate := m.GetOperation().(*datastorepb.Mutation_Update) + + key, prepared, res, err := c.writeTarget(m, e, isUpdate) + if err != nil { + return nil, err + } + + ks := keyString(key) + if err = c.touch(ks); err != nil { + return nil, err + } + + w := c.load(ks, key) + + if hit, cerr := c.conflict(m, w); cerr != nil { + return nil, cerr + } else if hit { + return c.skipped(w), nil + } + + if isInsert && w.ent != nil { + return nil, errExists("entity already exists: %s", keyDescription(key)) + } + + if isUpdate && w.ent == nil { + return nil, errNotFound("no entity to update: %s", keyDescription(key)) + } + + next := prepared + if m.GetPropertyMask() != nil && w.ent != nil { + next = applyMask(w.ent, prepared, m.GetPropertyMask().GetPaths()) + } + + next.Key = key + + if res.TransformResults, err = c.transform(next, m.GetPropertyTransforms()); err != nil { + return nil, err + } + + if w.ent == nil { + w.created = c.at + } + + w.ent, w.dirty, w.updated, w.version = next, true, c.at, c.version + res.Version = c.version + res.CreateTime, res.UpdateTime = timestamppb.New(w.created), timestamppb.New(c.at) + + return res, nil +} + +func applyMask(existing, incoming *datastorepb.Entity, paths []string) *datastorepb.Entity { + out, _ := proto.Clone(existing).(*datastorepb.Entity) + if out.GetProperties() == nil { + out.Properties = map[string]*datastorepb.Value{} + } + + for _, p := range paths { + setPath(out, incoming, splitPath(p)) + } + + return out +} + +func setPath(dst, src *datastorepb.Entity, path []string) { + head := path[0] + + if len(path) == 1 { + if v, ok := src.GetProperties()[head]; ok { + dst.Properties[head] = v + } else { + delete(dst.GetProperties(), head) + } + + return + } + + sv, ok := src.GetProperties()[head].GetValueType().(*datastorepb.Value_EntityValue) + if !ok { + delete(dst.GetProperties(), head) + + return + } + + dv := dst.GetProperties()[head] + if _, isEnt := dv.GetValueType().(*datastorepb.Value_EntityValue); !isEnt { + dv = &datastorepb.Value{ValueType: &datastorepb.Value_EntityValue{ + EntityValue: &datastorepb.Entity{Properties: map[string]*datastorepb.Value{}}, + }} + dst.Properties[head] = dv + } + + sub := dv.GetEntityValue() + if sub.GetProperties() == nil { + sub.Properties = map[string]*datastorepb.Value{} + } + + setPath(sub, sv.EntityValue, path[1:]) +} diff --git a/services/gcpdatastore/crud_test.go b/services/gcpdatastore/crud_test.go new file mode 100644 index 0000000000..17f4eabcd5 --- /dev/null +++ b/services/gcpdatastore/crud_test.go @@ -0,0 +1,153 @@ +package gcpdatastore_test + +import ( + "strings" + "testing" + + "cloud.google.com/go/datastore" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestCRUD(t *testing.T) { + t.Parallel() + + tests := []struct { + run func(t *testing.T, e *env) + name string + }{ + {func(t *testing.T, e *env) { + t.Helper() + + k := datastore.NameKey("Task", "one", nil) + _, err := e.client.Put(t.Context(), k, &task{Name: "one", Priority: 7, Tags: []string{"p", "q"}, Note: "n"}) + require.NoError(t, err) + + var got task + require.NoError(t, e.client.Get(t.Context(), k, &got)) + assert.Equal(t, task{Name: "one", Priority: 7, Tags: []string{"p", "q"}, Note: "n"}, got) + + require.NoError(t, e.client.Delete(t.Context(), k)) + assert.ErrorIs(t, e.client.Get(t.Context(), k, &got), datastore.ErrNoSuchEntity) + }, "put get delete"}, + {func(t *testing.T, e *env) { + t.Helper() + + k, err := e.client.Put(t.Context(), datastore.IncompleteKey("Task", nil), &task{Name: "auto"}) + require.NoError(t, err) + assert.NotZero(t, k.ID) + + var got task + require.NoError(t, e.client.Get(t.Context(), k, &got)) + assert.Equal(t, "auto", got.Name) + }, "incomplete key allocates id"}, + {func(t *testing.T, e *env) { + t.Helper() + + e.seed(t, "Task", map[string]task{"a": {}, "b": {}}) + + keys := []*datastore.Key{ + datastore.NameKey( + "Task", + "a", + nil, + ), + datastore.NameKey("Task", "nope", nil), + datastore.NameKey("Task", "b", nil), + } + dst := make([]task, 3) + err := e.client.GetMulti(t.Context(), keys, dst) + + var me datastore.MultiError + require.ErrorAs(t, err, &me) + require.NoError(t, me[0]) + require.ErrorIs(t, me[1], datastore.ErrNoSuchEntity) + require.NoError(t, me[2]) + assert.Equal(t, "b", dst[2].Name) + }, "getmulti reports missing per key"}, + {func(t *testing.T, e *env) { + t.Helper() + + keys := []*datastore.Key{datastore.NameKey("Task", "a", nil), datastore.NameKey("Task", "b", nil)} + _, err := e.client.PutMulti(t.Context(), keys, []task{{Name: "a"}, {Name: "b"}}) + require.NoError(t, err) + require.NoError(t, e.client.DeleteMulti(t.Context(), keys)) + + n, err := e.client.Count(t.Context(), datastore.NewQuery("Task")) + require.NoError(t, err) + assert.Zero(t, n) + }, "putmulti and deletemulti"}, + {func(t *testing.T, e *env) { + t.Helper() + + parent := datastore.NameKey("List", "l1", nil) + child := datastore.NameKey("Task", "c", parent) + nsKey := datastore.NameKey("Task", "c", nil) + nsKey.Namespace = "other" + + _, err := e.client.Put(t.Context(), child, &task{Name: "default-ns"}) + require.NoError(t, err) + _, err = e.client.Put(t.Context(), nsKey, &task{Name: "other-ns"}) + require.NoError(t, err) + + var a, b task + require.NoError(t, e.client.Get(t.Context(), child, &a)) + require.NoError(t, e.client.Get(t.Context(), nsKey, &b)) + assert.Equal(t, "default-ns", a.Name) + assert.Equal(t, "other-ns", b.Name) + }, "ancestor paths and namespaces are separate"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.run(t, newEnv(t)) + }) + } +} + +func TestInvalidRequests(t *testing.T) { + t.Parallel() + + tests := []struct { + key *datastore.Key + name string + code codes.Code + }{ + {datastore.NameKey("__bad__", "x", nil), "reserved kind", codes.InvalidArgument}, + {datastore.NameKey("Task", "__bad__", nil), "reserved name", codes.InvalidArgument}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.client.Put(t.Context(), tt.key, &task{}) + assert.Equal(t, tt.code, status.Code(err)) + }) + } + + t.Run("oversized indexed string", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.raw.Commit(t.Context(), &datastorepb.CommitRequest{ + ProjectId: project, Mode: datastorepb.CommitRequest_NON_TRANSACTIONAL, + Mutations: []*datastorepb.Mutation{{Operation: &datastorepb.Mutation_Upsert{Upsert: &datastorepb.Entity{ + Key: &datastorepb.Key{ + Path: []*datastorepb.Key_PathElement{ + {Kind: "Task", IdType: &datastorepb.Key_PathElement_Name{Name: "x"}}, + }, + }, + Properties: map[string]*datastorepb.Value{"s": { + ValueType: &datastorepb.Value_StringValue{StringValue: strings.Repeat("x", 1600)}, + }}, + }}}}, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) +} diff --git a/services/gcpdatastore/errors.go b/services/gcpdatastore/errors.go new file mode 100644 index 0000000000..43afb59b3a --- /dev/null +++ b/services/gcpdatastore/errors.go @@ -0,0 +1,36 @@ +package gcpdatastore + +import ( + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func errInvalid(format string, a ...any) error { + return status.Error(codes.InvalidArgument, fmt.Sprintf(format, a...)) +} + +func errNotFound(format string, a ...any) error { + return status.Error(codes.NotFound, fmt.Sprintf(format, a...)) +} + +func errExists(format string, a ...any) error { + return status.Error(codes.AlreadyExists, fmt.Sprintf(format, a...)) +} + +func errPrecondition(format string, a ...any) error { + return status.Error(codes.FailedPrecondition, fmt.Sprintf(format, a...)) +} + +func errUnimplemented(msg string) error { + return status.Error(codes.Unimplemented, msg) +} + +func errContention() error { + return status.Error(codes.Aborted, "too much contention on these datastore entities. please try again.") +} + +func errBadTxn() error { + return status.Error(codes.InvalidArgument, "The referenced transaction has expired or is no longer valid.") +} diff --git a/services/gcpdatastore/gql.go b/services/gcpdatastore/gql.go new file mode 100644 index 0000000000..3f5d81d2d2 --- /dev/null +++ b/services/gcpdatastore/gql.go @@ -0,0 +1,1055 @@ +package gcpdatastore + +import ( + "encoding/base64" + "strconv" + "strings" + "time" + "unicode" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +type gqlResult struct { + query *datastorepb.Query + agg *datastorepb.AggregationQuery +} + +type tokKind int + +const ( + tokEOF tokKind = iota + tokIdent + tokNumber + tokString + tokBinding + tokPunct +) + +type token struct { + text string + kind tokKind +} + +type gqlParser struct { + g *datastorepb.GqlQuery + toks []token + pos int + literals bool +} + +type lexer struct { + rs []rune + toks []token + i int +} + +func tokenizeGQL(s string) ([]token, error) { + l := &lexer{rs: []rune(s)} + + for l.i < len(l.rs) { + if err := l.step(); err != nil { + return nil, err + } + } + + return l.toks, nil +} + +func isIdentRune(c rune) bool { return unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' } + +func (l *lexer) step() error { + c := l.rs[l.i] + + switch { + case unicode.IsSpace(c): + l.i++ + case c == '\'' || c == '"' || c == '`': + return l.quoted(c) + case c == '@' || c == ':': + return l.binding() + case unicode.IsDigit(c) || (c == '-' && l.i+1 < len(l.rs) && unicode.IsDigit(l.rs[l.i+1])): + l.number() + case unicode.IsLetter(c) || c == '_': + l.ident() + default: + return l.punct(c) + } + + return nil +} + +func (l *lexer) quoted(q rune) error { + text, next, err := scanQuoted(l.rs, l.i) + if err != nil { + return err + } + + kind := tokString + if q != '\'' { + kind = tokIdent + } + + l.toks = append(l.toks, token{kind: kind, text: text}) + l.i = next + + return nil +} + +func (l *lexer) binding() error { + j := l.i + 1 + for j < len(l.rs) && isIdentRune(l.rs[j]) { + j++ + } + + if j == l.i+1 { + return errInvalid("GQL syntax error: empty binding name.") + } + + l.toks = append(l.toks, token{kind: tokBinding, text: string(l.rs[l.i+1 : j])}) + l.i = j + + return nil +} + +func isExponentSign(rs []rune, j int) bool { + return (rs[j] == '+' || rs[j] == '-') && (rs[j-1] == 'e' || rs[j-1] == 'E') +} + +func (l *lexer) number() { + j := l.i + 1 + for j < len(l.rs) && (unicode.IsDigit(l.rs[j]) || strings.ContainsRune(".eE", l.rs[j]) || isExponentSign(l.rs, j)) { + j++ + } + + l.toks = append(l.toks, token{kind: tokNumber, text: string(l.rs[l.i:j])}) + l.i = j +} + +func (l *lexer) ident() { + j := l.i + 1 + for j < len(l.rs) && (isIdentRune(l.rs[j]) || l.rs[j] == '.') { + j++ + } + + l.toks = append(l.toks, token{kind: tokIdent, text: string(l.rs[l.i:j])}) + l.i = j +} + +func (l *lexer) punct(c rune) error { + if l.i+1 < len(l.rs) { + switch two := string(l.rs[l.i : l.i+2]); two { + case "<=", ">=", "!=", "<>": + l.toks = append(l.toks, token{kind: tokPunct, text: two}) + l.i += 2 + + return nil + } + } + + if !strings.ContainsRune("(),*=<>;", c) { + return errInvalid("GQL syntax error: unexpected character %q.", string(c)) + } + + l.toks = append(l.toks, token{kind: tokPunct, text: string(c)}) + l.i++ + + return nil +} + +func scanQuoted(rs []rune, start int) (string, int, error) { + q := rs[start] + + var sb strings.Builder + + for i := start + 1; i < len(rs); i++ { + switch { + case rs[i] == '\\' && i+1 < len(rs): + i++ + sb.WriteRune(rs[i]) + case rs[i] == q && i+1 < len(rs) && rs[i+1] == q: + sb.WriteRune(q) + + i++ + case rs[i] == q: + return sb.String(), i + 1, nil + default: + sb.WriteRune(rs[i]) + } + } + + return "", 0, errInvalid("GQL syntax error: unterminated quoted text.") +} + +func parseGQL(g *datastorepb.GqlQuery) (*gqlResult, error) { + toks, err := tokenizeGQL(g.GetQueryString()) + if err != nil { + return nil, err + } + + p := &gqlParser{g: g, toks: toks} + + res, err := p.statement() + if err != nil { + return nil, err + } + + p.accept(";") + + if p.peek().kind != tokEOF { + return nil, errInvalid("GQL syntax error: unexpected %q.", p.peek().text) + } + + if p.literals && !g.GetAllowLiterals() { + return nil, errInvalid("The GQL query contains literals but allow_literals is false; bind the values instead.") + } + + return res, nil +} + +func (p *gqlParser) peek() token { + if p.pos >= len(p.toks) { + return token{kind: tokEOF} + } + + return p.toks[p.pos] +} + +func (p *gqlParser) next() token { + t := p.peek() + if p.pos < len(p.toks) { + p.pos++ + } + + return t +} + +func (p *gqlParser) isKw(kw string) bool { + t := p.peek() + + return t.kind == tokIdent && strings.EqualFold(t.text, kw) +} + +func (p *gqlParser) kw(kw string) bool { + if p.isKw(kw) { + p.pos++ + + return true + } + + return false +} + +func (p *gqlParser) accept(punct string) bool { + if t := p.peek(); t.kind == tokPunct && t.text == punct { + p.pos++ + + return true + } + + return false +} + +func (p *gqlParser) expect(punct string) error { + if !p.accept(punct) { + return errInvalid("GQL syntax error: expected %q but found %q.", punct, p.peek().text) + } + + return nil +} + +func (p *gqlParser) expectKw(kw string) error { + if !p.kw(kw) { + return errInvalid("GQL syntax error: expected %s but found %q.", kw, p.peek().text) + } + + return nil +} + +func (p *gqlParser) statement() (*gqlResult, error) { + if p.kw("AGGREGATE") { + return p.aggregate() + } + + if err := p.expectKw("SELECT"); err != nil { + return nil, err + } + + q := &datastorepb.Query{} + + aggs, err := p.selectList(q) + if err != nil { + return nil, err + } + + if err = p.queryTail(q); err != nil { + return nil, err + } + + if len(aggs) > 0 { + return &gqlResult{query: q, agg: &datastorepb.AggregationQuery{ + QueryType: &datastorepb.AggregationQuery_NestedQuery{NestedQuery: q}, + Aggregations: aggs, + }}, nil + } + + return &gqlResult{query: q}, nil +} + +func (p *gqlParser) aggregate() (*gqlResult, error) { + var aggs []*datastorepb.AggregationQuery_Aggregation + + for { + a, ok, err := p.aggFunc() + if err != nil { + return nil, err + } + + if !ok { + return nil, errInvalid("GQL syntax error: expected an aggregation but found %q.", p.peek().text) + } + + aggs = append(aggs, a) + + if !p.accept(",") { + break + } + } + + if err := p.expectKw("OVER"); err != nil { + return nil, err + } + + if err := p.expect("("); err != nil { + return nil, err + } + + if err := p.expectKw("SELECT"); err != nil { + return nil, err + } + + q := &datastorepb.Query{} + + if more, err := p.selectList(q); err != nil { + return nil, err + } else if len(more) > 0 { + return nil, errInvalid("GQL syntax error: aggregations are not allowed in the OVER query.") + } + + if err := p.queryTail(q); err != nil { + return nil, err + } + + if err := p.expect(")"); err != nil { + return nil, err + } + + return &gqlResult{query: q, agg: &datastorepb.AggregationQuery{ + QueryType: &datastorepb.AggregationQuery_NestedQuery{NestedQuery: q}, + Aggregations: aggs, + }}, nil +} + +func (p *gqlParser) aggFunc() (*datastorepb.AggregationQuery_Aggregation, bool, error) { + t := p.peek() + if t.kind != tokIdent || p.pos+1 >= len(p.toks) || p.toks[p.pos+1].text != "(" { + return nil, false, nil + } + + name := strings.ToUpper(t.text) + if name != "COUNT" && name != "COUNT_UP_TO" && name != "SUM" && name != "AVG" { + return nil, false, nil + } + + p.pos += 2 + + a := &datastorepb.AggregationQuery_Aggregation{} + if err := p.aggOperator(name, a); err != nil { + return nil, false, err + } + + if err := p.expect(")"); err != nil { + return nil, false, err + } + + if p.kw("AS") { + alias := p.next() + if alias.kind != tokIdent { + return nil, false, errInvalid("GQL syntax error: expected an alias after AS.") + } + + a.Alias = alias.text + } + + return a, true, nil +} + +func (p *gqlParser) aggOperator(name string, a *datastorepb.AggregationQuery_Aggregation) error { + switch name { + case "COUNT": + a.Operator = &datastorepb.AggregationQuery_Aggregation_Count_{ + Count: &datastorepb.AggregationQuery_Aggregation_Count{}, + } + + return p.expect("*") + case "COUNT_UP_TO": + v, err := p.value() + if err != nil { + return err + } + + a.Operator = &datastorepb.AggregationQuery_Aggregation_Count_{ + Count: &datastorepb.AggregationQuery_Aggregation_Count{ + UpTo: wrapperspb.Int64(v.GetIntegerValue()), + }, + } + + return nil + } + + prop := p.next() + if prop.kind != tokIdent { + return errInvalid("GQL syntax error: expected a property name in %s.", name) + } + + ref := &datastorepb.PropertyReference{Name: prop.text} + if name == "SUM" { + a.Operator = &datastorepb.AggregationQuery_Aggregation_Sum_{ + Sum: &datastorepb.AggregationQuery_Aggregation_Sum{Property: ref}, + } + } else { + a.Operator = &datastorepb.AggregationQuery_Aggregation_Avg_{ + Avg: &datastorepb.AggregationQuery_Aggregation_Avg{Property: ref}, + } + } + + return nil +} + +// selectList parses the projection after SELECT; aggregation shorthand is returned separately. +func (p *gqlParser) selectList(q *datastorepb.Query) ([]*datastorepb.AggregationQuery_Aggregation, error) { + if p.accept("*") { + return nil, nil + } + + distinct := p.kw("DISTINCT") + + var on []*datastorepb.PropertyReference + + if distinct && p.kw("ON") { + var err error + if on, err = p.distinctOn(); err != nil { + return nil, err + } + } + + aggs, props, err := p.selectItems() + if err != nil { + return nil, err + } + + for _, pr := range props { + q.Projection = append(q.Projection, &datastorepb.Projection{Property: pr}) + } + + switch { + case on != nil: + q.DistinctOn = on + case distinct: + q.DistinctOn = props + } + + return aggs, nil +} + +func (p *gqlParser) distinctOn() ([]*datastorepb.PropertyReference, error) { + if err := p.expect("("); err != nil { + return nil, err + } + + var on []*datastorepb.PropertyReference + + for { + t := p.next() + if t.kind != tokIdent { + return nil, errInvalid("GQL syntax error: expected a property name in DISTINCT ON.") + } + + on = append(on, &datastorepb.PropertyReference{Name: t.text}) + + if !p.accept(",") { + break + } + } + + return on, p.expect(")") +} + +func (p *gqlParser) selectItems() ( + []*datastorepb.AggregationQuery_Aggregation, []*datastorepb.PropertyReference, error, +) { + var ( + aggs []*datastorepb.AggregationQuery_Aggregation + props []*datastorepb.PropertyReference + ) + + for { + a, ok, err := p.aggFunc() + if err != nil { + return nil, nil, err + } + + if ok { + aggs = append(aggs, a) + } else { + t := p.next() + if t.kind != tokIdent { + return nil, nil, errInvalid("GQL syntax error: expected a property name but found %q.", t.text) + } + + props = append(props, &datastorepb.PropertyReference{Name: t.text}) + } + + if !p.accept(",") { + break + } + } + + if len(aggs) > 0 && len(props) > 0 { + return nil, nil, errInvalid("GQL syntax error: aggregations cannot be mixed with properties.") + } + + return aggs, props, nil +} + +func (p *gqlParser) queryTail(q *datastorepb.Query) error { + if p.kw("FROM") { + t := p.next() + if t.kind != tokIdent { + return errInvalid("GQL syntax error: expected a kind after FROM.") + } + + q.Kind = []*datastorepb.KindExpression{{Name: t.text}} + } + + if p.kw("WHERE") { + f, err := p.orExpr() + if err != nil { + return err + } + + q.Filter = f + } + + if p.kw("ORDER") { + if err := p.expectKw("BY"); err != nil { + return err + } + + if err := p.orderBy(q); err != nil { + return err + } + } + + return p.limitOffset(q) +} + +func (p *gqlParser) orderBy(q *datastorepb.Query) error { + for { + t := p.next() + if t.kind != tokIdent { + return errInvalid("GQL syntax error: expected a property name in ORDER BY.") + } + + dir := datastorepb.PropertyOrder_ASCENDING + + if p.kw("DESC") { + dir = datastorepb.PropertyOrder_DESCENDING + } else { + p.kw("ASC") + } + + q.Order = append(q.Order, &datastorepb.PropertyOrder{ + Property: &datastorepb.PropertyReference{Name: t.text}, Direction: dir, + }) + + if !p.accept(",") { + return nil + } + } +} + +func (p *gqlParser) intArg() (int64, error) { + v, err := p.value() + if err != nil { + return 0, err + } + + if _, ok := v.GetValueType().(*datastorepb.Value_IntegerValue); !ok { + return 0, errInvalid("GQL: LIMIT and OFFSET require integers.") + } + + return v.GetIntegerValue(), nil +} + +func (p *gqlParser) limitClause(q *datastorepb.Query) error { + n, err := p.intArg() + if err != nil { + return err + } + + if p.accept(",") { + q.Offset = clampInt32(int(n)) + + if n, err = p.intArg(); err != nil { + return err + } + } + + q.Limit = wrapperspb.Int32(clampInt32(int(n))) + + return nil +} + +func (p *gqlParser) offsetClause(q *datastorepb.Query) error { + n, err := p.intArg() + q.Offset = clampInt32(int(n)) + + return err +} + +func (p *gqlParser) limitOffset(q *datastorepb.Query) error { + for { + var err error + + switch { + case p.kw("LIMIT"): + err = p.limitClause(q) + case p.kw("OFFSET"): + err = p.offsetClause(q) + case p.kw("START"): + q.StartCursor, err = p.cursorArg() + case p.kw("END"): + q.EndCursor, err = p.cursorArg() + default: + return nil + } + + if err != nil { + return err + } + } +} + +func (p *gqlParser) cursorArg() ([]byte, error) { + if err := p.expectKw("CURSOR"); err != nil { + return nil, err + } + + t := p.next() + if t.kind != tokBinding { + return nil, errInvalid("GQL: cursors must be passed as bindings.") + } + + bp, err := p.binding(t.text) + if err != nil { + return nil, err + } + + if c, ok := bp.GetParameterType().(*datastorepb.GqlQueryParameter_Cursor); ok { + return c.Cursor, nil + } + + return nil, errInvalid("GQL: binding %q is not a cursor.", t.text) +} + +func (p *gqlParser) binding(name string) (*datastorepb.GqlQueryParameter, error) { + if n, err := strconv.Atoi(name); err == nil { + if n < 1 || n > len(p.g.GetPositionalBindings()) { + return nil, errInvalid("GQL: positional binding %d is not provided.", n) + } + + return p.g.GetPositionalBindings()[n-1], nil + } + + bp, ok := p.g.GetNamedBindings()[name] + if !ok { + return nil, errInvalid("GQL: named binding %q is not provided.", name) + } + + return bp, nil +} + +func (p *gqlParser) orExpr() (*datastorepb.Filter, error) { + return p.compose(datastorepb.CompositeFilter_OR, "OR", p.andExpr) +} + +func (p *gqlParser) andExpr() (*datastorepb.Filter, error) { + return p.compose(datastorepb.CompositeFilter_AND, "AND", p.term) +} + +func (p *gqlParser) compose( + op datastorepb.CompositeFilter_Operator, kw string, sub func() (*datastorepb.Filter, error), +) (*datastorepb.Filter, error) { + first, err := sub() + if err != nil { + return nil, err + } + + filters := []*datastorepb.Filter{first} + + for p.kw(kw) { + f, ferr := sub() + if ferr != nil { + return nil, ferr + } + + filters = append(filters, f) + } + + if len(filters) == 1 { + return first, nil + } + + return &datastorepb.Filter{FilterType: &datastorepb.Filter_CompositeFilter{ + CompositeFilter: &datastorepb.CompositeFilter{Op: op, Filters: filters}, + }}, nil +} + +func (p *gqlParser) term() (*datastorepb.Filter, error) { + if p.accept("(") { + f, err := p.orExpr() + if err != nil { + return nil, err + } + + return f, p.expect(")") + } + + prop := p.next() + if prop.kind != tokIdent { + return nil, errInvalid("GQL syntax error: expected a property name but found %q.", prop.text) + } + + op, val, err := p.condition() + if err != nil { + return nil, err + } + + return &datastorepb.Filter{ + FilterType: &datastorepb.Filter_PropertyFilter{PropertyFilter: &datastorepb.PropertyFilter{ + Property: &datastorepb.PropertyReference{Name: prop.text}, Op: op, Value: val, + }}, + }, nil +} + +func gqlOperator(s string) (datastorepb.PropertyFilter_Operator, bool) { + switch s { + case "=": + return datastorepb.PropertyFilter_EQUAL, true + case "<": + return datastorepb.PropertyFilter_LESS_THAN, true + case "<=": + return datastorepb.PropertyFilter_LESS_THAN_OR_EQUAL, true + case ">": + return datastorepb.PropertyFilter_GREATER_THAN, true + case ">=": + return datastorepb.PropertyFilter_GREATER_THAN_OR_EQUAL, true + case "!=", "<>": + return datastorepb.PropertyFilter_NOT_EQUAL, true + default: + return 0, false + } +} + +func (p *gqlParser) condition() (datastorepb.PropertyFilter_Operator, *datastorepb.Value, error) { + t := p.peek() + if op, ok := gqlOperator(t.text); ok && t.kind == tokPunct { + p.pos++ + v, err := p.value() + + return op, v, err + } + + switch { + case p.kw("IN"): + v, err := p.listValue() + + return datastorepb.PropertyFilter_IN, v, err + case p.kw("NOT"): + if err := p.expectKw("IN"); err != nil { + return 0, nil, err + } + + v, err := p.listValue() + + return datastorepb.PropertyFilter_NOT_IN, v, err + case p.kw("CONTAINS"): + v, err := p.value() + + return datastorepb.PropertyFilter_EQUAL, v, err + case p.kw("IS"): + if err := p.expectKw("NULL"); err != nil { + return 0, nil, err + } + + return datastorepb.PropertyFilter_EQUAL, nullValue(), nil + case p.kw("HAS"): + if err := p.expectKw("ANCESTOR"); err != nil { + return 0, nil, err + } + + v, err := p.value() + + return datastorepb.PropertyFilter_HAS_ANCESTOR, v, err + } + + return 0, nil, errInvalid("GQL syntax error: expected a comparison operator but found %q.", t.text) +} + +func (p *gqlParser) listValue() (*datastorepb.Value, error) { + p.kw("ARRAY") + + if t := p.peek(); t.kind == tokBinding { + return p.value() + } + + if err := p.expect("("); err != nil { + return nil, err + } + + var vals []*datastorepb.Value + + for { + v, err := p.value() + if err != nil { + return nil, err + } + + vals = append(vals, v) + + if !p.accept(",") { + break + } + } + + if err := p.expect(")"); err != nil { + return nil, err + } + + return &datastorepb.Value{ + ValueType: &datastorepb.Value_ArrayValue{ArrayValue: &datastorepb.ArrayValue{Values: vals}}, + }, nil +} + +func (p *gqlParser) value() (*datastorepb.Value, error) { + t := p.next() + + switch t.kind { + case tokBinding: + bp, err := p.binding(t.text) + if err != nil { + return nil, err + } + + v, ok := bp.GetParameterType().(*datastorepb.GqlQueryParameter_Value) + if !ok || v.Value == nil { + return nil, errInvalid("GQL: binding %q has no value.", t.text) + } + + return v.Value, nil + case tokNumber: + p.literals = true + + return numberLiteral(t.text) + case tokString: + p.literals = true + + return &datastorepb.Value{ValueType: &datastorepb.Value_StringValue{StringValue: t.text}}, nil + case tokIdent: + p.literals = true + + return p.identValue(t.text) + default: + return nil, errInvalid("GQL syntax error: expected a value but found %q.", t.text) + } +} + +func numberLiteral(s string) (*datastorepb.Value, error) { + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return intValue(n), nil + } + + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil, errInvalid("GQL: invalid number %q.", s) + } + + return doubleValue(f), nil +} + +func (p *gqlParser) identValue(word string) (*datastorepb.Value, error) { + switch strings.ToUpper(word) { + case "TRUE", "FALSE": + return &datastorepb.Value{ + ValueType: &datastorepb.Value_BooleanValue{BooleanValue: strings.EqualFold(word, "true")}, + }, nil + case "NULL": + return nullValue(), nil + case "BLOB": + s, err := p.stringArg() + if err != nil { + return nil, err + } + + raw, derr := base64.URLEncoding.DecodeString(s) + if derr != nil { + if raw, derr = base64.StdEncoding.DecodeString(s); derr != nil { + return nil, errInvalid("GQL: invalid BLOB literal.") + } + } + + return &datastorepb.Value{ValueType: &datastorepb.Value_BlobValue{BlobValue: raw}}, nil + case "DATETIME": + s, err := p.stringArg() + if err != nil { + return nil, err + } + + ts, perr := time.Parse(time.RFC3339Nano, s) + if perr != nil { + return nil, errInvalid("GQL: invalid DATETIME literal %q.", s) + } + + return timestampValue(timestamppb.New(ts)), nil + case "KEY": + return p.keyLiteral() + default: + return nil, errInvalid("GQL syntax error: unexpected %q.", word) + } +} + +func (p *gqlParser) stringArg() (string, error) { + if err := p.expect("("); err != nil { + return "", err + } + + t := p.next() + if t.kind != tokString { + return "", errInvalid("GQL syntax error: expected a string literal.") + } + + return t.text, p.expect(")") +} + +func (p *gqlParser) keyLiteral() (*datastorepb.Value, error) { + if err := p.expect("("); err != nil { + return nil, err + } + + key := &datastorepb.Key{PartitionId: &datastorepb.PartitionId{}} + + var parts []token + + for { + part, isPart, err := p.keyArg(key) + if err != nil { + return nil, err + } + + if isPart { + parts = append(parts, part) + } + + if !p.accept(",") { + break + } + } + + if err := p.expect(")"); err != nil { + return nil, err + } + + if len(parts) == 0 || len(parts)%2 != 0 { + return nil, errInvalid("GQL: KEY needs (kind, id-or-name) pairs.") + } + + for i := 0; i < len(parts); i += 2 { + el, err := pathElement(parts[i], parts[i+1]) + if err != nil { + return nil, err + } + + key.Path = append(key.Path, el) + } + + return keyValue(key), nil +} + +// keyArg parses one KEY(...) argument: a PROJECT/NAMESPACE setting (applied to key) or a path token. +func (p *gqlParser) keyArg(key *datastorepb.Key) (token, bool, error) { + if p.isKw("PROJECT") || p.isKw("NAMESPACE") { + which := strings.ToUpper(p.next().text) + + s, err := p.stringArg() + if err != nil { + return token{}, false, err + } + + if which == "PROJECT" { + key.PartitionId.ProjectId = s + } else { + key.PartitionId.NamespaceId = s + } + + return token{}, false, nil + } + + t := p.next() + if t.kind == tokBinding { + bt, err := p.bindingToken(t.text) + + return bt, true, err + } + + return t, true, nil +} + +func pathElement(kind, id token) (*datastorepb.Key_PathElement, error) { + el := &datastorepb.Key_PathElement{Kind: kind.text} + + if id.kind != tokNumber { + el.IdType = &datastorepb.Key_PathElement_Name{Name: id.text} + + return el, nil + } + + n, err := strconv.ParseInt(id.text, 10, 64) + if err != nil { + return nil, errInvalid("GQL: invalid key id %q.", id.text) + } + + el.IdType = &datastorepb.Key_PathElement_Id{Id: n} + + return el, nil +} + +func (p *gqlParser) bindingToken(name string) (token, error) { + bp, err := p.binding(name) + if err != nil { + return token{}, err + } + + if v, ok := bp.GetParameterType().(*datastorepb.GqlQueryParameter_Value); ok { + switch vt := v.Value.GetValueType().(type) { + case *datastorepb.Value_IntegerValue: + return token{kind: tokNumber, text: strconv.FormatInt(vt.IntegerValue, 10)}, nil + case *datastorepb.Value_StringValue: + return token{kind: tokString, text: vt.StringValue}, nil + } + } + + return token{}, errInvalid("GQL: binding %q cannot be used in a KEY.", name) +} diff --git a/services/gcpdatastore/gql_test.go b/services/gcpdatastore/gql_test.go new file mode 100644 index 0000000000..4827d9ccb7 --- /dev/null +++ b/services/gcpdatastore/gql_test.go @@ -0,0 +1,316 @@ +package gcpdatastore_test + +import ( + "testing" + + "cloud.google.com/go/datastore" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func param(v *datastorepb.Value) *datastorepb.GqlQueryParameter { + return &datastorepb.GqlQueryParameter{ParameterType: &datastorepb.GqlQueryParameter_Value{Value: v}} +} + +func (e *env) gql(t *testing.T, g *datastorepb.GqlQuery) ([]string, error) { + t.Helper() + + resp, err := e.raw.RunQuery(t.Context(), &datastorepb.RunQueryRequest{ + ProjectId: project, QueryType: &datastorepb.RunQueryRequest_GqlQuery{GqlQuery: g}, + }) + if err != nil { + return nil, err + } + + var out []string + for _, r := range resp.GetBatch().GetEntityResults() { + out = append(out, r.GetEntity().GetKey().GetPath()[len(r.GetEntity().GetKey().GetPath())-1].GetName()) + } + + return out, nil +} + +func TestGQL(t *testing.T) { + t.Parallel() + + tests := []struct { + gql *datastorepb.GqlQuery + name string + want []string + }{ + {&datastorepb.GqlQuery{QueryString: "SELECT * FROM Task"}, "select all", []string{"a", "b", "c", "d", "e"}}, + { + &datastorepb.GqlQuery{ + QueryString: "SELECT * FROM Task WHERE Done = @1 AND Priority > @2", + PositionalBindings: []*datastorepb.GqlQueryParameter{ + param(&datastorepb.Value{ValueType: &datastorepb.Value_BooleanValue{BooleanValue: true}}), + param(intVal(1)), + }, + }, + "positional bindings", []string{"c", "e"}, + }, + { + &datastorepb.GqlQuery{ + QueryString: "SELECT * FROM Task WHERE Priority >= @lo ORDER BY Priority DESC LIMIT @n", + NamedBindings: map[string]*datastorepb.GqlQueryParameter{"lo": param(intVal(2)), "n": param(intVal(2))}, + }, + "named bindings order limit", []string{"e", "d"}, + }, + { + &datastorepb.GqlQuery{ + QueryString: "SELECT * FROM Task WHERE Priority IN (1, 3) OR Tags = 'z'", + AllowLiterals: true, + }, + "literals in and or", + []string{"a", "c"}, + }, + { + &datastorepb.GqlQuery{ + QueryString: "SELECT * FROM Task WHERE Priority NOT IN ARRAY(1,2,3) ORDER BY Priority", + AllowLiterals: true, + }, + "not in", + []string{"d", "e"}, + }, + { + &datastorepb.GqlQuery{ + QueryString: "SELECT * FROM Task WHERE Tags CONTAINS 'x' ORDER BY Priority LIMIT 1, 5", + AllowLiterals: true, + }, + "contains and limit offset", + []string{"e"}, + }, + { + &datastorepb.GqlQuery{ + QueryString: "SELECT * FROM Task WHERE __key__ = KEY(Task, 'c')", + AllowLiterals: true, + }, + "key literal", + []string{"c"}, + }, + { + &datastorepb.GqlQuery{QueryString: "SELECT * FROM Task WHERE Priority > 3 OFFSET 1", AllowLiterals: true}, + "offset clause", []string{"e"}, + }, + { + &datastorepb.GqlQuery{ + QueryString: "SELECT __key__ FROM Task WHERE Done = true LIMIT 2", + AllowLiterals: true, + }, + "key only", + []string{"a", "c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + got, err := e.gql(t, tt.gql) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGQLErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + gql *datastorepb.GqlQuery + name string + }{ + {&datastorepb.GqlQuery{QueryString: "SELECT * FROM Task WHERE Done = true"}, "literals not allowed"}, + {&datastorepb.GqlQuery{QueryString: "SELEKT * FROM Task"}, "bad syntax"}, + {&datastorepb.GqlQuery{QueryString: "SELECT * FROM Task WHERE a = @missing"}, "unbound name"}, + {&datastorepb.GqlQuery{QueryString: "SELECT * FROM Task WHERE a = @1"}, "unbound position"}, + {&datastorepb.GqlQuery{QueryString: "SELECT COUNT(*) FROM Task"}, "aggregation via runquery"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + _, err := e.gql(t, tt.gql) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + } +} + +func TestAggregations(t *testing.T) { + t.Parallel() + + t.Run("client count sum avg", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + aq := datastore.NewQuery("Task"). + NewAggregationQuery(). + WithCount("n"). + WithSum("Priority", "total"). + WithAvg("Score", "mean") + + res, err := e.client.RunAggregationQuery(t.Context(), aq) + require.NoError(t, err) + + assert.EqualValues(t, 5, res["n"].(*datastorepb.Value).GetIntegerValue()) + assert.EqualValues(t, 15, res["total"].(*datastorepb.Value).GetIntegerValue()) + assert.InDelta(t, 3.5, res["mean"].(*datastorepb.Value).GetDoubleValue(), 1e-9) + }) + + t.Run("count with filter and limit", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + n, err := e.client.Count(t.Context(), datastore.NewQuery("Task").FilterField("Done", "=", true)) + require.NoError(t, err) + assert.Equal(t, 3, n) + + res, err := e.client.RunAggregationQuery( + t.Context(), + datastore.NewQuery("Task").Limit(2).NewAggregationQuery().WithCount("c"), + ) + require.NoError(t, err) + assert.EqualValues(t, 2, res["c"].(*datastorepb.Value).GetIntegerValue()) + }) + + t.Run("gql aggregations and default aliases", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + resp, err := e.raw.RunAggregationQuery(t.Context(), &datastorepb.RunAggregationQueryRequest{ + ProjectId: project, + QueryType: &datastorepb.RunAggregationQueryRequest_GqlQuery{GqlQuery: &datastorepb.GqlQuery{ + QueryString: "AGGREGATE COUNT_UP_TO(3) AS capped, COUNT(*), SUM(Score) " + + "OVER (SELECT * FROM Task WHERE Done = true)", + AllowLiterals: true, + }}, + }) + require.NoError(t, err) + + props := resp.GetBatch().GetAggregationResults()[0].GetAggregateProperties() + assert.EqualValues(t, 3, props["capped"].GetIntegerValue()) + assert.EqualValues(t, 3, props["property_1"].GetIntegerValue()) + assert.InDelta(t, 10.5, props["property_2"].GetDoubleValue(), 1e-9) + + short, err := e.raw.RunAggregationQuery(t.Context(), &datastorepb.RunAggregationQueryRequest{ + ProjectId: project, + QueryType: &datastorepb.RunAggregationQueryRequest_GqlQuery{GqlQuery: &datastorepb.GqlQuery{ + QueryString: "SELECT COUNT(*) AS n FROM Task", AllowLiterals: true, + }}, + }) + require.NoError(t, err) + assert.EqualValues( + t, + 5, + short.GetBatch().GetAggregationResults()[0].GetAggregateProperties()["n"].GetIntegerValue(), + ) + }) + + t.Run("sum of nothing and avg of nothing", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + res, err := e.client.RunAggregationQuery( + t.Context(), + datastore.NewQuery("Task"). + FilterField("Priority", ">", 100). + NewAggregationQuery(). + WithSum("Priority", "s"). + WithAvg("Priority", "a"), + ) + require.NoError(t, err) + assert.EqualValues(t, 0, res["s"].(*datastorepb.Value).GetIntegerValue()) + _, isNull := res["a"].(*datastorepb.Value).GetValueType().(*datastorepb.Value_NullValue) + assert.True(t, isNull) + }) + + t.Run("duplicate alias rejected", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + _, err := e.client.RunAggregationQuery( + t.Context(), + datastore.NewQuery("Task").NewAggregationQuery().WithCount("x").WithCount("x"), + ) + assert.Error(t, err) + }) +} + +func TestQueryValidation(t *testing.T) { + t.Parallel() + + prop := func(n string, op datastorepb.PropertyFilter_Operator, v *datastorepb.Value) *datastorepb.Filter { + return &datastorepb.Filter{ + FilterType: &datastorepb.Filter_PropertyFilter{PropertyFilter: &datastorepb.PropertyFilter{ + Property: &datastorepb.PropertyReference{Name: n}, Op: op, Value: v, + }}, + } + } + + tests := []struct { + query *datastorepb.Query + name string + }{ + {&datastorepb.Query{Kind: []*datastorepb.KindExpression{{Name: "A"}, {Name: "B"}}}, "two kinds"}, + {&datastorepb.Query{Kind: []*datastorepb.KindExpression{{Name: "A"}}, Offset: -1}, "negative offset"}, + {&datastorepb.Query{ + Kind: []*datastorepb.KindExpression{ + {Name: "A"}, + }, + Filter: prop("x", datastorepb.PropertyFilter_IN, intVal(1)), + }, "in needs array"}, + {&datastorepb.Query{ + Kind: []*datastorepb.KindExpression{{Name: "A"}}, + Filter: prop("x", datastorepb.PropertyFilter_GREATER_THAN, intVal(1)), + Order: []*datastorepb.PropertyOrder{{Property: &datastorepb.PropertyReference{Name: "y"}}}, + }, "inequality must sort first"}, + {&datastorepb.Query{ + Kind: []*datastorepb.KindExpression{{Name: "A"}}, + Filter: prop("x", datastorepb.PropertyFilter_HAS_ANCESTOR, intVal(1)), + }, "ancestor needs key"}, + { + &datastorepb.Query{Filter: prop("x", datastorepb.PropertyFilter_EQUAL, intVal(1))}, + "kindless property filter", + }, + {&datastorepb.Query{ + Kind: []*datastorepb.KindExpression{ + {Name: "A"}, + }, + Filter: &datastorepb.Filter{FilterType: &datastorepb.Filter_CompositeFilter{ + CompositeFilter: &datastorepb.CompositeFilter{ + Op: datastorepb.CompositeFilter_AND, + Filters: []*datastorepb.Filter{ + prop( + "x", + datastorepb.PropertyFilter_NOT_EQUAL, + intVal(1), + ), + prop("y", datastorepb.PropertyFilter_NOT_EQUAL, intVal(2)), + }, + }, + }}, + }, "two not equal"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + _, err := e.raw.RunQuery(t.Context(), &datastorepb.RunQueryRequest{ + ProjectId: project, QueryType: &datastorepb.RunQueryRequest_Query{Query: tt.query}, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + } +} diff --git a/services/gcpdatastore/grpc.go b/services/gcpdatastore/grpc.go new file mode 100644 index 0000000000..f6b46ba139 --- /dev/null +++ b/services/gcpdatastore/grpc.go @@ -0,0 +1,52 @@ +package gcpdatastore + +import ( + "context" + + "cloud.google.com/go/datastore/apiv1/datastorepb" +) + +type server struct { + datastorepb.UnimplementedDatastoreServer + b *InMemoryBackend +} + +func (s *server) Lookup(_ context.Context, in *datastorepb.LookupRequest) (*datastorepb.LookupResponse, error) { + return s.b.Lookup(in) +} + +func (s *server) RunQuery(_ context.Context, in *datastorepb.RunQueryRequest) (*datastorepb.RunQueryResponse, error) { + return s.b.RunQuery(in) +} + +func (s *server) RunAggregationQuery( + _ context.Context, in *datastorepb.RunAggregationQueryRequest, +) (*datastorepb.RunAggregationQueryResponse, error) { + return s.b.RunAggregationQuery(in) +} + +func (s *server) BeginTransaction( + _ context.Context, in *datastorepb.BeginTransactionRequest, +) (*datastorepb.BeginTransactionResponse, error) { + return s.b.BeginTransaction(in) +} + +func (s *server) Commit(_ context.Context, in *datastorepb.CommitRequest) (*datastorepb.CommitResponse, error) { + return s.b.Commit(in) +} + +func (s *server) Rollback(_ context.Context, in *datastorepb.RollbackRequest) (*datastorepb.RollbackResponse, error) { + return s.b.Rollback(in) +} + +func (s *server) AllocateIds( //nolint:revive // name fixed by the generated gRPC interface + _ context.Context, in *datastorepb.AllocateIdsRequest, +) (*datastorepb.AllocateIdsResponse, error) { + return s.b.AllocateIDs(in) +} + +func (s *server) ReserveIds( //nolint:revive // name fixed by the generated gRPC interface + _ context.Context, in *datastorepb.ReserveIdsRequest, +) (*datastorepb.ReserveIdsResponse, error) { + return s.b.ReserveIDs(in) +} diff --git a/services/gcpdatastore/handler.go b/services/gcpdatastore/handler.go new file mode 100644 index 0000000000..bc8c7ccf41 --- /dev/null +++ b/services/gcpdatastore/handler.go @@ -0,0 +1,183 @@ +package gcpdatastore + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/labstack/echo/v5" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const gracefulStopWait = 2 * time.Second + +// Handler serves the Datastore API on a dedicated gRPC and REST listener. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *grpc.Server + wg *sync.WaitGroup + addr string + Port int +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, + Port: DefaultPort, + srvMu: lockmetrics.New("gcpdatastore.server"), + wg: &sync.WaitGroup{}, + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "GCPDatastore" } + +// GetSupportedOperations lists the supported gRPC methods. +func (h *Handler) GetSupportedOperations() []string { + return []string{ + "Lookup", "RunQuery", "RunAggregationQuery", "BeginTransaction", "Commit", "Rollback", + "AllocateIds", "ReserveIds", + "CreateIndex", "DeleteIndex", "GetIndex", "ListIndexes", + "GetOperation", "ListOperations", "DeleteOperation", "CancelOperation", "WaitOperation", + } +} + +// RouteMatcher never matches: Datastore runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation is unused: requests never reach the shared router. +func (h *Handler) ExtractOperation(*echo.Context) string { return "Unknown" } + +// ExtractResource is unused: requests never reach the shared router. +func (h *Handler) ExtractResource(*echo.Context) string { return "" } + +// Addr returns the bound listener address, or "" before StartWorker. +func (h *Handler) Addr() string { + h.srvMu.RLock("Addr") + defer h.srvMu.RUnlock() + + return h.addr +} + +// Reset clears all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Handler returns a 404 handler; the real API is on the dedicated port. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + return c.String(http.StatusNotFound, "gcpdatastore serves gRPC and REST on its dedicated port") + } +} + +type debugLogger interface { + DebugContext(ctx context.Context, msg string, args ...any) +} + +func logUnary(log debugLogger) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { + resp, err := next(ctx, req) + if err != nil && status.Code(err) != codes.OK { + log.DebugContext(ctx, "gcpdatastore: rpc failed", "method", info.FullMethod, "error", err) + } + + return resp, err + } +} + +func (h *Handler) registerServers(srv *grpc.Server) { + datastorepb.RegisterDatastoreServer(srv, &server{b: h.Backend}) + adminpb.RegisterDatastoreAdminServer(srv, &adminServer{b: h.Backend}) + longrunningpb.RegisterOperationsServer(srv, h.Backend.ops) +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + var lc net.ListenConfig + + listener, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + if err != nil { + return fmt.Errorf("gcpdatastore: bind port %d: %w", h.Port, err) + } + + workerCtx := logger.WithWorker(ctx, "gcpdatastore", "listener") + log := logger.Load(workerCtx) + + srv := gcpenforce.NewServer(ctx, "datastore.googleapis.com", grpc.UnaryInterceptor(logUnary(log))) + h.registerServers(srv) + + h.srvMu.Lock("StartWorker") + h.srv, h.addr = srv, listener.Addr().String() + h.srvMu.Unlock() + + log.InfoContext(workerCtx, "gcpdatastore: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := gcprest.Serve(srv, listener); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) { + log.ErrorContext(workerCtx, "gcpdatastore: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener, forcing a close after a short grace period. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv := h.srv + h.srv = nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + done := make(chan struct{}) + + go func() { + srv.GracefulStop() + close(done) + }() + + grace := time.NewTimer(gracefulStopWait) + defer grace.Stop() + + select { + case <-done: + case <-grace.C: + srv.Stop() + <-done + case <-ctx.Done(): + srv.Stop() + <-done + } + + h.wg.Wait() +} diff --git a/services/gcpdatastore/helpers_test.go b/services/gcpdatastore/helpers_test.go new file mode 100644 index 0000000000..77ad081fc5 --- /dev/null +++ b/services/gcpdatastore/helpers_test.go @@ -0,0 +1,87 @@ +package gcpdatastore_test + +import ( + "context" + "testing" + + "cloud.google.com/go/datastore" + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "github.com/stretchr/testify/require" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/blackbirdworks/gopherstack/services/gcpdatastore" +) + +const project = "test-project" + +type env struct { + client *datastore.Client + raw datastorepb.DatastoreClient + admin adminpb.DatastoreAdminClient + handler *gcpdatastore.Handler + conn *grpc.ClientConn +} + +func newEnv(t *testing.T) *env { + t.Helper() + + h := gcpdatastore.NewHandler(gcpdatastore.NewInMemoryBackend()) + h.Port = 0 + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + conn, err := grpc.NewClient(h.Addr(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + client, err := datastore.NewClient(t.Context(), project, option.WithGRPCConn(conn)) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + return &env{ + client: client, raw: datastorepb.NewDatastoreClient(conn), admin: adminpb.NewDatastoreAdminClient(conn), + handler: h, conn: conn, + } +} + +type task struct { + Name string + Note string `datastore:",noindex"` + Tags []string + Priority int + Done bool + Score float64 +} + +func (e *env) seed(t *testing.T, kind string, tasks map[string]task) { + t.Helper() + + for name, tk := range tasks { + tk.Name = name + _, err := e.client.Put(t.Context(), datastore.NameKey(kind, name, nil), &tk) + require.NoError(t, err) + } +} + +func names(keys []*datastore.Key) []string { + out := make([]string, 0, len(keys)) + for _, k := range keys { + out = append(out, k.Name) + } + + return out +} + +func seedTasks() map[string]task { + return map[string]task{ + "a": {Priority: 1, Done: true, Score: 1.5, Tags: []string{"x", "y"}, Note: "first"}, + "b": {Priority: 2, Done: false, Score: 2.5, Tags: []string{"y"}}, + "c": {Priority: 3, Done: true, Score: 3.5, Tags: []string{"z"}}, + "d": {Priority: 4, Done: false, Score: 4.5}, + "e": {Priority: 5, Done: true, Score: 5.5, Tags: []string{"x"}}, + } +} diff --git a/services/gcpdatastore/index.go b/services/gcpdatastore/index.go new file mode 100644 index 0000000000..e7528fa032 --- /dev/null +++ b/services/gcpdatastore/index.go @@ -0,0 +1,141 @@ +package gcpdatastore + +import ( + "slices" + "strings" + + "cloud.google.com/go/datastore/admin/apiv1/adminpb" +) + +// indexSignature is the composite index a query needs. +type indexSignature struct { + eq []string + sorts []order + ancestor bool +} + +// signature collects the equality properties and the sort list a query's index must cover. +func (p *queryPlan) signature() (indexSignature, int) { + sig := indexSignature{ancestor: p.ancestor != nil} + + for n := range p.eqProps { + sig.eq = append(sig.eq, n) + } + + slices.Sort(sig.eq) + + for _, o := range p.explicit { + _, eq := p.eqProps[o.name] + if (eq && o.name != keyProperty) || (o.name == keyProperty && !o.desc) { + continue + } + + sig.sorts = append(sig.sorts, o) + } + + explicit := len(sig.sorts) + + if explicit == 0 { + for _, n := range p.ineq { + sig.sorts = append(sig.sorts, order{name: n}) + } + } + + return sig, explicit +} + +// compositeNeeded applies the documented rules for which queries built-in indexes cannot serve. +func (p *queryPlan) compositeNeeded() (indexSignature, bool) { + sig, explicit := p.signature() + + hasEq := len(sig.eq) > 0 + hasIneq := len(p.ineq) > 0 + onIneq := explicit == 1 && len(p.ineq) == 1 && sig.sorts[0].name == p.ineq[0] && !sig.sorts[0].desc + descKey := slices.ContainsFunc(sig.sorts, func(o order) bool { return o.name == keyProperty && o.desc }) + + switch { + case p.ancestor != nil && hasIneq, hasIneq && hasEq, descKey, explicit > 1: + return sig, true + case explicit == 1 && !onIneq: + return sig, hasEq || hasIneq || p.ancestor != nil + default: + return sig, false + } +} + +func (b *InMemoryBackend) checkIndex(p *queryPlan) error { + if !b.enforceIndexes || isReserved(p.kind) { + return nil + } + + sig, need := p.compositeNeeded() + if !need { + return nil + } + + for _, idx := range b.indexes { + if idx.GetProjectId() == p.part.project && idx.GetKind() == p.kind && indexServes(idx, sig) { + return nil + } + } + + return errPrecondition("no matching index found. recommended index is:\n%s", recommendedIndex(p.kind, sig)) +} + +func indexServes(idx *adminpb.Index, sig indexSignature) bool { + if sig.ancestor && idx.GetAncestor() != adminpb.Index_ALL_ANCESTORS { + return false + } + + var rest []order + + eqSeen := map[string]struct{}{} + + for _, ip := range idx.GetProperties() { + if slices.Contains(sig.eq, ip.GetName()) { + eqSeen[ip.GetName()] = struct{}{} + + continue + } + + rest = append(rest, order{name: ip.GetName(), desc: ip.GetDirection() == adminpb.Index_DESCENDING}) + } + + if len(eqSeen) != len(sig.eq) || len(rest) != len(sig.sorts) { + return false + } + + for i, o := range rest { + if o != sig.sorts[i] { + return false + } + } + + return true +} + +func recommendedIndex(kind string, sig indexSignature) string { + var sb strings.Builder + + sb.WriteString("- kind: " + kind + "\n") + + if sig.ancestor { + sb.WriteString(" ancestor: yes\n") + } + + sb.WriteString(" properties:\n") + + for _, n := range sig.eq { + sb.WriteString(" - name: " + n + "\n") + } + + for _, o := range sig.sorts { + sb.WriteString(" - name: " + o.name + "\n") + + if o.desc { + sb.WriteString(" direction: desc\n") + } + } + + return sb.String() +} diff --git a/services/gcpdatastore/index_test.go b/services/gcpdatastore/index_test.go new file mode 100644 index 0000000000..1f96c1322f --- /dev/null +++ b/services/gcpdatastore/index_test.go @@ -0,0 +1,256 @@ +package gcpdatastore_test + +import ( + "testing" + + "cloud.google.com/go/datastore" + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func prop(name string, desc bool) *adminpb.Index_IndexedProperty { + d := adminpb.Index_ASCENDING + if desc { + d = adminpb.Index_DESCENDING + } + + return &adminpb.Index_IndexedProperty{Name: name, Direction: d} +} + +func (e *env) createIndex( + t *testing.T, + kind string, + anc adminpb.Index_AncestorMode, + props ...*adminpb.Index_IndexedProperty, +) *adminpb.Index { + t.Helper() + + op, err := e.admin.CreateIndex(t.Context(), &adminpb.CreateIndexRequest{ + ProjectId: project, Index: &adminpb.Index{Kind: kind, Ancestor: anc, Properties: props}, + }) + require.NoError(t, err) + require.True(t, op.GetDone()) + + idx := &adminpb.Index{} + require.NoError(t, op.GetResponse().UnmarshalTo(idx)) + + return idx +} + +func TestCompositeIndexEnforcement(t *testing.T) { + t.Parallel() + + parent := datastore.NameKey("List", "l", nil) + + tests := []struct { + query *datastore.Query + name string + index []*adminpb.Index_IndexedProperty + anc adminpb.Index_AncestorMode + needed bool + }{ + {datastore.NewQuery("Task").Order("Priority"), "single order", nil, 0, false}, + {datastore.NewQuery("Task").Order("-Priority"), "single desc order", nil, 0, false}, + { + datastore.NewQuery("Task").FilterField("Done", "=", true).FilterField("Priority", "=", 1), + "multiple equality", + nil, + 0, + false, + }, + { + datastore.NewQuery("Task").FilterField("Priority", ">", 1).Order("Priority"), + "inequality sorted by itself", + nil, + 0, + false, + }, + { + datastore.NewQuery("Task").Ancestor(parent).FilterField("Done", "=", true), + "ancestor and equality", + nil, + 0, + false, + }, + { + datastore.NewQuery("Task").FilterField("Done", "=", true).Order("Priority"), + "equality and sort", + []*adminpb.Index_IndexedProperty{prop("Done", false), prop("Priority", false)}, adminpb.Index_NONE, true, + }, + { + datastore.NewQuery("Task").Order("Done").Order("-Priority"), + "two sorts", + []*adminpb.Index_IndexedProperty{prop("Done", false), prop("Priority", true)}, adminpb.Index_NONE, true, + }, + { + datastore.NewQuery("Task").FilterField("Done", "=", true).FilterField("Priority", ">", 1), + "equality and inequality", + []*adminpb.Index_IndexedProperty{prop("Done", false), prop("Priority", false)}, adminpb.Index_NONE, true, + }, + { + datastore.NewQuery("Task").Ancestor(parent).FilterField("Priority", ">", 1), + "ancestor and inequality", + []*adminpb.Index_IndexedProperty{prop("Priority", false)}, adminpb.Index_ALL_ANCESTORS, true, + }, + { + datastore.NewQuery("Task").Order("-__key__"), + "descending key", + []*adminpb.Index_IndexedProperty{prop("__key__", true)}, adminpb.Index_NONE, true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.client.GetAll(t.Context(), tt.query.KeysOnly(), nil) + + if !tt.needed { + assert.NoError(t, err) + + return + } + + require.Equal(t, codes.FailedPrecondition, status.Code(err), "%v", err) + assert.Contains(t, err.Error(), "no matching index found") + assert.Contains(t, err.Error(), "kind: Task") + + idx := e.createIndex(t, "Task", tt.anc, tt.index...) + assert.Equal(t, adminpb.Index_READY, idx.GetState()) + + _, err = e.client.GetAll(t.Context(), tt.query.KeysOnly(), nil) + require.NoError(t, err) + + _, err = e.admin.DeleteIndex( + t.Context(), + &adminpb.DeleteIndexRequest{ProjectId: project, IndexId: idx.GetIndexId()}, + ) + require.NoError(t, err) + + _, err = e.client.GetAll(t.Context(), tt.query.KeysOnly(), nil) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + }) + } +} + +func TestIndexAdmin(t *testing.T) { + t.Parallel() + + t.Run("create get list delete", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + first := e.createIndex(t, "A", adminpb.Index_NONE, prop("x", false), prop("y", true)) + second := e.createIndex(t, "B", adminpb.Index_ALL_ANCESTORS, prop("z", false)) + assert.NotEqual(t, first.GetIndexId(), second.GetIndexId()) + + got, err := e.admin.GetIndex( + t.Context(), + &adminpb.GetIndexRequest{ProjectId: project, IndexId: first.GetIndexId()}, + ) + require.NoError(t, err) + assert.Equal(t, "A", got.GetKind()) + assert.Equal(t, adminpb.Index_DESCENDING, got.GetProperties()[1].GetDirection()) + + page1, err := e.admin.ListIndexes(t.Context(), &adminpb.ListIndexesRequest{ProjectId: project, PageSize: 1}) + require.NoError(t, err) + require.Len(t, page1.GetIndexes(), 1) + require.NotEmpty(t, page1.GetNextPageToken()) + assert.Equal(t, first.GetIndexId(), page1.GetIndexes()[0].GetIndexId()) + + page2, err := e.admin.ListIndexes( + t.Context(), + &adminpb.ListIndexesRequest{ProjectId: project, PageSize: 1, PageToken: page1.GetNextPageToken()}, + ) + require.NoError(t, err) + require.Len(t, page2.GetIndexes(), 1) + assert.Empty(t, page2.GetNextPageToken()) + + op, err := e.admin.DeleteIndex( + t.Context(), + &adminpb.DeleteIndexRequest{ProjectId: project, IndexId: first.GetIndexId()}, + ) + require.NoError(t, err) + assert.True(t, op.GetDone()) + + _, err = e.admin.GetIndex( + t.Context(), + &adminpb.GetIndexRequest{ProjectId: project, IndexId: first.GetIndexId()}, + ) + assert.Equal(t, codes.NotFound, status.Code(err)) + + ops := longrunningpb.NewOperationsClient(e.conn) + got2, err := ops.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: op.GetName()}) + require.NoError(t, err) + assert.True(t, got2.GetDone()) + }) + + t.Run("invalid and duplicate", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createIndex(t, "A", adminpb.Index_NONE, prop("x", false), prop("y", false)) + + tests := []struct { + idx *adminpb.Index + name string + code codes.Code + }{ + { + &adminpb.Index{ + Kind: "A", + Ancestor: adminpb.Index_NONE, + Properties: []*adminpb.Index_IndexedProperty{prop("x", false), prop("y", false)}, + }, + "duplicate", + codes.AlreadyExists, + }, + { + &adminpb.Index{ + Ancestor: adminpb.Index_NONE, + Properties: []*adminpb.Index_IndexedProperty{prop("x", false)}, + }, + "no kind", + codes.InvalidArgument, + }, + { + &adminpb.Index{Kind: "A", Properties: []*adminpb.Index_IndexedProperty{prop("x", false)}}, + "no ancestor mode", + codes.InvalidArgument, + }, + {&adminpb.Index{Kind: "A", Ancestor: adminpb.Index_NONE}, "no properties", codes.InvalidArgument}, + { + &adminpb.Index{ + Kind: "A", + Ancestor: adminpb.Index_NONE, + Properties: []*adminpb.Index_IndexedProperty{{Name: "x"}}, + }, + "no direction", + codes.InvalidArgument, + }, + } + + for _, tt := range tests { + _, err := e.admin.CreateIndex(t.Context(), &adminpb.CreateIndexRequest{ProjectId: project, Index: tt.idx}) + assert.Equal(t, tt.code, status.Code(err), tt.name) + } + + _, err := e.admin.DeleteIndex(t.Context(), &adminpb.DeleteIndexRequest{ProjectId: project, IndexId: "999"}) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + + t.Run("enforcement can be switched off", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.handler.Backend.SetEnforceIndexes(false) + + _, err := e.client.GetAll(t.Context(), datastore.NewQuery("Task").Order("A").Order("B").KeysOnly(), nil) + assert.NoError(t, err) + }) +} diff --git a/services/gcpdatastore/keys.go b/services/gcpdatastore/keys.go new file mode 100644 index 0000000000..655441ad8c --- /dev/null +++ b/services/gcpdatastore/keys.go @@ -0,0 +1,355 @@ +package gcpdatastore + +import ( + "math" + "strconv" + "strings" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" +) + +const ( + maxKeyPath = 100 + maxNameBytes = 1500 + maxKeysLookup = 1000 + defaultDBName = "(default)" + pathSep = 0x1e + kindSep = 0x1f +) + +// partition is the (project, database, namespace) a key lives in. +type partition struct { + project, database, namespace string +} + +func (p partition) String() string { + return p.project + "\x00" + p.database + "\x00" + p.namespace +} + +func requestPartition(project, database string) (partition, error) { + if project == "" { + return partition{}, errInvalid("Missing required project ID.") + } + + if database == defaultDBName { + return partition{}, errInvalid( + "database_id '(default)' is not allowed; use the empty string for the default database.", + ) + } + + return partition{project: project, database: database}, nil +} + +// queryPartition applies a request's partition_id (namespace, and optional matching project/database). +func queryPartition(req partition, id *datastorepb.PartitionId) (partition, error) { + if id == nil { + return req, nil + } + + if id.GetProjectId() != "" && id.GetProjectId() != req.project { + return partition{}, errInvalid("The project ID in partition_id (%q) does not match the request project (%q).", + id.GetProjectId(), req.project) + } + + if id.GetDatabaseId() != "" && id.GetDatabaseId() != req.database { + return partition{}, errInvalid("The database ID in partition_id (%q) does not match the request database (%q).", + id.GetDatabaseId(), req.database) + } + + if err := validateNamespace(id.GetNamespaceId()); err != nil { + return partition{}, err + } + + req.namespace = id.GetNamespaceId() + + return req, nil +} + +func validateNamespace(ns string) error { + if len(ns) > maxNameBytes { + return errInvalid("Namespace is longer than %d bytes.", maxNameBytes) + } + + if isReserved(ns) { + return errInvalid("Namespace %q is reserved.", ns) + } + + return nil +} + +func isReserved(s string) bool { + return len(s) >= 4 && strings.HasPrefix(s, "__") && strings.HasSuffix(s, "__") +} + +func lastElem(k *datastorepb.Key) *datastorepb.Key_PathElement { + p := k.GetPath() + if len(p) == 0 { + return nil + } + + return p[len(p)-1] +} + +func elemHasID(e *datastorepb.Key_PathElement) bool { + switch e.GetIdType().(type) { + case *datastorepb.Key_PathElement_Id: + return e.GetId() != 0 + case *datastorepb.Key_PathElement_Name: + return true + default: + return false + } +} + +func isComplete(k *datastorepb.Key) bool { + e := lastElem(k) + + return e != nil && elemHasID(e) +} + +func keyKind(k *datastorepb.Key) string { return lastElem(k).GetKind() } + +// normKey validates k against the request partition and returns a copy stamped with the partition it lives in. +func normKey(k *datastorepb.Key, req partition, allowIncomplete, forWrite bool) (*datastorepb.Key, partition, error) { + if k == nil || len(k.GetPath()) == 0 { + return nil, partition{}, errInvalid("Key path is empty.") + } + + p, err := queryPartition(req, k.GetPartitionId()) + if err != nil { + return nil, partition{}, err + } + + if len(k.GetPath()) > maxKeyPath { + return nil, partition{}, errInvalid("Key path is longer than %d elements.", maxKeyPath) + } + + out := &datastorepb.Key{PartitionId: p.stored(), Path: make([]*datastorepb.Key_PathElement, 0, len(k.GetPath()))} + + for i, e := range k.GetPath() { + if err = validateElem(e, forWrite, i == len(k.GetPath())-1, allowIncomplete); err != nil { + return nil, partition{}, err + } + + el, _ := proto.Clone(e).(*datastorepb.Key_PathElement) + out.Path = append(out.Path, el) + } + + return out, p, nil +} + +func (p partition) stored() *datastorepb.PartitionId { + return &datastorepb.PartitionId{ProjectId: p.project, DatabaseId: p.database, NamespaceId: p.namespace} +} + +func validateElem(e *datastorepb.Key_PathElement, forWrite, last, allowIncomplete bool) error { + if e.GetKind() == "" { + return errInvalid("The key path element kind must not be empty.") + } + + if len(e.GetKind()) > maxNameBytes { + return errInvalid("The key path element kind is longer than %d bytes.", maxNameBytes) + } + + if forWrite && isReserved(e.GetKind()) { + return errInvalid("The key path element kind %q is reserved/read-only.", e.GetKind()) + } + + if err := validateElemID(e, forWrite); err != nil { + return err + } + + if elemHasID(e) { + return nil + } + + if !last { + return errInvalid("Only the last element of a key path may be incomplete.") + } + + if !allowIncomplete { + return errInvalid("The key path element must have an id or name: %s.", e.GetKind()) + } + + return nil +} + +func validateElemID(e *datastorepb.Key_PathElement, forWrite bool) error { + switch id := e.GetIdType().(type) { + case *datastorepb.Key_PathElement_Id: + if id.Id < 0 { + return errInvalid("The key path element id must be positive, got %d.", id.Id) + } + case *datastorepb.Key_PathElement_Name: + if id.Name == "" { + return errInvalid("The key path element name must not be empty.") + } + + if len(id.Name) > maxNameBytes { + return errInvalid("The key path element name is longer than %d bytes.", maxNameBytes) + } + + if forWrite && isReserved(id.Name) { + return errInvalid("The key path element name %q is reserved/read-only.", id.Name) + } + } + + return nil +} + +// keyString is the canonical map key of a complete key within its partition. +func keyString(k *datastorepb.Key) string { + var sb strings.Builder + + pid := k.GetPartitionId() + sb.WriteString(pid.GetProjectId()) + sb.WriteByte(0) + sb.WriteString(pid.GetDatabaseId()) + sb.WriteByte(0) + sb.WriteString(pid.GetNamespaceId()) + + for _, e := range k.GetPath() { + sb.WriteByte(pathSep) + sb.WriteString(e.GetKind()) + sb.WriteByte(kindSep) + + if n, ok := e.GetIdType().(*datastorepb.Key_PathElement_Name); ok { + sb.WriteByte('n') + sb.WriteString(n.Name) + } else { + sb.WriteByte('i') + sb.WriteString(strconv.FormatInt(e.GetId(), 10)) + } + } + + return sb.String() +} + +func keyPartition(k *datastorepb.Key) partition { + pid := k.GetPartitionId() + + return partition{project: pid.GetProjectId(), database: pid.GetDatabaseId(), namespace: pid.GetNamespaceId()} +} + +func compareElems(a, b *datastorepb.Key_PathElement) int { + if c := strings.Compare(a.GetKind(), b.GetKind()); c != 0 { + return c + } + + an, aName := a.GetIdType().(*datastorepb.Key_PathElement_Name) + bn, bName := b.GetIdType().(*datastorepb.Key_PathElement_Name) + + switch { + case aName && bName: + return strings.Compare(an.Name, bn.Name) + case aName: + return 1 + case bName: + return -1 + case a.GetId() < b.GetId(): + return -1 + case a.GetId() > b.GetId(): + return 1 + default: + return 0 + } +} + +// compareKeys orders keys by partition, then path element by element (numeric ids before names). +func compareKeys(a, b *datastorepb.Key) int { + if c := strings.Compare(keyPartition(a).String(), keyPartition(b).String()); c != 0 { + return c + } + + pa, pb := a.GetPath(), b.GetPath() + for i := 0; i < len(pa) && i < len(pb); i++ { + if c := compareElems(pa[i], pb[i]); c != 0 { + return c + } + } + + switch { + case len(pa) < len(pb): + return -1 + case len(pa) > len(pb): + return 1 + default: + return 0 + } +} + +// isDescendantOrSelf reports whether key's path starts with ancestor's path in the same partition. +func isDescendantOrSelf(key, ancestor *datastorepb.Key) bool { + if keyPartition(key) != keyPartition(ancestor) || len(key.GetPath()) < len(ancestor.GetPath()) { + return false + } + + for i, e := range ancestor.GetPath() { + if compareElems(key.GetPath()[i], e) != 0 { + return false + } + } + + return true +} + +func keyDescription(k *datastorepb.Key) string { + parts := make([]string, 0, len(k.GetPath())) + + for _, e := range k.GetPath() { + if n, ok := e.GetIdType().(*datastorepb.Key_PathElement_Name); ok { + parts = append(parts, e.GetKind()+", "+strconv.Quote(n.Name)) + } else { + parts = append(parts, e.GetKind()+", "+strconv.FormatInt(e.GetId(), 10)) + } + } + + return "Key(" + strings.Join(parts, ", ") + ")" +} + +func clampInt32(n int) int32 { + return int32(min(n, math.MaxInt32)) //nolint:gosec // clamped to the int32 range +} + +// stampKey returns a copy of k whose missing project and database come from p; a missing namespace comes +// from p only when nsDefault is set (query filters), because stored keys carry their own namespace. +func stampKey(k *datastorepb.Key, p partition, nsDefault bool) *datastorepb.Key { + out, _ := proto.Clone(k).(*datastorepb.Key) + + pid := out.GetPartitionId() + if pid == nil { + pid = &datastorepb.PartitionId{} + out.PartitionId = pid + } + + if pid.GetProjectId() == "" { + pid.ProjectId = p.project + } + + if pid.GetDatabaseId() == "" { + pid.DatabaseId = p.database + } + + if nsDefault && pid.GetNamespaceId() == "" { + pid.NamespaceId = p.namespace + } + + return out +} + +// stampValueKeys rewrites every key value inside v in place. +func stampValueKeys(v *datastorepb.Value, fn func(*datastorepb.Key) *datastorepb.Key) { + switch t := v.GetValueType().(type) { + case *datastorepb.Value_KeyValue: + t.KeyValue = fn(t.KeyValue) + case *datastorepb.Value_ArrayValue: + for _, el := range t.ArrayValue.GetValues() { + stampValueKeys(el, fn) + } + case *datastorepb.Value_EntityValue: + for _, pv := range t.EntityValue.GetProperties() { + stampValueKeys(pv, fn) + } + } +} diff --git a/services/gcpdatastore/leak_main_test.go b/services/gcpdatastore/leak_main_test.go new file mode 100644 index 0000000000..48735dd805 --- /dev/null +++ b/services/gcpdatastore/leak_main_test.go @@ -0,0 +1,11 @@ +package gcpdatastore_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + testleak.VerifyTestMain(m) +} diff --git a/services/gcpdatastore/ops.go b/services/gcpdatastore/ops.go new file mode 100644 index 0000000000..9da09a2627 --- /dev/null +++ b/services/gcpdatastore/ops.go @@ -0,0 +1,243 @@ +package gcpdatastore + +import ( + "strings" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (b *InMemoryBackend) resolve(project, database string) (partition, error) { + return requestPartition(project, database) +} + +// Lookup returns the entities for the requested keys, split into found and missing. +func (b *InMemoryBackend) Lookup(in *datastorepb.LookupRequest) (*datastorepb.LookupResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + if len(in.GetKeys()) == 0 { + return nil, errInvalid("Lookup requires at least one key.") + } + + if len(in.GetKeys()) > maxKeysLookup { + return nil, errInvalid("Cannot get more than %d keys in a single call.", maxKeysLookup) + } + + keys := make([]*datastorepb.Key, len(in.GetKeys())) + + for i, k := range in.GetKeys() { + if keys[i], _, err = normKey(k, req, false, false); err != nil { + return nil, err + } + } + + resp := &datastorepb.LookupResponse{} + + err = b.read(in.GetReadOptions(), req, func(rv *readView) error { + resp.Transaction = rv.newTxnID + resp.ReadTime = timestamppb.New(rv.at) + + for _, k := range keys { + ks := keyString(k) + if rv.txn != nil && !rv.txn.readOnly { + rv.txn.reads[ks] = struct{}{} + } + + ver := b.entityAt(ks, rv) + if ver == nil { + resp.Missing = append(resp.Missing, &datastorepb.EntityResult{ + Entity: &datastorepb.Entity{Key: k}, + Version: rv.at.UnixMicro(), + }) + + continue + } + + resp.Found = append(resp.Found, entityResult(k, ver, in.GetPropertyMask())) + } + + return nil + }) + if err != nil { + return nil, err + } + + return resp, nil +} + +func entityResult(k *datastorepb.Key, ver *entVer, mask *datastorepb.PropertyMask) *datastorepb.EntityResult { + return &datastorepb.EntityResult{ + Entity: maskEntity(k, ver.ent, mask), + Version: ver.version, + CreateTime: timestamppb.New(ver.created), + UpdateTime: timestamppb.New(ver.at), + } +} + +func maskEntity(k *datastorepb.Key, e *datastorepb.Entity, mask *datastorepb.PropertyMask) *datastorepb.Entity { + out, _ := proto.Clone(e).(*datastorepb.Entity) + out.Key = k + + if mask == nil { + return out + } + + keep := &datastorepb.Entity{Key: k, Properties: map[string]*datastorepb.Value{}} + + for _, p := range mask.GetPaths() { + copyPath(keep, out, strings.Split(p, ".")) + } + + return keep +} + +func copyPath(dst, src *datastorepb.Entity, path []string) { + v, ok := src.GetProperties()[path[0]] + if !ok { + return + } + + if len(path) == 1 { + dst.Properties[path[0]] = v + + return + } + + sub, ok := v.GetValueType().(*datastorepb.Value_EntityValue) + if !ok { + return + } + + cur, exists := dst.GetProperties()[path[0]] + if !exists { + cur = &datastorepb.Value{ + ValueType: &datastorepb.Value_EntityValue{EntityValue: &datastorepb.Entity{ + Key: sub.EntityValue.GetKey(), Properties: map[string]*datastorepb.Value{}, + }}, + ExcludeFromIndexes: v.GetExcludeFromIndexes(), + } + dst.Properties[path[0]] = cur + } + + copyPath(cur.GetEntityValue(), sub.EntityValue, path[1:]) +} + +// BeginTransaction starts a read-write or read-only transaction. +func (b *InMemoryBackend) BeginTransaction( + in *datastorepb.BeginTransactionRequest, +) (*datastorepb.BeginTransactionResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + b.mu.Lock("BeginTransaction") + defer b.mu.Unlock() + + b.sweepLocked(b.wallLocked()) + + id, _, err := b.beginLocked(in.GetTransactionOptions(), req) + if err != nil { + return nil, err + } + + return &datastorepb.BeginTransactionResponse{Transaction: []byte(id)}, nil +} + +// Rollback discards a transaction. +func (b *InMemoryBackend) Rollback(in *datastorepb.RollbackRequest) (*datastorepb.RollbackResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + b.mu.Lock("Rollback") + defer b.mu.Unlock() + + if _, done := b.ended[string(in.GetTransaction())]; done { + return &datastorepb.RollbackResponse{}, nil + } + + if _, err = b.txnLocked(in.GetTransaction(), req); err != nil { + return nil, err + } + + b.endTxnLocked(string(in.GetTransaction())) + + return &datastorepb.RollbackResponse{}, nil +} + +// AllocateIDs fills in the ids of incomplete keys. +func (b *InMemoryBackend) AllocateIDs(in *datastorepb.AllocateIdsRequest) (*datastorepb.AllocateIdsResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + if len(in.GetKeys()) == 0 { + return nil, errInvalid("AllocateIds requires at least one key.") + } + + norm := make([]*datastorepb.Key, len(in.GetKeys())) + parts := make([]partition, len(in.GetKeys())) + + for i, k := range in.GetKeys() { + if norm[i], parts[i], err = normKey(k, req, true, true); err != nil { + return nil, err + } + + if isComplete(norm[i]) { + return nil, errInvalid("Only incomplete keys may be passed to AllocateIds: %s.", keyDescription(norm[i])) + } + } + + b.mu.Lock("AllocateIds") + defer b.mu.Unlock() + + for i, k := range norm { + id, aerr := b.allocIDLocked(parts[i]) + if aerr != nil { + return nil, aerr + } + + lastElem(k).IdType = &datastorepb.Key_PathElement_Id{Id: id} + } + + return &datastorepb.AllocateIdsResponse{Keys: norm}, nil +} + +// ReserveIDs prevents the numeric ids of the given keys from being auto-allocated. +func (b *InMemoryBackend) ReserveIDs(in *datastorepb.ReserveIdsRequest) (*datastorepb.ReserveIdsResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + if len(in.GetKeys()) == 0 { + return nil, errInvalid("ReserveIds requires at least one key.") + } + + norm := make([]*datastorepb.Key, len(in.GetKeys())) + parts := make([]partition, len(in.GetKeys())) + + for i, k := range in.GetKeys() { + if norm[i], parts[i], err = normKey(k, req, false, true); err != nil { + return nil, err + } + } + + b.mu.Lock("ReserveIds") + defer b.mu.Unlock() + + for i, k := range norm { + if _, isID := lastElem(k).GetIdType().(*datastorepb.Key_PathElement_Id); isID { + b.reserveIDLocked(parts[i], lastElem(k).GetId()) + } + } + + return &datastorepb.ReserveIdsResponse{}, nil +} diff --git a/services/gcpdatastore/persist_test.go b/services/gcpdatastore/persist_test.go new file mode 100644 index 0000000000..e2a1b2dfea --- /dev/null +++ b/services/gcpdatastore/persist_test.go @@ -0,0 +1,122 @@ +package gcpdatastore_test + +import ( + "net/http" + "testing" + + "cloud.google.com/go/datastore" + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/gcprest/resttest" + "github.com/blackbirdworks/gopherstack/services/gcpdatastore" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + src := newLooseEnv(t) + idx := src.createIndex(t, "Task", adminpb.Index_NONE, prop("Done", false), prop("Priority", false)) + + _, err := src.client.Put( + t.Context(), + datastore.NameKey("P", "n", datastore.NameKey("Parent", "p", nil)), + &task{Name: "nested", Tags: []string{"q"}}, + ) + require.NoError(t, err) + + data := src.handler.Snapshot(t.Context()) + require.NotEmpty(t, data) + + dst := newEnv(t) + require.NoError(t, dst.handler.Restore(t.Context(), data)) + + keys := dst.keys(t, datastore.NewQuery("Task"), false) + assert.Equal(t, []string{"a", "b", "c", "d", "e"}, names(keys)) + + var got task + require.NoError( + t, + dst.client.Get(t.Context(), datastore.NameKey("P", "n", datastore.NameKey("Parent", "p", nil)), &got), + ) + assert.Equal(t, []string{"q"}, got.Tags) + + _, err = dst.admin.GetIndex(t.Context(), &adminpb.GetIndexRequest{ProjectId: project, IndexId: idx.GetIndexId()}) + require.NoError(t, err) + + dst.handler.Reset() + + keys = dst.keys(t, datastore.NewQuery("Task"), false) + assert.Empty(t, keys) +} + +func TestRestoreRejectsGarbage(t *testing.T) { + t.Parallel() + + b := gcpdatastore.NewInMemoryBackend() + assert.Error(t, b.Restore(t.Context(), []byte("not json"))) +} + +func TestREST(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + resttest.Run(t, e.handler.Addr(), []resttest.Step{ + { + Name: "commit", Method: http.MethodPost, Path: "/v1/projects/p:commit", + Body: `{"mode":"NON_TRANSACTIONAL","mutations":[{"upsert":{"key":{"path":[{"kind":"Task","name":"r1"}]},` + + `"properties":{"n":{"integerValue":"5"}}}}]}`, + Status: 200, Contains: []string{`"mutationResults"`, `"commitTime"`}, + }, + { + Name: "lookup", Method: http.MethodPost, Path: "/v1/projects/p:lookup", + Body: `{"keys":[{"path":[{"kind":"Task","name":"r1"}]},{"path":[{"kind":"Task","name":"nope"}]}]}`, + Status: 200, Contains: []string{`"found"`, `"missing"`, `"integerValue":"5"`}, + }, + { + Name: "run query", + Method: http.MethodPost, + Path: "/v1/projects/p:runQuery", + Body: `{"query":{"kind":[{"name":"Task"}],"filter":{"propertyFilter":` + + `{"property":{"name":"n"},"op":"GREATER_THAN","value":{"integerValue":"1"}}}}}`, + Status: 200, + Contains: []string{`"entityResults"`, `"NO_MORE_RESULTS"`}, + }, + { + Name: "gql query", + Method: http.MethodPost, + Path: "/v1/projects/p:runQuery", + Body: `{"gqlQuery":{"queryString":"SELECT * FROM Task WHERE n = @v",` + + `"namedBindings":{"v":{"value":{"integerValue":"5"}}}}}`, + Status: 200, + Contains: []string{`"r1"`}, + }, + { + Name: "aggregation", + Method: http.MethodPost, + Path: "/v1/projects/p:runAggregationQuery", + Body: `{"aggregationQuery":{"nestedQuery":{"kind":[{"name":"Task"}]},` + + `"aggregations":[{"count":{},"alias":"c"}]}}`, + Status: 200, + Contains: []string{`"c"`, `"integerValue":"1"`}, + }, + { + Name: "begin transaction", Method: http.MethodPost, Path: "/v1/projects/p:beginTransaction", + Body: `{}`, Status: 200, Contains: []string{`"transaction"`}, + }, + { + Name: "allocate ids", Method: http.MethodPost, Path: "/v1/projects/p:allocateIds", + Body: `{"keys":[{"path":[{"kind":"Task"}]}]}`, Status: 200, Contains: []string{`"id"`}, + }, + { + Name: "bad request maps to 400", Method: http.MethodPost, Path: "/v1/projects/p:lookup", + Body: `{"keys":[]}`, Status: 400, Contains: []string{`"INVALID_ARGUMENT"`}, + }, + { + Name: "list indexes", Method: http.MethodGet, Path: "/v1/projects/p/indexes", + Status: 200, + }, + }) +} diff --git a/services/gcpdatastore/persistence.go b/services/gcpdatastore/persistence.go new file mode 100644 index 0000000000..093551b1af --- /dev/null +++ b/services/gcpdatastore/persistence.go @@ -0,0 +1,163 @@ +package gcpdatastore + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the snapshot types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type entitySnap struct { + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Entity json.RawMessage `json:"entity"` + Version int64 `json:"version"` +} + +type backendSnapshot struct { + LastCommit time.Time `json:"lastCommit"` + Entities map[string]entitySnap `json:"entities"` + UsedIDs map[string][]int64 `json:"usedIds"` + Indexes map[string]json.RawMessage `json:"indexes"` + Operations map[string]json.RawMessage `json:"operations"` + NextIndex int64 `json:"nextIndex"` + Version int `json:"version"` +} + +// Snapshot serialises the current state of every entity, reserved ids, indexes and operations. +// Version history and open transactions are not persisted. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + snap := backendSnapshot{ + Version: snapshotVersion, + LastCommit: b.lastCommit, + NextIndex: b.nextIndex, + Entities: make(map[string]entitySnap, len(b.ents)), + UsedIDs: make(map[string][]int64, len(b.used)), + Indexes: make(map[string]json.RawMessage, len(b.indexes)), + Operations: b.ops.Snapshot(), + } + + for ks, rec := range b.ents { + ver := rec.cur() + if ver == nil { + continue + } + + e := ver.ent + + raw, err := protojson.Marshal(e) + if err != nil { + logger.Load(ctx).WarnContext(ctx, "gcpdatastore: skipping unmarshalable entity", "error", err) + + continue + } + + snap.Entities[ks] = entitySnap{Entity: raw, Created: ver.created, Updated: ver.at, Version: ver.version} + } + + for p, ids := range b.used { + for id := range ids { + snap.UsedIDs[p] = append(snap.UsedIDs[p], id) + } + } + + for k, idx := range b.indexes { + if raw, err := protojson.Marshal(idx); err == nil { + snap.Indexes[k] = raw + } + } + + return persistence.MarshalSnapshot(ctx, "gcpdatastore", snap) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "gcpdatastore", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "gcpdatastore: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + opts := protojson.UnmarshalOptions{DiscardUnknown: true} + ents := make(map[string]*entRec, len(snap.Entities)) + + for ks, es := range snap.Entities { + e := &datastorepb.Entity{} + if err := opts.Unmarshal(es.Entity, e); err != nil { + return fmt.Errorf("gcpdatastore: restore entity: %w", err) + } + + ents[ks] = &entRec{ + key: e.GetKey(), + vers: []entVer{{at: es.Updated, created: es.Created, ent: e, version: es.Version}}, + } + } + + indexes := make(map[string]*adminpb.Index, len(snap.Indexes)) + + for k, raw := range snap.Indexes { + idx := &adminpb.Index{} + if err := opts.Unmarshal(raw, idx); err != nil { + return fmt.Errorf("gcpdatastore: restore index: %w", err) + } + + indexes[k] = idx + } + + if err := b.ops.Restore(snap.Operations); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.ents, b.indexes = ents, indexes + b.txns = make(map[string]*txnRec) + b.changes = nil + b.lastCommit, b.nextIndex = snap.LastCommit, snap.NextIndex + b.used = make(map[string]map[int64]struct{}, len(snap.UsedIDs)) + + for p, ids := range snap.UsedIDs { + m := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + m[id] = struct{}{} + } + + b.used[p] = m + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("gcpdatastore: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/gcpdatastore/plan.go b/services/gcpdatastore/plan.go new file mode 100644 index 0000000000..8510de8806 --- /dev/null +++ b/services/gcpdatastore/plan.go @@ -0,0 +1,425 @@ +package gcpdatastore + +import ( + "slices" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" +) + +const ( + maxInValues = 30 + maxNotInValues = 10 + kindProperty = "__kind__" + nsProperty = "__namespace__" + propProperty = "__property__" +) + +type order struct { + name string + desc bool +} + +// queryPlan is a validated, normalised Query. +type queryPlan struct { + ancestor *datastorepb.Key + filter *datastorepb.Filter + q *datastorepb.Query + eqProps map[string]struct{} + part partition + kind string + distinct []string + proj []string + explicit []order + orders []order + ineq []string + offset int + limit int + nonKey bool + hasLimit bool + keysOnly bool + unbounded bool +} + +func buildPlan(part partition, q *datastorepb.Query) (*queryPlan, error) { + if q.GetFindNearest() != nil { + return nil, errUnimplemented("Vector search (find_nearest) is not supported.") + } + + p := &queryPlan{q: q, part: part, eqProps: map[string]struct{}{}, offset: int(q.GetOffset())} + + if len(q.GetKind()) > 1 { + return nil, errInvalid("Only one kind may be specified in a query.") + } + + if len(q.GetKind()) == 1 { + p.kind = q.GetKind()[0].GetName() + if p.kind == "" { + return nil, errInvalid("The query kind must not be empty.") + } + } + + if q.GetOffset() < 0 { + return nil, errInvalid("The query offset must not be negative.") + } + + if q.GetLimit() != nil { + if q.GetLimit().GetValue() < 0 { + return nil, errInvalid("The query limit must not be negative.") + } + + p.hasLimit, p.limit = true, int(q.GetLimit().GetValue()) + } + + rest, anc, err := splitAncestor(q.GetFilter()) + if err != nil { + return nil, err + } + + p.filter, p.ancestor = stampFilter(rest, part), anc + if anc != nil { + p.ancestor = stampKey(anc, part, true) + } + + if err = p.analyzeFilter(rest); err != nil { + return nil, err + } + + if err = p.planProjection(q); err != nil { + return nil, err + } + + if err = p.planOrders(q); err != nil { + return nil, err + } + + if p.kind == "" && !p.kindlessOK() { + return nil, errInvalid("Kindless queries can only filter on ancestor and __key__.") + } + + return p, nil +} + +func (p *queryPlan) kindlessOK() bool { + return !p.nonKey && !slices.ContainsFunc(p.explicit, func(o order) bool { return o.name != keyProperty }) +} + +// splitAncestor removes the HAS_ANCESTOR filter, which must sit at the top level of an AND. +func splitAncestor(f *datastorepb.Filter) (*datastorepb.Filter, *datastorepb.Key, error) { + switch t := f.GetFilterType().(type) { + case *datastorepb.Filter_PropertyFilter: + if t.PropertyFilter.GetOp() != datastorepb.PropertyFilter_HAS_ANCESTOR { + return f, nil, nil + } + + k, err := ancestorKey(t.PropertyFilter) + + return nil, k, err + case *datastorepb.Filter_CompositeFilter: + cf := t.CompositeFilter + if cf.GetOp() != datastorepb.CompositeFilter_AND { + if containsAncestor(f) { + return nil, nil, errInvalid("HAS_ANCESTOR cannot be used inside an OR filter.") + } + + return f, nil, nil + } + + return splitAncestorAnd(cf) + default: + return nil, nil, nil + } +} + +func splitAncestorAnd(cf *datastorepb.CompositeFilter) (*datastorepb.Filter, *datastorepb.Key, error) { + var anc *datastorepb.Key + + kept := make([]*datastorepb.Filter, 0, len(cf.GetFilters())) + + for _, child := range cf.GetFilters() { + rest, k, err := splitAncestor(child) + if err != nil { + return nil, nil, err + } + + if k != nil && anc != nil { + return nil, nil, errInvalid("A query may have at most one HAS_ANCESTOR filter.") + } + + if k != nil { + anc = k + } + + if rest != nil { + kept = append(kept, rest) + } + } + + if len(kept) == 0 { + return nil, anc, nil + } + + return &datastorepb.Filter{FilterType: &datastorepb.Filter_CompositeFilter{ + CompositeFilter: &datastorepb.CompositeFilter{Op: datastorepb.CompositeFilter_AND, Filters: kept}, + }}, anc, nil +} + +func containsAncestor(f *datastorepb.Filter) bool { + switch t := f.GetFilterType().(type) { + case *datastorepb.Filter_PropertyFilter: + return t.PropertyFilter.GetOp() == datastorepb.PropertyFilter_HAS_ANCESTOR + case *datastorepb.Filter_CompositeFilter: + return slices.ContainsFunc(t.CompositeFilter.GetFilters(), containsAncestor) + } + + return false +} + +func ancestorKey(pf *datastorepb.PropertyFilter) (*datastorepb.Key, error) { + if pf.GetProperty().GetName() != keyProperty { + return nil, errInvalid("HAS_ANCESTOR requires the __key__ property.") + } + + k := pf.GetValue().GetKeyValue() + if k == nil || len(k.GetPath()) == 0 { + return nil, errInvalid("HAS_ANCESTOR requires a key value.") + } + + return k, nil +} + +func (p *queryPlan) analyzeFilter(f *datastorepb.Filter) error { + if f == nil { + return nil + } + + notEq := 0 + + if err := walkFilter(f, func(pf *datastorepb.PropertyFilter) error { + return p.analyzeProp(pf, ¬Eq) + }); err != nil { + return err + } + + if notEq > 1 { + return errInvalid("A query may have at most one != or NOT_IN filter.") + } + + return nil +} + +func walkFilter(f *datastorepb.Filter, fn func(*datastorepb.PropertyFilter) error) error { + switch t := f.GetFilterType().(type) { + case *datastorepb.Filter_PropertyFilter: + return fn(t.PropertyFilter) + case *datastorepb.Filter_CompositeFilter: + if t.CompositeFilter.GetOp() == datastorepb.CompositeFilter_OPERATOR_UNSPECIFIED { + return errInvalid("A composite filter requires an operator.") + } + + for _, c := range t.CompositeFilter.GetFilters() { + if err := walkFilter(c, fn); err != nil { + return err + } + } + } + + return nil +} + +func (p *queryPlan) analyzeProp(pf *datastorepb.PropertyFilter, notEq *int) error { + name := pf.GetProperty().GetName() + if name == "" { + return errInvalid("A property filter requires a property name.") + } + + if pf.GetValue().GetValueType() == nil { + return errInvalid("The filter on %q requires a value.", name) + } + + if name == keyProperty && !isKeyValue(pf.GetValue(), pf.GetOp()) { + return errInvalid("Filters on __key__ require key values.") + } + + if name != keyProperty { + p.nonKey = true + } + + switch pf.GetOp() { + case datastorepb.PropertyFilter_OPERATOR_UNSPECIFIED: + return errInvalid("The filter on %q requires an operator.", name) + case datastorepb.PropertyFilter_HAS_ANCESTOR: + return errInvalid("HAS_ANCESTOR is invalid here.") + case datastorepb.PropertyFilter_EQUAL: + p.noteEq(name) + case datastorepb.PropertyFilter_IN, datastorepb.PropertyFilter_NOT_IN: + return p.analyzeList(pf, notEq) + case datastorepb.PropertyFilter_NOT_EQUAL: + *notEq++ + + p.noteIneq(name) + default: + p.noteIneq(name) + } + + return nil +} + +func (p *queryPlan) analyzeList(pf *datastorepb.PropertyFilter, notEq *int) error { + name := pf.GetProperty().GetName() + isIn := pf.GetOp() == datastorepb.PropertyFilter_IN + + limit := maxNotInValues + if isIn { + limit = maxInValues + } + + n, err := arrayFilterSize(pf, limit) + if err != nil { + return err + } + + if n == 0 { + return errInvalid("The %s filter on %q requires at least one value.", pf.GetOp(), name) + } + + if isIn { + p.noteEq(name) + + return nil + } + + *notEq++ + + p.noteIneq(name) + + return nil +} + +func isKeyValue(v *datastorepb.Value, op datastorepb.PropertyFilter_Operator) bool { + if op == datastorepb.PropertyFilter_IN || op == datastorepb.PropertyFilter_NOT_IN { + return !slices.ContainsFunc(arrayElems(v), func(e *datastorepb.Value) bool { return e.GetKeyValue() == nil }) + } + + return v.GetKeyValue() != nil +} + +func arrayFilterSize(pf *datastorepb.PropertyFilter, limit int) (int, error) { + if !isArray(pf.GetValue()) { + return 0, errInvalid("The %s filter on %q requires an array value.", pf.GetOp(), pf.GetProperty().GetName()) + } + + n := len(arrayElems(pf.GetValue())) + if n > limit { + return 0, errInvalid( + "The %s filter on %q allows at most %d values.", + pf.GetOp(), + pf.GetProperty().GetName(), + limit, + ) + } + + return n, nil +} + +func (p *queryPlan) noteEq(name string) { + p.eqProps[name] = struct{}{} +} + +func (p *queryPlan) noteIneq(name string) { + if name != keyProperty && !slices.Contains(p.ineq, name) { + p.ineq = append(p.ineq, name) + } +} + +func (p *queryPlan) planProjection(q *datastorepb.Query) error { + seen := map[string]struct{}{} + + for _, pr := range q.GetProjection() { + name := pr.GetProperty().GetName() + if name == "" { + return errInvalid("A projection requires a property name.") + } + + if _, dup := seen[name]; dup { + return errInvalid("Property %q is projected more than once.", name) + } + + seen[name] = struct{}{} + p.proj = append(p.proj, name) + } + + p.keysOnly = len(p.proj) == 1 && p.proj[0] == keyProperty + + for _, d := range q.GetDistinctOn() { + if d.GetName() == "" { + return errInvalid("distinct_on requires a property name.") + } + + p.distinct = append(p.distinct, d.GetName()) + } + + return nil +} + +func (p *queryPlan) planOrders(q *datastorepb.Query) error { + for _, o := range q.GetOrder() { + name := o.GetProperty().GetName() + if name == "" { + return errInvalid("A sort order requires a property name.") + } + + p.explicit = append( + p.explicit, + order{name: name, desc: o.GetDirection() == datastorepb.PropertyOrder_DESCENDING}, + ) + } + + if len(p.ineq) > 0 && len(p.explicit) > 0 && len(p.ineq) == 1 && p.explicit[0].name != p.ineq[0] { + return errInvalid( + "The first sort property must be the same as the property to which the inequality filter is applied. "+ + "In your query the first sort property is %s but the inequality filter is on %s", + p.explicit[0].name, p.ineq[0]) + } + + eff := make([]order, 0, len(p.explicit)+1) + + for _, o := range p.explicit { + if _, eq := p.eqProps[o.name]; eq && o.name != keyProperty { + continue + } + + eff = append(eff, o) + } + + if len(p.explicit) == 0 { + for _, n := range p.ineq { + eff = append(eff, order{name: n}) + } + } + + if !slices.ContainsFunc(eff, func(o order) bool { return o.name == keyProperty }) { + eff = append(eff, order{name: keyProperty}) + } + + p.orders = eff + + return nil +} + +// stampFilter clones f with every key value stamped into the query's partition. +func stampFilter(f *datastorepb.Filter, part partition) *datastorepb.Filter { + if f == nil { + return nil + } + + out, _ := proto.Clone(f).(*datastorepb.Filter) + + _ = walkFilter(out, func(pf *datastorepb.PropertyFilter) error { + stampValueKeys(pf.GetValue(), func(k *datastorepb.Key) *datastorepb.Key { return stampKey(k, part, true) }) + + return nil + }) + + return out +} diff --git a/services/gcpdatastore/provider.go b/services/gcpdatastore/provider.go new file mode 100644 index 0000000000..315cf7bbd1 --- /dev/null +++ b/services/gcpdatastore/provider.go @@ -0,0 +1,44 @@ +package gcpdatastore + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("gcpdatastore: nil app context") + +// ConfigProvider extracts Datastore configuration from the AppContext Config. +type ConfigProvider interface { + GetGCPDatastoreSettings() Settings +} + +// Provider implements service.Provider for Google Cloud Datastore. It serves gRPC and REST on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "GCPDatastore" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetGCPDatastoreSettings() + } + + backend := NewInMemoryBackend() + backend.SetEnforceIndexes(settings.EnforceIndexes) + + handler := NewHandler(backend) + handler.Port = settings.Port + + return handler, nil +} diff --git a/services/gcpdatastore/query.go b/services/gcpdatastore/query.go new file mode 100644 index 0000000000..e54a2eceb3 --- /dev/null +++ b/services/gcpdatastore/query.go @@ -0,0 +1,748 @@ +package gcpdatastore + +import ( + "bytes" + "maps" + "slices" + "sort" + "strconv" + "strings" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + batchSize = 300 + cursorMagic = "\x01DSC" + cursorKeyFmt = 6 + originCursor = cursorMagic + maxQueryScans = 10_000_000 +) + +type cand struct { + key *datastorepb.Key + ent *datastorepb.Entity + ver *entVer +} + +// row is one query result with its sort position. +type row struct { + key *datastorepb.Key + ent *datastorepb.Entity + ver *entVer + vals []*datastorepb.Value +} + +type queryResult struct { + readTime *timestamppb.Timestamp + rows []*row + skipCursor []byte + endCursor []byte + skipped int + more datastorepb.QueryResultBatch_MoreResultsType + resultType datastorepb.EntityResult_ResultType +} + +func (b *InMemoryBackend) candidates(p *queryPlan, rv *readView) ([]*cand, error) { + switch p.kind { + case kindProperty: + return b.kindCandidates(p, rv), nil + case nsProperty: + return b.namespaceCandidates(p, rv), nil + case propProperty: + return nil, errUnimplemented("__property__ metadata queries are not supported.") + } + + var out []*cand + + for _, rec := range b.ents { + if keyPartition(rec.key) != p.part { + continue + } + + if p.kind != "" && keyKind(rec.key) != p.kind { + continue + } + + if p.ancestor != nil && !isDescendantOrSelf(rec.key, p.ancestor) { + continue + } + + ver := rec.at(rv.at) + if ver == nil { + continue + } + + out = append(out, &cand{key: rec.key, ent: ver.ent, ver: ver}) + } + + return out, nil +} + +func (b *InMemoryBackend) metaCandidate(part partition, id *datastorepb.Key_PathElement) *cand { + key := &datastorepb.Key{PartitionId: part.stored(), Path: []*datastorepb.Key_PathElement{id}} + + return &cand{key: key, ent: &datastorepb.Entity{Key: key}, ver: &entVer{}} +} + +func (b *InMemoryBackend) kindCandidates(p *queryPlan, rv *readView) []*cand { + kinds := map[string]struct{}{} + + for _, rec := range b.ents { + if keyPartition(rec.key) == p.part && rec.at(rv.at) != nil && !isReserved(keyKind(rec.key)) { + kinds[keyKind(rec.key)] = struct{}{} + } + } + + out := make([]*cand, 0, len(kinds)) + + for k := range kinds { + out = append(out, b.metaCandidate(p.part, &datastorepb.Key_PathElement{ + Kind: kindProperty, IdType: &datastorepb.Key_PathElement_Name{Name: k}, + })) + } + + return out +} + +func (b *InMemoryBackend) namespaceCandidates(p *queryPlan, rv *readView) []*cand { + spaces := map[string]struct{}{} + + for _, rec := range b.ents { + kp := keyPartition(rec.key) + if kp.project == p.part.project && kp.database == p.part.database && rec.at(rv.at) != nil { + spaces[kp.namespace] = struct{}{} + } + } + + root := partition{project: p.part.project, database: p.part.database} + out := make([]*cand, 0, len(spaces)) + + for ns := range spaces { + el := &datastorepb.Key_PathElement{Kind: nsProperty, IdType: &datastorepb.Key_PathElement_Id{Id: 1}} + if ns != "" { + el.IdType = &datastorepb.Key_PathElement_Name{Name: ns} + } + + out = append(out, b.metaCandidate(root, el)) + } + + return out +} + +func matchFilter(f *datastorepb.Filter, c *cand) bool { + if f == nil || f.GetFilterType() == nil { + return true + } + + switch t := f.GetFilterType().(type) { + case *datastorepb.Filter_PropertyFilter: + return matchProp(t.PropertyFilter, c) + case *datastorepb.Filter_CompositeFilter: + cf := t.CompositeFilter + if cf.GetOp() == datastorepb.CompositeFilter_OR { + return slices.ContainsFunc(cf.GetFilters(), func(ch *datastorepb.Filter) bool { return matchFilter(ch, c) }) + } + + return !slices.ContainsFunc(cf.GetFilters(), func(ch *datastorepb.Filter) bool { return !matchFilter(ch, c) }) + } + + return true +} + +func filterValues(pf *datastorepb.PropertyFilter, c *cand) []*datastorepb.Value { + if pf.GetProperty().GetName() == keyProperty { + return []*datastorepb.Value{keyValue(c.key)} + } + + return propValues(c.ent, pf.GetProperty().GetName()) +} + +func matchProp(pf *datastorepb.PropertyFilter, c *cand) bool { + vals := filterValues(pf, c) + fv := pf.GetValue() + + switch pf.GetOp() { + case datastorepb.PropertyFilter_EQUAL: + return slices.ContainsFunc(vals, func(v *datastorepb.Value) bool { return compareValues(v, fv) == 0 }) + case datastorepb.PropertyFilter_IN: + return slices.ContainsFunc(vals, func(v *datastorepb.Value) bool { return inList(v, arrayElems(fv)) }) + case datastorepb.PropertyFilter_NOT_EQUAL: + return slices.ContainsFunc(vals, func(v *datastorepb.Value) bool { return compareValues(v, fv) != 0 }) + case datastorepb.PropertyFilter_NOT_IN: + for _, el := range arrayElems(fv) { + if !slices.ContainsFunc(vals, func(v *datastorepb.Value) bool { return compareValues(v, el) != 0 }) { + return false + } + } + + return len(vals) > 0 + default: + return slices.ContainsFunc(vals, func(v *datastorepb.Value) bool { return rangeMatch(pf.GetOp(), v, fv) }) + } +} + +func inList(v *datastorepb.Value, list []*datastorepb.Value) bool { + return slices.ContainsFunc(list, func(e *datastorepb.Value) bool { return compareValues(v, e) == 0 }) +} + +func rangeMatch(op datastorepb.PropertyFilter_Operator, v, fv *datastorepb.Value) bool { + if bucketOf(v) != bucketOf(fv) { + return false + } + + c := compareValues(v, fv) + + switch op { + case datastorepb.PropertyFilter_LESS_THAN: + return c < 0 + case datastorepb.PropertyFilter_LESS_THAN_OR_EQUAL: + return c <= 0 + case datastorepb.PropertyFilter_GREATER_THAN: + return c > 0 + case datastorepb.PropertyFilter_GREATER_THAN_OR_EQUAL: + return c >= 0 + default: + return false + } +} + +// sortValues picks the value a row sorts by for each effective order, or false when a property is missing. +func (p *queryPlan) sortValues(c *cand, projVals map[string]*datastorepb.Value) ([]*datastorepb.Value, bool) { + out := make([]*datastorepb.Value, 0, len(p.orders)) + + for _, o := range p.orders { + if o.name == keyProperty { + out = append(out, keyValue(c.key)) + + continue + } + + if v, ok := projVals[o.name]; ok { + out = append(out, v) + + continue + } + + vals := propValues(c.ent, o.name) + if len(vals) == 0 { + return nil, false + } + + out = append(out, extreme(vals, o.desc)) + } + + return out, true +} + +func extreme(vals []*datastorepb.Value, wantMax bool) *datastorepb.Value { + best := vals[0] + + for _, v := range vals[1:] { + if c := compareValues(v, best); (wantMax && c > 0) || (!wantMax && c < 0) { + best = v + } + } + + return best +} + +// projectionRows expands a candidate into one row per combination of projected array elements. +func (p *queryPlan) projectionRows(c *cand) []map[string]*datastorepb.Value { + combos := []map[string]*datastorepb.Value{{}} + + for _, name := range p.proj { + if name == keyProperty { + continue + } + + vals := propValues(c.ent, name) + if len(vals) == 0 { + return nil + } + + next := make([]map[string]*datastorepb.Value, 0, len(combos)*len(vals)) + + for _, combo := range combos { + for _, v := range uniqueValues(vals) { + m := make(map[string]*datastorepb.Value, len(combo)+1) + maps.Copy(m, combo) + + m[name] = v + next = append(next, m) + } + } + + combos = next + } + + return combos +} + +func uniqueValues(vals []*datastorepb.Value) []*datastorepb.Value { + out := make([]*datastorepb.Value, 0, len(vals)) + + for _, v := range vals { + if !inList(v, out) { + out = append(out, v) + } + } + + return out +} + +func (p *queryPlan) buildRows(cands []*cand) []*row { + var rows []*row + + hasProj := len(p.proj) > 0 && !p.keysOnly + + for _, c := range cands { + if !matchFilter(p.filter, c) { + continue + } + + combos := []map[string]*datastorepb.Value{nil} + if hasProj { + combos = p.projectionRows(c) + } + + for _, combo := range combos { + if r := p.rowFor(c, combo, hasProj); r != nil { + rows = append(rows, r) + } + } + } + + return rows +} + +func (p *queryPlan) rowFor(c *cand, combo map[string]*datastorepb.Value, hasProj bool) *row { + vals, ok := p.sortValues(c, combo) + if !ok { + return nil + } + + r := &row{key: c.key, ver: c.ver, vals: vals} + + switch { + case p.keysOnly: + r.ent = &datastorepb.Entity{Key: c.key} + case hasProj: + r.ent = projected(c, p.proj, combo) + + for _, name := range p.proj { + if name != keyProperty { + r.vals = append(r.vals, combo[name]) + } + } + default: + r.ent = c.ent + } + + return r +} + +func projected(c *cand, proj []string, combo map[string]*datastorepb.Value) *datastorepb.Entity { + e := &datastorepb.Entity{Key: c.key, Properties: map[string]*datastorepb.Value{}} + + for _, name := range proj { + if name != keyProperty { + e.Properties[name] = combo[name] + } + } + + return e +} + +func (p *queryPlan) comparePos(a, b []*datastorepb.Value) int { + for i := range a { + c := compareValues(a[i], b[i]) + if i < len(p.orders) && p.orders[i].desc { + c = -c + } + + if c != 0 { + return c + } + } + + return 0 +} + +func valueSig(v *datastorepb.Value) string { + var sb strings.Builder + + sb.WriteString(strconv.Itoa(bucketOf(v))) + sb.WriteByte(':') + + switch bucketOf(v) { + case bucketInt: + sb.WriteString(strconv.FormatInt(intOf(v), 10)) + case bucketBool: + sb.WriteString(strconv.FormatBool(v.GetBooleanValue())) + case bucketBlob: + sb.Write(v.GetBlobValue()) + case bucketString: + sb.WriteString(v.GetStringValue()) + case bucketDouble: + sb.WriteString(strconv.FormatFloat(v.GetDoubleValue(), 'g', -1, 64)) + case bucketGeo: + sb.WriteString(strconv.FormatFloat(v.GetGeoPointValue().GetLatitude(), 'g', -1, 64)) + sb.WriteByte(',') + sb.WriteString(strconv.FormatFloat(v.GetGeoPointValue().GetLongitude(), 'g', -1, 64)) + case bucketKey: + sb.WriteString(keyString(v.GetKeyValue())) + } + + return sb.String() +} + +func (p *queryPlan) applyDistinct(rows []*row, cands map[*datastorepb.Key]*cand) []*row { + if len(p.distinct) == 0 { + return rows + } + + seen := map[string]struct{}{} + out := rows[:0] + + for _, r := range rows { + var sb strings.Builder + + complete := true + + for _, name := range p.distinct { + v, ok := p.distinctValue(r, name, cands[r.key]) + if !ok { + complete = false + + break + } + + sb.WriteString(valueSig(v)) + sb.WriteByte(0) + } + + if !complete { + continue + } + + if _, dup := seen[sb.String()]; dup { + continue + } + + seen[sb.String()] = struct{}{} + + out = append(out, r) + } + + return out +} + +func (p *queryPlan) distinctValue(r *row, name string, c *cand) (*datastorepb.Value, bool) { + if name == keyProperty { + return keyValue(r.key), true + } + + if v, ok := r.ent.GetProperties()[name]; ok && len(p.proj) > 0 { + return v, true + } + + vals := propValues(c.ent, name) + if len(vals) == 0 { + return nil, false + } + + return extreme(vals, false), true +} + +func encodeCursor(vals []*datastorepb.Value) []byte { + e := &datastorepb.Entity{Properties: make(map[string]*datastorepb.Value, len(vals))} + + for i, v := range vals { + e.Properties[cursorProp(i)] = v + } + + raw, _ := proto.MarshalOptions{Deterministic: true}.Marshal(e) + + return append([]byte(cursorMagic), raw...) +} + +func cursorProp(i int) string { + s := strconv.Itoa(i) + + return strings.Repeat("0", cursorKeyFmt-len(s)) + s +} + +func decodeCursor(raw []byte, want int) ([]*datastorepb.Value, error) { + if !bytes.HasPrefix(raw, []byte(cursorMagic)) { + return nil, errInvalid("Invalid cursor.") + } + + e := &datastorepb.Entity{} + if err := proto.Unmarshal(raw[len(cursorMagic):], e); err != nil { + return nil, errInvalid("Invalid cursor.") + } + + if len(e.GetProperties()) == 0 { + return nil, nil + } + + if len(e.GetProperties()) != want { + return nil, errInvalid("Invalid cursor: it does not belong to this query.") + } + + out := make([]*datastorepb.Value, want) + + for i := range want { + v, ok := e.GetProperties()[cursorProp(i)] + if !ok { + return nil, errInvalid("Invalid cursor.") + } + + out[i] = v + } + + return out, nil +} + +// execQuery runs a validated plan against rv and slices the rows into one result batch. +func (b *InMemoryBackend) execQuery(p *queryPlan, rv *readView) (*queryResult, error) { + if rv.txn != nil && !rv.txn.readOnly { + rv.txn.queries = append(rv.txn.queries, queryScope{ancestor: p.ancestor, part: p.part, kind: p.kind}) + } + + if err := b.checkIndex(p); err != nil { + return nil, err + } + + cands, err := b.candidates(p, rv) + if err != nil { + return nil, err + } + + byKey := make(map[*datastorepb.Key]*cand, len(cands)) + for _, c := range cands { + byKey[c.key] = c + } + + rows := p.buildRows(cands) + + sort.SliceStable(rows, func(i, j int) bool { return p.comparePos(rows[i].vals, rows[j].vals) < 0 }) + + rows = p.applyDistinct(rows, byKey) + + return p.slice(rows, rv) +} + +func (p *queryPlan) posWidth() int { + width := len(p.orders) + if len(p.proj) > 0 && !p.keysOnly { + width += len(p.proj) + if slices.Contains(p.proj, keyProperty) { + width-- + } + } + + return width +} + +func (p *queryPlan) resultType() datastorepb.EntityResult_ResultType { + switch { + case p.keysOnly: + return datastorepb.EntityResult_KEY_ONLY + case len(p.proj) > 0: + return datastorepb.EntityResult_PROJECTION + default: + return datastorepb.EntityResult_FULL + } +} + +// window applies the start and end cursors and reports whether rows remain beyond the end cursor. +func (p *queryPlan) window(rows []*row) ([]*row, bool, error) { + startPos, err := cursorPos(p.q.GetStartCursor(), p.posWidth()) + if err != nil { + return nil, false, err + } + + endPos, err := cursorPos(p.q.GetEndCursor(), p.posWidth()) + if err != nil { + return nil, false, err + } + + if startPos != nil { + i := sort.Search(len(rows), func(i int) bool { return p.comparePos(rows[i].vals, startPos) > 0 }) + rows = rows[i:] + } + + beyondEnd := false + + if endPos != nil { + i := sort.Search(len(rows), func(i int) bool { return p.comparePos(rows[i].vals, endPos) > 0 }) + beyondEnd = i < len(rows) + rows = rows[:i] + } + + return rows, beyondEnd, nil +} + +func (p *queryPlan) slice(rows []*row, rv *readView) (*queryResult, error) { + res := &queryResult{readTime: timestamppb.New(rv.at), resultType: p.resultType()} + + rows, beyondEnd, err := p.window(rows) + if err != nil { + return nil, err + } + + skip := min(p.offset, len(rows)) + if skip > 0 { + res.skipCursor = encodeCursor(rows[skip-1].vals) + } + + res.skipped, rows = skip, rows[skip:] + + take := len(rows) + if p.hasLimit { + take = min(take, p.limit) + } + + capped := take + if !p.unbounded { + capped = min(take, batchSize) + } + + res.rows = rows[:capped] + res.more = moreResults(p, len(rows), capped, take, beyondEnd) + + switch { + case capped > 0: + res.endCursor = encodeCursor(res.rows[capped-1].vals) + case res.skipCursor != nil: + res.endCursor = res.skipCursor + case p.q.GetStartCursor() != nil: + res.endCursor = p.q.GetStartCursor() + default: + res.endCursor = []byte(originCursor) + } + + return res, nil +} + +func moreResults( + p *queryPlan, remaining, returned, take int, beyondEnd bool, +) datastorepb.QueryResultBatch_MoreResultsType { + switch { + case returned < take: + return datastorepb.QueryResultBatch_NOT_FINISHED + case p.hasLimit && returned == p.limit && remaining > returned: + return datastorepb.QueryResultBatch_MORE_RESULTS_AFTER_LIMIT + case beyondEnd: + return datastorepb.QueryResultBatch_MORE_RESULTS_AFTER_CURSOR + default: + return datastorepb.QueryResultBatch_NO_MORE_RESULTS + } +} + +func cursorPos(raw []byte, width int) ([]*datastorepb.Value, error) { + if len(raw) == 0 { + return nil, nil + } + + return decodeCursor(raw, width) +} + +func (r *queryResult) batch(mask *datastorepb.PropertyMask) *datastorepb.QueryResultBatch { + out := &datastorepb.QueryResultBatch{ + SkippedResults: clampInt32(r.skipped), + SkippedCursor: r.skipCursor, + EntityResultType: r.resultType, + EndCursor: r.endCursor, + MoreResults: r.more, + ReadTime: r.readTime, + SnapshotVersion: r.readTime.AsTime().UnixMicro(), + } + + for _, rw := range r.rows { + er := &datastorepb.EntityResult{Entity: rw.ent, Cursor: encodeCursor(rw.vals)} + + if mask != nil && r.resultType == datastorepb.EntityResult_FULL { + er.Entity = maskEntity(rw.key, rw.ent, mask) + } + + if r.resultType != datastorepb.EntityResult_PROJECTION && rw.ver != nil && rw.ver.ent != nil { + er.Version = rw.ver.version + er.CreateTime = timestamppb.New(rw.ver.created) + er.UpdateTime = timestamppb.New(rw.ver.at) + } + + out.EntityResults = append(out.EntityResults, er) + } + + return out +} + +// RunQuery executes a structured or GQL query. +func (b *InMemoryBackend) RunQuery(in *datastorepb.RunQueryRequest) (*datastorepb.RunQueryResponse, error) { + req, err := b.resolve(in.GetProjectId(), in.GetDatabaseId()) + if err != nil { + return nil, err + } + + if in.GetExplainOptions() != nil { + return nil, errUnimplemented("Query explain is not supported.") + } + + part, err := queryPartition(req, in.GetPartitionId()) + if err != nil { + return nil, err + } + + q, parsed, err := resolveQuery(in) + if err != nil { + return nil, err + } + + plan, err := buildPlan(part, q) + if err != nil { + return nil, err + } + + resp := &datastorepb.RunQueryResponse{Query: parsed} + + err = b.read(in.GetReadOptions(), req, func(rv *readView) error { + res, qerr := b.execQuery(plan, rv) + if qerr != nil { + return qerr + } + + resp.Batch, resp.Transaction = res.batch(in.GetPropertyMask()), rv.newTxnID + + return nil + }) + if err != nil { + return nil, err + } + + return resp, nil +} + +func resolveQuery(in *datastorepb.RunQueryRequest) (*datastorepb.Query, *datastorepb.Query, error) { + switch qt := in.GetQueryType().(type) { + case *datastorepb.RunQueryRequest_Query: + return qt.Query, nil, nil + case *datastorepb.RunQueryRequest_GqlQuery: + g, err := parseGQL(qt.GqlQuery) + if err != nil { + return nil, nil, err + } + + if g.agg != nil { + return nil, nil, errInvalid("Aggregation GQL must be run with RunAggregationQuery.") + } + + return g.query, g.query, nil + default: + return nil, nil, errInvalid("A query or gql_query is required.") + } +} diff --git a/services/gcpdatastore/query_test.go b/services/gcpdatastore/query_test.go new file mode 100644 index 0000000000..4e2cf3475f --- /dev/null +++ b/services/gcpdatastore/query_test.go @@ -0,0 +1,371 @@ +package gcpdatastore_test + +import ( + "errors" + "fmt" + "strconv" + "testing" + + "cloud.google.com/go/datastore" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func newLooseEnv(t *testing.T) *env { + t.Helper() + + e := newEnv(t) + e.handler.Backend.SetEnforceIndexes(false) + e.seed(t, "Task", seedTasks()) + + return e +} + +func (e *env) keys(t *testing.T, q *datastore.Query, keysOnly bool) []*datastore.Key { + t.Helper() + + var dst []task + if keysOnly { + keys, err := e.client.GetAll(t.Context(), q, nil) + require.NoError(t, err) + + return keys + } + + keys, err := e.client.GetAll(t.Context(), q, &dst) + require.NoError(t, err) + + return keys +} + +func TestQueryFilters(t *testing.T) { + t.Parallel() + + prop := func(n, op string, v any) datastore.PropertyFilter { + return datastore.PropertyFilter{FieldName: n, Operator: op, Value: v} + } + + tests := []struct { + query *datastore.Query + name string + want []string + }{ + {datastore.NewQuery("Task"), "all by key", []string{"a", "b", "c", "d", "e"}}, + {datastore.NewQuery("Task").FilterField("Done", "=", true), "equality", []string{"a", "c", "e"}}, + {datastore.NewQuery("Task").FilterField("Priority", ">", 2), "greater than", []string{"c", "d", "e"}}, + {datastore.NewQuery("Task").FilterField("Priority", "<=", 2), "less or equal", []string{"a", "b"}}, + {datastore.NewQuery("Task").FilterField("Priority", "!=", 3), "not equal", []string{"a", "b", "d", "e"}}, + {datastore.NewQuery("Task").FilterField("Priority", "in", []any{1, 5}), "in", []string{"a", "e"}}, + {datastore.NewQuery("Task").FilterField("Priority", "not-in", []any{1, 2}), "not in", []string{"c", "d", "e"}}, + {datastore.NewQuery("Task").FilterField("Tags", "=", "y"), "array membership", []string{"a", "b"}}, + {datastore.NewQuery("Task").FilterField("Score", ">", 3.0), "double range", []string{"c", "d", "e"}}, + {datastore.NewQuery("Task").FilterField("Priority", ">", 1.5), "int and double never mix", nil}, + {datastore.NewQuery("Task").FilterField("Note", "=", "first"), "unindexed property matches nothing", nil}, + {datastore.NewQuery("Task").FilterField("Missing", "=", "x"), "missing property", nil}, + { + datastore.NewQuery("Task").FilterEntity(datastore.OrFilter{Filters: []datastore.EntityFilter{ + prop("Priority", "<", 2), prop("Priority", ">", 4), + }}), + "or on one property", []string{"a", "e"}, + }, + { + datastore.NewQuery("Task").FilterEntity(datastore.OrFilter{Filters: []datastore.EntityFilter{ + prop("Done", "=", false), prop("Tags", "=", "z"), + }}), + "or across properties", []string{"b", "c", "d"}, + }, + { + datastore.NewQuery("Task").FilterEntity(datastore.AndFilter{Filters: []datastore.EntityFilter{ + prop("Done", "=", true), prop("Priority", ">", 1), + }}), + "and equality and range", []string{"c", "e"}, + }, + {datastore.NewQuery("Task").Order("-Priority"), "descending order", []string{"e", "d", "c", "b", "a"}}, + {datastore.NewQuery("Task").Order("-Priority").Limit(2), "limit", []string{"e", "d"}}, + {datastore.NewQuery("Task").Offset(1).Limit(2), "offset and limit", []string{"b", "c"}}, + {datastore.NewQuery("Task").Order("-Priority").Offset(3), "offset past most", []string{"b", "a"}}, + { + datastore.NewQuery("Task").Order("Priority").Order("-Score"), + "multiple orders", + []string{"a", "b", "c", "d", "e"}, + }, + {datastore.NewQuery("Task").EventualConsistency(), "eventual", []string{"a", "b", "c", "d", "e"}}, + {datastore.NewQuery("Task").KeysOnly().FilterField("Done", "=", true), "keys only", []string{"a", "c", "e"}}, + {datastore.NewQuery("Other"), "other kind", nil}, + {datastore.NewQuery("Task").Order("Missing"), "order on missing property excludes", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + keys := e.keys(t, tt.query, tt.name == "keys only") + assert.ElementsMatch(t, tt.want, names(keys)) + }) + } +} + +func TestQueryOrderExact(t *testing.T) { + t.Parallel() + + tests := []struct { + query *datastore.Query + name string + want []string + }{ + {datastore.NewQuery("Task").Order("-Priority"), "priority desc", []string{"e", "d", "c", "b", "a"}}, + {datastore.NewQuery("Task").Order("Score"), "score asc", []string{"a", "b", "c", "d", "e"}}, + { + datastore.NewQuery("Task").FilterField("Priority", ">", 1).Order("Priority"), + "range ordered", + []string{"b", "c", "d", "e"}, + }, + {datastore.NewQuery("Task").Order("-__key__"), "key desc", []string{"e", "d", "c", "b", "a"}}, + {datastore.NewQuery("Task").Offset(1).Limit(2).Order("-Priority"), "offset limit desc", []string{"d", "c"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + keys := e.keys(t, tt.query, false) + assert.Equal(t, tt.want, names(keys)) + }) + } +} + +func TestQueryAncestorAndKindless(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + parent := datastore.NameKey("List", "l1", nil) + + for _, n := range []string{"x", "y"} { + _, err := e.client.Put(t.Context(), datastore.NameKey("Item", n, parent), &task{Name: n, Priority: 1}) + require.NoError(t, err) + } + + _, err := e.client.Put( + t.Context(), + datastore.NameKey("Item", "z", datastore.NameKey("List", "l2", nil)), + &task{Name: "z"}, + ) + require.NoError(t, err) + + keys := e.keys(t, datastore.NewQuery("Item").Ancestor(parent), false) + assert.Equal(t, []string{"x", "y"}, names(keys)) + + keys, err = e.client.GetAll(t.Context(), datastore.NewQuery("").Ancestor(parent).KeysOnly(), nil) + require.NoError(t, err) + assert.Equal(t, []string{"x", "y"}, names(keys)) + + keys = e.keys(t, datastore.NewQuery("Item").Ancestor(parent).FilterField("Priority", "=", 1), false) + assert.Equal(t, []string{"x", "y"}, names(keys)) + + keys = e.keys( + t, + datastore.NewQuery("Item").FilterField("__key__", ">", datastore.NameKey("Item", "x", parent)), + false, + ) + assert.ElementsMatch(t, []string{"y", "z"}, names(keys)) + + _, err = e.client.GetAll(t.Context(), datastore.NewQuery("").FilterField("Priority", "=", 1).KeysOnly(), nil) + assert.Error(t, err) +} + +func TestQueryProjection(t *testing.T) { + t.Parallel() + + tests := []struct { + query *datastore.Query + name string + want []string + }{ + {datastore.NewQuery("Task").Project("Priority").Order("Priority"), "scalar", []string{"1", "2", "3", "4", "5"}}, + { + datastore.NewQuery("Task").Project("Tags").Order("Tags"), + "array expands per element", + []string{"x", "x", "y", "y", "z"}, + }, + { + datastore.NewQuery("Task").Project("Tags").Order("Tags").Distinct(), + "array distinct", + []string{"x", "y", "z"}, + }, + {datastore.NewQuery("Task").Project("Done").DistinctOn("Done"), "distinct on", []string{"true", "false"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + var got []task + + _, err := e.client.GetAll(t.Context(), tt.query, &got) + require.NoError(t, err) + + vals := make([]string, 0, len(got)) + for _, g := range got { + switch { + case len(g.Tags) > 0: + vals = append(vals, g.Tags[0]) + case tt.name == "scalar": + vals = append(vals, strconv.Itoa(g.Priority)) + default: + vals = append(vals, strconv.FormatBool(g.Done)) + } + } + + if tt.name == "distinct on" { + assert.ElementsMatch(t, tt.want, vals) + } else { + assert.Equal(t, tt.want, vals) + } + }) + } +} + +func TestQueryCursors(t *testing.T) { + t.Parallel() + + t.Run("page by cursor", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + var ( + all []string + cursor datastore.Cursor + have bool + ) + + for range 10 { + q := datastore.NewQuery("Task").Order("-Priority").Limit(2) + if have { + q = q.Start(cursor) + } + + it := e.client.Run(t.Context(), q) + n := 0 + + for { + var tk task + + _, err := it.Next(&tk) + if errors.Is(err, iterator.Done) { + break + } + + require.NoError(t, err) + + all = append(all, tk.Name) + n++ + } + + next, err := it.Cursor() + require.NoError(t, err) + + if n == 0 { + break + } + + cursor, have = next, true + } + + assert.Equal(t, []string{"e", "d", "c", "b", "a"}, all) + }) + + t.Run("end cursor bounds the page", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + it := e.client.Run(t.Context(), datastore.NewQuery("Task").Limit(3)) + + var tk task + for { + if _, err := it.Next(&tk); err != nil { + break + } + } + + end, err := it.Cursor() + require.NoError(t, err) + + keys := e.keys(t, datastore.NewQuery("Task").End(end), false) + assert.Equal(t, []string{"a", "b", "c"}, names(keys)) + }) + + t.Run("large result set spans batches", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.handler.Backend.SetEnforceIndexes(false) + + keys := make([]*datastore.Key, 650) + vals := make([]task, 650) + + for i := range keys { + keys[i] = datastore.NameKey("Big", fmt.Sprintf("k%04d", i), nil) + vals[i] = task{Priority: i} + } + + _, err := e.client.PutMulti(t.Context(), keys, vals) + require.NoError(t, err) + + got, err := e.client.GetAll(t.Context(), datastore.NewQuery("Big").KeysOnly(), nil) + require.NoError(t, err) + require.Len(t, got, 650) + assert.Equal(t, "k0000", got[0].Name) + assert.Equal(t, "k0649", got[649].Name) + + got, err = e.client.GetAll(t.Context(), datastore.NewQuery("Big").KeysOnly().Offset(310).Limit(5), nil) + require.NoError(t, err) + assert.Equal(t, []string{"k0310", "k0311", "k0312", "k0313", "k0314"}, names(got)) + }) + + t.Run("invalid cursor", func(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + _, err := e.raw.RunQuery(t.Context(), &datastorepb.RunQueryRequest{ + ProjectId: project, + QueryType: &datastorepb.RunQueryRequest_Query{Query: &datastorepb.Query{ + Kind: []*datastorepb.KindExpression{{Name: "Task"}}, + StartCursor: []byte("zzz"), + }}, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) +} + +func TestMetadataQueries(t *testing.T) { + t.Parallel() + + e := newLooseEnv(t) + + _, err := e.client.Put(t.Context(), datastore.NameKey("Other", "o", nil), &task{}) + require.NoError(t, err) + + keys, err := e.client.GetAll(t.Context(), datastore.NewQuery("__kind__").KeysOnly(), nil) + require.NoError(t, err) + assert.Equal(t, []string{"Other", "Task"}, names(keys)) + + nk := datastore.NameKey("Task", "ns", nil) + nk.Namespace = "tenant" + _, err = e.client.Put(t.Context(), nk, &task{}) + require.NoError(t, err) + + keys, err = e.client.GetAll(t.Context(), datastore.NewQuery("__namespace__").KeysOnly(), nil) + require.NoError(t, err) + assert.Len(t, keys, 2) +} diff --git a/services/gcpdatastore/settings.go b/services/gcpdatastore/settings.go new file mode 100644 index 0000000000..a2cd7d7fa1 --- /dev/null +++ b/services/gcpdatastore/settings.go @@ -0,0 +1,15 @@ +package gcpdatastore + +// DefaultPort is the dedicated gRPC and REST listener's default TCP port (DATASTORE_EMULATOR_HOST). +const DefaultPort = 8143 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.GCPDatastore. +type Settings struct { + Port int `json:"port" env:"GCP_DATASTORE_PORT" default:"8143" name:"port" help:"Fixed TCP port for the dedicated Google Cloud Datastore gRPC and REST listener (DATASTORE_EMULATOR_HOST); startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceIndexes bool `json:"enforceIndexes" env:"GCP_DATASTORE_ENFORCE_INDEXES" default:"true" name:"enforce-indexes" help:"Fail queries that need a composite index with FAILED_PRECONDITION unless a matching index was created through the admin API; false accepts every query."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { + return Settings{Port: DefaultPort, EnforceIndexes: true} +} diff --git a/services/gcpdatastore/store.go b/services/gcpdatastore/store.go new file mode 100644 index 0000000000..882a07e2f4 --- /dev/null +++ b/services/gcpdatastore/store.go @@ -0,0 +1,394 @@ +package gcpdatastore + +import ( + "crypto/rand" + "math/big" + "slices" + "time" + + "cloud.google.com/go/datastore/admin/apiv1/adminpb" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcplro" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + historyWindow = time.Hour + txnTTL = 270 * time.Second + txnIdleTTL = 60 * time.Second + sweepEvery = time.Minute + txnIDBytes = 16 + idSpace = 1_000_000_000_000_000 +) + +type entVer struct { + at time.Time + created time.Time + ent *datastorepb.Entity + version int64 +} + +// entRec is the version history of one entity; a nil ent marks a deletion. +type entRec struct { + key *datastorepb.Key + vers []entVer +} + +func (r *entRec) cur() *entVer { + if len(r.vers) == 0 || r.vers[len(r.vers)-1].ent == nil { + return nil + } + + return &r.vers[len(r.vers)-1] +} + +func (r *entRec) latest() time.Time { + if len(r.vers) == 0 { + return time.Time{} + } + + return r.vers[len(r.vers)-1].at +} + +func (r *entRec) at(t time.Time) *entVer { + for _, v := range slices.Backward(r.vers) { + if !v.at.After(t) { + if v.ent == nil { + return nil + } + + return &v + } + } + + return nil +} + +func (r *entRec) prune(now time.Time) { + cutoff := now.Add(-historyWindow) + keep := 0 + + for i := 0; i+1 < len(r.vers); i++ { + if !r.vers[i+1].at.After(cutoff) { + keep = i + 1 + } + } + + r.vers = r.vers[keep:] +} + +// change records one committed write for optimistic conflict detection. +type change struct { + at time.Time + key *datastorepb.Key + ks string +} + +// queryScope is what a transactional query read: entities of kind (or any kind) under ancestor. +type queryScope struct { + ancestor *datastorepb.Key + part partition + kind string +} + +func (q queryScope) covers(k *datastorepb.Key) bool { + if keyPartition(k) != q.part { + return false + } + + if q.kind != "" && keyKind(k) != q.kind { + return false + } + + return q.ancestor == nil || isDescendantOrSelf(k, q.ancestor) +} + +type txnRec struct { + created time.Time + lastUsed time.Time + readTime time.Time + reads map[string]struct{} + part partition + queries []queryScope + readOnly bool +} + +// InMemoryBackend holds every entity, transaction, reserved id and index under one lock. +type InMemoryBackend struct { + now func() time.Time + mu *lockmetrics.RWMutex + ents map[string]*entRec + txns map[string]*txnRec + ended map[string]time.Time + used map[string]map[int64]struct{} + indexes map[string]*adminpb.Index + ops *gcplro.Store + lastCommit time.Time + lastSweep time.Time + changes []change + nextIndex int64 + enforceIndexes bool +} + +// NewInMemoryBackend creates an empty backend that enforces composite indexes. +func NewInMemoryBackend() *InMemoryBackend { + return &InMemoryBackend{ + mu: lockmetrics.New("gcpdatastore"), + now: time.Now, + ents: make(map[string]*entRec), + txns: make(map[string]*txnRec), + ended: make(map[string]time.Time), + used: make(map[string]map[int64]struct{}), + indexes: make(map[string]*adminpb.Index), + ops: gcplro.New("gcpdatastore"), + enforceIndexes: true, + } +} + +// SetClock replaces the backend clock. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + + b.now = now +} + +// SetEnforceIndexes toggles FAILED_PRECONDITION for queries that need a missing composite index. +func (b *InMemoryBackend) SetEnforceIndexes(on bool) { + b.mu.Lock("SetEnforceIndexes") + defer b.mu.Unlock() + + b.enforceIndexes = on +} + +// Reset clears all entities, transactions, reserved ids, indexes and operations. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.ents = make(map[string]*entRec) + b.txns = make(map[string]*txnRec) + b.ended = make(map[string]time.Time) + b.used = make(map[string]map[int64]struct{}) + b.indexes = make(map[string]*adminpb.Index) + b.changes = nil + b.nextIndex = 0 + b.lastCommit = time.Time{} + b.mu.Unlock() + b.ops.Reset() +} + +func (b *InMemoryBackend) wallLocked() time.Time { return b.now().UTC().Truncate(time.Microsecond) } + +// readNowLocked is the read time of a latest-state read; it never precedes a commit. +func (b *InMemoryBackend) readNowLocked() time.Time { + t := b.wallLocked() + if t.Before(b.lastCommit) { + return b.lastCommit + } + + return t +} + +func (b *InMemoryBackend) sweepLocked(now time.Time) { + if now.Sub(b.lastSweep) < sweepEvery { + return + } + + b.lastSweep = now + + for ks, rec := range b.ents { + rec.prune(now) + + if rec.cur() == nil && len(rec.vers) == 1 && now.Sub(rec.latest()) > historyWindow { + delete(b.ents, ks) + } + } + + for id, at := range b.ended { + if now.Sub(at) > txnTTL { + delete(b.ended, id) + } + } + + oldest := now + for id, t := range b.txns { + if now.Sub(t.created) > txnTTL { + delete(b.txns, id) + + continue + } + + if t.readTime.Before(oldest) { + oldest = t.readTime + } + } + + b.changes = slices.DeleteFunc(b.changes, func(c change) bool { return c.at.Before(oldest.Add(-time.Second)) }) +} + +func newTxnID() (string, error) { + buf := make([]byte, txnIDBytes) + if _, err := rand.Read(buf); err != nil { + return "", errInvalid("transaction id generation failed: %v", err) + } + + return string(buf), nil +} + +func (b *InMemoryBackend) beginLocked(opts *datastorepb.TransactionOptions, part partition) (string, *txnRec, error) { + id, err := newTxnID() + if err != nil { + return "", nil, err + } + + now := b.wallLocked() + t := &txnRec{created: now, lastUsed: now, readTime: b.readNowLocked(), reads: map[string]struct{}{}, part: part} + + if ro := opts.GetReadOnly(); ro != nil { + t.readOnly = true + + if ro.GetReadTime() != nil { + if t.readTime, err = b.checkReadTimeLocked(ro.GetReadTime()); err != nil { + return "", nil, err + } + } + } + + b.txns[id] = t + + return id, t, nil +} + +func (b *InMemoryBackend) checkReadTimeLocked(ts *timestamppb.Timestamp) (time.Time, error) { + t := ts.AsTime().UTC().Truncate(time.Microsecond) + now := b.readNowLocked() + + if t.After(now) { + return time.Time{}, errInvalid("The requested read time is in the future.") + } + + if now.Sub(t) > historyWindow { + return time.Time{}, errInvalid("The requested read time is more than one hour in the past.") + } + + return t, nil +} + +func (b *InMemoryBackend) txnLocked(id []byte, part partition) (*txnRec, error) { + t, ok := b.txns[string(id)] + if !ok { + return nil, errBadTxn() + } + + now := b.wallLocked() + if now.Sub(t.created) > txnTTL || now.Sub(t.lastUsed) > txnIdleTTL { + delete(b.txns, string(id)) + + return nil, errBadTxn() + } + + if t.part.project != part.project || t.part.database != part.database { + return nil, errBadTxn() + } + + t.lastUsed = now + + return t, nil +} + +// readView is a consistent snapshot a read runs against. +type readView struct { + at time.Time + txn *txnRec + newTxnID []byte +} + +// read runs fn against the snapshot ro selects, holding the lock fn needs. +func (b *InMemoryBackend) read(ro *datastorepb.ReadOptions, part partition, fn func(rv *readView) error) error { + switch ct := ro.GetConsistencyType().(type) { + case *datastorepb.ReadOptions_Transaction: + b.mu.Lock("ReadTxn") + defer b.mu.Unlock() + + t, err := b.txnLocked(ct.Transaction, part) + if err != nil { + return err + } + + return fn(&readView{at: t.readTime, txn: t}) + case *datastorepb.ReadOptions_NewTransaction: + b.mu.Lock("ReadNewTxn") + defer b.mu.Unlock() + + id, t, err := b.beginLocked(ct.NewTransaction, part) + if err != nil { + return err + } + + return fn(&readView{at: t.readTime, txn: t, newTxnID: []byte(id)}) + case *datastorepb.ReadOptions_ReadTime: + b.mu.RLock("ReadAt") + defer b.mu.RUnlock() + + at, err := b.checkReadTimeLocked(ct.ReadTime) + if err != nil { + return err + } + + return fn(&readView{at: at}) + default: + b.mu.RLock("Read") + defer b.mu.RUnlock() + + return fn(&readView{at: b.readNowLocked()}) + } +} + +func (b *InMemoryBackend) entityAt(ks string, rv *readView) *entVer { + rec := b.ents[ks] + if rec == nil { + return nil + } + + return rec.at(rv.at) +} + +// reserveIDLocked records an explicit numeric id so auto-allocation never hands it out. +func (b *InMemoryBackend) reserveIDLocked(p partition, id int64) { + m := b.used[p.String()] + if m == nil { + m = make(map[int64]struct{}) + b.used[p.String()] = m + } + + m[id] = struct{}{} +} + +func (b *InMemoryBackend) allocIDLocked(p partition) (int64, error) { + for range 100 { + n, err := rand.Int(rand.Reader, big.NewInt(idSpace)) + if err != nil { + return 0, errInvalid("id generation failed: %v", err) + } + + id := n.Int64() + 1 + if _, taken := b.used[p.String()][id]; taken { + continue + } + + b.reserveIDLocked(p, id) + + return id, nil + } + + return 0, errInvalid("could not allocate a unique id") +} + +// endTxnLocked removes a finished transaction but remembers it so a late Rollback stays a no-op. +func (b *InMemoryBackend) endTxnLocked(id string) { + if _, ok := b.txns[id]; ok { + delete(b.txns, id) + b.ended[id] = b.wallLocked() + } +} diff --git a/services/gcpdatastore/transform.go b/services/gcpdatastore/transform.go new file mode 100644 index 0000000000..9f9ead2f5b --- /dev/null +++ b/services/gcpdatastore/transform.go @@ -0,0 +1,236 @@ +package gcpdatastore + +import ( + "math" + "strings" + "time" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func splitPath(p string) []string { return strings.Split(p, ".") } + +// transform applies property transforms in order and returns each one's result. +func (c *commitCtx) transform( + e *datastorepb.Entity, pts []*datastorepb.PropertyTransform, +) ([]*datastorepb.Value, error) { + if len(pts) == 0 { + return nil, nil + } + + out := make([]*datastorepb.Value, 0, len(pts)) + + for _, pt := range pts { + if pt.GetProperty() == "" || isReserved(pt.GetProperty()) { + return nil, errInvalid("Invalid property transform path %q.", pt.GetProperty()) + } + + parent := ensureParent(e, splitPath(pt.GetProperty())) + name := splitPath(pt.GetProperty())[len(splitPath(pt.GetProperty()))-1] + + res, err := applyTransform(parent.GetProperties()[name], pt, c.at) + if err != nil { + return nil, err + } + + if res.set != nil { + parent.GetProperties()[name] = res.set + } + + out = append(out, res.result) + } + + return out, nil +} + +func ensureParent(e *datastorepb.Entity, path []string) *datastorepb.Entity { + cur := e + if cur.GetProperties() == nil { + cur.Properties = map[string]*datastorepb.Value{} + } + + for _, head := range path[:len(path)-1] { + v := cur.GetProperties()[head] + ev, ok := v.GetValueType().(*datastorepb.Value_EntityValue) + + if !ok { + ev = &datastorepb.Value_EntityValue{EntityValue: &datastorepb.Entity{}} + cur.GetProperties()[head] = &datastorepb.Value{ValueType: ev} + } + + if ev.EntityValue.GetProperties() == nil { + ev.EntityValue.Properties = map[string]*datastorepb.Value{} + } + + cur = ev.EntityValue + } + + return cur +} + +type transformResult struct { + set *datastorepb.Value + result *datastorepb.Value +} + +func isNumber(v *datastorepb.Value) bool { + switch v.GetValueType().(type) { + case *datastorepb.Value_IntegerValue, *datastorepb.Value_DoubleValue: + return true + default: + return false + } +} + +func asFloat(v *datastorepb.Value) float64 { + if _, ok := v.GetValueType().(*datastorepb.Value_DoubleValue); ok { + return v.GetDoubleValue() + } + + return float64(v.GetIntegerValue()) +} + +func isDouble(v *datastorepb.Value) bool { + _, ok := v.GetValueType().(*datastorepb.Value_DoubleValue) + + return ok +} + +func addChecked(a, b int64) (int64, bool) { + s := a + b + if (a > 0 && b > 0 && s < 0) || (a < 0 && b < 0 && s >= 0) { + return s, true + } + + return s, false +} + +func addInts(a, b int64) int64 { + s, over := addChecked(a, b) + if !over { + return s + } + + if a > 0 { + return math.MaxInt64 + } + + return math.MinInt64 +} + +func applyTransform( + cur *datastorepb.Value, pt *datastorepb.PropertyTransform, at time.Time, +) (transformResult, error) { + switch t := pt.GetTransformType().(type) { + case *datastorepb.PropertyTransform_SetToServerValue: + if t.SetToServerValue != datastorepb.PropertyTransform_REQUEST_TIME { + return transformResult{}, errInvalid("Unsupported server value for property %q.", pt.GetProperty()) + } + + v := timestampValue(timestamppb.New(at)) + + return transformResult{set: v, result: v}, nil + case *datastorepb.PropertyTransform_Increment: + return numericTransform(cur, t.Increment, "increment") + case *datastorepb.PropertyTransform_Maximum: + return numericTransform(cur, t.Maximum, "maximum") + case *datastorepb.PropertyTransform_Minimum: + return numericTransform(cur, t.Minimum, "minimum") + case *datastorepb.PropertyTransform_AppendMissingElements: + return arrayTransform(cur, t.AppendMissingElements.GetValues(), true), nil + case *datastorepb.PropertyTransform_RemoveAllFromArray: + return arrayTransform(cur, t.RemoveAllFromArray.GetValues(), false), nil + default: + return transformResult{}, errInvalid("Property transform %q has no transform type.", pt.GetProperty()) + } +} + +func numericTransform(cur, arg *datastorepb.Value, op string) (transformResult, error) { + if !isNumber(arg) { + return transformResult{}, errInvalid("The %s transform requires an integer or double value.", op) + } + + if !isNumber(cur) { + return transformResult{set: arg, result: arg}, nil + } + + var v *datastorepb.Value + + switch op { + case "increment": + if isDouble(cur) || isDouble(arg) { + v = doubleValue(asFloat(cur) + asFloat(arg)) + } else { + v = intValue(addInts(cur.GetIntegerValue(), arg.GetIntegerValue())) + } + default: + v = pickExtreme(cur, arg, op == "maximum") + } + + return transformResult{set: v, result: v}, nil +} + +func pickExtreme(cur, arg *datastorepb.Value, wantMax bool) *datastorepb.Value { + a, b := asFloat(cur), asFloat(arg) + + switch { + case math.IsNaN(a): + return cur + case math.IsNaN(b): + return arg + case a == b: + return cur + case (a > b) == wantMax: + return cur + default: + return arg + } +} + +func arrayTransform(cur *datastorepb.Value, args []*datastorepb.Value, appendMissing bool) transformResult { + existing := arrayElems(cur) + next := make([]*datastorepb.Value, 0, len(existing)+len(args)) + + if appendMissing { + next = append(next, existing...) + + for _, a := range args { + if !containsValue(next, a) { + next = append(next, a) + } + } + } else { + for _, e := range existing { + if !containsValue(args, e) { + next = append(next, e) + } + } + } + + return transformResult{ + set: &datastorepb.Value{ + ValueType: &datastorepb.Value_ArrayValue{ArrayValue: &datastorepb.ArrayValue{Values: next}}, + }, + result: nullValue(), + } +} + +func containsValue(list []*datastorepb.Value, v *datastorepb.Value) bool { + for _, e := range list { + if bucketOf(e) == bucketOther || bucketOf(v) == bucketOther { + if proto.Equal(e, v) { + return true + } + + continue + } + + if compareValues(e, v) == 0 { + return true + } + } + + return false +} diff --git a/services/gcpdatastore/txn_test.go b/services/gcpdatastore/txn_test.go new file mode 100644 index 0000000000..f763b9faa9 --- /dev/null +++ b/services/gcpdatastore/txn_test.go @@ -0,0 +1,576 @@ +package gcpdatastore_test + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "cloud.google.com/go/datastore" + "cloud.google.com/go/datastore/apiv1/datastorepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type counter struct{ N int } + +func TestTransactionConflicts(t *testing.T) { + t.Parallel() + + tests := []struct { + want error + run func(t *testing.T, e *env, tx *datastore.Transaction) + name string + }{ + {datastore.ErrConcurrentTransaction, func(t *testing.T, e *env, tx *datastore.Transaction) { + t.Helper() + + var c counter + require.NoError(t, tx.Get(datastore.NameKey("C", "k", nil), &c)) + _, err := e.client.Put(t.Context(), datastore.NameKey("C", "k", nil), &counter{N: 9}) + require.NoError(t, err) + _, err = tx.Put(datastore.NameKey("C", "other", nil), &counter{N: 1}) + require.NoError(t, err) + }, "read entity changed outside"}, + {nil, func(t *testing.T, e *env, tx *datastore.Transaction) { + t.Helper() + + var c counter + require.NoError(t, tx.Get(datastore.NameKey("C", "k", nil), &c)) + _, err := e.client.Put(t.Context(), datastore.NameKey("C", "unrelated", nil), &counter{N: 9}) + require.NoError(t, err) + _, err = tx.Put(datastore.NameKey("C", "k", nil), &counter{N: 2}) + require.NoError(t, err) + }, "unrelated entity changed outside"}, + {datastore.ErrConcurrentTransaction, func(t *testing.T, e *env, tx *datastore.Transaction) { + t.Helper() + + var c counter + require.NoError(t, tx.Get(datastore.NameKey("C", "k", nil), &c)) + _, err := e.client.Put(t.Context(), datastore.NameKey("C", "blind", nil), &counter{N: 1}) + require.NoError(t, err) + _, err = tx.Put(datastore.NameKey("C", "blind", nil), &counter{N: 2}) + require.NoError(t, err) + }, "write write on same entity"}, + {datastore.ErrConcurrentTransaction, func(t *testing.T, e *env, tx *datastore.Transaction) { + t.Helper() + + var out []counter + _, err := e.client.GetAll(t.Context(), datastore.NewQuery("C").Transaction(tx), &out) + require.NoError(t, err) + _, err = e.client.Put(t.Context(), datastore.NameKey("C", "late", nil), &counter{N: 3}) + require.NoError(t, err) + _, err = tx.Put(datastore.NameKey("Elsewhere", "x", nil), &counter{N: 1}) + require.NoError(t, err) + }, "query scope gains an entity"}, + {nil, func(t *testing.T, e *env, tx *datastore.Transaction) { + t.Helper() + + var out []counter + _, err := e.client.GetAll(t.Context(), datastore.NewQuery("C").Transaction(tx), &out) + require.NoError(t, err) + _, err = e.client.Put(t.Context(), datastore.NameKey("Elsewhere", "y", nil), &counter{N: 3}) + require.NoError(t, err) + _, err = tx.Put(datastore.NameKey("Elsewhere", "x", nil), &counter{N: 1}) + require.NoError(t, err) + }, "query scope of another kind"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.handler.Backend.SetEnforceIndexes(false) + + _, err := e.client.Put(t.Context(), datastore.NameKey("C", "k", nil), &counter{N: 1}) + require.NoError(t, err) + + tx, err := e.client.NewTransaction(t.Context()) + require.NoError(t, err) + + tt.run(t, e, tx) + + _, err = tx.Commit() + if tt.want == nil { + assert.NoError(t, err) + } else { + assert.ErrorIs(t, err, tt.want) + } + }) + } +} + +func TestTransactionLifecycle(t *testing.T) { + t.Parallel() + + t.Run("rollback discards writes", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + tx, err := e.client.NewTransaction(t.Context()) + require.NoError(t, err) + + _, err = tx.Put(datastore.NameKey("C", "k", nil), &counter{N: 1}) + require.NoError(t, err) + require.NoError(t, tx.Rollback()) + + var c counter + assert.ErrorIs(t, e.client.Get(t.Context(), datastore.NameKey("C", "k", nil), &c), datastore.ErrNoSuchEntity) + }) + + t.Run("read only transactions see a snapshot and reject writes", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + key := datastore.NameKey("C", "k", nil) + _, err := e.client.Put(t.Context(), key, &counter{N: 1}) + require.NoError(t, err) + + tx, err := e.client.NewTransaction(t.Context(), datastore.ReadOnly) + require.NoError(t, err) + + _, err = e.client.Put(t.Context(), key, &counter{N: 2}) + require.NoError(t, err) + + var c counter + require.NoError(t, tx.Get(key, &c)) + assert.Equal(t, 1, c.N) + + _, err = tx.Commit() + require.NoError(t, err) + + ro, err := e.client.NewTransaction(t.Context(), datastore.ReadOnly) + require.NoError(t, err) + + _, err = ro.Put(key, &counter{N: 3}) + require.NoError(t, err) + + _, err = ro.Commit() + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("unknown transaction id", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.raw.Commit(t.Context(), &datastorepb.CommitRequest{ + ProjectId: project, + TransactionSelector: &datastorepb.CommitRequest_Transaction{Transaction: []byte("nope")}, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + _, err = e.raw.Rollback( + t.Context(), + &datastorepb.RollbackRequest{ProjectId: project, Transaction: []byte("nope")}, + ) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("transactional commit needs a transaction", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.raw.Commit(t.Context(), &datastorepb.CommitRequest{ProjectId: project}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("lookup can begin a transaction", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + resp, err := e.raw.Lookup(t.Context(), &datastorepb.LookupRequest{ + ProjectId: project, + Keys: []*datastorepb.Key{ + { + Path: []*datastorepb.Key_PathElement{ + {Kind: "C", IdType: &datastorepb.Key_PathElement_Name{Name: "k"}}, + }, + }, + }, + ReadOptions: &datastorepb.ReadOptions{ConsistencyType: &datastorepb.ReadOptions_NewTransaction{ + NewTransaction: &datastorepb.TransactionOptions{}, + }}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.GetTransaction()) + assert.Len(t, resp.GetMissing(), 1) + + _, err = e.raw.Commit(t.Context(), &datastorepb.CommitRequest{ + ProjectId: project, + TransactionSelector: &datastorepb.CommitRequest_Transaction{Transaction: resp.GetTransaction()}, + }) + assert.NoError(t, err) + }) +} + +func TestRunInTransactionRetries(t *testing.T) { + t.Parallel() + + t.Run("retries after a conflict", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + key := datastore.NameKey("C", "k", nil) + _, err := e.client.Put(t.Context(), key, &counter{N: 1}) + require.NoError(t, err) + + var attempts atomic.Int32 + + _, err = e.client.RunInTransaction(t.Context(), func(tx *datastore.Transaction) error { + var c counter + if gerr := tx.Get(key, &c); gerr != nil { + return gerr + } + + if attempts.Add(1) == 1 { + if _, perr := e.client.Put(t.Context(), key, &counter{N: 10}); perr != nil { + return perr + } + } + + c.N++ + _, perr := tx.Put(key, &c) + + return perr + }) + require.NoError(t, err) + assert.EqualValues(t, 2, attempts.Load()) + + var c counter + require.NoError(t, e.client.Get(t.Context(), key, &c)) + assert.Equal(t, 11, c.N) + }) + + t.Run("concurrent increments are serialisable", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + key := datastore.NameKey("C", "k", nil) + _, err := e.client.Put(t.Context(), key, &counter{}) + require.NoError(t, err) + + const workers = 8 + + var wg sync.WaitGroup + + errs := make(chan error, workers) + + for range workers { + wg.Go(func() { + _, terr := e.client.RunInTransaction(t.Context(), func(tx *datastore.Transaction) error { + var c counter + if gerr := tx.Get(key, &c); gerr != nil { + return gerr + } + + c.N++ + _, perr := tx.Put(key, &c) + + return perr + }, datastore.MaxAttempts(50)) + errs <- terr + }) + } + + wg.Wait() + close(errs) + + for terr := range errs { + require.NoError(t, terr) + } + + var c counter + require.NoError(t, e.client.Get(t.Context(), key, &c)) + assert.Equal(t, workers, c.N) + }) +} + +func nameKey(name string) *datastorepb.Key { + return &datastorepb.Key{ + Path: []*datastorepb.Key_PathElement{{Kind: "K", IdType: &datastorepb.Key_PathElement_Name{Name: name}}}, + } +} + +func strEntity(name string, props map[string]*datastorepb.Value) *datastorepb.Entity { + return &datastorepb.Entity{Key: nameKey(name), Properties: props} +} + +func intVal(n int64) *datastorepb.Value { + return &datastorepb.Value{ValueType: &datastorepb.Value_IntegerValue{IntegerValue: n}} +} + +func strVal(s string) *datastorepb.Value { + return &datastorepb.Value{ValueType: &datastorepb.Value_StringValue{StringValue: s}} +} + +func upsert(e *datastorepb.Entity) *datastorepb.Mutation { + return &datastorepb.Mutation{Operation: &datastorepb.Mutation_Upsert{Upsert: e}} +} + +func (e *env) commit(t *testing.T, muts ...*datastorepb.Mutation) (*datastorepb.CommitResponse, error) { + t.Helper() + + return e.raw.Commit(t.Context(), &datastorepb.CommitRequest{ + ProjectId: project, Mode: datastorepb.CommitRequest_NON_TRANSACTIONAL, Mutations: muts, + }) +} + +func TestMutationSemantics(t *testing.T) { + t.Parallel() + + t.Run("insert update and versions", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + ins := &datastorepb.Mutation{ + Operation: &datastorepb.Mutation_Insert{ + Insert: strEntity("a", map[string]*datastorepb.Value{"n": intVal(1)}), + }, + } + + r1, err := e.commit(t, ins) + require.NoError(t, err) + assert.NotZero(t, r1.GetMutationResults()[0].GetVersion()) + assert.NotNil(t, r1.GetCommitTime()) + + _, err = e.commit(t, ins) + assert.Equal(t, codes.AlreadyExists, status.Code(err)) + + upd := &datastorepb.Mutation{ + Operation: &datastorepb.Mutation_Update{ + Update: strEntity("a", map[string]*datastorepb.Value{"n": intVal(2)}), + }, + } + r2, err := e.commit(t, upd) + require.NoError(t, err) + assert.Greater(t, r2.GetMutationResults()[0].GetVersion(), r1.GetMutationResults()[0].GetVersion()) + assert.Equal( + t, + r1.GetMutationResults()[0].GetCreateTime().AsTime(), + r2.GetMutationResults()[0].GetCreateTime().AsTime(), + ) + + missing := &datastorepb.Mutation{Operation: &datastorepb.Mutation_Update{Update: strEntity("zz", nil)}} + _, err = e.commit(t, missing) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + + t.Run("base version conflicts", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + r1, err := e.commit(t, upsert(strEntity("a", map[string]*datastorepb.Value{"n": intVal(1)}))) + require.NoError(t, err) + + v1 := r1.GetMutationResults()[0].GetVersion() + + guarded := func(base int64, strat datastorepb.Mutation_ConflictResolutionStrategy, n int64) *datastorepb.Mutation { + m := upsert(strEntity("a", map[string]*datastorepb.Value{"n": intVal(n)})) + m.ConflictDetectionStrategy = &datastorepb.Mutation_BaseVersion{BaseVersion: base} + m.ConflictResolutionStrategy = strat + + return m + } + + r2, err := e.commit(t, guarded(v1, datastorepb.Mutation_STRATEGY_UNSPECIFIED, 2)) + require.NoError(t, err) + assert.False(t, r2.GetMutationResults()[0].GetConflictDetected()) + + r3, err := e.commit(t, guarded(v1, datastorepb.Mutation_SERVER_VALUE, 3)) + require.NoError(t, err) + assert.True(t, r3.GetMutationResults()[0].GetConflictDetected()) + assert.Equal(t, r2.GetMutationResults()[0].GetVersion(), r3.GetMutationResults()[0].GetVersion()) + + _, err = e.commit(t, guarded(v1, datastorepb.Mutation_FAIL, 4)) + assert.Equal(t, codes.Aborted, status.Code(err)) + + var got struct{ N int } + require.NoError(t, e.client.Get(t.Context(), datastore.NameKey("K", "a", nil), &got)) + assert.Equal(t, 2, got.N) + }) + + t.Run("property mask updates only masked paths", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.commit( + t, + upsert( + strEntity( + "a", + map[string]*datastorepb.Value{"keep": intVal(1), "change": intVal(1), "drop": intVal(1)}, + ), + ), + ) + require.NoError(t, err) + + m := upsert(strEntity("a", map[string]*datastorepb.Value{"change": intVal(2), "ignored": intVal(9)})) + m.PropertyMask = &datastorepb.PropertyMask{Paths: []string{"change", "drop"}} + _, err = e.commit(t, m) + require.NoError(t, err) + + resp, err := e.raw.Lookup( + t.Context(), + &datastorepb.LookupRequest{ProjectId: project, Keys: []*datastorepb.Key{nameKey("a")}}, + ) + require.NoError(t, err) + + props := resp.GetFound()[0].GetEntity().GetProperties() + assert.EqualValues(t, 1, props["keep"].GetIntegerValue()) + assert.EqualValues(t, 2, props["change"].GetIntegerValue()) + assert.NotContains(t, props, "drop") + assert.NotContains(t, props, "ignored") + }) + + t.Run("property transforms", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.commit(t, upsert(strEntity("a", map[string]*datastorepb.Value{"n": intVal(5)}))) + require.NoError(t, err) + + m := upsert(strEntity("a", map[string]*datastorepb.Value{"n": intVal(5)})) + m.PropertyTransforms = []*datastorepb.PropertyTransform{ + {Property: "n", TransformType: &datastorepb.PropertyTransform_Increment{Increment: intVal(3)}}, + {Property: "hi", TransformType: &datastorepb.PropertyTransform_Maximum{Maximum: intVal(10)}}, + { + Property: "stamp", + TransformType: &datastorepb.PropertyTransform_SetToServerValue{ + SetToServerValue: datastorepb.PropertyTransform_REQUEST_TIME, + }, + }, + { + Property: "tags", + TransformType: &datastorepb.PropertyTransform_AppendMissingElements{ + AppendMissingElements: &datastorepb.ArrayValue{ + Values: []*datastorepb.Value{strVal("a"), strVal("b")}, + }, + }, + }, + } + + resp, err := e.commit(t, m) + require.NoError(t, err) + assert.EqualValues(t, 8, resp.GetMutationResults()[0].GetTransformResults()[0].GetIntegerValue()) + + got, err := e.raw.Lookup( + t.Context(), + &datastorepb.LookupRequest{ProjectId: project, Keys: []*datastorepb.Key{nameKey("a")}}, + ) + require.NoError(t, err) + + props := got.GetFound()[0].GetEntity().GetProperties() + assert.EqualValues(t, 8, props["n"].GetIntegerValue()) + assert.EqualValues(t, 10, props["hi"].GetIntegerValue()) + assert.NotNil(t, props["stamp"].GetTimestampValue()) + assert.Len(t, props["tags"].GetArrayValue().GetValues(), 2) + }) + + t.Run("non transactional commit rejects duplicate entities", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.commit(t, upsert(strEntity("a", nil)), upsert(strEntity("a", nil))) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("failed commit applies nothing", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + bad := &datastorepb.Mutation{Operation: &datastorepb.Mutation_Update{Update: strEntity("missing", nil)}} + _, err := e.commit(t, upsert(strEntity("a", nil)), bad) + require.Error(t, err) + + var got struct{} + assert.ErrorIs(t, e.client.Get(t.Context(), datastore.NameKey("K", "a", nil), &got), datastore.ErrNoSuchEntity) + }) +} + +func TestReadTimeAndIDs(t *testing.T) { + t.Parallel() + + t.Run("read time sees history", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + r1, err := e.commit(t, upsert(strEntity("a", map[string]*datastorepb.Value{"n": intVal(1)}))) + require.NoError(t, err) + + _, err = e.commit(t, upsert(strEntity("a", map[string]*datastorepb.Value{"n": intVal(2)}))) + require.NoError(t, err) + + at := func(ts *timestamppb.Timestamp) (*datastorepb.LookupResponse, error) { + return e.raw.Lookup(t.Context(), &datastorepb.LookupRequest{ + ProjectId: project, Keys: []*datastorepb.Key{nameKey("a")}, + ReadOptions: &datastorepb.ReadOptions{ConsistencyType: &datastorepb.ReadOptions_ReadTime{ReadTime: ts}}, + }) + } + + old, err := at(r1.GetCommitTime()) + require.NoError(t, err) + assert.EqualValues(t, 1, old.GetFound()[0].GetEntity().GetProperties()["n"].GetIntegerValue()) + + before, err := at(timestamppb.New(r1.GetCommitTime().AsTime().Add(-time.Millisecond))) + require.NoError(t, err) + assert.Len(t, before.GetMissing(), 1) + + _, err = at(timestamppb.New(time.Now().Add(-2 * time.Hour))) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("allocate ids", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + parent := datastore.NameKey("P", "p", nil) + keys, err := e.client.AllocateIDs(t.Context(), []*datastore.Key{ + datastore.IncompleteKey( + "K", + parent, + ), + datastore.IncompleteKey("K", parent), + datastore.IncompleteKey("K", nil), + }) + require.NoError(t, err) + require.Len(t, keys, 3) + + seen := map[int64]bool{} + for _, k := range keys { + assert.NotZero(t, k.ID) + assert.False(t, seen[k.ID]) + seen[k.ID] = true + } + + assert.Equal(t, "p", keys[0].Parent.Name) + + _, err = e.client.AllocateIDs(t.Context(), []*datastore.Key{datastore.NameKey("K", "n", nil)}) + assert.Error(t, err) + }) + + t.Run("reserve ids and explicit ids", func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + _, err := e.raw.ReserveIds(t.Context(), &datastorepb.ReserveIdsRequest{ + ProjectId: project, + Keys: []*datastorepb.Key{ + {Path: []*datastorepb.Key_PathElement{{Kind: "K", IdType: &datastorepb.Key_PathElement_Id{Id: 77}}}}, + }, + }) + require.NoError(t, err) + + _, err = e.raw.ReserveIds(t.Context(), &datastorepb.ReserveIdsRequest{ + ProjectId: project, Keys: []*datastorepb.Key{{Path: []*datastorepb.Key_PathElement{{Kind: "K"}}}}, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + k, err := e.client.Put(t.Context(), datastore.IDKey("K", 77, nil), &task{Name: "x"}) + require.NoError(t, err) + assert.EqualValues(t, 77, k.ID) + }) +} diff --git a/services/gcpdatastore/values.go b/services/gcpdatastore/values.go new file mode 100644 index 0000000000..86666c9abe --- /dev/null +++ b/services/gcpdatastore/values.go @@ -0,0 +1,362 @@ +package gcpdatastore + +import ( + "bytes" + "math" + "strings" + + "cloud.google.com/go/datastore/apiv1/datastorepb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + maxIndexedBytes = 1500 + maxUnindexedSize = 1000000 + maxEntityBytes = 1048572 + maxEntityDepth = 20 + meaningReserved = 18 + keyProperty = "__key__" + entriesPerValue = 2 + nanosPerMicro = 1000 +) + +// Value type buckets in index order; values of different buckets never satisfy an inequality together. +const ( + bucketNull = iota + bucketInt + bucketBool + bucketBlob + bucketString + bucketDouble + bucketGeo + bucketKey + bucketOther +) + +func bucketOf(v *datastorepb.Value) int { + switch v.GetValueType().(type) { + case *datastorepb.Value_NullValue: + return bucketNull + case *datastorepb.Value_IntegerValue, *datastorepb.Value_TimestampValue: + return bucketInt + case *datastorepb.Value_BooleanValue: + return bucketBool + case *datastorepb.Value_BlobValue: + return bucketBlob + case *datastorepb.Value_StringValue: + return bucketString + case *datastorepb.Value_DoubleValue: + return bucketDouble + case *datastorepb.Value_GeoPointValue: + return bucketGeo + case *datastorepb.Value_KeyValue: + return bucketKey + default: + return bucketOther + } +} + +func intOf(v *datastorepb.Value) int64 { + if t, ok := v.GetValueType().(*datastorepb.Value_TimestampValue); ok { + return t.TimestampValue.AsTime().UnixMicro() + } + + return v.GetIntegerValue() +} + +func cmpOrdered[T int64 | float64](a, b T) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +func cmpDouble(a, b float64) int { + an, bn := math.IsNaN(a), math.IsNaN(b) + + switch { + case an && bn: + return 0 + case an: + return 1 + case bn: + return -1 + default: + return cmpOrdered(a, b) + } +} + +func cmpBool(a, b bool) int { + switch { + case a == b: + return 0 + case !a: + return -1 + default: + return 1 + } +} + +// compareValues totals the index order: by type bucket, then within the type. +func compareValues(a, b *datastorepb.Value) int { + ba, bb := bucketOf(a), bucketOf(b) + if ba != bb { + return cmpOrdered(int64(ba), int64(bb)) + } + + switch ba { + case bucketInt: + return cmpOrdered(intOf(a), intOf(b)) + case bucketBool: + return cmpBool(a.GetBooleanValue(), b.GetBooleanValue()) + case bucketBlob: + return bytes.Compare(a.GetBlobValue(), b.GetBlobValue()) + case bucketString: + return strings.Compare(a.GetStringValue(), b.GetStringValue()) + case bucketDouble: + return cmpDouble(a.GetDoubleValue(), b.GetDoubleValue()) + case bucketGeo: + if c := cmpOrdered(a.GetGeoPointValue().GetLatitude(), b.GetGeoPointValue().GetLatitude()); c != 0 { + return c + } + + return cmpOrdered(a.GetGeoPointValue().GetLongitude(), b.GetGeoPointValue().GetLongitude()) + case bucketKey: + return compareKeys(a.GetKeyValue(), b.GetKeyValue()) + default: + return 0 + } +} + +func keyValue(k *datastorepb.Key) *datastorepb.Value { + return &datastorepb.Value{ValueType: &datastorepb.Value_KeyValue{KeyValue: k}} +} + +func intValue(n int64) *datastorepb.Value { + return &datastorepb.Value{ValueType: &datastorepb.Value_IntegerValue{IntegerValue: n}} +} + +func doubleValue(f float64) *datastorepb.Value { + return &datastorepb.Value{ValueType: &datastorepb.Value_DoubleValue{DoubleValue: f}} +} + +func nullValue() *datastorepb.Value { + return &datastorepb.Value{ValueType: &datastorepb.Value_NullValue{}} +} + +func timestampValue(t *timestamppb.Timestamp) *datastorepb.Value { + return &datastorepb.Value{ValueType: &datastorepb.Value_TimestampValue{TimestampValue: t}} +} + +func arrayElems(v *datastorepb.Value) []*datastorepb.Value { + if a, ok := v.GetValueType().(*datastorepb.Value_ArrayValue); ok { + return a.ArrayValue.GetValues() + } + + return nil +} + +func isArray(v *datastorepb.Value) bool { + _, ok := v.GetValueType().(*datastorepb.Value_ArrayValue) + + return ok +} + +// indexedLeaves flattens v into the values an index holds: array elements individually, unindexed ones dropped. +func indexedLeaves(v *datastorepb.Value) []*datastorepb.Value { + if isArray(v) { + out := make([]*datastorepb.Value, 0, len(arrayElems(v))) + + for _, e := range arrayElems(v) { + out = append(out, indexedLeaves(e)...) + } + + return out + } + + if v.GetExcludeFromIndexes() || bucketOf(v) == bucketOther { + return nil + } + + return []*datastorepb.Value{v} +} + +// propValues resolves a (possibly dotted) property path to its indexed values. +func propValues(e *datastorepb.Entity, path string) []*datastorepb.Value { + if v, ok := e.GetProperties()[path]; ok { + return indexedLeaves(v) + } + + head, rest, dotted := strings.Cut(path, ".") + if !dotted { + return nil + } + + v, ok := e.GetProperties()[head] + if !ok { + return nil + } + + return descend(v, rest) +} + +func descend(v *datastorepb.Value, rest string) []*datastorepb.Value { + if isArray(v) { + out := make([]*datastorepb.Value, 0, len(arrayElems(v))) + + for _, el := range arrayElems(v) { + out = append(out, descend(el, rest)...) + } + + return out + } + + if v.GetExcludeFromIndexes() { + return nil + } + + if ev, ok := v.GetValueType().(*datastorepb.Value_EntityValue); ok { + return propValues(ev.EntityValue, rest) + } + + return nil +} + +func countIndexed(e *datastorepb.Entity) int { + if e == nil { + return 0 + } + + n := 0 + + for _, v := range e.GetProperties() { + n += entriesPerValue * len(indexedLeaves(v)) + } + + return n +} + +func checkValue(name string, v *datastorepb.Value, depth int, inArray bool) error { + if v.GetMeaning() == meaningReserved { + return errInvalid("The value of property %q must not have meaning %d.", name, meaningReserved) + } + + switch t := v.GetValueType().(type) { + case *datastorepb.Value_ArrayValue: + if inArray { + return errInvalid("Property %q: an array value cannot contain another array.", name) + } + + for _, el := range t.ArrayValue.GetValues() { + if err := checkValue(name, el, depth, true); err != nil { + return err + } + } + case *datastorepb.Value_StringValue: + return checkSize(name, len(t.StringValue), v.GetExcludeFromIndexes()) + case *datastorepb.Value_BlobValue: + return checkSize(name, len(t.BlobValue), v.GetExcludeFromIndexes()) + case *datastorepb.Value_EntityValue: + if depth+1 > maxEntityDepth { + return errInvalid("Property %q: entity values are nested deeper than %d levels.", name, maxEntityDepth) + } + + return checkProps(t.EntityValue, depth+1) + case *datastorepb.Value_KeyValue: + if t.KeyValue == nil || len(t.KeyValue.GetPath()) == 0 { + return errInvalid("Property %q has an empty key value.", name) + } + } + + return nil +} + +func checkSize(name string, n int, unindexed bool) error { + if unindexed { + if n > maxUnindexedSize { + return errInvalid("The value of property %q is longer than %d bytes.", name, maxUnindexedSize) + } + + return nil + } + + if n > maxIndexedBytes { + return errInvalid("The value of property %q is longer than %d bytes.", name, maxIndexedBytes) + } + + return nil +} + +func checkProps(e *datastorepb.Entity, depth int) error { + for name, v := range e.GetProperties() { + if name == "" { + return errInvalid("Property names must not be empty.") + } + + if len(name) > maxNameBytes { + return errInvalid("Property name %q is longer than %d bytes.", name, maxNameBytes) + } + + if isReserved(name) { + return errInvalid("Property name %q is reserved/read-only.", name) + } + + if v == nil || v.GetValueType() == nil { + return errInvalid("Property %q has no value.", name) + } + + if err := checkValue(name, v, depth, false); err != nil { + return err + } + } + + return nil +} + +// prepareEntity validates e and returns a copy with timestamps truncated to microseconds. +func prepareEntity(e *datastorepb.Entity, req partition) (*datastorepb.Entity, error) { + if err := checkProps(e, 0); err != nil { + return nil, err + } + + out, ok := proto.Clone(e).(*datastorepb.Entity) + if !ok { + return nil, errInvalid("invalid entity") + } + + truncateTimes(out) + + for _, pv := range out.GetProperties() { + stampValueKeys(pv, func(k *datastorepb.Key) *datastorepb.Key { return stampKey(k, req, false) }) + } + + if proto.Size(out) > maxEntityBytes { + return nil, errInvalid("The entity is larger than the maximum of %d bytes.", maxEntityBytes) + } + + return out, nil +} + +func truncateTimes(e *datastorepb.Entity) { + for _, v := range e.GetProperties() { + truncateValue(v) + } +} + +func truncateValue(v *datastorepb.Value) { + switch t := v.GetValueType().(type) { + case *datastorepb.Value_TimestampValue: + t.TimestampValue.Nanos -= t.TimestampValue.GetNanos() % nanosPerMicro + case *datastorepb.Value_ArrayValue: + for _, el := range t.ArrayValue.GetValues() { + truncateValue(el) + } + case *datastorepb.Value_EntityValue: + truncateTimes(t.EntityValue) + } +} diff --git a/services/gcpdns/PARITY.md b/services/gcpdns/PARITY.md index 9e6feca49c..5145e742b3 100644 --- a/services/gcpdns/PARITY.md +++ b/services/gcpdns/PARITY.md @@ -1,6 +1,6 @@ --- service: gcpdns -sdk_module: google.golang.org/api@v0.302.0 +sdk_module: google.golang.org/api@v0.304.0 last_audit_commit: 423518f77 last_audit_date: 2026-10-25 overall: B diff --git a/services/gcpdns/README.md b/services/gcpdns/README.md index e161c828ab..0a58974d7c 100644 --- a/services/gcpdns/README.md +++ b/services/gcpdns/README.md @@ -1,7 +1,7 @@ # Cloud DNS -**Parity grade: B** · SDK `google.golang.org/api@v0.302.0` · last audited 2026-10-25 (`423518f77`) +**Parity grade: B** · SDK `google.golang.org/api@v0.304.0` · last audited 2026-10-25 (`423518f77`) ## Coverage diff --git a/services/gcpdns/dataplane_test.go b/services/gcpdns/dataplane_test.go index 147c4f5021..a498199518 100644 --- a/services/gcpdns/dataplane_test.go +++ b/services/gcpdns/dataplane_test.go @@ -25,7 +25,7 @@ func newLiveEnv(t *testing.T) *liveEnv { backend := gcpdns.NewInMemoryBackend() h := gcpdns.NewHandler(backend) - h.Port, h.DNSPort = freePort(t), freePort(t) + h.Port, h.DNSEphemeral = 0, true require.NoError(t, h.StartWorker(t.Context())) t.Cleanup(func() { h.Shutdown(t.Context()) }) diff --git a/services/gcpdns/handler.go b/services/gcpdns/handler.go index f5468a4ec2..885c5f6025 100644 --- a/services/gcpdns/handler.go +++ b/services/gcpdns/handler.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "strconv" "strings" @@ -16,8 +15,10 @@ import ( dnsapi "google.golang.org/api/dns/v1" pkgdns "github.com/blackbirdworks/gopherstack/pkgs/dns" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -41,6 +42,8 @@ type Handler struct { addr string Port int DNSPort int + // DNSEphemeral binds the DNS server to an OS-assigned port (recorded in DNSPort) instead of DNSPort. + DNSEphemeral bool } var ( @@ -463,7 +466,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { var dnsSrv *pkgdns.Server - if h.DNSPort > 0 { + if h.DNSPort > 0 || h.DNSEphemeral { var err error if dnsSrv, err = h.startDNS(workerCtx); err != nil { @@ -471,9 +474,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { } } - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { if dnsSrv != nil { _ = dnsSrv.Stop() @@ -482,8 +483,11 @@ func (h *Handler) StartWorker(ctx context.Context) error { return fmt.Errorf("gcpdns: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(log)) + e.Use(gcpenforce.EchoMiddleware(ctx, gcpenforce.Target{Host: "dns.googleapis.com"})) e.Any("/*", telemetry.WrapEchoHandler("GCPDNS", h.Handler(), h)) srv := &http.Server{ @@ -506,7 +510,12 @@ func (h *Handler) StartWorker(ctx context.Context) error { } func (h *Handler) startDNS(ctx context.Context) (*pkgdns.Server, error) { - srv, err := pkgdns.New(pkgdns.Config{ListenAddr: fmt.Sprintf(":%d", h.DNSPort), Logger: logger.Load(ctx)}) + port := h.DNSPort + if h.DNSEphemeral { + port = 0 + } + + srv, err := pkgdns.New(pkgdns.Config{ListenAddr: fmt.Sprintf(":%d", port), Logger: logger.Load(ctx)}) if err != nil { return nil, fmt.Errorf("gcpdns: dns server: %w", err) } @@ -517,6 +526,10 @@ func (h *Handler) startDNS(ctx context.Context) (*pkgdns.Server, error) { return nil, fmt.Errorf("gcpdns: bind dns port %d: %w", h.DNSPort, err) } + if h.DNSEphemeral { + h.DNSPort = srv.Port() + } + return srv, nil } diff --git a/services/gcpdns/handler_test.go b/services/gcpdns/handler_test.go index f128443800..37543094fe 100644 --- a/services/gcpdns/handler_test.go +++ b/services/gcpdns/handler_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/gcpdns" ) @@ -43,7 +44,7 @@ func TestWireErrorsAndRouting(t *testing.T) { req, err := http.NewRequestWithContext(t.Context(), tt.method, e.url+tt.path, strings.NewReader(tt.body)) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -81,7 +82,7 @@ func TestWireShapes(t *testing.T) { e.zone(t, "z", "z.com.") get := func(path string) map[string]any { - resp, err := http.Get(e.url + path) + resp, err := testclient.New(t).Get(e.url + path) require.NoError(t, err) defer resp.Body.Close() @@ -131,16 +132,18 @@ func TestStartWorkerPortConflict(t *testing.T) { t.Parallel() first := gcpdns.NewHandler(gcpdns.NewInMemoryBackend()) - first.Port, first.DNSPort = freePort(t), freePort(t) + first.Port, first.DNSEphemeral = 0, true require.NoError(t, first.StartWorker(t.Context())) t.Cleanup(func() { first.Shutdown(t.Context()) }) + require.NotZero(t, first.Port) + require.NotZero(t, first.DNSPort) for name, ports := range map[string][2]int{ - "rest port taken": {first.Port, freePort(t)}, - "dns port taken": {freePort(t), first.DNSPort}, + "rest port taken": {first.Port, 0}, + "dns port taken": {0, first.DNSPort}, } { second := gcpdns.NewHandler(gcpdns.NewInMemoryBackend()) - second.Port, second.DNSPort = ports[0], ports[1] + second.Port, second.DNSPort, second.DNSEphemeral = ports[0], ports[1], ports[1] == 0 require.Error(t, second.StartWorker(t.Context()), name) second.Shutdown(t.Context()) } diff --git a/services/gcpdns/helpers_test.go b/services/gcpdns/helpers_test.go index 9fc91a8e29..f095141e67 100644 --- a/services/gcpdns/helpers_test.go +++ b/services/gcpdns/helpers_test.go @@ -1,9 +1,7 @@ package gcpdns_test import ( - "net" "net/http/httptest" - "strconv" "testing" "github.com/labstack/echo/v5" @@ -85,28 +83,3 @@ func assertAPIError(t *testing.T, err error, status int, reason string) { func rrset(name, typ string, ttl int64, data ...string) *dnsapi.ResourceRecordSet { return &dnsapi.ResourceRecordSet{Name: name, Type: typ, Ttl: ttl, Rrdatas: data} } - -func freePort(t *testing.T) int { - t.Helper() - - for range 50 { - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - port := l.Addr().(*net.TCPAddr).Port - _ = l.Close() - - pc, err := net.ListenPacket("udp", "127.0.0.1:"+strconv.Itoa(port)) - if err != nil { - continue - } - - _ = pc.Close() - - return port - } - - t.Fatal("no free tcp+udp port") - - return 0 -} diff --git a/services/gcpeventarc/delivery.go b/services/gcpeventarc/delivery.go index 4daaa62b77..3e0c681290 100644 --- a/services/gcpeventarc/delivery.go +++ b/services/gcpeventarc/delivery.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/blackbirdworks/gopherstack/pkgs/gcpdispatch" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/logger" ) @@ -249,6 +250,7 @@ func (b *InMemoryBackend) send(ctx context.Context, t *eventarcpb.Trigger, targe } req.Header.Set("Content-Type", contentTypeFor(t)) + gcpserve.MarkInternal(req) req.Header.Set("Ce-Specversion", "1.0") req.Header.Set("Ce-Id", ev.id) req.Header.Set("Ce-Source", ev.source) diff --git a/services/gcpeventarc/filter.go b/services/gcpeventarc/filter.go index 9cc39ea6f1..87a6355a22 100644 --- a/services/gcpeventarc/filter.go +++ b/services/gcpeventarc/filter.go @@ -2,9 +2,10 @@ package gcpeventarc import ( "regexp" - "strings" "cloud.google.com/go/eventarc/apiv1/eventarcpb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpevents" ) // eventType returns the value of the trigger's exact "type" filter. @@ -140,48 +141,8 @@ func matches(filters []*eventarcpb.EventFilter, attrs map[string]string) bool { return true } -var captureRe = regexp.MustCompile(`^\{[A-Za-z_][A-Za-z0-9_]*(=\*\*)?\}$`) - -// compilePathPattern turns an Eventarc path pattern into an anchored expression: "*" matches within one -// segment, "**" spans segments, "{name}" captures one segment and "{name=**}" several. func compilePathPattern(p string) (*regexp.Regexp, error) { - var sb strings.Builder - - sb.WriteString("^") - - segs := strings.Split(p, "/") - for i, seg := range segs { - if i > 0 { - sb.WriteString("/") - } - - switch { - case seg == "**": - sb.WriteString(".*") - case strings.HasPrefix(seg, "{"): - if !captureRe.MatchString(seg) { - return nil, errInvalid("bad capture %q", seg) - } - - if strings.HasSuffix(seg, "=**}") { - sb.WriteString(".+") - } else { - sb.WriteString("[^/]+") - } - default: - for _, part := range strings.SplitAfter(seg, "*") { - sb.WriteString(regexp.QuoteMeta(strings.TrimSuffix(part, "*"))) - - if strings.HasSuffix(part, "*") { - sb.WriteString("[^/]*") - } - } - } - } - - sb.WriteString("$") - - re, err := regexp.Compile(sb.String()) + re, err := gcpevents.CompilePathPattern(p) if err != nil { return nil, errInvalid("bad pattern %q", p) } diff --git a/services/gcpeventarc/handler.go b/services/gcpeventarc/handler.go index fd4a1e1dc9..bbe5834c28 100644 --- a/services/gcpeventarc/handler.go +++ b/services/gcpeventarc/handler.go @@ -19,6 +19,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -134,7 +135,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpeventarc", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logUnary(log))) + srv := gcpenforce.NewServer(ctx, "eventarc.googleapis.com", grpc.UnaryInterceptor(logUnary(log))) h.registerServers(srv) _, port, _ := net.SplitHostPort(listener.Addr().String()) diff --git a/services/gcpfirestore/events.go b/services/gcpfirestore/events.go new file mode 100644 index 0000000000..a5156087f8 --- /dev/null +++ b/services/gcpfirestore/events.go @@ -0,0 +1,108 @@ +package gcpfirestore + +import ( + "sort" + "time" + + "cloud.google.com/go/firestore/apiv1/firestorepb" + "google.golang.org/protobuf/proto" +) + +// DocumentEventKind names a document change, matching the suffix of the google.cloud.firestore.document.v1 types. +type DocumentEventKind string + +const ( + // DocumentCreated is a document appearing. + DocumentCreated DocumentEventKind = "created" + // DocumentUpdated is an existing document changing. + DocumentUpdated DocumentEventKind = "updated" + // DocumentDeleted is a document being removed. + DocumentDeleted DocumentEventKind = "deleted" +) + +// DocumentEvent describes one committed document change. Old is nil for a create, New is nil for a delete. +// UpdateMask lists the top-level fields that changed on an update. +type DocumentEvent struct { + Time time.Time + Old *firestorepb.Document + New *firestorepb.Document + Kind DocumentEventKind + UpdateMask []string +} + +// DocumentObserver is called after a commit, outside the backend lock, once per changed document. +type DocumentObserver func(DocumentEvent) + +// ObserveDocuments registers fn to see every document change from now on. +func (b *InMemoryBackend) ObserveDocuments(fn DocumentObserver) { + b.mu.Lock("ObserveDocuments") + defer b.mu.Unlock() + + b.observers = append(b.observers, fn) +} + +func (b *InMemoryBackend) queueDocLocked(old, cur *firestorepb.Document, at time.Time) { + if len(b.observers) == 0 || (old == nil && cur == nil) { + return + } + + ev := DocumentEvent{Time: at, Old: cloneNil(old), New: cloneNil(cur)} + + switch { + case old == nil: + ev.Kind = DocumentCreated + case cur == nil: + ev.Kind = DocumentDeleted + default: + ev.Kind, ev.UpdateMask = DocumentUpdated, changedFields(old, cur) + } + + b.pending = append(b.pending, ev) +} + +func cloneNil(d *firestorepb.Document) *firestorepb.Document { + if d == nil { + return nil + } + + return cloneDoc(d) +} + +func changedFields(old, cur *firestorepb.Document) []string { + seen := map[string]struct{}{} + + for k, v := range cur.GetFields() { + if ov, ok := old.GetFields()[k]; !ok || !proto.Equal(ov, v) { + seen[k] = struct{}{} + } + } + + for k := range old.GetFields() { + if _, ok := cur.GetFields()[k]; !ok { + seen[k] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + + sort.Strings(out) + + return out +} + +// flushDocEvents delivers queued events. Callers defer it before taking b.mu so it runs after the unlock. +func (b *InMemoryBackend) flushDocEvents() { + b.mu.Lock("flushDocEvents") + events, observers := b.pending, b.observers + b.pending = nil + b.mu.Unlock() + + for _, ev := range events { + for _, fn := range observers { + fn(ev) + } + } +} diff --git a/services/gcpfirestore/events_test.go b/services/gcpfirestore/events_test.go new file mode 100644 index 0000000000..10880dccbe --- /dev/null +++ b/services/gcpfirestore/events_test.go @@ -0,0 +1,104 @@ +package gcpfirestore_test + +import ( + "context" + "sync" + "testing" + "time" + + "cloud.google.com/go/firestore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/gcpfirestore" +) + +type eventLog struct { + events []gcpfirestore.DocumentEvent + mu sync.Mutex +} + +func (l *eventLog) add(ev gcpfirestore.DocumentEvent) { + l.mu.Lock() + defer l.mu.Unlock() + + l.events = append(l.events, ev) +} + +func (l *eventLog) snapshot() []gcpfirestore.DocumentEvent { + l.mu.Lock() + defer l.mu.Unlock() + + return append([]gcpfirestore.DocumentEvent(nil), l.events...) +} + +func TestObserveDocuments(t *testing.T) { + t.Parallel() + + e := newEnv(t) + log := &eventLog{} + e.handler.Backend.ObserveDocuments(log.add) + + ctx := t.Context() + ref := e.client.Collection("users").Doc("alice") + + _, err := ref.Create(ctx, map[string]any{"name": "Alice", "age": 30}) + require.NoError(t, err) + _, err = ref.Update(ctx, []firestore.Update{{Path: "age", Value: 31}}) + require.NoError(t, err) + _, err = ref.Update(ctx, []firestore.Update{{Path: "age", Value: 31}}) + require.NoError(t, err) + _, err = ref.Delete(ctx) + require.NoError(t, err) + + got := log.snapshot() + require.Len(t, got, 3, "a write that changes nothing is not an event") + + tests := []struct { + kind gcpfirestore.DocumentEventKind + updateMask []string + hasOld bool + hasNew bool + }{ + {gcpfirestore.DocumentCreated, nil, false, true}, + {gcpfirestore.DocumentUpdated, []string{"age"}, true, true}, + {gcpfirestore.DocumentDeleted, nil, true, false}, + } + + for i, tt := range tests { + ev := got[i] + assert.Equal(t, tt.kind, ev.Kind) + assert.Equal(t, tt.hasOld, ev.Old != nil) + assert.Equal(t, tt.hasNew, ev.New != nil) + assert.Equal(t, tt.updateMask, ev.UpdateMask) + assert.WithinDuration(t, time.Now(), ev.Time, time.Minute) + } + + assert.Equal(t, docRoot+"/users/alice", got[0].New.GetName()) + assert.Equal(t, int64(31), got[1].New.GetFields()["age"].GetIntegerValue()) + assert.Equal(t, int64(30), got[1].Old.GetFields()["age"].GetIntegerValue()) +} + +func TestObserveDocumentsTransaction(t *testing.T) { + t.Parallel() + + e := newEnv(t) + log := &eventLog{} + e.handler.Backend.ObserveDocuments(log.add) + + err := e.client.RunTransaction(t.Context(), func(_ context.Context, tx *firestore.Transaction) error { + if serr := tx.Set(e.client.Collection("a").Doc("1"), map[string]any{"v": 1}); serr != nil { + return serr + } + + return tx.Set(e.client.Collection("a").Doc("2"), map[string]any{"v": 2}) + }) + require.NoError(t, err) + + got := log.snapshot() + require.Len(t, got, 2) + + for _, ev := range got { + assert.Equal(t, gcpfirestore.DocumentCreated, ev.Kind) + } +} diff --git a/services/gcpfirestore/handler.go b/services/gcpfirestore/handler.go index 071466a22f..fdb478fb3b 100644 --- a/services/gcpfirestore/handler.go +++ b/services/gcpfirestore/handler.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -116,7 +117,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpfirestore", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "firestore.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) firestorepb.RegisterFirestoreServer(srv, &server{b: h.Backend}) h.srvMu.Lock("StartWorker") diff --git a/services/gcpfirestore/ops.go b/services/gcpfirestore/ops.go index 6f4ed9400d..e30b3a4e57 100644 --- a/services/gcpfirestore/ops.go +++ b/services/gcpfirestore/ops.go @@ -115,6 +115,8 @@ func (b *InMemoryBackend) BatchGetDocuments( } func (b *InMemoryBackend) commitOne(w *firestorepb.Write) (*firestorepb.WriteResult, error) { + defer b.flushDocEvents() + b.mu.Lock("write") defer b.mu.Unlock() @@ -193,6 +195,8 @@ func (b *InMemoryBackend) Commit(req *firestorepb.CommitRequest) (*firestorepb.C return nil, err } + defer b.flushDocEvents() + b.mu.Lock("Commit") defer b.mu.Unlock() diff --git a/services/gcpfirestore/store.go b/services/gcpfirestore/store.go index 308ca85f83..c9fb3da8e3 100644 --- a/services/gcpfirestore/store.go +++ b/services/gcpfirestore/store.go @@ -97,6 +97,8 @@ type InMemoryBackend struct { subs map[chan struct{}]struct{} lastCommit time.Time lastSweep time.Time + observers []DocumentObserver + pending []DocumentEvent } // NewInMemoryBackend creates an empty backend. diff --git a/services/gcpfirestore/write.go b/services/gcpfirestore/write.go index 3be90c65d7..75aba14b45 100644 --- a/services/gcpfirestore/write.go +++ b/services/gcpfirestore/write.go @@ -397,8 +397,10 @@ func (b *InMemoryBackend) storeLocked(name string, d *firestorepb.Document, ct t return } + old := rec.cur() rec.vers = append(rec.vers, docVer{at: ct, doc: d}) rec.prune(ct) + b.queueDocLocked(old, d, ct) } func cloneDoc(d *firestorepb.Document) *firestorepb.Document { diff --git a/services/gcpfunctions/PARITY.md b/services/gcpfunctions/PARITY.md index 5aeb36bb4b..f2242e4fdc 100644 --- a/services/gcpfunctions/PARITY.md +++ b/services/gcpfunctions/PARITY.md @@ -2,12 +2,12 @@ service: gcpfunctions sdk_module: cloud.google.com/go/functions@v1.26.0 last_audit_commit: 8d369e7be -last_audit_date: 2026-10-19 +last_audit_date: 2026-10-27 overall: B # Per-op or per-op-family status. Values: ok | partial | gap | deferred. # wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. ops: - CreateFunction: {wire: ok, errors: ok, state: ok, persist: ok, note: "function_id (or the last segment of function.name) must match [a-z][-a-z0-9]{0,62}. build_config needs a supported runtime, entry_point and a storage_source that resolves (GenerateUploadUrl slot that was PUT, or an object in the GCS emulator); repo_source/git_uri are rejected. Returns a pending operation that resolves to the Function once the archive is validated and the event trigger is wired; a bad archive ends FAILED with state_messages and a FAILED_PRECONDITION operation error."} + CreateFunction: {wire: ok, errors: ok, state: ok, persist: ok, note: "function_id (or the last segment of function.name) must match [a-z][-a-z0-9]{0,62}. build_config needs a supported runtime, entry_point and a storage_source that resolves (GenerateUploadUrl slot that was PUT, or an object in the GCS emulator); repo_source/git_uri are rejected. Returns a pending operation that resolves to the Function once the archive is validated and the event trigger is wired; a bad archive or an unreadable secret ends FAILED with state_messages and a FAILED_PRECONDITION operation error."} GetFunction: {wire: ok, errors: ok, state: ok, persist: n/a, note: "url and service_config.uri are http://--.localhost:; service_config.service is the synthetic Cloud Run service name. The revision field is not supported (INVALID_ARGUMENT)."} ListFunctions: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Sorted by name, paged via pkgs/page; parent locations/- lists every location; filter and order_by are rejected with INVALID_ARGUMENT."} UpdateFunction: {wire: ok, errors: ok, state: ok, persist: ok, note: "update_mask supports dotted paths into build_config, service_config and event_trigger plus description, labels and kms_key_name; an empty mask copies populated mutable fields. A changed source is re-resolved; every update adds a revision, redeploys, and replaces the running container. A function cannot switch between HTTP and event triggers. A concurrent operation is FAILED_PRECONDITION."} @@ -17,20 +17,23 @@ ops: ListRuntimes: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Lists the runtimes the emulator can run (go122-go125, nodejs20/22/24, python310-313), all GEN_2 and stage GA. The filter accepts only environment=GEN_1|GEN_2; display names and stages are the emulator's, not Google's lifecycle data."} families: operations: {status: ok, note: "google.longrunning.Operations (Get, List, Wait, Cancel, Delete) is served on the same gRPC listener by pkgs/gcplro. OperationMetadata carries target, verb, operation_type and end_time; stages and build_name are not populated. Operations persist; pending ones are aborted on restore."} - execution: {status: partial, note: "Functions run in real containers through pkgs/container. The source zip is unpacked into a scratch directory, mounted read-only at /src and copied to /workspace by the container command, which then runs the official Functions Framework: nodejs -> node: image, npm install then npx --yes @google-cloud/functions-framework; python -> python:3., pip install functions-framework and requirements.txt, then functions-framework; go -> golang:1., a generated gopherstack_main package importing the root package, go get functions-framework-go and go run. Needs the image (pulled by the runtime) and registry/proxy network access at start. Containers start lazily on the first request and must accept TCP within --gcp-functions-startup-timeout. The Docker path has an opt-in test (GOPHERSTACK_GCF_DOCKER_TEST=1) that has not run in CI."} + execution: {status: partial, note: "Functions run in real containers through pkgs/container. The source zip is unpacked into a scratch directory, mounted read-only at /src and copied to /workspace by the container command, which then runs the official Functions Framework: nodejs -> node: image, npm install then npx --yes @google-cloud/functions-framework; python -> python:3., pip install functions-framework and requirements.txt, then functions-framework; go -> golang:1., a generated gopherstack_main package importing the root package, go get functions-framework-go and go run. Needs the image (pulled by the runtime) and registry/proxy network access at start. Instances start lazily on the first request (min_instance_count start at deploy) and must accept TCP within --gcp-functions-startup-timeout. The Docker path has opt-in tests (GOPHERSTACK_GCF_DOCKER_TEST=1) that have not run in CI: a Node function through the real Functions Framework, and a Storage-triggered function on a tiny locally built image (a stand-in npx) whose logged ce-* headers are asserted."} http_trigger: {status: ok, note: "Second listener (--gcp-functions-ingress-port, default 8129) routes --.localhost[:port] to the container with the service_config timeout as a deadline. 404 unknown host/function or deleting, 503 deploying/failed/no runtime, 502 start failure, 504 timeout."} - event_trigger: {status: partial, note: "Only google.cloud.pubsub.topic.v1.messagePublished with a pubsub_topic that exists in the Pub/Sub emulator. Deploy creates a push subscription gcf--- targeting /_gcf/events/ on the ingress; the ingress turns the push envelope into a binary-mode CloudEvent (ce-id/type/source/time, MessagePublishedData body) for the container. RETRY_POLICY_DO_NOT_RETRY (default) acknowledges after a failed delivery, RETRY relays the failure so Pub/Sub redelivers. event_filters, channel, other event types and Eventarc are rejected."} + event_trigger: {status: partial, note: "Event types: google.cloud.pubsub.topic.v1.messagePublished (pubsub_topic must exist; deploy creates a push subscription gcf--- that targets /_gcf/events/), google.cloud.storage.object.v1.finalized|deleted|archived|metadataUpdated and google.cloud.firestore.document.v1.created|updated|deleted|written. Storage triggers need a bucket filter (exact or match-path-pattern; an exact bucket must exist in the GCS emulator); Firestore triggers need a database filter and accept namespace (always (default)) and document (exact or match-path-pattern: {id} one segment, {path=**} many, * and **). Changes come from observers on gcpstorage (ObserveObjects) and gcpfirestore (ObserveDocuments, one event per committed document change, deletes included, updated carries old value and the changed top-level fields), wired in internal/app. Delivery is a POST to the function in binary content mode: ce-specversion, ce-id, ce-type, ce-source, ce-time, ce-subject plus ce-bucket (storage) or ce-document/ce-database/ce-namespace (Firestore); storage data is the object resource as JSON, Firestore data is a google.events.cloud.firestore.v1.DocumentEventData protobuf (value, old_value, update_mask) with content-type application/protobuf. Pub/Sub keeps the push-envelope JSON body. RETRY_POLICY_DO_NOT_RETRY (default) makes one attempt; RETRY_POLICY_RETRY redelivers storage and Firestore events with the same id on exponential backoff from 10s to 600s (Eventarc docs) until a 2xx, 24h pass (Eventarc default retention) or the function is deleted or redeployed to another trigger; Pub/Sub retries through the subscription. Events are delivered only to ACTIVE functions and are not queued for functions that deploy later; a 2xx is the only acknowledgement. Not supported: audit-log and custom-channel events, event_filters on Pub/Sub triggers, Firestore databases other than the one the emulator serves, service-account based invocation."} + secrets: {status: ok, note: "secret_environment_variables (key, project_id, secret, version number or latest; empty version is latest) and secret_volumes (mount_path, versions[{version, path}]; no versions mounts latest in a file named after the secret) are read from the emulator's Secret Manager when internal/app wires it (SetSecretResolver, shared with Cloud Run through pkgs/gcpserve). Env values are resolved at each instance start and volumes are written to 0444 files in a read-only bind mount, as the Cloud Run docs describe. A deploy whose secret or version is missing, disabled, or has no Secret Manager FAILS with FAILED_PRECONDITION carrying the Secret Manager emulator's own message (for example Secret Version [...] is in DISABLED state.); secret names must not be resource names, versions must be numeric or latest, keys follow env-var rules and may not repeat or be reserved, mount paths must be absolute, unique and outside /dev, /proc and /sys. A secret that disappears after deploy fails the next instance start (502)."} + service_config: {status: partial, note: "available_memory (128Mi-32Gi) and available_cpu are validated against Cloud Run's documented combinations (<=0.08 CPU up to 512Mi, <=0.5 up to 1Gi, <1-1 up to 4Gi, 2 up to 8Gi, 4 from 2 to 16Gi, 6 from 4 to 24Gi, 8 from 4 to 32Gi); an unset available_cpu is derived from memory (the API documents only that it is calculated, the steps are the emulator's). Each function keeps an instance pool (pkgs/gcpscale): a request takes a slot on an instance with fewer than max_instance_request_concurrency (default 1) in flight, joins a starting one, or starts a new instance below max_instance_count (default 100); at the cap it queues max(10s, 3.5 x observed start time) then gets 429. min_instance_count instances start at deploy and survive idle scale-down (15 minutes, the Cloud Run documented upper bound; SetIdleTimeout and SetClock are not CLI flags). timeout_seconds (default 60, max 3600) cuts a request with 504, including queueing and instance start. CPU and memory are not enforced on containers, and the docs do not state whether concurrency above 1 needs at least 1 CPU, so it is not checked. gcprun keeps its own coupled instance pool (probes, liveness, sidecars); only the clock and secret helpers are shared."} + ingress_settings: {status: partial, note: "ALLOW_ALL (default) accepts every caller. ALLOW_INTERNAL_ONLY accepts only requests that carry X-Gopherstack-Network: internal, and ALLOW_INTERNAL_AND_GCLB also accepts a Via header containing 1.1 google (what a Google load balancer adds). The emulator cannot tell VPC from internet callers because everything arrives on loopback, so internal is an explicit marker: event deliveries from this service, Eventarc, Cloud Tasks and Cloud Scheduler set it, other callers (tests, curl) add the header. A blocked request gets 404; the docs say only that other sources cannot reach the service, the status is the emulator's choice. Same-project and VPC Service Controls conditions are not modelled."} runtime_unavailable: {status: ok, note: "With --gcp-functions-engine=none or no reachable daemon, functions deploy and report ACTIVE but invocations return 503 'container runtime is unavailable'."} routing_isolation: {status: ok, note: "Own gRPC server bound in StartWorker to --gcp-functions-port / GCP_FUNCTIONS_PORT (default 8128, fails fast if taken) plus the ingress port; never registered on the shared AWS router. cli.go reserves both ports in the shared PortAlloc pool. The official client has no emulator variable: use option.WithEndpoint with insecure credentials."} persistence: {status: partial, note: "Functions (protojson), source zips, upload slots and operations persist. Containers and in-flight work do not: interrupted deployments end FAILED and a restored function starts its container again on its first request. Trigger subscriptions persist with Pub/Sub, not here."} - auth: {status: partial, note: "No request is authenticated; ingress_settings, service accounts, vpc_connector, kms_key_name, security_level and binary_authorization are stored but not enforced."} + auth: {status: partial, note: "No request is authenticated; service accounts, vpc_connector, kms_key_name, security_level and binary_authorization are stored but not enforced (ingress_settings is, see ingress_settings)."} iam: {status: gap, note: "GetIamPolicy, SetIamPolicy, TestIamPermissions and the Locations service are not served (gRPC UNIMPLEMENTED)."} - observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; container stdout/stderr and HTTP invocations are written to Cloud Logging when gcplogging is enabled (internal/app wiring), on a cloud_run_revision resource as for GEN_2 (logs run.googleapis.com/stdout|stderr|requests; JSON lines become jsonPayload)."} + observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; container stdout/stderr and HTTP invocations are written to Cloud Logging when gcplogging is enabled (internal/app wiring), on a cloud_run_revision resource as for GEN_2 (logs run.googleapis.com/stdout|stderr|requests; JSON lines become jsonPayload). Pub/Sub, Storage and Firestore deliveries write the same requests entry as HTTP calls (POST, delivered status, instanceId label)."} items_still_open: - - "Pub/Sub event-triggered deliveries write container output to Cloud Logging but no run.googleapis.com/requests entry; the Docker log stream is covered by a unit test, not a live daemon run." + - "The Docker log stream is covered by a unit test and one opt-in Docker test, not a live daemon run in CI." - "GEN_1 functions (cloudfunctions.v1 API), repo_source/git_uri builds and Cloud Build are not implemented." - - "Event types other than Pub/Sub messagePublished (Cloud Storage, Firestore, audit logs, custom channels) and event_filters are rejected." - - "secret_environment_variables and secret_volumes are rejected; memory, cpu, concurrency, min/max instances, vpc_connector and ingress_settings are stored but not applied." + - "Audit-log and custom-channel (Eventarc) event types are rejected; Storage and Firestore events are delivered in-process, not through an Eventarc trigger or a Pub/Sub transport topic." + - "available_memory and available_cpu are validated but not enforced on containers; vpc_connector is stored but not applied; ingress internal is an explicit header marker, not network provenance." - "Functions IAM policy RPCs and the Locations service are not served." - "GetFunction does not support the revision field; ListFunctions rejects filter and order_by; there is no unreachable list." - "URLs are http .localhost addresses, not https://*.cloudfunctions.net or *.run.app, and the upload bucket is synthetic rather than a GCS bucket." @@ -39,3 +42,5 @@ leaks: {status: clean, note: "Background resources are the two servers, deployme --- Initial implementation (2026-10-19): google.cloud.functions.v2 FunctionService verified against cloud.google.com/go/functions v1.26.0 through the official client in the package tests, using an in-process fake container runtime and Pub/Sub bus. Event delivery into the real Pub/Sub emulator is covered by the internal/app wiring test; a Docker-backed run is opt-in and has not been exercised in CI. + +Depth pass (2026-10-27): secrets from Secret Manager, service-config validation and a per-function instance pool (concurrency, min/max instances, 429 and 504), ingress_settings, Storage and Firestore event triggers with retry, and requests log entries for event deliveries. Verified through the official functions/apiv2 client, raw CloudEvent receipt by the fake runtime, a wiring test against the real GCS, Firestore and Secret Manager emulators, and an opt-in Docker test with a Storage-triggered function. diff --git a/services/gcpfunctions/README.md b/services/gcpfunctions/README.md index a87680c22e..1dea6ce7e8 100644 --- a/services/gcpfunctions/README.md +++ b/services/gcpfunctions/README.md @@ -1,24 +1,24 @@ # Cloud Functions -**Parity grade: B** · SDK `cloud.google.com/go/functions@v1.26.0` · last audited 2026-10-19 (`8d369e7be`) +**Parity grade: B** · SDK `cloud.google.com/go/functions@v1.26.0` · last audited 2026-10-27 (`8d369e7be`) ## Coverage | Metric | Value | | --- | --- | | PARITY entries audited | 8 (8 ok) | -| Feature families | 10 (4 ok, 5 partial, 1 gap) | +| Feature families | 13 (5 ok, 7 partial, 1 gap) | | Known gaps | 8 | | Deferred items | 0 | | Resource leaks | clean | ### Known gaps -- Pub/Sub event-triggered deliveries write container output to Cloud Logging but no run.googleapis.com/requests entry; the Docker log stream is covered by a unit test, not a live daemon run. +- The Docker log stream is covered by a unit test and one opt-in Docker test, not a live daemon run in CI. - GEN_1 functions (cloudfunctions.v1 API), repo_source/git_uri builds and Cloud Build are not implemented. -- Event types other than Pub/Sub messagePublished (Cloud Storage, Firestore, audit logs, custom channels) and event_filters are rejected. -- secret_environment_variables and secret_volumes are rejected; memory, cpu, concurrency, min/max instances, vpc_connector and ingress_settings are stored but not applied. +- Audit-log and custom-channel (Eventarc) event types are rejected; Storage and Firestore events are delivered in-process, not through an Eventarc trigger or a Pub/Sub transport topic. +- available_memory and available_cpu are validated but not enforced on containers; vpc_connector is stored but not applied; ingress internal is an explicit header marker, not network provenance. - Functions IAM policy RPCs and the Locations service are not served. - GetFunction does not support the revision field; ListFunctions rejects filter and order_by; there is no unreachable list. - URLs are http .localhost addresses, not https://*.cloudfunctions.net or *.run.app, and the upload bucket is synthetic rather than a GCS bucket. diff --git a/services/gcpfunctions/deploy.go b/services/gcpfunctions/deploy.go index f8d006401e..a77b081740 100644 --- a/services/gcpfunctions/deploy.go +++ b/services/gcpfunctions/deploy.go @@ -11,6 +11,7 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" "github.com/blackbirdworks/gopherstack/pkgs/logger" ) @@ -34,8 +35,9 @@ func (b *InMemoryBackend) run(j job) { b.finishDeploy(j, err) } -// warm replaces the running container with one for the new revision and waits until it answers HTTP. -// Without a container runtime the function is still deployed; invocations then return 503. +// warm replaces the instance pool with one for the new revision and brings it to its minimum instances (at least +// one) so a bad container fails the deploy. Without a container runtime the function is still deployed; +// invocations then return 503. func (b *InMemoryBackend) warm(name string) error { b.mu.Lock("warm") @@ -46,13 +48,28 @@ func (b *InMemoryBackend) warm(name string) error { return errFunctionGone } - old := b.takeBoxLocked(rec) + old := b.takePoolLocked(rec) b.mu.Unlock() - b.teardown(old) + if old != nil { + old.Close() + } + + pool, err := b.poolFor(name) + if err != nil { + return errFunctionGone + } + + want := 1 + + b.mu.RLock("warm.min") + if r := b.functions[name]; r != nil { + want = max(int(r.pb.GetServiceConfig().GetMinInstanceCount()), 1) + } + b.mu.RUnlock() - err := b.startFunction(b.baseCtx(), name) - if errors.Is(err, errNoRuntime) { + err = pool.Ensure(b.baseCtx(), want) + if errors.Is(err, errNoRuntime) || errors.Is(err, gcpscale.ErrClosed) { return nil } @@ -71,6 +88,7 @@ func (b *InMemoryBackend) prepare(j job) error { } data, bc, et, oldSub := rec.zip, rec.pb.GetBuildConfig(), clone(rec.pb.GetEventTrigger()), rec.subName + fn := clone(rec.pb) b.mu.RUnlock() rt, err := lookupRuntime(bc.GetRuntime()) @@ -82,8 +100,12 @@ func (b *InMemoryBackend) prepare(j job) error { return err } - if et == nil { - return nil + if err = b.checkSecrets(fn); err != nil { + return err + } + + if et.GetEventType() != EventTypePubSub { + return b.dropSubscription(j.name, oldSub) } sub, err := b.ensureTrigger(j.name, et, oldSub) @@ -166,7 +188,7 @@ func (b *InMemoryBackend) finishDeploy(j job, deployErr error) { Severity: functionspb.StateMessage_ERROR, Type: "DeploymentFailed", Message: rec.failure, }} } else { - rec.failure, rec.startErr = "", "" + rec.failure = "" rec.pb.State, rec.pb.StateMessages = functionspb.Function_ACTIVE, nil } @@ -198,7 +220,7 @@ func (b *InMemoryBackend) runDelete(j job) { return } - x, sub, bus := b.takeBoxLocked(rec), rec.subName, b.events + pool, sub, bus := b.takePoolLocked(rec), rec.subName, b.events if rec.deployed != nil { close(rec.deployed) } @@ -206,7 +228,9 @@ func (b *InMemoryBackend) runDelete(j job) { delete(b.functions, j.name) b.mu.Unlock() - b.teardown(x) + if pool != nil { + pool.Close() + } if bus != nil && sub != "" { if err := bus.DeleteSubscription(sub); err != nil { @@ -217,3 +241,26 @@ func (b *InMemoryBackend) runDelete(j job) { b.finishOp(j, &emptypb.Empty{}) } + +// dropSubscription removes a push subscription left over from a previous Pub/Sub trigger. +func (b *InMemoryBackend) dropSubscription(name, sub string) error { + if sub == "" { + return nil + } + + b.mu.RLock("dropSubscription") + bus := b.events + b.mu.RUnlock() + + if bus != nil { + _ = bus.DeleteSubscription(sub) + } + + b.mu.Lock("dropSubscription.clear") + if cur := b.functions[name]; cur != nil { + cur.subName = "" + } + b.mu.Unlock() + + return nil +} diff --git a/services/gcpfunctions/docker_test.go b/services/gcpfunctions/docker_test.go index 769c8df3e7..ec796726d6 100644 --- a/services/gcpfunctions/docker_test.go +++ b/services/gcpfunctions/docker_test.go @@ -2,16 +2,24 @@ package gcpfunctions_test import ( "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "log/slog" "net/http" "os" + "os/exec" + "strings" "testing" "time" + "cloud.google.com/go/logging/apiv2/loggingpb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/services/gcpfunctions" + "github.com/blackbirdworks/gopherstack/services/gcpstorage" ) const dockerOptIn = "GOPHERSTACK_GCF_DOCKER_TEST" @@ -53,3 +61,104 @@ func TestDocker_NodeFunction(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } + +// handlerImage is a tiny image whose "npx" stands in for the Functions Framework: it serves the PORT with nc and +// logs the CloudEvent headers of every request to the container's stdout. +const handlerImage = `FROM alpine:3.21 +RUN printf '%s\n' '#!/bin/sh' \ + 'read -r method path rest' \ + 'while read -r l; do l=$(printf "%s" "$l" | tr -d "\r"); [ -z "$l" ] && break; \ +case "$l" in [Cc]e-*|[Cc]ontent-[Tt]ype:*) echo "recv $l" > /proc/1/fd/1;; esac; done' \ + 'printf "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"' > /handler.sh \ + && chmod +x /handler.sh \ + && printf '%s\n' '#!/bin/sh' 'exec nc -lk -p "$PORT" -e /handler.sh' > /usr/local/bin/npx \ + && chmod +x /usr/local/bin/npx +` + +// TestDocker_StorageTriggeredFunction deploys a function triggered by the GCS emulator onto a real container and +// checks the CloudEvent headers that reach it. It builds a tiny local image, so only Docker and alpine are needed. +func TestDocker_StorageTriggeredFunction(t *testing.T) { + t.Parallel() + + if os.Getenv(dockerOptIn) == "" { + t.Skipf("set %s=1 to run against a real container runtime", dockerOptIn) + } + + raw := make([]byte, 4) + _, err := rand.Read(raw) + require.NoError(t, err) + + image := "gopherstack-gcf-test:" + hex.EncodeToString(raw) + + build := exec.CommandContext(t.Context(), "docker", "build", "-q", "-t", image, "-") + build.Stdin = strings.NewReader(handlerImage) + + if out, berr := build.CombinedOutput(); berr != nil { + t.Skipf("cannot build the test image (is alpine:3.21 available?): %v: %s", berr, out) + } + + t.Cleanup(func() { + _ = exec.CommandContext(context.WithoutCancel(t.Context()), "docker", "rmi", "-f", image).Run() + }) + + lr := gcpserve.NewLazyRuntime(slog.Default(), "gcpfunctions.test.runtime") + t.Cleanup(func() { _ = lr.Close() }) + + if _, err = lr.Get(); err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + gcs := gcpstorage.NewInMemoryBackend() + _, err = gcs.CreateBucket("test-project", gcpstorage.Patch{"name": json.RawMessage(`"inbox"`)}) + require.NoError(t, err) + + e := newEnv(t, false) + ch := make(chan *loggingpb.LogEntry, 64) + e.handler.Backend.SetLogSink(chanSink{ch: ch}) + e.handler.Backend.SetRuntime(func() (gcpfunctions.Runner, error) { return lr.Get() }) + e.handler.Backend.SetImageResolver(func(_, _ string) string { return image }) + e.handler.Backend.SetBucketLookup(realBuckets{gcs}) + gcs.ObserveObjects(func(ev gcpstorage.ObjectEvent) { + e.handler.Backend.HandleStorageEvent(gcpfunctions.StorageEvent{ + Type: string(ev.Type), Bucket: ev.Object.Bucket, Name: ev.Object.Name, Data: ev.Data(), Time: ev.Time, + }) + }) + + triggerFn(t, e, typeFinalized, 0, filter("bucket", "inbox")) + + _, err = gcs.PutObject("inbox", "docs/a.txt", []byte("hello"), gcpstorage.Patch{}, gcpstorage.Preconds{}) + require.NoError(t, err) + + want := []string{ + "recv ce-type: " + typeFinalized, + "recv ce-subject: objects/docs/a.txt", + "recv ce-source: //storage.googleapis.com/projects/_/buckets/inbox", + "recv content-type: application/json", + } + seen := map[string]bool{} + deadline := time.After(2 * time.Minute) + + for len(seen) < len(want) { + select { + case entry := <-ch: + line := strings.ToLower(entry.GetTextPayload()) + for _, w := range want { + if line == strings.ToLower(w) { + seen[w] = true + } + } + case <-deadline: + require.FailNow(t, "container never logged the CloudEvent headers", "seen: %v", seen) + } + } + + assert.Len(t, seen, len(want)) +} + +type realBuckets struct{ b *gcpstorage.InMemoryBackend } + +func (r realBuckets) BucketExists(name string) bool { + _, err := r.b.GetBucket(name, gcpstorage.Preconds{}) + + return err == nil +} diff --git a/services/gcpfunctions/events.go b/services/gcpfunctions/events.go new file mode 100644 index 0000000000..985dc26ee2 --- /dev/null +++ b/services/gcpfunctions/events.go @@ -0,0 +1,233 @@ +package gcpfunctions + +import ( + "context" + "net/http" + "strings" + "time" + + "cloud.google.com/go/firestore/apiv1/firestorepb" + "cloud.google.com/go/functions/apiv2/functionspb" + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpevents" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + // DefaultRetryMin is Eventarc's default minimum redelivery backoff. + DefaultRetryMin = 10 * time.Second + // DefaultRetryMax is Eventarc's default maximum redelivery backoff. + DefaultRetryMax = 600 * time.Second + // DefaultRetention is how long Eventarc keeps retrying an event by default. + DefaultRetention = 24 * time.Hour +) + +// StorageEvent is one Cloud Storage object change; Data is the object resource JSON. +type StorageEvent struct { + Time time.Time + Type string + Bucket string + Name string + Data []byte +} + +// DocumentEvent is one Firestore document change. Old is nil for a create, New is nil for a delete. +type DocumentEvent struct { + Time time.Time + Old *firestorepb.Document + New *firestorepb.Document + Kind string + UpdateMask []string +} + +// SetClock replaces the time source used for event redelivery and instance scale-down; nil restores the wall clock. +func (b *InMemoryBackend) SetClock(c gcpscale.Clock) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + + if c == nil { + c = gcpscale.RealClock{} + } + + b.clock = c +} + +// SetIdleTimeout sets how long an idle instance above the minimum is kept before it scales down. +func (b *InMemoryBackend) SetIdleTimeout(d time.Duration) { + b.mu.Lock("SetIdleTimeout") + defer b.mu.Unlock() + + if d > 0 { + b.idleTimeout = d + } +} + +// SetRetryPolicy sets the redelivery backoff bounds and retention for RETRY_POLICY_RETRY triggers. +func (b *InMemoryBackend) SetRetryPolicy(minDelay, maxDelay, retention time.Duration) { + b.mu.Lock("SetRetryPolicy") + defer b.mu.Unlock() + + if minDelay > 0 { + b.retryMin = minDelay + } + + if maxDelay > 0 { + b.retryMax = max(maxDelay, b.retryMin) + } + + if retention > 0 { + b.retention = retention + } +} + +// stopEvents cancels event redeliveries in flight and waits for them. +func (b *InMemoryBackend) stopEvents() { + b.mu.Lock("stopEvents") + cancel, wg := b.evCancel, b.evWG + b.evCtx, b.evCancel = context.WithCancel(context.Background()) + b.evWG = nil + b.mu.Unlock() + + cancel() + wg.Wait() + + b.mu.Lock("stopEvents.wg") + b.evWG = wg + b.mu.Unlock() +} + +type eventTarget struct { + name string + t target +} + +// matching returns the active functions whose trigger has type typ and passes match. +func (b *InMemoryBackend) matching(typ string, match func(*functionspb.EventTrigger, resName) bool) []eventTarget { + b.mu.RLock("matching") + defer b.mu.RUnlock() + + var out []eventTarget + + for name, rec := range b.functions { + et := rec.pb.GetEventTrigger() + if et.GetEventType() != typ || rec.pb.GetState() != functionspb.Function_ACTIVE { + continue + } + + if n, err := parseName(name); err == nil && match(et, n) { + out = append(out, eventTarget{name: name, t: b.targetOf(name, rec)}) + } + } + + return out +} + +// HandleStorageEvent delivers a Cloud Storage object change to every function whose trigger matches its type and +// bucket filter. +func (b *InMemoryBackend) HandleStorageEvent(ev StorageEvent) { + typ := gcpevents.StorageTypePrefix + ev.Type + attrs := map[string]string{attrBucket: ev.Bucket} + + targets := b.matching(typ, func(et *functionspb.EventTrigger, _ resName) bool { + return matchFilters(et.GetEventFilters(), attrs) + }) + if len(targets) == 0 { + return + } + + ce := gcpevents.StorageEvent(uuid.NewString(), ev.Type, ev.Bucket, ev.Name, ev.Data, ev.Time) + for _, tg := range targets { + b.dispatch(tg, ce) + } +} + +// HandleDocumentEvent delivers a Firestore document change to every function whose trigger matches its type +// (created, updated, deleted or written), database, namespace and document filters. +func (b *InMemoryBackend) HandleDocumentEvent(ev DocumentEvent) { + doc := ev.New + if doc == nil { + doc = ev.Old + } + + head, path, ok := strings.Cut(doc.GetName(), "/documents/") + if !ok { + return + } + + parts := strings.Split(head, "/") + if len(parts) != 4 { //nolint:mnd // projects/{p}/databases/{d} + return + } + + project, database := parts[1], parts[3] + attrs := map[string]string{attrDatabase: database, attrNamespace: defaultNS, attrDocument: path} + match := func(et *functionspb.EventTrigger, n resName) bool { + return n.project == project && matchFilters(et.GetEventFilters(), attrs) + } + + ce, err := gcpevents.FirestoreEvent(uuid.NewString(), ev.Kind, ev.New, ev.Old, ev.UpdateMask, ev.Time) + if err != nil { + return + } + + for _, typ := range []string{ + gcpevents.FirestoreTypePrefix + ev.Kind, gcpevents.FirestoreTypePrefix + gcpevents.FirestoreWritten, + } { + out := ce + out.Type = typ + + for _, tg := range b.matching(typ, match) { + b.dispatch(tg, out) + } + } +} + +// dispatch delivers ce to tg in the background; RETRY_POLICY_RETRY redelivers with exponential backoff. +func (b *InMemoryBackend) dispatch(tg eventTarget, ce gcpevents.CloudEvent) { + b.mu.RLock("dispatch") + defer b.mu.RUnlock() + + if b.evWG == nil { + return + } + + ctx, clk, lo, hi, keep := b.evCtx, b.clock, b.retryMin, b.retryMax, b.retention + + b.evWG.Go(func() { b.deliverWithRetry(ctx, clk, tg, ce, [3]time.Duration{lo, hi, keep}) }) +} + +func (b *InMemoryBackend) deliverWithRetry( + ctx context.Context, clk gcpscale.Clock, tg eventTarget, ce gcpevents.CloudEvent, pol [3]time.Duration, +) { + delay, giveUp := pol[0], clk.Now().Add(pol[2]) + + for { + rec := &statusRecorder{header: http.Header{}, code: http.StatusOK} + b.deliver(ctx, rec, tg.t, ce) + + if rec.code < http.StatusBadRequest || !tg.t.retry { + return + } + + cur, still := b.byName(tg.name) + if !still || !cur.retry || ctx.Err() != nil || !clk.Now().Add(delay).Before(giveUp) { + logger.Load(ctx).DebugContext(ctx, "gcpfunctions: event dropped", "function", tg.name, "status", rec.code) + + return + } + + timer := clk.NewTimer(delay) + + select { + case <-timer.C(): + case <-ctx.Done(): + timer.Stop() + + return + } + + delay = min(delay*2, pol[1]) //nolint:mnd // exponential backoff + } +} diff --git a/services/gcpfunctions/fake_clock_test.go b/services/gcpfunctions/fake_clock_test.go new file mode 100644 index 0000000000..35e10304d4 --- /dev/null +++ b/services/gcpfunctions/fake_clock_test.go @@ -0,0 +1,151 @@ +package gcpfunctions_test + +import ( + "sync" + "testing" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" +) + +type fakeTimer struct { + clock *fakeClock + ch chan time.Time + fn func() + at time.Time + d time.Duration + armed bool +} + +func (t *fakeTimer) C() <-chan time.Time { return t.ch } + +func (t *fakeTimer) Stop() bool { + t.clock.mu.Lock() + defer t.clock.mu.Unlock() + + was := t.armed + t.armed = false + + return was +} + +// fakeClock is a manually advanced gcpscale.Clock that remembers every timer duration it was asked for. +type fakeClock struct { + now time.Time + changed chan struct{} + timers []*fakeTimer + asked []time.Duration + mu sync.Mutex +} + +func newFakeClock() *fakeClock { + return &fakeClock{now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), changed: make(chan struct{}, 1024)} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + return c.now +} + +func (c *fakeClock) add(d time.Duration, fn func()) *fakeTimer { + c.mu.Lock() + + t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), fn: fn, at: c.now.Add(d), d: d, armed: true} + c.timers = append(c.timers, t) + + if fn == nil { + c.asked = append(c.asked, d) + } + c.mu.Unlock() + + select { + case c.changed <- struct{}{}: + default: + } + + return t +} + +func (c *fakeClock) NewTimer(d time.Duration) gcpscale.Timer { return c.add(d, nil) } + +func (c *fakeClock) AfterFunc(d time.Duration, f func()) gcpscale.Timer { return c.add(d, f) } + +// pending counts armed timers; onlyNew restricts it to NewTimer timers (queue and retry waits, not idle timers). +func (c *fakeClock) pending(onlyNew bool) int { + c.mu.Lock() + defer c.mu.Unlock() + + n := 0 + + for _, t := range c.timers { + if t.armed && (!onlyNew || t.fn == nil) { + n++ + } + } + + return n +} + +// durations returns the duration of every NewTimer so far, in order (AfterFunc idle timers are not recorded). +func (c *fakeClock) durations() []time.Duration { + c.mu.Lock() + defer c.mu.Unlock() + + return append([]time.Duration(nil), c.asked...) +} + +// waitTimers blocks until at least n timers are armed; waitNew counts only NewTimer timers. +func (c *fakeClock) waitTimers(t *testing.T, n int) { t.Helper(); c.wait(t, n, false) } + +func (c *fakeClock) waitNew(t *testing.T) { t.Helper(); c.wait(t, 1, true) } + +func (c *fakeClock) wait(t *testing.T, n int, onlyNew bool) { + t.Helper() + + for c.pending(onlyNew) < n { + select { + case <-c.changed: + case <-t.Context().Done(): + t.Fatalf("timed out waiting for %d armed timers (have %d)", n, c.pending(onlyNew)) + } + } +} + +// advance moves time forward and fires every timer that comes due, earliest first. +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + target := c.now.Add(d) + c.mu.Unlock() + + for { + c.mu.Lock() + + var next *fakeTimer + + for _, t := range c.timers { + if t.armed && !t.at.After(target) && (next == nil || t.at.Before(next.at)) { + next = t + } + } + + if next == nil { + c.now = target + c.mu.Unlock() + + return + } + + next.armed = false + c.now = next.at + fire := c.now + c.mu.Unlock() + + if next.fn != nil { + next.fn() + } else { + next.ch <- fire + } + } +} diff --git a/services/gcpfunctions/fake_runtime_test.go b/services/gcpfunctions/fake_runtime_test.go index 989435b9ed..a103f6fc09 100644 --- a/services/gcpfunctions/fake_runtime_test.go +++ b/services/gcpfunctions/fake_runtime_test.go @@ -35,6 +35,7 @@ type fakeRuntime struct { failRun error gate chan struct{} boxes map[string]*fakeContainer + hold atomic.Pointer[chan struct{}] started []container.Spec reqs []received lines []fakeLine @@ -108,6 +109,14 @@ func (f *fakeRuntime) CreateAndStart(_ context.Context, spec container.Spec) (st f.reqs = append(f.reqs, rec) f.mu.Unlock() + if h := f.hold.Load(); h != nil { + select { + case <-*h: + case <-r.Context().Done(): + return + } + } + w.WriteHeader(int(f.reply.Load())) _ = json.NewEncoder(w).Encode(rec) })} @@ -206,3 +215,13 @@ func (f *fakeRuntime) StreamLogs(ctx context.Context, id string, stdout, stderr return nil } + +// holdRequests makes every request block until the returned function is called. +func (f *fakeRuntime) holdRequests() func() { + ch := make(chan struct{}) + f.hold.Store(&ch) + + var once sync.Once + + return func() { once.Do(func() { close(ch) }) } +} diff --git a/services/gcpfunctions/handler.go b/services/gcpfunctions/handler.go index 3b24d7a8b0..732c1bc86d 100644 --- a/services/gcpfunctions/handler.go +++ b/services/gcpfunctions/handler.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -164,7 +165,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpfunctions", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "cloudfunctions.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) h.registerServers(srv) runCtx, cancel := context.WithCancel(context.WithoutCancel(workerCtx)) diff --git a/services/gcpfunctions/ingress.go b/services/gcpfunctions/ingress.go index 6d7702d695..2aea6a1ad2 100644 --- a/services/gcpfunctions/ingress.go +++ b/services/gcpfunctions/ingress.go @@ -1,7 +1,6 @@ package gcpfunctions import ( - "bytes" "context" "encoding/json" "errors" @@ -15,6 +14,9 @@ import ( "cloud.google.com/go/functions/apiv2/functionspb" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpevents" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" ) const ( @@ -24,12 +26,14 @@ const ( // target is the routing view of one function. type target struct { - name string - topic string - timeout time.Duration - state functionspb.Function_State - hasEvent bool - retry bool + name string + topic string + eventType string + timeout time.Duration + state functionspb.Function_State + ingress functionspb.ServiceConfig_IngressSettings + hasEvent bool + retry bool } // IngressHandler serves --.localhost invocations plus the reserved /_gcf/ endpoints @@ -97,16 +101,26 @@ func (b *InMemoryBackend) serveDownload(w http.ResponseWriter, r *http.Request) _, _ = w.Write(data) } +func requestTimeout(sc *functionspb.ServiceConfig) time.Duration { + if t := sc.GetTimeoutSeconds(); t > 0 { + return time.Duration(t) * time.Second + } + + return defaultTO * time.Second +} + func (b *InMemoryBackend) targetOf(name string, rec *fnRec) target { et := rec.pb.GetEventTrigger() return target{ - name: name, - state: rec.pb.GetState(), - timeout: time.Duration(rec.pb.GetServiceConfig().GetTimeoutSeconds()) * time.Second, - hasEvent: et != nil, - topic: et.GetPubsubTopic(), - retry: et.GetRetryPolicy() == functionspb.EventTrigger_RETRY_POLICY_RETRY, + name: name, + state: rec.pb.GetState(), + timeout: requestTimeout(rec.pb.GetServiceConfig()), + ingress: rec.pb.GetServiceConfig().GetIngressSettings(), + hasEvent: et != nil, + topic: et.GetPubsubTopic(), + eventType: et.GetEventType(), + retry: et.GetRetryPolicy() == functionspb.EventTrigger_RETRY_POLICY_RETRY, } } @@ -163,48 +177,89 @@ func (b *InMemoryBackend) awaitDeploy(ctx context.Context, t target) target { return t } -// readyPort starts t's container if needed and returns its host port, or writes the error response. -func (b *InMemoryBackend) readyPort(w http.ResponseWriter, r *http.Request, t target) (int, bool) { +func internalRequest(r *http.Request) bool { return gcpserve.IsInternal(r) } + +// viaGCLB reports whether a Google Cloud load balancer forwarded the request (it adds "Via: 1.1 google"). +func viaGCLB(r *http.Request) bool { + return strings.Contains(strings.ToLower(strings.Join(r.Header.Values("Via"), ",")), "1.1 google") +} + +func ingressAllowed(mode functionspb.ServiceConfig_IngressSettings, r *http.Request) bool { + switch mode { //nolint:exhaustive // unspecified and ALLOW_ALL accept every caller + case functionspb.ServiceConfig_ALLOW_INTERNAL_ONLY: + return internalRequest(r) + case functionspb.ServiceConfig_ALLOW_INTERNAL_AND_GCLB: + return internalRequest(r) || viaGCLB(r) + } + + return true +} + +// invoke runs r on an instance of t's function: it enforces ingress settings, waits for a deployment in flight, +// takes a concurrency slot (starting an instance when allowed), applies the timeout and proxies the request. +func (b *InMemoryBackend) invoke(w http.ResponseWriter, r *http.Request, t target) { + if !ingressAllowed(t.ingress, r) { + http.Error(w, "function does not accept requests from this source (ingress_settings)", http.StatusNotFound) + + return + } + if t.state == functionspb.Function_DEPLOYING { t = b.awaitDeploy(r.Context(), t) } - switch t.state { //nolint:exhaustive // other states are serviceable or handled below + switch t.state { //nolint:exhaustive // other states are serviceable case functionspb.Function_DEPLOYING: http.Error(w, "function is still deploying", http.StatusServiceUnavailable) - return 0, false + return case functionspb.Function_DELETING: http.Error(w, "function is being deleted", http.StatusNotFound) - return 0, false + return case functionspb.Function_FAILED: http.Error(w, "function failed to deploy", http.StatusServiceUnavailable) - return 0, false + return } - if err := b.startFunction(r.Context(), t.name); err != nil { - code := http.StatusBadGateway - if errors.Is(err, errNoRuntime) { - code = http.StatusServiceUnavailable - } - - http.Error(w, "function is not serving: "+err.Error(), code) + pool, err := b.poolFor(t.name) + if err != nil { + http.Error(w, "function was removed", http.StatusServiceUnavailable) - return 0, false + return } - b.mu.RLock("readyPort") - defer b.mu.RUnlock() + ctx, cancel := context.WithTimeout(r.Context(), t.timeout) + defer cancel() + + bx, release, err := pool.Acquire(ctx) + if err != nil { + code, msg := acquireFailure(err) + http.Error(w, msg, code) - if rec := b.functions[t.name]; rec != nil && rec.hostPort != 0 { - return rec.hostPort, true + return } + defer release() + + r = r.WithContext(ctx) + r.Header.Del(gcpserve.InternalNetworkHeader) + b.serveLogged(w, r, bx.port, b.invokeTarget(t.name, bx.id)) +} - http.Error(w, "function was removed", http.StatusServiceUnavailable) +func acquireFailure(err error) (int, string) { + switch { + case errors.Is(err, gcpscale.ErrSaturated): + return http.StatusTooManyRequests, "no instance available: " + err.Error() + case errors.Is(err, context.DeadlineExceeded): + return http.StatusGatewayTimeout, "the request timed out before an instance served it" + case errors.Is(err, gcpscale.ErrClosed): + return http.StatusServiceUnavailable, "function is being redeployed; retry" + case errors.Is(err, errNoRuntime): + return http.StatusServiceUnavailable, "function is not serving: " + err.Error() + } - return 0, false + return http.StatusBadGateway, "function is not serving: " + err.Error() } func (b *InMemoryBackend) serveInvoke(w http.ResponseWriter, r *http.Request) { @@ -222,15 +277,7 @@ func (b *InMemoryBackend) serveInvoke(w http.ResponseWriter, r *http.Request) { return } - port, ready := b.readyPort(w, r, t) - if !ready { - return - } - - ctx, cancel := context.WithTimeout(r.Context(), t.timeout) - defer cancel() - - b.serveLogged(w, r.WithContext(ctx), port, b.invokeTarget(t.name)) + b.invoke(w, r, t) } type pushMessage struct { @@ -268,11 +315,21 @@ func (b *InMemoryBackend) serveEvent(w http.ResponseWriter, r *http.Request) { return } - rec := &statusRecorder{header: http.Header{}, code: http.StatusOK} - if port, ready := b.readyPort(rec, r, t); ready { - b.deliver(r.Context(), rec, port, t, env) + body, err := json.Marshal(env) + if err != nil { + http.Error(w, "encode event: "+err.Error(), http.StatusInternalServerError) + + return + } + + ev := gcpevents.CloudEvent{ + ID: env.Message.MessageID, Type: EventTypePubSub, Source: "//pubsub.googleapis.com/" + t.topic, + Time: env.Message.PublishTime, ContentType: "application/json", Data: body, } + rec := &statusRecorder{header: http.Header{}, code: http.StatusOK} + b.deliver(r.Context(), rec, t, ev) + if rec.code >= http.StatusBadRequest && !t.retry { w.WriteHeader(http.StatusNoContent) @@ -282,42 +339,34 @@ func (b *InMemoryBackend) serveEvent(w http.ResponseWriter, r *http.Request) { w.WriteHeader(rec.code) } -func (b *InMemoryBackend) deliver(ctx context.Context, rec *statusRecorder, port int, t target, env pushEnvelope) { - body, err := json.Marshal(env) +// deliver sends ev to t's function the way Eventarc does: a POST in binary content mode from inside the network. +func (b *InMemoryBackend) deliver(ctx context.Context, rec *statusRecorder, t target, ev gcpevents.CloudEvent) { + host := b.hostOf(t.name) + + req, err := ev.NewRequest(ctx, "http://"+host+"/") if err != nil { rec.code = http.StatusInternalServerError return } - ctx, cancel := context.WithTimeout(ctx, t.timeout) - defer cancel() + req.Host = host + req.RemoteAddr = net.JoinHostPort(container.LoopbackHost, "0") + gcpserve.MarkInternal(req) - url := "http://" + net.JoinHostPort(container.LoopbackHost, strconv.Itoa(port)) + "/" + b.invoke(rec, req, t) +} - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) +func (b *InMemoryBackend) hostOf(name string) string { + n, err := parseName(name) if err != nil { - rec.code = http.StatusInternalServerError - - return + return "localhost" } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Ce-Specversion", "1.0") - req.Header.Set("Ce-Id", env.Message.MessageID) - req.Header.Set("Ce-Type", EventTypePubSub) - req.Header.Set("Ce-Source", "//pubsub.googleapis.com/"+t.topic) - req.Header.Set("Ce-Time", env.Message.PublishTime) - - resp, err := (&http.Client{Transport: b.transport}).Do(req) - if err != nil { - rec.code = http.StatusBadGateway - - return - } - defer resp.Body.Close() + b.mu.RLock("hostOf") + defer b.mu.RUnlock() - rec.code = resp.StatusCode + return b.host(n) } // statusRecorder captures the status an internal step would have written. diff --git a/services/gcpfunctions/logs.go b/services/gcpfunctions/logs.go index 76487a23e7..9e4e3d30e1 100644 --- a/services/gcpfunctions/logs.go +++ b/services/gcpfunctions/logs.go @@ -36,7 +36,7 @@ func (b *InMemoryBackend) captureOutput(r Runner, id string, fn *functionspb.Fun } } -func (b *InMemoryBackend) invokeTarget(name string) gcpserve.LogTarget { +func (b *InMemoryBackend) invokeTarget(name, instance string) gcpserve.LogTarget { b.mu.RLock("invokeTarget") defer b.mu.RUnlock() @@ -45,7 +45,7 @@ func (b *InMemoryBackend) invokeTarget(name string) gcpserve.LogTarget { return gcpserve.LogTarget{} } - return functionLogTarget(rec.pb, rec.containerID) + return functionLogTarget(rec.pb, instance) } // serveLogged proxies the invocation to port and writes its request log entry. diff --git a/services/gcpfunctions/logs_test.go b/services/gcpfunctions/logs_test.go index e8bb199993..f70392273f 100644 --- a/services/gcpfunctions/logs_test.go +++ b/services/gcpfunctions/logs_test.go @@ -6,10 +6,13 @@ import ( "testing" "time" + "cloud.google.com/go/functions/apiv2/functionspb" "cloud.google.com/go/logging/apiv2/loggingpb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ltype "google.golang.org/genproto/googleapis/logging/type" + + "github.com/blackbirdworks/gopherstack/services/gcpfunctions" ) type chanSink struct{ ch chan *loggingpb.LogEntry } @@ -151,3 +154,62 @@ func TestLogCaptureDisabledWithoutSink(t *testing.T) { assert.Equal(t, http.StatusOK, code) assert.Zero(t, e.rt.streams.Load()) } + +func TestLogRequestEntryForEventDeliveries(t *testing.T) { + t.Parallel() + + const requests = "projects/test-project/logs/run.googleapis.com%2Frequests" + + tests := []struct { + deliver func(t *testing.T, e *env, fn *functionspb.Function) + name string + reply int32 + wantStatus int32 + wantSev ltype.LogSeverity + }{ + {func(t *testing.T, e *env, fn *functionspb.Function) { + t.Helper() + + code, _ := e.call(t, http.MethodPost, "localhost", "/_gcf/events/"+fn.GetName(), envelope) + require.Equal(t, http.StatusOK, code) + }, "pubsub ok", http.StatusOK, http.StatusOK, ltype.LogSeverity_INFO}, + {func(t *testing.T, e *env, fn *functionspb.Function) { + t.Helper() + e.call(t, http.MethodPost, "localhost", "/_gcf/events/"+fn.GetName(), envelope) + }, "pubsub failing", http.StatusInternalServerError, http.StatusInternalServerError, ltype.LogSeverity_ERROR}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e, ch := logEnv(t) + e.rt.reply.Store(tt.reply) + + fn := e.createFunction(t, "ev", eventSpec(e.upload(t, zipOf(t, nodeFilesFn())), 0)) + tt.deliver(t, e, fn) + + got := nextEntry(t, ch) + require.Equal(t, requests, got.GetLogName()) + assert.Equal(t, "cloud_run_revision", got.GetResource().GetType()) + assert.Equal(t, tt.wantStatus, got.GetHttpRequest().GetStatus()) + assert.Equal(t, tt.wantSev, got.GetSeverity()) + assert.Equal(t, http.MethodPost, got.GetHttpRequest().GetRequestMethod()) + assert.NotEmpty(t, got.GetLabels()["instanceId"]) + }) + } +} + +func TestLogRequestEntryForStorageTrigger(t *testing.T) { + t.Parallel() + + e, ch := logEnv(t) + triggerFn(t, e, typeFinalized, 0, filter("bucket", "b1")) + e.handler.Backend.HandleStorageEvent(gcpfunctions.StorageEvent{ + Type: "finalized", Bucket: "b1", Name: "o", Data: []byte(`{}`), + }) + + got := nextEntry(t, ch) + assert.Equal(t, "projects/test-project/logs/run.googleapis.com%2Frequests", got.GetLogName()) + assert.Equal(t, int32(http.StatusOK), got.GetHttpRequest().GetStatus()) +} diff --git a/services/gcpfunctions/persistence.go b/services/gcpfunctions/persistence.go index 9efcb58a56..bdb3c5eff6 100644 --- a/services/gcpfunctions/persistence.go +++ b/services/gcpfunctions/persistence.go @@ -92,7 +92,7 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { } b.mu.Lock("Restore") - boxes := b.takeAllLocked() + pools := b.takeAllLocked() b.resetMaps() err := b.restoreLocked(&snap) @@ -103,9 +103,7 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { } b.mu.Unlock() - for _, x := range boxes { - b.teardown(x) - } + closeAll(pools) return err } diff --git a/services/gcpfunctions/resources.go b/services/gcpfunctions/resources.go new file mode 100644 index 0000000000..1817815a52 --- /dev/null +++ b/services/gcpfunctions/resources.go @@ -0,0 +1,154 @@ +package gcpfunctions + +import ( + "strconv" + "strings" + + "cloud.google.com/go/functions/apiv2/functionspb" +) + +const ( + kiB = int64(1) << 10 + miB = kiB << 10 + giB = miB << 10 + + minMemory = 128 * miB + maxMemory = 32 * giB + minCPU = 0.08 + cpuTolerant = 0.0834 + cpuHalf = 0.5 + defaultMem = 256 * miB +) + +// memoryBytes parses a ServiceConfig.available_memory quantity (k, M, G, T decimal; Ki, Mi, Gi, Ti binary). +func memoryBytes(q string) (int64, bool) { + units := []struct { + suffix string + mult float64 + }{ + {"Ki", float64(kiB)}, {"Mi", float64(miB)}, {"Gi", float64(giB)}, {"Ti", float64(giB) * 1024}, + {"k", 1e3}, {"M", 1e6}, {"G", 1e9}, {"T", 1e12}, + } + + mult := 1.0 + + for _, u := range units { + if num, ok := strings.CutSuffix(q, u.suffix); ok { + q, mult = num, u.mult + + break + } + } + + v, err := strconv.ParseFloat(q, 64) + if err != nil || v < 0 { + return 0, false + } + + return int64(v * mult), true +} + +type cpuTier struct { + cpu float64 + minMem int64 + maxMem int64 + fraction bool +} + +// cpuTiers are Cloud Run's documented memory ranges per CPU count, which 2nd gen functions share: up to 0.08 vCPU +// up to 512 MiB, up to 0.5 up to 1 GiB, up to 1 up to 4 GiB, 2 up to 8 GiB, 4 from 2 to 16 GiB, 6 from 4 to 24 GiB +// and 8 from 4 to 32 GiB. Fractional tiers cover every CPU count up to their value. +func cpuTiers() []cpuTier { + return []cpuTier{ + {cpuTolerant, minMemory, 512 * miB, true}, + {cpuHalf, minMemory, giB, true}, + {1, minMemory, 4 * giB, true}, + {2, minMemory, 8 * giB, false}, + {4, 2 * giB, 16 * giB, false}, + {6, 4 * giB, 24 * giB, false}, + {8, 4 * giB, 32 * giB, false}, + } +} + +func memoryRange(cpu float64) (int64, int64, bool) { + if cpu < minCPU { + return 0, 0, false + } + + for _, t := range cpuTiers() { + if (t.fraction && cpu <= t.cpu) || cpu == t.cpu { + return t.minMem, t.maxMem, true + } + } + + return 0, 0, false +} + +func validateResources(sc *functionspb.ServiceConfig) error { + mem := int64(0) + + if q := sc.GetAvailableMemory(); q != "" { + v, ok := memoryBytes(q) + if !ok || v < minMemory || v > maxMemory { + return errInvalid("service_config.available_memory %q must be between 128Mi and 32Gi.", q) + } + + mem = v + } + + q := sc.GetAvailableCpu() + if q == "" { + return nil + } + + cpu, err := strconv.ParseFloat(q, 64) + if err != nil { + return errInvalid("service_config.available_cpu %q is not a number of CPUs (for example 1 or 0.5).", q) + } + + lo, hi, ok := memoryRange(cpu) + if !ok { + return errInvalid( + "service_config.available_cpu %q is not supported: use 0.08 to under 1, or 1, 2, 4, 6 or 8.", + q, + ) + } + + if mem == 0 { + mem = defaultMem + } + + if mem < lo || mem > hi { + return errInvalid("service_config.available_cpu %q supports between %dMi and %dMi of available_memory.", + q, lo/miB, hi/miB) + } + + return nil +} + +// defaultCPUFor derives the CPU count from the memory size, as the API documents for an unset available_cpu. +func defaultCPUFor(memory string) string { + mem, ok := memoryBytes(memory) + if !ok { + mem = defaultMem + } + + switch { + case mem <= minMemory: + return "0.083" + case mem <= defaultMem: + return "0.1666" + case mem <= 512*miB: + return "0.333" + case mem <= giB: + return "0.583" + case mem <= 2*giB: + return "1" + case mem <= 8*giB: + return "2" + case mem <= 16*giB: + return "4" + } + + return "8" +} diff --git a/services/gcpfunctions/secrets.go b/services/gcpfunctions/secrets.go new file mode 100644 index 0000000000..56f35d0fe9 --- /dev/null +++ b/services/gcpfunctions/secrets.go @@ -0,0 +1,243 @@ +package gcpfunctions + +import ( + "errors" + "fmt" + "os" + "path" + "regexp" + "strings" + + "cloud.google.com/go/functions/apiv2/functionspb" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" +) + +const ( + defaultSecretMode = 0o444 + secretDirPerm = 0o755 +) + +var ( + errSecretRead = errors.New("could not read secret") + errNoSecretManager = errors.New("no Secret Manager is available in this emulator") + versionRe = regexp.MustCompile(`^([0-9]+|latest)$`) +) + +// SetSecretResolver wires Secret Manager for secret environment variables and secret volumes. +func (b *InMemoryBackend) SetSecretResolver(fn gcpserve.SecretResolver) { + b.mu.Lock("SetSecretResolver") + defer b.mu.Unlock() + + b.secrets = fn +} + +func reservedMountPath(p string) bool { + for _, root := range []string{"/dev", "/proc", "/sys"} { + if p == root || strings.HasPrefix(p, root+"/") { + return true + } + } + + return false +} + +func validateSecrets(sc *functionspb.ServiceConfig) error { + if err := validateSecretEnv(sc); err != nil { + return err + } + + return validateSecretVolumes(sc.GetSecretVolumes()) +} + +func validateSecretEnv(sc *functionspb.ServiceConfig) error { + keys := map[string]bool{} + + for k := range sc.GetEnvironmentVariables() { + keys[k] = true + } + + for _, s := range sc.GetSecretEnvironmentVariables() { + switch { + case !envNameRe.MatchString(s.GetKey()): + return errInvalid("Secret environment variable name %q is invalid.", s.GetKey()) + case reservedEnv(s.GetKey()): + return errInvalid("The following reserved env names were provided: %s.", s.GetKey()) + case keys[s.GetKey()]: + return errInvalid("Environment variable %q is set more than once.", s.GetKey()) + } + + keys[s.GetKey()] = true + + if err := validateSecretRef(s.GetSecret(), s.GetVersion()); err != nil { + return err + } + } + + return nil +} + +func validateSecretVolumes(vols []*functionspb.SecretVolume) error { + mounts := map[string]bool{} + + for _, v := range vols { + mp := path.Clean(v.GetMountPath()) + + switch { + case !path.IsAbs(mp) || mp == "/" || reservedMountPath(mp) || mp == srcMount || mp == workspace: + return errInvalid("Secret volume mount_path %q is not allowed.", v.GetMountPath()) + case mounts[mp]: + return errInvalid("Secret volume mount_path %q is used more than once.", mp) + } + + mounts[mp] = true + + if err := validateSecretRef(v.GetSecret(), ""); err != nil { + return err + } + + for _, ver := range v.GetVersions() { + if err := validateSecretRef(v.GetSecret(), ver.GetVersion()); err != nil { + return err + } + + if ver.GetPath() == "" { + return errInvalid("Secret volume %q needs a path for every version.", v.GetMountPath()) + } + } + } + + return nil +} + +func validateSecretRef(secret, version string) error { + if secret == "" || strings.Contains(secret, "/") { + return errInvalid("Secret %q must be the secret's name, not its resource name.", secret) + } + + if version != "" && !versionRe.MatchString(version) { + return errInvalid("Secret %q version %q must be a version number or \"latest\".", secret, version) + } + + return nil +} + +// secretSet is everything a container needs from Secret Manager: environment entries, bind mounts and the +// scratch directories to remove with the instance. +type secretSet struct { + env []string + mounts []string + dirs []string +} + +func (s *secretSet) cleanup() { + for _, d := range s.dirs { + removeDir(d) + } +} + +func (b *InMemoryBackend) secretBytes(project, secret, version string) ([]byte, error) { + b.mu.RLock("secretBytes") + fn := b.secrets + b.mu.RUnlock() + + if fn == nil { + return nil, errNoSecretManager + } + + name := gcpserve.SecretVersionName(project, secret, version) + + data, err := fn(name) + if err != nil { + return nil, fmt.Errorf("%w %s: %s", errSecretRead, name, status.Convert(err).Message()) + } + + return data, nil +} + +// checkSecrets confirms every referenced secret version can be read; a failed deploy reports the first miss. +func (b *InMemoryBackend) checkSecrets(fn *functionspb.Function) error { + sec, err := b.mountSecrets(fn) + sec.cleanup() + + return err +} + +// mountSecrets resolves secret environment variables and writes secret volumes under fresh directories. +func (b *InMemoryBackend) mountSecrets(fn *functionspb.Function) (*secretSet, error) { + sc := fn.GetServiceConfig() + set := &secretSet{} + + n, err := parseName(fn.GetName()) + if err != nil { + return set, err + } + + project := func(p string) string { + if p != "" { + return p + } + + return n.project + } + + for _, s := range sc.GetSecretEnvironmentVariables() { + data, serr := b.secretBytes(project(s.GetProjectId()), s.GetSecret(), s.GetVersion()) + if serr != nil { + return set, serr + } + + set.env = append(set.env, s.GetKey()+"="+string(data)) + } + + for _, v := range sc.GetSecretVolumes() { + dir, derr := b.secretVolume(project(v.GetProjectId()), v) + if dir != "" { + set.dirs = append(set.dirs, dir) + } + + if derr != nil { + return set, derr + } + + set.mounts = append(set.mounts, dir+":"+path.Clean(v.GetMountPath())+":ro") + } + + return set, nil +} + +func (b *InMemoryBackend) secretVolume(project string, v *functionspb.SecretVolume) (string, error) { + dir, err := os.MkdirTemp("", "gopherstack-gcf-secret-") + if err != nil { + return "", fmt.Errorf("create secret volume: %w", err) + } + + if err = os.Chmod(dir, secretDirPerm); err != nil { + return dir, fmt.Errorf("chmod secret volume: %w", err) + } + + type item struct{ version, path string } + + items := make([]item, 0, len(v.GetVersions())) + for _, ver := range v.GetVersions() { + items = append(items, item{ver.GetVersion(), ver.GetPath()}) + } + + if len(items) == 0 { + items = append(items, item{"latest", v.GetSecret()}) + } + + for _, it := range items { + data, serr := b.secretBytes(project, v.GetSecret(), it.version) + if serr != nil { + return dir, serr + } + + if err = gcpserve.WriteSecretFile(dir, it.path, data, defaultSecretMode); err != nil { + return dir, err + } + } + + return dir, nil +} diff --git a/services/gcpfunctions/secrets_test.go b/services/gcpfunctions/secrets_test.go new file mode 100644 index 0000000000..8eab0968a5 --- /dev/null +++ b/services/gcpfunctions/secrets_test.go @@ -0,0 +1,222 @@ +package gcpfunctions_test + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "cloud.google.com/go/functions/apiv2/functionspb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func secretStore() func(string) ([]byte, error) { + data := map[string]string{ + "projects/test-project/secrets/api-key/versions/1": "one", + "projects/test-project/secrets/api-key/versions/latest": "two", + "projects/other/secrets/shared/versions/latest": "shared-value", + } + + return func(name string) ([]byte, error) { + if strings.Contains(name, "/off/") { + return nil, status.Errorf(codes.FailedPrecondition, "Secret Version [%s] is in DISABLED state.", name) + } + + if v, ok := data[name]; ok { + return []byte(v), nil + } + + return nil, status.Errorf(codes.NotFound, "Secret Version [%s] not found.", name) + } +} + +func (e *env) createErr(t *testing.T, id string, fn *functionspb.Function) error { + t.Helper() + + op, err := e.client.CreateFunction(t.Context(), &functionspb.CreateFunctionRequest{ + Parent: parent, FunctionId: id, Function: fn, + }) + if err != nil { + return err + } + + _, err = op.Wait(t.Context()) + + return err +} + +func TestSecrets_EnvAndVolumes(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + e.handler.Backend.SetSecretResolver(secretStore()) + + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + spec.GetServiceConfig().SecretEnvironmentVariables = []*functionspb.SecretEnvVar{ + {Key: "PINNED", Secret: "api-key", Version: "1"}, + {Key: "LATEST", Secret: "api-key", Version: "latest"}, + {Key: "UNSET", Secret: "api-key"}, + {Key: "CROSS", ProjectId: "other", Secret: "shared", Version: "latest"}, + } + spec.GetServiceConfig().SecretVolumes = []*functionspb.SecretVolume{ + {MountPath: "/etc/secrets", Secret: "api-key", Versions: []*functionspb.SecretVolume_SecretVersion{ + {Version: "1", Path: "old/key"}, {Version: "latest", Path: "key"}, + }}, + {MountPath: "/etc/bare", Secret: "api-key"}, + } + + fn := e.createFunction(t, "sec", spec) + code, body := e.call(t, http.MethodGet, hostOf(fn), "/", "") + require.Equal(t, http.StatusOK, code) + + got := decode(t, body) + assert.Equal(t, "one", got.Env["PINNED"]) + assert.Equal(t, "two", got.Env["LATEST"]) + assert.Equal(t, "two", got.Env["UNSET"]) + assert.Equal(t, "shared-value", got.Env["CROSS"]) + + mounts := map[string]string{} + + for _, m := range e.rt.specs()[0].Mounts { + parts := strings.Split(m, ":") + mounts[parts[1]] = parts[0] + + if parts[1] != "/src" { + assert.Equal(t, "ro", parts[2]) + } + } + + tests := []struct { + mount, file, want string + }{ + {"/etc/secrets", "old/key", "one"}, + {"/etc/secrets", "key", "two"}, + {"/etc/bare", "api-key", "two"}, + } + + for _, tt := range tests { + raw, err := os.ReadFile(filepath.Join(mounts[tt.mount], tt.file)) + require.NoError(t, err) + assert.Equal(t, tt.want, string(raw)) + } +} + +func TestSecrets_DeployFailures(t *testing.T) { + t.Parallel() + + tests := []struct { + secrets func(string) ([]byte, error) + name string + wantText string + env []*functionspb.SecretEnvVar + vols []*functionspb.SecretVolume + }{ + { + secretStore(), + "missing secret", + "Secret Version [projects/test-project/secrets/nope/versions/latest] not found.", + []*functionspb.SecretEnvVar{{Key: "K", Secret: "nope", Version: "latest"}}, + nil, + }, + { + secretStore(), + "missing version", + "versions/9] not found.", + []*functionspb.SecretEnvVar{{Key: "K", Secret: "api-key", Version: "9"}}, + nil, + }, + { + secretStore(), + "disabled version", + "is in DISABLED state.", + []*functionspb.SecretEnvVar{{Key: "K", Secret: "off", Version: "1"}}, + nil, + }, + { + secretStore(), + "missing volume secret", + "not found.", + nil, + []*functionspb.SecretVolume{{MountPath: "/etc/s", Secret: "nope"}}, + }, + {nil, "no secret manager", "Secret Manager", []*functionspb.SecretEnvVar{{Key: "K", Secret: "api-key"}}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + if tt.secrets != nil { + e.handler.Backend.SetSecretResolver(tt.secrets) + } + + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + spec.GetServiceConfig().SecretEnvironmentVariables = tt.env + spec.GetServiceConfig().SecretVolumes = tt.vols + + err := e.createErr(t, "bad", spec) + require.Error(t, err) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + assert.Contains(t, err.Error(), tt.wantText) + assert.Zero(t, e.rt.running()) + }) + } +} + +func TestSecrets_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + set func(sc *functionspb.ServiceConfig) + name string + }{ + {func(sc *functionspb.ServiceConfig) { + sc.SecretEnvironmentVariables = []*functionspb.SecretEnvVar{{Key: "1BAD", Secret: "s"}} + }, "bad key"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretEnvironmentVariables = []*functionspb.SecretEnvVar{{Key: "PORT", Secret: "s"}} + }, "reserved key"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretEnvironmentVariables = []*functionspb.SecretEnvVar{{Key: "GREETING", Secret: "s"}} + }, "duplicate with plain env"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretEnvironmentVariables = []*functionspb.SecretEnvVar{{Key: "K", Secret: "projects/p/secrets/s"}} + }, "resource name as secret"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretEnvironmentVariables = []*functionspb.SecretEnvVar{{Key: "K", Secret: "s", Version: "newest"}} + }, "bad version"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretVolumes = []*functionspb.SecretVolume{{MountPath: "/proc/x", Secret: "s"}} + }, "mount under proc"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretVolumes = []*functionspb.SecretVolume{{MountPath: "etc/s", Secret: "s"}} + }, "relative mount"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretVolumes = []*functionspb.SecretVolume{ + {MountPath: "/etc/s", Secret: "a"}, {MountPath: "/etc/s/", Secret: "b"}, + } + }, "duplicate mount"}, + {func(sc *functionspb.ServiceConfig) { + sc.SecretVolumes = []*functionspb.SecretVolume{{MountPath: "/etc/s", Secret: "s", + Versions: []*functionspb.SecretVolume_SecretVersion{{Version: "1", Path: ""}}}} + }, "escaping path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + tt.set(spec.GetServiceConfig()) + + err := e.createErr(t, "bad", spec) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + } +} diff --git a/services/gcpfunctions/serve.go b/services/gcpfunctions/serve.go index 1ab7096498..5d03560a06 100644 --- a/services/gcpfunctions/serve.go +++ b/services/gcpfunctions/serve.go @@ -11,6 +11,7 @@ import ( "cloud.google.com/go/functions/apiv2/functionspb" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/logger" ) @@ -23,34 +24,40 @@ const ( envExtra = 7 ) -var ( - errStartFailed = errors.New("function failed to start") - errStale = errors.New("function was redeployed while starting; retry") -) +var errStartFailed = errors.New("function failed to start") -// box is the container and scratch directory serving one function revision. +// box is one running instance: its container, scratch directories and host port. type box struct { id string - dir string + dirs []string port int } -func (b *InMemoryBackend) takeBoxLocked(r *fnRec) box { - x := box{id: r.containerID, dir: r.srcDir, port: r.hostPort} - r.containerID, r.srcDir, r.hostPort = "", "", 0 +func (b *InMemoryBackend) takePoolLocked(r *fnRec) *gcpscale.Pool[box] { + p := r.pool + r.pool = nil - return x + return p } -func (b *InMemoryBackend) takeAllLocked() []box { - out := make([]box, 0, len(b.functions)) +func (b *InMemoryBackend) takeAllLocked() []*gcpscale.Pool[box] { + out := make([]*gcpscale.Pool[box], 0, len(b.functions)) + for _, r := range b.functions { - out = append(out, b.takeBoxLocked(r)) + if p := b.takePoolLocked(r); p != nil { + out = append(out, p) + } } return out } +func closeAll(pools []*gcpscale.Pool[box]) { + for _, p := range pools { + p.Close() + } +} + func (b *InMemoryBackend) baseCtx() context.Context { b.mu.RLock("baseCtx") defer b.mu.RUnlock() @@ -91,9 +98,11 @@ func (b *InMemoryBackend) stopContainer(r Runner, id string) { } } -// teardown stops x's container, frees its port and removes its source directory. +// teardown stops x's container, frees its port and removes its scratch directories. func (b *InMemoryBackend) teardown(x box) { - removeDir(x.dir) + for _, d := range x.dirs { + removeDir(d) + } if x.id != "" { if r, err := b.runner(); err == nil { @@ -112,15 +121,22 @@ func signatureType(fn *functionspb.Function) string { return sigHTTP } -func functionSpec(fn *functionspb.Function, rt *runtimeSpec, srcDir string, hostPort int) container.Spec { +func functionSpec( + fn *functionspb.Function, + rt *runtimeSpec, + srcDir string, + hostPort int, + sec *secretSet, +) container.Spec { id := lastSegment(fn.GetName()) sc := fn.GetServiceConfig() - env := make([]string, 0, len(sc.GetEnvironmentVariables())+envExtra) + env := make([]string, 0, len(sc.GetEnvironmentVariables())+len(sec.env)+envExtra) for k, v := range sc.GetEnvironmentVariables() { env = append(env, k+"="+v) } + env = append(env, sec.env...) env = append(env, "PORT="+strconv.Itoa(containerPort), "FUNCTION_TARGET="+fn.GetBuildConfig().GetEntryPoint(), @@ -135,7 +151,7 @@ func functionSpec(fn *functionspb.Function, rt *runtimeSpec, srcDir string, host Image: rt.image, Name: "gopherstack-gcf-" + id + "-" + strconv.Itoa(hostPort), Env: env, - Mounts: []string{srcDir + ":" + srcMount + ":ro"}, + Mounts: append([]string{srcDir + ":" + srcMount + ":ro"}, sec.mounts...), Entrypoint: []string{"sh", "-c"}, Cmd: []string{rt.script()}, Ports: []string{ @@ -170,7 +186,7 @@ func materialize(data []byte, rt *runtimeSpec) (string, error) { return dir, nil } -// launch starts the container for fn and waits until it accepts connections. +// launch starts one instance of fn and waits until it accepts connections. func (b *InMemoryBackend) launch(fn *functionspb.Function, data []byte) (box, error) { rt, err := lookupRuntime(fn.GetBuildConfig().GetRuntime()) if err != nil { @@ -182,16 +198,25 @@ func (b *InMemoryBackend) launch(fn *functionspb.Function, data []byte) (box, er return box{}, err } + rt = b.withImage(rt) + dir, err := materialize(data, rt) if err != nil { return box{}, err } - pool := b.portPool() + sec, err := b.mountSecrets(fn) + if err != nil { + removeDir(dir) + sec.cleanup() + + return box{}, err + } - port, err := gcpserve.AcquirePort(b.baseCtx(), pool, "gcpfunctions") + port, err := gcpserve.AcquirePort(b.baseCtx(), b.portPool(), "gcpfunctions") if err != nil { removeDir(dir) + sec.cleanup() return box{}, fmt.Errorf("allocate host port: %w", err) } @@ -203,7 +228,7 @@ func (b *InMemoryBackend) launch(fn *functionspb.Function, data []byte) (box, er ctx, cancel := context.WithTimeout(b.baseCtx(), timeout) defer cancel() - id, err := runner.CreateAndStart(ctx, functionSpec(fn, rt, dir, port)) + id, err := runner.CreateAndStart(ctx, functionSpec(fn, rt, dir, port, sec)) if err == nil { b.captureOutput(runner, id, fn) @@ -217,7 +242,7 @@ func (b *InMemoryBackend) launch(fn *functionspb.Function, data []byte) (box, er } } - x := box{id: id, dir: dir, port: port} + x := box{id: id, dirs: append([]string{dir}, sec.dirs...), port: port} if err != nil { x.id = "" b.teardown(x) @@ -228,74 +253,64 @@ func (b *InMemoryBackend) launch(fn *functionspb.Function, data []byte) (box, er return x, nil } -// startFunction starts name's container once; concurrent callers wait for the same start. -func (b *InMemoryBackend) startFunction(ctx context.Context, name string) error { - b.mu.Lock("startFunction") +// newPoolLocked builds the instance pool for rec's current revision. +func (b *InMemoryBackend) newPoolLocked(rec *fnRec) *gcpscale.Pool[box] { + pb, data := clone(rec.pb), rec.zip + sc := pb.GetServiceConfig() + + return gcpscale.New(lastSegment(pb.GetName()), gcpscale.Config[box]{ + Start: func(context.Context) (box, error) { return b.launch(pb, data) }, + Stop: b.teardown, + Limits: func() gcpscale.Limits { + return gcpscale.Limits{ + Min: int(sc.GetMinInstanceCount()), + Max: int(sc.GetMaxInstanceCount()), + Concurrency: int(sc.GetMaxInstanceRequestConcurrency()), + } + }, + Clock: b.clock, + Base: b.runCtx, + IdleTimeout: b.idleTimeout, + }) +} + +// poolFor returns name's instance pool, creating it on first use. +func (b *InMemoryBackend) poolFor(name string) (*gcpscale.Pool[box], error) { + b.mu.Lock("poolFor") + defer b.mu.Unlock() r, ok := b.functions[name] if !ok { - b.mu.Unlock() - - return errStartFailed + return nil, errStartFailed } - if r.containerID != "" { - b.mu.Unlock() - - return nil + if r.pool == nil { + r.pool = b.newPoolLocked(r) } - if ch := r.starting; ch != nil { - b.mu.Unlock() - - select { - case <-ch: - case <-ctx.Done(): - return ctx.Err() - } - - return b.startResult(name) - } - - r.starting = make(chan struct{}) - ch, pb, data, epoch := r.starting, clone(r.pb), r.zip, r.epoch - b.mu.Unlock() - - x, err := b.launch(pb, data) - - b.mu.Lock("startFunction.done") - - cur, still := b.functions[name] - if still && cur.epoch != epoch && err == nil { - err = errStale - } - - if still { - cur.starting, cur.startErr = nil, "" - - if err != nil { - cur.startErr = err.Error() - } else { - cur.containerID, cur.srcDir, cur.hostPort = x.id, x.dir, x.port - } - } - b.mu.Unlock() - close(ch) + return r.pool, nil +} - if (!still || err != nil) && x.id != "" { - b.teardown(x) - } +// SetImageResolver lets the base image of a runtime be replaced, for example with a mirror. The resolver gets the +// runtime id and its default image. +func (b *InMemoryBackend) SetImageResolver(fn func(runtime, image string) string) { + b.mu.Lock("SetImageResolver") + defer b.mu.Unlock() - return err + b.images = fn } -func (b *InMemoryBackend) startResult(name string) error { - b.mu.RLock("startResult") - defer b.mu.RUnlock() +func (b *InMemoryBackend) withImage(rt *runtimeSpec) *runtimeSpec { + b.mu.RLock("withImage") + fn := b.images + b.mu.RUnlock() - if r, ok := b.functions[name]; ok && r.startErr != "" { - return fmt.Errorf("%w: %s", errStartFailed, r.startErr) + if fn == nil { + return rt } - return nil + out := *rt + out.image = fn(rt.id, rt.image) + + return &out } diff --git a/services/gcpfunctions/serviceconfig_test.go b/services/gcpfunctions/serviceconfig_test.go new file mode 100644 index 0000000000..5a4e16da1e --- /dev/null +++ b/services/gcpfunctions/serviceconfig_test.go @@ -0,0 +1,320 @@ +package gcpfunctions_test + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "cloud.google.com/go/functions/apiv2/functionspb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestServiceConfig_ResourceValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memory string + cpu string + wantErr bool + }{ + {"defaults", "", "", false}, + {"256M with fractional cpu", "256M", "0.1666", false}, + {"1Gi with 0.5", "1Gi", "0.5", false}, + {"2Gi with 0.5", "2Gi", "0.5", true}, + {"1Gi with 1", "1Gi", "1", false}, + {"8Gi with 1", "8Gi", "1", true}, + {"8Gi with 2", "8Gi", "2", false}, + {"1Gi with 4", "1Gi", "4", true}, + {"2Gi with 4", "2Gi", "4", false}, + {"32Gi with 8", "32Gi", "8", false}, + {"32Gi with 6", "32Gi", "6", true}, + {"64Gi", "64Gi", "", true}, + {"64Mi", "64Mi", "", true}, + {"cpu 3", "4Gi", "3", true}, + {"cpu above 8", "32Gi", "16", true}, + {"cpu below 0.08", "128Mi", "0.01", true}, + {"cpu not a number", "", "lots", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + spec.GetServiceConfig().AvailableMemory, spec.GetServiceConfig().AvailableCpu = tt.memory, tt.cpu + + err := e.createErr(t, "res", spec) + if tt.wantErr { + assert.Equal(t, codes.InvalidArgument, status.Code(err), "%v", err) + + return + } + + assert.NoError(t, err) + }) + } +} + +func TestServiceConfig_DerivedCPU(t *testing.T) { + t.Parallel() + + tests := []struct{ memory, want string }{ + {"", "0.1666"}, {"128Mi", "0.083"}, {"512Mi", "0.333"}, {"1Gi", "0.583"}, {"2Gi", "1"}, + {"4Gi", "2"}, {"16Gi", "4"}, {"32Gi", "8"}, + } + + for _, tt := range tests { + t.Run("memory "+tt.memory, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + spec.GetServiceConfig().AvailableMemory = tt.memory + + fn := e.createFunction(t, "cpu", spec) + assert.Equal(t, tt.want, fn.GetServiceConfig().GetAvailableCpu()) + }) + } +} + +func scaled(t *testing.T, e *env, id string, mutate func(sc *functionspb.ServiceConfig)) *functionspb.Function { + t.Helper() + + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + mutate(spec.GetServiceConfig()) + + return e.createFunction(t, id, spec) +} + +// callAsync issues a request in the background and reports its status. +func (e *env) callAsync(t *testing.T, host string) <-chan int { + t.Helper() + + out := make(chan int, 1) + + go func() { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, + "http://"+e.handler.IngressAddr()+"/", nil) + if err != nil { + out <- 0 + + return + } + + req.Host = host + + resp, err := e.http.Do(req) + if err != nil { + out <- 0 + + return + } + + _ = resp.Body.Close() + out <- resp.StatusCode + }() + + return out +} + +func waitRunning(t *testing.T, e *env, n int) { + t.Helper() + require.Eventually(t, func() bool { return e.rt.running() == n }, 10*time.Second, 5*time.Millisecond) +} + +func TestScaling_ConcurrencyAndMaxInstances(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + concurrency int32 + max int32 + requests int + wantRunning int + }{ + {"one request per instance scales out", 1, 5, 3, 3}, + {"concurrency shares an instance", 3, 5, 3, 1}, + {"concurrency spills to a second instance", 2, 5, 3, 2}, + {"max instances caps scale-out", 1, 2, 2, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + fn := scaled(t, e, "scale", func(sc *functionspb.ServiceConfig) { + sc.MaxInstanceRequestConcurrency, sc.MaxInstanceCount = tt.concurrency, tt.max + sc.AvailableCpu, sc.AvailableMemory = "1", "512Mi" + }) + release := e.rt.holdRequests() + + t.Cleanup(release) + + results := make([]<-chan int, 0, tt.requests) + for range tt.requests { + results = append(results, e.callAsync(t, hostOf(fn))) + } + + waitRunning(t, e, tt.wantRunning) + assert.Equal(t, tt.wantRunning, e.rt.running()) + release() + + for _, r := range results { + assert.Equal(t, http.StatusOK, <-r) + } + }) + } +} + +func TestScaling_QueueTimeoutIs429(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + clk := newFakeClock() + e.handler.Backend.SetClock(clk) + + fn := scaled(t, e, "full", func(sc *functionspb.ServiceConfig) { + sc.MaxInstanceRequestConcurrency, sc.MaxInstanceCount = 1, 1 + }) + release := e.rt.holdRequests() + + t.Cleanup(release) + + first := e.callAsync(t, hostOf(fn)) + require.Eventually(t, func() bool { return len(e.rt.requests()) == 1 }, 10*time.Second, 5*time.Millisecond) + + second := e.callAsync(t, hostOf(fn)) + clk.waitNew(t) + clk.advance(10 * time.Second) + + assert.Equal(t, http.StatusTooManyRequests, <-second) + assert.Equal(t, 1, e.rt.running()) + + release() + assert.Equal(t, http.StatusOK, <-first) +} + +func TestScaling_MinInstancesAndIdleScaleDown(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + clk := newFakeClock() + e.handler.Backend.SetClock(clk) + e.handler.Backend.SetIdleTimeout(time.Minute) + + fn := scaled(t, e, "warm", func(sc *functionspb.ServiceConfig) { + sc.MinInstanceCount, sc.MaxInstanceCount, sc.MaxInstanceRequestConcurrency = 2, 4, 1 + }) + assert.Equal(t, 2, e.rt.running(), "minimum instances start at deploy") + + release := e.rt.holdRequests() + + t.Cleanup(release) + + var wg sync.WaitGroup + + results := make([]<-chan int, 0, 3) + for range 3 { + results = append(results, e.callAsync(t, hostOf(fn))) + } + + waitRunning(t, e, 3) + release() + + for _, r := range results { + assert.Equal(t, http.StatusOK, <-r) + } + + wg.Wait() + clk.waitTimers(t, 3) + clk.advance(2 * time.Minute) + waitRunning(t, e, 2) +} + +func TestScaling_RequestTimeoutIs504(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + fn := scaled(t, e, "slow", func(sc *functionspb.ServiceConfig) { sc.TimeoutSeconds = 1 }) + release := e.rt.holdRequests() + + t.Cleanup(release) + + began := time.Now() + code, _ := e.call(t, http.MethodGet, hostOf(fn), "/", "") + assert.Equal(t, http.StatusGatewayTimeout, code) + assert.Less(t, time.Since(began), 20*time.Second, "timeout_seconds=1 must cut the request, not the 60s default") +} + +func TestIngressSettings(t *testing.T) { + t.Parallel() + + tests := []struct { + headers map[string]string + name string + want int + setting functionspb.ServiceConfig_IngressSettings + }{ + {nil, "allow all external", http.StatusOK, functionspb.ServiceConfig_ALLOW_ALL}, + {nil, "unspecified allows external", http.StatusOK, functionspb.ServiceConfig_INGRESS_SETTINGS_UNSPECIFIED}, + {nil, "internal only blocks external", http.StatusNotFound, functionspb.ServiceConfig_ALLOW_INTERNAL_ONLY}, + { + map[string]string{"Via": "1.1 google"}, + "internal only blocks gclb", + http.StatusNotFound, + functionspb.ServiceConfig_ALLOW_INTERNAL_ONLY, + }, + { + map[string]string{"X-Gopherstack-Network": "internal"}, + "internal only allows internal", + http.StatusOK, + functionspb.ServiceConfig_ALLOW_INTERNAL_ONLY, + }, + {nil, "gclb blocks external", http.StatusNotFound, functionspb.ServiceConfig_ALLOW_INTERNAL_AND_GCLB}, + { + map[string]string{"Via": "1.1 google"}, + "gclb allows load balancer", + http.StatusOK, + functionspb.ServiceConfig_ALLOW_INTERNAL_AND_GCLB, + }, + { + map[string]string{"X-Gopherstack-Network": "internal"}, + "gclb allows internal", + http.StatusOK, + functionspb.ServiceConfig_ALLOW_INTERNAL_AND_GCLB, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + fn := scaled(t, e, "ing", func(sc *functionspb.ServiceConfig) { sc.IngressSettings = tt.setting }) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, + "http://"+e.handler.IngressAddr()+"/", nil) + require.NoError(t, err) + + req.Host = hostOf(fn) + + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + resp, err := e.http.Do(req) + require.NoError(t, err) + + _ = resp.Body.Close() + assert.Equal(t, tt.want, resp.StatusCode) + }) + } +} diff --git a/services/gcpfunctions/store.go b/services/gcpfunctions/store.go index d2b11c0e3d..5a0ebcb034 100644 --- a/services/gcpfunctions/store.go +++ b/services/gcpfunctions/store.go @@ -14,6 +14,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/blackbirdworks/gopherstack/pkgs/gcplro" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/httpclient" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" @@ -40,19 +41,15 @@ type ObjectReader interface { } type fnRec struct { - pb *functionspb.Function - starting chan struct{} - deployed chan struct{} - failure string - containerID string - startErr string - srcDir string - subName string - zip []byte - hostPort int - revSeq int - epoch int - pending int + pb *functionspb.Function + pool *gcpscale.Pool[box] + deployed chan struct{} + failure string + subName string + zip []byte + revSeq int + epoch int + pending int } type uploadRec struct { @@ -73,10 +70,21 @@ type InMemoryBackend struct { transport *http.Transport ops *gcplro.Store logs *gcpserve.LogCapture + secrets gcpserve.SecretResolver + buckets BucketLookup + images func(runtime, image string) string + clock gcpscale.Clock + evCtx context.Context //nolint:containedctx // cancels event redelivery on Reset and Close + evCancel context.CancelFunc + evWG *sync.WaitGroup wg *sync.WaitGroup functions map[string]*fnRec uploads map[string]*uploadRec startupTimeout time.Duration + idleTimeout time.Duration + retryMin time.Duration + retryMax time.Duration + retention time.Duration ingressPort int lastGen int64 } @@ -92,7 +100,14 @@ func NewInMemoryBackend() *InMemoryBackend { wg: &sync.WaitGroup{}, transport: httpclient.NewTransport(), startupTimeout: DefaultStartupTimeout, + clock: gcpscale.RealClock{}, + idleTimeout: gcpscale.DefaultIdleTimeout, + retryMin: DefaultRetryMin, + retryMax: DefaultRetryMax, + retention: DefaultRetention, + evWG: &sync.WaitGroup{}, } + b.evCtx, b.evCancel = context.WithCancel(context.Background()) b.resetMaps() return b @@ -106,17 +121,14 @@ func (b *InMemoryBackend) resetMaps() { // Reset stops every function and clears all state. func (b *InMemoryBackend) Reset() { b.mu.Lock("Reset") - boxes := b.takeAllLocked() + pools := b.takeAllLocked() b.resetMaps() b.mu.Unlock() + b.stopEvents() b.ops.Reset() b.wg.Wait() - - for _, x := range boxes { - b.teardown(x) - } - + closeAll(pools) b.logs.Wait() } @@ -186,22 +198,30 @@ func (b *InMemoryBackend) SetIngressPort(p int) { b.ingressPort = p } -// Drain blocks until every background deployment has finished. -func (b *InMemoryBackend) Drain() { b.wg.Wait() } +// Drain blocks until every background deployment and event delivery has finished. +func (b *InMemoryBackend) Drain() { + b.wg.Wait() + + b.mu.RLock("Drain") + wg := b.evWG + b.mu.RUnlock() + + if wg != nil { + wg.Wait() + } +} // Close stops all containers and releases idle connections. func (b *InMemoryBackend) Close() { b.wg.Wait() b.mu.Lock("Close") - boxes := b.takeAllLocked() + pools := b.takeAllLocked() hook := b.onClose b.mu.Unlock() - for _, x := range boxes { - b.teardown(x) - } - + b.stopEvents() + closeAll(pools) b.logs.Wait() b.transport.CloseIdleConnections() diff --git a/services/gcpfunctions/triggers.go b/services/gcpfunctions/triggers.go new file mode 100644 index 0000000000..88c24d6ee3 --- /dev/null +++ b/services/gcpfunctions/triggers.go @@ -0,0 +1,166 @@ +package gcpfunctions + +import ( + "slices" + "strings" + + "cloud.google.com/go/functions/apiv2/functionspb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpevents" +) + +const ( + attrBucket = "bucket" + attrDatabase = "database" + attrNamespace = "namespace" + attrDocument = "document" + defaultNS = "(default)" +) + +// BucketLookup tells event triggers whether a Cloud Storage bucket exists. +type BucketLookup interface { + BucketExists(bucket string) bool +} + +// SetBucketLookup wires the GCS emulator so storage triggers can be checked against real buckets. +func (b *InMemoryBackend) SetBucketLookup(l BucketLookup) { + b.mu.Lock("SetBucketLookup") + defer b.mu.Unlock() + + b.buckets = l +} + +func storageKinds() []string { + return []string{"finalized", "deleted", "archived", "metadataUpdated"} +} + +func firestoreKinds() []string { + return []string{"created", "updated", "deleted", gcpevents.FirestoreWritten} +} + +func hasKind(prefix, typ string, kinds []string) bool { + suffix, ok := strings.CutPrefix(typ, prefix) + + return ok && slices.Contains(kinds, suffix) +} + +func isStorageType(typ string) bool { + return hasKind(gcpevents.StorageTypePrefix, typ, storageKinds()) +} + +func isFirestoreType(typ string) bool { + return hasKind(gcpevents.FirestoreTypePrefix, typ, firestoreKinds()) +} + +func supportedEventTypes() string { + out := make([]string, 1, 1+len(storageKinds())+len(firestoreKinds())) + out[0] = EventTypePubSub + + for _, k := range storageKinds() { + out = append(out, gcpevents.StorageTypePrefix+k) + } + + for _, k := range firestoreKinds() { + out = append(out, gcpevents.FirestoreTypePrefix+k) + } + + return strings.Join(out, ", ") +} + +// checkFilters rejects filters on attributes outside allowed, empty values, duplicates, bad operators and bad +// path patterns, and returns the filters by attribute. +func checkFilters( + et *functionspb.EventTrigger, + allowed []string, + patterned ...string, +) (map[string]*functionspb.EventFilter, error) { + out := map[string]*functionspb.EventFilter{} + + for _, f := range et.GetEventFilters() { + a := f.GetAttribute() + + switch { + case !slices.Contains(allowed, a): + return nil, errInvalid("Attribute %q cannot be used to filter events of type %q; use %s.", + a, et.GetEventType(), strings.Join(allowed, ", ")) + case f.GetValue() == "": + return nil, errInvalid("Event filter %q needs a value.", a) + case out[a] != nil: + return nil, errInvalid("Attribute %q is filtered more than once.", a) + } + + switch f.GetOperator() { + case "": + case gcpevents.OperatorPathPattern: + if !slices.Contains(patterned, a) { + return nil, errInvalid("Attribute %q does not support path patterns.", a) + } + + if _, err := gcpevents.CompilePathPattern(f.GetValue()); err != nil { + return nil, errInvalid("Invalid path pattern %q for attribute %q.", f.GetValue(), a) + } + default: + return nil, errInvalid("Unsupported operator %q for attribute %q: want %q or none.", + f.GetOperator(), a, gcpevents.OperatorPathPattern) + } + + out[a] = f + } + + return out, nil +} + +func (b *InMemoryBackend) validateStorageTrigger(et *functionspb.EventTrigger) error { + if et.GetPubsubTopic() != "" { + return errInvalid("pubsub_topic is only valid for %s events.", EventTypePubSub) + } + + fs, err := checkFilters(et, []string{attrBucket}, attrBucket) + if err != nil { + return err + } + + f := fs[attrBucket] + if f == nil { + return errInvalid("Events of type %q require a filter for the %q attribute.", et.GetEventType(), attrBucket) + } + + b.mu.RLock("validateStorageTrigger") + lookup := b.buckets + b.mu.RUnlock() + + if lookup != nil && f.GetOperator() == "" && !lookup.BucketExists(f.GetValue()) { + return errInvalid("Bucket %q does not exist.", f.GetValue()) + } + + return nil +} + +func validateFirestoreTrigger(et *functionspb.EventTrigger) error { + if et.GetPubsubTopic() != "" { + return errInvalid("pubsub_topic is only valid for %s events.", EventTypePubSub) + } + + fs, err := checkFilters(et, []string{attrDatabase, attrNamespace, attrDocument}, attrDocument) + if err != nil { + return err + } + + if fs[attrDatabase] == nil { + return errInvalid("Events of type %q require a filter for the %q attribute.", et.GetEventType(), attrDatabase) + } + + return nil +} + +// matchFilters reports whether attrs satisfy every event filter; a missing attribute never matches. +func matchFilters(filters []*functionspb.EventFilter, attrs map[string]string) bool { + for _, f := range filters { + got, ok := attrs[f.GetAttribute()] + if !ok || !gcpevents.MatchFilter(f.GetOperator(), f.GetValue(), got) { + return false + } + } + + return true +} diff --git a/services/gcpfunctions/triggers_test.go b/services/gcpfunctions/triggers_test.go new file mode 100644 index 0000000000..d521791143 --- /dev/null +++ b/services/gcpfunctions/triggers_test.go @@ -0,0 +1,459 @@ +package gcpfunctions_test + +import ( + "net/http" + "strings" + "testing" + "time" + + "cloud.google.com/go/firestore/apiv1/firestorepb" + "cloud.google.com/go/functions/apiv2/functionspb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpevents" + "github.com/blackbirdworks/gopherstack/services/gcpfunctions" +) + +type fakeBuckets map[string]bool + +func (f fakeBuckets) BucketExists(b string) bool { return f[b] } + +const ( + typeFinalized = "google.cloud.storage.object.v1.finalized" + typeDeleted = "google.cloud.storage.object.v1.deleted" + typeCreated = "google.cloud.firestore.document.v1.created" + typeUpdated = "google.cloud.firestore.document.v1.updated" + typeWritten = "google.cloud.firestore.document.v1.written" + docPrefix = "projects/test-project/databases/(default)/documents/" +) + +func filter(attr, value string) *functionspb.EventFilter { + return &functionspb.EventFilter{Attribute: attr, Value: value} +} + +func patternFilter(attr, value string) *functionspb.EventFilter { + return &functionspb.EventFilter{Attribute: attr, Value: value, Operator: gcpevents.OperatorPathPattern} +} + +func triggerFn(t *testing.T, e *env, typ string, retry functionspb.EventTrigger_RetryPolicy, + filters ...*functionspb.EventFilter, +) { + t.Helper() + + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + spec.EventTrigger = &functionspb.EventTrigger{EventType: typ, EventFilters: filters, RetryPolicy: retry} + + e.createFunction(t, "trig", spec) +} + +func TestTriggers_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + trigger *functionspb.EventTrigger + name string + wantErr bool + }{ + {&functionspb.EventTrigger{ + EventType: typeFinalized, EventFilters: []*functionspb.EventFilter{filter("bucket", "b1")}, + }, "storage ok", false}, + {&functionspb.EventTrigger{ + EventType: typeDeleted, EventFilters: []*functionspb.EventFilter{patternFilter("bucket", "logs-*")}, + }, "storage bucket pattern", false}, + {&functionspb.EventTrigger{EventType: typeFinalized}, "storage without bucket", true}, + {&functionspb.EventTrigger{ + EventType: typeFinalized, EventFilters: []*functionspb.EventFilter{filter("bucket", "nope")}, + }, "storage unknown bucket", true}, + {&functionspb.EventTrigger{ + EventType: typeFinalized, + EventFilters: []*functionspb.EventFilter{filter("bucket", "b1"), filter("document", "x")}, + }, "storage bad attribute", true}, + {&functionspb.EventTrigger{ + EventType: typeFinalized, + PubsubTopic: topic, + EventFilters: []*functionspb.EventFilter{filter("bucket", "b1")}, + }, "storage with topic", true}, + {&functionspb.EventTrigger{ + EventType: "google.cloud.storage.object.v1.exploded", + EventFilters: []*functionspb.EventFilter{filter("bucket", "b1")}, + }, "unknown storage kind", true}, + {&functionspb.EventTrigger{ + EventType: typeWritten, EventFilters: []*functionspb.EventFilter{ + filter("database", "(default)"), patternFilter("document", "users/{id}"), + }, + }, "firestore ok", false}, + {&functionspb.EventTrigger{ + EventType: typeCreated, EventFilters: []*functionspb.EventFilter{patternFilter("document", "users/{id}")}, + }, "firestore without database", true}, + {&functionspb.EventTrigger{ + EventType: typeCreated, EventFilters: []*functionspb.EventFilter{ + filter("database", "(default)"), patternFilter("document", "users/{bad id}"), + }, + }, "firestore bad pattern", true}, + {&functionspb.EventTrigger{ + EventType: typeCreated, EventFilters: []*functionspb.EventFilter{patternFilter("database", "(default)")}, + }, "firestore pattern on database", true}, + {&functionspb.EventTrigger{ + EventType: typeCreated, EventFilters: []*functionspb.EventFilter{ + filter("database", "(default)"), {Attribute: "document", Value: "a", Operator: "regex"}, + }, + }, "firestore bad operator", true}, + {&functionspb.EventTrigger{EventType: "google.cloud.audit.log.v1.written"}, "audit log unsupported", true}, + {&functionspb.EventTrigger{ + EventType: gcpfunctions.EventTypePubSub, PubsubTopic: topic, + EventFilters: []*functionspb.EventFilter{filter("bucket", "b1")}, + }, "pubsub with filters", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + e.handler.Backend.SetBucketLookup(fakeBuckets{"b1": true}) + + spec := fnSpec(e.upload(t, zipOf(t, nodeFilesFn()))) + spec.EventTrigger = tt.trigger + + err := e.createErr(t, "val", spec) + if tt.wantErr { + assert.Equal(t, codes.InvalidArgument, status.Code(err), "%v", err) + + return + } + + assert.NoError(t, err) + }) + } +} + +func (e *env) waitRequests(t *testing.T, n int) []received { + t.Helper() + require.Eventually(t, func() bool { return len(e.rt.requests()) >= n }, 10*time.Second, 5*time.Millisecond) + + return e.rt.requests() +} + +func TestStorageTrigger_Delivery(t *testing.T) { + t.Parallel() + + const object = `{"bucket":"b1","name":"dir/a.txt","generation":"7","size":"5","contentType":"text/plain"}` + + e := newEnv(t, true) + triggerFn(t, e, typeFinalized, 0, filter("bucket", "b1")) + + b := e.handler.Backend + at := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + + b.HandleStorageEvent( + gcpfunctions.StorageEvent{Type: "deleted", Bucket: "b1", Name: "x", Data: []byte(`{}`), Time: at}, + ) + b.HandleStorageEvent( + gcpfunctions.StorageEvent{Type: "finalized", Bucket: "other", Name: "x", Data: []byte(`{}`), Time: at}, + ) + b.HandleStorageEvent(gcpfunctions.StorageEvent{ + Type: "finalized", Bucket: "b1", Name: "dir/a.txt", Data: []byte(object), Time: at, + }) + + reqs := e.waitRequests(t, 1) + require.Len(t, reqs, 1, "wrong type and wrong bucket must not be delivered") + + h := reqs[0].Headers + assert.Equal(t, "1.0", h["ce-specversion"]) + assert.Equal(t, typeFinalized, h["ce-type"]) + assert.Equal(t, "//storage.googleapis.com/projects/_/buckets/b1", h["ce-source"]) + assert.Equal(t, "objects/dir/a.txt", h["ce-subject"]) + assert.Equal(t, "b1", h["ce-bucket"]) + assert.Equal(t, "2026-03-04T05:06:07Z", h["ce-time"]) + assert.NotEmpty(t, h["ce-id"]) + assert.Equal(t, "application/json", h["content-type"]) + assert.JSONEq(t, object, reqs[0].Body) +} + +func TestStorageTrigger_BucketPattern(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + triggerFn(t, e, typeFinalized, 0, patternFilter("bucket", "logs-*")) + + b := e.handler.Backend + b.HandleStorageEvent(gcpfunctions.StorageEvent{Type: "finalized", Bucket: "data", Name: "x", Data: []byte(`{}`)}) + b.HandleStorageEvent( + gcpfunctions.StorageEvent{Type: "finalized", Bucket: "logs-prod", Name: "x", Data: []byte(`{}`)}, + ) + + reqs := e.waitRequests(t, 1) + require.Len(t, reqs, 1) + assert.Equal(t, "logs-prod", reqs[0].Headers["ce-bucket"]) +} + +func docEvent(kind, path string, old, cur map[string]string) gcpfunctions.DocumentEvent { + mk := func(f map[string]string) *firestorepb.Document { + if f == nil { + return nil + } + + d := &firestorepb.Document{Name: docPrefix + path, Fields: map[string]*firestorepb.Value{}} + for k, v := range f { + d.Fields[k] = &firestorepb.Value{ValueType: &firestorepb.Value_StringValue{StringValue: v}} + } + + return d + } + + ev := gcpfunctions.DocumentEvent{ + Kind: kind, + Old: mk(old), + New: mk(cur), + Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + } + if kind == "updated" { + ev.UpdateMask = []string{"name"} + } + + return ev +} + +// fieldDoc decodes field number n of a DocumentEventData message as a Firestore document. +func fieldDoc(t *testing.T, body string, n protowire.Number) *firestorepb.Document { + t.Helper() + + raw := []byte(body) + for len(raw) > 0 { + num, typ, l := protowire.ConsumeTag(raw) + require.GreaterOrEqual(t, l, 0) + raw = raw[l:] + + v, l := protowire.ConsumeBytes(raw) + require.GreaterOrEqual(t, l, 0) + require.Equal(t, protowire.BytesType, typ) + + raw = raw[l:] + + if num == n { + d := &firestorepb.Document{} + require.NoError(t, proto.Unmarshal(v, d)) + + return d + } + } + + return nil +} + +func TestFirestoreTrigger_Delivery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + typ string + wantType string + event gcpfunctions.DocumentEvent + filters []*functionspb.EventFilter + want bool + }{ + { + "created matches pattern", + typeCreated, + typeCreated, + docEvent("created", "users/alice", nil, map[string]string{"name": "Alice"}), + []*functionspb.EventFilter{filter("database", "(default)"), patternFilter("document", "users/{id}")}, + true, + }, + {"created ignores updates", typeCreated, "", docEvent( + "updated", + "users/alice", + map[string]string{"name": "A"}, + map[string]string{"name": "B"}, + ), []*functionspb.EventFilter{filter("database", "(default)")}, false}, + {"updated carries old value", typeUpdated, typeUpdated, docEvent( + "updated", + "users/alice", + map[string]string{"name": "A"}, + map[string]string{"name": "B"}, + ), []*functionspb.EventFilter{filter("database", "(default)")}, true}, + { + "written sees deletes", + typeWritten, + typeWritten, + docEvent("deleted", "users/alice", map[string]string{"name": "A"}, nil), + []*functionspb.EventFilter{filter("database", "(default)")}, + true, + }, + { + "pattern is one segment", + typeWritten, + "", + docEvent("created", "users/alice/orders/1", nil, map[string]string{"a": "b"}), + []*functionspb.EventFilter{filter("database", "(default)"), patternFilter("document", "users/{id}")}, + false, + }, + { + "multi segment pattern", + typeWritten, + typeWritten, + docEvent("created", "users/alice/orders/1", nil, map[string]string{"a": "b"}), + []*functionspb.EventFilter{filter("database", "(default)"), patternFilter("document", "users/{path=**}")}, + true, + }, + { + "exact document", + typeWritten, + "", + docEvent("created", "users/alice", nil, map[string]string{"a": "b"}), + []*functionspb.EventFilter{filter("database", "(default)"), filter("document", "users/bob")}, + false, + }, + { + "other database", + typeWritten, + "", + docEvent("created", "users/alice", nil, map[string]string{"a": "b"}), + []*functionspb.EventFilter{filter("database", "analytics")}, + false, + }, + { + "namespace default", + typeWritten, + typeWritten, + docEvent("created", "users/alice", nil, map[string]string{"a": "b"}), + []*functionspb.EventFilter{filter("database", "(default)"), filter("namespace", "(default)")}, + true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + triggerFn(t, e, tt.typ, 0, tt.filters...) + e.handler.Backend.HandleDocumentEvent(tt.event) + + if !tt.want { + e.handler.Backend.Drain() + assert.Empty(t, e.rt.requests()) + + return + } + + reqs := e.waitRequests(t, 1) + require.Len(t, reqs, 1) + + h := reqs[0].Headers + assert.Equal(t, tt.wantType, h["ce-type"]) + assert.Equal(t, "//firestore.googleapis.com/projects/test-project/databases/(default)", h["ce-source"]) + assert.True(t, strings.HasPrefix(h["ce-subject"], "documents/"), h["ce-subject"]) + assert.Equal(t, strings.TrimPrefix(h["ce-subject"], "documents/"), h["ce-document"]) + assert.Equal(t, "(default)", h["ce-database"]) + assert.Equal(t, "application/protobuf", h["content-type"]) + + value, old := fieldDoc(t, reqs[0].Body, 1), fieldDoc(t, reqs[0].Body, 2) + if tt.event.New != nil { + assert.True(t, proto.Equal(tt.event.New, value)) + } else { + assert.Nil(t, value) + } + + if tt.event.Old != nil { + assert.True(t, proto.Equal(tt.event.Old, old)) + } + }) + } +} + +func TestEventTrigger_Retry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want []time.Duration + retry functionspb.EventTrigger_RetryPolicy + }{ + { + "retry backs off exponentially", + []time.Duration{10 * time.Second, 20 * time.Second, 40 * time.Second}, + functionspb.EventTrigger_RETRY_POLICY_RETRY, + }, + {"do not retry gives up", nil, functionspb.EventTrigger_RETRY_POLICY_DO_NOT_RETRY}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + clk := newFakeClock() + e.handler.Backend.SetClock(clk) + e.rt.reply.Store(http.StatusInternalServerError) + + triggerFn(t, e, typeFinalized, tt.retry, filter("bucket", "b1")) + e.handler.Backend.HandleStorageEvent(gcpfunctions.StorageEvent{ + Type: "finalized", Bucket: "b1", Name: "o", Data: []byte(`{}`), + }) + + e.waitRequests(t, 1) + + if tt.want == nil { + e.handler.Backend.Drain() + assert.Empty(t, clk.durations()) + assert.Len(t, e.rt.requests(), 1) + + return + } + + for i, d := range tt.want { + clk.waitNew(t) + require.Equal(t, d, clk.durations()[i]) + clk.advance(d) + e.waitRequests(t, i+2) + } + + e.rt.reply.Store(http.StatusOK) + clk.waitNew(t) + clk.advance(80 * time.Second) + + reqs := e.waitRequests(t, len(tt.want)+2) + assert.Len(t, reqs, len(tt.want)+2) + + ids := map[string]bool{} + for _, r := range reqs { + ids[r.Headers["ce-id"]] = true + } + + assert.Len(t, ids, 1, "redelivery keeps the event id") + assert.Len(t, clk.durations(), len(tt.want)+1, "a delivered event is not retried again") + }) + } +} + +func TestEventTrigger_RetryStopsWhenRetentionEnds(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + clk := newFakeClock() + e.handler.Backend.SetClock(clk) + e.handler.Backend.SetRetryPolicy(10*time.Second, 10*time.Second, 25*time.Second) + e.rt.reply.Store(http.StatusInternalServerError) + + triggerFn(t, e, typeFinalized, functionspb.EventTrigger_RETRY_POLICY_RETRY, filter("bucket", "b1")) + e.handler.Backend.HandleStorageEvent( + gcpfunctions.StorageEvent{Type: "finalized", Bucket: "b1", Name: "o", Data: []byte(`{}`)}, + ) + + for i := range 2 { + e.waitRequests(t, i+1) + clk.waitNew(t) + clk.advance(10 * time.Second) + } + + e.waitRequests(t, 3) + e.handler.Backend.Drain() + assert.Zero(t, clk.pending(true)) + assert.Len(t, e.rt.requests(), 3, "no redelivery past the retention window") +} diff --git a/services/gcpfunctions/validate.go b/services/gcpfunctions/validate.go index 049cec7d6f..ba12c6db67 100644 --- a/services/gcpfunctions/validate.go +++ b/services/gcpfunctions/validate.go @@ -10,13 +10,12 @@ import ( ) const ( - // EventTypePubSub is the only event type the emulator delivers. + // EventTypePubSub is the Pub/Sub event type. EventTypePubSub = "google.cloud.pubsub.topic.v1.messagePublished" maxTimeout = 3600 topicNameLen = 4 defaultMemory = "256M" - defaultCPU = "0.1666" defaultTO = 60 defaultMaxIns = 100 ) @@ -90,8 +89,12 @@ func validateService(sc *functionspb.ServiceConfig) error { return errInvalid("service_config.min_instance_count must not exceed max_instance_count.") } - if len(sc.GetSecretEnvironmentVariables()) > 0 || len(sc.GetSecretVolumes()) > 0 { - return errInvalid("Secret environment variables and volumes are not supported by the emulator.") + if err := validateResources(sc); err != nil { + return err + } + + if err := validateSecrets(sc); err != nil { + return err } return validateEnvVars(sc.GetEnvironmentVariables()) @@ -102,16 +105,21 @@ func (b *InMemoryBackend) validateTrigger(et *functionspb.EventTrigger) error { return nil } - if et.GetEventType() != EventTypePubSub { - return errInvalid( - "Event type %q is not supported by the emulator; supported: %s.", - et.GetEventType(), - EventTypePubSub, - ) + switch typ := et.GetEventType(); { + case typ == EventTypePubSub: + return b.validatePubSubTrigger(et) + case isStorageType(typ): + return b.validateStorageTrigger(et) + case isFirestoreType(typ): + return validateFirestoreTrigger(et) + default: + return errInvalid("Event type %q is not supported by the emulator; supported: %s.", typ, supportedEventTypes()) } +} +func (b *InMemoryBackend) validatePubSubTrigger(et *functionspb.EventTrigger) error { if len(et.GetEventFilters()) > 0 { - return errInvalid("event_filters are not supported by the emulator.") + return errInvalid("event_filters are not supported for Pub/Sub triggers; use pubsub_topic.") } if parts := strings.Split(et.GetPubsubTopic(), "/"); len(parts) != topicNameLen || parts[0] != "projects" || @@ -157,7 +165,7 @@ func applyDefaults(sc *functionspb.ServiceConfig) *functionspb.ServiceConfig { } if sc.GetAvailableCpu() == "" { - sc.AvailableCpu = defaultCPU + sc.AvailableCpu = defaultCPUFor(sc.GetAvailableMemory()) } if sc.GetTimeoutSeconds() == 0 { diff --git a/services/gcpiam/handler.go b/services/gcpiam/handler.go index fc9ccba22b..c46cfbc038 100644 --- a/services/gcpiam/handler.go +++ b/services/gcpiam/handler.go @@ -15,6 +15,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -125,7 +126,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpiam", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "iam.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) adminpb.RegisterIAMServer(srv, &server{b: h.Backend}) h.srvMu.Lock("StartWorker") diff --git a/services/gcpiam/verify.go b/services/gcpiam/verify.go new file mode 100644 index 0000000000..8e60551c21 --- /dev/null +++ b/services/gcpiam/verify.go @@ -0,0 +1,44 @@ +package gcpiam + +import ( + "crypto/rsa" + "crypto/x509" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// VerificationKeys returns the public keys of an enabled service account's usable keys, for services that +// verify tokens signed by it (Identity Platform custom tokens). Unknown or disabled accounts yield none. +func (b *InMemoryBackend) VerificationKeys(email string) []*rsa.PublicKey { + b.mu.RLock("VerificationKeys") + defer b.mu.RUnlock() + + var out []*rsa.PublicKey + + for _, rec := range b.accounts { + if !rec.active() || rec.pb.GetDisabled() || !strs.Equal(rec.pb.GetEmail(), email) { + continue + } + + keys := make([]*keyRec, 0, len(rec.keys)+1) + for _, k := range rec.keys { + if !k.pb.GetDisabled() { + keys = append(keys, k) + } + } + + if rec.system != nil { + keys = append(keys, rec.system) + } + + for _, k := range keys { + if pub, err := x509.ParsePKIXPublicKey(k.public); err == nil { + if rk, ok := pub.(*rsa.PublicKey); ok { + out = append(out, rk) + } + } + } + } + + return out +} diff --git a/services/gcpidentitytoolkit/PARITY.md b/services/gcpidentitytoolkit/PARITY.md new file mode 100644 index 0000000000..6d798a5d1e --- /dev/null +++ b/services/gcpidentitytoolkit/PARITY.md @@ -0,0 +1,56 @@ +--- +service: gcpidentitytoolkit +sdk_module: google.golang.org/api@v0.304.0 +last_audit_commit: 185f8a81e +last_audit_date: 2026-10-29 +overall: B +# Per-op or per-op-family status. Values: ok | partial | gap | deferred. +# wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. +ops: + accounts.signUp: {wire: ok, errors: partial, state: ok, persist: ok, note: "Email/password (EMAIL_EXISTS, WEAK_PASSWORD, INVALID_EMAIL, MISSING_PASSWORD, OPERATION_NOT_ALLOWED when the email provider or tenant allowPasswordSignup is off) and anonymous (ADMIN_ONLY_OPERATION when signIn.anonymous is off); with an idToken it links email/password to an anonymous account. Returns kind identitytoolkit#SignupNewUserResponse, idToken, refreshToken, expiresIn \"3600\" as a string, localId. Verified by decoding with google.golang.org/api/identitytoolkit/v1 (GoogleCloudIdentitytoolkitV1SignUpResponse)."} + accounts.signInWithPassword: {wire: ok, errors: partial, state: ok, persist: ok, note: "With email enumeration protection (config.emailPrivacyConfig.enableImprovedEmailPrivacy, default true as for projects created after 2023-09) an unknown email and a wrong password are both INVALID_LOGIN_CREDENTIALS; with it off they are EMAIL_NOT_FOUND and INVALID_PASSWORD. USER_DISABLED is checked after the password. The password hash is verified outside the lock; users imported with another hash scheme are re-hashed to the native scheme on first successful sign-in. TOO_MANY_ATTEMPTS_TRY_LATER is not produced."} + accounts.signInWithCustomToken: {wire: ok, errors: partial, state: ok, persist: ok, note: "Accepts the unsigned (alg none) tokens the Admin SDK mints in FIREBASE_AUTH_EMULATOR_HOST mode and RS256 tokens whose iss is a Cloud IAM service account known to the emulator (Backend.SetCustomTokenKeys, wired to gcpiam.VerificationKeys by internal/app; enabled keys and the system key). Without that wiring RS256 tokens are INVALID_CUSTOM_TOKEN. Checks aud, exp/iat (1h max lifetime, 5m skew), uid length, reserved developer claims (FORBIDDEN_CLAIM) and tenant_id (TENANT_ID_MISMATCH); creates the account on first use (isNewUser) and carries developer claims into ID tokens, including after refresh."} + accounts.lookup: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Client form (idToken) returns passwordHash as the REDACTED placeholder \"UkVEQUNURUQ=\" and no salt; admin form (Bearer) looks up by localId, email, phoneNumber or federatedUserId and returns the real passwordHash and salt. A miss returns the kind only, with no users."} + accounts.update: {wire: ok, errors: partial, state: ok, persist: ok, note: "Self-service by idToken, by oobCode (VERIFY_EMAIL, VERIFY_AND_CHANGE_EMAIL) or by localId for admins. email (re-verification reset), password (revokes older tokens by moving validSince), displayName, photoUrl, deleteAttribute (DISPLAY_NAME, PHOTO_URL, PASSWORD, EMAIL), deleteProvider, returnSecureToken. Admin only: disableUser, emailVerified, phoneNumber, customAttributes (reserved claims FORBIDDEN_CLAIM, over 1000 bytes CLAIMS_TOO_LARGE, bad JSON INVALID_CLAIMS), validSince, linkProviderUserInfo. Admin-only fields sent with an idToken are ignored."} + accounts.delete: {wire: ok, errors: ok, state: ok, persist: ok, note: "By idToken or, for admins, localId (USER_NOT_FOUND)."} + accounts.sendOobCode: {wire: ok, errors: partial, state: ok, persist: ok, note: "PASSWORD_RESET, VERIFY_EMAIL, EMAIL_SIGNIN, VERIFY_AND_CHANGE_EMAIL. No email is sent; codes are stored (TTL: reset 1h, verify 3d, sign-in 6h, change 3d, on the injectable clock) and listed by the emulator endpoint below. Admin calls with returnOobLink get oobCode and oobLink (http://host/emulator/action?mode=...&oobCode=...&apiKey=fake-api-key). Client PASSWORD_RESET for an unknown email is silent success under enumeration protection, EMAIL_NOT_FOUND otherwise. The TTLs are assumptions."} + accounts.resetPassword: {wire: ok, errors: ok, state: ok, persist: ok, note: "Without newPassword it only checks the code (email, requestType, newEmail); with it sets the password, marks the email verified, revokes older tokens and consumes the code. INVALID_OOB_CODE, EXPIRED_OOB_CODE."} + accounts.signInWithEmailLink: {wire: ok, errors: ok, state: ok, persist: ok, note: "Needs email enabled with passwordRequired false (or tenant enableEmailLinkSignin); creates the account on first use, marks the email verified. INVALID_EMAIL when the email differs from the code's."} + accounts.createAuthUri: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Email identifier flow only: registered/allProviders/signinMethods (password, emailLink) unless enumeration protection hides them. providerId (federated) flows are rejected with OPERATION_NOT_ALLOWED."} + accounts.signInWithIdp: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Not routed (404)."} + accounts.batchCreate: {wire: ok, errors: partial, state: ok, persist: ok, note: "Import with BCRYPT, SCRYPT (Firebase-modified), STANDARD_SCRYPT, HMAC_SHA1/256/512/MD5, MD5/SHA1/SHA256/SHA512 (rounds), PBKDF_SHA1, PBKDF2_SHA256 and passwordHashOrder. Per-user failures are reported in error[] with index and message (DUPLICATE_LOCAL_ID, EMAIL_EXISTS, ...), allowOverwrite replaces by localId. Verified against the Go Admin SDK ImportUsers. The failure message texts are not verified against production."} + accounts.batchGet: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Admin export with real hashes, ordered by creation time, paged through pkgs/page (maxResults default 20, max 1000)."} + accounts.batchDelete: {wire: ok, errors: ok, state: ok, persist: ok, note: "Without force only disabled accounts are removed (errors[] carries \"NOT_DISABLED : Disable the account before batch deletion.\"); missing uids succeed silently."} + accounts.query: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Also routed as projects/{p}:queryAccounts. Expressions (email, phoneNumber, userId) are ANDed; sortBy USER_ID, NAME, CREATED_AT, LAST_LOGIN_AT, USER_EMAIL; order; limit/offset; returnUserInfo false returns only recordsCount."} + projects.accounts.create: {wire: ok, errors: ok, state: ok, persist: ok, note: "POST projects/{p}/accounts (what Admin SDK CreateUser calls) creates an account without tokens: localId (DUPLICATE_LOCAL_ID), email, password, phoneNumber (PHONE_NUMBER_EXISTS), displayName, photoUrl, emailVerified, disabled, customAttributes."} + projects.createSessionCookie: {wire: ok, errors: ok, state: ok, persist: n/a, note: "validDuration 300s to 14d (INVALID_DURATION); RS256 cookie with iss https://session.firebase.google.com/, signed with the same key. Revocation uses the account's validSince."} + token.refresh: {wire: ok, errors: ok, state: ok, persist: n/a, note: "securetoken v1/token (form or JSON body): snake_case response with access_token, id_token, refresh_token (unchanged), expires_in \"3600\", token_type Bearer, user_id, project_id (derived number). Errors carry status INVALID_ARGUMENT: INVALID_GRANT_TYPE, MISSING_REFRESH_TOKEN, INVALID_REFRESH_TOKEN, TOKEN_EXPIRED (revoked), USER_NOT_FOUND, USER_DISABLED. Refresh tokens are stateless HMAC-signed payloads."} + v2.projects.config: {wire: ok, errors: ok, state: ok, persist: ok, note: "GET and PATCH (updateMask honoured, JSON merge) of projects/{p}/config using the v2 Config type. Enforced: signIn.email.enabled / passwordRequired, signIn.anonymous.enabled, emailPrivacyConfig, passwordPolicyConfig (ENFORCE with customStrengthOptions -> PASSWORD_DOES_NOT_MEET_REQUIREMENTS). signIn.hashConfig is output only and reports the project's native SCRYPT parameters. Defaults are emulator friendly: email enabled with passwordRequired false (so email-link sign-in also works) and anonymous enabled; the real default for a new project has providers off. Other config sections (mfa, blockingFunctions, recaptcha, sms, ...) are stored but have no effect."} + v2.projects.tenants: {wire: ok, errors: ok, state: ok, persist: ok, note: "CRUD plus list paging. Tenant ids are displayName-xxxxx. A tenant is a separate account namespace, reached as /v1/projects/{p}/tenants/{t}/accounts... or with tenantId in client bodies; tokens carry firebase.tenant. allowPasswordSignup, enableEmailLinkSignin and enableAnonymousUser default to false as in production. TENANT_NOT_FOUND is 404 NOT_FOUND."} + public_keys: {wire: ok, errors: ok, state: ok, persist: ok, note: "GET /robot/v1/metadata/x509/securetoken@system.gserviceaccount.com (kid -> PEM certificate), /service_accounts/v1/jwk/securetoken@system.gserviceaccount.com (JWKS) and /identitytoolkit/v3/relyingparty/publicKeys serve the one RS256 signing key (2048 bit, generated on first use, kept in the snapshot)."} + emulator.oobCodes: {wire: ok, errors: ok, state: ok, persist: n/a, note: "gopherstack/Firebase-emulator style endpoint: GET /emulator/v1/projects/{p}/oobCodes[?tenantId=] returns {oobCodes:[{email, oobCode, oobLink, requestType}]} oldest first, the only way to read a code since no email is sent. DELETE /emulator/v1/projects/{p}/accounts[?tenantId=] clears all accounts of a project or tenant."} +families: + id_tokens: {status: ok, note: "RS256 JWTs: iss https://securetoken.google.com/, aud , auth_time, user_id, sub, iat, exp = iat + 1h on the backend clock, email, email_verified, phone_number, name, picture, custom claims at the top level, firebase{identities, sign_in_provider (password, anonymous, custom), tenant}. Revoked (iat < validSince) and expired tokens fail client calls with TOKEN_EXPIRED."} + admin_sdk_emulator_mode: {status: ok, note: "Point the Go (and other) Admin SDKs at the listener with FIREBASE_AUTH_EMULATOR_HOST=localhost:8145: the SDK then uses http://host/identitytoolkit.googleapis.com/{v1,v2} (the prefix is stripped), authenticates with the literal Bearer owner, mints unsigned custom tokens and skips signature verification (it still performs the revocation lookup). Tested through firebase.google.com/go/v4/auth v4.22.0. Without the variable the SDKs fetch keys from googleapis.com, which cannot be redirected, so ID tokens only verify in emulator mode."} + auth: {status: partial, note: "Client calls need an API key (?key= or X-Goog-Api-Key, any value; the project is the one in the path or --gcp-identitytoolkit-project, default gopherstack-local). Admin operations need any Bearer token. Tokens are not validated and IAM permissions are not evaluated."} + persistence: {status: ok, note: "Projects (config, tenants, accounts with hash material, out-of-band codes) and the signing key plus refresh-token secret are in backendSnapshot, so tokens survive a restart."} + routing_isolation: {status: ok, note: "Own REST listener bound in StartWorker to --gcp-identitytoolkit-port / GCP_IDENTITYTOOLKIT_PORT (default 8145), fails fast if taken, reserved in the shared PortAlloc pool; never registered on the shared AWS router."} + observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; requests go through telemetry. No dashboard page."} +items_still_open: + - "accounts:signInWithIdp, federated createAuthUri, OIDC/SAML/OAuth provider configs (v2 defaultSupportedIdpConfigs, oauthIdpConfigs, inboundSamlConfigs) and linking federated providers through sign-in are not implemented." + - "Multi-factor authentication (phone/TOTP enrollment and sign-in, mfaInfo) and phone-number sign-in (sendVerificationCode, signInWithPhoneNumber) are not implemented; mfaInfo on create is ignored." + - "No email is sent and the /emulator/action page that applies a link in a browser is not served; codes are read from the emulator oobCodes endpoint." + - "Out-of-band code lifetimes, batchCreate failure message texts, the passwordHashOrder default for HMAC/plain hashes (password then salt), the REDACTED placeholder in client lookups and the config defaults were chosen from documentation and SDK behaviour without a production reference." + - "TOO_MANY_ATTEMPTS_TRY_LATER and quotas, API key restrictions, authorized-domain checks of continueUrl, blocking functions, reCAPTCHA, SMS region policy and v2 passwordPolicy/recaptchaConfig client endpoints are not implemented." + - "The Admin SDKs in production mode fetch signing keys from googleapis.com and cannot be redirected to this emulator; only FIREBASE_AUTH_EMULATOR_HOST mode verifies tokens." +--- + +# Identity Platform / Firebase Authentication parity + +Prose notes are history only; `items_still_open` above is the authoritative open list. + +## 2026-10-29 initial implementation + +Identity Toolkit v1 (client and admin), v2 admin (config, tenants) and Secure Token on one listener. Wire shapes were +checked by decoding with `google.golang.org/api/identitytoolkit/v1` and by driving the service through +`firebase.google.com/go/v4/auth` in emulator mode. diff --git a/services/gcpidentitytoolkit/README.md b/services/gcpidentitytoolkit/README.md new file mode 100644 index 0000000000..f6c50dca07 --- /dev/null +++ b/services/gcpidentitytoolkit/README.md @@ -0,0 +1,28 @@ + +# Identity Platform + +**Parity grade: B** · SDK `google.golang.org/api@v0.304.0` · last audited 2026-10-29 (`185f8a81e`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 1 (1 ok) | +| Feature families | 6 (4 ok, 2 partial) | +| Known gaps | 6 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- accounts:signInWithIdp, federated createAuthUri, OIDC/SAML/OAuth provider configs (v2 defaultSupportedIdpConfigs, oauthIdpConfigs, inboundSamlConfigs) and linking federated providers through sign-in are not implemented. +- Multi-factor authentication (phone/TOTP enrollment and sign-in, mfaInfo) and phone-number sign-in (sendVerificationCode, signInWithPhoneNumber) are not implemented; mfaInfo on create is ignored. +- No email is sent and the /emulator/action page that applies a link in a browser is not served; codes are read from the emulator oobCodes endpoint. +- Out-of-band code lifetimes, batchCreate failure message texts, the passwordHashOrder default for HMAC/plain hashes (password then salt), the REDACTED placeholder in client lookups and the config defaults were chosen from documentation and SDK behaviour without a production reference. +- TOO_MANY_ATTEMPTS_TRY_LATER and quotas, API key restrictions, authorized-domain checks of continueUrl, blocking functions, reCAPTCHA, SMS region policy and v2 passwordPolicy/recaptchaConfig client endpoints are not implemented. +- The Admin SDKs in production mode fetch signing keys from googleapis.com and cannot be redirected to this emulator; only FIREBASE_AUTH_EMULATOR_HOST mode verifies tokens. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/gcpidentitytoolkit/admin.go b/services/gcpidentitytoolkit/admin.go new file mode 100644 index 0000000000..0fd7f76c44 --- /dev/null +++ b/services/gcpidentitytoolkit/admin.go @@ -0,0 +1,718 @@ +package gcpidentitytoolkit + +import ( + "errors" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + idtkv2 "google.golang.org/api/identitytoolkit/v2" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + batchGetDefault = 20 + queryDefault = 500 + maxImportUsers = 1000 + tenantIDRandLen = 5 + tenantSuffixBytes = 8 + tenantPageSize = 20 +) + +var tenantNameRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]{3,19}$`) + +// AdminCreate creates an account without issuing tokens (projects/{p}/accounts). +func (b *InMemoryBackend) AdminCreate(c call, r *authRequest) (map[string]any, error) { + b.mu.Lock("AdminCreate") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + claims := "" + + if r.CustomAttributes != nil { + if claims, err = parseClaims(*r.CustomAttributes); err != nil { + return nil, err + } + } + + n := newUser{ + uid: r.LocalID.first(), email: r.Email.first(), password: r.Password, displayName: deref(r.DisplayName), + photoURL: deref(r.PhotoURL), phone: r.PhoneNumber.first(), customAttrs: claims, + emailVerified: r.EmailVerified != nil && *r.EmailVerified, disabled: r.Disabled != nil && *r.Disabled, + } + + u, err := b.createUserLocked(sc, n, false) + if err != nil { + return nil, err + } + + return map[string]any{keyKind: "identitytoolkit#SignupNewUserResponse", "localId": u.UID}, nil +} + +// uploadRequest is the body of accounts:batchCreate. +type uploadRequest struct { + HashAlgorithm string `json:"hashAlgorithm"` + SignerKey string `json:"signerKey"` + SaltSeparator string `json:"saltSeparator"` + PasswordHashOrder string `json:"passwordHashOrder"` + TenantID string `json:"tenantId"` + Users []userWire `json:"users"` + Rounds flexInt `json:"rounds"` + MemoryCost flexInt `json:"memoryCost"` + CPUMemCost flexInt `json:"cpuMemCost"` + BlockSize flexInt `json:"blockSize"` + Parallelization flexInt `json:"parallelization"` + DKLen flexInt `json:"dkLen"` + AllowOverwrite bool `json:"allowOverwrite"` +} + +func (u *uploadRequest) spec() (*hashSpec, error) { + if u.HashAlgorithm == "" { + return nil, nil //nolint:nilnil // no hash algorithm supplied + } + + key, err1 := decodeOptional(u.SignerKey) + sep, err2 := decodeOptional(u.SaltSeparator) + + if err1 != nil || err2 != nil { + return nil, idtk("INVALID_HASH_ALGORITHM", "Invalid base64 in hash parameters.") + } + + h := &hashSpec{ + Algorithm: strings.ToUpper(u.HashAlgorithm), SignerKey: key, SaltSeparator: sep, + PasswordHashOrder: u.PasswordHashOrder, Rounds: int64(u.Rounds), MemoryCost: int64(u.MemoryCost), + CPUMemCost: int64(u.CPUMemCost), BlockSize: int64(u.BlockSize), Parallelization: int64(u.Parallelization), + DKLen: int64(u.DKLen), + } + + if h.validate() != nil { + return nil, idtk("INVALID_HASH_ALGORITHM", "Unsupported or incomplete hash configuration.") + } + + return h, nil +} + +func decodeOptional(s string) ([]byte, error) { + if s == "" { + return nil, nil + } + + return decodeB64(s) +} + +// BatchCreate imports accounts with optional password hashes (accounts:batchCreate). +func (b *InMemoryBackend) BatchCreate(c call, r *uploadRequest) (map[string]any, error) { + if len(r.Users) == 0 { + return nil, idtk("MISSING_USER_ACCOUNT") + } + + if len(r.Users) > maxImportUsers { + return nil, idtk("TOO_MANY_USERS", "Too many users in one request.") + } + + spec, err := r.spec() + if err != nil { + return nil, err + } + + b.mu.Lock("BatchCreate") + defer b.mu.Unlock() + + tenant := r.TenantID + if c.tenant != "" { + tenant = c.tenant + } + + sc, err := b.scopeLocked(c.project, tenant) + if err != nil { + return nil, err + } + + var failures []map[string]any + + for i := range r.Users { + if msg := b.importOneLocked(sc, &r.Users[i], spec, r.AllowOverwrite); msg != "" { + failures = append(failures, map[string]any{"index": i, "message": msg}) + } + } + + out := map[string]any{keyKind: "identitytoolkit#UploadAccountResponse"} + if len(failures) > 0 { + out["error"] = failures + } + + return out, nil +} + +func (b *InMemoryBackend) importOneLocked(sc *scope, w *userWire, spec *hashSpec, overwrite bool) string { + if w.LocalID == "" { + return "localId is missing" + } + + n := newUser{ + uid: w.LocalID, email: w.Email, displayName: w.DisplayName, photoURL: w.PhotoURL, phone: w.PhoneNumber, + emailVerified: w.EmailVerified, disabled: w.Disabled, createdAt: int64(w.CreatedAt), + lastLoginAt: int64(w.LastLoginAt), + } + + if w.CustomAttributes != "" { + claims, err := parseClaims(w.CustomAttributes) + if err != nil { + return "Invalid custom claims" + } + + n.customAttrs = claims + } + + if msg := importPassword(&n, w, spec); msg != "" { + return msg + } + + for _, p := range w.ProviderUserInfo { + if p.ProviderID == passwordProv || p.ProviderID == phoneProv || p.ProviderID == "" || p.RawID == "" { + continue + } + + n.providers = append(n.providers, providerRec(p)) + } + + if _, err := b.createUserLocked(sc, n, overwrite); err != nil { + if ae, ok := errors.AsType[*APIError](err); ok { + return ae.Message + } + + return err.Error() + } + + return "" +} + +func importPassword(n *newUser, w *userWire, spec *hashSpec) string { + if w.PasswordHash == "" { + return "" + } + + if spec == nil || w.Email == "" { + return "Password hash requires an email and a hash algorithm" + } + + hash, err1 := decodeB64(w.PasswordHash) + salt, err2 := decodeOptional(w.Salt) + + if err1 != nil || err2 != nil { + return "Invalid base64 in passwordHash or salt" + } + + n.passwordHash, n.salt, n.hash = hash, salt, spec + + return "" +} + +// BatchDelete deletes accounts; without force only disabled accounts are deleted. +func (b *InMemoryBackend) BatchDelete(c call, ids []string, force bool, tenant string) (map[string]any, error) { + if len(ids) == 0 { + return nil, idtk("MISSING_LOCAL_ID") + } + + if len(ids) > maxImportUsers { + return nil, idtk("TOO_MANY_USERS", "Too many users in one request.") + } + + b.mu.Lock("BatchDelete") + defer b.mu.Unlock() + + if c.tenant != "" { + tenant = c.tenant + } + + sc, err := b.scopeLocked(c.project, tenant) + if err != nil { + return nil, err + } + + var failures []map[string]any + + for i, id := range ids { + u, ok := sc.users[id] + if !ok { + continue + } + + if !force && !u.Disabled { + failures = append(failures, map[string]any{ + "index": i, "localId": id, "message": "NOT_DISABLED : Disable the account before batch deletion.", + }) + + continue + } + + delete(sc.users, id) + } + + out := map[string]any{} + if len(failures) > 0 { + out["errors"] = failures + } + + return out, nil +} + +// BatchGet pages through accounts with their full password hashes. +func (b *InMemoryBackend) BatchGet(c call, tenant, token string, maxResults int) (map[string]any, error) { + if maxResults < 0 || maxResults > maxPage { + return nil, idtk("INVALID_MAX_RESULTS") + } + + if err := page.ValidateToken(token); err != nil { + return nil, idtk("INVALID_PAGE_TOKEN") + } + + b.mu.Lock("BatchGet") + defer b.mu.Unlock() + + if c.tenant != "" { + tenant = c.tenant + } + + sc, err := b.scopeLocked(c.project, tenant) + if err != nil { + return nil, err + } + + all := sc.sorted() + pg := page.New(all, token, maxResults, batchGetDefault) + users := make([]userWire, 0, len(pg.Data)) + + for _, u := range pg.Data { + users = append(users, b.toWire(sc, u, true)) + } + + out := map[string]any{keyKind: "identitytoolkit#DownloadAccountResponse"} + if len(users) > 0 { + out["users"] = users + } + + if pg.Next != "" { + out["nextPageToken"] = pg.Next + } + + return out, nil +} + +// queryRequest is the body of accounts:query. +type queryRequest struct { + ReturnUserInfo *bool `json:"returnUserInfo"` + SortBy string `json:"sortBy"` + Order string `json:"order"` + TenantID string `json:"tenantId"` + Expression []struct { + Email string `json:"email"` + PhoneNumber string `json:"phoneNumber"` + UserID string `json:"userId"` + } `json:"expression"` + Limit flexInt `json:"limit"` + Offset flexInt `json:"offset"` +} + +// Query filters, sorts and pages accounts (accounts:query). +func (b *InMemoryBackend) Query(c call, r *queryRequest) (map[string]any, error) { + if r.Limit < 0 || r.Offset < 0 { + return nil, idtk("INVALID_QUERY", "limit and offset must be non-negative") + } + + b.mu.Lock("Query") + defer b.mu.Unlock() + + tenant := r.TenantID + if c.tenant != "" { + tenant = c.tenant + } + + sc, err := b.scopeLocked(c.project, tenant) + if err != nil { + return nil, err + } + + matched := make([]*userRec, 0) + + for _, u := range sc.sorted() { + if r.matches(u) { + matched = append(matched, u) + } + } + + sortUsers(matched, r.SortBy, r.Order) + + out := map[string]any{"recordsCount": itoa(len(matched))} + if r.ReturnUserInfo != nil && !*r.ReturnUserInfo { + return out, nil + } + + limit := int(r.Limit) + if limit == 0 { + limit = queryDefault + } + + start := min(int(r.Offset), len(matched)) + end := min(start+limit, len(matched)) + users := make([]userWire, 0, end-start) + + for _, u := range matched[start:end] { + users = append(users, b.toWire(sc, u, true)) + } + + if len(users) > 0 { + out["userInfo"] = users + } + + return out, nil +} + +func (r *queryRequest) matches(u *userRec) bool { + for _, e := range r.Expression { + if (e.Email != "" && !strs.Equal(e.Email, u.Email)) || + (e.PhoneNumber != "" && e.PhoneNumber != u.Phone) || (e.UserID != "" && e.UserID != u.UID) { + return false + } + } + + return true +} + +func sortUsers(us []*userRec, by, order string) { + less := func(a, b *userRec) bool { return a.CreatedAt < b.CreatedAt } + + switch by { + case "USER_ID": + less = func(a, b *userRec) bool { return a.UID < b.UID } + case "NAME": + less = func(a, b *userRec) bool { return a.DisplayName < b.DisplayName } + case "LAST_LOGIN_AT": + less = func(a, b *userRec) bool { return a.LastLoginAt < b.LastLoginAt } + case "USER_EMAIL": + less = func(a, b *userRec) bool { return a.Email < b.Email } + } + + sort.SliceStable(us, func(i, j int) bool { + if order == "DESC" { + return less(us[j], us[i]) + } + + return less(us[i], us[j]) + }) +} + +func itoa(n int) string { return strconv.Itoa(n) } + +// ClearAccounts deletes every account in a project or tenant (emulator reset endpoint). +func (b *InMemoryBackend) ClearAccounts(project, tenant string) error { + b.mu.Lock("ClearAccounts") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(project, tenant) + if err != nil { + return err + } + + clear(sc.users) + + return nil +} + +// OOBCode is one stored out-of-band code, as listed by the emulator endpoint. +type OOBCode struct { + Email string `json:"email"` + OobCode string `json:"oobCode"` + OobLink string `json:"oobLink"` + RequestType string `json:"requestType"` +} + +// OOBCodes lists the live codes of a project, oldest first. No email is ever sent; tests read codes here. +func (b *InMemoryBackend) OOBCodes(project, tenant string) ([]OOBCode, error) { + b.mu.Lock("OOBCodes") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(project, tenant) + if err != nil { + return nil, err + } + + type entry struct { + rec *oobRec + code string + } + + var list []entry + + for code, rec := range sc.proj.Codes { + if rec.TenantID == tenant { + list = append(list, entry{rec, code}) + } + } + + sort.Slice(list, func(i, j int) bool { + if list[i].rec.Created != list[j].rec.Created { + return list[i].rec.Created < list[j].rec.Created + } + + return list[i].code < list[j].code + }) + + out := make([]OOBCode, 0, len(list)) + for _, e := range list { + out = append( + out, + OOBCode{Email: e.rec.Email, OobCode: e.code, OobLink: e.rec.Link, RequestType: e.rec.RequestType}, + ) + } + + return out, nil +} + +// GetConfig returns the project configuration. +func (b *InMemoryBackend) GetConfig(project string) *idtkv2.GoogleCloudIdentitytoolkitAdminV2Config { + b.mu.Lock("GetConfig") + defer b.mu.Unlock() + + p := b.projectLocked(project) + + return withHashConfig(p) +} + +func withHashConfig(p *projectRec) *idtkv2.GoogleCloudIdentitytoolkitAdminV2Config { + cfg := cloneJSON(p.Config) + if cfg.SignIn == nil { + cfg.SignIn = &idtkv2.GoogleCloudIdentitytoolkitAdminV2SignInConfig{} + } + + cfg.SignIn.HashConfig = hashConfigWire(p.Hash) + + return cfg +} + +func hashConfigWire(h hashSpec) *idtkv2.GoogleCloudIdentitytoolkitAdminV2HashConfig { + return &idtkv2.GoogleCloudIdentitytoolkitAdminV2HashConfig{ + Algorithm: h.Algorithm, MemoryCost: h.MemoryCost, Rounds: h.Rounds, + SignerKey: hashB64(h.SignerKey), SaltSeparator: hashB64(h.SaltSeparator), + } +} + +// maskedPatch keeps only the fields named by a comma-separated updateMask (all when empty). +func maskedPatch(patch map[string]any, mask string) map[string]any { + if mask == "" { + return patch + } + + out := map[string]any{} + + for path := range strings.SplitSeq(mask, ",") { + parts := strings.Split(strings.TrimSpace(path), ".") + + var val any = patch + + for _, part := range parts { + m, ok := val.(map[string]any) + if !ok { + val = nil + + break + } + + val = m[part] + } + + cur := out + + for i, part := range parts { + if i == len(parts)-1 { + cur[part] = val + + break + } + + next, ok := cur[part].(map[string]any) + if !ok { + next = map[string]any{} + cur[part] = next + } + + cur = next + } + } + + return out +} + +// UpdateConfig merges a patch into the project configuration. +func (b *InMemoryBackend) UpdateConfig( + project string, + patch map[string]any, + mask string, +) (*idtkv2.GoogleCloudIdentitytoolkitAdminV2Config, error) { + for _, k := range []string{"name", "signIn.hashConfig"} { + if mask == k { + return nil, errInvalidArg("Field %q is output only.", k) + } + } + + b.mu.Lock("UpdateConfig") + defer b.mu.Unlock() + + p := b.projectLocked(project) + + next, err := patched(p.Config, maskedPatch(patch, mask)) + if err != nil { + return nil, err + } + + next.Name = p.Config.Name + p.Config = next + + return withHashConfig(p), nil +} + +func tenantName(project, id string) string { return "projects/" + project + "/tenants/" + id } + +// CreateTenant creates a tenant with a generated id. +func (b *InMemoryBackend) CreateTenant( + project string, + in *idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant, +) (*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant, error) { + if !tenantNameRE.MatchString(in.DisplayName) { + return nil, errInvalidArg( + "Invalid tenant display name: must be 4-20 characters, start with a letter " + + "and contain only letters, digits and hyphens.", + ) + } + + b.mu.Lock("CreateTenant") + defer b.mu.Unlock() + + p := b.projectLocked(project) + + var id string + + for { + id = strs.Fold(in.DisplayName) + if len(id) > 20-tenantIDRandLen-1 { + id = id[:20-tenantIDRandLen-1] + } + + id += "-" + strings.ToLower(randomToken(tenantSuffixBytes))[:tenantIDRandLen] + id = strings.NewReplacer("_", "x").Replace(id) + + if _, taken := p.Tenants[id]; !taken { + break + } + } + + t := cloneJSON(in) + t.Name = tenantName(project, id) + p.Tenants[id] = t + p.Users[id] = map[string]*userRec{} + + return cloneJSON(t), nil +} + +// GetTenant returns one tenant. +func (b *InMemoryBackend) GetTenant(project, id string) (*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant, error) { + b.mu.Lock("GetTenant") + defer b.mu.Unlock() + + t, ok := b.projectLocked(project).Tenants[id] + if !ok { + return nil, errNotFound("TENANT_NOT_FOUND") + } + + return cloneJSON(t), nil +} + +// ListTenants pages through a project's tenants. +func (b *InMemoryBackend) ListTenants( + project, token string, + size int, +) (page.Page[*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant], error) { + if err := page.ValidateToken(token); err != nil { + return page.Page[*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant]{}, errInvalidArg("Invalid page token.") + } + + b.mu.Lock("ListTenants") + defer b.mu.Unlock() + + p := b.projectLocked(project) + ids := make([]string, 0, len(p.Tenants)) + + for id := range p.Tenants { + ids = append(ids, id) + } + + slices.Sort(ids) + + all := make([]*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant, 0, len(ids)) + for _, id := range ids { + all = append(all, cloneJSON(p.Tenants[id])) + } + + return page.New(all, token, size, tenantPageSize), nil +} + +// UpdateTenant merges a patch into a tenant. +func (b *InMemoryBackend) UpdateTenant( + project, id string, + patch map[string]any, + mask string, +) (*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant, error) { + b.mu.Lock("UpdateTenant") + defer b.mu.Unlock() + + p := b.projectLocked(project) + + cur, ok := p.Tenants[id] + if !ok { + return nil, errNotFound("TENANT_NOT_FOUND") + } + + next, err := patched(cur, maskedPatch(patch, mask)) + if err != nil { + return nil, err + } + + if next.DisplayName != cur.DisplayName && !tenantNameRE.MatchString(next.DisplayName) { + return nil, errInvalidArg("Invalid tenant display name.") + } + + next.Name = cur.Name + p.Tenants[id] = next + + return cloneJSON(next), nil +} + +// DeleteTenant removes a tenant and its accounts. +func (b *InMemoryBackend) DeleteTenant(project, id string) error { + b.mu.Lock("DeleteTenant") + defer b.mu.Unlock() + + p := b.projectLocked(project) + if _, ok := p.Tenants[id]; !ok { + return errNotFound("TENANT_NOT_FOUND") + } + + delete(p.Tenants, id) + delete(p.Users, id) + + for code, rec := range p.Codes { + if rec.TenantID == id { + delete(p.Codes, code) + } + } + + return nil +} diff --git a/services/gcpidentitytoolkit/admin_test.go b/services/gcpidentitytoolkit/admin_test.go new file mode 100644 index 0000000000..0350cc0476 --- /dev/null +++ b/services/gcpidentitytoolkit/admin_test.go @@ -0,0 +1,421 @@ +package gcpidentitytoolkit_test + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "maps" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/scrypt" +) + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func firebaseScrypt(t *testing.T, pw string, salt, sep, signer []byte, memCost, rounds int) []byte { + t.Helper() + + dk, err := scrypt.Key([]byte(pw), append(append([]byte{}, salt...), sep...), 1< 0 { + assert.Equal( + t, + "NOT_DISABLED : Disable the account before batch deletion.", + errs[0].(map[string]any)["message"], + ) + } + + q := e.admin(t, "query", map[string]any{"returnUserInfo": false}) + assert.Equal(t, tt.left, q.str("recordsCount")) + }) + } +} + +func TestAdminCreate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body map[string]any + msg string + }{ + { + name: "full", + body: map[string]any{ + "localId": "u1", + "email": "u1@example.com", + "password": "secret1", + "emailVerified": true, + "phoneNumber": "+15555550100", + "displayName": "U", + }, + }, + {name: "empty", body: map[string]any{}}, + {name: "bad phone", body: map[string]any{"phoneNumber": "5551"}, msg: "INVALID_PHONE_NUMBER : Invalid format."}, + { + name: "weak password", + body: map[string]any{"email": "w@example.com", "password": "1"}, + msg: "WEAK_PASSWORD : Password should be at least 6 characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + r := e.do(t, http.MethodPost, "/v1/projects/"+e.project+"/accounts", adminAuth, tt.body) + + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + assert.NotEmpty(t, r.str("localId")) + assert.NotContains(t, r.body, "idToken") + + if id, ok := tt.body["localId"]; ok { + dup := e.do(t, http.MethodPost, "/v1/projects/"+e.project+"/accounts", adminAuth, tt.body) + e.assertErr(t, dup, http.StatusBadRequest, "DUPLICATE_LOCAL_ID") + + lk := e.admin(t, "lookup", map[string]any{"phoneNumber": []string{"+15555550100"}}) + assert.Equal(t, id, lk.body["users"].([]any)[0].(map[string]any)["localId"]) + + phone := e.do( + t, + http.MethodPost, + "/v1/projects/"+e.project+"/accounts", + adminAuth, + map[string]any{"phoneNumber": "+15555550100"}, + ) + e.assertErr(t, phone, http.StatusBadRequest, "PHONE_NUMBER_EXISTS") + } + }) + } +} + +func TestSessionCookie(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + duration string + msg string + }{ + {name: "ok", duration: "3600"}, + {name: "too short", duration: "60", msg: "INVALID_DURATION"}, + {name: "too long", duration: "99999999", msg: "INVALID_DURATION"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "s@example.com") + + r := e.do(t, http.MethodPost, "/v1/projects/"+e.project+":createSessionCookie", adminAuth, + map[string]any{"idToken": up.str("idToken"), "validDuration": tt.duration}) + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + assert.Equal(t, 3, strings.Count(r.str("sessionCookie"), ".")+1) + + noAuth := e.do( + t, + http.MethodPost, + "/v1/projects/"+e.project+":createSessionCookie", + "", + map[string]any{"idToken": up.str("idToken")}, + ) + assert.Equal(t, http.StatusUnauthorized, noAuth.status) + }) + } +} diff --git a/services/gcpidentitytoolkit/client.go b/services/gcpidentitytoolkit/client.go new file mode 100644 index 0000000000..bb8e1a547d --- /dev/null +++ b/services/gcpidentitytoolkit/client.go @@ -0,0 +1,579 @@ +package gcpidentitytoolkit + +import ( + "net/url" + "slices" + "strconv" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// call identifies the project, tenant and caller class of one request. +type call struct { + project string + tenant string + host string + admin bool +} + +func tokenResp(kind string, u *userRec, s *session) *tokenResponse { + return &tokenResponse{ + Kind: kind, LocalID: u.UID, Email: u.Email, DisplayName: u.DisplayName, + IDToken: s.IDToken, RefreshToken: s.RefreshToken, ExpiresIn: expiresInSecs, + } +} + +func (b *InMemoryBackend) touchLoginLocked(u *userRec) { u.LastLoginAt = b.nowMillis() } + +// SignUp creates an email/password or anonymous account, or links email/password to an anonymous one. +func (b *InMemoryBackend) SignUp(c call, r *authRequest) (*tokenResponse, error) { + b.mu.Lock("SignUp") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + email, pw := r.Email.first(), r.Password + + if r.IDToken != "" { + return b.linkAnonymousLocked(sc, r, email, pw) + } + + if email == "" && pw == "" { + if !sc.anon { + return nil, idtk("ADMIN_ONLY_OPERATION") + } + + anon, cErr := b.createUserLocked(sc, newUser{anonymous: true}, false) + if cErr != nil { + return nil, cErr + } + + return b.finishSignInLocked(sc, anon, anonProvider, "identitytoolkit#SignupNewUserResponse") + } + + if !sc.password { + return nil, idtk("OPERATION_NOT_ALLOWED", "Password sign-in is disabled for this project.") + } + + if email == "" { + return nil, idtk("MISSING_EMAIL") + } + + if pw == "" { + return nil, idtk("MISSING_PASSWORD") + } + + u, err := b.createUserLocked(sc, newUser{email: email, password: pw, displayName: deref(r.DisplayName)}, false) + if err != nil { + return nil, err + } + + return b.finishSignInLocked(sc, u, passwordProv, "identitytoolkit#SignupNewUserResponse") +} + +func deref(s *string) string { + if s == nil { + return "" + } + + return *s +} + +func effectiveTenant(c call, r *authRequest) string { + if c.tenant != "" { + return c.tenant + } + + return r.TenantID +} + +func (b *InMemoryBackend) finishSignInLocked(sc *scope, u *userRec, provider, kind string) (*tokenResponse, error) { + b.touchLoginLocked(u) + + s, err := b.issueLocked(sc, u, provider, nil) + if err != nil { + return nil, err + } + + return tokenResp(kind, u, s), nil +} + +func (b *InMemoryBackend) linkAnonymousLocked(sc *scope, r *authRequest, email, pw string) (*tokenResponse, error) { + u, _, err := b.userForTokenLocked(sc, r.IDToken) + if err != nil { + return nil, err + } + + if !sc.password { + return nil, idtk("OPERATION_NOT_ALLOWED", "Password sign-in is disabled for this project.") + } + + if email == "" || pw == "" { + return nil, idtk("MISSING_EMAIL") + } + + if !validEmail(strs.Fold(email)) { + return nil, idtk("INVALID_EMAIL") + } + + if o := sc.byEmail(email); o != nil && o.UID != u.UID { + return nil, idtk("EMAIL_EXISTS") + } + + if err = checkPassword(sc, pw); err != nil { + return nil, err + } + + u.Email, u.Anonymous = strs.Fold(email), false + + if err = b.setPasswordLocked(sc, u, pw); err != nil { + return nil, err + } + + syncProviders(u) + + return b.finishSignInLocked(sc, u, passwordProv, "identitytoolkit#SignupNewUserResponse") +} + +// SignInWithPassword authenticates by email and password. +func (b *InMemoryBackend) SignInWithPassword(c call, r *authRequest) (*tokenResponse, error) { + email := strs.Fold(r.Email.first()) + + b.mu.Lock("SignInWithPassword") + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + + if err != nil { + b.mu.Unlock() + + return nil, err + } + + if !sc.password { + b.mu.Unlock() + + return nil, idtk("OPERATION_NOT_ALLOWED", "Password sign-in is disabled for this project.") + } + + if email == "" { + b.mu.Unlock() + + return nil, idtk("MISSING_EMAIL") + } + + if r.Password == "" { + b.mu.Unlock() + + return nil, idtk("MISSING_PASSWORD") + } + + u := sc.byEmail(email) + privacy := sc.privacy + + var snap userRec + + var spec hashSpec + + if u != nil && len(u.PasswordHash) > 0 { + snap = *u + spec = sc.proj.Hash + + if u.Hash != nil { + spec = *u.Hash + } + } + b.mu.Unlock() + + if u == nil { + return nil, loginFailure(privacy, "EMAIL_NOT_FOUND") + } + + if len(snap.PasswordHash) == 0 || !spec.verify(r.Password, snap.Salt, snap.PasswordHash) { + return nil, loginFailure(privacy, "INVALID_PASSWORD") + } + + return b.completePasswordSignIn(c, effectiveTenant(c, r), snap.UID, r.Password) +} + +func loginFailure(privacy bool, code string) *APIError { + if privacy { + return idtk("INVALID_LOGIN_CREDENTIALS") + } + + return idtk(code) +} + +func (b *InMemoryBackend) completePasswordSignIn(c call, tenant, uid, password string) (*tokenResponse, error) { + b.mu.Lock("completePasswordSignIn") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, tenant) + if err != nil { + return nil, err + } + + u, ok := sc.users[uid] + if !ok { + return nil, idtk("INVALID_LOGIN_CREDENTIALS") + } + + if u.Disabled { + return nil, idtk("USER_DISABLED", "The user account has been disabled by an administrator.") + } + + if u.Hash != nil { + changedAt := u.PasswordAt + if err = b.setPasswordLocked(sc, u, password); err == nil { + u.PasswordAt = changedAt + } + } + + resp, err := b.finishSignInLocked(sc, u, passwordProv, "identitytoolkit#VerifyPasswordResponse") + if err != nil { + return nil, err + } + + resp.Registered = true + + return resp, nil +} + +// SignInWithCustomToken exchanges a custom token for an ID token, creating the account on first use. +func (b *InMemoryBackend) SignInWithCustomToken(c call, r *authRequest) (*tokenResponse, error) { + b.mu.Lock("SignInWithCustomToken") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + if r.Token == "" { + return nil, idtk("MISSING_CUSTOM_TOKEN") + } + + uid, dev, err := b.customTokenLocked(r.Token, sc) + if err != nil { + return nil, err + } + + u, exists := sc.users[uid] + if !exists { + if u, err = b.createUserLocked(sc, newUser{uid: uid}, false); err != nil { + return nil, err + } + } + + if u.Disabled { + return nil, idtk("USER_DISABLED", "The user account has been disabled by an administrator.") + } + + b.touchLoginLocked(u) + + s, err := b.issueLocked(sc, u, customProv, dev) + if err != nil { + return nil, err + } + + return &tokenResponse{ + Kind: "identitytoolkit#VerifyCustomTokenResponse", IDToken: s.IDToken, RefreshToken: s.RefreshToken, + ExpiresIn: expiresInSecs, IsNewUser: !exists, + }, nil +} + +// Lookup returns accounts by ID token (client) or by uid, email, phone or provider id (admin). +func (b *InMemoryBackend) Lookup(c call, r *authRequest) (map[string]any, error) { + b.mu.Lock("Lookup") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + var found []*userRec + + if r.IDToken != "" { + u, _, tErr := b.userForTokenLocked(sc, r.IDToken) + if tErr != nil { + return nil, tErr + } + + found = append(found, u) + } else { + if !c.admin { + return nil, idtk("MISSING_ID_TOKEN") + } + + found = lookupAdmin(sc, r) + } + + users := make([]userWire, 0, len(found)) + for _, u := range found { + users = append(users, b.toWire(sc, u, c.admin && r.IDToken == "")) + } + + out := map[string]any{keyKind: "identitytoolkit#GetAccountInfoResponse"} + if len(users) > 0 { + out["users"] = users + } + + return out, nil +} + +func lookupAdmin(sc *scope, r *authRequest) []*userRec { + var found []*userRec + + add := func(u *userRec) { + if u != nil && !slices.Contains(found, u) { + found = append(found, u) + } + } + + for _, id := range r.LocalID { + add(sc.users[id]) + } + + for _, e := range r.Email { + add(sc.byEmail(e)) + } + + for _, p := range r.PhoneNumber { + add(sc.byPhone(p)) + } + + for _, f := range r.FederatedUserID { + add(sc.byProvider(f.ProviderID, f.RawID)) + } + + return found +} + +// DeleteAccount deletes the caller's account (ID token) or any account (admin, by uid). +func (b *InMemoryBackend) DeleteAccount(c call, r *authRequest) error { + b.mu.Lock("DeleteAccount") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return err + } + + uid := r.LocalID.first() + + if r.IDToken != "" { + u, _, tErr := b.userForTokenLocked(sc, r.IDToken) + if tErr != nil { + return tErr + } + + uid = u.UID + } else if !c.admin { + return idtk("MISSING_ID_TOKEN") + } + + if uid == "" { + return idtk("MISSING_LOCAL_ID") + } + + if _, ok := sc.users[uid]; !ok { + return idtk("USER_NOT_FOUND") + } + + delete(sc.users, uid) + + return nil +} + +// Refresh exchanges a refresh token for a new ID token (securetoken v1/token). +func (b *InMemoryBackend) Refresh(grantType, token string) (map[string]string, error) { + b.mu.Lock("Refresh") + defer b.mu.Unlock() + + if grantType != "refresh_token" { + return nil, idtk("INVALID_GRANT_TYPE") + } + + if token == "" { + return nil, idtk("MISSING_REFRESH_TOKEN") + } + + p, ok := b.parseRefreshLocked(token) + if !ok { + return nil, idtk("INVALID_REFRESH_TOKEN") + } + + sc, err := b.scopeLocked(p.Project, p.Tenant) + if err != nil { + return nil, idtk("INVALID_REFRESH_TOKEN") + } + + u, ok := sc.users[p.UID] + if !ok { + return nil, idtk("USER_NOT_FOUND") + } + + if u.Disabled { + return nil, idtk("USER_DISABLED") + } + + if u.ValidSince > 0 && p.Issued < u.ValidSince { + return nil, idtk("TOKEN_EXPIRED") + } + + id, err := b.signJWTLocked(b.tokenClaims(sc, u, p.Provider, p.AuthTime, securetokenIssuer, p.Claims)) + if err != nil { + return nil, err + } + + u.LastRefreshAt = b.nowMillis() + + return map[string]string{ + "access_token": id, "id_token": id, "refresh_token": token, "expires_in": expiresInSecs, + "token_type": "Bearer", "user_id": u.UID, "project_id": projectNumber(p.Project), + }, nil +} + +func projectNumber(project string) string { + var h uint64 = 14695981039346656037 + + for i := range len(project) { + h ^= uint64(project[i]) + h *= 1099511628211 + } + + return strconv.FormatUint(h%projectNumberMod+projectNumberFloor, 10) +} + +// CreateAuthURI reports sign-in methods for an identifier, honouring email enumeration protection. +func (b *InMemoryBackend) CreateAuthURI(c call, r *authRequest) (map[string]any, error) { + b.mu.Lock("CreateAuthURI") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + if r.ProviderID != "" { + return nil, idtk("OPERATION_NOT_ALLOWED", "Federated sign-in is not supported by this emulator.") + } + + if r.Identifier == "" { + return nil, idtk("MISSING_IDENTIFIER") + } + + if r.ContinueURI == "" { + return nil, idtk("MISSING_CONTINUE_URI") + } + + if !validEmail(strs.Fold(r.Identifier)) { + return nil, idtk("INVALID_IDENTIFIER") + } + + out := map[string]any{keyKind: "identitytoolkit#CreateAuthUriResponse", "sessionId": randomToken(sessionBytes)} + if sc.privacy { + return out, nil + } + + out["registered"] = false + + if u := sc.byEmail(r.Identifier); u != nil { + methods := []string{} + + if len(u.PasswordHash) > 0 { + methods = append(methods, passwordProv) + } + + if sc.link { + methods = append(methods, "emailLink") + } + + out["registered"] = true + out["allProviders"] = methods + out["signinMethods"] = methods + } + + return out, nil +} + +const ( + sessionBytes = 8 + projectNumberMod = 1_000_000_000_000 + projectNumberFloor = 100_000_000_000 + minCookieTTL = 5 * time.Minute + maxCookieTTL = 14 * 24 * time.Hour +) + +// CreateSessionCookie mints a session cookie from an ID token. +func (b *InMemoryBackend) CreateSessionCookie(c call, r *authRequest) (map[string]string, error) { + dur := int64(r.ValidDuration) + if dur == 0 { + dur = int64(maxCookieTTL.Seconds()) + } + + if dur < int64(minCookieTTL.Seconds()) || dur > int64(maxCookieTTL.Seconds()) { + return nil, idtk("INVALID_DURATION") + } + + b.mu.Lock("CreateSessionCookie") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + if r.IDToken == "" { + return nil, idtk("MISSING_ID_TOKEN") + } + + u, vt, err := b.userForTokenLocked(sc, r.IDToken) + if err != nil { + return nil, err + } + + provider := passwordProv + + if fb, ok := vt.claims["firebase"].(map[string]any); ok { + if p, _ := fb["sign_in_provider"].(string); p != "" { + provider = p + } + } + + authTime := b.now().Unix() + if f, ok := vt.claims["auth_time"].(float64); ok { + authTime = int64(f) + } + + claims := b.tokenClaims(sc, u, provider, authTime, sessionIssuer, nil) + claims["exp"] = b.now().Add(time.Duration(dur) * time.Second).Unix() + + cookie, err := b.signJWTLocked(claims) + if err != nil { + return nil, err + } + + return map[string]string{"sessionCookie": cookie}, nil +} + +func oobLink(host, mode, code, continueURL, tenant string) string { + q := url.Values{} + q.Set("mode", mode) + q.Set("lang", "en") + q.Set("oobCode", code) + q.Set("apiKey", fakeAPIKey) + + if continueURL != "" { + q.Set("continueUrl", continueURL) + } + + if tenant != "" { + q.Set("tenantId", tenant) + } + + return "http://" + host + "/emulator/action?" + q.Encode() +} diff --git a/services/gcpidentitytoolkit/config_test.go b/services/gcpidentitytoolkit/config_test.go new file mode 100644 index 0000000000..d144f898e0 --- /dev/null +++ b/services/gcpidentitytoolkit/config_test.go @@ -0,0 +1,495 @@ +package gcpidentitytoolkit_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/identitytoolkit/v1" + "google.golang.org/api/option" +) + +func TestProjectConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + body map[string]any + check func(*testing.T, result) + name string + method string + path string + status int + }{ + { + name: "defaults", method: http.MethodGet, status: http.StatusOK, + check: func(t *testing.T, r result) { + t.Helper() + + signIn := r.body["signIn"].(map[string]any) + assert.Equal(t, true, signIn["email"].(map[string]any)["enabled"]) + assert.Equal(t, true, signIn["anonymous"].(map[string]any)["enabled"]) + assert.Equal(t, "SCRYPT", signIn["hashConfig"].(map[string]any)["algorithm"]) + assert.Equal(t, true, r.body["emailPrivacyConfig"].(map[string]any)["enableImprovedEmailPrivacy"]) + }, + }, + { + name: "patch with mask", + method: http.MethodPatch, + path: "?updateMask=authorizedDomains", + body: map[string]any{ + "authorizedDomains": []string{"a.example.com"}, + "autodeleteAnonymousUsers": true, + }, + status: http.StatusOK, + check: func(t *testing.T, r result) { + t.Helper() + + assert.Equal(t, []any{"a.example.com"}, r.body["authorizedDomains"]) + assert.NotContains(t, r.body, "autodeleteAnonymousUsers", "fields outside the mask are ignored") + }, + }, + { + name: "patch nested", + method: http.MethodPatch, + body: map[string]any{ + "signIn": map[string]any{"anonymous": map[string]any{"enabled": false}}, + }, + status: http.StatusOK, + check: func(t *testing.T, r result) { + t.Helper() + + signIn := r.body["signIn"].(map[string]any) + assert.NotContains(t, signIn["anonymous"], "enabled") + assert.Equal(t, true, signIn["email"].(map[string]any)["enabled"], "siblings are preserved") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + r := e.do(t, tt.method, "/v2/projects/"+e.project+"/config"+tt.path, adminAuth, tt.body) + require.Equal(t, tt.status, r.status, r.body) + assert.Equal(t, "projects/"+e.project+"/config", r.str("name")) + tt.check(t, r) + }) + } +} + +func TestPasswordPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state string + pw string + msg string + }{ + {name: "enforced and met", state: "ENFORCE", pw: "Abcdef1!"}, + { + name: "enforced missing upper and digit", + state: "ENFORCE", + pw: "abcdefg!", + msg: "PASSWORD_DOES_NOT_MEET_REQUIREMENTS : Missing password requirements: " + + "[Password must contain an upper case character, Password must contain a numeric character]", + }, + { + name: "enforced too short", + state: "ENFORCE", + pw: "Ab1!", + msg: "WEAK_PASSWORD : Password should be at least 6 characters", + }, + {name: "off", state: "OFF", pw: "abcdefg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.setConfig(t, map[string]any{"passwordPolicyConfig": map[string]any{ + "passwordPolicyEnforcementState": tt.state, + "passwordPolicyVersions": []any{map[string]any{"customStrengthOptions": map[string]any{ + "minPasswordLength": 8, "containsUppercaseCharacter": true, "containsNumericCharacter": true, + }}}, + }}) + + r := e.client(t, "signUp", map[string]any{"email": "p@example.com", "password": tt.pw}) + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + assert.Equal(t, http.StatusOK, r.status, r.body) + }) + } +} + +func TestTenantsREST(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body map[string]any + msg string + }{ + {name: "ok", body: map[string]any{"displayName": "my-tenant", "allowPasswordSignup": true}}, + {name: "too short", body: map[string]any{"displayName": "ab"}, msg: "Invalid tenant display name"}, + { + name: "bad chars", + body: map[string]any{"displayName": "no spaces allowed"}, + msg: "Invalid tenant display name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + base := "/v2/projects/" + e.project + "/tenants" + r := e.do(t, http.MethodPost, base, adminAuth, tt.body) + + if tt.msg != "" { + assert.Equal(t, http.StatusBadRequest, r.status) + assert.Contains(t, r.errMsg(), tt.msg) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + + name := r.str("name") + id := name[len("projects/"+e.project+"/tenants/"):] + assert.Contains(t, id, "my-tenant-") + + sign := e.do(t, http.MethodPost, "/v1/projects/"+e.project+"/tenants/"+id+"/accounts:signUp?key=k", "", + map[string]any{"email": "t@example.com", "password": "secret1"}) + require.Equal(t, http.StatusOK, sign.status, sign.body) + + root := e.client(t, "signInWithPassword", map[string]any{"email": "t@example.com", "password": "secret1"}) + assert.Equal(t, http.StatusBadRequest, root.status, "project level is a separate namespace") + + list := e.do(t, http.MethodGet, base+"?pageSize=1", adminAuth, nil) + assert.Len(t, list.body["tenants"], 1) + + miss := e.do(t, http.MethodGet, base+"/nope-12345", adminAuth, nil) + e.assertErr(t, miss, http.StatusNotFound, "TENANT_NOT_FOUND") + + unknown := e.do( + t, + http.MethodPost, + "/v1/projects/"+e.project+"/tenants/nope-12345/accounts:signUp?key=k", + "", + map[string]any{}, + ) + e.assertErr(t, unknown, http.StatusNotFound, "TENANT_NOT_FOUND") + + del := e.do(t, http.MethodDelete, base+"/"+id, adminAuth, nil) + require.Equal(t, http.StatusOK, del.status, del.body) + + gone := e.do(t, http.MethodGet, base+"/"+id, adminAuth, nil) + assert.Equal(t, http.StatusNotFound, gone.status) + }) + } +} + +func TestTenantProviderFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tenant map[string]any + body map[string]any + msg string + }{ + { + name: "password signup off by default", + tenant: map[string]any{"displayName": "no-pass"}, + body: map[string]any{"email": "a@example.com", "password": "secret1"}, + msg: "OPERATION_NOT_ALLOWED : Password sign-in is disabled for this project.", + }, + { + name: "anonymous off by default", + tenant: map[string]any{"displayName": "no-anon"}, + body: map[string]any{}, + msg: "ADMIN_ONLY_OPERATION", + }, + { + name: "anonymous on", + tenant: map[string]any{"displayName": "yes-anon", "enableAnonymousUser": true}, + body: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + tn := e.do(t, http.MethodPost, "/v2/projects/"+e.project+"/tenants", adminAuth, tt.tenant) + require.Equal(t, http.StatusOK, tn.status, tn.body) + + id := tn.str("name")[len("projects/"+e.project+"/tenants/"):] + r := e.do( + t, + http.MethodPost, + "/v1/projects/"+e.project+"/tenants/"+id+"/accounts:signUp?key=k", + "", + tt.body, + ) + + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + assert.Equal(t, http.StatusOK, r.status, r.body) + }) + } +} + +func TestSigningKeyEndpoints(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "k@example.com") + + x509Resp := e.do(t, http.MethodGet, "/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com", "", nil) + require.Equal(t, http.StatusOK, x509Resp.status) + require.Len(t, x509Resp.body, 1) + + var kid, certPEM string + for k, v := range x509Resp.body { + kid, certPEM = k, v.(string) + } + + block, _ := pem.Decode([]byte(certPEM)) + require.NotNil(t, block) + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + claims := jwt.MapClaims{} + tok, err := jwt.ParseWithClaims(up.str("idToken"), claims, func(tk *jwt.Token) (any, error) { + assert.Equal(t, kid, tk.Header["kid"]) + + return cert.PublicKey, nil + }, jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(e.clock.Now)) + require.NoError(t, err) + assert.True(t, tok.Valid) + assert.Equal(t, "https://securetoken.google.com/"+e.project, claims["iss"]) + assert.Equal(t, e.project, claims["aud"]) + assert.Equal(t, up.str("localId"), claims["user_id"]) + assert.Equal(t, up.str("localId"), claims["sub"]) + assert.Equal(t, "password", claims["firebase"].(map[string]any)["sign_in_provider"]) + assert.InDelta(t, 3600, claims["exp"].(float64)-claims["iat"].(float64), 0) + assert.NotZero(t, claims["auth_time"]) + + jwk := e.do(t, http.MethodGet, "/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", "", nil) + keys := jwk.body["keys"].([]any) + require.Len(t, keys, 1) + + k := keys[0].(map[string]any) + assert.Equal(t, kid, k["kid"]) + assert.Equal(t, "RS256", k["alg"]) + + n, err := base64.RawURLEncoding.DecodeString(k["n"].(string)) + require.NoError(t, err) + assert.Equal(t, cert.PublicKey.(*rsa.PublicKey).N.Bytes(), n) +} + +func TestCustomTokenRS256(t *testing.T) { + t.Parallel() + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + other, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + const sa = "svc@example.iam.gserviceaccount.com" + + mint := func(key *rsa.PrivateKey, iss string, now time.Time, uid string) string { + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": iss, + "sub": iss, + "aud": "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit", + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + "uid": uid, + "claims": map[string]any{"k": "v"}, + }) + s, signErr := tok.SignedString(key) + require.NoError(t, signErr) + + return s + } + + tests := []struct { + key *rsa.PrivateKey + name string + iss string + uid string + advance time.Duration + wired bool + ok bool + }{ + {name: "known service account", key: priv, iss: sa, wired: true, uid: "u1", ok: true}, + {name: "wrong key", key: other, iss: sa, wired: true, uid: "u1"}, + {name: "unknown issuer", key: priv, iss: "ghost@example.com", wired: true, uid: "u1"}, + {name: "resolver not wired", key: priv, iss: sa, uid: "u1"}, + {name: "empty uid", key: priv, iss: sa, wired: true}, + {name: "expired", key: priv, iss: sa, wired: true, uid: "u1", advance: 3 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + if tt.wired { + e.backend.SetCustomTokenKeys(func(email string) []*rsa.PublicKey { + if email == sa { + return []*rsa.PublicKey{&priv.PublicKey} + } + + return nil + }) + } + + ct := mint(tt.key, tt.iss, e.clock.Now(), tt.uid) + e.clock.Advance(tt.advance) + + r := e.client(t, "signInWithCustomToken", map[string]any{"token": ct, "returnSecureToken": true}) + if !tt.ok { + assert.Equal(t, http.StatusBadRequest, r.status, r.body) + assert.Contains(t, r.errMsg(), "INVALID_CUSTOM_TOKEN") + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + assert.NotEmpty(t, r.str("refreshToken")) + }) + } +} + +func TestPersistence(t *testing.T) { + t.Parallel() + + src := newEnv(t) + up := src.signUp(t, "keep@example.com") + src.client(t, "sendOobCode", map[string]any{"requestType": "PASSWORD_RESET", "email": "keep@example.com"}) + src.setConfig(t, map[string]any{"authorizedDomains": []string{"keep.example.com"}}) + + tn := src.do( + t, + http.MethodPost, + "/v2/projects/"+src.project+"/tenants", + adminAuth, + map[string]any{"displayName": "kept-tenant"}, + ) + require.Equal(t, http.StatusOK, tn.status) + + data := src.backend.Snapshot(t.Context()) + require.NotEmpty(t, data) + + dst := newEnv(t) + dst.project = src.project + require.NoError(t, dst.backend.Restore(t.Context(), data)) + + lk := dst.client(t, "lookup", map[string]any{"idToken": up.str("idToken")}) + require.Equal(t, http.StatusOK, lk.status, "tokens signed before the snapshot still verify") + + in := dst.client(t, "signInWithPassword", map[string]any{"email": "keep@example.com", "password": "secret1"}) + assert.Equal(t, http.StatusOK, in.status, in.body) + + rf := dst.do( + t, + http.MethodPost, + "/v1/token?key=k", + "", + map[string]any{"grant_type": "refresh_token", "refresh_token": up.str("refreshToken")}, + ) + assert.Equal(t, http.StatusOK, rf.status, rf.body) + + assert.Len(t, dst.oobCodes(t), 1) + + cfg := dst.do(t, http.MethodGet, "/v2/projects/"+dst.project+"/config", adminAuth, nil) + assert.Equal(t, []any{"keep.example.com"}, cfg.body["authorizedDomains"]) + + list := dst.do(t, http.MethodGet, "/v2/projects/"+dst.project+"/tenants", adminAuth, nil) + assert.Len(t, list.body["tenants"], 1) + + dst.backend.Reset() + + gone := dst.client(t, "lookup", map[string]any{"idToken": up.str("idToken")}) + assert.Equal(t, http.StatusBadRequest, gone.status) + + assert.Error(t, dst.backend.Restore(t.Context(), []byte(`{"projects":{"p":null},"version":1}`))) +} + +func TestGeneratedClientWireShapes(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + svc, err := identitytoolkit.NewService( + t.Context(), + option.WithEndpoint(e.url+"/"), + option.WithoutAuthentication(), + option.WithHTTPClient( + &http.Client{Transport: keyAdder{rt: e.hc.Transport}}), + ) + require.NoError(t, err) + + up, err := svc.Accounts.SignUp(&identitytoolkit.GoogleCloudIdentitytoolkitV1SignUpRequest{ + Email: "gen@example.com", Password: "secret1", + }).Do() + require.NoError(t, err) + assert.Equal(t, int64(3600), up.ExpiresIn) + assert.NotEmpty(t, up.IdToken) + + in, err := svc.Accounts.SignInWithPassword(&identitytoolkit.GoogleCloudIdentitytoolkitV1SignInWithPasswordRequest{ + Email: "gen@example.com", Password: "secret1", ReturnSecureToken: true, + }).Do() + require.NoError(t, err) + assert.True(t, in.Registered) + assert.Equal(t, up.LocalId, in.LocalId) + + lookupReq := &identitytoolkit.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest{IdToken: in.IdToken} + lk, err := svc.Accounts.Lookup(lookupReq).Do() + require.NoError(t, err) + require.Len(t, lk.Users, 1) + assert.Positive(t, lk.Users[0].CreatedAt) + assert.Equal(t, "UkVEQUNURUQ=", lk.Users[0].PasswordHash) + assert.Equal(t, "password", lk.Users[0].ProviderUserInfo[0].ProviderId) + + upd, err := svc.Accounts.Update(&identitytoolkit.GoogleCloudIdentitytoolkitV1SetAccountInfoRequest{ + IdToken: in.IdToken, DisplayName: "Gen", ReturnSecureToken: true, + }).Do() + require.NoError(t, err) + assert.Equal(t, "Gen", upd.DisplayName) +} + +type keyAdder struct{ rt http.RoundTripper } + +func (k keyAdder) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("X-Goog-Api-Key", "k") + + return k.rt.RoundTrip(r) +} diff --git a/services/gcpidentitytoolkit/consts.go b/services/gcpidentitytoolkit/consts.go new file mode 100644 index 0000000000..f9021dad8f --- /dev/null +++ b/services/gcpidentitytoolkit/consts.go @@ -0,0 +1,31 @@ +package gcpidentitytoolkit + +const ( + opAdminCreate = "AdminCreate" + opBatchGet = "BatchGet" + opBatchCreate = "batchCreate" + opBatchDelete = "batchDelete" + opQuery = "query" + opCreateSessionCookie = "CreateSessionCookie" + opToken = "Token" + opPublicKeys = "PublicKeys" + opListOobCodes = "ListOobCodes" + opClearAccounts = "ClearAccounts" + opGetConfig = "GetConfig" + opUpdateConfig = "UpdateConfig" + opListTenants = "ListTenants" + opCreateTenant = "CreateTenant" + opGetTenant = "GetTenant" + opUpdateTenant = "UpdateTenant" + opDeleteTenant = "DeleteTenant" + opResetPassword = "resetPassword" + + segTenants = "tenants" + algRS256 = "RS256" + keyKind = "kind" + + algHMACSHA256 = "HMAC_SHA256" + algHMACSHA512 = "HMAC_SHA512" + algHMACSHA1 = "HMAC_SHA1" + algHMACMD5 = "HMAC_MD5" +) diff --git a/services/gcpidentitytoolkit/errors.go b/services/gcpidentitytoolkit/errors.go new file mode 100644 index 0000000000..d6fab40d60 --- /dev/null +++ b/services/gcpidentitytoolkit/errors.go @@ -0,0 +1,80 @@ +package gcpidentitytoolkit + +import ( + "errors" + "fmt" + "net/http" +) + +// APIError is a Google JSON REST error; Message carries the Identity Toolkit error code. +type APIError struct { + Message string + Status string + HTTP int +} + +// Error implements error. +func (e *APIError) Error() string { return fmt.Sprintf("gcpidentitytoolkit: %d %s", e.HTTP, e.Message) } + +// ErrSnapshotNull is returned when a snapshot holds a null record. +var ErrSnapshotNull = errors.New("gcpidentitytoolkit: snapshot contains a null record") + +// idtk builds a 400 error whose message is "CODE" or "CODE : detail", the form the Admin SDK parses. +func idtk(code string, detail ...string) *APIError { + msg := code + if len(detail) > 0 && detail[0] != "" { + msg = code + " : " + detail[0] + } + + return &APIError{HTTP: http.StatusBadRequest, Message: msg} +} + +func errNotFound(code string) *APIError { + return &APIError{HTTP: http.StatusNotFound, Message: code, Status: "NOT_FOUND"} +} + +func errUnauth() *APIError { + return &APIError{ + HTTP: http.StatusUnauthorized, Status: "UNAUTHENTICATED", + Message: "Request is missing required authentication credential. Expected OAuth 2 access token, " + + "login cookie or other valid authentication credential.", + } +} + +func errNoKey() *APIError { + return &APIError{ + HTTP: http.StatusForbidden, Status: "PERMISSION_DENIED", + Message: "The request is missing a valid API key.", + } +} + +func errInvalidArg(format string, a ...any) *APIError { + return &APIError{HTTP: http.StatusBadRequest, Status: "INVALID_ARGUMENT", Message: fmt.Sprintf(format, a...)} +} + +type errorItem struct { + Message string `json:"message"` + Domain string `json:"domain"` + Reason string `json:"reason"` +} + +type errorDetail struct { + Message string `json:"message"` + Status string `json:"status,omitempty"` + Errors []errorItem `json:"errors,omitempty"` + Code int `json:"code"` +} + +func (e *APIError) body() map[string]errorDetail { + d := errorDetail{Code: e.HTTP, Message: e.Message, Status: e.Status} + if e.Status == "" { + d.Errors = []errorItem{{Message: e.Message, Domain: "global", Reason: "invalid"}} + } + + return map[string]errorDetail{"error": d} +} + +var ( + errBadBase64 = errors.New("gcpidentitytoolkit: invalid base64") + errBadAlgorithm = errors.New("gcpidentitytoolkit: unsupported hash configuration") +) diff --git a/services/gcpidentitytoolkit/handler.go b/services/gcpidentitytoolkit/handler.go new file mode 100644 index 0000000000..5f46d821da --- /dev/null +++ b/services/gcpidentitytoolkit/handler.go @@ -0,0 +1,552 @@ +package gcpidentitytoolkit + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/labstack/echo/v5" + idtkv2 "google.golang.org/api/identitytoolkit/v2" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 8 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = 5 * time.Minute + idleTimeout = 120 * time.Second + keyCacheSeconds = 3600 + supportedOpCount = 14 +) + +// Handler serves the Identity Toolkit and Secure Token REST APIs on a dedicated listener. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + wg *sync.WaitGroup + addr string + Port int +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, srvMu: lockmetrics.New("gcpidentitytoolkit.server"), wg: &sync.WaitGroup{}, + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "GCPIdentityToolkit" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, supportedOpCount+len(accountVerbs)) + ops = append(ops, + opAdminCreate, opBatchGet, opCreateSessionCookie, opToken, opPublicKeys, opGetConfig, opUpdateConfig, + opListTenants, opCreateTenant, opGetTenant, opUpdateTenant, opDeleteTenant, opListOobCodes, opClearAccounts, + ) + ops = append(ops, accountVerbs...) + + return ops +} + +// RouteMatcher never matches: Identity Platform runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { + if op := parseRoute(c.Request()).op; op != "" { + return op + } + + return "Unknown" +} + +// ExtractResource returns project[/tenant] for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + rt := parseRoute(c.Request()) + if rt.tenant != "" { + return rt.project + "/" + rt.tenant + } + + return rt.project +} + +// Addr returns the bound listener address, or "" before StartWorker. +func (h *Handler) Addr() string { + h.srvMu.RLock("Addr") + defer h.srvMu.RUnlock() + + return h.addr +} + +// Reset removes all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +func (h *Handler) fail(c *echo.Context, err error) error { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(c.Request().Context()). + ErrorContext(c.Request().Context(), "gcpidentitytoolkit: internal error", "error", err) + + ae = &APIError{HTTP: http.StatusInternalServerError, Status: "INTERNAL", Message: "Internal error encountered."} + } + + return c.JSON(ae.HTTP, ae.body()) +} + +func bearer(r *http.Request) bool { + tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + + return ok && strings.TrimSpace(tok) != "" +} + +func (h *Handler) call(c *echo.Context, rt route) call { + project := rt.project + if project == "" { + project = h.Backend.defaultProjectName() + } + + return call{project: project, tenant: rt.tenant, host: c.Request().Host, admin: bearer(c.Request())} +} + +func (b *InMemoryBackend) defaultProjectName() string { + b.mu.RLock("defaultProjectName") + defer b.mu.RUnlock() + + return b.defaultProject +} + +func readJSON[T any](c *echo.Context) (*T, map[string]any, error) { + data, err := io.ReadAll(http.MaxBytesReader(c.Response(), c.Request().Body, maxBodyBytes)) + if err != nil { + return nil, nil, errInvalidArg("Request body is too large or unreadable.") + } + + v := new(T) + raw := map[string]any{} + + if strings.TrimSpace(string(data)) == "" { + return v, raw, nil + } + + if err = json.Unmarshal(data, v); err != nil { + return nil, nil, errInvalidArg("Invalid JSON payload received. %v", err) + } + + if err = json.Unmarshal(data, &raw); err != nil { + return nil, nil, errInvalidArg("Invalid JSON payload received. %v", err) + } + + return v, raw, nil +} + +// Handler returns the Echo handler for every Identity Toolkit and Secure Token request. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + rt := parseRoute(c.Request()) + if rt.op == "" { + return h.fail(c, errNotFound("NOT_FOUND")) + } + + out, err := h.dispatch(c, rt) + if err != nil { + return h.fail(c, err) + } + + if out == nil { + return c.NoContent(http.StatusNoContent) + } + + c.Response().Header().Set("Cache-Control", "no-store") + + return c.JSON(http.StatusOK, out) + } +} + +func (h *Handler) dispatch(c *echo.Context, rt route) (any, error) { + cl := h.call(c, rt) + + switch rt.op { + case opToken: + return h.token(c) + case opPublicKeys: + return h.publicKeys(c) + case opListOobCodes, opClearAccounts: + return h.emulatorOp(rt, cl) + } + + if (rt.version == "v2" || slices.Contains(adminOps, rt.op)) && !cl.admin { + return nil, errUnauth() + } + + if !cl.admin && c.QueryParam("key") == "" && c.Request().Header.Get("X-Goog-Api-Key") == "" { + return nil, errNoKey() + } + + if rt.version == "v2" { + return h.adminV2(c, rt) + } + + return h.accountOp(c, rt, cl) +} + +//nolint:gochecknoglobals // immutable lookup table +var adminOps = []string{opAdminCreate, opBatchGet, opBatchCreate, opBatchDelete, opQuery, opCreateSessionCookie} + +func (h *Handler) emulatorOp(rt route, cl call) (any, error) { + switch rt.op { + case opListOobCodes: + codes, err := h.Backend.OOBCodes(cl.project, rt.tenant) + if err != nil { + return nil, err + } + + return map[string]any{"oobCodes": codes}, nil + default: + if err := h.Backend.ClearAccounts(cl.project, rt.tenant); err != nil { + return nil, err + } + + return map[string]any{}, nil + } +} + +func adminGuard(cl call) error { + if !cl.admin { + return errUnauth() + } + + return nil +} + +func (h *Handler) accountOp(c *echo.Context, rt route, cl call) (any, error) { + switch rt.op { + case opBatchGet: + return h.batchGet(c, cl) + case opBatchCreate: + if err := adminGuard(cl); err != nil { + return nil, err + } + + req, _, err := readJSON[uploadRequest](c) + if err != nil { + return nil, err + } + + return h.Backend.BatchCreate(cl, req) + case opBatchDelete: + if err := adminGuard(cl); err != nil { + return nil, err + } + + req, _, err := readJSON[struct { + TenantID string `json:"tenantId"` + LocalIDs []string `json:"localIds"` + Force bool `json:"force"` + }](c) + if err != nil { + return nil, err + } + + return h.Backend.BatchDelete(cl, req.LocalIDs, req.Force, req.TenantID) + case opQuery: + if err := adminGuard(cl); err != nil { + return nil, err + } + + req, _, err := readJSON[queryRequest](c) + if err != nil { + return nil, err + } + + return h.Backend.Query(cl, req) + } + + req, _, err := readJSON[authRequest](c) + if err != nil { + return nil, err + } + + return h.authOp(rt.op, cl, req) +} + +func (h *Handler) authOp(op string, cl call, req *authRequest) (any, error) { + b := h.Backend + + switch op { + case "signUp": + return b.SignUp(cl, req) + case opAdminCreate: + return b.AdminCreate(cl, req) + case "signInWithPassword": + return b.SignInWithPassword(cl, req) + case "signInWithCustomToken": + return b.SignInWithCustomToken(cl, req) + case "signInWithEmailLink": + return b.SignInWithEmailLink(cl, req) + case "lookup": + return b.Lookup(cl, req) + case "update": + return b.Update(cl, req) + case "delete": + if err := b.DeleteAccount(cl, req); err != nil { + return nil, err + } + + return map[string]any{keyKind: "identitytoolkit#DeleteAccountResponse"}, nil + case "sendOobCode": + return b.SendOobCode(cl, req) + case opResetPassword: + return b.ResetPassword(cl, req) + case "createAuthUri": + return b.CreateAuthURI(cl, req) + case opCreateSessionCookie: + return b.CreateSessionCookie(cl, req) + } + + return nil, errNotFound("NOT_FOUND") +} + +func (h *Handler) batchGet(c *echo.Context, cl call) (any, error) { + if err := adminGuard(cl); err != nil { + return nil, err + } + + maxResults := 0 + + if raw := c.QueryParam("maxResults"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + return nil, idtk("INVALID_MAX_RESULTS") + } + + maxResults = n + } + + return h.Backend.BatchGet(cl, c.QueryParam("tenantId"), c.QueryParam("nextPageToken"), maxResults) +} + +func (h *Handler) adminV2(c *echo.Context, rt route) (any, error) { + switch rt.op { + case opGetConfig, opUpdateConfig: + return h.configOp(c, rt) + case opListTenants: + return h.listTenants(c, rt) + case opCreateTenant, opGetTenant, opUpdateTenant, opDeleteTenant: + return h.tenantOp(c, rt) + } + + return nil, errNotFound("NOT_FOUND") +} + +func (h *Handler) configOp(c *echo.Context, rt route) (any, error) { + if rt.op == opGetConfig { + return h.Backend.GetConfig(rt.project), nil + } + + _, raw, err := readJSON[idtkv2.GoogleCloudIdentitytoolkitAdminV2Config](c) + if err != nil { + return nil, err + } + + return h.Backend.UpdateConfig(rt.project, raw, c.QueryParam("updateMask")) +} + +func (h *Handler) tenantOp(c *echo.Context, rt route) (any, error) { + b := h.Backend + + switch rt.op { + case opCreateTenant: + in, _, err := readJSON[idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant](c) + if err != nil { + return nil, err + } + + return b.CreateTenant(rt.project, in) + case opGetTenant: + return b.GetTenant(rt.project, rt.id) + case opUpdateTenant: + _, raw, err := readJSON[idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant](c) + if err != nil { + return nil, err + } + + return b.UpdateTenant(rt.project, rt.id, raw, c.QueryParam("updateMask")) + } + + if err := b.DeleteTenant(rt.project, rt.id); err != nil { + return nil, err + } + + return map[string]any{}, nil +} + +func (h *Handler) listTenants(c *echo.Context, rt route) (any, error) { + size := 0 + + if raw := c.QueryParam("pageSize"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return nil, errInvalidArg("Invalid page size.") + } + + size = n + } + + pg, err := h.Backend.ListTenants(rt.project, c.QueryParam("pageToken"), size) + if err != nil { + return nil, err + } + + return &idtkv2.GoogleCloudIdentitytoolkitAdminV2ListTenantsResponse{Tenants: pg.Data, NextPageToken: pg.Next}, nil +} + +func (h *Handler) token(c *echo.Context) (any, error) { + var grant, refresh string + + if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "application/json") { + req, _, err := readJSON[struct { + GrantType string `json:"grant_type"` + RefreshToken string `json:"refresh_token"` + }](c) + if err != nil { + return nil, err + } + + grant, refresh = req.GrantType, req.RefreshToken + } else { + if err := c.Request().ParseForm(); err != nil { + return nil, errInvalidArg("Invalid form body.") + } + + grant, refresh = c.Request().PostForm.Get("grant_type"), c.Request().PostForm.Get("refresh_token") + } + + out, err := h.Backend.Refresh(grant, refresh) + if err != nil { + var ae *APIError + if errors.As(err, &ae) && ae.Status == "" { + return nil, &APIError{HTTP: ae.HTTP, Message: ae.Message, Status: "INVALID_ARGUMENT"} + } + + return nil, err + } + + return out, nil +} + +func (h *Handler) publicKeys(c *echo.Context) (any, error) { + kid, cert, pub, err := h.Backend.publicKeyCert() + if err != nil { + return nil, err + } + + c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", keyCacheSeconds)) + + if strings.Contains(c.Request().URL.Path, "/jwk/") { + return map[string]any{"keys": []map[string]string{jwkOf(kid, pub)}}, nil + } + + return map[string]string{kid: cert}, nil +} + +func jwkOf(kid string, pub *rsa.PublicKey) map[string]string { + return map[string]string{ + "kty": "RSA", "alg": algRS256, "use": "sig", "kid": kid, + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + var lc net.ListenConfig + + listener, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + if err != nil { + return fmt.Errorf("gcpidentitytoolkit: bind port %d: %w", h.Port, err) + } + + workerCtx := logger.WithWorker(ctx, "gcpidentitytoolkit", "listener") + log := logger.Load(workerCtx) + + e := echo.New() + e.Use(logger.EchoMiddleware(log)) + e.Use(gcpenforce.EchoMiddleware(ctx, gcpenforce.Target{Host: "identitytoolkit.googleapis.com"})) + e.Any("/*", telemetry.WrapEchoHandler("GCPIdentityToolkit", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + IdleTimeout: idleTimeout, + } + + h.srvMu.Lock("StartWorker") + h.srv, h.addr = srv, listener.Addr().String() + h.srvMu.Unlock() + + log.InfoContext(workerCtx, "gcpidentitytoolkit: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "gcpidentitytoolkit: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv := h.srv + h.srv = nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "gcpidentitytoolkit: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "gcpidentitytoolkit: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() +} diff --git a/services/gcpidentitytoolkit/hashes.go b/services/gcpidentitytoolkit/hashes.go new file mode 100644 index 0000000000..f64da627bc --- /dev/null +++ b/services/gcpidentitytoolkit/hashes.go @@ -0,0 +1,221 @@ +package gcpidentitytoolkit + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/md5" //nolint:gosec // wire-compatible import of legacy MD5 password hashes + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha1" //nolint:gosec // wire-compatible import of legacy SHA1 password hashes + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "hash" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/scrypt" +) + +const ( + algScrypt = "SCRYPT" + algStdScrypt = "STANDARD_SCRYPT" + algBcrypt = "BCRYPT" + algPBKDFSHA1 = "PBKDF_SHA1" + algPBKDF2SHA256 = "PBKDF2_SHA256" + orderSaltFirst = "SALT_AND_PASSWORD" + nativeRounds = 8 + nativeMemoryCost = 14 + scryptKeyLen = 32 + sha1Len = 20 + nativeSignerBytes = 64 + saltBytes = 16 + algMD5 = "MD5" + algSHA1 = "SHA1" + algSHA256 = "SHA256" + algSHA512 = "SHA512" +) + +// hashSpec describes how one password hash was produced; the zero Algorithm means the project's native scrypt. +type hashSpec struct { + Algorithm string `json:"algorithm"` + PasswordHashOrder string `json:"passwordHashOrder,omitempty"` + SignerKey []byte `json:"signerKey,omitempty"` + SaltSeparator []byte `json:"saltSeparator,omitempty"` + Rounds int64 `json:"rounds,omitempty"` + MemoryCost int64 `json:"memoryCost,omitempty"` + CPUMemCost int64 `json:"cpuMemCost,omitempty"` + BlockSize int64 `json:"blockSize,omitempty"` + Parallelization int64 `json:"parallelization,omitempty"` + DKLen int64 `json:"dkLen,omitempty"` +} + +func newNativeSpec() hashSpec { + key := make([]byte, nativeSignerBytes) + _, _ = rand.Read(key) + + return hashSpec{ + Algorithm: algScrypt, SignerKey: key, SaltSeparator: []byte{0x1d}, + Rounds: nativeRounds, MemoryCost: nativeMemoryCost, + } +} + +// decodeB64 accepts standard and URL alphabets, padded or not. +func decodeB64(s string) ([]byte, error) { + encodings := []*base64.Encoding{ + base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding, + } + + for _, enc := range encodings { + if b, err := enc.DecodeString(s); err == nil { + return b, nil + } + } + + return nil, errBadBase64 +} + +func newSalt() []byte { + salt := make([]byte, saltBytes) + _, _ = rand.Read(salt) + + return salt +} + +func hasher(alg string) func() hash.Hash { + switch alg { + case algHMACSHA256, algSHA256, algPBKDF2SHA256: + return sha256.New + case algHMACSHA512, algSHA512: + return sha512.New + case algHMACSHA1, algSHA1, algPBKDFSHA1: + return sha1.New + case algHMACMD5, algMD5: + return md5.New + } + + return nil +} + +func (h hashSpec) message(password string, salt []byte) []byte { + pw := []byte(password) + if h.PasswordHashOrder == orderSaltFirst { + out := append(append([]byte{}, salt...), h.SaltSeparator...) + + return append(out, pw...) + } + + out := append(append([]byte{}, pw...), h.SaltSeparator...) + + return append(out, salt...) +} + +// compute returns the raw hash of password. +func (h hashSpec) compute(password string, salt []byte) ([]byte, error) { + switch h.Algorithm { + case algScrypt: + return h.firebaseScrypt(password, salt) + case algStdScrypt: + return scrypt.Key( + []byte(password), + salt, + int(h.CPUMemCost), + int(h.BlockSize), + int(h.Parallelization), + int(h.DKLen), + ) + case algPBKDFSHA1: + return pbkdf2.Key(sha1.New, password, salt, int(h.Rounds), sha1Len) + case algPBKDF2SHA256: + return pbkdf2.Key(sha256.New, password, salt, int(h.Rounds), sha256.Size) + case algHMACSHA256, algHMACSHA512, algHMACSHA1, algHMACMD5: + mac := hmac.New(hasher(h.Algorithm), h.SignerKey) + mac.Write(h.message(password, salt)) + + return mac.Sum(nil), nil + case algMD5, algSHA1, algSHA256, algSHA512: + return h.iterated(password, salt), nil + } + + return nil, errBadAlgorithm +} + +func (h hashSpec) iterated(password string, salt []byte) []byte { + hf := hasher(h.Algorithm) + cur := h.message(password, salt) + + for range max(h.Rounds, 1) { + d := hf() + d.Write(cur) + cur = d.Sum(nil) + } + + return cur +} + +// firebaseScrypt is Firebase's modified scrypt: scrypt over salt||separator, then AES-256-CTR of the signer key. +func (h hashSpec) firebaseScrypt(password string, salt []byte) ([]byte, error) { + s := append(append([]byte{}, salt...), h.SaltSeparator...) + + dk, err := scrypt.Key([]byte(password), s, 1< 0 && h.Rounds >= 1 && h.MemoryCost >= 1 && h.MemoryCost <= 20 + }, + algStdScrypt: func(h hashSpec) bool { + return h.CPUMemCost >= 2 && h.BlockSize >= 1 && h.Parallelization >= 1 && h.DKLen >= 1 + }, + algPBKDFSHA1: func(h hashSpec) bool { return h.Rounds >= 1 }, + algPBKDF2SHA256: func(h hashSpec) bool { return h.Rounds >= 1 }, + algHMACSHA256: func(h hashSpec) bool { return len(h.SignerKey) > 0 }, + algHMACSHA512: func(h hashSpec) bool { return len(h.SignerKey) > 0 }, + algHMACSHA1: func(h hashSpec) bool { return len(h.SignerKey) > 0 }, + algHMACMD5: func(h hashSpec) bool { return len(h.SignerKey) > 0 }, + algMD5: func(h hashSpec) bool { return h.Rounds >= 1 }, + algSHA1: func(h hashSpec) bool { return h.Rounds >= 1 }, + algSHA256: func(h hashSpec) bool { return h.Rounds >= 1 }, + algSHA512: func(h hashSpec) bool { return h.Rounds >= 1 }, +} diff --git a/services/gcpidentitytoolkit/helpers_test.go b/services/gcpidentitytoolkit/helpers_test.go new file mode 100644 index 0000000000..a15f4771e1 --- /dev/null +++ b/services/gcpidentitytoolkit/helpers_test.go @@ -0,0 +1,178 @@ +package gcpidentitytoolkit_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + "github.com/blackbirdworks/gopherstack/services/gcpidentitytoolkit" +) + +const ( + adminAuth = "Bearer owner" + testPassword = "secret1" +) + +type fakeClock struct { + t time.Time + mu sync.Mutex +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + + return c.t +} + +func (c *fakeClock) Set(t time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + + c.t = t +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.t = c.t.Add(d) +} + +// skewedNow keeps token timestamps in the past (within the SDK's 5 minute tolerance) so revocation, which has +// second resolution, is deterministic. +func skewedNow() time.Time { return time.Now().Add(-2 * time.Minute) } + +type env struct { + backend *gcpidentitytoolkit.InMemoryBackend + clock *fakeClock + hc *http.Client + url string + project string +} + +func newEnv(t *testing.T) *env { + t.Helper() + + backend := gcpidentitytoolkit.NewInMemoryBackend() + clock := &fakeClock{t: skewedNow()} + backend.SetClock(clock.Now) + + e := echo.New() + e.Any("/*", gcpidentitytoolkit.NewHandler(backend).Handler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + return &env{ + backend: backend, clock: clock, hc: testclient.New(t), url: srv.URL, + project: "proj-" + strings.ToLower(strings.NewReplacer("/", "-", "_", "-").Replace(t.Name())), + } +} + +// result is a decoded REST response. +type result struct { + body map[string]any + status int +} + +func (r result) str(key string) string { + s, _ := r.body[key].(string) + + return s +} + +func (r result) errMsg() string { + e, _ := r.body["error"].(map[string]any) + m, _ := e["message"].(string) + + return m +} + +func (e *env) do(t *testing.T, method, path, auth string, body any) result { + t.Helper() + + var rdr *bytes.Reader + + if body != nil { + data, err := json.Marshal(body) + require.NoError(t, err) + + rdr = bytes.NewReader(data) + } else { + rdr = bytes.NewReader(nil) + } + + req, err := http.NewRequestWithContext(context.Background(), method, e.url+path, rdr) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + if auth != "" { + req.Header.Set("Authorization", auth) + } + + resp, err := e.hc.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out := result{status: resp.StatusCode} + _ = json.NewDecoder(resp.Body).Decode(&out.body) + + return out +} + +// client posts a key-authenticated client call under the test's project. +func (e *env) client(t *testing.T, verb string, body any) result { + t.Helper() + + return e.do(t, http.MethodPost, "/v1/projects/"+e.project+"/accounts:"+verb+"?key=k", "", body) +} + +// admin posts an OAuth-authenticated call under the test's project. +func (e *env) admin(t *testing.T, verb string, body any) result { + t.Helper() + + return e.do(t, http.MethodPost, "/v1/projects/"+e.project+"/accounts:"+verb, adminAuth, body) +} + +func (e *env) signUp(t *testing.T, email string) result { + t.Helper() + + r := e.client(t, "signUp", map[string]any{"email": email, "password": testPassword, "returnSecureToken": true}) + require.Equal(t, http.StatusOK, r.status, r.body) + + return r +} + +func (e *env) setConfig(t *testing.T, patch map[string]any) { + t.Helper() + + r := e.do(t, http.MethodPatch, "/v2/projects/"+e.project+"/config", adminAuth, patch) + require.Equal(t, http.StatusOK, r.status, r.body) +} + +func (e *env) assertErr(t *testing.T, r result, status int, msg string) { + t.Helper() + + assert.Equal(t, status, r.status, r.body) + assert.Equal(t, msg, r.errMsg()) +} + +func testClient(t *testing.T) *http.Client { + t.Helper() + + return testclient.New(t) +} diff --git a/services/gcpidentitytoolkit/inproc.go b/services/gcpidentitytoolkit/inproc.go new file mode 100644 index 0000000000..e5fb762a22 --- /dev/null +++ b/services/gcpidentitytoolkit/inproc.go @@ -0,0 +1,21 @@ +package gcpidentitytoolkit + +import "slices" + +// ProjectIDs lists the project IDs with stored state, always including the default project. +func (b *InMemoryBackend) ProjectIDs() []string { + b.mu.RLock("ProjectIDs") + defer b.mu.RUnlock() + + ids := []string{b.defaultProject} + + for id := range b.projects { + if id != b.defaultProject { + ids = append(ids, id) + } + } + + slices.Sort(ids) + + return ids +} diff --git a/services/gcpidentitytoolkit/leak_main_test.go b/services/gcpidentitytoolkit/leak_main_test.go new file mode 100644 index 0000000000..65afcd18a6 --- /dev/null +++ b/services/gcpidentitytoolkit/leak_main_test.go @@ -0,0 +1,11 @@ +package gcpidentitytoolkit_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + testleak.VerifyTestMain(m) +} diff --git a/services/gcpidentitytoolkit/oob_test.go b/services/gcpidentitytoolkit/oob_test.go new file mode 100644 index 0000000000..4f37e9e321 --- /dev/null +++ b/services/gcpidentitytoolkit/oob_test.go @@ -0,0 +1,379 @@ +package gcpidentitytoolkit_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (e *env) oobCodes(t *testing.T) []map[string]any { + t.Helper() + + path := "/emulator/v1/projects/" + e.project + "/oobCodes" + + r := e.do(t, http.MethodGet, path, "", nil) + require.Equal(t, http.StatusOK, r.status, r.body) + + raw, _ := r.body["oobCodes"].([]any) + out := make([]map[string]any, 0, len(raw)) + + for _, c := range raw { + out = append(out, c.(map[string]any)) + } + + return out +} + +func TestPasswordResetFlow(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + email string + wantErr string + wantOOB int + privacy bool + }{ + {name: "known email", email: "rp@example.com", privacy: true, wantOOB: 1}, + {name: "unknown email with protection is silent", email: "ghost@example.com", privacy: true}, + {name: "unknown email without protection", email: "ghost@example.com", wantErr: "EMAIL_NOT_FOUND"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.signUp(t, "rp@example.com") + e.setConfig( + t, + map[string]any{"emailPrivacyConfig": map[string]any{"enableImprovedEmailPrivacy": tt.privacy}}, + ) + + r := e.client(t, "sendOobCode", map[string]any{"requestType": "PASSWORD_RESET", "email": tt.email}) + if tt.wantErr != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.wantErr) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + assert.Equal(t, tt.email, r.str("email")) + assert.Empty(t, r.str("oobCode"), "client calls never see the code") + + codes := e.oobCodes(t) + require.Len(t, codes, tt.wantOOB) + + if tt.wantOOB == 0 { + return + } + + code := codes[0]["oobCode"].(string) + assert.Equal(t, "PASSWORD_RESET", codes[0]["requestType"]) + assert.Contains(t, codes[0]["oobLink"], "mode=resetPassword") + + check := e.client(t, "resetPassword", map[string]any{"oobCode": code}) + require.Equal(t, http.StatusOK, check.status, check.body) + assert.Equal(t, "PASSWORD_RESET", check.str("requestType")) + + weak := e.client(t, "resetPassword", map[string]any{"oobCode": code, "newPassword": "123"}) + e.assertErr(t, weak, http.StatusBadRequest, "WEAK_PASSWORD : Password should be at least 6 characters") + + done := e.client(t, "resetPassword", map[string]any{"oobCode": code, "newPassword": "brandnew1"}) + require.Equal(t, http.StatusOK, done.status, done.body) + + again := e.client(t, "resetPassword", map[string]any{"oobCode": code, "newPassword": "brandnew2"}) + e.assertErr(t, again, http.StatusBadRequest, "INVALID_OOB_CODE") + + old := e.client(t, "signInWithPassword", map[string]any{"email": "rp@example.com", "password": "secret1"}) + assert.Equal(t, http.StatusBadRequest, old.status) + + in := e.client(t, "signInWithPassword", map[string]any{"email": "rp@example.com", "password": "brandnew1"}) + assert.Equal(t, http.StatusOK, in.status, in.body) + }) + } +} + +func TestOobExpiry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msg string + advance time.Duration + }{ + {name: "within ttl", advance: 59 * time.Minute}, + {name: "after ttl", advance: 61 * time.Minute, msg: "EXPIRED_OOB_CODE"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.signUp(t, "x@example.com") + e.client(t, "sendOobCode", map[string]any{"requestType": "PASSWORD_RESET", "email": "x@example.com"}) + + code := e.oobCodes(t)[0]["oobCode"].(string) + e.clock.Advance(tt.advance) + + r := e.client(t, "resetPassword", map[string]any{"oobCode": code}) + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + assert.Equal(t, http.StatusOK, r.status, r.body) + }) + } +} + +func TestVerifyEmailFlow(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "v@example.com") + + r := e.client(t, "sendOobCode", map[string]any{"requestType": "VERIFY_EMAIL", "idToken": up.str("idToken")}) + require.Equal(t, http.StatusOK, r.status, r.body) + + code := e.oobCodes(t)[0]["oobCode"].(string) + + upd := e.client(t, "update", map[string]any{"oobCode": code}) + require.Equal(t, http.StatusOK, upd.status, upd.body) + assert.Equal(t, true, upd.body["emailVerified"]) + + lk := e.admin(t, "lookup", map[string]any{"localId": []string{up.str("localId")}}) + assert.Equal(t, true, lk.body["users"].([]any)[0].(map[string]any)["emailVerified"]) + assert.Empty(t, e.oobCodes(t)) +} + +func TestEmailLinkSignIn(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + email string + msg string + existing bool + link bool + isNew bool + }{ + {name: "new user", link: true, email: "new@example.com", isNew: true}, + {name: "existing user", existing: true, link: true, email: "old@example.com"}, + {name: "wrong email", link: true, email: "other@example.com", msg: "INVALID_EMAIL"}, + { + name: "link sign-in disabled", + link: false, + email: "new@example.com", + msg: "OPERATION_NOT_ALLOWED : Email link sign-in is disabled for this project.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + if tt.existing { + e.signUp(t, "old@example.com") + } + + target := tt.email + if tt.msg == "INVALID_EMAIL" { + target = "a@example.com" + } + + if !tt.link { + e.setConfig( + t, + map[string]any{"signIn": map[string]any{"email": map[string]any{"passwordRequired": true}}}, + ) + + r := e.client(t, "sendOobCode", map[string]any{"requestType": "EMAIL_SIGNIN", "email": target}) + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + r := e.client(t, "sendOobCode", map[string]any{"requestType": "EMAIL_SIGNIN", "email": target}) + require.Equal(t, http.StatusOK, r.status, r.body) + + code := e.oobCodes(t)[0]["oobCode"].(string) + + in := e.client(t, "signInWithEmailLink", map[string]any{"email": tt.email, "oobCode": code}) + if tt.msg != "" { + e.assertErr(t, in, http.StatusBadRequest, tt.msg) + + return + } + + require.Equal(t, http.StatusOK, in.status, in.body) + assert.NotEmpty(t, in.str("idToken")) + assert.Equal(t, tt.isNew, in.body["isNewUser"] == true) + + lk := e.client(t, "lookup", map[string]any{"idToken": in.str("idToken")}) + assert.Equal(t, true, lk.body["users"].([]any)[0].(map[string]any)["emailVerified"]) + }) + } +} + +func TestAdminOobLink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body map[string]any + mode string + msg string + }{ + { + name: "password reset", + body: map[string]any{"requestType": "PASSWORD_RESET", "email": "a@example.com"}, + mode: "mode=resetPassword", + }, + { + name: "verify email", + body: map[string]any{"requestType": "VERIFY_EMAIL", "email": "a@example.com"}, + mode: "mode=verifyEmail", + }, + { + name: "email signin", + body: map[string]any{ + "requestType": "EMAIL_SIGNIN", + "email": "z@example.com", + "continueUrl": "https://app.example.com/done", + }, + mode: "mode=signIn", + }, + { + name: "change email", + body: map[string]any{ + "requestType": "VERIFY_AND_CHANGE_EMAIL", + "email": "a@example.com", + "newEmail": "b@example.com", + }, + mode: "mode=verifyAndChangeEmail", + }, + { + name: "unknown user", + body: map[string]any{"requestType": "PASSWORD_RESET", "email": "no@example.com"}, + msg: "EMAIL_NOT_FOUND", + }, + { + name: "bad type", + body: map[string]any{"requestType": "NOPE", "email": "a@example.com"}, + msg: "INVALID_REQ_TYPE : Unsupported request type.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.signUp(t, "a@example.com") + + tt.body["returnOobLink"] = true + + r := e.admin(t, "sendOobCode", tt.body) + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + assert.Contains(t, r.str("oobLink"), tt.mode) + assert.Contains(t, r.str("oobLink"), "oobCode="+r.str("oobCode")) + }) + } +} + +func TestVerifyAndChangeEmail(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "a@example.com") + + r := e.admin(t, "sendOobCode", map[string]any{ + "requestType": "VERIFY_AND_CHANGE_EMAIL", + "email": "a@example.com", + "newEmail": "B@example.com", + "returnOobLink": true, + }) + require.Equal(t, http.StatusOK, r.status, r.body) + + upd := e.client(t, "update", map[string]any{"oobCode": r.str("oobCode")}) + require.Equal(t, http.StatusOK, upd.status, upd.body) + assert.Equal(t, "b@example.com", upd.str("email")) + + lk := e.admin(t, "lookup", map[string]any{"localId": []string{up.str("localId")}}) + u := lk.body["users"].([]any)[0].(map[string]any) + assert.Equal(t, "b@example.com", u["email"]) + assert.Equal(t, true, u["emailVerified"]) +} + +func TestCreateAuthURI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + msg string + methods []any + privacy bool + registered bool + }{ + {name: "protection hides existence", privacy: true, identifier: "me@example.com"}, + {name: "registered", identifier: "me@example.com", registered: true, methods: []any{"password", "emailLink"}}, + {name: "unregistered", identifier: "nobody@example.com"}, + {name: "invalid identifier", identifier: "nope", msg: "INVALID_IDENTIFIER"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.signUp(t, "me@example.com") + e.setConfig( + t, + map[string]any{"emailPrivacyConfig": map[string]any{"enableImprovedEmailPrivacy": tt.privacy}}, + ) + + r := e.client( + t, + "createAuthUri", + map[string]any{"identifier": tt.identifier, "continueUri": "http://localhost"}, + ) + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + + if tt.privacy { + assert.NotContains(t, r.body, "registered") + assert.NotContains(t, r.body, "allProviders") + + return + } + + assert.Equal(t, tt.registered, r.body["registered"]) + if tt.methods == nil { + assert.NotContains(t, r.body, "signinMethods") + + return + } + + assert.Equal(t, tt.methods, r.body["signinMethods"]) + }) + } +} diff --git a/services/gcpidentitytoolkit/persistence.go b/services/gcpidentitytoolkit/persistence.go new file mode 100644 index 0000000000..c1b920887e --- /dev/null +++ b/services/gcpidentitytoolkit/persistence.go @@ -0,0 +1,91 @@ +package gcpidentitytoolkit + +import ( + "context" + "fmt" + + idtkv2 "google.golang.org/api/identitytoolkit/v2" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the snapshot would make an older one unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Projects map[string]*projectRec `json:"projects"` + Keys *keyRec `json:"keys"` + Version int `json:"version"` +} + +// Snapshot serialises the backend state, including the token signing key. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "gcpidentitytoolkit", backendSnapshot{ + Version: snapshotVersion, Projects: b.projects, Keys: b.keys, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "gcpidentitytoolkit", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx). + WarnContext(ctx, "gcpidentitytoolkit: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + + return nil + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.projects = map[string]*projectRec{} + b.keys, b.priv = snap.Keys, nil + + for name, p := range snap.Projects { + if p == nil || p.Config == nil { + return fmt.Errorf("%w: project %q", ErrSnapshotNull, name) + } + + if p.Tenants == nil { + p.Tenants = map[string]*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant{} + } + + if p.Users == nil { + p.Users = map[string]map[string]*userRec{} + } + + if p.Users[rootTenantKey] == nil { + p.Users[rootTenantKey] = map[string]*userRec{} + } + + if p.Codes == nil { + p.Codes = map[string]*oobRec{} + } + + b.projects[name] = p + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("gcpidentitytoolkit: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/gcpidentitytoolkit/provider.go b/services/gcpidentitytoolkit/provider.go new file mode 100644 index 0000000000..ba4597442b --- /dev/null +++ b/services/gcpidentitytoolkit/provider.go @@ -0,0 +1,46 @@ +package gcpidentitytoolkit + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("gcpidentitytoolkit: nil app context") + +// ConfigProvider extracts Identity Platform configuration from the AppContext Config. +type ConfigProvider interface { + GetGCPIdentityToolkitSettings() Settings +} + +// Provider implements service.Provider for Identity Platform. It serves REST on its own listener +// (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "GCPIdentityToolkit" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetGCPIdentityToolkitSettings() + } + + backend := NewInMemoryBackend() + if settings.DefaultProject != "" { + backend.defaultProject = settings.DefaultProject + } + + handler := NewHandler(backend) + handler.Port = settings.Port + + return handler, nil +} diff --git a/services/gcpidentitytoolkit/rest_test.go b/services/gcpidentitytoolkit/rest_test.go new file mode 100644 index 0000000000..6dd7adbb5a --- /dev/null +++ b/services/gcpidentitytoolkit/rest_test.go @@ -0,0 +1,516 @@ +package gcpidentitytoolkit_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSignUpAndSignIn(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + email string + pw string + prep func(*testing.T, *env) + msg string + status int + }{ + {name: "ok", email: "a@example.com", pw: "secret1", status: http.StatusOK}, + {name: "email case folded", email: "Mixed@Example.com", pw: "secret1", status: http.StatusOK}, + { + name: "weak password", + email: "b@example.com", + pw: "123", + status: 400, + msg: "WEAK_PASSWORD : Password should be at least 6 characters", + }, + {name: "bad email", email: "not-an-email", pw: "secret1", status: 400, msg: "INVALID_EMAIL"}, + {name: "missing password", email: "c@example.com", status: 400, msg: "MISSING_PASSWORD"}, + { + name: "duplicate", email: "d@example.com", pw: "secret1", status: 400, msg: "EMAIL_EXISTS", + prep: func(t *testing.T, e *env) { + t.Helper() + e.signUp(t, "D@example.com") + }, + }, + { + name: "provider disabled", email: "e@example.com", pw: "secret1", status: 400, + msg: "OPERATION_NOT_ALLOWED : Password sign-in is disabled for this project.", + prep: func(t *testing.T, e *env) { + t.Helper() + + e.setConfig(t, map[string]any{"signIn": map[string]any{"email": map[string]any{"enabled": false}}}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + if tt.prep != nil { + tt.prep(t, e) + } + + r := e.client(t, "signUp", map[string]any{"email": tt.email, "password": tt.pw, "returnSecureToken": true}) + assert.Equal(t, tt.status, r.status, r.body) + + if tt.msg != "" { + assert.Equal(t, tt.msg, r.errMsg()) + + return + } + + assert.Equal(t, "identitytoolkit#SignupNewUserResponse", r.str("kind")) + assert.NotEmpty(t, r.str("idToken")) + assert.NotEmpty(t, r.str("refreshToken")) + assert.Equal(t, "3600", r.str("expiresIn")) + + in := e.client( + t, + "signInWithPassword", + map[string]any{"email": tt.email, "password": tt.pw, "returnSecureToken": true}, + ) + require.Equal(t, http.StatusOK, in.status, in.body) + assert.Equal(t, r.str("localId"), in.str("localId")) + assert.Equal(t, true, in.body["registered"]) + }) + } +} + +func TestSignInErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + email string + pw string + msg string + privacy bool + disable bool + }{ + { + name: "wrong password with protection", + email: "u@example.com", + pw: "wrongpw", + privacy: true, + msg: "INVALID_LOGIN_CREDENTIALS", + }, + { + name: "unknown email with protection", + email: "x@example.com", + pw: "wrongpw", + privacy: true, + msg: "INVALID_LOGIN_CREDENTIALS", + }, + {name: "wrong password without protection", email: "u@example.com", pw: "wrongpw", msg: "INVALID_PASSWORD"}, + {name: "unknown email without protection", email: "x@example.com", pw: "wrongpw", msg: "EMAIL_NOT_FOUND"}, + { + name: "disabled user", + email: "u@example.com", + pw: "secret1", + privacy: true, + disable: true, + msg: "USER_DISABLED : The user account has been disabled by an administrator.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "u@example.com") + + e.setConfig( + t, + map[string]any{"emailPrivacyConfig": map[string]any{"enableImprovedEmailPrivacy": tt.privacy}}, + ) + + if tt.disable { + r := e.admin(t, "update", map[string]any{"localId": up.str("localId"), "disableUser": true}) + require.Equal(t, http.StatusOK, r.status, r.body) + } + + r := e.client(t, "signInWithPassword", map[string]any{"email": tt.email, "password": tt.pw}) + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + }) + } +} + +func TestAuthRequirements(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + auth string + msg string + status int + }{ + { + name: "client call without key", + path: "/v1/accounts:signUp", + status: http.StatusForbidden, + msg: "The request is missing a valid API key.", + }, + {name: "client call with key", path: "/v1/accounts:signUp?key=abc", status: http.StatusOK}, + { + name: "admin path without bearer", + path: "/v1/projects/p1/accounts:batchGet?key=abc", + status: http.StatusUnauthorized, + }, + {name: "tenants without bearer", path: "/v2/projects/p1/tenants?key=abc", status: http.StatusUnauthorized}, + { + name: "emulator prefix", + path: "/identitytoolkit.googleapis.com/v1/accounts:signUp?key=abc", + status: http.StatusOK, + }, + {name: "unknown route", path: "/v1/nope", status: http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + method := http.MethodPost + if tt.path == "/v1/projects/p1/accounts:batchGet?key=abc" || tt.path == "/v2/projects/p1/tenants?key=abc" { + method = http.MethodGet + } + + e := newEnv(t) + r := e.do(t, method, tt.path, tt.auth, map[string]any{}) + assert.Equal(t, tt.status, r.status, r.body) + + if tt.msg != "" { + assert.Equal(t, tt.msg, r.errMsg()) + } + }) + } +} + +func TestAnonymousSignUp(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enabled bool + status int + }{ + {name: "enabled", enabled: true, status: http.StatusOK}, + {name: "disabled", enabled: false, status: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.setConfig(t, map[string]any{"signIn": map[string]any{"anonymous": map[string]any{"enabled": tt.enabled}}}) + + r := e.client(t, "signUp", map[string]any{"returnSecureToken": true}) + require.Equal(t, tt.status, r.status, r.body) + + if !tt.enabled { + assert.Equal(t, "ADMIN_ONLY_OPERATION", r.errMsg()) + + return + } + + lk := e.client(t, "lookup", map[string]any{"idToken": r.str("idToken")}) + require.Equal(t, http.StatusOK, lk.status, lk.body) + + users, _ := lk.body["users"].([]any) + require.Len(t, users, 1) + assert.NotContains(t, users[0], "email") + + link := e.client( + t, + "signUp", + map[string]any{"idToken": r.str("idToken"), "email": "anon@example.com", "password": "secret1"}, + ) + require.Equal(t, http.StatusOK, link.status, link.body) + assert.Equal(t, r.str("localId"), link.str("localId")) + }) + } +} + +func TestLookupRedactsHashForClients(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "h@example.com") + + client := e.client(t, "lookup", map[string]any{"idToken": up.str("idToken")}) + require.Equal(t, http.StatusOK, client.status, client.body) + + cu := client.body["users"].([]any)[0].(map[string]any) + assert.Equal(t, "UkVEQUNURUQ=", cu["passwordHash"]) + assert.NotContains(t, cu, "salt") + + admin := e.admin(t, "lookup", map[string]any{"email": []string{"h@example.com"}}) + require.Equal(t, http.StatusOK, admin.status, admin.body) + + au := admin.body["users"].([]any)[0].(map[string]any) + assert.NotEqual(t, "UkVEQUNURUQ=", au["passwordHash"]) + assert.NotEmpty(t, au["salt"]) + assert.Equal(t, "password", au["providerUserInfo"].([]any)[0].(map[string]any)["providerId"]) + + none := e.admin(t, "lookup", map[string]any{"localId": []string{"missing"}}) + require.Equal(t, http.StatusOK, none.status) + assert.NotContains(t, none.body, "users") + + noAuth := e.client(t, "lookup", map[string]any{"localId": []string{"x"}}) + e.assertErr(t, noAuth, http.StatusBadRequest, "MISSING_ID_TOKEN") +} + +func TestUpdateAccount(t *testing.T) { + t.Parallel() + + tests := []struct { + body func(idToken string) map[string]any + check func(*testing.T, result) + name string + msg string + second bool + }{ + { + name: "display name and photo", + body: func(id string) map[string]any { + return map[string]any{"idToken": id, "displayName": "Ann", "photoUrl": "http://x/y.png"} + }, + check: func(t *testing.T, r result) { + t.Helper() + + assert.Equal(t, "Ann", r.str("displayName")) + assert.Equal(t, "http://x/y.png", r.str("photoUrl")) + }, + }, + { + name: "delete attributes", + body: func(id string) map[string]any { + return map[string]any{"idToken": id, "deleteAttribute": []string{"DISPLAY_NAME", "PHOTO_URL"}} + }, + check: func(t *testing.T, r result) { + t.Helper() + + assert.Empty(t, r.str("displayName")) + assert.Empty(t, r.str("photoUrl")) + }, + }, + { + name: "change email unverifies", + body: func(id string) map[string]any { + return map[string]any{"idToken": id, "email": "new@example.com", "returnSecureToken": true} + }, + check: func(t *testing.T, r result) { + t.Helper() + + assert.Equal(t, "new@example.com", r.str("email")) + assert.NotEmpty(t, r.str("idToken")) + }, + }, + { + name: "weak password", + body: func(id string) map[string]any { return map[string]any{"idToken": id, "password": "abc"} }, + msg: "WEAK_PASSWORD : Password should be at least 6 characters", + }, + { + name: "email taken", + body: func(id string) map[string]any { return map[string]any{"idToken": id, "email": "other@example.com"} }, + msg: "EMAIL_EXISTS", + }, + { + name: "bad token", + body: func(string) map[string]any { return map[string]any{"idToken": "junk"} }, + msg: "INVALID_ID_TOKEN", + }, + { + name: "client cannot disable", + body: func(id string) map[string]any { return map[string]any{"idToken": id, "disableUser": true} }, + check: func(t *testing.T, r result) { + t.Helper() + assert.Equal(t, "identitytoolkit#SetAccountInfoResponse", r.str("kind")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "me@example.com") + e.signUp(t, "other@example.com") + + r := e.client(t, "update", tt.body(up.str("idToken"))) + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + tt.check(t, r) + }) + } +} + +func TestClientDisableIsIgnored(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "me@example.com") + e.client(t, "update", map[string]any{"idToken": up.str("idToken"), "disableUser": true}) + + in := e.client(t, "signInWithPassword", map[string]any{"email": "me@example.com", "password": "secret1"}) + assert.Equal(t, http.StatusOK, in.status, in.body) +} + +func TestCustomAttributes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + attrs string + msg string + }{ + {name: "valid", attrs: `{"role":"admin","level":3}`}, + {name: "reserved claim", attrs: `{"sub":"x"}`, msg: "FORBIDDEN_CLAIM : sub"}, + {name: "invalid json", attrs: `{nope`, msg: "INVALID_CLAIMS"}, + { + name: "too large", + attrs: `{"k":"` + string(make([]byte, 0)) + repeat('x', 1001) + `"}`, + msg: "CLAIMS_TOO_LARGE", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "c@example.com") + + r := e.admin(t, "update", map[string]any{"localId": up.str("localId"), "customAttributes": tt.attrs}) + if tt.msg != "" { + e.assertErr(t, r, http.StatusBadRequest, tt.msg) + + return + } + + require.Equal(t, http.StatusOK, r.status, r.body) + + lk := e.admin(t, "lookup", map[string]any{"localId": []string{up.str("localId")}}) + assert.JSONEq(t, tt.attrs, lk.body["users"].([]any)[0].(map[string]any)["customAttributes"].(string)) + }) + } +} + +func repeat(c byte, n int) string { + b := make([]byte, n) + for i := range b { + b[i] = c + } + + return string(b) +} + +func TestDeleteAccount(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "del@example.com") + + r := e.client(t, "delete", map[string]any{"idToken": up.str("idToken")}) + require.Equal(t, http.StatusOK, r.status, r.body) + + again := e.client(t, "delete", map[string]any{"idToken": up.str("idToken")}) + e.assertErr(t, again, http.StatusBadRequest, "USER_NOT_FOUND") + + adm := e.admin(t, "delete", map[string]any{"localId": "ghost"}) + e.assertErr(t, adm, http.StatusBadRequest, "USER_NOT_FOUND") + + in := e.client(t, "signInWithPassword", map[string]any{"email": "del@example.com", "password": "secret1"}) + e.assertErr(t, in, http.StatusBadRequest, "INVALID_LOGIN_CREDENTIALS") +} + +func TestTokenExpiryAndRefresh(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "tok@example.com") + + refresh := func(tok string) result { + return e.do( + t, + http.MethodPost, + "/v1/token?key=k", + "", + map[string]any{"grant_type": "refresh_token", "refresh_token": tok}, + ) + } + + e.clock.Advance(time.Hour + time.Minute) + + expired := e.client(t, "lookup", map[string]any{"idToken": up.str("idToken")}) + e.assertErr(t, expired, http.StatusBadRequest, "TOKEN_EXPIRED") + + r := refresh(up.str("refreshToken")) + require.Equal(t, http.StatusOK, r.status, r.body) + assert.Equal(t, "Bearer", r.str("token_type")) + assert.Equal(t, "3600", r.str("expires_in")) + assert.Equal(t, up.str("localId"), r.str("user_id")) + + fresh := e.client(t, "lookup", map[string]any{"idToken": r.str("id_token")}) + assert.Equal(t, http.StatusOK, fresh.status, fresh.body) + + bad := refresh("garbage") + assert.Equal(t, "INVALID_REFRESH_TOKEN", bad.errMsg()) + + none := refresh("") + assert.Equal(t, "MISSING_REFRESH_TOKEN", none.errMsg()) + + grant := e.do(t, http.MethodPost, "/v1/token?key=k", "", map[string]any{"grant_type": "x", "refresh_token": "y"}) + assert.Equal(t, "INVALID_GRANT_TYPE", grant.errMsg()) + + e.admin(t, "update", map[string]any{"localId": up.str("localId"), "disableUser": true}) + disabled := refresh(up.str("refreshToken")) + assert.Equal(t, "USER_DISABLED", disabled.errMsg()) +} + +func TestRevokeRefreshTokens(t *testing.T) { + t.Parallel() + + e := newEnv(t) + up := e.signUp(t, "rev@example.com") + + e.clock.Advance(2 * time.Second) + + r := e.admin(t, "update", map[string]any{"localId": up.str("localId"), "validSince": e.clock.Now().Unix()}) + require.Equal(t, http.StatusOK, r.status, r.body) + + lk := e.client(t, "lookup", map[string]any{"idToken": up.str("idToken")}) + e.assertErr(t, lk, http.StatusBadRequest, "TOKEN_EXPIRED") + + rf := e.do( + t, + http.MethodPost, + "/v1/token?key=k", + "", + map[string]any{"grant_type": "refresh_token", "refresh_token": up.str("refreshToken")}, + ) + assert.Equal(t, "TOKEN_EXPIRED", rf.errMsg()) + + in := e.client(t, "signInWithPassword", map[string]any{"email": "rev@example.com", "password": "secret1"}) + require.Equal(t, http.StatusOK, in.status) + + ok := e.client(t, "lookup", map[string]any{"idToken": in.str("idToken")}) + assert.Equal(t, http.StatusOK, ok.status, ok.body) +} diff --git a/services/gcpidentitytoolkit/route.go b/services/gcpidentitytoolkit/route.go new file mode 100644 index 0000000000..0bf60dd406 --- /dev/null +++ b/services/gcpidentitytoolkit/route.go @@ -0,0 +1,192 @@ +package gcpidentitytoolkit + +import ( + "net/http" + "net/url" + "slices" + "strings" +) + +//nolint:gochecknoglobals // immutable lookup tables +var ( + hostPrefixes = []string{"/identitytoolkit.googleapis.com", "/securetoken.googleapis.com"} + accountVerbs = []string{ + "signUp", "signInWithPassword", "signInWithCustomToken", "signInWithEmailLink", "lookup", "update", "delete", + "sendOobCode", opResetPassword, "createAuthUri", opBatchCreate, opBatchDelete, opQuery, + } +) + +type route struct { + op string + project string + tenant string + id string + version string +} + +func splitPath(r *http.Request) []string { + p := r.URL.EscapedPath() + + for _, pre := range hostPrefixes { + p = strings.TrimPrefix(p, pre) + } + + parts := strings.Split(strings.Trim(p, "/"), "/") + for i, s := range parts { + u, err := url.PathUnescape(s) + if err != nil { + return nil + } + + parts[i] = u + } + + return parts +} + +func parseRoute(r *http.Request) route { + parts := splitPath(r) + if len(parts) == 0 { + return route{} + } + + switch parts[0] { + case "emulator": + return emulatorRoute(r, parts) + case "robot", "service_accounts", "identitytoolkit": + return route{op: opPublicKeys} + case "v1", "v2": + return versionedRoute(r, route{version: parts[0]}, parts[1:]) + } + + return route{} +} + +func emulatorRoute(r *http.Request, parts []string) route { + const emulatorPathLen = 5 + + if len(parts) != emulatorPathLen || parts[1] != "v1" || parts[2] != "projects" { + return route{} + } + + rt := route{project: parts[3], tenant: r.URL.Query().Get("tenantId")} + + switch { + case parts[4] == "oobCodes" && r.Method == http.MethodGet: + rt.op = opListOobCodes + case parts[4] == "accounts" && r.Method == http.MethodDelete: + rt.op = opClearAccounts + } + + return rt +} + +func versionedRoute(r *http.Request, rt route, rest []string) route { + switch { + case len(rest) == 0: + return route{} + case rt.version == "v1" && rest[0] == "token" && len(rest) == 1: + rt.op = opToken + case rest[0] == "projects" && len(rest) >= 2: + return projectRoute(r, rt, rest[1], rest[2:]) + case rt.version == "v1" && len(rest) == 1: + rt.op = accountVerb(r, rest[0]) + } + + return rt +} + +func projectRoute(r *http.Request, rt route, proj string, tail []string) route { + rt.project = proj + + if before, verb, ok := strings.Cut(proj, ":"); ok { + rt.project = before + rt.op = projectColonVerb(r, rt, verb) + + return rt + } + + if rt.version == "v2" { + return v2Route(r, rt, tail) + } + + if len(tail) >= 2 && tail[0] == segTenants { + rt.tenant = tail[1] + tail = tail[2:] + + if before, verb, ok := strings.Cut(rt.tenant, ":"); ok { + rt.tenant = before + rt.op = projectColonVerb(r, rt, verb) + + return rt + } + } + + if len(tail) == 1 { + rt.op = projectVerb(r, tail[0]) + } + + return rt +} + +func projectColonVerb(r *http.Request, rt route, verb string) string { + if r.Method != http.MethodPost || rt.version != "v1" { + return "" + } + + switch verb { + case "createSessionCookie": + return opCreateSessionCookie + case "queryAccounts": + return opQuery + } + + return "" +} + +func projectVerb(r *http.Request, seg string) string { + if seg == "accounts" && r.Method == http.MethodPost { + return opAdminCreate + } + + return accountVerb(r, seg) +} + +func accountVerb(r *http.Request, seg string) string { + verb, ok := strings.CutPrefix(seg, "accounts:") + if !ok { + return "" + } + + if verb == "batchGet" { + if r.Method == http.MethodGet { + return opBatchGet + } + + return "" + } + + if r.Method == http.MethodPost && slices.Contains(accountVerbs, verb) { + return verb + } + + return "" +} + +func v2Route(r *http.Request, rt route, tail []string) route { + switch { + case len(tail) == 1 && tail[0] == "config": + rt.op = pick(r.Method, map[string]string{http.MethodGet: opGetConfig, http.MethodPatch: opUpdateConfig}) + case len(tail) == 1 && tail[0] == segTenants: + rt.op = pick(r.Method, map[string]string{http.MethodGet: opListTenants, http.MethodPost: opCreateTenant}) + case len(tail) == 2 && tail[0] == segTenants: + rt.id = tail[1] + rt.op = pick(r.Method, map[string]string{ + http.MethodGet: opGetTenant, http.MethodPatch: opUpdateTenant, http.MethodDelete: opDeleteTenant, + }) + } + + return rt +} + +func pick(method string, ops map[string]string) string { return ops[method] } diff --git a/services/gcpidentitytoolkit/sdk_test.go b/services/gcpidentitytoolkit/sdk_test.go new file mode 100644 index 0000000000..e0b66c9dd0 --- /dev/null +++ b/services/gcpidentitytoolkit/sdk_test.go @@ -0,0 +1,564 @@ +package gcpidentitytoolkit_test + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "testing" + "time" + + firebase "firebase.google.com/go/v4" + "firebase.google.com/go/v4/auth" + "firebase.google.com/go/v4/auth/hash" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "google.golang.org/api/iterator" + "google.golang.org/api/option" +) + +// sub returns an env on the same server with its own project, so subtests never see each other's accounts. +func (e *env) sub(t *testing.T) *env { + t.Helper() + + out := *e + out.project = "sub-" + strings.ToLower(strings.NewReplacer("/", "-", "_", "-").Replace(t.Name())) + + return &out +} + +func (e *env) sdk(t *testing.T) *auth.Client { + t.Helper() + + app, err := firebase.NewApp( + context.Background(), + &firebase.Config{ProjectID: e.project}, + option.WithoutAuthentication(), + ) + require.NoError(t, err) + + c, err := app.Auth(context.Background()) + require.NoError(t, err) + + return c +} + +func (e *env) signInToken(t *testing.T, email, pw string) result { + t.Helper() + + r := e.client(t, "signInWithPassword", map[string]any{"email": email, "password": pw, "returnSecureToken": true}) + require.Equal(t, http.StatusOK, r.status, r.body) + + return r +} + +// TestAdminSDK drives the server through firebase.google.com/go/v4/auth in FIREBASE_AUTH_EMULATOR_HOST mode. +// t.Setenv forbids t.Parallel on this parent; its subtests run in parallel. +func TestAdminSDK(t *testing.T) { //nolint:tparallel // t.Setenv forbids t.Parallel on the parent + root := newEnv(t) + + u, err := url.Parse(root.url) + require.NoError(t, err) + t.Setenv("FIREBASE_AUTH_EMULATOR_HOST", u.Host) + + run := func(t *testing.T, f func(*testing.T, *env, *auth.Client)) { + t.Helper() + + e := root.sub(t) + e.hc = testClient(t) + f(t, e, e.sdk(t)) + } + + // These own the shared clock (revocation compares second-resolution timestamps), so they run first and alone. + for _, tt := range []struct { + run func(*testing.T, *env, *auth.Client) + name string + }{ + {name: "revoke and disable", run: sdkRevokeDisable}, + {name: "session cookie", run: sdkSessionCookie}, + } { + t.Run(tt.name, func(t *testing.T) { + root.clock.Set(skewedNow()) + run(t, tt.run) + }) + } + + for _, tt := range []struct { + run func(*testing.T, *env, *auth.Client) + name string + }{ + {name: "users crud", run: sdkUsersCRUD}, + {name: "list and query", run: sdkListQuery}, + {name: "tokens and claims", run: sdkTokensClaims}, + {name: "custom token", run: sdkCustomToken}, + {name: "import hashes", run: sdkImportHashes}, + {name: "tenants", run: sdkTenants}, + {name: "email links", run: sdkEmailLinks}, + {name: "delete users", run: sdkDeleteUsers}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + run(t, tt.run) + }) + } +} + +func sdkUsersCRUD(t *testing.T, _ *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + created, err := c.CreateUser(ctx, (&auth.UserToCreate{}).UID("sdk-u1").Email("Sdk@Example.com").Password("secret1"). + DisplayName("Sdk User").PhotoURL("http://x/p.png").PhoneNumber("+15555550123").EmailVerified(true)) + require.NoError(t, err) + assert.Equal(t, "sdk-u1", created.UID) + assert.Equal(t, "sdk@example.com", created.Email) + assert.True(t, created.EmailVerified) + assert.Equal(t, "Sdk User", created.DisplayName) + assert.Equal(t, "+15555550123", created.PhoneNumber) + assert.NotZero(t, created.UserMetadata.CreationTimestamp) + require.Len(t, created.ProviderUserInfo, 2) + assert.Equal(t, "password", created.ProviderUserInfo[0].ProviderID) + + _, err = c.CreateUser(ctx, (&auth.UserToCreate{}).UID("sdk-u1")) + assert.True(t, auth.IsUIDAlreadyExists(err), err) + + _, err = c.CreateUser(ctx, (&auth.UserToCreate{}).Email("sdk@example.com")) + assert.True(t, auth.IsEmailAlreadyExists(err), err) + + _, err = c.CreateUser(ctx, (&auth.UserToCreate{}).PhoneNumber("+15555550123")) + assert.True(t, auth.IsPhoneNumberAlreadyExists(err), err) + + byMail, err := c.GetUserByEmail(ctx, "sdk@example.com") + require.NoError(t, err) + assert.Equal(t, "sdk-u1", byMail.UID) + + byPhone, err := c.GetUserByPhoneNumber(ctx, "+15555550123") + require.NoError(t, err) + assert.Equal(t, "sdk-u1", byPhone.UID) + + byProv, err := c.GetUserByProviderUID(ctx, "password", "sdk@example.com") + require.NoError(t, err) + assert.Equal(t, "sdk-u1", byProv.UID) + + updated, err := c.UpdateUser(ctx, "sdk-u1", (&auth.UserToUpdate{}).DisplayName("").PhotoURL(""). + Email("new@example.com").Disabled(true).PhoneNumber("")) + require.NoError(t, err) + assert.Empty(t, updated.DisplayName) + assert.Empty(t, updated.PhotoURL) + assert.Empty(t, updated.PhoneNumber) + assert.Equal(t, "new@example.com", updated.Email) + assert.True(t, updated.Disabled) + assert.Len(t, updated.ProviderUserInfo, 1) + + _, err = c.GetUser(ctx, "missing") + assert.True(t, auth.IsUserNotFound(err), err) + + require.NoError(t, c.DeleteUser(ctx, "sdk-u1")) + assert.True(t, auth.IsUserNotFound(c.DeleteUser(ctx, "sdk-u1"))) + + res, err := c.GetUsers( + ctx, + []auth.UserIdentifier{auth.UIDIdentifier{UID: "nope"}, auth.EmailIdentifier{Email: "x@example.com"}}, + ) + require.NoError(t, err) + assert.Empty(t, res.Users) + assert.Len(t, res.NotFound, 2) +} + +func sdkListQuery(t *testing.T, _ *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + for _, id := range []string{"l1", "l2", "l3"} { + _, err := c.CreateUser(ctx, (&auth.UserToCreate{}).UID(id).Email(id+"@example.com").Password("secret1")) + require.NoError(t, err) + } + + it := c.Users(ctx, "") + seen := map[string]string{} + + for { + u, err := it.Next() + if errors.Is(err, iterator.Done) { + break + } + + require.NoError(t, err) + seen[u.UID] = u.PasswordHash + } + + assert.Len(t, seen, 3) + + for _, h := range seen { + assert.NotEmpty(t, h, "admin export returns the real hash") + } + + q, err := c.QueryUsers(ctx, &auth.QueryUsersRequest{Expression: []*auth.Expression{{Email: "l2@example.com"}}}) + require.NoError(t, err) + assert.EqualValues(t, 1, q.Count) + require.Len(t, q.Users, 1) + assert.Equal(t, "l2", q.Users[0].UID) +} + +func sdkTokensClaims(t *testing.T, e *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + _, err := c.CreateUser( + ctx, + (&auth.UserToCreate{}).UID("t1").Email("t1@example.com").Password("secret1").DisplayName("T One"), + ) + require.NoError(t, err) + require.NoError(t, c.SetCustomUserClaims(ctx, "t1", map[string]any{"role": "admin", "level": 7})) + + in := e.signInToken(t, "t1@example.com", "secret1") + + tok, err := c.VerifyIDToken(ctx, in.str("idToken")) + require.NoError(t, err) + assert.Equal(t, "t1", tok.UID) + assert.Equal(t, "admin", tok.Claims["role"]) + assert.EqualValues(t, 7, tok.Claims["level"]) + assert.Equal(t, "password", tok.Firebase.SignInProvider) + assert.Equal(t, "https://securetoken.google.com/"+e.project, tok.Issuer) + assert.Equal(t, "t1@example.com", tok.Claims["email"]) + assert.Equal(t, "T One", tok.Claims["name"]) + assert.Equal(t, map[string]any{"email": []any{"t1@example.com"}}, tok.Firebase.Identities) + + _, err = c.VerifyIDToken(ctx, "garbage") + assert.True(t, auth.IsIDTokenInvalid(err), err) + + rec, err := c.GetUser(ctx, "t1") + require.NoError(t, err) + assert.Equal(t, "admin", rec.CustomClaims["role"]) + + require.NoError(t, c.SetCustomUserClaims(ctx, "t1", nil)) + + rec, err = c.GetUser(ctx, "t1") + require.NoError(t, err) + assert.Empty(t, rec.CustomClaims) +} + +func sdkRevokeDisable(t *testing.T, e *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + _, err := c.CreateUser(ctx, (&auth.UserToCreate{}).UID("r1").Email("r1@example.com").Password("secret1")) + require.NoError(t, err) + + in := e.signInToken(t, "r1@example.com", "secret1") + + _, err = c.VerifyIDTokenAndCheckRevoked(ctx, in.str("idToken")) + require.NoError(t, err) + + require.NoError(t, c.RevokeRefreshTokens(ctx, "r1")) + + _, err = c.VerifyIDTokenAndCheckRevoked(ctx, in.str("idToken")) + assert.True(t, auth.IsIDTokenRevoked(err), err) + + _, err = c.VerifyIDToken(ctx, in.str("idToken")) + assert.True(t, auth.IsIDTokenRevoked(err), "emulator mode always checks revocation: %v", err) + + rec, err := c.GetUser(ctx, "r1") + require.NoError(t, err) + assert.NotZero(t, rec.TokensValidAfterMillis) + + e.clock.Advance(3 * time.Minute) + + fresh := e.signInToken(t, "r1@example.com", "secret1") + _, err = c.VerifyIDTokenAndCheckRevoked(ctx, fresh.str("idToken")) + require.NoError(t, err) + + _, err = c.UpdateUser(ctx, "r1", (&auth.UserToUpdate{}).Disabled(true)) + require.NoError(t, err) + + _, err = c.VerifyIDTokenAndCheckRevoked(ctx, fresh.str("idToken")) + assert.True(t, auth.IsUserDisabled(err), err) + + r := e.client(t, "signInWithPassword", map[string]any{"email": "r1@example.com", "password": "secret1"}) + assert.Contains(t, r.errMsg(), "USER_DISABLED") +} + +func sdkCustomToken(t *testing.T, e *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + ct, err := c.CustomTokenWithClaims(ctx, "custom-1", map[string]any{"team": "blue"}) + require.NoError(t, err) + + r := e.client(t, "signInWithCustomToken", map[string]any{"token": ct, "returnSecureToken": true}) + require.Equal(t, http.StatusOK, r.status, r.body) + assert.Equal(t, true, r.body["isNewUser"]) + + tok, err := c.VerifyIDToken(ctx, r.str("idToken")) + require.NoError(t, err) + assert.Equal(t, "custom-1", tok.UID) + assert.Equal(t, "blue", tok.Claims["team"]) + assert.Equal(t, "custom", tok.Firebase.SignInProvider) + + again := e.client(t, "signInWithCustomToken", map[string]any{"token": ct}) + assert.NotEqual(t, true, again.body["isNewUser"]) + + refreshed := e.do( + t, + http.MethodPost, + "/v1/token?key=k", + "", + map[string]any{"grant_type": "refresh_token", "refresh_token": r.str("refreshToken")}, + ) + require.Equal(t, http.StatusOK, refreshed.status, refreshed.body) + + tok, err = c.VerifyIDToken(ctx, refreshed.str("id_token")) + require.NoError(t, err) + assert.Equal(t, "blue", tok.Claims["team"], "developer claims survive a refresh") + + bad := e.client(t, "signInWithCustomToken", map[string]any{"token": "x.y.z"}) + assert.Contains(t, bad.errMsg(), "INVALID_CUSTOM_TOKEN") +} + +func sdkImportHashes(t *testing.T, e *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + bc, err := bcrypt.GenerateFromPassword([]byte("secret1"), bcrypt.MinCost) + require.NoError(t, err) + + res, err := c.ImportUsers(ctx, []*auth.UserToImport{ + (&auth.UserToImport{}).UID("bc1").Email("bc1@example.com").PasswordHash(bc), + }, auth.WithHash(hash.Bcrypt{})) + require.NoError(t, err) + assert.Equal(t, 1, res.SuccessCount) + + in := e.client(t, "signInWithPassword", map[string]any{"email": "bc1@example.com", "password": "secret1"}) + require.Equal(t, http.StatusOK, in.status, in.body) + + salt := []byte("salty-salt") + key := []byte("signer-key") + sep := []byte{0x07} + h := firebaseScrypt(t, "secret1", salt, sep, key, 12, 8) + + res, err = c.ImportUsers(ctx, []*auth.UserToImport{ + (&auth.UserToImport{}).UID("sc1").Email("sc1@example.com").PasswordHash(h).PasswordSalt(salt), + (&auth.UserToImport{}).UID("bc1").Email("dup@example.com"), + }, auth.WithHash(hash.Scrypt{Key: key, SaltSeparator: sep, Rounds: 8, MemoryCost: 12})) + require.NoError(t, err) + assert.Equal(t, 1, res.SuccessCount) + require.Len(t, res.Errors, 1) + assert.Equal(t, 1, res.Errors[0].Index) + + in = e.client(t, "signInWithPassword", map[string]any{"email": "sc1@example.com", "password": "secret1"}) + assert.Equal(t, http.StatusOK, in.status, in.body) +} + +func sdkTenants(t *testing.T, e *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + tm := c.TenantManager + + a, err := tm.CreateTenant( + ctx, + (&auth.TenantToCreate{}).DisplayName("tenant-a").AllowPasswordSignUp(true).EnableEmailLinkSignIn(true), + ) + require.NoError(t, err) + assert.Equal(t, "tenant-a", a.DisplayName) + assert.True(t, a.AllowPasswordSignUp) + assert.True(t, strings.HasPrefix(a.ID, "tenant-a-"), a.ID) + + b, err := tm.CreateTenant(ctx, (&auth.TenantToCreate{}).DisplayName("tenant-b").AllowPasswordSignUp(true)) + require.NoError(t, err) + + got, err := tm.Tenant(ctx, a.ID) + require.NoError(t, err) + assert.Equal(t, a.ID, got.ID) + + upd, err := tm.UpdateTenant(ctx, a.ID, (&auth.TenantToUpdate{}).DisplayName("tenant-a2").EnableAnonymousUsers(true)) + require.NoError(t, err) + assert.Equal(t, "tenant-a2", upd.DisplayName) + assert.True(t, upd.EnableAnonymousUsers) + assert.True(t, upd.AllowPasswordSignUp, "unmasked fields survive the patch") + + ta, err := tm.AuthForTenant(a.ID) + require.NoError(t, err) + + tb, err := tm.AuthForTenant(b.ID) + require.NoError(t, err) + + ua, err := ta.CreateUser(ctx, (&auth.UserToCreate{}).UID("same").Email("same@example.com").Password("secret1")) + require.NoError(t, err) + assert.Equal(t, a.ID, ua.TenantID) + + _, err = tb.CreateUser(ctx, (&auth.UserToCreate{}).UID("same").Email("same@example.com").Password("secret2")) + require.NoError(t, err, "tenants are separate namespaces") + + _, err = c.GetUser(ctx, "same") + assert.True(t, auth.IsUserNotFound(err), "project level does not see tenant users") + + _, err = ta.CreateUser(ctx, (&auth.UserToCreate{}).Email("same@example.com")) + assert.True(t, auth.IsEmailAlreadyExists(err)) + + in := e.client( + t, + "signInWithPassword", + map[string]any{"email": "same@example.com", "password": "secret1", "tenantId": a.ID}, + ) + require.Equal(t, http.StatusOK, in.status, in.body) + + wrong := e.client( + t, + "signInWithPassword", + map[string]any{"email": "same@example.com", "password": "secret1", "tenantId": b.ID}, + ) + assert.Equal(t, http.StatusBadRequest, wrong.status) + + tok, err := ta.VerifyIDToken(ctx, in.str("idToken")) + require.NoError(t, err) + assert.Equal(t, a.ID, tok.Firebase.Tenant) + + _, err = tb.VerifyIDToken(ctx, in.str("idToken")) + require.Error(t, err) + + _, err = tm.AuthForTenant("ghost") + require.NoError(t, err) + + gone, err := tm.Tenant(ctx, "ghost-tenant") + assert.Nil(t, gone) + assert.True(t, auth.IsTenantNotFound(err), err) + + ids := map[string]bool{} + it := tm.Tenants(ctx, "") + + for { + tn, nErr := it.Next() + if errors.Is(nErr, iterator.Done) { + break + } + + require.NoError(t, nErr) + ids[tn.ID] = true + } + + assert.Equal(t, map[string]bool{a.ID: true, b.ID: true}, ids) + + require.NoError(t, tm.DeleteTenant(ctx, a.ID)) + + _, err = tm.Tenant(ctx, a.ID) + assert.True(t, auth.IsTenantNotFound(err)) + + _, err = tb.GetUser(ctx, "same") + assert.NoError(t, err, "deleting one tenant leaves others intact") +} + +func sdkSessionCookie(t *testing.T, e *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + _, err := c.CreateUser(ctx, (&auth.UserToCreate{}).UID("sc").Email("sc@example.com").Password("secret1")) + require.NoError(t, err) + + in := e.signInToken(t, "sc@example.com", "secret1") + + cookie, err := c.SessionCookie(ctx, in.str("idToken"), 6*3600*1e9) + require.NoError(t, err) + + tok, err := c.VerifySessionCookieAndCheckRevoked(ctx, cookie) + require.NoError(t, err) + assert.Equal(t, "sc", tok.UID) + assert.Equal(t, "https://session.firebase.google.com/"+e.project, tok.Issuer) + + require.NoError(t, c.RevokeRefreshTokens(ctx, "sc")) + + _, err = c.VerifySessionCookieAndCheckRevoked(ctx, cookie) + assert.True(t, auth.IsSessionCookieRevoked(err), err) + + _, err = c.SessionCookie(ctx, in.str("idToken"), 1) + assert.Error(t, err) +} + +func sdkEmailLinks(t *testing.T, e *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + _, err := c.CreateUser(ctx, (&auth.UserToCreate{}).UID("em").Email("em@example.com").Password("secret1")) + require.NoError(t, err) + + link, err := c.PasswordResetLink(ctx, "em@example.com") + require.NoError(t, err) + + lu, err := url.Parse(link) + require.NoError(t, err) + assert.Equal(t, "resetPassword", lu.Query().Get("mode")) + + done := e.client( + t, + "resetPassword", + map[string]any{"oobCode": lu.Query().Get("oobCode"), "newPassword": "changed1"}, + ) + require.Equal(t, http.StatusOK, done.status, done.body) + + e.signInToken(t, "em@example.com", "changed1") + + vlink, err := c.EmailVerificationLink(ctx, "em@example.com") + require.NoError(t, err) + + vu, err := url.Parse(vlink) + require.NoError(t, err) + + r := e.client(t, "update", map[string]any{"oobCode": vu.Query().Get("oobCode")}) + require.Equal(t, http.StatusOK, r.status, r.body) + + rec, err := c.GetUser(ctx, "em") + require.NoError(t, err) + assert.True(t, rec.EmailVerified) + + _, err = c.PasswordResetLink(ctx, "ghost@example.com") + assert.True(t, auth.IsEmailNotFound(err), err) + + slink, err := c.EmailSignInLink( + ctx, + "new-link@example.com", + &auth.ActionCodeSettings{URL: "https://app.example.com/finish", HandleCodeInApp: true}, + ) + require.NoError(t, err) + + su, err := url.Parse(slink) + require.NoError(t, err) + + in := e.client( + t, + "signInWithEmailLink", + map[string]any{"email": "new-link@example.com", "oobCode": su.Query().Get("oobCode")}, + ) + require.Equal(t, http.StatusOK, in.status, in.body) + assert.Equal(t, true, in.body["isNewUser"]) +} + +func sdkDeleteUsers(t *testing.T, _ *env, c *auth.Client) { + t.Helper() + + ctx := t.Context() + + for _, id := range []string{"d1", "d2"} { + _, err := c.CreateUser(ctx, (&auth.UserToCreate{}).UID(id)) + require.NoError(t, err) + } + + res, err := c.DeleteUsers(ctx, []string{"d1", "d2", "never-existed"}) + require.NoError(t, err) + assert.Equal(t, 3, res.SuccessCount) + assert.Zero(t, res.FailureCount) + + _, err = c.GetUser(ctx, "d1") + assert.True(t, auth.IsUserNotFound(err)) +} diff --git a/services/gcpidentitytoolkit/settings.go b/services/gcpidentitytoolkit/settings.go new file mode 100644 index 0000000000..0de6bcb4f8 --- /dev/null +++ b/services/gcpidentitytoolkit/settings.go @@ -0,0 +1,18 @@ +package gcpidentitytoolkit + +// DefaultPort is the dedicated Identity Platform listener's default TCP port (FIREBASE_AUTH_EMULATOR_HOST). +const DefaultPort = 8145 + +// DefaultProject is the project client calls resolve to when the request names none. +const DefaultProject = "gopherstack-local" + +// Settings holds service-level configuration, embedded in the root CLI as CLI.GCPIdentityToolkit. +type Settings struct { + DefaultProject string `json:"defaultProject" env:"GCP_IDENTITYTOOLKIT_PROJECT" default:"gopherstack-local" name:"project" help:"Project that client calls (API-key requests without a /projects/ path) resolve to."` //nolint:lll // config struct tags are intentionally verbose + Port int `json:"port" env:"GCP_IDENTITYTOOLKIT_PORT" default:"8145" name:"port" help:"Fixed TCP port for the dedicated Identity Platform / Firebase Auth listener (FIREBASE_AUTH_EMULATOR_HOST); startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { + return Settings{Port: DefaultPort, DefaultProject: DefaultProject} +} diff --git a/services/gcpidentitytoolkit/store.go b/services/gcpidentitytoolkit/store.go new file mode 100644 index 0000000000..8a290cc0f5 --- /dev/null +++ b/services/gcpidentitytoolkit/store.go @@ -0,0 +1,427 @@ +package gcpidentitytoolkit + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "math/big" + "sort" + "strings" + "time" + + idtkv2 "google.golang.org/api/identitytoolkit/v2" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + idTokenTTL = time.Hour + rsaBits = 2048 + keyValidity = 20 * 365 * 24 * time.Hour + maxUIDLen = 128 + defaultPage = 20 + maxPage = 1000 + secretBytes = 32 + oobIDBytes = 16 + anonProvider = "anonymous" + passwordProv = "password" + phoneProv = "phone" + customProv = "custom" + rootTenantKey = "" + fakeAPIKey = "fake-api-key" //nolint:gosec // placeholder shown in config, not a credential + timeLayoutRFC = time.RFC3339Nano + redactedHash = "UkVEQUNURUQ=" + maxClaimsBytes = 1000 + uidLen = 28 +) + +type providerRec struct { + ProviderID string `json:"providerId"` + RawID string `json:"rawId"` + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PhotoURL string `json:"photoUrl,omitempty"` + Phone string `json:"phoneNumber,omitempty"` + FederatedID string `json:"federatedId,omitempty"` +} + +type userRec struct { + Hash *hashSpec `json:"hash,omitempty"` + UID string `json:"uid"` + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PhotoURL string `json:"photoUrl,omitempty"` + Phone string `json:"phoneNumber,omitempty"` + PasswordHash []byte `json:"passwordHash,omitempty"` + Salt []byte `json:"salt,omitempty"` + CustomAttrs string `json:"customAttributes,omitempty"` + Providers []providerRec `json:"providers,omitempty"` + CreatedAt int64 `json:"createdAt"` + LastLoginAt int64 `json:"lastLoginAt,omitempty"` + LastRefreshAt int64 `json:"lastRefreshAt,omitempty"` + PasswordAt int64 `json:"passwordUpdatedAt,omitempty"` + ValidSince int64 `json:"validSince,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` + Disabled bool `json:"disabled,omitempty"` + Anonymous bool `json:"anonymous,omitempty"` +} + +type oobRec struct { + Email string `json:"email"` + NewEmail string `json:"newEmail,omitempty"` + RequestType string `json:"requestType"` + TenantID string `json:"tenantId,omitempty"` + ContinueURL string `json:"continueUrl,omitempty"` + Link string `json:"link"` + Created int64 `json:"created"` + Expires int64 `json:"expires"` +} + +type projectRec struct { + Config *idtkv2.GoogleCloudIdentitytoolkitAdminV2Config `json:"config"` + Tenants map[string]*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant `json:"tenants"` + Users map[string]map[string]*userRec `json:"users"` + Codes map[string]*oobRec `json:"codes"` + Hash hashSpec `json:"hash"` +} + +type keyRec struct { + KID string `json:"kid"` + Cert string `json:"cert"` + Priv []byte `json:"priv"` + Secret []byte `json:"secret"` +} + +// CustomTokenKeys resolves the public keys that may verify a custom token issued by a service account email. +type CustomTokenKeys func(email string) []*rsa.PublicKey + +// InMemoryBackend holds all Identity Platform state under one lock. +type InMemoryBackend struct { + now func() time.Time + mu *lockmetrics.RWMutex + projects map[string]*projectRec + keys *keyRec + priv *rsa.PrivateKey + customKeys CustomTokenKeys + defaultProject string +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{mu: lockmetrics.New("gcpidentitytoolkit"), now: time.Now, defaultProject: DefaultProject} + b.projects = map[string]*projectRec{} + + return b +} + +// SetClock replaces the backend clock. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + + b.now = now +} + +// SetCustomTokenKeys installs the resolver used to verify RS256 custom tokens. +func (b *InMemoryBackend) SetCustomTokenKeys(f CustomTokenKeys) { + b.mu.Lock("SetCustomTokenKeys") + defer b.mu.Unlock() + + b.customKeys = f +} + +// Reset removes all state, including signing keys. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + defer b.mu.Unlock() + + b.projects = map[string]*projectRec{} + b.keys, b.priv = nil, nil +} + +func (b *InMemoryBackend) nowMillis() int64 { return b.now().UnixMilli() } + +func defaultConfig(project string) *idtkv2.GoogleCloudIdentitytoolkitAdminV2Config { + return &idtkv2.GoogleCloudIdentitytoolkitAdminV2Config{ + Name: "projects/" + project + "/config", + AuthorizedDomains: []string{"localhost"}, + SignIn: &idtkv2.GoogleCloudIdentitytoolkitAdminV2SignInConfig{ + Email: &idtkv2.GoogleCloudIdentitytoolkitAdminV2Email{Enabled: true}, + Anonymous: &idtkv2.GoogleCloudIdentitytoolkitAdminV2Anonymous{Enabled: true}, + }, + EmailPrivacyConfig: &idtkv2.GoogleCloudIdentitytoolkitAdminV2EmailPrivacyConfig{ + EnableImprovedEmailPrivacy: true, + }, + Mfa: &idtkv2.GoogleCloudIdentitytoolkitAdminV2MultiFactorAuthConfig{State: "DISABLED"}, + Client: &idtkv2.GoogleCloudIdentitytoolkitAdminV2ClientConfig{ApiKey: fakeAPIKey}, + PasswordPolicyConfig: &idtkv2.GoogleCloudIdentitytoolkitAdminV2PasswordPolicyConfig{ + PasswordPolicyEnforcementState: "OFF", + }, + } +} + +// projectLocked returns the project record, creating it with defaults on first reference. +func (b *InMemoryBackend) projectLocked(project string) *projectRec { + if p, ok := b.projects[project]; ok { + return p + } + + p := &projectRec{ + Config: defaultConfig(project), + Tenants: map[string]*idtkv2.GoogleCloudIdentitytoolkitAdminV2Tenant{}, + Users: map[string]map[string]*userRec{rootTenantKey: {}}, + Codes: map[string]*oobRec{}, + Hash: newNativeSpec(), + } + b.projects[project] = p + + return p +} + +// scope bundles what an operation needs about one project or tenant. +type scope struct { + proj *projectRec + users map[string]*userRec + policy *idtkv2.GoogleCloudIdentitytoolkitAdminV2PasswordPolicyConfig + project string + tenant string + password bool + link bool + anon bool + privacy bool +} + +func (b *InMemoryBackend) scopeLocked(project, tenant string) (*scope, error) { + p := b.projectLocked(project) + s := &scope{proj: p, project: project, tenant: tenant} + + if tenant == rootTenantKey { + s.users = p.Users[rootTenantKey] + in := p.Config.SignIn + + if in != nil && in.Email != nil { + s.password = in.Email.Enabled + s.link = in.Email.Enabled && !in.Email.PasswordRequired + } + + s.anon = in != nil && in.Anonymous != nil && in.Anonymous.Enabled + s.privacy = p.Config.EmailPrivacyConfig != nil && p.Config.EmailPrivacyConfig.EnableImprovedEmailPrivacy + s.policy = p.Config.PasswordPolicyConfig + + return s, nil + } + + t, ok := p.Tenants[tenant] + if !ok { + return nil, errNotFound("TENANT_NOT_FOUND") + } + + if p.Users[tenant] == nil { + p.Users[tenant] = map[string]*userRec{} + } + + s.users = p.Users[tenant] + s.password, s.link, s.anon = t.AllowPasswordSignup, t.EnableEmailLinkSignin, t.EnableAnonymousUser + s.privacy = t.EmailPrivacyConfig != nil && t.EmailPrivacyConfig.EnableImprovedEmailPrivacy + s.policy = t.PasswordPolicyConfig + + return s, nil +} + +func (s *scope) byEmail(email string) *userRec { + for _, u := range s.users { + if u.Email != "" && strs.Equal(u.Email, email) { + return u + } + } + + return nil +} + +func (s *scope) byPhone(phone string) *userRec { + for _, u := range s.users { + if u.Phone != "" && u.Phone == phone { + return u + } + } + + return nil +} + +func (s *scope) byProvider(providerID, rawID string) *userRec { + for _, u := range s.users { + for _, p := range u.Providers { + if p.ProviderID == providerID && p.RawID == rawID { + return u + } + } + } + + return nil +} + +func (s *scope) sorted() []*userRec { + out := make([]*userRec, 0, len(s.users)) + for _, u := range s.users { + out = append(out, u) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt != out[j].CreatedAt { + return out[i].CreatedAt < out[j].CreatedAt + } + + return out[i].UID < out[j].UID + }) + + return out +} + +func newUID() string { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + raw := make([]byte, uidLen) + _, _ = rand.Read(raw) + + for i, c := range raw { + raw[i] = alphabet[int(c)%len(alphabet)] + } + + return string(raw) +} + +func randomToken(n int) string { + raw := make([]byte, n) + _, _ = rand.Read(raw) + + return strings.TrimRight(b64url(raw), "=") +} + +// ensureKeysLocked creates the signing key pair and token secret on first use. +func (b *InMemoryBackend) ensureKeysLocked() (*rsa.PrivateKey, *keyRec, error) { + if b.priv != nil && b.keys != nil { + return b.priv, b.keys, nil + } + + if b.keys != nil { + parsed, err := x509.ParsePKCS8PrivateKey(b.keys.Priv) + if err != nil { + return nil, nil, err //nolint:wrapcheck // corrupt snapshot + } + + priv, _ := parsed.(*rsa.PrivateKey) + b.priv = priv + + return b.priv, b.keys, nil + } + + priv, err := rsa.GenerateKey(rand.Reader, rsaBits) + if err != nil { + return nil, nil, err //nolint:wrapcheck // entropy failure + } + + now := b.now() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "securetoken@system.gserviceaccount.com"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(keyValidity), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + if err != nil { + return nil, nil, err //nolint:wrapcheck // entropy failure + } + + pkcs8, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, err //nolint:wrapcheck // unreachable for RSA + } + + sum := sha256.Sum256(x509.MarshalPKCS1PublicKey(&priv.PublicKey)) + secret := make([]byte, secretBytes) + _, _ = rand.Read(secret) + + b.priv = priv + b.keys = &keyRec{ + KID: hex.EncodeToString(sum[:20]), + Cert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), + Priv: pkcs8, + Secret: secret, + } + + return b.priv, b.keys, nil +} + +func cloneJSON[T any](v *T) *T { + if v == nil { + return nil + } + + data, err := json.Marshal(v) + if err != nil { + return v + } + + out := new(T) + if json.Unmarshal(data, out) != nil { + return v + } + + return out +} + +// mergePatch applies a JSON merge patch (RFC 7386) to dst. +func mergePatch(dst, patch map[string]any) { + for k, v := range patch { + if v == nil { + delete(dst, k) + + continue + } + + pm, isMap := v.(map[string]any) + dm, dIsMap := dst[k].(map[string]any) + + if isMap && dIsMap { + mergePatch(dm, pm) + + continue + } + + dst[k] = v + } +} + +func patched[T any](cur *T, patch map[string]any) (*T, error) { + data, err := json.Marshal(cur) + if err != nil { + return nil, errInvalidArg("Invalid JSON payload received.") + } + + var m map[string]any + if err = json.Unmarshal(data, &m); err != nil { + return nil, errInvalidArg("Invalid JSON payload received.") + } + + mergePatch(m, patch) + + data, err = json.Marshal(m) + if err != nil { + return nil, errInvalidArg("Invalid JSON payload received.") + } + + out := new(T) + if err = json.Unmarshal(data, out); err != nil { + return nil, errInvalidArg("Invalid JSON payload received: %v", err) + } + + return out, nil +} diff --git a/services/gcpidentitytoolkit/tokens.go b/services/gcpidentitytoolkit/tokens.go new file mode 100644 index 0000000000..ee7ff38336 --- /dev/null +++ b/services/gcpidentitytoolkit/tokens.go @@ -0,0 +1,392 @@ +package gcpidentitytoolkit + +import ( + "crypto/hmac" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "maps" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + securetokenIssuer = "https://securetoken.google.com/" //nolint:gosec // public URL, not a credential + sessionIssuer = "https://session.firebase.google.com/" + customTokenAud = "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit" //nolint:gosec,lll // public audience + maxCustomTokenAge = time.Hour + clockSkew = 5 * time.Minute +) + +//nolint:gochecknoglobals // immutable lookup table +var reservedClaims = map[string]bool{ + "acr": true, "amr": true, "at_hash": true, "aud": true, "auth_time": true, "azp": true, "cnf": true, + "c_hash": true, "exp": true, "firebase": true, "iat": true, "iss": true, "jti": true, "nbf": true, + "nonce": true, "sub": true, +} + +func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// refreshPayload is the signed content of a refresh token. +type refreshPayload struct { + Claims map[string]any `json:"c,omitempty"` + UID string `json:"u"` + Project string `json:"p"` + Tenant string `json:"t,omitempty"` + Provider string `json:"s"` + Issued int64 `json:"i"` + AuthTime int64 `json:"a"` +} + +func (b *InMemoryBackend) signRefreshLocked(p refreshPayload) (string, error) { + _, keys, err := b.ensureKeysLocked() + if err != nil { + return "", err + } + + body, err := json.Marshal(p) + if err != nil { + return "", err //nolint:wrapcheck // unreachable + } + + mac := hmac.New(sha256.New, keys.Secret) + mac.Write(body) + + return b64url(body) + "." + b64url(mac.Sum(nil)), nil +} + +func (b *InMemoryBackend) parseRefreshLocked(tok string) (*refreshPayload, bool) { + if b.keys == nil { + return nil, false + } + + bodyB64, sigB64, ok := strings.Cut(tok, ".") + if !ok { + return nil, false + } + + body, err1 := base64.RawURLEncoding.DecodeString(bodyB64) + sig, err2 := base64.RawURLEncoding.DecodeString(sigB64) + + if err1 != nil || err2 != nil { + return nil, false + } + + mac := hmac.New(sha256.New, b.keys.Secret) + mac.Write(body) + + if !hmac.Equal(sig, mac.Sum(nil)) { + return nil, false + } + + var p refreshPayload + if json.Unmarshal(body, &p) != nil { + return nil, false + } + + return &p, true +} + +// identities lists the user's identifiers per provider, as the token's firebase.identities claim. +func identities(u *userRec) map[string][]string { + out := map[string][]string{} + + if u.Email != "" { + out["email"] = []string{u.Email} + } + + if u.Phone != "" { + out[phoneProv] = []string{u.Phone} + } + + for _, p := range u.Providers { + if p.ProviderID == passwordProv || p.ProviderID == phoneProv { + continue + } + + out[p.ProviderID] = append(out[p.ProviderID], p.RawID) + } + + return out +} + +func (b *InMemoryBackend) tokenClaims( + sc *scope, + u *userRec, + provider string, + authTime int64, + issuer string, + extra map[string]any, +) jwt.MapClaims { + claims := jwt.MapClaims{} + + if u.CustomAttrs != "" { + var custom map[string]any + if json.Unmarshal([]byte(u.CustomAttrs), &custom) == nil { + maps.Copy(claims, custom) + } + } + + maps.Copy(claims, extra) + + if u.DisplayName != "" { + claims["name"] = u.DisplayName + } + + if u.PhotoURL != "" { + claims["picture"] = u.PhotoURL + } + + fb := map[string]any{"identities": identities(u), "sign_in_provider": provider} + if sc.tenant != rootTenantKey { + fb["tenant"] = sc.tenant + } + + now := b.now() + claims["iss"] = issuer + sc.project + claims["aud"] = sc.project + claims["auth_time"] = authTime + claims["user_id"] = u.UID + claims["sub"] = u.UID + claims["iat"] = now.Unix() + claims["exp"] = now.Add(idTokenTTL).Unix() + + if u.Email != "" { + claims["email"] = u.Email + claims["email_verified"] = u.EmailVerified + } + + if u.Phone != "" { + claims["phone_number"] = u.Phone + } + + claims["firebase"] = fb + + return claims +} + +func (b *InMemoryBackend) signJWTLocked(claims jwt.MapClaims) (string, error) { + priv, keys, err := b.ensureKeysLocked() + if err != nil { + return "", err + } + + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = keys.KID + + return tok.SignedString(priv) +} + +// session is one sign-in's token pair. +type session struct { + IDToken string + RefreshToken string +} + +// issueLocked mints an ID token and refresh token for a fresh sign-in. +func (b *InMemoryBackend) issueLocked(sc *scope, u *userRec, provider string, extra map[string]any) (*session, error) { + now := b.now().Unix() + + refresh, err := b.signRefreshLocked(refreshPayload{ + UID: u.UID, Project: sc.project, Tenant: sc.tenant, Provider: provider, + Issued: now, AuthTime: now, Claims: extra, + }) + if err != nil { + return nil, err + } + + id, err := b.signJWTLocked(b.tokenClaims(sc, u, provider, now, securetokenIssuer, extra)) + if err != nil { + return nil, err + } + + u.LastRefreshAt = b.nowMillis() + + return &session{IDToken: id, RefreshToken: refresh}, nil +} + +// verifiedToken is a validated ID token. +type verifiedToken struct { + claims jwt.MapClaims + uid string + tenant string +} + +// verifyJWTLocked checks signature, issuer, audience and expiry of a token this backend signed. +func (b *InMemoryBackend) verifyJWTLocked(raw, project, issuer string) (*verifiedToken, error) { + if b.priv == nil && b.keys == nil { + return nil, idtk("INVALID_ID_TOKEN") + } + + priv, _, err := b.ensureKeysLocked() + if err != nil { + return nil, err + } + + claims := jwt.MapClaims{} + parser := jwt.NewParser( + jwt.WithValidMethods([]string{algRS256}), + jwt.WithTimeFunc(b.now), + jwt.WithExpirationRequired(), + ) + + _, err = parser.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) { return &priv.PublicKey, nil }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, idtk("TOKEN_EXPIRED") + } + + return nil, idtk("INVALID_ID_TOKEN") + } + + if iss, _ := claims["iss"].(string); iss != issuer+project { + return nil, idtk("INVALID_ID_TOKEN") + } + + if aud, _ := claims["aud"].(string); aud != project { + return nil, idtk("INVALID_ID_TOKEN") + } + + uid, _ := claims["sub"].(string) + tenant := "" + + if fb, ok := claims["firebase"].(map[string]any); ok { + tenant, _ = fb["tenant"].(string) + } + + return &verifiedToken{claims: claims, uid: uid, tenant: tenant}, nil +} + +// userForTokenLocked resolves and checks the account behind an ID token. +func (b *InMemoryBackend) userForTokenLocked(sc *scope, raw string) (*userRec, *verifiedToken, error) { + vt, err := b.verifyJWTLocked(raw, sc.project, securetokenIssuer) + if err != nil { + return nil, nil, err + } + + if vt.tenant != sc.tenant { + return nil, nil, idtk("INVALID_ID_TOKEN", "tenant mismatch") + } + + u, ok := sc.users[vt.uid] + if !ok { + return nil, nil, idtk("USER_NOT_FOUND") + } + + iat, _ := vt.claims.GetIssuedAt() + if u.ValidSince > 0 && iat != nil && iat.Unix() < u.ValidSince { + return nil, nil, idtk("TOKEN_EXPIRED") + } + + if u.Disabled { + return nil, nil, idtk("USER_DISABLED", "The user account has been disabled by an administrator.") + } + + return u, vt, nil +} + +func (b *InMemoryBackend) customTokenLocked(raw string, sc *scope) (string, map[string]any, error) { + claims := jwt.MapClaims{} + + tok, _, err := jwt.NewParser().ParseUnverified(raw, claims) + if err != nil { + return "", nil, idtk("INVALID_CUSTOM_TOKEN", "Invalid token format.") + } + + if err = b.checkCustomSignature(raw, tok.Method.Alg(), claims); err != nil { + return "", nil, err + } + + return b.checkCustomClaims(claims, sc) +} + +func (b *InMemoryBackend) checkCustomSignature(raw, alg string, claims jwt.MapClaims) error { + bad := func(detail string) error { return idtk("INVALID_CUSTOM_TOKEN", detail) } + + switch alg { + case "none": + if parts := strings.Split(raw, "."); len(parts) != 3 || parts[2] != "" { + return bad("Unsigned token carries a signature.") + } + case algRS256: + if !b.verifyCustomSignature(raw, claims) { + return bad("Token signature could not be verified against a known service account key.") + } + default: + return bad("Unsupported algorithm.") + } + + return nil +} + +func (b *InMemoryBackend) checkCustomClaims(claims jwt.MapClaims, sc *scope) (string, map[string]any, error) { + bad := func(detail string) error { return idtk("INVALID_CUSTOM_TOKEN", detail) } + + if aud, _ := claims["aud"].(string); aud != customTokenAud { + return "", nil, bad("Invalid audience.") + } + + now := b.now() + + exp, err := claims.GetExpirationTime() + if err != nil || exp == nil || now.After(exp.Add(clockSkew)) { + return "", nil, bad("Token has expired.") + } + + iat, err := claims.GetIssuedAt() + if err != nil || iat == nil || iat.After(now.Add(clockSkew)) || exp.Sub(iat.Time) > maxCustomTokenAge+clockSkew { + return "", nil, bad("Invalid issue time.") + } + + uid, _ := claims["uid"].(string) + if uid == "" || len(uid) > maxUIDLen { + return "", nil, bad("Invalid uid.") + } + + if t, _ := claims["tenant_id"].(string); t != sc.tenant { + return "", nil, idtk("TENANT_ID_MISMATCH") + } + + dev, _ := claims["claims"].(map[string]any) + for k := range dev { + if reservedClaims[k] { + return "", nil, idtk("FORBIDDEN_CLAIM", k) + } + } + + return uid, dev, nil +} + +func (b *InMemoryBackend) verifyCustomSignature(raw string, claims jwt.MapClaims) bool { + if b.customKeys == nil { + return false + } + + iss, _ := claims["iss"].(string) + + for _, key := range b.customKeys(iss) { + parser := jwt.NewParser(jwt.WithValidMethods([]string{algRS256}), jwt.WithoutClaimsValidation()) + if _, err := parser.Parse(raw, func(*jwt.Token) (any, error) { return key, nil }); err == nil { + return true + } + } + + return false +} + +// publicKeyCert returns the signing certificate and key id, creating keys on first use. +func (b *InMemoryBackend) publicKeyCert() (string, string, *rsa.PublicKey, error) { + b.mu.Lock("publicKeyCert") + defer b.mu.Unlock() + + priv, keys, err := b.ensureKeysLocked() + if err != nil { + return "", "", nil, err + } + + return keys.KID, keys.Cert, &priv.PublicKey, nil +} diff --git a/services/gcpidentitytoolkit/update.go b/services/gcpidentitytoolkit/update.go new file mode 100644 index 0000000000..ee0f40c77a --- /dev/null +++ b/services/gcpidentitytoolkit/update.go @@ -0,0 +1,567 @@ +package gcpidentitytoolkit + +import ( + "slices" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + reqPasswordReset = "PASSWORD_RESET" + reqVerifyEmail = "VERIFY_EMAIL" + reqEmailSignin = "EMAIL_SIGNIN" + reqVerifyChange = "VERIFY_AND_CHANGE_EMAIL" + oobBytes = 16 + resetTTL = time.Hour + signinTTL = 6 * time.Hour + verifyTTL = 3 * 24 * time.Hour +) + +//nolint:gochecknoglobals // immutable lookup tables +var ( + oobModes = map[string]string{ + reqPasswordReset: opResetPassword, reqVerifyEmail: "verifyEmail", + reqEmailSignin: "signIn", reqVerifyChange: "verifyAndChangeEmail", + } + oobTTL = map[string]time.Duration{ + reqPasswordReset: resetTTL, reqVerifyEmail: verifyTTL, + reqEmailSignin: signinTTL, reqVerifyChange: verifyTTL, + } +) + +func (b *InMemoryBackend) revokeLocked(u *userRec) { u.ValidSince = b.now().Unix() } + +// Update modifies an account: self-service with an ID token, by oobCode, or by uid for admins. +func (b *InMemoryBackend) Update(c call, r *authRequest) (*setAccountResponse, error) { + b.mu.Lock("Update") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + if r.OobCode != "" { + return b.applyOobUpdateLocked(sc, r.OobCode) + } + + u, err := b.updateTargetLocked(sc, c, r) + if err != nil { + return nil, err + } + + selfService := r.IDToken != "" + + stale, err := b.applyUpdateLocked(sc, u, r, c.admin && !selfService) + if err != nil { + return nil, err + } + + syncProviders(u) + + out := updateResponse(u) + + if selfService && stale && r.ReturnSecureToken != nil && *r.ReturnSecureToken { + s, iErr := b.issueLocked(sc, u, passwordProv, nil) + if iErr != nil { + return nil, iErr + } + + out.IDToken, out.RefreshToken, out.ExpiresIn = s.IDToken, s.RefreshToken, expiresInSecs + } + + return out, nil +} + +func (b *InMemoryBackend) updateTargetLocked(sc *scope, c call, r *authRequest) (*userRec, error) { + if r.IDToken != "" { + u, _, err := b.userForTokenLocked(sc, r.IDToken) + + return u, err + } + + if !c.admin { + return nil, idtk("MISSING_ID_TOKEN") + } + + if r.LocalID.first() == "" { + return nil, idtk("MISSING_LOCAL_ID") + } + + u, ok := sc.users[r.LocalID.first()] + if !ok { + return nil, idtk("USER_NOT_FOUND") + } + + return u, nil +} + +func updateResponse(u *userRec) *setAccountResponse { + out := &setAccountResponse{ + Kind: "identitytoolkit#SetAccountInfoResponse", LocalID: u.UID, Email: u.Email, + DisplayName: u.DisplayName, PhotoURL: u.PhotoURL, EmailVerified: u.EmailVerified, + } + + if len(u.PasswordHash) > 0 { + out.PasswordHash = redactedHash + } + + for _, p := range u.Providers { + out.ProviderUserInfo = append(out.ProviderUserInfo, providerWire(p)) + } + + return out +} + +// applyUpdateLocked validates every change before mutating, so a rejected update leaves the account untouched. +func (b *InMemoryBackend) applyUpdateLocked(sc *scope, u *userRec, r *authRequest, admin bool) (bool, error) { + claims, err := b.validateUpdateLocked(sc, u, r, admin) + if err != nil { + return false, err + } + + stale := false + + if newEmail := strs.Fold(r.Email.first()); newEmail != "" && newEmail != u.Email { + u.Email, u.EmailVerified, stale = newEmail, false, true + } + + if admin && r.EmailVerified != nil { + u.EmailVerified = *r.EmailVerified + } + + if r.Password != "" { + if err = b.setPasswordLocked(sc, u, r.Password); err != nil { + return false, err + } + + b.revokeLocked(u) + + stale, u.Anonymous = true, false + } + + if r.DisplayName != nil { + u.DisplayName = *r.DisplayName + } + + if r.PhotoURL != nil { + u.PhotoURL = *r.PhotoURL + } + + b.deleteAttributes(u, r.DeleteAttribute, r.DeleteProvider) + + if admin { + b.applyAdminFieldsLocked(u, r, r.PhoneNumber.first(), claims) + } + + return stale, nil +} + +// validateUpdateLocked checks an update without mutating; it returns the parsed custom claims. +func (b *InMemoryBackend) validateUpdateLocked(sc *scope, u *userRec, r *authRequest, admin bool) (string, error) { + if err := validateSelfServiceUpdate(sc, u, r); err != nil { + return "", err + } + + if !admin { + return "", nil + } + + if err := validateAdminUpdate(sc, u, r); err != nil { + return "", err + } + + if r.CustomAttributes != nil { + return parseClaims(*r.CustomAttributes) + } + + return "", nil +} + +func validateSelfServiceUpdate(sc *scope, u *userRec, r *authRequest) error { + newEmail := strs.Fold(r.Email.first()) + + if newEmail != "" && newEmail != u.Email { + if !validEmail(newEmail) { + return idtk("INVALID_EMAIL") + } + + if o := sc.byEmail(newEmail); o != nil && o.UID != u.UID { + return idtk("EMAIL_EXISTS") + } + } + + if r.Password == "" { + return nil + } + + if err := checkPassword(sc, r.Password); err != nil { + return err + } + + if u.Email == "" && newEmail == "" { + return idtk("MISSING_EMAIL") + } + + return nil +} + +func validateAdminUpdate(sc *scope, u *userRec, r *authRequest) error { + phone := r.PhoneNumber.first() + if phone == "" { + return nil + } + + if !e164.MatchString(phone) { + return idtk("INVALID_PHONE_NUMBER", "Invalid format.") + } + + if o := sc.byPhone(phone); o != nil && o.UID != u.UID { + return idtk("PHONE_NUMBER_EXISTS") + } + + return nil +} + +func (b *InMemoryBackend) deleteAttributes(u *userRec, attrs, providers []string) { + for _, a := range attrs { + switch a { + case "DISPLAY_NAME": + u.DisplayName = "" + case "PHOTO_URL": + u.PhotoURL = "" + case "PASSWORD": + u.PasswordHash, u.Salt, u.Hash = nil, nil, nil + case "EMAIL": + u.Email, u.EmailVerified = "", false + u.PasswordHash, u.Salt, u.Hash = nil, nil, nil + } + } + + for _, p := range providers { + switch p { + case phoneProv: + u.Phone = "" + case passwordProv: + u.PasswordHash, u.Salt, u.Hash = nil, nil, nil + default: + u.Providers = slices.DeleteFunc(u.Providers, func(x providerRec) bool { return x.ProviderID == p }) + } + } +} + +func (b *InMemoryBackend) applyAdminFieldsLocked(u *userRec, r *authRequest, phone, claims string) { + if phone != "" { + u.Phone = phone + } + + if r.DisableUser != nil { + u.Disabled = *r.DisableUser + } + + if r.CustomAttributes != nil { + u.CustomAttrs = claims + } + + if r.ValidSince > 0 { + u.ValidSince = int64(r.ValidSince) + } + + if lp := r.LinkProvider; lp != nil && lp.ProviderID != "" && lp.RawID != "" { + u.Providers = slices.DeleteFunc(u.Providers, func(x providerRec) bool { return x.ProviderID == lp.ProviderID }) + u.Providers = append(u.Providers, providerRec(*lp)) + } +} + +func (b *InMemoryBackend) applyOobUpdateLocked(sc *scope, code string) (*setAccountResponse, error) { + rec, err := b.oobLocked(sc, code) + if err != nil { + return nil, err + } + + if rec.RequestType != reqVerifyEmail && rec.RequestType != reqVerifyChange { + return nil, idtk("INVALID_OOB_CODE") + } + + u := sc.byEmail(rec.Email) + if u == nil { + return nil, idtk("EMAIL_NOT_FOUND") + } + + out := &setAccountResponse{Kind: "identitytoolkit#SetAccountInfoResponse", LocalID: u.UID} + + if rec.RequestType == reqVerifyChange { + n := strs.Fold(rec.NewEmail) + if o := sc.byEmail(n); o != nil && o.UID != u.UID { + return nil, idtk("EMAIL_EXISTS") + } + + u.Email = n + b.revokeLocked(u) + syncProviders(u) + } + + u.EmailVerified = true + out.Email, out.EmailVerified = u.Email, true + + delete(sc.proj.Codes, code) + + return out, nil +} + +// oobLocked looks up a live out-of-band code within the scope. +func (b *InMemoryBackend) oobLocked(sc *scope, code string) (*oobRec, error) { + rec, ok := sc.proj.Codes[code] + if !ok || rec.TenantID != sc.tenant { + return nil, idtk("INVALID_OOB_CODE") + } + + if b.now().UnixMilli() > rec.Expires { + delete(sc.proj.Codes, code) + + return nil, idtk("EXPIRED_OOB_CODE") + } + + return rec, nil +} + +// SendOobCode records a reset, verification or sign-in code; no email is sent. +func (b *InMemoryBackend) SendOobCode(c call, r *authRequest) (*oobResponse, error) { + if _, ok := oobModes[r.RequestType]; !ok { + return nil, idtk("INVALID_REQ_TYPE", "Unsupported request type.") + } + + b.mu.Lock("SendOobCode") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + email, silent, err := b.oobTargetLocked(sc, c, r) + if err != nil { + return nil, err + } + + out := &oobResponse{Kind: "identitytoolkit#GetOobConfirmationCodeResponse", Email: email} + if silent { + return out, nil + } + + code := randomToken(oobBytes) + link := oobLink(c.host, oobModes[r.RequestType], code, r.ContinueURL, sc.tenant) + now := b.now() + + sc.proj.Codes[code] = &oobRec{ + Email: email, NewEmail: strs.Fold(r.NewEmail), RequestType: r.RequestType, TenantID: sc.tenant, + ContinueURL: r.ContinueURL, Link: link, Created: now.UnixMilli(), + Expires: now.Add(oobTTL[r.RequestType]).UnixMilli(), + } + + if r.ReturnOobLink && c.admin { + out.OobCode, out.OobLink = code, link + } + + return out, nil +} + +// oobTargetLocked resolves the address a code is for; silent means enumeration protection swallowed the request. +func (b *InMemoryBackend) oobTargetLocked(sc *scope, c call, r *authRequest) (string, bool, error) { + email := strs.Fold(r.Email.first()) + + switch r.RequestType { + case reqPasswordReset: + if !sc.password { + return "", false, idtk("OPERATION_NOT_ALLOWED", "Password sign-in is disabled for this project.") + } + + if email == "" { + return "", false, idtk("MISSING_EMAIL") + } + + if !validEmail(email) { + return "", false, idtk("INVALID_EMAIL") + } + + if sc.byEmail(email) == nil { + if sc.privacy && !c.admin { + return email, true, nil + } + + return "", false, idtk("EMAIL_NOT_FOUND") + } + case reqEmailSignin: + if !sc.link { + return "", false, idtk("OPERATION_NOT_ALLOWED", "Email link sign-in is disabled for this project.") + } + + if email == "" { + return "", false, idtk("MISSING_EMAIL") + } + + if !validEmail(email) { + return "", false, idtk("INVALID_EMAIL") + } + default: + return b.verifyTargetLocked(sc, c, r, email) + } + + return email, false, nil +} + +func (b *InMemoryBackend) verifyTargetLocked(sc *scope, c call, r *authRequest, email string) (string, bool, error) { + if r.IDToken != "" { + u, _, err := b.userForTokenLocked(sc, r.IDToken) + if err != nil { + return "", false, err + } + + email = u.Email + } else if !c.admin { + return "", false, idtk("MISSING_ID_TOKEN") + } + + if email == "" { + return "", false, idtk("MISSING_EMAIL") + } + + if sc.byEmail(email) == nil { + return "", false, idtk("EMAIL_NOT_FOUND") + } + + if r.RequestType == reqVerifyChange { + n := strs.Fold(r.NewEmail) + if n == "" || !validEmail(n) { + return "", false, idtk("INVALID_NEW_EMAIL") + } + + if sc.byEmail(n) != nil { + return "", false, idtk("EMAIL_EXISTS") + } + } + + return email, false, nil +} + +// ResetPassword checks (no newPassword) or applies (newPassword) a password reset code. +func (b *InMemoryBackend) ResetPassword(c call, r *authRequest) (map[string]any, error) { + b.mu.Lock("ResetPassword") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + if r.OobCode == "" { + return nil, idtk("MISSING_OOB_CODE") + } + + rec, err := b.oobLocked(sc, r.OobCode) + if err != nil { + return nil, err + } + + out := map[string]any{ + keyKind: "identitytoolkit#ResetPasswordResponse", "email": rec.Email, "requestType": rec.RequestType, + } + + if rec.RequestType == reqVerifyChange { + out["newEmail"], out["email"] = rec.NewEmail, rec.Email + } + + if r.NewPassword == "" { + return out, nil + } + + if rec.RequestType != reqPasswordReset { + return nil, idtk("INVALID_OOB_CODE") + } + + u := sc.byEmail(rec.Email) + if u == nil { + return nil, idtk("EMAIL_NOT_FOUND") + } + + if err = checkPassword(sc, r.NewPassword); err != nil { + return nil, err + } + + if err = b.setPasswordLocked(sc, u, r.NewPassword); err != nil { + return nil, err + } + + u.EmailVerified = true + + b.revokeLocked(u) + syncProviders(u) + delete(sc.proj.Codes, r.OobCode) + + return out, nil +} + +// SignInWithEmailLink completes passwordless sign-in, creating the account on first use. +func (b *InMemoryBackend) SignInWithEmailLink(c call, r *authRequest) (*tokenResponse, error) { + b.mu.Lock("SignInWithEmailLink") + defer b.mu.Unlock() + + sc, err := b.scopeLocked(c.project, effectiveTenant(c, r)) + if err != nil { + return nil, err + } + + if !sc.link { + return nil, idtk("OPERATION_NOT_ALLOWED", "Email link sign-in is disabled for this project.") + } + + if r.OobCode == "" { + return nil, idtk("MISSING_OOB_CODE") + } + + email := strs.Fold(r.Email.first()) + if email == "" { + return nil, idtk("MISSING_EMAIL") + } + + rec, err := b.oobLocked(sc, r.OobCode) + if err != nil { + return nil, err + } + + if rec.RequestType != reqEmailSignin { + return nil, idtk("INVALID_OOB_CODE") + } + + if rec.Email != email { + return nil, idtk("INVALID_EMAIL") + } + + existing := sc.byEmail(email) + created := existing == nil + + if created { + existing, err = b.createUserLocked(sc, newUser{email: email, emailVerified: true}, false) + if err != nil { + return nil, err + } + } else { + existing.EmailVerified = true + } + + if existing.Disabled { + return nil, idtk("USER_DISABLED", "The user account has been disabled by an administrator.") + } + + delete(sc.proj.Codes, r.OobCode) + + resp, err := b.finishSignInLocked(sc, existing, passwordProv, "identitytoolkit#EmailLinkSigninResponse") + if err != nil { + return nil, err + } + + resp.IsNewUser = created + + return resp, nil +} diff --git a/services/gcpidentitytoolkit/users.go b/services/gcpidentitytoolkit/users.go new file mode 100644 index 0000000000..f80ddb0c4b --- /dev/null +++ b/services/gcpidentitytoolkit/users.go @@ -0,0 +1,304 @@ +package gcpidentitytoolkit + +import ( + "encoding/base64" + "encoding/json" + "net/mail" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + idtkv2 "google.golang.org/api/identitytoolkit/v2" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + minPasswordLen = 6 + expiresInSecs = "3600" +) + +var e164 = regexp.MustCompile(`^\+[0-9]{4,15}$`) + +func validEmail(email string) bool { + if email == "" || strings.ContainsAny(email, " \t\r\n") { + return false + } + + addr, err := mail.ParseAddress(email) + + return err == nil && addr.Address == email && strings.Count(email, "@") == 1 +} + +func hashB64(b []byte) string { return base64.URLEncoding.EncodeToString(b) } + +// checkPassword enforces the default minimum length and, when enforced, the project's password policy. +func checkPassword(sc *scope, pw string) error { + if len(pw) < minPasswordLen { + return idtk("WEAK_PASSWORD", "Password should be at least 6 characters") + } + + pol := sc.policy + if pol == nil || pol.PasswordPolicyEnforcementState != "ENFORCE" || len(pol.PasswordPolicyVersions) == 0 { + return nil + } + + return checkStrength(pol.PasswordPolicyVersions[len(pol.PasswordPolicyVersions)-1].CustomStrengthOptions, pw) +} + +func checkStrength(o *idtkv2.GoogleCloudIdentitytoolkitAdminV2CustomStrengthOptions, pw string) error { + if o == nil { + return nil + } + + var missing []string + + if o.MinPasswordLength > 0 && int64(len(pw)) < o.MinPasswordLength { + missing = append( + missing, + "Password must contain at least "+strconv.FormatInt(o.MinPasswordLength, 10)+" characters", + ) + } + + if o.MaxPasswordLength > 0 && int64(len(pw)) > o.MaxPasswordLength { + missing = append( + missing, + "Password must contain at most "+strconv.FormatInt(o.MaxPasswordLength, 10)+" characters", + ) + } + + var lower, upper, digit, other bool + + for _, r := range pw { + switch { + case unicode.IsLower(r): + lower = true + case unicode.IsUpper(r): + upper = true + case unicode.IsDigit(r): + digit = true + default: + other = true + } + } + + for _, c := range []struct { + msg string + need, ok bool + }{ + {"Password must contain a lower case character", o.ContainsLowercaseCharacter, lower}, + {"Password must contain an upper case character", o.ContainsUppercaseCharacter, upper}, + {"Password must contain a numeric character", o.ContainsNumericCharacter, digit}, + {"Password must contain a non-alphanumeric character", o.ContainsNonAlphanumericCharacter, other}, + } { + if c.need && !c.ok { + missing = append(missing, c.msg) + } + } + + if len(missing) == 0 { + return nil + } + + return idtk( + "PASSWORD_DOES_NOT_MEET_REQUIREMENTS", + "Missing password requirements: ["+strings.Join(missing, ", ")+"]", + ) +} + +// setPasswordLocked hashes pw with the project's native scheme. +func (b *InMemoryBackend) setPasswordLocked(sc *scope, u *userRec, pw string) error { + salt := newSalt() + + h, err := sc.proj.Hash.compute(pw, salt) + if err != nil { + return err + } + + u.PasswordHash, u.Salt, u.Hash = h, salt, nil + u.PasswordAt = b.nowMillis() + + return nil +} + +// syncProvidersLocked keeps the derived password and phone provider entries in step with the account. +func syncProviders(u *userRec) { + kept := u.Providers[:0:0] + + for _, p := range u.Providers { + if p.ProviderID != passwordProv && p.ProviderID != phoneProv { + kept = append(kept, p) + } + } + + if len(u.PasswordHash) > 0 && u.Email != "" { + kept = append([]providerRec{{ + ProviderID: passwordProv, RawID: u.Email, Email: u.Email, FederatedID: u.Email, + DisplayName: u.DisplayName, PhotoURL: u.PhotoURL, + }}, kept...) + } + + if u.Phone != "" { + kept = append(kept, providerRec{ProviderID: phoneProv, RawID: u.Phone, Phone: u.Phone}) + } + + u.Providers = kept +} + +type newUser struct { + hash *hashSpec + uid string + email string + displayName string + photoURL string + phone string + password string + customAttrs string + providers []providerRec + passwordHash []byte + salt []byte + createdAt int64 + lastLoginAt int64 + emailVerified bool + disabled bool + anonymous bool +} + +func (b *InMemoryBackend) createUserLocked(sc *scope, n newUser, overwrite bool) (*userRec, error) { + uid := n.uid + if uid == "" { + uid = newUID() + for sc.users[uid] != nil { + uid = newUID() + } + } + + n.email = strs.Fold(n.email) + + if err := validateNewUserLocked(sc, uid, n, overwrite); err != nil { + return nil, err + } + + now := b.nowMillis() + u := &userRec{ + UID: uid, Email: n.email, DisplayName: n.displayName, PhotoURL: n.photoURL, Phone: n.phone, + EmailVerified: n.emailVerified, Disabled: n.disabled, Anonymous: n.anonymous, CustomAttrs: n.customAttrs, + CreatedAt: now, LastLoginAt: n.lastLoginAt, Providers: n.providers, + PasswordHash: n.passwordHash, Salt: n.salt, Hash: n.hash, + } + + if n.createdAt > 0 { + u.CreatedAt = n.createdAt + } + + if n.password != "" { + if err := b.setPasswordLocked(sc, u, n.password); err != nil { + return nil, err + } + } + + syncProviders(u) + sc.users[uid] = u + + return u, nil +} + +func validateNewUserLocked(sc *scope, uid string, n newUser, overwrite bool) error { + if len(uid) > maxUIDLen { + return idtk("INVALID_LOCAL_ID", "Invalid localId") + } + + if sc.users[uid] != nil && !overwrite { + return idtk("DUPLICATE_LOCAL_ID") + } + + if n.email != "" { + if !validEmail(n.email) { + return idtk("INVALID_EMAIL") + } + + if o := sc.byEmail(n.email); o != nil && o.UID != uid { + return idtk("EMAIL_EXISTS") + } + } + + if n.phone != "" { + if !e164.MatchString(n.phone) { + return idtk("INVALID_PHONE_NUMBER", "Invalid format.") + } + + if o := sc.byPhone(n.phone); o != nil && o.UID != uid { + return idtk("PHONE_NUMBER_EXISTS") + } + } + + if n.password != "" { + return checkPassword(sc, n.password) + } + + return nil +} + +func (b *InMemoryBackend) toWire(sc *scope, u *userRec, admin bool) userWire { + w := userWire{ + LocalID: u.UID, Email: u.Email, DisplayName: u.DisplayName, PhotoURL: u.PhotoURL, PhoneNumber: u.Phone, + EmailVerified: u.EmailVerified, Disabled: u.Disabled, CustomAttributes: u.CustomAttrs, + CreatedAt: flexInt(u.CreatedAt), LastLoginAt: flexInt(u.LastLoginAt), ValidSince: flexInt(u.ValidSince), + TenantID: sc.tenant, CustomAuth: false, + } + + if u.LastRefreshAt > 0 { + w.LastRefreshAt = time.UnixMilli(u.LastRefreshAt).UTC().Format(timeLayoutRFC) + } + + if u.PasswordAt > 0 { + f := float64(u.PasswordAt) + w.PasswordUpdatedAt = &f + } + + if len(u.PasswordHash) > 0 { + if admin { + w.PasswordHash = hashB64(u.PasswordHash) + if len(u.Salt) > 0 { + w.Salt = hashB64(u.Salt) + } + } else { + w.PasswordHash = redactedHash + } + } + + for _, p := range u.Providers { + w.ProviderUserInfo = append(w.ProviderUserInfo, providerWire(p)) + } + + return w +} + +func parseClaims(raw string) (string, error) { + if raw == "" { + return "", nil + } + + if len(raw) > maxClaimsBytes { + return "", idtk("CLAIMS_TOO_LARGE") + } + + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return "", idtk("INVALID_CLAIMS") + } + + for k := range m { + if reservedClaims[k] { + return "", idtk("FORBIDDEN_CLAIM", k) + } + } + + if len(m) == 0 { + return "", nil + } + + return raw, nil +} diff --git a/services/gcpidentitytoolkit/wire.go b/services/gcpidentitytoolkit/wire.go new file mode 100644 index 0000000000..f7d20370bc --- /dev/null +++ b/services/gcpidentitytoolkit/wire.go @@ -0,0 +1,187 @@ +package gcpidentitytoolkit + +import ( + "bytes" + "encoding/json" + "strconv" +) + +// flexInt decodes a JSON number or a quoted number, the two forms Google clients send for int64 fields. +type flexInt int64 + +func (f *flexInt) UnmarshalJSON(data []byte) error { + data = bytes.Trim(data, `"`) + if len(data) == 0 || string(data) == "null" { + return nil + } + + n, err := strconv.ParseInt(string(data), 10, 64) + if err != nil { + fl, ferr := strconv.ParseFloat(string(data), 64) + if ferr != nil { + return err //nolint:wrapcheck // surfaced as a JSON decode error + } + + n = int64(fl) + } + + *f = flexInt(n) + + return nil +} + +type providerWire struct { + ProviderID string `json:"providerId,omitempty"` + RawID string `json:"rawId,omitempty"` + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PhotoURL string `json:"photoUrl,omitempty"` + Phone string `json:"phoneNumber,omitempty"` + FederatedID string `json:"federatedId,omitempty"` +} + +type userWire struct { + PasswordUpdatedAt *float64 `json:"passwordUpdatedAt,omitempty"` + CustomAttributes string `json:"customAttributes,omitempty"` + TenantID string `json:"tenantId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PhotoURL string `json:"photoUrl,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + PasswordHash string `json:"passwordHash,omitempty"` + Salt string `json:"salt,omitempty"` + LocalID string `json:"localId,omitempty"` + Email string `json:"email,omitempty"` + LastRefreshAt string `json:"lastRefreshAt,omitempty"` + ProviderUserInfo []providerWire `json:"providerUserInfo,omitempty"` + ValidSince flexInt `json:"validSince,omitempty"` + CreatedAt flexInt `json:"createdAt,omitempty"` + LastLoginAt flexInt `json:"lastLoginAt,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` + Disabled bool `json:"disabled,omitempty"` + CustomAuth bool `json:"customAuth,omitempty"` +} + +// MarshalJSON writes the int64 fields as strings, as the Identity Toolkit API does. +func (u userWire) MarshalJSON() ([]byte, error) { + type alias userWire + + aux := struct { + CreatedAt string `json:"createdAt,omitempty"` + LastLoginAt string `json:"lastLoginAt,omitempty"` + ValidSince string `json:"validSince,omitempty"` + alias + }{alias: alias(u)} + + if u.CreatedAt != 0 { + aux.CreatedAt = strconv.FormatInt(int64(u.CreatedAt), 10) + } + + if u.LastLoginAt != 0 { + aux.LastLoginAt = strconv.FormatInt(int64(u.LastLoginAt), 10) + } + + if u.ValidSince != 0 { + aux.ValidSince = strconv.FormatInt(int64(u.ValidSince), 10) + } + + return json.Marshal(aux) +} + +// authRequest is the union of the request fields the account operations read. +type authRequest struct { + DisplayName *string `json:"displayName"` + DisableUser *bool `json:"disableUser"` + EmailVerified *bool `json:"emailVerified"` + Disabled *bool `json:"disabled"` + LinkProvider *providerWire `json:"linkProviderUserInfo"` + ReturnSecureToken *bool `json:"returnSecureToken"` + CustomAttributes *string `json:"customAttributes"` + PhotoURL *string `json:"photoUrl"` + ContinueURI string `json:"continueUri"` + NewPassword string `json:"newPassword"` + ProviderID string `json:"providerId"` + Identifier string `json:"identifier"` + Password string `json:"password"` + IDToken string `json:"idToken"` + TenantID string `json:"tenantId"` + ContinueURL string `json:"continueUrl"` + NewEmail string `json:"newEmail"` + RequestType string `json:"requestType"` + OobCode string `json:"oobCode"` + Token string `json:"token"` + DeleteAttribute []string `json:"deleteAttribute"` + LocalID flexStrings `json:"localId"` + PhoneNumber flexStrings `json:"phoneNumber"` + FederatedUserID []providerWire `json:"federatedUserId"` + Email flexStrings `json:"email"` + Provider []string `json:"provider"` + DeleteProvider []string `json:"deleteProvider"` + ValidSince flexInt `json:"validSince"` + CreatedAt flexInt `json:"createdAt"` + LastLoginAt flexInt `json:"lastLoginAt"` + ValidDuration flexInt `json:"validDuration"` + ReturnOobLink bool `json:"returnOobLink"` +} + +// flexStrings decodes a string or an array of strings. +type flexStrings []string + +func (f *flexStrings) UnmarshalJSON(data []byte) error { + var one string + if json.Unmarshal(data, &one) == nil { + *f = flexStrings{one} + + return nil + } + + var many []string + if err := json.Unmarshal(data, &many); err != nil { + return err //nolint:wrapcheck // surfaced as a JSON decode error + } + + *f = many + + return nil +} + +func (f flexStrings) first() string { + if len(f) == 0 { + return "" + } + + return f[0] +} + +type tokenResponse struct { + Kind string `json:"kind,omitempty"` + LocalID string `json:"localId,omitempty"` + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + IDToken string `json:"idToken,omitempty"` + RefreshToken string `json:"refreshToken,omitempty"` + ExpiresIn string `json:"expiresIn,omitempty"` + Registered bool `json:"registered,omitempty"` + IsNewUser bool `json:"isNewUser,omitempty"` +} + +type setAccountResponse struct { + Kind string `json:"kind"` + LocalID string `json:"localId,omitempty"` + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PhotoURL string `json:"photoUrl,omitempty"` + NewEmail string `json:"newEmail,omitempty"` + PasswordHash string `json:"passwordHash,omitempty"` + IDToken string `json:"idToken,omitempty"` + RefreshToken string `json:"refreshToken,omitempty"` + ExpiresIn string `json:"expiresIn,omitempty"` + ProviderUserInfo []providerWire `json:"providerUserInfo,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` +} + +type oobResponse struct { + Kind string `json:"kind"` + Email string `json:"email,omitempty"` + OobCode string `json:"oobCode,omitempty"` + OobLink string `json:"oobLink,omitempty"` +} diff --git a/services/gcpkms/handler.go b/services/gcpkms/handler.go index f0c274cc73..346c745aa6 100644 --- a/services/gcpkms/handler.go +++ b/services/gcpkms/handler.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -131,7 +132,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpkms", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "cloudkms.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) kmspb.RegisterKeyManagementServiceServer(srv, &server{b: h.Backend}) iampb.RegisterIAMPolicyServer(srv, &iamServer{b: h.Backend}) longrunningpb.RegisterOperationsServer(srv, h.Backend.ops) diff --git a/services/gcplogging/handler.go b/services/gcplogging/handler.go index 9146b991ce..ed24ed46ec 100644 --- a/services/gcplogging/handler.go +++ b/services/gcplogging/handler.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -134,7 +135,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcplogging", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "logging.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) registerServers(srv, h.Backend) h.srvMu.Lock("StartWorker") diff --git a/services/gcpmonitoring/handler.go b/services/gcpmonitoring/handler.go index a468348abf..89e5414e68 100644 --- a/services/gcpmonitoring/handler.go +++ b/services/gcpmonitoring/handler.go @@ -15,6 +15,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -151,7 +152,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpmonitoring", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "monitoring.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) registerServers(srv, h.Backend) loopCtx, cancel := context.WithCancel(context.WithoutCancel(workerCtx)) diff --git a/services/gcpmonitoring/promql_test.go b/services/gcpmonitoring/promql_test.go index 0cfe8610cc..89b6e5c8dc 100644 --- a/services/gcpmonitoring/promql_test.go +++ b/services/gcpmonitoring/promql_test.go @@ -17,6 +17,8 @@ import ( metricpb "google.golang.org/genproto/googleapis/api/metric" "google.golang.org/genproto/googleapis/api/monitoredres" "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) const ( @@ -47,7 +49,7 @@ func (e *env) promGet(t *testing.T, path string, params url.Values) (int, []byte // no keep-alive: an idle pooled connection outlives the test and trips goleak req.Close = true - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() diff --git a/services/gcppubsub/handler.go b/services/gcppubsub/handler.go index 2741eaafe3..8fe9b80e39 100644 --- a/services/gcppubsub/handler.go +++ b/services/gcppubsub/handler.go @@ -17,6 +17,7 @@ import ( "cloud.google.com/go/iam/apiv1/iampb" "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -157,7 +158,8 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcppubsub", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer( + srv := gcpenforce.NewServer( + ctx, "pubsub.googleapis.com", grpc.MaxRecvMsgSize(maxMessageBytes), grpc.UnaryInterceptor(logInterceptor(log)), ) diff --git a/services/gcpredis/handler.go b/services/gcpredis/handler.go index d1594d43dd..6c6a44f209 100644 --- a/services/gcpredis/handler.go +++ b/services/gcpredis/handler.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -128,7 +129,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpredis", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "redis.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) h.registerServers(srv) h.srvMu.Lock("StartWorker") diff --git a/services/gcpredis/rest_test.go b/services/gcpredis/rest_test.go index c45fa9a2b8..96f602d482 100644 --- a/services/gcpredis/rest_test.go +++ b/services/gcpredis/rest_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/gcprest/resttest" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestRESTFlow(t *testing.T) { @@ -54,7 +55,7 @@ func TestRESTFlow(t *testing.T) { return false } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) if err != nil { return false } diff --git a/services/gcpresourcemanager/PARITY.md b/services/gcpresourcemanager/PARITY.md new file mode 100644 index 0000000000..a562ddf622 --- /dev/null +++ b/services/gcpresourcemanager/PARITY.md @@ -0,0 +1,85 @@ +--- +service: gcpresourcemanager +sdk_module: cloud.google.com/go/resourcemanager@v1.18.1 +last_audit_commit: 4d0fa2d3a +last_audit_date: 2026-10-31 +overall: B +# Per-op or per-op-family status. Values: ok | partial | gap | deferred. +# wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. +ops: + CreateProject: {wire: ok, errors: partial, state: ok, persist: ok, note: "Long-running operation (already done) with CreateProjectMetadata and the Project response. project_id 6 to 30 lowercase letters, digits, hyphens, starts with a letter, no trailing hyphen (Project.project_id comment); display_name 4 to 30 characters of letters, digits, hyphen, quotes, space, '!' (Project.display_name comment); labels per the Project.labels regex and 64-label limit; a duplicate id is ALREADY_EXISTS, including a DELETE_REQUESTED project; parent must be an existing ACTIVE organization or folder, else PERMISSION_DENIED (nonexistent) or FAILED_PRECONDITION (DELETE_REQUESTED). project_number is a random 12-digit number. Restricted id substrings: only 'google' and 'ssl' (public Resource Manager docs); the full reserved list is not verified."} + GetProject: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Accepts projects/NUMBER or projects/ID. DELETE_REQUESTED projects stay gettable until the retention passes. An unknown project is 403 PERMISSION_DENIED with ErrorInfo IAM_PERMISSION_DENIED (the 'or it may not exist' form Google APIs use); recalled, not from a primary source."} + ListProjects: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Direct children of an organization or folder, ordered by display name (ListProjects comment); show_deleted adds DELETE_REQUESTED projects."} + SearchProjects: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Query terms are OR-ed (SearchProjectsRequest.query: 'results that match any of the fields'); fields displayName/name, id/projectId, state/lifecycleState, parent, parent.type, parent.id, labels, labels.KEY, case-insensitive, '*' wildcard; a bare word matches display name, id and labels. ACTIVE projects only unless the query has a state term (that default is unverified)."} + UpdateProject: {wire: ok, errors: partial, state: ok, persist: ok, note: "update_mask display_name and labels only (UpdateProject comment); other paths INVALID_ARGUMENT; an empty labels field under the labels mask clears labels; only ACTIVE projects (FAILED_PRECONDITION otherwise); a non-empty etag must match (ABORTED on mismatch, unverified code)."} + MoveProject: {wire: ok, errors: partial, state: ok, persist: ok, note: "Re-parents under the organization or an ACTIVE folder."} + DeleteProject: {wire: ok, errors: ok, state: ok, persist: ok, note: "ACTIVE to DELETE_REQUESTED with delete_time; deleting a DELETE_REQUESTED project is a no-op success (DeleteProject comment). --gcp-resourcemanager-delete-retention (default 720h) is how long it stays restorable; 0 purges at once."} + UndeleteProject: {wire: ok, errors: partial, state: ok, persist: ok, note: "Only DELETE_REQUESTED projects (FAILED_PRECONDITION for an ACTIVE one, unverified code); fails if the parent folder is gone or DELETE_REQUESTED."} + CreateFolder: {wire: ok, errors: partial, state: ok, persist: ok, note: "Display name regex from UpdateFolder doc ([\\p{L}\\p{N}][\\p{L}\\p{N}_- ]{1,28}[\\p{L}\\p{N}], 3 to 30 characters); the Folder.display_name field comment says {0,28} (2 characters). The two disagree and the method doc wins here. Names are unique among ACTIVE siblings (case-insensitive is unverified), active height at most 10, at most 300 folders under a parent (CreateFolder comment); violations are FAILED_PRECONDITION with a PreconditionFailure detail."} + GetFolder: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Unknown folder is 403 like projects."} + ListFolders: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Direct children ordered by display name; show_deleted."} + SearchFolders: {wire: ok, errors: partial, state: ok, persist: n/a, note: "displayName, parent, state/lifecycleState with = or :, NOT/AND/OR and '*' (SearchFoldersRequest.query); terms with no operator are AND-ed (unverified); ACTIVE only unless the query has a state term (unverified)."} + UpdateFolder: {wire: ok, errors: partial, state: ok, persist: ok, note: "display_name only; same name rules as CreateFolder; etag mismatch is ABORTED."} + MoveFolder: {wire: ok, errors: partial, state: ok, persist: ok, note: "Destination must be the organization or an ACTIVE folder; not into itself or a descendant; name, fanout and height (subtree included) re-checked."} + DeleteFolder: {wire: ok, errors: ok, state: ok, persist: ok, note: "Only an empty folder (no ACTIVE folders or projects); DELETE_REQUESTED already is a no-op success (DeleteFolder comment)."} + UndeleteFolder: {wire: ok, errors: ok, state: ok, persist: ok, note: "Parent must be ACTIVE and naming, height and fanout re-checked; an ACTIVE folder is a no-op success (UndeleteFolder comment)."} + GetOrganization: {wire: ok, errors: partial, state: ok, persist: n/a, note: "One organization from --gcp-resourcemanager-organization-id and --gcp-resourcemanager-domain; owner.directory_customer_id is C plus the id."} + SearchOrganizations: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Query fields domain and directoryCustomerId / owner.directoryCustomerId, case-insensitive (SearchOrganizationsRequest.query)."} + GetIamPolicy: {wire: ok, errors: ok, state: ok, persist: ok, note: "Organizations, folders, projects (by number or id), tag keys and tag values; requested_policy_version honoured through pkgs/gcpiampolicy."} + SetIamPolicy: {wire: ok, errors: partial, state: ok, persist: ok, note: "Etag compare-and-set (ABORTED on a stale etag), conditions need version 3, allUsers and allAuthenticatedUsers are rejected on projects (SetIamPolicy comment). The other project constraints (owner role invitations, ToS-accepted owner) are not enforced."} + TestIamPermissions: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Every requested permission is granted (the emulator authorises all callers); permission names are not validated."} + CreateTagKey: {wire: ok, errors: partial, state: ok, persist: ok, note: "Parent organization or project; short_name 1 to 63 characters (TagKey.short_name comment); description at most 256; GCE_FIREWALL needs purpose_data network; validate_only returns the would-be key without storing it."} + GetTagKey: {wire: ok, errors: partial, state: ok, persist: n/a, note: "tagKeys/ID."} + GetNamespacedTagKey: {wire: ok, errors: partial, state: ok, persist: n/a, note: "ORG_OR_PROJECT_ID/SHORT_NAME; served over REST at /v3/tagKeys/namespaced (gcprest ranks literal-final templates first)."} + ListTagKeys: {wire: ok, errors: ok, state: ok, persist: n/a, note: "By parent."} + UpdateTagKey: {wire: ok, errors: partial, state: ok, persist: ok, note: "description only; etag mismatch ABORTED."} + DeleteTagKey: {wire: ok, errors: partial, state: ok, persist: ok, note: "FAILED_PRECONDITION while the key has tag values."} + CreateTagValue: {wire: ok, errors: partial, state: ok, persist: ok, note: "Under a tag key; short_name unique per key."} + GetTagValue: {wire: ok, errors: partial, state: ok, persist: n/a, note: "tagValues/ID."} + GetNamespacedTagValue: {wire: ok, errors: partial, state: ok, persist: n/a, note: "ORG_OR_PROJECT_ID/KEY/VALUE."} + ListTagValues: {wire: ok, errors: ok, state: ok, persist: n/a, note: "By parent tag key."} + UpdateTagValue: {wire: ok, errors: partial, state: ok, persist: ok, note: "description only."} + DeleteTagValue: {wire: ok, errors: partial, state: ok, persist: ok, note: "FAILED_PRECONDITION while bound to a resource."} + CreateTagBinding: {wire: ok, errors: partial, state: ok, persist: ok, note: "parent is //cloudresourcemanager.googleapis.com/{projects,folders,organizations}/ID; tag_value or tag_value_namespaced_name (both set is INVALID_ARGUMENT); a resource holds one value per key (FAILED_PRECONDITION; the rule is the documented one-value-per-key constraint, error code unverified). Name is tagBindings/ESCAPED_PARENT/tagValues/ID."} + ListTagBindings: {wire: ok, errors: ok, state: ok, persist: n/a, note: "By parent resource."} + DeleteTagBinding: {wire: ok, errors: partial, state: ok, persist: ok, note: "By binding name."} + ListEffectiveTags: {wire: gap, errors: gap, state: gap, persist: n/a, note: "Not served (UNIMPLEMENTED): inheritance of tags through the hierarchy is not computed."} + TagHolds: {wire: gap, errors: gap, state: gap, persist: n/a, note: "CreateTagHold, DeleteTagHold, ListTagHolds are not served (UNIMPLEMENTED)."} + ServiceUsage.GetService: {wire: ok, errors: partial, state: ok, persist: ok, note: "serviceusage.googleapis.com v1, SDK cloud.google.com/go/serviceusage@v1.16.1. Name uses the project number whatever form was asked. Unknown service: 403 PERMISSION_DENIED reason SERVICE_CONFIG_NOT_FOUND_OR_PERMISSION_DENIED; unknown or DELETE_REQUESTED project: 403 reason CONSUMER_INVALID (both recalled, not primary)."} + ServiceUsage.ListServices: {wire: ok, errors: ok, state: ok, persist: n/a, note: "filter state:ENABLED or state:DISABLED only (ListServicesRequest.filter); default page 50, at most 200 (ListServicesRequest.page_size); ordered by name."} + ServiceUsage.EnableService: {wire: ok, errors: partial, state: ok, persist: ok, note: "Long-running operation (done) with OperationMetadata.resource_names and EnableServiceResponse; enabling an enabled service succeeds; dependencies are enabled with it."} + ServiceUsage.DisableService: {wire: ok, errors: ok, state: ok, persist: ok, note: "FAILED_PRECONDITION when the service is not enabled (DisableService comment) or when enabled services depend on it and disable_dependent_services is false (DisableServiceRequest.disable_dependent_services); true disables the dependents with it. check_if_service_has_usage CHECK is accepted but the emulator records no usage, so it never fails."} + ServiceUsage.BatchEnableServices: {wire: ok, errors: partial, state: ok, persist: ok, note: "At most 20 services (BatchEnableServicesRequest.service_ids); atomic (BatchEnableServices comment): one unknown service fails the whole batch."} + ServiceUsage.BatchGetServices: {wire: ok, errors: partial, state: ok, persist: n/a, note: "At most 30 names (BatchGetServicesRequest.names); every name must belong to the parent."} + Operations: {wire: ok, errors: ok, state: ok, persist: ok, note: "google.longrunning.Operations via pkgs/gcplro: GetOperation, ListOperations, DeleteOperation, WaitOperation, CancelOperation. REST: /v1/operations/NAME and (gcprest) any-version /vN/operations/NAME for the v3 clients."} + V1.projects: {wire: partial, errors: partial, state: ok, persist: ok, note: "cloudresourcemanager v1 REST, which the Terraform google provider still calls for projects and project IAM: POST /v1/projects, GET/PUT/DELETE /v1/projects/ID, GET /v1/projects?filter, :undelete, :getAncestry, :getIamPolicy, :setIamPolicy, :testIamPermissions, plus /v1/organizations:search, GET /v1/organizations/ID and the organization IAM verbs. Hand-written JSON (no v1 proto in the module): Project fields projectNumber, projectId, lifecycleState, name, createTime, labels, parent {type,id}. v1 PUT with a different parent moves the project (v1 Project.parent comment: 'can be set using projects.update'). v1 list terms are AND-ed (v1 ListProjects documentation) and its operation response carries the v3 Project."} + CloudBilling.projects.billingInfo: {wire: partial, errors: partial, state: ok, persist: ok, note: "GET and PUT /v1/projects/ID/billingInfo of cloudbilling v1, served on the same listener because the Terraform google_project resource reads it on every refresh. Billing accounts themselves are not modelled: any billingAccounts/XXXXXX-XXXXXX-XXXXXX name is accepted and billingEnabled is true while one is set. Field names from recall of the Cloud Billing ProjectBillingInfo resource, not a primary source."} +families: + routing_isolation: {status: ok, note: "Own gRPC server with the gcprest REST transcoder and the v1 handler on --gcp-resourcemanager-port / GCP_RESOURCEMANAGER_PORT (default 8148, fails fast if taken); never registered on the shared AWS router. cli.go reserves the port in the shared PortAlloc pool. Resource Manager v3 (projects, folders, organizations, tagKeys, tagValues, tagBindings) and Service Usage v1 share the listener and one backend lock."} + seeding: {status: ok, note: "One organization (--gcp-resourcemanager-organization-id, default 123456789012; --gcp-resourcemanager-domain, default gopherstack.local) and the projects of --gcp-resourcemanager-seed-projects (default gopherstack-test, no parent) exist at startup and after Reset. New projects start with the default-enabled services every API-created project gets."} + service_catalog: {status: partial, note: "51 Google APIs: one per GCP emulator service plus the ones Terraform commonly enables. Titles and summaries are abbreviated; ServiceConfig carries name, title, documentation.summary and endpoints only. Dependency edges are few and approximate (container needs compute, cloudfunctions needs cloudbuild and artifactregistry, ...)."} + retention: {status: ok, note: "DELETE_REQUESTED projects and folders are purged lazily on the next API call once the injectable clock passes delete_time plus the retention; a purged id can be created again (GCP never reuses ids)."} + auth: {status: partial, note: "No request is authenticated or authorised and no IAM permission is checked."} + observability: {status: partial, note: "One lockmetrics.RWMutex guards all state; RPC failures are logged at debug. The dashboard page (hierarchy, APIs, IAM, tags) calls the backend in-process."} +items_still_open: + - "Enforcement (--gcp-enforce-projects, --gcp-enforce-service-enablement, default off) is wired through pkgs/gcpenforce but not everywhere: unenforced are Cloud Storage calls that name no project (object and bucket-scoped paths; only ?project= calls such as bucket create and list are checked), the Artifact Registry Docker /v2 registry, the Resource Manager v1 and Cloud Billing REST handlers, Cloud Functions and Cloud Run workload ingress, the Cloud DNS wire server, Identity Toolkit calls keyed by API key (accounts:signUp), operations endpoints (deliberately skipped), and requests that name no project (projects/- wildcards, folder or organization parents). A service missing from the 51-entry catalog is treated as enabled. The error shapes (403 PERMISSION_DENIED with ErrorInfo reason SERVICE_DISABLED / CONSUMER_INVALID, domain googleapis.com, metadata service and consumer, plus a Help link) and the message texts are recalled, not from a primary source; a DELETE_REQUESTED project answers CONSUMER_INVALID like an unknown one (the real 'Project #N has been deleted' form is unverified)." + - "Error codes and texts without a primary source: 403 for unknown projects, folders, organizations and tag resources; ABORTED for an etag mismatch on update and delete; FAILED_PRECONDITION for undelete of an ACTIVE project; ErrorInfo reasons CONSUMER_INVALID and SERVICE_CONFIG_NOT_FOUND_OR_PERMISSION_DENIED; the Service Usage dependency-conflict text." + - "Search defaults: ACTIVE-only results unless the query names a state, AND for juxtaposed folder terms, case-insensitive folder name uniqueness, and the restricted project id substrings beyond google and ssl are unverified." + - "Folder display name minimum (2 or 3 characters) conflicts between the Folder field comment and the UpdateFolder method comment in the same SDK." + - "Project creation is instantaneous (operations are done when returned); real creation takes seconds and the operation can fail asynchronously. No quota (projects.create) or billing-account behaviour." + - "Not emulated: organizations other than the one configured, organization policies, project liens, tag holds, ListEffectiveTags, folders and projects as tag-binding targets are accepted but tags are not inherited, v1beta1 Service Usage, Service Usage consumer quota and admin overrides, usage checks for check_if_service_has_usage, GetIamPolicy audit configs." + - "Tag keys, tag values and bindings of a purged project or folder are not removed with it." + - "The v1 Operation JSON carries the v3 Project in its response Any; the v1 response type name is unverified (Terraform only reads done and error)." + - "Unlike Google, a purged project id can be created again." +leaks: {status: clean, note: "No background goroutines besides the listener, which Shutdown joins."} +--- + +Initial implementation (2026-10-31): google.cloud.resourcemanager.v3 Projects, Folders, Organizations, TagKeys, TagValues, TagBindings and google.api.serviceusage.v1 ServiceUsage, driven in the package tests through the official clients cloud.google.com/go/resourcemanager@v1.18.1 and cloud.google.com/go/serviceusage@v1.16.1 over gRPC and REST, plus raw v1 REST flows. + +Primary sources used: + +- cloud.google.com/go/resourcemanager@v1.18.1/apiv3 method and field comments: Project (project_id, display_name, labels), Folder (display_name regex, constraints), CreateFolder (height 10 active, 20 total, 300 children, unique display_name), DeleteFolder, UndeleteFolder, MoveFolder, DeleteProject, UndeleteProject, SearchProjects/SearchFolders/SearchOrganizations query grammar, TagKey/TagValue/TagBinding. +- cloud.google.com/go/serviceusage@v1.16.1/apiv1: ListServicesRequest.filter and page_size, BatchEnableServices atomicity and the 20-service limit, BatchGetServices 30-name limit, DisableService FAILED_PRECONDITION, CheckIfServiceHasUsage, REST paths in service_usage_client.go (/v1/{name=*/*/services/*}:enable). +- The Terraform google provider (hashicorp/google 8.5.0, as pinned by test/terraform/gcp) calls cloudresourcemanager v1 for google_project, data.google_project and google_project_iam_*, v3 for google_folder and tags, and serviceusage v1 (list with state:ENABLED, batchEnable, disable) for google_project_service; observed against this emulator by test/terraform/gcp/resourcemanager_test.go. + +Enforcement (2026-10-31): --gcp-enforce-projects and --gcp-enforce-service-enablement (GCP_ENFORCE_PROJECTS, GCP_ENFORCE_SERVICE_ENABLEMENT) install a gRPC unary and stream interceptor (gcpenforce.NewServer, in 22 services' grpc.NewServer calls; REST reaches it through the gcprest transcoder) and an echo middleware (gcpenforce.EchoMiddleware, in the 6 HTTP-only services bigquery, compute, dns, identitytoolkit, sqladmin, storage). Project comes from request fields by proto reflection or the REST path; the API host from each service's google.api.default_host. Enforced: apigateway, artifactregistry, batch, bigtable, cloudbuild, cloudtasks, container, datastore, eventarc, firestore, functions, iam, kms, logging, monitoring, pubsub, redis, run, scheduler, secretmanager, spanner, workflows (gRPC and REST) and bigquery, compute, dns, identitytoolkit, sqladmin, storage (REST). Not enforced: this service itself. diff --git a/services/gcpresourcemanager/README.md b/services/gcpresourcemanager/README.md new file mode 100644 index 0000000000..ab39368595 --- /dev/null +++ b/services/gcpresourcemanager/README.md @@ -0,0 +1,31 @@ + +# Google Cloud Resource Manager and Service Usage + +**Parity grade: B** · SDK `cloud.google.com/go/resourcemanager@v1.18.1` · last audited 2026-10-31 (`4d0fa2d3a`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 39 (12 ok, 25 partial, 2 gap) | +| Feature families | 6 (3 ok, 3 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Known gaps + +- Enforcement (--gcp-enforce-projects, --gcp-enforce-service-enablement, default off) is wired through pkgs/gcpenforce but not everywhere: unenforced are Cloud Storage calls that name no project (object and bucket-scoped paths; only ?project= calls such as bucket create and list are checked), the Artifact Registry Docker /v2 registry, the Resource Manager v1 and Cloud Billing REST handlers, Cloud Functions and Cloud Run workload ingress, the Cloud DNS wire server, Identity Toolkit calls keyed by API key (accounts:signUp), operations endpoints (deliberately skipped), and requests that name no project (projects/- wildcards, folder or organization parents). A service missing from the 51-entry catalog is treated as enabled. The error shapes (403 PERMISSION_DENIED with ErrorInfo reason SERVICE_DISABLED / CONSUMER_INVALID, domain googleapis.com, metadata service and consumer, plus a Help link) and the message texts are recalled, not from a primary source; a DELETE_REQUESTED project answers CONSUMER_INVALID like an unknown one (the real 'Project #N has been deleted' form is unverified). +- Error codes and texts without a primary source: 403 for unknown projects, folders, organizations and tag resources; ABORTED for an etag mismatch on update and delete; FAILED_PRECONDITION for undelete of an ACTIVE project; ErrorInfo reasons CONSUMER_INVALID and SERVICE_CONFIG_NOT_FOUND_OR_PERMISSION_DENIED; the Service Usage dependency-conflict text. +- Search defaults: ACTIVE-only results unless the query names a state, AND for juxtaposed folder terms, case-insensitive folder name uniqueness, and the restricted project id substrings beyond google and ssl are unverified. +- Folder display name minimum (2 or 3 characters) conflicts between the Folder field comment and the UpdateFolder method comment in the same SDK. +- Project creation is instantaneous (operations are done when returned); real creation takes seconds and the operation can fail asynchronously. No quota (projects.create) or billing-account behaviour. +- Not emulated: organizations other than the one configured, organization policies, project liens, tag holds, ListEffectiveTags, folders and projects as tag-binding targets are accepted but tags are not inherited, v1beta1 Service Usage, Service Usage consumer quota and admin overrides, usage checks for check_if_service_has_usage, GetIamPolicy audit configs. +- Tag keys, tag values and bindings of a purged project or folder are not removed with it. +- The v1 Operation JSON carries the v3 Project in its response Any; the v1 response type name is unverified (Terraform only reads done and error). +- Unlike Google, a purged project id can be created again. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/gcpresourcemanager/backend.go b/services/gcpresourcemanager/backend.go new file mode 100644 index 0000000000..6a39ef4cf7 --- /dev/null +++ b/services/gcpresourcemanager/backend.go @@ -0,0 +1,152 @@ +package gcpresourcemanager + +import ( + "crypto/rand" + "strings" + "sync/atomic" + "time" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpiampolicy" + "github.com/blackbirdworks/gopherstack/pkgs/gcplro" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + defaultListPage = 100 + numberDigits = 12 + tagIDDigits = 15 + maxIDTries = 64 + opIDDigits = 16 +) + +// InMemoryBackend holds Resource Manager and Service Usage state under one lock. +type InMemoryBackend struct { + clock atomic.Pointer[func() time.Time] + mu *lockmetrics.RWMutex + org orgRec + projects map[string]*projectRec + folders map[string]*folderRec + tagKeys map[string]*tagKeyRec + tagValues map[string]*tagValueRec + bindings map[string]*bindingRec + enabled map[string]map[string]time.Time + policies *gcpiampolicy.Store + lro *gcplro.Store + seed string + retention time.Duration +} + +// NewInMemoryBackend creates a backend for the default organization, with no projects. +func NewInMemoryBackend() *InMemoryBackend { return NewBackend(DefaultSettings()) } + +// NewBackend creates a backend with the organization and seed projects of s. +func NewBackend(s Settings) *InMemoryBackend { + b := &InMemoryBackend{ + mu: lockmetrics.New("gcpresourcemanager"), + policies: gcpiampolicy.NewStore(), + lro: gcplro.New("gcpresourcemanager"), + retention: s.DeleteRetention, + seed: s.SeedProjects, + } + b.SetClock(time.Now) + b.init(s) + + return b +} + +func (b *InMemoryBackend) init(s Settings) { + b.org = orgRec{ID: s.OrganizationID, Domain: s.Domain, Created: b.now()} + b.projects, b.folders = map[string]*projectRec{}, map[string]*folderRec{} + b.tagKeys, b.tagValues = map[string]*tagKeyRec{}, map[string]*tagValueRec{} + b.bindings, b.enabled = map[string]*bindingRec{}, map[string]map[string]time.Time{} + + for id := range strings.SplitSeq(s.SeedProjects, ",") { + if id = strings.TrimSpace(id); id != "" { + b.createProjectLocked(id, id, "", nil) + } + } +} + +// SetClock replaces the clock timestamps and the deletion retention derive from. +func (b *InMemoryBackend) SetClock(now func() time.Time) { b.clock.Store(&now) } + +func (b *InMemoryBackend) now() time.Time { return (*b.clock.Load())().UTC() } + +// Reset restores the seeded state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + defer b.mu.Unlock() + + seed := Settings{OrganizationID: b.org.ID, Domain: b.org.Domain, SeedProjects: b.seedIDsLocked()} + b.policies.Reset() + b.lro.Reset() + b.init(seed) +} + +func (b *InMemoryBackend) seedIDsLocked() string { + return b.seed +} + +func randDigits(n int) string { + raw := make([]byte, n) + _, _ = rand.Read(raw) + + out := make([]byte, n) + for i, c := range raw { + out[i] = '0' + c%10 //nolint:mnd // decimal digit + } + + if out[0] == '0' { + out[0] = '1' + } + + return string(out) +} + +func uniqueDigits(n int, taken func(string) bool) string { + for range maxIDTries { + if id := randDigits(n); !taken(id) { + return id + } + } + + return randDigits(n + 1) +} + +// opLocked records a finished long-running operation. Mutations are applied synchronously, so every +// operation is done when it is returned. +func (b *InMemoryBackend) opLocked(prefix string, meta, resp proto.Message) (*longrunningpb.Operation, error) { + op, err := gcplro.Done("operations/"+prefix+"."+randDigits(opIDDigits), meta, resp) + if err != nil { + return nil, errInvalid("record operation: %v", err) + } + + return b.lro.Put(op), nil +} + +// purgeLocked drops projects and folders whose deletion retention has passed. +func (b *InMemoryBackend) purgeLocked() { + now := b.now() + + for n, p := range b.projects { + if p.requested() && !now.Before(p.Deleted.Add(b.retention)) { + b.dropProjectLocked(n) + } + } + + for id, f := range b.folders { + if f.requested() && !now.Before(f.Deleted.Add(b.retention)) { + delete(b.folders, id) + b.policies.Drop(kindFolders + "/" + id) + } + } +} + +func (b *InMemoryBackend) dropProjectLocked(number string) { + delete(b.projects, number) + delete(b.enabled, number) + b.policies.Drop(kindProjects + "/" + number) +} diff --git a/services/gcpresourcemanager/billing.go b/services/gcpresourcemanager/billing.go new file mode 100644 index 0000000000..ec22e127e7 --- /dev/null +++ b/services/gcpresourcemanager/billing.go @@ -0,0 +1,52 @@ +package gcpresourcemanager + +import "regexp" + +var billingAccountRE = regexp.MustCompile(`^billingAccounts/[0-9A-Za-z]{6}-[0-9A-Za-z]{6}-[0-9A-Za-z]{6}$`) + +func (p *projectRec) billing() billingInfo { + return billingInfo{ + Name: kindProjects + "/" + p.ID + "/billingInfo", ProjectID: p.ID, + BillingAccountName: p.Billing, BillingEnabled: p.Billing != "", + } +} + +// BillingInfo returns the Cloud Billing association of a project (billingAccountName is empty when none). +func (b *InMemoryBackend) BillingInfo(ref string) (billingInfo, error) { + b.mu.Lock("BillingInfo") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.projectLocked(ref, "billing.resourceAssociations.list") + if err != nil { + return billingInfo{}, err + } + + return p.billing(), nil +} + +// SetBillingAccount links a project to a billing account, or unlinks it when account is empty. Billing accounts +// are not modelled, so any well-formed name is accepted. +func (b *InMemoryBackend) SetBillingAccount(ref, account string) (billingInfo, error) { + if account != "" && !billingAccountRE.MatchString(account) { + return billingInfo{}, errInvalid( + "Invalid billing account %q: use billingAccounts/XXXXXX-XXXXXX-XXXXXX.", + account, + ) + } + + b.mu.Lock("SetBillingAccount") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.activeProjectLocked(ref, "billing.resourceAssociations.create") + if err != nil { + return billingInfo{}, err + } + + p.Billing = account + + return p.billing(), nil +} diff --git a/services/gcpresourcemanager/catalog.go b/services/gcpresourcemanager/catalog.go new file mode 100644 index 0000000000..867d78a920 --- /dev/null +++ b/services/gcpresourcemanager/catalog.go @@ -0,0 +1,286 @@ +package gcpresourcemanager + +import "slices" + +const ( + svcArtifactRegistry = "artifactregistry.googleapis.com" + svcCompute = "compute.googleapis.com" +) + +// catalogEntry is one Google API a project can enable. +type catalogEntry struct { + name string + title string + summary string + depends []string + // defaultOn marks the services every API-created project starts with. + defaultOn bool +} + +// catalog lists the Google APIs the emulator knows: one per GCP emulator service plus the common ones Terraform +// enables. Names and titles follow the public Service Usage listing; dependencies are the few that gate other +// services here (container needs compute, functions need build and registry, ...). +// +//nolint:gochecknoglobals // immutable catalog +var catalog = []catalogEntry{ + { + name: "apigateway.googleapis.com", title: "API Gateway API", + summary: "Create and manage API gateways.", + depends: []string{"servicemanagement.googleapis.com", "servicecontrol.googleapis.com"}, + }, + { + name: svcArtifactRegistry, title: "Artifact Registry API", + summary: "Store and manage build artifacts and container images.", + }, + { + name: "batch.googleapis.com", title: "Batch API", + summary: "Run batch jobs on Google Cloud.", + depends: []string{svcCompute}, + }, + { + name: "bigquery.googleapis.com", title: "BigQuery API", + summary: "A data platform for customers to create, manage, share and query data.", + defaultOn: true, + }, + { + name: "bigquerymigration.googleapis.com", title: "BigQuery Migration API", + summary: "Migrate data warehouses to BigQuery.", + defaultOn: true, + }, + { + name: "bigquerystorage.googleapis.com", title: "BigQuery Storage API", + summary: "Read and write BigQuery table data at scale.", + defaultOn: true, + }, + { + name: "bigtable.googleapis.com", title: "Cloud Bigtable API", + summary: "Data API for Cloud Bigtable.", + }, + { + name: "bigtableadmin.googleapis.com", title: "Cloud Bigtable Admin API", + summary: "Administer Cloud Bigtable tables and instances.", + }, + { + name: "cloudapis.googleapis.com", title: "Google Cloud APIs", + summary: "Umbrella for Google Cloud APIs.", + defaultOn: true, + }, + { + name: "cloudasset.googleapis.com", title: "Cloud Asset API", + summary: "Inventory and history of Google Cloud resources.", + }, + { + name: "cloudbilling.googleapis.com", title: "Cloud Billing API", + summary: "Manage billing for Google Cloud projects.", + }, + { + name: "cloudbuild.googleapis.com", title: "Cloud Build API", + summary: "Build, test and deploy software.", + depends: []string{svcArtifactRegistry}, + }, + { + name: "cloudfunctions.googleapis.com", title: "Cloud Functions API", + summary: "Manage lightweight user-provided functions.", + depends: []string{"cloudbuild.googleapis.com", svcArtifactRegistry}, + }, + { + name: "cloudkms.googleapis.com", title: "Cloud Key Management Service (KMS) API", + summary: "Manage encryption keys on Google Cloud.", + }, + { + name: "cloudresourcemanager.googleapis.com", title: "Cloud Resource Manager API", + summary: "Create, read and update metadata for Google Cloud resource containers.", + }, + { + name: "cloudscheduler.googleapis.com", title: "Cloud Scheduler API", + summary: "Create and manage cron jobs.", + }, + { + name: "cloudtasks.googleapis.com", title: "Cloud Tasks API", + summary: "Manage distributed task queues.", + }, + { + name: "cloudtrace.googleapis.com", title: "Cloud Trace API", + summary: "Send and retrieve latency data.", + defaultOn: true, + }, + { + name: "clouddebugger.googleapis.com", title: "Cloud Debugger API", + summary: "Examine the call stack and variables of a running application.", + defaultOn: true, + }, + { + name: svcCompute, title: "Compute Engine API", + summary: "Create and run virtual machines on Google Cloud.", + }, + { + name: "container.googleapis.com", title: "Kubernetes Engine API", + summary: "Build and manage container-based applications.", + depends: []string{svcCompute}, + }, + { + name: "containerregistry.googleapis.com", title: "Container Registry API", + summary: "Store and manage container images.", + }, + { + name: "datastore.googleapis.com", title: "Cloud Datastore API", + summary: "Store schemaless NoSQL data.", + defaultOn: true, + }, + { + name: "dns.googleapis.com", title: "Cloud DNS API", + summary: "Manage DNS zones and records.", + }, + { + name: "eventarc.googleapis.com", title: "Eventarc API", + summary: "Build event-driven applications.", + }, + { + name: "firestore.googleapis.com", title: "Cloud Firestore API", + summary: "Store and sync data in a document database.", + }, + { + name: "iam.googleapis.com", title: "Identity and Access Management (IAM) API", + summary: "Manage identity and access control for Google Cloud resources.", + }, + { + name: "iamcredentials.googleapis.com", title: "IAM Service Account Credentials API", + summary: "Create short-lived credentials for service accounts.", + depends: []string{"iam.googleapis.com"}, + }, + { + name: "identitytoolkit.googleapis.com", title: "Identity Toolkit API", + summary: "Toolkit for authenticating users of an app.", + }, + { + name: "logging.googleapis.com", title: "Cloud Logging API", + summary: "Write log entries and manage logging configuration.", + defaultOn: true, + }, + { + name: "monitoring.googleapis.com", title: "Cloud Monitoring API", + summary: "Manage monitoring data and configuration.", + defaultOn: true, + }, + { + name: "networkservices.googleapis.com", title: "Network Services API", + summary: "Configure network services.", + }, + { + name: "orgpolicy.googleapis.com", title: "Organization Policy API", + summary: "Manage organization policies.", + }, + { + name: "pubsub.googleapis.com", title: "Cloud Pub/Sub API", + summary: "Ingest event streams and deliver them to subscribers.", + }, + { + name: "redis.googleapis.com", title: "Google Cloud Memorystore for Redis API", + summary: "Manage Redis instances.", + }, + { + name: "run.googleapis.com", title: "Cloud Run Admin API", + summary: "Deploy and manage user-provided container images.", + }, + { + name: "secretmanager.googleapis.com", title: "Secret Manager API", + summary: "Store sensitive data such as API keys and passwords.", + }, + { + name: "servicecontrol.googleapis.com", title: "Service Control API", + summary: "Report and check service usage.", + }, + { + name: "servicemanagement.googleapis.com", title: "Service Management API", + summary: "Manage Google API service configurations.", + defaultOn: true, + }, + { + name: "servicenetworking.googleapis.com", title: "Service Networking API", + summary: "Provide private connectivity to managed services.", + }, + { + name: "serviceusage.googleapis.com", title: "Service Usage API", + summary: "Enable and disable Google services on a project.", + defaultOn: true, + }, + { + name: "spanner.googleapis.com", title: "Cloud Spanner API", + summary: "Manage Cloud Spanner instances and databases.", + }, + { + name: "sql-component.googleapis.com", title: "Cloud SQL", + summary: "Cloud SQL component.", + defaultOn: true, + }, + { + name: "sqladmin.googleapis.com", title: "Cloud SQL Admin API", + summary: "Manage Cloud SQL databases.", + depends: []string{"sql-component.googleapis.com"}, + }, + { + name: "storage-api.googleapis.com", title: "Google Cloud Storage JSON API", + summary: "Lets you store and retrieve potentially-large, immutable data objects.", + defaultOn: true, + }, + { + name: "storage-component.googleapis.com", title: "Cloud Storage", + summary: "Google Cloud Storage component.", + defaultOn: true, + }, + { + name: "storage.googleapis.com", title: "Cloud Storage API", + summary: "Store and retrieve data objects.", + defaultOn: true, + }, + { + name: "sts.googleapis.com", title: "Security Token Service API", + summary: "Exchange credentials for short-lived access tokens.", + }, + { + name: "vpcaccess.googleapis.com", title: "Serverless VPC Access API", + summary: "Connect serverless services to a VPC network.", + }, + { + name: "workflows.googleapis.com", title: "Workflows API", + summary: "Orchestrate and automate Google Cloud and HTTP-based API services.", + }, + { + name: "workflowexecutions.googleapis.com", title: "Workflow Executions API", + summary: "Execute workflows.", + }, +} + +func lookupService(name string) (catalogEntry, bool) { + i := slices.IndexFunc(catalog, func(e catalogEntry) bool { return e.name == name }) + if i < 0 { + return catalogEntry{}, false + } + + return catalog[i], true +} + +// dependentsOf lists the entries that declare name as a dependency. +func dependentsOf(name string) []string { + var out []string + + for _, e := range catalog { + if slices.Contains(e.depends, name) { + out = append(out, e.name) + } + } + + return out +} + +// CatalogServices returns the googleapis.com names of every service the emulator can enable, sorted. +func CatalogServices() []string { + out := make([]string, 0, len(catalog)) + for _, e := range catalog { + out = append(out, e.name) + } + + slices.Sort(out) + + return out +} diff --git a/services/gcpresourcemanager/enforce.go b/services/gcpresourcemanager/enforce.go new file mode 100644 index 0000000000..90356163d7 --- /dev/null +++ b/services/gcpresourcemanager/enforce.go @@ -0,0 +1,37 @@ +package gcpresourcemanager + +import ( + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" +) + +var _ gcpenforce.Enforcer = (*InMemoryBackend)(nil) + +// CheckProject returns CONSUMER_INVALID for service unless project (id or number) exists and is ACTIVE. +func (b *InMemoryBackend) CheckProject(project, service string) error { + b.mu.RLock("CheckProject") + defer b.mu.RUnlock() + + p, ok := b.findProjectLocked(project) + if !ok || p.requested() { + return errConsumerFor(kindProjects+"/"+strings.TrimPrefix(project, kindProjects+"/"), service) + } + + return nil +} + +// SetEnforcement turns the opt-in project and API-enablement checks of the other GCP services on or off. +func (h *Handler) SetEnforcement(s gcpenforce.Settings) { h.enforce = s } + +// Enforcement returns the active enforcement switches. +func (h *Handler) Enforcement() gcpenforce.Settings { return h.enforce } + +// EnforcementGate returns the Gate the other GCP services consult, or nil when enforcement is off. +func (h *Handler) EnforcementGate() *gcpenforce.Gate { + if !h.enforce.Enabled() { + return nil + } + + return &gcpenforce.Gate{Enforcer: h.Backend, Settings: h.enforce} +} diff --git a/services/gcpresourcemanager/enforce_test.go b/services/gcpresourcemanager/enforce_test.go new file mode 100644 index 0000000000..68a7311025 --- /dev/null +++ b/services/gcpresourcemanager/enforce_test.go @@ -0,0 +1,68 @@ +package gcpresourcemanager_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +func TestCheckProject(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + tests := []struct { + name string + project string + wantErr bool + }{ + {"id", seedID, false}, + {"projects prefix", "projects/" + seedID, false}, + {"unknown", "nope-nope", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := e.backend.CheckProject(tt.project, "pubsub.googleapis.com") + if !tt.wantErr { + require.NoError(t, err) + + return + } + + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + + for _, d := range status.Convert(err).Details() { + if i, ok := d.(*errdetails.ErrorInfo); ok { + assert.Equal(t, "CONSUMER_INVALID", i.GetReason()) + assert.Equal(t, "pubsub.googleapis.com", i.GetMetadata()["service"]) + + return + } + } + + require.Fail(t, "no ErrorInfo") + }) + } +} + +func TestEnforcementGateFollowsSettings(t *testing.T) { + t.Parallel() + + h := gcpresourcemanager.NewHandler(gcpresourcemanager.NewInMemoryBackend()) + assert.Nil(t, h.EnforcementGate()) + + h.SetEnforcement(gcpenforce.Settings{Projects: true}) + g := h.EnforcementGate() + require.NotNil(t, g) + assert.True(t, g.Settings.Projects) +} diff --git a/services/gcpresourcemanager/errors.go b/services/gcpresourcemanager/errors.go new file mode 100644 index 0000000000..2ebaf8bcd2 --- /dev/null +++ b/services/gcpresourcemanager/errors.go @@ -0,0 +1,63 @@ +package gcpresourcemanager + +import ( + "fmt" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + apiHost = "cloudresourcemanager.googleapis.com" + reasonDenied = "IAM_PERMISSION_DENIED" + domainIAM = "iam.googleapis.com" + violationType = "FOLDER_OPERATION" +) + +func errInvalid(format string, a ...any) error { + return status.Error(codes.InvalidArgument, fmt.Sprintf(format, a...)) +} + +func errPrecondition(format string, a ...any) error { + return status.Error(codes.FailedPrecondition, fmt.Sprintf(format, a...)) +} + +func errExists(format string, a ...any) error { + return status.Error(codes.AlreadyExists, fmt.Sprintf(format, a...)) +} + +func errAborted(format string, a ...any) error { + return status.Error(codes.Aborted, fmt.Sprintf(format, a...)) +} + +// errDenied is what GCP answers for a resource that does not exist, so callers cannot probe for it. +func errDenied(permission, resource string) error { + st := status.New(codes.PermissionDenied, fmt.Sprintf( + "Permission '%s' denied on resource '//%s/%s' (or it may not exist).", permission, apiHost, resource)) + + withInfo, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: reasonDenied, Domain: domainIAM, + Metadata: map[string]string{"permission": permission, "resource": resource}, + }) + if err != nil { + return st.Err() + } + + return withInfo.Err() +} + +func preconditionFailure(msg, subject, desc string) error { + st := status.New(codes.FailedPrecondition, msg) + + withDetail, err := st.WithDetails(&errdetails.PreconditionFailure{ + Violations: []*errdetails.PreconditionFailure_Violation{ + {Type: violationType, Subject: subject, Description: desc}, + }, + }) + if err != nil { + return st.Err() + } + + return withDetail.Err() +} diff --git a/services/gcpresourcemanager/etag.go b/services/gcpresourcemanager/etag.go new file mode 100644 index 0000000000..e713c69823 --- /dev/null +++ b/services/gcpresourcemanager/etag.go @@ -0,0 +1,33 @@ +package gcpresourcemanager + +import ( + "crypto/sha256" + "encoding/base64" + "slices" + "strings" + "time" +) + +const etagBytes = 12 + +// etagOf is a checksum of the fields a client can change, so a stale etag is detectable. +func etagOf(id, parent, name string, updated time.Time, labels map[string]string) string { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + + slices.Sort(keys) + + var sb strings.Builder + + sb.WriteString(id + "\x00" + parent + "\x00" + name + "\x00" + updated.UTC().Format(time.RFC3339Nano)) + + for _, k := range keys { + sb.WriteString("\x00" + k + "=" + labels[k]) + } + + sum := sha256.Sum256([]byte(sb.String())) + + return base64.RawURLEncoding.EncodeToString(sum[:etagBytes]) +} diff --git a/services/gcpresourcemanager/folders.go b/services/gcpresourcemanager/folders.go new file mode 100644 index 0000000000..979d4b1663 --- /dev/null +++ b/services/gcpresourcemanager/folders.go @@ -0,0 +1,468 @@ +package gcpresourcemanager + +import ( + "cmp" + "slices" + "strings" + "time" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + permFolderGet = "resourcemanager.folders.get" + permFolderList = "resourcemanager.folders.list" +) + +func (b *InMemoryBackend) folderLocked(name, perm string) (*folderRec, error) { + id, ok := splitName(name, kindFolders) + if !ok { + return nil, errInvalid("Invalid folder name %q: use folders/ID.", name) + } + + f, found := b.folders[id] + if !found { + return nil, errDenied(perm, name) + } + + return f, nil +} + +// restorableParentLocked checks that a restored resource's parent is still active. +func (b *InMemoryBackend) restorableParentLocked(parent string) error { + if f, ok := b.folders[strings.TrimPrefix(parent, kindFolders+"/")]; strings.HasPrefix(parent, kindFolders+"/") { + if !ok { + return errPrecondition("The parent %s no longer exists.", parent) + } + + if f.requested() { + return errPrecondition("The parent %s is in the DELETE_REQUESTED state.", parent) + } + } + + return nil +} + +// depthLocked is the number of folders on the path from the organization down to and including parent. +func (b *InMemoryBackend) depthLocked(parent string) int { + depth := 0 + + for strings.HasPrefix(parent, kindFolders+"/") && depth <= 2*maxFolderDepth { + f, ok := b.folders[strings.TrimPrefix(parent, kindFolders+"/")] + if !ok { + break + } + + depth++ + parent = f.Parent + } + + return depth +} + +// heightLocked is the height of the active folder subtree rooted at id, counting id itself. +func (b *InMemoryBackend) heightLocked(id string) int { + height := 1 + + for cid, c := range b.folders { + if c.Parent == kindFolders+"/"+id && !c.requested() { + height = max(height, 1+b.heightLocked(cid)) + } + } + + return height +} + +func (b *InMemoryBackend) activeChildrenLocked(parent string) ([]*folderRec, int) { + var folders []*folderRec + + projects := 0 + + for _, f := range b.folders { + if f.Parent == parent && !f.requested() { + folders = append(folders, f) + } + } + + for _, p := range b.projects { + if p.Parent == parent && !p.requested() { + projects++ + } + } + + return folders, projects +} + +// placeLocked checks that a folder named name with a subtree of the given height may sit under parent. +func (b *InMemoryBackend) placeLocked(parent, name, self string, height int) error { + siblings, _ := b.activeChildrenLocked(parent) + + if len(siblings) >= maxFolderFanout && + !slices.ContainsFunc(siblings, func(f *folderRec) bool { return f.ID == self }) { + return preconditionFailure("Folder fanout limit reached.", parent, + "A parent can hold at most 300 folders.") + } + + if slices.ContainsFunc( + siblings, + func(f *folderRec) bool { return f.ID != self && strs.Equal(f.DisplayName, name) }, + ) { + return preconditionFailure("Folder display name is not unique under its parent.", parent, + "The display name "+name+" is already used by a sibling folder.") + } + + if b.depthLocked(parent)+height > maxFolderDepth { + return preconditionFailure("Folder height limit exceeded.", parent, + "The active folder hierarchy cannot exceed a height of 10.") + } + + return nil +} + +// CreateFolder creates a folder under the organization or another folder. +func (b *InMemoryBackend) CreateFolder(in *resourcemanagerpb.CreateFolderRequest) (*longrunningpb.Operation, error) { + fo := in.GetFolder() + if fo == nil || fo.GetParent() == "" { + return nil, errInvalid("folder.parent must be specified.") + } + + if err := validateFolderName(fo.GetDisplayName()); err != nil { + return nil, err + } + + b.mu.Lock("CreateFolder") + defer b.mu.Unlock() + + b.purgeLocked() + + if strings.HasPrefix(fo.GetParent(), kindProjects+"/") { + return nil, errInvalid("A folder's parent must be an organization or a folder.") + } + + if err := b.parentLocked(fo.GetParent(), "resourcemanager.folders.create"); err != nil { + return nil, err + } + + if err := b.placeLocked(fo.GetParent(), fo.GetDisplayName(), "", 1); err != nil { + return nil, err + } + + now := b.now() + id := uniqueDigits(numberDigits, func(n string) bool { + _, ok := b.folders[n] + + return ok + }) + f := &folderRec{ + ID: id, Parent: fo.GetParent(), DisplayName: fo.GetDisplayName(), + Created: now, Updated: now, + } + b.folders[id] = f + + return b.opLocked("cf", &resourcemanagerpb.CreateFolderMetadata{ + DisplayName: f.DisplayName, Parent: f.Parent, + }, f.view()) +} + +// GetFolder returns a folder, including one that is DELETE_REQUESTED. +func (b *InMemoryBackend) GetFolder(name string) (*resourcemanagerpb.Folder, error) { + b.mu.Lock("GetFolder") + defer b.mu.Unlock() + + b.purgeLocked() + + f, err := b.folderLocked(name, permFolderGet) + if err != nil { + return nil, err + } + + return f.view(), nil +} + +func folderViews(fs []*folderRec) []*resourcemanagerpb.Folder { + slices.SortFunc(fs, func(a, c *folderRec) int { + return cmp.Or(cmp.Compare(a.DisplayName, c.DisplayName), cmp.Compare(a.ID, c.ID)) + }) + + out := make([]*resourcemanagerpb.Folder, 0, len(fs)) + for _, f := range fs { + out = append(out, f.view()) + } + + return out +} + +// ListFolders lists the direct child folders of an organization or folder, ordered by display name. +func (b *InMemoryBackend) ListFolders(in *resourcemanagerpb.ListFoldersRequest) ( + *resourcemanagerpb.ListFoldersResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + if in.GetParent() == "" { + return nil, errInvalid("parent must be specified.") + } + + b.mu.Lock("ListFolders") + b.purgeLocked() + + if err := b.parentLocked(in.GetParent(), permFolderList); err != nil { + b.mu.Unlock() + + return nil, err + } + + var hits []*folderRec + + for _, f := range b.folders { + if f.Parent == in.GetParent() && (in.GetShowDeleted() || !f.requested()) { + hits = append(hits, f) + } + } + + out := folderViews(hits) + b.mu.Unlock() + + pg := page.New(out, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.ListFoldersResponse{Folders: pg.Data, NextPageToken: pg.Next}, nil +} + +func (f *folderRec) fields(name string) ([]string, bool) { + switch name { + case fieldDisplayName: + return []string{f.DisplayName}, true + case "parent": + return []string{f.Parent}, true + case fieldState: + if f.requested() { + return []string{stateRequested}, true + } + + return []string{stateActive}, true + case "id": + return []string{f.ID}, true + case fieldLabels: + return nil, true + default: + return nil, false + } +} + +// SearchFolders returns the folders matching the query; ACTIVE ones unless the query names a state. +func (b *InMemoryBackend) SearchFolders(in *resourcemanagerpb.SearchFoldersRequest) ( + *resourcemanagerpb.SearchFoldersResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + q, err := parseQuery(in.GetQuery(), true) + if err != nil { + return nil, err + } + + b.mu.Lock("SearchFolders") + b.purgeLocked() + + var hits []*folderRec + + for _, f := range b.folders { + ok, mErr := q.match(f.fields) + if mErr != nil { + b.mu.Unlock() + + return nil, mErr + } + + if ok && (q.hasState || !f.requested()) { + hits = append(hits, f) + } + } + + out := folderViews(hits) + b.mu.Unlock() + + pg := page.New(out, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.SearchFoldersResponse{Folders: pg.Data, NextPageToken: pg.Next}, nil +} + +func (b *InMemoryBackend) activeFolderLocked(name, perm string) (*folderRec, error) { + f, err := b.folderLocked(name, perm) + if err != nil { + return nil, err + } + + if f.requested() { + return nil, errPrecondition("Folder %s is in the DELETE_REQUESTED state.", name) + } + + return f, nil +} + +// UpdateFolder renames an ACTIVE folder. +func (b *InMemoryBackend) UpdateFolder(in *resourcemanagerpb.UpdateFolderRequest) (*longrunningpb.Operation, error) { + fo := in.GetFolder() + if fo == nil || fo.GetName() == "" { + return nil, errInvalid("folder.name must be specified.") + } + + if err := checkMask(in.GetUpdateMask(), "display_name"); err != nil { + return nil, err + } + + b.mu.Lock("UpdateFolder") + defer b.mu.Unlock() + + b.purgeLocked() + + f, err := b.activeFolderLocked(fo.GetName(), "resourcemanager.folders.update") + if err != nil { + return nil, err + } + + if et := fo.GetEtag(); et != "" && et != f.view().GetEtag() { + return nil, errAborted( + "The folder %s was modified since it was read; re-read it and retry (etag mismatch).", + fo.GetName(), + ) + } + + if maskHas(in.GetUpdateMask(), "display_name") && fo.GetDisplayName() != "" { + if err = validateFolderName(fo.GetDisplayName()); err != nil { + return nil, err + } + + if err = b.placeLocked(f.Parent, fo.GetDisplayName(), f.ID, 1); err != nil { + return nil, err + } + + f.DisplayName = fo.GetDisplayName() + } + + f.Updated = b.now() + + return b.opLocked("uf", &resourcemanagerpb.UpdateFolderMetadata{}, f.view()) +} + +func (b *InMemoryBackend) isDescendantLocked(parent, id string) bool { + for range 2 * maxFolderDepth { + if parent == kindFolders+"/"+id { + return true + } + + f, ok := b.folders[strings.TrimPrefix(parent, kindFolders+"/")] + if !ok { + return false + } + + parent = f.Parent + } + + return false +} + +// MoveFolder re-parents an ACTIVE folder under the organization or another active folder. +func (b *InMemoryBackend) MoveFolder(in *resourcemanagerpb.MoveFolderRequest) (*longrunningpb.Operation, error) { + dest := in.GetDestinationParent() + if dest == "" || strings.HasPrefix(dest, kindProjects+"/") { + return nil, errInvalid("destination_parent must be an organization or a folder.") + } + + b.mu.Lock("MoveFolder") + defer b.mu.Unlock() + + b.purgeLocked() + + f, err := b.activeFolderLocked(in.GetName(), "resourcemanager.folders.move") + if err != nil { + return nil, err + } + + if err = b.parentLocked(dest, "resourcemanager.folders.move"); err != nil { + return nil, err + } + + if b.isDescendantLocked(dest, f.ID) { + return nil, errPrecondition("A folder cannot be moved into itself or one of its descendants.") + } + + if err = b.placeLocked(dest, f.DisplayName, f.ID, b.heightLocked(f.ID)); err != nil { + return nil, err + } + + source := f.Parent + f.Parent, f.Updated = dest, b.now() + + return b.opLocked("mf", &resourcemanagerpb.MoveFolderMetadata{ + DisplayName: f.DisplayName, SourceParent: source, DestinationParent: dest, + }, f.view()) +} + +// DeleteFolder marks an empty folder DELETE_REQUESTED; deleting a requested folder is a no-op. +func (b *InMemoryBackend) DeleteFolder(in *resourcemanagerpb.DeleteFolderRequest) (*longrunningpb.Operation, error) { + b.mu.Lock("DeleteFolder") + defer b.mu.Unlock() + + b.purgeLocked() + + f, err := b.folderLocked(in.GetName(), "resourcemanager.folders.delete") + if err != nil { + return nil, err + } + + if !f.requested() { + folders, projects := b.activeChildrenLocked(in.GetName()) + if len(folders) > 0 || projects > 0 { + return nil, preconditionFailure("Folder is not empty.", in.GetName(), + "A folder can be deleted only when it holds no ACTIVE folders or projects.") + } + + now := b.now() + f.Deleted, f.Updated = now, now + } + + view := f.view() + + if b.retention <= 0 { + delete(b.folders, f.ID) + b.policies.Drop(in.GetName()) + } + + return b.opLocked("df", &resourcemanagerpb.DeleteFolderMetadata{}, view) +} + +// UndeleteFolder restores a DELETE_REQUESTED folder; an ACTIVE folder is a no-op success. +func (b *InMemoryBackend) UndeleteFolder(in *resourcemanagerpb.UndeleteFolderRequest) ( + *longrunningpb.Operation, error, +) { + b.mu.Lock("UndeleteFolder") + defer b.mu.Unlock() + + b.purgeLocked() + + f, err := b.folderLocked(in.GetName(), "resourcemanager.folders.undelete") + if err != nil { + return nil, err + } + + if f.requested() { + if err = b.restorableParentLocked(f.Parent); err != nil { + return nil, err + } + + if err = b.placeLocked(f.Parent, f.DisplayName, f.ID, b.heightLocked(f.ID)); err != nil { + return nil, err + } + + f.Deleted, f.Updated = time.Time{}, b.now() + } + + return b.opLocked("uf", &resourcemanagerpb.UndeleteFolderMetadata{}, f.view()) +} diff --git a/services/gcpresourcemanager/folders_test.go b/services/gcpresourcemanager/folders_test.go new file mode 100644 index 0000000000..85c0b8e6fb --- /dev/null +++ b/services/gcpresourcemanager/folders_test.go @@ -0,0 +1,318 @@ +package gcpresourcemanager_test + +import ( + "fmt" + "strings" + "testing" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +func TestFolderNameValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + code codes.Code + }{ + {"plain", "Engineering", codes.OK}, + {"with space hyphen underscore", "Team a_b-c", codes.OK}, + {"three chars", "abc", codes.OK}, + {"two chars", "ab", codes.InvalidArgument}, + {"empty", "", codes.InvalidArgument}, + {"thirty chars", strings.Repeat("a", 30), codes.OK}, + {"thirty one chars", strings.Repeat("a", 31), codes.InvalidArgument}, + {"leading space", " abc", codes.InvalidArgument}, + {"trailing hyphen", "abc-", codes.InvalidArgument}, + {"punctuation", "abc!def", codes.InvalidArgument}, + {"unicode letters", "Équipe 1", codes.OK}, + } + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + op, err := c.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: orgName, DisplayName: tt.in}, + }) + if err == nil { + _, err = op.Wait(t.Context()) + } + + assert.Equal(t, tt.code, codeOf(err), "%v", err) + }) + } + }) +} + +func TestFolderLifecycle(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + f := c.createFolder(t, orgName, "Platform") + assert.Regexp(t, `^folders/\d{12}$`, f.GetName()) + assert.Equal(t, resourcemanagerpb.Folder_ACTIVE, f.GetState()) + + got, err := c.folders.GetFolder(t.Context(), &resourcemanagerpb.GetFolderRequest{Name: f.GetName()}) + require.NoError(t, err) + assert.Equal(t, "Platform", got.GetDisplayName()) + + _ = c.createFolder(t, orgName, "Other") + + _, err = c.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: orgName, DisplayName: "platform"}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "display names are unique among siblings, ignoring case") + + up, err := c.folders.UpdateFolder(t.Context(), &resourcemanagerpb.UpdateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Name: f.GetName(), DisplayName: "Platform 2"}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name"}}, + }) + require.NoError(t, err) + + renamed, err := up.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, "Platform 2", renamed.GetDisplayName()) + + _, err = c.folders.UpdateFolder(t.Context(), &resourcemanagerpb.UpdateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Name: f.GetName(), DisplayName: "Other"}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name"}}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "rename to a sibling's name") + + _, err = c.folders.UpdateFolder(t.Context(), &resourcemanagerpb.UpdateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Name: f.GetName(), DisplayName: "x1", Etag: "stale"}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name"}}, + }) + assert.Equal(t, codes.Aborted, codeOf(err)) + + list := c.folders.ListFolders(t.Context(), &resourcemanagerpb.ListFoldersRequest{Parent: orgName}) + + var names []string + + for fo, lerr := list.Next(); lerr == nil; fo, lerr = list.Next() { + names = append(names, fo.GetDisplayName()) + } + + assert.Equal(t, []string{"Other", "Platform 2"}, names) + + search := c.folders.SearchFolders( + t.Context(), + &resourcemanagerpb.SearchFoldersRequest{Query: "displayName=Plat*"}, + ) + hit, err := search.Next() + require.NoError(t, err) + assert.Equal(t, f.GetName(), hit.GetName()) + }) +} + +func TestFolderDeleteUndelete(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + parent := c.createFolder(t, orgName, "Parent") + child := c.createFolder(t, parent.GetName(), "Child") + proj := c.createProject(t, "in-folder", child.GetName()) + + del := func(name string) error { + op, err := c.folders.DeleteFolder(t.Context(), &resourcemanagerpb.DeleteFolderRequest{Name: name}) + if err != nil { + return err + } + + _, err = op.Wait(t.Context()) + + return err + } + + assert.Equal(t, codes.FailedPrecondition, codeOf(del(parent.GetName())), "folder holds a folder") + assert.Equal(t, codes.FailedPrecondition, codeOf(del(child.GetName())), "folder holds a project") + + dp, err := c.projects.DeleteProject(t.Context(), &resourcemanagerpb.DeleteProjectRequest{Name: proj.GetName()}) + require.NoError(t, err) + + _, err = dp.Wait(t.Context()) + require.NoError(t, err) + + require.NoError(t, del(child.GetName())) + require.NoError(t, del(child.GetName()), "deleting a DELETE_REQUESTED folder is a no-op") + + got, err := c.folders.GetFolder(t.Context(), &resourcemanagerpb.GetFolderRequest{Name: child.GetName()}) + require.NoError(t, err) + assert.Equal(t, resourcemanagerpb.Folder_DELETE_REQUESTED, got.GetState()) + + _, err = c.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: child.GetName(), DisplayName: "Under deleted"}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "parent must be ACTIVE") + + _ = c.createFolder(t, parent.GetName(), "Child") + + un := func(name string) error { + op, uerr := c.folders.UndeleteFolder(t.Context(), &resourcemanagerpb.UndeleteFolderRequest{Name: name}) + if uerr != nil { + return uerr + } + + _, uerr = op.Wait(t.Context()) + + return uerr + } + + assert.Equal(t, codes.FailedPrecondition, codeOf(un(child.GetName())), "name now taken by a sibling") + assert.NoError(t, un(parent.GetName()), "undelete of an ACTIVE folder is a no-op") + }) +} + +func TestFolderDepthAndMove(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + parent := orgName + chain := make([]string, 0, 10) + + for i := range 10 { + f := c.createFolder(t, parent, fmt.Sprintf("Level %d", i+1)) + chain = append(chain, f.GetName()) + parent = f.GetName() + } + + _, err := c.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: parent, DisplayName: "Level 11"}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "height limit 10") + + side := c.createFolder(t, orgName, "Side") + mv := func(name, dest string) error { + op, merr := c.folders.MoveFolder( + t.Context(), + &resourcemanagerpb.MoveFolderRequest{Name: name, DestinationParent: dest}, + ) + if merr != nil { + return merr + } + + _, merr = op.Wait(t.Context()) + + return merr + } + + assert.Equal(t, codes.FailedPrecondition, codeOf(mv(side.GetName(), chain[9])), "move would exceed height") + assert.Equal(t, codes.FailedPrecondition, codeOf(mv(chain[0], chain[3])), "move into a descendant") + assert.Equal(t, codes.FailedPrecondition, codeOf(mv(chain[0], chain[0])), "move into itself") + require.NoError(t, mv(side.GetName(), chain[7])) + + moved, err := c.folders.GetFolder(t.Context(), &resourcemanagerpb.GetFolderRequest{Name: side.GetName()}) + require.NoError(t, err) + assert.Equal(t, chain[7], moved.GetParent()) + + assert.Equal( + t, + codes.FailedPrecondition, + codeOf(mv(chain[1], chain[7])), + "subtree of 9 under depth 8 is too tall", + ) + assert.Equal(t, codes.InvalidArgument, codeOf(mv(side.GetName(), "projects/1"))) + assert.Equal(t, codes.PermissionDenied, codeOf(mv(side.GetName(), "folders/1"))) + }) +} + +func TestFolderFanoutLimit(t *testing.T) { + t.Parallel() + + e := newEnv(t) + parent := e.grpc.createFolder(t, orgName, "Wide") + + for i := range 300 { + op, err := e.grpc.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: parent.GetName(), DisplayName: fmt.Sprintf("Child %03d", i)}, + }) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.NoError(t, err) + } + + _, err := e.grpc.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: parent.GetName(), DisplayName: "One too many"}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err)) +} + +func TestOperationsAcrossTransports(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + op, err := c.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: orgName, DisplayName: "Op folder"}, + }) + require.NoError(t, err) + + got, err := c.folders.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: op.Name()}) + require.NoError(t, err) + assert.True(t, got.GetDone()) + assert.Equal(t, op.Name(), got.GetName()) + + _, err = c.folders.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: "operations/nope"}) + assert.Equal(t, codes.NotFound, codeOf(err)) + }) +} + +func TestOrganization(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + org, err := c.orgs.GetOrganization(t.Context(), &resourcemanagerpb.GetOrganizationRequest{Name: orgName}) + require.NoError(t, err) + assert.Equal(t, "gopherstack.local", org.GetDisplayName()) + assert.Equal(t, resourcemanagerpb.Organization_ACTIVE, org.GetState()) + assert.NotEmpty(t, org.GetDirectoryCustomerId()) + + _, err = c.orgs.GetOrganization(t.Context(), &resourcemanagerpb.GetOrganizationRequest{Name: "organizations/1"}) + assert.Equal(t, codes.PermissionDenied, codeOf(err)) + + tests := []struct { + query string + want int + }{ + {"", 1}, + {"domain:gopherstack.local", 1}, + {"domain:GOPHERSTACK.LOCAL", 1}, + {"domain:other.example", 0}, + {"directorycustomerid:" + org.GetDirectoryCustomerId(), 1}, + } + + for _, tt := range tests { + it := c.orgs.SearchOrganizations( + t.Context(), + &resourcemanagerpb.SearchOrganizationsRequest{Query: tt.query}, + ) + n := 0 + + for _, ierr := it.Next(); ierr == nil; _, ierr = it.Next() { + n++ + } + + assert.Equal(t, tt.want, n, tt.query) + } + }) +} diff --git a/services/gcpresourcemanager/grpc.go b/services/gcpresourcemanager/grpc.go new file mode 100644 index 0000000000..7433e4515c --- /dev/null +++ b/services/gcpresourcemanager/grpc.go @@ -0,0 +1,316 @@ +package gcpresourcemanager + +import ( + "context" + + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" +) + +type iamMethods struct{ b *InMemoryBackend } + +// Unimplemented bases sit one level deeper than iamMethods so its IAM methods win the selector. +type unimplementedProjects struct { + resourcemanagerpb.UnimplementedProjectsServer +} + +type unimplementedFolders struct { + resourcemanagerpb.UnimplementedFoldersServer +} + +type unimplementedOrganizations struct { + resourcemanagerpb.UnimplementedOrganizationsServer +} + +type unimplementedTagKeys struct { + resourcemanagerpb.UnimplementedTagKeysServer +} + +type unimplementedTagValues struct { + resourcemanagerpb.UnimplementedTagValuesServer +} + +func (m iamMethods) GetIamPolicy(_ context.Context, in *iampb.GetIamPolicyRequest) (*iampb.Policy, error) { + return m.b.GetIamPolicy(in) +} + +func (m iamMethods) SetIamPolicy(_ context.Context, in *iampb.SetIamPolicyRequest) (*iampb.Policy, error) { + return m.b.SetIamPolicy(in) +} + +func (m iamMethods) TestIamPermissions( + _ context.Context, in *iampb.TestIamPermissionsRequest, +) (*iampb.TestIamPermissionsResponse, error) { + return m.b.TestIamPermissions(in) +} + +type projectsServer struct { + unimplementedProjects + iamMethods +} + +func (s projectsServer) GetProject(_ context.Context, in *resourcemanagerpb.GetProjectRequest) ( + *resourcemanagerpb.Project, error, +) { + return s.b.GetProject(in.GetName()) +} + +func (s projectsServer) ListProjects(_ context.Context, in *resourcemanagerpb.ListProjectsRequest) ( + *resourcemanagerpb.ListProjectsResponse, error, +) { + return s.b.ListProjects(in) +} + +func (s projectsServer) SearchProjects(_ context.Context, in *resourcemanagerpb.SearchProjectsRequest) ( + *resourcemanagerpb.SearchProjectsResponse, error, +) { + return s.b.SearchProjects(in) +} + +func (s projectsServer) CreateProject(_ context.Context, in *resourcemanagerpb.CreateProjectRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.CreateProject(in) +} + +func (s projectsServer) UpdateProject(_ context.Context, in *resourcemanagerpb.UpdateProjectRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.UpdateProject(in) +} + +func (s projectsServer) MoveProject(_ context.Context, in *resourcemanagerpb.MoveProjectRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.MoveProject(in) +} + +func (s projectsServer) DeleteProject(_ context.Context, in *resourcemanagerpb.DeleteProjectRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.DeleteProject(in) +} + +func (s projectsServer) UndeleteProject(_ context.Context, in *resourcemanagerpb.UndeleteProjectRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.UndeleteProject(in) +} + +type foldersServer struct { + unimplementedFolders + iamMethods +} + +func (s foldersServer) GetFolder(_ context.Context, in *resourcemanagerpb.GetFolderRequest) ( + *resourcemanagerpb.Folder, error, +) { + return s.b.GetFolder(in.GetName()) +} + +func (s foldersServer) ListFolders(_ context.Context, in *resourcemanagerpb.ListFoldersRequest) ( + *resourcemanagerpb.ListFoldersResponse, error, +) { + return s.b.ListFolders(in) +} + +func (s foldersServer) SearchFolders(_ context.Context, in *resourcemanagerpb.SearchFoldersRequest) ( + *resourcemanagerpb.SearchFoldersResponse, error, +) { + return s.b.SearchFolders(in) +} + +func (s foldersServer) CreateFolder(_ context.Context, in *resourcemanagerpb.CreateFolderRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.CreateFolder(in) +} + +func (s foldersServer) UpdateFolder(_ context.Context, in *resourcemanagerpb.UpdateFolderRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.UpdateFolder(in) +} + +func (s foldersServer) MoveFolder(_ context.Context, in *resourcemanagerpb.MoveFolderRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.MoveFolder(in) +} + +func (s foldersServer) DeleteFolder(_ context.Context, in *resourcemanagerpb.DeleteFolderRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.DeleteFolder(in) +} + +func (s foldersServer) UndeleteFolder(_ context.Context, in *resourcemanagerpb.UndeleteFolderRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.UndeleteFolder(in) +} + +type organizationsServer struct { + unimplementedOrganizations + iamMethods +} + +func (s organizationsServer) GetOrganization(_ context.Context, in *resourcemanagerpb.GetOrganizationRequest) ( + *resourcemanagerpb.Organization, error, +) { + return s.b.GetOrganization(in.GetName()) +} + +func (s organizationsServer) SearchOrganizations( + _ context.Context, in *resourcemanagerpb.SearchOrganizationsRequest, +) (*resourcemanagerpb.SearchOrganizationsResponse, error) { + return s.b.SearchOrganizations(in) +} + +type tagKeysServer struct { + unimplementedTagKeys + iamMethods +} + +func (s tagKeysServer) ListTagKeys(_ context.Context, in *resourcemanagerpb.ListTagKeysRequest) ( + *resourcemanagerpb.ListTagKeysResponse, error, +) { + return s.b.ListTagKeys(in) +} + +func (s tagKeysServer) GetTagKey(_ context.Context, in *resourcemanagerpb.GetTagKeyRequest) ( + *resourcemanagerpb.TagKey, error, +) { + return s.b.GetTagKey(in.GetName()) +} + +func (s tagKeysServer) GetNamespacedTagKey(_ context.Context, in *resourcemanagerpb.GetNamespacedTagKeyRequest) ( + *resourcemanagerpb.TagKey, error, +) { + return s.b.GetNamespacedTagKey(in.GetName()) +} + +func (s tagKeysServer) CreateTagKey(_ context.Context, in *resourcemanagerpb.CreateTagKeyRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.CreateTagKey(in) +} + +func (s tagKeysServer) UpdateTagKey(_ context.Context, in *resourcemanagerpb.UpdateTagKeyRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.UpdateTagKey(in) +} + +func (s tagKeysServer) DeleteTagKey(_ context.Context, in *resourcemanagerpb.DeleteTagKeyRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.DeleteTagKey(in) +} + +type tagValuesServer struct { + unimplementedTagValues + iamMethods +} + +func (s tagValuesServer) ListTagValues(_ context.Context, in *resourcemanagerpb.ListTagValuesRequest) ( + *resourcemanagerpb.ListTagValuesResponse, error, +) { + return s.b.ListTagValues(in) +} + +func (s tagValuesServer) GetTagValue(_ context.Context, in *resourcemanagerpb.GetTagValueRequest) ( + *resourcemanagerpb.TagValue, error, +) { + return s.b.GetTagValue(in.GetName()) +} + +func (s tagValuesServer) GetNamespacedTagValue( + _ context.Context, in *resourcemanagerpb.GetNamespacedTagValueRequest, +) (*resourcemanagerpb.TagValue, error) { + return s.b.GetNamespacedTagValue(in.GetName()) +} + +func (s tagValuesServer) CreateTagValue(_ context.Context, in *resourcemanagerpb.CreateTagValueRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.CreateTagValue(in) +} + +func (s tagValuesServer) UpdateTagValue(_ context.Context, in *resourcemanagerpb.UpdateTagValueRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.UpdateTagValue(in) +} + +func (s tagValuesServer) DeleteTagValue(_ context.Context, in *resourcemanagerpb.DeleteTagValueRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.DeleteTagValue(in) +} + +type tagBindingsServer struct { + resourcemanagerpb.UnimplementedTagBindingsServer + b *InMemoryBackend +} + +func (s tagBindingsServer) ListTagBindings(_ context.Context, in *resourcemanagerpb.ListTagBindingsRequest) ( + *resourcemanagerpb.ListTagBindingsResponse, error, +) { + return s.b.ListTagBindings(in) +} + +func (s tagBindingsServer) CreateTagBinding(_ context.Context, in *resourcemanagerpb.CreateTagBindingRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.CreateTagBinding(in) +} + +func (s tagBindingsServer) DeleteTagBinding(_ context.Context, in *resourcemanagerpb.DeleteTagBindingRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.DeleteTagBinding(in) +} + +type serviceUsageServer struct { + serviceusagepb.UnimplementedServiceUsageServer + b *InMemoryBackend +} + +func (s serviceUsageServer) EnableService(_ context.Context, in *serviceusagepb.EnableServiceRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.EnableService(in) +} + +func (s serviceUsageServer) DisableService(_ context.Context, in *serviceusagepb.DisableServiceRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.DisableService(in) +} + +func (s serviceUsageServer) GetService(_ context.Context, in *serviceusagepb.GetServiceRequest) ( + *serviceusagepb.Service, error, +) { + return s.b.GetService(in.GetName()) +} + +func (s serviceUsageServer) ListServices(_ context.Context, in *serviceusagepb.ListServicesRequest) ( + *serviceusagepb.ListServicesResponse, error, +) { + return s.b.ListServices(in) +} + +func (s serviceUsageServer) BatchEnableServices(_ context.Context, in *serviceusagepb.BatchEnableServicesRequest) ( + *longrunningpb.Operation, error, +) { + return s.b.BatchEnableServices(in) +} + +func (s serviceUsageServer) BatchGetServices(_ context.Context, in *serviceusagepb.BatchGetServicesRequest) ( + *serviceusagepb.BatchGetServicesResponse, error, +) { + return s.b.BatchGetServices(in) +} diff --git a/services/gcpresourcemanager/handler.go b/services/gcpresourcemanager/handler.go new file mode 100644 index 0000000000..e112deb990 --- /dev/null +++ b/services/gcpresourcemanager/handler.go @@ -0,0 +1,200 @@ +package gcpresourcemanager + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" + "github.com/labstack/echo/v5" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const gracefulStopWait = 2 * time.Second + +// Handler serves Resource Manager (v3 gRPC and REST, v1 REST) and Service Usage on a dedicated listener. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *grpc.Server + wg *sync.WaitGroup + addr string + enforce gcpenforce.Settings + Port int +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, + Port: DefaultPort, + srvMu: lockmetrics.New("gcpresourcemanager.server"), + wg: &sync.WaitGroup{}, + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "GCPResourceManager" } + +// GetSupportedOperations lists the supported gRPC methods. +func (h *Handler) GetSupportedOperations() []string { + return []string{ + "CreateProject", "GetProject", "ListProjects", "SearchProjects", "UpdateProject", "MoveProject", + "DeleteProject", "UndeleteProject", + "CreateFolder", "GetFolder", "ListFolders", "SearchFolders", "UpdateFolder", "MoveFolder", + "DeleteFolder", "UndeleteFolder", + "GetOrganization", "SearchOrganizations", + "GetIamPolicy", "SetIamPolicy", "TestIamPermissions", + "CreateTagKey", "GetTagKey", "GetNamespacedTagKey", "ListTagKeys", "UpdateTagKey", "DeleteTagKey", + "CreateTagValue", "GetTagValue", "GetNamespacedTagValue", "ListTagValues", "UpdateTagValue", "DeleteTagValue", + "CreateTagBinding", "ListTagBindings", "DeleteTagBinding", + "EnableService", "DisableService", "GetService", "ListServices", "BatchEnableServices", "BatchGetServices", + "GetOperation", "ListOperations", "DeleteOperation", "WaitOperation", "CancelOperation", + } +} + +// RouteMatcher never matches: the service runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation is unused: requests never reach the shared router. +func (h *Handler) ExtractOperation(*echo.Context) string { return "Unknown" } + +// ExtractResource is unused: requests never reach the shared router. +func (h *Handler) ExtractResource(*echo.Context) string { return "" } + +// Addr returns the bound listener address, or "" before StartWorker. +func (h *Handler) Addr() string { + h.srvMu.RLock("Addr") + defer h.srvMu.RUnlock() + + return h.addr +} + +// Reset restores the seeded state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Handler returns a 404 handler; the real API is on the dedicated port. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + return c.String(http.StatusNotFound, "gcpresourcemanager serves gRPC and REST on its dedicated port") + } +} + +type debugLogger interface { + DebugContext(ctx context.Context, msg string, args ...any) +} + +func logUnary(log debugLogger) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { + resp, err := next(ctx, req) + if err != nil && status.Code(err) != codes.OK { + log.DebugContext(ctx, "gcpresourcemanager: rpc failed", "method", info.FullMethod, "error", err) + } + + return resp, err + } +} + +func (h *Handler) registerServers(srv *grpc.Server) { + iam := iamMethods{b: h.Backend} + + resourcemanagerpb.RegisterProjectsServer(srv, projectsServer{iamMethods: iam}) + resourcemanagerpb.RegisterFoldersServer(srv, foldersServer{iamMethods: iam}) + resourcemanagerpb.RegisterOrganizationsServer(srv, organizationsServer{iamMethods: iam}) + resourcemanagerpb.RegisterTagKeysServer(srv, tagKeysServer{iamMethods: iam}) + resourcemanagerpb.RegisterTagValuesServer(srv, tagValuesServer{iamMethods: iam}) + resourcemanagerpb.RegisterTagBindingsServer(srv, tagBindingsServer{b: h.Backend}) + serviceusagepb.RegisterServiceUsageServer(srv, serviceUsageServer{b: h.Backend}) + longrunningpb.RegisterOperationsServer(srv, h.Backend.lro) +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + var lc net.ListenConfig + + listener, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + if err != nil { + return fmt.Errorf("gcpresourcemanager: bind port %d: %w", h.Port, err) + } + + workerCtx := logger.WithWorker(ctx, "gcpresourcemanager", "listener") + log := logger.Load(workerCtx) + + srv := grpc.NewServer(grpc.UnaryInterceptor(logUnary(log))) + h.registerServers(srv) + + h.srvMu.Lock("StartWorker") + h.srv, h.addr = srv, listener.Addr().String() + h.srvMu.Unlock() + + log.InfoContext(workerCtx, "gcpresourcemanager: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + v1 := gcprest.Extra{MatchRequest: matchV1, Handler: v1Handler{b: h.Backend}} + if serveErr := gcprest.ServeWithExtras(srv, listener, nil, v1); serveErr != nil && + !errors.Is(serveErr, grpc.ErrServerStopped) { + log.ErrorContext(workerCtx, "gcpresourcemanager: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener, forcing a close after a short grace period. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv := h.srv + h.srv = nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + done := make(chan struct{}) + + go func() { + srv.GracefulStop() + close(done) + }() + + grace := time.NewTimer(gracefulStopWait) + defer grace.Stop() + + select { + case <-done: + case <-grace.C: + srv.Stop() + <-done + case <-ctx.Done(): + srv.Stop() + <-done + } + + h.wg.Wait() +} diff --git a/services/gcpresourcemanager/helpers_test.go b/services/gcpresourcemanager/helpers_test.go new file mode 100644 index 0000000000..02982e6659 --- /dev/null +++ b/services/gcpresourcemanager/helpers_test.go @@ -0,0 +1,229 @@ +package gcpresourcemanager_test + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" + "testing" + "time" + + resourcemanager "cloud.google.com/go/resourcemanager/apiv3" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + serviceusage "cloud.google.com/go/serviceusage/apiv1" + "github.com/stretchr/testify/require" + "google.golang.org/api/googleapi" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +const ( + orgName = "organizations/" + gcpresourcemanager.DefaultOrganizationID + seedID = gcpresourcemanager.DefaultSeedProjects +) + +type fakeClock struct{ ns atomic.Int64 } + +func newClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } + +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +// clients are the official Go clients of one transport. +type clients struct { + projects *resourcemanager.ProjectsClient + folders *resourcemanager.FoldersClient + orgs *resourcemanager.OrganizationsClient + tagKeys *resourcemanager.TagKeysClient + tagVals *resourcemanager.TagValuesClient + bindings *resourcemanager.TagBindingsClient + usage *serviceusage.Client +} + +type env struct { + backend *gcpresourcemanager.InMemoryBackend + clock *fakeClock + grpc *clients + rest *clients + addr string +} + +type envOption func(*gcpresourcemanager.Settings) + +func withRetention(d time.Duration) envOption { + return func(s *gcpresourcemanager.Settings) { s.DeleteRetention = d } +} + +func newEnv(t *testing.T, opts ...envOption) *env { + t.Helper() + + settings := gcpresourcemanager.DefaultSettings() + for _, o := range opts { + o(&settings) + } + + clk := newClock() + b := gcpresourcemanager.NewBackend(settings) + b.SetClock(clk.Now) + + h := gcpresourcemanager.NewHandler(b) + h.Port = 0 + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + conn, err := grpc.NewClient(h.Addr(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + grpcOpts := []option.ClientOption{option.WithGRPCConn(conn)} + restOpts := []option.ClientOption{ + option.WithEndpoint( + "http://" + h.Addr(), + ), + option.WithoutAuthentication(), + option.WithHTTPClient(testclient.New(t)), + } + + return &env{ + backend: b, clock: clk, addr: h.Addr(), + grpc: newClients(t, false, grpcOpts), rest: newClients(t, true, restOpts), + } +} + +func newClients(t *testing.T, rest bool, opts []option.ClientOption) *clients { + t.Helper() + + ctx := t.Context() + c := &clients{} + + var err error + + if rest { + c.projects, err = resourcemanager.NewProjectsRESTClient(ctx, opts...) + require.NoError(t, err) + c.folders, err = resourcemanager.NewFoldersRESTClient(ctx, opts...) + require.NoError(t, err) + c.orgs, err = resourcemanager.NewOrganizationsRESTClient(ctx, opts...) + require.NoError(t, err) + c.tagKeys, err = resourcemanager.NewTagKeysRESTClient(ctx, opts...) + require.NoError(t, err) + c.tagVals, err = resourcemanager.NewTagValuesRESTClient(ctx, opts...) + require.NoError(t, err) + c.bindings, err = resourcemanager.NewTagBindingsRESTClient(ctx, opts...) + require.NoError(t, err) + c.usage, err = serviceusage.NewRESTClient(ctx, opts...) + require.NoError(t, err) + } else { + c.projects, err = resourcemanager.NewProjectsClient(ctx, opts...) + require.NoError(t, err) + c.folders, err = resourcemanager.NewFoldersClient(ctx, opts...) + require.NoError(t, err) + c.orgs, err = resourcemanager.NewOrganizationsClient(ctx, opts...) + require.NoError(t, err) + c.tagKeys, err = resourcemanager.NewTagKeysClient(ctx, opts...) + require.NoError(t, err) + c.tagVals, err = resourcemanager.NewTagValuesClient(ctx, opts...) + require.NoError(t, err) + c.bindings, err = resourcemanager.NewTagBindingsClient(ctx, opts...) + require.NoError(t, err) + c.usage, err = serviceusage.NewClient(ctx, opts...) + require.NoError(t, err) + } + + t.Cleanup(func() { + _ = c.projects.Close() + _ = c.folders.Close() + _ = c.orgs.Close() + _ = c.tagKeys.Close() + _ = c.tagVals.Close() + _ = c.bindings.Close() + _ = c.usage.Close() + }) + + return c +} + +func (c *clients) createProject(t *testing.T, id, parent string) *resourcemanagerpb.Project { + t.Helper() + + op, err := c.projects.CreateProject(t.Context(), &resourcemanagerpb.CreateProjectRequest{ + Project: &resourcemanagerpb.Project{ProjectId: id, Parent: parent, DisplayName: "Project " + id}, + }) + require.NoError(t, err) + + p, err := op.Wait(t.Context()) + require.NoError(t, err) + + return p +} + +func (c *clients) createFolder(t *testing.T, parent, name string) *resourcemanagerpb.Folder { + t.Helper() + + op, err := c.folders.CreateFolder(t.Context(), &resourcemanagerpb.CreateFolderRequest{ + Folder: &resourcemanagerpb.Folder{Parent: parent, DisplayName: name}, + }) + require.NoError(t, err) + + f, err := op.Wait(t.Context()) + require.NoError(t, err) + + return f +} + +// codeOf reads the canonical code of a gRPC or REST error; the REST client folds HTTP 400 and 409 into +// other codes, so the JSON status string is parsed instead. +func codeOf(err error) codes.Code { + if err == nil { + return codes.OK + } + + if ge, ok := errors.AsType[*googleapi.Error](err); ok { + var body struct { + Error struct { + Status string `json:"status"` + } `json:"error"` + } + + var c codes.Code + + if json.Unmarshal([]byte(ge.Body), &body) == nil && c.UnmarshalJSON([]byte(`"`+body.Error.Status+`"`)) == nil { + return c + } + } + + return status.Code(err) +} + +// eachTransport runs fn once per transport, each against its own backend. +func eachTransport(t *testing.T, fn func(t *testing.T, e *env, c *clients)) { + t.Helper() + + for _, tp := range []string{"grpc", "rest"} { + t.Run(tp, func(t *testing.T) { + t.Parallel() + + e := newEnv(t) + c := e.grpc + + if tp == "rest" { + c = e.rest + } + + fn(t, e, c) + }) + } +} diff --git a/services/gcpresourcemanager/iam.go b/services/gcpresourcemanager/iam.go new file mode 100644 index 0000000000..8a61f7ac37 --- /dev/null +++ b/services/gcpresourcemanager/iam.go @@ -0,0 +1,149 @@ +package gcpresourcemanager + +import ( + "slices" + "strings" + + "cloud.google.com/go/iam/apiv1/iampb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpiampolicy" +) + +const publicMemberAllUsers, publicMemberAllAuth = "allUsers", "allAuthenticatedUsers" + +// policyKeyLocked resolves the resource an IAM call names to the key its policy is stored under. +func (b *InMemoryBackend) policyKeyLocked(resource string, write bool) (string, error) { + kind, id, ok := strings.Cut(resource, "/") + if !ok || id == "" || strings.Contains(id, "/") { + return "", errInvalid("Invalid resource name %q.", resource) + } + + switch kind { + case kindOrganizations: + if id != b.org.ID { + return "", errDenied("resourcemanager.organizations.getIamPolicy", resource) + } + case kindFolders: + return resource, b.folderPolicyLocked(resource, write) + case kindProjects: + return b.projectPolicyKeyLocked(resource, write) + case kindTagKeys: + if _, found := b.tagKeys[id]; !found { + return "", errDenied("resourcemanager.tagKeys.getIamPolicy", resource) + } + case kindTagValues: + if _, found := b.tagValues[id]; !found { + return "", errDenied("resourcemanager.tagValues.getIamPolicy", resource) + } + default: + return "", errInvalid( + "IAM policies are available for organizations, folders, projects, tagKeys and tagValues, "+ + "not %q.", + resource, + ) + } + + return resource, nil +} + +func (b *InMemoryBackend) folderPolicyLocked(resource string, write bool) error { + f, err := b.folderLocked(resource, permFolderGet) + if err != nil { + return err + } + + if write && f.requested() { + return errPrecondition("Folder %s is in the DELETE_REQUESTED state.", resource) + } + + return nil +} + +func (b *InMemoryBackend) projectPolicyKeyLocked(resource string, write bool) (string, error) { + var ( + p *projectRec + err error + ) + + if write { + p, err = b.activeProjectLocked(resource, "resourcemanager.projects.setIamPolicy") + } else { + p, err = b.projectLocked(resource, "resourcemanager.projects.getIamPolicy") + } + + if err != nil { + return "", err + } + + return kindProjects + "/" + p.Number, nil +} + +// GetIamPolicy returns the policy of a resource at the requested version. +func (b *InMemoryBackend) GetIamPolicy(in *iampb.GetIamPolicyRequest) (*iampb.Policy, error) { + b.mu.Lock("GetIamPolicy") + defer b.mu.Unlock() + + b.purgeLocked() + + key, err := b.policyKeyLocked(in.GetResource(), false) + if err != nil { + return nil, err + } + + return b.policies.Get(key, in.GetOptions().GetRequestedPolicyVersion()), nil +} + +func rejectPublicMembers(p *iampb.Policy) error { + for _, bd := range p.GetBindings() { + if slices.ContainsFunc( + bd.GetMembers(), + func(m string) bool { return m == publicMemberAllUsers || m == publicMemberAllAuth }, + ) { + return errInvalid("Invalid IAM policy: %s and %s cannot be granted a role on a project.", + publicMemberAllUsers, publicMemberAllAuth) + } + } + + return nil +} + +// SetIamPolicy replaces a policy; a stale etag aborts the call. +func (b *InMemoryBackend) SetIamPolicy(in *iampb.SetIamPolicyRequest) (*iampb.Policy, error) { + if err := gcpiampolicy.Validate(in.GetPolicy()); err != nil { + return nil, err + } + + b.mu.Lock("SetIamPolicy") + defer b.mu.Unlock() + + b.purgeLocked() + + key, err := b.policyKeyLocked(in.GetResource(), true) + if err != nil { + return nil, err + } + + if strings.HasPrefix(key, kindProjects+"/") { + if err = rejectPublicMembers(in.GetPolicy()); err != nil { + return nil, err + } + } + + return b.policies.Set(key, in.GetPolicy()) +} + +// TestIamPermissions reports every requested permission as granted: the emulator authorises all callers. +func (b *InMemoryBackend) TestIamPermissions(in *iampb.TestIamPermissionsRequest) ( + *iampb.TestIamPermissionsResponse, error, +) { + b.mu.Lock("TestIamPermissions") + defer b.mu.Unlock() + + b.purgeLocked() + + if _, err := b.policyKeyLocked(in.GetResource(), false); err != nil { + return nil, err + } + + return &iampb.TestIamPermissionsResponse{Permissions: gcpiampolicy.Granted(in.GetPermissions(), "")}, nil +} diff --git a/services/gcpresourcemanager/iam_test.go b/services/gcpresourcemanager/iam_test.go new file mode 100644 index 0000000000..2ecc30ed61 --- /dev/null +++ b/services/gcpresourcemanager/iam_test.go @@ -0,0 +1,167 @@ +package gcpresourcemanager_test + +import ( + "context" + "testing" + + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "github.com/googleapis/gax-go/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/type/expr" + "google.golang.org/grpc/codes" +) + +type iamAPI interface { + GetIamPolicy(context.Context, *iampb.GetIamPolicyRequest, ...gax.CallOption) (*iampb.Policy, error) + SetIamPolicy(context.Context, *iampb.SetIamPolicyRequest, ...gax.CallOption) (*iampb.Policy, error) + TestIamPermissions( + context.Context, *iampb.TestIamPermissionsRequest, ...gax.CallOption, + ) (*iampb.TestIamPermissionsResponse, error) +} + +type iamTarget struct { + api iamAPI + name string + res string +} + +func iamTargets(t *testing.T, c *clients) []iamTarget { + t.Helper() + + folder := c.createFolder(t, orgName, "IAM folder") + proj := c.createProject(t, "iam-proj", folder.GetName()) + _ = c.createProject(t, "iam-proj-two", folder.GetName()) + + tk := mustTagKey(t, c, "iamkey") + + return []iamTarget{ + {c.orgs, "organization", orgName}, + {c.folders, "folder", folder.GetName()}, + {c.projects, "project number", proj.GetName()}, + {c.projects, "project id", "projects/iam-proj-two"}, + {c.tagKeys, "tag key", tk.GetName()}, + } +} + +func TestIamPolicy(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + for _, tg := range iamTargets(t, c) { + t.Run(tg.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + empty, err := tg.api.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: tg.res}) + require.NoError(t, err) + assert.Empty(t, empty.GetBindings()) + assert.NotEmpty(t, empty.GetEtag()) + + viewer := &iampb.Binding{Role: "roles/viewer", Members: []string{"user:a@example.com"}} + first, err := tg.api.SetIamPolicy(ctx, &iampb.SetIamPolicyRequest{ + Resource: tg.res, Policy: &iampb.Policy{Bindings: []*iampb.Binding{viewer}, Etag: empty.GetEtag()}, + }) + require.NoError(t, err) + assert.Len(t, first.GetBindings(), 1) + assert.NotEqual(t, empty.GetEtag(), first.GetEtag()) + + _, err = tg.api.SetIamPolicy(ctx, &iampb.SetIamPolicyRequest{ + Resource: tg.res, Policy: &iampb.Policy{Etag: empty.GetEtag()}, + }) + assert.Equal(t, codes.Aborted, codeOf(err), "the etag from before the first write is stale") + + got, err := tg.api.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: tg.res}) + require.NoError(t, err) + assert.Equal(t, first.GetEtag(), got.GetEtag()) + assert.Equal(t, "roles/viewer", got.GetBindings()[0].GetRole()) + + _, err = tg.api.SetIamPolicy(ctx, &iampb.SetIamPolicyRequest{ + Resource: tg.res, Policy: &iampb.Policy{Bindings: []*iampb.Binding{{ + Role: "roles/viewer", Members: []string{"user:a@example.com"}, + Condition: &expr.Expr{Expression: "true", Title: "t"}, + }}, Version: 1}, + }) + assert.Equal(t, codes.InvalidArgument, codeOf(err), "conditions need policy version 3") + + perms, err := tg.api.TestIamPermissions(ctx, &iampb.TestIamPermissionsRequest{ + Resource: tg.res, Permissions: []string{"resourcemanager.projects.get", "compute.instances.list"}, + }) + require.NoError(t, err) + assert.ElementsMatch( + t, + []string{"resourcemanager.projects.get", "compute.instances.list"}, + perms.GetPermissions(), + ) + }) + } + }) +} + +func TestIamPolicyErrors(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + tests := []struct { + policy *iampb.Policy + name string + resource string + code codes.Code + }{ + {&iampb.Policy{}, "unknown project", "projects/nope-nope", codes.PermissionDenied}, + {&iampb.Policy{}, "unknown folder", "folders/1", codes.PermissionDenied}, + {&iampb.Policy{}, "unknown org", "organizations/1", codes.PermissionDenied}, + {&iampb.Policy{Bindings: []*iampb.Binding{{ + Role: "roles/viewer", Members: []string{"allUsers"}, + }}}, "all users on project", "projects/" + seedID, codes.InvalidArgument}, + { + &iampb.Policy{Bindings: []*iampb.Binding{{Role: "roles/viewer"}}}, + "empty binding", + "projects/" + seedID, + codes.InvalidArgument, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := c.projects.SetIamPolicy( + t.Context(), + &iampb.SetIamPolicyRequest{Resource: tt.resource, Policy: tt.policy}, + ) + assert.Equal(t, tt.code, codeOf(err), "%v", err) + }) + } + }) +} + +func TestIamPolicyOfDeletedProject(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + p := c.createProject(t, "iam-deleted", "") + + op, err := c.projects.DeleteProject(t.Context(), &resourcemanagerpb.DeleteProjectRequest{Name: p.GetName()}) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.NoError(t, err) + + _, err = c.projects.GetIamPolicy(t.Context(), &iampb.GetIamPolicyRequest{Resource: p.GetName()}) + require.NoError(t, err, "a DELETE_REQUESTED project can still be read") + + _, err = c.projects.SetIamPolicy( + t.Context(), + &iampb.SetIamPolicyRequest{Resource: p.GetName(), Policy: &iampb.Policy{}}, + ) + assert.Equal(t, codes.FailedPrecondition, codeOf(err)) + }) +} diff --git a/services/gcpresourcemanager/inproc.go b/services/gcpresourcemanager/inproc.go new file mode 100644 index 0000000000..1488601526 --- /dev/null +++ b/services/gcpresourcemanager/inproc.go @@ -0,0 +1,36 @@ +package gcpresourcemanager + +import ( + "slices" + + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" +) + +// ProjectsAPI returns the Projects server for in-process callers. +func (h *Handler) ProjectsAPI() resourcemanagerpb.ProjectsServer { + return projectsServer{b: h.Backend} +} + +// ServiceUsageAPI returns the Service Usage server for in-process callers. +func (h *Handler) ServiceUsageAPI() serviceusagepb.ServiceUsageServer { + return serviceUsageServer{b: h.Backend} +} + +// ProjectIDs lists the ids of the projects that are not DELETE_REQUESTED, sorted. +func (b *InMemoryBackend) ProjectIDs() []string { + b.mu.RLock("ProjectIDs") + defer b.mu.RUnlock() + + out := make([]string, 0, len(b.projects)) + + for _, p := range b.projects { + if !p.requested() { + out = append(out, p.ID) + } + } + + slices.Sort(out) + + return out +} diff --git a/services/gcpresourcemanager/leak_main_test.go b/services/gcpresourcemanager/leak_main_test.go new file mode 100644 index 0000000000..946598f33d --- /dev/null +++ b/services/gcpresourcemanager/leak_main_test.go @@ -0,0 +1,11 @@ +package gcpresourcemanager_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + testleak.VerifyTestMain(m) +} diff --git a/services/gcpresourcemanager/model.go b/services/gcpresourcemanager/model.go new file mode 100644 index 0000000000..2df436415a --- /dev/null +++ b/services/gcpresourcemanager/model.go @@ -0,0 +1,120 @@ +package gcpresourcemanager + +import ( + "maps" + "time" + + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + kindOrganizations = "organizations" + kindFolders = "folders" + kindProjects = "projects" + kindTagKeys = "tagKeys" + kindTagValues = "tagValues" + kindTagBindings = "tagBindings" +) + +type lifecycle struct { + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Deleted time.Time `json:"deleted,omitzero"` +} + +func (l lifecycle) requested() bool { return !l.Deleted.IsZero() } + +type projectRec struct { + lifecycle + Labels map[string]string `json:"labels,omitempty"` + Number string `json:"number"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + Parent string `json:"parent,omitempty"` + Billing string `json:"billing,omitempty"` +} + +type folderRec struct { + lifecycle + ID string `json:"id"` + Parent string `json:"parent"` + DisplayName string `json:"displayName"` +} + +type orgRec struct { + Created time.Time + ID string + Domain string +} + +type tagKeyRec struct { + lifecycle + PurposeData map[string]string `json:"purposeData,omitempty"` + ID string `json:"id"` + Parent string `json:"parent"` + ShortName string `json:"shortName"` + Description string `json:"description,omitempty"` + Purpose int32 `json:"purpose,omitempty"` +} + +type tagValueRec struct { + lifecycle + ID string `json:"id"` + Parent string `json:"parent"` + ShortName string `json:"shortName"` + Description string `json:"description,omitempty"` +} + +type bindingRec struct { + Parent string `json:"parent"` + TagValue string `json:"tagValue"` +} + +func ts(t time.Time) *timestamppb.Timestamp { + if t.IsZero() { + return nil + } + + return timestamppb.New(t) +} + +func (p *projectRec) view() *resourcemanagerpb.Project { + state := resourcemanagerpb.Project_ACTIVE + if p.requested() { + state = resourcemanagerpb.Project_DELETE_REQUESTED + } + + labels := map[string]string(nil) + if len(p.Labels) > 0 { + labels = make(map[string]string, len(p.Labels)) + maps.Copy(labels, p.Labels) + } + + return &resourcemanagerpb.Project{ + Name: kindProjects + "/" + p.Number, Parent: p.Parent, ProjectId: p.ID, State: state, + DisplayName: p.DisplayName, CreateTime: ts(p.Created), UpdateTime: ts(p.Updated), + DeleteTime: ts(p.Deleted), Labels: labels, Etag: etagOf(p.Number, p.Parent, p.DisplayName, p.Updated, p.Labels), + } +} + +func (f *folderRec) view() *resourcemanagerpb.Folder { + state := resourcemanagerpb.Folder_ACTIVE + if f.requested() { + state = resourcemanagerpb.Folder_DELETE_REQUESTED + } + + return &resourcemanagerpb.Folder{ + Name: kindFolders + "/" + f.ID, Parent: f.Parent, DisplayName: f.DisplayName, State: state, + CreateTime: ts(f.Created), UpdateTime: ts(f.Updated), DeleteTime: ts(f.Deleted), + Etag: etagOf(f.ID, f.Parent, f.DisplayName, f.Updated, nil), + } +} + +func (o *orgRec) view() *resourcemanagerpb.Organization { + return &resourcemanagerpb.Organization{ + Name: kindOrganizations + "/" + o.ID, DisplayName: o.Domain, State: resourcemanagerpb.Organization_ACTIVE, + Owner: &resourcemanagerpb.Organization_DirectoryCustomerId{DirectoryCustomerId: "C" + o.ID}, + CreateTime: ts(o.Created), UpdateTime: ts(o.Created), Etag: etagOf(o.ID, o.Domain, "", o.Created, nil), + } +} diff --git a/services/gcpresourcemanager/names.go b/services/gcpresourcemanager/names.go new file mode 100644 index 0000000000..7ebd480d18 --- /dev/null +++ b/services/gcpresourcemanager/names.go @@ -0,0 +1,115 @@ +package gcpresourcemanager + +import ( + "regexp" + "strings" + "sync" + "unicode/utf8" +) + +const ( + projectIDMin = 6 + projectIDMax = 30 + projectNameMin = 4 + projectNameMax = 30 + maxLabels = 64 + maxLabelLen = 63 + maxFolderDepth = 10 + maxFolderFanout = 300 + maxTagDescLen = 256 + maxTagShortName = 63 +) + +var ( + projectIDRE = regexp.MustCompile(`^[a-z][a-z0-9-]*[a-z0-9]$`) + projectNameRE = regexp.MustCompile(`^[A-Za-z0-9'" !-]+$`) + labelKeyRE = regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`) + labelValueRE = regexp.MustCompile(`^([a-z]([-a-z0-9]*[a-z0-9])?)?$`) + folderNameRE = sync.OnceValue(func() *regexp.Regexp { //nolint:gochecknoglobals // lazy: eager compile costs MBs + return regexp.MustCompile(`^[\p{L}\p{N}][\p{L}\p{N}_\- ]{1,28}[\p{L}\p{N}]$`) + }) + tagNameRE = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?$`) +) + +// splitName splits "kind/id" and checks the kind. +func splitName(name, kind string) (string, bool) { + id, ok := strings.CutPrefix(name, kind+"/") + if !ok || id == "" || strings.Contains(id, "/") { + return "", false + } + + return id, true +} + +func validateProjectID(id string) error { + if n := len(id); n < projectIDMin || n > projectIDMax || !projectIDRE.MatchString(id) { + return errInvalid("Invalid project id %q: it must be 6 to 30 lowercase ASCII letters, digits, or hyphens, "+ + "start with a letter and not end with a hyphen.", id) + } + + for _, r := range []string{"google", "ssl"} { + if strings.Contains(id, r) { + return errInvalid("Invalid project id %q: it must not contain the restricted string %q.", id, r) + } + } + + return nil +} + +func validateProjectName(name string) error { + if name == "" { + return nil + } + + if n := utf8.RuneCountInString(name); n < projectNameMin || n > projectNameMax || !projectNameRE.MatchString(name) { + return errInvalid("Invalid display name %q: it must be 4 to 30 characters of letters, numbers, "+ + "hyphen, single-quote, double-quote, space and exclamation point.", name) + } + + return nil +} + +func validateLabels(labels map[string]string) error { + if len(labels) > maxLabels { + return errInvalid("A project can have at most %d labels.", maxLabels) + } + + for k, v := range labels { + if len(k) == 0 || len(k) > maxLabelLen || !labelKeyRE.MatchString(k) { + return errInvalid("Invalid label key %q: 1 to 63 characters matching [a-z]([-a-z0-9]*[a-z0-9])?.", k) + } + + if len(v) > maxLabelLen || !labelValueRE.MatchString(v) { + return errInvalid("Invalid label value %q for key %q: 0 to 63 characters matching "+ + "([a-z]([-a-z0-9]*[a-z0-9])?)?.", v, k) + } + } + + return nil +} + +func validateFolderName(name string) error { + if !folderNameRE().MatchString(name) { + return errInvalid("Invalid folder display name %q: it must start and end with a letter or digit, "+ + "contain letters, digits, spaces, hyphens and underscores and be 3 to 30 characters.", name) + } + + return nil +} + +func validateTagName(kind, name string) error { + if name == "" || len(name) > maxTagShortName || !tagNameRE.MatchString(name) { + return errInvalid("Invalid %s short_name %q: 1 to 63 characters, alphanumeric at both ends, with "+ + "dashes, underscores and dots between.", kind, name) + } + + return nil +} + +func validateDescription(d string) error { + if utf8.RuneCountInString(d) > maxTagDescLen { + return errInvalid("description must not exceed %d characters.", maxTagDescLen) + } + + return nil +} diff --git a/services/gcpresourcemanager/orgs.go b/services/gcpresourcemanager/orgs.go new file mode 100644 index 0000000000..7053262bd6 --- /dev/null +++ b/services/gcpresourcemanager/orgs.go @@ -0,0 +1,74 @@ +package gcpresourcemanager + +import ( + "strings" + + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +func (b *InMemoryBackend) orgFields(f string) ([]string, bool) { + switch f { + case "domain", fieldDisplayName: + return []string{b.org.Domain}, true + case "directorycustomerid", "owner.directorycustomerid": + return []string{"C" + b.org.ID}, true + case fieldState: + return []string{stateActive}, true + default: + return nil, false + } +} + +// GetOrganization returns the emulated organization. +func (b *InMemoryBackend) GetOrganization(name string) (*resourcemanagerpb.Organization, error) { + b.mu.RLock("GetOrganization") + defer b.mu.RUnlock() + + if id, ok := splitName(name, kindOrganizations); !ok || id != b.org.ID { + return nil, errDenied("resourcemanager.organizations.get", name) + } + + return b.org.view(), nil +} + +// SearchOrganizations returns the organization when it matches the query (domain or directoryCustomerId). +func (b *InMemoryBackend) SearchOrganizations(in *resourcemanagerpb.SearchOrganizationsRequest) ( + *resourcemanagerpb.SearchOrganizationsResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + q, err := parseQuery(in.GetQuery(), false) + if err != nil { + return nil, err + } + + b.mu.RLock("SearchOrganizations") + ok, err := q.match(b.orgFields) + view := b.org.view() + b.mu.RUnlock() + + if err != nil { + return nil, err + } + + var all []*resourcemanagerpb.Organization + if ok { + all = append(all, view) + } + + pg := page.New(all, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.SearchOrganizationsResponse{Organizations: pg.Data, NextPageToken: pg.Next}, nil +} + +// OrganizationName is the resource name of the emulated organization. +func (b *InMemoryBackend) OrganizationName() string { + b.mu.RLock("OrganizationName") + defer b.mu.RUnlock() + + return strings.Join([]string{kindOrganizations, b.org.ID}, "/") +} diff --git a/services/gcpresourcemanager/persistence.go b/services/gcpresourcemanager/persistence.go new file mode 100644 index 0000000000..5d4455e118 --- /dev/null +++ b/services/gcpresourcemanager/persistence.go @@ -0,0 +1,115 @@ +package gcpresourcemanager + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "cloud.google.com/go/iam/apiv1/iampb" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the snapshot types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Projects map[string]*projectRec `json:"projects"` + Folders map[string]*folderRec `json:"folders"` + TagKeys map[string]*tagKeyRec `json:"tagKeys"` + TagValues map[string]*tagValueRec `json:"tagValues"` + Bindings map[string]*bindingRec `json:"tagBindings"` + Enabled map[string]map[string]time.Time `json:"enabledServices"` + Policies map[string]json.RawMessage `json:"policies"` + Ops map[string]json.RawMessage `json:"operations"` + Seed string `json:"seed"` + Version int `json:"version"` + PolicySeq uint64 `json:"policySeq"` +} + +// Snapshot serialises projects, folders, tags, enabled services, IAM policies and operations. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + policies, seq := b.policies.Snapshot() + snap := backendSnapshot{ + Version: snapshotVersion, Seed: b.seed, Projects: b.projects, Folders: b.folders, TagKeys: b.tagKeys, + TagValues: b.tagValues, Bindings: b.bindings, Enabled: b.enabled, PolicySeq: seq, + Policies: make(map[string]json.RawMessage, len(policies)), Ops: b.lro.Snapshot(), + } + + for n, p := range policies { + if raw, err := protojson.Marshal(p); err == nil { + snap.Policies[n] = raw + } + } + + return persistence.MarshalSnapshot(ctx, "gcpresourcemanager", snap) +} + +// Restore loads a snapshot, replacing all state; the organization stays as configured. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "gcpresourcemanager", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx). + WarnContext(ctx, "gcpresourcemanager: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + policies := make(map[string]*iampb.Policy, len(snap.Policies)) + + for n, raw := range snap.Policies { + p := &iampb.Policy{} + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, p); err != nil { + return fmt.Errorf("gcpresourcemanager: restore policy %q: %w", n, err) + } + + policies[n] = p + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + if err := b.lro.Restore(orEmpty(snap.Ops)); err != nil { + return fmt.Errorf("gcpresourcemanager: restore operations: %w", err) + } + + b.projects, b.folders = orEmpty(snap.Projects), orEmpty(snap.Folders) + b.tagKeys, b.tagValues = orEmpty(snap.TagKeys), orEmpty(snap.TagValues) + b.bindings, b.enabled = orEmpty(snap.Bindings), orEmpty(snap.Enabled) + b.policies.Restore(policies, snap.PolicySeq) + + return nil +} + +func orEmpty[V any](m map[string]V) map[string]V { + if m == nil { + return map[string]V{} + } + + return m +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("gcpresourcemanager: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/gcpresourcemanager/persistence_test.go b/services/gcpresourcemanager/persistence_test.go new file mode 100644 index 0000000000..1a2eb139bb --- /dev/null +++ b/services/gcpresourcemanager/persistence_test.go @@ -0,0 +1,156 @@ +package gcpresourcemanager_test + +import ( + "testing" + "time" + + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + src := newEnv(t) + c := src.grpc + + folder := c.createFolder(t, orgName, "Persisted") + proj := c.createProject(t, "persisted-proj", folder.GetName()) + key := mustTagKey(t, c, "persist") + val := mustTagValue(t, c, key.GetName(), "yes") + + bind, err := c.bindings.CreateTagBinding(t.Context(), &resourcemanagerpb.CreateTagBindingRequest{ + TagBinding: &resourcemanagerpb.TagBinding{ + Parent: "//cloudresourcemanager.googleapis.com/" + proj.GetName(), TagValue: val.GetName(), + }, + }) + require.NoError(t, err) + + _, err = bind.Wait(t.Context()) + require.NoError(t, err) + + pol, err := c.projects.SetIamPolicy(t.Context(), &iampb.SetIamPolicyRequest{ + Resource: proj.GetName(), + Policy: &iampb.Policy{ + Bindings: []*iampb.Binding{{Role: "roles/viewer", Members: []string{"user:a@example.com"}}}, + }, + }) + require.NoError(t, err) + + en, err := c.usage.EnableService(t.Context(), &serviceusagepb.EnableServiceRequest{ + Name: svcName(proj.GetProjectId(), "run.googleapis.com"), + }) + require.NoError(t, err) + + _, err = en.Wait(t.Context()) + require.NoError(t, err) + + del, err := c.projects.DeleteProject(t.Context(), &resourcemanagerpb.DeleteProjectRequest{Name: proj.GetName()}) + require.NoError(t, err) + + _, err = del.Wait(t.Context()) + require.NoError(t, err) + + want, err := c.projects.GetProject(t.Context(), &resourcemanagerpb.GetProjectRequest{Name: proj.GetName()}) + require.NoError(t, err) + + snap := src.backend.Snapshot(t.Context()) + + dst := newEnv(t) + require.NoError(t, dst.backend.Restore(t.Context(), snap)) + + d := dst.grpc + + got, err := d.projects.GetProject(t.Context(), &resourcemanagerpb.GetProjectRequest{Name: proj.GetName()}) + require.NoError(t, err) + assert.True(t, proto.Equal(want, got), "project survives with state, parent, times and etag") + assert.Equal(t, resourcemanagerpb.Project_DELETE_REQUESTED, got.GetState()) + + gotFolder, err := d.folders.GetFolder(t.Context(), &resourcemanagerpb.GetFolderRequest{Name: folder.GetName()}) + require.NoError(t, err) + assert.Equal(t, "Persisted", gotFolder.GetDisplayName()) + + policy, err := d.projects.GetIamPolicy(t.Context(), &iampb.GetIamPolicyRequest{Resource: proj.GetName()}) + require.NoError(t, err) + assert.Equal(t, pol.GetEtag(), policy.GetEtag()) + + _, err = d.projects.SetIamPolicy(t.Context(), &iampb.SetIamPolicyRequest{ + Resource: proj.GetName(), Policy: &iampb.Policy{Etag: pol.GetEtag()}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "restored project is DELETE_REQUESTED") + + nsKey, err := d.tagKeys.GetNamespacedTagKey( + t.Context(), + &resourcemanagerpb.GetNamespacedTagKeyRequest{Name: "123456789012/persist"}, + ) + require.NoError(t, err) + assert.Equal(t, key.GetName(), nsKey.GetName()) + + _, err = d.bindings.ListTagBindings(t.Context(), &resourcemanagerpb.ListTagBindingsRequest{ + Parent: "//cloudresourcemanager.googleapis.com/" + proj.GetName(), + }).Next() + require.NoError(t, err) + + opGot, err := d.folders.GetOperation(t.Context(), &longrunningpb.GetOperationRequest{Name: bind.Name()}) + require.NoError(t, err) + assert.True(t, opGot.GetDone(), "operations survive") + + un, err := d.projects.UndeleteProject(t.Context(), &resourcemanagerpb.UndeleteProjectRequest{Name: proj.GetName()}) + require.NoError(t, err) + + _, err = un.Wait(t.Context()) + require.NoError(t, err) + + svc, err := d.usage.GetService( + t.Context(), + &serviceusagepb.GetServiceRequest{Name: svcName(proj.GetProjectId(), "run.googleapis.com")}, + ) + require.NoError(t, err) + assert.Equal(t, serviceusagepb.State_ENABLED, svc.GetState(), "enabled services survive") +} + +func TestRestoreRejectsOtherVersion(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.grpc.createProject(t, "before-restore", "") + + require.NoError(t, e.backend.Restore(t.Context(), []byte(`{"version":999}`))) + assert.Equal(t, []string{seedID}, e.backend.ProjectIDs(), "an incompatible snapshot resets to the seed") +} + +func TestSeedAndReset(t *testing.T) { + t.Parallel() + + b := gcpresourcemanager.NewBackend(gcpresourcemanager.Settings{ + OrganizationID: "999", Domain: "corp.example", SeedProjects: "first-proj, second-proj,,", + DeleteRetention: time.Hour, + }) + assert.Equal(t, []string{"first-proj", "second-proj"}, b.ProjectIDs()) + assert.Equal(t, "organizations/999", b.OrganizationName()) + + h := gcpresourcemanager.NewHandler(b) + h.Port = 0 + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(t.Context()) }) + + op, err := h.ProjectsAPI().CreateProject(t.Context(), &resourcemanagerpb.CreateProjectRequest{ + Project: &resourcemanagerpb.Project{ProjectId: "extra-proj", Parent: "organizations/999"}, + }) + require.NoError(t, err) + assert.True(t, op.GetDone()) + assert.Len(t, b.ProjectIDs(), 3) + + h.Reset() + assert.Equal(t, []string{"first-proj", "second-proj"}, b.ProjectIDs(), "reset restores the seed") + assert.Equal(t, "organizations/999", b.OrganizationName()) +} diff --git a/services/gcpresourcemanager/projects.go b/services/gcpresourcemanager/projects.go new file mode 100644 index 0000000000..b46000fe83 --- /dev/null +++ b/services/gcpresourcemanager/projects.go @@ -0,0 +1,456 @@ +package gcpresourcemanager + +import ( + "cmp" + "maps" + "slices" + "strings" + "time" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + permProjectGet = "resourcemanager.projects.get" + permProjectList = "resourcemanager.projects.list" + permProjectCreate = "resourcemanager.projects.create" + stateActive = "ACTIVE" + stateRequested = "DELETE_REQUESTED" +) + +// findProjectLocked resolves "projects/{number}", "projects/{id}" or a bare number or id. +func (b *InMemoryBackend) findProjectLocked(ref string) (*projectRec, bool) { + ref = strings.TrimPrefix(ref, kindProjects+"/") + if p, ok := b.projects[ref]; ok { + return p, true + } + + for _, p := range b.projects { + if p.ID == ref { + return p, true + } + } + + return nil, false +} + +func (b *InMemoryBackend) projectLocked(ref, perm string) (*projectRec, error) { + p, ok := b.findProjectLocked(ref) + if !ok { + return nil, errDenied(perm, kindProjects+"/"+strings.TrimPrefix(ref, kindProjects+"/")) + } + + return p, nil +} + +func (b *InMemoryBackend) activeProjectLocked(ref, perm string) (*projectRec, error) { + p, err := b.projectLocked(ref, perm) + if err != nil { + return nil, err + } + + if p.requested() { + return nil, errPrecondition("Project %s is being deleted (DELETE_REQUESTED).", p.ID) + } + + return p, nil +} + +// parentLocked checks that parent is the organization or an active folder; "" means no parent. +func (b *InMemoryBackend) parentLocked(parent, perm string) error { + switch { + case parent == "": + return nil + case strings.HasPrefix(parent, kindOrganizations+"/"): + if parent != kindOrganizations+"/"+b.org.ID { + return errDenied(perm, parent) + } + + return nil + case strings.HasPrefix(parent, kindFolders+"/"): + f, ok := b.folders[strings.TrimPrefix(parent, kindFolders+"/")] + if !ok { + return errDenied(perm, parent) + } + + if f.requested() { + return errPrecondition("Parent %s is in the DELETE_REQUESTED state.", parent) + } + + return nil + default: + return errInvalid("Invalid parent %q: use organizations/ID or folders/ID.", parent) + } +} + +func (b *InMemoryBackend) createProjectLocked(id, display, parent string, labels map[string]string) *projectRec { + now := b.now() + number := uniqueDigits(numberDigits, func(n string) bool { + _, ok := b.projects[n] + + return ok + }) + p := &projectRec{ + Number: number, ID: id, DisplayName: display, Parent: parent, Labels: maps.Clone(labels), + Created: now, Updated: now, + } + b.projects[number] = p + b.enableDefaultsLocked(number) + + return p +} + +// CreateProject creates a project; the returned operation is already done. +func (b *InMemoryBackend) CreateProject(in *resourcemanagerpb.CreateProjectRequest) (*longrunningpb.Operation, error) { + pr := in.GetProject() + if pr == nil { + return nil, errInvalid("project must be specified.") + } + + if err := validateProjectID(pr.GetProjectId()); err != nil { + return nil, err + } + + if err := validateProjectName(pr.GetDisplayName()); err != nil { + return nil, err + } + + if err := validateLabels(pr.GetLabels()); err != nil { + return nil, err + } + + b.mu.Lock("CreateProject") + defer b.mu.Unlock() + + b.purgeLocked() + + if _, ok := b.findProjectLocked(pr.GetProjectId()); ok { + return nil, errExists("Requested entity already exists: project %s.", pr.GetProjectId()) + } + + if err := b.parentLocked(pr.GetParent(), permProjectCreate); err != nil { + return nil, err + } + + display := pr.GetDisplayName() + if display == "" { + display = pr.GetProjectId() + } + + p := b.createProjectLocked(pr.GetProjectId(), display, pr.GetParent(), pr.GetLabels()) + + return b.opLocked("cp", &resourcemanagerpb.CreateProjectMetadata{ + CreateTime: ts(p.Created), Gettable: true, Ready: true, + }, p.view()) +} + +// GetProject returns a project by number or id, including one that is DELETE_REQUESTED. +func (b *InMemoryBackend) GetProject(name string) (*resourcemanagerpb.Project, error) { + b.mu.Lock("GetProject") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.projectLocked(name, permProjectGet) + if err != nil { + return nil, err + } + + return p.view(), nil +} + +// ListProjects lists the direct children of a folder or organization, ordered by display name. +func (b *InMemoryBackend) ListProjects(in *resourcemanagerpb.ListProjectsRequest) ( + *resourcemanagerpb.ListProjectsResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + if in.GetParent() == "" { + return nil, errInvalid("parent must be specified.") + } + + b.mu.Lock("ListProjects") + b.purgeLocked() + + if err := b.parentLocked(in.GetParent(), permProjectList); err != nil { + b.mu.Unlock() + + return nil, err + } + + var all []*projectRec + + for _, p := range b.projects { + if p.Parent == in.GetParent() && (in.GetShowDeleted() || !p.requested()) { + all = append(all, p) + } + } + + out := viewsOf(sortProjects(all)) + b.mu.Unlock() + + pg := page.New(out, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.ListProjectsResponse{Projects: pg.Data, NextPageToken: pg.Next}, nil +} + +func sortProjects(ps []*projectRec) []*projectRec { + slices.SortFunc(ps, func(a, c *projectRec) int { + return cmp.Or(cmp.Compare(a.DisplayName, c.DisplayName), cmp.Compare(a.ID, c.ID)) + }) + + return ps +} + +func viewsOf(ps []*projectRec) []*resourcemanagerpb.Project { + out := make([]*resourcemanagerpb.Project, 0, len(ps)) + for _, p := range ps { + out = append(out, p.view()) + } + + return out +} + +func (p *projectRec) fields(f string) ([]string, bool) { + switch f { + case fieldDisplayName, "name": + return []string{p.DisplayName}, true + case "id", "projectid": + return []string{p.ID}, true + case fieldState: + if p.requested() { + return []string{stateRequested}, true + } + + return []string{stateActive}, true + case "parent": + return []string{p.Parent}, true + case "parent.type", "parent.id": + kind, id, _ := strings.Cut(p.Parent, "/") + if f == "parent.id" { + return []string{id}, true + } + + return []string{strings.TrimSuffix(kind, "s")}, true + default: + return p.labelFields(f) + } +} + +func (p *projectRec) labelFields(f string) ([]string, bool) { + if f == fieldLabels { + vals := make([]string, 0, 2*len(p.Labels)) //nolint:mnd // key and value + for k, v := range p.Labels { + vals = append(vals, k, v) + } + + return vals, true + } + + key, ok := strings.CutPrefix(f, fieldLabels+".") + if !ok { + return nil, false + } + + if v, found := p.Labels[key]; found { + return []string{v}, true + } + + return nil, true +} + +// SearchProjects returns the projects matching any term of the query; ACTIVE ones unless the query names a state. +func (b *InMemoryBackend) SearchProjects(in *resourcemanagerpb.SearchProjectsRequest) ( + *resourcemanagerpb.SearchProjectsResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + q, err := parseQuery(in.GetQuery(), false) + if err != nil { + return nil, err + } + + b.mu.Lock("SearchProjects") + b.purgeLocked() + + var hits []*projectRec + + for _, p := range b.projects { + ok, mErr := q.match(p.fields) + if mErr != nil { + b.mu.Unlock() + + return nil, mErr + } + + if ok && (q.hasState || !p.requested()) { + hits = append(hits, p) + } + } + + out := viewsOf(sortProjects(hits)) + b.mu.Unlock() + + pg := page.New(out, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.SearchProjectsResponse{Projects: pg.Data, NextPageToken: pg.Next}, nil +} + +// maskHas reports whether mask updates field; a nil or empty mask updates every mutable field. +func maskHas(mask *fieldmaskpb.FieldMask, field string) bool { + if len(mask.GetPaths()) == 0 { + return true + } + + return slices.ContainsFunc(mask.GetPaths(), func(p string) bool { + return p == "*" || p == field || strings.HasPrefix(p, field+".") + }) +} + +func checkMask(mask *fieldmaskpb.FieldMask, allowed ...string) error { + for _, p := range mask.GetPaths() { + if p != "*" && + !slices.ContainsFunc(allowed, func(a string) bool { return p == a || strings.HasPrefix(p, a+".") }) { + return errInvalid("update_mask path %q cannot be updated; allowed: %s.", p, strings.Join(allowed, ", ")) + } + } + + return nil +} + +// UpdateProject changes the display name and labels of an ACTIVE project. +func (b *InMemoryBackend) UpdateProject(in *resourcemanagerpb.UpdateProjectRequest) (*longrunningpb.Operation, error) { + pr := in.GetProject() + if pr == nil || pr.GetName() == "" { + return nil, errInvalid("project.name must be specified.") + } + + if err := checkMask(in.GetUpdateMask(), "display_name", "labels"); err != nil { + return nil, err + } + + if err := validateProjectName(pr.GetDisplayName()); err != nil { + return nil, err + } + + if err := validateLabels(pr.GetLabels()); err != nil { + return nil, err + } + + b.mu.Lock("UpdateProject") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.activeProjectLocked(pr.GetName(), "resourcemanager.projects.update") + if err != nil { + return nil, err + } + + if et := pr.GetEtag(); et != "" && et != p.view().GetEtag() { + return nil, errAborted( + "The project %s was modified since it was read; re-read it and retry (etag mismatch).", + p.ID, + ) + } + + if maskHas(in.GetUpdateMask(), "display_name") && pr.GetDisplayName() != "" { + p.DisplayName = pr.GetDisplayName() + } + + if maskHas(in.GetUpdateMask(), "labels") { + p.Labels = maps.Clone(pr.GetLabels()) + } + + p.Updated = b.now() + + return b.opLocked("up", &resourcemanagerpb.UpdateProjectMetadata{}, p.view()) +} + +// MoveProject re-parents an ACTIVE project under the organization or an active folder. +func (b *InMemoryBackend) MoveProject(in *resourcemanagerpb.MoveProjectRequest) (*longrunningpb.Operation, error) { + if in.GetDestinationParent() == "" { + return nil, errInvalid("destination_parent must be specified.") + } + + b.mu.Lock("MoveProject") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.activeProjectLocked(in.GetName(), "resourcemanager.projects.move") + if err != nil { + return nil, err + } + + if err = b.parentLocked(in.GetDestinationParent(), "resourcemanager.projects.move"); err != nil { + return nil, err + } + + p.Parent = in.GetDestinationParent() + p.Updated = b.now() + + return b.opLocked("mp", &resourcemanagerpb.MoveProjectMetadata{}, p.view()) +} + +// DeleteProject marks an ACTIVE project DELETE_REQUESTED; deleting a requested project is a no-op. +func (b *InMemoryBackend) DeleteProject(in *resourcemanagerpb.DeleteProjectRequest) (*longrunningpb.Operation, error) { + b.mu.Lock("DeleteProject") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.projectLocked(in.GetName(), "resourcemanager.projects.delete") + if err != nil { + return nil, err + } + + if !p.requested() { + now := b.now() + p.Deleted, p.Updated = now, now + } + + view := p.view() + + if b.retention <= 0 { + b.dropProjectLocked(p.Number) + } + + return b.opLocked("dp", &resourcemanagerpb.DeleteProjectMetadata{}, view) +} + +// UndeleteProject restores a DELETE_REQUESTED project. +func (b *InMemoryBackend) UndeleteProject(in *resourcemanagerpb.UndeleteProjectRequest) ( + *longrunningpb.Operation, error, +) { + b.mu.Lock("UndeleteProject") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.projectLocked(in.GetName(), "resourcemanager.projects.undelete") + if err != nil { + return nil, err + } + + if !p.requested() { + return nil, errPrecondition("Project %s is not in the DELETE_REQUESTED state.", p.ID) + } + + if err = b.restorableParentLocked(p.Parent); err != nil { + return nil, err + } + + p.Deleted = time.Time{} + p.Updated = b.now() + + return b.opLocked("ud", &resourcemanagerpb.UndeleteProjectMetadata{}, p.view()) +} diff --git a/services/gcpresourcemanager/projects_test.go b/services/gcpresourcemanager/projects_test.go new file mode 100644 index 0000000000..bbb101f193 --- /dev/null +++ b/services/gcpresourcemanager/projects_test.go @@ -0,0 +1,434 @@ +package gcpresourcemanager_test + +import ( + "strings" + "testing" + "time" + + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +func TestCreateProjectValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + project *resourcemanagerpb.Project + name string + code codes.Code + }{ + {&resourcemanagerpb.Project{ProjectId: "tokyo-rain-123"}, "valid", codes.OK}, + { + &resourcemanagerpb.Project{ProjectId: "abcdef", DisplayName: "My Project!"}, + "valid with display name", + codes.OK, + }, + {&resourcemanagerpb.Project{ProjectId: "abcde"}, "min length", codes.InvalidArgument}, + { + &resourcemanagerpb.Project{ProjectId: "a" + strings.Repeat("b", 30)}, + "max length", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.Project{ProjectId: "a" + strings.Repeat("b", 29)}, + "thirty chars", + codes.OK, + }, + {&resourcemanagerpb.Project{ProjectId: "Tokyo-rain"}, "uppercase", codes.InvalidArgument}, + { + &resourcemanagerpb.Project{ProjectId: "1tokyo-rain"}, + "digit start", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.Project{ProjectId: "tokyo-rain-"}, + "trailing hyphen", + codes.InvalidArgument, + }, + {&resourcemanagerpb.Project{ProjectId: "tokyo_rain"}, "underscore", codes.InvalidArgument}, + { + &resourcemanagerpb.Project{ProjectId: "my-google-app"}, + "restricted google", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.Project{ProjectId: "my-ssl-app"}, + "restricted ssl", + codes.InvalidArgument, + }, + {&resourcemanagerpb.Project{}, "empty", codes.InvalidArgument}, + { + &resourcemanagerpb.Project{ProjectId: "proj-aaa", DisplayName: "abc"}, + "short display name", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.Project{ProjectId: "proj-bbb", DisplayName: "bad_name"}, + "bad display char", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.Project{ProjectId: "proj-ccc", Labels: map[string]string{"Env": "x"}}, + "bad label key", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.Project{ProjectId: "proj-ddd", Labels: map[string]string{"env": "A"}}, + "bad label value", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.Project{ProjectId: "proj-eee", Labels: map[string]string{"env": "prod", "k": ""}}, + "good labels", + codes.OK, + }, + { + &resourcemanagerpb.Project{ProjectId: "proj-fff", Parent: "organizations/1"}, + "unknown org", + codes.PermissionDenied, + }, + { + &resourcemanagerpb.Project{ProjectId: "proj-ggg", Parent: "folders/1"}, + "unknown folder", + codes.PermissionDenied, + }, + { + &resourcemanagerpb.Project{ProjectId: "proj-hhh", Parent: "projects/1"}, + "bad parent", + codes.InvalidArgument, + }, + {&resourcemanagerpb.Project{ProjectId: seedID}, "duplicate seed", codes.AlreadyExists}, + } + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + op, err := c.projects.CreateProject( + t.Context(), + &resourcemanagerpb.CreateProjectRequest{Project: tt.project}, + ) + if err == nil { + _, err = op.Wait(t.Context()) + } + + assert.Equal(t, tt.code, codeOf(err), "%v", err) + }) + } + }) +} + +func TestProjectLifecycle(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + id := "life-x" + p := c.createProject(t, id, orgName) + assert.Regexp(t, `^projects/\d{12}$`, p.GetName()) + assert.Equal(t, resourcemanagerpb.Project_ACTIVE, p.GetState()) + assert.Equal(t, orgName, p.GetParent()) + assert.NotNil(t, p.GetCreateTime()) + assert.NotEmpty(t, p.GetEtag()) + + byID, err := c.projects.GetProject(t.Context(), &resourcemanagerpb.GetProjectRequest{Name: "projects/" + id}) + require.NoError(t, err) + assert.Equal(t, p.GetName(), byID.GetName()) + + byNum, err := c.projects.GetProject(t.Context(), &resourcemanagerpb.GetProjectRequest{Name: p.GetName()}) + require.NoError(t, err) + assert.Equal(t, id, byNum.GetProjectId()) + + up, err := c.projects.UpdateProject(t.Context(), &resourcemanagerpb.UpdateProjectRequest{ + Project: &resourcemanagerpb.Project{ + Name: p.GetName(), + DisplayName: "Renamed", + Labels: map[string]string{"team": "a"}, + }, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name", "labels"}}, + }) + require.NoError(t, err) + + updated, err := up.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, "Renamed", updated.GetDisplayName()) + assert.Equal(t, map[string]string{"team": "a"}, updated.GetLabels()) + assert.NotEqual(t, p.GetEtag(), updated.GetEtag()) + + del, err := c.projects.DeleteProject(t.Context(), &resourcemanagerpb.DeleteProjectRequest{Name: p.GetName()}) + require.NoError(t, err) + + deleted, err := del.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, resourcemanagerpb.Project_DELETE_REQUESTED, deleted.GetState()) + assert.NotNil(t, deleted.GetDeleteTime()) + + again, err := c.projects.DeleteProject(t.Context(), &resourcemanagerpb.DeleteProjectRequest{Name: p.GetName()}) + require.NoError(t, err, "deleting a DELETE_REQUESTED project is idempotent") + + _, err = again.Wait(t.Context()) + require.NoError(t, err) + + _, err = c.projects.UpdateProject(t.Context(), &resourcemanagerpb.UpdateProjectRequest{ + Project: &resourcemanagerpb.Project{Name: p.GetName(), DisplayName: "Nope"}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err)) + + dup, err := c.projects.CreateProject(t.Context(), &resourcemanagerpb.CreateProjectRequest{ + Project: &resourcemanagerpb.Project{ProjectId: id}, + }) + if err == nil { + _, err = dup.Wait(t.Context()) + } + + assert.Equal(t, codes.AlreadyExists, codeOf(err), "a deleted project keeps its id") + + un, err := c.projects.UndeleteProject(t.Context(), &resourcemanagerpb.UndeleteProjectRequest{Name: p.GetName()}) + require.NoError(t, err) + + restored, err := un.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, resourcemanagerpb.Project_ACTIVE, restored.GetState()) + assert.Nil(t, restored.GetDeleteTime()) + + _, err = c.projects.UndeleteProject(t.Context(), &resourcemanagerpb.UndeleteProjectRequest{Name: p.GetName()}) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "undelete of an ACTIVE project") + }) +} + +func TestProjectNotFound(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + _, err := c.projects.GetProject( + t.Context(), + &resourcemanagerpb.GetProjectRequest{Name: "projects/does-not-exist"}, + ) + assert.Equal(t, codes.PermissionDenied, codeOf(err)) + assert.Contains(t, err.Error(), "or it may not exist") + }) +} + +func TestProjectRetentionPurge(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + retention time.Duration + advance time.Duration + code codes.Code + reuse bool + }{ + {"within retention", 24 * time.Hour, time.Hour, codes.OK, false}, + {"past retention", 24 * time.Hour, 25 * time.Hour, codes.PermissionDenied, true}, + {"zero purges at once", 0, 0, codes.PermissionDenied, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, withRetention(tt.retention)) + p := e.grpc.createProject(t, "purge-me", "") + + op, err := e.grpc.projects.DeleteProject( + t.Context(), + &resourcemanagerpb.DeleteProjectRequest{Name: p.GetName()}, + ) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.NoError(t, err) + + e.clock.Advance(tt.advance) + + _, err = e.grpc.projects.GetProject(t.Context(), &resourcemanagerpb.GetProjectRequest{Name: p.GetName()}) + assert.Equal(t, tt.code, codeOf(err)) + + if tt.reuse { + assert.NotEmpty( + t, + e.grpc.createProject(t, "purge-me", "").GetName(), + "a purged id can be created again", + ) + } + }) + } +} + +func TestProjectEtagConflict(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + p := c.createProject(t, "etag-x", "") + + _, err := c.projects.UpdateProject(t.Context(), &resourcemanagerpb.UpdateProjectRequest{ + Project: &resourcemanagerpb.Project{Name: p.GetName(), DisplayName: "Stale etag", Etag: "stale"}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name"}}, + }) + assert.Equal(t, codes.Aborted, codeOf(err)) + + op, err := c.projects.UpdateProject(t.Context(), &resourcemanagerpb.UpdateProjectRequest{ + Project: &resourcemanagerpb.Project{Name: p.GetName(), DisplayName: "Fresh etag", Etag: p.GetEtag()}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name"}}, + }) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.NoError(t, err) + }) +} + +func TestProjectUpdateMask(t *testing.T) { + t.Parallel() + + c := newEnv(t).grpc + p := c.createProject(t, "mask-proj", "") + + _, err := c.projects.UpdateProject(t.Context(), &resourcemanagerpb.UpdateProjectRequest{ + Project: &resourcemanagerpb.Project{Name: p.GetName(), Parent: orgName}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"parent"}}, + }) + assert.Equal(t, codes.InvalidArgument, codeOf(err)) + + op, err := c.projects.UpdateProject(t.Context(), &resourcemanagerpb.UpdateProjectRequest{ + Project: &resourcemanagerpb.Project{Name: p.GetName(), Labels: map[string]string{"a": "b"}}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"labels"}}, + }) + require.NoError(t, err) + + got, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, "Project mask-proj", got.GetDisplayName(), "display name is outside the mask") + + op, err = c.projects.UpdateProject(t.Context(), &resourcemanagerpb.UpdateProjectRequest{ + Project: &resourcemanagerpb.Project{Name: p.GetName()}, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"labels"}}, + }) + require.NoError(t, err) + + got, err = op.Wait(t.Context()) + require.NoError(t, err) + assert.Empty(t, got.GetLabels(), "an empty labels field under the mask clears labels") +} + +func TestProjectListSearchMove(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + folder := c.createFolder(t, orgName, "Team x") + a := c.createProject(t, "list-a-x", folder.GetName()) + b := c.createProject(t, "list-b-x", folder.GetName()) + _ = c.createProject(t, "list-c-x", orgName) + + it := c.projects.ListProjects(t.Context(), &resourcemanagerpb.ListProjectsRequest{Parent: folder.GetName()}) + + var got []string + + for { + p, err := it.Next() + if err != nil { + break + } + + got = append(got, p.GetProjectId()) + } + + assert.Equal(t, []string{a.GetProjectId(), b.GetProjectId()}, got, "ordered by display name") + + search := func(q string) []string { + var ids []string + + s := c.projects.SearchProjects(t.Context(), &resourcemanagerpb.SearchProjectsRequest{Query: q}) + + for { + p, err := s.Next() + if err != nil { + return ids + } + + ids = append(ids, p.GetProjectId()) + } + } + + assert.Equal(t, []string{"list-a-x"}, search("id:list-a-x")) + assert.ElementsMatch(t, []string{a.GetProjectId(), b.GetProjectId()}, search("parent:"+folder.GetName())) + assert.ElementsMatch(t, []string{"list-a-x", "list-c-x"}, search("id:list-a-x id:list-c-x"), "terms match any") + assert.Equal(t, []string{"list-b-x"}, search(`displayName:"Project list-b*"`)) + + mv, mvErr := c.projects.MoveProject(t.Context(), &resourcemanagerpb.MoveProjectRequest{ + Name: a.GetName(), DestinationParent: orgName, + }) + require.NoError(t, mvErr) + + moved, mvErr := mv.Wait(t.Context()) + require.NoError(t, mvErr) + assert.Equal(t, orgName, moved.GetParent()) + + _, mvErr = c.projects.MoveProject(t.Context(), &resourcemanagerpb.MoveProjectRequest{ + Name: a.GetName(), DestinationParent: "folders/1", + }) + assert.Equal(t, codes.PermissionDenied, codeOf(mvErr)) + + _, mvErr = c.projects.ListProjects(t.Context(), &resourcemanagerpb.ListProjectsRequest{}).Next() + assert.Equal(t, codes.InvalidArgument, codeOf(mvErr)) + + _, mvErr = c.projects.SearchProjects(t.Context(), &resourcemanagerpb.SearchProjectsRequest{Query: "bogus:x"}). + Next() + assert.Equal(t, codes.InvalidArgument, codeOf(mvErr)) + }) +} + +func TestSearchProjectsStateAndDeleted(t *testing.T) { + t.Parallel() + + c := newEnv(t).grpc + p := c.createProject(t, "gone-soon", orgName) + + op, err := c.projects.DeleteProject(t.Context(), &resourcemanagerpb.DeleteProjectRequest{Name: p.GetName()}) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.NoError(t, err) + + count := func(parent, q string, show bool) int { + n := 0 + + if q != "" { + it := c.projects.SearchProjects(t.Context(), &resourcemanagerpb.SearchProjectsRequest{Query: q}) + for _, nErr := it.Next(); nErr == nil; _, nErr = it.Next() { + n++ + } + + return n + } + + it := c.projects.ListProjects( + t.Context(), + &resourcemanagerpb.ListProjectsRequest{Parent: parent, ShowDeleted: show}, + ) + for _, nErr := it.Next(); nErr == nil; _, nErr = it.Next() { + n++ + } + + return n + } + + assert.Equal(t, 0, count(orgName, "", false), "list hides deleted by default") + assert.Equal(t, 1, count(orgName, "", true), "show_deleted lists it") + assert.Equal(t, 0, count("", "id:gone-soon", false), "search hides deleted by default") + assert.Equal(t, 1, count("", "id:gone-soon state:DELETE_REQUESTED", false), "a state term widens the search") +} diff --git a/services/gcpresourcemanager/provider.go b/services/gcpresourcemanager/provider.go new file mode 100644 index 0000000000..abaf1df8f2 --- /dev/null +++ b/services/gcpresourcemanager/provider.go @@ -0,0 +1,51 @@ +package gcpresourcemanager + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("gcpresourcemanager: nil app context") + +// ConfigProvider extracts Resource Manager configuration from the AppContext Config. +type ConfigProvider interface { + GetGCPResourceManagerSettings() Settings +} + +// EnforceConfigProvider extracts the opt-in GCP enforcement switches from the AppContext Config. +type EnforceConfigProvider interface { + GetGCPEnforceSettings() gcpenforce.Settings +} + +// Provider implements service.Provider for Resource Manager and Service Usage. It serves gRPC and REST on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "GCPResourceManager" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetGCPResourceManagerSettings() + } + + handler := NewHandler(NewBackend(settings)) + handler.Port = settings.Port + + if ep, ok := ctx.Config.(EnforceConfigProvider); ok { + handler.SetEnforcement(ep.GetGCPEnforceSettings()) + } + + return handler, nil +} diff --git a/services/gcpresourcemanager/query.go b/services/gcpresourcemanager/query.go new file mode 100644 index 0000000000..7f4bbac3eb --- /dev/null +++ b/services/gcpresourcemanager/query.go @@ -0,0 +1,270 @@ +package gcpresourcemanager + +import ( + "slices" + "strings" + "unicode" +) + +// fieldFunc returns the values of a searchable field; ok is false for an unknown field. +const ( + fieldDisplayName = "displayname" + fieldState = "state" + fieldLabels = "labels" + kwAnd = "AND" + kwOr = "OR" +) + +type fieldFunc func(field string) (vals []string, ok bool) + +type query struct { + root node + hasState bool +} + +type node interface { + eval(fields fieldFunc) (bool, error) +} + +type ( + termNode struct{ field, value string } + notNode struct{ n node } + binNode struct { + l, r node + and bool + } +) + +func (t termNode) eval(fields fieldFunc) (bool, error) { + if t.field == "" { + for _, f := range []string{fieldDisplayName, "id", fieldLabels} { + vals, _ := fields(f) + if slices.ContainsFunc(vals, func(v string) bool { return strings.Contains(strings.ToLower(v), t.value) }) { + return true, nil + } + } + + return false, nil + } + + vals, ok := fields(t.field) + if !ok { + return false, errInvalid("Invalid query: unknown field %q.", t.field) + } + + return slices.ContainsFunc(vals, func(v string) bool { return glob(t.value, strings.ToLower(v)) }), nil +} + +func (n notNode) eval(fields fieldFunc) (bool, error) { + v, err := n.n.eval(fields) + + return !v, err +} + +func (n binNode) eval(fields fieldFunc) (bool, error) { + l, err := n.l.eval(fields) + if err != nil { + return false, err + } + + if l == !n.and { + return l, nil + } + + return n.r.eval(fields) +} + +// glob matches s against pattern, where * stands for any run of characters. +func glob(pattern, s string) bool { + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == s + } + + if !strings.HasPrefix(s, parts[0]) { + return false + } + + s = s[len(parts[0]):] + + for _, mid := range parts[1 : len(parts)-1] { + i := strings.Index(s, mid) + if i < 0 { + return false + } + + s = s[i+len(mid):] + } + + return strings.HasSuffix(s, parts[len(parts)-1]) +} + +func tokenize(q string) []string { + var ( + out []string + cur strings.Builder + quoted bool + ) + + flush := func() { + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + } + + rs := []rune(q) + for i := 0; i < len(rs); i++ { + c := rs[i] + + switch { + case c == '\\' && i+1 < len(rs) && rs[i+1] == '"': + cur.WriteRune('"') + + i++ + case c == '"': + quoted = !quoted + case unicode.IsSpace(c) && !quoted: + flush() + default: + cur.WriteRune(c) + } + } + + flush() + + return out +} + +type parser struct { + toks []string + pos int + hasState bool + implicitAnd bool +} + +func (p *parser) peek() string { + if p.pos < len(p.toks) { + return p.toks[p.pos] + } + + return "" +} + +// parseQuery reads terms joined by AND, OR and NOT; terms with no operator between them combine with AND when +// implicitAnd is set and OR otherwise. +func parseQuery(q string, implicitAnd bool) (*query, error) { + toks := tokenize(q) + if len(toks) == 0 { + return &query{}, nil + } + + p := &parser{toks: toks, implicitAnd: implicitAnd} + + root, err := p.parseOr() + if err != nil { + return nil, err + } + + if p.pos < len(p.toks) { + return nil, errInvalid("Invalid query: unexpected %q.", p.peek()) + } + + return &query{root: root, hasState: p.hasState}, nil +} + +func (p *parser) parseOr() (node, error) { + l, err := p.parseAnd() + if err != nil { + return nil, err + } + + for p.pos < len(p.toks) { + explicit := p.peek() == kwOr + if !explicit && (p.implicitAnd || p.peek() == kwAnd) { + break + } + + if explicit { + p.pos++ + } + + r, rErr := p.parseAnd() + if rErr != nil { + return nil, rErr + } + + l = binNode{l: l, r: r} + } + + return l, nil +} + +func (p *parser) parseAnd() (node, error) { + l, err := p.parseNot() + if err != nil { + return nil, err + } + + for p.pos < len(p.toks) { + explicit := p.peek() == kwAnd + if !explicit && (!p.implicitAnd || p.peek() == kwOr) { + break + } + + if explicit { + p.pos++ + } + + r, rErr := p.parseNot() + if rErr != nil { + return nil, rErr + } + + l = binNode{l: l, r: r, and: true} + } + + return l, nil +} + +func (p *parser) parseNot() (node, error) { + if p.peek() == "NOT" { + p.pos++ + + n, err := p.parseNot() + if err != nil { + return nil, err + } + + return notNode{n}, nil + } + + tok := p.peek() + if tok == "" || tok == kwAnd || tok == kwOr { + return nil, errInvalid("Invalid query: expected a term.") + } + + p.pos++ + + field, value := "", tok + + if i := strings.IndexAny(tok, ":="); i > 0 { + field, value = strings.ToLower(tok[:i]), tok[i+1:] + } + + if field == fieldState || field == "lifecyclestate" { + p.hasState = true + field = fieldState + } + + return termNode{field: field, value: strings.ToLower(value)}, nil +} + +// match reports whether the query selects a resource with the given fields. An empty query selects all. +func (q *query) match(fields fieldFunc) (bool, error) { + if q.root == nil { + return true, nil + } + + return q.root.eval(fields) +} diff --git a/services/gcpresourcemanager/serviceusage.go b/services/gcpresourcemanager/serviceusage.go new file mode 100644 index 0000000000..237c79a152 --- /dev/null +++ b/services/gcpresourcemanager/serviceusage.go @@ -0,0 +1,456 @@ +package gcpresourcemanager + +import ( + "fmt" + "slices" + "strings" + "time" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" + "google.golang.org/genproto/googleapis/api/serviceconfig" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + maxBatchEnable = 20 + maxBatchGet = 30 + defaultSUPage = 50 + maxSUPage = 200 + suDomain = "googleapis.com" + reasonConsumer = "CONSUMER_INVALID" + reasonNoService = "SERVICE_CONFIG_NOT_FOUND_OR_PERMISSION_DENIED" + reasonDisabled = "SERVICE_DISABLED" + filterEnabled = "state:ENABLED" + filterDisabled = "state:DISABLED" + serviceUsageName = "serviceusage.googleapis.com" + mdService = "service" +) + +func suError(code codes.Code, msg, reason string, md map[string]string) error { + st := status.New(code, msg) + + withInfo, err := st.WithDetails(&errdetails.ErrorInfo{Reason: reason, Domain: suDomain, Metadata: md}) + if err != nil { + return st.Err() + } + + return withInfo.Err() +} + +func errConsumer(parent string) error { return errConsumerFor(parent, serviceUsageName) } + +func errConsumerFor(parent, service string) error { + return suError( + codes.PermissionDenied, + "Permission denied on resource project "+strings.TrimPrefix(parent, kindProjects+"/")+".", + reasonConsumer, + map[string]string{"consumer": parent, mdService: service}, + ) +} + +func errNoService(name string) error { + return suError( + codes.PermissionDenied, + "Permission denied to access service ["+name+"]; it was not found or you lack permission.", + reasonNoService, + map[string]string{mdService: name}, + ) +} + +func (b *InMemoryBackend) enableDefaultsLocked(number string) { + now := b.now() + set := map[string]time.Time{} + + for _, e := range catalog { + if e.defaultOn { + set[e.name] = now + } + } + + b.enabled[number] = set +} + +// enableLocked enables name and, first, every service it depends on. +func (b *InMemoryBackend) enableLocked(number, name string) { + set := b.enabled[number] + if set == nil { + set = map[string]time.Time{} + b.enabled[number] = set + } + + if _, on := set[name]; on { + return + } + + set[name] = b.now() + + if e, ok := lookupService(name); ok { + for _, d := range e.depends { + b.enableLocked(number, d) + } + } +} + +func (b *InMemoryBackend) consumerLocked(parent string) (*projectRec, error) { + if !strings.HasPrefix(parent, kindProjects+"/") { + if strings.HasPrefix(parent, kindFolders+"/") || strings.HasPrefix(parent, kindOrganizations+"/") { + return nil, errInvalid("Service Usage supports projects only, not %q.", parent) + } + + return nil, errInvalid("Invalid parent %q: use projects/PROJECT.", parent) + } + + p, ok := b.findProjectLocked(parent) + if !ok || p.requested() { + return nil, errConsumer(parent) + } + + return p, nil +} + +func splitServiceName(name string) (string, string, error) { + parent, service, ok := strings.Cut(name, "/services/") + if !ok || parent == "" || service == "" || strings.Contains(service, "/") || strings.Contains(parent, "/services") { + return "", "", errInvalid("Invalid service name %q: use projects/PROJECT/services/SERVICE.", name) + } + + return parent, service, nil +} + +func (b *InMemoryBackend) serviceView(p *projectRec, e catalogEntry) *serviceusagepb.Service { + state := serviceusagepb.State_DISABLED + if _, on := b.enabled[p.Number][e.name]; on { + state = serviceusagepb.State_ENABLED + } + + return &serviceusagepb.Service{ + Name: kindProjects + "/" + p.Number + "/services/" + e.name, + Parent: kindProjects + "/" + p.Number, + State: state, + Config: &serviceusagepb.ServiceConfig{ + Name: e.name, Title: e.title, + Documentation: &serviceconfig.Documentation{Summary: e.summary}, + Endpoints: []*serviceconfig.Endpoint{{Name: e.name}}, + }, + } +} + +func (b *InMemoryBackend) suOpLocked(p *projectRec, names []string, resp proto.Message, +) (*longrunningpb.Operation, error) { + resources := make([]string, len(names)) + for i, n := range names { + resources[i] = kindProjects + "/" + p.Number + "/services/" + n + } + + return b.opLocked("acat", &serviceusagepb.OperationMetadata{ResourceNames: resources}, resp) +} + +// GetService returns a service's state for a project. +func (b *InMemoryBackend) GetService(name string) (*serviceusagepb.Service, error) { + parent, svc, err := splitServiceName(name) + if err != nil { + return nil, err + } + + b.mu.Lock("GetService") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.consumerLocked(parent) + if err != nil { + return nil, err + } + + e, ok := lookupService(svc) + if !ok { + return nil, errNoService(svc) + } + + return b.serviceView(p, e), nil +} + +// BatchGetServices returns the state of up to 30 services of one project. +func (b *InMemoryBackend) BatchGetServices(in *serviceusagepb.BatchGetServicesRequest) ( + *serviceusagepb.BatchGetServicesResponse, error, +) { + if len(in.GetNames()) == 0 { + return nil, errInvalid("names must not be empty.") + } + + if len(in.GetNames()) > maxBatchGet { + return nil, errInvalid("A single request can get at most %d services.", maxBatchGet) + } + + b.mu.Lock("BatchGetServices") + defer b.mu.Unlock() + + b.purgeLocked() + + var ( + owner *projectRec + out = make([]*serviceusagepb.Service, 0, len(in.GetNames())) + ) + + for _, n := range in.GetNames() { + parent, svc, err := splitServiceName(n) + if err != nil { + return nil, err + } + + if in.GetParent() != "" && in.GetParent() != parent { + return nil, errInvalid("Service %q does not belong to parent %q.", n, in.GetParent()) + } + + p, err := b.consumerLocked(parent) + if err != nil { + return nil, err + } + + if owner != nil && owner != p { + return nil, errInvalid("All services must belong to the same project.") + } + + owner = p + + e, ok := lookupService(svc) + if !ok { + return nil, errNoService(svc) + } + + out = append(out, b.serviceView(p, e)) + } + + return &serviceusagepb.BatchGetServicesResponse{Services: out}, nil +} + +// ListServices lists the catalog's services with their state for a project, ordered by name. +func (b *InMemoryBackend) ListServices(in *serviceusagepb.ListServicesRequest) ( + *serviceusagepb.ListServicesResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + filter := strings.TrimSpace(in.GetFilter()) + if filter != "" && filter != filterEnabled && filter != filterDisabled { + return nil, errInvalid( + "Invalid filter %q: the allowed filters are %s and %s.", + filter, + filterEnabled, + filterDisabled, + ) + } + + b.mu.Lock("ListServices") + b.purgeLocked() + + p, err := b.consumerLocked(in.GetParent()) + if err != nil { + b.mu.Unlock() + + return nil, err + } + + all := make([]*serviceusagepb.Service, 0, len(catalog)) + + for _, name := range CatalogServices() { + e, _ := lookupService(name) + sv := b.serviceView(p, e) + + if filter == "" || (filter == filterEnabled) == (sv.GetState() == serviceusagepb.State_ENABLED) { + all = append(all, sv) + } + } + + b.mu.Unlock() + + pg := page.New(all, in.GetPageToken(), int(min(in.GetPageSize(), maxSUPage)), defaultSUPage) + + return &serviceusagepb.ListServicesResponse{Services: pg.Data, NextPageToken: pg.Next}, nil +} + +// EnableService enables a service and its dependencies; enabling an enabled service succeeds. +func (b *InMemoryBackend) EnableService(in *serviceusagepb.EnableServiceRequest) (*longrunningpb.Operation, error) { + parent, svc, err := splitServiceName(in.GetName()) + if err != nil { + return nil, err + } + + b.mu.Lock("EnableService") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.consumerLocked(parent) + if err != nil { + return nil, err + } + + e, ok := lookupService(svc) + if !ok { + return nil, errNoService(svc) + } + + b.enableLocked(p.Number, svc) + + return b.suOpLocked(p, []string{svc}, &serviceusagepb.EnableServiceResponse{Service: b.serviceView(p, e)}) +} + +// BatchEnableServices enables up to 20 services atomically: one unknown service fails the whole batch. +func (b *InMemoryBackend) BatchEnableServices(in *serviceusagepb.BatchEnableServicesRequest) ( + *longrunningpb.Operation, error, +) { + ids := in.GetServiceIds() + if len(ids) == 0 { + return nil, errInvalid("service_ids must not be empty.") + } + + if len(ids) > maxBatchEnable { + return nil, errInvalid("A single request can enable at most %d services.", maxBatchEnable) + } + + b.mu.Lock("BatchEnableServices") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.consumerLocked(in.GetParent()) + if err != nil { + return nil, err + } + + var unknown []string + + for _, id := range ids { + if _, ok := lookupService(id); !ok { + unknown = append(unknown, id) + } + } + + if len(unknown) > 0 { + return nil, suError(codes.PermissionDenied, + "Not found or permission denied for service(s): "+strings.Join(unknown, ", ")+".", + reasonNoService, map[string]string{mdService: strings.Join(unknown, ",")}) + } + + out := make([]*serviceusagepb.Service, 0, len(ids)) + + for _, id := range ids { + b.enableLocked(p.Number, id) + + e, _ := lookupService(id) + out = append(out, b.serviceView(p, e)) + } + + return b.suOpLocked(p, ids, &serviceusagepb.BatchEnableServicesResponse{Services: out}) +} + +// DisableService disables an enabled service, and its enabled dependents when asked to. +func (b *InMemoryBackend) DisableService(in *serviceusagepb.DisableServiceRequest) (*longrunningpb.Operation, error) { + parent, svc, err := splitServiceName(in.GetName()) + if err != nil { + return nil, err + } + + b.mu.Lock("DisableService") + defer b.mu.Unlock() + + b.purgeLocked() + + p, err := b.consumerLocked(parent) + if err != nil { + return nil, err + } + + e, ok := lookupService(svc) + if !ok { + return nil, errNoService(svc) + } + + set := b.enabled[p.Number] + if _, on := set[svc]; !on { + return nil, errPrecondition("Service %s is not enabled on project %s.", svc, p.Number) + } + + var dependents []string + + for _, d := range dependentsOf(svc) { + if _, on := set[d]; on { + dependents = append(dependents, d) + } + } + + if len(dependents) > 0 && !in.GetDisableDependentServices() { + slices.Sort(dependents) + + return nil, errPrecondition("Not ready to disable service %s: these enabled services depend on it: %s. "+ + "Set disable_dependent_services to disable them together.", svc, strings.Join(dependents, ", ")) + } + + for _, d := range dependents { + b.disableTreeLocked(set, d) + } + + delete(set, svc) + + return b.suOpLocked(p, []string{svc}, &serviceusagepb.DisableServiceResponse{Service: b.serviceView(p, e)}) +} + +func (b *InMemoryBackend) disableTreeLocked(set map[string]time.Time, name string) { + for _, d := range dependentsOf(name) { + if _, on := set[d]; on { + b.disableTreeLocked(set, d) + } + } + + delete(set, name) +} + +// CheckServiceEnabled returns the SERVICE_DISABLED error Google APIs answer with when project has not enabled +// service, and nil when it has (or the service is not in the catalog). An unknown project is CONSUMER_INVALID. +func (b *InMemoryBackend) CheckServiceEnabled(project, service string) error { + b.mu.RLock("CheckServiceEnabled") + defer b.mu.RUnlock() + + p, ok := b.findProjectLocked(project) + if !ok || p.requested() { + return errConsumerFor(kindProjects+"/"+strings.TrimPrefix(project, kindProjects+"/"), service) + } + + e, known := lookupService(service) + if _, on := b.enabled[p.Number][service]; on || !known { + return nil + } + + url := fmt.Sprintf("https://console.developers.google.com/apis/api/%s/overview?project=%s", service, p.Number) + msg := fmt.Sprintf( + "%s has not been used in project %s before or it is disabled. Enable it by visiting %s then retry. "+ + "If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + e.title, + p.Number, + url, + ) + st := status.New(codes.PermissionDenied, msg) + + withDetails, err := st.WithDetails( + &errdetails.ErrorInfo{Reason: reasonDisabled, Domain: suDomain, Metadata: map[string]string{ + "containerInfo": p.Number, "consumer": kindProjects + "/" + p.Number, mdService: service, + "serviceTitle": e.title, "activationUrl": url, + }}, + &errdetails.Help{ + Links: []*errdetails.Help_Link{{Description: "Google developers console API activation", Url: url}}, + }, + ) + if err != nil { + return st.Err() + } + + return withDetails.Err() +} diff --git a/services/gcpresourcemanager/serviceusage_test.go b/services/gcpresourcemanager/serviceusage_test.go new file mode 100644 index 0000000000..c1b4a7ea2c --- /dev/null +++ b/services/gcpresourcemanager/serviceusage_test.go @@ -0,0 +1,329 @@ +package gcpresourcemanager_test + +import ( + "testing" + + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/services/gcpresourcemanager" +) + +func svcName(project, svc string) string { return "projects/" + project + "/services/" + svc } + +func listServices(t *testing.T, c *clients, parent, filter string) []*serviceusagepb.Service { + t.Helper() + + var out []*serviceusagepb.Service + + it := c.usage.ListServices(t.Context(), &serviceusagepb.ListServicesRequest{Parent: parent, Filter: filter}) + for s, err := it.Next(); err == nil; s, err = it.Next() { + out = append(out, s) + } + + return out +} + +func stateOf(t *testing.T, c *clients, svc string) serviceusagepb.State { + t.Helper() + + s, err := c.usage.GetService(t.Context(), &serviceusagepb.GetServiceRequest{Name: svcName(seedID, svc)}) + require.NoError(t, err) + + return s.GetState() +} + +func TestServiceUsageListAndGet(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + all := listServices(t, c, "projects/"+seedID, "") + assert.Len(t, all, len(gcpresourcemanager.CatalogServices())) + + enabled := listServices(t, c, "projects/"+seedID, "state:ENABLED") + disabled := listServices(t, c, "projects/"+seedID, "state:DISABLED") + assert.Len(t, enabled, len(all)-len(disabled)) + assert.NotEmpty(t, enabled, "API-created projects start with default services") + + for _, s := range enabled { + assert.Equal(t, serviceusagepb.State_ENABLED, s.GetState()) + } + + names := map[string]bool{} + for _, s := range disabled { + names[s.GetConfig().GetName()] = true + } + + for _, want := range []string{"compute.googleapis.com", "pubsub.googleapis.com", "run.googleapis.com", + "secretmanager.googleapis.com", "container.googleapis.com", "sqladmin.googleapis.com"} { + assert.True(t, names[want], want) + } + + got, err := c.usage.GetService(t.Context(), &serviceusagepb.GetServiceRequest{ + Name: svcName(seedID, "storage.googleapis.com"), + }) + require.NoError(t, err) + assert.Equal(t, serviceusagepb.State_ENABLED, got.GetState()) + assert.Equal(t, "Cloud Storage API", got.GetConfig().GetTitle()) + assert.Regexp( + t, + `^projects/\d{12}/services/storage\.googleapis\.com$`, + got.GetName(), + "name carries the project number", + ) + assert.Regexp(t, `^projects/\d{12}$`, got.GetParent()) + + _, err = c.usage.GetService( + t.Context(), + &serviceusagepb.GetServiceRequest{Name: svcName(seedID, "bogus.googleapis.com")}, + ) + assert.Equal(t, codes.PermissionDenied, codeOf(err)) + + _, err = c.usage.GetService( + t.Context(), + &serviceusagepb.GetServiceRequest{Name: svcName("nope-project", "storage.googleapis.com")}, + ) + assert.Equal(t, codes.PermissionDenied, codeOf(err)) + + it := c.usage.ListServices( + t.Context(), + &serviceusagepb.ListServicesRequest{Parent: "projects/" + seedID, Filter: "state:BOGUS"}, + ) + _, err = it.Next() + assert.Equal(t, codes.InvalidArgument, codeOf(err)) + }) +} + +func TestServiceUsageEnableDisable(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + const svc = "pubsub.googleapis.com" + + assert.Equal(t, serviceusagepb.State_DISABLED, stateOf(t, c, svc)) + + op, err := c.usage.EnableService(t.Context(), &serviceusagepb.EnableServiceRequest{Name: svcName(seedID, svc)}) + require.NoError(t, err) + + resp, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, serviceusagepb.State_ENABLED, resp.GetService().GetState()) + assert.Equal(t, serviceusagepb.State_ENABLED, stateOf(t, c, svc)) + + again, err := c.usage.EnableService( + t.Context(), + &serviceusagepb.EnableServiceRequest{Name: svcName(seedID, svc)}, + ) + require.NoError(t, err, "enabling an enabled service succeeds") + + _, err = again.Wait(t.Context()) + require.NoError(t, err) + + off, err := c.usage.DisableService( + t.Context(), + &serviceusagepb.DisableServiceRequest{Name: svcName(seedID, svc)}, + ) + require.NoError(t, err) + + dis, err := off.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, serviceusagepb.State_DISABLED, dis.GetService().GetState()) + + _, err = c.usage.DisableService(t.Context(), &serviceusagepb.DisableServiceRequest{Name: svcName(seedID, svc)}) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "disabling a disabled service") + + _, err = c.usage.EnableService( + t.Context(), + &serviceusagepb.EnableServiceRequest{Name: svcName(seedID, "bogus.googleapis.com")}, + ) + assert.Equal(t, codes.PermissionDenied, codeOf(err)) + + _, err = c.usage.EnableService(t.Context(), &serviceusagepb.EnableServiceRequest{Name: "folders/1/services/x"}) + assert.Equal(t, codes.InvalidArgument, codeOf(err), "only projects are supported") + }) +} + +func TestServiceUsageDependencies(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + en, err := c.usage.EnableService(t.Context(), &serviceusagepb.EnableServiceRequest{ + Name: svcName(seedID, "container.googleapis.com"), + }) + require.NoError(t, err) + + _, err = en.Wait(t.Context()) + require.NoError(t, err) + assert.Equal( + t, + serviceusagepb.State_ENABLED, + stateOf(t, c, "compute.googleapis.com"), + "dependency enabled with it", + ) + + _, err = c.usage.DisableService( + t.Context(), + &serviceusagepb.DisableServiceRequest{Name: svcName(seedID, "compute.googleapis.com")}, + ) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "an enabled service depends on it") + assert.Equal(t, serviceusagepb.State_ENABLED, stateOf(t, c, "compute.googleapis.com")) + + off, err := c.usage.DisableService(t.Context(), &serviceusagepb.DisableServiceRequest{ + Name: svcName(seedID, "compute.googleapis.com"), DisableDependentServices: true, + CheckIfServiceHasUsage: serviceusagepb.DisableServiceRequest_CHECK, + }) + require.NoError(t, err) + + _, err = off.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, serviceusagepb.State_DISABLED, stateOf(t, c, "compute.googleapis.com")) + assert.Equal( + t, + serviceusagepb.State_DISABLED, + stateOf(t, c, "container.googleapis.com"), + "dependent disabled too", + ) + }) +} + +func TestServiceUsageBatch(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + parent := "projects/" + seedID + + op, err := c.usage.BatchEnableServices(t.Context(), &serviceusagepb.BatchEnableServicesRequest{ + Parent: parent, ServiceIds: []string{"run.googleapis.com", "secretmanager.googleapis.com"}, + }) + require.NoError(t, err) + + resp, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Len(t, resp.GetServices(), 2) + + _, err = c.usage.BatchEnableServices(t.Context(), &serviceusagepb.BatchEnableServicesRequest{ + Parent: parent, ServiceIds: []string{"dns.googleapis.com", "bogus.googleapis.com"}, + }) + assert.Equal(t, codes.PermissionDenied, codeOf(err)) + assert.Equal( + t, + serviceusagepb.State_DISABLED, + stateOf(t, c, "dns.googleapis.com"), + "the batch is atomic", + ) + + tooMany := make([]string, 21) + for i := range tooMany { + tooMany[i] = "dns.googleapis.com" + } + + _, err = c.usage.BatchEnableServices( + t.Context(), + &serviceusagepb.BatchEnableServicesRequest{Parent: parent, ServiceIds: tooMany}, + ) + assert.Equal(t, codes.InvalidArgument, codeOf(err)) + + _, err = c.usage.BatchEnableServices(t.Context(), &serviceusagepb.BatchEnableServicesRequest{Parent: parent}) + assert.Equal(t, codes.InvalidArgument, codeOf(err)) + + got, err := c.usage.BatchGetServices(t.Context(), &serviceusagepb.BatchGetServicesRequest{ + Parent: parent, + Names: []string{svcName(seedID, "run.googleapis.com"), svcName(seedID, "dns.googleapis.com")}, + }) + require.NoError(t, err) + require.Len(t, got.GetServices(), 2) + assert.Equal(t, serviceusagepb.State_ENABLED, got.GetServices()[0].GetState()) + assert.Equal(t, serviceusagepb.State_DISABLED, got.GetServices()[1].GetState()) + + _, err = c.usage.BatchGetServices(t.Context(), &serviceusagepb.BatchGetServicesRequest{ + Parent: "projects/other", Names: []string{svcName(seedID, "run.googleapis.com")}, + }) + assert.Equal(t, codes.InvalidArgument, codeOf(err), "names must belong to the parent") + }) +} + +func TestServiceUsageDeletedProject(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + p := c.createProject(t, "su-deleted", "") + name := p.GetName() + "/services/storage.googleapis.com" + + _, err := c.usage.GetService(t.Context(), &serviceusagepb.GetServiceRequest{Name: name}) + require.NoError(t, err) + + op, err := c.projects.DeleteProject(t.Context(), &resourcemanagerpb.DeleteProjectRequest{Name: p.GetName()}) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.NoError(t, err) + + _, err = c.usage.GetService(t.Context(), &serviceusagepb.GetServiceRequest{Name: name}) + assert.Equal(t, codes.PermissionDenied, codeOf(err), "a DELETE_REQUESTED project is not a valid consumer") + }) +} + +func TestCheckServiceEnabled(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + tests := []struct { + name string + project string + service string + reason string + code codes.Code + }{ + {"enabled", seedID, "storage.googleapis.com", "", codes.OK}, + {"unknown service passes", seedID, "not-in-catalog.googleapis.com", "", codes.OK}, + {"disabled", seedID, "compute.googleapis.com", "SERVICE_DISABLED", codes.PermissionDenied}, + {"unknown project", "nope-nope", "storage.googleapis.com", "CONSUMER_INVALID", codes.PermissionDenied}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := e.backend.CheckServiceEnabled(tt.project, tt.service) + assert.Equal(t, tt.code, status.Code(err)) + + if tt.reason == "" { + return + } + + var info *errdetails.ErrorInfo + + for _, d := range status.Convert(err).Details() { + if i, ok := d.(*errdetails.ErrorInfo); ok { + info = i + } + } + + require.NotNil(t, info) + assert.Equal(t, tt.reason, info.GetReason()) + assert.Equal(t, "googleapis.com", info.GetDomain()) + + if tt.reason == "SERVICE_DISABLED" { + assert.Equal(t, tt.service, info.GetMetadata()["service"]) + assert.Regexp(t, `^\d{12}$`, info.GetMetadata()["containerInfo"]) + assert.Contains(t, status.Convert(err).Message(), "Compute Engine API has not been used in project") + } + }) + } +} diff --git a/services/gcpresourcemanager/settings.go b/services/gcpresourcemanager/settings.go new file mode 100644 index 0000000000..616b00df5b --- /dev/null +++ b/services/gcpresourcemanager/settings.go @@ -0,0 +1,33 @@ +package gcpresourcemanager + +import "time" + +const ( + // DefaultPort is the dedicated gRPC and REST listener's default TCP port. + DefaultPort = 8148 + // DefaultOrganizationID is the id of the single emulated organization. + DefaultOrganizationID = "123456789012" + // DefaultDomain is the display name (the domain) of the emulated organization. + DefaultDomain = "gopherstack.local" + // DefaultSeedProjects are the project ids created at startup. + DefaultSeedProjects = "gopherstack-test" + // DefaultDeleteRetention is how long a DELETE_REQUESTED project or folder stays restorable. + DefaultDeleteRetention = 30 * 24 * time.Hour +) + +// Settings holds service-level configuration, embedded in the root CLI command as CLI.GCPResourceManager. +type Settings struct { + OrganizationID string `json:"organizationId" env:"GCP_RESOURCEMANAGER_ORGANIZATION_ID" default:"123456789012" name:"organization-id" help:"Numeric id of the one emulated organization (organizations/ID)."` //nolint:lll // config struct tags are intentionally verbose + Domain string `json:"domain" env:"GCP_RESOURCEMANAGER_DOMAIN" default:"gopherstack.local" name:"domain" help:"Display name (domain) of the emulated organization."` //nolint:lll // config struct tags are intentionally verbose + SeedProjects string `json:"seedProjects" env:"GCP_RESOURCEMANAGER_SEED_PROJECTS" default:"gopherstack-test" name:"seed-projects" help:"Comma-separated project ids created at startup, with no parent, so they can be read and used for IAM and Service Usage."` //nolint:lll // config struct tags are intentionally verbose + DeleteRetention time.Duration `json:"deleteRetention" env:"GCP_RESOURCEMANAGER_DELETE_RETENTION" default:"720h" name:"delete-retention" help:"How long a DELETE_REQUESTED project or folder can be undeleted before it is purged (GCP: about 30 days). 0 purges at once and frees the id."` //nolint:lll // config struct tags are intentionally verbose + Port int `json:"port" env:"GCP_RESOURCEMANAGER_PORT" default:"8148" name:"port" help:"Fixed TCP port for the dedicated Resource Manager and Service Usage gRPC and REST listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { + return Settings{ + OrganizationID: DefaultOrganizationID, Domain: DefaultDomain, SeedProjects: DefaultSeedProjects, + DeleteRetention: DefaultDeleteRetention, Port: DefaultPort, + } +} diff --git a/services/gcpresourcemanager/tags.go b/services/gcpresourcemanager/tags.go new file mode 100644 index 0000000000..38cc3479fd --- /dev/null +++ b/services/gcpresourcemanager/tags.go @@ -0,0 +1,658 @@ +package gcpresourcemanager + +import ( + "cmp" + "net/url" + "slices" + "strings" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const resourcePrefix = "//" + apiHost + "/" + +func (k *tagKeyRec) view(ns string) *resourcemanagerpb.TagKey { + return &resourcemanagerpb.TagKey{ + Name: kindTagKeys + "/" + k.ID, Parent: k.Parent, ShortName: k.ShortName, NamespacedName: ns, + Description: k.Description, CreateTime: ts(k.Created), UpdateTime: ts(k.Updated), + Etag: etagOf(k.ID, k.Parent, k.ShortName+k.Description, k.Updated, k.PurposeData), + Purpose: resourcemanagerpb.Purpose(k.Purpose), PurposeData: k.PurposeData, + } +} + +func (v *tagValueRec) view(ns string) *resourcemanagerpb.TagValue { + return &resourcemanagerpb.TagValue{ + Name: kindTagValues + "/" + v.ID, Parent: v.Parent, ShortName: v.ShortName, NamespacedName: ns, + Description: v.Description, CreateTime: ts(v.Created), UpdateTime: ts(v.Updated), + Etag: etagOf(v.ID, v.Parent, v.ShortName+v.Description, v.Updated, nil), + } +} + +// keyNamespaceLocked is "{org id or project id}/{short name}". +func (b *InMemoryBackend) keyNamespaceLocked(k *tagKeyRec) string { + owner := strings.TrimPrefix(k.Parent, kindOrganizations+"/") + + if num, ok := strings.CutPrefix(k.Parent, kindProjects+"/"); ok { + if p, found := b.projects[num]; found { + owner = p.ID + } + } + + return owner + "/" + k.ShortName +} + +func (b *InMemoryBackend) valueNamespaceLocked(v *tagValueRec) string { + if v == nil { + return "" + } + + k := b.tagKeys[strings.TrimPrefix(v.Parent, kindTagKeys+"/")] + if k == nil { + return v.ShortName + } + + return b.keyNamespaceLocked(k) + "/" + v.ShortName +} + +func (b *InMemoryBackend) tagKeyLocked(name string) (*tagKeyRec, error) { + id, ok := splitName(name, kindTagKeys) + if !ok { + return nil, errInvalid("Invalid tag key name %q: use tagKeys/ID.", name) + } + + k, found := b.tagKeys[id] + if !found { + return nil, errDenied("resourcemanager.tagKeys.get", name) + } + + return k, nil +} + +func (b *InMemoryBackend) tagValueLocked(name string) (*tagValueRec, error) { + id, ok := splitName(name, kindTagValues) + if !ok { + return nil, errInvalid("Invalid tag value name %q: use tagValues/ID.", name) + } + + v, found := b.tagValues[id] + if !found { + return nil, errDenied("resourcemanager.tagValues.get", name) + } + + return v, nil +} + +// normalizeTagParentLocked resolves a tag key parent (organization or project) to its stored form. +func (b *InMemoryBackend) normalizeTagParentLocked(parent string) (string, error) { + switch { + case strings.HasPrefix(parent, kindOrganizations+"/"): + if err := b.parentLocked(parent, "resourcemanager.tagKeys.create"); err != nil { + return "", err + } + + return parent, nil + case strings.HasPrefix(parent, kindProjects+"/"): + p, err := b.activeProjectLocked(parent, "resourcemanager.tagKeys.create") + if err != nil { + return "", err + } + + return kindProjects + "/" + p.Number, nil + default: + return "", errInvalid("A tag key's parent must be organizations/ID or projects/ID, not %q.", parent) + } +} + +// CreateTagKey creates a tag key under the organization or a project. +func (b *InMemoryBackend) CreateTagKey(in *resourcemanagerpb.CreateTagKeyRequest) (*longrunningpb.Operation, error) { + tk := in.GetTagKey() + if tk == nil { + return nil, errInvalid("tag_key must be specified.") + } + + if err := validateTagName("tag key", tk.GetShortName()); err != nil { + return nil, err + } + + if err := validateDescription(tk.GetDescription()); err != nil { + return nil, err + } + + if tk.GetPurpose() == resourcemanagerpb.Purpose_GCE_FIREWALL && tk.GetPurposeData()["network"] == "" { + return nil, errInvalid("purpose_data must name the network for the GCE_FIREWALL purpose.") + } + + b.mu.Lock("CreateTagKey") + defer b.mu.Unlock() + + b.purgeLocked() + + parent, err := b.normalizeTagParentLocked(tk.GetParent()) + if err != nil { + return nil, err + } + + for _, k := range b.tagKeys { + if k.Parent == parent && k.ShortName == tk.GetShortName() { + return nil, errExists("Requested entity already exists: tag key %s under %s.", tk.GetShortName(), parent) + } + } + + now := b.now() + k := &tagKeyRec{ + ID: uniqueDigits(tagIDDigits, func(n string) bool { + _, ok := b.tagKeys[n] + + return ok + }), + Parent: parent, ShortName: tk.GetShortName(), Description: tk.GetDescription(), + Purpose: int32(tk.GetPurpose()), PurposeData: tk.GetPurposeData(), + Created: now, Updated: now, + } + + if !in.GetValidateOnly() { + b.tagKeys[k.ID] = k + } + + return b.opLocked("ck", &resourcemanagerpb.CreateTagKeyMetadata{}, k.view(b.keyNamespaceLocked(k))) +} + +// GetTagKey returns a tag key. +func (b *InMemoryBackend) GetTagKey(name string) (*resourcemanagerpb.TagKey, error) { + b.mu.RLock("GetTagKey") + defer b.mu.RUnlock() + + k, err := b.tagKeyLocked(name) + if err != nil { + return nil, err + } + + return k.view(b.keyNamespaceLocked(k)), nil +} + +// GetNamespacedTagKey finds a tag key by "{org id or project id}/{short name}". +func (b *InMemoryBackend) GetNamespacedTagKey(ns string) (*resourcemanagerpb.TagKey, error) { + b.mu.RLock("GetNamespacedTagKey") + defer b.mu.RUnlock() + + for _, k := range b.tagKeys { + if b.keyNamespaceLocked(k) == ns { + return k.view(ns), nil + } + } + + return nil, errDenied("resourcemanager.tagKeys.get", "tagKeys/"+ns) +} + +// ListTagKeys lists the tag keys of an organization or project. +func (b *InMemoryBackend) ListTagKeys(in *resourcemanagerpb.ListTagKeysRequest) ( + *resourcemanagerpb.ListTagKeysResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + b.mu.Lock("ListTagKeys") + b.purgeLocked() + + parent, err := b.normalizeTagParentLocked(in.GetParent()) + if err != nil { + b.mu.Unlock() + + return nil, err + } + + var hits []*tagKeyRec + + for _, k := range b.tagKeys { + if k.Parent == parent { + hits = append(hits, k) + } + } + + slices.SortFunc(hits, func(a, c *tagKeyRec) int { return cmp.Compare(a.ShortName, c.ShortName) }) + + out := make([]*resourcemanagerpb.TagKey, 0, len(hits)) + for _, k := range hits { + out = append(out, k.view(b.keyNamespaceLocked(k))) + } + + b.mu.Unlock() + + pg := page.New(out, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.ListTagKeysResponse{TagKeys: pg.Data, NextPageToken: pg.Next}, nil +} + +// UpdateTagKey changes a tag key's description. +// +//nolint:dupl // UpdateTagKey and UpdateTagValue mirror each other over different record types +func (b *InMemoryBackend) UpdateTagKey(in *resourcemanagerpb.UpdateTagKeyRequest) (*longrunningpb.Operation, error) { + tk := in.GetTagKey() + if err := validateTagUpdate(tk.GetName(), in.GetUpdateMask(), tk.GetDescription()); err != nil { + return nil, err + } + + b.mu.Lock("UpdateTagKey") + defer b.mu.Unlock() + + k, err := b.tagKeyLocked(tk.GetName()) + if err != nil { + return nil, err + } + + if err = checkEtag(tk.GetName(), tk.GetEtag(), k.view("").GetEtag()); err != nil { + return nil, err + } + + if !in.GetValidateOnly() { + k.Description, k.Updated = tk.GetDescription(), b.now() + } + + return b.opLocked("uk", &resourcemanagerpb.UpdateTagKeyMetadata{}, k.view(b.keyNamespaceLocked(k))) +} + +// validateTagUpdate checks the parts of a tag key or value update that need no state. +func validateTagUpdate(name string, mask *fieldmaskpb.FieldMask, description string) error { + if name == "" { + return errInvalid("the tag's name must be specified.") + } + + if err := checkMask(mask, "description"); err != nil { + return err + } + + return validateDescription(description) +} + +// checkEtag fails with ABORTED when a supplied etag is stale. +func checkEtag(name, supplied, current string) error { + if supplied != "" && supplied != current { + return errAborted("%s was modified since it was read (etag mismatch).", name) + } + + return nil +} + +// DeleteTagKey deletes a tag key that has no tag values. +// +//nolint:dupl // DeleteTagKey and DeleteTagValue mirror each other over different record types +func (b *InMemoryBackend) DeleteTagKey(in *resourcemanagerpb.DeleteTagKeyRequest) (*longrunningpb.Operation, error) { + b.mu.Lock("DeleteTagKey") + defer b.mu.Unlock() + + k, err := b.tagKeyLocked(in.GetName()) + if err != nil { + return nil, err + } + + if err = checkEtag(in.GetName(), in.GetEtag(), k.view("").GetEtag()); err != nil { + return nil, err + } + + for _, v := range b.tagValues { + if v.Parent == in.GetName() { + return nil, errPrecondition("Tag key %s still has tag values; delete them first.", in.GetName()) + } + } + + view := k.view(b.keyNamespaceLocked(k)) + + if !in.GetValidateOnly() { + delete(b.tagKeys, k.ID) + b.policies.Drop(in.GetName()) + } + + return b.opLocked("dk", &resourcemanagerpb.DeleteTagKeyMetadata{}, view) +} + +// CreateTagValue creates a tag value under a tag key. +func (b *InMemoryBackend) CreateTagValue(in *resourcemanagerpb.CreateTagValueRequest) ( + *longrunningpb.Operation, error, +) { + tv := in.GetTagValue() + if tv == nil { + return nil, errInvalid("tag_value must be specified.") + } + + if err := validateTagName("tag value", tv.GetShortName()); err != nil { + return nil, err + } + + if err := validateDescription(tv.GetDescription()); err != nil { + return nil, err + } + + b.mu.Lock("CreateTagValue") + defer b.mu.Unlock() + + k, err := b.tagKeyLocked(tv.GetParent()) + if err != nil { + return nil, err + } + + for _, v := range b.tagValues { + if v.Parent == tv.GetParent() && v.ShortName == tv.GetShortName() { + return nil, errExists( + "Requested entity already exists: tag value %s under %s.", + tv.GetShortName(), + tv.GetParent(), + ) + } + } + + now := b.now() + v := &tagValueRec{ + ID: uniqueDigits(tagIDDigits, func(n string) bool { + _, ok := b.tagValues[n] + + return ok + }), + Parent: kindTagKeys + "/" + k.ID, ShortName: tv.GetShortName(), Description: tv.GetDescription(), + Created: now, Updated: now, + } + + if !in.GetValidateOnly() { + b.tagValues[v.ID] = v + } + + return b.opLocked("cv", &resourcemanagerpb.CreateTagValueMetadata{}, v.view(b.valueNamespaceLocked(v))) +} + +// GetTagValue returns a tag value. +func (b *InMemoryBackend) GetTagValue(name string) (*resourcemanagerpb.TagValue, error) { + b.mu.RLock("GetTagValue") + defer b.mu.RUnlock() + + v, err := b.tagValueLocked(name) + if err != nil { + return nil, err + } + + return v.view(b.valueNamespaceLocked(v)), nil +} + +// GetNamespacedTagValue finds a tag value by "{org or project id}/{key}/{value}". +func (b *InMemoryBackend) GetNamespacedTagValue(ns string) (*resourcemanagerpb.TagValue, error) { + b.mu.RLock("GetNamespacedTagValue") + defer b.mu.RUnlock() + + for _, v := range b.tagValues { + if b.valueNamespaceLocked(v) == ns { + return v.view(ns), nil + } + } + + return nil, errDenied("resourcemanager.tagValues.get", "tagValues/"+ns) +} + +// ListTagValues lists the values of a tag key. +func (b *InMemoryBackend) ListTagValues(in *resourcemanagerpb.ListTagValuesRequest) ( + *resourcemanagerpb.ListTagValuesResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + b.mu.RLock("ListTagValues") + + if _, err := b.tagKeyLocked(in.GetParent()); err != nil { + b.mu.RUnlock() + + return nil, err + } + + var hits []*tagValueRec + + for _, v := range b.tagValues { + if v.Parent == in.GetParent() { + hits = append(hits, v) + } + } + + slices.SortFunc(hits, func(a, c *tagValueRec) int { return cmp.Compare(a.ShortName, c.ShortName) }) + + out := make([]*resourcemanagerpb.TagValue, 0, len(hits)) + for _, v := range hits { + out = append(out, v.view(b.valueNamespaceLocked(v))) + } + + b.mu.RUnlock() + + pg := page.New(out, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.ListTagValuesResponse{TagValues: pg.Data, NextPageToken: pg.Next}, nil +} + +// UpdateTagValue changes a tag value's description. +// +//nolint:dupl // UpdateTagValue and UpdateTagKey mirror each other over different record types +func (b *InMemoryBackend) UpdateTagValue(in *resourcemanagerpb.UpdateTagValueRequest) ( + *longrunningpb.Operation, error, +) { + tv := in.GetTagValue() + if err := validateTagUpdate(tv.GetName(), in.GetUpdateMask(), tv.GetDescription()); err != nil { + return nil, err + } + + b.mu.Lock("UpdateTagValue") + defer b.mu.Unlock() + + v, err := b.tagValueLocked(tv.GetName()) + if err != nil { + return nil, err + } + + if err = checkEtag(tv.GetName(), tv.GetEtag(), v.view("").GetEtag()); err != nil { + return nil, err + } + + if !in.GetValidateOnly() { + v.Description, v.Updated = tv.GetDescription(), b.now() + } + + return b.opLocked("uv", &resourcemanagerpb.UpdateTagValueMetadata{}, v.view(b.valueNamespaceLocked(v))) +} + +// DeleteTagValue deletes a tag value that is not bound to any resource. +// +//nolint:dupl // DeleteTagValue and DeleteTagKey mirror each other over different record types +func (b *InMemoryBackend) DeleteTagValue(in *resourcemanagerpb.DeleteTagValueRequest) ( + *longrunningpb.Operation, error, +) { + b.mu.Lock("DeleteTagValue") + defer b.mu.Unlock() + + v, err := b.tagValueLocked(in.GetName()) + if err != nil { + return nil, err + } + + if err = checkEtag(in.GetName(), in.GetEtag(), v.view("").GetEtag()); err != nil { + return nil, err + } + + for _, bd := range b.bindings { + if bd.TagValue == in.GetName() { + return nil, errPrecondition( + "Tag value %s is bound to a resource; delete the tag binding first.", + in.GetName(), + ) + } + } + + view := v.view(b.valueNamespaceLocked(v)) + + if !in.GetValidateOnly() { + delete(b.tagValues, v.ID) + b.policies.Drop(in.GetName()) + } + + return b.opLocked("dv", &resourcemanagerpb.DeleteTagValueMetadata{}, view) +} + +func bindingName(parent, tagValue string) string { + return kindTagBindings + "/" + url.PathEscape(parent) + "/" + tagValue +} + +func (r *bindingRec) view(name, ns string) *resourcemanagerpb.TagBinding { + return &resourcemanagerpb.TagBinding{Name: name, Parent: r.Parent, TagValue: r.TagValue, TagValueNamespacedName: ns} +} + +// bindableLocked checks that parent names an existing organization, folder or project. +func (b *InMemoryBackend) bindableLocked(parent string) error { + res, ok := strings.CutPrefix(parent, resourcePrefix) + if !ok { + return errInvalid("Invalid parent %q: use the full resource name //%s/projects/ID.", parent, apiHost) + } + + switch { + case strings.HasPrefix(res, kindProjects+"/"): + _, err := b.activeProjectLocked(res, "resourcemanager.tagBindings.create") + + return err + case strings.HasPrefix(res, kindFolders+"/"): + _, err := b.activeFolderLocked(res, "resourcemanager.tagBindings.create") + + return err + case strings.HasPrefix(res, kindOrganizations+"/"): + return b.parentLocked(res, "resourcemanager.tagBindings.create") + default: + return errInvalid("Tag bindings are supported on organizations, folders and projects, not %q.", parent) + } +} + +// CreateTagBinding binds a tag value to a resource; a resource holds one value per tag key. +func (b *InMemoryBackend) CreateTagBinding(in *resourcemanagerpb.CreateTagBindingRequest) ( + *longrunningpb.Operation, error, +) { + tb := in.GetTagBinding() + if tb == nil || tb.GetParent() == "" { + return nil, errInvalid("tag_binding.parent must be specified.") + } + + if tb.GetTagValue() != "" && tb.GetTagValueNamespacedName() != "" { + return nil, errInvalid("Only one of tag_value and tag_value_namespaced_name may be set.") + } + + b.mu.Lock("CreateTagBinding") + defer b.mu.Unlock() + + b.purgeLocked() + + if err := b.bindableLocked(tb.GetParent()); err != nil { + return nil, err + } + + value, err := b.bindingValueLocked(tb) + if err != nil { + return nil, err + } + + name := bindingName(tb.GetParent(), value.name) + if _, dup := b.bindings[name]; dup { + return nil, errExists("Requested entity already exists: tag binding %s.", name) + } + + for _, other := range b.bindings { + if other.Parent == tb.GetParent() && + b.tagValues[strings.TrimPrefix(other.TagValue, kindTagValues+"/")].Parent == value.rec.Parent { + return nil, errPrecondition("%s already has a value of tag key %s.", tb.GetParent(), value.rec.Parent) + } + } + + rec := &bindingRec{Parent: tb.GetParent(), TagValue: value.name} + if !in.GetValidateOnly() { + b.bindings[name] = rec + } + + return b.opLocked("cb", &resourcemanagerpb.CreateTagBindingMetadata{}, + rec.view(name, b.valueNamespaceLocked(value.rec))) +} + +type boundValue struct { + rec *tagValueRec + name string +} + +func (b *InMemoryBackend) bindingValueLocked(tb *resourcemanagerpb.TagBinding) (boundValue, error) { + if ns := tb.GetTagValueNamespacedName(); ns != "" { + for _, v := range b.tagValues { + if b.valueNamespaceLocked(v) == ns { + return boundValue{rec: v, name: kindTagValues + "/" + v.ID}, nil + } + } + + return boundValue{}, errDenied("resourcemanager.tagValues.get", "tagValues/"+ns) + } + + v, err := b.tagValueLocked(tb.GetTagValue()) + if err != nil { + return boundValue{}, err + } + + return boundValue{rec: v, name: kindTagValues + "/" + v.ID}, nil +} + +// DeleteTagBinding removes a tag binding. +func (b *InMemoryBackend) DeleteTagBinding(in *resourcemanagerpb.DeleteTagBindingRequest) ( + *longrunningpb.Operation, error, +) { + b.mu.Lock("DeleteTagBinding") + defer b.mu.Unlock() + + rec, ok := b.bindings[in.GetName()] + if !ok { + return nil, errDenied("resourcemanager.tagBindings.delete", in.GetName()) + } + + view := rec.view(in.GetName(), "") + delete(b.bindings, in.GetName()) + + return b.opLocked("db", &resourcemanagerpb.DeleteTagBindingMetadata{}, view) +} + +// ListTagBindings lists the tag bindings of a resource. +func (b *InMemoryBackend) ListTagBindings(in *resourcemanagerpb.ListTagBindingsRequest) ( + *resourcemanagerpb.ListTagBindingsResponse, error, +) { + if in.GetPageSize() < 0 { + return nil, errInvalid("page_size must not be negative.") + } + + if in.GetParent() == "" { + return nil, errInvalid("parent must be specified.") + } + + b.mu.RLock("ListTagBindings") + + names := make([]string, 0, len(b.bindings)) + + for n, r := range b.bindings { + if r.Parent == in.GetParent() { + names = append(names, n) + } + } + + slices.Sort(names) + + out := make([]*resourcemanagerpb.TagBinding, 0, len(names)) + + for _, n := range names { + r := b.bindings[n] + out = append( + out, + r.view(n, b.valueNamespaceLocked(b.tagValues[strings.TrimPrefix(r.TagValue, kindTagValues+"/")])), + ) + } + + b.mu.RUnlock() + + pg := page.New(out, in.GetPageToken(), int(in.GetPageSize()), defaultListPage) + + return &resourcemanagerpb.ListTagBindingsResponse{TagBindings: pg.Data, NextPageToken: pg.Next}, nil +} diff --git a/services/gcpresourcemanager/tags_test.go b/services/gcpresourcemanager/tags_test.go new file mode 100644 index 0000000000..98fc2ced33 --- /dev/null +++ b/services/gcpresourcemanager/tags_test.go @@ -0,0 +1,230 @@ +package gcpresourcemanager_test + +import ( + "strings" + "testing" + + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func mustTagKey(t *testing.T, c *clients, short string) *resourcemanagerpb.TagKey { + t.Helper() + + op, err := c.tagKeys.CreateTagKey(t.Context(), &resourcemanagerpb.CreateTagKeyRequest{ + TagKey: &resourcemanagerpb.TagKey{Parent: orgName, ShortName: short, Description: "d"}, + }) + require.NoError(t, err) + + k, err := op.Wait(t.Context()) + require.NoError(t, err) + + return k +} + +func mustTagValue(t *testing.T, c *clients, parent, short string) *resourcemanagerpb.TagValue { + t.Helper() + + op, err := c.tagVals.CreateTagValue(t.Context(), &resourcemanagerpb.CreateTagValueRequest{ + TagValue: &resourcemanagerpb.TagValue{Parent: parent, ShortName: short}, + }) + require.NoError(t, err) + + v, err := op.Wait(t.Context()) + require.NoError(t, err) + + return v +} + +func TestTagKeyValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + key *resourcemanagerpb.TagKey + name string + code codes.Code + }{ + {&resourcemanagerpb.TagKey{Parent: orgName, ShortName: "env"}, "valid", codes.OK}, + { + &resourcemanagerpb.TagKey{Parent: "projects/" + seedID, ShortName: "env"}, + "project parent", + codes.OK, + }, + { + &resourcemanagerpb.TagKey{Parent: orgName, ShortName: "a.b-c_d"}, + "dots and dashes", + codes.OK, + }, + {&resourcemanagerpb.TagKey{Parent: orgName}, "empty name", codes.InvalidArgument}, + { + &resourcemanagerpb.TagKey{Parent: orgName, ShortName: "-a"}, + "leading dash", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.TagKey{Parent: orgName, ShortName: strings.Repeat("a", 64)}, + "too long", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.TagKey{Parent: orgName, ShortName: "ld", Description: strings.Repeat("d", 257)}, + "long description", + codes.InvalidArgument, + }, + { + &resourcemanagerpb.TagKey{Parent: "folders/1", ShortName: "f"}, + "folder parent", + codes.InvalidArgument, + }, + {&resourcemanagerpb.TagKey{ + Parent: orgName, ShortName: "fw", Purpose: resourcemanagerpb.Purpose_GCE_FIREWALL, + }, "firewall needs network", codes.InvalidArgument}, + {&resourcemanagerpb.TagKey{ + Parent: orgName, ShortName: "fw2", Purpose: resourcemanagerpb.Purpose_GCE_FIREWALL, + PurposeData: map[string]string{"network": "p/n"}, + }, "firewall with network", codes.OK}, + } + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + op, err := c.tagKeys.CreateTagKey(t.Context(), &resourcemanagerpb.CreateTagKeyRequest{TagKey: tt.key}) + if err == nil { + _, err = op.Wait(t.Context()) + } + + assert.Equal(t, tt.code, codeOf(err), "%v", err) + }) + } + }) +} + +func TestTagLifecycle(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + key := mustTagKey(t, c, "env") + assert.Equal(t, "123456789012/env", key.GetNamespacedName()) + assert.Regexp(t, `^tagKeys/\d{15}$`, key.GetName()) + + _, err := c.tagKeys.CreateTagKey(t.Context(), &resourcemanagerpb.CreateTagKeyRequest{ + TagKey: &resourcemanagerpb.TagKey{Parent: orgName, ShortName: "env"}, + }) + assert.Equal(t, codes.AlreadyExists, codeOf(err)) + + byNS, err := c.tagKeys.GetNamespacedTagKey( + t.Context(), + &resourcemanagerpb.GetNamespacedTagKeyRequest{Name: "123456789012/env"}, + ) + require.NoError(t, err) + assert.Equal(t, key.GetName(), byNS.GetName()) + + val := mustTagValue(t, c, key.GetName(), "prod") + assert.Equal(t, "123456789012/env/prod", val.GetNamespacedName()) + + nsVal, err := c.tagVals.GetNamespacedTagValue( + t.Context(), + &resourcemanagerpb.GetNamespacedTagValueRequest{Name: val.GetNamespacedName()}, + ) + require.NoError(t, err) + assert.Equal(t, val.GetName(), nsVal.GetName()) + + _, err = c.tagKeys.DeleteTagKey(t.Context(), &resourcemanagerpb.DeleteTagKeyRequest{Name: key.GetName()}) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "key still has a value") + + proj := c.createProject(t, "tagged-proj", "") + resource := "//cloudresourcemanager.googleapis.com/" + proj.GetName() + + bind, err := c.bindings.CreateTagBinding(t.Context(), &resourcemanagerpb.CreateTagBindingRequest{ + TagBinding: &resourcemanagerpb.TagBinding{Parent: resource, TagValue: val.GetName()}, + }) + require.NoError(t, err) + + binding, err := bind.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, resource, binding.GetParent()) + assert.Equal(t, val.GetName(), binding.GetTagValue()) + assert.Equal(t, "123456789012/env/prod", binding.GetTagValueNamespacedName()) + + _, err = c.bindings.CreateTagBinding(t.Context(), &resourcemanagerpb.CreateTagBindingRequest{ + TagBinding: &resourcemanagerpb.TagBinding{Parent: resource, TagValue: val.GetName()}, + }) + assert.Equal(t, codes.AlreadyExists, codeOf(err)) + + other := mustTagValue(t, c, key.GetName(), "dev") + _, err = c.bindings.CreateTagBinding(t.Context(), &resourcemanagerpb.CreateTagBindingRequest{ + TagBinding: &resourcemanagerpb.TagBinding{Parent: resource, TagValue: other.GetName()}, + }) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "one value per key per resource") + + _, err = c.tagVals.DeleteTagValue(t.Context(), &resourcemanagerpb.DeleteTagValueRequest{Name: val.GetName()}) + assert.Equal(t, codes.FailedPrecondition, codeOf(err), "value is bound") + + it := c.bindings.ListTagBindings(t.Context(), &resourcemanagerpb.ListTagBindingsRequest{Parent: resource}) + listed, err := it.Next() + require.NoError(t, err) + assert.Equal(t, binding.GetName(), listed.GetName()) + + del, err := c.bindings.DeleteTagBinding( + t.Context(), + &resourcemanagerpb.DeleteTagBindingRequest{Name: binding.GetName()}, + ) + require.NoError(t, err) + require.NoError(t, del.Wait(t.Context())) + + for _, name := range []string{val.GetName(), other.GetName()} { + op, derr := c.tagVals.DeleteTagValue(t.Context(), &resourcemanagerpb.DeleteTagValueRequest{Name: name}) + require.NoError(t, derr) + + _, derr = op.Wait(t.Context()) + require.NoError(t, derr) + } + + op, err := c.tagKeys.DeleteTagKey(t.Context(), &resourcemanagerpb.DeleteTagKeyRequest{Name: key.GetName()}) + require.NoError(t, err) + + _, err = op.Wait(t.Context()) + require.NoError(t, err) + + _, err = c.tagKeys.GetTagKey(t.Context(), &resourcemanagerpb.GetTagKeyRequest{Name: key.GetName()}) + assert.Equal(t, codes.PermissionDenied, codeOf(err)) + }) +} + +func TestTagKeyUpdateAndEtag(t *testing.T) { + t.Parallel() + + eachTransport(t, func(t *testing.T, _ *env, c *clients) { + t.Helper() + + key := mustTagKey(t, c, "upd") + + _, err := c.tagKeys.UpdateTagKey(t.Context(), &resourcemanagerpb.UpdateTagKeyRequest{ + TagKey: &resourcemanagerpb.TagKey{Name: key.GetName(), Description: "new", Etag: "stale"}, + }) + assert.Equal(t, codes.Aborted, codeOf(err)) + + op, err := c.tagKeys.UpdateTagKey(t.Context(), &resourcemanagerpb.UpdateTagKeyRequest{ + TagKey: &resourcemanagerpb.TagKey{Name: key.GetName(), Description: "new", Etag: key.GetEtag()}, + }) + require.NoError(t, err) + + got, err := op.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, "new", got.GetDescription()) + + _, err = c.tagKeys.DeleteTagKey( + t.Context(), + &resourcemanagerpb.DeleteTagKeyRequest{Name: key.GetName(), Etag: key.GetEtag()}, + ) + assert.Equal(t, codes.Aborted, codeOf(err), "the etag changed with the update") + }) +} diff --git a/services/gcpresourcemanager/v1.go b/services/gcpresourcemanager/v1.go new file mode 100644 index 0000000000..b55c7012f6 --- /dev/null +++ b/services/gcpresourcemanager/v1.go @@ -0,0 +1,641 @@ +package gcpresourcemanager + +import ( + "encoding/json" + "io" + "net/http" + "regexp" + "strings" + "time" + + "cloud.google.com/go/iam/apiv1/iampb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/blackbirdworks/gopherstack/pkgs/gcprest" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + v1Projects = "/v1/projects" + v1Organizations = "/v1/organizations" + maxBody = 1 << 20 + v1Page = 500 + decimalBase = 10 + verbGetIam = ":getIamPolicy" + verbSetIam = ":setIamPolicy" + verbTestIam = ":testIamPermissions" +) + +// v1JSON is the v1 Project resource, which the Terraform provider still uses for projects and project IAM. +type v1Project struct { + Labels map[string]string `json:"labels,omitempty"` + Parent *v1ResourceID `json:"parent,omitempty"` + ProjectNumber string `json:"projectNumber,omitempty"` + ProjectID string `json:"projectId,omitempty"` + LifecycleState string `json:"lifecycleState,omitempty"` + Name string `json:"name,omitempty"` + CreateTime string `json:"createTime,omitempty"` +} + +type v1ResourceID struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type v1Organization struct { + Owner map[string]string `json:"owner,omitempty"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + CreationTime string `json:"creationTime,omitempty"` + LifecycleState string `json:"lifecycleState"` +} + +func v1Parent(parent string) *v1ResourceID { + kind, id, ok := strings.Cut(parent, "/") + if !ok { + return nil + } + + return &v1ResourceID{Type: strings.TrimSuffix(kind, "s"), ID: id} +} + +func v1ParentName(r *v1ResourceID) string { + if r == nil || r.ID == "" { + return "" + } + + return r.Type + "s/" + r.ID +} + +func toV1Project(p *resourcemanagerpb.Project) v1Project { + out := v1Project{ + ProjectNumber: strings.TrimPrefix(p.GetName(), kindProjects+"/"), ProjectID: p.GetProjectId(), + LifecycleState: p.GetState().String(), Name: p.GetDisplayName(), Labels: p.GetLabels(), + Parent: v1Parent(p.GetParent()), + } + + if t := p.GetCreateTime(); t != nil { + out.CreateTime = t.AsTime().Format(time.RFC3339Nano) + } + + return out +} + +type v1Handler struct{ b *InMemoryBackend } + +// matchV1 selects the requests of the Cloud Resource Manager v1 API. +func matchV1(r *http.Request) bool { + p := r.URL.Path + if strings.Contains(p, "/services") { + return false + } + + return p == v1Projects || strings.HasPrefix(p, v1Projects+"/") || strings.HasPrefix(p, v1Projects+":") || + strings.HasPrefix(p, v1Organizations) +} + +func writeV1Error(w http.ResponseWriter, err error) { + st, _ := status.FromError(err) + httpCode := gcprest.HTTPStatus(st.Code()) + + details := make([]json.RawMessage, 0, len(st.Proto().GetDetails())) + + for _, d := range st.Proto().GetDetails() { + if raw, mErr := protojson.Marshal(d); mErr == nil { + details = append(details, raw) + } + } + + writeV1JSON(w, httpCode, map[string]any{"error": map[string]any{ + "code": httpCode, + "message": st.Message(), + "status": strings.ToUpper(strings.ReplaceAll(codeName(st.Code()), " ", "_")), + "details": details, + }}) +} + +func codeName(c codes.Code) string { + switch c { + case codes.InvalidArgument: + return "INVALID_ARGUMENT" + case codes.NotFound: + return "NOT_FOUND" + case codes.AlreadyExists: + return "ALREADY_EXISTS" + case codes.PermissionDenied: + return "PERMISSION_DENIED" + case codes.FailedPrecondition: + return "FAILED_PRECONDITION" + case codes.Aborted: + return "ABORTED" + case codes.Unimplemented: + return "UNIMPLEMENTED" + default: + return "INTERNAL" + } +} + +func writeV1JSON(w http.ResponseWriter, code int, v any) { + raw, err := json.Marshal(v) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + writeV1Raw(w, code, raw) +} + +func writeV1Raw(w http.ResponseWriter, code int, raw []byte) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.WriteHeader(code) + _, _ = w.Write(raw) +} + +func writeV1Proto(w http.ResponseWriter, m proto.Message) { + raw, err := protojson.Marshal(m) + if err != nil { + writeV1Error(w, errInvalid("encode response: %v", err)) + + return + } + + writeV1Raw(w, http.StatusOK, raw) +} + +func readBody(r *http.Request, into any) error { + raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) + if err != nil { + return errInvalid("read request body: %v", err) + } + + if len(strings.TrimSpace(string(raw))) == 0 { + return nil + } + + if err = json.Unmarshal(raw, into); err != nil { + return errInvalid("Invalid JSON payload: %v", err) + } + + return nil +} + +func readProtoBody(r *http.Request, into proto.Message) error { + raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) + if err != nil { + return errInvalid("read request body: %v", err) + } + + if len(strings.TrimSpace(string(raw))) == 0 { + return nil + } + + if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, into); err != nil { + return errInvalid("Invalid JSON payload: %v", err) + } + + return nil +} + +var v1Route = regexp.MustCompile(`^(projects|organizations)(?:/([^/:]+))?(/billingInfo)?(?::(\w+))?$`) + +func (h v1Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m := v1Route.FindStringSubmatch(strings.TrimPrefix(r.URL.Path, "/v1/")) + if m == nil { + writeV1Error(w, status.Error(codes.NotFound, "unknown resource "+r.URL.Path)) + + return + } + + id, verb := m[2], "" + if m[4] != "" { + verb = ":" + m[4] + } + + var err error + + switch { + case m[3] != "" && m[1] == kindProjects && verb == "": + err = h.billingInfo(w, r, id) + case m[3] != "": + err = methodNotAllowed(r) + case m[1] == kindProjects: + err = h.projects(w, r, id, verb) + default: + err = h.organizations(w, r, id, verb) + } + + if err != nil { + writeV1Error(w, err) + } +} + +func methodNotAllowed(r *http.Request) error { + return status.Errorf(codes.NotFound, "no route for %s %s", r.Method, r.URL.Path) +} + +func (h v1Handler) projects(w http.ResponseWriter, r *http.Request, id, verb string) error { + switch { + case verb != "" && r.Method == http.MethodPost: + return h.projectVerb(w, r, id, verb) + case verb != "": + return methodNotAllowed(r) + case id == "": + return h.projectCollection(w, r) + default: + return h.projectItem(w, r, id) + } +} + +func (h v1Handler) projectCollection(w http.ResponseWriter, r *http.Request) error { + switch r.Method { + case http.MethodPost: + return h.createProject(w, r) + case http.MethodGet: + return h.listProjects(w, r) + default: + return methodNotAllowed(r) + } +} + +func (h v1Handler) projectItem(w http.ResponseWriter, r *http.Request, id string) error { + switch r.Method { + case http.MethodGet: + p, err := h.b.GetProject(id) + if err != nil { + return err + } + + writeV1JSON(w, http.StatusOK, toV1Project(p)) + + return nil + case http.MethodPut: + return h.updateProject(w, r, id) + case http.MethodDelete: + if _, err := h.b.DeleteProject(&resourcemanagerpb.DeleteProjectRequest{Name: id}); err != nil { + return err + } + + writeV1Raw(w, http.StatusOK, []byte("{}")) + + return nil + default: + return methodNotAllowed(r) + } +} + +func (h v1Handler) projectVerb(w http.ResponseWriter, r *http.Request, id, verb string) error { + switch verb { + case ":undelete": + if _, err := h.b.UndeleteProject(&resourcemanagerpb.UndeleteProjectRequest{Name: id}); err != nil { + return err + } + + writeV1Raw(w, http.StatusOK, []byte("{}")) + + return nil + case ":getAncestry": + return h.ancestry(w, id) + case verbGetIam, verbSetIam, verbTestIam: + return h.iam(w, r, "projects/"+id, verb) + default: + return methodNotAllowed(r) + } +} + +func (h v1Handler) createProject(w http.ResponseWriter, r *http.Request) error { + var in v1Project + if err := readBody(r, &in); err != nil { + return err + } + + op, err := h.b.CreateProject(&resourcemanagerpb.CreateProjectRequest{Project: &resourcemanagerpb.Project{ + ProjectId: in.ProjectID, DisplayName: in.Name, Labels: in.Labels, Parent: v1ParentName(in.Parent), + }}) + if err != nil { + return err + } + + writeV1Proto(w, op) + + return nil +} + +func (h v1Handler) updateProject(w http.ResponseWriter, r *http.Request, id string) error { + var in v1Project + if err := readBody(r, &in); err != nil { + return err + } + + cur, err := h.b.GetProject(id) + if err != nil { + return err + } + + if in.ProjectID != "" && in.ProjectID != cur.GetProjectId() { + return errInvalid("project_id is immutable.") + } + + if parent := v1ParentName(in.Parent); parent != cur.GetParent() { + if parent == "" { + return errInvalid("The parent of a project cannot be cleared.") + } + + if _, err = h.b.MoveProject( + &resourcemanagerpb.MoveProjectRequest{Name: id, DestinationParent: parent}, + ); err != nil { + return err + } + } + + op, err := h.b.UpdateProject(&resourcemanagerpb.UpdateProjectRequest{Project: &resourcemanagerpb.Project{ + Name: id, DisplayName: in.Name, Labels: in.Labels, + }}) + if err != nil { + return err + } + + p := &resourcemanagerpb.Project{} + if resp := op.GetResponse(); resp != nil { + if uErr := resp.UnmarshalTo(p); uErr != nil { + return errInvalid("decode project: %v", uErr) + } + } + + writeV1JSON(w, http.StatusOK, toV1Project(p)) + + return nil +} + +func (h v1Handler) listProjects(w http.ResponseWriter, r *http.Request) error { + q := r.URL.Query() + + size := 0 + if s := q.Get("pageSize"); s != "" { + if n, err := parseInt(s); err == nil { + size = n + } + } + + h.b.mu.Lock("v1ListProjects") + h.b.purgeLocked() + + query, err := parseQuery(q.Get("filter"), true) + if err != nil { + h.b.mu.Unlock() + + return err + } + + var hits []*projectRec + + for _, p := range h.b.projects { + ok, mErr := query.match(p.fields) + if mErr != nil { + h.b.mu.Unlock() + + return mErr + } + + if ok && (query.hasState || !p.requested()) { + hits = append(hits, p) + } + } + + views := viewsOf(sortProjects(hits)) + h.b.mu.Unlock() + + pg := page.New(views, q.Get("pageToken"), size, v1Page) + out := make([]v1Project, 0, len(pg.Data)) + + for _, p := range pg.Data { + out = append(out, toV1Project(p)) + } + + resp := map[string]any{} + if len(out) > 0 { + resp["projects"] = out + } + + if pg.Next != "" { + resp["nextPageToken"] = pg.Next + } + + writeV1JSON(w, http.StatusOK, resp) + + return nil +} + +func (h v1Handler) ancestry(w http.ResponseWriter, id string) error { + p, err := h.b.GetProject(id) + if err != nil { + return err + } + + type ancestor struct { + ResourceID v1ResourceID `json:"resourceId"` + } + + chain := []ancestor{{v1ResourceID{Type: "project", ID: p.GetProjectId()}}} + + h.b.mu.RLock("v1Ancestry") + + for parent := p.GetParent(); parent != ""; { + ref := v1Parent(parent) + chain = append(chain, ancestor{*ref}) + + f, ok := h.b.folders[ref.ID] + if ref.Type != "folder" || !ok { + break + } + + parent = f.Parent + } + + h.b.mu.RUnlock() + + writeV1JSON(w, http.StatusOK, map[string]any{"ancestor": chain}) + + return nil +} + +func (h v1Handler) organizations(w http.ResponseWriter, r *http.Request, id, verb string) error { + switch { + case r.Method == http.MethodPost && id == "" && verb == ":search": + return h.searchOrganizations(w, r) + case r.Method == http.MethodPost && id != "" && (verb == verbGetIam || verb == verbSetIam || verb == verbTestIam): + return h.iam(w, r, "organizations/"+id, verb) + case r.Method == http.MethodGet && id != "" && verb == "": + o, err := h.b.GetOrganization("organizations/" + id) + if err != nil { + return err + } + + writeV1JSON(w, http.StatusOK, toV1Org(o)) + + return nil + default: + return methodNotAllowed(r) + } +} + +func (h v1Handler) searchOrganizations(w http.ResponseWriter, r *http.Request) error { + var in struct { + Filter string `json:"filter"` + PageToken string `json:"pageToken"` + PageSize int32 `json:"pageSize"` + } + + if err := readBody(r, &in); err != nil { + return err + } + + resp, err := h.b.SearchOrganizations(&resourcemanagerpb.SearchOrganizationsRequest{ + Query: in.Filter, PageToken: in.PageToken, PageSize: in.PageSize, + }) + if err != nil { + return err + } + + orgs := make([]v1Organization, 0, len(resp.GetOrganizations())) + for _, o := range resp.GetOrganizations() { + orgs = append(orgs, toV1Org(o)) + } + + out := map[string]any{} + if len(orgs) > 0 { + out["organizations"] = orgs + } + + if resp.GetNextPageToken() != "" { + out["nextPageToken"] = resp.GetNextPageToken() + } + + writeV1JSON(w, http.StatusOK, out) + + return nil +} + +func toV1Org(o *resourcemanagerpb.Organization) v1Organization { + out := v1Organization{ + Name: o.GetName(), DisplayName: o.GetDisplayName(), LifecycleState: o.GetState().String(), + Owner: map[string]string{"directoryCustomerId": o.GetDirectoryCustomerId()}, + } + + if t := o.GetCreateTime(); t != nil { + out.CreationTime = t.AsTime().Format(time.RFC3339Nano) + } + + return out +} + +func (h v1Handler) iam(w http.ResponseWriter, r *http.Request, resource, verb string) error { + switch verb { + case verbGetIam: + var in struct { + Options struct { + RequestedPolicyVersion int32 `json:"requestedPolicyVersion"` + } `json:"options"` + } + + if err := readBody(r, &in); err != nil { + return err + } + + p, err := h.b.GetIamPolicy(&iampb.GetIamPolicyRequest{ + Resource: resource, + Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: in.Options.RequestedPolicyVersion}, + }) + if err != nil { + return err + } + + writeV1Proto(w, p) + case verbSetIam: + req := &iampb.SetIamPolicyRequest{} + if err := readProtoBody(r, req); err != nil { + return err + } + + req.Resource = resource + + p, err := h.b.SetIamPolicy(req) + if err != nil { + return err + } + + writeV1Proto(w, p) + default: + req := &iampb.TestIamPermissionsRequest{} + if err := readProtoBody(r, req); err != nil { + return err + } + + req.Resource = resource + + resp, err := h.b.TestIamPermissions(req) + if err != nil { + return err + } + + writeV1Proto(w, resp) + } + + return nil +} + +func parseInt(s string) (int, error) { + n := 0 + + for _, c := range s { + if c < '0' || c > '9' { + return 0, errInvalid("invalid integer %q", s) + } + + n = n*decimalBase + int(c-'0') + } + + return n, nil +} + +type billingInfo struct { + Name string `json:"name"` + ProjectID string `json:"projectId"` + BillingAccountName string `json:"billingAccountName"` + BillingEnabled bool `json:"billingEnabled"` +} + +// billingInfo serves Cloud Billing projects.getBillingInfo and projects.updateBillingInfo, which the Terraform +// google_project resource calls on every read. +func (h v1Handler) billingInfo(w http.ResponseWriter, r *http.Request, id string) error { + var ( + info billingInfo + err error + ) + + switch r.Method { + case http.MethodGet: + info, err = h.b.BillingInfo(id) + case http.MethodPut: + var in billingInfo + if err = readBody(r, &in); err != nil { + return err + } + + info, err = h.b.SetBillingAccount(id, in.BillingAccountName) + default: + return methodNotAllowed(r) + } + + if err != nil { + return err + } + + writeV1JSON(w, http.StatusOK, info) + + return nil +} diff --git a/services/gcpresourcemanager/v1_test.go b/services/gcpresourcemanager/v1_test.go new file mode 100644 index 0000000000..be1a102dc7 --- /dev/null +++ b/services/gcpresourcemanager/v1_test.go @@ -0,0 +1,228 @@ +package gcpresourcemanager_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/gcprest/resttest" +) + +const v1Create = `{"projectId":"v1-proj","name":"V1 Project","labels":{"team":"a"},` + + `"parent":{"type":"organization","id":"123456789012"}}` + +func TestV1ProjectFlow(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + resttest.Run(t, e.addr, []resttest.Step{ + { + Name: "create", Method: "POST", Path: "/v1/projects", Body: v1Create, Status: 200, + Contains: []string{`"done":true`, `"name":"operations/cp.`}, + }, + { + Name: "get", Method: "GET", Path: "/v1/projects/v1-proj", Status: 200, + Contains: []string{ + `"projectId":"v1-proj"`, `"lifecycleState":"ACTIVE"`, `"projectNumber":"`, `"name":"V1 Project"`, + `"parent":{"type":"organization","id":"123456789012"}`, `"labels":{"team":"a"}`, `"createTime":"`, + }, + }, + { + Name: "duplicate", Method: "POST", Path: "/v1/projects", Body: v1Create, Status: 409, + Contains: []string{`"status":"ALREADY_EXISTS"`}, + }, + { + Name: "bad id", Method: "POST", Path: "/v1/projects", Body: `{"projectId":"Bad_ID"}`, Status: 400, + Contains: []string{`"status":"INVALID_ARGUMENT"`}, + }, + { + Name: "update", + Method: "PUT", + Path: "/v1/projects/v1-proj", + Status: 200, + Body: `{"projectId":"v1-proj","name":"Renamed","labels":{"team":"b"},` + + `"parent":{"type":"organization","id":"123456789012"}}`, + Contains: []string{`"name":"Renamed"`, `"labels":{"team":"b"}`}, + }, + { + Name: "update project id is immutable", Method: "PUT", Path: "/v1/projects/v1-proj", Status: 400, + Body: `{"projectId":"other-id","name":"Renamed"}`, + }, + { + Name: "list", Method: "GET", Path: "/v1/projects?filter=id:v1-proj", Status: 200, + Contains: []string{`"projects":[`, `"projectId":"v1-proj"`}, + }, + { + Name: "list empty", Method: "GET", Path: "/v1/projects?filter=id:nothing-here", Status: 200, + Contains: []string{`{}`}, + }, + { + Name: "ancestry", Method: "POST", Path: "/v1/projects/v1-proj:getAncestry", Status: 200, + Contains: []string{ + `{"resourceId":{"type":"project","id":"v1-proj"}}`, + `{"resourceId":{"type":"organization","id":"123456789012"}}`, + }, + }, + { + Name: "get iam", Method: "POST", Path: "/v1/projects/v1-proj:getIamPolicy", + Body: `{"options":{"requestedPolicyVersion":3}}`, Status: 200, Contains: []string{`"etag"`}, + }, + { + Name: "set iam", Method: "POST", Path: "/v1/projects/v1-proj:setIamPolicy", Status: 200, + Body: `{"policy":{"bindings":[{"role":"roles/viewer","members":["user:a@example.com"]}]}}`, + Contains: []string{`"role":"roles/viewer"`, `"user:a@example.com"`}, + }, + { + Name: "set iam stale etag", + Method: "POST", + Path: "/v1/projects/v1-proj:setIamPolicy", + Status: 409, + Body: `{"policy":{"etag":"AAAAAAAAAAA=","bindings":[{"role":"roles/viewer","members":["user:b@example.com"]}]}}`, + Contains: []string{`"status":"ABORTED"`}, + }, + { + Name: "test iam", Method: "POST", Path: "/v1/projects/v1-proj:testIamPermissions", Status: 200, + Body: `{"permissions":["compute.instances.get"]}`, Contains: []string{`"compute.instances.get"`}, + }, + { + Name: "billing info unset", + Method: "GET", + Path: "/v1/projects/v1-proj/billingInfo", + Status: 200, + Contains: []string{ + `"name":"projects/v1-proj/billingInfo"`, + `"projectId":"v1-proj"`, + `"billingEnabled":false`, + }, + }, + { + Name: "billing info set", Method: "PUT", Path: "/v1/projects/v1-proj/billingInfo", Status: 200, + Body: `{"billingAccountName":"billingAccounts/0X0X0X-0X0X0X-0X0X0X"}`, + Contains: []string{`"billingAccountName":"billingAccounts/0X0X0X-0X0X0X-0X0X0X"`, `"billingEnabled":true`}, + }, + { + Name: "billing info bad account", Method: "PUT", Path: "/v1/projects/v1-proj/billingInfo", Status: 400, + Body: `{"billingAccountName":"nope"}`, + }, + {Name: "billing info unknown", Method: "GET", Path: "/v1/projects/nope-nope/billingInfo", Status: 403}, + {Name: "delete", Method: "DELETE", Path: "/v1/projects/v1-proj", Status: 200, Contains: []string{`{}`}}, + { + Name: "get deleted", Method: "GET", Path: "/v1/projects/v1-proj", Status: 200, + Contains: []string{`"lifecycleState":"DELETE_REQUESTED"`}, + }, + { + Name: "undelete", + Method: "POST", + Path: "/v1/projects/v1-proj:undelete", + Status: 200, + Contains: []string{`{}`}, + }, + { + Name: "get restored", Method: "GET", Path: "/v1/projects/v1-proj", Status: 200, + Contains: []string{`"lifecycleState":"ACTIVE"`}, + }, + { + Name: "unknown project", Method: "GET", Path: "/v1/projects/nope-nope", Status: 403, + Contains: []string{`"status":"PERMISSION_DENIED"`, `IAM_PERMISSION_DENIED`}, + }, + {Name: "unknown route", Method: "GET", Path: "/v1/projects/v1-proj:bogus", Status: 404}, + }) +} + +func TestV1OrganizationFlow(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + resttest.Run(t, e.addr, []resttest.Step{ + { + Name: "search", + Method: "POST", + Path: "/v1/organizations:search", + Body: `{"filter":"domain:gopherstack.local"}`, + Status: 200, + Contains: []string{ + `"organizations":[`, + `"name":"organizations/123456789012"`, + `"displayName":"gopherstack.local"`, + `"lifecycleState":"ACTIVE"`, + }, + }, + { + Name: "search no match", + Method: "POST", + Path: "/v1/organizations:search", + Body: `{"filter":"domain:other.example"}`, + Status: 200, + Contains: []string{`{}`}, + }, + { + Name: "get", Method: "GET", Path: "/v1/organizations/123456789012", Status: 200, + Contains: []string{`"displayName":"gopherstack.local"`, `"owner":{"directoryCustomerId":"C123456789012"}`}, + }, + {Name: "get unknown", Method: "GET", Path: "/v1/organizations/1", Status: 403}, + { + Name: "set iam", + Method: "POST", + Path: "/v1/organizations/123456789012:setIamPolicy", + Status: 200, + Body: `{"policy":{"bindings":[{"role":"roles/resourcemanager.organizationViewer",` + + `"members":["user:a@example.com"]}]}}`, + Contains: []string{`organizationViewer`}, + }, + { + Name: "get iam", + Method: "POST", + Path: "/v1/organizations/123456789012:getIamPolicy", + Body: `{}`, + Status: 200, + Contains: []string{`organizationViewer`}, + }, + }) +} + +func TestServiceUsageRESTIsNotShadowedByV1(t *testing.T) { + t.Parallel() + + e := newEnv(t) + + resttest.Run(t, e.addr, []resttest.Step{ + { + Name: "list services", + Method: "GET", + Path: "/v1/projects/gopherstack-test/services?filter=state:ENABLED", + Status: 200, + Contains: []string{`"services":[`, `"state":"ENABLED"`}, + }, + { + Name: "enable", + Method: "POST", + Path: "/v1/projects/gopherstack-test/services/run.googleapis.com:enable", + Status: 200, + Contains: []string{`"done":true`, `"name":"operations/acat.`}, + }, + { + Name: "batch enable", + Method: "POST", + Path: "/v1/projects/gopherstack-test/services:batchEnable", + Status: 200, + Body: `{"serviceIds":["dns.googleapis.com"]}`, + Contains: []string{`"done":true`}, + }, + { + Name: "disable", + Method: "POST", + Path: "/v1/projects/gopherstack-test/services/run.googleapis.com:disable", + Status: 200, + Body: `{"disableDependentServices":true}`, + Contains: []string{`"done":true`}, + }, + { + Name: "batch get", + Method: "GET", + Path: "/v1/projects/gopherstack-test/services:batchGet" + + "?names=projects/gopherstack-test/services/run.googleapis.com", + Status: 200, + Contains: []string{`"state":"DISABLED"`}, + }, + }) +} diff --git a/services/gcprun/clock.go b/services/gcprun/clock.go index 55dc6bfc07..20edf9d709 100644 --- a/services/gcprun/clock.go +++ b/services/gcprun/clock.go @@ -1,35 +1,14 @@ package gcprun -import "time" +import "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" // Timer is the slice of *time.Timer that scaling and probing use. -type Timer interface { - C() <-chan time.Time - Stop() bool -} +type Timer = gcpscale.Timer // Clock abstracts time so idle scale-down, request queueing and probe schedules can be driven by tests. -type Clock interface { - Now() time.Time - NewTimer(d time.Duration) Timer - AfterFunc(d time.Duration, f func()) Timer -} - -type realClock struct{} - -type realTimer struct{ t *time.Timer } - -func (r realTimer) C() <-chan time.Time { return r.t.C } +type Clock = gcpscale.Clock -func (r realTimer) Stop() bool { return r.t.Stop() } - -func (realClock) Now() time.Time { return time.Now() } - -func (realClock) NewTimer(d time.Duration) Timer { return realTimer{t: time.NewTimer(d)} } - -func (realClock) AfterFunc(d time.Duration, f func()) Timer { - return realTimer{t: time.AfterFunc(d, f)} -} +type realClock = gcpscale.RealClock type clockBox struct{ Clock } diff --git a/services/gcprun/handler.go b/services/gcprun/handler.go index 8b28cfe116..2ea869e8ac 100644 --- a/services/gcprun/handler.go +++ b/services/gcprun/handler.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -172,7 +173,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcprun", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "run.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) h.registerServers(srv) runCtx, cancel := context.WithCancel(context.WithoutCancel(workerCtx)) diff --git a/services/gcprun/ingress.go b/services/gcprun/ingress.go index 164f0b3c8f..d0434a506b 100644 --- a/services/gcprun/ingress.go +++ b/services/gcprun/ingress.go @@ -12,6 +12,7 @@ import ( "cloud.google.com/go/run/apiv2/runpb" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" ) @@ -62,9 +63,9 @@ func (b *InMemoryBackend) serveIngress(w http.ResponseWriter, r *http.Request) { code := http.StatusBadGateway switch { - case errors.Is(err, errNoRuntime), errors.Is(err, errRevisionGone), errors.Is(err, errRetired): + case errors.Is(err, errNoRuntime), errors.Is(err, errRevisionGone), errors.Is(err, gcpscale.ErrRetired): code = http.StatusServiceUnavailable - case errors.Is(err, errQueueTimeout): + case errors.Is(err, gcpscale.ErrSaturated): code = http.StatusTooManyRequests } diff --git a/services/gcprun/launch.go b/services/gcprun/launch.go index 784b4aeb23..8c069d861a 100644 --- a/services/gcprun/launch.go +++ b/services/gcprun/launch.go @@ -2,14 +2,15 @@ package gcprun import ( "context" - "errors" "fmt" "slices" "strconv" + "sync/atomic" "cloud.google.com/go/run/apiv2/runpb" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" ) @@ -92,57 +93,67 @@ func containerSpec(rev *runpb.Revision, p prepared, seq uint64, idx int) contain } } -// bringUp starts the instance's containers and settles in.ready with the outcome. -func (b *InMemoryBackend) bringUp(name string, in *inst, rev *runpb.Revision) { - began := b.clk().Now() - err := b.startInstance(in, rev) - took := b.clk().Now().Sub(began) +// inst is one running copy of a revision: every container of the revision sharing a network namespace. +type inst struct { + mctx context.Context + ports map[int]int + cancel context.CancelFunc + hostPorts []int + ids []string + dirs []string + seq uint64 + hostPort int + cleaned atomic.Bool +} + +func (in *inst) id() string { + if len(in.ids) == 0 { + return "" + } - b.mu.Lock("bringUp.done") + return in.ids[0] +} + +// startInst brings up one instance of the revision for its pool; ctx ends when the pool retires it. +func (b *InMemoryBackend) startInst(ctx context.Context, name string) (*inst, error) { + b.mu.RLock("startInst") r := b.revisions[name] - if err == nil && (r == nil || in.dead) { - err = errRetired - } + if r == nil { + b.mu.RUnlock() - if err != nil { - b.failInstLocked(r, in, err) - } else { - r.startErr = "" - r.avgStart = took - b.armIdleLocked(name, r, in) - b.startMonitorsLocked(name, in, rev) - r.notifyLocked() + return nil, gcpscale.ErrRetired } - close(in.ready) - b.mu.Unlock() + rev := clone(r.pb) + b.mu.RUnlock() - if err != nil { - b.cleanupInst(in) - } -} + in := &inst{seq: b.instSeq.Add(1)} + in.mctx, in.cancel = context.WithCancel(ctx) -// failInstLocked records a failed start: the instance leaves the pool and waiters see err. -func (b *InMemoryBackend) failInstLocked(r *revRec, in *inst, err error) { - in.err = err + if err := b.startInstance(in, rev); err != nil { + b.teardownInst(in) + b.setStartErr(name, err.Error()) - if r == nil { - return + return nil, err } - if !in.dead { - b.detachLocked(r, in) - } + b.setStartErr(name, "") + b.startMonitors(name, in, rev) - if !errors.Is(err, errRetired) { - r.startErr = err.Error() - } + return in, nil +} + +func (b *InMemoryBackend) setStartErr(name, msg string) { + b.mu.Lock("setStartErr") + defer b.mu.Unlock() - r.notifyLocked() + if r := b.revisions[name]; r != nil { + r.startErr = msg + } } -func (b *InMemoryBackend) startMonitorsLocked(name string, in *inst, rev *runpb.Revision) { +func (b *InMemoryBackend) startMonitors(name string, in *inst, rev *runpb.Revision) { for _, c := range rev.GetContainers() { if spec, ok := resolveProbe(c, c.GetLivenessProbe()); ok { b.monWG.Go(func() { b.monitorLiveness(in.mctx, spec, in, name) }) @@ -274,23 +285,17 @@ func (b *InMemoryBackend) startOwner( }, release) } -// teardownInst retires a started instance: its containers, host ports and volume directories. +// teardownInst retires an instance: its monitors, containers, host ports and volume directories. func (b *InMemoryBackend) teardownInst(in *inst) { if in == nil { return } - stopTimer(in) - if in.cancel != nil { in.cancel() } - select { - case <-in.ready: - b.cleanupInst(in) - default: - } + b.cleanupInst(in) } func (b *InMemoryBackend) cleanupInst(in *inst) { diff --git a/services/gcprun/persistence.go b/services/gcprun/persistence.go index 0d813b180c..b1a1db2f8a 100644 --- a/services/gcprun/persistence.go +++ b/services/gcprun/persistence.go @@ -197,7 +197,7 @@ func (b *InMemoryBackend) restoreLocked(snap *backendSnapshot) error { return fmt.Errorf("gcprun: restore revision %q: %w", name, err) } - b.revisions[name] = &revRec{pb: pb, startErr: r.StartErr, seq: r.Seq, changed: make(chan struct{})} + b.revisions[name] = &revRec{pb: pb, startErr: r.StartErr, seq: r.Seq} } for name, raw := range snap.Jobs { diff --git a/services/gcprun/revisions.go b/services/gcprun/revisions.go index fe9d83dedb..c5eaba1a90 100644 --- a/services/gcprun/revisions.go +++ b/services/gcprun/revisions.go @@ -155,7 +155,7 @@ func newRevisionLocked( pb.Etag = etag(uid, 1, now) - return &revRec{pb: pb, seq: svc.revSeq, changed: make(chan struct{})}, nil + return &revRec{pb: pb, seq: svc.revSeq}, nil } func ingressContainer(cs []*runpb.Container) (*runpb.Container, int) { @@ -186,11 +186,11 @@ func (b *InMemoryBackend) stopContainer(runner Runner, id string) { // teardown retires every instance of a revision that is no longer reachable through the backend maps. func (b *InMemoryBackend) teardown(r *revRec) { - b.mu.Lock("teardown") - insts := b.detachAllLocked(r) - b.mu.Unlock() + b.mu.RLock("teardown") + pool := r.pool + b.mu.RUnlock() - for _, in := range insts { - b.teardownInst(in) + if pool != nil { + pool.Close() } } diff --git a/services/gcprun/scaling.go b/services/gcprun/scaling.go index 5b1656fcb9..6a64305998 100644 --- a/services/gcprun/scaling.go +++ b/services/gcprun/scaling.go @@ -3,51 +3,18 @@ package gcprun import ( "context" "errors" - "fmt" - "sync/atomic" "time" -) -const ( - defaultMaxInstances = 100 - // DefaultIdleTimeout is how long an idle instance above the minimum is kept; Cloud Run documents "up to 15 minutes". - DefaultIdleTimeout = 15 * time.Minute - minQueueTimeout = 10 * time.Second - queueStartupFactor = 3.5 + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" ) var ( - errQueueTimeout = errors.New("no instance became available: the request timed out in the pending queue") errRevisionGone = errors.New("revision was removed") - errRetired = errors.New("instance was retired while starting") errInstanceStart = errors.New("instance failed to start") ) -// inst is one running copy of a revision: every container of the revision sharing a network namespace. -type inst struct { - err error - idle Timer - mctx context.Context - ports map[int]int - cancel context.CancelFunc - ready chan struct{} - hostPorts []int - ids []string - dirs []string - seq uint64 - hostPort int - inflight int - cleaned atomic.Bool - dead bool -} - -func (in *inst) id() string { - if len(in.ids) == 0 { - return "" - } - - return in.ids[0] -} +// DefaultIdleTimeout is how long an idle instance above the minimum is kept; Cloud Run documents "up to 15 minutes". +const DefaultIdleTimeout = gcpscale.DefaultIdleTimeout // SetIdleTimeout sets how long an idle instance above the revision's minimum is kept before it scales to zero. func (b *InMemoryBackend) SetIdleTimeout(d time.Duration) { @@ -59,6 +26,22 @@ func (b *InMemoryBackend) SetIdleTimeout(d time.Duration) { } } +func (b *InMemoryBackend) idleFor() time.Duration { + b.mu.RLock("idleFor") + defer b.mu.RUnlock() + + return b.idleTimeout +} + +// backendClock follows SetClock for pools created before the swap. +type backendClock struct{ b *InMemoryBackend } + +func (c backendClock) Now() time.Time { return c.b.clk().Now() } + +func (c backendClock) NewTimer(d time.Duration) Timer { return c.b.clk().NewTimer(d) } + +func (c backendClock) AfterFunc(d time.Duration, f func()) Timer { return c.b.clk().AfterFunc(d, f) } + func (b *InMemoryBackend) scalingLimits(r *revRec) (int, int) { minN, maxN := int(r.pb.GetScaling().GetMinInstanceCount()), int(r.pb.GetScaling().GetMaxInstanceCount()) @@ -73,249 +56,77 @@ func (b *InMemoryBackend) scalingLimits(r *revRec) (int, int) { } if maxN <= 0 { - maxN = defaultMaxInstances + maxN = gcpscale.DefaultMaxInstances } return min(minN, maxN), maxN } -func (r *revRec) liveCount() int { - n := 0 +func (b *InMemoryBackend) newPoolLocked(name string, r *revRec) *gcpscale.Pool[*inst] { + return gcpscale.New(lastSegment(name), gcpscale.Config[*inst]{ + Start: func(ctx context.Context) (*inst, error) { return b.startInst(ctx, name) }, + Stop: b.teardownInst, + Limits: func() gcpscale.Limits { + b.mu.RLock("limits") + defer b.mu.RUnlock() - for _, in := range r.insts { - if !in.dead { - n++ - } - } - - return n -} - -func (r *revRec) notifyLocked() { - close(r.changed) - r.changed = make(chan struct{}) -} - -// pickLocked returns the first instance with a free request slot, preferring ones that already serve. -func pickLocked(r *revRec, conc int) *inst { - var starting *inst + minN, maxN := b.scalingLimits(r) - for _, in := range r.insts { - if in.dead || in.inflight >= conc { - continue - } - - select { - case <-in.ready: - return in - default: - if starting == nil { - starting = in + return gcpscale.Limits{ + Min: minN, Max: maxN, Concurrency: int(r.pb.GetMaxInstanceRequestConcurrency()), } - } - } - - return starting -} - -// growLocked adds a starting instance and brings it up in the background. -func (b *InMemoryBackend) growLocked(name string, r *revRec) *inst { - in := &inst{ready: make(chan struct{}), seq: b.instSeq.Add(1)} - in.mctx, in.cancel = context.WithCancel(b.runCtx) - r.insts = append(r.insts, in) - pb := clone(r.pb) - - b.wg.Go(func() { b.bringUp(name, in, pb) }) - - return in -} - -func (b *InMemoryBackend) queueTimeout(r *revRec) time.Duration { - return max(minQueueTimeout, time.Duration(queueStartupFactor*float64(r.avgStart))) -} - -// acquire reserves a request slot on an instance of the revision, starting one when every slot is taken and the -// revision is below max instances, and queueing the request otherwise until it times out (HTTP 429). -func (b *InMemoryBackend) acquire(ctx context.Context, name string) (*inst, func(), error) { - var deadline Timer - - defer func() { - if deadline != nil { - deadline.Stop() - } - }() - - for { - b.mu.Lock("acquire") - - r := b.revisions[name] - if r == nil { - b.mu.Unlock() - - return nil, nil, errRevisionGone - } - - conc := max(int(r.pb.GetMaxInstanceRequestConcurrency()), 1) - - in := pickLocked(r, conc) - if _, maxN := b.scalingLimits(r); in == nil && r.liveCount() < maxN { - in = b.growLocked(name, r) - } - - if in != nil { - in.inflight++ - stopTimer(in) - b.mu.Unlock() - - return b.await(ctx, name, in) - } - - changed, wait := r.changed, b.queueTimeout(r) - b.mu.Unlock() - - if deadline == nil { - deadline = b.clk().NewTimer(wait) - } - - select { - case <-changed: - case <-deadline.C(): - return nil, nil, errQueueTimeout - case <-ctx.Done(): - return nil, nil, fmt.Errorf("request cancelled while queued: %w", ctx.Err()) - } - } -} - -func stopTimer(in *inst) { - if in.idle != nil { - in.idle.Stop() - in.idle = nil - } + }, + Idle: b.idleFor, + Clock: backendClock{b: b}, + Base: b.runCtx, + }) } -func (b *InMemoryBackend) await(ctx context.Context, name string, in *inst) (*inst, func(), error) { - select { - case <-in.ready: - case <-ctx.Done(): - b.release(name, in) +// poolFor returns the revision's instance pool, creating it on first use. +func (b *InMemoryBackend) poolFor(name string) (*gcpscale.Pool[*inst], string, error) { + b.mu.Lock("poolFor") + defer b.mu.Unlock() - return nil, nil, fmt.Errorf("request cancelled while the instance started: %w", ctx.Err()) + r := b.revisions[name] + if r == nil { + return nil, "", errRevisionGone } - if in.err != nil { - b.release(name, in) - - return nil, nil, in.err + if r.pool == nil { + r.pool = b.newPoolLocked(name, r) } - return in, func() { b.release(name, in) }, nil -} - -// release frees a request slot and arms the idle timer when the instance has gone quiet. -func (b *InMemoryBackend) release(name string, in *inst) { - b.mu.Lock("release") - defer b.mu.Unlock() - - in.inflight-- - - if r := b.revisions[name]; r != nil { - r.notifyLocked() - b.armIdleLocked(name, r, in) - } + return r.pool, r.pb.GetService(), nil } -func (b *InMemoryBackend) armIdleLocked(name string, r *revRec, in *inst) { - if in.dead || in.inflight > 0 || in.idle != nil || in.err != nil { - return - } - - select { - case <-in.ready: - default: - return +func goneIfClosed(err error) error { + if errors.Is(err, gcpscale.ErrClosed) { + return errRevisionGone } - in.idle = b.clk().AfterFunc(b.idleTimeout, func() { b.idleExpired(name, r, in) }) + return err } -// idleExpired scales an instance to zero unless it is needed to keep the revision at its minimum. -func (b *InMemoryBackend) idleExpired(name string, r *revRec, in *inst) { - b.mu.Lock("idleExpired") - - minN, _ := b.scalingLimits(r) - if in.dead || in.inflight > 0 || r.liveCount() <= minN || b.revisions[name] != r { - in.idle = nil - b.mu.Unlock() - - return +// acquire reserves a request slot on an instance of the revision (see gcpscale.Pool.Acquire). +func (b *InMemoryBackend) acquire(ctx context.Context, name string) (*inst, func(), error) { + pool, _, err := b.poolFor(name) + if err != nil { + return nil, nil, err } - b.detachLocked(r, in) - r.notifyLocked() - b.mu.Unlock() + in, release, err := pool.Acquire(ctx) - b.teardownInst(in) -} - -// detachLocked marks in dead and removes it from the revision's pool. -func (b *InMemoryBackend) detachLocked(r *revRec, in *inst) { - in.dead = true - stopTimer(in) - - for i, x := range r.insts { - if x == in { - r.insts = append(r.insts[:i], r.insts[i+1:]...) - - break - } - } + return in, release, goneIfClosed(err) } // ensure brings the revision to at least n instances and waits until they are ready. func (b *InMemoryBackend) ensure(ctx context.Context, name string, n int) error { - b.mu.Lock("ensure") - - r := b.revisions[name] - if r == nil { - b.mu.Unlock() - - return errRevisionGone - } - - _, maxN := b.scalingLimits(r) - for r.liveCount() < min(n, maxN) { - b.growLocked(name, r) + pool, _, err := b.poolFor(name) + if err != nil { + return err } - waits := make([]*inst, 0, n) - - for _, in := range r.insts { - if !in.dead && len(waits) < n { - waits = append(waits, in) - } - } - b.mu.Unlock() - - for _, in := range waits { - select { - case <-in.ready: - case <-ctx.Done(): - return fmt.Errorf("waiting for instances: %w", ctx.Err()) - } - - if in.err != nil { - return in.err - } - } - - b.mu.Lock("ensure.idle") - for _, in := range waits { - b.armIdleLocked(name, r, in) - } - b.mu.Unlock() - - return nil + return goneIfClosed(pool.Ensure(ctx, n)) } // startRevision makes sure the revision has the instances its scaling settings call for (at least one). @@ -339,20 +150,25 @@ func (b *InMemoryBackend) startRevision(ctx context.Context, name string) error func (b *InMemoryBackend) warmServing(svcName string) { b.mu.RLock("warmServing") - var names []string + mins := map[string]int{} for name, r := range b.revisions { if r.pb.GetService() != svcName || !b.servingLocked(r) { continue } - if minN, _ := b.scalingLimits(r); minN > 0 && len(r.insts) < minN { - names = append(names, name) + if minN, _ := b.scalingLimits(r); minN > 0 { + mins[name] = minN } } b.mu.RUnlock() - for _, name := range names { + for name, minN := range mins { + pool, _, err := b.poolFor(name) + if err != nil || pool.Live() >= minN { + continue + } + b.wg.Go(func() { _ = b.ensure(b.baseCtx(), name, b.minFor(name)) }) } } @@ -372,20 +188,12 @@ func (b *InMemoryBackend) minFor(name string) int { // killInstance retires an instance whose liveness probe failed; Cloud Run starts a replacement on demand. func (b *InMemoryBackend) killInstance(name string, in *inst) { - b.mu.Lock("killInstance") - - r := b.revisions[name] - if r == nil || in.dead { - b.mu.Unlock() - + pool, svcName, err := b.poolFor(name) + if err != nil { return } - b.detachLocked(r, in) - r.notifyLocked() - svcName := r.pb.GetService() - b.mu.Unlock() - - b.teardownInst(in) - b.warmServing(svcName) + if pool.Remove(func(x *inst) bool { return x == in }) { + b.warmServing(svcName) + } } diff --git a/services/gcprun/scaling_test.go b/services/gcprun/scaling_test.go index 3bfd275bd3..5cba28fb4e 100644 --- a/services/gcprun/scaling_test.go +++ b/services/gcprun/scaling_test.go @@ -254,3 +254,26 @@ func TestScaling_Validation(t *testing.T) { }) } } + +func TestScaling_SidecarInstanceStopsTogetherAndColdStartsAgain(t *testing.T) { + t.Parallel() + + e := newEnv(t, true) + clock := newFakeClock() + e.handler.Backend.SetClock(clock) + + app, sidecar := appAndSidecar() + + svc, err := e.deploy(t, "web", multiService(app, sidecar)) + require.NoError(t, err) + require.Equal(t, 2, e.rt.running(), "ingress container and sidecar start together") + + clock.advance(idleTimeout) + assert.Zero(t, e.rt.running(), "idle scale-down stops every container of the instance") + assert.Equal(t, []string{"fake-2", "fake-1"}, e.rt.removedIDs(), "dependents stop before the network owner") + + code, _ := e.get(t, strings.TrimPrefix(svc.GetUri(), "http://"), "/") + assert.Equal(t, http.StatusOK, code, "a request cold-starts a fresh instance") + assert.Equal(t, 2, e.rt.running()) + assert.Len(t, e.rt.specs(), 4) +} diff --git a/services/gcprun/service.go b/services/gcprun/service.go index e22a2e9fcc..61a9f098f1 100644 --- a/services/gcprun/service.go +++ b/services/gcprun/service.go @@ -10,6 +10,8 @@ import ( "cloud.google.com/go/run/apiv2/runpb" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" ) func serviceMutable() []string { @@ -223,8 +225,8 @@ func (b *InMemoryBackend) deploy(op, svcName, revName string) { idle := b.reapIdleLocked(rec) b.mu.Unlock() - for _, in := range idle { - b.teardownInst(in) + for _, p := range idle { + p.Drain() } b.warmServing(svcName) @@ -286,8 +288,9 @@ func (b *InMemoryBackend) settleLocked(rec *svcRec, rev *revRec, err error) stri return failure } -// reapIdleLocked detaches the instances of revisions that receive no traffic; they restart on demand. -func (b *InMemoryBackend) reapIdleLocked(rec *svcRec) []*inst { +// reapIdleLocked returns the pools of revisions that receive no traffic; drain them outside the lock, they +// restart on demand. +func (b *InMemoryBackend) reapIdleLocked(rec *svcRec) []*gcpscale.Pool[*inst] { serving := map[string]bool{} for _, st := range rec.pb.GetTrafficStatuses() { if st.GetPercent() > 0 || st.GetTag() != "" { @@ -295,36 +298,19 @@ func (b *InMemoryBackend) reapIdleLocked(rec *svcRec) []*inst { } } - var idle []*inst + var idle []*gcpscale.Pool[*inst] for name, r := range b.revisions { - if r.pb.GetService() != rec.pb.GetName() || serving[lastSegment(name)] { + if r.pb.GetService() != rec.pb.GetName() || serving[lastSegment(name)] || r.pool == nil { continue } - idle = append(idle, b.detachAllLocked(r)...) + idle = append(idle, r.pool) } return idle } -// detachAllLocked removes every instance from r's pool and returns them for teardown. -func (b *InMemoryBackend) detachAllLocked(r *revRec) []*inst { - out := r.insts - r.insts = nil - - for _, in := range out { - in.dead = true - stopTimer(in) - } - - if len(out) > 0 { - r.notifyLocked() - } - - return out -} - // GetService returns the named service. func (b *InMemoryBackend) GetService(name string) (*runpb.Service, error) { n, err := parseName(name, "services") @@ -472,14 +458,14 @@ func (b *InMemoryBackend) commitUpdateLocked( b.refreshLocked(rec) view := b.viewLocked(rec) - var idle []*inst + var idle []*gcpscale.Pool[*inst] if rev == nil { idle = b.reapIdleLocked(rec) } b.mu.Unlock() - for _, in := range idle { - b.teardownInst(in) + for _, p := range idle { + p.Drain() } if rev == nil { diff --git a/services/gcprun/store.go b/services/gcprun/store.go index 18ac752e3e..04061ab903 100644 --- a/services/gcprun/store.go +++ b/services/gcprun/store.go @@ -16,6 +16,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/container" "github.com/blackbirdworks/gopherstack/pkgs/gcpiampolicy" "github.com/blackbirdworks/gopherstack/pkgs/gcplro" + "github.com/blackbirdworks/gopherstack/pkgs/gcpscale" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/httpclient" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" @@ -45,10 +46,8 @@ type svcRec struct { type revRec struct { pb *runpb.Revision - changed chan struct{} + pool *gcpscale.Pool[*inst] startErr string - insts []*inst - avgStart time.Duration seq int } diff --git a/services/gcprun/volumes.go b/services/gcprun/volumes.go index 058e3df447..1ec6413553 100644 --- a/services/gcprun/volumes.go +++ b/services/gcprun/volumes.go @@ -5,9 +5,10 @@ import ( "fmt" "os" "path/filepath" - "strings" "cloud.google.com/go/run/apiv2/runpb" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" ) const ( @@ -21,12 +22,11 @@ const ( var ( errNoSecrets = errors.New("secret manager is not available in this emulator") errUnsupportedVolume = errors.New("unsupported volume") - errSecretPath = errors.New("secret volume path must be relative and stay inside the volume") ) // SecretResolver returns the payload of a Secret Manager version resource name // (projects/{p}/secrets/{s}/versions/{v}). -type SecretResolver func(versionName string) ([]byte, error) +type SecretResolver = gcpserve.SecretResolver // SetSecretResolver wires Secret Manager for secret environment variables and secret volumes. func (b *InMemoryBackend) SetSecretResolver(fn SecretResolver) { @@ -49,12 +49,7 @@ func (b *InMemoryBackend) secretValue(project, secret, version string) ([]byte, version = "latest" } - name := secret - if !strings.HasPrefix(secret, "projects/") { - name = "projects/" + project + "/secrets/" + secret - } - - data, err := fn(name + "/versions/" + version) + data, err := fn(gcpserve.SecretVersionName(project, secret, version)) if err != nil { return nil, fmt.Errorf("secret %q version %q: %w", secret, version, err) } @@ -193,7 +188,7 @@ func (b *InMemoryBackend) secretDir(project string, src *runpb.SecretVolumeSourc mode = defaultSecMode } - if err = writeSecretFile(dir, it.GetPath(), data, mode); err != nil { + if err = gcpserve.WriteSecretFile(dir, it.GetPath(), data, mode); err != nil { return dir, err } } @@ -201,24 +196,6 @@ func (b *InMemoryBackend) secretDir(project string, src *runpb.SecretVolumeSourc return dir, nil } -func writeSecretFile(dir, rel string, data []byte, mode os.FileMode) error { - clean := filepath.Clean(rel) - if clean == "." || filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { - return fmt.Errorf("%w: %q", errSecretPath, rel) - } - - full := filepath.Join(dir, clean) - if err := os.MkdirAll(filepath.Dir(full), dirMode); err != nil { - return fmt.Errorf("create secret volume path: %w", err) - } - - if err := os.WriteFile(full, data, mode); err != nil { - return fmt.Errorf("write secret volume file: %w", err) - } - - return os.Chmod(full, mode) //nolint:wrapcheck // a plain chmod failure message is clear -} - // containerMounts returns the bind-mount specs of c: host directory (plus sub_path) to mount_path. func containerMounts(c *runpb.Container, paths map[string]string, readOnly map[string]bool) ([]string, error) { mounts := make([]string, 0, len(c.GetVolumeMounts())) diff --git a/services/gcpscheduler/dispatch.go b/services/gcpscheduler/dispatch.go index cf9562d5a2..7a38a1557e 100644 --- a/services/gcpscheduler/dispatch.go +++ b/services/gcpscheduler/dispatch.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/status" "github.com/blackbirdworks/gopherstack/pkgs/gcpdispatch" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/logger" ) @@ -123,6 +124,7 @@ func (b *InMemoryBackend) deliver(ctx context.Context, a attempt) result { } req.Header = a.http.header.Clone() + gcpserve.MarkInternal(req) resp, err := a.client.Do(req) if err != nil { diff --git a/services/gcpscheduler/handler.go b/services/gcpscheduler/handler.go index 9d61b7142b..f7656b834e 100644 --- a/services/gcpscheduler/handler.go +++ b/services/gcpscheduler/handler.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -135,7 +136,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpscheduler", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor(log))) + srv := gcpenforce.NewServer(ctx, "cloudscheduler.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log))) schedulerpb.RegisterCloudSchedulerServer(srv, &server{b: h.Backend}) location.RegisterLocationsServer(srv, locationsServer{}) diff --git a/services/gcpsecretmanager/handler.go b/services/gcpsecretmanager/handler.go index 528422b951..42c6475f2e 100644 --- a/services/gcpsecretmanager/handler.go +++ b/services/gcpsecretmanager/handler.go @@ -15,6 +15,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -124,7 +125,9 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpsecretmanager", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.ChainUnaryInterceptor(logInterceptor(log), regionalInterceptor)) + srv := gcpenforce.NewServer( + ctx, "secretmanager.googleapis.com", grpc.ChainUnaryInterceptor(logInterceptor(log), regionalInterceptor), + ) secretmanagerpb.RegisterSecretManagerServiceServer(srv, &server{b: h.Backend}) sweepCtx, cancel := context.WithCancel(context.WithoutCancel(workerCtx)) diff --git a/services/gcpspanner/handler.go b/services/gcpspanner/handler.go index 2c3ba0dd57..57db22d1e7 100644 --- a/services/gcpspanner/handler.go +++ b/services/gcpspanner/handler.go @@ -18,6 +18,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -161,7 +162,8 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpspanner", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer( + srv := gcpenforce.NewServer( + ctx, "spanner.googleapis.com", grpc.UnaryInterceptor(logInterceptor(log)), grpc.MaxRecvMsgSize(serverMaxMessage), ) diff --git a/services/gcpspanner/rest_test.go b/services/gcpspanner/rest_test.go index 25b79ee435..fac931f18d 100644 --- a/services/gcpspanner/rest_test.go +++ b/services/gcpspanner/rest_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func restDo(t *testing.T, base, method, path, body string) (int, map[string]any) { @@ -20,7 +22,7 @@ func restDo(t *testing.T, base, method, path, body string) (int, map[string]any) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/gcpsql/PARITY.md b/services/gcpsql/PARITY.md index d5502214a1..57a6511138 100644 --- a/services/gcpsql/PARITY.md +++ b/services/gcpsql/PARITY.md @@ -2,19 +2,23 @@ service: gcpsql sdk_module: hashicorp/terraform-provider-google@v8.5.0 last_audit_commit: c7d1c8106 -last_audit_date: 2026-10-20 +last_audit_date: 2026-10-29 overall: B # Per-op or per-op-family status. Values: ok | partial | gap | deferred. # wire=response/request shape vs SDK; errors=code+HTTP status; state=real mutate/read; persist=in backendSnapshot. ops: - InsertInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "POST /projects/{p}/instances. Name [a-z][-a-z0-9]*, databaseVersion POSTGRES_14..17 or MYSQL_8_0/MYSQL_8_4 (SQLSERVER and older versions are a 400 naming the supported list; default MYSQL_8_0), settings.tier required, region defaults us-central1. Returns a PENDING CREATE operation; the instance is PENDING_CREATE until the engine accepts connections, then RUNNABLE and the operation DONE. 409 instanceAlreadyExists on reuse."} + InsertInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "POST /projects/{p}/instances. Name [a-z][-a-z0-9]*, databaseVersion POSTGRES_14..17 or MYSQL_8_0/MYSQL_8_4 (SQLSERVER and older versions are a 400 naming the supported list; default MYSQL_8_0), settings.tier required, region defaults us-central1. masterInstanceName (bare name or project:name) creates a read replica (operation CREATE_REPLICA, instanceType READ_REPLICA_INSTANCE): PostgreSQL replicas are pg_basebackup copies streaming over a private docker network, MySQL replicas load a GTID-consistent dump and follow the primary's binary log; databaseVersion and region default to and must match the primary, tier defaults to the primary's, the primary must be RUNNABLE, MySQL primaries need backupConfiguration.binaryLogEnabled, replicas of replicas and cross-project replicas are a 400. The primary lists replicaNames and masterInstanceName is project:name. Replicas are read-only (database/user writes are a 400) and their database and user records follow the primary. Returns a PENDING CREATE operation; the instance is PENDING_CREATE until the engine accepts connections, then RUNNABLE and the operation DONE. 409 instanceAlreadyExists on reuse."} GetInstance: {wire: ok, errors: ok, state: ok, persist: n/a, note: "404 instanceDoesNotExist. Settings echo what was sent plus the API defaults (activationPolicy, availabilityType, dataDiskSizeGb, dataDiskType, pricingPlan, backupConfiguration, ipConfiguration, locationPreference, settingsVersion...). ipAddresses has the PRIMARY address 127.0.0.1; the published host port is in the non-standard gopherstackEndpoint field."} ListInstances: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Sorted by name, paged with maxResults/pageToken; the filter parameter is ignored."} PatchInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "Merge patch of settings (objects merge, lists and scalars replace); settingsVersion increments. activationPolicy NEVER stops the container (state SUSPENDED) and ALWAYS starts it; a changed databaseFlags list is applied to the engine with a restart. databaseVersion changes are a 400."} UpdateInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "PUT replaces settings and re-applies defaults; a stale settingsVersion is 412 conditionNotMet."} - DeleteInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "State PENDING_DELETE, then the container, host port and dump directory are released and the instance disappears. deletionProtectionEnabled is a 400."} + DeleteInstance: {wire: ok, errors: partial, state: ok, persist: ok, note: "State PENDING_DELETE, then the container, host port, dump directory and data volumes are released and the instance disappears. deletionProtectionEnabled is a 400, and so is a primary that still has replicas (message not verified)."} RestartInstance: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Persists databaseFlags in the data directory (ALTER SYSTEM / SET PERSIST_ONLY) and restarts the container; 409 while SUSPENDED."} - CloneInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "New instance with copied settings, databases, accounts (with passwords) and data (logical dump restored into the new engine). pointInTime and pitrTimestampMs are a 400."} + CloneInstance: {wire: ok, errors: partial, state: ok, persist: ok, note: "New instance with copied settings, databases, accounts (with passwords) and data (logical dump restored into the new engine). With pointInTime (RFC 3339) or pitrTimestampMs the clone is restored to that time from the source's logs: PostgreSQL replays archived WAL (recovery_target_time, millisecond resolution), MySQL replays the binary log from the base dump (one second resolution: transactions stamped in the same second as the target are included). Times after now are a 400 'after the latest recovery time', times before the base backup a 400 'before the earliest recovery time', malformed times a 400; an instance without PITR is a 400. The exact error strings for a disabled instance and for the earliest bound are not verified against the live service."} + GetLatestRecoveryTime: {wire: ok, errors: partial, state: ok, persist: ok, note: "GET /instances/{i}/getLatestRecoveryTime: earliestRecoveryTime is the completion of the base backup taken when PITR was enabled, latestRecoveryTime is now. 400 when PITR (PostgreSQL pointInTimeRecoveryEnabled, MySQL binaryLogEnabled) is off. State-only instances report a window from creation."} + PromoteReplica: {wire: ok, errors: partial, state: ok, persist: ok, note: "POST /instances/{i}/promoteReplica: pg_promote / STOP REPLICA + RESET REPLICA ALL, the instance becomes CLOUD_SQL_INSTANCE writable and leaves the primary's replicaNames. Operation type PROMOTE_REPLICA. A non-replica is a 400 (message not verified)."} + StartReplica: {wire: ok, errors: partial, state: ok, persist: ok, note: "START_REPLICA: START REPLICA (MySQL) or restoring primary_conninfo (PostgreSQL); the paused flag survives a gopherstack restart."} + StopReplica: {wire: ok, errors: partial, state: ok, persist: ok, note: "STOP_REPLICA: STOP REPLICA (MySQL) or clearing primary_conninfo so the WAL receiver stops (PostgreSQL). Reads on the replica keep working and stop following the primary."} ExportInstance: {wire: ok, errors: ok, state: ok, persist: n/a, note: "SQL (pg_dump / mysqldump via the container) or CSV (selectQuery) to gs:// in the GCS emulator; .gz objects are gzipped. SQL exports omit owners and privileges so they load elsewhere. BAK is a 400. State-only instances fail the operation."} ImportInstance: {wire: ok, errors: ok, state: ok, persist: n/a, note: "SQL (psql / mysql source) or CSV (table, columns) from gs://; gzip is detected by magic bytes. Databases the file creates are added to the instance. BAK is a 400."} RestoreBackup: {wire: ok, errors: ok, state: ok, persist: ok, note: "restoreBackupContext.backupRunId (same instance or instanceId) restores the logical dump; the engine family must match."} @@ -46,24 +50,30 @@ ops: ListFlags: {wire: partial, errors: ok, state: ok, persist: n/a, note: "Static subset of the real flag catalogue (12 PostgreSQL, 11 MySQL flags), filtered by databaseVersion."} ListTiers: {wire: partial, errors: ok, state: ok, persist: n/a, note: "Static list: db-f1-micro, db-g1-small, db-n1-standard-N, db-n1-highmem-N."} families: - engines: {status: ok, note: "PostgreSQL 14-17 and MySQL 8.0/8.4 run as containers (postgres:N, mysql:N) through pkgs/container with a host port from the shared pool (portalloc.AcquireFrom, CreateAndStartRetry). Admin SQL runs from the emulator over the published port (pgx, go-sql-driver); dumps and restores run pg_dump/psql/mysqldump/mysql inside the container against a bind-mounted temp directory. Verified end to end with real containers in TestDockerEngines (flags, users, databases, export, import, clone, backup, restore)."} + engines: {status: ok, note: "PostgreSQL 14-17 and MySQL 8.0/8.4 run as containers (postgres:N, mysql:N) through pkgs/container with a host port from the shared pool (portalloc.AcquireFrom, CreateAndStartRetry). MySQL containers (launch, volume, adoption, dump/restore, replication, binary-log PITR) run through pkgs/mysqlengine, shared with services/ocimysql and Azure flexible MySQL. Admin SQL runs from the emulator over the published port (pgx, go-sql-driver); dumps and restores run pg_dump/psql/mysqldump/mysql inside the container against a bind-mounted temp directory. Verified end to end with real containers in TestDockerEngines (flags, users, databases, export, import, clone, backup, restore)."} state_only: {status: partial, note: "With --gcp-sql-engine=none, or when no container runtime is reachable, instances are state-only: they reach RUNNABLE immediately, databases and users are records only, and export, import and clone data copy fail or skip. This is what the Terraform suite runs (the gopherstack container has no runtime socket)."} connecting: {status: partial, note: "Real Cloud SQL listens on 5432/3306 at the instance IP. The emulator publishes each engine on 127.0.0.1 and a pooled host port, reported in the non-standard instance and connectSettings field gopherstackEndpoint (host:port); ipAddresses carries only 127.0.0.1. Connect with psql -h 127.0.0.1 -p -U . The engine speaks plain TCP: serverCaCert and client certificates are issued but not enforced."} lro: {status: ok, note: "Every mutation returns a sql#operation (PENDING, RUNNING, DONE) that a per-instance queue executes in order; operations.get and list expose them. Clients poll; there is no wait endpoint in this API."} error_envelope: {status: ok, note: "Errors are {error:{code,message,errors:[{message,domain,reason}],status}} with reasons instanceDoesNotExist, instanceAlreadyExists, notFound, invalid, conditionNotMet, operationInProgress."} routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --gcp-sql-port / GCP_SQL_PORT (default 8130, fails fast if taken); never registered on the shared AWS router; cli.go reserves the port in the shared PortAlloc pool. Paths work under /v1/, /sql/v1/ and /sql/v1beta4/ (terraform sql_custom_endpoint = http://localhost:8130/sql/v1beta4/; Go client option.WithEndpoint(http://localhost:8130))."} - persistence: {status: partial, note: "Instances with settings, databases, accounts (including passwords), backups with their dumps, client certificates, the CA key and operations persist in one snapshot (version 1). Engine data does not: restored instances relaunch an empty engine and replay databases, users and flags."} + persistence: {status: ok, note: "Instances with settings, databases, accounts (including passwords), backups with their dumps, client certificates, the CA key, operations, replica links and the PITR window persist in one snapshot (version 1). Engine data lives in a labelled named docker volume per instance (gopherstack-gcpsql-data--, plus -pitr- for the PostgreSQL WAL archive): a restored instance relaunches on its volume, so rows survive a gopherstack restart (verified with real PostgreSQL and MySQL). Close keeps the volumes, delete and Reset remove them; unused labelled volumes older than ten minutes that no restored instance owns are swept before the first launch. A brand new instance discards a stale volume of the same name. Runtimes without volume support fall back to an anonymous volume and an empty engine after a restart."} + replication: {status: ok, note: "Real engines: PostgreSQL streaming replication (pg_basebackup -R over a private network, replication role, scram pg_hba rule) and MySQL GTID replication (dump seed, CHANGE REPLICATION SOURCE, super_read_only on the replica). Shared statements, scripts and status parsing live in pkgs/dbrepl, which services/rds also uses. Verified with real PostgreSQL 16 and MySQL 8.4: a write on the primary becomes readable on the replica, writes on the replica fail, an account created on the primary logs in on the replica, stopReplica freezes the replica, startReplica catches it up, promoteReplica makes it writable."} + point_in_time_recovery: {status: ok, note: "PostgreSQL: archive_mode with a WAL archive volume plus a pg_basebackup base backup taken when PITR is enabled; the clone starts from the base with recovery_target_time. MySQL: base mysqldump with binary log coordinates; the clone loads it and replays the source's binary log over replication up to the first transaction at or after the target. Enabling or disabling the setting through patch starts or stops archiving. Needs a runtime with volume support; otherwise the window is reported but the engine keeps no logs."} + tier_limits: {status: ok, note: "settings.tier maps to container CPU and memory caps from the documented machine sizes (db-f1-micro 1 shared vCPU 614 MiB, db-g1-small 1740 MiB, db-custom-N-M N vCPU M MiB, db-n1-standard-N 3840 MiB per vCPU, db-n1-highmem-N 6656 MiB, db-perf-optimized-N-X 8 GiB per vCPU); CPU is capped at the host's core count. A tier patch recreates the engine on its volume (instance restarts) unless it has replicas or is one, in which case the new caps apply at the next launch. Verified with docker inspect."} auth: {status: partial, note: "No request is authenticated or authorised. Passwords are never logged or returned."} observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} items_still_open: - - "Not served: SQL Server, read replicas and replica operations (promote, startReplica, stopReplica, failover, switchover, demote), point-in-time recovery and recovery-time queries, maintenance windows and reschedule, executeSql, resetSslConfig, rotateServerCa and the other certificate rotation calls, truncateLog, major version upgrade, the projects/backups resource, DNS connect settings, Auth Proxy and IAM database authentication." - - "Engine data is not persisted across a gopherstack restart: a restored instance starts an empty engine with its databases, users and flags replayed. Stored backup dumps are not applied automatically." - - "Settings are stored and echoed but only activationPolicy and databaseFlags reach the engine: tier, disk size and type, availabilityType, ipConfiguration (authorizedNetworks, sslMode, requireSsl), backupConfiguration schedule (no automated backups run), maintenanceWindow, insightsConfig and passwordValidationPolicy have no effect." + - "Not served: SQL Server, failover (HA), switchover, demote and demoteMaster, DR replicas and replicationCluster, cascading replicas, cross-region and cross-project replicas, maintenance windows and reschedule, executeSql, resetSslConfig, rotateServerCa and the other certificate rotation calls, truncateLog, major version upgrade, the projects/backups resource, DNS connect settings, Auth Proxy and IAM database authentication. SQL Server is out of scope: the RDS opt-in SQL Server engine is a separate proxy-based design that was not reused." + - "availabilityType REGIONAL is stored but creates no standby: the RDS Multi-AZ pattern is a record flag, so there is nothing to reuse, and a real standby needs the failover operation above." + - "Settings reaching the engine: tier (CPU and memory caps), databaseFlags, activationPolicy and the PITR switches. dataDiskSizeGb and dataDiskType are echoed only (docker volumes have no quota). ipConfiguration.authorizedNetworks, sslMode and requireSsl are not enforced: the engine publishes on 127.0.0.1 only. backupConfiguration schedule (no automated backups run), maintenanceWindow, insightsConfig and passwordValidationPolicy have no effect." - "PostgreSQL ignores database charset and collation (stored only); MySQL users get GRANT ALL on *.*; PostgreSQL users join a cloudsqlsuperuser role the emulator creates. Backups keep object ownership, so restoring one into a different instance needs the same roles to exist." - "Flags are validated by the engine when the restart applies them, not when the patch is accepted; flags.list is a static subset and tiers.list a fixed list. Duplicate database and user inserts return 409 but the real reason strings were not verified against the live service." - "Docker engines need the container runtime to see the emulator's temp directory (bind mount), so a gopherstack running inside a container that talks to a remote daemon cannot export, import, back up or clone; admin SQL still works." - - "Instance name reuse after delete is allowed (the real service blocks it for a week); automatic backups, binary logging and point-in-time settings are accepted but not acted on." + - "Instance name reuse after delete is allowed (the real service blocks it for a week); automatic backups are not run. MySQL PITR resolution is one second and replays over replication, so the source engine must be running; PostgreSQL clones need the WAL archive volume. A PostgreSQL recovery to a time before any commit following the base fails at the engine, not at the API. The earliest-recovery bound and the error strings for a replica without binary logging, a non-replica promote and deleting a primary with replicas are unverified." +crash_recovery: {status: ok, note: "Engine containers carry gopherstack.managed/owner/service/resource labels; after a crash a restart with the same data dir adopts the running container of a restored primary (same image, tier and dump directory, reachable with the persisted password) and removes the rest. Replicas, recoveries and fresh instances relaunch; the private replication network of a crashed run is not removed."} leaks: {status: clean, note: "Containers, host ports, dump directories and connections are released on delete, Reset and Close; goleak runs in TestMain and TestDockerEngines asserts no gopherstack-gcpsql containers remain."} --- Initial implementation (2026-10-20): Cloud SQL Admin API v1/v1beta4 verified through google.golang.org/api/sqladmin/v1 in the package tests (instance lifecycle with settings merge and settingsVersion checks, databases, users, backups, clone, export and import against a fake object store, certificates verified with crypto/x509, operations, persistence) and through hashicorp/google 8.5.0 in test/terraform/gcp (apply, no-drift re-plan, destroy of PostgreSQL and MySQL instances with databases and users, state-only). Real PostgreSQL 16 and MySQL 8.4 containers back TestDockerEngines. Wire shapes were read from sqladmin-api.json and sqladmin-gen.go in the pinned module; behaviour of the live service was not available for comparison. + +Replicas, PITR, tier limits and engine data volumes (2026-10-29): verified through google.golang.org/api/sqladmin/v1 (Instances.Insert with MasterInstanceName, PromoteReplica, StartReplica, StopReplica, Clone with CloneContext.PointInTime, Projects.Instances.GetLatestRecoveryTime) with a fake engine in the package tests and with real PostgreSQL 16 and MySQL 8.4 containers in TestDockerReadReplica, TestDockerPointInTimeRecovery, TestDockerEnginePersistsAcrossRestart and TestDockerTierLimitsReachContainer. Documentation used: Cloud SQL create-replica requirements and the point-in-time recovery page (RFC 3339 UTC pointInTime, getLatestRecoveryTime window, 'after the latest recovery time' error). diff --git a/services/gcpsql/README.md b/services/gcpsql/README.md index ca685fbec8..6d4a70f45e 100644 --- a/services/gcpsql/README.md +++ b/services/gcpsql/README.md @@ -1,27 +1,27 @@ # Cloud SQL -**Parity grade: B** · SDK `hashicorp/terraform-provider-google@v8.5.0` · last audited 2026-10-20 (`c7d1c8106`) +**Parity grade: B** · SDK `hashicorp/terraform-provider-google@v8.5.0` · last audited 2026-10-29 (`c7d1c8106`) ## Coverage | Metric | Value | | --- | --- | -| PARITY entries audited | 38 (33 ok, 5 partial) | -| Feature families | 9 (5 ok, 4 partial) | +| PARITY entries audited | 42 (31 ok, 11 partial) | +| Feature families | 12 (9 ok, 3 partial) | | Known gaps | 7 | | Deferred items | 0 | | Resource leaks | clean | ### Known gaps -- Not served: SQL Server, read replicas and replica operations (promote, startReplica, stopReplica, failover, switchover, demote), point-in-time recovery and recovery-time queries, maintenance windows and reschedule, executeSql, resetSslConfig, rotateServerCa and the other certificate rotation calls, truncateLog, major version upgrade, the projects/backups resource, DNS connect settings, Auth Proxy and IAM database authentication. -- Engine data is not persisted across a gopherstack restart: a restored instance starts an empty engine with its databases, users and flags replayed. Stored backup dumps are not applied automatically. -- Settings are stored and echoed but only activationPolicy and databaseFlags reach the engine: tier, disk size and type, availabilityType, ipConfiguration (authorizedNetworks, sslMode, requireSsl), backupConfiguration schedule (no automated backups run), maintenanceWindow, insightsConfig and passwordValidationPolicy have no effect. +- Not served: SQL Server, failover (HA), switchover, demote and demoteMaster, DR replicas and replicationCluster, cascading replicas, cross-region and cross-project replicas, maintenance windows and reschedule, executeSql, resetSslConfig, rotateServerCa and the other certificate rotation calls, truncateLog, major version upgrade, the projects/backups resource, DNS connect settings, Auth Proxy and IAM database authentication. SQL Server is out of scope: the RDS opt-in SQL Server engine is a separate proxy-based design that was not reused. +- availabilityType REGIONAL is stored but creates no standby: the RDS Multi-AZ pattern is a record flag, so there is nothing to reuse, and a real standby needs the failover operation above. +- Settings reaching the engine: tier (CPU and memory caps), databaseFlags, activationPolicy and the PITR switches. dataDiskSizeGb and dataDiskType are echoed only (docker volumes have no quota). ipConfiguration.authorizedNetworks, sslMode and requireSsl are not enforced: the engine publishes on 127.0.0.1 only. backupConfiguration schedule (no automated backups run), maintenanceWindow, insightsConfig and passwordValidationPolicy have no effect. - PostgreSQL ignores database charset and collation (stored only); MySQL users get GRANT ALL on *.*; PostgreSQL users join a cloudsqlsuperuser role the emulator creates. Backups keep object ownership, so restoring one into a different instance needs the same roles to exist. - Flags are validated by the engine when the restart applies them, not when the patch is accepted; flags.list is a static subset and tiers.list a fixed list. Duplicate database and user inserts return 409 but the real reason strings were not verified against the live service. - Docker engines need the container runtime to see the emulator's temp directory (bind mount), so a gopherstack running inside a container that talks to a remote daemon cannot export, import, back up or clone; admin SQL still works. -- Instance name reuse after delete is allowed (the real service blocks it for a week); automatic backups, binary logging and point-in-time settings are accepted but not acted on. +- Instance name reuse after delete is allowed (the real service blocks it for a week); automatic backups are not run. MySQL PITR resolution is one second and replays over replication, so the source engine must be running; PostgreSQL clones need the WAL archive volume. A PostgreSQL recovery to a time before any commit following the base fails at the engine, not at the API. The earliest-recovery bound and the error strings for a replica without binary logging, a non-replica promote and deleting a primary with replicas are unverified. crash_recovery: {status: ok, note: "Engine containers carry gopherstack.managed/owner/service/resource labels; after a crash a restart with the same data dir adopts the running container of a restored primary (same image, tier and dump directory, reachable with the persisted password) and removes the rest. Replicas, recoveries and fresh instances relaunch; the private replication network of a crashed run is not removed."} ## More diff --git a/services/gcpsql/accounts.go b/services/gcpsql/accounts.go index 5ea635b584..38635fcb2e 100644 --- a/services/gcpsql/accounts.go +++ b/services/gcpsql/accounts.go @@ -115,6 +115,21 @@ func (b *InMemoryBackend) opRec(project, name string) (*instRec, error) { return rec, nil } +// writableRec is opRec for changes a read replica rejects. +func (b *InMemoryBackend) writableRec(project, name string) (*instRec, error) { + rec, err := b.opRec(project, name) + if err != nil { + return nil, err + } + + if rec.Inst.MasterInstanceName != "" { + return nil, errInvalid("The instance is a read replica and cannot be changed through its own databases " + + "and users; change the primary instead.") + } + + return rec, nil +} + // InsertDatabase creates a database. func (b *InMemoryBackend) InsertDatabase(project, instance string, in DatabaseInput) (*Operation, error) { if !validDatabaseName(in.Name) { @@ -124,7 +139,7 @@ func (b *InMemoryBackend) InsertDatabase(project, instance string, in DatabaseIn b.mu.Lock("InsertDatabase") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } @@ -204,7 +219,7 @@ func (b *InMemoryBackend) UpdateDatabase(project, instance, name string, in Data b.mu.Lock("UpdateDatabase") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } @@ -226,7 +241,7 @@ func (b *InMemoryBackend) DeleteDatabase(project, instance, name string) (*Opera b.mu.Lock("DeleteDatabase") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } @@ -310,7 +325,7 @@ func (b *InMemoryBackend) InsertUser(project, instance string, in UserInput) (*O b.mu.Lock("InsertUser") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } @@ -353,7 +368,7 @@ func (b *InMemoryBackend) UpdateUser(project, instance, name, host string, in Us b.mu.Lock("UpdateUser") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } @@ -447,7 +462,7 @@ func (b *InMemoryBackend) DeleteUser(project, instance, name, host string) (*Ope b.mu.Lock("DeleteUser") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } diff --git a/services/gcpsql/backups.go b/services/gcpsql/backups.go index 00f94892de..9f3a93e350 100644 --- a/services/gcpsql/backups.go +++ b/services/gcpsql/backups.go @@ -177,7 +177,7 @@ func (b *InMemoryBackend) RestoreBackup(project, instance string, in RestoreBack b.mu.Lock("RestoreBackup") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } diff --git a/services/gcpsql/catalog.go b/services/gcpsql/catalog.go index 4ad3a224c0..2e3b87f87e 100644 --- a/services/gcpsql/catalog.go +++ b/services/gcpsql/catalog.go @@ -103,11 +103,11 @@ func ListTiers() []TierInfo { TierInfo{Tier: "db-g1-small", RAM: fmtInt(smallMiB * mib)}) for _, n := range []int64{1, 2, 4, 8, 16, 32, 64, 96} { - tiers = append(tiers, TierInfo{Tier: "db-n1-standard-" + fmtInt(n), RAM: fmtInt(n * 3840 * mib)}) + tiers = append(tiers, TierInfo{Tier: "db-n1-standard-" + fmtInt(n), RAM: fmtInt(n * standardMiB * mib)}) } for _, n := range []int64{2, 4, 8, 16, 32, 64, 96} { - tiers = append(tiers, TierInfo{Tier: "db-n1-highmem-" + fmtInt(n), RAM: fmtInt(n * 6656 * mib)}) + tiers = append(tiers, TierInfo{Tier: "db-n1-highmem-" + fmtInt(n), RAM: fmtInt(n * highmemMiB * mib)}) } for i := range tiers { diff --git a/services/gcpsql/catalog_test.go b/services/gcpsql/catalog_test.go index 8bcc7830c5..86bccb4c7b 100644 --- a/services/gcpsql/catalog_test.go +++ b/services/gcpsql/catalog_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" sqladmin "google.golang.org/api/sqladmin/v1" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestFlagsAndTiers(t *testing.T) { @@ -109,7 +111,7 @@ func TestUnknownRouteIs404(t *testing.T) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, e.url+"/v1/projects/p/nonsense", nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/gcpsql/clone.go b/services/gcpsql/clone.go index a7c12c7ab1..aba45b3c87 100644 --- a/services/gcpsql/clone.go +++ b/services/gcpsql/clone.go @@ -15,8 +15,9 @@ type CloneInput struct { // CloneInstance creates a new instance with the settings, accounts and data of the source. func (b *InMemoryBackend) CloneInstance(project, instance string, in CloneInput) (*Operation, error) { - if in.PointInTime != "" || (in.PitrTimestampMs != "" && in.PitrTimestampMs != "0") { - return nil, errInvalid("Point-in-time clones are not supported by the emulator.") + at, pitr, err := recoveryTarget(in) + if err != nil { + return nil, err } if in.DestinationProject != "" && in.DestinationProject != project { @@ -35,6 +36,12 @@ func (b *InMemoryBackend) CloneInstance(project, instance string, in CloneInput) return nil, err } + if pitr { + if err = b.checkRecoveryLocked(src, at); err != nil { + return nil, err + } + } + if _, dup := b.instances[instKey(project, in.DestinationInstanceName)]; dup { return nil, errConflict("instanceAlreadyExists", "The Cloud SQL instance already exists.") } @@ -47,6 +54,10 @@ func (b *InMemoryBackend) CloneInstance(project, instance string, in CloneInput) b.instances[instKey(project, in.DestinationInstanceName)] = dst op := b.newOpLocked(project, instance, "CLONE", opMeta{}) b.enqueueLocked(dst, task{op: op.Name, fn: func(ctx context.Context) error { + if pitr { + return b.populateRecovery(ctx, src, dst, at) + } + return b.populateClone(ctx, src, dst, in.DatabaseNames) }}) diff --git a/services/gcpsql/docker_admin.go b/services/gcpsql/docker_admin.go index ff391b1626..f0dc95214c 100644 --- a/services/gcpsql/docker_admin.go +++ b/services/gcpsql/docker_admin.go @@ -5,13 +5,11 @@ import ( "database/sql" "errors" "fmt" - "math" "regexp" - "strconv" "strings" + "sync" "time" - "github.com/go-sql-driver/mysql" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" ) @@ -20,45 +18,33 @@ const connectTimeout = 5 * time.Second var ( errBadValue = errors.New("value contains a NUL byte") - identRe = regexp.MustCompile(`^[\p{L}\p{N}_ $.@#-]{1,128}$`) + identRe = sync.OnceValue(func() *regexp.Regexp { //nolint:gochecknoglobals // lazy: eager compile costs MBs + return regexp.MustCompile(`^[\p{L}\p{N}_ $.@#-]{1,128}$`) + }) ) func (e *dockerEngine) open(db string) (*sql.DB, error) { h, p := e.Endpoint() - if e.kind == kindPostgres { - cfg, err := pgx.ParseConfig(fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=disable connect_timeout=5", - h, p, pgAdminUser, db)) - if err != nil { - return nil, fmt.Errorf("pg config: %w", err) - } - - cfg.Password = e.pass() - - return stdlib.OpenDB(*cfg), nil - } - - cfg := mysql.NewConfig() - cfg.User, cfg.Passwd, cfg.Net, cfg.Addr, cfg.DBName = mysqlAdminUser, e.pass(), "tcp", e.addr(), db - cfg.InterpolateParams = true - cfg.Timeout = connectTimeout - - conn, err := mysql.NewConnector(cfg) + cfg, err := pgx.ParseConfig(fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=disable connect_timeout=5", + h, p, pgAdminUser, db)) if err != nil { - return nil, fmt.Errorf("mysql config: %w", err) + return nil, fmt.Errorf("pg config: %w", err) } - return sql.OpenDB(conn), nil + cfg.Password = e.pass() + + return stdlib.OpenDB(*cfg), nil } -func (e *dockerEngine) exec(ctx context.Context, db, stmt string, args ...any) error { +func (e *dockerEngine) exec(ctx context.Context, db, stmt string) error { conn, err := e.open(db) if err != nil { return err } defer conn.Close() - if _, err = conn.ExecContext(ctx, stmt, args...); err != nil { + if _, err = conn.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("sql: %w", err) } @@ -66,11 +52,8 @@ func (e *dockerEngine) exec(ctx context.Context, db, stmt string, args ...any) e } func (e *dockerEngine) bootstrap(ctx context.Context) error { - if e.kind != kindPostgres { - return nil - } - - if err := e.exec(ctx, pgAdminUser, "CREATE ROLE "+pgSuperRole+" NOLOGIN CREATEDB CREATEROLE"); err != nil { + if err := e.exec(ctx, pgAdminUser, "DO $$ BEGIN CREATE ROLE "+pgSuperRole+ + " NOLOGIN CREATEDB CREATEROLE; EXCEPTION WHEN duplicate_object THEN NULL; END $$"); err != nil { return err } @@ -89,17 +72,12 @@ type rowQuerier interface { // quoteIdent has the server quote s, so no client-built string reaches the DDL statement. func (e *dockerEngine) quoteIdent(ctx context.Context, q rowQuerier, s string) (string, error) { - if !identRe.MatchString(s) { + if !identRe().MatchString(s) { return "", errInvalid("identifier %q is invalid", s) } - query := "SELECT quote_ident($1::text)" - if e.kind != kindPostgres { - query = "SELECT CONCAT('`', REPLACE(?, '`', '``'), '`')" - } - var out string - if err := q.QueryRowContext(ctx, query, s).Scan(&out); err != nil { + if err := q.QueryRowContext(ctx, "SELECT quote_ident($1::text)", s).Scan(&out); err != nil { return "", fmt.Errorf("sql: %w", err) } @@ -139,7 +117,7 @@ func (e *dockerEngine) quoteLiteral(ctx context.Context, q rowQuerier, s string) func (e *dockerEngine) adminConn() (*sql.DB, error) { return e.open(e.adminDB()) } // CreateDatabase implements Engine. -func (e *dockerEngine) CreateDatabase(ctx context.Context, name, charset, collation string) error { +func (e *dockerEngine) CreateDatabase(ctx context.Context, name, _, _ string) error { conn, err := e.adminConn() if err != nil { return err @@ -151,25 +129,7 @@ func (e *dockerEngine) CreateDatabase(ctx context.Context, name, charset, collat return err } - if e.kind == kindPostgres { - return e.exec(ctx, pgAdminUser, "CREATE DATABASE "+ident) - } - - stmt, args := "CREATE DATABASE "+ident, []any{} - - if charset != "" && charsetRe.MatchString(charset) { - stmt += " CHARACTER SET ?" - - args = append(args, charset) - } - - if collation != "" && charsetRe.MatchString(collation) { - stmt += " COLLATE ?" - - args = append(args, collation) - } - - return e.exec(ctx, "", stmt, args...) + return e.exec(ctx, pgAdminUser, "CREATE DATABASE "+ident) } // DropDatabase implements Engine. @@ -185,21 +145,12 @@ func (e *dockerEngine) DropDatabase(ctx context.Context, name string) error { return err } - if e.kind == kindPostgres { - return e.exec(ctx, pgAdminUser, "DROP DATABASE "+ident+" WITH (FORCE)") - } - - return e.exec(ctx, "", "DROP DATABASE "+ident) + return e.exec(ctx, pgAdminUser, "DROP DATABASE "+ident+" WITH (FORCE)") } // ListDatabases implements Engine. func (e *dockerEngine) ListDatabases(ctx context.Context) ([]string, error) { - query := "SHOW DATABASES" - if e.kind == kindPostgres { - query = "SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY 1" - } - - rows, err := e.Query(ctx, e.adminDB(), query) + rows, err := e.Query(ctx, pgAdminUser, "SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY 1") if err != nil { return nil, err } @@ -215,14 +166,9 @@ func (e *dockerEngine) ListDatabases(ctx context.Context) ([]string, error) { // UpsertUser implements Engine. func (e *dockerEngine) UpsertUser(ctx context.Context, u UserSpec) error { - var err error - if e.kind == kindPostgres { - err = e.upsertPGUser(ctx, u) - } else { - err = e.upsertMySQLUser(ctx, u) - } + err := e.upsertPGUser(ctx, u) - if err == nil && u.Password != "" && u.Name == e.adminUser() { + if err == nil && u.Password != "" && u.Name == pgAdminUser { e.mu.Lock("UpsertUser") e.password = u.Password e.mu.Unlock() @@ -231,14 +177,6 @@ func (e *dockerEngine) UpsertUser(ctx context.Context, u UserSpec) error { return err } -func (e *dockerEngine) adminUser() string { - if e.kind == kindPostgres { - return pgAdminUser - } - - return mysqlAdminUser -} - func (e *dockerEngine) upsertPGUser(ctx context.Context, u UserSpec) error { conn, err := e.open(pgAdminUser) if err != nil { @@ -282,41 +220,8 @@ func (e *dockerEngine) upsertPGUser(ctx context.Context, u UserSpec) error { return nil } -func (e *dockerEngine) upsertMySQLUser(ctx context.Context, u UserSpec) error { - conn, err := e.open("") - if err != nil { - return err - } - defer conn.Close() - - if _, err = conn.ExecContext(ctx, "CREATE USER IF NOT EXISTS ?@?", u.Name, u.Host); err != nil { - return fmt.Errorf("sql: %w", err) - } - - if u.Password != "" { - if _, err = conn.ExecContext(ctx, "ALTER USER ?@? IDENTIFIED BY ?", u.Name, u.Host, u.Password); err != nil { - return fmt.Errorf("sql: %w", err) - } - } - - if _, err = conn.ExecContext( - ctx, - "GRANT ALL PRIVILEGES ON *.* TO ?@? WITH GRANT OPTION", - u.Name, - u.Host, - ); err != nil { - return fmt.Errorf("sql: %w", err) - } - - return nil -} - // DropUser implements Engine. -func (e *dockerEngine) DropUser(ctx context.Context, name, host string) error { - if e.kind != kindPostgres { - return e.exec(ctx, "", "DROP USER ?@?", name, host) - } - +func (e *dockerEngine) DropUser(ctx context.Context, name, _ string) error { conn, err := e.open(pgAdminUser) if err != nil { return err @@ -345,60 +250,7 @@ func (e *dockerEngine) Restart(ctx context.Context, flags []Flag) error { } func (e *dockerEngine) applyFlags(ctx context.Context, flags []Flag) error { - if e.kind == kindPostgres { - return e.applyPGFlags(ctx, flags) - } - - _ = e.exec(ctx, "", "RESET PERSIST") - - for _, f := range flags { - if !flagNameRe.MatchString(f.Name) { - return errInvalid("flag name %q is invalid", f.Name) - } - - if err := e.setMySQLFlag(ctx, f); err != nil { - return err - } - } - - return nil -} - -// mysqlFlagArg types numeric and boolean values so the driver interpolates them unquoted. -func mysqlFlagArg(v string) any { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - return n - } - - if f, err := strconv.ParseFloat(v, 64); err == nil && !math.IsInf(f, 0) && !math.IsNaN(f) && - !strings.ContainsAny(v, "eExXpP_") { - return f - } - - switch strings.ToUpper(v) { - case "TRUE": - return true - case "FALSE": - return false - } - - return v -} - -// setMySQLFlag persists a server variable; numbers and booleans must not be quoted. -func (e *dockerEngine) setMySQLFlag(ctx context.Context, f Flag) error { - conn, err := e.open("") - if err != nil { - return err - } - defer conn.Close() - - name, err := e.quoteDotted(ctx, conn, f.Name) - if err != nil { - return err - } - - return e.exec(ctx, "", "SET PERSIST_ONLY "+name+" = ?", mysqlFlagArg(f.Value)) + return e.applyPGFlags(ctx, flags) } func (e *dockerEngine) applyPGFlags(ctx context.Context, flags []Flag) error { diff --git a/services/gcpsql/docker_adopt.go b/services/gcpsql/docker_adopt.go new file mode 100644 index 0000000000..e3a65a0cf1 --- /dev/null +++ b/services/gcpsql/docker_adopt.go @@ -0,0 +1,106 @@ +package gcpsql + +import ( + "context" + "os" + "strconv" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const pgPortNum = 5432 + +func (e *dockerEngine) resourceKey() string { return e.spec.Project + "/" + e.id } + +func (e *dockerEngine) shape() string { + return e.image + "|" + strconv.FormatInt(e.spec.MemoryBytes, 10) + "|" + strconv.FormatInt(e.spec.NanoCPUs, 10) +} + +func (e *dockerEngine) ownerLabels() map[string]string { + l := container.ResourceLabels("gcpsql", e.resourceKey()) + l[dumpDirLabel] = e.dir + l[shapeLabel] = e.shape() + + return l +} + +// adopt takes over the running container a crashed gopherstack left for this instance; false means launch fresh. +// Replicas, recoveries and fresh instances never adopt: their container is built from state the label cannot carry. +func (e *dockerEngine) adopt(ctx context.Context) bool { + return e.adoptFrom(ctx, container.ClaimOrphan) +} + +func (e *dockerEngine) adoptFrom( + ctx context.Context, + claim func(service, resource string) (container.ManagedContainer, bool), +) bool { + if e.spec.Replica != nil || e.spec.Recovery != nil || e.spec.Fresh { + return false + } + + oc, ok := claim("gcpsql", e.resourceKey()) + if !ok { + return false + } + + dir, port := oc.Labels[dumpDirLabel], oc.Ports[pgPortNum] + if !e.adoptable(oc, dir, port) { + e.discard(ctx, oc.ID) + + return false + } + + if e.pool != nil && e.pool.Reserve(port, "gcpsql") != nil { + e.discard(ctx, oc.ID) + + return false + } + + old := e.dir + e.mu.Lock("adopt") + e.dir, e.port, e.ctrName, e.ctrID = dir, port, oc.Name, oc.ID + e.mu.Unlock() + + wctx, cancel := context.WithTimeout(ctx, adoptReadyWait) + defer cancel() + + if err := e.waitReady(wctx); err != nil { + logger.Load(ctx).WarnContext(ctx, "gcpsql: orphaned container unusable, launching fresh", + "instance", e.id, "container", oc.ID, "error", err) + e.discard(ctx, oc.ID) + e.mu.Lock("adopt") + e.dir, e.ctrID, e.port = old, "", 0 + e.mu.Unlock() + + if e.pool != nil { + portalloc.ReleaseTo(e.pool, port) + } + + return false + } + + _ = os.RemoveAll(old) + + logger.Load(ctx).InfoContext(ctx, "gcpsql: adopted container from an earlier run", + "instance", e.id, "container", oc.ID) + + return true +} + +func (e *dockerEngine) adoptable(oc container.ManagedContainer, dir string, port int) bool { + if oc.Labels[shapeLabel] != e.shape() || port == 0 || dir == "" { + return false + } + + st, err := os.Stat(dir) + + return err == nil && st.IsDir() +} + +func (e *dockerEngine) discard(ctx context.Context, id string) { + if err := e.rt.StopAndRemove(ctx, id); err != nil { + logger.Load(ctx).WarnContext(ctx, "gcpsql: orphan removal failed", "container", id, "error", err) + } +} diff --git a/services/gcpsql/docker_adopt_internal_test.go b/services/gcpsql/docker_adopt_internal_test.go new file mode 100644 index 0000000000..81c657a0cc --- /dev/null +++ b/services/gcpsql/docker_adopt_internal_test.go @@ -0,0 +1,130 @@ +package gcpsql + +import ( + "context" + "maps" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +type removeRecorder struct { + removed []string + mu sync.Mutex +} + +func (r *removeRecorder) CreateAndStart(context.Context, container.Spec) (string, error) { + return "", nil +} +func (r *removeRecorder) StopContainer(context.Context, string) error { return nil } +func (r *removeRecorder) StartContainer(context.Context, string) error { return nil } + +func (r *removeRecorder) Exec(context.Context, string, []string) (container.ExecResult, error) { + return container.ExecResult{}, nil +} + +func (r *removeRecorder) StopAndRemove(_ context.Context, id string) error { + r.mu.Lock() + r.removed = append(r.removed, id) + r.mu.Unlock() + + return nil +} + +type orphanLister struct{ all []container.ManagedContainer } + +func (o orphanLister) ListManaged(context.Context) ([]container.ManagedContainer, error) { + return o.all, nil +} +func (orphanLister) StopAndRemove(context.Context, string) error { return nil } + +func TestAdoptFrom(t *testing.T) { + t.Parallel() + + tests := []struct { + mutate func(*dockerEngine, *container.ManagedContainer) + name string + wantClaim bool + wantRemove bool + }{ + { + name: "fresh instance leaves orphan held", + mutate: func(e *dockerEngine, _ *container.ManagedContainer) { e.spec.Fresh = true }, + }, + { + name: "replica leaves orphan held", + mutate: func(e *dockerEngine, _ *container.ManagedContainer) { e.spec.Replica = &ReplicaSource{} }, + }, + { + name: "recovery leaves orphan held", + mutate: func(e *dockerEngine, _ *container.ManagedContainer) { e.spec.Recovery = &Recovery{} }, + }, + { + name: "shape mismatch removed", + wantClaim: true, wantRemove: true, + mutate: func(_ *dockerEngine, c *container.ManagedContainer) { c.Labels[shapeLabel] = "other" }, + }, + { + name: "missing dump dir removed", + wantClaim: true, wantRemove: true, + mutate: func(_ *dockerEngine, c *container.ManagedContainer) { c.Labels[dumpDirLabel] = "/nonexistent/x" }, + }, + { + name: "unreachable database removed", + wantClaim: true, wantRemove: true, + mutate: func(*dockerEngine, *container.ManagedContainer) {}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := &removeRecorder{} + orig := t.TempDir() + dumpDir := t.TempDir() + e := &dockerEngine{ + rt: rec, mu: lockmetrics.New("t"), id: "i", image: "postgres:16", dir: orig, + spec: LaunchSpec{Project: "p"}, + } + + id := container.CurrentIdentity() + labels := id.Labels() + labels[container.LabelPID] = "999999999:1" + + maps.Copy(labels, e.ownerLabels()) + + labels[dumpDirLabel] = dumpDir + + oc := container.ManagedContainer{ + ID: "ctr1", Running: true, Labels: labels, Ports: map[int]int{5432: 1}, + } + tt.mutate(e, &oc) + + lister := orphanLister{all: []container.ManagedContainer{oc}} + orphans, err := container.Reconcile(t.Context(), lister, id, nil) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancel() + + assert.False(t, e.adoptFrom(ctx, orphans.Claim)) + assert.Equal(t, tt.wantRemove, len(rec.removed) == 1) + assert.Equal(t, orig, e.dir) + assert.Empty(t, e.ctrID) + + _, stillHeld := orphans.Claim("gcpsql", e.resourceKey()) + assert.Equal(t, !tt.wantClaim, stillHeld) + + _, err = os.Stat(orig) + require.NoError(t, err) + }) + } +} diff --git a/services/gcpsql/docker_adopt_test.go b/services/gcpsql/docker_adopt_test.go new file mode 100644 index 0000000000..9d484f1395 --- /dev/null +++ b/services/gcpsql/docker_adopt_test.go @@ -0,0 +1,91 @@ +package gcpsql_test + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sqladmin "google.golang.org/api/sqladmin/v1" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +func resourceContainers(t *testing.T, name string) []string { + t.Helper() + + out, err := exec.CommandContext(t.Context(), "docker", "ps", "-aq", + "--filter", "label="+container.LabelResource+"="+project+"/"+name).Output() + require.NoError(t, err) + + return strings.Fields(string(out)) +} + +// TestDockerOrphanAdoption crashes a backend (no Close) and restores a second one from its snapshot: the +// engine container must be adopted, not duplicated, and the data must survive. +func TestDockerOrphanAdoption(t *testing.T) { + t.Parallel() + + const name = "dadopt" + + e := dockerEnv(t) + ctx, cancel := context.WithTimeout(t.Context(), dockerWait) + defer cancel() + + assertNoContainers(t, name) + + e.createInstance(t, name, "POSTGRES_16", nil) + + op, err := e.svc.Databases.Insert(project, name, &sqladmin.Database{Name: "app"}).Context(ctx).Do() + e.mustWait(t, op, err) + op, err = e.svc.Users.Insert(project, name, &sqladmin.User{Name: "alice", Host: "%", Password: "alice-pw"}). + Context(ctx).Do() + e.mustWait(t, op, err) + + db := openDB(t, "POSTGRES_16", e.endpoint(t, name), "alice", "alice-pw") + _, err = db.ExecContext(ctx, "CREATE TABLE items (id int primary key)") + require.NoError(t, err) + _, err = db.ExecContext(ctx, "INSERT INTO items VALUES (1), (2)") + require.NoError(t, err) + + before := resourceContainers(t, name) + require.Len(t, before, 1) + + snap := e.backend.Snapshot(ctx) + + rt, err := container.NewRuntime(container.Config{}) + require.NoError(t, err) + t.Cleanup(func() { _ = rt.Close() }) + + lister, ok := rt.(container.ManagedLister) + require.True(t, ok) + + orphans, err := container.Reconcile(ctx, lister, container.CurrentIdentity(), nil) + require.NoError(t, err) + + container.SetOrphans(orphans) + t.Cleanup(func() { container.SetOrphans(nil) }) + + second := newDockerHandler(t) + t.Cleanup(second.Backend.Reset) + t.Cleanup(second.Backend.Close) + + require.NoError(t, second.Backend.Restore(ctx, snap)) + + require.Eventually(t, func() bool { + in, getErr := second.Backend.GetInstance(project, name) + + return getErr == nil && in.State == "RUNNABLE" && in.GopherstackEndpoint != "" + }, dockerWait, time.Second) + + assert.Equal(t, before, resourceContainers(t, name), "the crashed backend's container is reused, not duplicated") + + in, err := second.Backend.GetInstance(project, name) + require.NoError(t, err) + + restored := openDB(t, "POSTGRES_16", in.GopherstackEndpoint, "alice", "alice-pw") + assert.Equal(t, "2", scalar(t, restored, "SELECT count(*) FROM items")) +} diff --git a/services/gcpsql/docker_dump.go b/services/gcpsql/docker_dump.go index a9d4619fcf..56dd727a01 100644 --- a/services/gcpsql/docker_dump.go +++ b/services/gcpsql/docker_dump.go @@ -9,33 +9,77 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/container" ) const ( stderrLimit = 512 connectLine = `\connect ` dumpFileMode = 0o600 - mysqlClient = "mysql" + psqlBin = "psql" + pgDumpBin = "pg_dump" ) var errExec = errors.New("command failed") func (e *dockerEngine) run(ctx context.Context, cmd []string) error { - res, err := e.rt.Exec(ctx, e.container(), cmd) + _, err := e.runOut(ctx, cmd) + + return err +} + +func (e *dockerEngine) runOut(ctx context.Context, cmd []string) (string, error) { + return e.runOutEnv(ctx, cmd, nil) +} + +func (e *dockerEngine) runOutEnv(ctx context.Context, cmd, env []string) (string, error) { + name := cmdLabel(cmd) + + res, err := container.ExecWithEnv(ctx, e.rt, e.container(), cmd, env) if err != nil { - return fmt.Errorf("exec %s: %w", cmd[0], err) + return "", fmt.Errorf("%w: exec %s: %s", errExec, name, e.scrub(err.Error())) } if res.ExitCode != 0 { - msg := strings.TrimSpace(res.Stderr + res.Stdout) + msg := e.scrub(strings.TrimSpace(res.Stderr + res.Stdout)) if len(msg) > stderrLimit { msg = msg[:stderrLimit] } - return fmt.Errorf("%w: %s exited %d: %s", errExec, cmd[0], res.ExitCode, msg) + return "", fmt.Errorf("%w: %s exited %d: %s", errExec, name, res.ExitCode, msg) } - return nil + return res.Stdout, nil +} + +// cmdLabel maps argv to a fixed label so credentials in argv never reach errors or logs. +func cmdLabel(cmd []string) string { + for _, a := range cmd { + switch a { + case psqlBin: + return psqlBin + case pgDumpBin: + return pgDumpBin + } + } + + return "command" +} + +// scrub redacts the engine's credentials from command output. +func (e *dockerEngine) scrub(msg string) string { + e.mu.RLock("scrub") + secrets := []string{e.password, e.replPass} + e.mu.RUnlock() + + for _, sec := range secrets { + if sec != "" { + msg = strings.ReplaceAll(msg, sec, "***") + } + } + + return msg } func (e *dockerEngine) readDump(name string) ([]byte, error) { @@ -56,16 +100,12 @@ func (e *dockerEngine) readDump(name string) ([]byte, error) { // Dump implements Engine. func (e *dockerEngine) Dump(ctx context.Context, dbs []string, opts DumpOptions) ([]byte, error) { - if e.kind == kindMySQL { - return e.dumpMySQL(ctx, dbs) - } - var out bytes.Buffer for i, db := range dbs { name := "dump" + strconv.Itoa(i) + ".sql" - cmd := []string{"pg_dump", "-U", pgAdminUser, "-d", db, "--clean", "--if-exists", "-f", dumpMount + "/" + name} + cmd := []string{pgDumpBin, "-U", pgAdminUser, "-d", db, "--clean", "--if-exists", "-f", dumpMount + "/" + name} if opts.Portable { cmd = append(cmd, "--no-owner", "--no-privileges") } @@ -94,29 +134,8 @@ func (e *dockerEngine) Dump(ctx context.Context, dbs []string, opts DumpOptions) return out.Bytes(), nil } -func (e *dockerEngine) dumpMySQL(ctx context.Context, dbs []string) ([]byte, error) { - if len(dbs) == 0 { - return nil, nil - } - - cmd := append([]string{ - "env", "MYSQL_PWD=" + e.pass(), "mysqldump", "-u" + mysqlAdminUser, "--single-transaction", "--routines", - "--add-drop-database", "-r", dumpMount + "/dump.sql", "--databases", - }, dbs...) - - if err := e.run(ctx, cmd); err != nil { - return nil, err - } - - return e.readDump("dump.sql") -} - // Restore implements Engine. func (e *dockerEngine) Restore(ctx context.Context, db string, data []byte) error { - if e.kind == kindMySQL { - return e.restoreFile(ctx, db, data) - } - for _, seg := range splitDump(db, data) { if seg.db != pgAdminUser { if err := e.CreateDatabase(ctx, seg.db, "", ""); err != nil && @@ -143,16 +162,7 @@ func (e *dockerEngine) restoreFile(ctx context.Context, db string, data []byte) file := dumpMount + "/in.sql" - if e.kind == kindPostgres { - return e.run(ctx, []string{"psql", "-U", pgAdminUser, "-d", db, "-v", "ON_ERROR_STOP=1", "-q", "-f", file}) - } - - cmd := []string{"env", "MYSQL_PWD=" + e.pass(), mysqlClient, "-u" + mysqlAdminUser} - if db != "" { - cmd = append(cmd, "--database="+db) - } - - return e.run(ctx, append(cmd, "-e", "source "+file)) + return e.run(ctx, []string{psqlBin, "-U", pgAdminUser, "-d", db, "-v", "ON_ERROR_STOP=1", "-q", "-f", file}) } type dumpSegment struct { diff --git a/services/gcpsql/docker_launcher.go b/services/gcpsql/docker_launcher.go index 514adf8de5..0f93f3b089 100644 --- a/services/gcpsql/docker_launcher.go +++ b/services/gcpsql/docker_launcher.go @@ -4,25 +4,31 @@ import ( "context" "fmt" "log/slog" - "net" "os" "strconv" + "strings" "time" "github.com/blackbirdworks/gopherstack/pkgs/container" "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/mysqlengine" "github.com/blackbirdworks/gopherstack/pkgs/portalloc" ) const ( - dumpMount = "/gopherstack-dump" - pgPort = "5432" - mysqlPort = "3306" - readyInterval = 500 * time.Millisecond - teardownLimit = 30 * time.Second - maxDumpBytes = 256 << 20 + dumpMount = "/gopherstack-dump" + pgDataDir = "/var/lib/postgresql/data" + volumeLabel = "gopherstack.gcpsql" + staleVolumeAge = 10 * time.Minute + dumpDirLabel = "gopherstack.gcpsql.dumpdir" + shapeLabel = "gopherstack.gcpsql.shape" + adoptReadyWait = 30 * time.Second + pgPort = "5432" + readyInterval = 500 * time.Millisecond + teardownLimit = 30 * time.Second + maxDumpBytes = 256 << 20 ) type containerRuntime interface { @@ -36,11 +42,12 @@ type containerRuntime interface { type dockerLauncher struct { lr *gcpserve.LazyRuntime pool *portalloc.Allocator + log *slog.Logger startup time.Duration } func newDockerLauncher(log *slog.Logger, pool *portalloc.Allocator, startup time.Duration) *dockerLauncher { - return &dockerLauncher{lr: gcpserve.NewLazyRuntime(log, "gcpsql.runtime"), pool: pool, startup: startup} + return &dockerLauncher{lr: gcpserve.NewLazyRuntime(log, "gcpsql.runtime"), pool: pool, log: log, startup: startup} } // Close releases the runtime client. @@ -63,14 +70,27 @@ func (l *dockerLauncher) Launch(ctx context.Context, spec LaunchSpec) (Engine, e return nil, err } + if kind == kindMySQL { + return l.launchMySQL(ctx, r, rt, spec, image) + } + dir, err := os.MkdirTemp("", "gopherstack-gcpsql-") if err != nil { return nil, fmt.Errorf("dump dir: %w", err) } e := &dockerEngine{ - rt: rt, pool: l.pool, kind: kind, image: image, id: spec.ID, password: spec.Password, dir: dir, - mu: lockmetrics.New("gcpsql.engine"), + rt: rt, pool: l.pool, image: image, id: spec.ID, password: spec.Password, dir: dir, + mu: lockmetrics.New("gcpsql.engine"), spec: spec, + } + + if vr, vok := r.(container.VolumeRuntime); vok && spec.Persist { + e.vols = vr + e.volData = volumeName("data", spec.Project, spec.ID) + + if spec.Replica == nil { + e.volPITR = volumeName("pitr", spec.Project, spec.ID) + } } lctx, cancel := context.WithTimeout(ctx, l.startup) @@ -85,34 +105,132 @@ func (l *dockerLauncher) Launch(ctx context.Context, spec LaunchSpec) (Engine, e return e, nil } +func (l *dockerLauncher) launchMySQL( + ctx context.Context, r any, rt containerRuntime, spec LaunchSpec, image string, +) (Engine, error) { + var vr container.VolumeRuntime + if v, ok := r.(container.VolumeRuntime); ok { + vr = v + } + + eng, err := mysqlengine.New(mysqlengine.Config{ + Runtime: rt, Volumes: vr, Ports: l.pool, Logger: l.log, Prefix: "gopherstack-gcpsql", + Service: "gcpsql", ReplUser: replUser, StartTimeout: l.startup, + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrRuntimeUnavailable, err) + } + + srv, err := eng.Launch(ctx, mysqlSpec(spec, image)) + if err != nil { + return nil, err //nolint:wrapcheck // launch errors carry the image + } + + return &mysqlEngine{srv: srv}, nil +} + +// SweepVolumes deletes the unused persistent volumes of instances not in keep, left by an earlier run. +func (l *dockerLauncher) SweepVolumes(ctx context.Context, keep map[string]bool) { + r, err := l.lr.Get() + if err != nil { + return + } + + vr, ok := r.(container.VolumeRuntime) + if !ok { + return + } + + names, err := vr.ListUnusedVolumes(ctx, volumeLabel, staleVolumeAge) + if err != nil { + return + } + + for _, n := range names { + if !keep[n] && strings.HasPrefix(n, volumePrefix) { + if err = vr.RemoveVolume(ctx, n); err != nil { + logger.Load(ctx).WarnContext(ctx, "gcpsql: stale volume removal failed", "volume", n, "error", err) + } + } + } +} + type dockerEngine struct { rt containerRuntime + vols container.VolumeRuntime pool *portalloc.Allocator mu *lockmetrics.RWMutex + net *sourceNet + ctrID string image string id string - password string + replPass string dir string - ctrID string + password string + ctrName string + volData string + volPITR string + netID string + spec LaunchSpec port int - kind dbKind + promoted bool } func (e *dockerEngine) boot(ctx context.Context) error { - id, err := container.CreateAndStartRetry(ctx, e.rt, e.prepare, e.releasePort) - if err != nil { - return fmt.Errorf("start %s: %w", e.image, err) + if err := e.createVolumes(ctx); err != nil { + return err } - e.setContainer(id) + if !e.adopt(ctx) { + id, err := container.CreateAndStartRetry(ctx, e.rt, e.prepare, e.releasePort) + if err != nil { + return fmt.Errorf("start %s: %w", e.image, err) + } - if err = e.waitReady(ctx); err != nil { + e.setContainer(id) + } + + if err := e.waitReady(ctx); err != nil { return err } + if e.spec.Replica != nil { + return nil + } + + if e.spec.Recovery != nil { + if err := e.waitPromoted(ctx); err != nil { + return err + } + } + return e.bootstrap(ctx) } +func (e *dockerEngine) volumeLabels() map[string]string { + return map[string]string{volumeLabel: "true", volumeLabel + ".instance": e.spec.Project + "/" + e.id} +} + +func (e *dockerEngine) createVolumes(ctx context.Context) error { + for _, v := range []string{e.volData, e.volPITR} { + if v == "" { + continue + } + + if e.spec.Fresh { + if err := e.vols.RemoveVolume(ctx, v); err != nil { + return fmt.Errorf("discard stale volume %s: %w", v, err) + } + } + + if err := e.vols.CreateVolume(ctx, v, e.volumeLabels()); err != nil { + return fmt.Errorf("create volume %s: %w", v, err) + } + } + + return nil +} + func (e *dockerEngine) setContainer(id string) { e.mu.Lock("setContainer") e.ctrID = id @@ -143,22 +261,33 @@ func (e *dockerEngine) prepare() (container.Spec, error) { e.port = port e.mu.Unlock() + name := "gopherstack-gcpsql-" + sanitizeName(e.id) + "-" + strconv.Itoa(port) + + e.mu.Lock("prepare") + e.ctrName = name + e.mu.Unlock() + spec := container.Spec{ - Name: "gopherstack-gcpsql-" + sanitizeName(e.id) + "-" + strconv.Itoa(port), - Image: e.image, - Mounts: []string{e.dir + ":" + dumpMount}, + Name: name, + Labels: e.ownerLabels(), + Image: e.image, + Mounts: []string{e.dir + ":" + dumpMount}, + MemoryBytes: e.spec.MemoryBytes, + NanoCPUs: e.spec.NanoCPUs, } - cport := pgPort + if r := e.netSource(); r != nil { + spec.Network = r.Network - if e.kind == kindMySQL { - cport = mysqlPort - spec.Env = []string{"MYSQL_ROOT_PASSWORD=" + e.pass()} - } else { - spec.Env = []string{"POSTGRES_USER=" + pgAdminUser, "POSTGRES_PASSWORD=" + e.pass()} + e.mu.Lock("prepare") + e.netID = r.NetworkID + e.mu.Unlock() } - spec.Ports = []string{container.PortSpec(container.LoopbackHost, strconv.Itoa(port), cport)} + spec.Env = []string{"POSTGRES_USER=" + pgAdminUser, "POSTGRES_PASSWORD=" + e.pass()} + e.pgLaunch(&spec) + + spec.Ports = []string{container.PortSpec(container.LoopbackHost, strconv.Itoa(port), pgPort)} return spec, nil } @@ -191,12 +320,6 @@ func (e *dockerEngine) Endpoint() (string, int) { return container.LoopbackHost, e.port } -func (e *dockerEngine) addr() string { - h, p := e.Endpoint() - - return net.JoinHostPort(h, strconv.Itoa(p)) -} - func (e *dockerEngine) waitReady(ctx context.Context) error { tick := time.NewTicker(readyInterval) defer tick.Stop() @@ -227,13 +350,7 @@ func (e *dockerEngine) ping(ctx context.Context) error { return db.PingContext(pctx) } -func (e *dockerEngine) adminDB() string { - if e.kind == kindPostgres { - return pgAdminUser - } - - return "" -} +func (e *dockerEngine) adminDB() string { return pgAdminUser } // Stop implements Engine. func (e *dockerEngine) Stop(ctx context.Context) error { @@ -260,6 +377,7 @@ func (e *dockerEngine) Close(ctx context.Context) { } } + e.releaseNetwork(ctx) e.releasePort() _ = os.RemoveAll(e.dir) diff --git a/services/gcpsql/docker_pitr.go b/services/gcpsql/docker_pitr.go new file mode 100644 index 0000000000..f43f9ba110 --- /dev/null +++ b/services/gcpsql/docker_pitr.go @@ -0,0 +1,208 @@ +package gcpsql + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +const ( + pitrWait = 90 * time.Second + pitrReplayWait = 6 * time.Minute + pitrPoll = 250 * time.Millisecond + pgArchiveCmd = "test ! -f " + pitrMount + "/wal/%f && cp %p " + pitrMount + "/wal/%f" + pitrBaseTime = "base-time" +) + +var ( + errNoPITR = errors.New("gcpsql: point-in-time recovery needs persistent volumes from the container runtime") + errPITRNotOn = errors.New("gcpsql: point-in-time recovery is not enabled on the source engine") +) + +func (e *dockerEngine) readFile(ctx context.Context, path string) (string, error) { + return e.runOut(ctx, []string{"cat", path}) +} + +func (e *dockerEngine) writeFile(ctx context.Context, path, content string) error { + return e.run(ctx, []string{"sh", "-c", `printf %s "$1" > "$0"`, path, content}) +} + +func (e *dockerEngine) baseTimeFile() string { return pitrMount + "/" + pitrBaseTime } + +// storedBase returns the time of the base backup a previous run left in the volume. +func (e *dockerEngine) storedBase(ctx context.Context) (time.Time, bool) { + out, err := e.readFile(ctx, e.baseTimeFile()) + if err != nil { + return time.Time{}, false + } + + t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(out)) + + return t, err == nil +} + +// EnablePITR implements Recoverer. +func (e *dockerEngine) EnablePITR(ctx context.Context, _ []string) (time.Time, error) { + if e.volData == "" { + return time.Time{}, errNoPITR + } + + if t, ok := e.storedBase(ctx); ok { + if err := e.setArchiving(ctx, true); err != nil { + return time.Time{}, err + } + + return t, nil + } + + if err := e.pgBase(ctx); err != nil { + return time.Time{}, err + } + + now := time.Now().UTC() + + return now, e.writeFile(ctx, e.baseTimeFile(), now.Format(time.RFC3339Nano)) +} + +func (e *dockerEngine) setArchiving(ctx context.Context, on bool) error { + cur, err := e.psql(ctx, "SHOW archive_mode") + if err != nil { + return err + } + + want := "off" + if on { + want = "on" + } + + if strings.TrimSpace(cur) == want { + return nil + } + + for _, stmt := range []string{ + "ALTER SYSTEM SET archive_mode = '" + want + "'", + "ALTER SYSTEM SET archive_command = '" + pgArchiveCmd + "'", + } { + if _, err = e.psql(ctx, stmt); err != nil { + return err + } + } + + if err = e.Stop(ctx); err != nil { + return err + } + + return e.Start(ctx) +} + +func (e *dockerEngine) pgBase(ctx context.Context) error { + if err := e.setArchiving(ctx, true); err != nil { + return err + } + + return e.run(ctx, []string{ + "sh", "-c", "rm -rf " + pitrMount + "/base && pg_basebackup -U " + pgAdminUser + " -D " + pitrMount + + "/base -X fetch -c fast", + }) +} + +// DisablePITR implements Recoverer. +func (e *dockerEngine) DisablePITR(ctx context.Context) error { + if e.volData == "" { + return nil + } + + if err := e.setArchiving(ctx, false); err != nil { + return err + } + + return e.run( + ctx, + []string{"sh", "-c", "rm -rf " + pitrMount + "/base " + pitrMount + "/wal/* " + e.baseTimeFile()}, + ) +} + +// PrepareRecovery implements Recoverer. +func (e *dockerEngine) PrepareRecovery(ctx context.Context, at time.Time) (Recovery, error) { + if e.volData == "" { + return Recovery{}, errNoPITR + } + + if _, ok := e.storedBase(ctx); !ok { + return Recovery{}, errPITRNotOn + } + + return Recovery{At: at, Volume: e.volPITR}, e.archiveNow(ctx) +} + +// archiveNow forces a commit record and a WAL switch, then waits until the segment is in the archive. +func (e *dockerEngine) archiveNow(ctx context.Context) error { + if _, err := e.psql(ctx, "SELECT pg_current_xact_id()"); err != nil { + return err + } + + seg, err := e.psql(ctx, "SELECT pg_walfile_name(pg_current_wal_lsn())") + if err != nil { + return err + } + + seg = strings.TrimSpace(seg) + + if _, err = e.psql(ctx, "SELECT pg_switch_wal()"); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(ctx, pitrWait) + defer cancel() + + tick := time.NewTicker(pitrPoll) + defer tick.Stop() + + for { + last, qerr := e.psql(ctx, "SELECT COALESCE(last_archived_wal, '') FROM pg_stat_archiver") + if qerr == nil && strings.TrimSpace(last) >= seg && !strings.HasSuffix(strings.TrimSpace(last), ".history") { + return nil + } + + select { + case <-ctx.Done(): + return fmt.Errorf("%w: WAL segment %s was not archived", errExec, seg) + case <-tick.C: + } + } +} + +// waitPromoted blocks until a recovery has reached its target and the server accepts writes, then drops the +// recovery settings. +func (e *dockerEngine) waitPromoted(ctx context.Context) error { + tick := time.NewTicker(pitrPoll) + defer tick.Stop() + + for { + out, err := e.psql(ctx, "SELECT pg_is_in_recovery()") + if err == nil && strings.TrimSpace(out) == "f" { + break + } + + select { + case <-ctx.Done(): + return fmt.Errorf("recovery did not finish: %w", ctx.Err()) + case <-tick.C: + } + } + + for _, s := range []string{"restore_command", "recovery_target_time", "recovery_target_action"} { + if _, err := e.psql(ctx, "ALTER SYSTEM RESET "+s); err != nil { + return err + } + } + + _, err := e.psql(ctx, "SELECT pg_reload_conf()") + + return err +} + +// ApplyRecovery implements Recoverer; PostgreSQL recovers during startup. +func (*dockerEngine) ApplyRecovery(context.Context, Recovery) error { return nil } diff --git a/services/gcpsql/docker_query.go b/services/gcpsql/docker_query.go index 8549ecd2fb..d434117209 100644 --- a/services/gcpsql/docker_query.go +++ b/services/gcpsql/docker_query.go @@ -119,11 +119,7 @@ func (e *dockerEngine) insertStmt( cols = append(cols, col) } - if e.kind == kindPostgres { - marks = append(marks, "$"+strconv.Itoa(i+1)) - } else { - marks = append(marks, "?") - } + marks = append(marks, "$"+strconv.Itoa(i+1)) } colList := "" diff --git a/services/gcpsql/docker_replica.go b/services/gcpsql/docker_replica.go new file mode 100644 index 0000000000..5bdd7133f8 --- /dev/null +++ b/services/gcpsql/docker_replica.go @@ -0,0 +1,308 @@ +package gcpsql + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync/atomic" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + replUser = "cloudsqlreplica" + pitrMount = "/gopherstack-pitr" + pitrSrcMount = "/gopherstack-pitr-src" + recoveryEnv = "GS_RECOVERY_AT" + + pgWrapper = `set -e +if [ -d ` + pitrMount + ` ]; then + mkdir -p ` + pitrMount + `/wal + chown postgres:postgres ` + pitrMount + ` ` + pitrMount + `/wal +fi +if [ -n "$` + recoveryEnv + `" ] && [ ! -s "$PGDATA/PG_VERSION" ]; then + mkdir -p "$PGDATA" + cp -a ` + pitrSrcMount + `/base/. "$PGDATA"/ + chown -R postgres:postgres "$PGDATA" + chmod 700 "$PGDATA" + touch "$PGDATA/recovery.signal" + echo "restore_command = 'cp ` + pitrSrcMount + `/wal/%f %p'" >> "$PGDATA/postgresql.auto.conf" + echo "recovery_target_time = '$` + recoveryEnv + `'" >> "$PGDATA/postgresql.auto.conf" + echo "recovery_target_action = 'promote'" >> "$PGDATA/postgresql.auto.conf" +fi +exec docker-entrypoint.sh "$@"` +) + +// sourceNet is the docker network a replication source shares with its replicas. +type sourceNet struct { + id string + name string + connected atomic.Bool +} + +// pgLaunch fills the PostgreSQL part of the container spec: replication-ready flags, the data volume and, for +// replicas and recoveries, the entrypoint that seeds the data directory. +func (e *dockerEngine) pgLaunch(s *container.Spec) { + args := []string{ + "postgres", "-c", "wal_level=replica", "-c", "max_wal_senders=10", "-c", "max_replication_slots=10", + } + + if e.volData != "" { + s.Mounts = append(s.Mounts, e.volData+":"+pgDataDir) + } + + switch { + case e.spec.Replica != nil: + r := e.spec.Replica + s.Entrypoint = append([]string{ + "sh", "-c", dbrepl.PGReplicaScript(replUser), "gs-pg-replica", r.Host, strconv.Itoa(r.Port), + }, args...) + s.Env = append(s.Env, dbrepl.PasswordEnv+"="+r.Password) + case e.volPITR != "": + s.Mounts = append(s.Mounts, e.volPITR+":"+pitrMount) + + if rec := e.spec.Recovery; rec != nil { + s.Mounts = append(s.Mounts, rec.Volume+":"+pitrSrcMount+":ro") + s.Env = append(s.Env, recoveryEnv+"="+rec.At.UTC().Format("2006-01-02 15:04:05.000000+00")) + } + + s.Entrypoint = append([]string{"sh", "-c", pgWrapper, "gs-pg"}, args...) + default: + s.Cmd = args + } +} + +func (e *dockerEngine) psql(ctx context.Context, sqlText string) (string, error) { + return e.runOut( + ctx, + []string{psqlBin, "-tA", "-q", "-v", "ON_ERROR_STOP=1", "-U", pgAdminUser, "-d", pgAdminUser, "-c", sqlText}, + ) +} + +// netSource is the source whose replication network this engine joins: its primary, or the engine it recovers from. +func (e *dockerEngine) netSource() *ReplicaSource { + if e.spec.Replica != nil { + return e.spec.Replica + } + + if e.spec.Recovery != nil { + return e.spec.Recovery.Source + } + + return nil +} + +func (e *dockerEngine) replicaPass() string { + e.mu.Lock("replicaPass") + defer e.mu.Unlock() + + if e.replPass == "" { + e.replPass = randomPassword()[:30] + } + + return e.replPass +} + +// ReplicationSource implements Replicator. +func (e *dockerEngine) ReplicationSource(ctx context.Context) (ReplicaSource, error) { + nr, ok := e.rt.(container.NetworkRuntime) + if !ok { + return ReplicaSource{}, errNoReplication + } + + pass := e.replicaPass() + + if err := e.preparePGSource(ctx, pass); err != nil { + return ReplicaSource{}, err + } + + n, err := e.sourceNetwork(ctx, nr) + if err != nil { + return ReplicaSource{}, err + } + + return ReplicaSource{ + Network: n.name, + NetworkID: n.id, + Host: e.containerName(), + Port: pgPortNum, + Password: pass, + }, nil +} + +func (e *dockerEngine) containerName() string { + e.mu.RLock("containerName") + defer e.mu.RUnlock() + + return e.ctrName +} + +func (e *dockerEngine) preparePGSource(ctx context.Context, pass string) error { + if _, err := e.psql(ctx, dbrepl.PGReplicaRoleSQL(replUser, pass)); err != nil { + return err + } + + if err := e.run(ctx, []string{"sh", "-c", dbrepl.PGHBAScript(replUser), "pg-hba"}); err != nil { + return err + } + + _, err := e.psql(ctx, "SELECT pg_reload_conf()") + + return err +} + +func (e *dockerEngine) sourceNetwork(ctx context.Context, nr container.NetworkRuntime) (*sourceNet, error) { + e.mu.Lock("sourceNetwork") + defer e.mu.Unlock() + + if e.net == nil { + name := "gopherstack-gcpsql-net-" + sanitizeName(e.id) + "-" + randomPassword()[:8] + + id, err := nr.CreateNetwork(ctx, name) + if err != nil { + return nil, fmt.Errorf("create replication network: %w", err) + } + + e.net = &sourceNet{id: id, name: name} + e.netID = id + } + + if !e.net.connected.Load() { + if err := nr.ConnectNetwork(ctx, e.net.id, e.ctrID); err != nil { + return nil, fmt.Errorf("connect source to replication network: %w", err) + } + + e.net.connected.Store(true) + } + + return e.net, nil +} + +// releaseNetwork removes the replication network; a network still in use by another engine is removed by +// whichever engine closes last. +func (e *dockerEngine) releaseNetwork(ctx context.Context) { + nr, ok := e.rt.(container.NetworkRuntime) + if !ok { + return + } + + e.mu.RLock("releaseNetwork") + id := e.netID + e.mu.RUnlock() + + if id != "" { + _ = nr.RemoveNetwork(ctx, id) + } +} + +// Persistent reports whether the engine keeps its data in a volume that survives the container. +func (e *dockerEngine) Persistent() bool { return e.volData != "" } + +// ReplicaSeed implements Replicator; PostgreSQL replicas copy the data themselves. +func (*dockerEngine) ReplicaSeed(context.Context, []string) ([]byte, error) { return nil, nil } + +// AttachReplica implements Replicator; PostgreSQL replicas already stream from pg_basebackup's recovery config. +func (e *dockerEngine) AttachReplica(ctx context.Context, src ReplicaSource, _ []byte) error { + e.mu.Lock("AttachReplica") + e.spec.Replica, e.netID = &src, src.NetworkID + e.mu.Unlock() + + return e.repointPG(ctx, src) +} + +func (e *dockerEngine) conninfo(src ReplicaSource) string { + return fmt.Sprintf("host=%s port=%d user=%s password=%s", src.Host, src.Port, replUser, src.Password) +} + +// repointPG aims a streaming replica at its source's current container name, which changes across restarts. +func (e *dockerEngine) repointPG(ctx context.Context, src ReplicaSource) error { + if _, err := e.psql(ctx, "ALTER SYSTEM SET primary_conninfo = '"+e.conninfo(src)+"'"); err != nil { + return err + } + + _, err := e.psql(ctx, "SELECT pg_reload_conf()") + + return err +} + +// Promote implements Replicator. +func (e *dockerEngine) Promote(ctx context.Context) error { + out, err := e.psql(ctx, "SELECT pg_promote(true, 60)") + if err != nil { + return err + } + + if strings.TrimSpace(out) != "t" { + return fmt.Errorf("%w: pg_promote did not complete", errExec) + } + + e.mu.Lock("Promote") + e.promoted, e.spec.Replica = true, nil + e.mu.Unlock() + + return nil +} + +// SetReplication implements Replicator. +func (e *dockerEngine) SetReplication(ctx context.Context, on bool) error { + e.mu.RLock("SetReplication") + src := e.spec.Replica + e.mu.RUnlock() + + if src == nil { + return errNoReplication + } + + conn := "" + if on { + conn = e.conninfo(*src) + } + + if _, err := e.psql(ctx, "ALTER SYSTEM SET primary_conninfo = '"+conn+"'"); err != nil { + return err + } + + _, err := e.psql(ctx, "SELECT pg_reload_conf()") + + return err +} + +// ReplicationStatus implements Replicator. +func (e *dockerEngine) ReplicationStatus(ctx context.Context) (dbrepl.Status, error) { + db, err := e.open(e.adminDB()) + if err != nil { + return dbrepl.Status{}, err + } + defer db.Close() + + return dbrepl.QueryStatus(ctx, db, true) +} + +// Purge implements Purger: it closes the engine and deletes its persistent volumes. +func (e *dockerEngine) Purge(ctx context.Context) { + e.Close(ctx) + e.RemoveVolumes(ctx) +} + +// RemoveVolumes implements VolumeRemover; the engine must be closed first. +func (e *dockerEngine) RemoveVolumes(ctx context.Context) { + if e.vols == nil { + return + } + + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teardownLimit) + defer cancel() + + for _, v := range []string{e.volData, e.volPITR} { + if v == "" { + continue + } + + if err := e.vols.RemoveVolume(ctx, v); err != nil { + logger.Load(ctx).WarnContext(ctx, "gcpsql: volume removal failed", "volume", v, "error", err) + } + } +} diff --git a/services/gcpsql/docker_replica_test.go b/services/gcpsql/docker_replica_test.go new file mode 100644 index 0000000000..99ebdeb183 --- /dev/null +++ b/services/gcpsql/docker_replica_test.go @@ -0,0 +1,325 @@ +package gcpsql_test + +import ( + "context" + "database/sql" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sqladmin "google.golang.org/api/sqladmin/v1" +) + +const ( + pgVersion = "POSTGRES_16" + mysqlVersion = "MYSQL_8_4" + itemCount = "SELECT count(*) FROM items" +) + +func shortKind(version string) string { + if version == mysqlVersion { + return "my" + } + + return "pg" +} + +func dockerVersions() []string { return []string{pgVersion, mysqlVersion} } + +func dockerSettings(version string, pitr bool) *sqladmin.Settings { + s := &sqladmin.Settings{Tier: "db-custom-1-3840"} + + if version == mysqlVersion { + s.BackupConfiguration = &sqladmin.BackupConfiguration{Enabled: true, BinaryLogEnabled: true} + + return s + } + + if pitr { + s.BackupConfiguration = &sqladmin.BackupConfiguration{Enabled: true, PointInTimeRecoveryEnabled: true} + } + + return s +} + +// seedInstance creates an instance with database app, user alice and a table holding rows 1 and 2. +func seedInstance(ctx context.Context, t *testing.T, e *env, name, version string, pitr bool) *sql.DB { + t.Helper() + + op, err := e.svc.Instances.Insert(project, &sqladmin.DatabaseInstance{ + Name: name, DatabaseVersion: version, Region: "us-central1", RootPassword: "root-pw", + Settings: dockerSettings(version, pitr), + }).Context(ctx).Do() + e.mustWait(t, op, err) + + op, err = e.svc.Databases.Insert(project, name, &sqladmin.Database{Name: "app"}).Context(ctx).Do() + e.mustWait(t, op, err) + + op, err = e.svc.Users.Insert(project, name, &sqladmin.User{Name: "alice", Host: "%", Password: "alice-pw"}). + Context(ctx).Do() + e.mustWait(t, op, err) + + db := openDB(t, version, e.endpoint(t, name), "alice", "alice-pw") + _, err = db.ExecContext(ctx, "CREATE TABLE items (id int primary key, name varchar(50))") + require.NoError(t, err) + _, err = db.ExecContext(ctx, "INSERT INTO items VALUES (1, 'alpha'), (2, 'beta')") + require.NoError(t, err) + + return db +} + +func eventuallyCount(t *testing.T, db *sql.DB, want string, msg string) { + t.Helper() + + require.Eventually(t, func() bool { + var n string + + return db.QueryRowContext(t.Context(), itemCount).Scan(&n) == nil && n == want + }, 90*time.Second, 250*time.Millisecond, msg) +} + +func TestDockerReadReplica(t *testing.T) { + t.Parallel() + assertNoContainers(t, "rprim-pg", "rrep-pg", "rprim-my", "rrep-my") + + for _, version := range dockerVersions() { + t.Run(version, func(t *testing.T) { + t.Parallel() + + e := dockerEnv(t) + ctx, cancel := context.WithTimeout(t.Context(), dockerWait) + defer cancel() + + runReplicaFlow(ctx, t, e, version) + }) + } +} + +func runReplicaFlow(ctx context.Context, t *testing.T, e *env, version string) { + t.Helper() + + prim, rep := "rprim-"+shortKind(version), "rrep-"+shortKind(version) + + primary := seedInstance(ctx, t, e, prim, version, false) + + op, err := e.svc.Instances.Insert(project, &sqladmin.DatabaseInstance{ + Name: rep, DatabaseVersion: version, MasterInstanceName: prim, + Settings: &sqladmin.Settings{Tier: "db-custom-1-3840"}, + }).Context(ctx).Do() + e.mustWait(t, op, err) + + inst, err := e.svc.Instances.Get(project, rep).Context(ctx).Do() + require.NoError(t, err) + assert.Equal(t, "READ_REPLICA_INSTANCE", inst.InstanceType) + + replica := openDB(t, version, e.endpoint(t, rep), "alice", "alice-pw") + eventuallyCount(t, replica, "2", "the replica starts with the primary's rows") + + _, err = replica.ExecContext(ctx, "INSERT INTO items VALUES (99, 'nope')") + require.Error(t, err, "a replica rejects writes") + + _, err = primary.ExecContext(ctx, "INSERT INTO items VALUES (3, 'gamma')") + require.NoError(t, err) + eventuallyCount(t, replica, "3", "a write on the primary becomes readable on the replica") + + op, err = e.svc.Users.Insert(project, prim, &sqladmin.User{Name: "bob", Host: "%", Password: "bob-pw"}). + Context(ctx).Do() + e.mustWait(t, op, err) + + require.Eventually(t, func() bool { + return openDB(t, version, e.endpoint(t, rep), "bob", "bob-pw").PingContext(ctx) == nil + }, 90*time.Second, 250*time.Millisecond, "an account created on the primary can log in to the replica") + + replicating, _, err := e.backend.ReplicaStatus(ctx, project, rep) + require.NoError(t, err) + assert.True(t, replicating) + + stopStartReplica(ctx, t, e, rep, primary, replica) + + op, err = e.svc.Instances.PromoteReplica(project, rep).Context(ctx).Do() + e.mustWait(t, op, err) + + _, err = replica.ExecContext(ctx, "INSERT INTO items VALUES (50, 'solo')") + require.NoError(t, err, "a promoted replica accepts writes") + + prom, err := e.svc.Instances.Get(project, rep).Context(ctx).Do() + require.NoError(t, err) + assert.Equal(t, "CLOUD_SQL_INSTANCE", prom.InstanceType) + + pinst, err := e.svc.Instances.Get(project, prim).Context(ctx).Do() + require.NoError(t, err) + assert.Empty(t, pinst.ReplicaNames) +} + +func stopStartReplica(ctx context.Context, t *testing.T, e *env, rep string, primary, replica *sql.DB) { + t.Helper() + + op, err := e.svc.Instances.StopReplica(project, rep).Context(ctx).Do() + e.mustWait(t, op, err) + + require.Eventually(t, func() bool { + ok, _, serr := e.backend.ReplicaStatus(ctx, project, rep) + + return serr == nil && !ok + }, 60*time.Second, 250*time.Millisecond, "stopReplica ends replication") + + _, err = primary.ExecContext(ctx, "INSERT INTO items VALUES (4, 'delta')") + require.NoError(t, err) + assert.Equal(t, "3", scalar(t, replica, itemCount), "a stopped replica does not follow the primary") + + op, err = e.svc.Instances.StartReplica(project, rep).Context(ctx).Do() + e.mustWait(t, op, err) + eventuallyCount(t, replica, "4", "startReplica catches the replica up") +} + +func TestDockerPointInTimeRecovery(t *testing.T) { + t.Parallel() + assertNoContainers(t, "psrc-pg", "pclone-pg", "psrc-my", "pclone-my") + + for _, version := range dockerVersions() { + t.Run(version, func(t *testing.T) { + t.Parallel() + + e := dockerEnv(t) + ctx, cancel := context.WithTimeout(t.Context(), dockerWait) + defer cancel() + + runPITRFlow(ctx, t, e, version) + }) + } +} + +func dbClock(t *testing.T, db *sql.DB, version string) time.Time { + t.Helper() + + query := `SELECT to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` + if version == mysqlVersion { + query = `SELECT DATE_FORMAT(UTC_TIMESTAMP(6), '%Y-%m-%dT%H:%i:%s.%fZ')` + } + + at, err := time.Parse(time.RFC3339Nano, scalar(t, db, query)) + require.NoError(t, err) + + return at +} + +func runPITRFlow(ctx context.Context, t *testing.T, e *env, version string) { + t.Helper() + + db := seedInstance(ctx, t, e, "psrc-"+shortKind(version), version, true) + + win, err := e.svc.Projects.Instances.GetLatestRecoveryTime(project, "psrc-"+shortKind(version)).Context(ctx).Do() + require.NoError(t, err) + assert.NotEmpty(t, win.EarliestRecoveryTime) + + at := dbClock(t, db, version) + + if version == mysqlVersion { + require.Eventually(t, func() bool { + return dbClock(t, db, version).Unix() > at.Unix() + }, 10*time.Second, 50*time.Millisecond, "binary log timestamps have one second resolution") + } + + _, err = db.ExecContext(ctx, "INSERT INTO items VALUES (3, 'after')") + require.NoError(t, err) + + op, err := e.svc.Instances.Clone(project, "psrc-"+shortKind(version), &sqladmin.InstancesCloneRequest{ + CloneContext: &sqladmin.CloneContext{ + DestinationInstanceName: "pclone-" + shortKind(version), PointInTime: at.Format(time.RFC3339Nano), + }, + }).Context(ctx).Do() + e.mustWait(t, op, err) + + clone := openDB(t, version, e.endpoint(t, "pclone-"+shortKind(version)), "alice", "alice-pw") + assert.Equal(t, "2", scalar(t, clone, itemCount), "the clone has only the rows committed before the point in time") + assert.Equal(t, "0", scalar(t, clone, "SELECT count(*) FROM items WHERE id = 3")) + + assert.Equal(t, "3", scalar(t, db, itemCount), "the source keeps its later row") + + _, err = e.svc.Instances.Clone(project, "psrc-"+shortKind(version), &sqladmin.InstancesCloneRequest{ + CloneContext: &sqladmin.CloneContext{ + DestinationInstanceName: "pfuture-" + shortKind( + version, + ), PointInTime: time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), + }, + }).Context(ctx).Do() + require.Error(t, err) + assert.Contains(t, err.Error(), "after the latest recovery time") +} + +func TestDockerEnginePersistsAcrossRestart(t *testing.T) { + t.Parallel() + assertNoContainers(t, "keep-pg", "keep-my") + + for _, version := range dockerVersions() { + t.Run(version, func(t *testing.T) { + t.Parallel() + + e := dockerEnv(t) + ctx, cancel := context.WithTimeout(t.Context(), dockerWait) + defer cancel() + + seedInstance(ctx, t, e, "keep-"+shortKind(version), version, false) + snap := e.backend.Snapshot(ctx) + e.backend.Close() + + h := newDockerHandler(t) + t.Cleanup(h.Backend.Close) + t.Cleanup(h.Backend.Reset) + require.NoError(t, h.Backend.Restore(ctx, snap)) + + require.Eventually(t, func() bool { + in, err := h.Backend.GetInstance(project, "keep-"+shortKind(version)) + + return err == nil && in.State == "RUNNABLE" + }, dockerWait, 250*time.Millisecond) + + in, err := h.Backend.GetInstance(project, "keep-"+shortKind(version)) + require.NoError(t, err) + + db := openDB(t, version, in.GopherstackEndpoint, "alice", "alice-pw") + assert.Equal(t, "alpha", scalar(t, db, "SELECT name FROM items WHERE id = 1"), + "rows survive the restart, they are not rebuilt from records") + }) + } +} + +func TestDockerTierLimitsReachContainer(t *testing.T) { + t.Parallel() + assertNoContainers(t, "lim") + + e := dockerEnv(t) + ctx, cancel := context.WithTimeout(t.Context(), dockerWait) + defer cancel() + + op, err := e.svc.Instances.Insert(project, &sqladmin.DatabaseInstance{ + Name: "lim", DatabaseVersion: pgVersion, RootPassword: "root-pw", + Settings: &sqladmin.Settings{Tier: "db-custom-1-2048"}, + }).Context(ctx).Do() + e.mustWait(t, op, err) + + out := dockerInspect(t, "{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}", "gopherstack-gcpsql-lim-") + assert.Equal(t, fmt.Sprintf("%d 1000000000", int64(2048)<<20), out) +} + +func dockerInspect(t *testing.T, format, namePrefix string) string { + t.Helper() + + names := dockerOut(t, "ps", "-q", "--filter", "name="+namePrefix) + require.NotEmpty(t, names, "no container named %s*", namePrefix) + + return dockerOut(t, "inspect", "-f", format, strings.Fields(names)[0]) +} + +func dockerOut(t *testing.T, args ...string) string { + t.Helper() + + out, err := exec.CommandContext(t.Context(), "docker", args...).Output() + require.NoError(t, err) + + return strings.TrimSpace(string(out)) +} diff --git a/services/gcpsql/docker_spec_internal_test.go b/services/gcpsql/docker_spec_internal_test.go new file mode 100644 index 0000000000..5842b8f7b0 --- /dev/null +++ b/services/gcpsql/docker_spec_internal_test.go @@ -0,0 +1,61 @@ +package gcpsql + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" +) + +func TestPGLaunchSpec(t *testing.T) { + t.Parallel() + + rec := &Recovery{At: time.Date(2026, 10, 29, 12, 0, 1, 500_000_000, time.UTC), Volume: "src-pitr"} + + tests := []struct { + eng *dockerEngine + name string + wantMounts []string + wantEnv []string + wantEntry bool + wantCmd bool + }{ + {name: "plain without volumes", eng: &dockerEngine{}, wantCmd: true}, + { + name: "primary with volumes", eng: &dockerEngine{volData: "d", volPITR: "p"}, wantEntry: true, + wantMounts: []string{"d:" + pgDataDir, "p:" + pitrMount}, + }, + { + name: "replica", wantEntry: true, wantMounts: []string{"d:" + pgDataDir}, + eng: &dockerEngine{volData: "d", spec: LaunchSpec{ + Replica: &ReplicaSource{Host: "h", Port: 5432, Password: "pw"}, + }}, + wantEnv: []string{dbrepl.PasswordEnv + "=pw"}, + }, + { + name: "recovery", wantEntry: true, + eng: &dockerEngine{volData: "d", volPITR: "p", spec: LaunchSpec{Recovery: rec}}, + wantMounts: []string{"d:" + pgDataDir, "p:" + pitrMount, "src-pitr:" + pitrSrcMount + ":ro"}, + wantEnv: []string{recoveryEnv + "=2026-10-29 12:00:01.500000+00"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var s container.Spec + + tt.eng.pgLaunch(&s) + + assert.Equal(t, tt.wantMounts, s.Mounts) + assert.Equal(t, tt.wantEnv, s.Env) + assert.Equal(t, tt.wantEntry, len(s.Entrypoint) > 0) + assert.Equal(t, tt.wantCmd, len(s.Cmd) > 0) + assert.Contains(t, append(append([]string{}, s.Cmd...), s.Entrypoint...), "wal_level=replica") + }) + } +} diff --git a/services/gcpsql/docker_test.go b/services/gcpsql/docker_test.go index f01b862191..13e2d7dcb8 100644 --- a/services/gcpsql/docker_test.go +++ b/services/gcpsql/docker_test.go @@ -41,22 +41,12 @@ func dockerEnv(t *testing.T) *env { t.Skip("docker engine test skipped in short mode") } - lr := gcpserve.NewLazyRuntime(slog.Default(), "gcpsql.test.runtime") - t.Cleanup(func() { _ = lr.Close() }) - - if _, err := lr.Get(); err != nil { - t.Skipf("container runtime unavailable: %v", err) - } - - reg, err := (&gcpsql.Provider{}).Init(&service.AppContext{Logger: slog.Default()}) - require.NoError(t, err) - - h, ok := reg.(*gcpsql.Handler) - require.True(t, ok) + h := newDockerHandler(t) objects := &fakeObjects{data: map[string][]byte{}} h.Backend.SetObjectStore(objects) t.Cleanup(h.Backend.Close) + t.Cleanup(h.Backend.Reset) e := echo.New() e.Any("/*", h.Handler()) @@ -70,6 +60,27 @@ func dockerEnv(t *testing.T) *env { return &env{svc: svc, backend: h.Backend, objects: objects, url: srv.URL, timeout: dockerWait} } +func newDockerHandler(t *testing.T) *gcpsql.Handler { + t.Helper() + + lr := gcpserve.NewLazyRuntime(slog.Default(), "gcpsql.test.runtime") + t.Cleanup(func() { _ = lr.Close() }) + + if _, err := lr.Get(); err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + reg, err := (&gcpsql.Provider{}).Init(&service.AppContext{Logger: slog.Default()}) + require.NoError(t, err) + + h, ok := reg.(*gcpsql.Handler) + require.True(t, ok) + + h.Backend.SetStartupTimeout(3 * time.Minute) + + return h +} + func openDB(t *testing.T, version, endpoint, user, pass string) *sql.DB { t.Helper() @@ -121,7 +132,7 @@ func TestDockerEngines(t *testing.T) { {"mysql", "MYSQL_8_4", gcpsql.Flag{Name: "max_connections", Value: "151"}, "SELECT @@max_connections", "151"}, } - assertNoContainers(t) + assertNoContainers(t, "dpostgres", "dmysql") for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -244,14 +255,25 @@ func importInto(ctx context.Context, t *testing.T, e *env, tc dockerCase, from s assert.Equal(t, "alpha", scalar(t, imp, "SELECT name FROM items WHERE id = 1")) } -func assertNoContainers(t *testing.T) { +// assertNoContainers fails the test if containers, volumes or networks of the named instances outlive it. +func assertNoContainers(t *testing.T, instances ...string) { + t.Helper() + + for _, in := range instances { + assertNoDockerObjects(t, "container", "ps", "-aq", "--filter", "name=gopherstack-gcpsql-"+in+"-") + assertNoDockerObjects(t, "volume", "ls", "-q", "--filter", "name=gopherstack-gcpsql-data-"+project+"-"+in) + assertNoDockerObjects(t, "volume", "ls", "-q", "--filter", "name=gopherstack-gcpsql-pitr-"+project+"-"+in) + assertNoDockerObjects(t, "network", "ls", "-q", "--filter", "name=gopherstack-gcpsql-net-"+in) + } +} + +func assertNoDockerObjects(t *testing.T, what string, args ...string) { t.Helper() t.Cleanup(func() { - out, err := exec.CommandContext(context.Background(), "docker", "ps", "-aq", "--filter", "name=gopherstack-gcpsql-"). - Output() + out, err := exec.CommandContext(context.Background(), "docker", args...).Output() if err == nil { - assert.Empty(t, strings.TrimSpace(string(out)), "containers left behind") + assert.Empty(t, strings.TrimSpace(string(out)), what+"s left behind") } }) } diff --git a/services/gcpsql/engine.go b/services/gcpsql/engine.go index 38e605e927..d3622c6fc6 100644 --- a/services/gcpsql/engine.go +++ b/services/gcpsql/engine.go @@ -5,8 +5,13 @@ import ( "errors" "regexp" "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" ) +var errNoReplication = errors.New("gcpsql: the container runtime cannot run replication") + // ErrRuntimeUnavailable is returned by a Launcher when no container runtime is reachable; the instance // then runs state-only. var ErrRuntimeUnavailable = errors.New("gcpsql: container runtime unavailable") @@ -32,10 +37,85 @@ type Flag struct { // LaunchSpec describes the engine of one instance. type LaunchSpec struct { - ID string - Version string - Password string - Kind dbKind + Replica *ReplicaSource + Recovery *Recovery + ID string + Project string + Version string + Password string + MemoryBytes int64 + NanoCPUs int64 + Kind dbKind + ServerID uint32 + Persist bool + Fresh bool +} + +// ReplicaSource tells a replica's engine how to reach its primary. +type ReplicaSource struct { + Network string + NetworkID string + Host string + Password string + Port int +} + +// Recovery seeds an engine from another instance's point-in-time recovery state. Volume names the source's +// PostgreSQL archive; for MySQL, Script is the base dump and Source the engine whose binary log is replayed. +type Recovery struct { + At time.Time + Source *ReplicaSource + Volume string + // FromFile and FromPos, UntilFile and UntilPos bound the MySQL binary log range a clone replays from Source. + FromFile string + UntilFile string + Script []byte + FromPos uint64 + UntilPos uint64 +} + +// Replicator is implemented by engines that can feed or follow a replica. +type Replicator interface { + // ReplicationSource readies the engine as a replication source and says how a replica reaches it. + ReplicationSource(ctx context.Context) (ReplicaSource, error) + // AttachReplica starts replication from src; seed is a source dump for engines that cannot copy the data + // themselves. + AttachReplica(ctx context.Context, src ReplicaSource, seed []byte) error + // ReplicaSeed returns the dump a MySQL replica starts from; PostgreSQL replicas copy the data themselves. + ReplicaSeed(ctx context.Context, dbs []string) ([]byte, error) + // Promote detaches a replica from its source and makes it writable. + Promote(ctx context.Context) error + // SetReplication pauses (false) or resumes (true) replication on a replica. + SetReplication(ctx context.Context, on bool) error + ReplicationStatus(ctx context.Context) (dbrepl.Status, error) +} + +// Recoverer is implemented by engines that keep the logs point-in-time recovery needs. +type Recoverer interface { + // EnablePITR starts archiving and returns the earliest time a recovery can target. + EnablePITR(ctx context.Context, dbs []string) (time.Time, error) + // DisablePITR stops archiving. + DisablePITR(ctx context.Context) error + // PrepareRecovery makes everything up to at recoverable and describes how a new engine starts from it. + PrepareRecovery(ctx context.Context, at time.Time) (Recovery, error) + // ApplyRecovery finishes a recovery on a freshly launched engine. + ApplyRecovery(ctx context.Context, rec Recovery) error +} + +// Purger is implemented by engines that own persistent storage; Purge also deletes it. +type Purger interface { + Purge(ctx context.Context) +} + +// VolumeRemover is implemented by engines whose persistent volumes can be deleted after Close; Reset closes every +// engine before removing any volume so a clone's mount of its source's archive cannot block the removal. +type VolumeRemover interface { + RemoveVolumes(ctx context.Context) +} + +// VolumeSweeper is implemented by launchers that can delete persistent volumes no instance owns. +type VolumeSweeper interface { + SweepVolumes(ctx context.Context, keep map[string]bool) } // DumpOptions tunes a dump. Markers prefixes each database with a switch line; Portable drops ownership and @@ -45,6 +125,13 @@ type DumpOptions struct { Portable bool } +const volumePrefix = "gopherstack-gcpsql-" + +// volumeName is the data volume of an instance; kind is "data" or "pitr". +func volumeName(kind, project, name string) string { + return volumePrefix + kind + "-" + sanitizeName(project) + "-" + sanitizeName(name) +} + // UserSpec is a database account to create or update. type UserSpec struct { Name string diff --git a/services/gcpsql/handler.go b/services/gcpsql/handler.go index 1fd60d64f8..2c2d6f5983 100644 --- a/services/gcpsql/handler.go +++ b/services/gcpsql/handler.go @@ -13,6 +13,7 @@ import ( "github.com/labstack/echo/v5" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/service" @@ -229,6 +230,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Use(gcpenforce.EchoMiddleware(ctx, gcpenforce.Target{Host: "sqladmin.googleapis.com"})) e.Any("/*", telemetry.WrapEchoHandler("GCPSQL", h.Handler(), h)) srv := &http.Server{ diff --git a/services/gcpsql/handler_ops.go b/services/gcpsql/handler_ops.go index 389f2f603d..7b7fecb9a6 100644 --- a/services/gcpsql/handler_ops.go +++ b/services/gcpsql/handler_ops.go @@ -27,6 +27,26 @@ func (h *Handler) registerInstanceOps() { return h.reply(c, v, err) } h.ops[opCloneInstance] = h.cloneInstance + h.ops[opPromoteReplica] = func(c *echo.Context, rt route) error { + v, err := h.Backend.PromoteReplica(rt.project, rt.instance) + + return h.reply(c, v, err) + } + h.ops[opStartReplica] = func(c *echo.Context, rt route) error { + v, err := h.Backend.SetReplicaRunning(rt.project, rt.instance, true) + + return h.reply(c, v, err) + } + h.ops[opStopReplica] = func(c *echo.Context, rt route) error { + v, err := h.Backend.SetReplicaRunning(rt.project, rt.instance, false) + + return h.reply(c, v, err) + } + h.ops[opLatestRecovery] = func(c *echo.Context, rt route) error { + v, err := h.Backend.LatestRecoveryTime(rt.project, rt.instance) + + return h.reply(c, v, err) + } h.ops[opExportInstance] = h.exportInstance h.ops[opImportInstance] = h.importInstance h.ops[opRestoreBackup] = h.restoreBackup diff --git a/services/gcpsql/helpers_test.go b/services/gcpsql/helpers_test.go index cd3649a2c8..1030a7f060 100644 --- a/services/gcpsql/helpers_test.go +++ b/services/gcpsql/helpers_test.go @@ -6,6 +6,7 @@ import ( "maps" "net/http/httptest" "sort" + "strconv" "strings" "sync" "testing" @@ -17,6 +18,7 @@ import ( "google.golang.org/api/option" sqladmin "google.golang.org/api/sqladmin/v1" + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" "github.com/blackbirdworks/gopherstack/services/gcpsql" ) @@ -30,18 +32,32 @@ var ( ) type fakeEngine struct { - dbs map[string]bool - users map[string]string - restored []string - rows [][]string - flags []gcpsql.Flag - inserted [][]string - mu sync.Mutex - port int - restarts int - stops int - starts int - closed bool + enableBase time.Time + users map[string]string + dbs map[string]bool + replSrc gcpsql.ReplicaSource + rows [][]string + inserted [][]string + flags []gcpsql.Flag + seed []byte + pitrDBs []string + applied []gcpsql.Recovery + prepared []time.Time + replCalls []bool + restored []string + pitrEnabled int + starts int + stops int + restarts int + port int + pitrDisabled int + attached int + promoted int + statusLag float64 + mu sync.Mutex + closed bool + purged bool + persistent bool } func (f *fakeEngine) Endpoint() (string, int) { return "127.0.0.1", f.port } @@ -173,6 +189,101 @@ func (f *fakeEngine) Close(context.Context) { f.closed = true } +func (f *fakeEngine) ReplicationSource(context.Context) (gcpsql.ReplicaSource, error) { + return gcpsql.ReplicaSource{Network: "net-" + strconv.Itoa(f.port), Host: "ctr-" + strconv.Itoa(f.port), Port: 5432, + Password: "repl-pw"}, nil +} + +func (f *fakeEngine) ReplicaSeed(_ context.Context, dbs []string) ([]byte, error) { + return []byte("seed:" + strings.Join(dbs, ",")), nil +} + +func (f *fakeEngine) AttachReplica(_ context.Context, src gcpsql.ReplicaSource, seed []byte) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.replSrc, f.seed = src, seed + f.attached++ + + return nil +} + +func (f *fakeEngine) Promote(context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + f.promoted++ + + return nil +} + +func (f *fakeEngine) SetReplication(_ context.Context, on bool) error { + f.mu.Lock() + defer f.mu.Unlock() + f.replCalls = append(f.replCalls, on) + + return nil +} + +func (f *fakeEngine) ReplicationStatus(context.Context) (dbrepl.Status, error) { + return dbrepl.Status{Replicating: true, LagSeconds: f.statusLag}, nil +} + +func (f *fakeEngine) EnablePITR(_ context.Context, dbs []string) (time.Time, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.pitrEnabled++ + f.pitrDBs = dbs + + if f.enableBase.IsZero() { + f.enableBase = time.Now().UTC().Add(-time.Hour) + } + + return f.enableBase, nil +} + +func (f *fakeEngine) DisablePITR(context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + f.pitrDisabled++ + + return nil +} + +func (f *fakeEngine) PrepareRecovery(_ context.Context, at time.Time) (gcpsql.Recovery, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.prepared = append(f.prepared, at) + + return gcpsql.Recovery{At: at, Volume: "pitr-vol", Script: []byte("replay")}, nil +} + +func (f *fakeEngine) ApplyRecovery(_ context.Context, rec gcpsql.Recovery) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.applied = append(f.applied, rec) + + return nil +} + +func (f *fakeEngine) Purge(context.Context) { + f.mu.Lock() + defer f.mu.Unlock() + + f.purged, f.closed = true, true +} + +func (f *fakeEngine) RemoveVolumes(context.Context) { + f.mu.Lock() + defer f.mu.Unlock() + + f.purged = true +} + +func (f *fakeEngine) Persistent() bool { return f.persistent } + func (f *fakeEngine) snapshot() fakeEngine { f.mu.Lock() defer f.mu.Unlock() @@ -180,7 +291,10 @@ func (f *fakeEngine) snapshot() fakeEngine { return fakeEngine{ restarts: f.restarts, stops: f.stops, starts: f.starts, closed: f.closed, flags: f.flags, restored: append([]string(nil), f.restored...), inserted: append([][]string(nil), f.inserted...), - dbs: copyBool(f.dbs), users: copyStr(f.users), + dbs: copyBool(f.dbs), users: copyStr(f.users), replSrc: f.replSrc, seed: f.seed, + replCalls: append([]bool(nil), f.replCalls...), prepared: append([]time.Time(nil), f.prepared...), + applied: append([]gcpsql.Recovery(nil), f.applied...), pitrDBs: f.pitrDBs, pitrEnabled: f.pitrEnabled, + pitrDisabled: f.pitrDisabled, attached: f.attached, promoted: f.promoted, purged: f.purged, } } @@ -194,9 +308,11 @@ func copyStr(m map[string]string) map[string]string { type fakeLauncher struct { err error + swept []map[string]bool engines []*fakeEngine specs []gcpsql.LaunchSpec mu sync.Mutex + persist bool } func (l *fakeLauncher) Launch(_ context.Context, spec gcpsql.LaunchSpec) (gcpsql.Engine, error) { @@ -208,7 +324,7 @@ func (l *fakeLauncher) Launch(_ context.Context, spec gcpsql.LaunchSpec) (gcpsql defer l.mu.Unlock() e := &fakeEngine{ - dbs: map[string]bool{}, users: map[string]string{}, port: 40000 + len(l.engines), + dbs: map[string]bool{}, users: map[string]string{}, port: 40000 + len(l.engines), persistent: l.persist, rows: [][]string{{"id", "name"}, {"1", "a,b"}, {"2", "c"}}, } l.engines = append(l.engines, e) @@ -217,6 +333,13 @@ func (l *fakeLauncher) Launch(_ context.Context, spec gcpsql.LaunchSpec) (gcpsql return e, nil } +func (l *fakeLauncher) SweepVolumes(_ context.Context, keep map[string]bool) { + l.mu.Lock() + defer l.mu.Unlock() + + l.swept = append(l.swept, keep) +} + func (l *fakeLauncher) engine(i int) *fakeEngine { l.mu.Lock() defer l.mu.Unlock() diff --git a/services/gcpsql/instances.go b/services/gcpsql/instances.go index 5fa8162852..7de5e81e58 100644 --- a/services/gcpsql/instances.go +++ b/services/gcpsql/instances.go @@ -49,10 +49,6 @@ func validateInstance(in *InstanceInput) error { return errInvalid("Invalid instance name: %q.", in.Name) } - if in.MasterInstanceName != "" || in.InstanceType == "READ_REPLICA_INSTANCE" { - return errInvalid("Read replicas are not supported by the emulator.") - } - if _, _, err := parseVersion(in.DatabaseVersion); err != nil { return versionError(in.DatabaseVersion) } @@ -85,6 +81,8 @@ func (b *InMemoryBackend) newInstance(project string, in *InstanceInput) (*instR } rec := &instRec{ + ServerID: randomServerID(), + fresh: true, Databases: map[string]*Database{}, Users: map[string]*userRec{}, Backups: map[string]*backupRec{}, Certs: map[string]*certRec{}, RootPassword: in.RootPassword, CACert: caPEM, CAKey: keyPEM, Inst: Instance{ @@ -105,8 +103,22 @@ func (b *InMemoryBackend) newInstance(project string, in *InstanceInput) (*instR return rec, nil } -// InsertInstance creates an instance and starts its engine. +// InsertInstance creates an instance, or a read replica when masterInstanceName is set, and starts its engine. func (b *InMemoryBackend) InsertInstance(project string, in InstanceInput) (*Operation, error) { + replica := in.MasterInstanceName != "" || in.InstanceType == instanceTypeReplica + + b.mu.Lock("InsertInstance") + defer b.mu.Unlock() + + var prim *instRec + + if replica { + var err error + if prim, err = b.prepareReplicaLocked(project, &in); err != nil { + return nil, err + } + } + if in.Region == "" { in.Region = defaultRegion } @@ -124,9 +136,6 @@ func (b *InMemoryBackend) InsertInstance(project string, in InstanceInput) (*Ope return nil, err } - b.mu.Lock("InsertInstance") - defer b.mu.Unlock() - if _, exists := b.instances[instKey(project, in.Name)]; exists { return nil, errConflict("instanceAlreadyExists", "The Cloud SQL instance already exists. When you delete an instance, you can't reuse the name of the "+ @@ -135,7 +144,14 @@ func (b *InMemoryBackend) InsertInstance(project string, in InstanceInput) (*Ope b.instances[instKey(project, in.Name)] = rec - return b.submitLocked(rec, project, "CREATE", opMeta{}, func(ctx context.Context) error { + opType := "CREATE" + if replica { + b.linkReplicaLocked(prim, rec) + + opType = "CREATE_REPLICA" + } + + return b.submitLocked(rec, project, opType, opMeta{}, func(ctx context.Context) error { return b.bringUp(ctx, rec) }), nil } @@ -144,6 +160,7 @@ func (b *InMemoryBackend) viewLocked(rec *instRec) *Instance { cp := rec.Inst cp.Settings = asMap(deepCopy(rec.Inst.Settings)) cp.IPAddresses = append([]IPMapping(nil), rec.Inst.IPAddresses...) + cp.ReplicaNames = append([]string(nil), rec.Inst.ReplicaNames...) return &cp } @@ -248,6 +265,9 @@ func (b *InMemoryBackend) settingsTask(rec *instRec, old, next map[string]any) f oldPolicy, newPolicy := settingString(old, "activationPolicy"), settingString(next, "activationPolicy") flagsChanged := flagsSig(flagsOf(old)) != flagsSig(flagsOf(next)) newFlags := flagsOf(next) + version := rec.Inst.DatabaseVersion + pitrChanged := pitrOn(version, old) != pitrOn(version, next) + tierChanged := settingString(old, "tier") != settingString(next, "tier") return func(ctx context.Context) error { switch { @@ -255,14 +275,54 @@ func (b *InMemoryBackend) settingsTask(rec *instRec, old, next map[string]any) f return b.suspend(ctx, rec) case oldPolicy == policyNever && newPolicy != policyNever: return b.resume(ctx, rec) - case flagsChanged: - return b.restartEngine(ctx, rec, newFlags) + } + + if tierChanged { + if relaunched, err := b.relaunch(ctx, rec); relaunched || err != nil { + return err + } + } + + if flagsChanged { + if err := b.restartEngine(ctx, rec, newFlags); err != nil { + return err + } + } + + if pitrChanged { + return b.syncPITR(ctx, rec, b.engineOf(rec)) } return nil } } +// relaunch recreates the engine container so a new tier's CPU and memory limits apply. It only does so when the +// data survives in a volume and no replica is tied to the container; otherwise the limits apply at the next launch. +func (b *InMemoryBackend) relaunch(ctx context.Context, rec *instRec) (bool, error) { + eng := b.engineOf(rec) + + p, ok := eng.(interface{ Persistent() bool }) + if !ok || !p.Persistent() { + return false, nil + } + + b.mu.Lock("relaunch") + tied := len(rec.Inst.ReplicaNames) > 0 || b.isReplica(rec) + if !tied { + rec.eng = nil + } + b.mu.Unlock() + + if tied { + return false, nil + } + + eng.Close(ctx) + + return true, b.bringUp(ctx, rec) +} + // RestartInstance restarts the engine, applying the configured flags. func (b *InMemoryBackend) RestartInstance(project, name string) (*Operation, error) { b.mu.Lock("RestartInstance") @@ -302,6 +362,11 @@ func (b *InMemoryBackend) DeleteInstance(project, name string) (*Operation, erro return nil, errInvalid("The instance cannot be deleted while deletion protection is enabled.") } + if len(rec.Inst.ReplicaNames) > 0 { + return nil, errInvalid("The instance has read replicas; delete them before deleting the primary.") + } + + b.unlinkReplicaLocked(rec) rec.Inst.State = stateDeleting b.touchLocked(rec) @@ -312,14 +377,23 @@ func (b *InMemoryBackend) DeleteInstance(project, name string) (*Operation, erro delete(b.instances, instKey(project, name)) b.mu.Unlock() - if eng != nil { - eng.Close(ctx) - } + purgeEngine(ctx, eng) return nil }), nil } +// purgeEngine closes eng and deletes its persistent storage. +func purgeEngine(ctx context.Context, eng Engine) { + switch p := eng.(type) { + case nil: + case Purger: + p.Purge(ctx) + default: + eng.Close(ctx) + } +} + func (b *InMemoryBackend) engineOf(rec *instRec) Engine { b.mu.RLock("engineOf") defer b.mu.RUnlock() @@ -369,11 +443,67 @@ func (b *InMemoryBackend) resume(ctx context.Context, rec *instRec) error { return b.bringUp(ctx, rec) } +// launchSpec describes the engine of rec; for a replica it also readies the primary. A replica with a nil +// source is state-only like its primary. +func (b *InMemoryBackend) launchSpec(ctx context.Context, rec *instRec) (LaunchSpec, Engine, bool, error) { + b.mu.RLock("launchSpec") + spec := LaunchSpec{ + ID: rec.Inst.Name, Project: rec.Inst.Project, Version: rec.Inst.DatabaseVersion, Password: rec.RootPassword, + ServerID: rec.ServerID, Persist: true, Recovery: rec.recovery, Fresh: rec.fresh, + } + tier := settingString(rec.Inst.Settings, "tier") + replica := b.isReplica(rec) + b.mu.RUnlock() + + spec.NanoCPUs, spec.MemoryBytes = tierLimits(tier) + + if !replica { + return spec, nil, false, nil + } + + src, err := b.primaryEngine(ctx, rec) + if err != nil || src == nil { + return spec, nil, true, err + } + + rp, ok := src.(Replicator) + if !ok { + return spec, nil, true, errNoReplication + } + + rs, err := rp.ReplicationSource(ctx) + if err != nil { + return spec, nil, true, err + } + + spec.Replica = &rs + + return spec, src, true, nil +} + +func (b *InMemoryBackend) sweepVolumes(ctx context.Context, l Launcher) { + sw, ok := l.(VolumeSweeper) + + b.mu.Lock("sweepVolumes") + first := !b.swept + b.swept = true + + keep := map[string]bool{} + for _, r := range b.instances { + keep[volumeName("data", r.Inst.Project, r.Inst.Name)] = true + keep[volumeName("pitr", r.Inst.Project, r.Inst.Name)] = true + } + b.mu.Unlock() + + if ok && first { + sw.SweepVolumes(ctx, keep) + } +} + // bringUp launches the engine of rec (or runs state-only) and replays databases, users and flags. func (b *InMemoryBackend) bringUp(ctx context.Context, rec *instRec) error { b.mu.RLock("bringUp") l := b.launcher - spec := LaunchSpec{ID: rec.Inst.Name, Version: rec.Inst.DatabaseVersion, Password: rec.RootPassword} suspended := settingString(rec.Inst.Settings, "activationPolicy") == policyNever b.mu.RUnlock() @@ -383,24 +513,40 @@ func (b *InMemoryBackend) bringUp(ctx context.Context, rec *instRec) error { return nil } - eng, err := b.launch(ctx, l, spec) + b.sweepVolumes(ctx, l) + + spec, src, replica, err := b.launchSpec(ctx, rec) if err != nil { b.setState(rec, stateFailed) return err } + var eng Engine + if !replica || src != nil { + eng, err = b.launch(ctx, l, spec) + if err != nil { + b.setState(rec, stateFailed) + + return err + } + } + if eng != nil { - if err = b.replay(ctx, rec, eng); err != nil { - eng.Close(ctx) + if err = b.finishEngine(ctx, rec, eng, spec, src); err != nil { + purgeAfterFailure(ctx, eng, spec) b.setState(rec, stateFailed) return err } + } else if err = b.syncPITR(ctx, rec, nil); err != nil { + b.setState(rec, stateFailed) + + return err } b.mu.Lock("bringUp") - rec.eng = eng + rec.eng, rec.recovery, rec.fresh = eng, nil, false rec.Inst.IPAddresses, rec.Inst.GopherstackEndpoint = addresses(eng) rec.Inst.State = stateRunnable b.touchLocked(rec) @@ -409,6 +555,101 @@ func (b *InMemoryBackend) bringUp(ctx context.Context, rec *instRec) error { return nil } +// purgeAfterFailure drops a half-built engine; volumes holding data a restart could still use are kept. +func purgeAfterFailure(ctx context.Context, eng Engine, spec LaunchSpec) { + if spec.Recovery != nil || spec.Replica != nil { + purgeEngine(ctx, eng) + + return + } + + eng.Close(ctx) +} + +// finishEngine brings a launched engine to the state the records describe. +func (b *InMemoryBackend) finishEngine( + ctx context.Context, rec *instRec, eng Engine, spec LaunchSpec, src Engine, +) error { + if spec.Replica != nil { + return b.attachReplica(ctx, rec, eng, spec, src) + } + + if spec.Recovery != nil { + if rc, ok := eng.(Recoverer); ok { + if err := rc.ApplyRecovery(ctx, *spec.Recovery); err != nil { + return err + } + } + + if err := b.alignDatabases(ctx, rec, eng); err != nil { + return err + } + } + + if err := b.replay(ctx, rec, eng); err != nil { + return err + } + + return b.syncPITR(ctx, rec, eng) +} + +func (b *InMemoryBackend) attachReplica( + ctx context.Context, rec *instRec, eng Engine, spec LaunchSpec, src Engine, +) error { + rp, ok := eng.(Replicator) + if !ok { + return errNoReplication + } + + var seed []byte + + if !isPG(rec.Inst.DatabaseVersion) { + if err := b.replay(ctx, rec, eng); err != nil { + return err + } + + primary, err := b.primaryRec(rec) + if err != nil { + return err + } + + srcRepl, isRepl := src.(Replicator) + if !isRepl { + return errNoReplication + } + + if seed, err = srcRepl.ReplicaSeed(ctx, b.exportableDBs(primary, nil)); err != nil { + return err + } + } + + if err := rp.AttachReplica(ctx, *spec.Replica, seed); err != nil { + return err + } + + b.mu.RLock("attachReplica") + paused := rec.ReplicaPaused + b.mu.RUnlock() + + if paused { + return rp.SetReplication(ctx, false) + } + + return nil +} + +func (b *InMemoryBackend) primaryRec(rec *instRec) (*instRec, error) { + name, err := masterName(rec.Inst.Project, rec.Inst.MasterInstanceName) + if err != nil { + return nil, err + } + + b.mu.RLock("primaryRec") + defer b.mu.RUnlock() + + return b.lookupLocked(rec.Inst.Project, name) +} + func (b *InMemoryBackend) launch(ctx context.Context, l Launcher, spec LaunchSpec) (Engine, error) { if l == nil { return nil, nil //nolint:nilnil // no launcher means state-only @@ -468,8 +709,17 @@ func (b *InMemoryBackend) replay(ctx context.Context, rec *instRec, eng Engine) } } + existing, err := eng.ListDatabases(ctx) + if err != nil { + return err + } + for _, d := range dbs { - if err := eng.CreateDatabase(ctx, d.Name, d.Charset, d.Collation); err != nil { + if contains(existing, d.Name) { + continue + } + + if err = eng.CreateDatabase(ctx, d.Name, d.Charset, d.Collation); err != nil { return err } } diff --git a/services/gcpsql/instances_test.go b/services/gcpsql/instances_test.go index b8c558b694..1257e8e1ac 100644 --- a/services/gcpsql/instances_test.go +++ b/services/gcpsql/instances_test.go @@ -139,7 +139,7 @@ func TestInstanceValidation(t *testing.T) { DatabaseVersion: "MYSQL_8_0", MasterInstanceName: "m", Settings: &sqladmin.Settings{Tier: "db-f1-micro"}, - }, wantCode: http.StatusBadRequest, wantMsg: "replicas"}, + }, wantCode: http.StatusNotFound, wantMsg: "does not exist"}, } for _, tt := range tests { diff --git a/services/gcpsql/model.go b/services/gcpsql/model.go index b4d478ee9a..8909185f4e 100644 --- a/services/gcpsql/model.go +++ b/services/gcpsql/model.go @@ -69,7 +69,10 @@ type Instance struct { MaintenanceVersion string `json:"maintenanceVersion,omitempty"` SQLNetworkArchitecture string `json:"sqlNetworkArchitecture,omitempty"` GopherstackEndpoint string `json:"gopherstackEndpoint,omitempty"` + MasterInstanceName string `json:"masterInstanceName,omitempty"` + ReplicaConfiguration map[string]any `json:"replicaConfiguration,omitempty"` IPAddresses []IPMapping `json:"ipAddresses,omitempty"` + ReplicaNames []string `json:"replicaNames,omitempty"` } // OperationError is one error of a failed operation. diff --git a/services/gcpsql/mysql_engine.go b/services/gcpsql/mysql_engine.go new file mode 100644 index 0000000000..29acd51c10 --- /dev/null +++ b/services/gcpsql/mysql_engine.go @@ -0,0 +1,431 @@ +package gcpsql + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" + "github.com/blackbirdworks/gopherstack/pkgs/mysqlengine" +) + +// mysqlEngine adapts a pkgs/mysqlengine server to Engine, Replicator, Recoverer, Purger and VolumeRemover. +type mysqlEngine struct { + srv *mysqlengine.Server +} + +func mapMySQLErr(err error) error { + switch { + case errors.Is(err, mysqlengine.ErrNoReplication): + return errNoReplication + case errors.Is(err, mysqlengine.ErrNoPITR): + return errNoPITR + case errors.Is(err, mysqlengine.ErrPITRNotOn): + return errPITRNotOn + case errors.Is(err, mysqlengine.ErrTooLarge): + return errTooLarge + } + + return err +} + +func mysqlSpec(spec LaunchSpec, image string) mysqlengine.Spec { + ms := mysqlengine.Spec{ + ID: spec.ID, + Image: image, + RootPassword: spec.Password, + Persist: spec.Persist, + Fresh: spec.Fresh, + Volume: volumeName("data", spec.Project, spec.ID), + VolumeLabels: map[string]string{volumeLabel: "true", volumeLabel + ".instance": spec.Project + "/" + spec.ID}, + Resource: spec.Project + "/" + spec.ID, + Adopt: spec.Replica == nil && spec.Recovery == nil && !spec.Fresh, + ServerID: max(spec.ServerID, 1), + MemoryBytes: spec.MemoryBytes, + NanoCPUs: spec.NanoCPUs, + } + + switch { + case spec.Replica != nil: + ms.Network, ms.NetworkID = spec.Replica.Network, spec.Replica.NetworkID + case spec.Recovery != nil && spec.Recovery.Source != nil: + ms.Network, ms.NetworkID = spec.Recovery.Source.Network, spec.Recovery.Source.NetworkID + } + + return ms +} + +func (e *mysqlEngine) Endpoint() (string, int) { return e.srv.Endpoint() } +func (e *mysqlEngine) Stop(ctx context.Context) error { return e.srv.Stop(ctx) } +func (e *mysqlEngine) Start(ctx context.Context) error { return e.srv.Start(ctx) } +func (e *mysqlEngine) Close(ctx context.Context) { e.srv.Close(ctx, false) } +func (e *mysqlEngine) Persistent() bool { return e.srv.Persistent() } +func (e *mysqlEngine) Purge(ctx context.Context) { e.srv.Close(ctx, true) } +func (e *mysqlEngine) exec(ctx context.Context, stmt string, args ...any) error { + conn, err := e.srv.DB("") + if err != nil { + return err //nolint:wrapcheck // already wrapped by the engine + } + defer conn.Close() + + if _, err = conn.ExecContext(ctx, stmt, args...); err != nil { + return fmt.Errorf("sql: %w", err) + } + + return nil +} + +// RemoveVolumes implements VolumeRemover; the engine must be closed first. +func (e *mysqlEngine) RemoveVolumes(ctx context.Context) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teardownLimit) + defer cancel() + + _ = e.srv.RemoveVolume(ctx) +} + +// CreateDatabase implements Engine. +func (e *mysqlEngine) CreateDatabase(ctx context.Context, name, charset, collation string) error { + conn, err := e.srv.DB("") + if err != nil { + return err //nolint:wrapcheck // already wrapped by the engine + } + defer conn.Close() + + ident, err := mysqlQuoteIdent(ctx, conn, name) + if err != nil { + return err + } + + stmt, args := "CREATE DATABASE "+ident, []any{} + + if charset != "" && charsetRe.MatchString(charset) { + stmt += " CHARACTER SET ?" + + args = append(args, charset) + } + + if collation != "" && charsetRe.MatchString(collation) { + stmt += " COLLATE ?" + + args = append(args, collation) + } + + return e.exec(ctx, stmt, args...) +} + +// DropDatabase implements Engine. +func (e *mysqlEngine) DropDatabase(ctx context.Context, name string) error { + conn, err := e.srv.DB("") + if err != nil { + return err //nolint:wrapcheck // already wrapped by the engine + } + defer conn.Close() + + ident, err := mysqlQuoteIdent(ctx, conn, name) + if err != nil { + return err + } + + return e.exec(ctx, "DROP DATABASE "+ident) +} + +// ListDatabases implements Engine. +func (e *mysqlEngine) ListDatabases(ctx context.Context) ([]string, error) { + rows, err := e.Query(ctx, "", "SHOW DATABASES") + if err != nil { + return nil, err + } + + out := make([]string, 0, len(rows)) + + for _, r := range rows[1:] { + out = append(out, r[0]) + } + + return out, nil +} + +// UpsertUser implements Engine. +func (e *mysqlEngine) UpsertUser(ctx context.Context, u UserSpec) error { + conn, err := e.srv.DB("") + if err != nil { + return err //nolint:wrapcheck // already wrapped by the engine + } + defer conn.Close() + + if _, err = conn.ExecContext(ctx, "CREATE USER IF NOT EXISTS ?@?", u.Name, u.Host); err != nil { + return fmt.Errorf("sql: %w", err) + } + + if u.Password != "" { + if _, err = conn.ExecContext(ctx, "ALTER USER ?@? IDENTIFIED BY ?", u.Name, u.Host, u.Password); err != nil { + return fmt.Errorf("sql: %w", err) + } + } + + if _, err = conn.ExecContext(ctx, "GRANT ALL PRIVILEGES ON *.* TO ?@? WITH GRANT OPTION", + u.Name, u.Host); err != nil { + return fmt.Errorf("sql: %w", err) + } + + if u.Password != "" && u.Name == mysqlAdminUser { + e.srv.SetRootPassword(u.Password) + } + + return nil +} + +// DropUser implements Engine. +func (e *mysqlEngine) DropUser(ctx context.Context, name, host string) error { + return e.exec(ctx, "DROP USER ?@?", name, host) +} + +// Restart implements Engine. +func (e *mysqlEngine) Restart(ctx context.Context, flags []Flag) error { + _ = e.exec(ctx, "RESET PERSIST") + + for _, f := range flags { + if !flagNameRe.MatchString(f.Name) { + return errInvalid("flag name %q is invalid", f.Name) + } + + if err := e.setFlag(ctx, f); err != nil { + return err + } + } + + if err := e.Stop(ctx); err != nil { + return err + } + + return e.Start(ctx) +} + +// setFlag persists a server variable; numbers and booleans must not be quoted. +func (e *mysqlEngine) setFlag(ctx context.Context, f Flag) error { + conn, err := e.srv.DB("") + if err != nil { + return err //nolint:wrapcheck // already wrapped by the engine + } + defer conn.Close() + + name, err := mysqlQuoteDotted(ctx, conn, f.Name) + if err != nil { + return err + } + + return e.exec(ctx, "SET PERSIST_ONLY "+name+" = ?", mysqlFlagArg(f.Value)) +} + +// Dump implements Engine. +func (e *mysqlEngine) Dump(ctx context.Context, dbs []string, _ DumpOptions) ([]byte, error) { + b, err := e.srv.DumpDatabases(ctx, dbs) + + return b, mapMySQLErr(err) +} + +// Restore implements Engine. +func (e *mysqlEngine) Restore(ctx context.Context, db string, data []byte) error { + return e.srv.RestoreInto(ctx, db, data) //nolint:wrapcheck // engine errors carry the command +} + +// Query implements Engine. +func (e *mysqlEngine) Query(ctx context.Context, db, query string) ([][]string, error) { + rows, err := e.srv.QueryMax(ctx, db, query, maxQueryCells) + + return rows, mapMySQLErr(err) +} + +// InsertRows implements Engine. +func (e *mysqlEngine) InsertRows(ctx context.Context, db, table string, columns []string, rows [][]string) error { + conn, err := e.srv.DB(db) + if err != nil { + return err //nolint:wrapcheck // already wrapped by the engine + } + defer conn.Close() + + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("sql: %w", err) + } + defer func() { _ = tx.Rollback() }() + + stmt, err := mysqlInsertStmt(ctx, conn, table, columns, len(rows[0])) + if err != nil { + return err + } + + for _, r := range rows { + args := make([]any, len(r)) + for i, v := range r { + args[i] = v + } + + if _, err = tx.ExecContext(ctx, stmt, args...); err != nil { + return fmt.Errorf("sql: %w", err) + } + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("sql: %w", err) + } + + return nil +} + +func mysqlInsertStmt(ctx context.Context, q rowQuerier, table string, columns []string, width int) (string, error) { + var cols, marks []string + + for i := range width { + if i < len(columns) { + col, err := mysqlQuoteIdent(ctx, q, columns[i]) + if err != nil { + return "", err + } + + cols = append(cols, col) + } + + marks = append(marks, "?") + } + + colList := "" + if len(cols) > 0 { + colList = " (" + strings.Join(cols, ", ") + ")" + } + + tbl, err := mysqlQuoteDotted(ctx, q, table) + if err != nil { + return "", err + } + + return "INSERT INTO " + tbl + colList + " VALUES (" + strings.Join(marks, ", ") + ")", nil +} + +// ReplicationSource implements Replicator. +func (e *mysqlEngine) ReplicationSource(ctx context.Context) (ReplicaSource, error) { + src, err := e.srv.ReplicationSource(ctx) + + return ReplicaSource(src), mapMySQLErr(err) +} + +// AttachReplica implements Replicator. +func (e *mysqlEngine) AttachReplica(ctx context.Context, src ReplicaSource, seed []byte) error { + return e.srv.AttachReplica(ctx, mysqlengine.ReplSource(src), seed) //nolint:wrapcheck // carries the command +} + +// ReplicaSeed implements Replicator. +func (e *mysqlEngine) ReplicaSeed(ctx context.Context, dbs []string) ([]byte, error) { + return e.srv.ReplicaSeed(ctx, dbs) //nolint:wrapcheck // carries the command +} + +// Promote implements Replicator. +func (e *mysqlEngine) Promote(ctx context.Context) error { + return e.srv.Promote(ctx) //nolint:wrapcheck // carries the command +} + +// SetReplication implements Replicator. +func (e *mysqlEngine) SetReplication(ctx context.Context, on bool) error { + return e.srv.SetReplication(ctx, on) //nolint:wrapcheck // carries the command +} + +// ReplicationStatus implements Replicator. +func (e *mysqlEngine) ReplicationStatus(ctx context.Context) (dbrepl.Status, error) { + return e.srv.ReplicationStatus(ctx) //nolint:wrapcheck // carries the command +} + +// EnablePITR implements Recoverer. +func (e *mysqlEngine) EnablePITR(ctx context.Context, dbs []string) (time.Time, error) { + t, err := e.srv.EnablePITR(ctx, dbs) + + return t, mapMySQLErr(err) +} + +// DisablePITR implements Recoverer. +func (e *mysqlEngine) DisablePITR(ctx context.Context) error { + return e.srv.DisablePITR(ctx) //nolint:wrapcheck // carries the command +} + +// PrepareRecovery implements Recoverer. +func (e *mysqlEngine) PrepareRecovery(ctx context.Context, at time.Time) (Recovery, error) { + r, err := e.srv.PrepareRecovery(ctx, at) + if err != nil { + return Recovery{}, mapMySQLErr(err) + } + + src := ReplicaSource(*r.Source) + + return Recovery{ + At: r.At, Source: &src, FromFile: r.FromFile, UntilFile: r.UntilFile, Script: r.Script, + FromPos: r.FromPos, UntilPos: r.UntilPos, + }, nil +} + +// ApplyRecovery implements Recoverer. +func (e *mysqlEngine) ApplyRecovery(ctx context.Context, rec Recovery) error { + if rec.Source == nil { + return nil + } + + src := mysqlengine.ReplSource(*rec.Source) + + return e.srv.ApplyRecovery(ctx, mysqlengine.Recovery{ //nolint:wrapcheck // carries the command + At: rec.At, Source: &src, FromFile: rec.FromFile, UntilFile: rec.UntilFile, Script: rec.Script, + FromPos: rec.FromPos, UntilPos: rec.UntilPos, + }) +} + +// mysqlQuoteIdent has the MySQL server quote s, so no client-built string reaches the DDL statement. +func mysqlQuoteIdent(ctx context.Context, q rowQuerier, s string) (string, error) { + if !identRe().MatchString(s) { + return "", errInvalid("identifier %q is invalid", s) + } + + var out string + if err := q.QueryRowContext(ctx, "SELECT CONCAT('`', REPLACE(?, '`', '``'), '`')", s).Scan(&out); err != nil { + return "", fmt.Errorf("sql: %w", err) + } + + return out, nil +} + +// mysqlQuoteDotted quotes each dot-separated part of s and rejoins them. +func mysqlQuoteDotted(ctx context.Context, q rowQuerier, s string) (string, error) { + parts := strings.Split(s, ".") + + for i, p := range parts { + qp, err := mysqlQuoteIdent(ctx, q, p) + if err != nil { + return "", err + } + + parts[i] = qp + } + + return strings.Join(parts, "."), nil +} + +// mysqlFlagArg types numeric and boolean values so the driver interpolates them unquoted. +func mysqlFlagArg(v string) any { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + + if f, err := strconv.ParseFloat(v, 64); err == nil && !math.IsInf(f, 0) && !math.IsNaN(f) && + !strings.ContainsAny(v, "eExXpP_") { + return f + } + + switch strings.ToUpper(v) { + case "TRUE": + return true + case "FALSE": + return false + } + + return v +} diff --git a/services/gcpsql/ops.go b/services/gcpsql/ops.go index cca986bcd8..31989d51c0 100644 --- a/services/gcpsql/ops.go +++ b/services/gcpsql/ops.go @@ -140,6 +140,10 @@ func (b *InMemoryBackend) runTask(ctx context.Context, rec *instRec, t task) { b.mu.Lock("runTask") op.Status, op.EndTime = opDone, stamp(b.now()) + if err == nil && len(rec.Inst.ReplicaNames) > 0 { + b.syncReplicasLocked(rec) + } + if err != nil { op.Error = opError(err) logger.Load(ctx).WarnContext(ctx, "gcpsql: operation failed", "operation", op.OperationType, diff --git a/services/gcpsql/pitr.go b/services/gcpsql/pitr.go new file mode 100644 index 0000000000..87337e650e --- /dev/null +++ b/services/gcpsql/pitr.go @@ -0,0 +1,202 @@ +package gcpsql + +import ( + "context" + "errors" + "strconv" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const kindLatestRecovery = "sql#getLatestRecoveryTime" + +// RecoveryTimes is the sql.projects.instances.getLatestRecoveryTime response. +type RecoveryTimes struct { + Kind string `json:"kind"` + EarliestRecoveryTime string `json:"earliestRecoveryTime"` + LatestRecoveryTime string `json:"latestRecoveryTime"` +} + +func pitrOn(version string, settings map[string]any) bool { + bc := asMap(settings["backupConfiguration"]) + + key := "binaryLogEnabled" + if isPG(version) { + key = "pointInTimeRecoveryEnabled" + } + + v, _ := bc[key].(bool) + + return v +} + +// syncPITR starts or stops log archiving so the engine matches the instance's PITR setting. +func (b *InMemoryBackend) syncPITR(ctx context.Context, rec *instRec, eng Engine) error { + b.mu.RLock("syncPITR") + on := pitrOn(rec.Inst.DatabaseVersion, rec.Inst.Settings) && !b.isReplica(rec) + had := rec.PitrBase != "" + b.mu.RUnlock() + + rc, ok := eng.(Recoverer) + + switch { + case !on && had && ok: + if err := rc.DisablePITR(ctx); err != nil { + return err + } + + b.setPitrBase(rec, time.Time{}) + case !on: + b.setPitrBase(rec, time.Time{}) + case eng == nil || !ok: + if !had { + b.setPitrBase(rec, b.now()) + } + default: + t, err := rc.EnablePITR(ctx, b.exportableDBs(rec, nil)) + if errors.Is(err, errNoPITR) { + logger.Load(ctx).WarnContext(ctx, "gcpsql: point-in-time recovery needs volumes, window unavailable", + "instance", rec.Inst.Name) + b.setPitrBase(rec, time.Time{}) + + return nil + } + + if err != nil { + return err + } + + b.setPitrBase(rec, t) + } + + return nil +} + +func (b *InMemoryBackend) setPitrBase(rec *instRec, t time.Time) { + b.mu.Lock("setPitrBase") + defer b.mu.Unlock() + + if t.IsZero() { + rec.PitrBase = "" + + return + } + + rec.PitrBase = stamp(t) +} + +// LatestRecoveryTime reports the window point-in-time recovery can target. +func (b *InMemoryBackend) LatestRecoveryTime(project, name string) (*RecoveryTimes, error) { + b.mu.RLock("LatestRecoveryTime") + defer b.mu.RUnlock() + + rec, err := b.lookupLocked(project, name) + if err != nil { + return nil, err + } + + if rec.PitrBase == "" { + return nil, errPITRDisabled() + } + + return &RecoveryTimes{ + Kind: kindLatestRecovery, EarliestRecoveryTime: rec.PitrBase, LatestRecoveryTime: stamp(b.now()), + }, nil +} + +func errPITRDisabled() *APIError { + return errInvalid("Point-in-time recovery is not enabled for this instance.") +} + +// recoveryTarget parses the cloneContext time; ok is false for a plain clone. +func recoveryTarget(in CloneInput) (time.Time, bool, error) { + if in.PointInTime != "" { + t, err := time.Parse(time.RFC3339Nano, in.PointInTime) + if err != nil { + return time.Time{}, false, errInvalid("Failed to parse date/time %q; use RFC 3339 in UTC.", in.PointInTime) + } + + return t.UTC(), true, nil + } + + if in.PitrTimestampMs != "" && in.PitrTimestampMs != "0" { + ms, err := strconv.ParseInt(in.PitrTimestampMs, 10, 64) + if err != nil { + return time.Time{}, false, errInvalid("Invalid pitrTimestampMs %q.", in.PitrTimestampMs) + } + + return time.UnixMilli(ms).UTC(), true, nil + } + + return time.Time{}, false, nil +} + +// checkRecoveryLocked rejects times outside the instance's recovery window. +func (b *InMemoryBackend) checkRecoveryLocked(src *instRec, at time.Time) error { + if b.isReplica(src) || src.PitrBase == "" { + return errPITRDisabled() + } + + earliest, err := time.Parse(timeLayout, src.PitrBase) + if err != nil { + return errPITRDisabled() + } + + if latest := b.now(); at.After(latest) { + return errInvalid("The timestamp for point-in-time recovery is after the latest recovery time of %s.", + stamp(latest)) + } + + if at.Before(earliest) { + return errInvalid("The timestamp for point-in-time recovery is before the earliest recovery time of %s.", + src.PitrBase) + } + + return nil +} + +func (b *InMemoryBackend) populateRecovery(ctx context.Context, src, dst *instRec, at time.Time) error { + if srcEng := b.engineOf(src); srcEng != nil { + rc, ok := srcEng.(Recoverer) + if !ok { + return errNoPITR + } + + r, err := rc.PrepareRecovery(ctx, at) + if err != nil { + return err + } + + b.mu.Lock("populateRecovery") + dst.recovery = &r + b.mu.Unlock() + } + + return b.bringUp(ctx, dst) +} + +// alignDatabases makes dst's database records match what the engine holds. +func (b *InMemoryBackend) alignDatabases(ctx context.Context, rec *instRec, eng Engine) error { + names, err := eng.ListDatabases(ctx) + if err != nil { + return err + } + + b.mu.Lock("alignDatabases") + defer b.mu.Unlock() + + for name, d := range rec.Databases { + if !d.System && !contains(names, name) { + delete(rec.Databases, name) + } + } + + for _, n := range names { + if _, ok := rec.Databases[n]; !ok { + rec.Databases[n] = newDatabase(rec, n, "", "", isSystemDatabase(rec.Inst.DatabaseVersion, n)) + } + } + + return nil +} diff --git a/services/gcpsql/pitr_test.go b/services/gcpsql/pitr_test.go new file mode 100644 index 0000000000..b95faa626f --- /dev/null +++ b/services/gcpsql/pitr_test.go @@ -0,0 +1,208 @@ +package gcpsql_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sqladmin "google.golang.org/api/sqladmin/v1" +) + +func pitrSettings(version string, on bool) *sqladmin.Settings { + bc := &sqladmin.BackupConfiguration{Enabled: true} + + if version == "POSTGRES_16" { + bc.PointInTimeRecoveryEnabled = on + } else { + bc.BinaryLogEnabled = on + } + + return &sqladmin.Settings{BackupConfiguration: bc} +} + +func TestLatestRecoveryTime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + on bool + want int + }{ + {name: "postgres enabled", version: "POSTGRES_16", on: true, want: http.StatusOK}, + {name: "mysql enabled", version: "MYSQL_8_4", on: true, want: http.StatusOK}, + {name: "postgres disabled", version: "POSTGRES_16", on: false, want: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + e.createInstance(t, "src", tt.version, pitrSettings(tt.version, tt.on)) + + got, err := e.svc.Projects.Instances.GetLatestRecoveryTime(project, "src").Context(t.Context()).Do() + if tt.want != http.StatusOK { + require.Error(t, err) + assert.Equal(t, tt.want, statusOf(t, err)) + assert.Zero(t, l.engineFor("src").snapshot().pitrEnabled) + + return + } + + require.NoError(t, err) + assert.Equal(t, "sql#getLatestRecoveryTime", got.Kind) + + earliest, perr := time.Parse(time.RFC3339, got.EarliestRecoveryTime) + require.NoError(t, perr) + latest, perr := time.Parse(time.RFC3339, got.LatestRecoveryTime) + require.NoError(t, perr) + assert.True(t, latest.After(earliest)) + assert.Equal(t, 1, l.engineFor("src").snapshot().pitrEnabled, "the engine starts archiving") + }) + } +} + +func TestPointInTimeClone(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + + tests := []struct { + name string + version string + at string + wantMsg string + want int + }{ + { + name: "postgres in window", + version: "POSTGRES_16", + at: now.Add(-10 * time.Minute).Format(time.RFC3339Nano), + want: 200, + }, + { + name: "mysql in window", + version: "MYSQL_8_4", + at: now.Add(-10 * time.Minute).Format(time.RFC3339Nano), + want: 200, + }, + { + name: "after latest", version: "POSTGRES_16", at: now.Add(time.Hour).Format(time.RFC3339Nano), + want: http.StatusBadRequest, wantMsg: "after the latest recovery time", + }, + { + name: "before earliest", version: "POSTGRES_16", at: now.Add(-3 * time.Hour).Format(time.RFC3339Nano), + want: http.StatusBadRequest, wantMsg: "before the earliest recovery time", + }, + { + name: "malformed", version: "POSTGRES_16", at: "yesterday", + want: http.StatusBadRequest, wantMsg: "Failed to parse date/time", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + e.createInstance(t, "src", tt.version, pitrSettings(tt.version, true)) + + op, err := e.svc.Instances.Clone(project, "src", &sqladmin.InstancesCloneRequest{ + CloneContext: &sqladmin.CloneContext{DestinationInstanceName: "dst", PointInTime: tt.at}, + }).Context(t.Context()).Do() + + if tt.want != 200 { + require.Error(t, err) + assert.Equal(t, tt.want, statusOf(t, err)) + assert.Contains(t, err.Error(), tt.wantMsg) + + return + } + + e.mustWait(t, op, err) + + want, perr := time.Parse(time.RFC3339Nano, tt.at) + require.NoError(t, perr) + + src := l.engineFor("src").snapshot() + require.Len(t, src.prepared, 1) + assert.WithinDuration(t, want, src.prepared[0], time.Millisecond) + + spec := l.specFor("dst") + require.NotNil(t, spec.Recovery, "the clone launches from the source's recovery state") + assert.Equal(t, "pitr-vol", spec.Recovery.Volume) + + dst := l.engineFor("dst").snapshot() + require.Len(t, dst.applied, 1) + assert.Equal(t, "replay", string(dst.applied[0].Script)) + }) + } +} + +func TestPointInTimeCloneNeedsPITR(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createInstance(t, "src", "POSTGRES_16", nil) + + _, err := e.svc.Instances.Clone(project, "src", &sqladmin.InstancesCloneRequest{ + CloneContext: &sqladmin.CloneContext{ + DestinationInstanceName: "dst", PointInTime: time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), + }, + }).Context(t.Context()).Do() + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, statusOf(t, err)) + assert.Contains(t, err.Error(), "not enabled") +} + +func TestPITRSettingFollowsPatch(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + ctx := t.Context() + + e.createInstance(t, "src", "POSTGRES_16", nil) + + op, err := e.svc.Instances.Patch(project, "src", &sqladmin.DatabaseInstance{ + Settings: pitrSettings("POSTGRES_16", true), + }).Context(ctx).Do() + e.mustWait(t, op, err) + assert.Equal(t, 1, l.engineFor("src").snapshot().pitrEnabled) + + _, err = e.svc.Projects.Instances.GetLatestRecoveryTime(project, "src").Context(ctx).Do() + require.NoError(t, err) + + disable := pitrSettings("POSTGRES_16", false) + disable.BackupConfiguration.ForceSendFields = []string{"PointInTimeRecoveryEnabled"} + + op, err = e.svc.Instances.Patch(project, "src", &sqladmin.DatabaseInstance{Settings: disable}).Context(ctx).Do() + e.mustWait(t, op, err) + assert.Equal(t, 1, l.engineFor("src").snapshot().pitrDisabled) + + _, err = e.svc.Projects.Instances.GetLatestRecoveryTime(project, "src").Context(ctx).Do() + require.Error(t, err) +} + +func TestPITRStateOnly(t *testing.T) { + t.Parallel() + + e := newEnvWith(t, nil) + e.createInstance(t, "src", "POSTGRES_16", pitrSettings("POSTGRES_16", true)) + + got, err := e.svc.Projects.Instances.GetLatestRecoveryTime(project, "src").Context(t.Context()).Do() + require.NoError(t, err) + assert.NotEmpty(t, got.EarliestRecoveryTime) + + op, err := e.svc.Instances.Clone(project, "src", &sqladmin.InstancesCloneRequest{ + CloneContext: &sqladmin.CloneContext{ + DestinationInstanceName: "dst", PointInTime: time.Now().UTC().Format(time.RFC3339Nano), + }, + }).Context(t.Context()).Do() + e.mustWait(t, op, err) +} diff --git a/services/gcpsql/replicas.go b/services/gcpsql/replicas.go new file mode 100644 index 0000000000..ee7ab5b7a1 --- /dev/null +++ b/services/gcpsql/replicas.go @@ -0,0 +1,329 @@ +package gcpsql + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "slices" + "strings" + "time" +) + +const ( + instanceTypePrimary = "CLOUD_SQL_INSTANCE" + instanceTypeReplica = "READ_REPLICA_INSTANCE" + primaryPollEvery = 200 * time.Millisecond + serverIDMin = 2 + serverIDSpan = 1<<31 - serverIDMin +) + +func randomServerID() uint32 { + var buf [4]byte + + _, _ = rand.Read(buf[:]) + + return binary.BigEndian.Uint32(buf[:])%serverIDSpan + serverIDMin +} + +// masterName strips the optional "project:" prefix of masterInstanceName. +func masterName(project, v string) (string, error) { + p, name, found := strings.Cut(v, ":") + if !found { + return v, nil + } + + if p != project { + return "", errInvalid("Read replicas in another project are not supported by the emulator.") + } + + return name, nil +} + +func (b *InMemoryBackend) isReplica(rec *instRec) bool { return rec.Inst.MasterInstanceName != "" } + +func binlogOn(settings map[string]any) bool { + v, _ := asMap(settings["backupConfiguration"])["binaryLogEnabled"].(bool) + + return v +} + +// prepareReplicaLocked validates a replica request against its primary and fills what it inherits. +func (b *InMemoryBackend) prepareReplicaLocked(project string, in *InstanceInput) (*instRec, error) { + if in.MasterInstanceName == "" { + return nil, errInvalid("masterInstanceName is required for a read replica.") + } + + if in.InstanceType != "" && in.InstanceType != instanceTypeReplica { + return nil, errInvalid( + "instanceType %q does not match a replica of %q.", + in.InstanceType, + in.MasterInstanceName, + ) + } + + name, err := masterName(project, in.MasterInstanceName) + if err != nil { + return nil, err + } + + prim, err := b.lookupLocked(project, name) + if err != nil { + return nil, err + } + + if prim.Inst.State != stateRunnable { + return nil, errNotRunnable() + } + + if b.isReplica(prim) { + return nil, errInvalid("Replicas of replicas are not supported by the emulator.") + } + + if err = inheritFromPrimary(in, prim); err != nil { + return nil, err + } + + in.MasterInstanceName = name + + return prim, nil +} + +// inheritFromPrimary fills the fields a replica takes from its primary and rejects mismatches. +func inheritFromPrimary(in *InstanceInput, prim *instRec) error { + if in.DatabaseVersion == "" { + in.DatabaseVersion = prim.Inst.DatabaseVersion + } + + if in.Region == "" { + in.Region = prim.Inst.Region + } + + if in.DatabaseVersion != prim.Inst.DatabaseVersion || in.Region != prim.Inst.Region { + return errInvalid("The database version and region of a read replica must match its primary.") + } + + if !isPG(prim.Inst.DatabaseVersion) && !binlogOn(prim.Inst.Settings) { + return errInvalid("Binary logging must be enabled on the primary instance to create a MySQL read replica.") + } + + if in.Settings == nil { + in.Settings = map[string]any{} + } + + if settingString(in.Settings, "tier") == "" { + in.Settings["tier"] = settingString(prim.Inst.Settings, "tier") + } + + in.RootPassword = prim.RootPassword + + return nil +} + +// linkReplicaLocked records the replica on its primary and copies the primary's databases and accounts. +func (b *InMemoryBackend) linkReplicaLocked(prim, rep *instRec) { + rep.Inst.InstanceType = instanceTypeReplica + rep.Inst.MasterInstanceName = prim.Inst.Project + ":" + prim.Inst.Name + rep.Inst.ReplicaConfiguration = map[string]any{keyKind: "sql#replicaConfiguration"} + + prim.Inst.ReplicaNames = append(prim.Inst.ReplicaNames, rep.Inst.Name) + slices.Sort(prim.Inst.ReplicaNames) + b.touchLocked(prim) + b.mirrorLocked(prim, rep) +} + +// mirrorLocked makes dst's database and account records equal to src's. +func (b *InMemoryBackend) mirrorLocked(src, dst *instRec) { + dst.Databases = map[string]*Database{} + dst.Users = map[string]*userRec{} + + for name, d := range src.Databases { + dst.Databases[name] = newDatabase(dst, name, d.Charset, d.Collation, d.System) + } + + for key, u := range src.Users { + cp := newUser(dst, u.User.Name, u.User.Host, u.User.Type) + cp.User.PasswordPolicy, cp.User.DatabaseRoles, cp.Password = u.User.PasswordPolicy, u.User.DatabaseRoles, u.Password + dst.Users[key] = cp + } +} + +// syncReplicasLocked mirrors a primary's records onto its replicas after a successful task. +func (b *InMemoryBackend) syncReplicasLocked(prim *instRec) { + for _, n := range prim.Inst.ReplicaNames { + if rep, ok := b.instances[instKey(prim.Inst.Project, n)]; ok && !rep.gone { + b.mirrorLocked(prim, rep) + } + } +} + +func (b *InMemoryBackend) unlinkReplicaLocked(rep *instRec) { + name, err := masterName(rep.Inst.Project, rep.Inst.MasterInstanceName) + if err != nil { + return + } + + if prim, ok := b.instances[instKey(rep.Inst.Project, name)]; ok { + prim.Inst.ReplicaNames = slices.DeleteFunc( + prim.Inst.ReplicaNames, + func(n string) bool { return n == rep.Inst.Name }, + ) + b.touchLocked(prim) + } +} + +// primaryEngine waits for the primary of rec to settle; a nil engine with a nil error means the primary is +// state-only. +func (b *InMemoryBackend) primaryEngine(ctx context.Context, rec *instRec) (Engine, error) { + project := rec.Inst.Project + + name, err := masterName(project, rec.Inst.MasterInstanceName) + if err != nil { + return nil, err + } + + tick := time.NewTicker(primaryPollEvery) + defer tick.Stop() + + for { + b.mu.RLock("primaryEngine") + prim, ok := b.instances[instKey(project, name)] + + var ( + eng Engine + state string + ) + + if ok && !prim.gone { + eng, state = prim.eng, prim.Inst.State + } + b.mu.RUnlock() + + switch { + case !ok: + return nil, errNoInstance() + case eng != nil: + return eng, nil + case state == stateRunnable: + return nil, nil //nolint:nilnil // state-only primary + case state == stateFailed || state == stateSuspended || state == stateDeleting: + return nil, fmt.Errorf("%w: primary %s is %s", errExec, name, state) + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("waiting for primary %s: %w", name, ctx.Err()) + case <-tick.C: + } + } +} + +func (b *InMemoryBackend) replicaOnly(project, name string) (*instRec, error) { + rec, err := b.opRec(project, name) + if err != nil { + return nil, err + } + + if !b.isReplica(rec) { + return nil, errInvalid("The instance is not a read replica.") + } + + return rec, nil +} + +// PromoteReplica turns a read replica into a standalone primary. +func (b *InMemoryBackend) PromoteReplica(project, name string) (*Operation, error) { + b.mu.Lock("PromoteReplica") + defer b.mu.Unlock() + + rec, err := b.replicaOnly(project, name) + if err != nil { + return nil, err + } + + return b.submitLocked(rec, project, "PROMOTE_REPLICA", opMeta{}, func(ctx context.Context) error { + if eng := b.engineOf(rec); eng != nil { + rp, ok := eng.(Replicator) + if !ok { + return errNoReplication + } + + if perr := rp.Promote(ctx); perr != nil { + return perr + } + } + + b.mu.Lock("PromoteReplica.task") + defer b.mu.Unlock() + + b.unlinkReplicaLocked(rec) + rec.Inst.MasterInstanceName, rec.Inst.ReplicaConfiguration = "", nil + rec.Inst.InstanceType, rec.ReplicaPaused = instanceTypePrimary, false + b.touchLocked(rec) + + return nil + }), nil +} + +// SetReplicaRunning starts (true) or stops (false) replication on a read replica. +func (b *InMemoryBackend) SetReplicaRunning(project, name string, on bool) (*Operation, error) { + b.mu.Lock("SetReplicaRunning") + defer b.mu.Unlock() + + rec, err := b.replicaOnly(project, name) + if err != nil { + return nil, err + } + + opType := "STOP_REPLICA" + if on { + opType = "START_REPLICA" + } + + return b.submitLocked(rec, project, opType, opMeta{}, func(ctx context.Context) error { + if eng := b.engineOf(rec); eng != nil { + rp, ok := eng.(Replicator) + if !ok { + return errNoReplication + } + + if serr := rp.SetReplication(ctx, on); serr != nil { + return serr + } + } + + b.mu.Lock("SetReplicaRunning.task") + rec.ReplicaPaused = !on + b.mu.Unlock() + + return nil + }), nil +} + +// ReplicaStatus reports a replica's replication health; it errors for non-replicas and state-only instances. +func (b *InMemoryBackend) ReplicaStatus(ctx context.Context, project, name string) (bool, float64, error) { + b.mu.RLock("ReplicaStatus") + rec, err := b.replicaOnly(project, name) + + var eng Engine + if err == nil { + eng = rec.eng + } + b.mu.RUnlock() + + if err != nil { + return false, 0, err + } + + rp, ok := eng.(Replicator) + if !ok { + return false, 0, errNoReplication + } + + st, err := rp.ReplicationStatus(ctx) + if err != nil { + return false, 0, err + } + + return st.Replicating, st.LagSeconds, nil +} diff --git a/services/gcpsql/replicas_test.go b/services/gcpsql/replicas_test.go new file mode 100644 index 0000000000..b12f595de5 --- /dev/null +++ b/services/gcpsql/replicas_test.go @@ -0,0 +1,292 @@ +package gcpsql_test + +import ( + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/googleapi" + sqladmin "google.golang.org/api/sqladmin/v1" + + "github.com/blackbirdworks/gopherstack/services/gcpsql" +) + +func (l *fakeLauncher) engineFor(name string) *fakeEngine { + l.mu.Lock() + defer l.mu.Unlock() + + for i, s := range l.specs { + if s.ID == name { + return l.engines[i] + } + } + + return nil +} + +func (l *fakeLauncher) specFor(name string) gcpsql.LaunchSpec { + l.mu.Lock() + defer l.mu.Unlock() + + for _, s := range l.specs { + if s.ID == name { + return s + } + } + + return gcpsql.LaunchSpec{} +} + +func binlogSettings() *sqladmin.Settings { + return &sqladmin.Settings{BackupConfiguration: &sqladmin.BackupConfiguration{Enabled: true, BinaryLogEnabled: true}} +} + +func (e *env) createReplica(t *testing.T, name, master, version string) *sqladmin.DatabaseInstance { + t.Helper() + + op, err := e.svc.Instances.Insert(project, &sqladmin.DatabaseInstance{ + Name: name, DatabaseVersion: version, MasterInstanceName: master, + Settings: &sqladmin.Settings{Tier: "db-custom-1-3840"}, + }).Context(t.Context()).Do() + done := e.mustWait(t, op, err) + assert.Equal(t, "CREATE_REPLICA", done.OperationType) + + inst, err := e.svc.Instances.Get(project, name).Context(t.Context()).Do() + require.NoError(t, err) + + return inst +} + +func statusOf(t *testing.T, err error) int { + t.Helper() + + var ge *googleapi.Error + + require.ErrorAs(t, err, &ge) + + return ge.Code +} + +func TestReplicaCreate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + prim *sqladmin.Settings + master string + }{ + {name: "postgres bare name", version: "POSTGRES_16", master: "prim"}, + {name: "mysql project prefix", version: "MYSQL_8_4", prim: binlogSettings(), master: project + ":prim"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + e.createInstance(t, "prim", tt.version, tt.prim) + + rep := e.createReplica(t, "rep", tt.master, tt.version) + assert.Equal(t, "READ_REPLICA_INSTANCE", rep.InstanceType) + assert.Equal(t, project+":prim", rep.MasterInstanceName) + assert.Equal(t, "us-central1", rep.Region, "region follows the primary") + assert.Equal(t, "db-custom-1-3840", rep.Settings.Tier) + + prim, err := e.svc.Instances.Get(project, "prim").Context(t.Context()).Do() + require.NoError(t, err) + assert.Equal(t, []string{"rep"}, prim.ReplicaNames) + assert.Equal(t, "CLOUD_SQL_INSTANCE", prim.InstanceType) + + spec := l.specFor("rep") + require.NotNil(t, spec.Replica, "replica engine streams from the primary") + assert.Equal(t, "repl-pw", spec.Replica.Password) + + eng := l.engineFor("rep").snapshot() + assert.Equal(t, 1, eng.attached) + assert.Equal(t, spec.Replica.Host, eng.replSrc.Host) + }) + } +} + +func TestReplicaCreateErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantMsg string + in sqladmin.DatabaseInstance + want int + }{ + { + name: "missing primary", + in: sqladmin.DatabaseInstance{MasterInstanceName: "ghost"}, + want: http.StatusNotFound, + }, + { + name: "mysql without binlog", + in: sqladmin.DatabaseInstance{MasterInstanceName: "my", DatabaseVersion: "MYSQL_8_0"}, + want: http.StatusBadRequest, wantMsg: "Binary logging", + }, + { + name: "version mismatch", + in: sqladmin.DatabaseInstance{MasterInstanceName: "pg", DatabaseVersion: "POSTGRES_15"}, + want: http.StatusBadRequest, wantMsg: "must match", + }, + { + name: "other project", in: sqladmin.DatabaseInstance{MasterInstanceName: "elsewhere:pg"}, + want: http.StatusBadRequest, wantMsg: "another project", + }, + { + name: "replica of replica", in: sqladmin.DatabaseInstance{MasterInstanceName: "pgrep"}, + want: http.StatusBadRequest, wantMsg: "Replicas of replicas", + }, + { + name: "wrong instance type", + in: sqladmin.DatabaseInstance{MasterInstanceName: "pg", InstanceType: "CLOUD_SQL_INSTANCE"}, + want: http.StatusBadRequest, wantMsg: "instanceType", + }, + {name: "replica type without primary", in: sqladmin.DatabaseInstance{InstanceType: "READ_REPLICA_INSTANCE"}, + want: http.StatusBadRequest, wantMsg: "masterInstanceName"}, + } + + e := newEnv(t) + e.createInstance(t, "pg", "POSTGRES_16", nil) + e.createInstance(t, "my", "MYSQL_8_0", nil) + e.createReplica(t, "pgrep", "pg", "POSTGRES_16") + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + in := tt.in + in.Name = "bad" + strconv.Itoa(i) + in.Settings = &sqladmin.Settings{Tier: "db-custom-1-3840"} + + _, err := e.svc.Instances.Insert(project, &in).Context(t.Context()).Do() + require.Error(t, err) + assert.Equal(t, tt.want, statusOf(t, err)) + + if tt.wantMsg != "" { + assert.Contains(t, err.Error(), tt.wantMsg) + } + }) + } +} + +func TestReplicaLifecycle(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + ctx := t.Context() + + e.createInstance(t, "prim", "POSTGRES_16", nil) + e.createReplica(t, "rep", "prim", "POSTGRES_16") + + _, err := e.svc.Instances.Delete(project, "prim").Context(ctx).Do() + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, statusOf(t, err), "a primary with replicas cannot be deleted") + + _, err = e.svc.Instances.StartReplica(project, "prim").Context(ctx).Do() + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, statusOf(t, err), "a primary has no replication to start") + + op, err := e.svc.Instances.StopReplica(project, "rep").Context(ctx).Do() + done := e.mustWait(t, op, err) + assert.Equal(t, "STOP_REPLICA", done.OperationType) + + op, err = e.svc.Instances.StartReplica(project, "rep").Context(ctx).Do() + done = e.mustWait(t, op, err) + assert.Equal(t, "START_REPLICA", done.OperationType) + assert.Equal(t, []bool{false, true}, l.engineFor("rep").snapshot().replCalls) + + op, err = e.svc.Instances.PromoteReplica(project, "rep").Context(ctx).Do() + done = e.mustWait(t, op, err) + assert.Equal(t, "PROMOTE_REPLICA", done.OperationType) + assert.Equal(t, 1, l.engineFor("rep").snapshot().promoted) + + rep, err := e.svc.Instances.Get(project, "rep").Context(ctx).Do() + require.NoError(t, err) + assert.Equal(t, "CLOUD_SQL_INSTANCE", rep.InstanceType) + assert.Empty(t, rep.MasterInstanceName) + + prim, err := e.svc.Instances.Get(project, "prim").Context(ctx).Do() + require.NoError(t, err) + assert.Empty(t, prim.ReplicaNames, "the promoted replica leaves the primary") + + _, err = e.svc.Instances.PromoteReplica(project, "rep").Context(ctx).Do() + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, statusOf(t, err), "a standalone instance cannot be promoted") + + op, err = e.svc.Instances.Delete(project, "prim").Context(ctx).Do() + e.mustWait(t, op, err) + assert.True(t, l.engineFor("prim").snapshot().purged, "delete purges the engine volumes") +} + +func TestReplicaDeleteUnlinks(t *testing.T) { + t.Parallel() + + e := newEnv(t) + e.createInstance(t, "prim", "POSTGRES_16", nil) + e.createReplica(t, "rep", "prim", "POSTGRES_16") + + op, err := e.svc.Instances.Delete(project, "rep").Context(t.Context()).Do() + e.mustWait(t, op, err) + + prim, err := e.svc.Instances.Get(project, "prim").Context(t.Context()).Do() + require.NoError(t, err) + assert.Empty(t, prim.ReplicaNames) +} + +func TestReplicaRecordsFollowPrimary(t *testing.T) { + t.Parallel() + + e := newEnv(t) + ctx := t.Context() + + e.createInstance(t, "prim", "POSTGRES_16", nil) + e.createReplica(t, "rep", "prim", "POSTGRES_16") + + op, err := e.svc.Databases.Insert(project, "prim", &sqladmin.Database{Name: "app"}).Context(ctx).Do() + e.mustWait(t, op, err) + + require.Eventually(t, func() bool { + dbs, lerr := e.svc.Databases.List(project, "rep").Context(ctx).Do() + + return lerr == nil && len(dbs.Items) > 0 && func() bool { + for _, d := range dbs.Items { + if d.Name == "app" { + return true + } + } + + return false + }() + }, e.timeout, 5*time.Millisecond, "databases created on the primary appear on the replica") + + _, err = e.svc.Databases.Insert(project, "rep", &sqladmin.Database{Name: "nope"}).Context(ctx).Do() + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, statusOf(t, err), "replicas are read-only") + + _, err = e.svc.Users.Insert(project, "rep", &sqladmin.User{Name: "u", Password: "p"}).Context(ctx).Do() + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, statusOf(t, err)) +} + +func TestReplicaOfStateOnlyPrimary(t *testing.T) { + t.Parallel() + + e := newEnvWith(t, nil) + e.createInstance(t, "prim", "POSTGRES_16", nil) + rep := e.createReplica(t, "rep", "prim", "POSTGRES_16") + assert.Equal(t, "RUNNABLE", rep.State) + assert.Equal(t, "READ_REPLICA_INSTANCE", rep.InstanceType) + + op, err := e.svc.Instances.PromoteReplica(project, "rep").Context(t.Context()).Do() + e.mustWait(t, op, err) +} diff --git a/services/gcpsql/resources.go b/services/gcpsql/resources.go new file mode 100644 index 0000000000..fa2d21ea17 --- /dev/null +++ b/services/gcpsql/resources.go @@ -0,0 +1,60 @@ +package gcpsql + +import ( + "regexp" + "runtime" + "strconv" +) + +const ( + standardMiB = 3840 + highmemMiB = 6656 + perfOptimizedMiB = 8192 + nanoPerCPU = int64(1_000_000_000) +) + +var ( + customTierRe = regexp.MustCompile(`^db-custom-(\d+)-(\d+)$`) + sizedTierRe = regexp.MustCompile(`^db-(n1-standard|n1-highmem|perf-optimized-N)-(\d+)$`) +) + +// tierShape maps a documented machine type to vCPUs and memory in MiB; ok is false for unknown tiers. +func tierShape(tier string) (int64, int64, bool) { + switch tier { + case "db-f1-micro": + return 1, microMiB, true + case "db-g1-small": + return 1, smallMiB, true + } + + if m := customTierRe.FindStringSubmatch(tier); m != nil { + cpu, _ := strconv.ParseInt(m[1], 10, 64) + mem, _ := strconv.ParseInt(m[2], 10, 64) + + return cpu, mem, cpu > 0 && mem > 0 + } + + m := sizedTierRe.FindStringSubmatch(tier) + if m == nil { + return 0, 0, false + } + + n, _ := strconv.ParseInt(m[2], 10, 64) + perVCPU := map[string]int64{ + "n1-standard": standardMiB, "n1-highmem": highmemMiB, "perf-optimized-N": perfOptimizedMiB, + }[m[1]] + + return n, n * perVCPU, n > 0 +} + +// tierLimits turns a tier into container CPU and memory caps; the CPU cap never exceeds the host's cores. +func tierLimits(tier string) (int64, int64) { + cpu, memMiB, ok := tierShape(tier) + if !ok { + return 0, 0 + } + + cpu = min(cpu, int64(runtime.NumCPU())) + + return cpu * nanoPerCPU, memMiB * mib +} diff --git a/services/gcpsql/resources_internal_test.go b/services/gcpsql/resources_internal_test.go new file mode 100644 index 0000000000..00caf9dafb --- /dev/null +++ b/services/gcpsql/resources_internal_test.go @@ -0,0 +1,52 @@ +package gcpsql + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTierShape(t *testing.T) { + t.Parallel() + + tests := []struct { + tier string + cpu int64 + memMiB int64 + ok bool + }{ + {tier: "db-f1-micro", cpu: 1, memMiB: 614, ok: true}, + {tier: "db-g1-small", cpu: 1, memMiB: 1740, ok: true}, + {tier: "db-custom-2-7680", cpu: 2, memMiB: 7680, ok: true}, + {tier: "db-n1-standard-4", cpu: 4, memMiB: 4 * 3840, ok: true}, + {tier: "db-n1-highmem-8", cpu: 8, memMiB: 8 * 6656, ok: true}, + {tier: "db-perf-optimized-N-4", cpu: 4, memMiB: 4 * 8192, ok: true}, + {tier: "db-custom-0-0"}, + {tier: "mystery-tier"}, + {tier: ""}, + } + + for _, tt := range tests { + t.Run(tt.tier, func(t *testing.T) { + t.Parallel() + + cpu, mem, ok := tierShape(tt.tier) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.cpu, cpu) + assert.Equal(t, tt.memMiB, mem) + }) + } +} + +func TestTierLimitsClampToHost(t *testing.T) { + t.Parallel() + + nano, mem := tierLimits("db-custom-512-1024") + assert.Equal(t, int64(runtime.NumCPU())*nanoPerCPU, nano) + assert.Equal(t, 1024*mib, mem) + + nano, mem = tierLimits("unknown") + assert.Zero(t, nano) + assert.Zero(t, mem) +} diff --git a/services/gcpsql/route.go b/services/gcpsql/route.go index fcbe9f6684..582fe4039d 100644 --- a/services/gcpsql/route.go +++ b/services/gcpsql/route.go @@ -18,6 +18,10 @@ const ( opDeleteInstance = "DeleteInstance" opRestartInstance = "RestartInstance" opCloneInstance = "CloneInstance" + opPromoteReplica = "PromoteReplica" + opStartReplica = "StartReplica" + opStopReplica = "StopReplica" + opLatestRecovery = "GetLatestRecoveryTime" opExportInstance = "ExportInstance" opImportInstance = "ImportInstance" opRestoreBackup = "RestoreBackup" @@ -176,14 +180,18 @@ func instanceRoute(method string, s []string, rt route) route { func subCollectionOp(method, sub string) string { table := map[string]map[string]string{ - "restart": {http.MethodPost: opRestartInstance}, - "clone": {http.MethodPost: opCloneInstance}, - "export": {http.MethodPost: opExportInstance}, - "import": {http.MethodPost: opImportInstance}, - "restoreBackup": {http.MethodPost: opRestoreBackup}, - "connectSettings": {http.MethodGet: opGetConnectSettings}, - "createEphemeral": {http.MethodPost: opCreateEphemeral}, - "databases": {http.MethodGet: opListDatabases, http.MethodPost: opInsertDatabase}, + "restart": {http.MethodPost: opRestartInstance}, + "clone": {http.MethodPost: opCloneInstance}, + "promoteReplica": {http.MethodPost: opPromoteReplica}, + "startReplica": {http.MethodPost: opStartReplica}, + "stopReplica": {http.MethodPost: opStopReplica}, + "getLatestRecoveryTime": {http.MethodGet: opLatestRecovery}, + "export": {http.MethodPost: opExportInstance}, + "import": {http.MethodPost: opImportInstance}, + "restoreBackup": {http.MethodPost: opRestoreBackup}, + "connectSettings": {http.MethodGet: opGetConnectSettings}, + "createEphemeral": {http.MethodPost: opCreateEphemeral}, + "databases": {http.MethodGet: opListDatabases, http.MethodPost: opInsertDatabase}, "users": { http.MethodGet: opListUsers, http.MethodPost: opInsertUser, http.MethodPut: opUpdateUser, http.MethodDelete: opDeleteUser, diff --git a/services/gcpsql/store.go b/services/gcpsql/store.go index 4b55c1142d..31cc310837 100644 --- a/services/gcpsql/store.go +++ b/services/gcpsql/store.go @@ -34,18 +34,23 @@ type task struct { } type instRec struct { - eng Engine - Databases map[string]*Database `json:"databases"` - Users map[string]*userRec `json:"users"` - Backups map[string]*backupRec `json:"backups"` - Certs map[string]*certRec `json:"certs"` - Inst Instance `json:"inst"` - RootPassword string `json:"rootPassword"` - CACert string `json:"caCert"` - CAKey string `json:"caKey"` - queue []task - draining bool - gone bool + eng Engine + recovery *Recovery + Databases map[string]*Database `json:"databases"` + Backups map[string]*backupRec `json:"backups"` + Certs map[string]*certRec `json:"certs"` + Users map[string]*userRec `json:"users"` + RootPassword string `json:"rootPassword"` + CACert string `json:"caCert"` + PitrBase string `json:"pitrBase,omitempty"` + CAKey string `json:"caKey"` + Inst Instance `json:"inst"` + queue []task + ServerID uint32 `json:"serverID,omitempty"` + fresh bool + ReplicaPaused bool `json:"replicaPaused,omitempty"` + draining bool + gone bool } // InMemoryBackend holds all Cloud SQL state under one lock. @@ -61,6 +66,7 @@ type InMemoryBackend struct { ops map[string]*Operation startup time.Duration seq int64 + swept bool } // NewInMemoryBackend creates an empty backend without a database launcher (state-only instances). @@ -156,6 +162,14 @@ func (b *InMemoryBackend) shutdown(renew bool) { e.Close(context.Background()) } + if renew { + for _, e := range engines { + if vr, ok := e.(VolumeRemover); ok { + vr.RemoveVolumes(context.Background()) + } + } + } + if !renew && closer != nil { closer.Close() } diff --git a/services/gcpsql/transfer.go b/services/gcpsql/transfer.go index 7324588b70..bb3baf9880 100644 --- a/services/gcpsql/transfer.go +++ b/services/gcpsql/transfer.go @@ -215,7 +215,7 @@ func (b *InMemoryBackend) ImportInstance( b.mu.Lock("ImportInstance") defer b.mu.Unlock() - rec, err := b.opRec(project, instance) + rec, err := b.writableRec(project, instance) if err != nil { return nil, err } diff --git a/services/gcpsql/volumes_test.go b/services/gcpsql/volumes_test.go new file mode 100644 index 0000000000..eedb4e6271 --- /dev/null +++ b/services/gcpsql/volumes_test.go @@ -0,0 +1,192 @@ +package gcpsql_test + +import ( + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sqladmin "google.golang.org/api/sqladmin/v1" + + "github.com/blackbirdworks/gopherstack/services/gcpsql" +) + +func TestTierReachesEngine(t *testing.T) { + t.Parallel() + + tests := []struct { + tier string + wantCPU int64 + wantMem int64 + }{ + {tier: "db-custom-1-3840", wantCPU: 1, wantMem: 3840 << 20}, + {tier: "db-f1-micro", wantCPU: 1, wantMem: 614 << 20}, + {tier: "db-n1-standard-1", wantCPU: 1, wantMem: 3840 << 20}, + {tier: "db-bogus", wantCPU: 0, wantMem: 0}, + } + + for _, tt := range tests { + t.Run(tt.tier, func(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + e.createInstance(t, "a", "POSTGRES_16", &sqladmin.Settings{Tier: tt.tier}) + + spec := l.specFor("a") + assert.Equal(t, tt.wantCPU*1_000_000_000, spec.NanoCPUs) + assert.Equal(t, tt.wantMem, spec.MemoryBytes) + assert.True(t, spec.Persist, "engine data lives in a volume") + assert.Equal(t, project, spec.Project) + assert.NotZero(t, spec.ServerID) + }) + } +} + +func TestTierChangeRelaunchesEngine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + persist bool + replica bool + wantLaunches int + }{ + {name: "persistent", persist: true, wantLaunches: 2}, + {name: "not persistent", persist: false, wantLaunches: 1}, + {name: "has replica", persist: true, replica: true, wantLaunches: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{persist: tt.persist} + e := newEnvWith(t, l) + e.createInstance(t, "a", "POSTGRES_16", &sqladmin.Settings{Tier: "db-custom-1-3840"}) + + if tt.replica { + e.createReplica(t, "r", "a", "POSTGRES_16") + } + + op, err := e.svc.Instances.Patch(project, "a", &sqladmin.DatabaseInstance{ + Settings: &sqladmin.Settings{Tier: "db-custom-2-7680"}, + }).Context(t.Context()).Do() + e.mustWait(t, op, err) + + l.mu.Lock() + defer l.mu.Unlock() + + launches := 0 + for _, s := range l.specs { + if s.ID == "a" { + launches++ + } + } + + assert.Equal(t, tt.wantLaunches, launches) + + if tt.wantLaunches == 2 { + last := l.specs[len(l.specs)-1] + assert.Equal(t, int64(min(2, runtime.NumCPU()))*1_000_000_000, last.NanoCPUs) + assert.Equal(t, int64(7680)<<20, last.MemoryBytes) + assert.True(t, l.engines[0].closed) + assert.False(t, l.engines[0].purged, "a relaunch keeps the data volume") + } + }) + } +} + +func TestVolumeLifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + end func(*env) + name string + wantPurge bool + }{ + {name: "reset removes volumes", end: func(e *env) { e.backend.Reset() }, wantPurge: true}, + {name: "close keeps volumes", end: func(e *env) { e.backend.Close() }, wantPurge: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + e.createInstance(t, "a", "POSTGRES_16", nil) + + tt.end(e) + + eng := l.engineFor("a").snapshot() + assert.True(t, eng.closed) + assert.Equal(t, tt.wantPurge, eng.purged) + }) + } +} + +func TestStaleVolumesSweptOnce(t *testing.T) { + t.Parallel() + + l := &fakeLauncher{} + e := newEnvWith(t, l) + e.createInstance(t, "a", "POSTGRES_16", nil) + e.createInstance(t, "b", "MYSQL_8_0", nil) + + l.mu.Lock() + defer l.mu.Unlock() + + require.Len(t, l.swept, 1, "the sweep runs before the first launch only") + + names := make([]string, 0, len(l.swept[0])) + for n := range l.swept[0] { + names = append(names, n) + } + + joined := strings.Join(names, " ") + assert.Contains(t, joined, "gopherstack-gcpsql-data-test-project-a") +} + +func TestSnapshotRestoreRelaunchesReplicaAfterPrimary(t *testing.T) { + t.Parallel() + + e := newEnv(t) + ctx := t.Context() + e.createInstance(t, "prim", "POSTGRES_16", nil) + e.createReplica(t, "rep", "prim", "POSTGRES_16") + + op, err := e.svc.Instances.StopReplica(project, "rep").Context(ctx).Do() + e.mustWait(t, op, err) + + snap := e.backend.Snapshot(ctx) + + fresh := &fakeLauncher{} + b := gcpsql.NewInMemoryBackend() + b.SetLauncher(fresh) + t.Cleanup(b.Close) + + require.NoError(t, b.Restore(ctx, snap)) + + require.Eventually(t, func() bool { + in, gerr := b.GetInstance(project, "rep") + + return gerr == nil && in.State == "RUNNABLE" + }, 10*time.Second, 5*time.Millisecond) + + in, err := b.GetInstance(project, "rep") + require.NoError(t, err) + assert.Equal(t, "READ_REPLICA_INSTANCE", in.InstanceType) + + prim, err := b.GetInstance(project, "prim") + require.NoError(t, err) + assert.Equal(t, []string{"rep"}, prim.ReplicaNames) + + require.NotNil(t, fresh.specFor("rep").Replica) + + rep := fresh.engineFor("rep").snapshot() + assert.Equal(t, 1, rep.attached) + assert.Equal(t, []bool{false}, rep.replCalls, "a stopped replica stays stopped across a restart") +} diff --git a/services/gcpstorage/acl_test.go b/services/gcpstorage/acl_test.go index 695470cf4b..791341d9a3 100644 --- a/services/gcpstorage/acl_test.go +++ b/services/gcpstorage/acl_test.go @@ -12,6 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/api/iterator" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func aclMap(rules []storage.ACLRule) map[string]storage.ACLRole { @@ -300,7 +302,7 @@ func TestACLComposePredefined(t *testing.T) { req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/services/gcpstorage/handler.go b/services/gcpstorage/handler.go index 3446784f40..7cf75cbc19 100644 --- a/services/gcpstorage/handler.go +++ b/services/gcpstorage/handler.go @@ -13,6 +13,7 @@ import ( "github.com/labstack/echo/v5" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/service" @@ -253,6 +254,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Use(gcpenforce.EchoMiddleware(ctx, gcpenforce.Target{Host: "storage.googleapis.com", QueryOnly: true})) e.Any("/*", telemetry.WrapEchoHandler("GCPStorage", h.Handler(), h)) srv := &http.Server{ diff --git a/services/gcpstorage/handler_test.go b/services/gcpstorage/handler_test.go index 14f7769fee..169d835165 100644 --- a/services/gcpstorage/handler_test.go +++ b/services/gcpstorage/handler_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/gcpstorage" ) @@ -45,7 +46,7 @@ func (r *rawEnv) do(t *testing.T, method, path, body string, hdr map[string]stri req.Header.Set(k, v) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/gcpworkflows/handler.go b/services/gcpworkflows/handler.go index 28a1335f27..2986025232 100644 --- a/services/gcpworkflows/handler.go +++ b/services/gcpworkflows/handler.go @@ -18,6 +18,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/blackbirdworks/gopherstack/pkgs/gcpenforce" "github.com/blackbirdworks/gopherstack/pkgs/gcprest" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" @@ -131,7 +132,7 @@ func (h *Handler) StartWorker(ctx context.Context) error { workerCtx := logger.WithWorker(ctx, "gcpworkflows", "listener") log := logger.Load(workerCtx) - srv := grpc.NewServer(grpc.UnaryInterceptor(logUnary(log))) + srv := gcpenforce.NewServer(ctx, "workflows.googleapis.com", grpc.UnaryInterceptor(logUnary(log))) h.registerServers(srv) _, port, _ := net.SplitHostPort(listener.Addr().String()) diff --git a/services/gcpworkflows/integration_test.go b/services/gcpworkflows/integration_test.go index 26fd480528..1754c5440a 100644 --- a/services/gcpworkflows/integration_test.go +++ b/services/gcpworkflows/integration_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/gcprest/resttest" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/gcpfirestore" "github.com/blackbirdworks/gopherstack/services/gcppubsub" "github.com/blackbirdworks/gopherstack/services/gcpsecretmanager" @@ -97,12 +98,12 @@ main: require.NoError(t, jsonUnmarshal(raw, &told)) require.Contains(t, told.URL, "/callbacks/") - resp, err := http.Get(told.URL) + resp, err := testclient.New(t).Get(told.URL) require.NoError(t, err) _ = resp.Body.Close() assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) - resp, err = http.Post( + resp, err = testclient.New(t).Post( told.URL, "application/json", strings.NewReader(`{"approved":true}`), @@ -115,7 +116,7 @@ main: assert.Equal(t, executionspb.Execution_SUCCEEDED, done.GetState(), done.GetError().GetPayload()) assert.JSONEq(t, `{"body":{"approved":true},"method":"POST"}`, done.GetResult()) - resp, err = http.Post(told.URL, "application/json", strings.NewReader(`{}`)) + resp, err = testclient.New(t).Post(told.URL, "application/json", strings.NewReader(`{}`)) require.NoError(t, err) _ = resp.Body.Close() assert.Equal(t, http.StatusNotFound, resp.StatusCode, "a spent callback is gone") diff --git a/services/glacier/iam_enforcement_test.go b/services/glacier/iam_enforcement_test.go index 889aec194f..7c78d5e462 100644 --- a/services/glacier/iam_enforcement_test.go +++ b/services/glacier/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/glacier" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/glacier/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/glue/PARITY.md b/services/glue/PARITY.md index b6145e7d31..9d0bfe3e49 100644 --- a/services/glue/PARITY.md +++ b/services/glue/PARITY.md @@ -178,8 +178,9 @@ gaps: [] items_still_open: - "Operations added by the SDK bump and not yet implemented (listed in sdk_completeness_test.go notImplemented): GetSystemLogsForJobRun, GetSystemLogsForSession. Both return a SystemLogsUrl into AWS-managed Spark driver/executor system logs; this emulator runs no Spark and keeps no such logs, so any URL would be fabricated (UNVERIFIABLE: no data source exists)." - "Request members accepted and ignored with no determinable effect: Tags on CreateColumnStatisticsTaskSettings/CreateIntegrationResourceProperty/RegisterConnectionType/CreateCustomEntityType (the Glue ARN guide lists no ARN for them); UpdateTable VersionId/Force/ViewUpdateAction (UpdateTable API reference names no mismatch behavior); ModifyIntegration.IntegrationName (ARN is name-keyed); Get/ResetJobBookmark.RunId (one bookmark per job; per-run semantics undocumented); GetConnection.ApplyOverrideForComputeEnvironment; GetTable/GetTables/SearchTables IncludeStatusDetails; GetDatabases/SearchTables ResourceShareType (no cross-account shares)." - - "Crawler targets not crawled: CatalogTargets (re-crawling existing catalog tables; needs Data Catalog event/DLQ semantics for Iceberg/Delta variants) and MongoDBTargets (no MongoDB/DocumentDB wire driver in go.mod). A crawl with either target stores them and produces no tables. Crawler gaps in the implemented targets: JDBC SECRET_ID auth is not resolved (PASSWORD only; SECRET_ID fails the crawl), JDBC engines are postgresql/mysql/redshift only; Delta checkpoint parquet files are not read (JSON commits only; WriteManifest needs the log to start at commit 0); Hudi schema is read from hoodie.table.create.schema or JSON .commit files (1.x Avro timeline files are not parsed); DynamoDB scanRate is accepted but never throttles (no RCU accounting); CRAWL_EVENT_MODE is not event driven; Delta native-table and DEPRECATED_BY_CRAWLER parameter names are best-effort (not verified against a real catalog)." + - "Crawler gaps: JDBC engines are postgresql/mysql/redshift only; CatalogTargets recrawl existing S3, DynamoDB and Delta tables only (DeleteBehavior is always LOG per the console docs; EventQueueArn/DlqEventQueueArn are validated and stored but no SQS events are consumed; Iceberg/Hudi-backed catalog tables are rejected, matching the documented S3/DynamoDB/Delta-only rule); MongoDB crawls sample or scan through the Go driver against the connection's CONNECTION_URL (table name is _, _id renders as struct; the naming and the oid shape are best-effort, not verified against a real catalog); DynamoDB scanRate is accepted but never throttles (no RCU accounting); CRAWL_EVENT_MODE is not event driven; Delta native-table and DEPRECATED_BY_CRAWLER parameter names are best-effort (not verified against a real catalog)." - "PublishDataQualityResultV2 members with no read path in glue@v1.170.0 are accepted and not stored: RunConfiguration, Statistics, Telemetry, DataQualitySecurityConfiguration. Published results carry no DataSource/ProfileId, so a ListDataQualityResultsV2 DataSource filter matches nothing and GetDataQualityResultV2 omits both." + - "Interactive sessions run statements only with --glue-engine=docker (GLUE_ENGINE, or --engines=docker): CreateSession then starts a long-lived container (public.ecr.aws/glue/aws-glue-libs image per GlueVersion, or GLUE_ENGINE_IMAGE) holding a persistent Python REPL (pyspark/GlueContext preloaded for glueetl/gluestreaming). With the engine disabled (default) RunStatement on a READY session returns a statement in ERROR whose Output.ErrorValue says how to enable it; no output is fabricated. Not modeled even with the engine: the session Role's credentials and S3 endpoint wiring inside the container, WorkerType/NumberOfWorkers resource limits (recorded only), Connections, SecurityConfiguration, Session Progress/DPUSeconds/ExecutionTime/ProfileName, and the default AWS Glue image tags are not pull-verified in CI. Interpreter state does not survive a gopherstack restart: restored READY sessions fail on the next RunStatement, restored queued/running statements become ERROR." structural_gaps: - "StartMaterializedViewRefreshTaskRun.FullRefresh and the RequestOrigin members on session/statement ops have no corresponding output field in glue@v1.157.0, so nothing observable exists to emulate; ClientToken on Put*/Update*/Associate*/Disassociate* ops is already idempotent by key." - "No backing engine or data: Lake Formation cell/row filtering (GetUnfiltered*Metadata) and catalog federation; GetDataQualityResult metrics/rule results and StartDataQualityRulesetEvaluationRun evaluation; ListConnectionTypes DisplayName/LogoUrl/Vendor (no connector catalog); GetPlan Location/Sinks (code generation); GetEntityRecords ConnectionOptions/SelectedFields (real connectors); FindMatchesTaskRunProperties JobId/JobName/JobRunId (no transform job)." @@ -198,6 +199,10 @@ leaks: {status: clean, note: "backend_reconciler.go's managed goroutine (StartRe ## Notes +### 2026-10-29: catalog, MongoDB, SECRET_ID, Delta checkpoint and Hudi 1.x crawls (gopherstack-49qct.54) + +CatalogTargets recrawl existing tables in place (crawler_catalog.go; never create, DeleteBehavior LOG); MongoDBTargets use go.mongodb.org/mongo-driver/v2 (crawler_mongodb.go, docker-gated mongo:5.0 test); JDBC/Mongo SECRET_ID resolves username/password JSON from Secrets Manager (CrawlerSecrets, wired in internal/app); Delta crawls replay through pkgs/deltalog (checkpoint parquet plus later commits); Hudi reads .hoodie/timeline instants including Avro container commits. + ### 2026-10-25: crawlers crawl every implemented target kind (gopherstack-49qct.54) crawl_run.go owns one crawl: every target funnels tables through one upsert so SchemaChangePolicy (UpdateBehavior LOG, DeleteBehavior LOG/DELETE_FROM_DATABASE/DEPRECATE_IN_DATABASE), Configuration AddOrUpdateBehavior=MergeNewColumns and RecrawlPolicy CRAWL_NEW_FOLDERS_ONLY (S3) apply uniformly, and tables are only counted updated when something changed. Targets: S3 (existing, now fails the crawl on list/read errors), DynamoDB (crawler_dynamodb.go: DescribeTable + Scan of the in-process DynamoDB backend, scanAll scans everything, otherwise a 1000-item sample), Delta (crawler_delta.go: _delta_log JSON replay, symlink-manifest table, manifests written only with WriteManifest, CreateNativeDeltaTable gives table_type=DELTA), Iceberg (crawler_iceberg.go: metadata folder discovery to MaximumTraversalDepth, version-hint.text or highest metadata.json, table_type=ICEBERG + metadata_location), Hudi (crawler_hudi.go: .hoodie/hoodie.properties, MOR gives _ro and _rt tables), JDBC (crawler_jdbc.go: information_schema over pgx/mysql drivers via the Connection's JDBC_CONNECTION_URL). A failed crawl ends the crawler READY with LastCrawl.Status FAILED and ErrorMessage, ListCrawls state FAILED. Also fixed: ListCrawls returned StartedOn/CompletedOn but CrawlerHistory (glue@v1.169.0) is StartTime/EndTime plus ErrorMessage/LogGroup/LogStream/MessagePrefix. Proof: crawler_targets_sdk_test.go (glue SDK client over in-process S3/DynamoDB), crawler_jdbc_test.go (postgres:16 and mysql:8.4 containers, GOPHERSTACK_TEST_DOCKER=1). @@ -2761,3 +2766,7 @@ BatchCreatePartition/CreatePartition reject a partition whose value count differ ## 2026-10-26 (Data Quality V2 results) PublishDataQualityResultV2, GetDataQualityResultV2, BatchGetDataQualityResultV2 and ListDataQualityResultsV2 (added by the glue@v1.170.0 bump) are real: Publish assigns a `dqresult-` ResultId and ResultPublishedOn, requires Dimensions/Score/RuleResults/EvaluationContext, validates rule Result against PASS/FAIL/ERROR, replays a repeated ClientToken to the original ResultId, and mirrors ResultId/Score into the V1 result table so GetDataQualityResult/ListDataQualityResults/BatchGetDataQualityResult see it. The V2 reads also surface V1-only results with just ResultId and Score. List filters on EvaluationContext, Dimensions (subset), PublishedAfter/Before, ordered by publish time then ResultId; a missing Get is EntityNotFoundException. Proof: `TestRealClient_DataQualityResultsV2` (data_quality_results_v2.go, persisted as the `dataQualityResultV2` table). + +### 2026-10-28: interactive session engine (gopherstack-49qct.65) + +RunStatement used to append a WAITING statement that nothing advanced. Sessions now have an engine (session_engine.go, session_docker.go, session_repl_py.txt). Lifecycle: CreateSession PROVISIONING, interpreter container started, READY; Timeout (default 2880 min) and IdleTimeout (default Timeout, minutes) move a READY session to TIMEOUT and stop the container; StopSession STOPPING then STOPPED once the container is gone; DeleteSession, Reset and Close stop containers. RunStatement on a session that is not READY returns IllegalSessionStateException (RunStatement error list, API_RunStatement.html). Statements run one at a time in order: WAITING, RUNNING, AVAILABLE or ERROR with Output{Status, Data.TextPlain, ExecutionCount, ErrorName, ErrorValue, Traceback}; CancelStatement cancels WAITING directly and interrupts RUNNING (CANCELLING then CANCELLED). StatementOutput.Status is the StatementState enum (API_StatementOutput.html), so success is AVAILABLE, not "ok". Statements whose code raises end in ERROR (Statement.State enum includes ERROR; whether real Glue uses ERROR or AVAILABLE with an error output is unverified). diff --git a/services/glue/README.md b/services/glue/README.md index b38a14fdfb..d44fe94c68 100644 --- a/services/glue/README.md +++ b/services/glue/README.md @@ -9,7 +9,7 @@ | --- | --- | | PARITY entries audited | 59 (58 ok, 1 partial) | | Feature families | 29 (24 ok, 5 partial) | -| Known gaps | 4 | +| Known gaps | 5 | | Structural gaps (can't be emulated) | 5 | | Deferred items | 2 | | Resource leaks | clean | @@ -18,8 +18,9 @@ - Operations added by the SDK bump and not yet implemented (listed in sdk_completeness_test.go notImplemented): GetSystemLogsForJobRun, GetSystemLogsForSession. Both return a SystemLogsUrl into AWS-managed Spark driver/executor system logs; this emulator runs no Spark and keeps no such logs, so any URL would be fabricated (UNVERIFIABLE: no data source exists). - Request members accepted and ignored with no determinable effect: Tags on CreateColumnStatisticsTaskSettings/CreateIntegrationResourceProperty/RegisterConnectionType/CreateCustomEntityType (the Glue ARN guide lists no ARN for them); UpdateTable VersionId/Force/ViewUpdateAction (UpdateTable API reference names no mismatch behavior); ModifyIntegration.IntegrationName (ARN is name-keyed); Get/ResetJobBookmark.RunId (one bookmark per job; per-run semantics undocumented); GetConnection.ApplyOverrideForComputeEnvironment; GetTable/GetTables/SearchTables IncludeStatusDetails; GetDatabases/SearchTables ResourceShareType (no cross-account shares). -- Crawler targets not crawled: CatalogTargets (re-crawling existing catalog tables; needs Data Catalog event/DLQ semantics for Iceberg/Delta variants) and MongoDBTargets (no MongoDB/DocumentDB wire driver in go.mod). A crawl with either target stores them and produces no tables. Crawler gaps in the implemented targets: JDBC SECRET_ID auth is not resolved (PASSWORD only; SECRET_ID fails the crawl), JDBC engines are postgresql/mysql/redshift only; Delta checkpoint parquet files are not read (JSON commits only; WriteManifest needs the log to start at commit 0); Hudi schema is read from hoodie.table.create.schema or JSON .commit files (1.x Avro timeline files are not parsed); DynamoDB scanRate is accepted but never throttles (no RCU accounting); CRAWL_EVENT_MODE is not event driven; Delta native-table and DEPRECATED_BY_CRAWLER parameter names are best-effort (not verified against a real catalog). +- Crawler gaps: JDBC engines are postgresql/mysql/redshift only; CatalogTargets recrawl existing S3, DynamoDB and Delta tables only (DeleteBehavior is always LOG per the console docs; EventQueueArn/DlqEventQueueArn are validated and stored but no SQS events are consumed; Iceberg/Hudi-backed catalog tables are rejected, matching the documented S3/DynamoDB/Delta-only rule); MongoDB crawls sample or scan through the Go driver against the connection's CONNECTION_URL (table name is _, _id renders as struct; the naming and the oid shape are best-effort, not verified against a real catalog); DynamoDB scanRate is accepted but never throttles (no RCU accounting); CRAWL_EVENT_MODE is not event driven; Delta native-table and DEPRECATED_BY_CRAWLER parameter names are best-effort (not verified against a real catalog). - PublishDataQualityResultV2 members with no read path in glue@v1.170.0 are accepted and not stored: RunConfiguration, Statistics, Telemetry, DataQualitySecurityConfiguration. Published results carry no DataSource/ProfileId, so a ListDataQualityResultsV2 DataSource filter matches nothing and GetDataQualityResultV2 omits both. +- Interactive sessions run statements only with --glue-engine=docker (GLUE_ENGINE, or --engines=docker): CreateSession then starts a long-lived container (public.ecr.aws/glue/aws-glue-libs image per GlueVersion, or GLUE_ENGINE_IMAGE) holding a persistent Python REPL (pyspark/GlueContext preloaded for glueetl/gluestreaming). With the engine disabled (default) RunStatement on a READY session returns a statement in ERROR whose Output.ErrorValue says how to enable it; no output is fabricated. Not modeled even with the engine: the session Role's credentials and S3 endpoint wiring inside the container, WorkerType/NumberOfWorkers resource limits (recorded only), Connections, SecurityConfiguration, Session Progress/DPUSeconds/ExecutionTime/ProfileName, and the default AWS Glue image tags are not pull-verified in CI. Interpreter state does not survive a gopherstack restart: restored READY sessions fail on the next RunStatement, restored queued/running statements become ERROR. ### Structural gaps diff --git a/services/glue/crawl_run.go b/services/glue/crawl_run.go index 07a7abd216..830955d049 100644 --- a/services/glue/crawl_run.go +++ b/services/glue/crawl_run.go @@ -29,6 +29,8 @@ const ( paramClassification = "classification" paramTypeOfData = "typeOfData" + typeOfDataTable = "table" + paramConnectionName = "connectionName" typeSmallint = "smallint" typeTinyint = "tinyint" typeTimestamp = "timestamp" @@ -48,6 +50,7 @@ type crawlStats struct { type crawlRun struct { s3 CrawlerS3 ddb CrawlerDynamoDB + secrets CrawlerSecrets b *InMemoryBackend c *Crawler conns map[string]*Connection @@ -58,6 +61,7 @@ type crawlRun struct { ops []func(*crawlRun) stats crawlStats logOnlyUpdates bool + catalogMode bool mergeColumns bool newFoldersOnly bool } @@ -89,6 +93,7 @@ func newCrawlRun(c *Crawler) *crawlRun { } r.mergeColumns = configuredMergeNewColumns(c.Configuration) + r.catalogMode = len(c.Targets.CatalogTargets) > 0 return r } @@ -97,17 +102,35 @@ func newCrawlRun(c *Crawler) *crawlRun { // its I/O can run unlocked. b.mu must be held. func (b *InMemoryBackend) snapshotCrawlLocked(c *Crawler) *crawlRun { r := newCrawlRun(cloneCrawler(c)) - r.s3, r.ddb = b.crawlerS3, b.crawlerDDB + r.s3, r.ddb, r.secrets = b.crawlerS3, b.crawlerDDB, b.crawlerSecrets r.accountID, r.region = b.accountID, b.region r.conns = map[string]*Connection{} r.tables = tableOverlay{} + for _, ct := range c.Targets.CatalogTargets { + if conn, ok := b.connections.Get(ct.ConnectionName); ok && ct.ConnectionName != "" { + r.conns[ct.ConnectionName] = cloneConnection(conn) + } + + for _, name := range ct.Tables { + if t, ok := b.tables.Get(tableKey(ct.DatabaseName, name)); ok { + r.tables.Put(cloneTable(t)) + } + } + } + for _, jt := range c.Targets.JdbcTargets { if conn, ok := b.connections.Get(jt.ConnectionName); ok { r.conns[jt.ConnectionName] = cloneConnection(conn) } } + for _, mt := range c.Targets.MongoDBTargets { + if conn, ok := b.connections.Get(mt.ConnectionName); ok { + r.conns[mt.ConnectionName] = cloneConnection(conn) + } + } + for _, t := range b.tables.All() { if strs.Equal(t.DatabaseName, c.DatabaseName) { r.tables.Put(cloneTable(t)) @@ -170,41 +193,32 @@ func configuredMergeNewColumns(cfg string) bool { return doc.CrawlerOutput.Tables.AddOrUpdateBehavior == mergeNewColumns } -func (r *crawlRun) crawlAllTargets() error { - t := r.c.Targets - - for _, s3t := range t.S3Targets { - if err := r.crawlS3(s3t); err != nil { +func crawlEach[T any](targets []T, crawl func(T) error) error { + for _, t := range targets { + if err := crawl(t); err != nil { return err } } - for _, dt := range t.DynamoDBTargets { - if err := r.crawlDynamoDB(dt); err != nil { - return err - } - } - - for _, dt := range t.DeltaTargets { - if err := r.crawlDelta(dt); err != nil { - return err - } - } + return nil +} - for _, it := range t.IcebergTargets { - if err := r.crawlIceberg(it); err != nil { - return err - } - } +func (r *crawlRun) crawlAllTargets() error { + t := r.c.Targets - for _, ht := range t.HudiTargets { - if err := r.crawlHudi(ht); err != nil { - return err - } + steps := []func() error{ + func() error { return crawlEach(t.S3Targets, r.crawlS3) }, + func() error { return crawlEach(t.CatalogTargets, r.crawlCatalog) }, + func() error { return crawlEach(t.DynamoDBTargets, r.crawlDynamoDB) }, + func() error { return crawlEach(t.DeltaTargets, r.crawlDelta) }, + func() error { return crawlEach(t.IcebergTargets, r.crawlIceberg) }, + func() error { return crawlEach(t.HudiTargets, r.crawlHudi) }, + func() error { return crawlEach(t.JdbcTargets, r.crawlJDBC) }, + func() error { return crawlEach(t.MongoDBTargets, r.crawlMongoDB) }, } - for _, jt := range t.JdbcTargets { - if err := r.crawlJDBC(jt); err != nil { + for _, step := range steps { + if err := step(); err != nil { return err } } @@ -244,7 +258,7 @@ func (r *crawlRun) upsertCore(st tableStore, version func(*Table), desired *Tabl r.stats.added++ return desired - case cur.Parameters[crawlerManagedParam] != r.c.Name: + case !r.catalogMode && cur.Parameters[crawlerManagedParam] != r.c.Name: return nil } @@ -304,7 +318,7 @@ func (r *crawlRun) sweepGoneTables() { behavior = r.c.SchemaChangePolicy.DeleteBehavior } - if behavior == deleteBehaviorLog { + if behavior == deleteBehaviorLog || r.catalogMode { return } diff --git a/services/glue/crawler_catalog.go b/services/glue/crawler_catalog.go new file mode 100644 index 0000000000..f784cf3927 --- /dev/null +++ b/services/glue/crawler_catalog.go @@ -0,0 +1,242 @@ +package glue + +import ( + "context" + "errors" + "fmt" + "maps" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +var ( + errCatalogNoTable = errors.New("catalog table not found") + errCatalogStore = errors.New("catalog table is not backed by Amazon S3, Amazon DynamoDB or Delta Lake") + errCatalogNoConn = errors.New("catalog target connection not found") + errCatalogMix = errors.New("catalog targets cannot be combined with other target types") + errCatalogTarget = errors.New("invalid catalog target") + errCatalogDeltaNative = errors.New("delta table location is not an S3 path") +) + +func validateCrawlerTargets(t CrawlerTarget) error { + if len(t.CatalogTargets) == 0 { + return nil + } + + if len(t.S3Targets)+len(t.JdbcTargets)+len(t.DynamoDBTargets)+len(t.DeltaTargets)+ + len(t.HudiTargets)+len(t.IcebergTargets)+len(t.MongoDBTargets) > 0 { + return fmt.Errorf("%w: %w", ErrValidation, errCatalogMix) + } + + for _, ct := range t.CatalogTargets { + switch { + case ct.DatabaseName == "" || len(ct.Tables) == 0: + return fmt.Errorf( + "%w: %w: DatabaseName and at least one table are required", + ErrValidation, + errCatalogTarget, + ) + case ct.EventQueueArn != "" && !isSQSARN(ct.EventQueueArn): + return fmt.Errorf( + "%w: %w: EventQueueArn %q is not an SQS ARN", + ErrValidation, + errCatalogTarget, + ct.EventQueueArn, + ) + case ct.DlqEventQueueArn != "" && !isSQSARN(ct.DlqEventQueueArn): + return fmt.Errorf( + "%w: %w: DlqEventQueueArn %q is not an SQS ARN", + ErrValidation, + errCatalogTarget, + ct.DlqEventQueueArn, + ) + case ct.DlqEventQueueArn != "" && ct.EventQueueArn == "": + return fmt.Errorf("%w: %w: DlqEventQueueArn requires EventQueueArn", ErrValidation, errCatalogTarget) + } + } + + return nil +} + +func isSQSARN(s string) bool { + parts := strings.Split(s, ":") + + return len(parts) == 6 && parts[0] == "arn" && parts[2] == "sqs" && parts[5] != "" +} + +func (r *crawlRun) crawlCatalog(ct CatalogTarget) error { + if ct.ConnectionName != "" { + if _, ok := r.conns[ct.ConnectionName]; !ok { + return fmt.Errorf("%w: %q", errCatalogNoConn, ct.ConnectionName) + } + } + + for _, name := range ct.Tables { + cur, ok := r.tables.Get(tableKey(ct.DatabaseName, name)) + if !ok { + return fmt.Errorf("%w: %s.%s", errCatalogNoTable, ct.DatabaseName, name) + } + + var err error + + switch { + case isDeltaCatalogTable(cur): + err = r.recrawlDelta(cur) + case isDDBCatalogTable(cur): + err = r.recrawlDynamoDB(cur) + case strings.HasPrefix(cur.StorageDescriptor.Location, "s3://"): + err = r.recrawlS3(cur) + default: + err = fmt.Errorf("%w: %s.%s", errCatalogStore, ct.DatabaseName, name) + } + + if err != nil { + return fmt.Errorf("catalog table %s.%s: %w", ct.DatabaseName, name, err) + } + } + + return nil +} + +func isDeltaCatalogTable(t *Table) bool { + return strings.EqualFold(t.Parameters["table_type"], "delta") || + t.StorageDescriptor.InputFormat == symlinkInputFormat || + strings.Contains(t.StorageDescriptor.Location, symlinkDir) +} + +func isDDBCatalogTable(t *Table) bool { + return t.StorageDescriptor.InputFormat == ddbInputFormat || + strings.EqualFold(t.Parameters[paramClassification], ddbClassification) +} + +// applyCatalogTable rewrites an existing catalog table's schema in place; the +// table's storage and format settings stay as the user defined them. +func (r *crawlRun) applyCatalogTable(cur *Table, cols, pkeys []Column) *Table { + desired := cloneTable(cur) + desired.StorageDescriptor.Columns = keepColumnMeta(cur.StorageDescriptor.Columns, cols) + + if pkeys != nil { + desired.PartitionKeys = keepColumnMeta(cur.PartitionKeys, pkeys) + } + + desired.Parameters = mapsWith(cur.Parameters, crawlerManagedParam, r.c.Name) + + return r.upsert(desired) +} + +func keepColumnMeta(old, fresh []Column) []Column { + out := make([]Column, len(fresh)) + + for i, c := range fresh { + out[i] = c + + for _, o := range old { + if strs.Equal(o.Name, c.Name) { + out[i].Comment, out[i].Parameters = o.Comment, o.Parameters + + break + } + } + } + + return out +} + +func (r *crawlRun) recrawlS3(cur *Table) error { + if r.s3 == nil { + return errLakeNoS3 + } + + bucket, prefix, ok := splitCrawlS3URI(cur.StorageDescriptor.Location) + if !ok { + return fmt.Errorf("%w %q", errInvalidCrawlPath, cur.StorageDescriptor.Location) + } + + ctx, cancel := context.WithTimeout(context.Background(), crawlerS3Timeout) + defer cancel() + + all, err := r.listCrawlFiles(ctx, bucket, prefix, nil) + if err != nil { + return fmt.Errorf("list s3://%s/%s: %w", bucket, prefix, err) + } + + if len(all) == 0 { + r.markSeen(tableKey(cur.DatabaseName, cur.Name)) + + return nil + } + + files := mostCommonDepth(all) + + sample, err := r.readSample(ctx, bucket, files[0].key) + if err != nil { + return fmt.Errorf("read s3://%s/%s: %w", bucket, files[0].key, err) + } + + pkeys := partitionKeysFor(files) + if len(cur.PartitionKeys) == len(pkeys) && len(pkeys) > 0 { + pkeys = cur.PartitionKeys + } + + if tbl := r.applyCatalogTable(cur, inferFormat(files[0].key, sample).columns, pkeys); tbl != nil { + r.crawlPartitions(tbl, bucket, prefix, files) + } + + return nil +} + +func (r *crawlRun) recrawlDelta(cur *Table) error { + ctx, cancel, err := r.lakeContext() + if err != nil { + return err + } + defer cancel() + + loc := strings.TrimSuffix(cur.StorageDescriptor.Location, "/") + loc = strings.TrimSuffix(loc, "/"+symlinkDir) + + bucket, prefix, ok := splitCrawlS3URI(loc) + if !ok { + return fmt.Errorf("%w: %q", errCatalogDeltaNative, cur.StorageDescriptor.Location) + } + + st, err := r.loadDeltaLog(ctx, bucket, prefix) + if err != nil { + return err + } + + cols, pkeys := moveToPartitionKeys(st.cols, st.parts) + tbl := r.applyCatalogTable(cur, cols, pkeys) + + if tbl != nil && strings.Contains(cur.StorageDescriptor.Location, symlinkDir) { + r.addDeltaPartitions(tbl, strings.TrimSuffix(cur.StorageDescriptor.Location, "/")+"/", st, pkeys) + } + + return nil +} + +func (r *crawlRun) recrawlDynamoDB(cur *Table) error { + if r.ddb == nil { + return fmt.Errorf("%w: %q", errDDBUnavailable, cur.StorageDescriptor.Location) + } + + ctx, cancel := context.WithTimeout(awsmeta.WithRegion(context.Background(), r.region), crawlerS3Timeout) + defer cancel() + + cols, params, err := r.describeDDBTable(ctx, DynamoDBTarget{Path: cur.StorageDescriptor.Location, ScanAll: true}) + if err != nil { + return err + } + + merged := mapsWith(cur.Parameters, crawlerManagedParam, r.c.Name) + maps.Copy(merged, params) + + desired := cloneTable(cur) + desired.StorageDescriptor.Columns = keepColumnMeta(cur.StorageDescriptor.Columns, cols) + desired.Parameters = merged + r.upsert(desired) + + return nil +} diff --git a/services/glue/crawler_catalog_target_test.go b/services/glue/crawler_catalog_target_test.go new file mode 100644 index 0000000000..7720cf2097 --- /dev/null +++ b/services/glue/crawler_catalog_target_test.go @@ -0,0 +1,291 @@ +package glue_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + gluesdk "github.com/aws/aws-sdk-go-v2/service/glue" + gluetypes "github.com/aws/aws-sdk-go-v2/service/glue/types" + "github.com/aws/smithy-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (e *crawlEnv) createTable(name, location string, params map[string]string, cols []gluetypes.Column, + pkeys []gluetypes.Column, +) { + e.t.Helper() + + _, err := e.client.CreateTable(e.t.Context(), &gluesdk.CreateTableInput{ + DatabaseName: aws.String("db"), + TableInput: &gluetypes.TableInput{ + Name: aws.String(name), Parameters: params, PartitionKeys: pkeys, + StorageDescriptor: &gluetypes.StorageDescriptor{Location: aws.String(location), Columns: cols}, + }, + }) + require.NoError(e.t, err) +} + +func strCol(n string) gluetypes.Column { + return gluetypes.Column{Name: aws.String(n), Type: aws.String("string")} +} + +func catalogTargets(tables ...string) *gluetypes.CrawlerTargets { + return &gluetypes.CrawlerTargets{CatalogTargets: []gluetypes.CatalogTarget{{ + DatabaseName: aws.String("db"), Tables: tables, + }}} +} + +func TestCrawler_CatalogTarget(t *testing.T) { + t.Parallel() + + salesObjs := map[string]string{ + "sales/year=2024/a.csv": "id,name\n1,a\n", + "sales/year=2025/a.csv": "id,name\n2,b\n", + } + + tests := []struct { + check func(t *testing.T, env *crawlEnv) + setup func(env *crawlEnv) + mut func(in *gluesdk.CreateCrawlerInput) + objs map[string]string + name string + tables []string + }{ + { + name: "s3 table schema and partitions", objs: salesObjs, tables: []string{"sales"}, + setup: func(env *crawlEnv) { + env.createTable( + "sales", + "s3://lake/sales/", + nil, + []gluetypes.Column{strCol("old")}, + []gluetypes.Column{strCol("year")}, + ) + }, + check: func(t *testing.T, env *crawlEnv) { + t.Helper() + + tbl := env.table("sales") + assert.Equal(t, []string{"id:bigint", "name:string"}, colSpec(tbl.StorageDescriptor.Columns)) + assert.Equal(t, []string{"year:string"}, colSpec(tbl.PartitionKeys)) + assert.Equal(t, "cr", tbl.Parameters["UPDATED_BY_CRAWLER"]) + assert.Equal(t, [][]string{{"2024"}, {"2025"}}, env.partitionValues("sales")) + assert.Equal(t, int32(1), env.metrics().TablesUpdated) + assert.Equal(t, int32(0), env.metrics().TablesCreated) + }, + }, + { + name: "update behavior log keeps schema but adds partitions", objs: salesObjs, tables: []string{"sales"}, + mut: func(in *gluesdk.CreateCrawlerInput) { + in.SchemaChangePolicy = &gluetypes.SchemaChangePolicy{ + UpdateBehavior: gluetypes.UpdateBehaviorLog, DeleteBehavior: gluetypes.DeleteBehaviorLog, + } + }, + setup: func(env *crawlEnv) { + env.createTable( + "sales", + "s3://lake/sales/", + nil, + []gluetypes.Column{strCol("old")}, + []gluetypes.Column{strCol("year")}, + ) + }, + check: func(t *testing.T, env *crawlEnv) { + t.Helper() + + assert.Equal(t, []string{"old:string"}, colSpec(env.table("sales").StorageDescriptor.Columns)) + assert.Equal(t, [][]string{{"2024"}, {"2025"}}, env.partitionValues("sales")) + }, + }, + { + name: "table without data is kept despite delete behavior", tables: []string{"gone"}, + mut: func(in *gluesdk.CreateCrawlerInput) { + in.SchemaChangePolicy = &gluetypes.SchemaChangePolicy{ + UpdateBehavior: gluetypes.UpdateBehaviorUpdateInDatabase, + DeleteBehavior: gluetypes.DeleteBehaviorDeleteFromDatabase, + } + }, + setup: func(env *crawlEnv) { + env.createTable("gone", "s3://lake/gone/", map[string]string{"UPDATED_BY_CRAWLER": "cr"}, + []gluetypes.Column{strCol("c")}, nil) + }, + check: func(t *testing.T, env *crawlEnv) { + t.Helper() + + assert.True(t, env.hasTable("gone")) + assert.Equal(t, int32(0), env.metrics().TablesDeleted) + }, + }, + { + name: "delta table", tables: []string{"events"}, + objs: map[string]string{ + "events/_delta_log/00000000000000000000.json": deltaCommit0(), + "events/_delta_log/00000000000000000001.json": deltaCommit1(), + }, + setup: func(env *crawlEnv) { + env.createTable("events", "s3://lake/events/", map[string]string{"table_type": "DELTA"}, + []gluetypes.Column{strCol("old")}, nil) + }, + check: func(t *testing.T, env *crawlEnv) { + t.Helper() + + tbl := env.table("events") + assert.Equal( + t, + []string{"id:bigint", "name:string", "tags:array"}, + colSpec(tbl.StorageDescriptor.Columns), + ) + assert.Equal(t, []string{"year:int"}, colSpec(tbl.PartitionKeys)) + }, + }, + { + name: "dynamodb table", tables: []string{"orders"}, + setup: func(env *crawlEnv) { + env.seedDynamoDB(env.t.Context()) + env.createTable("orders", "orders", map[string]string{"classification": "dynamodb"}, + []gluetypes.Column{strCol("old")}, nil) + }, + check: func(t *testing.T, env *crawlEnv) { + t.Helper() + + cols := colSpec(env.table("orders").StorageDescriptor.Columns) + assert.Equal(t, "id:bigint", cols[0]) + assert.Contains(t, cols, "name:string") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + env.put(tt.objs) + tt.setup(env) + env.create(catalogTargets(tt.tables...), tt.mut) + + c := env.crawl() + require.Equal( + t, + gluetypes.LastCrawlStatusSucceeded, + c.LastCrawl.Status, + aws.ToString(c.LastCrawl.ErrorMessage), + ) + tt.check(t, env) + }) + } +} + +func TestCrawler_CatalogTargetFailures(t *testing.T) { + t.Parallel() + + tests := []struct { + setup func(env *crawlEnv) + targets *gluetypes.CrawlerTargets + name string + want string + }{ + {name: "missing table", targets: catalogTargets("nope"), want: "catalog table not found: db.nope"}, + { + name: "unsupported store", targets: catalogTargets("pg"), want: "not backed by Amazon S3", + setup: func(env *crawlEnv) { + env.createTable("pg", "appdb.public.t", nil, []gluetypes.Column{strCol("c")}, nil) + }, + }, + { + name: "missing connection", want: "connection not found", + targets: &gluetypes.CrawlerTargets{CatalogTargets: []gluetypes.CatalogTarget{{ + DatabaseName: aws.String("db"), Tables: []string{"t"}, ConnectionName: aws.String("net"), + }}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + if tt.setup != nil { + tt.setup(env) + } + + env.create(tt.targets, nil) + + c := env.crawl() + assert.Equal(t, gluetypes.LastCrawlStatusFailed, c.LastCrawl.Status) + assert.Contains(t, aws.ToString(c.LastCrawl.ErrorMessage), tt.want) + }) + } +} + +func TestCrawler_CatalogTargetValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + targets *gluetypes.CrawlerTargets + name string + }{ + { + name: "mixed with s3", + targets: &gluetypes.CrawlerTargets{ + CatalogTargets: []gluetypes.CatalogTarget{{DatabaseName: aws.String("db"), Tables: []string{"t"}}}, + S3Targets: []gluetypes.S3Target{{Path: aws.String("s3://lake/x")}}, + }, + }, + { + name: "bad queue arn", + targets: &gluetypes.CrawlerTargets{CatalogTargets: []gluetypes.CatalogTarget{{ + DatabaseName: aws.String("db"), Tables: []string{"t"}, EventQueueArn: aws.String("not-an-arn"), + }}}, + }, + { + name: "dlq without queue", + targets: &gluetypes.CrawlerTargets{CatalogTargets: []gluetypes.CatalogTarget{{ + DatabaseName: aws.String("db"), Tables: []string{"t"}, + DlqEventQueueArn: aws.String("arn:aws:sqs:us-east-1:000000000000:dlq"), + }}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + _, err := env.client.CreateCrawler(t.Context(), &gluesdk.CreateCrawlerInput{ + Name: aws.String( + crawlerName, + ), + Role: aws.String(crawlRole), + DatabaseName: aws.String("db"), + Targets: tt.targets, + }) + + var apiErr smithy.APIError + + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "InvalidInputException", apiErr.ErrorCode()) + }) + } +} + +func TestCrawler_CatalogTargetRoundTrip(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + env.create(&gluetypes.CrawlerTargets{CatalogTargets: []gluetypes.CatalogTarget{{ + DatabaseName: aws.String("db"), Tables: []string{"t"}, ConnectionName: aws.String("net"), + EventQueueArn: aws.String("arn:aws:sqs:us-east-1:000000000000:q"), + DlqEventQueueArn: aws.String("arn:aws:sqs:us-east-1:000000000000:dlq"), + }}}, nil) + + out, err := env.client.GetCrawler(t.Context(), &gluesdk.GetCrawlerInput{Name: aws.String(crawlerName)}) + require.NoError(t, err) + require.Len(t, out.Crawler.Targets.CatalogTargets, 1) + + ct := out.Crawler.Targets.CatalogTargets[0] + assert.Equal(t, "net", aws.ToString(ct.ConnectionName)) + assert.Equal(t, "arn:aws:sqs:us-east-1:000000000000:q", aws.ToString(ct.EventQueueArn)) + assert.Equal(t, "arn:aws:sqs:us-east-1:000000000000:dlq", aws.ToString(ct.DlqEventQueueArn)) +} diff --git a/services/glue/crawler_delta.go b/services/glue/crawler_delta.go index c399ca7b5b..e0eed2eaf6 100644 --- a/services/glue/crawler_delta.go +++ b/services/glue/crawler_delta.go @@ -1,30 +1,19 @@ package glue import ( - "bufio" - "bytes" "context" - "encoding/json" "errors" "fmt" "maps" - "path" - "regexp" - "slices" "sort" - "strconv" "strings" "time" + "github.com/blackbirdworks/gopherstack/pkgs/deltalog" "github.com/blackbirdworks/gopherstack/pkgs/strs" ) const ( - deltaInteger = "integer" - deltaShort = "short" - deltaByte = "byte" - deltaTimestampNTZ = "timestamp_ntz" - deltaLogDir = "_delta_log" symlinkDir = "_symlink_format_manifest" symlinkInputFormat = "org.apache.hadoop.hive.ql.io.SymlinkTextInputFormat" textOutputFormat = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat" @@ -33,18 +22,7 @@ const ( hiveNullPartition = "__HIVE_DEFAULT_PARTITION__" ) -var ( - errDeltaNoLog = errors.New("no Delta commits found") - errDeltaNoMetadata = errors.New("delta log carries no metaData action in its JSON commits") - errDeltaCheckpoint = errors.New("delta log starts at a checkpoint; manifest generation needs commit 0") - - deltaCommitRe = regexp.MustCompile(`^(\d{20})\.json$`) -) - -type deltaMeta struct { - schema string - partitions []string -} +var errDeltaNoLog = errors.New("no Delta commits found") type deltaFile struct { values map[string]string @@ -52,75 +30,59 @@ type deltaFile struct { } type deltaState struct { - files map[string]deltaFile - meta deltaMeta - hasMeta bool - firstVer int64 + files []deltaFile + cols []Column + parts []string } -func replayDeltaLog(commits map[int64][]byte) (*deltaState, error) { - if len(commits) == 0 { - return nil, errDeltaNoLog - } +type deltaS3 struct { + r *crawlRun + bucket string + base string +} - vers := make([]int64, 0, len(commits)) - for v := range commits { - vers = append(vers, v) +func (d deltaS3) List(ctx context.Context, prefix string) ([]string, error) { + keys, err := d.r.listKeys(ctx, d.bucket, d.base+prefix) + if err != nil { + return nil, err } - slices.Sort(vers) - - st := &deltaState{files: map[string]deltaFile{}, firstVer: vers[0]} - - for _, v := range vers { - sc := bufio.NewScanner(bytes.NewReader(commits[v])) - sc.Buffer(make([]byte, 0, lakeScanBuf), lakeMetaLimit) - - for sc.Scan() { - if err := st.apply(sc.Bytes()); err != nil { - return nil, fmt.Errorf("commit %020d: %w", v, err) - } - } + for i, k := range keys { + keys[i] = strings.TrimPrefix(k, d.base) } - if !st.hasMeta { - return nil, errDeltaNoMetadata - } + return keys, nil +} - return st, nil +func (d deltaS3) Get(ctx context.Context, key string) ([]byte, error) { + return d.r.readObject(ctx, d.bucket, d.base+key) } -func (st *deltaState) apply(line []byte) error { - if len(bytes.TrimSpace(line)) == 0 { - return nil +func (r *crawlRun) loadDeltaLog(ctx context.Context, bucket, prefix string) (*deltaState, error) { + base := "" + if prefix != "" { + base = strings.TrimSuffix(prefix, "/") + "/" } - var act struct { - MetaData *struct { - SchemaString string `json:"schemaString"` - PartitionColumns []string `json:"partitionColumns"` - } `json:"metaData"` - Add *struct { - PartitionValues map[string]*string `json:"partitionValues"` - Path string `json:"path"` - } `json:"add"` - Remove *struct { - Path string `json:"path"` - } `json:"remove"` + snap, err := deltalog.Load(ctx, deltaS3{r: r, bucket: bucket, base: base}) + if err != nil { + if errors.Is(err, deltalog.ErrNotDeltaTable) { + return nil, errDeltaNoLog + } + + return nil, err } - if err := json.Unmarshal(line, &act); err != nil { - return err + st := &deltaState{parts: snap.PartitionColumns} + + for _, c := range snap.Columns { + st.cols = append(st.cols, Column{Name: c.Name, Type: c.Type}) } - switch { - case act.MetaData != nil: - st.meta = deltaMeta{schema: act.MetaData.SchemaString, partitions: act.MetaData.PartitionColumns} - st.hasMeta = true - case act.Add != nil: - vals := map[string]string{} + for _, f := range snap.Files { + vals := make(map[string]string, len(f.PartitionValues)) - for k, v := range act.Add.PartitionValues { + for k, v := range f.PartitionValues { if v == nil { vals[k] = hiveNullPartition } else { @@ -128,82 +90,10 @@ func (st *deltaState) apply(line []byte) error { } } - st.files[act.Add.Path] = deltaFile{path: act.Add.Path, values: vals} - case act.Remove != nil: - delete(st.files, act.Remove.Path) + st.files = append(st.files, deltaFile{path: f.Path, values: vals}) } - return nil -} - -func deltaColumns(schemaString string) ([]Column, error) { - var schema struct { - Fields []struct { - Type any `json:"type"` - Name string `json:"name"` - } `json:"fields"` - } - - if err := json.Unmarshal([]byte(schemaString), &schema); err != nil { - return nil, fmt.Errorf("parse Delta schemaString: %w", err) - } - - cols := make([]Column, len(schema.Fields)) - for i, f := range schema.Fields { - cols[i] = Column{Name: f.Name, Type: deltaType(f.Type)} - } - - return cols, nil -} - -func deltaType(t any) string { - switch v := t.(type) { - case string: - return deltaPrimitive(v) - case map[string]any: - switch v["type"] { - case "struct": - fields, _ := v["fields"].([]any) - parts := make([]string, 0, len(fields)) - - for _, f := range fields { - fm, _ := f.(map[string]any) - name, _ := fm["name"].(string) - parts = append(parts, name+":"+deltaType(fm["type"])) - } - - return "struct<" + strings.Join(parts, ",") + ">" - case "array": - return "array<" + deltaType(v["elementType"]) + ">" - case avroMap: - return "map<" + deltaType(v["keyType"]) + "," + deltaType(v["valueType"]) + ">" - } - } - - return avroString -} - -func deltaPrimitive(t string) string { - switch t { - case avroLong: - return typeBigint - case deltaInteger: - return avroInt - case deltaShort: - return typeSmallint - case deltaByte: - return typeTinyint - case deltaTimestampNTZ: - return typeTimestamp - case avroString, avroFloat, avroDouble, avroBoolean, typeBinary, typeDate, typeTimestamp: - return t - default: - if strings.HasPrefix(t, "decimal") { - return t - } - - return avroString - } + return st, nil } func (r *crawlRun) crawlDelta(t DeltaTarget) error { @@ -222,35 +112,6 @@ func (r *crawlRun) crawlDelta(t DeltaTarget) error { return nil } -func (r *crawlRun) loadDeltaLog(ctx context.Context, bucket, prefix string) (*deltaState, error) { - keys, err := r.listKeys(ctx, bucket, joinKey(prefix, deltaLogDir)) - if err != nil { - return nil, err - } - - commits := map[int64][]byte{} - - for _, k := range keys { - _, file := splitKey(k) - - m := deltaCommitRe.FindStringSubmatch(file) - if m == nil || path.Dir(k) != joinKey(prefix, deltaLogDir) { - continue - } - - ver, _ := strconv.ParseInt(m[1], 10, 64) - - body, rerr := r.readObject(ctx, bucket, k) - if rerr != nil { - return nil, rerr - } - - commits[ver] = body - } - - return replayDeltaLog(commits) -} - func (r *crawlRun) crawlDeltaTable(ctx context.Context, t DeltaTarget, p string) error { bucket, prefix, ok := splitCrawlS3URI(p) if !ok { @@ -262,12 +123,7 @@ func (r *crawlRun) crawlDeltaTable(ctx context.Context, t DeltaTarget, p string) return err } - cols, err := deltaColumns(st.meta.schema) - if err != nil { - return err - } - - cols, pkeys := moveToPartitionKeys(cols, st.meta.partitions) + cols, pkeys := moveToPartitionKeys(st.cols, st.parts) root := "s3://" + bucket + "/" + prefix + "/" name := crawlTableName(r.c.TablePrefix, lastPathSegment(prefix)) params := map[string]string{paramClassification: classificationParq, crawlerManagedParam: r.c.Name} @@ -295,10 +151,6 @@ func (r *crawlRun) crawlDeltaTable(ctx context.Context, t DeltaTarget, p string) } if t.WriteManifest { - if st.firstVer != 0 { - return errDeltaCheckpoint - } - if err = r.writeDeltaManifests(ctx, bucket, prefix, st, pkeys); err != nil { return err } diff --git a/services/glue/crawler_delta_checkpoint_test.go b/services/glue/crawler_delta_checkpoint_test.go new file mode 100644 index 0000000000..220044a8c9 --- /dev/null +++ b/services/glue/crawler_delta_checkpoint_test.go @@ -0,0 +1,118 @@ +package glue_test + +import ( + "bytes" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + gluetypes "github.com/aws/aws-sdk-go-v2/service/glue/types" + "github.com/parquet-go/parquet-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type cpAdd struct { + PartitionValues map[string]string `parquet:"partitionValues"` + Path string `parquet:"path"` + Size int64 `parquet:"size"` +} + +type cpMeta struct { + Configuration map[string]string `parquet:"configuration"` + SchemaString string `parquet:"schemaString"` + PartitionColumns []string `parquet:"partitionColumns"` +} + +type cpProto struct { + MinReaderVersion int32 `parquet:"minReaderVersion"` +} + +type cpRow struct { + Add *cpAdd `parquet:"add,optional"` + MetaData *cpMeta `parquet:"metaData,optional"` + Protocol *cpProto `parquet:"protocol,optional"` +} + +func deltaCheckpoint(t *testing.T) string { + t.Helper() + + var buf bytes.Buffer + + w := parquet.NewGenericWriter[cpRow](&buf) + _, err := w.Write([]cpRow{ + {Protocol: &cpProto{MinReaderVersion: 1}}, + {MetaData: &cpMeta{ + SchemaString: deltaSchema, PartitionColumns: []string{"year"}, Configuration: map[string]string{}, + }}, + {Add: &cpAdd{Path: "year=2023/a.parquet", Size: 1, PartitionValues: map[string]string{"year": "2023"}}}, + {Add: &cpAdd{Path: "year=2024/b.parquet", Size: 1, PartitionValues: map[string]string{"year": "2024"}}}, + }) + require.NoError(t, err) + require.NoError(t, w.Close()) + + return buf.String() +} + +func TestCrawler_DeltaCheckpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + objs func(t *testing.T) map[string]string + name string + wantPart [][]string + }{ + { + name: "checkpoint pointer then later commit", + objs: func(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "events/_delta_log/00000000000000000010.checkpoint.parquet": deltaCheckpoint(t), + "events/_delta_log/_last_checkpoint": `{"version":10,"size":4}`, + "events/_delta_log/00000000000000000011.json": deltaCommit1(), + } + }, + wantPart: [][]string{{"2024"}, {"2025"}}, + }, + { + name: "checkpoint without pointer", + objs: func(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "events/_delta_log/00000000000000000010.checkpoint.parquet": deltaCheckpoint(t), + } + }, + wantPart: [][]string{{"2023"}, {"2024"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + env.put(tt.objs(t)) + env.create(&gluetypes.CrawlerTargets{DeltaTargets: []gluetypes.DeltaTarget{{ + DeltaTables: []string{"s3://lake/events"}, WriteManifest: aws.Bool(true), + }}}, nil) + + c := env.crawl() + require.Equal( + t, + gluetypes.LastCrawlStatusSucceeded, + c.LastCrawl.Status, + aws.ToString(c.LastCrawl.ErrorMessage), + ) + + tbl := env.table("events") + assert.Equal( + t, + []string{"id:bigint", "name:string", "tags:array"}, + colSpec(tbl.StorageDescriptor.Columns), + ) + assert.Equal(t, []string{"year:int"}, colSpec(tbl.PartitionKeys)) + assert.Equal(t, tt.wantPart, env.partitionValues("events")) + }) + } +} diff --git a/services/glue/crawler_dynamodb.go b/services/glue/crawler_dynamodb.go index a6889f112d..b6b8cbae95 100644 --- a/services/glue/crawler_dynamodb.go +++ b/services/glue/crawler_dynamodb.go @@ -220,24 +220,17 @@ func (r *crawlRun) scanDDBTypes( } } -func (r *crawlRun) crawlDynamoDB(t DynamoDBTarget) error { - if r.ddb == nil { - return fmt.Errorf("%w: %q", errDDBUnavailable, t.Path) - } - - ctx, cancel := context.WithTimeout(awsmeta.WithRegion(context.Background(), r.region), crawlerS3Timeout) - defer cancel() - +func (r *crawlRun) describeDDBTable(ctx context.Context, t DynamoDBTarget) ([]Column, map[string]string, error) { desc, err := r.ddb.DescribeTable(ctx, &sdk_ddb.DescribeTableInput{TableName: aws.String(t.Path)}) if err != nil { - return fmt.Errorf("describe dynamodb table %s: %w", t.Path, err) + return nil, nil, fmt.Errorf("describe dynamodb table %s: %w", t.Path, err) } tbl := desc.Table types := ddbKeyTypes(tbl.AttributeDefinitions) if err = r.scanDDBTypes(ctx, t.Path, t, types); err != nil { - return err + return nil, nil, err } keys := make([]string, 0, len(tbl.KeySchema)) @@ -262,11 +255,30 @@ func (r *crawlRun) crawlDynamoDB(t DynamoDBTarget) error { } params := map[string]string{ - paramClassification: ddbClassification, paramTypeOfData: "table", crawlerManagedParam: r.c.Name, "recordCount": strconv.FormatInt(aws.ToInt64(tbl.ItemCount), 10), "sizeKey": strconv.FormatInt(aws.ToInt64(tbl.TableSizeBytes), 10), } + return cols, params, nil +} + +func (r *crawlRun) crawlDynamoDB(t DynamoDBTarget) error { + if r.ddb == nil { + return fmt.Errorf("%w: %q", errDDBUnavailable, t.Path) + } + + ctx, cancel := context.WithTimeout(awsmeta.WithRegion(context.Background(), r.region), crawlerS3Timeout) + defer cancel() + + cols, params, err := r.describeDDBTable(ctx, t) + if err != nil { + return err + } + + params[paramClassification] = ddbClassification + params[paramTypeOfData] = typeOfDataTable + params[crawlerManagedParam] = r.c.Name + r.upsert(&Table{ Name: crawlTableName(r.c.TablePrefix, t.Path), DatabaseName: strs.Fold(r.c.DatabaseName), Parameters: params, diff --git a/services/glue/crawler_hudi.go b/services/glue/crawler_hudi.go index 33c7e7fc1d..c0684377e4 100644 --- a/services/glue/crawler_hudi.go +++ b/services/glue/crawler_hudi.go @@ -8,28 +8,73 @@ import ( "fmt" "maps" "slices" - "sort" "strings" + "github.com/iskorotkov/avro/v2/ocf" + "github.com/blackbirdworks/gopherstack/pkgs/strs" ) const ( - hudiDir = ".hoodie" - hudiProps = "hoodie.properties" - hudiTypeMOR = "MERGE_ON_READ" - hudiInput = "org.apache.hudi.hadoop.HoodieParquetInputFormat" - hudiRealtimeIn = "org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat" - hudiMetaPrefix = "_hoodie_" - hudiSchemaProp = "hoodie.table.create.schema" - hudiPartProp = "hoodie.table.partition.fields" - hudiNameProp = "hoodie.table.name" - hudiTypeProp = "hoodie.table.type" - hudiCommitSuffix = ".commit" + hudiDir = ".hoodie" + hudiProps = "hoodie.properties" + hudiTypeMOR = "MERGE_ON_READ" + hudiInput = "org.apache.hudi.hadoop.HoodieParquetInputFormat" + hudiRealtimeIn = "org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat" + hudiMetaPrefix = "_hoodie_" + hudiSchemaProp = "hoodie.table.create.schema" + hudiPartProp = "hoodie.table.partition.fields" + hudiNameProp = "hoodie.table.name" + hudiTypeProp = "hoodie.table.type" + hudiTimelineDir = ".hoodie/timeline" ) var errHudiNoSchema = errors.New("no Hudi schema in hoodie.properties or a completed commit") +func isHudiCompletedCommit(file string) bool { + return strings.HasSuffix(file, ".commit") || strings.HasSuffix(file, ".deltacommit") || + strings.HasSuffix(file, ".replacecommit") +} + +// hudiCommitSchema reads extraMetadata.schema from a completed instant: JSON +// in 0.x timelines, an Avro container file in 1.x. +func hudiCommitSchema(body []byte) string { + if !bytes.HasPrefix(body, []byte("Obj\x01")) { + var commit struct { + ExtraMetadata map[string]string `json:"extraMetadata"` + } + + if decodeJSON(body, &commit) != nil { + return "" + } + + return commit.ExtraMetadata["schema"] + } + + dec, err := ocf.NewDecoder(bytes.NewReader(body)) + if err != nil { + return "" + } + + for dec.HasNext() { + var row map[string]any + if dec.Decode(&row) != nil { + return "" + } + + switch m := row["extraMetadata"].(type) { + case map[string]string: + return m["schema"] + case map[string]any: + s, _ := m["schema"].(string) + + return s + } + } + + return "" +} + func hudiMetaColumns() []string { return []string{ "_hoodie_commit_time", "_hoodie_commit_seqno", "_hoodie_record_key", @@ -103,30 +148,32 @@ func (r *crawlRun) hudiSchema( for _, k := range keys { dir, file := splitKey(k) - if dir == joinKey(root, hudiDir) && strings.HasSuffix(file, hudiCommitSuffix) { + if (dir == joinKey(root, hudiDir) || dir == joinKey(root, hudiTimelineDir)) && isHudiCompletedCommit(file) { commits = append(commits, k) } } - sort.Strings(commits) + slices.SortFunc(commits, func(x, y string) int { + _, fx := splitKey(x) + _, fy := splitKey(y) + + return strings.Compare(fx, fy) + }) - for _, commit := range slices.Backward(commits) { - body, err := r.readObject(ctx, bucket, commit) + for _, key := range slices.Backward(commits) { + body, err := r.readObject(ctx, bucket, key) if err != nil { return nil, err } - var commit struct { - ExtraMetadata map[string]string `json:"extraMetadata"` - } - - if decodeJSON(body, &commit) != nil || commit.ExtraMetadata["schema"] == "" { + def := hudiCommitSchema(body) + if def == "" { continue } var schema any - if err = decodeJSON([]byte(commit.ExtraMetadata["schema"]), &schema); err != nil { - return nil, fmt.Errorf("parse schema of %s: %w", commit, err) + if err = decodeJSON([]byte(def), &schema); err != nil { + return nil, fmt.Errorf("parse schema of s3://%s/%s: %w", bucket, key, err) } return schema, nil diff --git a/services/glue/crawler_hudi_timeline_test.go b/services/glue/crawler_hudi_timeline_test.go new file mode 100644 index 0000000000..6f21f0454b --- /dev/null +++ b/services/glue/crawler_hudi_timeline_test.go @@ -0,0 +1,120 @@ +package glue_test + +import ( + "bytes" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + gluetypes "github.com/aws/aws-sdk-go-v2/service/glue/types" + "github.com/iskorotkov/avro/v2/ocf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const hudiCommitAvroSchema = `{"type":"record","name":"HoodieCommitMetadata",` + + `"namespace":"org.apache.hudi.avro.model",` + + `"fields":[{"name":"extraMetadata","type":{"type":"map","values":"string"}},` + + `{"name":"version","type":"int"}]}` + +func hudiAvroCommit(t *testing.T, schema string) string { + t.Helper() + + var buf bytes.Buffer + + enc, err := ocf.NewEncoder(hudiCommitAvroSchema, &buf) + require.NoError(t, err) + require.NoError(t, enc.Encode(map[string]any{ + "extraMetadata": map[string]any{"schema": schema}, "version": int32(1), + })) + require.NoError(t, enc.Close()) + + return buf.String() +} + +func TestCrawler_HudiTimelineLayouts(t *testing.T) { + t.Parallel() + + const staleSchema = `{"type":"record","name":"trips","fields":[{"name":"old","type":"string"}]}` + + tests := []struct { + objs func(t *testing.T) map[string]string + name string + wantCols []string + }{ + { + name: "v8 avro commit in timeline dir picks latest", + objs: func(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "hudi/trips/.hoodie/timeline/20240101000000000_20240101000500000.commit": hudiAvroCommit( + t, + staleSchema, + ), + "hudi/trips/.hoodie/timeline/20240102000000000_20240102000500000.commit": hudiAvroCommit( + t, + hudiSchema, + ), + "hudi/trips/.hoodie/timeline/20240103000000000.commit.requested": "", + } + }, + wantCols: []string{"uuid:string", "fare:double", "ts:timestamp"}, + }, + { + name: "v8 json commit in timeline dir", + objs: func(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "hudi/trips/.hoodie/timeline/20240102000000000_20240102000500000.commit": `{"extraMetadata":{"schema":` + + jsonString( + hudiSchema, + ) + `}}`, + } + }, + wantCols: []string{"uuid:string", "fare:double", "ts:timestamp"}, + }, + { + name: "v8 deltacommit", + objs: func(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "hudi/trips/.hoodie/timeline/20240102000000000_20240102000500000.deltacommit": hudiAvroCommit( + t, + hudiSchema, + ), + } + }, + wantCols: []string{"uuid:string", "fare:double", "ts:timestamp"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + objs := tt.objs(t) + objs["hudi/trips/.hoodie/hoodie.properties"] = "hoodie.table.name=trips\nhoodie.table.version=8\n" + + "hoodie.table.type=COPY_ON_WRITE\nhoodie.table.partition.fields=city\n" + objs["hudi/trips/city=sf/f1.parquet"] = "x" + env.put(objs) + env.create(&gluetypes.CrawlerTargets{HudiTargets: []gluetypes.HudiTarget{{ + Paths: []string{"s3://lake/hudi/"}, + }}}, nil) + + c := env.crawl() + require.Equal( + t, + gluetypes.LastCrawlStatusSucceeded, + c.LastCrawl.Status, + aws.ToString(c.LastCrawl.ErrorMessage), + ) + + tbl := env.table("trips") + assert.Equal(t, tt.wantCols, colSpec(tbl.StorageDescriptor.Columns)[5:]) + assert.Equal(t, [][]string{{"sf"}}, env.partitionValues("trips")) + }) + } +} diff --git a/services/glue/crawler_jdbc.go b/services/glue/crawler_jdbc.go index 5c2b603815..3022ebe224 100644 --- a/services/glue/crawler_jdbc.go +++ b/services/glue/crawler_jdbc.go @@ -26,14 +26,15 @@ const ( ) var ( - errJDBCSecret = errors.New("connection authenticates through SECRET_ID, which the crawler cannot resolve") + errJDBCSecret = errors.New("cannot resolve the connection's SECRET_ID") + errJDBCSecretBody = errors.New("secret has no username and password") errJDBCNoConnection = errors.New("connection not found") errJDBCNoURL = errors.New("connection has no JDBC_CONNECTION_URL") errJDBCEngine = errors.New("unsupported JDBC engine") ) type jdbcEndpoint struct { - engine, host, port, database, user, password string + engine, host, port, database, user, password, secretID string } func parseJDBCURL(conn *Connection) (jdbcEndpoint, error) { @@ -55,6 +56,7 @@ func parseJDBCURL(conn *Connection) (jdbcEndpoint, error) { ep := jdbcEndpoint{ host: u.Hostname(), port: u.Port(), database: strings.TrimPrefix(u.Path, "/"), user: conn.ConnectionProperties["USERNAME"], password: conn.ConnectionProperties["PASSWORD"], + secretID: conn.ConnectionProperties["SECRET_ID"], } switch u.Scheme { @@ -72,10 +74,6 @@ func parseJDBCURL(conn *Connection) (jdbcEndpoint, error) { ep.port = map[string]string{engPostgres: "5432", engMySQL: "3306", engRedshift: "5439"}[ep.engine] } - if ep.password == "" && conn.ConnectionProperties["SECRET_ID"] != "" { - return jdbcEndpoint{}, errJDBCSecret - } - return ep, nil } @@ -137,6 +135,12 @@ func (r *crawlRun) crawlJDBC(t JDBCTarget) error { ctx, cancel := context.WithTimeout(context.Background(), jdbcTimeout) defer cancel() + if ep.secretID != "" { + if ep.user, ep.password, err = r.resolveSecretCredentials(ctx, ep.secretID); err != nil { + return err + } + } + db, err := ep.open(dbName) if err != nil { return fmt.Errorf("connect to JDBC data store: %w", err) @@ -251,8 +255,8 @@ func (r *crawlRun) writeJDBCTables(t JDBCTarget, ep jdbcEndpoint, dbName string, for _, key := range order { tc := tables[key] params := map[string]string{ - paramClassification: ep.engine, "connectionName": t.ConnectionName, - paramTypeOfData: "table", crawlerManagedParam: r.c.Name, + paramClassification: ep.engine, paramConnectionName: t.ConnectionName, + paramTypeOfData: typeOfDataTable, crawlerManagedParam: r.c.Name, } r.upsert(&Table{ @@ -264,7 +268,7 @@ func (r *crawlRun) writeJDBCTables(t JDBCTarget, ep jdbcEndpoint, dbName string, Parameters: params, StorageDescriptor: StorageDescriptor{ Columns: tc.cols, Location: tc.location, - Parameters: map[string]string{"connectionName": t.ConnectionName, crawlerManagedParam: r.c.Name}, + Parameters: map[string]string{paramConnectionName: t.ConnectionName, crawlerManagedParam: r.c.Name}, }, }) } @@ -337,3 +341,25 @@ func mysqlHiveType(c jdbcColumn) string { return avroString } } + +func (r *crawlRun) resolveSecretCredentials(ctx context.Context, secretID string) (string, string, error) { + if r.secrets == nil { + return "", "", fmt.Errorf("%w %q: Secrets Manager is not available", errJDBCSecret, secretID) + } + + raw, err := r.secrets.SecretString(ctx, r.region, secretID) + if err != nil { + return "", "", fmt.Errorf("%w %q: %w", errJDBCSecret, secretID, err) + } + + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err = decodeJSON([]byte(raw), &body); err != nil || body.Username == "" { + return "", "", fmt.Errorf("%w %q: %w", errJDBCSecret, secretID, errJDBCSecretBody) + } + + return body.Username, body.Password, nil +} diff --git a/services/glue/crawler_jdbc_secret_test.go b/services/glue/crawler_jdbc_secret_test.go new file mode 100644 index 0000000000..2e89506519 --- /dev/null +++ b/services/glue/crawler_jdbc_secret_test.go @@ -0,0 +1,147 @@ +package glue_test + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + gluesdk "github.com/aws/aws-sdk-go-v2/service/glue" + gluetypes "github.com/aws/aws-sdk-go-v2/service/glue/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/glue" +) + +const sslRequestLen = 8 + +var errSecretNotFound = errors.New("ResourceNotFoundException: secret not found") + +type fakeSecrets map[string]string + +func (f fakeSecrets) SecretString(_ context.Context, _, id string) (string, error) { + v, ok := f[id] + if !ok { + return "", errSecretNotFound + } + + return v, nil +} + +// startupCapture accepts one connection and reports the first bytes the +// client sends, which for a PostgreSQL client include the user name. +func startupCapture(t *testing.T) (string, <-chan string) { + t.Helper() + + ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + got := make(chan string, 1) + done := make(chan struct{}) + + go func() { + defer close(done) + + for { + c, aerr := ln.Accept() + if aerr != nil { + return + } + + _ = c.SetReadDeadline(time.Now().Add(5 * time.Second)) + buf := make([]byte, 512) + n, _ := c.Read(buf) + + if n == sslRequestLen { + _, _ = c.Write([]byte("N")) + n, _ = c.Read(buf) + } + + if n > 0 { + select { + case got <- string(buf[:n]): + default: + } + } + + _ = c.Close() + } + }() + + t.Cleanup(func() { + _ = ln.Close() + <-done + }) + + return ln.Addr().String(), got +} + +func TestCrawler_JDBCSecretID(t *testing.T) { + t.Parallel() + + tests := []struct { + secrets glue.CrawlerSecrets + name string + secretID string + wantErr string + wantUser string + useServer bool + }{ + { + name: "credentials from secret", secretID: "db-creds", useServer: true, wantUser: "svc_user", + secrets: fakeSecrets{"db-creds": `{"username":"svc_user","password":"pw"}`}, + wantErr: "connect to JDBC data store", + }, + { + name: "missing secret fails the crawl", secretID: "absent", secrets: fakeSecrets{}, + wantErr: `cannot resolve the connection's SECRET_ID "absent"`, + }, + { + name: "secret without username", secretID: "bad", secrets: fakeSecrets{"bad": `{"password":"x"}`}, + wantErr: "secret has no username and password", + }, + {name: "secrets manager unavailable", secretID: "x", wantErr: "Secrets Manager is not available"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + if tt.secrets != nil { + env.glue.SetSecretsBackend(tt.secrets) + } + + addr, got := "127.0.0.1:1", (<-chan string)(nil) + if tt.useServer { + addr, got = startupCapture(t) + } + + _, err := env.client.CreateConnection(t.Context(), &gluesdk.CreateConnectionInput{ + ConnectionInput: &gluetypes.ConnectionInput{ + Name: aws.String("conn"), ConnectionType: gluetypes.ConnectionTypeJdbc, + ConnectionProperties: map[string]string{ + "JDBC_CONNECTION_URL": "jdbc:postgresql://" + addr + "/appdb", "SECRET_ID": tt.secretID, + }, + }, + }) + require.NoError(t, err) + + env.create(&gluetypes.CrawlerTargets{JdbcTargets: []gluetypes.JdbcTarget{{ + ConnectionName: aws.String("conn"), Path: aws.String("appdb/%"), + }}}, nil) + + c := env.crawl() + assert.Equal(t, gluetypes.LastCrawlStatusFailed, c.LastCrawl.Status) + assert.Contains(t, aws.ToString(c.LastCrawl.ErrorMessage), tt.wantErr) + assert.Equal(t, gluetypes.CrawlerHistoryStateFailed, env.lastCrawl().State) + + if got != nil { + assert.Contains(t, <-got, tt.wantUser) + } + }) + } +} diff --git a/services/glue/crawler_mongodb.go b/services/glue/crawler_mongodb.go new file mode 100644 index 0000000000..9ee72fe9db --- /dev/null +++ b/services/glue/crawler_mongodb.go @@ -0,0 +1,340 @@ +package glue + +import ( + "context" + "errors" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + mongoTimeout = 30 * time.Second + mongoSampleDocs = 1000 + mongoClassification = "mongodb" + mongoConnType = "MONGODB" + mongoKindStruct = "struct" + mongoKindArray = "array" +) + +var ( + errMongoConnection = errors.New("MongoDB target needs a connection of type MONGODB") + errMongoNoURL = errors.New("connection has no CONNECTION_URL") + errMongoPath = errors.New("MongoDB path must be database/collection") +) + +type mongoType struct { + fields map[string]*mongoType + elem *mongoType + kind string + order []string +} + +func (t *mongoType) render() string { + switch t.kind { + case mongoKindStruct: + parts := make([]string, 0, len(t.order)) + for _, n := range t.order { + parts = append(parts, n+":"+t.fields[n].render()) + } + + return "struct<" + strings.Join(parts, ",") + ">" + case mongoKindArray: + if t.elem == nil { + return "array" + } + + return "array<" + t.elem.render() + ">" + default: + return t.kind + } +} + +func isMongoNumeric(k string) bool { return k == avroInt || k == typeBigint || k == avroDouble } + +func mergeMongoType(a, b *mongoType) *mongoType { + switch { + case a == nil: + return b + case b == nil: + return a + case a.kind != b.kind: + return mergeMongoScalars(a, b) + case a.kind == mongoKindStruct: + for _, n := range b.order { + if _, ok := a.fields[n]; !ok { + a.order = append(a.order, n) + } + + a.fields[n] = mergeMongoType(a.fields[n], b.fields[n]) + } + case a.kind == mongoKindArray: + a.elem = mergeMongoType(a.elem, b.elem) + } + + return a +} + +func mergeMongoScalars(a, b *mongoType) *mongoType { + if !isMongoNumeric(a.kind) || !isMongoNumeric(b.kind) { + return &mongoType{kind: avroString} + } + + if a.kind == avroDouble || b.kind == avroDouble { + return &mongoType{kind: avroDouble} + } + + return &mongoType{kind: typeBigint} +} + +func inferMongoDoc(pairs []bson.E) *mongoType { + t := &mongoType{kind: mongoKindStruct, fields: map[string]*mongoType{}} + + for _, e := range pairs { + if ft := inferMongoValue(e.Value); ft != nil { + t.order = append(t.order, e.Key) + t.fields[e.Key] = ft + } + } + + return t +} + +func inferMongoValue(v any) *mongoType { + switch x := v.(type) { + case nil: + return nil + case bson.D, bson.M, bson.A, bson.ObjectID: + return inferMongoComposite(x) + default: + return &mongoType{kind: mongoScalarKind(v)} + } +} + +func mongoScalarKind(v any) string { + switch v.(type) { + case int32: + return avroInt + case int, int64: + return typeBigint + case float64: + return avroDouble + case bool: + return avroBoolean + case bson.DateTime, time.Time, bson.Timestamp: + return typeTimestamp + case bson.Binary: + return typeBinary + default: + return avroString + } +} + +func inferMongoComposite(v any) *mongoType { + switch x := v.(type) { + case bson.D: + return inferMongoDoc(x) + case bson.M: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + + slices.Sort(keys) + + pairs := make([]bson.E, len(keys)) + for i, k := range keys { + pairs[i] = bson.E{Key: k, Value: x[k]} + } + + return inferMongoDoc(pairs) + case bson.A: + arr := &mongoType{kind: mongoKindArray} + for _, el := range x { + arr.elem = mergeMongoType(arr.elem, inferMongoValue(el)) + } + + return arr + default: + return &mongoType{ + kind: mongoKindStruct, order: []string{"oid"}, + fields: map[string]*mongoType{"oid": {kind: avroString}}, + } + } +} + +func isMongoSystemDB(name string) bool { + switch name { + case "admin", "local", "config": + return true + default: + return false + } +} + +func mongoPattern(p string) *regexp.Regexp { + parts := strings.Split(p, "%") + for i := range parts { + parts[i] = regexp.QuoteMeta(parts[i]) + } + + return regexp.MustCompile("^" + strings.Join(parts, ".*") + "$") +} + +func (r *crawlRun) mongoClient(ctx context.Context, conn *Connection) (*mongo.Client, error) { + if !strings.EqualFold(conn.ConnectionType, mongoConnType) { + return nil, fmt.Errorf("%w: %q is %s", errMongoConnection, conn.Name, conn.ConnectionType) + } + + props := conn.ConnectionProperties + + uri := props["CONNECTION_URL"] + if uri == "" { + return nil, errMongoNoURL + } + + user, pass := props["USERNAME"], props["PASSWORD"] + + if props["SECRET_ID"] != "" { + var err error + if user, pass, err = r.resolveSecretCredentials(ctx, props["SECRET_ID"]); err != nil { + return nil, err + } + } + + opts := options.Client().SetServerSelectionTimeout(mongoTimeout).ApplyURI(uri) + if user != "" { + opts.SetAuth(options.Credential{Username: user, Password: pass}) + } + + return mongo.Connect(opts) +} + +func (r *crawlRun) crawlMongoDB(t MongoDBTarget) error { + conn, ok := r.conns[t.ConnectionName] + if !ok { + return fmt.Errorf("%w: %q", errJDBCNoConnection, t.ConnectionName) + } + + dbPat, collPat, ok := strings.Cut(strings.Trim(t.Path, "/"), "/") + if !ok || dbPat == "" || collPat == "" { + return fmt.Errorf("%w: %q", errMongoPath, t.Path) + } + + ctx, cancel := context.WithTimeout(context.Background(), mongoTimeout) + defer cancel() + + client, err := r.mongoClient(ctx, conn) + if err != nil { + return err + } + + defer func() { _ = client.Disconnect(context.WithoutCancel(ctx)) }() + + dbs, err := client.ListDatabaseNames(ctx, bson.D{}) + if err != nil { + return fmt.Errorf("connect to MongoDB data store: %w", err) + } + + dbRe, collRe := mongoPattern(dbPat), mongoPattern(collPat) + + for _, dbName := range dbs { + if isMongoSystemDB(dbName) || !dbRe.MatchString(dbName) { + continue + } + + if err = r.crawlMongoDatabase(ctx, client.Database(dbName), collRe, t); err != nil { + return err + } + } + + return nil +} + +func (r *crawlRun) crawlMongoDatabase( + ctx context.Context, db *mongo.Database, collRe *regexp.Regexp, t MongoDBTarget, +) error { + names, err := db.ListCollectionNames(ctx, bson.D{}) + if err != nil { + return fmt.Errorf("list MongoDB collections of %s: %w", db.Name(), err) + } + + slices.Sort(names) + + for _, name := range names { + if strings.HasPrefix(name, "system.") || !collRe.MatchString(name) { + continue + } + + if err = r.crawlMongoCollection(ctx, db, name, t); err != nil { + return err + } + } + + return nil +} + +func (r *crawlRun) crawlMongoCollection(ctx context.Context, db *mongo.Database, name string, t MongoDBTarget) error { + coll := db.Collection(name) + + findOpts := options.Find() + if !t.ScanAll { + findOpts.SetLimit(mongoSampleDocs) + } + + cur, err := coll.Find(ctx, bson.D{}, findOpts) + if err != nil { + return fmt.Errorf("read MongoDB collection %s.%s: %w", db.Name(), name, err) + } + defer cur.Close(ctx) + + var merged *mongoType + + for cur.Next(ctx) { + var doc bson.D + if err = cur.Decode(&doc); err != nil { + return fmt.Errorf("decode MongoDB document of %s.%s: %w", db.Name(), name, err) + } + + merged = mergeMongoType(merged, inferMongoDoc(doc)) + } + + if err = cur.Err(); err != nil { + return fmt.Errorf("read MongoDB collection %s.%s: %w", db.Name(), name, err) + } + + if merged == nil || len(merged.order) == 0 { + return nil + } + + cols := make([]Column, 0, len(merged.order)) + for _, n := range merged.order { + cols = append(cols, Column{Name: n, Type: merged.fields[n].render()}) + } + + count, _ := coll.EstimatedDocumentCount(ctx) + params := map[string]string{ + paramClassification: mongoClassification, paramTypeOfData: typeOfDataTable, crawlerManagedParam: r.c.Name, + paramConnectionName: t.ConnectionName, "recordCount": strconv.FormatInt(count, 10), + } + + r.upsert(&Table{ + Name: crawlTableName(r.c.TablePrefix, db.Name()+"_"+name), + DatabaseName: strs.Fold(r.c.DatabaseName), Parameters: params, + StorageDescriptor: StorageDescriptor{ + Columns: cols, Location: db.Name() + "." + name, + Parameters: map[string]string{paramConnectionName: t.ConnectionName, crawlerManagedParam: r.c.Name}, + }, + }) + + return nil +} diff --git a/services/glue/crawler_mongodb_test.go b/services/glue/crawler_mongodb_test.go new file mode 100644 index 0000000000..df0a7bc221 --- /dev/null +++ b/services/glue/crawler_mongodb_test.go @@ -0,0 +1,124 @@ +package glue_test + +import ( + "maps" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + gluesdk "github.com/aws/aws-sdk-go-v2/service/glue" + gluetypes "github.com/aws/aws-sdk-go-v2/service/glue/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func (e *crawlEnv) mongoConnection(name, connType, url string, extra map[string]string) { + e.t.Helper() + + props := map[string]string{"CONNECTION_URL": url} + maps.Copy(props, extra) + + _, err := e.client.CreateConnection(e.t.Context(), &gluesdk.CreateConnectionInput{ + ConnectionInput: &gluetypes.ConnectionInput{ + Name: aws.String(name), ConnectionType: gluetypes.ConnectionType(connType), ConnectionProperties: props, + }, + }) + require.NoError(e.t, err) +} + +func TestCrawler_MongoDBTargetFailures(t *testing.T) { + t.Parallel() + + const dead = "mongodb://127.0.0.1:1/?serverSelectionTimeoutMS=300&connectTimeoutMS=300" + + tests := []struct { + extra map[string]string + name, connType, url, path, want string + noConn bool + }{ + {name: "no connection", noConn: true, path: "db/%", want: "connection not found"}, + { + name: "wrong connection type", + connType: "NETWORK", + url: dead, + path: "db/%", + want: "needs a connection of type MONGODB", + }, + {name: "path without collection", connType: "MONGODB", url: dead, path: "db", want: "database/collection"}, + {name: "unreachable", connType: "MONGODB", url: dead, path: "db/%", want: "MongoDB data store"}, + { + name: "secret cannot be resolved", connType: "MONGODB", url: dead, path: "db/%", + extra: map[string]string{"SECRET_ID": "creds"}, want: "Secrets Manager is not available", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newCrawlEnv(t) + if !tt.noConn { + env.mongoConnection("mongo", tt.connType, tt.url, tt.extra) + } + + env.create(&gluetypes.CrawlerTargets{MongoDBTargets: []gluetypes.MongoDBTarget{{ + ConnectionName: aws.String("mongo"), Path: aws.String(tt.path), + }}}, nil) + + c := env.crawl() + assert.Equal(t, gluetypes.LastCrawlStatusFailed, c.LastCrawl.Status) + assert.Contains(t, aws.ToString(c.LastCrawl.ErrorMessage), tt.want) + assert.Equal(t, gluetypes.CrawlerHistoryStateFailed, env.lastCrawl().State) + }) + } +} + +func TestCrawler_MongoDBTarget_Docker(t *testing.T) { + t.Parallel() + + addr := startContainer(t, jdbcEngine{image: "mongo:5.0", port: "27017"}) + uri := "mongodb://" + addr + + client, err := mongo.Connect(options.Client().ApplyURI(uri)) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Disconnect(t.Context()) }) + + require.Eventually(t, func() bool { + return client.Ping(t.Context(), nil) == nil + }, 2*time.Minute, time.Second) + + db := client.Database("appdb") + _, err = db.Collection("orders").InsertMany(t.Context(), []any{ + bson.D{{Key: "sku", Value: "a"}, {Key: "qty", Value: int32(2)}, {Key: "price", Value: 1.5}, + {Key: "tags", Value: bson.A{"x", "y"}}, {Key: "ship", Value: bson.D{{Key: "city", Value: "sf"}}}}, + bson.D{{Key: "sku", Value: "b"}, {Key: "qty", Value: int64(3)}, {Key: "ok", Value: true}, + {Key: "at", Value: time.Unix(1700000000, 0)}}, + }) + require.NoError(t, err) + _, err = db.Collection("scratch").InsertOne(t.Context(), bson.D{{Key: "z", Value: "1"}}) + require.NoError(t, err) + + env := newCrawlEnv(t) + env.mongoConnection("mongo", "MONGODB", uri, nil) + env.create(&gluetypes.CrawlerTargets{MongoDBTargets: []gluetypes.MongoDBTarget{{ + ConnectionName: aws.String("mongo"), Path: aws.String("appdb/ord%"), ScanAll: aws.Bool(true), + }}}, nil) + + c := env.crawl() + require.Equal(t, gluetypes.LastCrawlStatusSucceeded, c.LastCrawl.Status, aws.ToString(c.LastCrawl.ErrorMessage)) + + tbl := env.table("appdb_orders") + assert.Equal(t, []string{ + "_id:struct", "sku:string", "qty:bigint", "price:double", + "tags:array", "ship:struct", "ok:boolean", "at:timestamp", + }, colSpec(tbl.StorageDescriptor.Columns)) + assert.Equal(t, "appdb.orders", aws.ToString(tbl.StorageDescriptor.Location)) + assert.Equal(t, "mongodb", tbl.Parameters["classification"]) + assert.Equal(t, "mongo", tbl.Parameters["connectionName"]) + assert.Equal(t, "2", tbl.Parameters["recordCount"]) + assert.False(t, env.hasTable("appdb_scratch"), "collection outside the % pattern") + assert.Equal(t, int32(1), env.metrics().TablesCreated) +} diff --git a/services/glue/crawler_secrets.go b/services/glue/crawler_secrets.go new file mode 100644 index 0000000000..ce194456ac --- /dev/null +++ b/services/glue/crawler_secrets.go @@ -0,0 +1,25 @@ +package glue + +import "context" + +// CrawlerSecrets resolves the Secrets Manager secrets that connections name in +// SECRET_ID. +type CrawlerSecrets interface { + SecretString(ctx context.Context, region, secretID string) (string, error) +} + +// SetSecretsBackend wires Secrets Manager so crawlers authenticate JDBC and +// MongoDB connections through SECRET_ID. +func (b *InMemoryBackend) SetSecretsBackend(s CrawlerSecrets) { + b.mu.Lock("SetSecretsBackend") + defer b.mu.Unlock() + + b.crawlerSecrets = s +} + +func (b *InMemoryBackend) currentCrawlerSecrets() CrawlerSecrets { + b.mu.RLock("currentCrawlerSecrets") + defer b.mu.RUnlock() + + return b.crawlerSecrets +} diff --git a/services/glue/crawler_targets_sdk_test.go b/services/glue/crawler_targets_sdk_test.go index 614be7d1eb..721a37bebf 100644 --- a/services/glue/crawler_targets_sdk_test.go +++ b/services/glue/crawler_targets_sdk_test.go @@ -27,6 +27,7 @@ const crawlRole = "arn:aws:iam::000000000000:role/glue" type crawlEnv struct { client *gluesdk.Client + glue *glue.InMemoryBackend s3 *s3backend.InMemoryBackend ddb *ddbbackend.InMemoryDB t *testing.T @@ -41,7 +42,7 @@ func newCrawlEnv(t *testing.T) *crawlEnv { b.SetS3Backend(s3b) b.SetDynamoDBBackend(ddb) - env := &crawlEnv{client: newTestGlueClient(t, glue.NewHandler(b)), s3: s3b, ddb: ddb, t: t} + env := &crawlEnv{client: newTestGlueClient(t, glue.NewHandler(b)), glue: b, s3: s3b, ddb: ddb, t: t} _, err := s3b.CreateBucket(t.Context(), &awss3.CreateBucketInput{Bucket: aws.String("lake")}) require.NoError(t, err) diff --git a/services/glue/crawlers.go b/services/glue/crawlers.go index ad79e44bcb..088e1770c8 100644 --- a/services/glue/crawlers.go +++ b/services/glue/crawlers.go @@ -157,6 +157,10 @@ func (b *InMemoryBackend) CreateCrawlerWithOptions( return nil, err } + if err := validateCrawlerTargets(targets); err != nil { + return nil, err + } + if dbName != "" { if !b.databases.Has(strs.Fold(dbName)) { return nil, ErrNotFound @@ -274,6 +278,10 @@ func (b *InMemoryBackend) UpdateCrawlerWithOptions( return ErrNotFound } + if err := validateCrawlerTargets(targets); err != nil { + return err + } + // AWS rejects UpdateCrawler while the crawler is actively running, same as // DeleteCrawler. if c.State == stateRunning || c.State == stateStarting || c.State == stateStopping { @@ -284,49 +292,7 @@ func (b *InMemoryBackend) UpdateCrawlerWithOptions( c.DatabaseName = dbName c.Targets = targets - if opts.Description != "" { - c.Description = opts.Description - } - - if opts.Configuration != "" { - c.Configuration = opts.Configuration - } - - if opts.TablePrefix != "" { - c.TablePrefix = opts.TablePrefix - } - - if opts.Classifiers != nil { - c.Classifiers = append([]string(nil), opts.Classifiers...) - } - - if opts.Schedule != "" { - c.Schedule = CrawlerSchedule{ScheduleExpression: opts.Schedule, State: stateScheduled} - } - - if opts.CrawlerSecurityConfiguration != "" { - c.CrawlerSecurityConfiguration = opts.CrawlerSecurityConfiguration - } - - if opts.SchemaChangePolicy != nil { - v := *opts.SchemaChangePolicy - c.SchemaChangePolicy = &v - } - - if opts.RecrawlPolicy != nil { - v := *opts.RecrawlPolicy - c.RecrawlPolicy = &v - } - - if opts.LineageConfiguration != nil { - v := *opts.LineageConfiguration - c.LineageConfiguration = &v - } - - if opts.LakeFormationConfiguration != nil { - v := *opts.LakeFormationConfiguration - c.LakeFormationConfiguration = &v - } + applyCrawlerUpdateOptions(c, opts) c.LastUpdated = float64(time.Now().Unix()) @@ -637,3 +603,49 @@ func (b *InMemoryBackend) setLastCrawlLocked(c *Crawler, status string) { MessagePrefix: last.CrawlID, } } + +func applyCrawlerUpdateOptions(c *Crawler, opts CrawlerOptions) { + if opts.Description != "" { + c.Description = opts.Description + } + + if opts.Configuration != "" { + c.Configuration = opts.Configuration + } + + if opts.TablePrefix != "" { + c.TablePrefix = opts.TablePrefix + } + + if opts.Classifiers != nil { + c.Classifiers = append([]string(nil), opts.Classifiers...) + } + + if opts.Schedule != "" { + c.Schedule = CrawlerSchedule{ScheduleExpression: opts.Schedule, State: stateScheduled} + } + + if opts.CrawlerSecurityConfiguration != "" { + c.CrawlerSecurityConfiguration = opts.CrawlerSecurityConfiguration + } + + if opts.SchemaChangePolicy != nil { + v := *opts.SchemaChangePolicy + c.SchemaChangePolicy = &v + } + + if opts.RecrawlPolicy != nil { + v := *opts.RecrawlPolicy + c.RecrawlPolicy = &v + } + + if opts.LineageConfiguration != nil { + v := *opts.LineageConfiguration + c.LineageConfiguration = &v + } + + if opts.LakeFormationConfiguration != nil { + v := *opts.LakeFormationConfiguration + c.LakeFormationConfiguration = &v + } +} diff --git a/services/glue/dashboard.go b/services/glue/dashboard.go index f31624a06e..8f49c4f6f6 100644 --- a/services/glue/dashboard.go +++ b/services/glue/dashboard.go @@ -88,7 +88,7 @@ func (b *InMemoryBackend) GetDashboardURL(resourceID, resourceType, requestOrigi // URL/token are deterministic mock values (same modeling choice as // GetDashboardURL above), but the existence and state checks are real. func (b *InMemoryBackend) GetSessionEndpoint(sessionID string) (*SessionEndpoint, error) { - b.advanceStates(time.Now()) + b.advanceStates(b.clockNow()) b.mu.RLock("GetSessionEndpoint") defer b.mu.RUnlock() @@ -98,7 +98,7 @@ func (b *InMemoryBackend) GetSessionEndpoint(sessionID string) (*SessionEndpoint return nil, fmt.Errorf("session %q not found: %w", sessionID, ErrNotFound) } - if s.Status == stateStopped || s.Status == stateStopping { + if s.Status == stateStopped || s.Status == stateStopping || s.Status == statusTimeout || s.Status == statusFailed { return nil, fmt.Errorf( "%w: session %q is not in a usable state (status=%s)", ErrIllegalSessionState, sessionID, s.Status, ) diff --git a/services/glue/handler.go b/services/glue/handler.go index 5883590060..de6085a5b5 100644 --- a/services/glue/handler.go +++ b/services/glue/handler.go @@ -53,6 +53,8 @@ func (h *Handler) EnableRegions(ctx context.Context) { nb.ramShareCreator = home.currentResourceShareCreator() nb.crawlerS3 = home.currentCrawlerS3() nb.crawlerDDB = home.currentCrawlerDDB() + nb.crawlerSecrets = home.currentCrawlerSecrets() + nb.sessionEngine = home.currentSessionEngine() nb.StartReconciler(ctx) return NewHandler(nb) diff --git a/services/glue/handler_crawlers_test.go b/services/glue/handler_crawlers_test.go index 1eace7b218..336d135c6d 100644 --- a/services/glue/handler_crawlers_test.go +++ b/services/glue/handler_crawlers_test.go @@ -32,8 +32,15 @@ func Test_Crawler_CreateOptions_RoundTrip(t *testing.T) { "Configuration": `{"Version":1.0}`, "TablePrefix": "raw_", "Targets": map[string]any{ - "S3Targets": []map[string]any{{"Path": "s3://bucket/data/"}}, - "JdbcTargets": []map[string]any{{"ConnectionName": "conn1", "Path": "db/%"}}, + "S3Targets": []map[string]any{{"Path": "s3://bucket/data/"}}, + "JdbcTargets": []map[string]any{{"ConnectionName": "conn1", "Path": "db/%"}}, + }, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = doGlueRequest(t, h, "CreateCrawler", map[string]any{ + "Name": "crawler2", "Role": "role1", + "Targets": map[string]any{ "CatalogTargets": []map[string]any{{"DatabaseName": "db1", "Tables": []string{"t1"}}}, }, }) @@ -73,8 +80,15 @@ func Test_Crawler_CreateOptions_RoundTrip(t *testing.T) { assert.Equal(t, []string{"my-classifier"}, out.Crawler.Classifiers) require.Len(t, out.Crawler.Targets.JdbcTargets, 1) assert.Equal(t, "conn1", out.Crawler.Targets.JdbcTargets[0].ConnectionName) - require.Len(t, out.Crawler.Targets.CatalogTargets, 1) - assert.Equal(t, "db1", out.Crawler.Targets.CatalogTargets[0].DatabaseName) + + rec = doGlueRequest(t, h, "GetCrawler", map[string]any{"Name": "crawler2"}) + require.Equal(t, http.StatusOK, rec.Code) + + cat := out + cat.Crawler.Targets.CatalogTargets = nil + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cat)) + require.Len(t, cat.Crawler.Targets.CatalogTargets, 1) + assert.Equal(t, "db1", cat.Crawler.Targets.CatalogTargets[0].DatabaseName) // UpdateCrawler should also accept and persist the same options. rec = doGlueRequest(t, h, "UpdateCrawler", map[string]any{ diff --git a/services/glue/handler_sessions_test.go b/services/glue/handler_sessions_test.go index c17343c7c5..a3b1e28176 100644 --- a/services/glue/handler_sessions_test.go +++ b/services/glue/handler_sessions_test.go @@ -36,6 +36,8 @@ func TestSession(t *testing.T) { t.Errorf("GetSession ID mismatch: %v", sess2) } + waitHandlerSessionReady(t, h, "sess-1") + // RunStatement out3 := dispatchNewOp(t, h, "RunStatement", map[string]any{ "SessionId": "sess-1", diff --git a/services/glue/iam_enforcement_test.go b/services/glue/iam_enforcement_test.go index 5ea9cadd49..3c46035b20 100644 --- a/services/glue/iam_enforcement_test.go +++ b/services/glue/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/glue" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/glue/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/glue/models.go b/services/glue/models.go index 2f3c1e094a..54ce6dec3d 100644 --- a/services/glue/models.go +++ b/services/glue/models.go @@ -271,8 +271,11 @@ type JDBCTarget struct { // CatalogTarget synchronizes an existing Data Catalog database/tables as a // crawler target, mirroring aws-sdk-go-v2/service/glue/types.CatalogTarget. type CatalogTarget struct { - DatabaseName string `json:"DatabaseName,omitempty"` - Tables []string `json:"Tables,omitempty"` + DatabaseName string `json:"DatabaseName,omitempty"` + ConnectionName string `json:"ConnectionName,omitempty"` + EventQueueArn string `json:"EventQueueArn,omitempty"` + DlqEventQueueArn string `json:"DlqEventQueueArn,omitempty"` + Tables []string `json:"Tables,omitempty"` } // Crawler represents a Glue crawler. @@ -1107,6 +1110,7 @@ type Session struct { DefaultArguments map[string]string `json:"DefaultArguments,omitempty"` Command SessionCommand `json:"Command,omitzero"` Status string `json:"Status"` + ErrorMessage string `json:"ErrorMessage,omitempty"` GlueVersion string `json:"GlueVersion,omitempty"` WorkerType string `json:"WorkerType,omitempty"` SecurityConfiguration string `json:"SecurityConfiguration,omitempty"` @@ -1115,6 +1119,7 @@ type Session struct { Description string `json:"Description,omitempty"` Connections ConnectionsList `json:"Connections,omitzero"` CreatedOn float64 `json:"CreatedOn,omitempty"` + CompletedOn float64 `json:"CompletedOn,omitempty"` MaxCapacity float64 `json:"MaxCapacity,omitempty"` NumberOfWorkers int32 `json:"NumberOfWorkers,omitempty"` Timeout int32 `json:"Timeout,omitempty"` diff --git a/services/glue/persistence.go b/services/glue/persistence.go index aa2ab5907a..e33e76525b 100644 --- a/services/glue/persistence.go +++ b/services/glue/persistence.go @@ -235,6 +235,7 @@ func (b *InMemoryBackend) restoreFromSnapshot(snap backendSnapshot) { b.workflowRuns = snap.WorkflowRuns b.schemaVersions = snap.SchemaVersions b.sessionStatements = snap.SessionStatements + interruptRestoredStatements(b.sessionStatements) b.crawlHistory = snap.CrawlHistory b.schemaVersionMetadata = snap.SchemaVersionMetadata b.glueIdentityCenterConfig = snap.GlueIdentityCenterConfig @@ -327,3 +328,22 @@ func foldKeyHeads[V any](m map[string]V) map[string]V { } const keyHeadParts = 2 + +// interruptRestoredStatements fails statements that were queued or running at +// snapshot time: their interpreter did not survive the restart. +func interruptRestoredStatements(all map[string][]*Statement) { + for _, stmts := range all { + for _, st := range stmts { + switch st.State { + case stmtWaiting, stmtRunning: + st.State = stmtError + st.Output = &StatementOutput{ + Status: stmtError, ErrorName: "SessionInterrupted", + ErrorValue: "the session interpreter did not survive a restart", + } + case stmtCancelling: + st.State = stmtCancelled + } + } + } +} diff --git a/services/glue/persistence_test.go b/services/glue/persistence_test.go index 783f7d20b5..344d5b58fc 100644 --- a/services/glue/persistence_test.go +++ b/services/glue/persistence_test.go @@ -3,6 +3,7 @@ package glue_test import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -87,6 +88,7 @@ func seedFullState(t *testing.T, b *glue.InMemoryBackend) { require.NoError(t, err) _, err = b.CreateSession("sess1", "role1", glue.SessionCommand{Name: "glueetl"}, glue.Session{}) require.NoError(t, err) + glue.AdvanceStatesForTest(b, time.Now().Add(time.Minute)) _, err = b.RunStatement("sess1", "print(1)") // populates raw sessionStatements require.NoError(t, err) require.NoError(t, b.CreateTableOptimizer("cat1", "db1", "tbl1", "compaction", glue.TableOptimizerConfiguration{})) diff --git a/services/glue/provider.go b/services/glue/provider.go index 2d674f4c77..300c0454e4 100644 --- a/services/glue/provider.go +++ b/services/glue/provider.go @@ -1,9 +1,11 @@ package glue import ( + "context" "errors" "fmt" + "github.com/blackbirdworks/gopherstack/pkgs/container" "github.com/blackbirdworks/gopherstack/pkgs/service" ) @@ -26,9 +28,73 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { accountID, region := service.AccountRegionOrDefault(ctx) + var settings Settings + + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetGlueSettings() + } + backend := NewInMemoryBackend(accountID, region) + if settings.Engine == EngineDocker { + enableDockerEngine(ctx, backend, settings) + } + handler := NewHandler(backend) handler.EnableRegions(ctx.JanitorCtx) return handler, nil } + +func enableDockerEngine(ctx *service.AppContext, backend *InMemoryBackend, settings Settings) { + rt, err := container.NewRuntime(container.Config{Logger: ctx.Logger}) + if err != nil { + ctx.Logger.Warn("Glue: container runtime unavailable; session statements will fail", "error", err) + + return + } + + backend.SetSessionEngine(NewDockerSessionEngine(DockerSessionConfig{ + Runtime: NewContainerSessionRuntime(rt), Image: settings.EngineImage, + })) +} + +var errLivenessProbe = errors.New("liveness probe failed") + +// NewContainerSessionRuntime adapts a container runtime for NewDockerSessionEngine. +func NewContainerSessionRuntime(rt container.Runtime) SessionRuntime { + return containerSessionRuntime{rt: rt} +} + +// containerSessionRuntime adapts container.Runtime to SessionRuntime. +type containerSessionRuntime struct{ rt container.Runtime } + +func (c containerSessionRuntime) HasImage(ctx context.Context, ref string) (bool, error) { + return c.rt.HasImage(ctx, ref) +} + +func (c containerSessionRuntime) PullImage(ctx context.Context, ref string) error { + return c.rt.PullImage(ctx, ref) +} + +func (c containerSessionRuntime) CreateAndStart(ctx context.Context, spec SessionContainerSpec) (string, error) { + return c.rt.CreateAndStart(ctx, container.Spec{ + Image: spec.Image, Entrypoint: spec.Entrypoint, Env: spec.Env, Mounts: spec.Mounts, + }) +} + +func (c containerSessionRuntime) StopAndRemove(ctx context.Context, id string) error { + return c.rt.StopAndRemove(ctx, id) +} + +func (c containerSessionRuntime) Alive(ctx context.Context, id string) error { + res, err := c.rt.Exec(ctx, id, []string{"true"}) + if err != nil { + return err + } + + if res.ExitCode != 0 { + return fmt.Errorf("%w: exit %d", errLivenessProbe, res.ExitCode) + } + + return nil +} diff --git a/services/glue/realclient_core_catalog_and_job_management_test.go b/services/glue/realclient_core_catalog_and_job_management_test.go index 3c125ccbbd..38dd1c5327 100644 --- a/services/glue/realclient_core_catalog_and_job_management_test.go +++ b/services/glue/realclient_core_catalog_and_job_management_test.go @@ -662,6 +662,8 @@ func testSessionsRealClient(t *testing.T) { require.NotNil(t, ep.SparkConnect) assert.NotEmpty(t, aws.ToString(ep.SparkConnect.Url)) + waitClientSessionReady(t, client, "sess1") + run, err := client.RunStatement(ctx, &gluesdk.RunStatementInput{ SessionId: aws.String("sess1"), Code: aws.String("print(1)"), }) diff --git a/services/glue/reconciler.go b/services/glue/reconciler.go index 87df17ecab..d817cf7deb 100644 --- a/services/glue/reconciler.go +++ b/services/glue/reconciler.go @@ -145,9 +145,11 @@ func (b *InMemoryBackend) reconcileLocked(now time.Time) { // PROVISIONING→READY, STOPPING→STOPPED. Split out of reconcileLocked to keep // its cognitive complexity down. Must be called with b.mu held. func (b *InMemoryBackend) reconcileSessionsLocked(now time.Time) { + b.expireSessionsLocked(now) + for id, readyAt := range b.sessionReadyAt { if now.After(readyAt) { - if s, ok := b.sessions.Get(id); ok && s.Status == "PROVISIONING" { + if s, ok := b.sessions.Get(id); ok && s.Status == statusProvisioning { s.Status = stateReady } @@ -230,7 +232,8 @@ func (b *InMemoryBackend) pendingDueLocked(now time.Time) bool { flatTimerDue(b.crawlerReadyAt, now) || flatTimerDue(b.integrationReadyAt, now) || flatTimerDue(b.sessionReadyAt, now) || - flatTimerDue(b.sessionStopAt, now) + flatTimerDue(b.sessionStopAt, now) || + b.sessionExpiryDueLocked(now) } // nestedTimerDue reports whether any timer in a jobName→runID→time map is due at now. @@ -339,7 +342,7 @@ func (b *InMemoryBackend) reconcileLoop(ctx context.Context, stop <-chan struct{ case <-stop: return case <-ticker.C: - b.advanceStates(time.Now()) + b.advanceStates(b.clockNow()) } } } @@ -348,6 +351,8 @@ func (b *InMemoryBackend) reconcileLoop(ctx context.Context, stop <-chan struct{ // exited, guaranteeing no leaked goroutine survives shutdown. It is idempotent and // safe to call even when the reconciler was never started. func (b *InMemoryBackend) StopReconciler() { + defer b.detachLiveSessions() + wasOn := func() bool { b.reconcileMu.Lock() defer b.reconcileMu.Unlock() diff --git a/services/glue/session_docker.go b/services/glue/session_docker.go new file mode 100644 index 0000000000..a113b7473b --- /dev/null +++ b/services/glue/session_docker.go @@ -0,0 +1,363 @@ +package glue + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "sync/atomic" + "time" +) + +//go:embed session_repl.pyrepl +var sessionReplScript []byte + +const ( + defaultStartTimeout = 10 * time.Minute + replPollInterval = 25 * time.Millisecond + replLivenessEvery = 5 * time.Second + replHeartbeatStale = time.Minute + replReadyPoll = 200 * time.Millisecond + dirPerm = 0o777 + filePerm = 0o644 + containerSessionDir = "/session" +) + +var ( + errReplStart = errors.New("session interpreter failed to start") + errReplDied = errors.New("session interpreter is not running") + errReplHung = errors.New("session interpreter stopped responding") + errSessionDone = errors.New("session interpreter stopped") +) + +// glueImages maps GlueVersion to the public AWS Glue libs image. +var glueImages = map[string]string{ //nolint:gochecknoglobals // read-only lookup table + "5.0": "public.ecr.aws/glue/aws-glue-libs:5", + "4.0": "public.ecr.aws/glue/aws-glue-libs:glue_libs_4.0.0_image_01", + "3.0": "public.ecr.aws/glue/aws-glue-libs:glue_libs_3.0.0_image_01", +} + +// SessionContainerSpec is the container a session interpreter runs in. +type SessionContainerSpec struct { + Image string + Entrypoint []string + Env []string + Mounts []string +} + +// SessionRuntime is the slice of the container runtime a docker session needs. +type SessionRuntime interface { + HasImage(ctx context.Context, imageRef string) (bool, error) + PullImage(ctx context.Context, imageRef string) error + CreateAndStart(ctx context.Context, spec SessionContainerSpec) (string, error) + StopAndRemove(ctx context.Context, containerID string) error + Alive(ctx context.Context, containerID string) error +} + +// DockerSessionConfig configures NewDockerSessionEngine. +type DockerSessionConfig struct { + Runtime SessionRuntime + // Image overrides the per-GlueVersion AWS Glue libs image. + Image string + // WorkDir is the host directory holding session exchange dirs; default is the OS temp dir. + WorkDir string + // StartTimeout bounds interpreter startup (image pull, Spark init); default 10m. + StartTimeout time.Duration +} + +type dockerSessionEngine struct{ cfg DockerSessionConfig } + +// NewDockerSessionEngine runs each session as a long-lived container holding a Python REPL. +func NewDockerSessionEngine(cfg DockerSessionConfig) SessionEngine { + if cfg.StartTimeout <= 0 { + cfg.StartTimeout = defaultStartTimeout + } + + return &dockerSessionEngine{cfg: cfg} +} + +func (e *dockerSessionEngine) image(glueVersion string) string { + if e.cfg.Image != "" { + return e.cfg.Image + } + + if img, ok := glueImages[glueVersion]; ok { + return img + } + + return glueImages["5.0"] +} + +func sparkCommand(name string) bool { return name == "glueetl" || name == "gluestreaming" } + +func sessionEnv(spec SessionSpec) ([]string, error) { + args, err := json.Marshal(spec.DefaultArguments) + if err != nil { + return nil, fmt.Errorf("encode default arguments: %w", err) + } + + conf, err := json.Marshal(parseSparkConf(spec.DefaultArguments["--conf"])) + if err != nil { + return nil, fmt.Errorf("encode spark conf: %w", err) + } + + spark := "0" + if sparkCommand(spec.CommandName) { + spark = "1" + } + + return []string{ + "GLUE_SESSION_ID=" + spec.SessionID, + "GLUE_SESSION_SPARK=" + spark, + "GLUE_VERSION=" + spec.GlueVersion, + "GLUE_WORKER_TYPE=" + spec.WorkerType, + "GLUE_NUMBER_OF_WORKERS=" + strconv.Itoa(int(spec.NumberOfWorkers)), + "GLUE_DEFAULT_ARGUMENTS=" + string(args), + "GLUE_SPARK_CONF=" + string(conf), + "PYTHONUNBUFFERED=1", + }, nil +} + +func prepareExchangeDir(base string) (string, error) { + dir, err := os.MkdirTemp(base, "glue-session-") + if err != nil { + return "", fmt.Errorf("create session dir: %w", err) + } + + for _, d := range []string{dir, filepath.Join(dir, "req"), filepath.Join(dir, "resp"), filepath.Join(dir, "cancel")} { + if err = os.MkdirAll(d, dirPerm); err != nil { + return dir, fmt.Errorf("create %s: %w", d, err) + } + + if err = os.Chmod(d, dirPerm); err != nil { + return dir, fmt.Errorf("chmod %s: %w", d, err) + } + } + + if err = os.WriteFile(filepath.Join(dir, "repl.py"), sessionReplScript, filePerm); err != nil { + return dir, fmt.Errorf("write repl: %w", err) + } + + return dir, nil +} + +func (e *dockerSessionEngine) StartSession(ctx context.Context, spec SessionSpec) (SessionProcess, error) { + env, err := sessionEnv(spec) + if err != nil { + return nil, err + } + + img := e.image(spec.GlueVersion) + + if err = e.ensureImage(ctx, img); err != nil { + return nil, err + } + + dir, err := prepareExchangeDir(e.cfg.WorkDir) + if err != nil { + _ = os.RemoveAll(dir) + + return nil, err + } + + id, err := e.cfg.Runtime.CreateAndStart(ctx, SessionContainerSpec{ + Image: img, + Entrypoint: []string{"python3", "-u", containerSessionDir + "/repl.py"}, + Env: env, + Mounts: []string{dir + ":" + containerSessionDir}, + }) + if err != nil { + _ = os.RemoveAll(dir) + + return nil, fmt.Errorf("start session container: %w", err) + } + + p := &dockerSession{rt: e.cfg.Runtime, containerID: id, dir: dir} + + if err = p.awaitReady(ctx, e.cfg.StartTimeout); err != nil { + _ = p.Stop(context.WithoutCancel(ctx)) + + return nil, err + } + + return p, nil +} + +func (e *dockerSessionEngine) ensureImage(ctx context.Context, img string) error { + if ok, err := e.cfg.Runtime.HasImage(ctx, img); err == nil && ok { + return nil + } + + if err := e.cfg.Runtime.PullImage(ctx, img); err != nil { + return fmt.Errorf("pull session image %q: %w", img, err) + } + + return nil +} + +type dockerSession struct { + rt SessionRuntime + containerID string + dir string + seq int + cur atomic.Int64 +} + +func (p *dockerSession) awaitReady(ctx context.Context, timeout time.Duration) error { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + + tick := time.NewTicker(replReadyPoll) + defer tick.Stop() + + lastCheck := time.Now() + + for { + if data, err := os.ReadFile(filepath.Join(p.dir, "ready.json")); err == nil { + var r struct { + Status string `json:"status"` + Message string `json:"message"` + } + if json.Unmarshal(data, &r) == nil && r.Status == "ok" { + return nil + } else if r.Status == "error" { + return fmt.Errorf("%w: %s", errReplStart, r.Message) + } + } + + select { + case <-ctx.Done(): + return fmt.Errorf("%w: %w", errReplStart, ctx.Err()) + case <-deadline.C: + return fmt.Errorf("%w: not ready after %s", errReplStart, timeout) + case <-tick.C: + } + + if time.Since(lastCheck) >= replLivenessEvery { + lastCheck = time.Now() + + if err := p.rt.Alive(ctx, p.containerID); err != nil { + return fmt.Errorf("%w: container exited: %w", errReplStart, err) + } + } + } +} + +type replResponse struct { + Text string `json:"text"` + ErrorName string `json:"ename"` + ErrorVal string `json:"evalue"` + Traceback []string `json:"traceback"` + Count int32 `json:"count"` + OK bool `json:"ok"` +} + +func writeAtomic(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, filePerm); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("rename %s: %w", tmp, err) + } + + return nil +} + +func (p *dockerSession) Run(ctx context.Context, code string) (StatementResult, error) { + p.seq++ + n := p.seq + + body, err := json.Marshal(map[string]string{"code": code}) + if err != nil { + return StatementResult{}, fmt.Errorf("encode statement: %w", err) + } + + p.cur.Store(int64(n)) + defer p.cur.Store(0) + + if err = writeAtomic(filepath.Join(p.dir, "req", strconv.Itoa(n)+".json"), body); err != nil { + return StatementResult{}, err + } + + resp, err := p.awaitResponse(ctx, n) + if err != nil { + return StatementResult{}, err + } + + return StatementResult{ + Text: resp.Text, ErrorName: resp.ErrorName, ErrorValue: resp.ErrorVal, + Traceback: resp.Traceback, ExecutionCount: resp.Count, Failed: !resp.OK, + }, nil +} + +func (p *dockerSession) awaitResponse(ctx context.Context, n int) (replResponse, error) { + path := filepath.Join(p.dir, "resp", strconv.Itoa(n)+".json") + tick := time.NewTicker(replPollInterval) + defer tick.Stop() + + lastCheck := time.Now() + + for { + if data, err := os.ReadFile(path); err == nil { + var r replResponse + if err = json.Unmarshal(data, &r); err != nil { + return r, fmt.Errorf("decode interpreter response: %w", err) + } + + return r, nil + } + + select { + case <-ctx.Done(): + return replResponse{}, fmt.Errorf("%w: %w", errSessionDone, ctx.Err()) + case <-tick.C: + } + + if time.Since(lastCheck) >= replLivenessEvery { + lastCheck = time.Now() + + if err := p.checkAlive(ctx); err != nil { + return replResponse{}, err + } + } + } +} + +func (p *dockerSession) checkAlive(ctx context.Context) error { + if err := p.rt.Alive(ctx, p.containerID); err != nil { + return fmt.Errorf("%w: %w", errReplDied, err) + } + + fi, err := os.Stat(filepath.Join(p.dir, "heartbeat")) + if err == nil && time.Since(fi.ModTime()) > replHeartbeatStale { + return errReplHung + } + + return nil +} + +func (p *dockerSession) Interrupt(context.Context) error { + n := p.cur.Load() + if n == 0 { + return nil + } + + return writeAtomic(filepath.Join(p.dir, "cancel", strconv.FormatInt(n, 10)), []byte("1")) +} + +func (p *dockerSession) Stop(ctx context.Context) error { + err := p.rt.StopAndRemove(ctx, p.containerID) + _ = os.RemoveAll(p.dir) + + if err != nil { + return fmt.Errorf("stop session container: %w", err) + } + + return nil +} diff --git a/services/glue/session_docker_test.go b/services/glue/session_docker_test.go new file mode 100644 index 0000000000..f45cb4e3da --- /dev/null +++ b/services/glue/session_docker_test.go @@ -0,0 +1,107 @@ +package glue_test + +import ( + "os" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/services/glue" +) + +const dockerSessionImage = "python:3.12-slim" + +func TestDockerSessionEngine_PersistentREPL(t *testing.T) { + t.Parallel() + + if os.Getenv("GOPHERSTACK_TEST_DOCKER") != "1" { + t.Skip("set GOPHERSTACK_TEST_DOCKER=1 to run docker-backed session engine tests") + } + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + + rt, err := container.NewRuntime(container.Config{}) + require.NoError(t, err) + t.Cleanup(func() { _ = rt.Close() }) + + b := glue.NewInMemoryBackend(testAccountID, testRegion) + b.SetSessionEngine(glue.NewDockerSessionEngine(glue.DockerSessionConfig{ + Runtime: glue.NewContainerSessionRuntime(rt), Image: dockerSessionImage, StartTimeout: 3 * time.Minute, + })) + t.Cleanup(b.StopReconciler) + + _, err = b.CreateSession( + "dk", "arn:aws:iam::123456789012:role/r", glue.SessionCommand{Name: "python"}, glue.Session{}, + ) + require.NoError(t, err) + + require.Eventually(t, func() bool { + s, gErr := b.GetSession("dk") + require.NoError(t, gErr) + require.NotEqual(t, "FAILED", s.Status, s.ErrorMessage) + + return s.Status == "READY" + }, 4*time.Minute, 200*time.Millisecond) + + run := func(code, state string) *glue.StatementOutput { + st, rErr := b.RunStatement("dk", code) + require.NoError(t, rErr) + + return outputOf(t, waitStatementFor(t, b, "dk", st.Id, state, 60*time.Second)) + } + + run("x = 21", "AVAILABLE") + + out := run("x * 2", "AVAILABLE") + require.NotNil(t, out.Data) + assert.Equal(t, "42", out.Data.TextPlain) + assert.Equal(t, int32(2), out.ExecutionCount) + + out = run("print('hi'); 1/0", "ERROR") + assert.Equal(t, "ZeroDivisionError", out.ErrorName) + assert.NotEmpty(t, out.Traceback) + + out = run("x + 1", "AVAILABLE") + assert.Equal(t, "22", out.Data.TextPlain) + + slow, err := b.RunStatement("dk", "import time\ntime.sleep(120)") + require.NoError(t, err) + waitStatementFor(t, b, "dk", slow.Id, "RUNNING", 30*time.Second) + require.NoError(t, b.CancelStatement("dk", slow.Id)) + waitStatementFor(t, b, "dk", slow.Id, "CANCELLED", 30*time.Second) + + out = run("x", "AVAILABLE") + assert.Equal(t, "21", out.Data.TextPlain) + + require.NoError(t, b.StopSession("dk")) + require.Eventually(t, func() bool { + s, gErr := b.GetSession("dk") + require.NoError(t, gErr) + + return s.Status == "STOPPED" + }, 30*time.Second, 50*time.Millisecond) +} + +func waitStatementFor( + t *testing.T, b *glue.InMemoryBackend, sid string, id int32, want string, d time.Duration, +) *glue.Statement { + t.Helper() + + var got *glue.Statement + + require.Eventually(t, func() bool { + st, err := b.GetStatement(sid, id) + require.NoError(t, err) + got = st + + return st.State == want + }, d, 50*time.Millisecond, "statement %d never reached %s", id, want) + + return got +} diff --git a/services/glue/session_engine.go b/services/glue/session_engine.go new file mode 100644 index 0000000000..d80737a565 --- /dev/null +++ b/services/glue/session_engine.go @@ -0,0 +1,488 @@ +package glue + +import ( + "context" + "fmt" + "strings" + "time" +) + +const ( + defaultSessionTimeoutMinutes = 2880 + engineDisabledMessage = "the interactive-session engine is disabled; start gopherstack with " + + "--glue-engine=docker (GLUE_ENGINE=docker, or --engines=docker) so statements run in a Spark/Python container" + sessionStopTimeout = 30 * time.Second +) + +// SessionSpec describes the interpreter a session needs. +type SessionSpec struct { + DefaultArguments map[string]string + SessionID string + CommandName string + PythonVersion string + GlueVersion string + WorkerType string + NumberOfWorkers int32 +} + +// StatementResult is the outcome of one executed statement. +type StatementResult struct { + Text string + ErrorName string + ErrorValue string + Traceback []string + ExecutionCount int32 + Failed bool +} + +// SessionProcess is a live interpreter holding a session's variables. +type SessionProcess interface { + // Run executes code to completion; cancelling ctx abandons it. + Run(ctx context.Context, code string) (StatementResult, error) + // Interrupt aborts the statement currently running, if any. + Interrupt(ctx context.Context) error + // Stop releases the interpreter and its container. + Stop(ctx context.Context) error +} + +// SessionEngine starts session interpreters. +type SessionEngine interface { + StartSession(ctx context.Context, spec SessionSpec) (SessionProcess, error) +} + +// StatementOutputData is Statement.Output.Data. +type StatementOutputData struct { + TextPlain string `json:"TextPlain,omitempty"` +} + +// StatementOutput is the SDK StatementOutput shape. +type StatementOutput struct { + Data *StatementOutputData `json:"Data,omitempty"` + Status string `json:"Status"` + ErrorName string `json:"ErrorName,omitempty"` + ErrorValue string `json:"ErrorValue,omitempty"` + Traceback []string `json:"Traceback,omitempty"` + ExecutionCount int32 `json:"ExecutionCount"` +} + +const ( + statusProvisioning = "PROVISIONING" + statusFailed = "FAILED" + statusTimeout = "TIMEOUT" +) + +const ( + stmtWaiting = "WAITING" + stmtRunning = "RUNNING" + stmtAvailable = "AVAILABLE" + stmtCancelling = "CANCELLING" + stmtCancelled = "CANCELLED" + stmtError = "ERROR" +) + +type liveSession struct { + ctx context.Context //nolint:containedctx // owned by the session worker + cancel context.CancelFunc + proc SessionProcess + wake chan struct{} + lastActivity time.Time + deadline time.Time + running *Statement +} + +// SetSessionEngine enables engine-backed interactive sessions; nil restores the disabled default. +func (b *InMemoryBackend) SetSessionEngine(e SessionEngine) { + b.mu.Lock("SetSessionEngine") + defer b.mu.Unlock() + + b.sessionEngine = e +} + +// SetClock overrides the clock used for session activity and timeouts. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + + b.clockFn = now +} + +func (b *InMemoryBackend) currentSessionEngine() SessionEngine { + b.mu.RLock("currentSessionEngine") + defer b.mu.RUnlock() + + return b.sessionEngine +} + +func (b *InMemoryBackend) clockNow() time.Time { + b.mu.RLock("clockNow") + fn := b.clockFn + b.mu.RUnlock() + + if fn == nil { + return time.Now() + } + + return fn() +} + +func (b *InMemoryBackend) trackWorkerLocked() { b.sessWorkers++ } + +func (b *InMemoryBackend) workerDone() { + b.mu.Lock("workerDone") + defer b.mu.Unlock() + + b.sessWorkers-- + if b.sessWorkers == 0 && b.sessIdle != nil { + close(b.sessIdle) + b.sessIdle = nil + } +} + +// waitSessionWorkers blocks until every session worker goroutine has exited. +func (b *InMemoryBackend) waitSessionWorkers() { + b.mu.Lock("waitSessionWorkers") + if b.sessWorkers == 0 { + b.mu.Unlock() + + return + } + + if b.sessIdle == nil { + b.sessIdle = make(chan struct{}) + } + + ch := b.sessIdle + b.mu.Unlock() + + <-ch +} + +// detachLiveSessions cancels every live session and returns once its worker has stopped the interpreter. +func (b *InMemoryBackend) detachLiveSessions() { + b.mu.Lock("detachLiveSessions") + + for id, ls := range b.liveSessions { + ls.cancel() + delete(b.liveSessions, id) + } + + b.mu.Unlock() + + b.waitSessionWorkers() +} + +// StopSessions stops every session container. +func (b *InMemoryBackend) StopSessions() { b.detachLiveSessions() } + +func sessionSpecOf(s *Session) SessionSpec { + return SessionSpec{ + SessionID: s.SessionID, + CommandName: s.Command.Name, + PythonVersion: s.Command.PythonVersion, + GlueVersion: s.GlueVersion, + WorkerType: s.WorkerType, + NumberOfWorkers: s.NumberOfWorkers, + DefaultArguments: cloneSession(s).DefaultArguments, + } +} + +func minutes(m int32) time.Duration { return time.Duration(m) * time.Minute } + +func sessionTimeouts(s *Session) (time.Duration, time.Duration) { + timeout := minutes(defaultSessionTimeoutMinutes) + if s.Timeout > 0 { + timeout = minutes(s.Timeout) + } + + idle := timeout + if s.IdleTimeout > 0 { + idle = minutes(s.IdleTimeout) + } + + return timeout, idle +} + +// launchSessionLocked registers a live session and starts its worker. b.mu must be held. +func (b *InMemoryBackend) launchSessionLocked(s *Session, now time.Time) { + timeout, _ := sessionTimeouts(s) + ctx, cancel := context.WithCancel(context.Background()) + ls := &liveSession{ + ctx: ctx, cancel: cancel, wake: make(chan struct{}, 1), + lastActivity: now, deadline: now.Add(timeout), + } + b.liveSessions[s.SessionID] = ls + b.trackWorkerLocked() + + go b.sessionWorker(ls, b.sessionEngine, sessionSpecOf(s)) +} + +func (b *InMemoryBackend) sessionWorker(ls *liveSession, eng SessionEngine, spec SessionSpec) { + defer b.workerDone() + + proc, err := eng.StartSession(ls.ctx, spec) + if err != nil { + b.failSession(ls, spec.SessionID, err) + + return + } + + defer b.retireSession(ls, spec.SessionID, proc) + + if !b.markSessionReady(ls, spec.SessionID, proc) { + return + } + + for { + st := b.nextStatement(ls, spec.SessionID) + if st == nil { + select { + case <-ls.wake: + continue + case <-ls.ctx.Done(): + return + } + } + + res, runErr := proc.Run(ls.ctx, st.Code) + if !b.finishStatement(ls, spec.SessionID, st, res, runErr) { + return + } + } +} + +func (b *InMemoryBackend) owns(id string, ls *liveSession) bool { return b.liveSessions[id] == ls } + +func (b *InMemoryBackend) failSession(ls *liveSession, id string, cause error) { + b.mu.Lock("failSession") + defer b.mu.Unlock() + + if !b.owns(id, ls) { + return + } + + delete(b.liveSessions, id) + ls.cancel() + + s, ok := b.sessions.Get(id) + if !ok { + return + } + + s.CompletedOn = float64(b.nowLocked().Unix()) + + if s.Status == stateStopping { + s.Status = stateStopped + + return + } + + s.Status = statusFailed + s.ErrorMessage = fmt.Sprintf("session interpreter failed to start: %v", cause) +} + +func (b *InMemoryBackend) nowLocked() time.Time { + if b.clockFn == nil { + return time.Now() + } + + return b.clockFn() +} + +func (b *InMemoryBackend) markSessionReady(ls *liveSession, id string, proc SessionProcess) bool { + b.mu.Lock("markSessionReady") + defer b.mu.Unlock() + + if !b.owns(id, ls) || ls.ctx.Err() != nil { + return false + } + + ls.proc = proc + ls.lastActivity = b.nowLocked() + + if s, ok := b.sessions.Get(id); ok && s.Status == statusProvisioning { + s.Status = stateReady + } + + return true +} + +// retireSession stops the interpreter and settles the session's final status. +func (b *InMemoryBackend) retireSession(ls *liveSession, id string, proc SessionProcess) { + ls.cancel() + + ctx, cancel := context.WithTimeout(context.WithoutCancel(ls.ctx), sessionStopTimeout) + defer cancel() + + _ = proc.Stop(ctx) + + b.mu.Lock("retireSession") + defer b.mu.Unlock() + + if !b.owns(id, ls) { + return + } + + delete(b.liveSessions, id) + b.settleStatementsLocked(id) + + if s, ok := b.sessions.Get(id); ok && s.Status == stateStopping { + s.Status = stateStopped + s.CompletedOn = float64(b.nowLocked().Unix()) + } +} + +func (b *InMemoryBackend) settleStatementsLocked(id string) { + now := float64(b.nowLocked().Unix()) + + for _, st := range b.sessionStatements[id] { + switch st.State { + case stmtWaiting, stmtRunning, stmtCancelling: + st.State = stmtCancelled + st.CompletedOn = now + } + } +} + +func (b *InMemoryBackend) nextStatement(ls *liveSession, id string) *Statement { + b.mu.Lock("nextStatement") + defer b.mu.Unlock() + + if !b.owns(id, ls) { + return nil + } + + for _, st := range b.sessionStatements[id] { + if st.State != stmtWaiting { + continue + } + + st.State = stmtRunning + st.StartedOn = float64(b.nowLocked().Unix()) + ls.running = st + + return cloneStatement(st) + } + + return nil +} + +// finishStatement records a statement's outcome; it reports whether the worker should keep going. +func (b *InMemoryBackend) finishStatement( + ls *liveSession, id string, snap *Statement, res StatementResult, runErr error, +) bool { + b.mu.Lock("finishStatement") + defer b.mu.Unlock() + + if !b.owns(id, ls) { + return false + } + + now := b.nowLocked() + ls.running = nil + ls.lastActivity = now + + for _, st := range b.sessionStatements[id] { + if st.Id != snap.Id { + continue + } + + st.CompletedOn = float64(now.Unix()) + st.Progress = 1 + applyResult(st, res, runErr) + } + + return ls.ctx.Err() == nil +} + +func applyResult(st *Statement, res StatementResult, runErr error) { + switch { + case st.State == stmtCancelling: + st.State = stmtCancelled + st.Output = &StatementOutput{Status: stmtCancelled, ExecutionCount: res.ExecutionCount} + case runErr != nil: + st.State = stmtError + st.Output = &StatementOutput{ + Status: stmtError, ErrorName: "SessionError", ErrorValue: runErr.Error(), + } + case res.Failed: + st.State = stmtError + st.Output = &StatementOutput{ + Status: stmtError, ErrorName: res.ErrorName, ErrorValue: res.ErrorValue, + Traceback: res.Traceback, ExecutionCount: res.ExecutionCount, + Data: &StatementOutputData{TextPlain: res.Text}, + } + default: + st.State = stmtAvailable + st.Output = &StatementOutput{ + Status: stmtAvailable, ExecutionCount: res.ExecutionCount, + Data: &StatementOutputData{TextPlain: res.Text}, + } + } +} + +// expireSessionsLocked moves live sessions past their idle or total timeout to TIMEOUT. +func (b *InMemoryBackend) expireSessionsLocked(now time.Time) { + for id, ls := range b.liveSessions { + if b.sessionExpiredLocked(id, ls, now) { + if s, ok := b.sessions.Get(id); ok { + s.Status = statusTimeout + s.CompletedOn = float64(now.Unix()) + } + + b.settleStatementsLocked(id) + ls.cancel() + } + } +} + +func (b *InMemoryBackend) sessionExpiredLocked(id string, ls *liveSession, now time.Time) bool { + s, ok := b.sessions.Get(id) + if !ok || s.Status != stateReady { + return false + } + + if !now.Before(ls.deadline) { + return true + } + + if ls.running != nil || b.hasWaitingLocked(id) { + return false + } + + _, idle := sessionTimeouts(s) + + return !now.Before(ls.lastActivity.Add(idle)) +} + +func (b *InMemoryBackend) hasWaitingLocked(id string) bool { + for _, st := range b.sessionStatements[id] { + if st.State == stmtWaiting { + return true + } + } + + return false +} + +func (b *InMemoryBackend) sessionExpiryDueLocked(now time.Time) bool { + for id, ls := range b.liveSessions { + if b.sessionExpiredLocked(id, ls, now) { + return true + } + } + + return false +} + +// parseSparkConf extracts key=value pairs from Glue's "--conf a=b --conf c=d" argument. +func parseSparkConf(v string) []string { + var out []string + + for part := range strings.SplitSeq(v, "--conf") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + + return out +} diff --git a/services/glue/session_engine_test.go b/services/glue/session_engine_test.go new file mode 100644 index 0000000000..082855d499 --- /dev/null +++ b/services/glue/session_engine_test.go @@ -0,0 +1,541 @@ +package glue_test + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + gluesdk "github.com/aws/aws-sdk-go-v2/service/glue" + "github.com/aws/aws-sdk-go-v2/service/glue/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/glue" +) + +var errFakeStart = errors.New("fake start failure") + +// fakeEngine is an in-memory REPL: "x = v" sets a variable, "x" reads it, +// "boom" fails, "block" waits for an interrupt. +type fakeEngine struct { + startErr error + procs []*fakeProc + mu sync.Mutex +} + +type fakeProc struct { + vars map[string]string + interrupt chan struct{} + executed []string + mu sync.Mutex + count int32 + stopped atomic.Bool + interrupted atomic.Bool +} + +func (e *fakeEngine) StartSession(_ context.Context, _ glue.SessionSpec) (glue.SessionProcess, error) { + if e.startErr != nil { + return nil, e.startErr + } + + p := &fakeProc{vars: map[string]string{}, interrupt: make(chan struct{}, 1)} + + e.mu.Lock() + e.procs = append(e.procs, p) + e.mu.Unlock() + + return p, nil +} + +func (e *fakeEngine) proc() *fakeProc { + e.mu.Lock() + defer e.mu.Unlock() + + if len(e.procs) == 0 { + return nil + } + + return e.procs[0] +} + +func (p *fakeProc) Run(ctx context.Context, code string) (glue.StatementResult, error) { + p.mu.Lock() + p.executed = append(p.executed, code) + p.count++ + count := p.count + p.mu.Unlock() + + switch { + case code == "boom": + return glue.StatementResult{ + Failed: true, ErrorName: "ValueError", ErrorValue: "bad", Traceback: []string{"tb"}, ExecutionCount: count, + }, nil + case code == "block": + select { + case <-p.interrupt: + return glue.StatementResult{Failed: true, ErrorName: "KeyboardInterrupt", ExecutionCount: count}, nil + case <-ctx.Done(): + return glue.StatementResult{}, ctx.Err() + } + case strings.Contains(code, "="): + k, v, _ := strings.Cut(code, "=") + p.mu.Lock() + p.vars[strings.TrimSpace(k)] = strings.TrimSpace(v) + p.mu.Unlock() + + return glue.StatementResult{ExecutionCount: count}, nil + default: + p.mu.Lock() + v := p.vars[code] + p.mu.Unlock() + + return glue.StatementResult{Text: v, ExecutionCount: count}, nil + } +} + +func (p *fakeProc) Interrupt(context.Context) error { + p.interrupted.Store(true) + p.interrupt <- struct{}{} + + return nil +} + +func (p *fakeProc) Stop(context.Context) error { + p.stopped.Store(true) + + return nil +} + +func (p *fakeProc) codes() []string { + p.mu.Lock() + defer p.mu.Unlock() + + return append([]string(nil), p.executed...) +} + +type fakeClock struct{ now atomic.Int64 } + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.now.Load()) } + +func (c *fakeClock) Advance(d time.Duration) { c.now.Add(int64(d)) } + +func newEngineBackend(t *testing.T, eng glue.SessionEngine) (*glue.InMemoryBackend, *fakeClock) { + t.Helper() + + b := glue.NewInMemoryBackend(testAccountID, testRegion) + clk := &fakeClock{} + clk.now.Store(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()) + b.SetClock(clk.Now) + b.SetSessionEngine(eng) + t.Cleanup(b.StopReconciler) + + return b, clk +} + +func createReady(t *testing.T, b *glue.InMemoryBackend, id string, opts glue.Session) { + t.Helper() + + _, err := b.CreateSession(id, "arn:aws:iam::123456789012:role/r", glue.SessionCommand{Name: "glueetl"}, opts) + require.NoError(t, err) + waitSessionStatus(t, b, id, "READY") +} + +func waitSessionStatus(t *testing.T, b *glue.InMemoryBackend, id, want string) { + t.Helper() + + require.Eventually(t, func() bool { + s, err := b.GetSession(id) + require.NoError(t, err) + + return s.Status == want + }, 5*time.Second, time.Millisecond, "session %s never reached %s", id, want) +} + +func waitStatement(t *testing.T, b *glue.InMemoryBackend, sid string, id int32, want string) *glue.Statement { + t.Helper() + + var got *glue.Statement + + require.Eventually(t, func() bool { + st, err := b.GetStatement(sid, id) + require.NoError(t, err) + got = st + + return st.State == want + }, 5*time.Second, time.Millisecond, "statement %d never reached %s", id, want) + + return got +} + +func outputOf(t *testing.T, st *glue.Statement) *glue.StatementOutput { + t.Helper() + + out, ok := st.Output.(*glue.StatementOutput) + require.True(t, ok, "output type %T", st.Output) + + return out +} + +func TestSessionEngine_Statements(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + codes []string + wantState []string + wantText []string + }{ + { + name: "variables persist", + codes: []string{"x = 42", "x"}, + wantState: []string{"AVAILABLE", "AVAILABLE"}, + wantText: []string{"", "42"}, + }, + { + name: "error then continue", + codes: []string{"a = 1", "boom", "a"}, + wantState: []string{"AVAILABLE", "ERROR", "AVAILABLE"}, + wantText: []string{"", "", "1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + eng := &fakeEngine{} + b, _ := newEngineBackend(t, eng) + createReady(t, b, "s", glue.Session{}) + + for i, code := range tt.codes { + st, err := b.RunStatement("s", code) + require.NoError(t, err) + + got := waitStatement(t, b, "s", st.Id, tt.wantState[i]) + out := outputOf(t, got) + assert.Equal(t, tt.wantState[i], out.Status) + assert.Equal(t, int32(i+1), out.ExecutionCount) + assert.InDelta(t, 1.0, got.Progress, 0) + assert.NotZero(t, got.StartedOn) + assert.NotZero(t, got.CompletedOn) + + if tt.wantState[i] == "AVAILABLE" { + require.NotNil(t, out.Data) + assert.Equal(t, tt.wantText[i], out.Data.TextPlain) + } else { + assert.Equal(t, "ValueError", out.ErrorName) + assert.Equal(t, "bad", out.ErrorValue) + assert.Equal(t, []string{"tb"}, out.Traceback) + } + } + }) + } +} + +func TestSessionEngine_OrderingAndState(t *testing.T) { + t.Parallel() + + eng := &fakeEngine{} + b, _ := newEngineBackend(t, eng) + createReady(t, b, "s", glue.Session{}) + + want := []string{"a = 1", "b = 2", "a", "b", "c = 3"} + var last int32 + + for _, c := range want { + st, err := b.RunStatement("s", c) + require.NoError(t, err) + last = st.Id + } + + waitStatement(t, b, "s", last, "AVAILABLE") + assert.Equal(t, want, eng.proc().codes()) +} + +func TestSessionEngine_Cancel(t *testing.T) { + t.Parallel() + + eng := &fakeEngine{} + b, _ := newEngineBackend(t, eng) + createReady(t, b, "s", glue.Session{}) + + blocked, err := b.RunStatement("s", "block") + require.NoError(t, err) + queued, err := b.RunStatement("s", "x = 1") + require.NoError(t, err) + + waitStatement(t, b, "s", blocked.Id, "RUNNING") + require.NoError(t, b.CancelStatement("s", queued.Id)) + waitStatement(t, b, "s", queued.Id, "CANCELLED") + + require.NoError(t, b.CancelStatement("s", blocked.Id)) + waitStatement(t, b, "s", blocked.Id, "CANCELLED") + assert.True(t, eng.proc().interrupted.Load()) + + after, err := b.RunStatement("s", "y = 1") + require.NoError(t, err) + waitStatement(t, b, "s", after.Id, "AVAILABLE") + assert.Equal(t, []string{"block", "y = 1"}, eng.proc().codes()) +} + +func TestSessionEngine_Lifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + act func(t *testing.T, b *glue.InMemoryBackend, clk *fakeClock) + name string + want string + opts glue.Session + }{ + { + name: "idle timeout", + opts: glue.Session{IdleTimeout: 5}, + act: func(_ *testing.T, b *glue.InMemoryBackend, clk *fakeClock) { + clk.Advance(6 * time.Minute) + glue.AdvanceStatesForTest(b, clk.Now()) + }, + want: "TIMEOUT", + }, + { + name: "total timeout despite activity", + opts: glue.Session{Timeout: 10, IdleTimeout: 5}, + act: func(t *testing.T, b *glue.InMemoryBackend, clk *fakeClock) { + t.Helper() + clk.Advance(4 * time.Minute) + _, err := b.RunStatement("s", "a = 1") + require.NoError(t, err) + clk.Advance(7 * time.Minute) + glue.AdvanceStatesForTest(b, clk.Now()) + }, + want: "TIMEOUT", + }, + { + name: "stop", + act: func(t *testing.T, b *glue.InMemoryBackend, _ *fakeClock) { + t.Helper() + require.NoError(t, b.StopSession("s")) + }, + want: "STOPPED", + }, + { + name: "not timed out before idle limit", + opts: glue.Session{IdleTimeout: 5}, + act: func(_ *testing.T, b *glue.InMemoryBackend, clk *fakeClock) { + clk.Advance(4 * time.Minute) + glue.AdvanceStatesForTest(b, clk.Now()) + }, + want: "READY", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + eng := &fakeEngine{} + b, clk := newEngineBackend(t, eng) + createReady(t, b, "s", tt.opts) + tt.act(t, b, clk) + waitSessionStatus(t, b, "s", tt.want) + + p := eng.proc() + if tt.want == "READY" { + assert.False(t, p.stopped.Load()) + + return + } + + require.Eventually(t, p.stopped.Load, 5*time.Second, time.Millisecond) + + _, err := b.RunStatement("s", "x") + require.ErrorIs(t, err, glue.ErrIllegalSessionState) + }) + } +} + +func TestSessionEngine_BusySessionDoesNotIdleOut(t *testing.T) { + t.Parallel() + + eng := &fakeEngine{} + b, clk := newEngineBackend(t, eng) + createReady(t, b, "s", glue.Session{IdleTimeout: 5}) + + st, err := b.RunStatement("s", "block") + require.NoError(t, err) + waitStatement(t, b, "s", st.Id, "RUNNING") + + clk.Advance(30 * time.Minute) + glue.AdvanceStatesForTest(b, clk.Now()) + + s, err := b.GetSession("s") + require.NoError(t, err) + assert.Equal(t, "READY", s.Status) +} + +func TestSessionEngine_TeardownStopsContainers(t *testing.T) { + t.Parallel() + + tests := []struct { + act func(b *glue.InMemoryBackend) + name string + }{ + {name: "delete", act: func(b *glue.InMemoryBackend) { require.NoError(t, b.DeleteSession("s0")) }}, + {name: "reset", act: func(b *glue.InMemoryBackend) { b.Reset() }}, + {name: "close", act: func(b *glue.InMemoryBackend) { b.Close() }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + eng := &fakeEngine{} + b, _ := newEngineBackend(t, eng) + createReady(t, b, "s0", glue.Session{}) + + st, err := b.RunStatement("s0", "block") + require.NoError(t, err) + waitStatement(t, b, "s0", st.Id, "RUNNING") + + tt.act(b) + require.Eventually(t, eng.proc().stopped.Load, 5*time.Second, time.Millisecond) + }) + } +} + +func TestSessionEngine_StartFailure(t *testing.T) { + t.Parallel() + + b, _ := newEngineBackend(t, &fakeEngine{startErr: errFakeStart}) + _, err := b.CreateSession("s", "r", glue.SessionCommand{Name: "glueetl"}, glue.Session{}) + require.NoError(t, err) + waitSessionStatus(t, b, "s", "FAILED") + + s, err := b.GetSession("s") + require.NoError(t, err) + assert.Contains(t, s.ErrorMessage, "fake start failure") + + _, err = b.RunStatement("s", "x") + require.ErrorIs(t, err, glue.ErrIllegalSessionState) +} + +func TestSessionEngine_RunBeforeReady(t *testing.T) { + t.Parallel() + + b := glue.NewInMemoryBackend(testAccountID, testRegion) + _, err := b.CreateSession("s", "r", glue.SessionCommand{Name: "glueetl"}, glue.Session{}) + require.NoError(t, err) + + _, err = b.RunStatement("s", "x") + require.ErrorIs(t, err, glue.ErrIllegalSessionState) +} + +func TestSessionEngineDisabled_StatementsError(t *testing.T) { + t.Parallel() + + client := newRealClient(t) + ctx := t.Context() + + _, err := client.CreateSession(ctx, &gluesdk.CreateSessionInput{ + Id: aws.String("s"), Role: aws.String("arn:aws:iam::123456789012:role/r"), + Command: &types.SessionCommand{Name: aws.String("glueetl")}, + }) + require.NoError(t, err) + + _, err = client.RunStatement(ctx, &gluesdk.RunStatementInput{SessionId: aws.String("s"), Code: aws.String("1")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "IllegalSessionStateException") + + require.Eventually(t, func() bool { + g, gErr := client.GetSession(ctx, &gluesdk.GetSessionInput{Id: aws.String("s")}) + require.NoError(t, gErr) + + return g.Session.Status == types.SessionStatusReady + }, 2*time.Second, 10*time.Millisecond) + + run, err := client.RunStatement(ctx, &gluesdk.RunStatementInput{SessionId: aws.String("s"), Code: aws.String("1")}) + require.NoError(t, err) + + got, err := client.GetStatement(ctx, &gluesdk.GetStatementInput{SessionId: aws.String("s"), Id: run.Id}) + require.NoError(t, err) + assert.Equal(t, types.StatementStateError, got.Statement.State) + require.NotNil(t, got.Statement.Output) + assert.Equal(t, types.StatementStateError, got.Statement.Output.Status) + assert.Contains(t, aws.ToString(got.Statement.Output.ErrorValue), "--glue-engine=docker") +} + +func TestSessionEngine_SDK(t *testing.T) { + t.Parallel() + + eng := &fakeEngine{} + backend := glue.NewInMemoryBackend(testAccountID, testRegion) + backend.SetSessionEngine(eng) + t.Cleanup(backend.StopReconciler) + + client := newTestGlueClient(t, glue.NewHandler(backend)) + ctx := t.Context() + + _, err := client.CreateSession(ctx, &gluesdk.CreateSessionInput{ + Id: aws.String("s"), Role: aws.String("arn:aws:iam::123456789012:role/r"), + Command: &types.SessionCommand{Name: aws.String("glueetl")}, + }) + require.NoError(t, err) + + require.Eventually(t, func() bool { + g, gErr := client.GetSession(ctx, &gluesdk.GetSessionInput{Id: aws.String("s")}) + require.NoError(t, gErr) + + return g.Session.Status == types.SessionStatusReady + }, 5*time.Second, time.Millisecond) + + var last int32 + + for _, code := range []string{"x = 7", "x"} { + run, rErr := client.RunStatement(ctx, &gluesdk.RunStatementInput{ + SessionId: aws.String("s"), Code: aws.String(code), + }) + require.NoError(t, rErr) + last = run.Id + } + + require.Eventually(t, func() bool { + g, gErr := client.GetStatement(ctx, &gluesdk.GetStatementInput{SessionId: aws.String("s"), Id: last}) + require.NoError(t, gErr) + + return g.Statement.State == types.StatementStateAvailable + }, 5*time.Second, time.Millisecond) + + got, err := client.GetStatement(ctx, &gluesdk.GetStatementInput{SessionId: aws.String("s"), Id: last}) + require.NoError(t, err) + require.NotNil(t, got.Statement.Output.Data) + assert.Equal(t, "7", aws.ToString(got.Statement.Output.Data.TextPlain)) + assert.Equal(t, types.StatementStateAvailable, got.Statement.Output.Status) + assert.Equal(t, int32(2), got.Statement.Output.ExecutionCount) + assert.InDelta(t, 1.0, got.Statement.Progress, 0) +} + +func waitHandlerSessionReady(t *testing.T, h *glue.Handler, id string) { + t.Helper() + + require.Eventually(t, func() bool { + out := dispatchNewOp(t, h, "GetSession", map[string]any{"Id": id}) + sess, ok := out["Session"].(map[string]any) + + return ok && sess["Status"] == "READY" + }, 5*time.Second, 10*time.Millisecond) +} + +func waitClientSessionReady(t *testing.T, client *gluesdk.Client, id string) { + t.Helper() + + require.Eventually(t, func() bool { + g, err := client.GetSession(t.Context(), &gluesdk.GetSessionInput{Id: aws.String(id)}) + require.NoError(t, err) + + return g.Session.Status == types.SessionStatusReady + }, 5*time.Second, 10*time.Millisecond) +} diff --git a/services/glue/session_repl.pyrepl b/services/glue/session_repl.pyrepl new file mode 100644 index 0000000000..8dedd5ba09 --- /dev/null +++ b/services/glue/session_repl.pyrepl @@ -0,0 +1,123 @@ +import ast +import contextlib +import io +import json +import os +import signal +import sys +import threading +import time +import traceback + +D = "/session" +ns = {"__name__": "__main__"} +state = {"cur": 0} + + +def put(path, obj): + tmp = path + ".tmp" + with open(tmp, "w") as f: + f.write(json.dumps(obj)) + os.chmod(tmp, 0o644) + os.rename(tmp, path) + + +def init_spark(): + from pyspark.conf import SparkConf + from pyspark.context import SparkContext + from awsglue.context import GlueContext + from awsglue.job import Job + + conf = SparkConf() + for kv in json.loads(os.environ.get("GLUE_SPARK_CONF", "[]")): + k, _, v = kv.partition("=") + conf.set(k, v) + sc = SparkContext.getOrCreate(conf=conf) + gc = GlueContext(sc) + ns.update(sc=sc, sparkContext=sc, glueContext=gc, spark=gc.spark_session, job=Job(gc)) + + +def run(code, count): + out = io.StringIO() + try: + tree = ast.parse(code, "", "exec") + last = None + if tree.body and isinstance(tree.body[-1], ast.Expr): + last = ast.Expression(tree.body.pop().value) + ast.fix_missing_locations(last) + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + exec(compile(tree, "", "exec"), ns) + if last is not None: + v = eval(compile(last, "", "eval"), ns) + if v is not None: + ns["_"] = v + out.write(repr(v)) + return {"ok": True, "text": out.getvalue(), "count": count} + except BaseException as e: + tb = [ln.rstrip("\n") for ln in traceback.format_exception(type(e), e, e.__traceback__)] + return {"ok": False, "text": out.getvalue(), "ename": type(e).__name__, + "evalue": str(e), "traceback": tb, "count": count} + + +def watcher(): + signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT}) + fired = 0 + while True: + try: + with open(os.path.join(D, "heartbeat"), "w") as f: + f.write(str(time.time())) + n = state["cur"] + if n and n != fired and os.path.exists(os.path.join(D, "cancel", str(n))): + fired = n + sc = ns.get("sc") + if sc is not None: + try: + sc.cancelAllJobs() + except Exception: + pass + os.kill(os.getpid(), signal.SIGINT) + except Exception: + pass + time.sleep(0.2) + + +def main(): + signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) + if os.environ.get("GLUE_SESSION_SPARK") == "1": + try: + init_spark() + except BaseException as e: + put(os.path.join(D, "ready.json"), {"status": "error", "message": "%s: %s" % (type(e).__name__, e)}) + sys.exit(1) + threading.Thread(target=watcher, daemon=True).start() + put(os.path.join(D, "ready.json"), {"status": "ok"}) + n = 0 + count = 0 + while True: + n += 1 + req = os.path.join(D, "req", "%d.json" % n) + while True: + try: + if os.path.exists(req): + break + time.sleep(0.02) + except KeyboardInterrupt: + pass + with open(req) as f: + code = json.load(f)["code"] + count += 1 + state["cur"] = n + if os.path.exists(os.path.join(D, "cancel", str(n))): + res = {"ok": False, "text": "", "ename": "KeyboardInterrupt", "evalue": "cancelled", + "traceback": [], "count": count} + else: + try: + res = run(code, count) + except KeyboardInterrupt: + res = {"ok": False, "text": "", "ename": "KeyboardInterrupt", "evalue": "cancelled", + "traceback": [], "count": count} + state["cur"] = 0 + put(os.path.join(D, "resp", "%d.json" % n), res) + + +main() diff --git a/services/glue/session_repl_test.go b/services/glue/session_repl_test.go new file mode 100644 index 0000000000..f00c8c337e --- /dev/null +++ b/services/glue/session_repl_test.go @@ -0,0 +1,21 @@ +package glue_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSessionReplScriptCommitted(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile("session_repl.pyrepl") + require.NoError(t, err) + require.NotEmpty(t, data) + + for _, marker := range []string{`"req"`, `"resp"`, "ready.json", "def run(", "def main("} { + assert.Contains(t, string(data), marker) + } +} diff --git a/services/glue/sessions.go b/services/glue/sessions.go index 215bc9fbda..404e1fb3fe 100644 --- a/services/glue/sessions.go +++ b/services/glue/sessions.go @@ -1,6 +1,7 @@ package glue import ( + "context" "fmt" "maps" "slices" @@ -35,7 +36,7 @@ func (b *InMemoryBackend) CreateSession( cmd SessionCommand, opts Session, ) (*Session, error) { - now := time.Now() + now := b.clockNow() b.mu.Lock("CreateSession") defer b.mu.Unlock() @@ -47,7 +48,7 @@ func (b *InMemoryBackend) CreateSession( SessionID: id, Role: role, Command: cmd, - Status: "PROVISIONING", + Status: statusProvisioning, CreatedOn: float64(now.Unix()), Timeout: opts.Timeout, IdleTimeout: opts.IdleTimeout, @@ -64,13 +65,18 @@ func (b *InMemoryBackend) CreateSession( } b.sessions.Put(s) b.sessionStatements[id] = nil - b.sessionReadyAt[id] = now.Add(sessionTransitionDelay) + + if b.sessionEngine != nil { + b.launchSessionLocked(s, now) + } else { + b.sessionReadyAt[id] = now.Add(sessionTransitionDelay) + } return cloneSession(s), nil } func (b *InMemoryBackend) GetSession(id string) (*Session, error) { - b.advanceStates(time.Now()) + b.advanceStates(b.clockNow()) b.mu.RLock("GetSession") defer b.mu.RUnlock() @@ -84,7 +90,7 @@ func (b *InMemoryBackend) GetSession(id string) (*Session, error) { } func (b *InMemoryBackend) ListSessions() []*Session { - b.advanceStates(time.Now()) + b.advanceStates(b.clockNow()) b.mu.RLock("ListSessions") defer b.mu.RUnlock() @@ -103,7 +109,7 @@ func (b *InMemoryBackend) ListSessions() []*Session { // has no EntityNotFoundException case, unlike GetSession's, so an unknown Id // surfaces as InvalidInputException. func (b *InMemoryBackend) DeleteSession(id string) error { - b.advanceStates(time.Now()) + b.advanceStates(b.clockNow()) b.mu.Lock("DeleteSession") defer b.mu.Unlock() @@ -112,6 +118,12 @@ func (b *InMemoryBackend) DeleteSession(id string) error { return fmt.Errorf("session %q not found: %w", id, ErrValidation) } b.sessions.Delete(id) + + if ls, live := b.liveSessions[id]; live { + delete(b.liveSessions, id) + ls.cancel() + } + delete(b.sessionStatements, id) delete(b.sessionReadyAt, id) delete(b.sessionStopAt, id) @@ -122,7 +134,7 @@ func (b *InMemoryBackend) DeleteSession(id string) error { // StopSession stops a session. Its error switch also has no // EntityNotFoundException case. func (b *InMemoryBackend) StopSession(id string) error { - now := time.Now() + now := b.clockNow() b.advanceStates(now) @@ -135,31 +147,76 @@ func (b *InMemoryBackend) StopSession(id string) error { } s.Status = stateStopping delete(b.sessionReadyAt, id) + + if ls, live := b.liveSessions[id]; live { + ls.cancel() + + return nil + } + b.sessionStopAt[id] = now.Add(sessionTransitionDelay) return nil } +// RunStatement queues code on a READY session. Without a session engine the +// statement fails immediately with a message naming how to enable one. func (b *InMemoryBackend) RunStatement(sessionID, code string) (*Statement, error) { + b.advanceStates(b.clockNow()) + b.mu.Lock("RunStatement") defer b.mu.Unlock() - if !b.sessions.Has(sessionID) { + s, ok := b.sessions.Get(sessionID) + if !ok { return nil, fmt.Errorf("session %q not found: %w", sessionID, ErrNotFound) } + + if s.Status != stateReady { + return nil, fmt.Errorf( + "%w: session %q is not READY (status=%s)", ErrIllegalSessionState, sessionID, s.Status) + } + + now := b.nowLocked() stmts := b.sessionStatements[sessionID] st := &Statement{ Id: int32(len(stmts) + 1), //nolint:gosec // statement count always small SessionId: sessionID, Code: code, - State: "WAITING", - StartedOn: float64(time.Now().Unix()), + State: stmtWaiting, } + + ls := b.liveSessions[sessionID] + + switch { + case b.sessionEngine == nil: + failStatement(st, "EngineNotEnabled", engineDisabledMessage, now) + case ls == nil: + s.Status = statusFailed + s.ErrorMessage = "session interpreter is no longer running (lost across a restart)" + + return nil, fmt.Errorf("%w: session %q interpreter is gone", ErrIllegalSessionState, sessionID) + default: + ls.lastActivity = now + + select { + case ls.wake <- struct{}{}: + default: + } + } + b.sessionStatements[sessionID] = append(stmts, st) return cloneStatement(st), nil } +func failStatement(st *Statement, name, msg string, now time.Time) { + st.State = stmtError + st.CompletedOn = float64(now.Unix()) + st.Progress = 1 + st.Output = &StatementOutput{Status: stmtError, ErrorName: name, ErrorValue: msg} +} + func (b *InMemoryBackend) GetStatement(sessionID string, statementID int32) (*Statement, error) { b.mu.RLock("GetStatement") defer b.mu.RUnlock() @@ -198,23 +255,50 @@ func (b *InMemoryBackend) GetStatements(sessionID string) ([]*Statement, error) return out, nil } +// CancelStatement cancels a WAITING statement outright and interrupts a RUNNING one (CANCELLING, then CANCELLED). func (b *InMemoryBackend) CancelStatement(sessionID string, statementID int32) error { + proc, err := b.cancelStatementLocked(sessionID, statementID) + if err != nil || proc == nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), sessionStopTimeout) + defer cancel() + + return proc.Interrupt(ctx) +} + +func (b *InMemoryBackend) cancelStatementLocked(sessionID string, statementID int32) (SessionProcess, error) { b.mu.Lock("CancelStatement") defer b.mu.Unlock() stmts, ok := b.sessionStatements[sessionID] if !ok { - return fmt.Errorf("session %q not found: %w", sessionID, ErrNotFound) + return nil, fmt.Errorf("session %q not found: %w", sessionID, ErrNotFound) } + for _, st := range stmts { - if st.Id == statementID { - st.State = "CANCELLING" + if st.Id != statementID { + continue + } - return nil + switch st.State { + case stmtWaiting: + st.State = stmtCancelled + st.CompletedOn = float64(b.nowLocked().Unix()) + st.Output = &StatementOutput{Status: stmtCancelled} + case stmtRunning: + st.State = stmtCancelling + + if ls := b.liveSessions[sessionID]; ls != nil { + return ls.proc, nil + } } + + return nil, nil //nolint:nilnil // nothing to interrupt } - return fmt.Errorf( + return nil, fmt.Errorf( "statement %d not found in session %q: %w", statementID, sessionID, diff --git a/services/glue/settings.go b/services/glue/settings.go new file mode 100644 index 0000000000..1c228b0f7f --- /dev/null +++ b/services/glue/settings.go @@ -0,0 +1,18 @@ +package glue + +// Engine modes for interactive sessions. +const ( + EngineStub = "stub" + EngineDocker = "docker" +) + +// Settings holds service-level configuration for the Glue backend. +type Settings struct { + Engine string `json:"engine" env:"GLUE_ENGINE" default:"stub" help:"Glue interactive-session engine: stub (statements fail with an engine-disabled error) or docker (run statements in a Spark/Python container)."` //nolint:lll // Kong struct tag makes this line long + EngineImage string `json:"engine_image" env:"GLUE_ENGINE_IMAGE" default:"" help:"Image for docker-engine sessions; empty picks the AWS Glue libs image matching the session GlueVersion."` //nolint:lll // Kong struct tag makes this line long +} + +// ConfigProvider supplies Glue settings from the application config. +type ConfigProvider interface { + GetGlueSettings() Settings +} diff --git a/services/glue/store.go b/services/glue/store.go index a80ca2a526..9584cae29b 100644 --- a/services/glue/store.go +++ b/services/glue/store.go @@ -27,6 +27,9 @@ var ErrAlreadyExists = awserr.New("AlreadyExistsException", awserr.ErrAlreadyExi // awsAwsjson11_deserializeOpErrorDeleteGlossary/DeleteFormType error switches. var ErrConflict = awserr.New("ConflictException", awserr.ErrConflict) +// ErrTableParameterChanged is returned by SwapTableParameters when the guarded parameter moved. +var ErrTableParameterChanged = awserr.New("ConcurrentModificationException", awserr.ErrConflict) + // ErrConcurrentRunsExceeded is returned by StartJobRun/StartWorkflowRun when // the job/workflow's MaxConcurrentRuns limit is already reached, mirroring // AWS's ConcurrentRunsExceededException (confirmed in @@ -208,6 +211,13 @@ type InMemoryBackend struct { ramShareCreator ResourceShareCreator crawlerS3 CrawlerS3 crawlerDDB CrawlerDynamoDB + crawlerSecrets CrawlerSecrets + + // engine-backed interactive sessions; liveSessions is never persisted + sessionEngine SessionEngine + liveSessions map[string]*liveSession + clockFn func() time.Time + sessIdle chan struct{} // lifecycle reconciler timers jobRunReadyAt map[string]map[string]time.Time // jobName → runID → readyAt for STARTING→RUNNING @@ -270,6 +280,7 @@ type InMemoryBackend struct { reconcileMu sync.Mutex reconcileInterval time.Duration reconcileOn bool + sessWorkers int } // NewInMemoryBackend creates a new in-memory Glue backend. @@ -303,6 +314,7 @@ func NewInMemoryBackend(accountID, region string) *InMemoryBackend { integrationReadyAt: make(map[string]time.Time), sessionReadyAt: make(map[string]time.Time), sessionStopAt: make(map[string]time.Time), + liveSessions: make(map[string]*liveSession), } registerAllTables(b) @@ -323,6 +335,8 @@ func (b *InMemoryBackend) WithResourceLimits(l ResourceLimits) *InMemoryBackend // Reset clears all backend state, returning it to the initial empty state. func (b *InMemoryBackend) Reset() { + b.detachLiveSessions() + b.mu.Lock("Reset") defer b.mu.Unlock() diff --git a/services/glue/tables.go b/services/glue/tables.go index ca9b129a22..fc009e124c 100644 --- a/services/glue/tables.go +++ b/services/glue/tables.go @@ -278,6 +278,34 @@ func (b *InMemoryBackend) UpdateTable(dbName string, input TableInput) error { return nil } +// SwapTableParameters applies set to the table's parameters only if guardKey still holds guardValue +// (optimistic locking for catalog-committed Iceberg metadata). +func (b *InMemoryBackend) SwapTableParameters( + dbName, tableName, guardKey, guardValue string, set map[string]string, +) error { + dbName, tableName = strs.Fold(dbName), strs.Fold(tableName) + b.mu.Lock("SwapTableParameters") + defer b.mu.Unlock() + + t, ok := b.tables.Get(tableKey(dbName, tableName)) + if !ok { + return ErrNotFound + } + + if t.Parameters[guardKey] != guardValue { + return ErrTableParameterChanged + } + + if t.Parameters == nil { + t.Parameters = map[string]string{} + } + + maps.Copy(t.Parameters, set) + t.UpdateTime = float64(time.Now().Unix()) + + return nil +} + // DeleteTable deletes a Glue table and all its partitions. func (b *InMemoryBackend) DeleteTable(dbName, tableName string) error { dbName, tableName = strs.Fold(dbName), strs.Fold(tableName) diff --git a/services/glue/tables_test.go b/services/glue/tables_test.go index ff38cfe99f..f92c478913 100644 --- a/services/glue/tables_test.go +++ b/services/glue/tables_test.go @@ -2,6 +2,7 @@ package glue_test import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -104,6 +105,7 @@ func TestExtendedStateSnapshotRestore(t *testing.T) { require.Empty(t, errs) _, err = b.CreateSession("session", "role", glue.SessionCommand{}, glue.Session{}) require.NoError(t, err) + glue.AdvanceStatesForTest(b, time.Now().Add(time.Minute)) _, err = b.RunStatement("session", "select 1") require.NoError(t, err) require.NoError( diff --git a/services/glue/undeclared_members_test.go b/services/glue/undeclared_members_test.go index 7e1bd5695d..0836fab113 100644 --- a/services/glue/undeclared_members_test.go +++ b/services/glue/undeclared_members_test.go @@ -29,6 +29,7 @@ func TestHandler_StatementOmitsSessionID(t *testing.T) { dispatchNewOp(t, h, "CreateSession", map[string]any{ "Id": "s", "Role": "r", "Command": map[string]any{"Name": "glueetl"}, }) + waitHandlerSessionReady(t, h, "s") run := dispatchNewOp(t, h, "RunStatement", map[string]any{"SessionId": "s", "Code": tc.code}) id := run["Id"] @@ -67,6 +68,8 @@ func TestSDK_StatementFieldsSurvive(t *testing.T) { }) require.NoError(t, err) + waitClientSessionReady(t, client, "s") + run, err := client.RunStatement(ctx, &gluesdk.RunStatementInput{ SessionId: aws.String("s"), Code: aws.String(tc.code), }) diff --git a/services/grafana/iam_enforcement_test.go b/services/grafana/iam_enforcement_test.go index 966aa291b9..cac0eb5d84 100644 --- a/services/grafana/iam_enforcement_test.go +++ b/services/grafana/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/grafana" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -172,7 +173,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/grafana/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/guardduty/iam_enforcement_test.go b/services/guardduty/iam_enforcement_test.go index 0199ec95e2..9879620e90 100644 --- a/services/guardduty/iam_enforcement_test.go +++ b/services/guardduty/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/guardduty" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/guardduty/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/guardduty/wire_field_fixes_test.go b/services/guardduty/wire_field_fixes_test.go index d5eec3282c..889c77444b 100644 --- a/services/guardduty/wire_field_fixes_test.go +++ b/services/guardduty/wire_field_fixes_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/guardduty" ) @@ -355,7 +356,7 @@ func TestListMalwareProtectionPlans_NoInventedArn(t *testing.T) { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/malware-protection-plan", nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) diff --git a/services/iam/PARITY.md b/services/iam/PARITY.md index 2c1ffee90f..bb8729ce15 100644 --- a/services/iam/PARITY.md +++ b/services/iam/PARITY.md @@ -153,6 +153,8 @@ structural_gaps: items_still_open: - "AcquireRole ReplacementValues with several Values: neither api_op_AcquireRole.go, API_ReplacementValueEntry.html nor the role-template user guide says how a multi-value parameter is substituted into the template (checked 2026-10-07)." - "SimulateCustomPolicy/SimulatePrincipalPolicy MatchedStatements are not emitted: the API reference examples contradict each other on SourcePolicyType (\"Resource Policy\" vs the SDK enum \"resource\") and report Line 1 positions for multi-line input, so the Start/EndPosition and SourcePolicyType conventions cannot be derived. Top-level EvalResourceName stays '*'." + - "Resource control policies (RCPs) are not evaluated in the enforcement path: they constrain resources in member accounts for any principal, and resolving the owning account from S3-style account-less resource ARNs is not derivable. SCPs are enforced (scp.go)." + - "The AWS root user of a member account is not SCP-restricted: enforcement skips root principals entirely (resolveSTSUserIdentityPolicies), whereas SCPs apply to member-account root." --- ## Notes @@ -952,3 +954,13 @@ Create{User,Role,Group,Policy,InstanceProfile} validate name pattern and max len - Service-linked roles: canonical AWS names for well-known principals (`AWSServiceRoleForElasticLoadBalancing`, `...ForECS`, `...ForAutoScaling`), `Description` and `MaxSessionDuration` persisted, DeleteServiceLinkedRole of a missing role is NoSuchEntity (was a fake task), GetServiceLinkedRoleDeletionStatus rejects ids not issued by DeleteServiceLinkedRole. - `GetUserIDByAccessKeyID` added so STS GetCallerIdentity can report the IAM user's AIDA id. - Kept lenient: CreateServiceLinkedRole accepts any `*.amazonaws.com` principal (AWS rejects unsupported services; the supported list is not in the SDK). + +## 2026-10-31 SCP evaluation in IAM enforcement + +With `--enforce-iam` (or audit mode) on, an otherwise-allowed request from a member-account principal is additionally gated on the Organizations SCPs for that account (`scp.go`, wired in `internal/app/iam_policy_stream.go`; `--iam-enforce-scps=false` disables). Rules, verified against the AWS Organizations User Guide pages "Service control policies (SCPs)" (SCP effects on permissions; Tasks and entities not restricted by SCPs) and "SCP evaluation": + +- An Allow is needed at every level root -> each OU on the path -> account (intersection); any Deny at any level denies. Evaluated with the existing evaluator (`ExplainPolicies`), so Condition/NotAction/Resource work. +- Only applies when SERVICE_CONTROL_POLICY is enabled on the root. Management-account principals and service-linked roles (`role/aws-service-role/...` path or `AWSServiceRoleFor*` name) are exempt. Accounts outside the organization are not affected. +- Denial text follows the IAM User Guide "Troubleshoot access denied error messages": `User: is not authorized to perform: with an explicit deny in a service control policy[: ]` / `... because no service control policy allows the action`. Policy stream events carry `reason=service-control-policy`, `policy.type=service-control-policy`. +- Tests: `internal/app/iam_scp_test.go` (SDK-driven full app), `services/iam/scp_whitebox_test.go`. +- Not verified: whether S3 and other non-IAM-format services embed this exact message verbatim; the docs state some services do not support the format. diff --git a/services/iam/README.md b/services/iam/README.md index 1aa05cc710..0a86b04ac7 100644 --- a/services/iam/README.md +++ b/services/iam/README.md @@ -9,7 +9,7 @@ | --- | --- | | PARITY entries audited | 38 (38 ok) | | Feature families | 7 (7 ok) | -| Known gaps | 2 | +| Known gaps | 4 | | Structural gaps (can't be emulated) | 4 | | Deferred items | 0 | | Resource leaks | clean | @@ -18,6 +18,8 @@ - AcquireRole ReplacementValues with several Values: neither api_op_AcquireRole.go, API_ReplacementValueEntry.html nor the role-template user guide says how a multi-value parameter is substituted into the template (checked 2026-10-07). - SimulateCustomPolicy/SimulatePrincipalPolicy MatchedStatements are not emitted: the API reference examples contradict each other on SourcePolicyType ("Resource Policy" vs the SDK enum "resource") and report Line 1 positions for multi-line input, so the Start/EndPosition and SourcePolicyType conventions cannot be derived. Top-level EvalResourceName stays '*'. +- Resource control policies (RCPs) are not evaluated in the enforcement path: they constrain resources in member accounts for any principal, and resolving the owning account from S3-style account-less resource ARNs is not derivable. SCPs are enforced (scp.go). +- The AWS root user of a member account is not SCP-restricted: enforcement skips root principals entirely (resolveSTSUserIdentityPolicies), whereas SCPs apply to member-account root. ### Structural gaps diff --git a/services/iam/iam_enforcement_test.go b/services/iam/iam_enforcement_test.go index e4405ca2d0..38dfe3c08a 100644 --- a/services/iam/iam_enforcement_test.go +++ b/services/iam/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" ) @@ -155,7 +156,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/iam/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/iam/middleware.go b/services/iam/middleware.go index d9a71227a4..547735ed14 100644 --- a/services/iam/middleware.go +++ b/services/iam/middleware.go @@ -1,6 +1,7 @@ package iam import ( + "cmp" "context" "encoding/json" "encoding/xml" @@ -52,6 +53,8 @@ type EnforcementConfig struct { Global *config.GlobalConfig `json:"global,omitempty"` // Recorder, when non-nil, receives one event per evaluated request. Recorder *iampolicystream.Recorder `json:"-"` + // SCPs, when non-nil, gates otherwise-allowed requests on the caller account's service control policies. + SCPs SCPProvider `json:"-"` // ResourceProviders is a list of backends that can return resource-based // policies (e.g. S3 bucket policies, SQS queue policies). ResourceProviders []ResourcePolicyProvider `json:"resourceProviders,omitempty"` @@ -460,6 +463,9 @@ func enforceIAMPolicy(c *echo.Context, next echo.HandlerFunc, backend Enforcemen } dec := decideScoped(policyDocs, boundaryDocs, resourceDocs, action, matchResource, condCtx, scope) + + dec, scpMsg := cfg.applySCPs(dec, action, matchResource, condCtx) + if cfg.Recorder != nil { dec = dec.attribute(backend, attribution{ principalArn: condCtx.PrincipalARN, resourceArn: resourceARN, identity: policyDocs, resource: resourceDocs, @@ -468,6 +474,17 @@ func enforceIAMPolicy(c *echo.Context, next echo.HandlerFunc, backend Enforcemen cfg.record(c, dec, action, matchResource, condCtx.PrincipalARN, callerName) + return cfg.finish(c, next, dec, scope, scpMsg, callerName, action, matchResource) +} + +// finish lets an allowed (or audited) request through and otherwise writes the denial. +func (cfg EnforcementConfig) finish( + c *echo.Context, next echo.HandlerFunc, dec requestDecision, scope RequestScope, + scpMsg, callerName, action, matchResource string, +) error { + ctx := c.Request().Context() + log := logger.Load(ctx) + if dec.allow { return next(c) } @@ -483,6 +500,10 @@ func enforceIAMPolicy(c *echo.Context, next echo.HandlerFunc, backend Enforcemen "caller", callerName, "action", action, "resource", matchResource, "reason", dec.reason, "matchedSid", dec.match.Sid) + if scpMsg != "" && scope.Deny == nil { + return writeAccessDeniedMsg(c, action, matchResource, scpMsg) + } + return scope.deny(c, action, matchResource) } @@ -725,10 +746,10 @@ func isS3Request(r *http.Request) bool { return true } -func writeJSONRPCAccessDenied(c *echo.Context, action string) error { +func writeJSONRPCAccessDenied(c *echo.Context, action, msg string) error { resp := jsonRPCErrorResponse{ Type: "com.amazon.coral.service#AccessDeniedException", - Message: "User is not authorized to perform: " + action, + Message: cmp.Or(msg, "User is not authorized to perform: "+action), } body, err := json.Marshal(resp) @@ -757,15 +778,15 @@ func isRESTJSONAction(action string) bool { } // writeRESTJSONAccessDenied answers with the code in X-Amzn-Errortype, which REST-JSON SDKs read first. -func writeRESTJSONAccessDenied(c *echo.Context, action string) error { +func writeRESTJSONAccessDenied(c *echo.Context, action, msg string) error { c.Response().Header().Set("X-Amzn-Errortype", "AccessDeniedException") return c.JSON(http.StatusForbidden, map[string]string{ - "message": "User is not authorized to perform: " + action, + "message": cmp.Or(msg, "User is not authorized to perform: "+action), }) } -func writeS3AccessDenied(c *echo.Context, resource string) error { +func writeS3AccessDenied(c *echo.Context, resource, msg string) error { reqID := c.Response().Header().Get("X-Amz-Request-Id") if reqID == "" { reqID = "gopherstack-request" @@ -773,7 +794,7 @@ func writeS3AccessDenied(c *echo.Context, resource string) error { resp := s3AccessDeniedError{ Code: codeAccessDenied, - Message: "Access Denied", + Message: cmp.Or(msg, "Access Denied"), Resource: resource, RequestID: reqID, HostID: "gopherstack", @@ -789,12 +810,12 @@ func writeS3AccessDenied(c *echo.Context, resource string) error { return c.XMLBlob(http.StatusForbidden, append([]byte(xml.Header), body...)) } -func writeQueryXMLAccessDenied(c *echo.Context, action string) error { +func writeQueryXMLAccessDenied(c *echo.Context, action, msg string) error { resp := accessDeniedResponse{ Xmlns: iamXMLNS, Error: iamDeniedError{ Code: "AccessDenied", - Message: "User is not authorized to perform: " + action, + Message: cmp.Or(msg, "User is not authorized to perform: "+action), Type: "Sender", }, RequestID: c.Response().Header().Get("X-Amz-Request-Id"), @@ -812,20 +833,25 @@ func writeQueryXMLAccessDenied(c *echo.Context, action string) error { // writeAccessDenied writes a protocol-appropriate access denied error response. func writeAccessDenied(c *echo.Context, action, resource string) error { + return writeAccessDeniedMsg(c, action, resource, "") +} + +// writeAccessDeniedMsg is writeAccessDenied with msg replacing the default text when non-empty. +func writeAccessDeniedMsg(c *echo.Context, action, resource, msg string) error { r := c.Request() if isJSONRPCRequest(r) { - return writeJSONRPCAccessDenied(c, action) + return writeJSONRPCAccessDenied(c, action, msg) } if isRESTJSONAction(action) { - return writeRESTJSONAccessDenied(c, action) + return writeRESTJSONAccessDenied(c, action, msg) } if isS3Request(r) { - return writeS3AccessDenied(c, resource) + return writeS3AccessDenied(c, resource, msg) } - return writeQueryXMLAccessDenied(c, action) + return writeQueryXMLAccessDenied(c, action, msg) } // canonicalDocs rewrites non-commercial ARN partitions to the form the evaluator matches against. diff --git a/services/iam/policy_attribution.go b/services/iam/policy_attribution.go index ee8925d1c0..9e965140d2 100644 --- a/services/iam/policy_attribution.go +++ b/services/iam/policy_attribution.go @@ -106,6 +106,7 @@ const ( sourceIdentity sourceResource sourceBoundary + sourceSCP ) // boundaryTrace is how the permissions boundary evaluated a request. @@ -137,7 +138,7 @@ func (d requestDecision) attribute(backend EnforcementBackend, in attribution) r d.policy = &iampolicystream.PolicyRef{Type: iampolicystream.PolicyResource, Owner: in.resourceArn} case sourceIdentity: d.policy = identityPolicyRef(at, in, d.match.PolicyIndex) - case sourceNone: + case sourceSCP, sourceNone: } return d diff --git a/services/iam/policy_stream.go b/services/iam/policy_stream.go index 59e7d826d1..04162c7c0e 100644 --- a/services/iam/policy_stream.go +++ b/services/iam/policy_stream.go @@ -11,11 +11,12 @@ import ( ) const ( - reasonIdentityPolicy = iampolicystream.ReasonIdentityPolicy - reasonResourcePolicy = iampolicystream.ReasonResourcePolicy - reasonPermissionBoundary = iampolicystream.ReasonPermissionBound - reasonImplicitDeny = iampolicystream.ReasonImplicitDeny - reasonUnresolvedCaller = iampolicystream.ReasonUnresolvedCaller + reasonIdentityPolicy = iampolicystream.ReasonIdentityPolicy + reasonResourcePolicy = iampolicystream.ReasonResourcePolicy + reasonPermissionBoundary = iampolicystream.ReasonPermissionBound + reasonImplicitDeny = iampolicystream.ReasonImplicitDeny + reasonUnresolvedCaller = iampolicystream.ReasonUnresolvedCaller + reasonServiceControlPolicy = iampolicystream.ReasonServiceControl ) // requestDecision is the outcome of evaluating one request. For a deny, reason diff --git a/services/iam/scp.go b/services/iam/scp.go new file mode 100644 index 0000000000..c1a2d7abe5 --- /dev/null +++ b/services/iam/scp.go @@ -0,0 +1,141 @@ +package iam + +import ( + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/iampolicystream" +) + +// SCPPolicy is one service control policy attached to a level. +type SCPPolicy struct { + ARN string + Document string +} + +// SCPLevel is the SCPs attached directly to one node (root, OU or account) on an account's path. +type SCPLevel struct { + TargetID string + Policies []SCPPolicy +} + +// SCPProvider resolves the SCPs governing an account. +type SCPProvider interface { + // SCPsForAccount returns the levels root-first. ok is false when SCPs do not + // apply: no organization, SCPs not enabled on the root, a management + // account, or an account outside the organization. + SCPsForAccount(accountID string) (levels []SCPLevel, ok bool) +} + +const serviceLinkedRoleNamePrefix = "AWSServiceRoleFor" + +// scpExemptPrincipal reports whether SCPs never restrict principalArn: service-linked roles. +func scpExemptPrincipal(principalArn string) bool { + if strings.Contains(principalArn, ":role/aws-service-role/") { + return true + } + + return strings.HasPrefix(extractRoleNameFromArn(principalArn), serviceLinkedRoleNamePrefix) +} + +// scpVerdict is the outcome of evaluating the SCP chain. +type scpVerdict struct { + policyArn string + match EvalMatch + denied bool + explicit bool +} + +// evaluateSCPs applies the documented SCP rules: any explicit Deny at any level +// denies; otherwise every level needs at least one Allow. +func evaluateSCPs(levels []SCPLevel, action, resource string, condCtx ConditionContext) scpVerdict { + var implicit bool + + for _, lvl := range levels { + docs := make([]string, len(lvl.Policies)) + for i, p := range lvl.Policies { + docs[i] = arn.Canonical(p.Document) + } + + res, m := ExplainPolicies(docs, action, resource, condCtx) + + switch res { + case EvalExplicitDeny: + v := scpVerdict{denied: true, explicit: true, match: m} + if m.PolicyIndex >= 0 && m.PolicyIndex < len(lvl.Policies) { + v.policyArn = lvl.Policies[m.PolicyIndex].ARN + } + + return v + case EvalImplicitDeny: + implicit = true + case EvalAllow: + } + } + + return scpVerdict{denied: implicit} +} + +// scpDecision gates an otherwise-allowed request on the caller account's SCPs. +func (cfg EnforcementConfig) scpDecision( + action, resource string, condCtx ConditionContext, +) (scpVerdict, bool) { + if cfg.SCPs == nil || condCtx.PrincipalAccount == "" || scpExemptPrincipal(condCtx.PrincipalARN) { + return scpVerdict{}, false + } + + levels, ok := cfg.SCPs.SCPsForAccount(condCtx.PrincipalAccount) + if !ok || len(levels) == 0 { + return scpVerdict{}, false + } + + v := evaluateSCPs(levels, action, resource, condCtx) + + return v, v.denied +} + +// applySCPs turns an allow into an SCP deny when the caller account's SCPs do not permit the request. +func (cfg EnforcementConfig) applySCPs( + dec requestDecision, action, resource string, condCtx ConditionContext, +) (requestDecision, string) { + if !dec.allow { + return dec, "" + } + + v, denied := cfg.scpDecision(action, resource, condCtx) + if !denied { + return dec, "" + } + + return v.toDecision(), scpDeniedMessage(condCtx.PrincipalARN, action, v) +} + +// scpDeniedMessage is the AccessDenied text AWS documents for SCP denials +// (IAM User Guide, "Troubleshoot access denied error messages"). +func scpDeniedMessage(principalArn, action string, v scpVerdict) string { + msg := "User: " + principalArn + " is not authorized to perform: " + action + + if !v.explicit { + return msg + " because no service control policy allows the " + action + " action" + } + + msg += " with an explicit deny in a service control policy" + if v.policyArn != "" { + msg += ": " + v.policyArn + } + + return msg +} + +func (v scpVerdict) toDecision() requestDecision { + d := requestDecision{ + reason: reasonServiceControlPolicy, source: sourceSCP, match: v.match, explicit: v.explicit, + policy: &iampolicystream.PolicyRef{Type: iampolicystream.PolicySCP, Arn: v.policyArn}, + } + + if !v.explicit { + d.match = EvalMatch{PolicyIndex: -1, StatementIdx: -1} + } + + return d +} diff --git a/services/iam/scp_whitebox_test.go b/services/iam/scp_whitebox_test.go new file mode 100644 index 0000000000..4db087c9a4 --- /dev/null +++ b/services/iam/scp_whitebox_test.go @@ -0,0 +1,129 @@ +package iam + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func scpDoc(effect, action string) SCPPolicy { + return SCPPolicy{ + ARN: "arn:aws:organizations::1:policy/o-x/service_control_policy/p-" + effect, + Document: `{"Version":"2012-10-17","Statement":[{"Effect":"` + effect + + `","Action":"` + action + `","Resource":"*"}]}`, + } +} + +func TestEvaluateSCPs(t *testing.T) { + t.Parallel() + + allowAll := scpDoc("Allow", "*") + + tests := []struct { + name string + wantArn string + levels []SCPLevel + wantDenied bool + wantExplicit bool + }{ + { + name: "all_levels_allow", + levels: []SCPLevel{{Policies: []SCPPolicy{allowAll}}, {Policies: []SCPPolicy{allowAll}}}, + }, + { + name: "level_without_allow_denies", + levels: []SCPLevel{{Policies: []SCPPolicy{allowAll}}, {}}, + wantDenied: true, + }, + { + name: "level_allow_for_other_action_denies", + levels: []SCPLevel{ + {Policies: []SCPPolicy{allowAll}}, + {Policies: []SCPPolicy{scpDoc("Allow", "ec2:*")}}, + }, + wantDenied: true, + }, + { + name: "deny_wins_over_allow_at_other_level", + levels: []SCPLevel{ + {Policies: []SCPPolicy{allowAll, scpDoc("Deny", "s3:DeleteBucket")}}, + {Policies: []SCPPolicy{allowAll}}, + }, + wantDenied: true, + wantExplicit: true, + wantArn: "arn:aws:organizations::1:policy/o-x/service_control_policy/p-Deny", + }, + { + name: "union_of_allows_within_level", + levels: []SCPLevel{{Policies: []SCPPolicy{scpDoc("Allow", "ec2:*"), scpDoc("Allow", "s3:*")}}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := evaluateSCPs(tt.levels, "s3:DeleteBucket", "*", ConditionContext{}) + assert.Equal(t, tt.wantDenied, v.denied) + assert.Equal(t, tt.wantExplicit, v.explicit) + assert.Equal(t, tt.wantArn, v.policyArn) + }) + } +} + +func TestSCPExemptPrincipal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + arn string + want bool + }{ + {"user", "arn:aws:iam::111122223333:user/alice", false}, + {"role", "arn:aws:iam::111122223333:role/app", false}, + {"slr_path", "arn:aws:iam::111122223333:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS", true}, + {"slr_assumed", "arn:aws:sts::111122223333:assumed-role/AWSServiceRoleForECS/sess", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, scpExemptPrincipal(tt.arn)) + }) + } +} + +func TestSCPDeniedMessage(t *testing.T) { + t.Parallel() + + const user = "arn:aws:iam::123456789012:user/John" + + tests := []struct { + name string + want string + v scpVerdict + }{ + { + "implicit", "User: " + user + " is not authorized to perform: codecommit:ListRepositories " + + "because no service control policy allows the codecommit:ListRepositories action", + scpVerdict{denied: true}, + }, + { + "explicit_no_arn", "User: " + user + " is not authorized to perform: codecommit:ListRepositories " + + "with an explicit deny in a service control policy", + scpVerdict{denied: true, explicit: true}, + }, + { + "explicit_arn", "User: " + user + " is not authorized to perform: codecommit:ListRepositories " + + "with an explicit deny in a service control policy: arn:p", + scpVerdict{denied: true, explicit: true, policyArn: "arn:p"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, scpDeniedMessage(user, "codecommit:ListRepositories", tt.v)) + }) + } +} diff --git a/services/identitystore/iam_enforcement_test.go b/services/identitystore/iam_enforcement_test.go index d19240b73e..849e339b86 100644 --- a/services/identitystore/iam_enforcement_test.go +++ b/services/identitystore/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/identitystore" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/identitystore/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/inspector2/iam_enforcement_test.go b/services/inspector2/iam_enforcement_test.go index 876479fb1d..57cfa23cc4 100644 --- a/services/inspector2/iam_enforcement_test.go +++ b/services/inspector2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/inspector2" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/inspector2/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/iot/broker.go b/services/iot/broker.go index 9b482e920e..934149b3b9 100644 --- a/services/iot/broker.go +++ b/services/iot/broker.go @@ -362,6 +362,12 @@ func (b *Broker) DisconnectClient(clientID string, cleanSession, preventWill boo atomic.StoreUint32(&cl.Properties.Will.Flag, 0) } + will := cl.Properties.Will + if !preventWill && will.WillDelayInterval == 0 && atomic.CompareAndSwapUint32(&cl.Properties.Will.Flag, 1, 0) { + // mochi drops the will when its read loop sees Closed() before a read error; publish it here. + _ = s.Publish(will.TopicName, will.Payload, will.Retain, will.Qos) + } + if cleanSession { cl.Properties.Clean = true cl.Properties.Props.SessionExpiryInterval = 0 diff --git a/services/iot/handler_helpers.go b/services/iot/handler_helpers.go index 483183fc44..fb29116175 100644 --- a/services/iot/handler_helpers.go +++ b/services/iot/handler_helpers.go @@ -16,6 +16,9 @@ import ( // rejects a request before it reaches backend validation. const errTypeInvalidRequest = "InvalidRequestException" +// maxTopicRuleBodyBytes bounds a rule payload: 8 KiB SQL plus up to 11 actions fit well within 1 MiB. +const maxTopicRuleBodyBytes int64 = 1 << 20 + func readBody(c *echo.Context, dst any) error { if err := json.NewDecoder(c.Request().Body).Decode(dst); err != nil && !errors.Is(err, io.EOF) { if jsonErr := c.JSON(http.StatusBadRequest, awsErrBody{errTypeInvalidRequest, err.Error()}); jsonErr != nil { diff --git a/services/iot/handler_topic_rules.go b/services/iot/handler_topic_rules.go index 8228c41d79..b520dd13a0 100644 --- a/services/iot/handler_topic_rules.go +++ b/services/iot/handler_topic_rules.go @@ -11,6 +11,7 @@ import ( "github.com/labstack/echo/v5" "github.com/blackbirdworks/gopherstack/pkgs/awstime" + "github.com/blackbirdworks/gopherstack/pkgs/httputils" ) // resolveTopicRuleDestinationOps resolves the topic-rule-destination op @@ -131,8 +132,15 @@ func (h *Handler) handleCreateTopicRule(c *echo.Context) error { ruleName := strings.TrimPrefix(c.Request().URL.Path, "/rules/") - rawBody, err := io.ReadAll(c.Request().Body) + rawBody, err := httputils.ReadBodyLimit(c.Request(), maxTopicRuleBodyBytes) if err != nil { + if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge { + return c.JSON( + http.StatusRequestEntityTooLarge, + awsErrBody{"RequestEntityTooLargeException", "The request body exceeds the maximum size allowed"}, + ) + } + return c.JSON(http.StatusBadRequest, awsErrBody{errTypeInvalidRequest, err.Error()}) } @@ -299,8 +307,15 @@ func (h *Handler) handleReplaceTopicRule(c *echo.Context) error { ruleName := strings.TrimPrefix(c.Request().URL.Path, "/rules/") - rawBody, err := io.ReadAll(c.Request().Body) + rawBody, err := httputils.ReadBodyLimit(c.Request(), maxTopicRuleBodyBytes) if err != nil { + if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge { + return c.JSON( + http.StatusRequestEntityTooLarge, + awsErrBody{"RequestEntityTooLargeException", "The request body exceeds the maximum size allowed"}, + ) + } + return c.JSON(http.StatusBadRequest, awsErrBody{errTypeInvalidRequest, err.Error()}) } diff --git a/services/iot/iam_enforcement_test.go b/services/iot/iam_enforcement_test.go index 865c4d7c8b..de55860bd9 100644 --- a/services/iot/iam_enforcement_test.go +++ b/services/iot/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/iot" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/iot/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/iot/jobs_data_plane_test.go b/services/iot/jobs_data_plane_test.go index 6cfe49ace7..a97badcc56 100644 --- a/services/iot/jobs_data_plane_test.go +++ b/services/iot/jobs_data_plane_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iot" ) @@ -62,7 +63,7 @@ func (e jobsDataEnv) device(t *testing.T, method, path string, body any) (int, m req.Header.Set("Authorization", jobsDataAuth) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/iot/topic_rule_body_limit_test.go b/services/iot/topic_rule_body_limit_test.go new file mode 100644 index 0000000000..07fb2a1c74 --- /dev/null +++ b/services/iot/topic_rule_body_limit_test.go @@ -0,0 +1,60 @@ +package iot_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTopicRuleBodyLimit(t *testing.T) { + t.Parallel() + + const limit = 1 << 20 + + prefix := `{"sql":"SELECT * FROM 't'","description":"` + suffix := `"}` + + body := func(total int) string { + return prefix + strings.Repeat("a", total-len(prefix)-len(suffix)) + suffix + } + + tests := []struct { + name string + method string + path string + size int + wantTooLarge bool + }{ + {name: "create at limit", method: http.MethodPost, path: "/rules/r1", size: limit}, + {name: "create over limit", method: http.MethodPost, path: "/rules/r2", size: limit + 1, wantTooLarge: true}, + {name: "replace at limit", method: http.MethodPatch, path: "/rules/r3", size: limit}, + {name: "replace over limit", method: http.MethodPatch, path: "/rules/r4", size: limit + 1, wantTooLarge: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + h := newTestHandler() + req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte(body(tc.size)))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.New().NewContext(req, rec))) + + if tc.wantTooLarge { + assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + assert.Contains(t, rec.Body.String(), "RequestEntityTooLargeException") + + return + } + + assert.NotEqual(t, http.StatusRequestEntityTooLarge, rec.Code) + }) + } +} diff --git a/services/iotanalytics/iam_enforcement_test.go b/services/iotanalytics/iam_enforcement_test.go index 8b456f82e0..7bbb4d27ff 100644 --- a/services/iotanalytics/iam_enforcement_test.go +++ b/services/iotanalytics/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/iotanalytics" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/iotanalytics/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/iotdataplane/iam_enforcement_test.go b/services/iotdataplane/iam_enforcement_test.go index 1320c4ccce..8f600f06b6 100644 --- a/services/iotdataplane/iam_enforcement_test.go +++ b/services/iotdataplane/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/iotdataplane" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/iotdata/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/iotdataplane/wire_sdk_roundtrip_test.go b/services/iotdataplane/wire_sdk_roundtrip_test.go index 82679f392d..4eba4a5657 100644 --- a/services/iotdataplane/wire_sdk_roundtrip_test.go +++ b/services/iotdataplane/wire_sdk_roundtrip_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iotdataplane" ) @@ -62,7 +63,7 @@ func registerConnectionAdmin(t *testing.T, baseURL, clientID string) { ) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/iotwireless/iam_enforcement_test.go b/services/iotwireless/iam_enforcement_test.go index 53b0f6e420..a9211fde20 100644 --- a/services/iotwireless/iam_enforcement_test.go +++ b/services/iotwireless/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/iotwireless" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/iotwireless/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/kinesis/iam_enforcement_test.go b/services/kinesis/iam_enforcement_test.go index b4a7c297f1..1208d39573 100644 --- a/services/kinesis/iam_enforcement_test.go +++ b/services/kinesis/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/kinesis" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/kinesis/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/kinesis/inspect_test.go b/services/kinesis/inspect_test.go index 49dc1fcf04..7af71a8125 100644 --- a/services/kinesis/inspect_test.go +++ b/services/kinesis/inspect_test.go @@ -22,6 +22,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/kinesis" ) @@ -69,7 +70,7 @@ func (e *inspectEnv) get(t *testing.T, path string, out any) int { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, e.url+path, nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/kinesisanalytics/iam_enforcement_test.go b/services/kinesisanalytics/iam_enforcement_test.go index 66fa40a525..c5c69b280a 100644 --- a/services/kinesisanalytics/iam_enforcement_test.go +++ b/services/kinesisanalytics/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/kinesisanalytics" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/kinesisanalytics/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/kinesisanalyticsv2/iam_enforcement_test.go b/services/kinesisanalyticsv2/iam_enforcement_test.go index 8372f80ec7..f8cb3be951 100644 --- a/services/kinesisanalyticsv2/iam_enforcement_test.go +++ b/services/kinesisanalyticsv2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/kinesisanalyticsv2" ) @@ -168,7 +169,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/kinesisanalytics/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/kms/PARITY.md b/services/kms/PARITY.md index c531c7abbc..5a22895845 100644 --- a/services/kms/PARITY.md +++ b/services/kms/PARITY.md @@ -56,11 +56,11 @@ ops: EnableKey: {wire: ok, errors: ok, state: ok, persist: ok} ScheduleKeyDeletion: {wire: ok, errors: ok, state: ok, persist: ok, note: "7-30 day window enforced; janitor purges past DeletionDate"} CancelKeyDeletion: {wire: ok, errors: ok, state: ok, persist: ok} - CreateGrant: {wire: fixed, errors: fixed, state: fixed, persist: ok, note: "region-resolution fix (see below) PLUS this pass's 3 gap closures: (1) GrantConstraints gained SourceArn (real SDK field; stored/round-tripped through ListGrants/ListRetirableGrants, NOT enforced -- no cross-service request-context plumbing exists anywhere in this mock to carry a caller/resource ARN through crypto calls, same documented scope boundary as grant-token authorization); (2) CreateGrantInput gained GrantTokens (real SDK field; accepted as a no-op, same precedent as CreateKeyInput/ReplicateKeyInput's BypassPolicyLockoutSafetyCheck -- no IAM layer exists to authorize the CreateGrant call itself); (3) CreateGrantInput/Grant gained GranteeServicePrincipal + RetiringServicePrincipal (real SDK fields), WITH real validation: exactly one of GranteePrincipal/GranteeServicePrincipal required, RetiringPrincipal/RetiringServicePrincipal mutually exclusive, and a service grantee requires a SourceArn constraint + a retiring principal of either kind, matching the real CreateGrantInput doc comments exactly. Also added Grant.IssuingAccount (real GrantListEntry field, was entirely absent -- populated from the backend's account ID). See TestCreateGrant_ServicePrincipals, TestGrantConstraint_SourceArn_RoundTrips, TestCreateGrant_IssuingAccount_Populated, TestCreateGrant_GrantTokens_AcceptedAsNoOp in grants_test.go, plus persistence coverage in TestInMemoryBackend_FullStateSnapshotRestoreRoundTrip."} + CreateGrant: {wire: fixed, errors: fixed, state: fixed, persist: ok, note: "region-resolution fix (see below) PLUS this pass's 3 gap closures: (1) GrantConstraints gained SourceArn (real SDK field; stored/round-tripped through ListGrants/ListRetirableGrants, NOT enforced -- no cross-service request-context plumbing exists anywhere in this mock to carry a caller/resource ARN through crypto calls, same documented scope boundary as grant-token authorization); (2) CreateGrantInput gained GrantTokens (real SDK field; accepted as a no-op, same precedent as CreateKeyInput/ReplicateKeyInput's BypassPolicyLockoutSafetyCheck -- no IAM layer exists to authorize the CreateGrant call itself); (3) CreateGrantInput/Grant gained GranteeServicePrincipal + RetiringServicePrincipal (real SDK fields), WITH real validation: exactly one of GranteePrincipal/GranteeServicePrincipal required, RetiringPrincipal/RetiringServicePrincipal mutually exclusive, and a service grantee requires a SourceArn constraint + a retiring principal of either kind, matching the real CreateGrantInput doc comments exactly. Also added Grant.IssuingAccount (real GrantListEntry field, was entirely absent -- populated from the backend's account ID). See TestCreateGrant_ServicePrincipals, TestGrantConstraint_SourceArn_RoundTrips, TestCreateGrant_IssuingAccount_Populated, TestCreateGrant_GrantTokens_AcceptedAsNoOp in grants_test.go, plus persistence coverage in TestInMemoryBackend_FullStateSnapshotRestoreRoundTrip. 2026-10-30 (gopherstack-tnqy.6): cross-account callers need a key-policy Allow (principal account root/ARN/*, kms:CreateGrant|kms:*, no Condition) else AccessDeniedException."} ListGrants: {wire: ok, errors: ok, state: fixed, persist: ok, note: "same region-resolution fix as CreateGrant. Re-verified this pass (over-wide-response sweep): GrantListEntry's eleven members match types.GrantListEntry exactly -- no leaks, no gaps."} - RevokeGrant: {wire: ok, errors: ok, state: fixed, persist: ok, note: "same region-resolution fix as CreateGrant"} - RetireGrant: {wire: ok, errors: ok, state: fixed, persist: ok, note: "GrantId+KeyId path now uses the key's own region; GrantId-only (no KeyId, no region hint) now searches all regions instead of only the request region"} - ListRetirableGrants: {wire: fixed, errors: ok, state: fixed, persist: ok, note: "2026-08-28 write-only-state sweep: ListRetirableGrantsInput had no RetiringServicePrincipal field (real SDK: aws-sdk-go-v2/service/kms@v1.55.4 api_op_ListRetirableGrants.go, ListRetirableGrantsInput carries both RetiringPrincipal and RetiringServicePrincipal -- 'You must specify either ... but not both'), and the backend filtered solely on g.RetiringPrincipal == input.RetiringPrincipal. CreateGrant has always accepted and stored GranteeServicePrincipal/RetiringServicePrincipal on the Grant (see the CreateGrant op row above), so a grant whose only retiring principal was a service principal could be created but never discovered through ListRetirableGrants -- KMS's only real read path for 'which grants can I retire' (RetireGrant itself requires a GrantId/GrantToken you'd otherwise have no way to find). Worth noting for the next auditor: a naive round-trip test here can pass by accident, because both the (dropped) request field and the unset Grant.RetiringPrincipal default to the empty string, so an empty-string == empty-string match looks like a hit; the real test needs a decoy grant with neither retiring-principal field set and an exact-count assertion. Fixed: added RetiringServicePrincipal to ListRetirableGrantsInput and OR'd it into the filter (each side only matches when its own input field is non-empty). See TestListRetirableGrants_RetiringServicePrincipal_RealClient in wire_field_fixes_test.go. Re-verified this pass (over-wide-response sweep): shares GrantListEntry with ListGrants, already confirmed exact above."} + RevokeGrant: {wire: ok, errors: ok, state: fixed, persist: ok, note: "same region-resolution fix as CreateGrant 2026-10-30 (gopherstack-tnqy.6): only callers in the key's account, or cross-account callers named by a key-policy Allow for kms:RevokeGrant, may revoke; grantee/retiring principals alone get AccessDeniedException."} + RetireGrant: {wire: ok, errors: ok, state: fixed, persist: ok, note: "GrantId+KeyId path now uses the key's own region; GrantId-only (no KeyId, no region hint) now searches all regions instead of only the request region 2026-10-30 (gopherstack-tnqy.6): caller identity enforced (kms@v1.61.4 api_op_RetireGrant.go): AccessDeniedException unless the caller is the RetiringPrincipal, the GranteePrincipal when RetireGrant is a grant operation, or a principal of the issuing account; applies to GrantId+KeyId, GrantId-only and GrantToken paths. No resolved principal = permitted."} + ListRetirableGrants: {wire: fixed, errors: ok, state: fixed, persist: ok, note: "2026-08-28 write-only-state sweep: ListRetirableGrantsInput had no RetiringServicePrincipal field (real SDK: aws-sdk-go-v2/service/kms@v1.55.4 api_op_ListRetirableGrants.go, ListRetirableGrantsInput carries both RetiringPrincipal and RetiringServicePrincipal -- 'You must specify either ... but not both'), and the backend filtered solely on g.RetiringPrincipal == input.RetiringPrincipal. CreateGrant has always accepted and stored GranteeServicePrincipal/RetiringServicePrincipal on the Grant (see the CreateGrant op row above), so a grant whose only retiring principal was a service principal could be created but never discovered through ListRetirableGrants -- KMS's only real read path for 'which grants can I retire' (RetireGrant itself requires a GrantId/GrantToken you'd otherwise have no way to find). Worth noting for the next auditor: a naive round-trip test here can pass by accident, because both the (dropped) request field and the unset Grant.RetiringPrincipal default to the empty string, so an empty-string == empty-string match looks like a hit; the real test needs a decoy grant with neither retiring-principal field set and an exact-count assertion. Fixed: added RetiringServicePrincipal to ListRetirableGrantsInput and OR'd it into the filter (each side only matches when its own input field is non-empty). See TestListRetirableGrants_RetiringServicePrincipal_RealClient in wire_field_fixes_test.go. Re-verified this pass (over-wide-response sweep): shares GrantListEntry with ListGrants, already confirmed exact above. 2026-10-30 (gopherstack-tnqy.6): RetiringPrincipal must belong to the caller's account (api_op_ListRetirableGrants.go cross-account note) else AccessDeniedException; results still span all grants."} PutKeyPolicy: {wire: ok, errors: ok, state: fixed, persist: ok, note: "same region-resolution fix as CreateGrant -- policy now stored in the key's own region so a cross-region ARN round-trips through GetKeyPolicy"} GetKeyPolicy: {wire: ok, errors: ok, state: fixed, persist: ok, note: "same region-resolution fix as CreateGrant -- reads the policy from the key's own region (ARN-embedded region for an ARN input)"} ListKeyPolicies: {wire: ok, errors: ok, state: ok, persist: n/a, note: "already region-aware (routes through lookupKey); confirmed no change needed"} @@ -87,6 +87,7 @@ families: multi_region: {status: ok, note: "ReplicateKey/UpdatePrimaryRegion primary<->replica promotion verified by existing TestUpdatePrimaryRegion_RoleSwap; DescribeKey MultiRegionConfiguration built correctly for both primary and replica sides"} gaps: [] items_still_open: + - "Grant-authorized use by identity: operations presented with a grant token do not check that the caller is the GranteePrincipal, and key-policy/IAM evaluation lives in the iam EnforcementMiddleware, which does not consult grants (gopherstack-tnqy.6)." - "RotateKeyOnDemand on an EXTERNAL key with nothing pending, and a second NEW_KEY_MATERIAL import while one is pending, return KMSInvalidStateException: API_RotateKeyOnDemand.html requires the material to be PENDING_ROTATION and lists KMSInvalidStateException among its errors, but names no error for these two cases (re-checked 2026-10-07)." structural_gaps: - "External key store proxies are never contacted: XksProxyUriUnreachable/IncorrectAuthenticationCredential/InvalidResponse, XksKeyNotFound/XksKeyInvalidConfiguration for the external key itself, and VPC endpoint service existence checks need a real proxy or VPC." diff --git a/services/kms/README.md b/services/kms/README.md index 78cd2fca0a..c355b9f8ed 100644 --- a/services/kms/README.md +++ b/services/kms/README.md @@ -9,13 +9,14 @@ | --- | --- | | PARITY entries audited | 54 (54 ok) | | Feature families | 4 (4 ok) | -| Known gaps | 1 | +| Known gaps | 2 | | Structural gaps (can't be emulated) | 1 | | Deferred items | 1 | | Resource leaks | fixed | ### Known gaps +- Grant-authorized use by identity: operations presented with a grant token do not check that the caller is the GranteePrincipal, and key-policy/IAM evaluation lives in the iam EnforcementMiddleware, which does not consult grants (gopherstack-tnqy.6). - RotateKeyOnDemand on an EXTERNAL key with nothing pending, and a second NEW_KEY_MATERIAL import while one is pending, return KMSInvalidStateException: API_RotateKeyOnDemand.html requires the material to be PENDING_ROTATION and lists KMSInvalidStateException among its errors, but names no error for these two cases (re-checked 2026-10-07). ### Structural gaps diff --git a/services/kms/grant_authz.go b/services/kms/grant_authz.go new file mode 100644 index 0000000000..511503e250 --- /dev/null +++ b/services/kms/grant_authz.go @@ -0,0 +1,198 @@ +package kms + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + gopherarn "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" +) + +const ( + arnAccountField = 4 + arnMinFields = 6 + actionRetire = "RetireGrant" +) + +// arnAccount returns the account segment of an ARN, or "". +func arnAccount(a string) string { + parts := strings.SplitN(a, ":", arnMinFields) + if len(parts) < arnMinFields { + return "" + } + + return parts[arnAccountField] +} + +// principalMatchesARN reports whether caller is the principal named by target: +// the identical ARN, the account root of the caller's account (account +// delegation), or the IAM role behind an assumed-role session. +func principalMatchesARN(caller *awsmeta.Principal, target string) bool { + if target == "" { + return false + } + + if caller.Arn == target { + return true + } + + if target == gopherarn.Build("iam", "", caller.AccountID, "root") { + return true + } + + _, rest, ok := strings.Cut(caller.Arn, ":assumed-role/") + if !ok { + return false + } + + roleName, _, _ := strings.Cut(rest, "/") + if arnAccount(target) != caller.AccountID { + return false + } + + return strings.Contains(target, ":role/") && strings.HasSuffix(target, "/"+roleName) +} + +func callerAccount(ctx context.Context, p *awsmeta.Principal) string { + if p != nil && p.AccountID != "" { + return p.AccountID + } + + return awsmeta.Account(ctx) +} + +// authorizeGrantRetire enforces who may retire g: its RetiringPrincipal, its +// GranteePrincipal when RetireGrant is a grant operation, or any principal of +// the issuing account (kms@v1.61 api_op_RetireGrant.go). No resolved caller +// means no identity to check, so the call is permitted. +func authorizeGrantRetire(ctx context.Context, g *Grant) error { + p := awsmeta.GetPrincipal(ctx) + if p == nil { + return nil + } + + if callerAccount(ctx, p) == g.IssuingAccount && g.IssuingAccount != "" { + return nil + } + + if principalMatchesARN(p, g.RetiringPrincipal) { + return nil + } + + if slices.Contains(g.Operations, actionRetire) && principalMatchesARN(p, g.GranteePrincipal) { + return nil + } + + return fmt.Errorf("%w: caller %s is not authorized to retire grant %s", ErrAccessDenied, p.Arn, g.GrantID) +} + +// authorizeKeyAction permits a caller of the key's own account, or a +// cross-account caller named by an Allow statement of the key policy +// (and not denied). Callers without a resolved principal are permitted. +func (b *InMemoryBackend) authorizeKeyAction(ctx context.Context, region, keyID, action string) error { + p := awsmeta.GetPrincipal(ctx) + if p == nil || callerAccount(ctx, p) == b.accountID { + return nil + } + + policy, ok := b.policiesStore(region)[keyID] + if ok && keyPolicyAllows(policy, p, action) { + return nil + } + + return fmt.Errorf("%w: caller %s is not authorized to perform kms:%s on this key", ErrAccessDenied, p.Arn, action) +} + +type policyStatement struct { + Principal any `json:"Principal"` + Action any `json:"Action"` + Condition any `json:"Condition"` + Effect string `json:"Effect"` +} + +func stringList(v any) []string { + switch t := v.(type) { + case string: + return []string{t} + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + + return out + } + + return nil +} + +func statementNamesPrincipal(s *policyStatement, p *awsmeta.Principal) bool { + if s.Principal == "*" { + return true + } + + m, ok := s.Principal.(map[string]any) + if !ok { + return false + } + + for _, name := range stringList(m["AWS"]) { + if name == "*" || name == p.AccountID || principalMatchesARN(p, name) { + return true + } + } + + return false +} + +func statementCoversAction(s *policyStatement, action string) bool { + for _, a := range stringList(s.Action) { + if a == "*" || strings.EqualFold(a, "kms:*") || strings.EqualFold(a, "kms:"+action) { + return true + } + } + + return false +} + +func keyPolicyAllows(policy string, p *awsmeta.Principal, action string) bool { + var doc struct { + Statement json.RawMessage `json:"Statement"` + } + if json.Unmarshal([]byte(policy), &doc) != nil { + return false + } + + var stmts []policyStatement + if json.Unmarshal(doc.Statement, &stmts) != nil { + var one policyStatement + if json.Unmarshal(doc.Statement, &one) != nil { + return false + } + + stmts = []policyStatement{one} + } + + allowed := false + + for i := range stmts { + s := &stmts[i] + if !statementNamesPrincipal(s, p) || !statementCoversAction(s, action) { + continue + } + + switch { + case strings.EqualFold(s.Effect, "Deny") && s.Condition == nil: + return false + case strings.EqualFold(s.Effect, "Allow") && s.Condition == nil: + allowed = true + } + } + + return allowed +} diff --git a/services/kms/grant_caller_identity_test.go b/services/kms/grant_caller_identity_test.go new file mode 100644 index 0000000000..2699c0b3a1 --- /dev/null +++ b/services/kms/grant_caller_identity_test.go @@ -0,0 +1,285 @@ +package kms_test + +import ( + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + kmssdk "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/kms" +) + +const ( + grantOwnerAcct = "000000000000" + grantGranteeAcct = "111111111111" + grantRetirerAcct = "222222222222" + grantStrangerAcct = "333333333333" + grantRoleARN = "arn:aws:iam::111111111111:role/GranteeRole" + grantRetirerARN = "arn:aws:iam::222222222222:user/retirer" +) + +func grantPrincipals() map[string]*awsmeta.Principal { + return map[string]*awsmeta.Principal{ + "owner": { + Kind: awsmeta.PrincipalKindUser, AccountID: grantOwnerAcct, + Arn: "arn:aws:iam::000000000000:user/owner", + }, + "grantee": { + Kind: awsmeta.PrincipalKindAssumedRole, AccountID: grantGranteeAcct, + Arn: "arn:aws:sts::111111111111:assumed-role/GranteeRole/s1", + }, + "retirer": {Kind: awsmeta.PrincipalKindUser, AccountID: grantRetirerAcct, Arn: grantRetirerARN}, + "stranger": { + Kind: awsmeta.PrincipalKindUser, AccountID: grantStrangerAcct, + Arn: "arn:aws:iam::333333333333:user/stranger", + }, + } +} + +type grantStack struct { + clients map[string]*kmssdk.Client +} + +func newGrantStack(t *testing.T) *grantStack { + t.Helper() + + principals := grantPrincipals() + e := echo.New() + e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + meta := awsmeta.FromRequest(req, config.DefaultRegion) + if p, ok := principals[meta.AccessKeyID]; ok { + meta.Principal = p + meta.Account = p.AccountID + } + + c.SetRequest(req.WithContext(awsmeta.Set(req.Context(), meta))) + + return next(c) + } + }) + + registry := service.NewRegistry() + require.NoError(t, registry.Register(kms.NewHandler(kms.NewInMemoryBackend()))) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + s := &grantStack{clients: map[string]*kmssdk.Client{}} + + for name := range principals { + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion(config.DefaultRegion), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(name, "x", "")), + ) + require.NoError(t, err) + + s.clients[name] = kmssdk.NewFromConfig(cfg, func(o *kmssdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }) + } + + return s +} + +func requireAccessDenied(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "AccessDeniedException") +} + +func (s *grantStack) newKeyAndGrant(t *testing.T, ops []types.GrantOperation) (string, string, string) { + t.Helper() + + k, err := s.clients["owner"].CreateKey(t.Context(), &kmssdk.CreateKeyInput{}) + require.NoError(t, err) + + keyID := aws.ToString(k.KeyMetadata.KeyId) + g, err := s.clients["owner"].CreateGrant(t.Context(), &kmssdk.CreateGrantInput{ + KeyId: aws.String(keyID), GranteePrincipal: aws.String(grantRoleARN), + RetiringPrincipal: aws.String(grantRetirerARN), Operations: ops, + }) + require.NoError(t, err) + + return keyID, aws.ToString(g.GrantId), aws.ToString(g.GrantToken) +} + +func TestGrantRetireCallerIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caller string + ops []types.GrantOperation + byToken bool + denied bool + }{ + {name: "issuing account", caller: "owner", ops: []types.GrantOperation{"Decrypt"}}, + {name: "retiring principal", caller: "retirer", ops: []types.GrantOperation{"Decrypt"}}, + { + name: "grantee with retire op", caller: "grantee", + ops: []types.GrantOperation{"Decrypt", "RetireGrant"}, + }, + {name: "grantee without retire op", caller: "grantee", ops: []types.GrantOperation{"Decrypt"}, denied: true}, + {name: "stranger by id", caller: "stranger", ops: []types.GrantOperation{"Decrypt"}, denied: true}, + { + name: "stranger by token", caller: "stranger", ops: []types.GrantOperation{"Decrypt"}, + byToken: true, denied: true, + }, + {name: "retiring principal by token", caller: "retirer", ops: []types.GrantOperation{"Decrypt"}, byToken: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newGrantStack(t) + keyID, grantID, token := s.newKeyAndGrant(t, tt.ops) + + in := &kmssdk.RetireGrantInput{} + if tt.byToken { + in.GrantToken = aws.String(token) + } else { + in.KeyId, in.GrantId = aws.String(keyID), aws.String(grantID) + } + + _, err := s.clients[tt.caller].RetireGrant(t.Context(), in) + if tt.denied { + requireAccessDenied(t, err) + + list, lerr := s.clients["owner"].ListGrants( + t.Context(), + &kmssdk.ListGrantsInput{KeyId: aws.String(keyID)}, + ) + require.NoError(t, lerr) + assert.Len(t, list.Grants, 1) + + return + } + + require.NoError(t, err) + + list, lerr := s.clients["owner"].ListGrants(t.Context(), &kmssdk.ListGrantsInput{KeyId: aws.String(keyID)}) + require.NoError(t, lerr) + assert.Empty(t, list.Grants) + }) + } +} + +func TestGrantRevokeCreateCallerIdentity(t *testing.T) { + t.Parallel() + + policyFor := func(acct, action string) string { + return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["arn:aws:iam::` + + grantOwnerAcct + `:root","arn:aws:iam::` + acct + `:root"]},"Action":"` + action + `","Resource":"*"}]}` + } + + tests := []struct { + name string + op string + caller string + policy string + denied bool + }{ + {name: "revoke owner", op: "revoke", caller: "owner"}, + {name: "revoke retiring principal", op: "revoke", caller: "retirer", denied: true}, + {name: "revoke grantee", op: "revoke", caller: "grantee", denied: true}, + { + name: "revoke cross account allowed by key policy", op: "revoke", caller: "stranger", + policy: policyFor(grantStrangerAcct, "kms:RevokeGrant"), + }, + {name: "create owner", op: "create", caller: "owner"}, + {name: "create cross account no policy", op: "create", caller: "stranger", denied: true}, + { + name: "create cross account allowed by key policy", op: "create", caller: "stranger", + policy: policyFor(grantStrangerAcct, "kms:CreateGrant"), + }, + { + name: "create cross account policy wrong action", op: "create", caller: "stranger", + policy: policyFor(grantStrangerAcct, "kms:Decrypt"), denied: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newGrantStack(t) + keyID, grantID, _ := s.newKeyAndGrant(t, []types.GrantOperation{"Decrypt"}) + + if tt.policy != "" { + _, err := s.clients["owner"].PutKeyPolicy(t.Context(), &kmssdk.PutKeyPolicyInput{ + KeyId: aws.String(keyID), Policy: aws.String(tt.policy), + }) + require.NoError(t, err) + } + + var err error + if tt.op == "revoke" { + _, err = s.clients[tt.caller].RevokeGrant(t.Context(), &kmssdk.RevokeGrantInput{ + KeyId: aws.String(keyID), GrantId: aws.String(grantID), + }) + } else { + _, err = s.clients[tt.caller].CreateGrant(t.Context(), &kmssdk.CreateGrantInput{ + KeyId: aws.String(keyID), GranteePrincipal: aws.String(grantRoleARN), + Name: aws.String("second"), Operations: []types.GrantOperation{"Encrypt"}, + }) + } + + if tt.denied { + requireAccessDenied(t, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestListRetirableGrantsCallerIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caller string + principal string + wantCount int + denied bool + }{ + {name: "own account principal", caller: "retirer", principal: grantRetirerARN, wantCount: 1}, + {name: "other account principal", caller: "stranger", principal: grantRetirerARN, denied: true}, + {name: "owner asks for other account", caller: "owner", principal: grantRetirerARN, denied: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newGrantStack(t) + s.newKeyAndGrant(t, []types.GrantOperation{"Decrypt"}) + + out, err := s.clients[tt.caller].ListRetirableGrants(t.Context(), &kmssdk.ListRetirableGrantsInput{ + RetiringPrincipal: aws.String(tt.principal), + }) + if tt.denied { + requireAccessDenied(t, err) + + return + } + + require.NoError(t, err) + assert.Len(t, out.Grants, tt.wantCount) + }) + } +} diff --git a/services/kms/grants.go b/services/kms/grants.go index d9ecade05f..d4c3eaac22 100644 --- a/services/kms/grants.go +++ b/services/kms/grants.go @@ -11,6 +11,8 @@ import ( "time" "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" ) // isValidGrantOperation reports whether op is a grant operation permitted by AWS KMS. @@ -133,6 +135,10 @@ func (b *InMemoryBackend) CreateGrant( return nil, keyStateError(key) } + if err = b.authorizeKeyAction(ctx, region, keyID, "CreateGrant"); err != nil { + return nil, err + } + grantsForKey := b.grantsRegion(region) if len(grantsForKey.byKey.Get(keyID)) >= maxGrantsPerKey { @@ -372,6 +378,10 @@ func (b *InMemoryBackend) RevokeGrant(ctx context.Context, input *RevokeGrantInp return ErrGrantNotFound } + if err = b.authorizeKeyAction(ctx, region, key.KeyID, "RevokeGrant"); err != nil { + return err + } + if input.DryRun { return ErrDryRun } @@ -389,7 +399,7 @@ func (b *InMemoryBackend) RetireGrant(ctx context.Context, input *RetireGrantInp defer b.mu.Unlock() if input.GrantToken != "" { - return b.retireGrantByToken(input.GrantToken, input.DryRun) + return b.retireGrantByToken(ctx, input.GrantToken, input.DryRun) } if input.GrantID == "" { @@ -404,18 +414,22 @@ func (b *InMemoryBackend) RetireGrant(ctx context.Context, input *RetireGrantInp return b.retireGrantByKeyID(ctx, input.KeyID, input.GrantID, input.DryRun) } - return b.retireGrantByIDOnly(input.GrantID, input.DryRun) + return b.retireGrantByIDOnly(ctx, input.GrantID, input.DryRun) } // retireGrantByToken searches all regions for a grant token and retires the matching grant. // Must be called with the write lock held. -func (b *InMemoryBackend) retireGrantByToken(grantToken string, dryRun bool) error { +func (b *InMemoryBackend) retireGrantByToken(ctx context.Context, grantToken string, dryRun bool) error { for _, gs := range b.grants { matches := gs.byToken.Get(grantToken) if len(matches) == 0 { continue } + if err := authorizeGrantRetire(ctx, matches[0]); err != nil { + return err + } + if dryRun { return ErrDryRun } @@ -441,6 +455,10 @@ func (b *InMemoryBackend) retireGrantByKeyID(ctx context.Context, keyID, grantID return ErrGrantNotFound } + if err = authorizeGrantRetire(ctx, grant); err != nil { + return err + } + if dryRun { return ErrDryRun } @@ -452,9 +470,13 @@ func (b *InMemoryBackend) retireGrantByKeyID(ctx context.Context, keyID, grantID // retireGrantByIDOnly searches all regions for a grant ID (no KeyId region hint) and retires it. // Must be called with the write lock held. -func (b *InMemoryBackend) retireGrantByIDOnly(grantID string, dryRun bool) error { +func (b *InMemoryBackend) retireGrantByIDOnly(ctx context.Context, grantID string, dryRun bool) error { for _, gs := range b.grants { - if _, ok := gs.table.Get(grantID); ok { + if g, ok := gs.table.Get(grantID); ok { + if err := authorizeGrantRetire(ctx, g); err != nil { + return err + } + if dryRun { return ErrDryRun } @@ -484,6 +506,13 @@ func (b *InMemoryBackend) ListRetirableGrants( ) } + if p := awsmeta.GetPrincipal(ctx); p != nil && input.RetiringPrincipal != "" && + arnAccount(input.RetiringPrincipal) != callerAccount(ctx, p) { + return nil, fmt.Errorf( + "%w: RetiringPrincipal must be a principal in the caller's account", ErrAccessDenied, + ) + } + b.mu.RLock("ListRetirableGrants") defer b.mu.RUnlock() diff --git a/services/kms/iam_enforcement_test.go b/services/kms/iam_enforcement_test.go index 6ea7c5c4dd..2c8dfed759 100644 --- a/services/kms/iam_enforcement_test.go +++ b/services/kms/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/kms" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/kms/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/lakeformation/iam_enforcement_test.go b/services/lakeformation/iam_enforcement_test.go index 2a7d51d109..edc999d53d 100644 --- a/services/lakeformation/iam_enforcement_test.go +++ b/services/lakeformation/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/lakeformation" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/lakeformation/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/lambda/PARITY.md b/services/lambda/PARITY.md index ae3a2c975b..8e4659150a 100644 --- a/services/lambda/PARITY.md +++ b/services/lambda/PARITY.md @@ -20,8 +20,13 @@ families: gaps: [] # 2026-10-08: CreateFunction defaults an omitted PackageType to Zip (was Image). items_still_open: + - "Extensions API: the Telemetry/Logs API (/2022-07-01/telemetry, /2020-08-15/logs) is not served; extensions that subscribe get 404." + - "Extensions API: container-image functions do not start /opt/extensions executables (no image entrypoint inspection; only Zip functions with layers are wrapped by the extension launcher), and internal extensions registering for SHUTDOWN are not rejected (internal vs external is not distinguishable at register time)." + - "Extensions API: the error-type strings in register 4xx bodies (Extension.InvalidExtensionName, Extension.TooManyExtensions, Extension.RegistrationClosed, Extension.AlreadyRegistered, Extension.InvalidEventType) are unverified recall; the AWS docs list only the status codes. SHUTDOWN waits until each extension took the event (up to 2s), not until the process exits." + - "GET /_aws/lambda/runtimes returns {\"Runtimes\": [...]}; LocalStack's docs name the endpoint and filter=deprecated|all but do not document the response shape, so the shape is unverified. LOCALSTACK_INIT_LOG_LEVEL and LOCALSTACK_USER are not applicable (no LocalStack init binary); pass them through LAMBDA_DOCKER_FLAGS -e if an image needs them." - "--lambda-isolation=firecracker is rejected at startup: no firecracker binary is installed on the build host, so no microVM runner exists. Design: export the function image with `docker export` into an ext4 rootfs, boot it under firecracker with a configured kernel image (--lambda-firecracker-kernel), and reach the Runtime API over vsock (or a TAP device) instead of the docker bridge. Needs /dev/kvm access and a firecracker binary to build and test (gopherstack-cefu7.7)." structural_gaps: + - "Stream ESM ParallelizationFactor/TumblingWindowInSeconds: records are hashed by partition key (Kinesis) or item key (DynamoDB) onto N ordered lanes, drained concurrently; a retrying lane pauses reads for the whole shard, tumbling state is kept per lane, and windows close on the clock only once the shard is caught up (Kinesis's up-to-2-minute idle wait is not modeled)." - "ESM SchemaRegistryErrorCount and EventPollerUnit metrics: no Glue/Confluent schema-registry integration or poller-unit billing model exist to measure (ProvisionedPollers is emitted for SQS mappings with ProvisionedPollerConfig)." - "Kafka ESM LoggingConfig DEBUG offsets are logged on every commit, not once a minute, and omit endOffset; INFO/WARN omit securityProtocol, saslMechanism, networkConfig, assignedPartitions (no VPC/auth/rebalance model)." - "Invoke TenantId is validated (required for PER_TENANT, rejected otherwise) but does not route to a tenant-dedicated execution environment: one environment pool per function." @@ -1407,3 +1412,10 @@ The pinned SDK (v1.107.0) has no "MicroVM" operations. The bd title maps to the ## Notes (2026-10-25 -- ESM concurrency and capacity-provider routing, gopherstack-49qct.56) SQS mappings now invoke the function synchronously (RequestResponse) so a mapping holds its concurrency slot until the function returns; a function error or throttle (reserved concurrency exhausted, TooManyRequestsException) leaves the messages in the queue to reappear after the visibility timeout. `ReportBatchItemFailures` now sees the real response body. Capacity-provider functions are admitted through per-environment slots (`capacity_routing.go`); a missing provider is ResourceNotFoundException and a non-Active provider is ResourceNotReadyException (502). Proof: `esm_sqs_fleet_internal_test.go`, `capacity_routing_internal_test.go` (both fail with the routing disabled). + + +## Notes (2026-10-31 -- Extensions API, runtimes endpoint, LAMBDA_DOCKER_FLAGS, gopherstack-49qct.82) + +Extensions API 2020-01-01 (register, event/next, init/error, exit/error) is served on the per-function runtime port. Source: https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html. INVOKE is fanned out when the runtime takes an invocation; as the docs say, the invoke response returns to the caller without waiting, but the runtime's next Next is held until every subscribed extension calls Next again (bounded by the invocation deadline). SHUTDOWN (reason spindown/timeout/failure, deadlineMs) is sent on environment teardown. The AWS base images only start /opt/extensions under the runtime interface emulator, which is skipped once AWS_LAMBDA_RUNTIME_API is set, so Zip functions whose layers carry executables in extensions/ get an entrypoint wrapper that starts them, and the runtime's first Next waits for them to register (10s init timeout). + +LAMBDA_DOCKER_FLAGS / --lambda-docker-flags are tokenised (quotes and backslashes only, never shell-evaluated) into an allow-list: -e/--env, -v/--volume (bind or named; not onto /, /opt, /var/task, /var/runtime, /var/lang; not the docker/podman socket or /; options ro,rw,z,Z,delegated,cached,consistent), --add-host, --dns, --network/--net (not host or container:*), -m/--memory, --cpus, --platform. Everything else (--privileged, --pid, --ipc, --cap-add, --device, --user, --security-opt, ...) is dropped with a warning. Reserved Lambda variables cannot be overridden. Flag semantics: https://docs.localstack.cloud/aws/services/lambda/ (example `-e LOCALSTACK_INIT_LOG_LEVEL=debug`; no other flags documented). diff --git a/services/lambda/README.md b/services/lambda/README.md index ae7f38f8ec..2a543807a7 100644 --- a/services/lambda/README.md +++ b/services/lambda/README.md @@ -8,19 +8,24 @@ | Metric | Value | | --- | --- | | Feature families | 11 (11 ok) | -| Known gaps | 1 | -| Structural gaps (can't be emulated) | 10 | +| Known gaps | 5 | +| Structural gaps (can't be emulated) | 11 | | Deferred items | 0 | | Resource leaks | ok | ### Known gaps +- Extensions API: the Telemetry/Logs API (/2022-07-01/telemetry, /2020-08-15/logs) is not served; extensions that subscribe get 404. +- Extensions API: container-image functions do not start /opt/extensions executables (no image entrypoint inspection; only Zip functions with layers are wrapped by the extension launcher), and internal extensions registering for SHUTDOWN are not rejected (internal vs external is not distinguishable at register time). +- Extensions API: the error-type strings in register 4xx bodies (Extension.InvalidExtensionName, Extension.TooManyExtensions, Extension.RegistrationClosed, Extension.AlreadyRegistered, Extension.InvalidEventType) are unverified recall; the AWS docs list only the status codes. SHUTDOWN waits until each extension took the event (up to 2s), not until the process exits. +- GET /_aws/lambda/runtimes returns {"Runtimes": [...]}; LocalStack's docs name the endpoint and filter=deprecated|all but do not document the response shape, so the shape is unverified. LOCALSTACK_INIT_LOG_LEVEL and LOCALSTACK_USER are not applicable (no LocalStack init binary); pass them through LAMBDA_DOCKER_FLAGS -e if an image needs them. - --lambda-isolation=firecracker is rejected at startup: no firecracker binary is installed on the build host, so no microVM runner exists. Design: export the function image with `docker export` into an ext4 rootfs, boot it under firecracker with a configured kernel image (--lambda-firecracker-kernel), and reach the Runtime API over vsock (or a TAP device) instead of the docker bridge. Needs /dev/kvm access and a firecracker binary to build and test (gopherstack-cefu7.7). ### Structural gaps These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. +- Stream ESM ParallelizationFactor/TumblingWindowInSeconds: records are hashed by partition key (Kinesis) or item key (DynamoDB) onto N ordered lanes, drained concurrently; a retrying lane pauses reads for the whole shard, tumbling state is kept per lane, and windows close on the clock only once the shard is caught up (Kinesis's up-to-2-minute idle wait is not modeled). - ESM SchemaRegistryErrorCount and EventPollerUnit metrics: no Glue/Confluent schema-registry integration or poller-unit billing model exist to measure (ProvisionedPollers is emitted for SQS mappings with ProvisionedPollerConfig). - Kafka ESM LoggingConfig DEBUG offsets are logged on every commit, not once a minute, and omit endOffset; INFO/WARN omit securityProtocol, saslMechanism, networkConfig, assignedPartitions (no VPC/auth/rebalance model). - Invoke TenantId is validated (required for PER_TENANT, rejected otherwise) but does not route to a tenant-dedicated execution environment: one environment pool per function. diff --git a/services/lambda/containers.go b/services/lambda/containers.go index df9f64162f..3b4dcae654 100644 --- a/services/lambda/containers.go +++ b/services/lambda/containers.go @@ -168,6 +168,12 @@ func (b *InMemoryBackend) cleanupTimedOutRuntime(functionName string) { return } + rt.mu.RLock("cleanupTimedOutRuntime") + if rt.srv != nil { + rt.srv.setShutdownReason(shutdownReasonTimeout) + } + rt.mu.RUnlock() + var sem chan struct{} func() { @@ -234,6 +240,10 @@ func (b *InMemoryBackend) getOrCreateRuntime( } srv := newRuntimeServer(port) + srv.exts.setMeta(extensionMeta{ + functionName: fn.FunctionName, version: functionVersionOrLatest(fn.Version), handler: fn.Handler, + functionARN: buildARN(b.region, b.accountID, fn.FunctionName), accountID: b.accountID, + }) if startErr := srv.start(ctx); startErr != nil { _ = b.portAlloc.Release(port) @@ -253,7 +263,7 @@ func (b *InMemoryBackend) getOrCreateRuntime( env, credsExpire := b.buildContainerEnv(ctx, fn, port) - zipDir, layerDirs, containerID, hotFP, containerErr := b.startRuntimeContainer(ctx, fn, env) + zipDir, layerDirs, containerID, hotFP, containerErr := b.startRuntimeContainer(ctx, fn, env, srv) if containerErr != nil { // handleContainerStartFailure receives rt.mu already locked and releases // it itself once its cleanup completes, before doing further b.mu work. @@ -280,13 +290,14 @@ func (b *InMemoryBackend) startRuntimeContainer( ctx context.Context, fn *FunctionConfiguration, env []string, + srv *runtimeServer, ) (string, []string, string, string, error) { hotFP, err := b.hotReloadFingerprintFor(fn) if err != nil { return "", nil, "", "", fmt.Errorf("%w: %w", ErrLambdaUnavailable, err) } - zipDir, layerDirs, containerID, err := b.startContainer(ctx, fn, env) + zipDir, layerDirs, containerID, err := b.startContainer(ctx, fn, env, srv) return zipDir, layerDirs, containerID, hotFP, err } @@ -647,6 +658,7 @@ func (b *InMemoryBackend) startContainer( ctx context.Context, fn *FunctionConfiguration, env []string, + srv *runtimeServer, ) (string, []string, string, error) { // Extract layer zips and prepare the /opt mount for both Zip and Image functions. layerMount, layerDirs, layerErr := b.prepareLayerMount(fn) @@ -657,6 +669,10 @@ func (b *InMemoryBackend) startContainer( ctx, layerDirs = b.withDebugPlan(ctx, fn, layerDirs) + if srv != nil && layerMount != "" { + srv.exts.setExpected(len(listLayerExtensions(layerHostDir(layerMount)))) + } + if fn.PackageType == PackageTypeZip { zipDir, containerID, err := b.startZipContainer(ctx, fn, env, layerMount) @@ -686,6 +702,7 @@ func (b *InMemoryBackend) startContainer( efs.apply(&spec) applyDebugPlan(ctx, &spec) + b.applyDockerFlags(ctx, &spec) spec.OCIRuntime = b.ociRuntime containerID, err := b.docker.CreateAndStart(ctx, spec) @@ -824,7 +841,10 @@ func (b *InMemoryBackend) createZipContainer( spec.Cmd = []string{fn.Handler} } + wrapWithExtensionLauncher(&spec, layerMount) + applyDebugPlan(ctx, &spec) + b.applyDockerFlags(ctx, &spec) spec.OCIRuntime = b.ociRuntime return b.docker.CreateAndStart(ctx, spec) @@ -941,3 +961,13 @@ func (b *InMemoryBackend) fetchS3Code(ctx context.Context, fn *FunctionConfigura return versioned.GetObjectVersionBytes(ctx, fn.S3BucketCode, fn.S3KeyCode, fn.S3ObjectVersionCode) } + +// applyDockerFlags overlays the allow-listed LAMBDA_DOCKER_FLAGS on spec, logging each dropped flag. +func (b *InMemoryBackend) applyDockerFlags(ctx context.Context, spec *container.Spec) { + flags, warnings := parseDockerFlags(b.settings.DockerFlags) + for _, w := range warnings { + logger.Load(ctx).WarnContext(ctx, "lambda: LAMBDA_DOCKER_FLAGS", "detail", w) + } + + flags.apply(spec) +} diff --git a/services/lambda/docker_flags.go b/services/lambda/docker_flags.go new file mode 100644 index 0000000000..b014c2c633 --- /dev/null +++ b/services/lambda/docker_flags.go @@ -0,0 +1,406 @@ +package lambda + +import ( + "errors" + "fmt" + "math" + "net/netip" + "os" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +const ( + bytesPerKiB = 1 << 10 + bytesPerMiB = 1 << 20 + bytesPerGiB = 1 << 30 + minDockerMemory = 6 * bytesPerMiB + nanoCPUsPerCPU = 1_000_000_000 + volumeMaxParts = 3 + shortFlagLen = 2 + volumeMinParts = 2 +) + +var errInvalidDockerFlags = errors.New("invalid LAMBDA_DOCKER_FLAGS") + +var platformPattern = regexp.MustCompile(`^[a-z0-9]+/[a-z0-9_]+(/[a-z0-9]+)?$`) + +//nolint:gochecknoglobals // static allow-list of bind-mount options +var allowedVolumeOptions = map[string]struct{}{ + "ro": {}, "rw": {}, "z": {}, "Z": {}, "delegated": {}, "cached": {}, "consistent": {}, +} + +//nolint:gochecknoglobals // container paths owned by the Lambda sandbox +var reservedMountTargets = map[string]struct{}{ + "/": {}, "/var/task": {}, "/var/runtime": {}, "/var/lang": {}, "/opt": {}, +} + +//nolint:gochecknoglobals // host sockets that hand over the container engine +var deniedMountSources = map[string]struct{}{ + "/": {}, "/var/run/docker.sock": {}, "/run/docker.sock": {}, "/var/run/podman/podman.sock": {}, + "/run/podman/podman.sock": {}, +} + +// dockerFlags is the allow-listed subset of LAMBDA_DOCKER_FLAGS applied to function containers. +type dockerFlags struct { + Network string + Platform string + Env []string + Mounts []string + ExtraHosts []string + DNS []string + MemoryBytes int64 + NanoCPUs int64 +} + +// flagSplitter accumulates shell-style words without performing any expansion. +type flagSplitter struct { + cur strings.Builder + tokens []string + quote rune + inToken bool + escaped bool +} + +func (f *flagSplitter) flush() { + if f.inToken { + f.tokens = append(f.tokens, f.cur.String()) + f.cur.Reset() + + f.inToken = false + } +} + +func (f *flagSplitter) feed(r rune) { + switch { + case f.escaped: + f.cur.WriteRune(r) + + f.escaped = false + case r == '\\' && f.quote != '\'': + f.escaped, f.inToken = true, true + case f.quote != 0: + if r == f.quote { + f.quote = 0 + } else { + f.cur.WriteRune(r) + } + case r == '\'' || r == '"': + f.quote, f.inToken = r, true + case unicode.IsSpace(r): + f.flush() + default: + f.cur.WriteRune(r) + + f.inToken = true + } +} + +// splitDockerFlags tokenises s like a shell would split words (quotes and +// backslash escapes) without performing any expansion or evaluation. +func splitDockerFlags(s string) ([]string, error) { + var f flagSplitter + + for _, r := range s { + f.feed(r) + } + + if f.quote != 0 || f.escaped { + return nil, fmt.Errorf("%w: unterminated quote or escape", errInvalidDockerFlags) + } + + f.flush() + + return f.tokens, nil +} + +const ( + flagEnv = "env" + flagVolume = "volume" + flagAddHost = "add-host" + flagDNS = "dns" + flagNetwork = "network" + flagMemory = "memory" + flagCPUs = "cpus" + flagPlatform = "platform" +) + +// flagAliases maps each supported spelling to its canonical long name. +// +//nolint:gochecknoglobals // static alias table +var flagAliases = map[string]string{ + "-e": flagEnv, "--env": flagEnv, + "-v": flagVolume, "--volume": flagVolume, + "--add-host": flagAddHost, "--dns": flagDNS, + "--network": flagNetwork, "--net": flagNetwork, + "-m": flagMemory, "--memory": flagMemory, + "--cpus": flagCPUs, "--platform": flagPlatform, +} + +// parseDockerFlags parses s into the allow-listed options. Anything outside the +// allow-list, or invalid, is dropped and reported in warnings. +func parseDockerFlags(s string) (dockerFlags, []string) { + var f dockerFlags + + if strings.TrimSpace(s) == "" { + return f, nil + } + + tokens, err := splitDockerFlags(s) + if err != nil { + return f, []string{err.Error()} + } + + var warnings []string + + for i := 0; i < len(tokens); i++ { + name, value, hasValue, ok := cutFlag(tokens[i]) + if !ok { + warnings = append(warnings, fmt.Sprintf("ignoring unexpected argument %q", tokens[i])) + + continue + } + + canon, supported := flagAliases[name] + if !supported { + warnings = append(warnings, fmt.Sprintf("flag %s is not permitted", name)) + + if !hasValue && i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { + i++ // its value, e.g. "--cap-add SYS_ADMIN" + } + + continue + } + + if !hasValue { + if i+1 >= len(tokens) { + warnings = append(warnings, fmt.Sprintf("flag %s needs a value", name)) + + continue + } + + i++ + value = tokens[i] + } + + if msg := f.set(canon, value); msg != "" { + warnings = append(warnings, fmt.Sprintf("%s %q: %s", name, value, msg)) + } + } + + return f, warnings +} + +// cutFlag splits one token into flag name and an attached value (--flag=v or -eV). +func cutFlag(tok string) (string, string, bool, bool) { + switch { + case strings.HasPrefix(tok, "--"): + name, val, has := strings.Cut(tok, "=") + + return name, val, has, true + case strings.HasPrefix(tok, "-") && len(tok) > 1: + if _, known := flagAliases[tok]; known || len(tok) == shortFlagLen { + return tok, "", false, true + } + + return tok[:shortFlagLen], strings.TrimPrefix(tok[shortFlagLen:], "="), true, true + default: + return "", "", false, false + } +} + +func (f *dockerFlags) set(canon, value string) string { + switch canon { + case flagEnv: + return f.addEnv(value) + case flagVolume: + return f.addVolume(value) + case flagAddHost: + return f.addHost(value) + case flagDNS: + return f.addDNS(value) + case flagNetwork: + return f.setNetwork(value) + case flagMemory: + return f.setMemory(value) + case flagCPUs: + return f.setCPUs(value) + case flagPlatform: + return f.setPlatform(value) + } + + return "" +} + +func (f *dockerFlags) addHost(value string) string { + host, ip, ok := strings.Cut(value, ":") + if !ok || host == "" || ip == "" { + return "want host:ip" + } + + f.ExtraHosts = append(f.ExtraHosts, value) + + return "" +} + +func (f *dockerFlags) addDNS(value string) string { + if _, err := netip.ParseAddr(value); err != nil { + return "not an IP address" + } + + f.DNS = append(f.DNS, value) + + return "" +} + +func (f *dockerFlags) setNetwork(value string) string { + if value == "" || value == "host" || strings.HasPrefix(value, "container:") { + return "network mode not permitted" + } + + f.Network = value + + return "" +} + +func (f *dockerFlags) setMemory(value string) string { + n, ok := parseMemoryBytes(value) + if !ok { + return "invalid memory size" + } + + f.MemoryBytes = n + + return "" +} + +func (f *dockerFlags) setCPUs(value string) string { + c, err := strconv.ParseFloat(value, 64) + if err != nil || c <= 0 || math.IsNaN(c) || math.IsInf(c, 0) { + return "invalid cpu count" + } + + f.NanoCPUs = int64(c * nanoCPUsPerCPU) + + return "" +} + +func (f *dockerFlags) setPlatform(value string) string { + if !platformPattern.MatchString(value) { + return "want os/arch" + } + + f.Platform = value + + return "" +} + +func (f *dockerFlags) addEnv(value string) string { + key, _, hasEq := strings.Cut(value, "=") + if key == "" || strings.ContainsAny(key, " \t") { + return "invalid variable name" + } + + if isReservedEnvKey(key) { + return "reserved Lambda variable" + } + + if !hasEq { + v, set := os.LookupEnv(key) + if !set { + return "" + } + + value = key + "=" + v + } + + f.Env = append(f.Env, value) + + return "" +} + +func (f *dockerFlags) addVolume(value string) string { + parts := strings.Split(value, ":") + if len(parts) < volumeMinParts || len(parts) > volumeMaxParts { + return "want source:target[:options]" + } + + src, dst := parts[0], parts[1] + if src == "" || strings.HasPrefix(src, ".") { + return "invalid source" + } + + if _, bad := deniedMountSources[src]; bad { + return "source not permitted" + } + + if !strings.HasPrefix(dst, "/") { + return "target must be absolute" + } + + if _, bad := reservedMountTargets[strings.TrimRight(dst, "/")]; bad || dst == "/" { + return "target reserved by the Lambda sandbox" + } + + if len(parts) == volumeMaxParts { + for opt := range strings.SplitSeq(parts[2], ",") { + if _, ok := allowedVolumeOptions[opt]; !ok { + return "mount option not permitted" + } + } + } + + f.Mounts = append(f.Mounts, value) + + return "" +} + +// parseMemoryBytes parses docker-style sizes such as 512m, 1g, 65536. +func parseMemoryBytes(s string) (int64, bool) { + s = strings.ToLower(strings.TrimSuffix(strings.ToLower(s), "b")) + mult := int64(1) + + switch { + case strings.HasSuffix(s, "k"): + mult, s = bytesPerKiB, strings.TrimSuffix(s, "k") + case strings.HasSuffix(s, "m"): + mult, s = bytesPerMiB, strings.TrimSuffix(s, "m") + case strings.HasSuffix(s, "g"): + mult, s = bytesPerGiB, strings.TrimSuffix(s, "g") + } + + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || n <= 0 || n > math.MaxInt64/mult || n*mult < minDockerMemory { + return 0, false + } + + return n * mult, true +} + +// apply overlays the flags on spec; user env is appended last so it wins. +func (f *dockerFlags) apply(spec *container.Spec) { + spec.Env = append(spec.Env, f.Env...) + spec.Mounts = append(spec.Mounts, f.Mounts...) + spec.ExtraHosts = append(spec.ExtraHosts, f.ExtraHosts...) + spec.DNS = append(spec.DNS, f.DNS...) + + if f.Network != "" { + spec.Network = f.Network + } + + if f.MemoryBytes > 0 { + spec.MemoryBytes = f.MemoryBytes + } + + if f.NanoCPUs > 0 { + spec.NanoCPUs = f.NanoCPUs + } + + if f.Platform != "" { + spec.Platform = f.Platform + } +} diff --git a/services/lambda/docker_flags_internal_test.go b/services/lambda/docker_flags_internal_test.go new file mode 100644 index 0000000000..5a70c46be7 --- /dev/null +++ b/services/lambda/docker_flags_internal_test.go @@ -0,0 +1,182 @@ +package lambda + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +func TestSplitDockerFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want []string + wantErr bool + }{ + {name: "empty", in: "", want: nil}, + {name: "spaces", in: " -e A=1\t--dns 1.1.1.1 ", want: []string{"-e", "A=1", "--dns", "1.1.1.1"}}, + {name: "double_quotes", in: `-e "A=hello world"`, want: []string{"-e", "A=hello world"}}, + {name: "single_quotes_no_expansion", in: `-e 'A=$HOME \n'`, want: []string{"-e", `A=$HOME \n`}}, + {name: "no_shell_eval", in: `-e A=$(id) -e B=` + "`id`", want: []string{"-e", "A=$(id)", "-e", "B=`id`"}}, + {name: "escape", in: `-e A=a\ b`, want: []string{"-e", "A=a b"}}, + {name: "unterminated", in: `-e "A=1`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := splitDockerFlags(tt.in) + if tt.wantErr { + require.ErrorIs(t, err, errInvalidDockerFlags) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseDockerFlags(t *testing.T) { + t.Parallel() + + pathValue := os.Getenv("PATH") + + tests := []struct { + name string + in string + want dockerFlags + warnings int + }{ + {name: "empty"}, + { + name: "env_forms", + in: "-e A=1 --env B=2 --env=C=3 -eD=4", + want: dockerFlags{Env: []string{"A=1", "B=2", "C=3", "D=4"}}, + }, + {name: "env_inherit", in: "-e PATH", want: dockerFlags{Env: []string{"PATH=" + pathValue}}}, + {name: "env_inherit_unset", in: "-e LAMBDA_FLAGS_SURELY_UNSET"}, + {name: "env_reserved", in: "-e AWS_LAMBDA_RUNTIME_API=evil:1", warnings: 1}, + { + name: "volume", + in: "-v /host/data:/data:ro --volume=named:/n", + want: dockerFlags{Mounts: []string{"/host/data:/data:ro", "named:/n"}}, + }, + {name: "volume_reserved_target", in: "-v /x:/var/task -v /y:/opt", warnings: 2}, + {name: "volume_docker_sock", in: "-v /var/run/docker.sock:/s.sock", warnings: 1}, + {name: "volume_relative", in: "-v ./rel:/data", warnings: 1}, + {name: "volume_propagation", in: "-v /a:/b:rshared", warnings: 1}, + {name: "volume_bad_shape", in: "-v /only", warnings: 1}, + { + name: "add_host", + in: "--add-host db.local:10.0.0.5", + want: dockerFlags{ExtraHosts: []string{"db.local:10.0.0.5"}}, + }, + {name: "add_host_bad", in: "--add-host nocolon", warnings: 1}, + {name: "dns", in: "--dns 8.8.8.8 --dns=1.1.1.1", want: dockerFlags{DNS: []string{"8.8.8.8", "1.1.1.1"}}}, + {name: "dns_bad", in: "--dns not-an-ip", warnings: 1}, + {name: "network", in: "--network my-net", want: dockerFlags{Network: "my-net"}}, + {name: "network_host", in: "--network host --net=container:abc", warnings: 2}, + {name: "memory", in: "-m 512m", want: dockerFlags{MemoryBytes: 512 << 20}}, + {name: "memory_g", in: "--memory=1g", want: dockerFlags{MemoryBytes: 1 << 30}}, + {name: "memory_too_small", in: "-m 1k", warnings: 1}, + {name: "cpus", in: "--cpus 1.5", want: dockerFlags{NanoCPUs: 1_500_000_000}}, + {name: "cpus_bad", in: "--cpus -1", warnings: 1}, + {name: "platform", in: "--platform linux/arm64", want: dockerFlags{Platform: "linux/arm64"}}, + {name: "platform_bad", in: "--platform nonsense", warnings: 1}, + {name: "privileged", in: "--privileged", warnings: 1}, + { + name: "dangerous_set", + in: "--pid=host --ipc host --cap-add SYS_ADMIN --device /dev/sda " + + "--user root --security-opt seccomp=unconfined", + warnings: 6, + }, + {name: "mixed", in: "--privileged -e KEEP=1", want: dockerFlags{Env: []string{"KEEP=1"}}, warnings: 1}, + {name: "missing_value", in: "-e", warnings: 1}, + {name: "stray_arg", in: "bash -c id", warnings: 2}, + {name: "unterminated_quote", in: `-e "A=1`, warnings: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, warnings := parseDockerFlags(tt.in) + assert.Equal(t, tt.want, got) + assert.Len(t, warnings, tt.warnings, "%v", warnings) + }) + } +} + +func TestDockerFlagsApply(t *testing.T) { + t.Parallel() + + spec := container.Spec{Env: []string{"A=base"}, Mounts: []string{"/code:/var/task:ro"}} + + flags, warnings := parseDockerFlags( + "-e A=override -v /h:/data --add-host x:1.2.3.4 --dns 9.9.9.9 --network n -m 256m --cpus 2 --platform linux/amd64", + ) + require.Empty(t, warnings) + flags.apply(&spec) + + assert.Equal(t, []string{"A=base", "A=override"}, spec.Env) + assert.Equal(t, []string{"/code:/var/task:ro", "/h:/data"}, spec.Mounts) + assert.Equal(t, []string{"x:1.2.3.4"}, spec.ExtraHosts) + assert.Equal(t, []string{"9.9.9.9"}, spec.DNS) + assert.Equal(t, "n", spec.Network) + assert.EqualValues(t, 256<<20, spec.MemoryBytes) + assert.EqualValues(t, 2_000_000_000, spec.NanoCPUs) + assert.Equal(t, "linux/amd64", spec.Platform) + assert.False(t, spec.Privileged) +} + +func TestRuntimeIdentifiers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filter string + has string + hasNot string + wantErr bool + }{ + {name: "default", filter: "", has: "python3.12", hasNot: "python3.7"}, + {name: "supported", filter: "supported", has: "provided.al2023", hasNot: "nodejs12.x"}, + {name: "deprecated", filter: "deprecated", has: "python3.7", hasNot: "python3.12"}, + {name: "all", filter: "all", has: "python3.7"}, + {name: "invalid", filter: "bogus", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := RuntimeIdentifiers(tt.filter) + if tt.wantErr { + require.ErrorIs(t, err, ErrInvalidRuntimeFilter) + + return + } + + require.NoError(t, err) + assert.Contains(t, got, tt.has) + + if tt.hasNot != "" { + assert.NotContains(t, got, tt.hasNot) + } + + assert.IsIncreasing(t, got) + }) + } + + all, _ := RuntimeIdentifiers("all") + assert.Contains(t, all, "python3.12") +} diff --git a/services/lambda/esm_failure.go b/services/lambda/esm_failure.go index 7ad67f5f98..4a944f7fba 100644 --- a/services/lambda/esm_failure.go +++ b/services/lambda/esm_failure.go @@ -87,6 +87,7 @@ type invokeOutcome struct { requestID string functionError string executedVersion string + body []byte statusCode int } diff --git a/services/lambda/esm_logging.go b/services/lambda/esm_logging.go index 93a5131e0b..8bec9b6bab 100644 --- a/services/lambda/esm_logging.go +++ b/services/lambda/esm_logging.go @@ -84,7 +84,7 @@ func (l esmLogger) emit(ctx context.Context, at, eventType string, extra map[str } rec := map[string]any{ - "eventType": eventType, + jsonKeyEventType: eventType, logKeyTimestamp: time.Now().UnixMilli(), "resourceArn": l.resourceARN, "eventProcessorId": l.uuid + "/0", diff --git a/services/lambda/esm_stream_retry.go b/services/lambda/esm_stream_retry.go index 2129a8cee2..8330cc8f28 100644 --- a/services/lambda/esm_stream_retry.go +++ b/services/lambda/esm_stream_retry.go @@ -17,14 +17,18 @@ import ( type streamEvent struct { arrival time.Time seq string + key string raw json.RawMessage } // streamBatch is a batch awaiting a successful invocation. type streamBatch struct { - last invokeOutcome + win *tumblingWindow events []streamEvent + last invokeOutcome attempts int + final bool + early bool } // streamShardRef identifies the shard a batch belongs to; key is the poller's per-shard map key. @@ -82,14 +86,6 @@ func (p *EventSourcePoller) replaceFrontStreamBatch(key string, with ...*streamB p.streamPending[key] = rest } -// deliverStreamEvents queues the events as a batch and drains the shard's pending batches. -func (p *EventSourcePoller) deliverStreamEvents( - ctx context.Context, m *EventSourceMapping, ref streamShardRef, events []streamEvent, -) { - p.enqueueStreamBatch(ref.key, &streamBatch{events: events}) - p.drainStream(ctx, m, ref) -} - // drainStream works through the shard's pending batches in order, stopping at the first one that must be retried. func (p *EventSourcePoller) drainStream(ctx context.Context, m *EventSourceMapping, ref streamShardRef) { pol := policyFor(m) @@ -106,19 +102,21 @@ func (p *EventSourcePoller) drainStream(ctx context.Context, m *EventSourceMappi func (p *EventSourcePoller) stepStreamBatch( ctx context.Context, m *EventSourceMapping, ref streamShardRef, pol retryPolicy, b *streamBatch, ) bool { - fresh, expired := splitExpiredEvents(b.events, pol.maxAge, time.Now()) - if len(expired) > 0 { - p.dropStreamEvents(ctx, m, ref, b, expired) - b.events = fresh - } + if !b.final { + fresh, expired := splitExpiredEvents(b.events, pol.maxAge, p.nowTime()) + if len(expired) > 0 { + p.dropStreamEvents(ctx, m, ref, b, expired) + b.events = fresh + } - if len(b.events) == 0 { - p.replaceFrontStreamBatch(ref.key) + if len(b.events) == 0 { + p.replaceFrontStreamBatch(ref.key) - return true + return true + } } - payload, err := streamEventPayload(b.events) + payload, err := p.streamBatchPayload(m, ref, b) if err != nil { logger.Load(ctx).WarnContext(ctx, "event source poller: failed to marshal stream event", "error", err) p.replaceFrontStreamBatch(ref.key) @@ -126,13 +124,23 @@ func (p *EventSourcePoller) stepStreamBatch( return true } - out := p.invokeStream(ctx, m, ref, payload) + out, done := p.interpretStreamResponse(m, b, p.invokeStream(ctx, m, ref, payload)) b.attempts++ b.last = out - p.esmInvokeMetrics(m, len(b.events), failedCount(boolErr(out.failed()), len(b.events))) + failed := len(b.events) if !out.failed() { + failed = 0 + if done > 0 { + failed = len(b.events) - done + } + } + + p.esmInvokeMetrics(m, len(b.events), failed) + + if !out.failed() && done == 0 { p.lambdaBackend.setESMLastProcessingResult(m.UUID, "OK") + p.settleWindowBatch(ref.key, b, true) p.replaceFrontStreamBatch(ref.key) return true @@ -142,10 +150,12 @@ func (p *EventSourcePoller) stepStreamBatch( logger.Load(ctx).WarnContext(ctx, "event source poller: stream invocation failed", "uuid", m.UUID, "attempt", b.attempts, "error", out.message()) - if pol.bisect && len(b.events) > 1 { + if done > 0 { + b.events = b.events[done:] + } else if pol.bisect && len(b.events) > 1 { mid := len(b.events) / bisectParts - left := &streamBatch{events: b.events[:mid], attempts: b.attempts - 1, last: out} - right := &streamBatch{events: b.events[mid:], attempts: b.attempts - 1, last: out} + left := &streamBatch{events: b.events[:mid], attempts: b.attempts - 1, last: out, win: b.win} + right := &streamBatch{events: b.events[mid:], attempts: b.attempts - 1, last: out, win: b.win} p.replaceFrontStreamBatch(ref.key, left, right) return true @@ -153,6 +163,7 @@ func (p *EventSourcePoller) stepStreamBatch( if pol.limited && b.attempts > pol.maxRetries { p.dropStreamEvents(ctx, m, ref, b, b.events) + p.settleWindowBatch(ref.key, &streamBatch{win: b.win, final: b.final, early: b.early}, false) p.replaceFrontStreamBatch(ref.key) return true @@ -161,14 +172,6 @@ func (p *EventSourcePoller) stepStreamBatch( return false } -func boolErr(failed bool) error { - if failed { - return errKafkaFunction - } - - return nil -} - func splitExpiredEvents(events []streamEvent, maxAge time.Duration, now time.Time) ([]streamEvent, []streamEvent) { if maxAge <= 0 { return events, nil @@ -224,11 +227,12 @@ func (p *EventSourcePoller) invokeStreamUntraced( out.executedVersion = fn.Version } - _, _, fnErr, status, err := p.lambdaBackend.InvokeFunctionWithQualifier( + body, _, fnErr, status, err := p.lambdaBackend.InvokeFunctionWithQualifier( ctx, fnName, qualifier, "", "", InvocationTypeRequestResponse, payload, ) out.statusCode = cmp.Or(status, out.statusCode) out.functionError = fnErr + out.body = body if err != nil { out.err = err @@ -254,6 +258,10 @@ func fillOutcome(got, def invokeOutcome) invokeOutcome { func (p *EventSourcePoller) dropStreamEvents( ctx context.Context, m *EventSourceMapping, ref streamShardRef, b *streamBatch, events []streamEvent, ) { + if len(events) == 0 { + return + } + p.esmCount(m, esmMetricDropped, len(events)) dest := failureDestination(m) diff --git a/services/lambda/esm_stream_window.go b/services/lambda/esm_stream_window.go new file mode 100644 index 0000000000..13374b33d2 --- /dev/null +++ b/services/lambda/esm_stream_window.go @@ -0,0 +1,444 @@ +package lambda + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "hash/fnv" + "math" + "slices" + "strconv" + "strings" + "sync" + "time" +) + +const ( + maxTumblingStateBytes = 1 << 20 + tumblingTimeLayout = "2006-01-02T15:04:05Z" + reportBatchItemFailed = "ReportBatchItemFailures" + emptyJSONObject = "{}" +) + +var ( + errTumblingStateMissing = errors.New("tumbling window response has no state property") + errBatchItemFailure = errors.New("function reported a failure for the first record of the batch") +) + +// tumblingWindow is one open aggregation window of a shard lane; state is the last state a function returned. +type tumblingWindow struct { + start time.Time + end time.Time + state json.RawMessage + finalQueued bool +} + +// streamBuffer holds records waiting for BatchSize or MaximumBatchingWindowInSeconds. +type streamBuffer struct { + firstSeen time.Time + events []streamEvent +} + +func (p *EventSourcePoller) nowTime() time.Time { + if p.clock != nil { + return p.clock() + } + + return time.Now() +} + +func laneKey(shardKey string, lane, lanes int) string { + if lanes <= 1 { + return shardKey + } + + return shardKey + "#" + strconv.Itoa(lane) +} + +func isLaneOf(key, shardKey string) bool { + return key == shardKey || strings.HasPrefix(key, shardKey+"#") +} + +func laneIndex(partitionKey string, lanes int) int { + if lanes <= 1 { + return 0 + } + + h := fnv.New32a() + _, _ = h.Write([]byte(partitionKey)) + + return int(h.Sum32()&math.MaxInt32) % lanes +} + +// deliverStreamEvents buffers the events and dispatches whatever is due. +func (p *EventSourcePoller) deliverStreamEvents( + ctx context.Context, m *EventSourceMapping, ref streamShardRef, events []streamEvent, +) { + p.bufferStreamEvents(ref.key, events) + p.flushStreamShard(ctx, m, ref, false) +} + +// settleStreamShard is called when the shard has no new records: due windows close and due buffers flush. +func (p *EventSourcePoller) settleStreamShard(ctx context.Context, m *EventSourceMapping, ref streamShardRef) { + p.flushStreamShard(ctx, m, ref, true) +} + +func (p *EventSourcePoller) bufferStreamEvents(key string, events []streamEvent) { + p.mu.Lock("bufferStreamEvents") + defer p.mu.Unlock() + + buf := p.streamBuffers[key] + if buf == nil { + buf = &streamBuffer{firstSeen: p.nowTime()} + p.streamBuffers[key] = buf + } + + buf.events = append(buf.events, events...) +} + +// takeDueEvents pops the buffered events that are ready to be invoked, in BatchSize chunks. +func (p *EventSourcePoller) takeDueEvents(m *EventSourceMapping, key string, now time.Time) [][]streamEvent { + p.mu.Lock("takeDueEvents") + defer p.mu.Unlock() + + buf := p.streamBuffers[key] + if buf == nil || len(buf.events) == 0 { + return nil + } + + size := m.BatchSize + if size <= 0 { + size = len(buf.events) + } + + window := time.Duration(m.MaximumBatchingWindowInSeconds) * time.Second + take := len(buf.events) + + if window > 0 && now.Sub(buf.firstSeen) < window { + take = len(buf.events) / size * size + if take == 0 { + return nil + } + } + + taken := buf.events[:take] + if take == len(buf.events) { + delete(p.streamBuffers, key) + } else { + buf.events = slices.Clone(buf.events[take:]) + } + + return slices.Collect(slices.Chunk(taken, size)) +} + +func (p *EventSourcePoller) flushStreamShard( + ctx context.Context, m *EventSourceMapping, ref streamShardRef, caughtUp bool, +) { + now := p.nowTime() + lanes := max(1, m.ParallelizationFactor) + + for _, chunk := range p.takeDueEvents(m, ref.key, now) { + perLane := make([][]streamEvent, lanes) + for _, e := range chunk { + i := laneIndex(e.key, lanes) + perLane[i] = append(perLane[i], e) + } + + for i, evs := range perLane { + if len(evs) > 0 { + p.enqueueLane(m, laneKey(ref.key, i, lanes), evs, now) + } + } + } + + if caughtUp && m.TumblingWindowInSeconds > 0 { + p.queueDueFinals(ref.key, now) + } + + p.drainShard(ctx, m, ref) +} + +func (p *EventSourcePoller) enqueueLane(m *EventSourceMapping, lane string, events []streamEvent, now time.Time) { + if m.TumblingWindowInSeconds <= 0 { + p.enqueueStreamBatch(lane, &streamBatch{events: events}) + + return + } + + size := time.Duration(m.TumblingWindowInSeconds) * time.Second + + p.mu.Lock("enqueueLane") + defer p.mu.Unlock() + + for i := 0; i < len(events); { + w := p.streamWindows[lane] + + var floor time.Time + if w != nil && !w.finalQueued { + floor = w.start + } + + start := laterOf(windowStart(events[i], size, now), floor) + j := i + 1 + + for j < len(events) && laterOf(windowStart(events[j], size, now), floor).Equal(start) { + j++ + } + + if w == nil || w.finalQueued || !w.start.Equal(start) { + if w != nil && !w.finalQueued { + p.queueFinalLocked(lane, w, false) + } + + w = &tumblingWindow{start: start, end: start.Add(size)} + p.streamWindows[lane] = w + } + + p.streamPending[lane] = append(p.streamPending[lane], &streamBatch{events: events[i:j], win: w}) + i = j + } +} + +func laterOf(a, b time.Time) time.Time { + if b.After(a) { + return b + } + + return a +} + +func windowStart(e streamEvent, size time.Duration, now time.Time) time.Time { + t := e.arrival + if t.IsZero() { + t = now + } + + secs := int64(size / time.Second) + + return time.Unix(t.Unix()/secs*secs, 0).UTC() +} + +func (p *EventSourcePoller) queueFinalLocked(lane string, w *tumblingWindow, early bool) { + w.finalQueued = true + p.streamPending[lane] = append(p.streamPending[lane], &streamBatch{win: w, final: true, early: early}) +} + +func (p *EventSourcePoller) queueDueFinals(shardKey string, now time.Time) { + p.mu.Lock("queueDueFinals") + defer p.mu.Unlock() + + for lane, w := range p.streamWindows { + if isLaneOf(lane, shardKey) && !w.finalQueued && !now.Before(w.end) { + p.queueFinalLocked(lane, w, false) + } + } +} + +// shardBlocked reports whether any lane of the shard still has batches to retry. +func (p *EventSourcePoller) shardBlocked(shardKey string) bool { + p.mu.RLock("shardBlocked") + defer p.mu.RUnlock() + + for k, q := range p.streamPending { + if len(q) > 0 && isLaneOf(k, shardKey) { + return true + } + } + + return false +} + +// drainShard drains every lane of the shard concurrently; batches within a lane stay ordered. +func (p *EventSourcePoller) drainShard(ctx context.Context, m *EventSourceMapping, ref streamShardRef) { + p.mu.RLock("drainShard") + + var keys []string + + for k, q := range p.streamPending { + if len(q) > 0 && isLaneOf(k, ref.key) { + keys = append(keys, k) + } + } + + p.mu.RUnlock() + + if len(keys) == 1 { + lane := ref + lane.key = keys[0] + p.drainStream(ctx, m, lane) + + return + } + + var wg sync.WaitGroup + + for _, k := range keys { + lane := ref + lane.key = k + + wg.Go(func() { p.drainStream(ctx, m, lane) }) + } + + wg.Wait() +} + +type windowRange struct { + Start string `json:"start"` + End string `json:"end"` +} + +type windowedPayload struct { + State json.RawMessage `json:"state"` + Window windowRange `json:"window"` + ShardID string `json:"shardId"` + EventSourceARN string `json:"eventSourceARN"` + Records []json.RawMessage `json:"Records"` + IsFinalInvokeForWindow bool `json:"isFinalInvokeForWindow"` + IsWindowTerminatedEarly bool `json:"isWindowTerminatedEarly"` +} + +func (p *EventSourcePoller) streamBatchPayload( + m *EventSourceMapping, ref streamShardRef, b *streamBatch, +) ([]byte, error) { + if b.win == nil { + return streamEventPayload(b.events) + } + + p.mu.RLock("streamBatchPayload") + state := b.win.state + p.mu.RUnlock() + + if len(state) == 0 { + state = json.RawMessage(emptyJSONObject) + } + + raws := make([]json.RawMessage, len(b.events)) + for i, e := range b.events { + raws[i] = e.raw + } + + return json.Marshal(windowedPayload{ + Records: raws, + Window: windowRange{ + Start: b.win.start.UTC().Format(tumblingTimeLayout), + End: b.win.end.UTC().Format(tumblingTimeLayout), + }, + State: state, + ShardID: ref.shardID, + EventSourceARN: m.EventSourceARN, + IsFinalInvokeForWindow: b.final, + IsWindowTerminatedEarly: b.early, + }) +} + +// windowResponseState extracts the required state property of a tumbling-window response. +func windowResponseState(body []byte) (json.RawMessage, bool) { + var resp struct { + State *json.RawMessage `json:"state"` + } + + if err := json.Unmarshal(body, &resp); err != nil || resp.State == nil { + return nil, false + } + + if bytes.Equal(bytes.TrimSpace(*resp.State), []byte("null")) { + return json.RawMessage(emptyJSONObject), true + } + + return *resp.State, true +} + +// batchFailureIndex returns -1 when the response reports no failures, the position of the lowest-sequence failed +// record otherwise, and 0 when the report is unusable (a complete failure). +func batchFailureIndex(body []byte, events []streamEvent) int { + var resp struct { + BatchItemFailures []struct { + ItemIdentifier *string `json:"itemIdentifier"` + } `json:"batchItemFailures"` + } + + if len(bytes.TrimSpace(body)) == 0 || json.Unmarshal(body, &resp) != nil || len(resp.BatchItemFailures) == 0 { + return -1 + } + + first := len(events) + + for _, f := range resp.BatchItemFailures { + if f.ItemIdentifier == nil || *f.ItemIdentifier == "" { + return 0 + } + + i := slices.IndexFunc(events, func(e streamEvent) bool { return e.seq == *f.ItemIdentifier }) + if i < 0 { + return 0 + } + + first = min(first, i) + } + + return first +} + +// interpretStreamResponse applies the tumbling-window and partial-batch rules to a successful invocation. +// It returns the outcome (marked failed when the response is unusable) and the leading records confirmed processed. +func (p *EventSourcePoller) interpretStreamResponse( + m *EventSourceMapping, b *streamBatch, out invokeOutcome, +) (invokeOutcome, int) { + if out.failed() { + return out, 0 + } + + idx := -1 + if len(b.events) > 0 && slices.Contains(m.FunctionResponseTypes, reportBatchItemFailed) { + idx = batchFailureIndex(out.body, b.events) + } + + if idx == 0 { + out.err, out.functionError = errBatchItemFailure, functionErrorUnhandled + + return out, 0 + } + + if b.win != nil { + state, ok := windowResponseState(out.body) + if !ok { + out.err, out.functionError = errTumblingStateMissing, functionErrorUnhandled + + return out, 0 + } + + p.mu.Lock("interpretStreamResponse") + b.win.state = state + p.mu.Unlock() + } + + return out, max(idx, 0) +} + +// settleWindowBatch finishes a window after its final invocation succeeds or is dropped, or ends it early +// when the aggregated state outgrows the per-shard limit. +func (p *EventSourcePoller) settleWindowBatch(lane string, b *streamBatch, succeeded bool) { + if b.win == nil { + return + } + + p.mu.Lock("settleWindowBatch") + defer p.mu.Unlock() + + switch { + case b.final && b.early: + b.win.finalQueued = false + b.win.state = nil + case b.final: + if p.streamWindows[lane] == b.win { + delete(p.streamWindows, lane) + } + + b.win.state = nil + case succeeded && len(b.win.state) > maxTumblingStateBytes && !b.win.finalQueued: + b.win.finalQueued = true + q := p.streamPending[lane] + p.streamPending[lane] = slices.Concat(q[:1], []*streamBatch{{win: b.win, final: true, early: true}}, q[1:]) + } +} diff --git a/services/lambda/esm_stream_window_internal_test.go b/services/lambda/esm_stream_window_internal_test.go new file mode 100644 index 0000000000..cec3b22455 --- /dev/null +++ b/services/lambda/esm_stream_window_internal_test.go @@ -0,0 +1,721 @@ +package lambda + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type scriptedKinesisReader struct { + polls [][]KinesisRecord + mu sync.Mutex +} + +func (*scriptedKinesisReader) GetShardIDs(string) ([]string, error) { + return []string{"shardId-000000000001"}, nil +} + +func (*scriptedKinesisReader) GetShardIterator(_, _, _, _ string) (string, error) { return "it", nil } + +func (r *scriptedKinesisReader) GetRecords(string, int) ([]KinesisRecord, string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if len(r.polls) == 0 { + return nil, "it", nil + } + + recs := r.polls[0] + r.polls = r.polls[1:] + + return recs, "it", nil +} + +type streamCall struct { + Window *windowRange `json:"window"` + State map[string]any `json:"state"` + ShardID string `json:"shardId"` + EventSourceARN string `json:"eventSourceARN"` + Records []json.RawMessage `json:"Records"` + IsFinalInvokeForWindow bool `json:"isFinalInvokeForWindow"` + IsWindowTerminatedEarly bool `json:"isWindowTerminatedEarly"` +} + +func (c streamCall) seqs(t *testing.T) []string { + t.Helper() + + out := make([]string, 0, len(c.Records)) + + for _, r := range c.Records { + var rec struct { + Kinesis struct { + Seq string `json:"sequenceNumber"` + } `json:"kinesis"` + } + + require.NoError(t, json.Unmarshal(r, &rec)) + out = append(out, rec.Kinesis.Seq) + } + + return out +} + +type streamHarness struct { + p *EventSourcePoller + now time.Time + calls []streamCall + mu sync.Mutex +} + +func newStreamHarness( + t *testing.T, in *CreateEventSourceMappingInput, polls [][]KinesisRecord, base time.Time, + respond func(h *streamHarness, c streamCall) invokeOutcome, +) *streamHarness { + t.Helper() + + b := newKafkaTestBackend(t) + require.NoError(t, b.CreateFunction(&FunctionConfiguration{FunctionName: "f"})) + + in.FunctionName, in.EventSourceARN, in.Enabled = "f", testKinesisARN, true + in.StartingPosition = "TRIM_HORIZON" + + if in.BatchSize == 0 { + in.BatchSize = 100 + } + + _, err := b.CreateEventSourceMapping(in) + require.NoError(t, err) + + h := &streamHarness{now: base} + h.p = NewEventSourcePoller(b, &scriptedKinesisReader{polls: polls}) + h.p.clock = func() time.Time { + h.mu.Lock() + defer h.mu.Unlock() + + return h.now + } + h.p.streamInvoker = func(_ context.Context, _ string, payload []byte) invokeOutcome { + var c streamCall + + require.NoError(t, json.Unmarshal(payload, &c)) + + h.mu.Lock() + h.calls = append(h.calls, c) + h.mu.Unlock() + + return respond(h, c) + } + + return h +} + +func (h *streamHarness) advance(d time.Duration) { + h.mu.Lock() + defer h.mu.Unlock() + + h.now = h.now.Add(d) +} + +func (h *streamHarness) snapshot() []streamCall { + h.mu.Lock() + defer h.mu.Unlock() + + return append([]streamCall(nil), h.calls...) +} + +func keyedRecords(base time.Time, keys ...string) []KinesisRecord { + recs := make([]KinesisRecord, len(keys)) + for i, k := range keys { + recs[i] = KinesisRecord{ + PartitionKey: k, + SequenceNumber: strconv.Itoa(i + 1), + Data: []byte(k), + ArrivalTime: base.Add(time.Duration(i) * time.Second), + } + } + + return recs +} + +func TestStreamParallelizationFactor(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + keys := []string{"a", "b", "c", "d", "e", "f", "a", "b", "c", "d", "e", "f"} + + tests := []struct { + name string + factor int + wantPeak int + wantLanes int + }{ + {name: "factor one is one batch", factor: 1, wantPeak: 1, wantLanes: 1}, + {name: "factor three runs lanes concurrently", factor: 3, wantLanes: 3}, + {name: "factor ten", factor: 10, wantLanes: 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + lanes := map[int]struct{}{} + for _, k := range keys { + lanes[laneIndex(k, tt.factor)] = struct{}{} + } + + want := len(lanes) + + var ( + mu sync.Mutex + active int + peak int + release = make(chan struct{}) + once sync.Once + timedOut bool + ) + + h := newStreamHarness(t, + &CreateEventSourceMappingInput{ParallelizationFactor: tt.factor}, + [][]KinesisRecord{keyedRecords(base, keys...)}, base, + func(*streamHarness, streamCall) invokeOutcome { + mu.Lock() + active++ + peak = max(peak, active) + + if active == want { + once.Do(func() { close(release) }) + } + + mu.Unlock() + + select { + case <-release: + case <-time.After(10 * time.Second): + mu.Lock() + timedOut = true + mu.Unlock() + } + + mu.Lock() + active-- + mu.Unlock() + + return invokeOutcome{} + }) + + h.p.poll(t.Context()) + + assert.False(t, timedOut, "lanes did not run concurrently") + assert.Equal(t, want, peak) + assert.Len(t, h.snapshot(), want) + + perKey := map[string][]string{} + + for _, c := range h.snapshot() { + laneOf := -1 + + for _, r := range c.Records { + var rec struct { + Kinesis struct { + PK string `json:"partitionKey"` + Seq string `json:"sequenceNumber"` + } `json:"kinesis"` + } + + require.NoError(t, json.Unmarshal(r, &rec)) + + l := laneIndex(rec.Kinesis.PK, tt.factor) + if laneOf >= 0 { + assert.Equal(t, laneOf, l, "batch mixes lanes") + } + + laneOf = l + perKey[rec.Kinesis.PK] = append(perKey[rec.Kinesis.PK], rec.Kinesis.Seq) + } + } + + for k, seqs := range perKey { + assert.Len(t, seqs, 2, k) + first, _ := strconv.Atoi(seqs[0]) + second, _ := strconv.Atoi(seqs[1]) + assert.Less(t, first, second, "partition key %s out of order", k) + } + }) + } +} + +func TestStreamTumblingWindow(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + + type step struct { + recs []KinesisRecord + advance time.Duration + } + + type want struct { + state string + records int + final bool + } + + counting := func(_ *streamHarness, c streamCall) invokeOutcome { + n := 0 + if v, ok := c.State["n"].(float64); ok { + n = int(v) + } + + return invokeOutcome{body: []byte(`{"state":{"n":` + strconv.Itoa(n+len(c.Records)) + `}}`)} + } + + tests := []struct { + respond func(h *streamHarness, c streamCall) invokeOutcome + name string + steps []step + want []want + }{ + { + name: "state carried and final invoke at window end", + steps: []step{ + {recs: keyedRecords(base.Add(5*time.Second), "k", "k")}, + {recs: keyedRecords(base.Add(30*time.Second), "k")}, + {advance: 30 * time.Second}, + {advance: 31 * time.Second}, + }, + want: []want{ + {records: 2, state: `{}`}, + {records: 1, state: `{"n":2}`}, + {records: 0, state: `{"n":3}`, final: true}, + }, + respond: counting, + }, + { + name: "record in next window closes the previous one", + steps: []step{ + {recs: keyedRecords(base.Add(5*time.Second), "k")}, + {recs: keyedRecords(base.Add(65*time.Second), "k")}, + }, + want: []want{ + {records: 1, state: `{}`}, + {records: 0, state: `{"n":1}`, final: true}, + {records: 1, state: `{}`}, + }, + respond: counting, + }, + { + name: "response without state is a failure and retries with the same state", + steps: []step{ + {recs: keyedRecords(base.Add(5*time.Second), "k")}, + {}, + }, + want: []want{ + {records: 1, state: `{}`}, + {records: 1, state: `{}`}, + }, + respond: func(h *streamHarness, c streamCall) invokeOutcome { + if len(h.snapshot()) == 1 { + return invokeOutcome{body: []byte(`{}`)} + } + + return counting(h, c) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + polls := make([][]KinesisRecord, 0, len(tt.steps)) + for _, s := range tt.steps { + polls = append(polls, s.recs) + } + + in := &CreateEventSourceMappingInput{TumblingWindowInSeconds: 60} + h := newStreamHarness(t, in, polls, base, tt.respond) + h.advance(10 * time.Second) + + for _, s := range tt.steps { + h.advance(s.advance) + h.p.poll(t.Context()) + } + + calls := h.snapshot() + require.Len(t, calls, len(tt.want)) + + for i, w := range tt.want { + c := calls[i] + assert.Len(t, c.Records, w.records, "call %d", i) + assert.Equal(t, w.final, c.IsFinalInvokeForWindow, "call %d", i) + assert.False(t, c.IsWindowTerminatedEarly) + assert.Equal(t, "shardId-000000000001", c.ShardID) + assert.Equal(t, testKinesisARN, c.EventSourceARN) + require.NotNil(t, c.Window) + + state, err := json.Marshal(c.State) + require.NoError(t, err) + assert.JSONEq(t, w.state, string(state), "call %d", i) + } + + assert.Equal(t, "2026-01-01T10:00:00Z", calls[0].Window.Start) + assert.Equal(t, "2026-01-01T10:01:00Z", calls[0].Window.End) + }) + } +} + +func TestStreamPartialBatchResponse(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + fail := func(body string) func(*streamHarness, streamCall) invokeOutcome { + return func(h *streamHarness, _ streamCall) invokeOutcome { + if len(h.snapshot()) == 1 { + return invokeOutcome{body: []byte(body)} + } + + return invokeOutcome{} + } + } + + tests := []struct { + respond func(*streamHarness, streamCall) invokeOutcome + name string + types []string + wantSeqs [][]string + bisect bool + retryOnce bool + }{ + { + name: "retries from the failed record", + types: []string{"ReportBatchItemFailures"}, + respond: fail(`{"batchItemFailures":[{"itemIdentifier":"3"}]}`), + wantSeqs: [][]string{{"1", "2", "3", "4"}, {"3", "4"}}, + }, + { + name: "lowest failed sequence is the checkpoint", + types: []string{"ReportBatchItemFailures"}, + respond: fail(`{"batchItemFailures":[{"itemIdentifier":"4"},{"itemIdentifier":"2"}]}`), + wantSeqs: [][]string{{"1", "2", "3", "4"}, {"2", "3", "4"}}, + }, + { + name: "bisect with partial response retries only the remainder", + types: []string{"ReportBatchItemFailures"}, + bisect: true, + respond: fail(`{"batchItemFailures":[{"itemIdentifier":"3"}]}`), + wantSeqs: [][]string{{"1", "2", "3", "4"}, {"3", "4"}}, + }, + { + name: "empty identifier is a complete failure", + types: []string{"ReportBatchItemFailures"}, + respond: fail(`{"batchItemFailures":[{"itemIdentifier":""}]}`), + wantSeqs: [][]string{{"1", "2", "3", "4"}, {"1", "2", "3", "4"}}, + }, + { + name: "empty list is a complete success", + types: []string{"ReportBatchItemFailures"}, + respond: fail(`{"batchItemFailures":[]}`), + wantSeqs: [][]string{{"1", "2", "3", "4"}}, + }, + { + name: "ignored without ReportBatchItemFailures", + respond: fail(`{"batchItemFailures":[{"itemIdentifier":"3"}]}`), + wantSeqs: [][]string{{"1", "2", "3", "4"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + in := &CreateEventSourceMappingInput{ + FunctionResponseTypes: tt.types, BisectBatchOnFunctionError: tt.bisect, + } + h := newStreamHarness(t, in, [][]KinesisRecord{keyedRecords(base, "k", "k", "k", "k")}, base, tt.respond) + + for range 3 { + h.p.poll(t.Context()) + } + + var got [][]string + for _, c := range h.snapshot() { + got = append(got, c.seqs(t)) + } + + assert.Equal(t, tt.wantSeqs, got) + }) + } +} + +func TestStreamMaximumBatchingWindow(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + polls [][]KinesisRecord + wantSizes []int + batchSize int + advance time.Duration + }{ + { + name: "partial batch waits for the window", + polls: [][]KinesisRecord{keyedRecords(base, "k", "k", "k")}, + batchSize: 10, advance: 0, wantSizes: nil, + }, + { + name: "partial batch flushes when the window elapses", + polls: [][]KinesisRecord{keyedRecords(base, "k", "k", "k")}, + batchSize: 10, advance: 30 * time.Second, wantSizes: []int{3}, + }, + { + name: "full batch flushes immediately", + polls: [][]KinesisRecord{keyedRecords(base, "k", "k", "k", "k")}, + batchSize: 2, advance: 0, wantSizes: []int{2, 2}, + }, + { + name: "records accumulate across polls", + polls: [][]KinesisRecord{keyedRecords(base, "k", "k"), keyedRecords(base, "k", "k")}, + batchSize: 4, advance: 0, wantSizes: []int{4}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + in := &CreateEventSourceMappingInput{MaximumBatchingWindowInSeconds: 30, BatchSize: tt.batchSize} + h := newStreamHarness(t, in, tt.polls, base, + func(*streamHarness, streamCall) invokeOutcome { return invokeOutcome{} }) + + for range tt.polls { + h.p.poll(t.Context()) + } + + h.advance(tt.advance) + h.p.poll(t.Context()) + + var sizes []int + for _, c := range h.snapshot() { + sizes = append(sizes, len(c.Records)) + } + + assert.Equal(t, tt.wantSizes, sizes) + }) + } +} + +func TestStreamTuningValidation(t *testing.T) { + t.Parallel() + + const sqsARN = "arn:aws:sqs:us-east-1:000000000000:q" + + const ddbARN = "arn:aws:dynamodb:us-east-1:000000000000:table/t/stream/2026-01-01T00:00:00.000" + + tests := []struct { + name string + arn string + pf int + tumble int + wantErr bool + }{ + {name: "unset", arn: testKinesisARN}, + {name: "factor 1", arn: testKinesisARN, pf: 1}, + {name: "factor 10", arn: testKinesisARN, pf: 10}, + {name: "factor 11", arn: testKinesisARN, pf: 11, wantErr: true}, + {name: "factor negative", arn: testKinesisARN, pf: -1, wantErr: true}, + {name: "ddb factor 10", arn: ddbARN, pf: 10}, + {name: "tumbling 900", arn: ddbARN, tumble: 900}, + {name: "tumbling 901", arn: ddbARN, tumble: 901, wantErr: true}, + {name: "tumbling negative", arn: testKinesisARN, tumble: -1, wantErr: true}, + {name: "factor on sqs", arn: sqsARN, pf: 2, wantErr: true}, + {name: "tumbling on sqs", arn: sqsARN, tumble: 60, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := newKafkaTestBackend(t) + require.NoError(t, b.CreateFunction(&FunctionConfiguration{FunctionName: "f"})) + + m, err := b.CreateEventSourceMapping(&CreateEventSourceMappingInput{ + FunctionName: "f", EventSourceARN: tt.arn, StartingPosition: "LATEST", + ParallelizationFactor: tt.pf, TumblingWindowInSeconds: tt.tumble, + }) + + if tt.wantErr { + require.ErrorIs(t, err, ErrInvalidParameterValue) + + return + } + + require.NoError(t, err) + + if isStreamSourceARN(tt.arn) { + assert.Equal(t, max(tt.pf, 1), m.ParallelizationFactor) + } + }) + } +} + +func TestStreamTuningUpdateValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + tumble *int32 + name string + pf int32 + wantErr bool + }{ + {name: "factor 5", pf: 5}, + {name: "factor 0", pf: 0, wantErr: true}, + {name: "factor 11", pf: 11, wantErr: true}, + {name: "tumbling 901", pf: 1, tumble: new(int32(901)), wantErr: true}, + {name: "tumbling 900", pf: 1, tumble: new(int32(900))}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := newKafkaTestBackend(t) + require.NoError(t, b.CreateFunction(&FunctionConfiguration{FunctionName: "f"})) + + m, err := b.CreateEventSourceMapping(&CreateEventSourceMappingInput{ + FunctionName: "f", EventSourceARN: testKinesisARN, StartingPosition: "LATEST", + }) + require.NoError(t, err) + + _, err = b.UpdateEventSourceMapping(m.UUID, &UpdateEventSourceMappingInput{ + ParallelizationFactor: &tt.pf, TumblingWindowInSeconds: tt.tumble, + }) + + if tt.wantErr { + require.ErrorIs(t, err, ErrInvalidParameterValue) + + return + } + + require.NoError(t, err) + }) + } +} + +type keyedDDBReader struct { + recs []DynamoDBStreamRecord + mu sync.Mutex + served bool +} + +func (*keyedDDBReader) DescribeStreamShards(string) ([]string, error) { + return []string{"shardId-1"}, nil +} + +func (*keyedDDBReader) GetStreamShardIterator(_, _, _ string) (string, error) { return "it", nil } + +func (r *keyedDDBReader) GetStreamRecords(string, int) ([]DynamoDBStreamRecord, string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.served { + return nil, "it", nil + } + + r.served = true + + return r.recs, "it", nil +} + +func TestStreamDynamoDBParallelizationKeepsItemOrder(t *testing.T) { + t.Parallel() + + const ddbARN = "arn:aws:dynamodb:us-east-1:000000000000:table/t/stream/2026-01-01T00:00:00.000" + + ids := []string{"a", "b", "c", "d", "a", "b", "c", "d", "a", "b"} + recs := make([]DynamoDBStreamRecord, len(ids)) + + for i, id := range ids { + recs[i] = DynamoDBStreamRecord{ + EventID: strconv.Itoa(i), EventName: "MODIFY", SequenceNumber: strconv.Itoa(100 + i), + Keys: map[string]any{"Id": map[string]any{"S": id}}, + } + } + + b := newKafkaTestBackend(t) + require.NoError(t, b.CreateFunction(&FunctionConfiguration{FunctionName: "f"})) + _, err := b.CreateEventSourceMapping(&CreateEventSourceMappingInput{ + FunctionName: "f", EventSourceARN: ddbARN, StartingPosition: "TRIM_HORIZON", Enabled: true, + ParallelizationFactor: 4, BatchSize: 100, + }) + require.NoError(t, err) + + p := NewEventSourcePoller(b, nil) + p.SetDynamoDBStreamsReader(&keyedDDBReader{recs: recs}) + + var ( + mu sync.Mutex + calls [][]string + ) + + p.streamInvoker = func(_ context.Context, _ string, payload []byte) invokeOutcome { + var ev struct { + Records []struct { + Dynamodb struct { + Keys map[string]map[string]string `json:"Keys"` + Seq string `json:"SequenceNumber"` + } `json:"dynamodb"` + } `json:"Records"` + } + + require.NoError(t, json.Unmarshal(payload, &ev)) + + got := make([]string, 0, len(ev.Records)) + for _, r := range ev.Records { + got = append(got, r.Dynamodb.Keys["Id"]["S"]+":"+r.Dynamodb.Seq) + } + + mu.Lock() + calls = append(calls, got) + mu.Unlock() + + return invokeOutcome{} + } + + p.poll(t.Context()) + + lanes := map[string]int{} + last := map[string]int{} + total := 0 + + for _, c := range calls { + lane := -1 + + for _, e := range c { + id, seqStr, _ := strings.Cut(e, ":") + seq, _ := strconv.Atoi(seqStr) + assert.Greater(t, seq, last[id], "item %s out of order", id) + last[id] = seq + + l := laneIndex(`{"Id":{"S":"`+id+`"}}`, 4) + if lane >= 0 { + assert.Equal(t, lane, l) + } + + lane = l + lanes[id] = l + total++ + } + } + + assert.Equal(t, len(ids), total) + assert.Greater(t, len(calls), 1, "records were not split across lanes") +} diff --git a/services/lambda/event_source_mapping.go b/services/lambda/event_source_mapping.go index f58067f187..5cb9d7731b 100644 --- a/services/lambda/event_source_mapping.go +++ b/services/lambda/event_source_mapping.go @@ -330,9 +330,52 @@ func validateCreateESM(input *CreateEventSourceMappingInput) error { return err } + if err := validateStreamTuning( + input.EventSourceARN, input.ParallelizationFactor, input.TumblingWindowInSeconds, + ); err != nil { + return err + } + return validateESMBatch(input.EventSourceARN, input.BatchSize, input.MaximumBatchingWindowInSeconds) } +const ( + esmParallelizationMin = 1 + esmParallelizationMax = 10 + esmTumblingWindowMax = 900 +) + +func isStreamSourceARN(sourceARN string) bool { + return isDynamoDBStreamARN(sourceARN) || arn.HasService(sourceARN, "kinesis") +} + +// validateStreamTuning enforces ParallelizationFactor 1-10 and TumblingWindowInSeconds 0-900 on Kinesis and +// DynamoDB Streams sources (CreateEventSourceMapping API reference); zero means unset. +func validateStreamTuning(sourceARN string, parallelization, tumbling int) error { + if parallelization == 0 && tumbling == 0 { + return nil + } + + if !isStreamSourceARN(sourceARN) { + return fmt.Errorf( + "%w: ParallelizationFactor and TumblingWindowInSeconds apply to Kinesis and DynamoDB Streams sources only", + ErrInvalidParameterValue, + ) + } + + if parallelization != 0 && (parallelization < esmParallelizationMin || parallelization > esmParallelizationMax) { + return fmt.Errorf("%w: ParallelizationFactor must be between %d and %d", + ErrInvalidParameterValue, esmParallelizationMin, esmParallelizationMax) + } + + if tumbling < 0 || tumbling > esmTumblingWindowMax { + return fmt.Errorf("%w: TumblingWindowInSeconds must be between 0 and %d", + ErrInvalidParameterValue, esmTumblingWindowMax) + } + + return nil +} + // CreateEventSourceMapping creates a new event source mapping. func (b *InMemoryBackend) CreateEventSourceMapping( input *CreateEventSourceMappingInput, @@ -377,6 +420,11 @@ func (b *InMemoryBackend) CreateEventSourceMapping( ) } + parallelization := input.ParallelizationFactor + if parallelization == 0 && isStreamSourceARN(input.EventSourceARN) { + parallelization = esmParallelizationMin + } + m := &EventSourceMapping{ UUID: id, EventSourceARN: input.EventSourceARN, @@ -401,7 +449,7 @@ func (b *InMemoryBackend) CreateEventSourceMapping( TumblingWindowInSeconds: input.TumblingWindowInSeconds, MaximumRecordAgeInSeconds: input.MaximumRecordAgeInSeconds, MaximumRetryAttempts: input.MaximumRetryAttempts, - ParallelizationFactor: input.ParallelizationFactor, + ParallelizationFactor: parallelization, BisectBatchOnFunctionError: input.BisectBatchOnFunctionError, FunctionResponseTypes: input.FunctionResponseTypes, ScalingConfig: input.ScalingConfig, @@ -714,6 +762,22 @@ func (b *InMemoryBackend) UpdateEventSourceMapping( return } + validateErr = validateStreamTuning( + esm.EventSourceARN, + ptrInt32(input.ParallelizationFactor, esm.ParallelizationFactor), + ptrInt32(input.TumblingWindowInSeconds, esm.TumblingWindowInSeconds), + ) + if validateErr != nil { + return + } + + if input.ParallelizationFactor != nil && *input.ParallelizationFactor == 0 { + validateErr = fmt.Errorf("%w: ParallelizationFactor must be between %d and %d", + ErrInvalidParameterValue, esmParallelizationMin, esmParallelizationMax) + + return + } + validateErr = validateESMBatch( esm.EventSourceARN, ptrInt32(input.BatchSize, esm.BatchSize), ptrInt32(input.MaximumBatchingWindowInSeconds, esm.MaximumBatchingWindowInSeconds), diff --git a/services/lambda/event_source_poller.go b/services/lambda/event_source_poller.go index 830453c6e4..2b0763b355 100644 --- a/services/lambda/event_source_poller.go +++ b/services/lambda/event_source_poller.go @@ -115,6 +115,10 @@ type EventSourcePoller struct { lambdaBackend *InMemoryBackend shardIterators map[string]string streamPending map[string][]*streamBatch + streamBuffers map[string]*streamBuffer + streamWindows map[string]*tumblingWindow + // clock overrides time.Now for stream batching windows and tumbling windows in tests. + clock func() time.Time // sqsBatchBuffers holds partial SQS batches per mapping UUID while the // MaximumBatchingWindow has not yet elapsed. Keyed by ESM UUID. sqsBatchBuffers map[string]*sqsBatchBuffer @@ -160,6 +164,8 @@ func NewEventSourcePoller( kinesisReader: kinesisReader, shardIterators: make(map[string]string), streamPending: make(map[string][]*streamBatch), + streamBuffers: make(map[string]*streamBuffer), + streamWindows: make(map[string]*tumblingWindow), sqsBatchBuffers: make(map[string]*sqsBatchBuffer), sqsFleets: make(map[string]*sqsFleet), kafkaWorkers: make(map[string]*kafkaWorker), @@ -428,6 +434,18 @@ func (p *EventSourcePoller) sweepStaleIterators(activeUUIDs map[string]struct{}) } } + for key := range p.streamBuffers { + if _, active := activeUUIDs[p.shardKeyMapping(key)]; !active { + delete(p.streamBuffers, key) + } + } + + for key := range p.streamWindows { + if _, active := activeUUIDs[p.shardKeyMapping(key)]; !active { + delete(p.streamWindows, key) + } + } + for uuid := range p.sqsBatchBuffers { if _, active := activeUUIDs[uuid]; !active { delete(p.sqsBatchBuffers, uuid) @@ -461,6 +479,18 @@ func (p *EventSourcePoller) RemoveMapping(uuid string) { } } + for key := range p.streamBuffers { + if p.shardKeyMapping(key) == uuid { + delete(p.streamBuffers, key) + } + } + + for key := range p.streamWindows { + if p.shardKeyMapping(key) == uuid { + delete(p.streamWindows, key) + } + } + delete(p.sqsBatchBuffers, uuid) delete(p.sqsFleets, uuid) p.stopKafkaWorker(uuid) @@ -556,6 +586,8 @@ func (p *EventSourcePoller) processMapping(ctx context.Context, m *EventSourceMa p.esmCount(m, esmMetricPolled, len(records)) if len(records) == 0 { + p.settleStreamShard(ctx, m, ref) + continue } @@ -616,7 +648,9 @@ func (p *EventSourcePoller) invokeLambda( continue } - events = append(events, streamEvent{arrival: r.ArrivalTime, seq: r.SequenceNumber, raw: raw}) + events = append(events, streamEvent{ + arrival: r.ArrivalTime, seq: r.SequenceNumber, key: r.PartitionKey, raw: raw, + }) } p.esmFilteredMetric(m, len(records)-len(events)) @@ -770,11 +804,15 @@ func (p *EventSourcePoller) processDDBShard( p.esmCount(m, esmMetricPolled, len(records)) + ref := streamShardRef{shardID: shardID, key: iterKey, ddb: true} + if len(records) == 0 { + p.settleStreamShard(ctx, m, ref) + return } - p.invokeLambdaForDDB(ctx, m, streamShardRef{shardID: shardID, key: iterKey, ddb: true}, records) + p.invokeLambdaForDDB(ctx, m, ref, records) } // invokeLambdaForDDB formats DynamoDB stream records as a Lambda event and invokes the function. @@ -838,7 +876,8 @@ func (p *EventSourcePoller) invokeLambdaForDDB( arrival = time.Time{} } - events = append(events, streamEvent{arrival: arrival, seq: r.SequenceNumber, raw: raw}) + keyJSON, _ := json.Marshal(r.Keys) + events = append(events, streamEvent{arrival: arrival, seq: r.SequenceNumber, key: string(keyJSON), raw: raw}) } p.esmFilteredMetric(m, len(records)-len(events)) @@ -850,15 +889,12 @@ func (p *EventSourcePoller) invokeLambdaForDDB( p.deliverStreamEvents(ctx, m, ref, events) } -// resumePendingStream retries the shard's pending batches and reports whether the shard is still blocked. +// resumePendingStream flushes due buffers and retries the shard's pending batches; it reports whether the shard is +// still blocked. func (p *EventSourcePoller) resumePendingStream(ctx context.Context, m *EventSourceMapping, ref streamShardRef) bool { - if p.frontStreamBatch(ref.key) == nil { - return false - } + p.flushStreamShard(ctx, m, ref, false) - p.drainStream(ctx, m, ref) - - return p.frontStreamBatch(ref.key) != nil + return p.shardBlocked(ref.key) } // processSQSMapping polls an SQS queue, invokes Lambda with the messages, and @@ -1144,14 +1180,6 @@ func filterByBatchItemFailures(reportFailures bool, respBody []byte, msgs []*SQS return toDelete } -func failedCount(err error, n int) int { - if err != nil { - return n - } - - return 0 -} - // esmFilteredMetric reports FilteredOutEventCount only for mappings that carry filter criteria. func (p *EventSourcePoller) esmFilteredMetric(m *EventSourceMapping, n int) { if m.FilterCriteria != nil && len(m.FilterCriteria.Filters) > 0 { diff --git a/services/lambda/extension_launcher.go b/services/lambda/extension_launcher.go new file mode 100644 index 0000000000..31fa2abf09 --- /dev/null +++ b/services/lambda/extension_launcher.go @@ -0,0 +1,72 @@ +package lambda + +import ( + "os" + "path/filepath" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +const ( + extensionsDir = "extensions" + extensionLauncherName = ".gopherstack-launch-extensions.sh" + extensionLauncherPath = "/opt/" + extensionLauncherName + extensionLauncherMode = 0o755 + baseImageEntrypoint = "/lambda-entrypoint.sh" + executableBits = 0o111 + extensionLauncherShell = `#!/bin/sh +for f in /opt/extensions/*; do + if [ -f "$f" ] && [ -x "$f" ]; then + "$f" & + fi +done +exec "$@" +` +) + +// listLayerExtensions returns the executable files in /extensions. +func listLayerExtensions(layerDir string) []string { + entries, err := os.ReadDir(filepath.Join(layerDir, extensionsDir)) + if err != nil { + return nil + } + + var out []string + + for _, e := range entries { + info, infoErr := e.Info() + if infoErr == nil && info.Mode().IsRegular() && info.Mode().Perm()&executableBits != 0 { + out = append(out, e.Name()) + } + } + + return out +} + +// wrapWithExtensionLauncher starts the layer's /opt/extensions/* executables +// before the runtime. The AWS base images only do this under the runtime +// interface emulator, which is bypassed once AWS_LAMBDA_RUNTIME_API is set. +func wrapWithExtensionLauncher(spec *container.Spec, layerMount string) { + if layerMount == "" { + return + } + + dir := layerHostDir(layerMount) + if len(listLayerExtensions(dir)) == 0 { + return + } + + script := filepath.Join(dir, extensionLauncherName) + if err := os.WriteFile(script, []byte(extensionLauncherShell), extensionLauncherMode); err != nil { // #nosec G306 + return + } + + _ = os.Chmod(script, extensionLauncherMode) // #nosec G302 + + inner := spec.Entrypoint + if len(inner) == 0 { + inner = []string{baseImageEntrypoint} + } + + spec.Entrypoint = append([]string{"/bin/sh", extensionLauncherPath}, inner...) +} diff --git a/services/lambda/extension_launcher_internal_test.go b/services/lambda/extension_launcher_internal_test.go new file mode 100644 index 0000000000..5a6fc356f8 --- /dev/null +++ b/services/lambda/extension_launcher_internal_test.go @@ -0,0 +1,71 @@ +package lambda + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" +) + +func TestWrapWithExtensionLauncher(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]os.FileMode + entrypoint []string + want []string + wantCount int + }{ + {name: "no_extensions", files: map[string]os.FileMode{"lib/x.so": 0o644}}, + {name: "not_executable", files: map[string]os.FileMode{"extensions/e": 0o644}}, + { + name: "default_entrypoint", files: map[string]os.FileMode{"extensions/e": 0o755}, + want: []string{"/bin/sh", extensionLauncherPath, baseImageEntrypoint}, + wantCount: 1, + }, + { + name: "provided_entrypoint", files: map[string]os.FileMode{"extensions/a": 0o755, "extensions/b": 0o755}, + entrypoint: []string{"/var/task/bootstrap"}, + want: []string{"/bin/sh", extensionLauncherPath, "/var/task/bootstrap"}, + wantCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + for name, mode := range tt.files { + p := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) + require.NoError(t, os.Chmod(p, mode)) + } + + assert.Len(t, listLayerExtensions(dir), tt.wantCount) + + spec := container.Spec{Entrypoint: tt.entrypoint} + wrapWithExtensionLauncher(&spec, dir+":/opt:ro") + + if tt.want == nil { + assert.Equal(t, tt.entrypoint, spec.Entrypoint) + assert.NoFileExists(t, filepath.Join(dir, extensionLauncherName)) + + return + } + + assert.Equal(t, tt.want, spec.Entrypoint) + assert.FileExists(t, filepath.Join(dir, extensionLauncherName)) + }) + } + + spec := container.Spec{} + wrapWithExtensionLauncher(&spec, "") + assert.Empty(t, spec.Entrypoint) +} diff --git a/services/lambda/extensions_api.go b/services/lambda/extensions_api.go new file mode 100644 index 0000000000..906d1c3610 --- /dev/null +++ b/services/lambda/extensions_api.go @@ -0,0 +1,584 @@ +package lambda + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +// Lambda Extensions API 2020-01-01: +// https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html +const ( + extensionPathPrefix = "/2020-01-01/extension/" + + headerExtensionName = "Lambda-Extension-Name" + headerExtensionID = "Lambda-Extension-Identifier" + headerExtensionEventID = "Lambda-Extension-Event-Identifier" + headerExtensionErrorType = "Lambda-Extension-Function-Error-Type" + headerExtensionFeature = "Lambda-Extension-Accept-Feature" + + extEventInvoke = "INVOKE" + extEventShutdown = "SHUTDOWN" + + shutdownReasonSpindown = "spindown" + shutdownReasonTimeout = "timeout" + shutdownReasonFailure = "failure" + + jsonKeyEventType = "eventType" + + maxExtensions = 10 + extensionEventBuffer = 8 + defaultExtensionShutdownTimeout = 2 * time.Second + defaultExtensionInitTimeout = 10 * time.Second + maxExtensionBodyBytes = 1 << 20 +) + +// extensionMeta is the function identity returned to extensions on register. +type extensionMeta struct { + functionName string + version string + handler string + functionARN string + accountID string +} + +type extensionEvent struct { + delivered chan struct{} + id string + invokeReq string + body []byte +} + +type extension struct { + events chan extensionEvent + id string + name string + delivered string + invoke bool + shutdown bool + exited bool +} + +// invokeWait tracks the extensions that still owe a Next call for one invocation. +type invokeWait struct { + pending map[string]struct{} + done chan struct{} + deadline time.Time +} + +// extensionRegistry is the per-function state machine behind the Extensions API. +type extensionRegistry struct { + mu *lockmetrics.RWMutex + exts map[string]*extension + names map[string]struct{} + waits map[string]*invokeWait + ready chan struct{} + closing chan struct{} + meta extensionMeta + expected int + shutdownTimeout time.Duration + initTimeout time.Duration + closeOnce sync.Once + initDone bool + shutdownSent bool + initFailed bool +} + +func newExtensionRegistry() *extensionRegistry { + return &extensionRegistry{ + mu: lockmetrics.New("lambda.extensions"), + exts: make(map[string]*extension), + names: make(map[string]struct{}), + waits: make(map[string]*invokeWait), + closing: make(chan struct{}), + shutdownTimeout: defaultExtensionShutdownTimeout, + initTimeout: defaultExtensionInitTimeout, + } +} + +func (r *extensionRegistry) close() { + r.closeOnce.Do(func() { + close(r.closing) + r.mu.Close() + }) +} + +// setExpected records how many external extensions the launcher will start; the +// runtime's first Next waits for that many registrations (Extension init phase). +func (r *extensionRegistry) setExpected(n int) { + if r == nil || n <= 0 { + return + } + + r.mu.Lock("setExpected") + defer r.mu.Unlock() + + r.expected = n + r.ready = make(chan struct{}) + r.markReadyLocked() +} + +func (r *extensionRegistry) setMeta(m extensionMeta) { + r.mu.Lock("setMeta") + r.meta = m + r.mu.Unlock() +} + +func (r *extensionRegistry) markReadyLocked() { + if r.ready == nil { + return + } + + if len(r.exts) >= r.expected || r.initFailed { + select { + case <-r.ready: + default: + close(r.ready) + } + } +} + +// awaitInit blocks the runtime's first Next until expected extensions registered. +func (r *extensionRegistry) awaitInit(ctx context.Context) { + r.mu.RLock("awaitInit") + ready, timeout, done := r.ready, r.initTimeout, r.initDone + r.mu.RUnlock() + + if done { + return + } + + if ready != nil { + t := time.NewTimer(timeout) + defer t.Stop() + + select { + case <-ready: + case <-t.C: + logger.Load(ctx).WarnContext(ctx, "lambda: extensions did not register before init timeout") + case <-ctx.Done(): + return + case <-r.closing: + return + } + } + + r.mu.Lock("awaitInit-done") + r.initDone = true + r.mu.Unlock() +} + +type extensionError struct { + Type string `json:"errorType"` + Message string `json:"errorMessage"` +} + +type registerRequest struct { + Events []string `json:"events"` +} + +type registerResponse struct { + FunctionName string `json:"functionName"` + FunctionVersion string `json:"functionVersion"` + Handler string `json:"handler"` + AccountID string `json:"accountId,omitempty"` +} + +func writeExtError(w http.ResponseWriter, status int, typ, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(extensionError{Type: typ, Message: msg}) +} + +// register adds an extension; it returns the response, identifier or an HTTP failure. +func (r *extensionRegistry) register( + name string, events []string, withAccount bool, +) (registerResponse, string, int, *extensionError) { + var invoke, shutdown bool + + for _, e := range events { + switch e { + case extEventInvoke: + invoke = true + case extEventShutdown: + shutdown = true + default: + return registerResponse{}, "", http.StatusBadRequest, + &extensionError{"Extension.InvalidEventType", "unsupported event type " + e} + } + } + + r.mu.Lock("register") + defer r.mu.Unlock() + + switch { + case r.initDone || r.shutdownSent: + return registerResponse{}, "", http.StatusForbidden, + &extensionError{"Extension.RegistrationClosed", "extensions can only register during the Init phase"} + case len(r.exts) >= maxExtensions: + return registerResponse{}, "", http.StatusForbidden, + &extensionError{"Extension.TooManyExtensions", "at most 10 extensions can be registered"} + } + + if _, dup := r.names[name]; dup { + return registerResponse{}, "", http.StatusForbidden, + &extensionError{"Extension.AlreadyRegistered", "extension " + name + " is already registered"} + } + + ext := &extension{ + id: uuid.NewString(), name: name, invoke: invoke, shutdown: shutdown, + events: make(chan extensionEvent, extensionEventBuffer), + } + r.exts[ext.id] = ext + r.names[name] = struct{}{} + r.markReadyLocked() + + resp := registerResponse{ + FunctionName: r.meta.functionName, FunctionVersion: r.meta.version, Handler: r.meta.handler, + } + if withAccount { + resp.AccountID = r.meta.accountID + } + + return resp, ext.id, http.StatusOK, nil +} + +// ackLocked records that ext asked for its next event, completing any delivered invocation. +func (r *extensionRegistry) ackLocked(ext *extension) { + req := ext.delivered + if req == "" { + return + } + + ext.delivered = "" + + w, ok := r.waits[req] + if !ok { + return + } + + delete(w.pending, ext.id) + + if len(w.pending) == 0 { + close(w.done) + delete(r.waits, req) + } +} + +// fanoutInvoke queues an INVOKE event for every extension subscribed to it. +func (r *extensionRegistry) fanoutInvoke(reqID string, deadline time.Time, fnARN, trace string) { + r.mu.Lock("fanoutInvoke") + defer r.mu.Unlock() + + body := map[string]any{ + jsonKeyEventType: extEventInvoke, + "deadlineMs": deadline.UnixMilli(), + "requestId": reqID, + "invokedFunctionArn": fnARN, + } + if trace != "" { + body["tracing"] = map[string]string{"type": "X-Amzn-Trace-Id", "value": trace} + } + + raw, _ := json.Marshal(body) + wait := &invokeWait{pending: map[string]struct{}{}, done: make(chan struct{}), deadline: deadline} + + for id, ext := range r.exts { + if !ext.invoke || ext.exited { + continue + } + + select { + case ext.events <- extensionEvent{id: uuid.NewString(), invokeReq: reqID, body: raw}: + wait.pending[id] = struct{}{} + default: + } + } + + if len(wait.pending) > 0 { + r.waits[reqID] = wait + } +} + +// awaitIdle blocks until no extension owes a Next call for a finished invocation +// (the Invoke phase ends only then), bounded by that invocation's deadline. +func (r *extensionRegistry) awaitIdle(ctx context.Context) { + for { + r.mu.RLock("awaitIdle") + + var ( + id string + w *invokeWait + ) + + for k, v := range r.waits { + id, w = k, v + + break + } + + r.mu.RUnlock() + + if w == nil { + return + } + + t := time.NewTimer(time.Until(w.deadline)) + + select { + case <-w.done: + t.Stop() + case <-t.C: + logger.Load(ctx).WarnContext(ctx, "lambda: extensions did not finish before the invocation deadline", + "requestId", id) + r.dropWait(id) + case <-ctx.Done(): + t.Stop() + + return + case <-r.closing: + t.Stop() + + return + } + } +} + +func (r *extensionRegistry) dropWait(id string) { + r.mu.Lock("dropWait") + delete(r.waits, id) + r.mu.Unlock() +} + +var errExtensionUnknown = errors.New("unknown extension identifier") + +// next returns the extension's next event, acking its previous one. +func (r *extensionRegistry) next(ctx context.Context, id string) (extensionEvent, error) { + r.mu.Lock("next") + + ext, ok := r.exts[id] + if !ok || ext.exited { + r.mu.Unlock() + + return extensionEvent{}, errExtensionUnknown + } + + r.ackLocked(ext) + events := ext.events + r.mu.Unlock() + + select { + case ev := <-events: + if ev.invokeReq != "" { + r.mu.Lock("next-delivered") + ext.delivered = ev.invokeReq + r.mu.Unlock() + } + + if ev.delivered != nil { + close(ev.delivered) + } + + return ev, nil + case <-ctx.Done(): + return extensionEvent{}, ctx.Err() + case <-r.closing: + return extensionEvent{}, context.Canceled + } +} + +// fail marks an extension as exited after init/error or exit/error. +func (r *extensionRegistry) fail(id string, init bool) bool { + r.mu.Lock("fail") + defer r.mu.Unlock() + + ext, ok := r.exts[id] + if !ok || ext.exited { + return false + } + + r.ackLocked(ext) + ext.exited = true + + if init { + r.initFailed = true + r.markReadyLocked() + } + + return true +} + +// shutdown sends SHUTDOWN to subscribed extensions and waits until each took it, +// at most shutdownTimeout. +func (r *extensionRegistry) shutdown(ctx context.Context, reason string) { + r.mu.Lock("shutdown") + + if r.shutdownSent { + r.mu.Unlock() + + return + } + + r.shutdownSent = true + deadline := time.Now().Add(r.shutdownTimeout) + raw, _ := json.Marshal(map[string]any{ + jsonKeyEventType: extEventShutdown, "shutdownReason": reason, "deadlineMs": deadline.UnixMilli(), + }) + + var taken []chan struct{} + + for _, ext := range r.exts { + if !ext.shutdown || ext.exited { + continue + } + + ev := extensionEvent{id: uuid.NewString(), body: raw, delivered: make(chan struct{})} + + select { + case ext.events <- ev: + taken = append(taken, ev.delivered) + default: + } + } + r.mu.Unlock() + + t := time.NewTimer(time.Until(deadline)) + defer t.Stop() + + for _, c := range taken { + select { + case <-c: + case <-t.C: + logger.Load(ctx).WarnContext(ctx, "lambda: extension did not take the SHUTDOWN event in time") + + return + case <-ctx.Done(): + return + } + } +} + +// handleExtensionRegister serves POST /2020-01-01/extension/register. +func (s *runtimeServer) handleExtensionRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + + return + } + + name := r.Header.Get(headerExtensionName) + if name == "" || strings.ContainsAny(name, "/\\") { + writeExtError(w, http.StatusBadRequest, "Extension.InvalidExtensionName", + "Lambda-Extension-Name header must carry the extension's file name") + + return + } + + var req registerRequest + + body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, maxExtensionBodyBytes)) + if len(body) > 0 { + if err := json.Unmarshal(body, &req); err != nil { + writeExtError(w, http.StatusBadRequest, "Extension.InvalidRequestBody", "malformed registration body") + + return + } + } + + withAccount := false + + for f := range strings.SplitSeq(r.Header.Get(headerExtensionFeature), ",") { + if strings.TrimSpace(f) == "accountId" { + withAccount = true + } + } + + resp, id, status, regErr := s.exts.register(name, req.Events, withAccount) + if regErr != nil { + writeExtError(w, status, regErr.Type, regErr.Message) + + return + } + + w.Header().Set(headerExtensionID, id) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// handleExtensionNext serves GET /2020-01-01/extension/event/next (long poll). +func (s *runtimeServer) handleExtensionNext(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + + return + } + + ev, err := s.exts.next(r.Context(), r.Header.Get(headerExtensionID)) + if err != nil { + if errors.Is(err, errExtensionUnknown) { + writeExtError(w, http.StatusForbidden, "Extension.InvalidExtensionID", "unknown extension identifier") + } + + return + } + + w.Header().Set(headerExtensionEventID, ev.id) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(ev.body) +} + +// handleExtensionError serves POST /extension/init/error and /extension/exit/error. +func (s *runtimeServer) handleExtensionError(init bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + + return + } + + errType := r.Header.Get(headerExtensionErrorType) + if errType == "" { + writeExtError(w, http.StatusBadRequest, "Extension.MissingErrorType", + "Lambda-Extension-Function-Error-Type header is required") + + return + } + + body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, maxExtensionBodyBytes)) + + if !s.exts.fail(r.Header.Get(headerExtensionID), init) { + writeExtError(w, http.StatusForbidden, "Extension.InvalidExtensionID", "unknown extension identifier") + + return + } + + logger.Load(r.Context()).ErrorContext(r.Context(), "lambda extension reported an error", + "init", init, "errorType", errType, "body", string(body)) + + if init { + s.failPending(body) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"status":"OK"}`)) + } +} + +func functionVersionOrLatest(v string) string { + if v == "" { + return latestVersion + } + + return v +} diff --git a/services/lambda/extensions_api_internal_test.go b/services/lambda/extensions_api_internal_test.go new file mode 100644 index 0000000000..428d36c629 --- /dev/null +++ b/services/lambda/extensions_api_internal_test.go @@ -0,0 +1,489 @@ +package lambda + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" +) + +const extTestFnARN = "arn:aws:lambda:us-east-1:000000000000:function:ext-fn" + +type extHarness struct { + srv *runtimeServer + client *http.Client + base string +} + +func newExtHarness(t *testing.T) *extHarness { + t.Helper() + + srv := newRuntimeServer(0) + srv.exts.setMeta(extensionMeta{ + functionName: "ext-fn", version: "$LATEST", handler: "index.handler", + functionARN: extTestFnARN, accountID: "000000000000", + }) + + ts := httptest.NewServer(srv.routes()) + t.Cleanup(ts.Close) + t.Cleanup(func() { srv.stop(context.Background()) }) + + return &extHarness{srv: srv, client: testclient.New(t), base: ts.URL} +} + +func (h *extHarness) doRaw( + ctx context.Context, method, path string, hdr map[string]string, body string, +) (int, http.Header, string, error) { + req, err := http.NewRequestWithContext(ctx, method, h.base+path, strings.NewReader(body)) + if err != nil { + return 0, nil, "", err + } + + for k, v := range hdr { + req.Header.Set(k, v) + } + + resp, err := h.client.Do(req) + if err != nil { + return 0, nil, "", err + } + + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + + return resp.StatusCode, resp.Header, string(raw), err +} + +func (h *extHarness) do( + t *testing.T, + method, path string, + hdr map[string]string, + body string, +) (int, http.Header, string) { + t.Helper() + + status, respHdr, raw, err := h.doRaw(t.Context(), method, path, hdr, body) + require.NoError(t, err) + + return status, respHdr, raw +} + +func (h *extHarness) register(t *testing.T, name string, events ...string) string { + t.Helper() + + b, err := json.Marshal(map[string]any{"events": events}) + require.NoError(t, err) + + status, hdr, body := h.do(t, http.MethodPost, "/2020-01-01/extension/register", + map[string]string{headerExtensionName: name}, string(b)) + require.Equal(t, http.StatusOK, status, body) + + return hdr.Get(headerExtensionID) +} + +func (h *extHarness) nextAsync(t *testing.T, id string) <-chan string { + t.Helper() + + out := make(chan string, 1) + + go func() { + _, _, body, _ := h.doRaw(t.Context(), http.MethodGet, "/2020-01-01/extension/event/next", + map[string]string{headerExtensionID: id}, "") + out <- body + }() + + return out +} + +func TestExtensionRegister(t *testing.T) { + t.Parallel() + + tests := []struct { + prepare func(*testing.T, *extHarness) + name string + extName string + body string + feature string + wantType string + wantStatus int + }{ + {name: "ok", extName: "e1", body: `{"events":["INVOKE","SHUTDOWN"]}`, wantStatus: 200}, + {name: "no_body", extName: "e1", wantStatus: 200}, + {name: "account_id", extName: "e1", feature: "accountId", wantStatus: 200}, + {name: "missing_name", body: `{}`, wantStatus: 400, wantType: "Extension.InvalidExtensionName"}, + { + name: "bad_event", + extName: "e1", + body: `{"events":["FOO"]}`, + wantStatus: 400, + wantType: "Extension.InvalidEventType", + }, + {name: "bad_json", extName: "e1", body: `{`, wantStatus: 400, wantType: "Extension.InvalidRequestBody"}, + { + name: "duplicate", extName: "dup", wantStatus: 403, wantType: "Extension.AlreadyRegistered", + prepare: func(t *testing.T, h *extHarness) { + t.Helper() + h.register(t, "dup") + }, + }, + { + name: "too_many", extName: "eleven", wantStatus: 403, wantType: "Extension.TooManyExtensions", + prepare: func(t *testing.T, h *extHarness) { + t.Helper() + + for i := range maxExtensions { + h.register(t, "e"+string(rune('a'+i))) + } + }, + }, + { + name: "after_init", extName: "late", wantStatus: 403, wantType: "Extension.RegistrationClosed", + prepare: func(_ *testing.T, h *extHarness) { h.srv.exts.awaitInit(context.Background()) }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newExtHarness(t) + if tt.prepare != nil { + tt.prepare(t, h) + } + + hdr := map[string]string{} + if tt.extName != "" { + hdr[headerExtensionName] = tt.extName + } + + if tt.feature != "" { + hdr[headerExtensionFeature] = tt.feature + } + + status, respHdr, body := h.do(t, http.MethodPost, "/2020-01-01/extension/register", hdr, tt.body) + require.Equal(t, tt.wantStatus, status, body) + + if tt.wantStatus != http.StatusOK { + var e extensionError + + require.NoError(t, json.Unmarshal([]byte(body), &e)) + assert.Equal(t, tt.wantType, e.Type) + + return + } + + assert.NotEmpty(t, respHdr.Get(headerExtensionID)) + + var resp map[string]string + + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + assert.Equal(t, "ext-fn", resp["functionName"]) + assert.Equal(t, "$LATEST", resp["functionVersion"]) + assert.Equal(t, "index.handler", resp["handler"]) + + if tt.feature != "" { + assert.Equal(t, "000000000000", resp["accountId"]) + } else { + assert.NotContains(t, resp, "accountId") + } + }) + } +} + +func TestExtensionInvokeFanout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + events []string + trace bool + want bool + }{ + {name: "subscribed", events: []string{"INVOKE"}, want: true}, + {name: "subscribed_with_trace", events: []string{"INVOKE", "SHUTDOWN"}, trace: true, want: true}, + {name: "shutdown_only", events: []string{"SHUTDOWN"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newExtHarness(t) + h.srv.traceActive.Store(tt.trace) + id := h.register(t, "ext", tt.events...) + other := h.register(t, "other", "INVOKE") + + type result struct { + err error + payload []byte + } + + done := make(chan result, 1) + + go func() { + out, _, _, err := h.srv.invoke(t.Context(), []byte(`{"a":1}`), "", time.Minute) + done <- result{payload: out, err: err} + }() + + otherEv := h.nextAsync(t, other) + var extEv <-chan string + if tt.want { + extEv = h.nextAsync(t, id) + } + + status, hdr, _ := h.do(t, http.MethodGet, "/2018-06-01/runtime/invocation/next", nil, "") + require.Equal(t, http.StatusOK, status) + + reqID := hdr.Get("Lambda-Runtime-Aws-Request-Id") + assert.Equal(t, extTestFnARN, hdr.Get("Lambda-Runtime-Invoked-Function-Arn")) + + var ev map[string]any + + require.NoError(t, json.Unmarshal([]byte(<-otherEv), &ev)) + assert.Equal(t, "INVOKE", ev["eventType"]) + assert.Equal(t, reqID, ev["requestId"]) + assert.Equal(t, extTestFnARN, ev["invokedFunctionArn"]) + deadline, _ := ev["deadlineMs"].(float64) + assert.Equal(t, hdr.Get("Lambda-Runtime-Deadline-Ms"), strconv.FormatInt(int64(deadline), 10)) + + if tt.trace { + tr, ok := ev["tracing"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "X-Amzn-Trace-Id", tr["type"]) + assert.Equal(t, hdr.Get("Lambda-Runtime-Trace-Id"), tr["value"]) + } else { + assert.NotContains(t, ev, "tracing") + } + + if tt.want { + assert.Contains(t, <-extEv, reqID) + } + + status, _, _ = h.do(t, http.MethodPost, "/2018-06-01/runtime/invocation/"+reqID+"/response", nil, `"ok"`) + require.Equal(t, http.StatusAccepted, status) + + res := <-done + require.NoError(t, res.err) + assert.JSONEq(t, `"ok"`, string(res.payload)) + + // The response returned before the extensions finished; the Invoke phase + // stays open until every subscribed extension calls Next again. + h.srv.exts.mu.RLock("test") + open := len(h.srv.exts.waits) + h.srv.exts.mu.RUnlock() + assert.Equal(t, 1, open) + + if tt.want { + _ = h.nextAsync(t, id) + } + + _ = h.nextAsync(t, other) + + idle := make(chan struct{}) + + go func() { + h.srv.exts.awaitIdle(t.Context()) + close(idle) + }() + + <-idle + + h.srv.exts.mu.RLock("test") + assert.Empty(t, h.srv.exts.waits) + h.srv.exts.mu.RUnlock() + }) + } +} + +func TestExtensionShutdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reason string + wantReason string + }{ + {name: "default_spindown", wantReason: "spindown"}, + {name: "timeout", reason: shutdownReasonTimeout, wantReason: "timeout"}, + {name: "failure", reason: shutdownReasonFailure, wantReason: "failure"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newExtHarness(t) + sub := h.register(t, "sub", "INVOKE", "SHUTDOWN") + h.register(t, "invoke-only", "INVOKE") + + ev := h.nextAsync(t, sub) + + if tt.reason != "" { + h.srv.setShutdownReason(tt.reason) + } + + h.srv.stop(t.Context()) + + var got map[string]any + + require.NoError(t, json.Unmarshal([]byte(<-ev), &got)) + assert.Equal(t, "SHUTDOWN", got["eventType"]) + assert.Equal(t, tt.wantReason, got["shutdownReason"]) + assert.Positive(t, got["deadlineMs"]) + }) + } +} + +func TestExtensionErrorsAndAuth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + method string + errType string + wantStatus int + withID bool + }{ + {name: "next_unknown_id", path: "event/next", method: http.MethodGet, wantStatus: 403}, + { + name: "init_error_unknown_id", + path: "init/error", + method: http.MethodPost, + errType: "Extension.X", + wantStatus: 403, + }, + {name: "init_error_missing_type", path: "init/error", method: http.MethodPost, withID: true, wantStatus: 400}, + { + name: "init_error_ok", + path: "init/error", + method: http.MethodPost, + withID: true, + errType: "Extension.X", + wantStatus: 202, + }, + { + name: "exit_error_ok", + path: "exit/error", + method: http.MethodPost, + withID: true, + errType: "Extension.X", + wantStatus: 202, + }, + {name: "exit_error_missing_type", path: "exit/error", method: http.MethodPost, withID: true, wantStatus: 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newExtHarness(t) + id := h.register(t, "ext", "INVOKE") + + hdr := map[string]string{} + if tt.withID { + hdr[headerExtensionID] = id + } else { + hdr[headerExtensionID] = "nope" + } + + if tt.errType != "" { + hdr[headerExtensionErrorType] = tt.errType + } + + status, _, body := h.do(t, tt.method, "/2020-01-01/extension/"+tt.path, hdr, + `{"errorMessage":"boom","errorType":"X","stackTrace":[]}`) + require.Equal(t, tt.wantStatus, status, body) + + if tt.wantStatus != http.StatusAccepted { + return + } + + assert.JSONEq(t, `{"status":"OK"}`, body) + + status, _, _ = h.do(t, http.MethodGet, "/2020-01-01/extension/event/next", + map[string]string{headerExtensionID: id}, "") + assert.Equal(t, http.StatusForbidden, status, "an extension that reported an error is closed") + }) + } +} + +func TestExtensionInitGate(t *testing.T) { + t.Parallel() + + h := newExtHarness(t) + h.srv.exts.setExpected(2) + + released := make(chan struct{}) + + go func() { + h.srv.exts.awaitInit(t.Context()) + close(released) + }() + + h.register(t, "a") + h.register(t, "b") + <-released + + status, _, _ := h.do(t, http.MethodPost, "/2020-01-01/extension/register", + map[string]string{headerExtensionName: "c"}, "") + assert.Equal(t, http.StatusForbidden, status) +} + +func TestExtensionInitErrorFailsInvocationAndOpensGate(t *testing.T) { + t.Parallel() + + h := newExtHarness(t) + h.srv.exts.setExpected(1) + id := h.register(t, "bad", "INVOKE") + + type res struct { + err error + payload []byte + isErr bool + } + + done := make(chan res, 1) + + go func() { + out, isErr, _, err := h.srv.invoke(t.Context(), []byte(`{}`), "", time.Minute) + done <- res{payload: out, isErr: isErr, err: err} + }() + + go func() { + _, _, _, _ = h.doRaw(t.Context(), http.MethodGet, "/2018-06-01/runtime/invocation/next", nil, "") + }() + + // Wait for the runtime to pick the invocation up. + require.Eventually(t, func() bool { + n := 0 + h.srv.pending.Range(func(_, _ any) bool { + n++ + + return true + }) + + return n == 1 + }, 5*time.Second, time.Millisecond) + + status, _, _ := h.do(t, http.MethodPost, "/2020-01-01/extension/init/error", + map[string]string{headerExtensionID: id, headerExtensionErrorType: "Extension.ConfigInvalid"}, + `{"errorMessage":"bad config"}`) + require.Equal(t, http.StatusAccepted, status) + + r := <-done + require.NoError(t, r.err) + assert.True(t, r.isErr) + assert.Contains(t, string(r.payload), "bad config") +} diff --git a/services/lambda/function_url_sdk_test.go b/services/lambda/function_url_sdk_test.go index ce00be8b66..3647af06ca 100644 --- a/services/lambda/function_url_sdk_test.go +++ b/services/lambda/function_url_sdk_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/sdktest" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/lambda" ) @@ -88,7 +89,7 @@ func TestFunctionURL_SDKOpsAndHostRouting(t *testing.T) { req.Host = host req.Header.Set("Origin", "https://example.com") - resp, doErr := http.DefaultClient.Do(req) + resp, doErr := testclient.New(t).Do(req) require.NoError(t, doErr) defer resp.Body.Close() diff --git a/services/lambda/invocation_test.go b/services/lambda/invocation_test.go index f7b8283513..75f6953721 100644 --- a/services/lambda/invocation_test.go +++ b/services/lambda/invocation_test.go @@ -11,6 +11,7 @@ import ( "net/url" "strings" "testing" + "testing/synctest" "time" "github.com/labstack/echo/v5" @@ -834,21 +835,24 @@ func TestLambda_AsyncDestinationDelivery(t *testing.T) { func TestLambda_FunctionLifecycle_PendingToActive(t *testing.T) { t.Parallel() - backend := lambda.NewInMemoryBackend(nil, nil, lambda.DefaultSettings(), "000000000000", "us-east-1") - closeBackend(t, backend) + synctest.Test(t, func(t *testing.T) { + backend := lambda.NewInMemoryBackend(nil, nil, lambda.DefaultSettings(), "000000000000", "us-east-1") + closeBackend(t, backend) - backend.SetActivationDelay(200 * time.Millisecond) + backend.SetActivationDelay(200 * time.Millisecond) - require.NoError(t, backend.CreateFunction(&lambda.FunctionConfiguration{ - FunctionName: "life-fn", - State: lambda.FunctionStateActive, // handler default; backend overrides to Pending - })) + require.NoError(t, backend.CreateFunction(&lambda.FunctionConfiguration{ + FunctionName: "life-fn", + State: lambda.FunctionStateActive, // handler default; backend overrides to Pending + })) + + assert.Equal(t, lambda.FunctionStatePending, lambda.GetFunctionStateForTest(backend, "life-fn")) - assert.Equal(t, lambda.FunctionStatePending, lambda.GetFunctionStateForTest(backend, "life-fn")) + time.Sleep(200 * time.Millisecond) + synctest.Wait() - require.Eventually(t, func() bool { - return lambda.GetFunctionStateForTest(backend, "life-fn") == lambda.FunctionStateActive - }, 3*time.Second, 20*time.Millisecond) + assert.Equal(t, lambda.FunctionStateActive, lambda.GetFunctionStateForTest(backend, "life-fn")) + }) } func TestLambda_FunctionLifecycle_NoDelayActiveImmediately(t *testing.T) { diff --git a/services/lambda/isolation_internal_test.go b/services/lambda/isolation_internal_test.go index c927e1f23b..b8cf4409cf 100644 --- a/services/lambda/isolation_internal_test.go +++ b/services/lambda/isolation_internal_test.go @@ -122,7 +122,7 @@ func TestStartContainer_PassesOCIRuntime(t *testing.T) { b.ociRuntime = tt.oci fn := &FunctionConfiguration{FunctionName: "f", PackageType: PackageTypeImage, ImageURI: "img:latest"} - _, _, id, err := b.startContainer(t.Context(), fn, nil) + _, _, id, err := b.startContainer(t.Context(), fn, nil, nil) require.NoError(t, err) require.NotEmpty(t, id) diff --git a/services/lambda/models.go b/services/lambda/models.go index 21047a613b..e14b248b12 100644 --- a/services/lambda/models.go +++ b/services/lambda/models.go @@ -437,6 +437,10 @@ type FunctionVersion struct { KMSKeyArn string `json:"KMSKeyArn,omitempty"` Layers []*FunctionLayer `json:"Layers,omitempty"` Architectures []string `json:"Architectures,omitempty"` + S3BucketCode string `json:"-"` + S3KeyCode string `json:"-"` + S3ObjectVersionCode string `json:"-"` + ZipData []byte `json:"-"` MemorySize int `json:"MemorySize"` Timeout int `json:"Timeout"` CodeSize int64 `json:"CodeSize"` diff --git a/services/lambda/provisioned_concurrency_test.go b/services/lambda/provisioned_concurrency_test.go index a972fa67fc..f53503851f 100644 --- a/services/lambda/provisioned_concurrency_test.go +++ b/services/lambda/provisioned_concurrency_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" @@ -593,26 +594,30 @@ func TestProvisionedConcurrency_List_IncludesAllQualifiers(t *testing.T) { func TestLambda_ProvisionedConcurrencyLifecycle(t *testing.T) { t.Parallel() - backend := lambda.NewInMemoryBackend(nil, nil, lambda.DefaultSettings(), "000000000000", "us-east-1") - closeBackend(t, backend) + synctest.Test(t, func(t *testing.T) { + backend := lambda.NewInMemoryBackend(nil, nil, lambda.DefaultSettings(), "000000000000", "us-east-1") + closeBackend(t, backend) - require.NoError(t, backend.CreateFunction(&lambda.FunctionConfiguration{ - FunctionName: "pc-fn", - FunctionArn: "arn:aws:lambda:us-east-1:000000000000:function:pc-fn", - })) + require.NoError(t, backend.CreateFunction(&lambda.FunctionConfiguration{ + FunctionName: "pc-fn", + FunctionArn: "arn:aws:lambda:us-east-1:000000000000:function:pc-fn", + })) - backend.SetProvisionedConcurrencyDelay(200 * time.Millisecond) + backend.SetProvisionedConcurrencyDelay(200 * time.Millisecond) - cfg, err := backend.PutProvisionedConcurrencyConfig("pc-fn", "1", 5) - require.NoError(t, err) - assert.Equal(t, "IN_PROGRESS", cfg.Status) - assert.Equal(t, 0, cfg.AvailableProvisionedConcurrentExecutions) + cfg, err := backend.PutProvisionedConcurrencyConfig("pc-fn", "1", 5) + require.NoError(t, err) + assert.Equal(t, "IN_PROGRESS", cfg.Status) + assert.Equal(t, 0, cfg.AvailableProvisionedConcurrentExecutions) - require.Eventually(t, func() bool { - got, getErr := backend.GetProvisionedConcurrencyConfig("pc-fn", "1") + time.Sleep(200 * time.Millisecond) + synctest.Wait() - return getErr == nil && got.Status == "READY" && got.AvailableProvisionedConcurrentExecutions == 5 - }, 3*time.Second, 20*time.Millisecond) + got, err := backend.GetProvisionedConcurrencyConfig("pc-fn", "1") + require.NoError(t, err) + assert.Equal(t, "READY", got.Status) + assert.Equal(t, 5, got.AvailableProvisionedConcurrentExecutions) + }) } func TestLambda_ProvisionedConcurrency_NoDelayReadyImmediately(t *testing.T) { diff --git a/services/lambda/published_version_code_test.go b/services/lambda/published_version_code_test.go new file mode 100644 index 0000000000..d88db7fd7c --- /dev/null +++ b/services/lambda/published_version_code_test.go @@ -0,0 +1,76 @@ +package lambda_test + +import ( + "archive/zip" + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/services/lambda" +) + +func TestInvokePublishedVersionKeepsCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + qualifier string + }{ + {name: "numbered_version", qualifier: "1"}, + {name: "latest", qualifier: "$LATEST"}, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pa, err := portalloc.New(19700+i*20, 19710+i*20) + require.NoError(t, err) + + bk := lambda.NewInMemoryBackend( + newMockDockerClient(), + pa, + lambda.DefaultSettings(), + "000000000000", + "us-east-1", + ) + closeBackend(t, bk) + + require.NoError(t, bk.CreateFunction(&lambda.FunctionConfiguration{ + FunctionName: "zipfn", PackageType: lambda.PackageTypeZip, Runtime: "python3.12", + Handler: "index.handler", ZipData: tinyZip(t), Timeout: 3, + })) + + _, err = bk.PublishVersion("zipfn", "") + require.NoError(t, err) + + _, _, _, status, invokeErr := bk.InvokeFunctionWithQualifier( + t.Context(), "zipfn", tt.qualifier, "", "", lambda.InvocationTypeEvent, []byte(`{}`), + ) + if invokeErr != nil { + assert.NotContains(t, invokeErr.Error(), "no zip data available") + } + + assert.NotEqual(t, http.StatusInternalServerError, status, "%v", invokeErr) + }) + } +} + +func tinyZip(t *testing.T) []byte { + t.Helper() + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + w, err := zw.Create("index.py") + require.NoError(t, err) + _, err = w.Write([]byte("def handler(e, c):\n return {}\n")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + return buf.Bytes() +} diff --git a/services/lambda/runtime_api.go b/services/lambda/runtime_api.go index 88dd04a270..fed4f7e150 100644 --- a/services/lambda/runtime_api.go +++ b/services/lambda/runtime_api.go @@ -51,13 +51,16 @@ type invocationResult struct { // runtimeServer is a per-function Lambda Runtime API server. // Containers call /next to receive invocations and /response or /error to return results. type runtimeServer struct { - srv *http.Server - queue chan *pendingInvocation - done chan struct{} - pending sync.Map - // traceActive makes /next carry a Lambda-Runtime-Trace-Id (Active tracing). - traceActive atomic.Bool - port int + srv *http.Server + queue chan *pendingInvocation + done chan struct{} + exts *extensionRegistry + shutdownReason atomic.Pointer[string] + closing chan struct{} + pending sync.Map + port int + stopOnce sync.Once + traceActive atomic.Bool } // runtimeQueueSize is the buffered depth of the invocation queue per function. @@ -84,19 +87,34 @@ func newRuntimeServer(port int) *runtimeServer { port: port, queue: make(chan *pendingInvocation, runtimeQueueSize), done: make(chan struct{}), + + exts: newExtensionRegistry(), + closing: make(chan struct{}), } } -// start begins listening on the configured port and serves the Runtime API. -func (s *runtimeServer) start(ctx context.Context) error { +// setShutdownReason sets the reason carried by the SHUTDOWN event extensions receive. +func (s *runtimeServer) setShutdownReason(reason string) { s.shutdownReason.Store(&reason) } + +// routes returns the Runtime API and Extensions API handler. +func (s *runtimeServer) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/2018-06-01/runtime/invocation/next", s.handleNext) mux.HandleFunc("/2018-06-01/runtime/invocation/", s.handleInvocationResult) mux.HandleFunc("/2018-06-01/runtime/init/error", s.handleInitError) + mux.HandleFunc(extensionPathPrefix+"register", s.handleExtensionRegister) + mux.HandleFunc(extensionPathPrefix+"event/next", s.handleExtensionNext) + mux.HandleFunc(extensionPathPrefix+"init/error", s.handleExtensionError(true)) + mux.HandleFunc(extensionPathPrefix+"exit/error", s.handleExtensionError(false)) + return mux +} + +// start begins listening on the configured port and serves the Runtime API. +func (s *runtimeServer) start(ctx context.Context) error { s.srv = &http.Server{ Addr: fmt.Sprintf(":%d", s.port), - Handler: mux, + Handler: s.routes(), ReadHeaderTimeout: runtimeReadHeaderTimeout, } @@ -120,6 +138,17 @@ func (s *runtimeServer) start(ctx context.Context) error { // stop shuts down the runtime server gracefully. func (s *runtimeServer) stop(ctx context.Context) { + reason := shutdownReasonSpindown + if r := s.shutdownReason.Load(); r != nil { + reason = *r + } + + s.exts.shutdown(ctx, reason) + s.stopOnce.Do(func() { + close(s.closing) + s.exts.close() + }) + if s.srv != nil { _ = s.srv.Shutdown(ctx) @@ -206,24 +235,32 @@ func (s *runtimeServer) handleNext(w http.ResponseWriter, r *http.Request) { return } + s.exts.awaitInit(r.Context()) + s.exts.awaitIdle(r.Context()) + select { case inv := <-s.queue: s.pending.Store(inv.requestID, inv) w.Header().Set("Lambda-Runtime-Aws-Request-Id", inv.requestID) w.Header().Set("Lambda-Runtime-Deadline-Ms", strconv.FormatInt(inv.deadline.UnixMilli(), 10)) - w.Header().Set("Lambda-Runtime-Invoked-Function-Arn", - arn.Build("lambda", config.DefaultRegion, config.DefaultAccountID, "function:unknown")) + fnARN := s.functionARN() + w.Header().Set("Lambda-Runtime-Invoked-Function-Arn", fnARN) if inv.clientContext != "" { w.Header().Set("Lambda-Runtime-Client-Context", inv.clientContext) } + trace := "" if s.traceActive.Load() { - w.Header().Set("Lambda-Runtime-Trace-Id", newTraceHeader(time.Now())) + trace = newTraceHeader(time.Now()) + w.Header().Set("Lambda-Runtime-Trace-Id", trace) } + + s.exts.fanoutInvoke(inv.requestID, inv.deadline, fnARN, trace) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write(inv.payload) case <-r.Context().Done(): // Container disconnected or request cancelled — do nothing. + case <-s.closing: } } @@ -317,8 +354,27 @@ func (s *runtimeServer) handleInitError(w http.ResponseWriter, r *http.Request) logger.Load(r.Context()).ErrorContext(r.Context(), "lambda runtime init error", "error", string(body)) - // Deliver the init error to the first pending invocation so the caller gets an - // immediate error response instead of timing out. + s.setShutdownReason(shutdownReasonFailure) + s.failPending(body) + + w.WriteHeader(http.StatusAccepted) +} + +// functionARN is the ARN reported to the runtime and extensions as the invoked function. +func (s *runtimeServer) functionARN() string { + s.exts.mu.RLock("functionARN") + defer s.exts.mu.RUnlock() + + if s.exts.meta.functionARN != "" { + return s.exts.meta.functionARN + } + + return arn.Build("lambda", config.DefaultRegion, config.DefaultAccountID, "function:unknown") +} + +// failPending delivers an init error to the first pending invocation so the caller +// gets an immediate error response instead of timing out. +func (s *runtimeServer) failPending(body []byte) { s.pending.Range(func(key, value any) bool { inv, ok := value.(*pendingInvocation) if !ok { @@ -334,6 +390,4 @@ func (s *runtimeServer) handleInitError(w http.ResponseWriter, r *http.Request) return false // stop after first }) - - w.WriteHeader(http.StatusAccepted) } diff --git a/services/lambda/runtime_env_test.go b/services/lambda/runtime_env_test.go index 176c19ae25..8e7ff0f475 100644 --- a/services/lambda/runtime_env_test.go +++ b/services/lambda/runtime_env_test.go @@ -20,6 +20,7 @@ import ( "github.com/blackbirdworks/gopherstack/internal/dockercompat/api/types/image" gophercontainer "github.com/blackbirdworks/gopherstack/pkgs/container" "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/lambda" ) @@ -390,7 +391,7 @@ func TestRuntimeTraceHeader(t *testing.T) { fmt.Sprintf("http://127.0.0.1:%d/2018-06-01/runtime/invocation/next", base), nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/lambda/runtimes_catalog.go b/services/lambda/runtimes_catalog.go new file mode 100644 index 0000000000..3e9577dc81 --- /dev/null +++ b/services/lambda/runtimes_catalog.go @@ -0,0 +1,58 @@ +package lambda + +import ( + "errors" + "fmt" + "slices" +) + +// Runtime catalog filters accepted by GET /_aws/lambda/runtimes. +const ( + RuntimeFilterSupported = "supported" + RuntimeFilterDeprecated = "deprecated" + RuntimeFilterAll = "all" +) + +// ErrInvalidRuntimeFilter is returned for a filter other than supported, deprecated or all. +var ErrInvalidRuntimeFilter = errors.New("invalid runtime filter") + +// RuntimeIdentifiers lists Lambda runtime identifiers by filter: supported +// (runnable here; the default for ""), deprecated, or all. The result is sorted. +func RuntimeIdentifiers(filter string) ([]string, error) { + var out []string + + switch filter { + case "", RuntimeFilterSupported: + for r := range runtimeBaseImages { + out = append(out, r) + } + case RuntimeFilterDeprecated: + for r := range deprecatedRuntimes { + if !isRunnableRuntime(r) { + out = append(out, r) + } + } + case RuntimeFilterAll: + for r := range runtimeBaseImages { + out = append(out, r) + } + + for r := range deprecatedRuntimes { + if !isRunnableRuntime(r) { + out = append(out, r) + } + } + default: + return nil, fmt.Errorf("%w: %q", ErrInvalidRuntimeFilter, filter) + } + + slices.Sort(out) + + return out, nil +} + +func isRunnableRuntime(r string) bool { + _, ok := runtimeBaseImages[r] + + return ok +} diff --git a/services/lambda/settings.go b/services/lambda/settings.go index f9a13dec67..8305d706df 100644 --- a/services/lambda/settings.go +++ b/services/lambda/settings.go @@ -9,23 +9,24 @@ import ( // Settings holds configurable settings for the Lambda service. type Settings struct { - DockerHost string `json:"docker_host" name:"docker-host" env:"LAMBDA_DOCKER_HOST" default:"172.17.0.1" help:"Host that Lambda containers use to reach the Runtime API."` //nolint:lll // config struct tags are intentionally verbose - ContainerRuntime string `json:"container_runtime" name:"container-runtime" env:"CONTAINER_RUNTIME" default:"docker" help:"Container runtime to use: docker, podman, or auto."` //nolint:lll // config struct tags are intentionally verbose - Isolation string `json:"isolation" name:"isolation" env:"LAMBDA_ISOLATION" default:"container" help:"Lambda isolation: container, gvisor, kata (firecracker is not yet supported)."` //nolint:lll // config struct tags are intentionally verbose - MaxEnvironmentBytes int `json:"max_environment_bytes" name:"max-environment-bytes" env:"LAMBDA_MAX_ENVIRONMENT_BYTES" default:"0" help:"Maximum environment variable size in bytes (0 = AWS default 4096)."` //nolint:lll // config struct tags are intentionally verbose - TotalCodeSize int64 `json:"total_code_size" name:"account-total-code-size" env:"LAMBDA_ACCOUNT_TOTAL_CODE_SIZE" default:"0" help:"Account code storage limit in bytes (0 = AWS default 75 GB)."` //nolint:lll // config struct tags are intentionally verbose - MaxRuntimes int `json:"max_runtimes" name:"max-runtimes" env:"LAMBDA_MAX_RUNTIMES" default:"50" help:"Maximum number of simultaneous per-function Lambda runtimes."` //nolint:lll // config struct tags are intentionally verbose - CreateFunctionRequestSize int64 `json:"create_function_request_size" name:"create-function-request-size" env:"LAMBDA_CREATE_FUNCTION_REQUEST_SIZE" default:"0" help:"Maximum CreateFunction request size in bytes (0 = AWS default 70167211)."` //nolint:lll // config struct tags are intentionally verbose - CodeSizeUnzipped int64 `json:"code_size_unzipped" name:"code-size-unzipped" env:"LAMBDA_CODE_SIZE_UNZIPPED" default:"0" help:"Maximum unzipped package size in bytes (0 = AWS default 250 MB)."` //nolint:lll // config struct tags are intentionally verbose - CodeSizeZipped int64 `json:"code_size_zipped" name:"code-size-zipped" env:"LAMBDA_CODE_SIZE_ZIPPED" default:"0" help:"Maximum zipped package size in bytes (0 = AWS default 50 MB)."` //nolint:lll // config struct tags are intentionally verbose - IdleTimeout time.Duration `json:"idle_timeout" name:"idle-timeout" env:"LAMBDA_IDLE_TIMEOUT" default:"10m" help:"Idle container timeout."` //nolint:lll // config struct tags are intentionally verbose - ConcurrentExecutions int `json:"concurrent_executions" name:"account-concurrent-executions" env:"LAMBDA_ACCOUNT_CONCURRENT_EXECUTIONS" default:"0" help:"Account concurrent execution limit (0 = AWS default 1000)."` //nolint:lll // config struct tags are intentionally verbose - MinimumUnreservedConcurrency int `json:"minimum_unreserved_concurrency" name:"account-min-unreserved-concurrency" env:"LAMBDA_ACCOUNT_MIN_UNRESERVED_CONCURRENCY" default:"0" help:"Minimum unreserved concurrency (0 = AWS default 100)."` //nolint:lll // config struct tags are intentionally verbose - PoolSize int `json:"pool_size" name:"pool-size" env:"LAMBDA_POOL_SIZE" default:"3" help:"Max warm containers per Lambda function."` //nolint:lll // config struct tags are intentionally verbose - HotReloadIntervalMS int32 `json:"hot_reload_interval_ms" name:"hot-reload-interval-ms" env:"LAMBDA_HOT_RELOAD_INTERVAL_MS" default:"250" help:"Minimum milliseconds between hot-reload change scans."` //nolint:lll // config struct tags are intentionally verbose - StrictRuntimeValidation bool `json:"strict_runtime_validation" name:"strict-runtime-validation" env:"LAMBDA_STRICT_RUNTIME_VALIDATION" default:"false" help:"Reject deprecated Lambda runtimes (LocalStack LAMBDA_RUNTIME_VALIDATION=1)."` //nolint:lll // config struct tags are intentionally verbose - KeepContainers bool `json:"keep_containers" name:"keep-containers" env:"LAMBDA_KEEP_CONTAINERS" default:"false" help:"If true, keep Lambda containers alive for debugging."` //nolint:lll // config struct tags are intentionally verbose - DisableHotReload bool `json:"disable_hot_reload" name:"disable-hot-reload" env:"LAMBDA_DISABLE_HOT_RELOAD" default:"false" help:"Disable Lambda hot reloading from local directories."` //nolint:lll // config struct tags are intentionally verbose + DockerHost string `json:"docker_host" name:"docker-host" env:"LAMBDA_DOCKER_HOST" default:"172.17.0.1" help:"Host that Lambda containers use to reach the Runtime API."` //nolint:lll // config struct tags are intentionally verbose + ContainerRuntime string `json:"container_runtime" name:"container-runtime" env:"CONTAINER_RUNTIME" default:"docker" help:"Container runtime to use: docker, podman, or auto."` //nolint:lll // config struct tags are intentionally verbose + Isolation string `json:"isolation" name:"isolation" env:"LAMBDA_ISOLATION" default:"container" help:"Lambda isolation: container, gvisor, kata (firecracker is not yet supported)."` //nolint:lll // config struct tags are intentionally verbose + DockerFlags string `json:"docker_flags" name:"docker-flags" env:"LAMBDA_DOCKER_FLAGS" default:"" help:"Extra docker options for function containers: -e, -v, --add-host, --dns, --network, -m, --cpus, --platform."` //nolint:lll // config struct tags are intentionally verbose + CodeSizeZipped int64 `json:"code_size_zipped" name:"code-size-zipped" env:"LAMBDA_CODE_SIZE_ZIPPED" default:"0" help:"Maximum zipped package size in bytes (0 = AWS default 50 MB)."` //nolint:lll // config struct tags are intentionally verbose + MinimumUnreservedConcurrency int `json:"minimum_unreserved_concurrency" name:"account-min-unreserved-concurrency" env:"LAMBDA_ACCOUNT_MIN_UNRESERVED_CONCURRENCY" default:"0" help:"Minimum unreserved concurrency (0 = AWS default 100)."` //nolint:lll // config struct tags are intentionally verbose + CreateFunctionRequestSize int64 `json:"create_function_request_size" name:"create-function-request-size" env:"LAMBDA_CREATE_FUNCTION_REQUEST_SIZE" default:"0" help:"Maximum CreateFunction request size in bytes (0 = AWS default 70167211)."` //nolint:lll // config struct tags are intentionally verbose + CodeSizeUnzipped int64 `json:"code_size_unzipped" name:"code-size-unzipped" env:"LAMBDA_CODE_SIZE_UNZIPPED" default:"0" help:"Maximum unzipped package size in bytes (0 = AWS default 250 MB)."` //nolint:lll // config struct tags are intentionally verbose + TotalCodeSize int64 `json:"total_code_size" name:"account-total-code-size" env:"LAMBDA_ACCOUNT_TOTAL_CODE_SIZE" default:"0" help:"Account code storage limit in bytes (0 = AWS default 75 GB)."` //nolint:lll // config struct tags are intentionally verbose + IdleTimeout time.Duration `json:"idle_timeout" name:"idle-timeout" env:"LAMBDA_IDLE_TIMEOUT" default:"10m" help:"Idle container timeout."` //nolint:lll // config struct tags are intentionally verbose + ConcurrentExecutions int `json:"concurrent_executions" name:"account-concurrent-executions" env:"LAMBDA_ACCOUNT_CONCURRENT_EXECUTIONS" default:"0" help:"Account concurrent execution limit (0 = AWS default 1000)."` //nolint:lll // config struct tags are intentionally verbose + MaxRuntimes int `json:"max_runtimes" name:"max-runtimes" env:"LAMBDA_MAX_RUNTIMES" default:"50" help:"Maximum number of simultaneous per-function Lambda runtimes."` //nolint:lll // config struct tags are intentionally verbose + PoolSize int `json:"pool_size" name:"pool-size" env:"LAMBDA_POOL_SIZE" default:"3" help:"Max warm containers per Lambda function."` //nolint:lll // config struct tags are intentionally verbose + MaxEnvironmentBytes int `json:"max_environment_bytes" name:"max-environment-bytes" env:"LAMBDA_MAX_ENVIRONMENT_BYTES" default:"0" help:"Maximum environment variable size in bytes (0 = AWS default 4096)."` //nolint:lll // config struct tags are intentionally verbose + HotReloadIntervalMS int32 `json:"hot_reload_interval_ms" name:"hot-reload-interval-ms" env:"LAMBDA_HOT_RELOAD_INTERVAL_MS" default:"250" help:"Minimum milliseconds between hot-reload change scans."` //nolint:lll // config struct tags are intentionally verbose + StrictRuntimeValidation bool `json:"strict_runtime_validation" name:"strict-runtime-validation" env:"LAMBDA_STRICT_RUNTIME_VALIDATION" default:"false" help:"Reject deprecated Lambda runtimes (LocalStack LAMBDA_RUNTIME_VALIDATION=1)."` //nolint:lll // config struct tags are intentionally verbose + KeepContainers bool `json:"keep_containers" name:"keep-containers" env:"LAMBDA_KEEP_CONTAINERS" default:"false" help:"If true, keep Lambda containers alive for debugging."` //nolint:lll // config struct tags are intentionally verbose + DisableHotReload bool `json:"disable_hot_reload" name:"disable-hot-reload" env:"LAMBDA_DISABLE_HOT_RELOAD" default:"false" help:"Disable Lambda hot reloading from local directories."` //nolint:lll // config struct tags are intentionally verbose } const ( @@ -89,6 +90,7 @@ func DefaultSettings() Settings { MaxRuntimes: maxRuntimes, KeepContainers: keepContainers, Isolation: isolationFromEnv(), + DockerFlags: os.Getenv("LAMBDA_DOCKER_FLAGS"), } return st.withHotReloadEnv().withLimitEnv() diff --git a/services/lambda/versions_aliases.go b/services/lambda/versions_aliases.go index 4641afac63..31ac257b16 100644 --- a/services/lambda/versions_aliases.go +++ b/services/lambda/versions_aliases.go @@ -77,6 +77,10 @@ func (b *InMemoryBackend) publishVersion( Environment: deepCopyEnvironment(fn.Environment), Layers: deepCopyFunctionLayers(fn.Layers), CodeSize: fn.CodeSize, + ZipData: fn.ZipData, + S3BucketCode: fn.S3BucketCode, + S3KeyCode: fn.S3KeyCode, + S3ObjectVersionCode: fn.S3ObjectVersionCode, RevisionID: uuid.New().String(), CreatedAt: fn.LastModified, State: fn.State, @@ -457,12 +461,19 @@ func versionToFn(v *FunctionVersion) *FunctionConfiguration { PackageType: v.PackageType, ImageURI: v.ImageURI, Environment: v.Environment, + Layers: v.Layers, + KMSKeyArn: v.KMSKeyArn, CodeSize: v.CodeSize, RevisionID: v.RevisionID, LastModified: v.CreatedAt, - State: v.State, - SnapStart: v.SnapStart, - Version: v.Version, + // Code is immutable per version: a cold start of a published version must not need $LATEST. + ZipData: v.ZipData, + S3BucketCode: v.S3BucketCode, + S3KeyCode: v.S3KeyCode, + S3ObjectVersionCode: v.S3ObjectVersionCode, + State: v.State, + SnapStart: v.SnapStart, + Version: v.Version, // Invoke reads this to detect durable invocations of versions/aliases. DurableConfig: v.DurableConfig, CapacityProviderConfig: v.CapacityProviderConfig, diff --git a/services/lambda/wire_tags_leak_test.go b/services/lambda/wire_tags_leak_test.go index 8038a9e7ea..be04c407a3 100644 --- a/services/lambda/wire_tags_leak_test.go +++ b/services/lambda/wire_tags_leak_test.go @@ -17,17 +17,19 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/lambda" ) // captureTransport records the raw bytes of the last HTTP response, then // replays them so the real SDK deserializer still sees the full body. type captureTransport struct { + rt http.RoundTripper body []byte } func (c *captureTransport) Do(req *http.Request) (*http.Response, error) { - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := c.rt.RoundTrip(req) if err != nil { return nil, err } @@ -68,7 +70,7 @@ func newCapturingWireTestLambdaClient(t *testing.T, h *lambda.Handler) (*lambdas ) require.NoError(t, err) - capture := &captureTransport{} + capture := &captureTransport{rt: testclient.New(t).Transport} client := lambdasdk.NewFromConfig(cfg, func(o *lambdasdk.Options) { o.BaseEndpoint = aws.String(srv.URL) diff --git a/services/lightsail/error_envelope_test.go b/services/lightsail/error_envelope_test.go index cc24446519..57df4b49cc 100644 --- a/services/lightsail/error_envelope_test.go +++ b/services/lightsail/error_envelope_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/lightsail" ) @@ -92,7 +93,7 @@ func rawErrorResponse(t *testing.T, baseURL, target, body string) ([]byte, int) req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", target) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/lightsail/iam_enforcement_test.go b/services/lightsail/iam_enforcement_test.go index 4ec5c3efca..996ef4b01f 100644 --- a/services/lightsail/iam_enforcement_test.go +++ b/services/lightsail/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/lightsail" ) @@ -168,7 +169,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/lightsail/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/lightsail/lifecycle_realism_test.go b/services/lightsail/lifecycle_realism_test.go index 990c7e5b50..7106d8a091 100644 --- a/services/lightsail/lifecycle_realism_test.go +++ b/services/lightsail/lifecycle_realism_test.go @@ -2,6 +2,7 @@ package lightsail_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -45,43 +46,47 @@ func instanceStateName(t *testing.T, c *lightsailsdk.Client, name string) string func TestInstanceStopStartTransitions(t *testing.T) { t.Parallel() - c := newDelayedClient(t, time.Hour) - createInstance(t, c, "web-1") - assert.Equal(t, "pending", instanceStateName(t, c, "web-1")) + synctest.Test(t, func(t *testing.T) { + c := newDelayedClient(t, time.Hour) + createInstance(t, c, "web-1") + assert.Equal(t, "pending", instanceStateName(t, c, "web-1")) - _, err := c.StopInstance(t.Context(), &lightsailsdk.StopInstanceInput{InstanceName: aws.String("web-1")}) + _, err := c.StopInstance(t.Context(), &lightsailsdk.StopInstanceInput{InstanceName: aws.String("web-1")}) - var apiErr smithy.APIError - require.ErrorAs(t, err, &apiErr) - assert.Equal(t, "InvalidInputException", apiErr.ErrorCode()) + var apiErr smithy.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "InvalidInputException", apiErr.ErrorCode()) - fast := newDelayedClient(t, time.Millisecond) - createInstance(t, fast, "web-2") - require.Eventually(t, func() bool { return instanceStateName(t, fast, "web-2") == "running" }, - defaultAsyncWait, defaultAsyncPoll) + fast := newDelayedClient(t, time.Millisecond) + createInstance(t, fast, "web-2") + require.Eventually(t, func() bool { return instanceStateName(t, fast, "web-2") == "running" }, + defaultAsyncWait, defaultAsyncPoll) - _, err = fast.StartInstance(t.Context(), &lightsailsdk.StartInstanceInput{InstanceName: aws.String("web-2")}) - require.ErrorAs(t, err, &apiErr) - assert.Equal(t, "InvalidInputException", apiErr.ErrorCode()) + _, err = fast.StartInstance(t.Context(), &lightsailsdk.StartInstanceInput{InstanceName: aws.String("web-2")}) + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "InvalidInputException", apiErr.ErrorCode()) + }) } func TestStopShowsStoppingFirst(t *testing.T) { t.Parallel() - backend := lightsail.NewInMemoryBackend(t.Context(), rtTestAccountID, rtTestRegion) - t.Cleanup(backend.Close) - backend.SetTransitionDelay(time.Millisecond) - c := newRoundTripClient(t, lightsail.NewHandler(backend)) + synctest.Test(t, func(t *testing.T) { + backend := lightsail.NewInMemoryBackend(t.Context(), rtTestAccountID, rtTestRegion) + t.Cleanup(backend.Close) + backend.SetTransitionDelay(time.Millisecond) + c := newRoundTripClient(t, lightsail.NewHandler(backend)) - createInstance(t, c, "web-3") - require.Eventually(t, func() bool { return instanceStateName(t, c, "web-3") == "running" }, - defaultAsyncWait, defaultAsyncPoll) + createInstance(t, c, "web-3") + require.Eventually(t, func() bool { return instanceStateName(t, c, "web-3") == "running" }, + defaultAsyncWait, defaultAsyncPoll) - backend.SetTransitionDelay(time.Hour) + backend.SetTransitionDelay(time.Hour) - _, err := c.StopInstance(t.Context(), &lightsailsdk.StopInstanceInput{InstanceName: aws.String("web-3")}) - require.NoError(t, err) - assert.Equal(t, "stopping", instanceStateName(t, c, "web-3")) + _, err := c.StopInstance(t.Context(), &lightsailsdk.StopInstanceInput{InstanceName: aws.String("web-3")}) + require.NoError(t, err) + assert.Equal(t, "stopping", instanceStateName(t, c, "web-3")) + }) } func TestCreateInstances_Validation(t *testing.T) { diff --git a/services/lightsail/sdk_roundtrip_helper_test.go b/services/lightsail/sdk_roundtrip_helper_test.go index f154a3b4de..6672faf031 100644 --- a/services/lightsail/sdk_roundtrip_helper_test.go +++ b/services/lightsail/sdk_roundtrip_helper_test.go @@ -1,17 +1,16 @@ package lightsail_test import ( - "net/http/httptest" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" lightsailsdk "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/labstack/echo/v5" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/lightsail" ) @@ -52,20 +51,11 @@ func newRoundTripClient(t *testing.T, h *lightsail.Handler) *lightsailsdk.Client require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(rtTestRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return lightsailsdk.NewFromConfig(cfg, func(o *lightsailsdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + return lightsailsdk.NewFromConfig(aws.Config{ + Region: rtTestRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/macie2/iam_enforcement_test.go b/services/macie2/iam_enforcement_test.go index 1bb61cc4c9..ce8d7c80be 100644 --- a/services/macie2/iam_enforcement_test.go +++ b/services/macie2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/macie2" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/macie2/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/managedblockchain/iam_enforcement_test.go b/services/managedblockchain/iam_enforcement_test.go index 2b6933862b..57de31937c 100644 --- a/services/managedblockchain/iam_enforcement_test.go +++ b/services/managedblockchain/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/managedblockchain" ) @@ -188,7 +189,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/managedblockchain/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/marketplaceentitlementservice/entitlements_test.go b/services/marketplaceentitlementservice/entitlements_test.go index e9047d5288..bbbdc19ea8 100644 --- a/services/marketplaceentitlementservice/entitlements_test.go +++ b/services/marketplaceentitlementservice/entitlements_test.go @@ -19,6 +19,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awstime" "github.com/blackbirdworks/gopherstack/pkgs/sdktest" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ent "github.com/blackbirdworks/gopherstack/services/marketplaceentitlementservice" ) @@ -318,7 +319,7 @@ func TestLegacyJSONTarget(t *testing.T) { req.Header.Set("X-Amz-Target", tt.target) req.Header.Set("Content-Type", "application/x-amz-json-1.1") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mediaconvert/iam_enforcement_test.go b/services/mediaconvert/iam_enforcement_test.go index 8adfbf956c..5c215850d0 100644 --- a/services/mediaconvert/iam_enforcement_test.go +++ b/services/mediaconvert/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mediaconvert" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mediaconvert/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/medialive/handler_error_type_test.go b/services/medialive/handler_error_type_test.go index 000f21b871..df89404e59 100644 --- a/services/medialive/handler_error_type_test.go +++ b/services/medialive/handler_error_type_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/medialive" ) @@ -78,7 +79,7 @@ func TestDescribeChannel_UnknownChannelRawEnvelope(t *testing.T) { srv.URL+"/prod/channels/no-such-channel", nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/medialive/iam_enforcement_test.go b/services/medialive/iam_enforcement_test.go index d7d990c0df..7559fbbe31 100644 --- a/services/medialive/iam_enforcement_test.go +++ b/services/medialive/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/medialive" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/medialive/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mediapackage/iam_enforcement_test.go b/services/mediapackage/iam_enforcement_test.go index 66b3d5c52a..577568a537 100644 --- a/services/mediapackage/iam_enforcement_test.go +++ b/services/mediapackage/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mediapackage" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mediapackage/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mediastore/iam_enforcement_test.go b/services/mediastore/iam_enforcement_test.go index b5b52a28ea..9aca08c380 100644 --- a/services/mediastore/iam_enforcement_test.go +++ b/services/mediastore/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mediastore" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mediastore/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mediastoredata/iam_enforcement_test.go b/services/mediastoredata/iam_enforcement_test.go index b92c53d4b3..dbe8a606b7 100644 --- a/services/mediastoredata/iam_enforcement_test.go +++ b/services/mediastoredata/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mediastoredata" ) @@ -166,7 +167,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mediastore/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mediatailor/iam_enforcement_test.go b/services/mediatailor/iam_enforcement_test.go index 17309ce69d..985f63979b 100644 --- a/services/mediatailor/iam_enforcement_test.go +++ b/services/mediatailor/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mediatailor" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mediatailor/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/memorydb/iam_enforcement_test.go b/services/memorydb/iam_enforcement_test.go index ca9fe4abe6..36ceb274b3 100644 --- a/services/memorydb/iam_enforcement_test.go +++ b/services/memorydb/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/memorydb" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/memorydb/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mgn/iam_enforcement_test.go b/services/mgn/iam_enforcement_test.go index bb398b1b68..6a2dc7cb99 100644 --- a/services/mgn/iam_enforcement_test.go +++ b/services/mgn/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mgn" ) @@ -166,7 +167,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mgn/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mgn/realism_test.go b/services/mgn/realism_test.go index d0e48b57bc..7942a944b7 100644 --- a/services/mgn/realism_test.go +++ b/services/mgn/realism_test.go @@ -2,6 +2,7 @@ package mgn_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -28,38 +29,42 @@ func TestTransitionDelay_Export(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - h, client := newTestHandlerAndClient(t) - h.Backend.SetTransitionDelay(tt.delay) - ctx := t.Context() + synctest.Test(t, func(t *testing.T) { + h, client := newInMemoryHandlerAndClient(t) + h.Backend.SetTransitionDelay(tt.delay) + ctx := t.Context() - _, err := client.InitializeService(ctx, &mgnsdk.InitializeServiceInput{}) - require.NoError(t, err) + _, err := client.InitializeService(ctx, &mgnsdk.InitializeServiceInput{}) + require.NoError(t, err) - started, err := client.StartExport(ctx, &mgnsdk.StartExportInput{ - S3Bucket: aws.String("my-bucket"), S3Key: aws.String("export.json"), - }) - require.NoError(t, err) - - status := func() mgntypes.ExportStatus { - out, lErr := client.ListExports(ctx, &mgnsdk.ListExportsInput{ - Filters: &mgntypes.ListExportsRequestFilters{ - ExportIDs: []string{aws.ToString(started.ExportTask.ExportID)}, - }, + started, err := client.StartExport(ctx, &mgnsdk.StartExportInput{ + S3Bucket: aws.String("my-bucket"), S3Key: aws.String("export.json"), }) - require.NoError(t, lErr) - require.Len(t, out.Items, 1) + require.NoError(t, err) + + status := func() mgntypes.ExportStatus { + out, lErr := client.ListExports(ctx, &mgnsdk.ListExportsInput{ + Filters: &mgntypes.ListExportsRequestFilters{ + ExportIDs: []string{aws.ToString(started.ExportTask.ExportID)}, + }, + }) + require.NoError(t, lErr) + require.Len(t, out.Items, 1) - return out.Items[0].Status - } + return out.Items[0].Status + } - if tt.wantStays { - assert.NotEqual(t, mgntypes.ExportStatusSucceeded, status()) + if tt.wantStays { + assert.NotEqual(t, mgntypes.ExportStatusSucceeded, status()) - return - } + return + } - require.Eventually(t, func() bool { return status() == mgntypes.ExportStatusSucceeded }, - defaultAsyncWait, defaultAsyncPoll) + time.Sleep(10 * tt.delay) + synctest.Wait() + + assert.Equal(t, mgntypes.ExportStatusSucceeded, status()) + }) }) } } diff --git a/services/mgn/sdk_roundtrip_helper_test.go b/services/mgn/sdk_roundtrip_helper_test.go index fae37efd70..bd28743fe9 100644 --- a/services/mgn/sdk_roundtrip_helper_test.go +++ b/services/mgn/sdk_roundtrip_helper_test.go @@ -18,6 +18,7 @@ import ( "github.com/labstack/echo/v5" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/mgn" ) @@ -146,3 +147,24 @@ func seedSourceServerViaImport( return created } + +// newInMemoryHandlerAndClient is newTestHandlerAndClient over an in-process transport so it can +// run inside a testing/synctest bubble. +func newInMemoryHandlerAndClient(t *testing.T) (*mgn.Handler, *mgnsdk.Client) { + t.Helper() + + backend := mgn.NewInMemoryBackend(t.Context(), rtTestAccountID, rtTestRegion) + t.Cleanup(backend.Close) + + backend.InitializeService() + + h := mgn.NewHandler(backend) + client := mgnsdk.NewFromConfig(aws.Config{ + Region: rtTestRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.ServeInMemory(t, h), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), + }) + + return h, client +} diff --git a/services/mgn/wire_field_fixes_test.go b/services/mgn/wire_field_fixes_test.go index 16d0bbd5e6..924cf8026c 100644 --- a/services/mgn/wire_field_fixes_test.go +++ b/services/mgn/wire_field_fixes_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/mgn" ) @@ -43,7 +44,7 @@ func rawPost(t *testing.T, url string, body map[string]any) map[string]any { require.NoError(t, err) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) diff --git a/services/mq/broker_engine_test.go b/services/mq/broker_engine_test.go index 2c2145663b..cafcf2a22d 100644 --- a/services/mq/broker_engine_test.go +++ b/services/mq/broker_engine_test.go @@ -434,9 +434,7 @@ func TestDockerBrokerRetriesPortBindRace(t *testing.T) { }, 10*time.Second, 10*time.Millisecond) if tt.wantState == mq.BrokerStateCreationFailed { - require.Eventually(t, func() bool { - return strings.Contains(mq.BrokerStartFailure(b, br.BrokerID), "port is already allocated") - }, 10*time.Second, 10*time.Millisecond) + assert.Contains(t, mq.BrokerStartFailure(b, br.BrokerID), "port is already allocated") } }) } diff --git a/services/mq/iam_enforcement_test.go b/services/mq/iam_enforcement_test.go index 8e7aa63345..0de0d16622 100644 --- a/services/mq/iam_enforcement_test.go +++ b/services/mq/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mq" ) @@ -178,7 +179,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mq/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/mwaa/iam_enforcement_test.go b/services/mwaa/iam_enforcement_test.go index 12b72ca1ee..7a165437bf 100644 --- a/services/mwaa/iam_enforcement_test.go +++ b/services/mwaa/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/mwaa" ) @@ -178,7 +179,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/airflow/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/neptune/PARITY.md b/services/neptune/PARITY.md index 8c43c5e977..c23291b135 100644 --- a/services/neptune/PARITY.md +++ b/services/neptune/PARITY.md @@ -89,9 +89,8 @@ families: gaps: [] items_still_open: - "UNVERIFIABLE: OrderableDBInstanceOption.SupportedNetworkTypes stays empty; the pinned SDK states no per-instance-class network type support." - - "--neptune-engine=embedded|docker: IAM database authentication (SigV4 on the Gremlin/openCypher/Bolt endpoints) is not enforced, so no 401 is ever returned; the port accepts unauthenticated requests whatever EnableIAMDatabaseAuthentication says." - "SPARQL is not served in any engine mode. The port speaks plain HTTP/WebSocket and unencrypted Bolt, not TLS. With NEPTUNE_GREMLIN=container (docker mode only) Gremlin runs in a stock tinkerpop/gremlin-server container whose graph is separate from the in-process openCypher/Bolt graph (the default in-process mode shares one graph)." - - "Bolt: auth schemes none and basic are accepted without checking; Neptune IAM (SigV4-signed password) tokens are not signature-verified. Explicit Bolt transactions hold the cluster graph exclusively until COMMIT/ROLLBACK/disconnect, and results are materialised before PULL streaming. Not smoke-tested against a real neo4j driver (none available on the dev box); proven by the pkgs/bolt protocol tests and the neptune bolt_front_test.go." + - "Bolt: explicit transactions hold the cluster graph exclusively until COMMIT/ROLLBACK/disconnect, and results are materialised before PULL streaming. Not smoke-tested against a real neo4j driver (none available on the dev box); proven by the pkgs/bolt protocol tests and the neptune bolt_front_test.go." - "openCypher subset (services/neptune/opencypher): CALL/UNION/FOREACH/LOAD CSV, pattern predicates in WHERE, list comprehensions, shortestPath, temporal/spatial types and constraints are not implemented and return UnsupportedOperationException or MalformedQueryException." - "NEPTUNE_GREMLIN=container: the Gremlin graph in the container is not captured by cluster snapshots or persistence; relaunched clusters start with an empty Gremlin graph. In the default in-process mode the single shared graph is persisted. The writer and reader endpoints are the same host:port." - "Gremlin subset (services/neptune/gremlin): lambdas, sessions and multi-request transactions, mergeV/mergeE, tree/subgraph/sack/math/sample/index and string-manipulation steps, meta-properties, g.tx(), profile/explain and variable assignment in scripts are not implemented and return MalformedQueryException/UnsupportedOperationException. Integer and float widths are folded to Int64/Double on write; property cardinality is single (list/set stored as a list value); vertex property ids are derived, not stored." @@ -998,3 +997,7 @@ The cluster port sniffs each connection: the Bolt preamble `60 60 B0 17` goes to ADDED: `services/neptune/gremlin`, an in-process Gremlin server over the same `opencypher.Graph` the openCypher and Bolt fronts use, so one property graph (vertex labels with `::`, edge types, properties) is visible to all three languages and is persisted with the cluster. `--neptune-engine=embedded` serves it with no container runtime; `--neptune-engine=docker` now uses it too unless `NEPTUNE_GREMLIN=container` keeps the stock Gremlin Server container (proxied at `/gremlin` as before). Protocol: WebSocket `/gremlin` on the cluster port (alongside HTTP and Bolt), ops `eval` and `bytecode`, GraphBinary v1, GraphSON v3 and v2, statuses 200/204/206 (batched, `batchSize` honoured, default 64), 499/597/598/500 with a Neptune-shaped JSON status message; HTTP `GET|POST /gremlin` (GraphSON v3, v2 via Accept) with 400/500 `{requestId, code, detailedMessage}` errors; `/gremlin/status` list/get/cancel. Each request is one transaction: failures roll the graph back. Proven by `services/neptune/gremlin/*_test.go` and, with the real `gremlin-go` v3.7.7 driver (GraphBinary bytecode and string eval) plus Bolt and openCypher HTTP cross-language checks, `services/neptune/gremlin_front_test.go`. + +### 2026-10-29 data-plane IAM authentication + +Clusters with IAMDatabaseAuthenticationEnabled require a SigV4 signature with service `neptune-db` (credential-scope region = cluster region, X-Amz-Date within 5 minutes) on Gremlin HTTP, Gremlin WebSocket (signed upgrade headers), openCypher HTTP, the status routes and Bolt (basic password = the documented JSON of Authorization/HttpMethod/X-Amz-Date/Host/X-Amz-Security-Token for GET /opencypher). Failures return 403 AccessDeniedException (Bolt: Neo.ClientError.Security.Unauthorized / Forbidden). With `--enforce-iam`, query steps map to neptune-db:ReadDataViaQuery / WriteDataViaQuery / DeleteDataViaQuery (per the "Using query actions" rules), status to GetQueryStatus and DELETE to CancelQuery, against `arn:aws:neptune-db:::/*`, with the `neptune-db:QueryLanguage` condition key. Without `--enforce-iam` any validly signed known caller is admitted. Not modelled: presigned-query signatures, SPARQL/loader/ML/export actions (routes absent), and WebSocket frames on the docker-proxied Gremlin Server (handshake needs read only). diff --git a/services/neptune/README.md b/services/neptune/README.md index 247c6527d9..6304b43945 100644 --- a/services/neptune/README.md +++ b/services/neptune/README.md @@ -8,7 +8,7 @@ | Metric | Value | | --- | --- | | Feature families | 13 (13 ok) | -| Known gaps | 7 | +| Known gaps | 6 | | Structural gaps (can't be emulated) | 2 | | Deferred items | 2 | | Resource leaks | clean | @@ -16,9 +16,8 @@ ### Known gaps - UNVERIFIABLE: OrderableDBInstanceOption.SupportedNetworkTypes stays empty; the pinned SDK states no per-instance-class network type support. -- --neptune-engine=embedded|docker: IAM database authentication (SigV4 on the Gremlin/openCypher/Bolt endpoints) is not enforced, so no 401 is ever returned; the port accepts unauthenticated requests whatever EnableIAMDatabaseAuthentication says. - SPARQL is not served in any engine mode. The port speaks plain HTTP/WebSocket and unencrypted Bolt, not TLS. With NEPTUNE_GREMLIN=container (docker mode only) Gremlin runs in a stock tinkerpop/gremlin-server container whose graph is separate from the in-process openCypher/Bolt graph (the default in-process mode shares one graph). -- Bolt: auth schemes none and basic are accepted without checking; Neptune IAM (SigV4-signed password) tokens are not signature-verified. Explicit Bolt transactions hold the cluster graph exclusively until COMMIT/ROLLBACK/disconnect, and results are materialised before PULL streaming. Not smoke-tested against a real neo4j driver (none available on the dev box); proven by the pkgs/bolt protocol tests and the neptune bolt_front_test.go. +- Bolt: explicit transactions hold the cluster graph exclusively until COMMIT/ROLLBACK/disconnect, and results are materialised before PULL streaming. Not smoke-tested against a real neo4j driver (none available on the dev box); proven by the pkgs/bolt protocol tests and the neptune bolt_front_test.go. - openCypher subset (services/neptune/opencypher): CALL/UNION/FOREACH/LOAD CSV, pattern predicates in WHERE, list comprehensions, shortestPath, temporal/spatial types and constraints are not implemented and return UnsupportedOperationException or MalformedQueryException. - NEPTUNE_GREMLIN=container: the Gremlin graph in the container is not captured by cluster snapshots or persistence; relaunched clusters start with an empty Gremlin graph. In the default in-process mode the single shared graph is persisted. The writer and reader endpoints are the same host:port. - Gremlin subset (services/neptune/gremlin): lambdas, sessions and multi-request transactions, mergeV/mergeE, tree/subgraph/sack/math/sample/index and string-manipulation steps, meta-properties, g.tx(), profile/explain and variable assignment in scripts are not implemented and return MalformedQueryException/UnsupportedOperationException. Integer and float widths are folded to Int64/Double on write; property cardinality is single (list/set stored as a list value); vertex property ids are derived, not stored. diff --git a/services/neptune/bolt_backend.go b/services/neptune/bolt_backend.go index 58a9e3c327..e01536ca93 100644 --- a/services/neptune/bolt_backend.go +++ b/services/neptune/bolt_backend.go @@ -5,6 +5,7 @@ import ( "errors" "hash/fnv" "math" + "time" "github.com/blackbirdworks/gopherstack/pkgs/bolt" "github.com/blackbirdworks/gopherstack/services/neptune/opencypher" @@ -19,15 +20,30 @@ const ( type boltBackend struct { graph *opencypher.Graph queries *queryTracker + auth func() clusterAuth + caller *iamCaller } func (b *boltBackend) NewSession() bolt.Session { return &boltSession{b: b} } -// Authenticate accepts the none and basic schemes; Neptune IAM tokens (basic with a SigV4-signed password) -// are not signature-checked. +// Authenticate accepts the none and basic schemes; on a cluster with IAM authentication the basic password must be +// the signed-request JSON Neptune documents, verified like an HTTP request. func (b *boltBackend) Authenticate(_ context.Context, auth map[string]any) error { scheme, _ := auth["scheme"].(string) + if b.auth != nil { + if ca := b.auth(); ca.enabled { + caller, err := ca.authenticateBolt(auth, time.Now()) + if err != nil { + return &bolt.Error{Code: bolt.CodeUnauthorized, Message: "AccessDeniedException: " + err.Error()} + } + + b.caller = caller + + return nil + } + } + switch scheme { case "", "none", "basic": return nil @@ -42,6 +58,12 @@ type boltSession struct { } func (s *boltSession) Run(ctx context.Context, query string, params map[string]any) (*bolt.Result, error) { + if c := s.b.caller; c != nil { + if a := c.denied(langOpenCypher, cypherAccessActions(query)...); a != "" { + return nil, boltDenied(accessDeniedDetail(a)) + } + } + ctx, finish := s.b.queries.begin(ctx, query) defer finish() diff --git a/services/neptune/engine.go b/services/neptune/engine.go index f72b800a7d..2795a459f2 100644 --- a/services/neptune/engine.go +++ b/services/neptune/engine.go @@ -370,7 +370,7 @@ func (b *InMemoryBackend) runEmbeddedUnit(lb *liveCluster) { return } - front, err := startCypherFront(e.cfg.Host, port, "", lb.graph, b.clusterAdmit(lb)) + front, err := startCypherFront(e.cfg.Host, port, "", lb.graph, b.clusterAdmit(lb), b.clusterAuthFn(lb)) if err != nil { e.releasePort(port) b.failUnit(lb) diff --git a/services/neptune/engine_sg.go b/services/neptune/engine_sg.go index 42a8bd32ae..205233edaf 100644 --- a/services/neptune/engine_sg.go +++ b/services/neptune/engine_sg.go @@ -70,7 +70,7 @@ func (b *InMemoryBackend) startFronts(lb *liveCluster, port, inner int) (*cypher admit := b.clusterAdmit(lb) if e.cfg.OpenCypher { - front, err := startCypherFront(e.cfg.Host, port, upstream, lb.graph, admit) + front, err := startCypherFront(e.cfg.Host, port, upstream, lb.graph, admit, b.clusterAuthFn(lb)) return front, nil, err } diff --git a/services/neptune/gremlin/access.go b/services/neptune/gremlin/access.go new file mode 100644 index 0000000000..ef88763f83 --- /dev/null +++ b/services/neptune/gremlin/access.go @@ -0,0 +1,84 @@ +package gremlin + +import ( + "regexp" +) + +// Access is the extra permission a query needs beyond read; every query reads. +type Access struct { + Write bool + Delete bool +} + +var writeStepRE = regexp.MustCompile(`\b(addV|addE|mergeV|mergeE|property|drop)\s*\(`) + +// ScriptAccess scans a script for steps that can write or delete; it over-approximates, never under. +func ScriptAccess(script string) Access { + var a Access + + for _, m := range writeStepRE.FindAllStringSubmatch(script, -1) { + a.merge(stepNameAccess(m[1])) + } + + return a +} + +// Access reports what the traversal's steps, modulators and nested traversals can do to the graph. +func (t *Traversal) Access() Access { + var a Access + + for i := range t.Steps { + a.merge(stepAccess(&t.Steps[i])) + } + + return a +} + +func (a *Access) merge(o Access) { + a.Write = a.Write || o.Write + a.Delete = a.Delete || o.Delete +} + +func stepNameAccess(name string) Access { + switch { + case isCreateStep(name): + return Access{Write: true} + case name == "drop": + return Access{Delete: true} + case mutatingStep(name): + return Access{Write: true, Delete: true} // property + } + + return Access{} +} + +func stepAccess(s *Step) Access { + a := stepNameAccess(s.Name) + + for _, arg := range s.Args { + a.merge(argAccess(arg)) + } + + for i := range s.Mods { + a.merge(stepAccess(&s.Mods[i])) + } + + return a +} + +func argAccess(v any) Access { + switch t := v.(type) { + case *Traversal: + return t.Access() + case []any: + var a Access + + for _, e := range t { + a.merge(argAccess(e)) + } + + return a + } + + return Access{} +} diff --git a/services/neptune/gremlin/access_test.go b/services/neptune/gremlin/access_test.go new file mode 100644 index 0000000000..3f0fb7305f --- /dev/null +++ b/services/neptune/gremlin/access_test.go @@ -0,0 +1,33 @@ +package gremlin_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/blackbirdworks/gopherstack/services/neptune/gremlin" +) + +func TestScriptAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + script string + want gremlin.Access + }{ + {"read", "g.V().has('a',1).count()", gremlin.Access{}}, + {"add_vertex", "g.addV('p')", gremlin.Access{Write: true}}, + {"drop", "g.V().drop()", gremlin.Access{Delete: true}}, + {"property", "g.V('1').property('k','v')", gremlin.Access{Write: true, Delete: true}}, + {"nested", "g.V().where(__.addE('x').to(__.V()))", gremlin.Access{Write: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, gremlin.ScriptAccess(tt.script)) + }) + } +} diff --git a/services/neptune/gremlin/errors.go b/services/neptune/gremlin/errors.go index b1cff40a0f..b3965dc25d 100644 --- a/services/neptune/gremlin/errors.go +++ b/services/neptune/gremlin/errors.go @@ -23,6 +23,7 @@ const ( StatusNoContent = 204 StatusPartial = 206 StatusUnauthorized = 401 + StatusForbidden = 403 StatusInvalidRequest = 499 StatusServerError = 500 StatusScriptEvalError = 597 diff --git a/services/neptune/gremlin/frame.go b/services/neptune/gremlin/frame.go new file mode 100644 index 0000000000..5f789a44e5 --- /dev/null +++ b/services/neptune/gremlin/frame.go @@ -0,0 +1,118 @@ +package gremlin + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +const uuidBytes = 16 + +// FrameRequest is a client request frame inspected without being executed. +type FrameRequest struct { + ID string + Access Access + codec codec +} + +// ClassifyFrame parses a client request frame (GraphSON text or mime-prefixed binary) and reports the access its +// script or bytecode needs. Frames that cannot be understood, or ops other than eval, bytecode, close and +// authentication, yield an error and a FrameRequest carrying whatever correlation could be recovered. +func ClassifyFrame(messageType int, data []byte) (FrameRequest, error) { + if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage { + return FrameRequest{ID: uuid.Nil.String()}, invalidArg("Unsupported WebSocket frame type") + } + + msg, err := parseFrame(messageType, data) + if err != nil { + return recoverFrame(messageType, data), err + } + + req := FrameRequest{ID: msg.id, codec: msg.codec} + + switch msg.op { + case opEval: + script, ok := msg.args["gremlin"].(string) + if !ok || strings.TrimSpace(script) == "" { + return req, invalidArg("A message with an [eval] op code requires a [gremlin] argument.") + } + + req.Access = ScriptAccess(script) + case opBytecode: + tr, ok := msg.args["gremlin"].(*Traversal) + if !ok { + return req, invalidArg("A message with a [bytecode] op code requires a [gremlin] argument of bytecode.") + } + + req.Access = tr.Access() + case "close", "authentication": + default: + return req, invalidArg("Message with op code [%s] is not recognized.", msg.op) + } + + return req, nil +} + +// recoverFrame extracts the request id and codec from a frame that failed to parse, best effort. +func recoverFrame(messageType int, data []byte) FrameRequest { + req := FrameRequest{ID: uuid.Nil.String()} + + body := data + + if messageType == websocket.BinaryMessage { + var mime string + + mime, body = splitMime(data) + if mime == mimeGraphBinary { + return recoverBinary(req, body) + } + + if mime == mimeGraphSONv2 { + req.codec = codecV2 + } + } + + var raw struct { + RequestID string `json:"requestId"` + } + + if json.NewDecoder(bytes.NewReader(body)).Decode(&raw) == nil && raw.RequestID != "" { + req.ID = raw.RequestID + } + + return req +} + +// DeniedFrame builds the response frame refusing req with err, in the codec the client used. +func DeniedFrame(req FrameRequest, err error) (int, []byte) { + e := asGremlinError(err) + + return encodeFrame(&message{id: req.ID, codec: req.codec}, e.Status, errorMessage(req.ID, e), nil, false) +} + +// DeniedError is the AccessDeniedException a client receives when detail explains the refusal. +func DeniedError(detail string) error { + return &Error{Code: CodeUnauthorized, Status: StatusForbidden, Detail: detail} +} + +func splitMime(data []byte) (string, []byte) { + if len(data) == 0 || int(data[0])+1 > len(data) { + return "", nil + } + + n := int(data[0]) + + return string(data[1 : 1+n]), data[1+n:] +} + +func recoverBinary(req FrameRequest, body []byte) FrameRequest { + if len(body) >= 1+uuidBytes && body[0] == gbVersion { + req.ID = formatUUID(body[1 : 1+uuidBytes]) + req.codec = codecBinary + } + + return req +} diff --git a/services/neptune/gremlin/server.go b/services/neptune/gremlin/server.go index b70eec60cf..fe995d2fd9 100644 --- a/services/neptune/gremlin/server.go +++ b/services/neptune/gremlin/server.go @@ -17,6 +17,8 @@ import ( ) const ( + opEval = "eval" + opBytecode = "bytecode" maxFrameBytes = 4 << 20 maxScriptBytes = 1 << 20 defaultBatchSize = 64 @@ -28,8 +30,10 @@ const ( // Options configures a Server. type Options struct { // Track registers a running query and returns its cancellable context and a finish callback. - Track func(parent context.Context, query string) (context.Context, func()) - Timeout time.Duration + Track func(parent context.Context, query string) (context.Context, func()) + // Authorize, when set, vets each query before it runs; ctx is the request (or WebSocket handshake) context. + Authorize func(ctx context.Context, write, del bool) error + Timeout time.Duration } // Server serves Gremlin over WebSocket and HTTP against one graph. @@ -100,7 +104,7 @@ func (s *Server) serveWS(w http.ResponseWriter, r *http.Request) { continue } - if s.handleFrame(conn, mt, data) != nil { + if s.handleFrame(r.Context(), conn, mt, data) != nil { return } } @@ -172,23 +176,23 @@ func parseJSONRequest(body []byte, c codec) (*message, error) { return msg, nil } -func (s *Server) handleFrame(conn *websocket.Conn, mt int, data []byte) error { +func (s *Server) handleFrame(ctx context.Context, conn *websocket.Conn, mt int, data []byte) error { msg, err := parseFrame(mt, data) if err != nil { return writeResult(conn, &message{id: uuid.Nil.String(), codec: codecV3}, nil, err) } - out, err := s.execute(msg) + out, err := s.execute(ctx, msg) return writeResult(conn, msg, out, err) } -func (s *Server) execute(msg *message) ([]any, error) { +func (s *Server) execute(ctx context.Context, msg *message) ([]any, error) { switch msg.op { - case "eval": - return s.evalMessage(msg) - case "bytecode": - return s.bytecodeMessage(msg) + case opEval: + return s.evalMessage(ctx, msg) + case opBytecode: + return s.bytecodeMessage(ctx, msg) case "close": return nil, nil } @@ -217,7 +221,7 @@ func (s *Server) queryContext(msg *message, label string) (context.Context, func } } -func (s *Server) evalMessage(msg *message) ([]any, error) { +func (s *Server) evalMessage(reqCtx context.Context, msg *message) ([]any, error) { script, ok := msg.args["gremlin"].(string) if !ok || strings.TrimSpace(script) == "" { return nil, invalidArg("A message with an [eval] op code requires a [gremlin] argument.") @@ -227,24 +231,40 @@ func (s *Server) evalMessage(msg *message) ([]any, error) { return nil, invalidArg("The Gremlin script exceeds the maximum size of %d bytes", maxScriptBytes) } + if err := s.authorize(reqCtx, ScriptAccess(script)); err != nil { + return nil, err + } + ctx, finish := s.queryContext(msg, script) defer finish() return s.eng.Eval(ctx, s.graph, script, bindingsOf(msg.args["bindings"])) } -func (s *Server) bytecodeMessage(msg *message) ([]any, error) { +func (s *Server) bytecodeMessage(reqCtx context.Context, msg *message) ([]any, error) { tr, ok := msg.args["gremlin"].(*Traversal) if !ok { return nil, invalidArg("A message with a [bytecode] op code requires a [gremlin] argument of bytecode.") } + if err := s.authorize(reqCtx, tr.Access()); err != nil { + return nil, err + } + ctx, finish := s.queryContext(msg, "bytecode: "+describe(tr)) defer finish() return s.eng.Bytecode(ctx, s.graph, tr) } +func (s *Server) authorize(ctx context.Context, a Access) error { + if s.opt.Authorize == nil { + return nil + } + + return s.opt.Authorize(ctx, a.Write, a.Delete) +} + func describe(tr *Traversal) string { names := make([]string, len(tr.Steps)) for i, st := range tr.Steps { @@ -325,27 +345,26 @@ func writeResult(conn *websocket.Conn, msg *message, out []any, err error) error } func writeFrame(conn *websocket.Conn, msg *message, code int, text string, data []any, hasData bool) error { - var ( - payload []byte - mt = websocket.TextMessage - ) + mt, payload := encodeFrame(msg, code, text, data, hasData) + + _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + return conn.WriteMessage(mt, payload) +} + +func encodeFrame(msg *message, code int, text string, data []any, hasData bool) (int, []byte) { if msg.codec == codecBinary { - mt = websocket.BinaryMessage w := &bbWriter{} - payload = w.response(msg.id, code, text, data, hasData) - } else { - var err error - payload, err = graphsonWriter{v2: msg.codec == codecV2}.response(msg.id, code, text, data, hasData) - if err != nil { - payload, _ = graphsonWriter{}.response(msg.id, StatusSerializationErr, err.Error(), nil, false) - } + return websocket.BinaryMessage, w.response(msg.id, code, text, data, hasData) } - _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + payload, err := graphsonWriter{v2: msg.codec == codecV2}.response(msg.id, code, text, data, hasData) + if err != nil { + payload, _ = graphsonWriter{}.response(msg.id, StatusSerializationErr, err.Error(), nil, false) + } - return conn.WriteMessage(mt, payload) + return websocket.TextMessage, payload } type httpRequest struct { @@ -362,7 +381,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { } id := uuid.NewString() - msg := &message{id: id, op: "eval", args: map[string]any{"gremlin": req.Gremlin}} + msg := &message{id: id, op: opEval, args: map[string]any{"gremlin": req.Gremlin}} if req.Bindings != nil { b := NewMap() @@ -381,7 +400,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { msg.args["bindings"] = b } - out, err := s.evalMessage(msg) + out, err := s.evalMessage(r.Context(), msg) if err != nil { writeHTTPError(w, asGremlinError(err), id) diff --git a/services/neptune/gremlin/server_test.go b/services/neptune/gremlin/server_test.go index 16c7ddacf3..70ac09fe0c 100644 --- a/services/neptune/gremlin/server_test.go +++ b/services/neptune/gremlin/server_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/neptune/gremlin" "github.com/blackbirdworks/gopherstack/services/neptune/opencypher" ) @@ -311,7 +312,7 @@ func TestHTTPEndpoint(t *testing.T) { req.Header.Set("Accept", tt.accept) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() diff --git a/services/neptune/gremlin_ws_proxy.go b/services/neptune/gremlin_ws_proxy.go new file mode 100644 index 0000000000..488ff94143 --- /dev/null +++ b/services/neptune/gremlin_ws_proxy.go @@ -0,0 +1,161 @@ +package neptune + +import ( + "context" + "net/http" + "net/url" + "time" + + "github.com/gorilla/websocket" + + "github.com/blackbirdworks/gopherstack/services/neptune/gremlin" +) + +const ( + wsFrameLimit = 4 << 20 + wsDialTimeout = 10 * time.Second + wsOutQueue = 16 + wsWriteTimeout = 30 * time.Second + detailUnparsed = "Access denied: the request frame could not be inspected for authorization: " +) + +type wsOut struct { + data []byte + mt int +} + +// enforcesFrames reports whether proxied Gremlin WebSocket frames must be authorized one by one. +func enforcesFrames(r *http.Request) bool { + c := callerFrom(r.Context()) + + return c != nil && c.enforce && websocket.IsWebSocketUpgrade(r) +} + +// proxyGremlinWS relays a Gremlin WebSocket to the container, authorizing every client request frame against the +// caller bound at the handshake. Denied frames are answered locally with a 403 response and never forwarded. +func (f *cypherFront) proxyGremlinWS(w http.ResponseWriter, r *http.Request) { + caller := callerFrom(r.Context()) + target := url.URL{Scheme: "ws", Host: f.upstream, Path: r.URL.Path, RawQuery: r.URL.RawQuery} + + dialCtx, cancelDial := context.WithTimeout(r.Context(), wsDialTimeout) + up, resp, err := (&websocket.Dialer{}).DialContext(dialCtx, target.String(), nil) + + cancelDial() + + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + if err != nil { + http.Error(w, "gremlin server unavailable", http.StatusBadGateway) + + return + } + + defer up.Close() + + client, err := (&websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}).Upgrade(w, r, nil) + if err != nil { + return + } + + defer client.Close() + + client.SetReadLimit(wsFrameLimit) + up.SetReadLimit(wsFrameLimit) + + ctx, cancel := context.WithCancel(f.ctx) + defer cancel() + + stop := context.AfterFunc(ctx, func() { + _ = client.Close() + _ = up.Close() + }) + defer stop() + + out := make(chan wsOut, wsOutQueue) + + go relayUpstream(ctx, cancel, up, out) + go writeClient(ctx, cancel, client, out) + + f.pumpClientFrames(ctx, client, up, caller, out) +} + +// pumpClientFrames forwards authorized client frames upstream and queues refusals for the client. +func (f *cypherFront) pumpClientFrames( + ctx context.Context, client, up *websocket.Conn, caller *iamCaller, out chan<- wsOut, +) { + for { + mt, data, err := client.ReadMessage() + if err != nil { + return + } + + if mt != websocket.TextMessage && mt != websocket.BinaryMessage { + continue + } + + if refuseFrame(ctx, caller, mt, data, out) { + continue + } + + if up.WriteMessage(mt, data) != nil { + return + } + } +} + +// refuseFrame returns true when the frame was refused (and the refusal queued). +func refuseFrame(ctx context.Context, caller *iamCaller, mt int, data []byte, out chan<- wsOut) bool { + req, err := gremlin.ClassifyFrame(mt, data) + if err != nil { + err = gremlin.DeniedError(detailUnparsed + err.Error()) + } else if a := caller.denied(langGremlin, queryActions(req.Access.Write, req.Access.Delete)...); a != "" { + err = gremlin.DeniedError(accessDeniedDetail(a)) + } else { + return false + } + + dmt, payload := gremlin.DeniedFrame(req, err) + + select { + case out <- wsOut{mt: dmt, data: payload}: + case <-ctx.Done(): + } + + return true +} + +func relayUpstream(ctx context.Context, cancel context.CancelFunc, up *websocket.Conn, out chan<- wsOut) { + defer cancel() + + for { + mt, data, err := up.ReadMessage() + if err != nil { + return + } + + select { + case out <- wsOut{mt: mt, data: data}: + case <-ctx.Done(): + return + } + } +} + +func writeClient(ctx context.Context, cancel context.CancelFunc, client *websocket.Conn, out <-chan wsOut) { + defer cancel() + + for { + select { + case m := <-out: + _ = client.SetWriteDeadline(time.Now().Add(wsWriteTimeout)) + + if client.WriteMessage(m.mt, m.data) != nil { + return + } + case <-ctx.Done(): + return + } + } +} diff --git a/services/neptune/gremlin_ws_proxy_test.go b/services/neptune/gremlin_ws_proxy_test.go new file mode 100644 index 0000000000..7e43c9c833 --- /dev/null +++ b/services/neptune/gremlin_ws_proxy_test.go @@ -0,0 +1,311 @@ +package neptune + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/iam" +) + +const ( + wsTestRegion = "us-east-1" + wsTestSecret = "ws-secret-key-0123456789abcdefghijklmnop" + wsTestReqID = "7f1a8f4c-0b0e-4a8a-9d3a-1c2d3e4f5a6b" + wsTestMime = "application/vnd.graphbinary-v1.0" +) + +var errWSNoUser = errors.New("no such user") + +func wsPolicy(actions ...string) string { + quoted := make([]string, len(actions)) + for i, a := range actions { + quoted[i] = strconv.Quote(a) + } + + return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":[` + + strings.Join(quoted, ",") + `],"Resource":"*"}]}` +} + +type wsIdentities struct{} + +func (wsIdentities) ListAllAccessKeys() []iam.AccessKey { + return []iam.AccessKey{ + {AccessKeyID: "AKIAREAD", SecretAccessKey: wsTestSecret, UserName: "read", Status: "Active"}, + {AccessKeyID: "AKIAWRITE", SecretAccessKey: wsTestSecret, UserName: "write", Status: "Active"}, + } +} + +func (wsIdentities) GetUserByAccessKeyID(id string) (*iam.User, error) { + switch id { + case "AKIAREAD": + return &iam.User{UserName: "read", Arn: "arn:aws:iam::000000000000:user/read"}, nil + case "AKIAWRITE": + return &iam.User{UserName: "write", Arn: "arn:aws:iam::000000000000:user/write"}, nil + } + + return nil, errWSNoUser +} + +func (wsIdentities) GetPoliciesForUser(name string) ([]string, error) { + if name == "write" { + return []string{wsPolicy(actionRead, actionWrite)}, nil + } + + return []string{wsPolicy(actionRead)}, nil +} + +func (wsIdentities) GetPoliciesForRole(string) ([]string, error) { return nil, nil } + +// fakeGremlin records the frames a Gremlin Server would receive and answers each with a 200 text response. +type fakeGremlin struct { + srv *httptest.Server + got [][]byte + mu sync.Mutex +} + +func newFakeGremlin(t *testing.T) *fakeGremlin { + t.Helper() + + fg := &fakeGremlin{} + up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + + fg.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + + for { + _, data, rerr := c.ReadMessage() + if rerr != nil { + return + } + + fg.mu.Lock() + fg.got = append(fg.got, data) + fg.mu.Unlock() + + _ = c.WriteMessage(websocket.TextMessage, + []byte(`{"requestId":"`+wsTestReqID+`","status":{"code":200,"message":"upstream","attributes":{}},`+ + `"result":{"data":[],"meta":{}}}`)) + } + })) + t.Cleanup(fg.srv.Close) + + return fg +} + +func (fg *fakeGremlin) frames() int { + fg.mu.Lock() + defer fg.mu.Unlock() + + return len(fg.got) +} + +func wsFreePort(t *testing.T) int { + t.Helper() + + ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + port := ln.Addr().(*net.TCPAddr).Port //nolint:forcetypeassert // tcp listener + require.NoError(t, ln.Close()) + + return port +} + +func wsEval(script string) []byte { + b, _ := json.Marshal(map[string]any{ + "requestId": wsTestReqID, "op": "eval", "processor": "", "args": map[string]any{"gremlin": script}, + }) + + return b +} + +func gbStr(s string) []byte { + return append(binary.BigEndian.AppendUint32(nil, uint32(len(s))), s...) +} + +func gbBytecodeFrame(steps ...string) []byte { + var bc []byte + + bc = binary.BigEndian.AppendUint32(bc, uint32(len(steps))) + for _, s := range steps { + bc = append(bc, gbStr(s)...) + bc = binary.BigEndian.AppendUint32(bc, 0) + } + + bc = binary.BigEndian.AppendUint32(bc, 0) + + body := []byte{0x81} + body = append(body, bytes.Repeat([]byte{0x11}, 16)...) + body = append(body, gbStr("bytecode")...) + body = append(body, gbStr("traversal")...) + body = binary.BigEndian.AppendUint32(body, 2) + body = append(body, 0x03, 0) + body = append(body, gbStr("gremlin")...) + body = append(body, 0x15, 0) + body = append(body, bc...) + body = append(body, 0x03, 0) + body = append(body, gbStr("aliases")...) + body = append(body, 0x0a, 0) + body = binary.BigEndian.AppendUint32(body, 0) + + return append(append([]byte{byte(len(wsTestMime))}, wsTestMime...), body...) +} + +func TestGremlinProxyFrameAuthorization(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accessKey string + wantMsg string + payload []byte + mt int + wantCode float64 + enforce bool + wantForward bool + }{ + { + name: "read_forwarded", accessKey: "AKIAREAD", mt: websocket.TextMessage, + payload: wsEval("g.V().count()"), enforce: true, wantForward: true, wantCode: 200, wantMsg: "upstream", + }, + { + name: "write_denied", accessKey: "AKIAREAD", mt: websocket.TextMessage, + payload: wsEval("g.addV('person')"), enforce: true, wantCode: 403, wantMsg: actionWrite, + }, + { + name: "write_allowed", accessKey: "AKIAWRITE", mt: websocket.TextMessage, + payload: wsEval("g.addV('person')"), enforce: true, wantForward: true, wantCode: 200, wantMsg: "upstream", + }, + { + name: "drop_denied", accessKey: "AKIAWRITE", mt: websocket.TextMessage, + payload: wsEval("g.V().drop()"), enforce: true, wantCode: 403, wantMsg: actionDelete, + }, + { + name: "graphbinary_bytecode_write_denied", accessKey: "AKIAREAD", mt: websocket.BinaryMessage, + payload: gbBytecodeFrame("V", "addV"), enforce: true, wantCode: 403, wantMsg: actionWrite, + }, + { + name: "graphbinary_bytecode_read_forwarded", accessKey: "AKIAREAD", mt: websocket.BinaryMessage, + payload: gbBytecodeFrame( + "V", + "count", + ), enforce: true, wantForward: true, wantCode: 200, wantMsg: "upstream", + }, + { + name: "unparseable_denied", accessKey: "AKIAWRITE", mt: websocket.TextMessage, + payload: []byte(`{"requestId":"` + wsTestReqID + `","op":`), enforce: true, + wantCode: 403, wantMsg: "could not be inspected", + }, + { + name: "unknown_op_denied", accessKey: "AKIAWRITE", mt: websocket.TextMessage, + payload: []byte(`{"requestId":"` + wsTestReqID + `","op":"nuke","args":{}}`), enforce: true, + wantCode: 403, wantMsg: "could not be inspected", + }, + { + name: "enforcement_off_passthrough", accessKey: "AKIAREAD", mt: websocket.TextMessage, + payload: wsEval("g.V().drop()"), enforce: false, wantForward: true, wantCode: 200, wantMsg: "upstream", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fg := newFakeGremlin(t) + port := wsFreePort(t) + auth := func() clusterAuth { + return clusterAuth{ + enabled: true, region: wsTestRegion, + resourceARN: "arn:aws:neptune-db:us-east-1:000000000000:cluster-X/*", + cfg: IAMAuthConfig{Identities: wsIdentities{}, Enforce: tt.enforce}, + } + } + + f, err := startCypherFront("127.0.0.1", port, strings.TrimPrefix(fg.srv.URL, "http://"), nil, nil, auth) + require.NoError(t, err) + t.Cleanup(f.Close) + f.up.Store(true) + + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+addr+"/gremlin", nil) + require.NoError(t, err) + + sum := sha256.Sum256(nil) + require.NoError(t, v4.NewSigner().SignHTTP(t.Context(), + aws.Credentials{AccessKeyID: tt.accessKey, SecretAccessKey: wsTestSecret}, req, + hex.EncodeToString(sum[:]), "neptune-db", wsTestRegion, time.Now())) + + hdr := http.Header{} + hdr.Set("Authorization", req.Header.Get("Authorization")) + hdr.Set("X-Amz-Date", req.Header.Get("X-Amz-Date")) + + conn, resp, err := websocket.DefaultDialer.DialContext(t.Context(), "ws://"+addr+"/gremlin", hdr) + require.NoError(t, err) + + _ = resp.Body.Close() + + defer func() { _ = conn.Close() }() + + require.NoError(t, conn.WriteMessage(tt.mt, tt.payload)) + require.NoError(t, conn.SetReadDeadline(time.Now().Add(10*time.Second))) + + _, raw, err := conn.ReadMessage() + require.NoError(t, err) + + var out struct { + RequestID string `json:"requestId"` + Status struct { + Message string `json:"message"` + Code float64 `json:"code"` + } `json:"status"` + } + + if tt.mt == websocket.BinaryMessage && !tt.wantForward { + assert.Contains(t, string(raw), "AccessDeniedException") + assert.Contains(t, string(raw), tt.wantMsg) + assert.Zero(t, fg.frames()) + + return + } + + require.NoError(t, json.Unmarshal(raw, &out), string(raw)) + assert.InDelta(t, tt.wantCode, out.Status.Code, 0) + assert.Contains(t, out.Status.Message, tt.wantMsg) + + if tt.wantForward { + assert.Equal(t, 1, fg.frames()) + + return + } + + assert.Zero(t, fg.frames()) + assert.Contains(t, out.Status.Message, "AccessDeniedException") + + if !strings.Contains(tt.name, "unparseable") { + assert.Equal(t, wsTestReqID, out.RequestID) + } + }) + } +} diff --git a/services/neptune/handler_sdk_roundtrip_test.go b/services/neptune/handler_sdk_roundtrip_test.go index 4149844735..e8a1c94d73 100644 --- a/services/neptune/handler_sdk_roundtrip_test.go +++ b/services/neptune/handler_sdk_roundtrip_test.go @@ -1,11 +1,9 @@ package neptune_test import ( - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" neptunesdk "github.com/aws/aws-sdk-go-v2/service/neptune" "github.com/aws/aws-sdk-go-v2/service/neptune/types" @@ -13,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/neptune" ) @@ -34,20 +33,11 @@ func newTestNeptuneClient(t *testing.T, h *neptune.Handler) *neptunesdk.Client { require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(testRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return neptunesdk.NewFromConfig(cfg, func(o *neptunesdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + return neptunesdk.NewFromConfig(aws.Config{ + Region: testRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/neptune/iam_auth.go b/services/neptune/iam_auth.go new file mode 100644 index 0000000000..519d2bddfa --- /dev/null +++ b/services/neptune/iam_auth.go @@ -0,0 +1,445 @@ +package neptune + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/bolt" + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/services/iam" + "github.com/blackbirdworks/gopherstack/services/neptune/gremlin" + "github.com/blackbirdworks/gopherstack/services/neptune/opencypher" + "github.com/blackbirdworks/gopherstack/services/sts" +) + +const ( + iamSigningService = "neptune-db" + iamMaxSkew = 5 * time.Minute + iamArnParts = 6 + iamAmzDateLayout = "20060102T150405Z" + boltTokenPath = "/opencypher" + + actionRead = "neptune-db:ReadDataViaQuery" + actionWrite = "neptune-db:WriteDataViaQuery" + actionDelete = "neptune-db:DeleteDataViaQuery" + actionStatus = "neptune-db:GetQueryStatus" + actionCancel = "neptune-db:CancelQuery" + + codeAccessDenied = "AccessDeniedException" + langGremlin = "Gremlin" + langOpenCypher = "OpenCypher" + condQueryLang = "neptune-db:querylanguage" +) + +var ( + errIAMNoCredentials = errors.New("missing authentication token") + errIAMBadService = errors.New("credential should be scoped to the neptune-db service") + errIAMBadRegion = errors.New("credential should be scoped to the cluster's region") + errIAMSkew = errors.New("signature expired: the request time is outside the allowed window") + errIAMUnknownKey = errors.New("the security token included in the request is invalid") + errIAMBadSignature = errors.New("the request signature does not match the signature provided") + errIAMBadBoltToken = errors.New("malformed Bolt IAM authentication token") +) + +// IAMAuthIdentityProvider is the slice of the IAM backend that data-plane request verification needs. +type IAMAuthIdentityProvider interface { + ListAllAccessKeys() []iam.AccessKey + GetUserByAccessKeyID(accessKeyID string) (*iam.User, error) + GetPoliciesForUser(userName string) ([]string, error) + GetPoliciesForRole(roleName string) ([]string, error) +} + +// IAMAuthSessionProvider resolves STS-issued temporary credentials. +type IAMAuthSessionProvider interface { + LookupSession(accessKeyID, sessionToken string) *sts.SessionInfo +} + +// IAMAuthConfig wires Neptune IAM database authentication to the credential stores. +type IAMAuthConfig struct { + Identities IAMAuthIdentityProvider + Sessions IAMAuthSessionProvider + // Enforce evaluates neptune-db: actions against the caller's policies; off, any validly signed caller is admitted. + Enforce bool +} + +// IAMAuthResolver returns the current IAM wiring; it is called per request so IAM may be created later. +type IAMAuthResolver func() IAMAuthConfig + +// SetIAMAuth registers the resolver consulted for data-plane requests to clusters with IAM authentication enabled. +func (b *InMemoryBackend) SetIAMAuth(r IAMAuthResolver) { + b.mu.Lock("SetIAMAuth") + defer b.mu.Unlock() + + b.iamAuth = r +} + +// clusterAuth is a cluster's data-plane auth state, re-read on every request. +type clusterAuth struct { + cfg IAMAuthConfig + resourceARN string + region string + enabled bool +} + +// clusterAuthFn returns the live auth state of the cluster behind lb. +func (b *InMemoryBackend) clusterAuthFn(lb *liveCluster) func() clusterAuth { + return func() clusterAuth { + b.mu.RLock("neptuneClusterAuth") + defer b.mu.RUnlock() + + c, ok := b.clusterGet(lb.region, lb.id) + if !ok || !c.EnableIAMDatabaseAuthentication { + return clusterAuth{} + } + + cfg := IAMAuthConfig{} + if b.iamAuth != nil { + cfg = b.iamAuth() + } + + return clusterAuth{ + enabled: true, cfg: cfg, region: lb.region, + resourceARN: arn.Build("neptune-db", lb.region, b.accountID, c.DBClusterResourceID+"/*"), + } + } +} + +type iamPrincipal struct { + arn string + account string + boundary string + secret string + policies []string +} + +// iamCaller is an authenticated data-plane caller. +type iamCaller struct { + pr *iamPrincipal + resource string + region string + sourceIP string + enforce bool +} + +type callerKey struct{} + +func callerFrom(ctx context.Context) *iamCaller { + c, _ := ctx.Value(callerKey{}).(*iamCaller) + + return c +} + +func (c *iamCaller) allows(action, language string) bool { + if !c.enforce { + return true + } + + cc := iam.ConditionContext{ + PrincipalARN: c.pr.arn, PrincipalAccount: c.pr.account, RequestedRegion: c.region, + SourceIP: c.sourceIP, SecureTransport: formTrue, + Extra: map[string]string{condQueryLang: language}, + } + + if iam.EvaluatePolicies(c.pr.policies, action, c.resource, cc) != iam.EvalAllow { + return false + } + + return c.pr.boundary == "" || iam.EvaluatePolicies([]string{c.pr.boundary}, action, c.resource, cc) == iam.EvalAllow +} + +// denied returns the first action the caller lacks, or "". +func (c *iamCaller) denied(language string, actions ...string) string { + for _, a := range actions { + if !c.allows(a, language) { + return a + } + } + + return "" +} + +func queryActions(write, del bool) []string { + out := []string{actionRead} + + if write { + out = append(out, actionWrite) + } + + if del { + out = append(out, actionDelete) + } + + return out +} + +func accessDeniedDetail(action string) string { + return "Access denied: the caller is not authorized to perform " + action +} + +func (c IAMAuthConfig) principalFor(accessKeyID, sessionToken string) (*iamPrincipal, error) { + if c.Sessions != nil && sessionToken != "" { + if s := c.Sessions.LookupSession(accessKeyID, sessionToken); s != nil { + return c.sessionPrincipal(s), nil + } + } + + if c.Identities == nil { + return nil, errIAMUnknownKey + } + + for _, ak := range c.Identities.ListAllAccessKeys() { + if ak.AccessKeyID != accessKeyID || (ak.Status != "" && ak.Status != "Active") { + continue + } + + user, err := c.Identities.GetUserByAccessKeyID(ak.AccessKeyID) + if err != nil { + return nil, errIAMUnknownKey + } + + docs, _ := c.Identities.GetPoliciesForUser(user.UserName) + p := &iamPrincipal{arn: user.Arn, secret: ak.SecretAccessKey, policies: docs} + + if pb, ok := c.Identities.(interface{ PermissionsBoundaryDocForUser(string) string }); ok { + p.boundary = pb.PermissionsBoundaryDocForUser(user.UserName) + } + + return p, nil + } + + return nil, errIAMUnknownKey +} + +func (c IAMAuthConfig) sessionPrincipal(s *sts.SessionInfo) *iamPrincipal { + p := &iamPrincipal{arn: s.AssumedRoleArn, account: s.AccountID, secret: s.SecretAccessKey} + if c.Identities == nil { + return p + } + + resource := "" + if parts := strings.SplitN(s.AssumedRoleArn, ":", iamArnParts); len(parts) == iamArnParts { + resource = parts[iamArnParts-1] + } + + switch { + case s.IsAssumedRole && strings.HasPrefix(resource, "assumed-role/"): + role, _, _ := strings.Cut(strings.TrimPrefix(resource, "assumed-role/"), "/") + p.policies, _ = c.Identities.GetPoliciesForRole(role) + case strings.HasPrefix(resource, "user/"): + p.policies, _ = c.Identities.GetPoliciesForUser(strings.TrimPrefix(resource, "user/")) + } + + return p +} + +// authenticate verifies r's SigV4 signature (service neptune-db) and resolves the caller. +func (ca clusterAuth) authenticate(r *http.Request, now time.Time) (*iamCaller, error) { + if httputils.HeaderValue(r.Header, "Authorization") == "" { + return nil, errIAMNoCredentials + } + + accessKey, region, service := httputils.SigV4RequestFields(r, "") + if accessKey == "" { + return nil, errIAMNoCredentials + } + + if service != iamSigningService { + return nil, errIAMBadService + } + + if region != ca.region { + return nil, errIAMBadRegion + } + + signedAt, err := time.Parse(iamAmzDateLayout, httputils.HeaderValue(r.Header, "X-Amz-Date")) + if err != nil || signedAt.Before(now.Add(-iamMaxSkew)) || signedAt.After(now.Add(iamMaxSkew)) { + return nil, errIAMSkew + } + + pr, err := ca.cfg.principalFor(accessKey, httputils.HeaderValue(r.Header, "X-Amz-Security-Token")) + if err != nil { + return nil, err + } + + if verr := httputils.NewSigV4Validator(pr.secret).Verify(r); verr != nil { + return nil, errIAMBadSignature + } + + return &iamCaller{ + pr: pr, resource: ca.resourceARN, region: ca.region, sourceIP: remoteIP(r.RemoteAddr), enforce: ca.cfg.Enforce, + }, nil +} + +// authenticateBolt verifies the signed-request JSON a Neptune Bolt client sends as the basic-auth password. +func (ca clusterAuth) authenticateBolt(auth map[string]any, now time.Time) (*iamCaller, error) { + scheme, _ := auth["scheme"].(string) + raw, _ := auth["credentials"].(string) + + if scheme != "basic" || raw == "" { + return nil, errIAMNoCredentials + } + + var tok map[string]string + if err := json.Unmarshal([]byte(raw), &tok); err != nil { + return nil, errIAMBadBoltToken + } + + if tok["Authorization"] == "" || tok["Host"] == "" || tok["X-Amz-Date"] == "" { + return nil, errIAMBadBoltToken + } + + method := tok["HttpMethod"] + if method == "" { + method = http.MethodGet + } + + req, err := http.NewRequestWithContext(context.Background(), method, "http://"+tok["Host"]+boltTokenPath, nil) + if err != nil { + return nil, errIAMBadBoltToken + } + + req.Host = tok["Host"] + req.Header.Set("Authorization", tok["Authorization"]) + req.Header.Set("X-Amz-Date", tok["X-Amz-Date"]) + + if st := tok["X-Amz-Security-Token"]; st != "" { + req.Header.Set("X-Amz-Security-Token", st) + } + + return ca.authenticate(req, now) +} + +func remoteIP(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + + return host +} + +// gremlinAuthorizer vets Gremlin queries against the caller bound to the request context. +func gremlinAuthorizer(ctx context.Context, write, del bool) error { + c := callerFrom(ctx) + if c == nil { + return nil + } + + if a := c.denied(langGremlin, queryActions(write, del)...); a != "" { + return &gremlin.Error{ + Code: gremlin.CodeUnauthorized, Status: gremlin.StatusForbidden, Detail: accessDeniedDetail(a), + } + } + + return nil +} + +func cypherAccessActions(query string) []string { + a := opencypher.QueryAccess(query) + + return queryActions(a.Write, a.Delete) +} + +func boltDenied(detail string) *bolt.Error { + return &bolt.Error{Code: bolt.CodeForbidden, Message: "AccessDeniedException: " + detail} +} + +// authenticate admits r when its cluster has no IAM authentication or it carries a valid neptune-db signature; the +// returned request carries the caller. A false result means the 403 was already written. +func (f *cypherFront) authenticate(w http.ResponseWriter, r *http.Request) (*http.Request, bool) { + if f.auth == nil { + return r, true + } + + ca := f.auth() + if !ca.enabled { + return r, true + } + + caller, err := ca.authenticate(r, time.Now()) + if err != nil { + writeCypherError(w, http.StatusForbidden, codeAccessDenied, err.Error()) + + return r, false + } + + return r.WithContext(context.WithValue(r.Context(), callerKey{}, caller)), true +} + +// authorizeActions requires every action for the authenticated caller; true when there is no caller to vet. +func authorizeActions(w http.ResponseWriter, r *http.Request, language string, actions ...string) bool { + c := callerFrom(r.Context()) + if c == nil { + return true + } + + if a := c.denied(language, actions...); a != "" { + writeCypherError(w, http.StatusForbidden, codeAccessDenied, accessDeniedDetail(a)) + + return false + } + + return true +} + +// authorizeStatus gates the query status and cancel routes. +func authorizeStatus(w http.ResponseWriter, r *http.Request) bool { + path := strings.ToLower(strings.TrimRight(r.URL.Path, "/")) + if path != cypherStatusPath && !strings.HasPrefix(path, cypherStatusPath+"/") && + path != gremlinStatusPath && !strings.HasPrefix(path, gremlinStatusPath+"/") { + return true + } + + language := langOpenCypher + if strings.HasPrefix(path, gremlinStatusPath) { + language = langGremlin + } + + if r.Method == http.MethodDelete { + return authorizeActions(w, r, language, actionCancel) + } + + return authorizeActions(w, r, language, actionStatus) +} + +// authorizeProxied vets a request forwarded to the Gremlin Server container. A WebSocket handshake needs read access +// here and its frames are authorized individually by proxyGremlinWS; +// HTTP queries are classified from their gremlin field. +func authorizeProxied(w http.ResponseWriter, r *http.Request) bool { + if callerFrom(r.Context()) == nil { + return true + } + + script := r.URL.Query().Get("gremlin") + + if r.Method == http.MethodPost && r.Body != nil { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, cypherMaxBody)) + if err != nil { + writeCypherError(w, http.StatusBadRequest, opencypher.CodeMalformedQuery, err.Error()) + + return false + } + + r.Body = io.NopCloser(bytes.NewReader(body)) + + var req struct { + Gremlin string `json:"gremlin"` + } + + _ = json.Unmarshal(body, &req) + script = cmp.Or(req.Gremlin, script) + } + + a := gremlin.ScriptAccess(script) + + return authorizeActions(w, r, langGremlin, queryActions(a.Write, a.Delete)...) +} diff --git a/services/neptune/iam_dataplane_test.go b/services/neptune/iam_dataplane_test.go new file mode 100644 index 0000000000..76c2aee13b --- /dev/null +++ b/services/neptune/iam_dataplane_test.go @@ -0,0 +1,407 @@ +package neptune_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/bolt" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + "github.com/blackbirdworks/gopherstack/services/iam" + "github.com/blackbirdworks/gopherstack/services/neptune" +) + +const ( + dpAccount = "000000000000" + dpRegion = "us-east-1" + dpSecret = "dp-secret-key-0123456789abcdefghijklmnop" +) + +var errDPNoUser = errors.New("no such user") + +func dpPolicy(actions ...string) string { + quoted := make([]string, len(actions)) + for i, a := range actions { + quoted[i] = strconv.Quote(a) + } + + return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":[` + + strings.Join(quoted, ",") + `],"Resource":"*"}]}` +} + +// dpIdentities serves the access keys AKIAFULL, AKIAREAD and AKIANONE. +type dpIdentities struct{ policies map[string][]string } + +func newDPIdentities() *dpIdentities { + return &dpIdentities{policies: map[string][]string{ + "full": {dpPolicy("neptune-db:ReadDataViaQuery", "neptune-db:WriteDataViaQuery", + "neptune-db:DeleteDataViaQuery", "neptune-db:GetQueryStatus", "neptune-db:CancelQuery")}, + "read": {dpPolicy("neptune-db:ReadDataViaQuery")}, + "none": nil, + }} +} + +func (d *dpIdentities) ListAllAccessKeys() []iam.AccessKey { + return []iam.AccessKey{ + {AccessKeyID: "AKIAFULL", SecretAccessKey: dpSecret, UserName: "full", Status: "Active"}, + {AccessKeyID: "AKIAREAD", SecretAccessKey: dpSecret, UserName: "read", Status: "Active"}, + {AccessKeyID: "AKIANONE", SecretAccessKey: dpSecret, UserName: "none", Status: "Active"}, + } +} + +func (d *dpIdentities) GetUserByAccessKeyID(id string) (*iam.User, error) { + name := map[string]string{"AKIAFULL": "full", "AKIAREAD": "read", "AKIANONE": "none"}[id] + if name == "" { + return nil, errDPNoUser + } + + return &iam.User{UserName: name, Arn: "arn:aws:iam::" + dpAccount + ":user/" + name}, nil +} + +func (d *dpIdentities) GetPoliciesForUser(name string) ([]string, error) { + return d.policies[name], nil +} + +func (d *dpIdentities) GetPoliciesForRole(string) ([]string, error) { return nil, nil } + +func newIAMCluster(t *testing.T, iamEnabled, enforce bool) neptune.DBCluster { + t.Helper() + + b := neptune.NewInMemoryBackend(dpAccount, dpRegion) + b.EnableEngine(neptune.EngineConfig{InProcessGremlin: true, OpenCypher: true}) + b.SetIAMAuth(func() neptune.IAMAuthConfig { + return neptune.IAMAuthConfig{Identities: newDPIdentities(), Enforce: enforce} + }) + t.Cleanup(b.Close) + + _, err := b.CreateDBCluster(t.Context(), "c1", "", 0, neptune.DBClusterCreateOptions{ + EngineVersion: "1.3.0.0", EnableIAMDatabaseAuthentication: iamEnabled, + }) + require.NoError(t, err) + + return waitCluster(t, b, "c1", "available") +} + +func dpSign(t *testing.T, req *http.Request, body, accessKey, secret, service string) { + t.Helper() + + sum := sha256.Sum256([]byte(body)) + creds := aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secret} + + require.NoError(t, v4.NewSigner().SignHTTP(t.Context(), creds, req, hex.EncodeToString(sum[:]), + service, dpRegion, time.Now())) +} + +func dpDo(t *testing.T, c neptune.DBCluster, method, path, body, accessKey, secret, service string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, "http://"+clusterAddr(c)+path, strings.NewReader(body)) + require.NoError(t, err) + + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + + if accessKey != "" { + dpSign(t, req, body, accessKey, secret, service) + } + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(raw) +} + +func TestDataPlaneIAMHTTP(t *testing.T) { + t.Parallel() + + countQ := "/openCypher?" + url.Values{"query": {"MATCH (n) RETURN count(n) AS c"}}.Encode() + createQ := "/openCypher?" + url.Values{"query": {"CREATE (n:T) RETURN n"}}.Encode() + gremlinCount := `{"gremlin":"g.V().count()"}` + gremlinDrop := `{"gremlin":"g.V().drop()"}` + + tests := []struct { + name string + method string + path string + body string + accessKey string + secret string + service string + wantBody string + wantStatus int + iamEnabled bool + enforce bool + }{ + {name: "unsigned_denied", method: "GET", path: countQ, iamEnabled: true, enforce: true, + wantStatus: 403, wantBody: "AccessDeniedException"}, + {name: "signed_allowed", method: "GET", path: countQ, accessKey: "AKIAREAD", secret: dpSecret, + service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 200}, + {name: "signed_without_policy_denied", method: "GET", path: countQ, accessKey: "AKIANONE", secret: dpSecret, + service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 403, + wantBody: "neptune-db:ReadDataViaQuery"}, + {name: "wrong_service_denied", method: "GET", path: countQ, accessKey: "AKIAFULL", secret: dpSecret, + service: "neptune", iamEnabled: true, enforce: true, wantStatus: 403, wantBody: "AccessDeniedException"}, + {name: "wrong_secret_denied", method: "GET", path: countQ, accessKey: "AKIAFULL", secret: "wrong", + service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 403, wantBody: "signature"}, + {name: "unknown_key_denied", method: "GET", path: countQ, accessKey: "AKIAUNKNOWN", secret: dpSecret, + service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 403}, + {name: "write_needs_write_action", method: "GET", path: createQ, accessKey: "AKIAREAD", secret: dpSecret, + service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 403, + wantBody: "neptune-db:WriteDataViaQuery"}, + {name: "write_allowed_with_policy", method: "GET", path: createQ, accessKey: "AKIAFULL", secret: dpSecret, + service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 200}, + {name: "status_needs_action", method: "GET", path: "/openCypher/status", accessKey: "AKIAREAD", + secret: dpSecret, service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 403, + wantBody: "neptune-db:GetQueryStatus"}, + {name: "status_allowed", method: "GET", path: "/openCypher/status", accessKey: "AKIAFULL", + secret: dpSecret, service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 200}, + {name: "gremlin_read_allowed", method: "POST", path: "/gremlin", body: gremlinCount, accessKey: "AKIAREAD", + secret: dpSecret, service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 200}, + {name: "gremlin_drop_denied", method: "POST", path: "/gremlin", body: gremlinDrop, accessKey: "AKIAREAD", + secret: dpSecret, service: "neptune-db", iamEnabled: true, enforce: true, wantStatus: 403, + wantBody: "neptune-db:DeleteDataViaQuery"}, + {name: "gremlin_unsigned_denied", method: "POST", path: "/gremlin", body: gremlinCount, + iamEnabled: true, enforce: true, wantStatus: 403}, + {name: "enforce_off_admits_signed_caller", method: "GET", path: createQ, accessKey: "AKIANONE", + secret: dpSecret, service: "neptune-db", iamEnabled: true, enforce: false, wantStatus: 200}, + {name: "enforce_off_still_requires_signature", method: "GET", path: countQ, iamEnabled: true, + enforce: false, wantStatus: 403}, + {name: "iam_disabled_unsigned_ok", method: "GET", path: createQ, iamEnabled: false, enforce: true, + wantStatus: 200}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := newIAMCluster(t, tt.iamEnabled, tt.enforce) + code, body := dpDo(t, c, tt.method, tt.path, tt.body, tt.accessKey, tt.secret, tt.service) + + assert.Equal(t, tt.wantStatus, code, body) + assert.Contains(t, body, tt.wantBody) + }) + } +} + +func dpGremlinWS(t *testing.T, c neptune.DBCluster, accessKey, script string) (map[string]any, error) { + t.Helper() + + hdr := http.Header{} + + if accessKey != "" { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+clusterAddr(c)+"/gremlin", nil) + require.NoError(t, err) + dpSign(t, req, "", accessKey, dpSecret, "neptune-db") + + for _, k := range []string{"Authorization", "X-Amz-Date"} { + hdr.Set(k, req.Header.Get(k)) + } + } + + conn, resp, err := websocket.DefaultDialer.DialContext(t.Context(), gremlinWS(c), hdr) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + if err != nil { + return nil, err + } + + defer func() { _ = conn.Close() }() + + frame, err := json.Marshal(map[string]any{ + "requestId": "7f1a8f4c-0b0e-4a8a-9d3a-1c2d3e4f5a6b", "op": "eval", "processor": "", + "args": map[string]any{"gremlin": script}, + }) + require.NoError(t, err) + require.NoError(t, conn.WriteMessage(websocket.TextMessage, frame)) + + _, raw, err := conn.ReadMessage() + require.NoError(t, err) + + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + + return out, nil +} + +func TestDataPlaneIAMGremlinWebSocket(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accessKey string + script string + wantDialOK bool + wantCode float64 + }{ + {name: "unsigned_handshake_rejected", script: "g.V().count()"}, + {name: "signed_read_allowed", accessKey: "AKIAREAD", script: "g.V().count()", wantDialOK: true, wantCode: 200}, + {name: "signed_drop_denied", accessKey: "AKIAREAD", script: "g.V().drop()", wantDialOK: true, wantCode: 403}, + {name: "signed_drop_allowed", accessKey: "AKIAFULL", script: "g.V().drop()", wantDialOK: true, wantCode: 204}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := newIAMCluster(t, true, true) + out, err := dpGremlinWS(t, c, tt.accessKey, tt.script) + + if !tt.wantDialOK { + require.Error(t, err) + + return + } + + require.NoError(t, err) + + status, ok := out["status"].(map[string]any) + require.True(t, ok, out) + assert.InDelta(t, tt.wantCode, status["code"], 0) + }) + } +} + +func dpBoltToken(t *testing.T, c neptune.DBCluster, accessKey, secret, service string) string { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+clusterAddr(c)+"/opencypher", nil) + require.NoError(t, err) + dpSign(t, req, "", accessKey, secret, service) + + raw, err := json.Marshal(map[string]string{ + "Authorization": req.Header.Get("Authorization"), "HttpMethod": http.MethodGet, + "X-Amz-Date": req.Header.Get("X-Amz-Date"), "Host": req.Host, + }) + require.NoError(t, err) + + return string(raw) +} + +func dpBoltConnect(t *testing.T, c neptune.DBCluster, creds string) (*bolt.Client, bolt.Response) { + t.Helper() + + nc, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", net.JoinHostPort(c.Endpoint, strconv.Itoa(c.Port))) + require.NoError(t, err) + t.Cleanup(func() { _ = nc.Close() }) + + cl, err := bolt.Dial(nc, bolt.Version{Major: 4, Minor: 4}) + require.NoError(t, err) + + auth := map[string]any{"user_agent": "t", "scheme": "basic", "principal": "username", "credentials": creds} + r, err := cl.Call(bolt.TagHello, auth) + require.NoError(t, err) + + return cl, r +} + +func TestDataPlaneIAMBolt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token func(t *testing.T, c neptune.DBCluster) string + query string + wantAuth bool + wantRun bool + }{ + {name: "signed_read", wantAuth: true, wantRun: true, query: "MATCH (n) RETURN count(n)", + token: func(t *testing.T, c neptune.DBCluster) string { + t.Helper() + + return dpBoltToken(t, c, "AKIAREAD", dpSecret, "neptune-db") + }}, + {name: "signed_write_denied", wantAuth: true, query: "CREATE (n:T) RETURN n", + token: func(t *testing.T, c neptune.DBCluster) string { + t.Helper() + + return dpBoltToken(t, c, "AKIAREAD", dpSecret, "neptune-db") + }}, + {name: "signed_write_allowed", wantAuth: true, wantRun: true, query: "CREATE (n:T) RETURN n", + token: func(t *testing.T, c neptune.DBCluster) string { + t.Helper() + + return dpBoltToken(t, c, "AKIAFULL", dpSecret, "neptune-db") + }}, + {name: "wrong_service_rejected", query: "RETURN 1", + token: func(t *testing.T, c neptune.DBCluster) string { + t.Helper() + + return dpBoltToken(t, c, "AKIAFULL", dpSecret, "neptune") + }}, + {name: "wrong_secret_rejected", query: "RETURN 1", + token: func(t *testing.T, c neptune.DBCluster) string { + t.Helper() + + return dpBoltToken(t, c, "AKIAFULL", "wrong", "neptune-db") + }}, + {name: "bogus_token_rejected", query: "RETURN 1", + token: func(*testing.T, neptune.DBCluster) string { return "not-json" }}, + {name: "empty_password_rejected", query: "RETURN 1", + token: func(*testing.T, neptune.DBCluster) string { return "" }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := newIAMCluster(t, true, true) + cl, r := dpBoltConnect(t, c, tt.token(t, c)) + + if !tt.wantAuth { + assert.Equal(t, bolt.TagFailure, r.Tag) + assert.Equal(t, bolt.CodeUnauthorized, r.Meta()["code"]) + + return + } + + require.Equal(t, bolt.TagSuccess, r.Tag, r.Meta()) + + _, sum, err := cl.Query(tt.query, nil) + require.NoError(t, err) + + if tt.wantRun { + assert.Equal(t, bolt.TagSuccess, sum.Tag, sum.Meta()) + + return + } + + assert.Equal(t, bolt.TagFailure, sum.Tag) + assert.Equal(t, bolt.CodeForbidden, sum.Meta()["code"]) + assert.Contains(t, sum.Meta()["message"], "neptune-db:WriteDataViaQuery") + }) + } +} + +func TestDataPlaneIAMBoltDisabledAcceptsNone(t *testing.T) { + t.Parallel() + + c := newIAMCluster(t, false, true) + cl := boltDial(t, c, bolt.Version{Major: 4, Minor: 4}) + + _, sum, err := cl.Query("RETURN 1", nil) + require.NoError(t, err) + assert.Equal(t, bolt.TagSuccess, sum.Tag) +} diff --git a/services/neptune/iam_enforcement_test.go b/services/neptune/iam_enforcement_test.go index 16cd721158..1599ded8bb 100644 --- a/services/neptune/iam_enforcement_test.go +++ b/services/neptune/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/neptune" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/rds/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/neptune/lifecycle_sdk_test.go b/services/neptune/lifecycle_sdk_test.go index 17a1b04241..5b941628dd 100644 --- a/services/neptune/lifecycle_sdk_test.go +++ b/services/neptune/lifecycle_sdk_test.go @@ -2,6 +2,7 @@ package neptune_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -29,42 +30,44 @@ func TestSDK_CreateReportsCreatingUntilDelay(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - backend := neptune.NewInMemoryBackend("123456789012", "us-east-1") - backend.SetLifecycleDelay(tt.delay) - client := newTestNeptuneClient(t, neptune.NewHandler(backend)) - ctx := t.Context() + synctest.Test(t, func(t *testing.T) { + backend := neptune.NewInMemoryBackend("123456789012", "us-east-1") + backend.SetLifecycleDelay(tt.delay) + client := newTestNeptuneClient(t, neptune.NewHandler(backend)) + ctx := t.Context() - cl, err := client.CreateDBCluster(ctx, &neptunesdk.CreateDBClusterInput{ - DBClusterIdentifier: aws.String("c-1"), Engine: aws.String("neptune"), - }) - require.NoError(t, err) - assert.Equal(t, tt.wantFirst, aws.ToString(cl.DBCluster.Status)) + cl, err := client.CreateDBCluster(ctx, &neptunesdk.CreateDBClusterInput{ + DBClusterIdentifier: aws.String("c-1"), Engine: aws.String("neptune"), + }) + require.NoError(t, err) + assert.Equal(t, tt.wantFirst, aws.ToString(cl.DBCluster.Status)) - inst, err := client.CreateDBInstance(ctx, &neptunesdk.CreateDBInstanceInput{ - DBInstanceIdentifier: aws.String("i-1"), DBInstanceClass: aws.String("db.r5.large"), - Engine: aws.String("neptune"), DBClusterIdentifier: aws.String("c-1"), - }) - require.NoError(t, err) - assert.Equal(t, tt.wantFirst, aws.ToString(inst.DBInstance.DBInstanceStatus)) + inst, err := client.CreateDBInstance(ctx, &neptunesdk.CreateDBInstanceInput{ + DBInstanceIdentifier: aws.String("i-1"), DBInstanceClass: aws.String("db.r5.large"), + Engine: aws.String("neptune"), DBClusterIdentifier: aws.String("c-1"), + }) + require.NoError(t, err) + assert.Equal(t, tt.wantFirst, aws.ToString(inst.DBInstance.DBInstanceStatus)) - waiter := neptunesdk.NewDBInstanceAvailableWaiter( - client, - func(o *neptunesdk.DBInstanceAvailableWaiterOptions) { - o.MinDelay = 20 * time.Millisecond - o.MaxDelay = 50 * time.Millisecond - }, - ) - require.NoError(t, waiter.Wait(ctx, &neptunesdk.DescribeDBInstancesInput{ - DBInstanceIdentifier: aws.String("i-1"), - }, 10*time.Second)) + waiter := neptunesdk.NewDBInstanceAvailableWaiter( + client, + func(o *neptunesdk.DBInstanceAvailableWaiterOptions) { + o.MinDelay = 20 * time.Millisecond + o.MaxDelay = 50 * time.Millisecond + }, + ) + require.NoError(t, waiter.Wait(ctx, &neptunesdk.DescribeDBInstancesInput{ + DBInstanceIdentifier: aws.String("i-1"), + }, 10*time.Second)) - require.Eventually(t, func() bool { - out, derr := client.DescribeDBClusters(ctx, &neptunesdk.DescribeDBClustersInput{ - DBClusterIdentifier: aws.String("c-1"), - }) + require.Eventually(t, func() bool { + out, derr := client.DescribeDBClusters(ctx, &neptunesdk.DescribeDBClustersInput{ + DBClusterIdentifier: aws.String("c-1"), + }) - return derr == nil && aws.ToString(out.DBClusters[0].Status) == "available" - }, 5*time.Second, 20*time.Millisecond) + return derr == nil && aws.ToString(out.DBClusters[0].Status) == "available" + }, 5*time.Second, 20*time.Millisecond) + }) }) } } diff --git a/services/neptune/opencypher/query_access.go b/services/neptune/opencypher/query_access.go new file mode 100644 index 0000000000..47b446b94a --- /dev/null +++ b/services/neptune/opencypher/query_access.go @@ -0,0 +1,30 @@ +package opencypher + +// Access is the extra permission a query needs beyond read; every query reads. +type Access struct { + Write bool + Delete bool +} + +// QueryAccess classifies query by its clauses; an unparsable query needs only read (it fails when run). +func QueryAccess(query string) Access { + q, err := parse(query) + if q == nil || err != nil { + return Access{} + } + + var a Access + + for _, c := range q.clauses { + switch c.(type) { + case createClause, mergeClause: + a.Write = true + case setClause: + a.Write, a.Delete = true, true + case removeClause, deleteClause: + a.Delete = true + } + } + + return a +} diff --git a/services/neptune/opencypher/query_access_test.go b/services/neptune/opencypher/query_access_test.go new file mode 100644 index 0000000000..a895aea270 --- /dev/null +++ b/services/neptune/opencypher/query_access_test.go @@ -0,0 +1,34 @@ +package opencypher_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/blackbirdworks/gopherstack/services/neptune/opencypher" +) + +func TestQueryAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + want opencypher.Access + }{ + {"read", "MATCH (n) RETURN n", opencypher.Access{}}, + {"create", "CREATE (n:T) RETURN n", opencypher.Access{Write: true}}, + {"merge", "MERGE (n:T {a:1}) RETURN n", opencypher.Access{Write: true}}, + {"set", "MATCH (n) SET n.a = 1 RETURN n", opencypher.Access{Write: true, Delete: true}}, + {"detach_delete", "MATCH (n) DETACH DELETE n", opencypher.Access{Delete: true}}, + {"unparsable", "NOT CYPHER", opencypher.Access{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, opencypher.QueryAccess(tt.query)) + }) + } +} diff --git a/services/neptune/opencypher_front.go b/services/neptune/opencypher_front.go index d73426c41e..1a864f5095 100644 --- a/services/neptune/opencypher_front.go +++ b/services/neptune/opencypher_front.go @@ -37,6 +37,7 @@ const ( // cypherFront serves a cluster's Neptune port: /openCypher, Bolt and (with no upstream) /gremlin are answered // in-process over one graph; with an upstream, /gremlin is proxied to the Gremlin Server container instead. type cypherFront struct { + ctx context.Context srv *http.Server proxy *httputil.ReverseProxy tr *http.Transport @@ -44,14 +45,16 @@ type cypherFront struct { queries *queryTracker gremlin *gremlin.Server gremlinQueries *queryTracker - ctx context.Context //nolint:containedctx // lifetime of the listener + auth func() clusterAuth cancel context.CancelFunc + upstream string up atomic.Bool } // startCypherFront listens on port. An empty innerAddr serves Gremlin in-process over graph. func startCypherFront( host string, port int, innerAddr string, graph *opencypher.Graph, admit sgfilter.Admitter, + auth func() clusterAuth, ) (*cypherFront, error) { ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", net.JoinHostPort(container.BindHostFor(host), strconv.Itoa(port))) @@ -63,13 +66,16 @@ func startCypherFront( graph = opencypher.NewGraph() } - f := &cypherFront{graph: graph, queries: newQueryTracker(), tr: httpclient.NewTransport()} + f := &cypherFront{auth: auth, graph: graph, queries: newQueryTracker(), tr: httpclient.NewTransport()} f.ctx, f.cancel = context.WithCancel(context.Background()) if innerAddr == "" { f.gremlinQueries = newQueryTracker() - f.gremlin = gremlin.NewServer(f.ctx, graph, gremlin.Options{Track: f.gremlinQueries.begin}) + f.gremlin = gremlin.NewServer(f.ctx, graph, gremlin.Options{ + Track: f.gremlinQueries.begin, Authorize: gremlinAuthorizer, + }) } else { + f.upstream = innerAddr f.proxy = httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: innerAddr}) f.proxy.Transport = f.tr } @@ -101,12 +107,21 @@ func (f *cypherFront) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + r, ok := f.authenticate(w, r) + if !ok { + return + } + if strings.EqualFold(strings.TrimRight(r.URL.Path, "/"), cypherPath) { f.serveOpenCypher(w, r) return } + if !authorizeStatus(w, r) { + return + } + if f.serveQueryStatus(w, r) { return } @@ -123,6 +138,16 @@ func (f *cypherFront) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if !authorizeProxied(w, r) { + return + } + + if enforcesFrames(r) { + f.proxyGremlinWS(w, r) + + return + } + f.proxy.ServeHTTP(w, r) } @@ -228,6 +253,10 @@ func (f *cypherFront) serveOpenCypher(w http.ResponseWriter, r *http.Request) { return } + if !authorizeActions(w, r, langOpenCypher, cypherAccessActions(call.query)...) { + return + } + params, err := opencypher.ParseParameters(call.params) if err != nil { writeCypherFailure(r.Context(), w, err) @@ -329,7 +358,7 @@ func (f *cypherFront) serveBolt(c net.Conn) { } bolt.Serve(f.ctx, c, bolt.Config{ - Backend: &boltBackend{graph: f.graph, queries: f.queries}, + Backend: &boltBackend{graph: f.graph, queries: f.queries, auth: f.auth}, Agent: boltAgent, Database: boltDatabase, }) } diff --git a/services/neptune/opencypher_front_test.go b/services/neptune/opencypher_front_test.go index 35df00e3b4..2574392dcc 100644 --- a/services/neptune/opencypher_front_test.go +++ b/services/neptune/opencypher_front_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/neptune" ) @@ -62,7 +63,7 @@ func cypherDo(t *testing.T, method, target, contentType, body string) (int, map[ req.Header.Set("Content-Type", contentType) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() @@ -162,5 +163,5 @@ func cypherGet(t *testing.T, target string) (*http.Response, error) { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) require.NoError(t, err) - return http.DefaultClient.Do(req) + return testclient.New(t).Do(req) } diff --git a/services/neptune/store.go b/services/neptune/store.go index 414f22d375..e083a4008b 100644 --- a/services/neptune/store.go +++ b/services/neptune/store.go @@ -172,6 +172,7 @@ type InMemoryBackend struct { pendingMaintenanceActions map[string]map[string]PendingMaintenanceAction registry *store.Registry engine *dbEngine + iamAuth IAMAuthResolver graphs map[string]*opencypher.Graph mu *lockmetrics.RWMutex accountID string diff --git a/services/networkfirewall/container_test.go b/services/networkfirewall/container_test.go index 9415e90ad0..b3dbcd27c8 100644 --- a/services/networkfirewall/container_test.go +++ b/services/networkfirewall/container_test.go @@ -2,6 +2,7 @@ package networkfirewall_test import ( "testing" + "testing/synctest" "github.com/aws/aws-sdk-go-v2/aws" sdk "github.com/aws/aws-sdk-go-v2/service/networkfirewall" @@ -125,7 +126,10 @@ func TestContainerAssociations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tt.run(t, newFixture(t)) + + synctest.Test(t, func(t *testing.T) { + tt.run(t, newFixture(t)) + }) }) } } diff --git a/services/networkfirewall/endpoints_test.go b/services/networkfirewall/endpoints_test.go index 3af6d171f5..99d5a3a161 100644 --- a/services/networkfirewall/endpoints_test.go +++ b/services/networkfirewall/endpoints_test.go @@ -2,6 +2,7 @@ package networkfirewall_test import ( "testing" + "testing/synctest" "github.com/aws/aws-sdk-go-v2/aws" sdk "github.com/aws/aws-sdk-go-v2/service/networkfirewall" @@ -141,7 +142,10 @@ func TestVpcEndpointAssociations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tt.run(t, newFixture(t)) + + synctest.Test(t, func(t *testing.T) { + tt.run(t, newFixture(t)) + }) }) } } @@ -287,10 +291,12 @@ func TestFlowOperations(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - f := newFixture(t) - pol := f.policy(t, "pol") - fw := f.firewall(t, "fw", aws.ToString(pol.FirewallPolicyResponse.FirewallPolicyArn)) - tt.run(t, f, fw.Firewall.FirewallArn) + synctest.Test(t, func(t *testing.T) { + f := newFixture(t) + pol := f.policy(t, "pol") + fw := f.firewall(t, "fw", aws.ToString(pol.FirewallPolicyResponse.FirewallPolicyArn)) + tt.run(t, f, fw.Firewall.FirewallArn) + }) }) } } @@ -371,7 +377,10 @@ func TestAnalysisReports(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tt.run(t, newFixture(t)) + + synctest.Test(t, func(t *testing.T) { + tt.run(t, newFixture(t)) + }) }) } } diff --git a/services/networkfirewall/firewall_test.go b/services/networkfirewall/firewall_test.go index 464490cc30..792a511dcd 100644 --- a/services/networkfirewall/firewall_test.go +++ b/services/networkfirewall/firewall_test.go @@ -2,6 +2,7 @@ package networkfirewall_test import ( "testing" + "testing/synctest" "github.com/aws/aws-sdk-go-v2/aws" sdk "github.com/aws/aws-sdk-go-v2/service/networkfirewall" @@ -304,7 +305,10 @@ func TestFirewall_Lifecycle(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tt.run(t, newFixture(t)) + + synctest.Test(t, func(t *testing.T) { + tt.run(t, newFixture(t)) + }) }) } } diff --git a/services/networkfirewall/fixture_test.go b/services/networkfirewall/fixture_test.go index c2b3a64532..2e32bd0dce 100644 --- a/services/networkfirewall/fixture_test.go +++ b/services/networkfirewall/fixture_test.go @@ -1,12 +1,10 @@ package networkfirewall_test import ( - "net/http/httptest" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" sdk "github.com/aws/aws-sdk-go-v2/service/networkfirewall" "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" @@ -15,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" nf "github.com/blackbirdworks/gopherstack/services/networkfirewall" ) @@ -62,18 +61,14 @@ func newClient(t *testing.T, h *nf.Handler, region string) *sdk.Client { require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) + cfg := aws.Config{ + Region: region, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), + } - url := srv.URL - - cfg, err := awscfg.LoadDefaultConfig(t.Context(), - awscfg.WithRegion(region), - awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), - ) - require.NoError(t, err) - - return sdk.NewFromConfig(cfg, func(o *sdk.Options) { o.BaseEndpoint = aws.String(url) }) + return sdk.NewFromConfig(cfg) } func errCode(t *testing.T, err error) string { diff --git a/services/networkfirewall/groups_test.go b/services/networkfirewall/groups_test.go index fea669e4f9..906b540d83 100644 --- a/services/networkfirewall/groups_test.go +++ b/services/networkfirewall/groups_test.go @@ -2,6 +2,7 @@ package networkfirewall_test import ( "testing" + "testing/synctest" "github.com/aws/aws-sdk-go-v2/aws" sdk "github.com/aws/aws-sdk-go-v2/service/networkfirewall" @@ -244,7 +245,10 @@ func TestRuleGroupAndPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tt.run(t, newFixture(t)) + + synctest.Test(t, func(t *testing.T) { + tt.run(t, newFixture(t)) + }) }) } } diff --git a/services/networkfirewall/proxy_test.go b/services/networkfirewall/proxy_test.go index 735ef3ac31..448b2e5d02 100644 --- a/services/networkfirewall/proxy_test.go +++ b/services/networkfirewall/proxy_test.go @@ -2,6 +2,7 @@ package networkfirewall_test import ( "testing" + "testing/synctest" "github.com/aws/aws-sdk-go-v2/aws" sdk "github.com/aws/aws-sdk-go-v2/service/networkfirewall" @@ -515,7 +516,10 @@ func TestProxies(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tt.run(t, newFixture(t)) + + synctest.Test(t, func(t *testing.T) { + tt.run(t, newFixture(t)) + }) }) } } diff --git a/services/networkfirewall/summary_test.go b/services/networkfirewall/summary_test.go index bcb062acf5..27813ad6d9 100644 --- a/services/networkfirewall/summary_test.go +++ b/services/networkfirewall/summary_test.go @@ -2,6 +2,7 @@ package networkfirewall_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -116,39 +117,41 @@ func TestSetLifecycleDelay(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - b := nf.NewInMemoryBackend(testAccount, testRegion) - b.SetLifecycleDelay(tt.delay) - - h := nf.NewHandler(b) - h.AccountID, h.DefaultRegion = testAccount, testRegion - f := &fixture{handler: h, client: newClient(t, h, testRegion)} - - pol := f.policy(t, "pol") - created := f.firewall(t, "fw", aws.ToString(pol.FirewallPolicyResponse.FirewallPolicyArn)) - assert.Equal(t, types.FirewallStatusValueProvisioning, created.FirewallStatus.Status) - - status := func() types.FirewallStatusValue { - d, err := f.client.DescribeFirewall( - t.Context(), - &sdk.DescribeFirewallInput{FirewallName: aws.String("fw")}, - ) - require.NoError(t, err) - - return d.FirewallStatus.Status - } - - if tt.settles { - require.Eventually( - t, - func() bool { return status() == types.FirewallStatusValueReady }, - 5*time.Second, - 5*time.Millisecond, - ) - } - - if tt.stillRun { - assert.Equal(t, types.FirewallStatusValueProvisioning, status()) - } + synctest.Test(t, func(t *testing.T) { + b := nf.NewInMemoryBackend(testAccount, testRegion) + b.SetLifecycleDelay(tt.delay) + + h := nf.NewHandler(b) + h.AccountID, h.DefaultRegion = testAccount, testRegion + f := &fixture{handler: h, client: newClient(t, h, testRegion)} + + pol := f.policy(t, "pol") + created := f.firewall(t, "fw", aws.ToString(pol.FirewallPolicyResponse.FirewallPolicyArn)) + assert.Equal(t, types.FirewallStatusValueProvisioning, created.FirewallStatus.Status) + + status := func() types.FirewallStatusValue { + d, err := f.client.DescribeFirewall( + t.Context(), + &sdk.DescribeFirewallInput{FirewallName: aws.String("fw")}, + ) + require.NoError(t, err) + + return d.FirewallStatus.Status + } + + if tt.settles { + require.Eventually( + t, + func() bool { return status() == types.FirewallStatusValueReady }, + 5*time.Second, + 5*time.Millisecond, + ) + } + + if tt.stillRun { + assert.Equal(t, types.FirewallStatusValueProvisioning, status()) + } + }) }) } } diff --git a/services/networkfirewall/zones_test.go b/services/networkfirewall/zones_test.go index 68eff650c7..a4cb56869c 100644 --- a/services/networkfirewall/zones_test.go +++ b/services/networkfirewall/zones_test.go @@ -2,6 +2,7 @@ package networkfirewall_test import ( "testing" + "testing/synctest" "github.com/aws/aws-sdk-go-v2/aws" sdk "github.com/aws/aws-sdk-go-v2/service/networkfirewall" @@ -292,7 +293,10 @@ func TestTransitGatewayFirewalls(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - tt.run(t, newFixture(t)) + + synctest.Test(t, func(t *testing.T) { + tt.run(t, newFixture(t)) + }) }) } } diff --git a/services/networkmanager/iam_enforcement_test.go b/services/networkmanager/iam_enforcement_test.go index 3243f971e9..0411ab5397 100644 --- a/services/networkmanager/iam_enforcement_test.go +++ b/services/networkmanager/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/networkmanager" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/networkmanager/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/networkmonitor/iam_enforcement_test.go b/services/networkmonitor/iam_enforcement_test.go index 39d44cfada..57b9b339a4 100644 --- a/services/networkmonitor/iam_enforcement_test.go +++ b/services/networkmonitor/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/networkmonitor" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/networkmonitor/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ociapigateway/PARITY.md b/services/ociapigateway/PARITY.md new file mode 100644 index 0000000000..d2c8c56295 --- /dev/null +++ b/services/ociapigateway/PARITY.md @@ -0,0 +1,55 @@ +--- +service: ociapigateway +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every control-plane operation in the service tests +last_audit_commit: 3b8d909be +last_audit_date: 2026-10-29 +overall: B +ops: + CreateGateway: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with the Gateway body, etag, location and opc-work-request-id. endpointType PUBLIC or PRIVATE, subnetId required (checked against OCI Core when wired), optional certificateId must exist, ipMode IPV4/IPV6/DUAL_STACK, hostname <26 hex>.apigateway.us-ashburn-1.oci.customer-oci.com. CREATING then ACTIVE on the injectable clock. Display name unique per compartment (409 Conflict). responseCacheDetails other than NONE is rejected."} + GetGateway: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; DELETED gateways are still readable."} + ListGateways: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; displayName, lifecycleState, certificateId filters; sortBy timeCreated or displayName; opc-next-page; DELETED hidden unless lifecycleState=DELETED."} + UpdateGateway: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName, networkSecurityGroupIds, certificateId, caBundles, tags; UPDATING for the transition window (409 IncorrectState meanwhile); If-Match gives 412 NoEtagMatch."} + DeleteGateway: {wire: ok, errors: ok, state: ok, persist: ok, note: "DELETING then DELETED; 409 Conflict while the gateway still has deployments."} + ChangeGatewayCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "Body compartmentId; 202 with an UPDATE_GATEWAY work request."} + CreateDeployment: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with the Deployment body; endpoint is https://. pathPrefix and route-path rules follow the Oracle deployment documentation; one prefix per gateway and / excludes every other deployment (409). The specification is validated and echoed back verbatim; see families.deployment_spec."} + GetDeployment: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Returns the stored specification."} + ListDeployments: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; gatewayId, displayName, lifecycleState filters; summaries omit the specification."} + UpdateDeployment: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName, specification, tags; the new specification is served once the deployment is ACTIVE again."} + DeleteDeployment: {wire: ok, errors: ok, state: ok, persist: ok, note: "DELETING then DELETED; the data plane stops serving it."} + ChangeDeploymentCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "UPDATE_DEPLOYMENT work request."} + CreateCertificate: {wire: ok, errors: partial, state: ok, persist: ok, note: "The PEM certificate and private key must form a pair (crypto/tls) and intermediateCertificates must parse; subjectNames come from the common name and DNS SANs, timeNotValidAfter from the leaf. The private key is validated and then discarded, never stored or returned."} + GetCertificate: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Returns the certificate chain, never the key."} + ListCertificates: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Same filters and paging as the other lists; summaries omit the PEM."} + UpdateCertificate: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName and tags only."} + DeleteCertificate: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 Conflict while a gateway references the certificate."} + ChangeCertificateCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "UPDATE_CERTIFICATE work request."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS until the resource transition time, then SUCCEEDED; resources carry entityType gateway, deployment or certificate."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required, optional resourceId; items use workRequestId as in the SDK summary model."} + ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Always empty: no emulated operation fails asynchronously."} + ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Accepted and succeeded entries."} +families: + auth: {status: ok, note: "Control plane: pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; --oci-apigateway-enforce-auth rejects unsigned and unknown keys. The data plane is unauthenticated at the transport level, as a public deployed API is."} + error_envelope: {status: partial, note: "Control plane errors are {code,message} with opc-request-id (NotAuthenticated, NotAuthorizedOrNotFound, InvalidParameter, MissingParameter, Conflict, IncorrectState, NoEtagMatch; OCI API error reference, docs.oracle.com/en-us/iaas/Content/API/References/apierrors.htm). Data plane errors are {\"code\":,\"message\":...}; that body shape is not verified against a primary source."} + data_plane: {status: partial, note: "gopherstack scheme (the real hostnames do not resolve locally): a request whose Host is a gateway hostname, or whose path starts with /_apigw//, is routed to the gateway's ACTIVE or UPDATING deployment with the longest matching pathPrefix (segment bounded), then to a route of that deployment. Same listener and port as the control plane (default 8163). 404 when nothing matches, 405 with Allow when only the method differs, 401/403 for authentication/authorization, 429 for rate limiting, 502 for backend failure."} + route_matching: {status: partial, note: "Path syntax per docs.oracle.com/en-us/iaas/Content/APIGateway/Tasks/apigatewaycreatingdeployment.htm (route path relative to the prefix, no adjacent slashes, allowed characters) and apigatewayaddingparamswildcards.htm ({name} one segment, {name*} zero or more segments, wildcard last). Precedence between overlapping routes is not documented by Oracle; the emulator picks literal over {param} over {param*} at the first differing segment and an exact template over a trailing wildcard, then filters by method. A trailing slash on the request is ignored."} + deployment_spec: {status: partial, note: "Accepted and enforced: routes (methods incl. ANY, default ANY), backends HTTP_BACKEND, ORACLE_FUNCTIONS_BACKEND, STOCK_RESPONSE_BACKEND, DYNAMIC_ROUTING_BACKEND (SINGLE selector request.path/query/headers[..]; ANY_OF, WILDCARD, default keys; exact then wildcard then default); request policies authentication (JWT_AUTHENTICATION, TOKEN_AUTHENTICATION with STATIC_KEYS or REMOTE_JWKS, CUSTOM_AUTHENTICATION), rateLimiting, cors (deployment and route), route authorization (ANONYMOUS, ANY_OF scopes, AUTHENTICATION_ONLY), route header and query parameter transformations, route response header transformations. Accepted and stored without effect: loggingPolicies. Rejected with 400 (not silently ignored): mutualTls, usagePlans, dynamicAuthentication, header/query/body validations, response caching, OAUTH2 login/logout backends, REMOTE_DISCOVERY token validation."} + http_backend: {status: ok, note: "Forwards method, body, headers and query (after transformations) to the url, which may reference ${request.path[x]}, ${request.query[x]} and ${request.headers[x]}. Redirects are returned to the client, not followed. TLS is always verified: isSslVerifyDisabled is stored and echoed but never honoured (no InsecureSkipVerify)."} + ssrf_guard: {status: ok, note: "HTTP backends and remote JWKS URIs are dialed through a transport whose dialer checks the RESOLVED peer address and refuses anything that is not loopback (127.0.0.0/8, ::1) unless --oci-apigateway-allow-external-backends / OCI_APIGATEWAY_ALLOW_EXTERNAL_BACKENDS is set; proxy environment variables are ignored. A refused backend is 502; an unreachable JWKS fails closed with 401."} + jwt: {status: partial, note: "golang-jwt/v5 with an allow-list of RS/PS/ES algorithms (no none, no HMAC), exp required, nbf honoured, maxClockSkewInSeconds as leeway, time from the injectable clock; issuers, audiences, verifyClaims (isRequired defaults to true), kid selection. Static keys are JSON_WEB_KEY (RSA) or PEM (RSA/EC public key or certificate); remote JWKS keys are RSA only and cached for maxCacheDurationInHours (default 1). Scopes come from the scope or scp claim."} + custom_auth: {status: partial, note: "CUSTOM_AUTHENTICATION invokes the function through the narrow ociapigateway.FunctionInvoker interface (wired to OCI Functions in internal/app) with {\"type\":\"TOKEN\",\"token\":..} or, when parameters are set, {\"type\":\"USER_DEFINED\",\"data\":{..}} and expects {\"active\":bool,\"scope\":[..],\"wwwAuthenticate\":..}. That contract is from memory, see items_still_open."} + rate_limiting: {status: partial, note: "Deployment-wide token bucket per second (capacity = rate) on the injectable clock, keyed TOTAL or CLIENT_IP (IPv4 address, IPv6 /64 prefix; connection address, X-Forwarded-For is ignored). Exceeding it is 429 with Retry-After; Oracle's page (apigatewaylimitingbackendaccess.htm) says only that a response header gives the retry time. State is in memory and per process."} + lifecycle: {status: ok, note: "CREATING, UPDATING and DELETING last 2 seconds on the injectable backend clock; transitions are applied on every request and by a one-minute sweeper."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-apigateway-port / OCI_APIGATEWAY_PORT (default 8163); never on the shared AWS router; cli.go reserves the port in the shared pool."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource (data plane requests are operation Invoke); one lockmetrics.RWMutex guards all resource state."} +items_still_open: + - "Not routed: AddGatewayLock/RemoveGatewayLock (and deployment/certificate equivalents), CancelWorkRequest, the APIs, SDKs, subscribers and usage-plans resource families. Locks supplied at create are stored and echoed but never enforced." + - "Gateways report no ipAddresses and the IPv4/IPv6 address configuration objects are ignored; the subnet is only checked for existence (public gateways are not required to sit in a public subnet)." + - "Unverified, chosen by the emulator: route precedence between overlapping paths, trailing-slash handling, 405 with Allow for a method mismatch, the 200 status of CORS preflight answers, the data plane error body shape, Retry-After on 429, default HTTP backend timeouts (60 s connect, 10 s read, 10 s send, applied as one overall deadline), the order filter then rename then set for transformations, isRequired defaulting to true for verifyClaims, JWT tokenHeader defaulting to Authorization with scheme Bearer, a missing optional claim passing." + - "Unverified, from memory: the CUSTOM_AUTHENTICATION function request/response contract (TOKEN and USER_DEFINED payloads, active/scope/wwwAuthenticate fields), 502 for a failing function, and that tokenHeader values are passed to the authorizer without the scheme." + - "CORS allowedOrigins match '*' or the exact origin string; Oracle's wildcard hostnames in origins are not expanded. Routes of one deployment share the deployment CORS policy unless they carry their own." + - "The data plane is plain HTTP on the emulator listener: gateway certificates are validated and stored but no TLS is terminated, deployment endpoints use the https:// form that does not resolve locally, and PRIVATE gateways are served exactly like PUBLIC ones." + - "Mutual TLS, usage plans, dynamic authentication, request validations, response caching, OAuth2 backends and token introspection (REMOTE_DISCOVERY) are rejected with 400; access and execution log policies are stored but nothing is written to OCI Logging." + - "No separate connect/send timeout enforcement, no retries, no streaming flush; request bodies are buffered up to 10 MiB." + - "opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. Tag values are stored verbatim and systemTags are never set." + - "The dashboard has no OCI API Gateway page." +--- diff --git a/services/ociapigateway/README.md b/services/ociapigateway/README.md new file mode 100644 index 0000000000..b51975f494 --- /dev/null +++ b/services/ociapigateway/README.md @@ -0,0 +1,32 @@ + +# OCI API Gateway + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-29 (`3b8d909be`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 22 (19 ok, 3 partial) | +| Feature families | 13 (6 ok, 7 partial) | +| Known gaps | 10 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Not routed: AddGatewayLock/RemoveGatewayLock (and deployment/certificate equivalents), CancelWorkRequest, the APIs, SDKs, subscribers and usage-plans resource families. Locks supplied at create are stored and echoed but never enforced. +- Gateways report no ipAddresses and the IPv4/IPv6 address configuration objects are ignored; the subnet is only checked for existence (public gateways are not required to sit in a public subnet). +- Unverified, chosen by the emulator: route precedence between overlapping paths, trailing-slash handling, 405 with Allow for a method mismatch, the 200 status of CORS preflight answers, the data plane error body shape, Retry-After on 429, default HTTP backend timeouts (60 s connect, 10 s read, 10 s send, applied as one overall deadline), the order filter then rename then set for transformations, isRequired defaulting to true for verifyClaims, JWT tokenHeader defaulting to Authorization with scheme Bearer, a missing optional claim passing. +- Unverified, from memory: the CUSTOM_AUTHENTICATION function request/response contract (TOKEN and USER_DEFINED payloads, active/scope/wwwAuthenticate fields), 502 for a failing function, and that tokenHeader values are passed to the authorizer without the scheme. +- CORS allowedOrigins match '*' or the exact origin string; Oracle's wildcard hostnames in origins are not expanded. Routes of one deployment share the deployment CORS policy unless they carry their own. +- The data plane is plain HTTP on the emulator listener: gateway certificates are validated and stored but no TLS is terminated, deployment endpoints use the https:// form that does not resolve locally, and PRIVATE gateways are served exactly like PUBLIC ones. +- Mutual TLS, usage plans, dynamic authentication, request validations, response caching, OAuth2 backends and token introspection (REMOTE_DISCOVERY) are rejected with 400; access and execution log policies are stored but nothing is written to OCI Logging. +- No separate connect/send timeout enforcement, no retries, no streaming flush; request bodies are buffered up to 10 MiB. +- opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. Tag values are stored verbatim and systemTags are never set. +- The dashboard has no OCI API Gateway page. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ociapigateway/auth.go b/services/ociapigateway/auth.go new file mode 100644 index 0000000000..7228d56e18 --- /dev/null +++ b/services/ociapigateway/auth.go @@ -0,0 +1,483 @@ +package ociapigateway + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "slices" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + jwksMaxBytes = 1 << 20 + jwksFetchLimit = 10 * time.Second + defaultCacheHr = 1 + defaultScheme = "Bearer" + defaultHeader = "Authorization" +) + +var ( + errNoKey = errors.New("no matching signing key") + errClaim = errors.New("claim validation failed") + errKeyFormat = errors.New("unsupported key") + errJWKSStatus = errors.New("jwks endpoint answered") +) + +// authResult is the outcome of authenticating a data-plane request. +type authResult struct { + wwwAuth string + scopes []string + ok bool +} + +func parseStaticKey(k *staticKeyDoc) (crypto.PublicKey, error) { + if k.Kid == "" { + return nil, fmt.Errorf("%w: kid is required", errKeyFormat) + } + + switch k.Format { + case "JSON_WEB_KEY": + return rsaFromJWK(k.Kty, k.N, k.E) + case "PEM": + blk, _ := pem.Decode([]byte(k.Key)) + if blk == nil { + return nil, fmt.Errorf("%w: key is not PEM", errKeyFormat) + } + + if pub, err := x509.ParsePKIXPublicKey(blk.Bytes); err == nil { + return pub, nil + } + + cert, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + return nil, fmt.Errorf("%w: key is neither a public key nor a certificate", errKeyFormat) + } + + return cert.PublicKey, nil + default: + return nil, fmt.Errorf("%w: format must be JSON_WEB_KEY or PEM", errKeyFormat) + } +} + +func rsaFromJWK(kty, n, e string) (*rsa.PublicKey, error) { + if kty != "RSA" { + return nil, fmt.Errorf("%w: only RSA web keys are supported", errKeyFormat) + } + + nb, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(n, "=")) + if err != nil || len(nb) == 0 { + return nil, fmt.Errorf("%w: invalid modulus", errKeyFormat) + } + + eb, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(e, "=")) + if err != nil || len(eb) == 0 { + return nil, fmt.Errorf("%w: invalid exponent", errKeyFormat) + } + + exp := new(big.Int).SetBytes(eb) + if !exp.IsInt64() || exp.Int64() < 3 || exp.Int64() > 1<<31-1 { + return nil, fmt.Errorf("%w: invalid exponent", errKeyFormat) + } + + return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: int(exp.Int64())}, nil +} + +type jwksEntry struct { + expires time.Time + keys map[string]crypto.PublicKey +} + +// jwksCache holds remote key sets until their maxCacheDurationInHours elapses on the backend clock. +type jwksCache struct { + mu *lockmetrics.RWMutex + entries map[string]*jwksEntry +} + +func newJWKSCache() *jwksCache { + return &jwksCache{mu: lockmetrics.New("ociapigateway.jwks"), entries: map[string]*jwksEntry{}} +} + +func (c *jwksCache) reset() { + c.mu.Lock("reset") + c.entries = map[string]*jwksEntry{} + c.mu.Unlock() +} + +func (c *jwksCache) get(uri string, now time.Time) (map[string]crypto.PublicKey, bool) { + c.mu.RLock("get") + defer c.mu.RUnlock() + + e, ok := c.entries[uri] + if !ok || !e.expires.After(now) { + return nil, false + } + + return e.keys, true +} + +func (c *jwksCache) put(uri string, keys map[string]crypto.PublicKey, expires time.Time) { + c.mu.Lock("put") + c.entries[uri] = &jwksEntry{keys: keys, expires: expires} + c.mu.Unlock() +} + +func (b *InMemoryBackend) remoteKeys(ctx context.Context, uri string, hours *int) (map[string]crypto.PublicKey, error) { + now := b.clock() + if keys, ok := b.jwks.get(uri, now); ok { + return keys, nil + } + + ctx, cancel := context.WithTimeout(ctx, jwksFetchLimit) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + resp, err := b.client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %d", errJWKSStatus, resp.StatusCode) + } + + var doc struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + + if err = json.NewDecoder(io.LimitReader(resp.Body, jwksMaxBytes)).Decode(&doc); err != nil { + return nil, err + } + + keys := map[string]crypto.PublicKey{} + + for _, k := range doc.Keys { + if pub, kerr := rsaFromJWK(k.Kty, k.N, k.E); kerr == nil { + keys[k.Kid] = pub + } + } + + h := defaultCacheHr + if hours != nil && *hours > 0 { + h = *hours + } + + b.jwks.put(uri, keys, now.Add(time.Duration(h)*time.Hour)) + + return keys, nil +} + +// signingKeys resolves the key set of a JWT or token policy. +func (b *InMemoryBackend) signingKeys(ctx context.Context, a *authDoc) (map[string]crypto.PublicKey, error) { + ks := keySource(a) + if ks.kind == keysRemote { + return b.remoteKeys(ctx, ks.uri, ks.hours) + } + + static := ks.keys + + keys := map[string]crypto.PublicKey{} + + for i := range static { + pub, err := parseStaticKey(&static[i]) + if err != nil { + return nil, err + } + + keys[static[i].Kid] = pub + } + + return keys, nil +} + +type keySet struct { + hours *int + kind string + uri string + keys []staticKeyDoc +} + +func keySource(a *authDoc) keySet { + if a.PublicKeys != nil { + k := a.PublicKeys + + return keySet{kind: k.Type, uri: k.URI, hours: k.MaxCacheHours, keys: k.Keys} + } + + if a.ValidationPolicy != nil { + v := a.ValidationPolicy + + return keySet{kind: v.Type, uri: v.URI, hours: v.MaxCacheHours, keys: v.Keys} + } + + return keySet{} +} + +// effectiveChecks returns the issuers, audiences and claims the token must satisfy. +func effectiveChecks(a *authDoc) additionalDoc { + if a.ValidationPolicy != nil && a.ValidationPolicy.Additional != nil { + return *a.ValidationPolicy.Additional + } + + return additionalDoc{Issuers: a.Issuers, Audiences: a.Audiences, VerifyClaims: a.VerifyClaims} +} + +// tokenOf reads the bearer token from the configured header (after the scheme) or query parameter. +func tokenOf(a *authDoc, r *http.Request) string { + header, query := "", "" + if a.TokenHeader != nil { + header = *a.TokenHeader + } + + if a.TokenQueryParam != nil { + query = *a.TokenQueryParam + } + + if header == "" && query == "" { + header = defaultHeader + } + + if header != "" { + v := strings.TrimSpace(r.Header.Get(header)) + + scheme := defaultScheme + if a.TokenAuthScheme != nil && *a.TokenAuthScheme != "" { + scheme = *a.TokenAuthScheme + } + + if a.Type == authCustom { + return v + } + + if len(v) > len(scheme)+1 && strings.EqualFold(v[:len(scheme)], scheme) && v[len(scheme)] == ' ' { + return strings.TrimSpace(v[len(scheme)+1:]) + } + + if v != "" && header != defaultHeader { + return v + } + } + + if query != "" { + return r.URL.Query().Get(query) + } + + return "" +} + +func (b *InMemoryBackend) verifyJWT(ctx context.Context, a *authDoc, token string) authResult { + keys, err := b.signingKeys(ctx, a) + if err != nil { + return authResult{} + } + + skew := time.Duration(0) + if a.MaxClockSkew != nil && *a.MaxClockSkew > 0 { + skew = time.Duration(*a.MaxClockSkew * float64(time.Second)) + } + + claims := jwt.MapClaims{} + parser := jwt.NewParser( + jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}), + jwt.WithTimeFunc(b.clock), jwt.WithLeeway(skew), jwt.WithExpirationRequired(), + ) + + _, err = parser.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + if k, ok := keys[kid]; ok { + return checkKeyAlg(k, t) + } + + if kid == "" && len(keys) == 1 { + for _, k := range keys { + return checkKeyAlg(k, t) + } + } + + return nil, errNoKey + }) + if err != nil { + return authResult{} + } + + if checkClaims(claims, effectiveChecks(a)) != nil { + return authResult{} + } + + return authResult{ok: true, scopes: scopesOf(claims)} +} + +// checkKeyAlg refuses a key whose type does not fit the token's signing algorithm family. +func checkKeyAlg(k crypto.PublicKey, t *jwt.Token) (crypto.PublicKey, error) { + alg := t.Method.Alg() + + switch k.(type) { + case *rsa.PublicKey: + if strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS") { + return k, nil + } + case *ecdsa.PublicKey: + if strings.HasPrefix(alg, "ES") { + return k, nil + } + } + + return nil, errKeyFormat +} + +func claimStrings(v any) []string { + switch t := v.(type) { + case string: + return []string{t} + case []any: + out := make([]string, 0, len(t)) + + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + + return out + default: + return nil + } +} + +func checkClaims(claims jwt.MapClaims, want additionalDoc) error { + issuers, audiences, extra := want.Issuers, want.Audiences, want.VerifyClaims + + if len(issuers) > 0 { + iss, _ := claims["iss"].(string) + if !slices.Contains(issuers, iss) { + return errClaim + } + } + + if len(audiences) > 0 { + have := claimStrings(claims["aud"]) + if !slices.ContainsFunc(have, func(s string) bool { return slices.Contains(audiences, s) }) { + return errClaim + } + } + + for _, c := range extra { + v, present := claims[c.Key] + required := c.IsRequired == nil || *c.IsRequired + + if !present { + if required { + return errClaim + } + + continue + } + + if len(c.Values) > 0 && + !slices.ContainsFunc(claimStrings(v), func(s string) bool { return slices.Contains(c.Values, s) }) { + return errClaim + } + } + + return nil +} + +func scopesOf(claims jwt.MapClaims) []string { + var out []string + + for _, k := range []string{"scope", "scp"} { + for _, s := range claimStrings(claims[k]) { + out = append(out, strings.Fields(s)...) + } + } + + return out +} + +// authenticate runs the deployment authentication policy; anonymous reports a request that carried no token. +func (b *InMemoryBackend) authenticate( + ctx context.Context, a *authDoc, r *http.Request, rv *reqView, +) (authResult, bool) { + token := tokenOf(a, r) + if token == "" { + if a.IsAnonymousAllowed != nil && *a.IsAnonymousAllowed { + return authResult{ok: true}, true + } + + return authResult{}, false + } + + if a.Type == authCustom { + return b.customAuth(ctx, a, token, rv), false + } + + return b.verifyJWT(ctx, a, token), false +} + +// customAuth asks the authorizer function to validate the token or the configured request parameters. +func (b *InMemoryBackend) customAuth(ctx context.Context, a *authDoc, token string, rv *reqView) authResult { + b.mu.RLock("customAuth") + inv := b.functions + b.mu.RUnlock() + + if inv == nil { + return authResult{} + } + + payload := map[string]any{"type": "TOKEN", "token": token} + + if len(a.Parameters) > 0 { + data := make(map[string]string, len(a.Parameters)) + for name, expr := range a.Parameters { + data[name] = rv.lookup(expr) + } + + payload = map[string]any{"type": "USER_DEFINED", "data": data} + } + + body, _ := json.Marshal(payload) //nolint:errchkjson // static shape + + out, err := inv.InvokeFunction(ctx, a.FunctionID, FunctionRequest{ + Method: http.MethodPost, Header: http.Header{"Content-Type": {"application/json"}}, Body: body, + }) + if err != nil || out.Status != http.StatusOK { + return authResult{} + } + + var verdict struct { + WWWAuth string `json:"wwwAuthenticate"` + Scope []string `json:"scope"` + Active bool `json:"active"` + } + + if json.Unmarshal(out.Body, &verdict) != nil || !verdict.Active { + return authResult{wwwAuth: verdict.WWWAuth} + } + + return authResult{ok: true, scopes: verdict.Scope} +} diff --git a/services/ociapigateway/auth_test.go b/services/ociapigateway/auth_test.go new file mode 100644 index 0000000000..390b485b5f --- /dev/null +++ b/services/ociapigateway/auth_test.go @@ -0,0 +1,423 @@ +package ociapigateway_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ociapigateway "github.com/blackbirdworks/gopherstack/services/ociapigateway" +) + +const ( + jwtIssuer = "https://issuer.example.test" + jwtAudience = "api://orders" +) + +func rsaKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + return k +} + +func jwkOf(k *rsa.PrivateKey, kid string) string { + n := base64.RawURLEncoding.EncodeToString(k.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()) + + return `{"format":"JSON_WEB_KEY","kid":"` + kid + `","kty":"RSA","alg":"RS256","use":"sig",` + + `"n":"` + n + `","e":"` + e + `"}` +} + +func pemOf(t *testing.T, k *rsa.PrivateKey, kid string) string { + t.Helper() + + der, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + b, err := json.Marshal(map[string]string{ + "format": "PEM", "kid": kid, "key": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), + }) + require.NoError(t, err) + + return string(b) +} + +func signToken(t *testing.T, k *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { + t.Helper() + + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = kid + + s, err := tok.SignedString(k) + require.NoError(t, err) + + return s +} + +func goodClaims(now time.Time) jwt.MapClaims { + return jwt.MapClaims{ + "iss": jwtIssuer, "aud": jwtAudience, "sub": "u1", "scope": "orders.read orders.write", + "exp": now.Add(time.Hour).Unix(), "nbf": now.Add(-time.Minute).Unix(), "tier": "gold", + } +} + +func authSpec(keysJSON, extra string) string { + return `{"requestPolicies":{"authentication":{"type":"JWT_AUTHENTICATION","issuers":["` + jwtIssuer + + `"],"audiences":["` + jwtAudience + `"],"tokenHeader":"Authorization","tokenAuthScheme":"Bearer",` + extra + + `"publicKeys":` + keysJSON + `}},"routes":[` + stock("/secure", `"GET"`, "secret") + `, + {"path":"/scoped","methods":["GET"],"backend":{"type":"STOCK_RESPONSE_BACKEND","status":200,"body":"scoped"}, + "requestPolicies":{"authorization":{"type":"ANY_OF","allowedScope":["admin","orders.write"]}}}, + {"path":"/needs-admin","methods":["GET"],"backend":{"type":"STOCK_RESPONSE_BACKEND","status":200}, + "requestPolicies":{"authorization":{"type":"ANY_OF","allowedScope":["admin"]}}}, + {"path":"/public","methods":["GET"],"backend":{"type":"STOCK_RESPONSE_BACKEND","status":200,"body":"public"}, + "requestPolicies":{"authorization":{"type":"ANONYMOUS"}}}]}` +} + +func TestJWTStaticKeys(t *testing.T) { + t.Parallel() + + good, other := rsaKey(t), rsaKey(t) + keyVariants := map[string]string{ + "jwk": `{"type":"STATIC_KEYS","keys":[` + jwkOf(good, "k1") + `]}`, + "pem": `{"type":"STATIC_KEYS","keys":[` + pemOf(t, good, "k1") + `]}`, + } + + for variant, keys := range keyVariants { + t.Run(variant, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "jwt") + e.mkDeployment( + t, + *gw.Id, + "/j", + authSpec(keys, `"isAnonymousAccessAllowed":true,"maxClockSkewInSeconds":30,`), + ) + now := e.clock.Now() + + bad := func(mod func(jwt.MapClaims)) string { + c := goodClaims(now) + mod(c) + + return signToken(t, good, "k1", c) + } + + hs, err := jwt.NewWithClaims(jwt.SigningMethodHS256, goodClaims(now)).SignedString([]byte("secret")) + require.NoError(t, err) + + none, err := jwt.NewWithClaims(jwt.SigningMethodNone, goodClaims(now)). + SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + tests := []struct { + name, path, token string + status int + }{ + {"valid", "/j/secure", signToken(t, good, "k1", goodClaims(now)), 200}, + {"valid no kid match single key", "/j/secure", signToken(t, good, "", goodClaims(now)), 200}, + {"wrong issuer", "/j/secure", bad(func(c jwt.MapClaims) { c["iss"] = "https://evil" }), 401}, + {"wrong audience", "/j/secure", bad(func(c jwt.MapClaims) { c["aud"] = "other" }), 401}, + { + "audience list", + "/j/secure", + bad(func(c jwt.MapClaims) { c["aud"] = []string{"x", jwtAudience} }), + 200, + }, + {"expired", "/j/secure", bad(func(c jwt.MapClaims) { c["exp"] = now.Add(-time.Hour).Unix() }), 401}, + { + "expired within skew", + "/j/secure", + bad(func(c jwt.MapClaims) { c["exp"] = now.Add(-10 * time.Second).Unix() }), + 200, + }, + { + "not yet valid", + "/j/secure", + bad(func(c jwt.MapClaims) { c["nbf"] = now.Add(time.Hour).Unix() }), + 401, + }, + {"missing exp", "/j/secure", bad(func(c jwt.MapClaims) { delete(c, "exp") }), 401}, + {"wrong signing key", "/j/secure", signToken(t, other, "k1", goodClaims(now)), 401}, + {"unknown kid", "/j/secure", signToken(t, good, "zzz", goodClaims(now)), 401}, + {"hmac confusion", "/j/secure", hs, 401}, + {"alg none", "/j/secure", none, 401}, + {"garbage", "/j/secure", "not.a.jwt", 401}, + {"no token anonymous allowed", "/j/secure", "", 200}, + {"scope any_of match", "/j/scoped", signToken(t, good, "k1", goodClaims(now)), 200}, + {"scope any_of miss", "/j/needs-admin", signToken(t, good, "k1", goodClaims(now)), 403}, + {"anonymous route with bad token", "/j/public", "garbage", 200}, + {"anonymous route no token", "/j/public", "", 200}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hdr := map[string]string{} + if tt.token != "" { + hdr["Authorization"] = "Bearer " + tt.token + } + + assert.Equal(t, tt.status, e.call(t, *gw.Id, http.MethodGet, tt.path, hdr, "").status) + }) + } + }) + } +} + +func TestJWTRequiredWhenAnonymousDisallowed(t *testing.T) { + t.Parallel() + + k := rsaKey(t) + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "jwt2") + e.mkDeployment( + t, + *gw.Id, + "/j", + `{"requestPolicies":{"authentication":{"type":"JWT_AUTHENTICATION","issuers":["`+jwtIssuer+ + `"],"audiences":["`+jwtAudience+`"],"tokenQueryParam":"access_token",`+ + `"publicKeys":{"type":"STATIC_KEYS","keys":[`+jwkOf(k, "k1")+ + `]}}},"routes":[`+stock("/x", `"GET"`, "ok")+`]}`, + ) + + assert.Equal(t, 401, e.call(t, *gw.Id, http.MethodGet, "/j/x", nil, "").status) + + tok := signToken(t, k, "k1", goodClaims(e.clock.Now())) + assert.Equal(t, 200, e.call(t, *gw.Id, http.MethodGet, "/j/x?access_token="+tok, nil, "").status) + + e.clock.Advance(2 * time.Hour) + assert.Equal( + t, + 401, + e.call(t, *gw.Id, http.MethodGet, "/j/x?access_token="+tok, nil, "").status, + "expiry follows the injected clock", + ) +} + +func TestJWTVerifyClaims(t *testing.T) { + t.Parallel() + + k := rsaKey(t) + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "claims") + e.mkDeployment(t, *gw.Id, "/c", authSpec( + `{"type":"STATIC_KEYS","keys":[`+jwkOf(k, "k1")+`]}`, + `"isAnonymousAccessAllowed":true,"verifyClaims":[{"key":"tier","values":["gold","silver"]},`+ + `{"key":"dept","isRequired":false,"values":["eng"]}],`, + )) + now := e.clock.Now() + + tests := []struct { + mod func(jwt.MapClaims) + name string + status int + }{ + {func(jwt.MapClaims) {}, "claim matches", 200}, + {func(c jwt.MapClaims) { c["tier"] = "bronze" }, "required claim wrong value", 401}, + {func(c jwt.MapClaims) { delete(c, "tier") }, "required claim missing", 401}, + {func(c jwt.MapClaims) { c["dept"] = "ops" }, "optional claim wrong value", 401}, + {func(c jwt.MapClaims) { c["dept"] = "eng" }, "optional claim right value", 200}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := goodClaims(now) + tt.mod(c) + + r := e.call(t, *gw.Id, http.MethodGet, "/c/secure", map[string]string{ + "Authorization": "Bearer " + signToken(t, k, "k1", c), + }, "") + assert.Equal(t, tt.status, r.status) + }) + } +} + +func TestJWTRemoteJWKS(t *testing.T) { + t.Parallel() + + k := rsaKey(t) + + var fetches atomic.Int32 + + jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetches.Add(1) + + var key map[string]any + _ = json.Unmarshal([]byte(jwkOf(k, "r1")), &key) + + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{key}}) + })) + t.Cleanup(jwks.Close) + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "remote") + e.mkDeployment( + t, + *gw.Id, + "/r", + authSpec( + `{"type":"REMOTE_JWKS","uri":"`+jwks.URL+`","maxCacheDurationInHours":2}`, + `"isAnonymousAccessAllowed":true,`, + ), + ) + + hdr := func() map[string]string { + return map[string]string{"Authorization": "Bearer " + signToken(t, k, "r1", goodClaims(e.clock.Now()))} + } + + assert.Equal(t, 200, e.call(t, *gw.Id, http.MethodGet, "/r/secure", hdr(), "").status) + assert.Equal(t, 200, e.call(t, *gw.Id, http.MethodGet, "/r/secure", hdr(), "").status) + assert.Equal(t, int32(1), fetches.Load(), "key set is cached") + + e.clock.Advance(3 * time.Hour) + assert.Equal(t, 200, e.call(t, *gw.Id, http.MethodGet, "/r/secure", hdr(), "").status) + assert.Equal(t, int32(2), fetches.Load(), "cache expires after maxCacheDurationInHours") +} + +func TestJWTRemoteJWKSBlockedOffLoopback(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "ssrf") + e.mkDeployment( + t, + *gw.Id, + "/r", + authSpec(`{"type":"REMOTE_JWKS","uri":"http://192.0.2.7:9/jwks"}`, `"isAnonymousAccessAllowed":true,`), + ) + + r := e.call(t, *gw.Id, http.MethodGet, "/r/secure", map[string]string{"Authorization": "Bearer abc.def.ghi"}, "") + assert.Equal(t, 401, r.status, "unreachable JWKS fails closed") +} + +func TestTokenAuthenticationAdditionalPolicy(t *testing.T) { + t.Parallel() + + k := rsaKey(t) + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "tokenauth") + e.mkDeployment( + t, + *gw.Id, + "/t", + `{"requestPolicies":{"authentication":{"type":"TOKEN_AUTHENTICATION","tokenHeader":"X-Token", + "tokenAuthScheme":"Bearer","validationPolicy":{"type":"STATIC_KEYS","keys":[`+jwkOf(k, "k1")+`], + "additionalValidationPolicy":{"issuers":["`+jwtIssuer+`"],"audiences":["`+jwtAudience+`"]}}}}, + "routes":[`+stock("/x", `"GET"`, "ok")+`]}`, + ) + + now := e.clock.Now() + tok := signToken(t, k, "k1", goodClaims(now)) + + assert.Equal( + t, + 200, + e.call(t, *gw.Id, http.MethodGet, "/t/x", map[string]string{"X-Token": "Bearer " + tok}, "").status, + ) + assert.Equal( + t, + 401, + e.call(t, *gw.Id, http.MethodGet, "/t/x", map[string]string{"Authorization": "Bearer " + tok}, "").status, + ) + + c := goodClaims(now) + c["iss"] = "x" + assert.Equal(t, 401, e.call(t, *gw.Id, http.MethodGet, "/t/x", + map[string]string{"X-Token": "Bearer " + signToken(t, k, "k1", c)}, "").status) +} + +func TestCustomAuthenticator(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + inv := &fakeInvoker{fn: func(_ string, in ociapigateway.FunctionRequest) (ociapigateway.FunctionResponse, error) { + var p struct { + Data map[string]string `json:"data"` + Type string `json:"type"` + Token string `json:"token"` + } + + _ = json.Unmarshal(in.Body, &p) + + verdict := map[string]any{"active": false, "wwwAuthenticate": `Bearer realm="orders"`} + + switch { + case p.Type == "TOKEN" && p.Token == "good": + verdict = map[string]any{"active": true, "scope": []string{"orders.write"}} + case p.Type == "USER_DEFINED" && p.Data["key"] == "letmein": + verdict = map[string]any{"active": true, "scope": []string{"orders.write"}} + } + + out, _ := json.Marshal(verdict) + + return ociapigateway.FunctionResponse{Status: 200, Body: out}, nil + }} + e.backend.SetFunctionInvoker(inv) + + gw := e.mkGateway(t, "custom") + e.mkDeployment( + t, + *gw.Id, + "/a", + `{"requestPolicies":{"authentication":{"type":"CUSTOM_AUTHENTICATION","functionId":"`+testFunction+ + `","tokenHeader":"Authorization"}},"routes":[`+stock("/x", `"GET"`, "ok")+`, + {"path":"/scoped","methods":["GET"],"backend":{"type":"STOCK_RESPONSE_BACKEND","status":200}, + "requestPolicies":{"authorization":{"type":"ANY_OF","allowedScope":["admin"]}}}]}`, + ) + + assert.Equal( + t, + 200, + e.call(t, *gw.Id, http.MethodGet, "/a/x", map[string]string{"Authorization": "good"}, "").status, + ) + + denied := e.call(t, *gw.Id, http.MethodGet, "/a/x", map[string]string{"Authorization": "bad"}, "") + assert.Equal(t, 401, denied.status) + assert.Equal(t, `Bearer realm="orders"`, denied.header.Get("WWW-Authenticate")) + + assert.Equal(t, 401, e.call(t, *gw.Id, http.MethodGet, "/a/x", nil, "").status) + assert.Equal( + t, + 403, + e.call(t, *gw.Id, http.MethodGet, "/a/scoped", map[string]string{"Authorization": "good"}, "").status, + ) + + e.mkDeployment( + t, + *gw.Id, + "/b", + `{"requestPolicies":{"authentication":{"type":"CUSTOM_AUTHENTICATION","functionId":"`+testFunction+ + `","tokenHeader":"X-Auth","parameters":{"key":"request.headers[X-Key]"}}},`+ + `"routes":[`+stock("/x", `"GET"`, "ok")+`]}`, + ) + + assert.Equal( + t, + 200, + e.call(t, *gw.Id, http.MethodGet, "/b/x", map[string]string{"X-Auth": "t", "X-Key": "letmein"}, "").status, + ) + assert.Equal( + t, + 401, + e.call(t, *gw.Id, http.MethodGet, "/b/x", map[string]string{"X-Auth": "t", "X-Key": "no"}, "").status, + ) +} diff --git a/services/ociapigateway/backend.go b/services/ociapigateway/backend.go new file mode 100644 index 0000000000..fbd4ae67ed --- /dev/null +++ b/services/ociapigateway/backend.go @@ -0,0 +1,300 @@ +package ociapigateway + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "slices" + "strings" + "syscall" + "time" +) + +// Default HTTP backend timeouts. +const ( + defaultConnectSec = 60 + defaultReadSec = 10 + defaultSendSec = 10 + dialerKeepAlive = 30 * time.Second +) + +var ( + errBackendURL = errors.New("invalid backend url") + errNoFunctions = errors.New("OCI Functions is not available") +) + +// ErrDestinationNotPermitted is returned when a backend resolves outside the permitted network range. +var ErrDestinationNotPermitted = errors.New( + "ociapigateway: destination is outside loopback and external backends are not allowed", +) + +// FunctionRequest is the request an API gateway hands to OCI Functions. +type FunctionRequest struct { + Header http.Header + Method string + Path string + RawQuery string + Body []byte +} + +// FunctionResponse is the function's reply. +type FunctionResponse struct { + Header http.Header + Body []byte + Status int +} + +// FunctionInvoker invokes an OCI function synchronously; wired to OCI Functions in internal/app. +type FunctionInvoker interface { + InvokeFunction(ctx context.Context, functionID string, in FunctionRequest) (FunctionResponse, error) +} + +// guardedDialer refuses connections to non-loopback peers unless external backends are allowed. The check runs +// on the resolved address, so a hostname that resolves elsewhere (or later rebinds) is still refused. +func (b *InMemoryBackend) guardedDialer() *net.Dialer { + return &net.Dialer{ + KeepAlive: dialerKeepAlive, + Control: func(_, address string, _ syscall.RawConn) error { + if b.externalAllowed() { + return nil + } + + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + + if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() { + return ErrDestinationNotPermitted + } + + return nil + }, + } +} + +func seconds(v *float64, def int) time.Duration { + if v == nil || *v <= 0 { + return time.Duration(def) * time.Second + } + + return time.Duration(*v * float64(time.Second)) +} + +var hopHeaders = map[string]bool{ //nolint:gochecknoglobals // immutable + "Connection": true, "Keep-Alive": true, "Proxy-Authenticate": true, "Proxy-Authorization": true, + "Te": true, "Trailer": true, "Transfer-Encoding": true, "Upgrade": true, "Proxy-Connection": true, +} + +// backendResponse is what a route backend produced. +type backendResponse struct { + body io.ReadCloser + err error + header http.Header + status int +} + +func (b *InMemoryBackend) runBackend( + ctx context.Context, be *backendDoc, r *http.Request, rv *reqView, body []byte, +) backendResponse { + switch be.Type { + case backendStock: + return stockResponse(be, rv) + case backendHTTP: + return b.httpBackend(ctx, be, r, rv, body) + case backendFn: + return b.functionBackend(ctx, be, r, rv, body) + case backendDynamic: + chosen := selectDynamic(be, rv) + if chosen == nil { + return backendResponse{status: http.StatusNotFound, header: http.Header{}, body: http.NoBody} + } + + return b.runBackend(ctx, chosen, r, rv, body) + default: + return backendResponse{err: fmt.Errorf("%w: backend type %s", errKeyFormat, be.Type)} + } +} + +func stockResponse(be *backendDoc, rv *reqView) backendResponse { + h := http.Header{} + for _, hs := range be.Headers { + if hs.Name != "" { + h.Add(hs.Name, rv.interpolate(hs.Value, nil)) + } + } + + text := "" + if be.Body != nil { + text = rv.interpolate(*be.Body, nil) + } + + return backendResponse{status: *be.Status, header: h, body: io.NopCloser(strings.NewReader(text))} +} + +func (b *InMemoryBackend) httpBackend( + ctx context.Context, be *backendDoc, r *http.Request, rv *reqView, body []byte, +) backendResponse { + target := rv.interpolate(*be.URL, escapePath) + + u, err := url.Parse(target) + if err != nil || !httpURL(u) { + return backendResponse{err: fmt.Errorf("%w: %q", errBackendURL, target)} + } + + appendQuery(u, rv.query.Encode()) + + total := seconds(be.ConnectTimeout, defaultConnectSec) + seconds(be.ReadTimeout, defaultReadSec) + + seconds(be.SendTimeout, defaultSendSec) + + ctx, cancel := context.WithTimeout(ctx, total) + + out, err := http.NewRequestWithContext(ctx, r.Method, u.String(), bytes.NewReader(body)) + if err != nil { + cancel() + + return backendResponse{err: err} + } + + copyRequestHeaders(out.Header, rv.header) + out.Header.Set("X-Forwarded-For", clientKey(r)) + + resp, err := b.client.Do(out) //nolint:gosec,bodyclose // G704 loopback-only by default; body closed by cancelBody + if err != nil { + cancel() + + return backendResponse{err: err} + } + + return wrapResponse(resp, cancel) +} + +// wrapResponse hands the body to the caller; cancelBody.Close closes it and cancels the request context. +func wrapResponse(resp *http.Response, cancel context.CancelFunc) backendResponse { + h := http.Header{} + + for k, vs := range resp.Header { + if !hopHeaders[k] { + h[k] = vs + } + } + + return backendResponse{ + status: resp.StatusCode, + header: h, + body: &cancelBody{ReadCloser: resp.Body, cancel: cancel}, + } +} + +func appendQuery(u *url.URL, q string) { + if q == "" { + return + } + + if u.RawQuery != "" { + u.RawQuery += "&" + } + + u.RawQuery += q +} + +func copyRequestHeaders(dst, src http.Header) { + for k, vs := range src { + if hopHeaders[k] || k == "Host" || k == "Content-Length" || strings.HasPrefix(k, "X-Forwarded-") { + continue + } + + dst[k] = vs + } +} + +type cancelBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (c *cancelBody) Close() error { + err := c.ReadCloser.Close() + c.cancel() + + return err +} + +func (b *InMemoryBackend) functionBackend( + ctx context.Context, be *backendDoc, r *http.Request, rv *reqView, body []byte, +) backendResponse { + b.mu.RLock("functionBackend") + inv := b.functions + b.mu.RUnlock() + + if inv == nil { + return backendResponse{err: errNoFunctions} + } + + out, err := inv.InvokeFunction(ctx, be.FunctionID, FunctionRequest{ + Method: r.Method, Path: r.URL.Path, RawQuery: rv.query.Encode(), Header: rv.header.Clone(), Body: body, + }) + if err != nil { + return backendResponse{err: err} + } + + h := out.Header + if h == nil { + h = http.Header{} + } + + return backendResponse{status: out.Status, header: h, body: io.NopCloser(bytes.NewReader(out.Body))} +} + +// selectDynamic picks the routing backend: exact keys first, then wildcard keys, then the default key. +func selectDynamic(be *backendDoc, rv *reqView) *backendDoc { + val := rv.lookup(be.SelectionSource.Selector) + + for i := range be.RoutingBackends { + k := &be.RoutingBackends[i].Key + if k.Type == anyOf && containsStr(k.Values, val) { + return be.RoutingBackends[i].Backend + } + } + + for i := range be.RoutingBackends { + k := &be.RoutingBackends[i].Key + if k.Type == wildcardKey && wildcardMatch(k.Expression, val) { + return be.RoutingBackends[i].Backend + } + } + + for i := range be.RoutingBackends { + if d := be.RoutingBackends[i].Key.IsDefault; d != nil && *d { + return be.RoutingBackends[i].Backend + } + } + + return nil +} + +func containsStr(list []string, s string) bool { + return slices.Contains(list, s) +} + +// wildcardMatch matches a key with one '*' (zero or more characters) or '+' (one or more) +// at its start or end. +func wildcardMatch(expr, val string) bool { + switch { + case strings.HasPrefix(expr, "*"): + return strings.HasSuffix(val, expr[1:]) + case strings.HasPrefix(expr, "+"): + return len(val) > len(expr)-1 && strings.HasSuffix(val, expr[1:]) + case strings.HasSuffix(expr, "*"): + return strings.HasPrefix(val, expr[:len(expr)-1]) + case strings.HasSuffix(expr, "+"): + return len(val) > len(expr)-1 && strings.HasPrefix(val, expr[:len(expr)-1]) + default: + return val == expr + } +} diff --git a/services/ociapigateway/certificates.go b/services/ociapigateway/certificates.go new file mode 100644 index 0000000000..4e776742ba --- /dev/null +++ b/services/ociapigateway/certificates.go @@ -0,0 +1,341 @@ +package ociapigateway + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +type createCertificateIn struct { + Tags + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + PrivateKey string `json:"privateKey"` + Certificate string `json:"certificate"` + Intermediate string `json:"intermediateCertificates"` + Locks json.RawMessage `json:"locks"` +} + +type updateCertificateIn struct { + Tags + DisplayName *string `json:"displayName"` +} + +// CertificateWire is the wire form of a certificate. +type CertificateWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + TimeNotValidAfter time.Time `json:"timeNotValidAfter"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + Locks json.RawMessage `json:"locks,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + Certificate string `json:"certificate,omitempty"` + Intermediate string `json:"intermediateCertificates,omitempty"` + LifecycleState string `json:"lifecycleState"` + SubjectNames []string `json:"subjectNames"` +} + +func certificateView(c *Certificate, full bool) CertificateWire { + w := CertificateWire{ + TimeCreated: c.TimeCreated, TimeUpdated: c.TimeUpdated, TimeNotValidAfter: c.TimeNotValidAfter, + DefinedTags: c.Tags.DefinedTags, FreeformTags: c.Tags.FreeformTags, Locks: c.Locks, ID: c.ID, + CompartmentID: c.CompartmentID, DisplayName: c.DisplayName, LifecycleState: c.State, + SubjectNames: c.SubjectNames, + } + if full { + w.Certificate, w.Intermediate = c.CertPEM, c.Intermediate + } + + return w +} + +// parseCertificate checks that the PEM chain parses and that the private key matches the leaf; the key is not kept. +func parseCertificate(in createCertificateIn) (*x509.Certificate, *APIError) { + if in.Certificate == "" || in.PrivateKey == "" { + return nil, errMissing("certificate and privateKey are required") + } + + pair, err := tls.X509KeyPair([]byte(in.Certificate), []byte(in.PrivateKey)) + if err != nil { + return nil, errParam("certificate and privateKey are not a matching PEM pair: " + err.Error()) + } + + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return nil, errParam("certificate is not a valid X.509 certificate") + } + + if in.Intermediate != "" { + rest, n := []byte(in.Intermediate), 0 + + for { + var blk *pem.Block + + blk, rest = pem.Decode(rest) + if blk == nil { + break + } + + if _, perr := x509.ParseCertificate(blk.Bytes); perr != nil { + return nil, errParam("intermediateCertificates contains an invalid certificate") + } + + n++ + } + + if n == 0 { + return nil, errParam("intermediateCertificates contains no PEM certificate") + } + } + + return leaf, nil +} + +func subjectNames(c *x509.Certificate) []string { + names := []string{} + if c.Subject.CommonName != "" { + names = append(names, c.Subject.CommonName) + } + + for _, d := range c.DNSNames { + if d != c.Subject.CommonName { + names = append(names, d) + } + } + + return names +} + +func (b *InMemoryBackend) certNameTaken(compartment, name, except string) bool { + for _, c := range b.certificates { + if c.ID != except && c.CompartmentID == compartment && c.State != stateDeleted && + strs.Equal(c.DisplayName, name) { + return true + } + } + + return false +} + +// CreateCertificate stores a certificate after validating the PEM material. +func (b *InMemoryBackend) CreateCertificate(in createCertificateIn) (CertificateWire, string, string, error) { + b.mu.Lock("CreateCertificate") + defer b.mu.Unlock() + + if err := b.checkCompartment(in.CompartmentID); err != nil { + return CertificateWire{}, "", "", err + } + + leaf, err := parseCertificate(in) + if err != nil { + return CertificateWire{}, "", "", err + } + + now := b.nowUTC() + name := in.DisplayName + + if name == "" { + name = "certificate" + now.Format("20060102150405") + } + + if b.certNameTaken(in.CompartmentID, name, "") { + return CertificateWire{}, "", "", errConflict( + "a certificate named " + name + " already exists in this compartment", + ) + } + + c := &Certificate{ + ID: newOCID("apigatewaycertificate"), CompartmentID: in.CompartmentID, DisplayName: name, + CertPEM: in.Certificate, Intermediate: in.Intermediate, SubjectNames: subjectNames(leaf), + TimeNotValidAfter: leaf.NotAfter.UTC(), Locks: nullRaw(in.Locks), State: stateCreating, + TransitionAt: b.transitionAt(), TimeCreated: now, TimeUpdated: now, Etag: newEtag(), Tags: in.Tags, + } + b.certificates[c.ID] = c + wr := b.newWR(wrCreateCert, c.CompartmentID, entityCert, c.ID, actionCreated, *c.TransitionAt) + + return certificateView(c, true), wr, c.Etag, nil +} + +func (b *InMemoryBackend) liveCertificate(id string) (*Certificate, *APIError) { + c, ok := b.certificates[id] + if !ok { + return nil, errNotFound(id) + } + + return c, nil +} + +// GetCertificate returns one certificate and its etag. +func (b *InMemoryBackend) GetCertificate(id string) (CertificateWire, string, error) { + b.mu.RLock("GetCertificate") + defer b.mu.RUnlock() + + c, err := b.liveCertificate(id) + if err != nil { + return CertificateWire{}, "", err + } + + return certificateView(c, true), c.Etag, nil +} + +// ListCertificates lists the certificates of a compartment. +func (b *InMemoryBackend) ListCertificates( + compartment, name, state string, o ListOpts, +) (page.Page[CertificateWire], error) { + if compartment == "" { + return page.Page[CertificateWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListCertificates") + defer b.mu.RUnlock() + + var all []*Certificate + + for _, c := range b.certificates { + if c.CompartmentID != compartment || !stateMatches(c.State, state) || + (name != "" && !strs.Equal(c.DisplayName, name)) { + continue + } + + all = append(all, c) + } + + pg := paginate(all, o, func(c *Certificate) string { return c.DisplayName }, + func(c *Certificate) (time.Time, int64) { return c.TimeCreated, 0 }) + + out := make([]CertificateWire, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, certificateView(c, false)) + } + + return page.Page[CertificateWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateCertificate updates the display name and tags. +func (b *InMemoryBackend) UpdateCertificate(id, ifMatch string, in updateCertificateIn) (string, error) { + b.mu.Lock("UpdateCertificate") + defer b.mu.Unlock() + + c, err := b.liveCertificate(id) + if err != nil { + return "", err + } + + if c.State != stateActive { + return "", errState("certificate " + id + " is in state " + c.State) + } + + if err = checkEtag(c.Etag, ifMatch); err != nil { + return "", err + } + + if in.DisplayName != nil && *in.DisplayName != "" { + if b.certNameTaken(c.CompartmentID, *in.DisplayName, c.ID) { + return "", errConflict("a certificate named " + *in.DisplayName + " already exists in this compartment") + } + + c.DisplayName = *in.DisplayName + } + + c.Tags.merge(in.Tags) + c.State, c.TransitionAt = stateUpdating, b.transitionAt() + c.TimeUpdated, c.Etag = b.nowUTC(), newEtag() + + return b.newWR(wrUpdateCert, c.CompartmentID, entityCert, id, actionUpdated, *c.TransitionAt), nil +} + +// DeleteCertificate starts deleting a certificate that no gateway uses. +func (b *InMemoryBackend) DeleteCertificate(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteCertificate") + defer b.mu.Unlock() + + c, err := b.liveCertificate(id) + if err != nil { + return "", err + } + + if c.State != stateActive { + return "", errState("certificate " + id + " is in state " + c.State) + } + + if err = checkEtag(c.Etag, ifMatch); err != nil { + return "", err + } + + for _, g := range b.gateways { + if g.CertificateID == id && g.State != stateDeleted { + return "", errConflict("certificate " + id + " is used by gateway " + g.ID) + } + } + + c.State, c.TransitionAt = stateDeleting, b.transitionAt() + c.TimeUpdated = b.nowUTC() + + return b.newWR(wrDeleteCert, c.CompartmentID, entityCert, id, actionDeleted, *c.TransitionAt), nil +} + +// ChangeCertificateCompartment moves a certificate. +func (b *InMemoryBackend) ChangeCertificateCompartment(id, ifMatch string, in changeCompartmentIn) (string, error) { + b.mu.Lock("ChangeCertificateCompartment") + defer b.mu.Unlock() + + c, err := b.liveCertificate(id) + if err != nil { + return "", err + } + + if err = b.checkMove(c.Etag, ifMatch, in.CompartmentID); err != nil { + return "", err + } + + if b.certNameTaken(in.CompartmentID, c.DisplayName, c.ID) { + return "", errConflict("a certificate named " + c.DisplayName + " already exists in the target compartment") + } + + c.CompartmentID, c.TimeUpdated, c.Etag = in.CompartmentID, b.nowUTC(), newEtag() + + return b.newWR(wrUpdateCert, c.CompartmentID, entityCert, id, actionUpdated, b.nowUTC().Add(b.transition)), nil +} + +func (h *Handler) createCertificate(q *req) error { + return created(q, "/certificates/", func(w CertificateWire) string { return w.ID }, h.Backend.CreateCertificate) +} + +func (h *Handler) getCertificate(q *req) error { + w, etag, err := h.Backend.GetCertificate(q.param("certificateId")) + + return reply(q, w, etag, err) +} + +func (h *Handler) listCertificates(q *req) error { + pg, err := h.Backend.ListCertificates(q.query("compartmentId"), q.query("displayName"), + q.query("lifecycleState"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateCertificate(q *req) error { + return withBody(q, func(in updateCertificateIn) (string, error) { + return h.Backend.UpdateCertificate(q.param("certificateId"), q.ifMatch(), in) + }) +} + +func (h *Handler) deleteCertificate(q *req) error { + wr, err := h.Backend.DeleteCertificate(q.param("certificateId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeCertificateCompartment(q *req) error { + return withBody(q, func(in changeCompartmentIn) (string, error) { + return h.Backend.ChangeCertificateCompartment(q.param("certificateId"), q.ifMatch(), in) + }) +} diff --git a/services/ociapigateway/certs_test.go b/services/ociapigateway/certs_test.go new file mode 100644 index 0000000000..35f4d20f8e --- /dev/null +++ b/services/ociapigateway/certs_test.go @@ -0,0 +1,131 @@ +package ociapigateway_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/apigateway" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func selfSigned(t *testing.T, cn string) (string, string) { + t.Helper() + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: cn}, DNSNames: []string{cn, "alt." + cn}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), + } + + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &k.PublicKey, k) + require.NoError(t, err) + + kb, err := x509.MarshalECPrivateKey(k) + require.NoError(t, err) + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})) +} + +func TestCertificateLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + certPEM, keyPEM := selfSigned(t, "api.example.test") + + created, err := e.api.CreateCertificate(t.Context(), apigateway.CreateCertificateRequest{ + CompartmentId: new(testCompartment), Certificate: &certPEM, PrivateKey: &keyPEM, DisplayName: new("c1"), + }) + require.NoError(t, err) + require.NotNil(t, created.OpcWorkRequestId) + assert.Equal(t, []string{"api.example.test", "alt.api.example.test"}, created.SubjectNames) + assert.True(t, created.TimeNotValidAfter.After(time.Now())) + + e.settle() + + got, err := e.api.GetCertificate(t.Context(), apigateway.GetCertificateRequest{CertificateId: created.Id}) + require.NoError(t, err) + assert.Equal(t, apigateway.CertificateLifecycleStateActive, got.LifecycleState) + assert.Equal(t, certPEM, *got.Certificate.Certificate) + + list, err := e.api.ListCertificates( + t.Context(), + apigateway.ListCertificatesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + gw, err := e.gw.CreateGateway(t.Context(), apigateway.CreateGatewayRequest{ + CompartmentId: new(testCompartment), EndpointType: apigateway.GatewayEndpointTypePublic, + SubnetId: new(testSubnet), CertificateId: created.Id, + }) + require.NoError(t, err) + assert.Equal(t, *created.Id, *gw.CertificateId) + + _, err = e.api.DeleteCertificate(t.Context(), apigateway.DeleteCertificateRequest{CertificateId: created.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "certificate in use") + + _, err = e.api.UpdateCertificate(t.Context(), apigateway.UpdateCertificateRequest{ + CertificateId: created.Id, + DisplayName: new("c2"), + }) + require.NoError(t, err) + + e.settle() + + _, err = e.api.ChangeCertificateCompartment(t.Context(), apigateway.ChangeCertificateCompartmentRequest{ + CertificateId: created.Id, + CompartmentId: new(otherCompart), + }) + require.NoError(t, err) +} + +func TestCertificateValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + certPEM, keyPEM := selfSigned(t, "a.example.test") + _, otherKey := selfSigned(t, "b.example.test") + + tests := []struct { + name string + cert, key string + intermediate string + status int + }{ + {"mismatched key", certPEM, otherKey, "", 400}, + {"garbage cert", "nope", keyPEM, "", 400}, + {"missing key", certPEM, "", "", 400}, + {"bad intermediate", certPEM, keyPEM, "junk", 400}, + {"ok with intermediate", certPEM, keyPEM, certPEM, 202}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.api.CreateCertificate(t.Context(), apigateway.CreateCertificateRequest{ + CompartmentId: new(testCompartment), Certificate: &tt.cert, PrivateKey: &tt.key, + IntermediateCertificates: &tt.intermediate, DisplayName: new(tt.name), + }) + if tt.status == 202 { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.status, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} diff --git a/services/ociapigateway/control_test.go b/services/ociapigateway/control_test.go new file mode 100644 index 0000000000..59de06b0d5 --- /dev/null +++ b/services/ociapigateway/control_test.go @@ -0,0 +1,387 @@ +package ociapigateway_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/apigateway" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatewayLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + + created := e.createGateway(t, "gw1") + require.NotNil(t, created.OpcWorkRequestId) + assert.Equal(t, apigateway.GatewayLifecycleStateCreating, created.LifecycleState) + assert.Regexp(t, `^[0-9a-f]{26}\.apigateway\.us-ashburn-1\.oci\.customer-oci\.com$`, *created.Hostname) + + wr, err := e.wr.GetWorkRequest( + t.Context(), + apigateway.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Equal(t, apigateway.WorkRequestStatusInProgress, wr.Status) + assert.Equal(t, apigateway.WorkRequestOperationTypeCreateGateway, wr.OperationType) + + e.settle() + + wr, err = e.wr.GetWorkRequest( + t.Context(), + apigateway.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Equal(t, apigateway.WorkRequestStatusSucceeded, wr.Status) + + got, err := e.gw.GetGateway(t.Context(), apigateway.GetGatewayRequest{GatewayId: created.Id}) + require.NoError(t, err) + assert.Equal(t, apigateway.GatewayLifecycleStateActive, got.LifecycleState) + require.NotNil(t, got.Etag) + + upd, err := e.gw.UpdateGateway(t.Context(), apigateway.UpdateGatewayRequest{ + GatewayId: created.Id, IfMatch: got.Etag, + DisplayName: new("renamed"), FreeformTags: map[string]string{"k": "v"}, + }) + require.NoError(t, err) + require.NotNil(t, upd.OpcWorkRequestId) + + _, err = e.gw.UpdateGateway(t.Context(), apigateway.UpdateGatewayRequest{ + GatewayId: created.Id, DisplayName: new("again"), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "UPDATING gateway rejects updates") + + e.settle() + + got, err = e.gw.GetGateway(t.Context(), apigateway.GetGatewayRequest{GatewayId: created.Id}) + require.NoError(t, err) + assert.Equal(t, "renamed", *got.DisplayName) + assert.Equal(t, "v", got.FreeformTags["k"]) + + _, err = e.gw.UpdateGateway(t.Context(), apigateway.UpdateGatewayRequest{ + GatewayId: created.Id, IfMatch: new("stale"), + DisplayName: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + list, err := e.gw.ListGateways(t.Context(), apigateway.ListGatewaysRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + _, err = e.gw.DeleteGateway(t.Context(), apigateway.DeleteGatewayRequest{GatewayId: created.Id}) + require.NoError(t, err) + + e.settle() + + list, err = e.gw.ListGateways(t.Context(), apigateway.ListGatewaysRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, list.Items) + + deleted, err := e.gw.ListGateways(t.Context(), apigateway.ListGatewaysRequest{ + CompartmentId: new(testCompartment), LifecycleState: apigateway.GatewayLifecycleStateDeleted, + }) + require.NoError(t, err) + assert.Len(t, deleted.Items, 1) + + wrs, err := e.wr.ListWorkRequests(t.Context(), apigateway.ListWorkRequestsRequest{ + CompartmentId: new(testCompartment), ResourceId: created.Id, + }) + require.NoError(t, err) + assert.Len(t, wrs.Items, 3) +} + +func TestCreateGatewayValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.backend.SetSubnetChecker(func(id string) bool { return id == testSubnet }) + e.backend.SetCompartmentChecker(func(id string) bool { return id == testCompartment }) + + tests := []struct { + mutate func(*apigateway.CreateGatewayDetails) + name string + status int + }{ + { + func(d *apigateway.CreateGatewayDetails) { d.SubnetId = new("ocid1.subnet.oc1..nope") }, + "unknown subnet", + 404, + }, + {func(d *apigateway.CreateGatewayDetails) { d.CompartmentId = new(otherCompart) }, "unknown compartment", 404}, + {func(d *apigateway.CreateGatewayDetails) { d.CertificateId = new("ocid1.cert") }, "unknown certificate", 404}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + d := apigateway.CreateGatewayDetails{ + CompartmentId: new(testCompartment), EndpointType: apigateway.GatewayEndpointTypePrivate, + SubnetId: new(testSubnet), + } + tt.mutate(&d) + + _, err := e.gw.CreateGateway(t.Context(), apigateway.CreateGatewayRequest{CreateGatewayDetails: d}) + assert.Equal(t, tt.status, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + t.Run("duplicate name", func(t *testing.T) { + t.Parallel() + + e.createGateway(t, "dup") + + _, err := e.gw.CreateGateway(t.Context(), apigateway.CreateGatewayRequest{ + CompartmentId: new(testCompartment), EndpointType: apigateway.GatewayEndpointTypePublic, + SubnetId: new(testSubnet), DisplayName: new("DUP"), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + }) +} + +func TestChangeGatewayCompartment(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "mv") + + resp, err := e.gw.ChangeGatewayCompartment(t.Context(), apigateway.ChangeGatewayCompartmentRequest{ + GatewayId: gw.Id, + CompartmentId: new(otherCompart), + }) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + + got, err := e.gw.GetGateway(t.Context(), apigateway.GetGatewayRequest{GatewayId: gw.Id}) + require.NoError(t, err) + assert.Equal(t, otherCompart, *got.CompartmentId) +} + +func TestDeleteGatewayWithDeployment(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "busy") + dep := e.mkDeployment(t, *gw.Id, "/v1", stockSpec(200, "ok")) + + _, err := e.gw.DeleteGateway(t.Context(), apigateway.DeleteGatewayRequest{GatewayId: gw.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.dep.DeleteDeployment(t.Context(), apigateway.DeleteDeploymentRequest{DeploymentId: &dep}) + require.NoError(t, err) + + e.settle() + + _, err = e.gw.DeleteGateway(t.Context(), apigateway.DeleteGatewayRequest{GatewayId: gw.Id}) + require.NoError(t, err) +} + +func TestAuthEnforcement(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + e.createGateway(t, "signed") + + status, body, _ := rawDo(t, http.MethodGet, e.base+"/20190501/gateways?compartmentId="+testCompartment, "") + assert.Equal(t, http.StatusUnauthorized, status) + assert.Contains(t, body, "NotAuthenticated") +} + +func TestUnknownRoute(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + status, body, hdr := rawDo(t, http.MethodGet, e.base+"/20190501/nothing", "") + assert.Equal(t, http.StatusNotFound, status) + assert.Contains(t, body, `"code":"NotFound"`) + assert.NotEmpty(t, hdr.Get("Opc-Request-Id")) +} + +func TestDeploymentValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "val") + url := e.base + "/20190501/deployments" + + mk := func(prefix, spec string) string { + return `{"gatewayId":"` + *gw.Id + `","compartmentId":"` + testCompartment + `","pathPrefix":"` + prefix + + `","specification":` + spec + `}` + } + route := func(path, backend string) string { + return `{"routes":[{"path":"` + path + `","methods":["GET"],"backend":` + backend + `}]}` + } + stock := `{"type":"STOCK_RESPONSE_BACKEND","status":200}` + + tests := []struct { + name string + body string + status int + }{ + {"prefix no slash", mk("v1", route("/a", stock)), 400}, + {"prefix trailing slash", mk("/v1/", route("/a", stock)), 400}, + {"prefix parameter", mk("/{x}", route("/a", stock)), 400}, + {"adjacent slashes", mk("/a//b", route("/a", stock)), 400}, + {"route no slash", mk("/v1", route("a", stock)), 400}, + {"wildcard not last", mk("/v1", route("/a/{x*}/b", stock)), 400}, + {"bad param name", mk("/v1", route("/a/{x-y}", stock)), 400}, + {"bad method", mk("/v1", `{"routes":[{"path":"/a","methods":["FETCH"],"backend":`+stock+`}]}`), 400}, + {"stock bad status", mk("/v1", route("/a", `{"type":"STOCK_RESPONSE_BACKEND","status":99}`)), 400}, + {"http bad url", mk("/v1", route("/a", `{"type":"HTTP_BACKEND","url":"ftp://x"}`)), 400}, + {"function bad id", mk("/v1", route("/a", `{"type":"ORACLE_FUNCTIONS_BACKEND","functionId":"x"}`)), 400}, + {"unknown backend", mk("/v1", route("/a", `{"type":"NOPE"}`)), 400}, + {"oauth2 backend unsupported", mk("/v1", route("/a", `{"type":"OAUTH2_LOGIN_BACKEND"}`)), 400}, + {"missing backend", mk("/v1", `{"routes":[{"path":"/a"}]}`), 400}, + {"duplicate routes", mk("/v1", `{"routes":[{"path":"/a/{x}","methods":["GET"],"backend":`+stock+ + `},{"path":"/a/{y}","methods":["GET"],"backend":`+stock+`}]}`), 400}, + { + "usage plans unsupported", + mk("/v1", `{"requestPolicies":{"usagePlans":{"tokenLocations":["request.headers[t]"]}},"routes":[]}`), + 400, + }, + { + "rate limit zero", + mk( + "/v1", + `{"requestPolicies":{"rateLimiting":{"rateKey":"TOTAL","rateInRequestsPerSecond":0}},"routes":[]}`, + ), + 400, + }, + {"cors no origins", mk("/v1", `{"requestPolicies":{"cors":{"allowedOrigins":[]}},"routes":[]}`), 400}, + { + "jwt without keys", + mk( + "/v1", + `{"requestPolicies":{"authentication":{"type":"JWT_AUTHENTICATION",`+ + `"issuers":["i"],"audiences":["a"]}},"routes":[]}`, + ), + 400, + }, + { + "remote discovery unsupported", + mk( + "/v1", + `{"requestPolicies":{"authentication":{"type":"TOKEN_AUTHENTICATION","tokenHeader":"Authorization",`+ + `"tokenAuthScheme":"Bearer","validationPolicy":{"type":"REMOTE_DISCOVERY"}}},"routes":[]}`, + ), + 400, + }, + {"anonymous route needs anonymous access", mk("/v1", `{"routes":[{"path":"/a","backend":`+stock+ + `,"requestPolicies":{"authorization":{"type":"ANONYMOUS"}}}]}`), 400}, + { + "unknown gateway", + strings.Replace(mk("/v1", route("/a", stock)), *gw.Id, "ocid1.apigateway.oc1..none", 1), + 404, + }, + { + "missing spec", + `{"gatewayId":"` + *gw.Id + `","compartmentId":"` + testCompartment + `","pathPrefix":"/v1"}`, + 400, + }, + {"ok", mk("/ok", route("/a/{x}", stock)), 202}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body, _ := rawDo(t, http.MethodPost, url, tt.body) + assert.Equal(t, tt.status, status, body) + }) + } +} + +func TestDeploymentCRUD(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + gw := e.mkGateway(t, "crud") + + resp, err := e.createDeployment(t, *gw.Id, "/v1", specFromJSON(t, stockSpec(200, "one"))) + require.NoError(t, err) + assert.Equal(t, apigateway.DeploymentLifecycleStateCreating, resp.LifecycleState) + assert.Equal(t, "https://"+*gw.Hostname+"/v1", *resp.Endpoint) + + _, err = e.createDeployment(t, *gw.Id, "/v1", specFromJSON(t, stockSpec(200, "dup"))) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.createDeployment(t, *gw.Id, "/", specFromJSON(t, stockSpec(200, "root"))) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "root prefix excludes other deployments") + + e.settle() + + got, err := e.dep.GetDeployment(t.Context(), apigateway.GetDeploymentRequest{DeploymentId: resp.Id}) + require.NoError(t, err) + require.Len(t, got.Specification.Routes, 1) + assert.Equal(t, "/hi", *got.Specification.Routes[0].Path) + + _, err = e.dep.UpdateDeployment(t.Context(), apigateway.UpdateDeploymentRequest{ + DeploymentId: resp.Id, IfMatch: got.Etag, + DisplayName: new("renamed"), Specification: specFromJSON(t, stockSpec(201, "two")), + }) + require.NoError(t, err) + + e.settle() + + assert.Equal(t, 201, e.call(t, *gw.Id, http.MethodGet, "/v1/hi", nil, "").status) + + list, err := e.dep.ListDeployments(t.Context(), apigateway.ListDeploymentsRequest{ + CompartmentId: new(testCompartment), GatewayId: gw.Id, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, "renamed", *list.Items[0].DisplayName) + + _, err = e.dep.ChangeDeploymentCompartment(t.Context(), apigateway.ChangeDeploymentCompartmentRequest{ + DeploymentId: resp.Id, + CompartmentId: new(otherCompart), + }) + require.NoError(t, err) + + _, err = e.dep.DeleteDeployment(t.Context(), apigateway.DeleteDeploymentRequest{DeploymentId: resp.Id}) + require.NoError(t, err) + + e.settle() + + assert.Equal(t, http.StatusNotFound, e.call(t, *gw.Id, http.MethodGet, "/v1/hi", nil, "").status) +} + +func TestCreateGatewayRawValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + url := e.base + "/20190501/gateways" + + tests := []struct { + name, body string + }{ + { + "bad endpoint type", + `{"compartmentId":"` + testCompartment + `","endpointType":"WEIRD","subnetId":"` + testSubnet + `"}`, + }, + { + "bad ip mode", + `{"compartmentId":"` + testCompartment + `","endpointType":"PUBLIC","subnetId":"` + testSubnet + `","ipMode":"X"}`, + }, + {"missing subnet", `{"compartmentId":"` + testCompartment + `","endpointType":"PUBLIC"}`}, + { + "response cache unsupported", + `{"compartmentId":"` + testCompartment + `","endpointType":"PUBLIC","subnetId":"` + testSubnet + + `","responseCacheDetails":{"type":"EXTERNAL_RESP_CACHE"}}`, + }, + {"not json", `{`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body, _ := rawDo(t, http.MethodPost, url, tt.body) + assert.Equal(t, http.StatusBadRequest, status, body) + }) + } +} diff --git a/services/ociapigateway/dataplane.go b/services/ociapigateway/dataplane.go new file mode 100644 index 0000000000..24794a4f73 --- /dev/null +++ b/services/ociapigateway/dataplane.go @@ -0,0 +1,273 @@ +package ociapigateway + +import ( + "encoding/json" + "io" + "maps" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const maxDataBody = 10 << 20 + +// target is the deployment a data-plane request resolved to. +type target struct { + spec *compiledSpec + depID string + rel string + gateway string +} + +func writeGWError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + out, _ := json.Marshal(map[string]any{"code": status, "message": msg}) //nolint:errchkjson // static shape + _, _ = w.Write(out) +} + +// resolveTarget maps a request to a deployment by gateway hostname (Host header) or by /_apigw/ path. +func (b *InMemoryBackend) resolveTarget(r *http.Request) (target, bool) { + path := r.URL.Path + gwRef, byID := "", false + + if rest, ok := strings.CutPrefix(path, dataPathPrefix); ok { + var tail string + + gwRef, tail, _ = strings.Cut(rest, "/") + path, byID = "/"+tail, true + } + + b.mu.RLock("resolveTarget") + defer b.mu.RUnlock() + + gwID := b.findGateway(gwRef, stripPort(r.Host), byID) + if gwID == "" { + return target{}, false + } + + best := b.findDeployment(gwID, path) + if best == nil || b.specs[best.ID] == nil { + return target{}, false + } + + rel := strings.TrimPrefix(path, strings.TrimSuffix(best.PathPrefix, "/")) + if rel == "" { + rel = "/" + } + + return target{spec: b.specs[best.ID], depID: best.ID, rel: rel, gateway: gwID}, true +} + +func servable(state string) bool { return state == stateActive || state == stateUpdating } + +func (b *InMemoryBackend) findGateway(ref, host string, byID bool) string { + for _, g := range b.gateways { + if !servable(g.State) { + continue + } + + if (byID && g.ID == ref) || (!byID && strs.Equal(g.Hostname, host)) { + return g.ID + } + } + + return "" +} + +// findDeployment returns the servable deployment with the longest pathPrefix matching the path. +func (b *InMemoryBackend) findDeployment(gwID, path string) *Deployment { + var best *Deployment + + for _, d := range b.deployments { + if d.GatewayID != gwID || !servable(d.State) || !prefixMatches(d.PathPrefix, path) { + continue + } + + if best == nil || len(d.PathPrefix) > len(best.PathPrefix) { + best = d + } + } + + return best +} + +func prefixMatches(prefix, path string) bool { + return prefix == "/" || path == prefix || strings.HasPrefix(path, prefix+"/") +} + +// ServeData serves a request addressed to a deployed API. +func (b *InMemoryBackend) ServeData(w http.ResponseWriter, r *http.Request) { + tgt, ok := b.resolveTarget(r) + if !ok { + writeGWError(w, http.StatusNotFound, "Not Found") + + return + } + + rp := tgt.spec.doc.RequestPolicies + + if r.Method == http.MethodOptions && r.Header.Get("Origin") != "" && + r.Header.Get("Access-Control-Request-Method") != "" { + if c := effectiveCors(rp, tgt.spec.anyRouteFor(tgt.rel)); c != nil { + preflight(w, r, c) + + return + } + } + + m := tgt.spec.match(tgt.rel, r.Method) + if m.route == nil { + if len(m.allow) > 0 { + w.Header().Set("Allow", strings.Join(m.allow, ", ")) + writeGWError(w, http.StatusMethodNotAllowed, "Method Not Allowed") + + return + } + + writeGWError(w, http.StatusNotFound, "Not Found") + + return + } + + b.serveRoute(w, r, tgt, m) +} + +func effectiveCors(rp *requestPolicies, route *compiledRoute) *corsDoc { + if route != nil && route.doc.RequestPolicies != nil && route.doc.RequestPolicies.Cors != nil { + return route.doc.RequestPolicies.Cors + } + + if rp != nil { + return rp.Cors + } + + return nil +} + +func (b *InMemoryBackend) serveRoute(w http.ResponseWriter, r *http.Request, tgt target, m matchResult) { + rp := tgt.spec.doc.RequestPolicies + cors := effectiveCors(rp, m.route) + origin := r.Header.Get("Origin") + + if rp != nil && rp.RateLimiting != nil { + key := tgt.depID + "|total" + if rp.RateLimiting.Key == rateKeyClient { + key = tgt.depID + "|" + clientKey(r) + } + + if ok, retry := b.limiter.allow(key, *rp.RateLimiting.Rate, b.clock()); !ok { + w.Header().Set("Retry-After", strconv.Itoa(retry)) + corsHeaders(w.Header(), cors, origin) + writeGWError(w, http.StatusTooManyRequests, "Too Many Requests") + + return + } + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxDataBody)) + if err != nil { + writeGWError(w, http.StatusRequestEntityTooLarge, "Request Entity Too Large") + + return + } + + rv := &reqView{params: m.params, query: cloneValues(r.URL.Query()), header: r.Header.Clone()} + + if d := b.authorize(r, rp, m.route, rv); d.status != 0 { + if d.www != "" { + w.Header().Set("WWW-Authenticate", d.www) + } + + corsHeaders(w.Header(), cors, origin) + writeGWError(w, d.status, d.msg) + + return + } + + if pol := m.route.doc.RequestPolicies; pol != nil { + transformHeaders(rv.header, pol.HeaderTransform, rv) + transformQuery(rv.query, pol.QueryTransform, rv) + } + + res := b.runBackend(r.Context(), m.route.doc.Backend, r, rv, body) + if res.err != nil { + corsHeaders(w.Header(), cors, origin) + writeGWError(w, http.StatusBadGateway, "Bad Gateway") + + return + } + + defer res.body.Close() + + if pol := m.route.doc.ResponsePolicies; pol != nil { + transformHeaders(res.header, pol.HeaderTransform, rv) + } + + corsHeaders(res.header, cors, origin) + + maps.Copy(w.Header(), res.header) + + w.WriteHeader(res.status) + + if r.Method != http.MethodHead { + _, _ = io.Copy(w, res.body) + } +} + +func cloneValues(v url.Values) url.Values { + out := make(url.Values, len(v)) + for k, vs := range v { + out[k] = append([]string(nil), vs...) + } + + return out +} + +// denial is an authentication or authorization refusal; a zero status means the request may proceed. +type denial struct { + www string + msg string + status int +} + +func (b *InMemoryBackend) authorize(r *http.Request, rp *requestPolicies, route *compiledRoute, rv *reqView) denial { + if rp == nil || rp.Authentication == nil { + return denial{} + } + + var authz *authzDoc + + if route.doc.RequestPolicies != nil { + authz = route.doc.RequestPolicies.Authorization + } + + if authz != nil && authz.Type == authzAnonymous { + return denial{} + } + + res, anonymous := b.authenticate(r.Context(), rp.Authentication, r, rv) + if !res.ok { + return denial{status: http.StatusUnauthorized, www: res.wwwAuth, msg: "Unauthorized"} + } + + if authz != nil && authz.Type == anyOf && (anonymous || !hasAnyScope(res.scopes, authz.AllowedScope)) { + return denial{status: http.StatusForbidden, msg: "Forbidden"} + } + + return denial{} +} + +func hasAnyScope(have, allowed []string) bool { + for _, s := range have { + if containsStr(allowed, s) { + return true + } + } + + return false +} diff --git a/services/ociapigateway/dataplane_test.go b/services/ociapigateway/dataplane_test.go new file mode 100644 index 0000000000..fef97d428b --- /dev/null +++ b/services/ociapigateway/dataplane_test.go @@ -0,0 +1,458 @@ +package ociapigateway_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ociapigateway "github.com/blackbirdworks/gopherstack/services/ociapigateway" +) + +func stock(path, methods, body string) string { + return `{"path":"` + path + `","methods":[` + methods + `],` + + `"backend":{"type":"STOCK_RESPONSE_BACKEND","status":200,"body":"` + body + `"}}` +} + +func TestRoutePrecedence(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "prec") + e.mkDeployment(t, *gw.Id, "/api", `{"routes":[`+ + stock("/users/me", `"GET"`, "literal")+`,`+ + stock("/users/{id}", `"GET"`, "param")+`,`+ + stock("/users/{id}", `"DELETE"`, "param-delete")+`,`+ + stock("/users/{id}/posts/{post}", `"ANY"`, "two-params")+`,`+ + stock("/files/{rest*}", `"GET"`, "wild")+`,`+ + stock("/files/readme", `"GET"`, "exact-file")+`,`+ + stock("/", `"GET"`, "root")+`,`+ + stock("/any", `"ANY"`, "any")+`]}`) + + tests := []struct { + name string + method string + path string + body string + allow string + status int + }{ + {"literal beats param", "GET", "/api/users/me", "literal", "", 200}, + {"param", "GET", "/api/users/42", "param", "", 200}, + {"param other method", "DELETE", "/api/users/42", "param-delete", "", 200}, + {"method not allowed", "POST", "/api/users/42", "", "DELETE, GET", 405}, + {"two params", "PUT", "/api/users/1/posts/2", "two-params", "", 200}, + {"wildcard multi segment", "GET", "/api/files/a/b/c", "wild", "", 200}, + {"wildcard zero segments", "GET", "/api/files", "wild", "", 200}, + {"exact beats wildcard", "GET", "/api/files/readme", "exact-file", "", 200}, + {"root", "GET", "/api", "root", "", 200}, + {"root slash", "GET", "/api/", "root", "", 200}, + {"any method", "PATCH", "/api/any", "any", "", 200}, + {"no route", "GET", "/api/nothing/here", "", "", 404}, + {"outside prefix", "GET", "/other/users/me", "", "", 404}, + {"prefix is segment bounded", "GET", "/apiary/users/me", "", "", 404}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := e.call(t, *gw.Id, tt.method, tt.path, nil, "") + assert.Equal(t, tt.status, r.status) + + if tt.status == 200 { + assert.Equal(t, tt.body, r.body) + } + + if tt.allow != "" { + assert.Equal(t, tt.allow, r.header.Get("Allow")) + } + }) + } +} + +func TestHostHeaderRouting(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "host") + e.mkDeployment(t, *gw.Id, "/v1", stockSpec(200, "by-host")) + + r := e.callURL(t, e.base+"/v1/hi", *gw.Hostname, http.MethodGet, nil, "") + assert.Equal(t, 200, r.status) + assert.Equal(t, "by-host", r.body) + + r = e.callURL( + t, + e.base+"/v1/hi", + "unknown123.apigateway.us-ashburn-1.oci.customer-oci.com", + http.MethodGet, + nil, + "", + ) + assert.Equal(t, 404, r.status) + + r = e.call(t, "ocid1.apigateway.oc1..missing", http.MethodGet, "/v1/hi", nil, "") + assert.Equal(t, 404, r.status) +} + +func TestInactiveGatewayDoesNotServe(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + id := *e.createGateway(t, "booting").Id + + assert.Equal(t, 404, e.call(t, id, http.MethodGet, "/v1/hi", nil, "").status) +} + +func TestStockResponseHeaders(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "stock") + e.mkDeployment(t, *gw.Id, "/s", `{"routes":[{"path":"/echo/{name}","methods":["GET"],"backend":{ + "type":"STOCK_RESPONSE_BACKEND","status":202,"body":"hello ${request.path[name]}", + "headers":[{"name":"X-Stock","value":"yes"}]}}]}`) + + r := e.call(t, *gw.Id, http.MethodGet, "/s/echo/bob", nil, "") + assert.Equal(t, 202, r.status) + assert.Equal(t, "hello bob", r.body) + assert.Equal(t, "yes", r.header.Get("X-Stock")) +} + +func TestHTTPBackend(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + seen []string + ) + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + mu.Lock() + seen = append(seen, r.Method+" "+r.URL.RequestURI()+" body="+string(body)+" h="+r.Header.Get("X-Client")) + mu.Unlock() + + if r.URL.Path == "/redir" { + http.Redirect(w, r, "/elsewhere", http.StatusFound) + + return + } + + w.Header().Set("X-Backend", "1") + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("from-backend")) + })) + t.Cleanup(backend.Close) + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "http") + e.mkDeployment(t, *gw.Id, "/p", `{"routes":[ + {"path":"/svc/{rest*}","methods":["ANY"], + "backend":{"type":"HTTP_BACKEND","url":"`+backend.URL+`/base/${request.path[rest]}"}}, + {"path":"/redir","methods":["GET"],"backend":{"type":"HTTP_BACKEND","url":"`+backend.URL+`/redir"}}, + {"path":"/slow","methods":["GET"], + "backend":{"type":"HTTP_BACKEND","url":"http://192.0.2.10:9/x","isSslVerifyDisabled":true}}]}`) + + r := e.call(t, *gw.Id, http.MethodPost, "/p/svc/a/b?x=1&y=two", map[string]string{"X-Client": "c1"}, "payload") + assert.Equal(t, http.StatusTeapot, r.status) + assert.Equal(t, "from-backend", r.body) + assert.Equal(t, "1", r.header.Get("X-Backend")) + + mu.Lock() + assert.Contains(t, seen, "POST /base/a/b?x=1&y=two body=payload h=c1") + mu.Unlock() + + red := e.call(t, *gw.Id, http.MethodGet, "/p/redir", nil, "") + assert.Equal(t, http.StatusFound, red.status, "redirects are passed to the client, never followed") + assert.Equal(t, "/elsewhere", red.header.Get("Location")) + + blocked := e.call(t, *gw.Id, http.MethodGet, "/p/slow", nil, "") + assert.Equal(t, http.StatusBadGateway, blocked.status, "non-loopback destinations are refused by default") +} + +var errBoom = errors.New("boom") + +type fakeInvoker struct { + fn func(id string, in ociapigateway.FunctionRequest) (ociapigateway.FunctionResponse, error) + logs []string + mu sync.Mutex +} + +func (f *fakeInvoker) InvokeFunction( + _ context.Context, id string, in ociapigateway.FunctionRequest, +) (ociapigateway.FunctionResponse, error) { + f.mu.Lock() + f.logs = append(f.logs, id+" "+in.Method+" "+string(in.Body)) + f.mu.Unlock() + + return f.fn(id, in) +} + +func TestFunctionsBackend(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + inv := &fakeInvoker{fn: func(id string, in ociapigateway.FunctionRequest) (ociapigateway.FunctionResponse, error) { + if id == "ocid1.fnfunc.oc1.iad.broken" { + return ociapigateway.FunctionResponse{}, errBoom + } + + return ociapigateway.FunctionResponse{ + Status: 201, Header: http.Header{"X-Fn": {"1"}}, Body: []byte("fn:" + string(in.Body)), + }, nil + }} + e.backend.SetFunctionInvoker(inv) + + gw := e.mkGateway(t, "fn") + e.mkDeployment(t, *gw.Id, "/f", `{"routes":[ + {"path":"/run","methods":["POST"],"backend":{"type":"ORACLE_FUNCTIONS_BACKEND","functionId":"`+testFunction+`"}}, + {"path":"/bad","methods":["POST"], + "backend":{"type":"ORACLE_FUNCTIONS_BACKEND","functionId":"ocid1.fnfunc.oc1.iad.broken"}}]}`) + + r := e.call(t, *gw.Id, http.MethodPost, "/f/run", nil, "ping") + assert.Equal(t, 201, r.status) + assert.Equal(t, "fn:ping", r.body) + assert.Equal(t, "1", r.header.Get("X-Fn")) + + assert.Equal(t, http.StatusBadGateway, e.call(t, *gw.Id, http.MethodPost, "/f/bad", nil, "").status) +} + +func TestFunctionsBackendUnwired(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "nofn") + e.mkDeployment( + t, + *gw.Id, + "/f", + `{"routes":[{"path":"/run","backend":{"type":"ORACLE_FUNCTIONS_BACKEND","functionId":"`+testFunction+`"}}]}`, + ) + + assert.Equal(t, http.StatusBadGateway, e.call(t, *gw.Id, http.MethodGet, "/f/run", nil, "").status) +} + +func TestCORS(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "cors") + e.mkDeployment(t, *gw.Id, "/c", `{ + "requestPolicies":{"cors":{"allowedOrigins":["https://app.example.test"],"allowedMethods":["GET","POST"], + "allowedHeaders":["X-Custom"],"exposedHeaders":["X-Out"],"isAllowCredentialsEnabled":true,"maxAgeInSeconds":600}}, + "routes":[`+stock("/r", `"GET"`, "ok")+`, + {"path":"/open","methods":["GET"],"backend":{"type":"STOCK_RESPONSE_BACKEND","status":200}, + "requestPolicies":{"cors":{"allowedOrigins":["*"]}}}]}`) + + pre := e.call(t, *gw.Id, http.MethodOptions, "/c/r", map[string]string{ + "Origin": "https://app.example.test", "Access-Control-Request-Method": "POST", + }, "") + assert.Equal(t, 200, pre.status) + assert.Equal(t, "https://app.example.test", pre.header.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "GET, POST", pre.header.Get("Access-Control-Allow-Methods")) + assert.Equal(t, "X-Custom", pre.header.Get("Access-Control-Allow-Headers")) + assert.Equal(t, "600", pre.header.Get("Access-Control-Max-Age")) + assert.Equal(t, "true", pre.header.Get("Access-Control-Allow-Credentials")) + + deniedPre := e.call(t, *gw.Id, http.MethodOptions, "/c/r", map[string]string{ + "Origin": "https://evil.example.test", "Access-Control-Request-Method": "GET", + }, "") + assert.Empty(t, deniedPre.header.Get("Access-Control-Allow-Origin")) + + actual := e.call(t, *gw.Id, http.MethodGet, "/c/r", map[string]string{"Origin": "https://app.example.test"}, "") + assert.Equal(t, "https://app.example.test", actual.header.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "X-Out", actual.header.Get("Access-Control-Expose-Headers")) + + other := e.call(t, *gw.Id, http.MethodGet, "/c/r", map[string]string{"Origin": "https://evil.example.test"}, "") + assert.Empty(t, other.header.Get("Access-Control-Allow-Origin")) + + open := e.call( + t, + *gw.Id, + http.MethodGet, + "/c/open", + map[string]string{"Origin": "https://anything.example.test"}, + "", + ) + assert.Equal(t, "*", open.header.Get("Access-Control-Allow-Origin"), "route policy overrides the deployment policy") +} + +func TestRateLimiting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + }{ + {"total", "TOTAL"}, + {"client ip", "CLIENT_IP"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "rl") + e.mkDeployment(t, *gw.Id, "/r", `{"requestPolicies":{"rateLimiting":{"rateKey":"`+tt.key+ + `","rateInRequestsPerSecond":2}},"routes":[`+stock("/x", `"GET"`, "ok")+`]}`) + + codes := make([]int, 0, 3) + for range 3 { + codes = append(codes, e.call(t, *gw.Id, http.MethodGet, "/r/x", nil, "").status) + } + + assert.Equal(t, []int{200, 200, 429}, codes) + + limited := e.call(t, *gw.Id, http.MethodGet, "/r/x", nil, "") + assert.Equal(t, 429, limited.status) + assert.Equal(t, "1", limited.header.Get("Retry-After")) + + e.clock.Advance(time1s) + assert.Equal( + t, + 200, + e.call(t, *gw.Id, http.MethodGet, "/r/x", nil, "").status, + "tokens refill on the clock", + ) + }) + } +} + +func TestTransformations(t *testing.T) { + t.Parallel() + + var got struct { + h http.Header + uri string + sync.Mutex + } + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.Lock() + got.uri, got.h = r.URL.RequestURI(), r.Header.Clone() + got.Unlock() + + w.Header().Set("X-Internal", "secret") + w.Header().Set("X-Keep", "k") + })) + t.Cleanup(backend.Close) + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "tx") + e.mkDeployment(t, *gw.Id, "/t", `{"routes":[{"path":"/go/{id}","methods":["GET"], + "backend":{"type":"HTTP_BACKEND","url":"`+backend.URL+`/svc"}, + "requestPolicies":{ + "headerTransformations":{ + "filterHeaders":{"type":"BLOCK","items":[{"name":"X-Drop"}]}, + "renameHeaders":{"items":[{"from":"X-Old","to":"X-New"}]}, + "setHeaders":{"items":[ + {"name":"X-Id","values":["${request.path[id]}"]}, + {"name":"X-Fixed","values":["first"],"ifExists":"SKIP"}, + {"name":"X-Multi","values":["b"],"ifExists":"APPEND"}, + {"name":"X-Over","values":["new"]}]}}, + "queryParameterTransformations":{ + "filterQueryParameters":{"type":"BLOCK","items":[{"name":"secret"}]}, + "renameQueryParameters":{"items":[{"from":"q","to":"query"}]}, + "setQueryParameters":{"items":[{"name":"src","values":["gw"]}]}}}, + "responsePolicies":{"headerTransformations":{ + "filterHeaders":{"type":"BLOCK","items":[{"name":"X-Internal"}]}, + "setHeaders":{"items":[{"name":"X-Gw","values":["1"]}]}}}}]}`) + + r := e.call(t, *gw.Id, http.MethodGet, "/t/go/77?q=v&secret=s", map[string]string{ + "X-Drop": "d", "X-Old": "o", "X-Fixed": "keep", "X-Multi": "a", "X-Over": "old", + }, "") + assert.Equal(t, 200, r.status) + assert.Empty(t, r.header.Get("X-Internal")) + assert.Equal(t, "k", r.header.Get("X-Keep")) + assert.Equal(t, "1", r.header.Get("X-Gw")) + + got.Lock() + defer got.Unlock() + + assert.Empty(t, got.h.Get("X-Drop")) + assert.Empty(t, got.h.Get("X-Old")) + assert.Equal(t, "o", got.h.Get("X-New")) + assert.Equal(t, "77", got.h.Get("X-Id")) + assert.Equal(t, "keep", got.h.Get("X-Fixed")) + assert.Equal(t, []string{"a", "b"}, got.h.Values("X-Multi")) + assert.Equal(t, "new", got.h.Get("X-Over")) + assert.Contains(t, got.uri, "query=v") + assert.Contains(t, got.uri, "src=gw") + assert.NotContains(t, got.uri, "secret") +} + +func TestDynamicRouting(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "dyn") + e.mkDeployment(t, *gw.Id, "/d", `{"routes":[{"path":"/x","methods":["GET"],"backend":{ + "type":"DYNAMIC_ROUTING_BACKEND","selectionSource":{"type":"SINGLE","selector":"request.headers[X-Tier]"}, + "routingBackends":[ + {"key":{"type":"ANY_OF","name":"gold","values":["gold","platinum"]}, + "backend":{"type":"STOCK_RESPONSE_BACKEND","status":200,"body":"gold"}}, + {"key":{"type":"WILDCARD","name":"beta","expression":"beta-*"}, + "backend":{"type":"STOCK_RESPONSE_BACKEND","status":200,"body":"beta"}}, + {"key":{"type":"WILDCARD","name":"any","expression":"+"}, + "backend":{"type":"STOCK_RESPONSE_BACKEND","status":200,"body":"nonempty"}}, + {"key":{"type":"ANY_OF","name":"default","isDefault":true}, + "backend":{"type":"STOCK_RESPONSE_BACKEND","status":200,"body":"default"}}]}}]}`) + + tests := []struct { + name, tier, want string + }{ + {"exact", "gold", "gold"}, + {"exact second", "platinum", "gold"}, + {"wildcard prefix", "beta-7", "beta"}, + {"default", "", "default"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hdr := map[string]string{} + if tt.tier != "" { + hdr["X-Tier"] = tt.tier + } + + assert.Equal(t, tt.want, e.call(t, *gw.Id, http.MethodGet, "/d/x", hdr, "").body) + }) + } +} + +func TestSpecRoundTrip(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "rt") + id := e.mkDeployment(t, *gw.Id, "/rt", stockSpec(200, "x")) + + status, body, _ := rawDo(t, http.MethodGet, e.base+"/20190501/deployments/"+id, "") + require.Equal(t, 200, status) + + var out struct { + Specification struct { + Routes []struct { + Backend struct { + Type string `json:"type"` + } `json:"backend"` + } `json:"routes"` + } `json:"specification"` + } + + require.NoError(t, json.Unmarshal([]byte(body), &out)) + assert.Equal(t, "STOCK_RESPONSE_BACKEND", out.Specification.Routes[0].Backend.Type) + assert.NotContains(t, body, "StubResponse") +} diff --git a/services/ociapigateway/deployments.go b/services/ociapigateway/deployments.go new file mode 100644 index 0000000000..e0b3aa781f --- /dev/null +++ b/services/ociapigateway/deployments.go @@ -0,0 +1,300 @@ +package ociapigateway + +import ( + "encoding/json" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +type createDeploymentIn struct { + Tags + GatewayID string `json:"gatewayId"` + CompartmentID string `json:"compartmentId"` + PathPrefix string `json:"pathPrefix"` + DisplayName string `json:"displayName"` + Locks json.RawMessage `json:"locks"` + Specification json.RawMessage `json:"specification"` +} + +type updateDeploymentIn struct { + Tags + DisplayName *string `json:"displayName"` + Specification json.RawMessage `json:"specification"` +} + +// DeploymentWire is the wire form of a deployment. +type DeploymentWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + ID string `json:"id"` + GatewayID string `json:"gatewayId"` + CompartmentID string `json:"compartmentId"` + PathPrefix string `json:"pathPrefix"` + Endpoint string `json:"endpoint"` + DisplayName string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + Locks json.RawMessage `json:"locks,omitempty"` + Specification json.RawMessage `json:"specification,omitempty"` +} + +func (b *InMemoryBackend) deploymentView(d *Deployment, full bool) DeploymentWire { + w := DeploymentWire{ + TimeCreated: d.TimeCreated, TimeUpdated: d.TimeUpdated, DefinedTags: d.Tags.DefinedTags, + FreeformTags: d.Tags.FreeformTags, Locks: d.Locks, ID: d.ID, GatewayID: d.GatewayID, + CompartmentID: d.CompartmentID, PathPrefix: d.PathPrefix, DisplayName: d.DisplayName, LifecycleState: d.State, + } + if g, ok := b.gateways[d.GatewayID]; ok { + w.Endpoint = "https://" + g.Hostname + d.PathPrefix + } + + if full { + w.Specification = d.Spec + } + + return w +} + +func (b *InMemoryBackend) prefixTaken(gatewayID, prefix, except string) bool { + for _, d := range b.deployments { + if d.ID == except || d.GatewayID != gatewayID || d.State == stateDeleted { + continue + } + + if d.PathPrefix == prefix || d.PathPrefix == "/" || prefix == "/" { + return true + } + } + + return false +} + +// CreateDeployment creates a deployment in CREATING state. +func (b *InMemoryBackend) CreateDeployment(in createDeploymentIn) (DeploymentWire, string, string, error) { + b.mu.Lock("CreateDeployment") + defer b.mu.Unlock() + + if err := b.checkCompartment(in.CompartmentID); err != nil { + return DeploymentWire{}, "", "", err + } + + if in.GatewayID == "" { + return DeploymentWire{}, "", "", errMissing("gatewayId is required") + } + + g, ok := b.gateways[in.GatewayID] + if !ok || g.State == stateDeleted { + return DeploymentWire{}, "", "", errNotFound(in.GatewayID) + } + + if g.State == stateDeleting { + return DeploymentWire{}, "", "", errState("gateway " + g.ID + " is being deleted") + } + + if err := validatePathPrefix(in.PathPrefix); err != nil { + return DeploymentWire{}, "", "", err + } + + cs, err := compileSpec(in.Specification) + if err != nil { + return DeploymentWire{}, "", "", err + } + + if b.prefixTaken(g.ID, in.PathPrefix, "") { + return DeploymentWire{}, "", "", errConflict( + "pathPrefix " + in.PathPrefix + " conflicts with another deployment on this gateway", + ) + } + + now := b.nowUTC() + name := in.DisplayName + + if name == "" { + name = "deployment" + now.Format("20060102150405") + } + + d := &Deployment{ + ID: newOCID("apideployment"), GatewayID: g.ID, CompartmentID: in.CompartmentID, PathPrefix: in.PathPrefix, + DisplayName: name, Spec: in.Specification, Locks: nullRaw(in.Locks), State: stateCreating, + TransitionAt: b.transitionAt(), TimeCreated: now, TimeUpdated: now, Etag: newEtag(), Tags: in.Tags, + } + b.deployments[d.ID] = d + b.specs[d.ID] = cs + wr := b.newWR(wrCreateDeploy, d.CompartmentID, entityDeploy, d.ID, actionCreated, *d.TransitionAt) + + return b.deploymentView(d, true), wr, d.Etag, nil +} + +func (b *InMemoryBackend) liveDeployment(id string) (*Deployment, *APIError) { + d, ok := b.deployments[id] + if !ok { + return nil, errNotFound(id) + } + + return d, nil +} + +// GetDeployment returns one deployment and its etag. +func (b *InMemoryBackend) GetDeployment(id string) (DeploymentWire, string, error) { + b.mu.RLock("GetDeployment") + defer b.mu.RUnlock() + + d, err := b.liveDeployment(id) + if err != nil { + return DeploymentWire{}, "", err + } + + return b.deploymentView(d, true), d.Etag, nil +} + +// ListDeployments lists the deployments of a compartment, optionally of one gateway. +func (b *InMemoryBackend) ListDeployments( + compartment, gatewayID, name, state string, o ListOpts, +) (page.Page[DeploymentWire], error) { + if compartment == "" { + return page.Page[DeploymentWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListDeployments") + defer b.mu.RUnlock() + + var all []*Deployment + + for _, d := range b.deployments { + if d.CompartmentID != compartment || !stateMatches(d.State, state) || + (gatewayID != "" && d.GatewayID != gatewayID) || (name != "" && !strs.Equal(d.DisplayName, name)) { + continue + } + + all = append(all, d) + } + + pg := paginate(all, o, func(d *Deployment) string { return d.DisplayName }, + func(d *Deployment) (time.Time, int64) { return d.TimeCreated, 0 }) + + out := make([]DeploymentWire, 0, len(pg.Data)) + for _, d := range pg.Data { + out = append(out, b.deploymentView(d, false)) + } + + return page.Page[DeploymentWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateDeployment replaces the specification and/or display name and tags. +func (b *InMemoryBackend) UpdateDeployment(id, ifMatch string, in updateDeploymentIn) (string, error) { + b.mu.Lock("UpdateDeployment") + defer b.mu.Unlock() + + d, err := b.liveDeployment(id) + if err != nil { + return "", err + } + + if d.State != stateActive { + return "", errState("deployment " + id + " is in state " + d.State) + } + + if err = checkEtag(d.Etag, ifMatch); err != nil { + return "", err + } + + if isPresent(in.Specification) { + cs, cerr := compileSpec(in.Specification) + if cerr != nil { + return "", cerr + } + + d.Spec, b.specs[id] = in.Specification, cs + } + + if in.DisplayName != nil && *in.DisplayName != "" { + d.DisplayName = *in.DisplayName + } + + d.Tags.merge(in.Tags) + d.State, d.TransitionAt = stateUpdating, b.transitionAt() + d.TimeUpdated, d.Etag = b.nowUTC(), newEtag() + + return b.newWR(wrUpdateDeploy, d.CompartmentID, entityDeploy, id, actionUpdated, *d.TransitionAt), nil +} + +// DeleteDeployment starts deleting a deployment. +func (b *InMemoryBackend) DeleteDeployment(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteDeployment") + defer b.mu.Unlock() + + d, err := b.liveDeployment(id) + if err != nil { + return "", err + } + + if d.State != stateActive { + return "", errState("deployment " + id + " is in state " + d.State) + } + + if err = checkEtag(d.Etag, ifMatch); err != nil { + return "", err + } + + d.State, d.TransitionAt = stateDeleting, b.transitionAt() + d.TimeUpdated = b.nowUTC() + + return b.newWR(wrDeleteDeploy, d.CompartmentID, entityDeploy, id, actionDeleted, *d.TransitionAt), nil +} + +// ChangeDeploymentCompartment moves a deployment. +func (b *InMemoryBackend) ChangeDeploymentCompartment(id, ifMatch string, in changeCompartmentIn) (string, error) { + b.mu.Lock("ChangeDeploymentCompartment") + defer b.mu.Unlock() + + d, err := b.liveDeployment(id) + if err != nil { + return "", err + } + + if err = b.checkMove(d.Etag, ifMatch, in.CompartmentID); err != nil { + return "", err + } + + d.CompartmentID, d.TimeUpdated, d.Etag = in.CompartmentID, b.nowUTC(), newEtag() + + return b.newWR(wrUpdateDeploy, d.CompartmentID, entityDeploy, id, actionUpdated, b.nowUTC().Add(b.transition)), nil +} + +func (h *Handler) createDeployment(q *req) error { + return created(q, "/deployments/", func(w DeploymentWire) string { return w.ID }, h.Backend.CreateDeployment) +} + +func (h *Handler) getDeployment(q *req) error { + w, etag, err := h.Backend.GetDeployment(q.param("deploymentId")) + + return reply(q, w, etag, err) +} + +func (h *Handler) listDeployments(q *req) error { + pg, err := h.Backend.ListDeployments(q.query("compartmentId"), q.query("gatewayId"), + q.query("displayName"), q.query("lifecycleState"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateDeployment(q *req) error { + return withBody(q, func(in updateDeploymentIn) (string, error) { + return h.Backend.UpdateDeployment(q.param("deploymentId"), q.ifMatch(), in) + }) +} + +func (h *Handler) deleteDeployment(q *req) error { + wr, err := h.Backend.DeleteDeployment(q.param("deploymentId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeDeploymentCompartment(q *req) error { + return withBody(q, func(in changeCompartmentIn) (string, error) { + return h.Backend.ChangeDeploymentCompartment(q.param("deploymentId"), q.ifMatch(), in) + }) +} diff --git a/services/ociapigateway/errors.go b/services/ociapigateway/errors.go new file mode 100644 index 0000000000..50d54ee5a4 --- /dev/null +++ b/services/ociapigateway/errors.go @@ -0,0 +1,51 @@ +package ociapigateway + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ociapigateway: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errLimit(msg string) *APIError { return newErr(http.StatusBadRequest, "LimitExceeded", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } + +func errConflict(msg string) *APIError { return newErr(http.StatusConflict, "Conflict", msg) } + +func errUnsupported(what string) *APIError { + return newErr(http.StatusBadRequest, "InvalidParameter", what+" is not supported by this emulator") +} diff --git a/services/ociapigateway/gateways.go b/services/ociapigateway/gateways.go new file mode 100644 index 0000000000..91ae8de001 --- /dev/null +++ b/services/ociapigateway/gateways.go @@ -0,0 +1,386 @@ +package ociapigateway + +import ( + "encoding/json" + "slices" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +type createGatewayIn struct { + Tags + Locks json.RawMessage `json:"locks"` + CaBundles json.RawMessage `json:"caBundles"` + ResponseCache json.RawMessage `json:"responseCacheDetails"` + DisplayName string `json:"displayName"` + CompartmentID string `json:"compartmentId"` + EndpointType string `json:"endpointType"` + SubnetID string `json:"subnetId"` + CertificateID string `json:"certificateId"` + IPMode string `json:"ipMode"` + NSGs []string `json:"networkSecurityGroupIds"` +} + +type updateGatewayIn struct { + Tags + CaBundles json.RawMessage `json:"caBundles"` + ResponseCache json.RawMessage `json:"responseCacheDetails"` + DisplayName *string `json:"displayName"` + CertificateID *string `json:"certificateId"` + NSGs []string `json:"networkSecurityGroupIds"` +} + +type changeCompartmentIn struct { + CompartmentID string `json:"compartmentId"` +} + +// GatewayWire is the wire form of a gateway. +type GatewayWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + Locks json.RawMessage `json:"locks,omitempty"` + CaBundles json.RawMessage `json:"caBundles,omitempty"` + ResponseCache json.RawMessage `json:"responseCacheDetails,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + EndpointType string `json:"endpointType"` + DisplayName string `json:"displayName"` + SubnetID string `json:"subnetId"` + Hostname string `json:"hostname"` + CertificateID string `json:"certificateId,omitempty"` + IPMode string `json:"ipMode,omitempty"` + LifecycleState string `json:"lifecycleState"` + NSGs []string `json:"networkSecurityGroupIds,omitempty"` +} + +func gatewayView(g *Gateway) GatewayWire { + return GatewayWire{ + TimeCreated: g.TimeCreated, TimeUpdated: g.TimeUpdated, DefinedTags: g.Tags.DefinedTags, + FreeformTags: g.Tags.FreeformTags, Locks: g.Locks, CaBundles: g.CaBundles, ResponseCache: g.ResponseCache, + ID: g.ID, CompartmentID: g.CompartmentID, EndpointType: g.EndpointType, DisplayName: g.DisplayName, + SubnetID: g.SubnetID, Hostname: g.Hostname, CertificateID: g.CertificateID, IPMode: g.IPMode, + LifecycleState: g.State, NSGs: g.NSGs, + } +} + +func (b *InMemoryBackend) liveGateway(id string) (*Gateway, *APIError) { + g, ok := b.gateways[id] + if !ok { + return nil, errNotFound(id) + } + + return g, nil +} + +func (b *InMemoryBackend) checkCertificateRef(id string) *APIError { + if id == "" { + return nil + } + + if c, ok := b.certificates[id]; !ok || c.State == stateDeleted { + return errNotFound(id) + } + + return nil +} + +func (b *InMemoryBackend) validateGateway(in *createGatewayIn) *APIError { + if err := b.checkCompartment(in.CompartmentID); err != nil { + return err + } + + if in.EndpointType != endpointPublic && in.EndpointType != endpointPrivate { + return errParam("endpointType must be PUBLIC or PRIVATE") + } + + if in.SubnetID == "" { + return errMissing("subnetId is required") + } + + if b.subnet != nil && !b.subnet(in.SubnetID) { + return errNotFound(in.SubnetID) + } + + if in.IPMode != "" && !slices.Contains([]string{"IPV4", "IPV6", "DUAL_STACK"}, in.IPMode) { + return errParam("ipMode must be IPV4, IPV6 or DUAL_STACK") + } + + if len(in.ResponseCache) > 0 && string(in.ResponseCache) != jsonNull && !jsonIsNoCache(in.ResponseCache) { + return errUnsupported("responseCacheDetails other than type NONE") + } + + return b.checkCertificateRef(in.CertificateID) +} + +func jsonIsNoCache(raw json.RawMessage) bool { + var d struct { + Type string `json:"type"` + } + + return json.Unmarshal(raw, &d) == nil && d.Type == "NONE" +} + +func (b *InMemoryBackend) gatewayNameTaken(compartment, name, except string) bool { + for _, g := range b.gateways { + if g.ID != except && g.CompartmentID == compartment && g.State != stateDeleted && + strs.Equal(g.DisplayName, name) { + return true + } + } + + return false +} + +// CreateGateway creates a gateway in CREATING state and returns it with its work request id. +func (b *InMemoryBackend) CreateGateway(in createGatewayIn) (GatewayWire, string, string, error) { + b.mu.Lock("CreateGateway") + defer b.mu.Unlock() + + if err := b.validateGateway(&in); err != nil { + return GatewayWire{}, "", "", err + } + + now := b.nowUTC() + name := in.DisplayName + + if name == "" { + name = "gateway" + now.Format("20060102150405") + } + + if b.gatewayNameTaken(in.CompartmentID, name, "") { + return GatewayWire{}, "", "", errConflict("a gateway named " + name + " already exists in this compartment") + } + + g := &Gateway{ + ID: newOCID("apigateway"), CompartmentID: in.CompartmentID, EndpointType: in.EndpointType, + DisplayName: name, SubnetID: in.SubnetID, Hostname: newHostname(), CertificateID: in.CertificateID, + IPMode: in.IPMode, NSGs: in.NSGs, Locks: nullRaw(in.Locks), CaBundles: nullRaw(in.CaBundles), + ResponseCache: nullRaw(in.ResponseCache), State: stateCreating, TransitionAt: b.transitionAt(), + TimeCreated: now, TimeUpdated: now, Etag: newEtag(), Tags: in.Tags, + } + b.gateways[g.ID] = g + wr := b.newWR(wrCreateGateway, g.CompartmentID, entityGateway, g.ID, actionCreated, *g.TransitionAt) + + return gatewayView(g), wr, g.Etag, nil +} + +func nullRaw(r json.RawMessage) json.RawMessage { + if string(r) == jsonNull { + return nil + } + + return r +} + +// GetGateway returns one gateway and its etag. +func (b *InMemoryBackend) GetGateway(id string) (GatewayWire, string, error) { + b.mu.RLock("GetGateway") + defer b.mu.RUnlock() + + g, err := b.liveGateway(id) + if err != nil { + return GatewayWire{}, "", err + } + + return gatewayView(g), g.Etag, nil +} + +// ListGateways lists the gateways of a compartment. +func (b *InMemoryBackend) ListGateways( + compartment, name, state, certificate string, o ListOpts, +) (page.Page[GatewayWire], error) { + if compartment == "" { + return page.Page[GatewayWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListGateways") + defer b.mu.RUnlock() + + var all []*Gateway + + for _, g := range b.gateways { + if g.CompartmentID != compartment || !stateMatches(g.State, state) || + (name != "" && !strs.Equal(g.DisplayName, name)) || (certificate != "" && g.CertificateID != certificate) { + continue + } + + all = append(all, g) + } + + pg := paginate(all, o, func(g *Gateway) string { return g.DisplayName }, + func(g *Gateway) (time.Time, int64) { return g.TimeCreated, 0 }) + + out := make([]GatewayWire, 0, len(pg.Data)) + for _, g := range pg.Data { + out = append(out, gatewayView(g)) + } + + return page.Page[GatewayWire]{Data: out, Next: pg.Next}, nil +} + +// stateMatches filters by lifecycleState; without a filter, DELETED resources are hidden. +func stateMatches(have, want string) bool { + if want == "" { + return have != stateDeleted + } + + return strings.EqualFold(have, want) +} + +// UpdateGateway updates mutable gateway attributes and returns the work request id. +func (b *InMemoryBackend) UpdateGateway(id, ifMatch string, in updateGatewayIn) (string, error) { + b.mu.Lock("UpdateGateway") + defer b.mu.Unlock() + + g, err := b.liveGateway(id) + if err != nil { + return "", err + } + + if g.State != stateActive { + return "", errState("gateway " + id + " is in state " + g.State) + } + + if err = checkEtag(g.Etag, ifMatch); err != nil { + return "", err + } + + if in.CertificateID != nil { + if cerr := b.checkCertificateRef(*in.CertificateID); cerr != nil { + return "", cerr + } + + g.CertificateID = *in.CertificateID + } + + if in.DisplayName != nil && *in.DisplayName != "" { + if b.gatewayNameTaken(g.CompartmentID, *in.DisplayName, g.ID) { + return "", errConflict("a gateway named " + *in.DisplayName + " already exists in this compartment") + } + + g.DisplayName = *in.DisplayName + } + + if in.NSGs != nil { + g.NSGs = in.NSGs + } + + if len(in.CaBundles) > 0 { + g.CaBundles = nullRaw(in.CaBundles) + } + + if len(in.ResponseCache) > 0 && !jsonIsNoCache(in.ResponseCache) && string(in.ResponseCache) != jsonNull { + return "", errUnsupported("responseCacheDetails other than type NONE") + } + + g.Tags.merge(in.Tags) + + return b.touchGateway(g, wrUpdateGateway), nil +} + +func (b *InMemoryBackend) touchGateway(g *Gateway, op string) string { + g.State, g.TransitionAt = stateUpdating, b.transitionAt() + g.TimeUpdated, g.Etag = b.nowUTC(), newEtag() + + return b.newWR(op, g.CompartmentID, entityGateway, g.ID, actionUpdated, *g.TransitionAt) +} + +// DeleteGateway starts deleting a gateway that has no deployments. +func (b *InMemoryBackend) DeleteGateway(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteGateway") + defer b.mu.Unlock() + + g, err := b.liveGateway(id) + if err != nil { + return "", err + } + + if g.State != stateActive { + return "", errState("gateway " + id + " is in state " + g.State) + } + + if err = checkEtag(g.Etag, ifMatch); err != nil { + return "", err + } + + for _, d := range b.deployments { + if d.GatewayID == id && d.State != stateDeleted { + return "", errConflict("gateway " + id + " still has API deployments") + } + } + + g.State, g.TransitionAt = stateDeleting, b.transitionAt() + g.TimeUpdated = b.nowUTC() + + return b.newWR(wrDeleteGateway, g.CompartmentID, entityGateway, id, actionDeleted, *g.TransitionAt), nil +} + +// ChangeGatewayCompartment moves a gateway. +func (b *InMemoryBackend) ChangeGatewayCompartment(id, ifMatch string, in changeCompartmentIn) (string, error) { + b.mu.Lock("ChangeGatewayCompartment") + defer b.mu.Unlock() + + g, err := b.liveGateway(id) + if err != nil { + return "", err + } + + if err = b.checkMove(g.Etag, ifMatch, in.CompartmentID); err != nil { + return "", err + } + + if b.gatewayNameTaken(in.CompartmentID, g.DisplayName, g.ID) { + return "", errConflict("a gateway named " + g.DisplayName + " already exists in the target compartment") + } + + g.CompartmentID, g.TimeUpdated, g.Etag = in.CompartmentID, b.nowUTC(), newEtag() + + return b.newWR( + wrUpdateGateway, + g.CompartmentID, + entityGateway, + id, + actionUpdated, + b.nowUTC().Add(b.transition), + ), nil +} + +func (h *Handler) createGateway(q *req) error { + return created(q, "/gateways/", func(w GatewayWire) string { return w.ID }, h.Backend.CreateGateway) +} + +func (h *Handler) getGateway(q *req) error { + w, etag, err := h.Backend.GetGateway(q.param("gatewayId")) + + return reply(q, w, etag, err) +} + +func (h *Handler) listGateways(q *req) error { + pg, err := h.Backend.ListGateways(q.query("compartmentId"), q.query("displayName"), + q.query("lifecycleState"), q.query("certificateId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateGateway(q *req) error { + return withBody(q, func(in updateGatewayIn) (string, error) { + return h.Backend.UpdateGateway(q.param("gatewayId"), q.ifMatch(), in) + }) +} + +func (h *Handler) deleteGateway(q *req) error { + wr, err := h.Backend.DeleteGateway(q.param("gatewayId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeGatewayCompartment(q *req) error { + return withBody(q, func(in changeCompartmentIn) (string, error) { + return h.Backend.ChangeGatewayCompartment(q.param("gatewayId"), q.ifMatch(), in) + }) +} diff --git a/services/ociapigateway/handler.go b/services/ociapigateway/handler.go new file mode 100644 index 0000000000..714b9cd74f --- /dev/null +++ b/services/ociapigateway/handler.go @@ -0,0 +1,372 @@ +package ociapigateway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Minute + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI API Gateway: gateways, deployments, certificates, work requests and the deployed-API data plane. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ociapigateway.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIAPIGateway" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIAPIGateway runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"gatewayId", "deploymentId", "certificateId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ociapigateway: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + dec := json.NewDecoder(bytes.NewReader(q.body)) + dec.UseNumber() + + if err := dec.Decode(v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ociapigateway: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIAPIGateway requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if q.rt.op == opDataPlane { + h.Backend.ServeData(q.w, q.r) + + return nil + } + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ociapigateway: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIAPIGateway", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ociapigateway", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ociapigateway: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ociapigateway: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ociapigateway: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ociapigateway: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() +} diff --git a/services/ociapigateway/helpers_test.go b/services/ociapigateway/helpers_test.go new file mode 100644 index 0000000000..e791ca8a95 --- /dev/null +++ b/services/ociapigateway/helpers_test.go @@ -0,0 +1,269 @@ +package ociapigateway_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/apigateway" + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + ociapigateway "github.com/blackbirdworks/gopherstack/services/ociapigateway" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + otherCompart = "ocid1.compartment.oc1..other" + testSubnet = "ocid1.subnet.oc1..subnet" + testFunction = "ocid1.fnfunc.oc1.iad.fn1" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UTC().Truncate(time.Second).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *ociapigateway.InMemoryBackend + handler *ociapigateway.Handler + clock *fakeClock + gw apigateway.GatewayClient + dep apigateway.DeploymentClient + api apigateway.ApiGatewayClient + wr apigateway.WorkRequestsClient + base string + port int +} + +type envOpts struct { + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := ociapigateway.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + h := ociapigateway.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + prov := common.NewRawConfigurationProvider(testTenancy, testUser, ociapigateway.Region, kp.fprint, kp.privPEM, nil) + e := &env{backend: be, handler: h, clock: clock, port: h.Port, base: fmt.Sprintf("http://localhost:%d", h.Port)} + + var err error + + e.gw, err = apigateway.NewGatewayClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.dep, err = apigateway.NewDeploymentClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.api, err = apigateway.NewApiGatewayClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.wr, err = apigateway.NewWorkRequestsClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.gw.Host, e.dep.Host, e.api.Host, e.wr.Host = e.base, e.base, e.base, e.base + + return e +} + +func (e *env) settle() { e.clock.Advance(5 * time.Second) } + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string, http.Header) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out), resp.Header +} + +func (e *env) createGateway(t *testing.T, name string) apigateway.CreateGatewayResponse { + t.Helper() + + resp, err := e.gw.CreateGateway(t.Context(), apigateway.CreateGatewayRequest{ + CompartmentId: new(testCompartment), EndpointType: apigateway.GatewayEndpointTypePublic, + SubnetId: new(testSubnet), DisplayName: new(name), + }) + require.NoError(t, err) + + return resp +} + +// mkGateway creates a gateway and settles it to ACTIVE. +func (e *env) mkGateway(t *testing.T, name string) apigateway.Gateway { + t.Helper() + + id := *e.createGateway(t, name).Id + e.settle() + + resp, err := e.gw.GetGateway(t.Context(), apigateway.GetGatewayRequest{GatewayId: &id}) + require.NoError(t, err) + require.Equal(t, apigateway.GatewayLifecycleStateActive, resp.LifecycleState) + + return resp.Gateway +} + +func specFromJSON(t *testing.T, s string) *apigateway.ApiSpecification { + t.Helper() + + var spec apigateway.ApiSpecification + require.NoError(t, json.Unmarshal([]byte(s), &spec)) + + return &spec +} + +func (e *env) createDeployment( + t *testing.T, gwID, prefix string, spec *apigateway.ApiSpecification, +) (apigateway.CreateDeploymentResponse, error) { + t.Helper() + + return e.dep.CreateDeployment(t.Context(), apigateway.CreateDeploymentRequest{ + GatewayId: &gwID, CompartmentId: new(testCompartment), PathPrefix: &prefix, Specification: spec, + }) +} + +// mkDeployment creates a deployment from specification JSON and settles it to ACTIVE. +func (e *env) mkDeployment(t *testing.T, gwID, prefix, spec string) string { + t.Helper() + + resp, err := e.createDeployment(t, gwID, prefix, specFromJSON(t, spec)) + require.NoError(t, err) + + e.settle() + + return *resp.Id +} + +type dataResp struct { + header http.Header + body string + status int +} + +// call sends a data-plane request through the /_apigw/ path scheme. +func (e *env) call(t *testing.T, gwID, method, path string, headers map[string]string, body string) dataResp { + t.Helper() + + return e.callURL(t, e.base+"/_apigw/"+gwID+path, "", method, headers, body) +} + +func (e *env) callURL(t *testing.T, url, host, method string, headers map[string]string, body string) dataResp { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + if host != "" { + req.Host = host + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + client := testclient.New(t) + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + resp, err := client.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return dataResp{status: resp.StatusCode, body: string(out), header: resp.Header} +} + +func stockSpec(status int, body string) string { + return fmt.Sprintf( + `{"routes":[{"path":"/hi","methods":["GET"],"backend":{"type":"STOCK_RESPONSE_BACKEND","status":%d,"body":%q}}]}`, + status, + body, + ) +} + +const time1s = time.Second diff --git a/services/ociapigateway/inproc.go b/services/ociapigateway/inproc.go new file mode 100644 index 0000000000..568c1efb2d --- /dev/null +++ b/services/ociapigateway/inproc.go @@ -0,0 +1,51 @@ +package ociapigateway + +import "slices" + +// Compartments lists the distinct compartments that hold gateways, deployments or certificates. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, g := range b.gateways { + if g.State != stateDeleted { + seen[g.CompartmentID] = struct{}{} + } + } + + for _, d := range b.deployments { + if d.State != stateDeleted { + seen[d.CompartmentID] = struct{}{} + } + } + + for _, c := range b.certificates { + if c.State != stateDeleted { + seen[c.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} + +// GatewayState returns the lifecycle state of a gateway. +func (b *InMemoryBackend) GatewayState(id string) (string, bool) { + b.mu.RLock("GatewayState") + defer b.mu.RUnlock() + + g, ok := b.gateways[id] + if !ok || g.State == stateDeleted { + return "", false + } + + return g.State, true +} diff --git a/services/ociapigateway/leak_main_test.go b/services/ociapigateway/leak_main_test.go new file mode 100644 index 0000000000..e70aad7de0 --- /dev/null +++ b/services/ociapigateway/leak_main_test.go @@ -0,0 +1,18 @@ +package ociapigateway_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ociapigateway/match.go b/services/ociapigateway/match.go new file mode 100644 index 0000000000..e64faabaf0 --- /dev/null +++ b/services/ociapigateway/match.go @@ -0,0 +1,253 @@ +package ociapigateway + +import ( + "cmp" + "errors" + "fmt" + "slices" + "strconv" + "strings" +) + +var ( + errPathParam = errors.New("not a valid path parameter") + errWildcardLast = errors.New("the wildcard parameter must be the last segment") + errPathChars = errors.New("path contains characters that are not allowed") +) + +type segKind int + +const ( + segWild segKind = iota + segParam + segLiteral +) + +type segment struct { + lit string + name string + kind segKind +} + +type compiledRoute struct { + doc *routeDoc + methods map[string]bool + segs []segment +} + +type compiledSpec struct { + doc *specDoc + routes []*compiledRoute +} + +func splitSegments(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + + return strings.Split(p, "/") +} + +func compileRoute(r *routeDoc, idx int) (*compiledRoute, *APIError) { + where := "routes[" + strconv.Itoa(idx) + "]" + + if !strings.HasPrefix(r.Path, "/") || strings.Contains(r.Path, "//") { + return nil, errParam(where + ".path must start with a forward slash and not contain adjacent slashes") + } + + cr := &compiledRoute{doc: r, methods: map[string]bool{}} + + parts := splitSegments(r.Path) + for i, p := range parts { + seg, err := compileSegment(p, i == len(parts)-1) + if err != nil { + return nil, errParam(where + ".path: " + err.Error()) + } + + cr.segs = append(cr.segs, seg) + } + + if err := validateMethods(r.Methods); err != nil { + return nil, err + } + + for _, m := range r.Methods { + cr.methods[m] = true + } + + if len(r.Methods) == 0 { + cr.methods[methodAny] = true + } + + if err := validateBackend(r.Backend, 0); err != nil { + return nil, err + } + + if err := validateRouteRequestPolicies(r.RequestPolicies); err != nil { + return nil, err + } + + if r.ResponsePolicies != nil && isPresent(r.ResponsePolicies.ResponseCacheStore) { + return nil, errUnsupported("route responsePolicies.responseCacheStore") + } + + return cr, nil +} + +func compileSegment(p string, last bool) (segment, error) { + if strings.ContainsAny(p, "{}") { + m := paramRe.FindStringSubmatch(p) + if m == nil { + return segment{}, fmt.Errorf("%w: %q (use {name} or {name*})", errPathParam, p) + } + + if m[2] == "*" { + if !last { + return segment{}, fmt.Errorf("%w: %q", errWildcardLast, p) + } + + return segment{kind: segWild, name: m[1]}, nil + } + + return segment{kind: segParam, name: m[1]}, nil + } + + if !pathCharsRe.MatchString(p) { + return segment{}, fmt.Errorf("%w: %q", errPathChars, p) + } + + return segment{kind: segLiteral, lit: p}, nil +} + +// shape renders a route path with parameter names erased, for duplicate detection. +func (c *compiledRoute) shape() string { + var sb strings.Builder + + for _, s := range c.segs { + switch s.kind { + case segLiteral: + sb.WriteString("/" + s.lit) + case segParam: + sb.WriteString("/{}") + case segWild: + sb.WriteString("/{*}") + } + } + + return sb.String() +} + +func checkDuplicateRoutes(routes []*compiledRoute) *APIError { + seen := map[string]map[string]bool{} + + for _, r := range routes { + shape := r.shape() + if seen[shape] == nil { + seen[shape] = map[string]bool{} + } + + for m := range r.methods { + if seen[shape][m] || seen[shape][methodAny] || (m == methodAny && len(seen[shape]) > 0) { + return errParam("duplicate route for path " + r.doc.Path + " and method " + m) + } + + seen[shape][m] = true + } + } + + return nil +} + +// sortRoutes orders routes most specific first: at the first segment where two routes differ, a literal +// beats a parameter, which beats a wildcard; at a shared prefix the shorter (exact) template wins +// over a trailing wildcard. +func sortRoutes(routes []*compiledRoute) { + slices.SortStableFunc(routes, func(a, b *compiledRoute) int { + for i := 0; i < len(a.segs) && i < len(b.segs); i++ { + if c := cmp.Compare(b.segs[i].kind, a.segs[i].kind); c != 0 { + return c + } + } + + return cmp.Compare(len(a.segs), len(b.segs)) + }) +} + +func (c *compiledRoute) matchPath(parts []string) (map[string]string, bool) { + params := map[string]string{} + + for i, s := range c.segs { + switch s.kind { + case segWild: + if i <= len(parts) { + params[s.name] = strings.Join(parts[i:], "/") + + return params, true + } + + return nil, false + case segParam: + if i >= len(parts) { + return nil, false + } + + params[s.name] = parts[i] + case segLiteral: + if i >= len(parts) || parts[i] != s.lit { + return nil, false + } + } + } + + return params, len(parts) == len(c.segs) +} + +func (c *compiledRoute) allows(method string) bool { + return c.methods[methodAny] || c.methods[method] +} + +type matchResult struct { + route *compiledRoute + params map[string]string + allow []string +} + +// match finds the most specific route for the path (relative to the deployment prefix) and method. +func (s *compiledSpec) match(path, method string) matchResult { + parts := splitSegments(path) + + var res matchResult + + for _, r := range s.routes { + params, ok := r.matchPath(parts) + if !ok { + continue + } + + if r.allows(method) { + return matchResult{route: r, params: params} + } + + for m := range r.methods { + res.allow = append(res.allow, m) + } + } + + slices.Sort(res.allow) + + return res +} + +// anyRouteFor returns the best route for a path regardless of method (used for CORS preflight). +func (s *compiledSpec) anyRouteFor(path string) *compiledRoute { + parts := splitSegments(path) + + for _, r := range s.routes { + if _, ok := r.matchPath(parts); ok { + return r + } + } + + return nil +} diff --git a/services/ociapigateway/models.go b/services/ociapigateway/models.go new file mode 100644 index 0000000000..6158ff5b8a --- /dev/null +++ b/services/ociapigateway/models.go @@ -0,0 +1,107 @@ +package ociapigateway + +import ( + "encoding/json" + "time" +) + +// Lifecycle states. +const ( + stateActive = "ACTIVE" + stateCreating = "CREATING" + stateUpdating = "UPDATING" + stateDeleting = "DELETING" + stateDeleted = "DELETED" +) + +// Endpoint types. +const ( + endpointPublic = "PUBLIC" + endpointPrivate = "PRIVATE" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// Gateway is an API gateway. +type Gateway struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + Tags Tags `json:"tags"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Locks json.RawMessage `json:"locks,omitempty"` + CaBundles json.RawMessage `json:"caBundles,omitempty"` + ResponseCache json.RawMessage `json:"responseCache,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + EndpointType string `json:"endpointType"` + DisplayName string `json:"displayName"` + SubnetID string `json:"subnetId"` + Hostname string `json:"hostname"` + CertificateID string `json:"certificateId,omitempty"` + IPMode string `json:"ipMode,omitempty"` + State string `json:"state"` + Etag string `json:"etag"` + NSGs []string `json:"networkSecurityGroupIds,omitempty"` +} + +// Deployment is an API deployment on a gateway. +type Deployment struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + Tags Tags `json:"tags"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + ID string `json:"id"` + GatewayID string `json:"gatewayId"` + CompartmentID string `json:"compartmentId"` + PathPrefix string `json:"pathPrefix"` + DisplayName string `json:"displayName"` + State string `json:"state"` + Etag string `json:"etag"` + Locks json.RawMessage `json:"locks,omitempty"` + Spec json.RawMessage `json:"specification"` +} + +// Certificate is a TLS certificate usable by a gateway. +type Certificate struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + TimeNotValidAfter time.Time `json:"timeNotValidAfter"` + Tags Tags `json:"tags"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Locks json.RawMessage `json:"locks,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + CertPEM string `json:"certificate"` + Intermediate string `json:"intermediateCertificates,omitempty"` + State string `json:"state"` + Etag string `json:"etag"` + SubjectNames []string `json:"subjectNames"` +} + +// WorkRequest records an asynchronous operation. +type WorkRequest struct { + Accepted time.Time `json:"accepted"` + FinishAt time.Time `json:"finishAt"` + ID string `json:"id"` + Operation string `json:"operation"` + CompartmentID string `json:"compartmentId"` + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + Action string `json:"action"` + Seq int64 `json:"seq"` +} diff --git a/services/ociapigateway/ops.go b/services/ociapigateway/ops.go new file mode 100644 index 0000000000..bc4921abcb --- /dev/null +++ b/services/ociapigateway/ops.go @@ -0,0 +1,81 @@ +package ociapigateway + +import ( + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const itemsKey = "items" + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateGateway: (*Handler).createGateway, opGetGateway: (*Handler).getGateway, + opListGateways: (*Handler).listGateways, opUpdateGateway: (*Handler).updateGateway, + opDeleteGateway: (*Handler).deleteGateway, opChangeGatewayComp: (*Handler).changeGatewayCompartment, + opCreateDeployment: (*Handler).createDeployment, opGetDeployment: (*Handler).getDeployment, + opListDeployments: (*Handler).listDeployments, opUpdateDeployment: (*Handler).updateDeployment, + opDeleteDeployment: (*Handler).deleteDeployment, opChangeDeployComp: (*Handler).changeDeploymentCompartment, + opCreateCert: (*Handler).createCertificate, opGetCert: (*Handler).getCertificate, + opListCerts: (*Handler).listCertificates, opUpdateCert: (*Handler).updateCertificate, + opDeleteCert: (*Handler).deleteCertificate, opChangeCertComp: (*Handler).changeCertificateCompartment, + opGetWorkRequest: (*Handler).getWorkRequest, opListWorkRequests: (*Handler).listWorkRequests, + opListWorkReqErrors: (*Handler).listWorkRequestErrors, opListWorkReqLogs: (*Handler).listWorkRequestLogs, + } +} + +func reply[T any](q *req, w T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +// withBody decodes the request body, runs the operation and answers 202 with its work request. +func withBody[I any](q *req, fn func(in I) (string, error)) error { + var in I + if err := q.decode(&in); err != nil { + return err + } + + wr, err := fn(in) + + return acceptedWR(q, wr, err) +} + +// created answers 202 with the new resource, its etag, location and work request. +func created[I, W any](q *req, path string, id func(W) string, fn func(I) (W, string, string, error)) error { + var in I + if err := q.decode(&in); err != nil { + return err + } + + w, wr, etag, err := fn(in) + if err != nil { + return err + } + + q.header("opc-work-request-id", wr) + q.header("location", base+path+id(w)) + + return q.json(http.StatusAccepted, w, etag) +} + +func acceptedWR(q *req, wr string, err error) error { + if err != nil { + return err + } + + return q.accepted(wr) +} + +func collectionReply[T any](q *req, pg page.Page[T], err error) error { + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, map[string]any{itemsKey: pg.Data}, "") +} diff --git a/services/ociapigateway/persistence.go b/services/ociapigateway/persistence.go new file mode 100644 index 0000000000..d1ff0f82d9 --- /dev/null +++ b/services/ociapigateway/persistence.go @@ -0,0 +1,130 @@ +package ociapigateway + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Gateways map[string]*Gateway `json:"gateways"` + Deployments map[string]*Deployment `json:"deployments"` + Certificates map[string]*Certificate `json:"certificates"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ociapigateway", backendSnapshot{ + Version: snapshotVersion, Gateways: b.gateways, Deployments: b.deployments, Certificates: b.certificates, + WorkReqs: b.workReqs, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ociapigateway", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ociapigateway: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + specs, err := compileAll(snap.Deployments) + if err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.gateways, b.deployments, b.certificates = snap.Gateways, snap.Deployments, snap.Certificates + b.workReqs, b.seq, b.specs = snap.WorkReqs, snap.Seq, specs + b.Auth.Load(snap.Auth) + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{ + nullCheck("gateway", &snap.Gateways), nullCheck("deployment", &snap.Deployments), + nullCheck("certificate", &snap.Certificates), nullCheck("work request", &snap.WorkReqs), + } { + if err != nil { + return err + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ociapigateway: restore snapshot: %w", err) + } + + return nil +} + +// compileAll recompiles the specification of every live deployment; compiled specs are not persisted. +func compileAll(deps map[string]*Deployment) (map[string]*compiledSpec, error) { + specs := make(map[string]*compiledSpec, len(deps)) + + for id, d := range deps { + if d.State == stateDeleted { + continue + } + + cs, err := compileSpec(d.Spec) + if err != nil { + return nil, fmt.Errorf("ociapigateway: deployment %q: %w", id, err) + } + + specs[id] = cs + } + + return specs, nil +} diff --git a/services/ociapigateway/persistence_test.go b/services/ociapigateway/persistence_test.go new file mode 100644 index 0000000000..ec5087ca70 --- /dev/null +++ b/services/ociapigateway/persistence_test.go @@ -0,0 +1,107 @@ +package ociapigateway_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + ociapigateway "github.com/blackbirdworks/gopherstack/services/ociapigateway" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "persist") + e.mkDeployment(t, *gw.Id, "/v1", stockSpec(200, "restored")) + + snap := e.handler.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + other := ociapigateway.NewInMemoryBackend() + other.SetClock(e.clock.Now) + require.NoError(t, ociapigateway.NewHandler(other).Restore(t.Context(), snap)) + + other.Sweep() + + rec := httptest.NewRecorder() + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/_apigw/"+*gw.Id+"/v1/hi", nil) + other.ServeData(rec, r) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "restored", rec.Body.String()) + + got, _, err := other.GetGateway(*gw.Id) + require.NoError(t, err) + assert.Equal(t, "persist", got.DisplayName) +} + +func TestRestoreRejectsBadSnapshots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantErr bool + }{ + {"null gateway", `{"version":1,"gateways":{"a":null}}`, true}, + { + "unparseable spec", + `{"version":1,"deployments":{"d":{"id":"d","state":"ACTIVE","specification":{"routes":[{"path":"x"}]}}}}`, + true, + }, + {"old version starts empty", `{"version":0,"gateways":{"a":null}}`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := ociapigateway.NewHandler(ociapigateway.NewInMemoryBackend()) + err := h.Restore(t.Context(), []byte(tt.body)) + + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestResetClearsState(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + gw := e.mkGateway(t, "gone") + e.mkDeployment(t, *gw.Id, "/v1", stockSpec(200, "x")) + + e.handler.Reset() + + assert.Equal(t, http.StatusNotFound, e.call(t, *gw.Id, http.MethodGet, "/v1/hi", nil, "").status) + + _, _, err := e.backend.GetGateway(*gw.Id) + require.Error(t, err) +} + +func TestProviderInit(t *testing.T) { + t.Parallel() + + _, err := (&ociapigateway.Provider{}).Init(nil) + require.ErrorIs(t, err, ociapigateway.ErrNilAppContext) + + reg, err := (&ociapigateway.Provider{}).Init(&service.AppContext{}) + require.NoError(t, err) + + h, ok := reg.(*ociapigateway.Handler) + require.True(t, ok) + assert.Equal(t, ociapigateway.DefaultPort, h.Port) + assert.Equal(t, "OCIAPIGateway", h.Name()) + assert.Len(t, h.GetSupportedOperations(), 22) +} diff --git a/services/ociapigateway/policies.go b/services/ociapigateway/policies.go new file mode 100644 index 0000000000..50fabff227 --- /dev/null +++ b/services/ociapigateway/policies.go @@ -0,0 +1,185 @@ +package ociapigateway + +import ( + "net/http" + "net/url" + "slices" + "strconv" + "strings" +) + +func originAllowed(c *corsDoc, origin string) bool { + return slices.Contains(c.AllowedOrigins, "*") || slices.Contains(c.AllowedOrigins, origin) +} + +func credentialed(c *corsDoc) bool { return c.AllowCredentials != nil && *c.AllowCredentials } + +func allowOriginValue(c *corsDoc, origin string) string { + if slices.Contains(c.AllowedOrigins, "*") && !credentialed(c) { + return "*" + } + + return origin +} + +// preflight answers a CORS preflight; an origin outside the policy gets no CORS headers. +func preflight(w http.ResponseWriter, r *http.Request, c *corsDoc) { + origin := r.Header.Get("Origin") + h := w.Header() + h.Add("Vary", "Origin") + + if originAllowed(c, origin) { + preflightHeaders(h, r, c, origin) + } + + w.WriteHeader(http.StatusOK) +} + +func preflightHeaders(h http.Header, r *http.Request, c *corsDoc, origin string) { + h.Set("Access-Control-Allow-Origin", allowOriginValue(c, origin)) + h.Set("Access-Control-Allow-Methods", listOrEcho(c.AllowedMethods, r.Header.Get("Access-Control-Request-Method"))) + + if headers := listOrEcho(c.AllowedHeaders, r.Header.Get("Access-Control-Request-Headers")); headers != "" { + h.Set("Access-Control-Allow-Headers", headers) + } + + if credentialed(c) { + h.Set("Access-Control-Allow-Credentials", "true") + } + + if c.MaxAge != nil && *c.MaxAge > 0 { + h.Set("Access-Control-Max-Age", strconv.Itoa(*c.MaxAge)) + } +} + +// listOrEcho joins the configured list, or echoes the requested value for an empty list or '*'. +func listOrEcho(list []string, requested string) string { + if len(list) == 0 || slices.Contains(list, "*") { + return requested + } + + return strings.Join(list, ", ") +} + +// corsHeaders decorates an actual (non-preflight) response. +func corsHeaders(h http.Header, c *corsDoc, origin string) { + if c == nil || origin == "" { + return + } + + h.Add("Vary", "Origin") + + if !originAllowed(c, origin) { + return + } + + h.Set("Access-Control-Allow-Origin", allowOriginValue(c, origin)) + + if credentialed(c) { + h.Set("Access-Control-Allow-Credentials", "true") + } + + if len(c.ExposedHeaders) > 0 { + h.Set("Access-Control-Expose-Headers", strings.Join(c.ExposedHeaders, ", ")) + } +} + +func hasName(items []nameItem, name string) bool { + return slices.ContainsFunc(items, func(i nameItem) bool { return strings.EqualFold(i.Name, name) }) +} + +// transformHeaders applies filter, then rename, then set. +func transformHeaders(h http.Header, t *transformDoc, rv *reqView) { + if t == nil { + return + } + + if f := t.FilterHeaders; f != nil { + for k := range h { + if filtered(f.Type, hasName(f.Items, k)) { + delete(h, k) + } + } + } + + if t.RenameHeaders != nil { + renameKeys(h, t.RenameHeaders.Items, http.CanonicalHeaderKey) + } + + if t.SetHeaders != nil { + setValues(h, t.SetHeaders.Items, http.CanonicalHeaderKey, rv, h.Add) + } +} + +// renameKeys moves values from each item's From key to its To key. +func renameKeys(m map[string][]string, items []renameItem, norm func(string) string) { + for _, it := range items { + from := norm(it.From) + if vs, ok := m[from]; ok { + delete(m, from) + m[norm(it.To)] = vs + } + } +} + +// setValues applies set items honouring ifExists; add appends one value to a key. +func setValues( + m map[string][]string, items []setItem, norm func(string) string, rv *reqView, add func(k, v string), +) { + for _, it := range items { + key := norm(it.Name) + if !prepareSet(m, key, it.IfExists) { + continue + } + + for _, v := range it.Values { + add(key, rv.interpolate(v, nil)) + } + } +} + +// filtered reports whether an ALLOW/BLOCK filter removes an entry. +func filtered(kind string, listed bool) bool { + return (kind == "ALLOW" && !listed) || (kind == "BLOCK" && listed) +} + +// prepareSet applies the ifExists mode to an existing key and reports whether new values should be added. +func prepareSet(h map[string][]string, key, ifExists string) bool { + if _, exists := h[key]; exists { + switch ifExists { + case "SKIP": + return false + case "APPEND": + return true + } + + delete(h, key) + } + + return true +} + +// transformQuery applies filter, then rename, then set to query parameters. +func transformQuery(q url.Values, t *transformDoc, rv *reqView) { + if t == nil { + return + } + + if f := t.FilterQuery; f != nil { + for k := range q { + if filtered(f.Type, slices.ContainsFunc(f.Items, func(i nameItem) bool { return i.Name == k })) { + delete(q, k) + } + } + } + + if t.RenameQuery != nil { + renameKeys(q, t.RenameQuery.Items, identity) + } + + if t.SetQuery != nil { + setValues(q, t.SetQuery.Items, identity, rv, q.Add) + } +} + +func identity(s string) string { return s } diff --git a/services/ociapigateway/provider.go b/services/ociapigateway/provider.go new file mode 100644 index 0000000000..bb02ca619f --- /dev/null +++ b/services/ociapigateway/provider.go @@ -0,0 +1,45 @@ +package ociapigateway + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ociapigateway: nil app context") + +// ConfigProvider extracts OCI API Gateway configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIAPIGatewaySettings() Settings +} + +// Provider implements service.Provider for OCI API Gateway. It runs on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIAPIGateway" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIAPIGatewaySettings() + } + + backend := NewInMemoryBackend() + backend.SetAllowExternal(settings.AllowExternalBackend) + + handler := NewHandler(backend) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ociapigateway/ratelimit.go b/services/ociapigateway/ratelimit.go new file mode 100644 index 0000000000..5e0225cde7 --- /dev/null +++ b/services/ociapigateway/ratelimit.go @@ -0,0 +1,96 @@ +package ociapigateway + +import ( + "math" + "net" + "net/http" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" +) + +const ( + maxBuckets = 10000 + bucketIdle = time.Minute + ipv6PrefixBits = 64 + ipv6Bits = 128 +) + +type bucket struct { + last time.Time + tokens float64 +} + +// rateLimiter is a per-key token bucket: capacity and refill rate both equal the configured requests per second. +type rateLimiter struct { + mu *lockmetrics.RWMutex + buckets map[string]*bucket +} + +func newRateLimiter() *rateLimiter { + return &rateLimiter{mu: lockmetrics.New("ociapigateway.ratelimit"), buckets: map[string]*bucket{}} +} + +func (l *rateLimiter) reset() { + l.mu.Lock("reset") + l.buckets = map[string]*bucket{} + l.mu.Unlock() +} + +// allow takes one token; when refused it returns the seconds until a token is available. +func (l *rateLimiter) allow(key string, rate int, now time.Time) (bool, int) { + l.mu.Lock("allow") + defer l.mu.Unlock() + + capacity := float64(rate) + + bk, ok := l.buckets[key] + if !ok { + if len(l.buckets) >= maxBuckets { + l.prune(now) + } + + bk = &bucket{tokens: capacity, last: now} + l.buckets[key] = bk + } + + if elapsed := now.Sub(bk.last).Seconds(); elapsed > 0 { + bk.tokens = math.Min(capacity, bk.tokens+elapsed*capacity) + bk.last = now + } + + if bk.tokens >= 1 { + bk.tokens-- + + return true, 0 + } + + return false, max(1, int(math.Ceil((1-bk.tokens)/capacity))) +} + +func (l *rateLimiter) prune(now time.Time) { + for k, bk := range l.buckets { + if now.Sub(bk.last) > bucketIdle { + delete(l.buckets, k) + } + } +} + +// clientKey identifies the API client: the IPv4 address, or the IPv6 /64 prefix. +func clientKey(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + ip := net.ParseIP(host) + if ip == nil { + return host + } + + if v4 := ip.To4(); v4 != nil { + return v4.String() + } + + return ip.Mask(net.CIDRMask(ipv6PrefixBits, ipv6Bits)).String() +} diff --git a/services/ociapigateway/route.go b/services/ociapigateway/route.go new file mode 100644 index 0000000000..530e467a9f --- /dev/null +++ b/services/ociapigateway/route.go @@ -0,0 +1,115 @@ +package ociapigateway + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateGateway = "CreateGateway" + opGetGateway = "GetGateway" + opListGateways = "ListGateways" + opUpdateGateway = "UpdateGateway" + opDeleteGateway = "DeleteGateway" + opChangeGatewayComp = "ChangeGatewayCompartment" + opCreateDeployment = "CreateDeployment" + opGetDeployment = "GetDeployment" + opListDeployments = "ListDeployments" + opUpdateDeployment = "UpdateDeployment" + opDeleteDeployment = "DeleteDeployment" + opChangeDeployComp = "ChangeDeploymentCompartment" + opCreateCert = "CreateCertificate" + opGetCert = "GetCertificate" + opListCerts = "ListCertificates" + opUpdateCert = "UpdateCertificate" + opDeleteCert = "DeleteCertificate" + opChangeCertComp = "ChangeCertificateCompartment" + opGetWorkRequest = "GetWorkRequest" + opListWorkRequests = "ListWorkRequests" + opListWorkReqErrors = "ListWorkRequestErrors" + opListWorkReqLogs = "ListWorkRequestLogs" + opAdminKeys = "ManageAPIKeys" + opDataPlane = "Invoke" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + dataPathPrefix = "/_apigw/" + base = "/20190501" + gatewayPath = base + "/gateways/{gatewayId}" + deploymentPath = base + "/deployments/{deploymentId}" + certificatePath = base + "/certificates/{certificateId}" + workRequestPath = base + "/workRequests/{workRequestId}" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/gateways", Op: opCreateGateway}, + {Method: http.MethodGet, Path: base + "/gateways", Op: opListGateways}, + {Method: http.MethodGet, Path: gatewayPath, Op: opGetGateway}, + {Method: http.MethodPut, Path: gatewayPath, Op: opUpdateGateway}, + {Method: http.MethodDelete, Path: gatewayPath, Op: opDeleteGateway}, + {Method: http.MethodPost, Path: gatewayPath + "/actions/changeCompartment", Op: opChangeGatewayComp}, + {Method: http.MethodPost, Path: base + "/deployments", Op: opCreateDeployment}, + {Method: http.MethodGet, Path: base + "/deployments", Op: opListDeployments}, + {Method: http.MethodGet, Path: deploymentPath, Op: opGetDeployment}, + {Method: http.MethodPut, Path: deploymentPath, Op: opUpdateDeployment}, + {Method: http.MethodDelete, Path: deploymentPath, Op: opDeleteDeployment}, + {Method: http.MethodPost, Path: deploymentPath + "/actions/changeCompartment", Op: opChangeDeployComp}, + {Method: http.MethodPost, Path: base + "/certificates", Op: opCreateCert}, + {Method: http.MethodGet, Path: base + "/certificates", Op: opListCerts}, + {Method: http.MethodGet, Path: certificatePath, Op: opGetCert}, + {Method: http.MethodPut, Path: certificatePath, Op: opUpdateCert}, + {Method: http.MethodDelete, Path: certificatePath, Op: opDeleteCert}, + {Method: http.MethodPost, Path: certificatePath + "/actions/changeCompartment", Op: opChangeCertComp}, + {Method: http.MethodGet, Path: base + "/workRequests", Op: opListWorkRequests}, + {Method: http.MethodGet, Path: workRequestPath, Op: opGetWorkRequest}, + {Method: http.MethodGet, Path: workRequestPath + "/errors", Op: opListWorkReqErrors}, + {Method: http.MethodGet, Path: workRequestPath + "/logs", Op: opListWorkReqLogs}, +}) + +type route struct { + params map[string]string + op string +} + +// isDataPlane reports whether the request addresses a deployed API rather than the control plane: +// a gateway hostname in Host, or the /_apigw// path scheme. +func isDataPlane(r *http.Request) bool { + return strings.HasPrefix(r.URL.Path, dataPathPrefix) || hostGateway(r.Host) +} + +// hostGateway reports whether host is a gateway hostname (optionally with a port). +func hostGateway(host string) bool { + host = stripPort(host) + + return strings.HasSuffix(host, ".apigateway."+Region+".oci.customer-oci.com") +} + +func stripPort(host string) string { + if i := strings.LastIndexByte(host, ':'); i >= 0 && !strings.Contains(host[i:], "]") { + return host[:i] + } + + return host +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + if isDataPlane(r) { + return route{op: opDataPlane} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ociapigateway/settings.go b/services/ociapigateway/settings.go new file mode 100644 index 0000000000..f99c15072e --- /dev/null +++ b/services/ociapigateway/settings.go @@ -0,0 +1,14 @@ +package ociapigateway + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8163 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIAPIGateway. +type Settings struct { + Port int `json:"port" env:"OCI_APIGATEWAY_PORT" default:"8163" name:"port" help:"Fixed TCP port for the dedicated OCI API Gateway listener (control plane and data plane); startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_APIGATEWAY_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned control-plane requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose + AllowExternalBackend bool `json:"allowExternalBackend" env:"OCI_APIGATEWAY_ALLOW_EXTERNAL_BACKENDS" name:"allow-external-backends" help:"Let HTTP backends and remote JWKS URIs reach hosts outside loopback; by default the data plane only dials 127.0.0.0/8 and ::1."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ociapigateway/spec.go b/services/ociapigateway/spec.go new file mode 100644 index 0000000000..331f768915 --- /dev/null +++ b/services/ociapigateway/spec.go @@ -0,0 +1,568 @@ +package ociapigateway + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "regexp" + "slices" + "strings" +) + +// Backend, authentication and policy discriminators. +const ( + backendHTTP = "HTTP_BACKEND" + backendFn = "ORACLE_FUNCTIONS_BACKEND" + backendStock = "STOCK_RESPONSE_BACKEND" + backendDynamic = "DYNAMIC_ROUTING_BACKEND" + + authJWT = "JWT_AUTHENTICATION" + authToken = "TOKEN_AUTHENTICATION" + authCustom = "CUSTOM_AUTHENTICATION" + + keysStatic = "STATIC_KEYS" + keysRemote = "REMOTE_JWKS" + + methodAny = "ANY" + maxStatusCode = 599 + minStatusCode = 100 + maxRoutes = 1000 + maxSpecDepth = 2 + fnOCIDPrefix = "ocid1.fnfunc." + rateKeyClient = "CLIENT_IP" + rateKeyTotal = "TOTAL" + selectorSingle = "SINGLE" + anyOf = "ANY_OF" + wildcardKey = "WILDCARD" + authzAnonymous = "ANONYMOUS" + jsonNull = "null" +) + +type headerSpec struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type selectionKey struct { + IsDefault *bool `json:"isDefault"` + Type string `json:"type"` + Name string `json:"name"` + Expression string `json:"expression"` + Values []string `json:"values"` +} + +type dynamicBackend struct { + Backend *backendDoc `json:"backend"` + Key selectionKey `json:"key"` +} + +type backendDoc struct { + SelectionSource *struct { + Type string `json:"type"` + Selector string `json:"selector"` + } `json:"selectionSource"` + URL *string `json:"url"` + ConnectTimeout *float64 `json:"connectTimeoutInSeconds"` + ReadTimeout *float64 `json:"readTimeoutInSeconds"` + SendTimeout *float64 `json:"sendTimeoutInSeconds"` + IsSslVerifyDisabled *bool `json:"isSslVerifyDisabled"` + Status *int `json:"status"` + Body *string `json:"body"` + Type string `json:"type"` + FunctionID string `json:"functionId"` + Headers []headerSpec `json:"headers"` + RoutingBackends []dynamicBackend `json:"routingBackends"` +} + +type corsDoc struct { + MaxAge *int `json:"maxAgeInSeconds"` + AllowCredentials *bool `json:"isAllowCredentialsEnabled"` + AllowedOrigins []string `json:"allowedOrigins"` + AllowedMethods []string `json:"allowedMethods"` + AllowedHeaders []string `json:"allowedHeaders"` + ExposedHeaders []string `json:"exposedHeaders"` +} + +type rateDoc struct { + Rate *int `json:"rateInRequestsPerSecond"` + Key string `json:"rateKey"` +} + +type claimDoc struct { + IsRequired *bool `json:"isRequired"` + Key string `json:"key"` + Values []string `json:"values"` +} + +type staticKeyDoc struct { + Kid string `json:"kid"` + Format string `json:"format"` + Key string `json:"key"` + N string `json:"n"` + E string `json:"e"` + Kty string `json:"kty"` +} + +type additionalDoc struct { + Issuers []string `json:"issuers"` + Audiences []string `json:"audiences"` + VerifyClaims []claimDoc `json:"verifyClaims"` +} + +type validationDoc struct { + Additional *additionalDoc `json:"additionalValidationPolicy"` + URI string `json:"uri"` + Type string `json:"type"` + MaxCacheHours *int `json:"maxCacheDurationInHours"` + IsSslVerifyDisabled *bool `json:"isSslVerifyDisabled"` + Keys []staticKeyDoc `json:"keys"` +} + +type publicKeysDoc struct { + URI string `json:"uri"` + Type string `json:"type"` + MaxCacheHours *int `json:"maxCacheDurationInHours"` + Keys []staticKeyDoc `json:"keys"` +} + +type authDoc struct { + PublicKeys *publicKeysDoc `json:"publicKeys"` + TokenHeader *string `json:"tokenHeader"` + TokenQueryParam *string `json:"tokenQueryParam"` + TokenAuthScheme *string `json:"tokenAuthScheme"` + ValidationPolicy *validationDoc `json:"validationPolicy"` + IsAnonymousAllowed *bool `json:"isAnonymousAccessAllowed"` + MaxClockSkew *float64 `json:"maxClockSkewInSeconds"` + Parameters map[string]string `json:"parameters"` + Type string `json:"type"` + FunctionID string `json:"functionId"` + CacheKey []string `json:"cacheKey"` + VerifyClaims []claimDoc `json:"verifyClaims"` + Audiences []string `json:"audiences"` + Issuers []string `json:"issuers"` +} + +type requestPolicies struct { + Authentication *authDoc `json:"authentication"` + RateLimiting *rateDoc `json:"rateLimiting"` + Cors *corsDoc `json:"cors"` + MutualTLS json.RawMessage `json:"mutualTls"` + UsagePlans json.RawMessage `json:"usagePlans"` + DynamicAuthentication json.RawMessage `json:"dynamicAuthentication"` +} + +type setItem struct { + Name string `json:"name"` + IfExists string `json:"ifExists"` + Values []string `json:"values"` +} + +type renameItem struct { + From string `json:"from"` + To string `json:"to"` +} + +type nameItem struct { + Name string `json:"name"` +} + +type transformDoc struct { + SetHeaders *struct { + Items []setItem `json:"items"` + } `json:"setHeaders"` + RenameHeaders *struct { + Items []renameItem `json:"items"` + } `json:"renameHeaders"` + FilterHeaders *struct { + Type string `json:"type"` + Items []nameItem `json:"items"` + } `json:"filterHeaders"` + SetQuery *struct { + Items []setItem `json:"items"` + } `json:"setQueryParameters"` + RenameQuery *struct { + Items []renameItem `json:"items"` + } `json:"renameQueryParameters"` + FilterQuery *struct { + Type string `json:"type"` + Items []nameItem `json:"items"` + } `json:"filterQueryParameters"` +} + +type authzDoc struct { + Type string `json:"type"` + AllowedScope []string `json:"allowedScope"` +} + +type routeRequestPolicies struct { + Authorization *authzDoc `json:"authorization"` + Cors *corsDoc `json:"cors"` + HeaderTransform *transformDoc `json:"headerTransformations"` + QueryTransform *transformDoc `json:"queryParameterTransformations"` + QueryValidations json.RawMessage `json:"queryParameterValidations"` + HeaderValidations json.RawMessage `json:"headerValidations"` + BodyValidation json.RawMessage `json:"bodyValidation"` + ResponseCacheLookup json.RawMessage `json:"responseCacheLookup"` +} + +type routeResponsePolicies struct { + HeaderTransform *transformDoc `json:"headerTransformations"` + ResponseCacheStore json.RawMessage `json:"responseCacheStore"` +} + +type routeDoc struct { + Backend *backendDoc `json:"backend"` + RequestPolicies *routeRequestPolicies `json:"requestPolicies"` + ResponsePolicies *routeResponsePolicies `json:"responsePolicies"` + Path string `json:"path"` + Methods []string `json:"methods"` +} + +type specDoc struct { + RequestPolicies *requestPolicies `json:"requestPolicies"` + LoggingPolicies json.RawMessage `json:"loggingPolicies"` + Routes []routeDoc `json:"routes"` +} + +var ( + pathCharsRe = regexp.MustCompile(`^[A-Za-z0-9$\-_.+!*'(),%;:@&=]+$`) + paramRe = regexp.MustCompile(`^\{([A-Za-z0-9_]+)(\*?)\}$`) + prefixCharRe = regexp.MustCompile(`^[A-Za-z0-9$\-_.+!*'(),%;:@&=]+$`) +) + +func isPresent(raw json.RawMessage) bool { + return len(raw) > 0 && !bytes.Equal(raw, []byte(jsonNull)) +} + +// validatePathPrefix applies the deployment pathPrefix rules documented by Oracle. +func validatePathPrefix(p string) *APIError { + if !strings.HasPrefix(p, "/") { + return errParam("pathPrefix must start with a forward slash") + } + + if p == "/" { + return nil + } + + if strings.HasSuffix(p, "/") { + return errParam("pathPrefix must not end with a forward slash") + } + + for seg := range strings.SplitSeq(p[1:], "/") { + if seg == "" { + return errParam("pathPrefix must not contain adjacent forward slashes") + } + + if !prefixCharRe.MatchString(seg) { + return errParam( + "pathPrefix contains characters that are not allowed (parameters and wildcards are not permitted)", + ) + } + } + + return nil +} + +// compileSpec decodes and validates a deployment specification. +func compileSpec(raw json.RawMessage) (*compiledSpec, *APIError) { + if !isPresent(raw) { + return nil, errMissing("specification is required") + } + + var doc specDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, errParam("specification is not valid: " + err.Error()) + } + + if len(doc.Routes) > maxRoutes { + return nil, errLimit(fmt.Sprintf("a specification may have at most %d routes", maxRoutes)) + } + + if err := validateRequestPolicies(doc.RequestPolicies); err != nil { + return nil, err + } + + cs := &compiledSpec{doc: &doc} + + for i := range doc.Routes { + cr, err := compileRoute(&doc.Routes[i], i) + if err != nil { + return nil, err + } + + cs.routes = append(cs.routes, cr) + } + + if err := checkAnonymousRoutes(&doc); err != nil { + return nil, err + } + + if err := checkDuplicateRoutes(cs.routes); err != nil { + return nil, err + } + + sortRoutes(cs.routes) + + return cs, nil +} + +func validateRequestPolicies(p *requestPolicies) *APIError { + if p == nil { + return nil + } + + for name, raw := range map[string]json.RawMessage{ + "mutualTls": p.MutualTLS, "usagePlans": p.UsagePlans, "dynamicAuthentication": p.DynamicAuthentication, + } { + if isPresent(raw) { + return errUnsupported("requestPolicies." + name) + } + } + + if p.RateLimiting != nil { + if p.RateLimiting.Rate == nil || *p.RateLimiting.Rate < 1 { + return errParam("rateLimiting.rateInRequestsPerSecond must be a positive integer") + } + + if p.RateLimiting.Key != rateKeyClient && p.RateLimiting.Key != rateKeyTotal { + return errParam("rateLimiting.rateKey must be CLIENT_IP or TOTAL") + } + } + + if err := validateCors(p.Cors, "requestPolicies.cors"); err != nil { + return err + } + + return validateAuth(p.Authentication) +} + +func validateCors(c *corsDoc, where string) *APIError { + if c == nil { + return nil + } + + if len(c.AllowedOrigins) == 0 { + return errParam(where + ".allowedOrigins must not be empty") + } + + if c.MaxAge != nil && *c.MaxAge < 0 { + return errParam(where + ".maxAgeInSeconds must not be negative") + } + + return nil +} + +func validateAuth(a *authDoc) *APIError { + if a == nil { + return nil + } + + if a.TokenHeader != nil && *a.TokenHeader != "" && (a.TokenAuthScheme == nil || *a.TokenAuthScheme == "") && + a.Type != authCustom { + return errParam("authentication.tokenAuthScheme is required when tokenHeader is set") + } + + switch a.Type { + case authJWT: + return validateJWT(a) + case authToken: + return validateTokenPolicy(a) + case authCustom: + if !strings.HasPrefix(a.FunctionID, fnOCIDPrefix) { + return errParam("authentication.functionId must be a function OCID") + } + + return nil + default: + return errParam("authentication.type must be JWT_AUTHENTICATION, TOKEN_AUTHENTICATION or CUSTOM_AUTHENTICATION") + } +} + +func validateJWT(a *authDoc) *APIError { + if len(a.Issuers) == 0 || len(a.Audiences) == 0 { + return errParam("JWT authentication requires issuers and audiences") + } + + if a.PublicKeys == nil { + return errMissing("JWT authentication requires publicKeys") + } + + return validateKeySource(a.PublicKeys.Type, a.PublicKeys.URI, a.PublicKeys.Keys) +} + +func validateTokenPolicy(a *authDoc) *APIError { + if a.ValidationPolicy == nil { + return errMissing("TOKEN_AUTHENTICATION requires validationPolicy") + } + + return validateKeySource(a.ValidationPolicy.Type, a.ValidationPolicy.URI, a.ValidationPolicy.Keys) +} + +func validateKeySource(kind, uri string, keys []staticKeyDoc) *APIError { + switch kind { + case keysStatic: + if len(keys) == 0 { + return errParam("static keys require at least one key") + } + + for i := range keys { + if _, err := parseStaticKey(&keys[i]); err != nil { + return errParam("static key " + keys[i].Kid + ": " + err.Error()) + } + } + + return nil + case keysRemote: + u, err := url.Parse(uri) + if err != nil || !httpURL(u) { + return errParam("remote JWKS uri must be an http or https URL") + } + + return nil + default: + return errUnsupported("key source type " + kind) + } +} + +func validateMethods(methods []string) *APIError { + for _, m := range methods { + if !slices.Contains([]string{methodAny, "HEAD", "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, m) { + return errParam("unsupported route method " + m) + } + } + + return nil +} + +func validateBackend(b *backendDoc, depth int) *APIError { + if b == nil { + return errMissing("route backend is required") + } + + switch b.Type { + case backendHTTP: + return validateHTTPBackend(b) + case backendFn: + if !strings.HasPrefix(b.FunctionID, fnOCIDPrefix) { + return errParam("ORACLE_FUNCTIONS_BACKEND functionId must be a function OCID") + } + + return nil + case backendStock: + if b.Status == nil || *b.Status < minStatusCode || *b.Status > maxStatusCode { + return errParam("STOCK_RESPONSE_BACKEND status must be between 100 and 599") + } + + return nil + case backendDynamic: + return validateDynamic(b, depth) + default: + return errUnsupported("backend type " + b.Type) + } +} + +var varRe = regexp.MustCompile(`\$\{[^}]*\}`) + +// substituteStub replaces ${...} variables so a templated URL can be parsed at validation time. +func substituteStub(s string) string { return varRe.ReplaceAllString(s, "x") } + +func validateHTTPBackend(b *backendDoc) *APIError { + if b.URL == nil { + return errMissing("HTTP_BACKEND requires url") + } + + u, err := url.Parse(substituteStub(*b.URL)) + if err != nil || !httpURL(u) { + return errParam("HTTP_BACKEND url must be an http or https URL") + } + + return nil +} + +func validateDynamic(b *backendDoc, depth int) *APIError { + if depth >= maxSpecDepth { + return errParam("dynamic routing backends cannot be nested") + } + + if b.SelectionSource == nil || b.SelectionSource.Type != selectorSingle || b.SelectionSource.Selector == "" { + return errParam("DYNAMIC_ROUTING_BACKEND requires a SINGLE selectionSource with a selector") + } + + if _, _, ok := parseVariable(b.SelectionSource.Selector); !ok { + return errParam("selectionSource.selector must be request.path[..], request.query[..] or request.headers[..]") + } + + if len(b.RoutingBackends) == 0 { + return errParam("DYNAMIC_ROUTING_BACKEND requires routingBackends") + } + + for i := range b.RoutingBackends { + rb := &b.RoutingBackends[i] + if rb.Key.Name == "" { + return errParam("routing backend key name is required") + } + + if rb.Key.Type != anyOf && rb.Key.Type != wildcardKey { + return errParam("routing backend key type must be ANY_OF or WILDCARD") + } + + if rb.Key.Type == wildcardKey && + strings.Count(rb.Key.Expression, "*")+strings.Count(rb.Key.Expression, "+") > 1 { + return errParam("a wildcard key expression can only contain one wildcard") + } + + if err := validateBackend(rb.Backend, depth+1); err != nil { + return err + } + } + + return nil +} + +func validateRouteRequestPolicies(p *routeRequestPolicies) *APIError { + if p == nil { + return nil + } + + for name, raw := range map[string]json.RawMessage{ + "queryParameterValidations": p.QueryValidations, "headerValidations": p.HeaderValidations, + "bodyValidation": p.BodyValidation, "responseCacheLookup": p.ResponseCacheLookup, + } { + if isPresent(raw) { + return errUnsupported("route requestPolicies." + name) + } + } + + if a := p.Authorization; a != nil { + switch a.Type { + case authzAnonymous, "AUTHENTICATION_ONLY": + case anyOf: + if len(a.AllowedScope) == 0 { + return errParam("ANY_OF authorization requires allowedScope") + } + default: + return errParam("authorization.type must be ANONYMOUS, ANY_OF or AUTHENTICATION_ONLY") + } + } + + return validateCors(p.Cors, "route requestPolicies.cors") +} + +func checkAnonymousRoutes(doc *specDoc) *APIError { + anonymousOK := doc.RequestPolicies != nil && doc.RequestPolicies.Authentication != nil && + doc.RequestPolicies.Authentication.IsAnonymousAllowed != nil && *doc.RequestPolicies.Authentication.IsAnonymousAllowed + + for i := range doc.Routes { + p := doc.Routes[i].RequestPolicies + if p != nil && p.Authorization != nil && p.Authorization.Type == authzAnonymous && !anonymousOK { + return errParam("ANONYMOUS route authorization requires authentication.isAnonymousAccessAllowed to be true") + } + } + + return nil +} + +func httpURL(u *url.URL) bool { + return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} diff --git a/services/ociapigateway/store.go b/services/ociapigateway/store.go new file mode 100644 index 0000000000..4cec3b69a8 --- /dev/null +++ b/services/ociapigateway/store.go @@ -0,0 +1,278 @@ +package ociapigateway + +import ( + "cmp" + "net/http" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + // Region is the fixed region baked into emulated OCIDs and gateway hostnames. + Region = "us-ashburn-1" + defaultListLimit = 100 + ocidTailLen = 52 + hostLabelLen = 26 + + defaultTransition = 2 * time.Second +) + +// InMemoryBackend holds every gateway, deployment, certificate and work request under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + gateways map[string]*Gateway + deployments map[string]*Deployment + certificates map[string]*Certificate + workReqs map[string]*WorkRequest + specs map[string]*compiledSpec + Auth *ociauth.Registry + compartment func(string) bool + subnet func(string) bool + functions FunctionInvoker + limiter *rateLimiter + client *http.Client + jwks *jwksCache + transition time.Duration + seq int64 + allowExternal bool +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{mu: lockmetrics.New("ociapigateway"), now: time.Now, transition: defaultTransition} + b.resetMaps() + b.Auth = ociauth.NewRegistry() + b.limiter = newRateLimiter() + b.jwks = newJWKSCache() + b.client = b.newBackendClient() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.gateways, b.deployments = map[string]*Gateway{}, map[string]*Deployment{} + b.certificates, b.workReqs = map[string]*Certificate{}, map[string]*WorkRequest{} + b.specs = map[string]*compiledSpec{} +} + +// newBackendClient builds the data-plane HTTP client. The dial guard rejects non-loopback peers +// unless external backends are allowed, and redirects are returned to the caller rather than followed. +func (b *InMemoryBackend) newBackendClient() *http.Client { + tr := httpclient.NewTransport() + tr.Proxy = nil + tr.DialContext = b.guardedDialer().DialContext + + return &http.Client{ + Transport: tr, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } +} + +// SetClock injects the time source used for lifecycle transitions, rate limits and token validation. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTransition sets how long CREATING, UPDATING and DELETING states last. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// SetSubnetChecker validates subnetId arguments against another service (OCI Core). +func (b *InMemoryBackend) SetSubnetChecker(fn func(string) bool) { + b.mu.Lock("SetSubnetChecker") + b.subnet = fn + b.mu.Unlock() +} + +// SetFunctionInvoker wires OCI Functions for ORACLE_FUNCTIONS_BACKEND and CUSTOM_AUTHENTICATION. +func (b *InMemoryBackend) SetFunctionInvoker(fn FunctionInvoker) { + b.mu.Lock("SetFunctionInvoker") + b.functions = fn + b.mu.Unlock() +} + +// SetAllowExternal lets HTTP backends and remote JWKS URIs reach non-loopback hosts. +func (b *InMemoryBackend) SetAllowExternal(allow bool) { + b.mu.Lock("SetAllowExternal") + b.allowExternal = allow + b.mu.Unlock() +} + +func (b *InMemoryBackend) externalAllowed() bool { + b.mu.RLock("externalAllowed") + defer b.mu.RUnlock() + + return b.allowExternal +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() + b.limiter.reset() + b.jwks.reset() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) clock() time.Time { + b.mu.RLock("clock") + defer b.mu.RUnlock() + + return b.nowUTC() +} + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func newHostname() string { + return awsid.NewN("", hostLabelLen) + ".apigateway." + Region + ".oci.customer-oci.com" +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +// checkMove validates the etag and the target compartment of a ChangeCompartment call. +func (b *InMemoryBackend) checkMove(current, ifMatch, target string) *APIError { + if err := checkEtag(current, ifMatch); err != nil { + return err + } + + return b.checkCompartment(target) +} + +func (b *InMemoryBackend) transitionAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +// paginate sorts by displayName or timeCreated (the only sortBy values API Gateway documents) and pages. +func paginate[T any](items []T, o ListOpts, name func(T) string, created func(T) (time.Time, int64)) page.Page[T] { + byName := strings.EqualFold(o.SortBy, "displayName") + desc := !byName + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + slices.SortStableFunc(items, func(a, c T) int { + var r int + if byName { + r = cmp.Compare(strs.Fold(name(a)), strs.Fold(name(c))) + } else { + ta, sa := created(a) + tc, sc := created(c) + r = cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +func due(at *time.Time, now time.Time) bool { return at != nil && !at.After(now) } + +// settleState applies a pending transition to a lifecycle state. +func settleState(state *string, at **time.Time, now time.Time) { + if !due(*at, now) { + return + } + + *at = nil + + if *state == stateDeleting { + *state = stateDeleted + } else { + *state = stateActive + } +} + +// Sweep applies lifecycle transitions against the backend clock. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + now := b.nowUTC() + for _, g := range b.gateways { + settleState(&g.State, &g.TransitionAt, now) + } + + for id, d := range b.deployments { + settleState(&d.State, &d.TransitionAt, now) + + if d.State == stateDeleted { + delete(b.specs, id) + } + } + + for _, c := range b.certificates { + settleState(&c.State, &c.TransitionAt, now) + } +} diff --git a/services/ociapigateway/vars.go b/services/ociapigateway/vars.go new file mode 100644 index 0000000000..dcb56b1d06 --- /dev/null +++ b/services/ociapigateway/vars.go @@ -0,0 +1,68 @@ +package ociapigateway + +import ( + "net/http" + "net/url" + "regexp" + "strings" +) + +// reqView is the request context that policy and backend expressions read. +type reqView struct { + params map[string]string + query url.Values + header http.Header +} + +var ( + varExprRe = regexp.MustCompile(`^request\.(path|query|headers)\[([^\]]+)\]$`) + interpoRe = regexp.MustCompile(`\$\{([^}]*)\}`) +) + +// parseVariable splits request.path[x], request.query[x] or request.headers[x]. +func parseVariable(expr string) (string, string, bool) { + m := varExprRe.FindStringSubmatch(expr) + if m == nil { + return "", "", false + } + + return m[1], m[2], true +} + +func (v *reqView) lookup(expr string) string { + kind, name, ok := parseVariable(expr) + if !ok { + return "" + } + + switch kind { + case "path": + return v.params[name] + case "query": + return v.query.Get(name) + default: + return v.header.Get(name) + } +} + +// interpolate replaces ${request.*[..]} variables; unknown variables become empty. +func (v *reqView) interpolate(s string, esc func(string) string) string { + return interpoRe.ReplaceAllStringFunc(s, func(m string) string { + val := v.lookup(m[2 : len(m)-1]) + if esc != nil { + return esc(val) + } + + return val + }) +} + +// escapePath escapes a value for a URL path while keeping the slashes of a wildcard parameter. +func escapePath(s string) string { + parts := strings.Split(s, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + + return strings.Join(parts, "/") +} diff --git a/services/ociapigateway/workreq.go b/services/ociapigateway/workreq.go new file mode 100644 index 0000000000..2e11fb3c39 --- /dev/null +++ b/services/ociapigateway/workreq.go @@ -0,0 +1,214 @@ +package ociapigateway + +import ( + "net/http" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + halfComplete = 50 + entityGateway = "gateway" + entityDeploy = "deployment" + entityCert = "certificate" + + wrCreateGateway = "CREATE_GATEWAY" + wrUpdateGateway = "UPDATE_GATEWAY" + wrDeleteGateway = "DELETE_GATEWAY" + wrCreateDeploy = "CREATE_DEPLOYMENT" + wrUpdateDeploy = "UPDATE_DEPLOYMENT" + wrDeleteDeploy = "DELETE_DEPLOYMENT" + wrCreateCert = "CREATE_CERTIFICATE" + wrUpdateCert = "UPDATE_CERTIFICATE" + wrDeleteCert = "DELETE_CERTIFICATE" + + actionCreated = "CREATED" + actionUpdated = "UPDATED" + actionDeleted = "DELETED" +) + +type wrResourceWire struct { + EntityType string `json:"entityType"` + ActionType string `json:"actionType"` + Identifier string `json:"identifier"` + EntityURI string `json:"entityUri,omitempty"` +} + +// WorkRequestWire is the wire form of a work request. +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted *time.Time `json:"timeStarted,omitempty"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + OperationType string `json:"operationType"` + Status string `json:"status"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Resources []wrResourceWire `json:"resources"` + PercentComplete float32 `json:"percentComplete"` +} + +// workRequestSummaryWire is the list item form: the id field is named workRequestId. +type workRequestSummaryWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted *time.Time `json:"timeStarted,omitempty"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + OperationType string `json:"operationType"` + Status string `json:"status"` + WorkRequestID string `json:"workRequestId"` + CompartmentID string `json:"compartmentId"` + PercentComplete float32 `json:"percentComplete"` +} + +type wrLogWire struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +func entityPath(entityType string) string { + switch entityType { + case entityGateway: + return "/gateways/" + case entityDeploy: + return "/deployments/" + default: + return "/certificates/" + } +} + +// newWR records a work request that completes with the resource transition. +func (b *InMemoryBackend) newWR(op, compartment, entityType, id, action string, finish time.Time) string { + wr := &WorkRequest{ + ID: newOCID("apigatewayworkrequest"), Operation: op, CompartmentID: compartment, EntityType: entityType, + EntityID: id, Action: action, Accepted: b.nowUTC(), FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr.ID +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + now := b.nowUTC() + out := WorkRequestWire{ + OperationType: wr.Operation, ID: wr.ID, CompartmentID: wr.CompartmentID, TimeAccepted: wr.Accepted, + Status: "IN_PROGRESS", PercentComplete: halfComplete, TimeStarted: &wr.Accepted, + Resources: []wrResourceWire{{ + EntityType: wr.EntityType, ActionType: "IN_PROGRESS", Identifier: wr.EntityID, + EntityURI: base + entityPath(wr.EntityType) + wr.EntityID, + }}, + } + + if !wr.FinishAt.After(now) { + out.Status, out.PercentComplete, out.TimeFinished = "SUCCEEDED", 100, &wr.FinishAt + out.Resources[0].ActionType = wr.Action + } + + return out +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// ListWorkRequests lists the work requests of a compartment, optionally for one resource. +func (b *InMemoryBackend) ListWorkRequests( + compartment, resourceID string, o ListOpts, +) (page.Page[workRequestSummaryWire], error) { + if compartment == "" { + return page.Page[workRequestSummaryWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + var all []*WorkRequest + + for _, wr := range b.workReqs { + if wr.CompartmentID != compartment || (resourceID != "" && wr.EntityID != resourceID) { + continue + } + + all = append(all, wr) + } + + pg := paginate(all, o, func(w *WorkRequest) string { return w.ID }, + func(w *WorkRequest) (time.Time, int64) { return w.Accepted, w.Seq }) + + out := make([]workRequestSummaryWire, 0, len(pg.Data)) + + for _, wr := range pg.Data { + v := b.wrView(wr) + out = append(out, workRequestSummaryWire{ + TimeAccepted: v.TimeAccepted, TimeStarted: v.TimeStarted, TimeFinished: v.TimeFinished, + OperationType: v.OperationType, Status: v.Status, WorkRequestID: v.ID, CompartmentID: v.CompartmentID, + PercentComplete: v.PercentComplete, + }) + } + + return page.Page[workRequestSummaryWire]{Data: out, Next: pg.Next}, nil +} + +// WorkRequestLogs returns the log entries of a work request. +func (b *InMemoryBackend) WorkRequestLogs(id string, o ListOpts) (page.Page[wrLogWire], error) { + b.mu.RLock("WorkRequestLogs") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrLogWire]{}, errNotFound(id) + } + + logs := []wrLogWire{{Timestamp: wr.Accepted, Message: wr.Operation + " accepted"}} + if !wr.FinishAt.After(b.nowUTC()) { + logs = append(logs, wrLogWire{Timestamp: wr.FinishAt, Message: wr.Operation + " succeeded"}) + } + + return page.New(logs, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestExists reports whether a work request exists (its error list is always empty). +func (b *InMemoryBackend) WorkRequestExists(id string) error { + b.mu.RLock("WorkRequestExists") + defer b.mu.RUnlock() + + if _, ok := b.workReqs[id]; !ok { + return errNotFound(id) + } + + return nil +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + + return reply(q, w, "", err) +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(q.query("compartmentId"), q.query("resourceId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestLogs(q *req) error { + pg, err := h.Backend.WorkRequestLogs(q.param("workRequestId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestErrors(q *req) error { + if err := h.Backend.WorkRequestExists(q.param("workRequestId")); err != nil { + return err + } + + return q.json(http.StatusOK, map[string]any{itemsKey: []struct{}{}}, "") +} diff --git a/services/ociartifacts/PARITY.md b/services/ociartifacts/PARITY.md new file mode 100644 index 0000000000..43105b81d5 --- /dev/null +++ b/services/ociartifacts/PARITY.md @@ -0,0 +1,64 @@ +--- +service: ociartifacts +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every control-plane and content operation in the service tests +last_audit_commit: 4f3e4c057 +last_audit_date: 2026-10-30 +overall: B +ops: + CreateContainerRepository: {wire: ok, errors: partial, state: ok, persist: ok, note: "200 with the ContainerRepository and etag. displayName and compartmentId are required; displayName is one or more lowercase path components of letters, digits, dots, underscores and hyphens joined by slashes, at most 255 characters (Docker distribution name grammar, which OCIR pushes must satisfy). Duplicate names in the tenancy are 409 Conflict (code unverified). isImmutable and isPublic default to false; readme needs content and a TEXT_MARKDOWN or TEXT_PLAIN format. createdBy is the signing user OCID."} + GetContainerRepository: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Live imageCount, layerCount and layersSizeInBytes from the pushed images (shared layers count once), billableSizeInGBs rounded up to whole GB (unverified), namespace, timeLastPushed after the first push."} + ListContainerRepositories: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; compartmentIdInSubtree through OCI Identity when linked; repositoryId, displayName (case-insensitive), isPublic and lifecycleState filters; sortBy TIMECREATED (default, newest first) or DISPLAYNAME; sortOrder; limit and opc-next-page; collection totals (repositoryCount, imageCount, layerCount, layersSizeInBytes, remainingItemsCount)."} + UpdateContainerRepository: {wire: ok, errors: ok, state: ok, persist: ok, note: "isImmutable, isPublic, readme and tags; If-Match mismatch is 412 NoEtagMatch. Making a repository private takes effect for the next anonymous pull."} + DeleteContainerRepository: {wire: ok, errors: ok, state: ok, persist: ok, note: "204. The repository, its images and signatures are removed and their manifests deleted from the embedded registry; a later GET is 404 (OCI documents DELETING and DELETED states; the emulator has no such window)."} + ChangeContainerRepositoryCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 without a body; target compartment validated against OCI Identity when linked."} + ListContainerImages: {wire: ok, errors: partial, state: ok, persist: n/a, note: "One summary per image (manifest digest). displayName is repo:latestVersion, or repo:lastVersion@digest when the image has no versions and repo:unknown@digest when it never had one (SDK ContainerImage field docs). Filters: displayName, imageId, repositoryId, repositoryName and version (trailing * wildcard), isVersioned, lifecycleState, imageDigest. DELETED images are hidden unless filtered for."} + GetContainerImage: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Full model: versions (push order), layers ordered by digest with sizes, layersSizeInBytes, manifestSizeInBytes, pullCount and timeLastPulled. DELETED images stay readable."} + DeleteContainerImage: {wire: ok, errors: ok, state: ok, persist: ok, note: "204. The image becomes DELETED at once: listings hide it and the registry answers MANIFEST_UNKNOWN for its tags and digest until it is restored. Deleting a DELETED image is 404."} + UpdateContainerImage: {wire: ok, errors: ok, state: ok, persist: ok, note: "Tags only; If-Match 412."} + RestoreContainerImage: {wire: ok, errors: partial, state: ok, persist: ok, note: "DELETED to AVAILABLE, keeping versions not since taken by another image; an optional version re-tags the image (re-put in the registry). 409 IncorrectState when the image is not DELETED, 409 Conflict when the version is held in an immutable repository."} + RemoveContainerVersion: {wire: ok, errors: ok, state: ok, persist: ok, note: "Removes one version and the tag in the registry; the image stays. Unknown version is 404."} + LookupContainerImageByUri: {wire: ok, errors: ok, state: ok, persist: n/a, note: "namespace/repo:version or namespace/repo@digest; only AVAILABLE images."} + CreateContainerImageSignature: {wire: ok, errors: partial, state: ok, persist: ok, note: "All fields required; signingAlgorithm must be one of SHA_224/256/384/512_RSA_PKCS_PSS (SDK enum) else 400 InvalidParameter; message and signature must be base64 (SDK field docs: base64 encoded). The image must exist and be AVAILABLE. displayName is last 10 characters of kmsKeyId, kmsKeyVersionId, the algorithm and the last 10 characters of the signature id, joined by :: (SDK example). An identical signature is 409 Conflict. The signature is stored, not verified against Vault."} + GetContainerImageSignature: {wire: ok, errors: ok, state: ok, persist: n/a, note: "With etag."} + ListContainerImageSignatures: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; imageId, repositoryId, repositoryName (wildcard), imageDigest, displayName, kmsKeyId, kmsKeyVersionId and signingAlgorithm filters; paged and sorted like the other listings."} + UpdateContainerImageSignature: {wire: ok, errors: ok, state: ok, persist: ok, note: "Tags only."} + DeleteContainerImageSignature: {wire: ok, errors: ok, state: ok, persist: ok, note: "204; removed outright."} + GetContainerConfiguration: {wire: ok, errors: ok, state: ok, persist: n/a, note: "namespace and isRepositoryCreatedOnFirstPush (default true, unverified) with an etag. The configuration is tenancy-wide; compartmentId is required and checked."} + UpdateContainerConfiguration: {wire: ok, errors: ok, state: ok, persist: ok, note: "isRepositoryCreatedOnFirstPush; If-Match 412. When false, a push to a repository that does not exist is refused with 403 DENIED."} + CreateRepository: {wire: ok, errors: ok, state: ok, persist: ok, note: "Generic repositories only: repositoryType GENERIC (else 400), isImmutable and compartmentId required, displayName optional (generated as repository plus a timestamp when absent). Returns the GenericRepository with repositoryType."} + GetRepository: {wire: ok, errors: ok, state: ok, persist: n/a, note: "With etag."} + ListRepositories: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Generic repositories of a compartment; id, displayName, isImmutable and lifecycleState filters; paged."} + UpdateRepository: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName, description and tags; repositoryType must be GENERIC."} + DeleteRepository: {wire: ok, errors: ok, state: ok, persist: ok, note: "204; also deletes the repository's artifacts (unverified whether Oracle refuses a non-empty repository)."} + ChangeRepositoryCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 without a body."} + GetGenericArtifact: {wire: ok, errors: ok, state: ok, persist: n/a, note: "sha256 is upper-case hex of the uploaded bytes; displayName is path:version truncated to 255 (SDK field docs)."} + GetGenericArtifactByPath: {wire: ok, errors: ok, state: ok, persist: n/a, note: "The SDK does not escape / in artifactPath, so the path is everything between /artifactPaths/ and the last /versions/."} + ListGenericArtifacts: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId and repositoryId required; id, displayName, sha256 and lifecycleState filters; artifactPath and version are prefix filters (SDK docs); paged."} + UpdateGenericArtifact: {wire: ok, errors: ok, state: ok, persist: ok, note: "Tags only."} + UpdateGenericArtifactByPath: {wire: ok, errors: ok, state: ok, persist: ok, note: "Tags only."} + DeleteGenericArtifact: {wire: ok, errors: ok, state: ok, persist: ok, note: "204."} + DeleteGenericArtifactByPath: {wire: ok, errors: ok, state: ok, persist: ok, note: "204."} + PutGenericArtifactContentByPath: {wire: ok, errors: partial, state: ok, persist: ok, note: "genericartifactscontent API (SDK path .../versions/{version}/content), served on the same listener instead of the generic.artifacts host. Creates the artifact or replaces it in a mutable repository (id kept, new sha256 and etag); an immutable repository answers 409 Conflict (code unverified); If-Match 412. Bodies are capped at 256 MiB (emulator limit) and held in memory."} + GetGenericArtifactContent: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Raw bytes, application/octet-stream, etag."} + GetGenericArtifactContentByPath: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Raw bytes by repository, path and version."} + GetAccessToken: {wire: ok, errors: ok, state: ok, persist: n/a, note: "containerregistry API 20180419 GET /docker/token (SDK containerregistry.GetAccessToken): Basic credentials get a signed token for the requested repository scopes, a request without credentials gets an anonymous pull-only token. Body {token, access_token, scope, expires_in 3600}. Wrong namespace, missing user or empty password is 401."} +families: + auth: {status: partial, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-artifacts-enforce-auth rejects unsigned and unknown keys. Registry logins use docker login -u / with an OCI auth token as password (Oracle: Pushing Images Using the Docker CLI): the namespace must be the tenancy namespace, the user part non-empty and the token non-empty. OCI Identity does not keep auth tokens after CreateAuthToken, so the token value cannot be compared and any non-empty token is accepted; tokens are never logged or returned."} + error_envelope: {status: partial, note: "Control plane errors are {code,message} with opc-request-id: InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch, IncorrectState, Conflict, CannotParseRequest (OCI API error reference). Registry errors use the distribution {errors:[{code,message}]} envelope: UNAUTHORIZED with a WWW-Authenticate Bearer challenge, NAME_UNKNOWN, NAME_INVALID, MANIFEST_UNKNOWN, DENIED, TAG_INVALID, UNSUPPORTED."} + registry_data_plane: {status: partial, note: "Docker Registry v2 from pkgs/ociregistry (distribution v3) at /v2 on the same listener, repository names /. docker login, push and pull were run against the listener with the real docker CLI. 401 challenges point at /20180419/docker/token (OCIR's token realm); Basic credentials are also accepted directly. Tokens are HMAC-signed with a per-process secret, carry the granted repository scopes and expire after one hour on the injectable clock; a token without a needed scope gets 401 insufficient_scope. Cross-repository blob mounts need pull on the source. _catalog is not offered (404 UNSUPPORTED)."} + first_push: {status: partial, note: "A push to a repository that does not exist creates it in the tenancy root compartment (private, mutable, createdBy the pushing user name) when isRepositoryCreatedOnFirstPush is true (SDK ContainerConfiguration docs: repositories created this way belong to the root compartment). When false the push is 403 DENIED. Invalid names are 400 NAME_INVALID; a name outside the namespace is 404 NAME_UNKNOWN. Pulls never create repositories."} + immutable_public: {status: partial, note: "In an immutable repository a tag that already points at a different manifest cannot be overwritten (409 TAG_INVALID, code unverified); re-pushing the same manifest, new tags and digest pushes are allowed. Public repositories allow anonymous pull (GET/HEAD manifests, blobs, tag lists) with an anonymous token; anonymous push and delete, and any access to private or unknown repositories, are 401."} + images: {status: partial, note: "Every manifest pushed becomes an image keyed by digest with versions for its tags; a tag moves between images on overwrite. Layers and sizes come from the manifest; indexes list no layers. pullCount and timeLastPulled count GET manifest requests (HEAD does not count). Data plane deletes by digest remove the image, by tag remove the version. Image createdBy and version createdBy are the registry user name, not a user OCID."} + persistence: {status: partial, note: "Snapshot carries repositories, images, signatures, generic repositories and artifacts (content included), the container configuration, API keys and the sequence counter. Registry blobs and manifests live under the data directory when --persist is on; without one they are in memory and Restore drops the image and signature records so the control plane does not list images whose blobs are gone."} + lifecycle: {status: partial, note: "All state changes are immediate; the injectable clock stamps timeCreated, timeLastPulled and token expiry."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-artifacts-port / OCI_ARTIFACTS_PORT (default 8166); never on the shared AWS router; cli.go reserves the port in the shared pool."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} +items_still_open: + - "Auth tokens are not validated: OCI Identity discards them after CreateAuthToken, so any non-empty token with a / login is accepted, and the user part is not checked against Identity users. IAM policies, repository policies, resource principals and instance principals are not applied; only API-key signatures are verified on the control plane." + - "Registry hostnames are not modelled: one host:port serves every region key and the namespace is the first path segment, as OCIR paths are (.ocir.io//). The generic artifact content API is served on the control-plane listener instead of generic.artifacts..oci.oraclecloud.com. No TLS (docker treats localhost registries as insecure)." + - "Unverified, chosen by the emulator: 409 Conflict for duplicate repository names, identical signatures and immutable generic overwrites; 409 TAG_INVALID for immutable tag overwrites; isRepositoryCreatedOnFirstPush defaulting to true; billableSizeInGBs as whole GB rounded up; DeleteRepository cascading to artifacts; the 256 MiB artifact cap; one-hour registry token lifetime; the unversioned image displayName form." + - "Repositories and images have no DELETING window: deletes are immediate (images soft-delete to DELETED until restored; repositories vanish). OCI's deletion retention, repository quotas and per-image limits are not modelled, and blobs of deleted images are not garbage collected." + - "createdBy on images and versions is the registry login user name rather than a user OCID; signatures and repositories created through the API use the signing user OCID." + - "pullCount counts GET manifest requests only, including ones the registry then rejects; blob pulls and HEAD requests do not count. Image scanning, vulnerability reports and replication are not provided." + - "opc-retry-token idempotency and compartmentIdInSubtree outside OCI Identity are not applied; systemTags are always empty." +--- diff --git a/services/ociartifacts/README.md b/services/ociartifacts/README.md new file mode 100644 index 0000000000..f16741c3f0 --- /dev/null +++ b/services/ociartifacts/README.md @@ -0,0 +1,29 @@ + +# OCI Artifacts and Container Registry + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-30 (`4f3e4c057`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 37 (32 ok, 5 partial) | +| Feature families | 10 (2 ok, 8 partial) | +| Known gaps | 7 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Auth tokens are not validated: OCI Identity discards them after CreateAuthToken, so any non-empty token with a / login is accepted, and the user part is not checked against Identity users. IAM policies, repository policies, resource principals and instance principals are not applied; only API-key signatures are verified on the control plane. +- Registry hostnames are not modelled: one host:port serves every region key and the namespace is the first path segment, as OCIR paths are (.ocir.io//). The generic artifact content API is served on the control-plane listener instead of generic.artifacts..oci.oraclecloud.com. No TLS (docker treats localhost registries as insecure). +- Unverified, chosen by the emulator: 409 Conflict for duplicate repository names, identical signatures and immutable generic overwrites; 409 TAG_INVALID for immutable tag overwrites; isRepositoryCreatedOnFirstPush defaulting to true; billableSizeInGBs as whole GB rounded up; DeleteRepository cascading to artifacts; the 256 MiB artifact cap; one-hour registry token lifetime; the unversioned image displayName form. +- Repositories and images have no DELETING window: deletes are immediate (images soft-delete to DELETED until restored; repositories vanish). OCI's deletion retention, repository quotas and per-image limits are not modelled, and blobs of deleted images are not garbage collected. +- createdBy on images and versions is the registry login user name rather than a user OCID; signatures and repositories created through the API use the signing user OCID. +- pullCount counts GET manifest requests only, including ones the registry then rejects; blob pulls and HEAD requests do not count. Image scanning, vulnerability reports and replication are not provided. +- opc-retry-token idempotency and compartmentIdInSubtree outside OCI Identity are not applied; systemTags are always empty. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ociartifacts/control_test.go b/services/ociartifacts/control_test.go new file mode 100644 index 0000000000..7e1cbedfbb --- /dev/null +++ b/services/ociartifacts/control_test.go @@ -0,0 +1,446 @@ +package ociartifacts_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/artifacts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateContainerRepositoryValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + display string + compart string + wantCode string + wantHTTP int + }{ + {name: "simple", display: "app", compart: testCompartment}, + {name: "path", display: "team/app-1/api_v2.x", compart: testCompartment}, + {name: "uppercase", display: "App", compart: testCompartment, wantCode: "InvalidParameter", wantHTTP: 400}, + { + name: "trailing slash", + display: "app/", + compart: testCompartment, + wantCode: "InvalidParameter", + wantHTTP: 400, + }, + {name: "leading dash", display: "-app", compart: testCompartment, wantCode: "InvalidParameter", wantHTTP: 400}, + {name: "double slash", display: "a//b", compart: testCompartment, wantCode: "InvalidParameter", wantHTTP: 400}, + {name: "space", display: "my app", compart: testCompartment, wantCode: "InvalidParameter", wantHTTP: 400}, + { + name: "too long", + display: strings.Repeat("a", 256), + compart: testCompartment, + wantCode: "InvalidParameter", + wantHTTP: 400, + }, + {name: "no compartment", display: "app", compart: "", wantCode: "MissingParameter", wantHTTP: 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + display := tt.display + + resp, err := e.client.CreateContainerRepository(t.Context(), artifacts.CreateContainerRepositoryRequest{ + CompartmentId: new(tt.compart), DisplayName: new(display)}) + + if tt.wantCode != "" { + se := svcErr(t, err) + assert.Equal(t, tt.wantCode, se.GetCode()) + assert.Equal(t, tt.wantHTTP, se.GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + assert.Equal(t, display, *resp.DisplayName) + assert.Equal(t, testNamespace, *resp.Namespace) + assert.Equal(t, artifacts.ContainerRepositoryLifecycleStateAvailable, resp.LifecycleState) + assert.False(t, *resp.IsPublic) + assert.False(t, *resp.IsImmutable) + assert.Zero(t, *resp.ImageCount) + assert.NotEmpty(t, *resp.Id) + assert.NotEmpty(t, *resp.Etag) + }) + } +} + +func TestContainerRepositoryLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + + repo := e.createRepo(t, "svc/api", func(d *artifacts.CreateContainerRepositoryDetails) { + d.IsImmutable, d.IsPublic = new(true), new(true) + d.FreeformTags = map[string]string{"env": "dev"} + d.Readme = &artifacts.ContainerRepositoryReadme{ + Content: new("# hi"), + Format: artifacts.ContainerRepositoryReadmeFormatMarkdown, + } + }) + assert.Equal(t, testUser, *repo.CreatedBy) + assert.Equal(t, "# hi", *repo.Readme.Content) + + _, err := e.client.CreateContainerRepository(t.Context(), artifacts.CreateContainerRepositoryRequest{ + CompartmentId: new(testCompartment), DisplayName: new("svc/api")}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + got, err := e.client.GetContainerRepository( + t.Context(), + artifacts.GetContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + assert.Equal(t, map[string]string{"env": "dev"}, got.FreeformTags) + assert.True(t, *got.IsPublic) + + _, err = e.client.UpdateContainerRepository(t.Context(), artifacts.UpdateContainerRepositoryRequest{ + RepositoryId: repo.Id, IfMatch: new("wrong"), + IsPublic: new(false), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.client.UpdateContainerRepository(t.Context(), artifacts.UpdateContainerRepositoryRequest{ + RepositoryId: repo.Id, + IfMatch: got.Etag, + IsPublic: new(false), + IsImmutable: new(false), + Readme: &artifacts.ContainerRepositoryReadme{ + Content: new("plain"), + Format: artifacts.ContainerRepositoryReadmeFormatPlain, + }, + }) + require.NoError(t, err) + assert.False(t, *upd.IsPublic) + assert.False(t, *upd.IsImmutable) + assert.Equal(t, artifacts.ContainerRepositoryReadmeFormatPlain, upd.Readme.Format) + assert.Equal(t, map[string]string{"env": "dev"}, upd.FreeformTags) + + _, err = e.client.ChangeContainerRepositoryCompartment( + t.Context(), + artifacts.ChangeContainerRepositoryCompartmentRequest{ + RepositoryId: repo.Id, + CompartmentId: new(otherCompart), + }, + ) + require.NoError(t, err) + + moved, err := e.client.GetContainerRepository( + t.Context(), + artifacts.GetContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + assert.Equal(t, otherCompart, *moved.CompartmentId) + + _, err = e.client.DeleteContainerRepository(t.Context(), artifacts.DeleteContainerRepositoryRequest{ + RepositoryId: repo.Id, IfMatch: new("stale")}) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteContainerRepository( + t.Context(), + artifacts.DeleteContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + + _, err = e.client.GetContainerRepository( + t.Context(), + artifacts.GetContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + se := svcErr(t, err) + assert.Equal(t, "NotAuthorizedOrNotFound", se.GetCode()) + assert.Equal(t, http.StatusNotFound, se.GetHTTPStatusCode()) +} + +func TestUpdateRepositoryReadmeValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + readme *artifacts.ContainerRepositoryReadme + code string + }{ + { + "bad format", + &artifacts.ContainerRepositoryReadme{Content: new("x"), Format: "TEXT_HTML"}, + "InvalidParameter", + }, + { + "no content", + &artifacts.ContainerRepositoryReadme{Format: artifacts.ContainerRepositoryReadmeFormatPlain}, + "MissingParameter", + }, + {"no format", &artifacts.ContainerRepositoryReadme{Content: new("x")}, "MissingParameter"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + repo := e.createRepo(t, "r", nil) + + _, err := e.client.UpdateContainerRepository(t.Context(), artifacts.UpdateContainerRepositoryRequest{ + RepositoryId: repo.Id, + Readme: tt.readme, + }) + assert.Equal(t, tt.code, svcErr(t, err).GetCode()) + }) + } +} + +func TestListContainerRepositories(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "alpha", nil) + e.clock.Advance(1_000_000_000) + e.createRepo(t, "beta", func(d *artifacts.CreateContainerRepositoryDetails) { d.IsPublic = new(true) }) + e.clock.Advance(1_000_000_000) + e.createRepo( + t, + "gamma", + func(d *artifacts.CreateContainerRepositoryDetails) { d.CompartmentId = new(otherCompart) }, + ) + + names := func(items []artifacts.ContainerRepositorySummary) []string { + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, *it.DisplayName) + } + + return out + } + + tests := []struct { + name string + req artifacts.ListContainerRepositoriesRequest + want []string + }{ + { + "default newest first", + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(testCompartment)}, + []string{"beta", "alpha"}, + }, + {"by name asc", artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(testCompartment), SortBy: artifacts.ListContainerRepositoriesSortByDisplayname, + }, []string{"alpha", "beta"}}, + {"by name desc", artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(testCompartment), SortBy: artifacts.ListContainerRepositoriesSortByDisplayname, + SortOrder: artifacts.ListContainerRepositoriesSortOrderDesc, + }, []string{"beta", "alpha"}}, + { + "public only", + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(testCompartment), IsPublic: new(true)}, + []string{"beta"}, + }, + { + "display name", + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(testCompartment), DisplayName: new("ALPHA")}, + []string{"alpha"}, + }, + { + "other compartment", + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(otherCompart)}, + []string{"gamma"}, + }, + { + "deleted state", + artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(testCompartment), + LifecycleState: new("DELETED"), + }, + []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.client.ListContainerRepositories(t.Context(), tt.req) + require.NoError(t, err) + assert.Equal(t, tt.want, names(resp.Items)) + assert.Equal(t, len(tt.want), *resp.RepositoryCount) + }) + } + + t.Run("pagination", func(t *testing.T) { + t.Parallel() + + var got []string + + page := (*string)(nil) + + for range 5 { + resp, err := e.client.ListContainerRepositories(t.Context(), artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(testCompartment), Limit: new(1), Page: page, + }) + require.NoError(t, err) + + got = append(got, names(resp.Items)...) + + if resp.OpcNextPage == nil { + assert.Zero(t, *resp.RemainingItemsCount) + + break + } + + assert.Equal(t, 1, *resp.RemainingItemsCount) + + page = resp.OpcNextPage + } + + assert.Equal(t, []string{"beta", "alpha"}, got) + }) + + t.Run("errors", func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, http.MethodGet, e.base+"/20160918/container/repositories", "") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") + + _, err := e.client.ListContainerRepositories(t.Context(), artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(testCompartment), Page: new("!!not-a-token"), + }) + assert.Equal(t, "InvalidParameter", svcErr(t, err).GetCode()) + }) +} + +func TestContainerConfiguration(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + got, err := e.client.GetContainerConfiguration( + t.Context(), + artifacts.GetContainerConfigurationRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Equal(t, testNamespace, *got.Namespace) + assert.True(t, *got.IsRepositoryCreatedOnFirstPush) + + _, err = e.client.UpdateContainerConfiguration(t.Context(), artifacts.UpdateContainerConfigurationRequest{ + CompartmentId: new(testCompartment), IfMatch: new("stale"), + IsRepositoryCreatedOnFirstPush: new(false), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.client.UpdateContainerConfiguration(t.Context(), artifacts.UpdateContainerConfigurationRequest{ + CompartmentId: new(testCompartment), IfMatch: got.Etag, + IsRepositoryCreatedOnFirstPush: new(false), + }) + require.NoError(t, err) + assert.False(t, *upd.IsRepositoryCreatedOnFirstPush) + + again, err := e.client.GetContainerConfiguration( + t.Context(), + artifacts.GetContainerConfigurationRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.False(t, *again.IsRepositoryCreatedOnFirstPush) + + status, body := rawDo(t, http.MethodGet, e.base+"/20160918/container/configuration", "") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") +} + +func TestControlPlaneAuth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts envOpts + wantCode int + }{ + {"accept any when not enforced", envOpts{}, http.StatusOK}, + {"signed by registered key", envOpts{enforce: true, register: true}, http.StatusOK}, + {"unknown key rejected", envOpts{enforce: true}, http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, tt.opts) + + _, err := e.client.GetContainerConfiguration( + t.Context(), + artifacts.GetContainerConfigurationRequest{CompartmentId: new(testCompartment)}, + ) + if tt.wantCode == http.StatusOK { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.wantCode, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + t.Run("unsigned rejected when enforced", func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + status, body := rawDo( + t, + http.MethodGet, + e.base+"/20160918/container/configuration?compartmentId="+testCompartment, + "", + ) + assert.Equal(t, http.StatusUnauthorized, status) + assert.Contains(t, body, "NotAuthenticated") + }) +} + +func TestUnknownRouteAndBadBody(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + method string + path string + body string + code string + want int + }{ + {"no route", http.MethodGet, "/20160918/nope", "", "NotFound", http.StatusNotFound}, + { + "bad json", + http.MethodPost, + "/20160918/container/repositories", + "{", + "CannotParseRequest", + http.StatusBadRequest, + }, + { + "empty body", + http.MethodPost, + "/20160918/container/repositories", + "", + "MissingParameter", + http.StatusBadRequest, + }, + {"wrong method", http.MethodPatch, "/20160918/container/repositories", "{}", "NotFound", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.want, status) + assert.Contains(t, body, tt.code) + }) + } +} diff --git a/services/ociartifacts/dataplane.go b/services/ociartifacts/dataplane.go new file mode 100644 index 0000000000..3772030349 --- /dev/null +++ b/services/ociartifacts/dataplane.go @@ -0,0 +1,352 @@ +package ociartifacts + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/ociregistry" +) + +const ( + actionPull = "pull" + actionPush = "push" + actionDel = "delete" + tokenTTL = time.Hour + scopeRepo = "repository" + maxManifest = 4 << 20 +) + +// Permit outcomes for a manifest request. +const ( + permitOK = iota + permitUnknown + permitTagExists +) + +// repoAccess is what the registry front end needs to know about a repository. +type repoAccess struct { + ID string + Public bool + Immutable bool +} + +type tokenClaims struct { + Sub string `json:"sub"` + Scopes []string `json:"scopes"` + Exp int64 `json:"exp"` + Anon bool `json:"anon"` +} + +// Scope is one `repository::` grant. +type Scope struct { + Name string + Actions []string +} + +func (s Scope) String() string { return scopeRepo + ":" + s.Name + ":" + strings.Join(s.Actions, ",") } + +// ParseScope parses `repository::[,]`; other resource types are not supported. +func ParseScope(raw string) (Scope, bool) { + typ, rest, ok := strings.Cut(raw, ":") + if !ok || typ != scopeRepo { + return Scope{}, false + } + + i := strings.LastIndex(rest, ":") + if i <= 0 { + return Scope{}, false + } + + return Scope{Name: rest[:i], Actions: strings.Split(rest[i+1:], ",")}, true +} + +func (c tokenClaims) allows(name, action string) bool { + for _, raw := range c.Scopes { + s, ok := ParseScope(raw) + if ok && s.Name == name && (slices.Contains(s.Actions, action) || slices.Contains(s.Actions, "*")) { + return true + } + } + + return false +} + +func (b *InMemoryBackend) sign(payload string) string { + m := hmac.New(sha256.New, b.secret) + m.Write([]byte(payload)) + + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +// IssueToken returns a signed registry token and its lifetime in seconds. Scopes are granted as requested; an +// anonymous token is limited to pull. +func (b *InMemoryBackend) IssueToken(user string, anon bool, scopes []Scope) (string, []Scope, int) { + granted := make([]Scope, 0, len(scopes)) + + for _, s := range scopes { + if anon { + if !slices.Contains(s.Actions, actionPull) { + continue + } + + s.Actions = []string{actionPull} + } + + granted = append(granted, s) + } + + raw := make([]string, 0, len(granted)) + for _, s := range granted { + raw = append(raw, s.String()) + } + + b.mu.RLock("IssueToken") + exp := b.now().Add(tokenTTL).Unix() + b.mu.RUnlock() + + body, _ := json.Marshal( + tokenClaims{Sub: user, Anon: anon, Scopes: raw, Exp: exp}, + ) //nolint:errchkjson // static shape + payload := base64.RawURLEncoding.EncodeToString(body) + + return payload + "." + b.sign(payload), granted, int(tokenTTL.Seconds()) +} + +// ParseToken verifies a registry token's signature and expiry. +func (b *InMemoryBackend) ParseToken(token string) (tokenClaims, bool) { + payload, sig, ok := strings.Cut(token, ".") + if !ok || !hmac.Equal([]byte(sig), []byte(b.sign(payload))) { + return tokenClaims{}, false + } + + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return tokenClaims{}, false + } + + var c tokenClaims + if json.Unmarshal(raw, &c) != nil { + return tokenClaims{}, false + } + + b.mu.RLock("ParseToken") + defer b.mu.RUnlock() + + return c, b.now().Unix() < c.Exp +} + +// ValidLogin accepts `/` with any non-empty auth token and returns the user part. Auth tokens +// are not stored by OCI Identity, so there is nothing to compare the password with. +func (b *InMemoryBackend) ValidLogin(username, password string) (string, bool) { + ns, user, ok := strings.Cut(username, "/") + if !ok || user == "" || password == "" { + return "", false + } + + b.mu.RLock("ValidLogin") + defer b.mu.RUnlock() + + return user, strings.EqualFold(ns, b.namespace) +} + +// SplitRegistryName strips the namespace from a registry repository name. +func (b *InMemoryBackend) SplitRegistryName(name string) (string, bool) { + b.mu.RLock("SplitRegistryName") + defer b.mu.RUnlock() + + prefix := strings.ToLower(b.namespace) + "/" + if len(name) <= len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) { + return "", false + } + + return name[len(prefix):], true +} + +// RepoAccess looks up the repository addressed by a display name. +func (b *InMemoryBackend) RepoAccess(display string) (repoAccess, bool) { + b.mu.RLock("RepoAccess") + defer b.mu.RUnlock() + + r := b.repoByName(display) + if r == nil { + return repoAccess{}, false + } + + return repoAccess{ID: r.ID, Public: r.IsPublic, Immutable: r.IsImmutable}, true +} + +// EnsureRepo creates the repository on first push when the container configuration allows it. +func (b *InMemoryBackend) EnsureRepo(display, user string) *APIError { + if err := validRepoName(display); err != nil { + return err + } + + b.mu.Lock("EnsureRepo") + defer b.mu.Unlock() + + if b.repoByName(display) != nil { + return nil + } + + if !b.createOnPush { + return newErr( + http.StatusForbidden, + "DENIED", + "The repository does not exist and creating repositories on first push is disabled", + ) + } + + b.newRepo(display, b.tenancy, user) + + return nil +} + +// PermitManifest decides whether a manifest read, write or delete may proceed. digest is the digest of the body +// being written, when known. +func (b *InMemoryBackend) PermitManifest(display, ref, op, digest string, pull bool) int { + b.mu.Lock("PermitManifest") + defer b.mu.Unlock() + + r := b.repoByName(display) + if r == nil { + return permitOK + } + + switch op { + case ociregistry.OpRead: + im := b.anyImageByRef(r.ID, ref) + if im == nil { + return permitOK + } + + if im.State == stateDeleted { + return permitUnknown + } + + if pull { + now := b.nowUTC() + im.PullCount++ + im.TimeLastPulled = &now + } + case ociregistry.OpWrite: + if r.IsImmutable && !ociregistry.IsDigest(ref) { + if im := b.anyImageByRef(r.ID, ref); im != nil && im.Digest != digest { + return permitTagExists + } + } + } + + return permitOK +} + +func (b *InMemoryBackend) anyImageByRef(repoID, ref string) *Image { + if ociregistry.IsDigest(ref) { + return b.imageByDigest(repoID, ref) + } + + for _, im := range b.images { + if im.RepoID == repoID && im.hasVersion(ref) { + return im + } + } + + return nil +} + +type manifestDoc struct { + Layers []struct { + Digest string `json:"digest"` + Size int64 `json:"size"` + } `json:"layers"` +} + +// RecordPush notes a stored manifest as an image of the repository, tagging it when ref is a tag. +func (b *InMemoryBackend) RecordPush(display, ref, digest, user string, body []byte) { + if digest == "" { + return + } + + var doc manifestDoc + + _ = json.Unmarshal(body, &doc) + + b.mu.Lock("RecordPush") + defer b.mu.Unlock() + + r := b.repoByName(display) + if r == nil { + return + } + + now := b.nowUTC() + r.TimeLastPushed = &now + + im := b.imageByDigest(r.ID, digest) + if im == nil { + im = &Image{ + ID: newOCID("containerimage"), RepoID: r.ID, Digest: digest, CreatedBy: user, State: stateAvailable, + TimeCreated: now, Seq: b.nextSeq(), ManifestSize: len(body), + } + b.images[im.ID] = im + b.setLayers(im, doc, now) + } else if im.State == stateDeleted { + im.State, im.Versions = stateAvailable, nil + } + + if !ociregistry.IsDigest(ref) { + b.moveTag(r.ID, im, ref, user) + } + + im.Etag = newEtag() +} + +func (b *InMemoryBackend) setLayers(im *Image, doc manifestDoc, now time.Time) { + seen := map[string]struct{}{} + + for _, l := range doc.Layers { + if _, dup := seen[l.Digest]; dup { + continue + } + + seen[l.Digest] = struct{}{} + im.Layers = append(im.Layers, Layer{Digest: l.Digest, Size: l.Size, TimeCreated: now}) + im.LayersSize += l.Size + } +} + +// RecordDelete forgets a manifest deleted through the registry API, or just the tag when ref is one. +func (b *InMemoryBackend) RecordDelete(display, ref string) { + b.mu.Lock("RecordDelete") + defer b.mu.Unlock() + + r := b.repoByName(display) + if r == nil { + return + } + + im := b.anyImageByRef(r.ID, ref) + if im == nil { + return + } + + if !ociregistry.IsDigest(ref) { + b.dropVersion(im, ref) + im.Etag = newEtag() + + return + } + + for sid, s := range b.sigs { + if s.ImageID == im.ID { + delete(b.sigs, sid) + } + } + + delete(b.images, im.ID) +} diff --git a/services/ociartifacts/errors.go b/services/ociartifacts/errors.go new file mode 100644 index 0000000000..0a2224e149 --- /dev/null +++ b/services/ociartifacts/errors.go @@ -0,0 +1,49 @@ +package ociartifacts + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ociartifacts: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errConflict(msg string) *APIError { return newErr(http.StatusConflict, "Conflict", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } + +func errTooLarge() *APIError { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") +} diff --git a/services/ociartifacts/generic.go b/services/ociartifacts/generic.go new file mode 100644 index 0000000000..d9e0b2a047 --- /dev/null +++ b/services/ociartifacts/generic.go @@ -0,0 +1,545 @@ +package ociartifacts + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "unicode" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + maxGenericName = 255 + maxArtifactPath = 255 + maxArtifactVer = 255 + maxArtifactBytes = 256 << 20 + repositoryTypeGen = "GENERIC" +) + +type createGenericRepoIn struct { + Tags + IsImmutable *bool `json:"isImmutable"` + DisplayName *string `json:"displayName"` + Description *string `json:"description"` + CompartmentID string `json:"compartmentId"` + RepositoryType string `json:"repositoryType"` +} + +type updateGenericRepoIn struct { + Tags + DisplayName *string `json:"displayName"` + Description *string `json:"description"` + RepositoryType string `json:"repositoryType"` +} + +func (r *GenericRepo) view() genericRepoWire { + return genericRepoWire{ + TimeCreated: r.TimeCreated, ID: r.ID, DisplayName: r.Name, CompartmentID: r.CompartmentID, + Description: r.Description, LifecycleState: stateAvailable, RepositoryType: repositoryTypeGen, + IsImmutable: r.IsImmutable, FreeformTags: r.Tags.free(), DefinedTags: r.Tags.defined(), + } +} + +func (b *InMemoryBackend) artifactView(a *GenericArtifact, repo *GenericRepo) genericArtifactWire { + name := a.Path + ":" + a.Version + if len(name) > maxGenericName { + name = name[:maxGenericName] + } + + return genericArtifactWire{ + TimeCreated: a.TimeCreated, ID: a.ID, DisplayName: name, CompartmentID: repo.CompartmentID, + RepositoryID: repo.ID, ArtifactPath: a.Path, Version: a.Version, Sha256: a.Sha256, + LifecycleState: stateAvailable, SizeInBytes: int64(len(a.Content)), FreeformTags: a.Tags.free(), + DefinedTags: a.Tags.defined(), + } +} + +func validGenericName(name string) *APIError { + if name == "" || len(name) > maxGenericName || strings.ContainsFunc(name, unicode.IsControl) { + return errParam("displayName must be 1 to 255 printable characters") + } + + return nil +} + +func (b *InMemoryBackend) genericRepo(id string) (*GenericRepo, *APIError) { + r, ok := b.gens[id] + if !ok { + return nil, errNotFound(id) + } + + return r, nil +} + +// CreateRepository creates a generic artifacts repository. +func (b *InMemoryBackend) CreateRepository(in createGenericRepoIn) (genericRepoWire, string, error) { + if in.RepositoryType != repositoryTypeGen { + return genericRepoWire{}, "", errParam("repositoryType must be GENERIC") + } + + if in.IsImmutable == nil { + return genericRepoWire{}, "", errMissing("isImmutable is required") + } + + b.mu.Lock("CreateRepository") + defer b.mu.Unlock() + + if err := b.checkCompartment(in.CompartmentID); err != nil { + return genericRepoWire{}, "", err + } + + r := &GenericRepo{ + ID: newOCID("artifactrepository"), CompartmentID: in.CompartmentID, IsImmutable: *in.IsImmutable, + Description: str(in.Description), TimeCreated: b.nowUTC(), Etag: newEtag(), Seq: b.nextSeq(), Tags: in.Tags, + } + + if in.DisplayName != nil { + if err := validGenericName(*in.DisplayName); err != nil { + return genericRepoWire{}, "", err + } + + r.Name = *in.DisplayName + } else { + r.Name = "repository" + r.TimeCreated.Format("20060102150405") + } + + b.gens[r.ID] = r + + return r.view(), r.Etag, nil +} + +// GetRepository returns a generic repository and its etag. +func (b *InMemoryBackend) GetRepository(id string) (genericRepoWire, string, error) { + b.mu.RLock("GetRepository") + defer b.mu.RUnlock() + + r, err := b.genericRepo(id) + if err != nil { + return genericRepoWire{}, "", err + } + + return r.view(), r.Etag, nil +} + +type genericRepoFilter struct { + isImmutable *bool + compartment string + id string + name string + state string +} + +// ListRepositories lists the generic repositories of a compartment. +func (b *InMemoryBackend) ListRepositories(f genericRepoFilter, o ListOpts) (genericRepoCollectionWire, string, error) { + if f.compartment == "" { + return genericRepoCollectionWire{}, "", errMissing("compartmentId is required") + } + + b.mu.RLock("ListRepositories") + defer b.mu.RUnlock() + + var all []*GenericRepo + + for _, r := range b.gens { + if (f.state == "" || strings.EqualFold(f.state, stateAvailable)) && r.CompartmentID == f.compartment && + (f.id == "" || f.id == r.ID) && (f.name == "" || strs.Equal(f.name, r.Name)) && + (f.isImmutable == nil || *f.isImmutable == r.IsImmutable) { + all = append(all, r) + } + } + + pg, _, err := paginate(all, o, func(r *GenericRepo) sortKey { + return sortKey{created: r.TimeCreated, name: r.Name, seq: r.Seq} + }) + if err != nil { + return genericRepoCollectionWire{}, "", err + } + + out := genericRepoCollectionWire{Items: make([]genericRepoWire, 0, len(pg.Data))} + for _, r := range pg.Data { + out.Items = append(out.Items, r.view()) + } + + return out, pg.Next, nil +} + +// UpdateRepository changes a generic repository's name, description and tags. +func (b *InMemoryBackend) UpdateRepository( + id, ifMatch string, + in updateGenericRepoIn, +) (genericRepoWire, string, error) { + if in.RepositoryType != repositoryTypeGen { + return genericRepoWire{}, "", errParam("repositoryType must be GENERIC") + } + + b.mu.Lock("UpdateRepository") + defer b.mu.Unlock() + + r, err := b.genericRepo(id) + if err != nil { + return genericRepoWire{}, "", err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return genericRepoWire{}, "", err + } + + if in.DisplayName != nil { + if err = validGenericName(*in.DisplayName); err != nil { + return genericRepoWire{}, "", err + } + + r.Name = *in.DisplayName + } + + if in.Description != nil { + r.Description = *in.Description + } + + r.Tags.merge(in.Tags) + r.Etag = newEtag() + + return r.view(), r.Etag, nil +} + +// DeleteRepository deletes a generic repository together with its artifacts. +func (b *InMemoryBackend) DeleteRepository(id, ifMatch string) error { + b.mu.Lock("DeleteRepository") + defer b.mu.Unlock() + + r, err := b.genericRepo(id) + if err != nil { + return err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return err + } + + for aid, a := range b.arts { + if a.RepoID == id { + delete(b.arts, aid) + } + } + + delete(b.gens, id) + + return nil +} + +// ChangeRepositoryCompartment moves a generic repository. +func (b *InMemoryBackend) ChangeRepositoryCompartment(id, ifMatch string, in changeCompartmentIn) error { + b.mu.Lock("ChangeRepositoryCompartment") + defer b.mu.Unlock() + + r, err := b.genericRepo(id) + if err != nil { + return err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return err + } + + if err = b.checkCompartment(in.CompartmentID); err != nil { + return err + } + + r.CompartmentID, r.Etag = in.CompartmentID, newEtag() + + return nil +} + +func (b *InMemoryBackend) artifact(id string) (*GenericArtifact, *GenericRepo, *APIError) { + a, ok := b.arts[id] + if !ok { + return nil, nil, errNotFound(id) + } + + repo, ok := b.gens[a.RepoID] + if !ok { + return nil, nil, errNotFound(id) + } + + return a, repo, nil +} + +func (b *InMemoryBackend) artifactByPath(repoID, path, version string) (*GenericArtifact, *GenericRepo, *APIError) { + repo, err := b.genericRepo(repoID) + if err != nil { + return nil, nil, err + } + + for _, a := range b.arts { + if a.RepoID == repoID && a.Path == path && a.Version == version { + return a, repo, nil + } + } + + return nil, nil, errNotFound(path + ":" + version) +} + +// GetGenericArtifact returns an artifact by id. +func (b *InMemoryBackend) GetGenericArtifact(id string) (genericArtifactWire, string, error) { + b.mu.RLock("GetGenericArtifact") + defer b.mu.RUnlock() + + a, repo, err := b.artifact(id) + if err != nil { + return genericArtifactWire{}, "", err + } + + return b.artifactView(a, repo), a.Etag, nil +} + +// GetGenericArtifactByPath returns an artifact by repository, path and version. +func (b *InMemoryBackend) GetGenericArtifactByPath(repoID, path, version string) (genericArtifactWire, string, error) { + b.mu.RLock("GetGenericArtifactByPath") + defer b.mu.RUnlock() + + a, repo, err := b.artifactByPath(repoID, path, version) + if err != nil { + return genericArtifactWire{}, "", err + } + + return b.artifactView(a, repo), a.Etag, nil +} + +// GenericContent is an artifact body with its etag. +type GenericContent struct { + Etag string + Sha256 string + Content []byte +} + +// GetGenericArtifactContent returns an artifact's bytes by id. +func (b *InMemoryBackend) GetGenericArtifactContent(id string) (GenericContent, error) { + b.mu.RLock("GetGenericArtifactContent") + defer b.mu.RUnlock() + + a, _, err := b.artifact(id) + if err != nil { + return GenericContent{}, err + } + + return GenericContent{Etag: a.Etag, Sha256: a.Sha256, Content: a.Content}, nil +} + +// GetGenericArtifactContentByPath returns an artifact's bytes by repository, path and version. +func (b *InMemoryBackend) GetGenericArtifactContentByPath(repoID, path, version string) (GenericContent, error) { + b.mu.RLock("GetGenericArtifactContentByPath") + defer b.mu.RUnlock() + + a, _, err := b.artifactByPath(repoID, path, version) + if err != nil { + return GenericContent{}, err + } + + return GenericContent{Etag: a.Etag, Sha256: a.Sha256, Content: a.Content}, nil +} + +func validArtifactKey(path, version string) *APIError { + if path == "" || len(path) > maxArtifactPath || strings.ContainsFunc(path, unicode.IsControl) { + return errParam("artifactPath must be 1 to 255 printable characters") + } + + if version == "" || len(version) > maxArtifactVer || strings.ContainsFunc(version, unicode.IsControl) { + return errParam("version must be 1 to 255 printable characters") + } + + return nil +} + +// PutGenericArtifactContentByPath uploads an artifact, replacing the version in a mutable repository. +func (b *InMemoryBackend) PutGenericArtifactContentByPath( + repoID, path, version, ifMatch string, content []byte, +) (genericArtifactWire, string, error) { + if err := validArtifactKey(path, version); err != nil { + return genericArtifactWire{}, "", err + } + + if len(content) > maxArtifactBytes { + return genericArtifactWire{}, "", errTooLarge() + } + + sum := sha256.Sum256(content) + + b.mu.Lock("PutGenericArtifactContentByPath") + defer b.mu.Unlock() + + repo, err := b.genericRepo(repoID) + if err != nil { + return genericArtifactWire{}, "", err + } + + a, _, lookupErr := b.artifactByPath(repoID, path, version) + if lookupErr == nil { + if repo.IsImmutable { + return genericArtifactWire{}, "", errConflict( + fmt.Sprintf("artifact %s:%s already exists in an immutable repository", path, version)) + } + + if err = checkEtag(a.Etag, ifMatch); err != nil { + return genericArtifactWire{}, "", err + } + } else { + a = &GenericArtifact{ + ID: newOCID("genericartifact"), RepoID: repoID, Path: path, Version: version, + TimeCreated: b.nowUTC(), Seq: b.nextSeq(), + } + b.arts[a.ID] = a + } + + a.Content, a.Sha256, a.Etag = append( + []byte(nil), + content...), strings.ToUpper( + hex.EncodeToString(sum[:]), + ), newEtag() + + return b.artifactView(a, repo), a.Etag, nil +} + +type genericArtifactFilter struct { + compartment string + repoID string + id string + name string + path string + version string + sha256 string + state string +} + +// ListGenericArtifacts lists the artifacts of a repository. +func (b *InMemoryBackend) ListGenericArtifacts( + f genericArtifactFilter, o ListOpts, +) (genericArtifactCollectionWire, string, error) { + if f.compartment == "" { + return genericArtifactCollectionWire{}, "", errMissing("compartmentId is required") + } + + if f.repoID == "" { + return genericArtifactCollectionWire{}, "", errMissing("repositoryId is required") + } + + b.mu.RLock("ListGenericArtifacts") + defer b.mu.RUnlock() + + repo, err := b.genericRepo(f.repoID) + if err != nil { + return genericArtifactCollectionWire{}, "", err + } + + var all []*GenericArtifact + + for _, a := range b.arts { + if a.RepoID == f.repoID && repo.CompartmentID == f.compartment && b.artifactMatch(a, repo, f) { + all = append(all, a) + } + } + + pg, _, perr := paginate(all, o, func(a *GenericArtifact) sortKey { + return sortKey{created: a.TimeCreated, name: b.artifactView(a, repo).DisplayName, seq: a.Seq} + }) + if perr != nil { + return genericArtifactCollectionWire{}, "", perr + } + + out := genericArtifactCollectionWire{Items: make([]genericArtifactWire, 0, len(pg.Data))} + for _, a := range pg.Data { + out.Items = append(out.Items, b.artifactView(a, repo)) + } + + return out, pg.Next, nil +} + +func (b *InMemoryBackend) artifactMatch(a *GenericArtifact, repo *GenericRepo, f genericArtifactFilter) bool { + return (f.state == "" || strings.EqualFold(f.state, stateAvailable)) && (f.id == "" || f.id == a.ID) && + (f.name == "" || f.name == b.artifactView(a, repo).DisplayName) && + strings.HasPrefix(a.Path, f.path) && strings.HasPrefix(a.Version, f.version) && + (f.sha256 == "" || strings.EqualFold(f.sha256, a.Sha256)) +} + +func (b *InMemoryBackend) updateArtifact( + a *GenericArtifact, repo *GenericRepo, ifMatch string, in updateTagsIn, +) (genericArtifactWire, string, error) { + if err := checkEtag(a.Etag, ifMatch); err != nil { + return genericArtifactWire{}, "", err + } + + a.Tags.merge(in.Tags) + a.Etag = newEtag() + + return b.artifactView(a, repo), a.Etag, nil +} + +// UpdateGenericArtifact changes an artifact's tags. +func (b *InMemoryBackend) UpdateGenericArtifact( + id, ifMatch string, + in updateTagsIn, +) (genericArtifactWire, string, error) { + b.mu.Lock("UpdateGenericArtifact") + defer b.mu.Unlock() + + a, repo, err := b.artifact(id) + if err != nil { + return genericArtifactWire{}, "", err + } + + return b.updateArtifact(a, repo, ifMatch, in) +} + +// UpdateGenericArtifactByPath changes an artifact's tags. +func (b *InMemoryBackend) UpdateGenericArtifactByPath( + repoID, path, version, ifMatch string, in updateTagsIn, +) (genericArtifactWire, string, error) { + b.mu.Lock("UpdateGenericArtifactByPath") + defer b.mu.Unlock() + + a, repo, err := b.artifactByPath(repoID, path, version) + if err != nil { + return genericArtifactWire{}, "", err + } + + return b.updateArtifact(a, repo, ifMatch, in) +} + +// DeleteGenericArtifact deletes an artifact by id. +func (b *InMemoryBackend) DeleteGenericArtifact(id, ifMatch string) error { + b.mu.Lock("DeleteGenericArtifact") + defer b.mu.Unlock() + + a, _, err := b.artifact(id) + if err != nil { + return err + } + + if err = checkEtag(a.Etag, ifMatch); err != nil { + return err + } + + delete(b.arts, id) + + return nil +} + +// DeleteGenericArtifactByPath deletes an artifact by repository, path and version. +func (b *InMemoryBackend) DeleteGenericArtifactByPath(repoID, path, version, ifMatch string) error { + b.mu.Lock("DeleteGenericArtifactByPath") + defer b.mu.Unlock() + + a, _, err := b.artifactByPath(repoID, path, version) + if err != nil { + return err + } + + if err = checkEtag(a.Etag, ifMatch); err != nil { + return err + } + + delete(b.arts, a.ID) + + return nil +} diff --git a/services/ociartifacts/generic_test.go b/services/ociartifacts/generic_test.go new file mode 100644 index 0000000000..630f613cfc --- /dev/null +++ b/services/ociartifacts/generic_test.go @@ -0,0 +1,454 @@ +package ociartifacts_test + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/artifacts" + "github.com/oracle/oci-go-sdk/v65/genericartifactscontent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sha256Upper(b []byte) string { + sum := sha256.Sum256(b) + + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +func TestGenericRepositoryLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + repo := e.createGenericRepo(t, false) + assert.Equal(t, "generic-repo", *repo.DisplayName) + assert.False(t, *repo.IsImmutable) + assert.Equal(t, artifacts.RepositoryLifecycleStateAvailable, repo.LifecycleState) + + got, err := e.client.GetRepository(t.Context(), artifacts.GetRepositoryRequest{RepositoryId: repo.Id}) + require.NoError(t, err) + + g, ok := got.Repository.(artifacts.GenericRepository) + require.True(t, ok) + assert.Equal(t, *repo.Id, *g.Id) + + upd, err := e.client.UpdateRepository(t.Context(), artifacts.UpdateRepositoryRequest{ + RepositoryId: repo.Id, IfMatch: got.Etag, + UpdateRepositoryDetails: artifacts.UpdateGenericRepositoryDetails{ + DisplayName: new("renamed"), Description: new("binaries"), FreeformTags: map[string]string{"a": "b"}, + }}) + require.NoError(t, err) + + u, ok := upd.Repository.(artifacts.GenericRepository) + require.True(t, ok) + assert.Equal(t, "renamed", *u.DisplayName) + assert.Equal(t, "binaries", *u.Description) + + _, err = e.client.UpdateRepository(t.Context(), artifacts.UpdateRepositoryRequest{ + RepositoryId: repo.Id, IfMatch: new("stale"), + UpdateRepositoryDetails: artifacts.UpdateGenericRepositoryDetails{Description: new("x")}, + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + list, err := e.client.ListRepositories(t.Context(), artifacts.ListRepositoriesRequest{ + CompartmentId: new(testCompartment), DisplayName: new("renamed"), IsImmutable: new(false)}) + require.NoError(t, err) + assert.Len(t, list.Items, 1) + + empty, err := e.client.ListRepositories(t.Context(), artifacts.ListRepositoriesRequest{ + CompartmentId: new(testCompartment), IsImmutable: new(true)}) + require.NoError(t, err) + assert.Empty(t, empty.Items) + + _, err = e.client.ChangeRepositoryCompartment(t.Context(), artifacts.ChangeRepositoryCompartmentRequest{ + RepositoryId: repo.Id, + CompartmentId: new(otherCompart), + }) + require.NoError(t, err) + + moved, err := e.client.ListRepositories( + t.Context(), + artifacts.ListRepositoriesRequest{CompartmentId: new(otherCompart)}, + ) + require.NoError(t, err) + assert.Len(t, moved.Items, 1) + + _, err = e.client.DeleteRepository(t.Context(), artifacts.DeleteRepositoryRequest{RepositoryId: repo.Id}) + require.NoError(t, err) + + _, err = e.client.GetRepository(t.Context(), artifacts.GetRepositoryRequest{RepositoryId: repo.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestCreateGenericRepositoryValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + want string + }{ + { + "wrong type", + `{"repositoryType":"MAVEN","compartmentId":"` + testCompartment + `","isImmutable":false}`, + "InvalidParameter", + }, + { + "no immutable flag", + `{"repositoryType":"GENERIC","compartmentId":"` + testCompartment + `"}`, + "MissingParameter", + }, + {"no compartment", `{"repositoryType":"GENERIC","isImmutable":false}`, "MissingParameter"}, + { + "bad name", + `{"repositoryType":"GENERIC","compartmentId":"` + testCompartment + `","isImmutable":false,"displayName":""}`, + "InvalidParameter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + status, body := rawDo(t, http.MethodPost, e.base+"/20160918/repositories", tt.body) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, tt.want) + }) + } + + t.Run("generated name", func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + status, body := rawDo(t, http.MethodPost, e.base+"/20160918/repositories", + `{"repositoryType":"GENERIC","compartmentId":"`+testCompartment+`","isImmutable":false}`) + require.Equal(t, http.StatusOK, status) + assert.Contains(t, body, `"displayName":"repository`) + }) +} + +func TestGenericArtifactUploadDownload(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + repo := e.createGenericRepo(t, false) + payload := []byte("binary\x00payload\n") + + art, err := e.upload(t, *repo.Id, "tools/cli/linux-amd64", "1.0.0", payload) + require.NoError(t, err) + assert.Equal(t, sha256Upper(payload), *art.Sha256) + assert.EqualValues(t, len(payload), *art.SizeInBytes) + assert.Equal(t, "tools/cli/linux-amd64:1.0.0", *art.DisplayName) + assert.Equal(t, "tools/cli/linux-amd64", *art.ArtifactPath) + assert.Equal(t, genericartifactscontent.GenericArtifactLifecycleStateAvailable, art.LifecycleState) + + download := func(byID bool) []byte { + t.Helper() + + var body io.ReadCloser + + if byID { + resp, dlErr := e.content.GetGenericArtifactContent( + t.Context(), + genericartifactscontent.GetGenericArtifactContentRequest{ArtifactId: art.Id}, + ) + require.NoError(t, dlErr) + + body = resp.Content + } else { + resp, dlErr := e.content.GetGenericArtifactContentByPath( + t.Context(), + genericartifactscontent.GetGenericArtifactContentByPathRequest{ + RepositoryId: repo.Id, ArtifactPath: new("tools/cli/linux-amd64"), Version: new("1.0.0")}, + ) + require.NoError(t, dlErr) + + body = resp.Content + } + + defer body.Close() + + out, dlErr := io.ReadAll(body) + require.NoError(t, dlErr) + + return out + } + + assert.Equal(t, payload, download(true)) + assert.Equal(t, payload, download(false)) + + // Overwriting in a mutable repository keeps the artifact id and changes the digest. + next := []byte("second revision") + again, err := e.upload(t, *repo.Id, "tools/cli/linux-amd64", "1.0.0", next) + require.NoError(t, err) + assert.Equal(t, *art.Id, *again.Id) + assert.Equal(t, sha256Upper(next), *again.Sha256) + + meta, err := e.client.GetGenericArtifactByPath(t.Context(), artifacts.GetGenericArtifactByPathRequest{ + RepositoryId: repo.Id, ArtifactPath: new("tools/cli/linux-amd64"), Version: new("1.0.0")}) + require.NoError(t, err) + assert.Equal(t, *art.Id, *meta.Id) + assert.EqualValues(t, len(next), *meta.SizeInBytes) + + byID, err := e.client.GetGenericArtifact(t.Context(), artifacts.GetGenericArtifactRequest{ArtifactId: art.Id}) + require.NoError(t, err) + assert.Equal(t, sha256Upper(next), *byID.Sha256) + + // If-Match guards the overwrite. + _, err = e.content.PutGenericArtifactContentByPath( + t.Context(), + genericartifactscontent.PutGenericArtifactContentByPathRequest{ + RepositoryId: repo.Id, ArtifactPath: new("tools/cli/linux-amd64"), Version: new("1.0.0"), + GenericArtifactContentBody: io.NopCloser(strings.NewReader("x")), IfMatch: new("stale"), + }, + ) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.client.UpdateGenericArtifact(t.Context(), artifacts.UpdateGenericArtifactRequest{ + ArtifactId: art.Id, + FreeformTags: map[string]string{"k": "v"}, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{"k": "v"}, upd.FreeformTags) + + upd2, err := e.client.UpdateGenericArtifactByPath(t.Context(), artifacts.UpdateGenericArtifactByPathRequest{ + RepositoryId: repo.Id, ArtifactPath: new("tools/cli/linux-amd64"), Version: new("1.0.0"), + FreeformTags: map[string]string{"k": "w"}, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{"k": "w"}, upd2.FreeformTags) + + _, err = e.client.DeleteGenericArtifact( + t.Context(), + artifacts.DeleteGenericArtifactRequest{ArtifactId: art.Id, IfMatch: new("stale")}, + ) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteGenericArtifact(t.Context(), artifacts.DeleteGenericArtifactRequest{ArtifactId: art.Id}) + require.NoError(t, err) + + _, err = e.content.GetGenericArtifactContent( + t.Context(), + genericartifactscontent.GetGenericArtifactContentRequest{ArtifactId: art.Id}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.upload(t, *repo.Id, "a/b", "2", []byte("two")) + require.NoError(t, err) + + _, err = e.client.DeleteGenericArtifactByPath(t.Context(), artifacts.DeleteGenericArtifactByPathRequest{ + RepositoryId: repo.Id, ArtifactPath: new("a/b"), Version: new("2")}) + require.NoError(t, err) + + _, err = e.client.GetGenericArtifactByPath(t.Context(), artifacts.GetGenericArtifactByPathRequest{ + RepositoryId: repo.Id, ArtifactPath: new("a/b"), Version: new("2")}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestGenericArtifactImmutableAndKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + version string + wantHTTP int + immutable bool + prefill bool + }{ + {name: "new artifact", path: "p", version: "1", wantHTTP: http.StatusOK}, + {name: "immutable new", immutable: true, path: "p", version: "1", wantHTTP: http.StatusOK}, + { + name: "immutable overwrite", + immutable: true, + path: "p", + version: "1", + prefill: true, + wantHTTP: http.StatusConflict, + }, + { + name: "immutable other version", + immutable: true, + path: "p", + version: "2", + prefill: true, + wantHTTP: http.StatusOK, + }, + {name: "mutable overwrite", path: "p", version: "1", prefill: true, wantHTTP: http.StatusOK}, + {name: "empty version", path: "p", version: " ", wantHTTP: http.StatusOK}, + {name: "path too long", path: strings.Repeat("a", 256), version: "1", wantHTTP: http.StatusBadRequest}, + {name: "version too long", path: "p", version: strings.Repeat("v", 256), wantHTTP: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + repo := e.createGenericRepo(t, tt.immutable) + + if tt.prefill { + _, err := e.upload(t, *repo.Id, "p", "1", []byte("old")) + require.NoError(t, err) + } + + _, err := e.upload(t, *repo.Id, tt.path, tt.version, []byte("new")) + if tt.wantHTTP == http.StatusOK { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.wantHTTP, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + t.Run("unknown repository", func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + _, err := e.upload(t, "ocid1.artifactrepository.oc1..nope", "p", "1", []byte("x")) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + }) +} + +func TestListGenericArtifacts(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + repo := e.createGenericRepo(t, false) + other := e.createGenericRepo(t, false) + + for _, a := range []struct{ path, version string }{ + {"app/server", "1.0"}, {"app/server", "1.1"}, {"app/client", "2.0"}, {"docs/readme", "1.0"}, + } { + e.clock.Advance(1_000_000_000) + _, err := e.upload(t, *repo.Id, a.path, a.version, []byte(a.path+a.version)) + require.NoError(t, err) + } + + _, err := e.upload(t, *other.Id, "app/server", "9", []byte("x")) + require.NoError(t, err) + + names := func(r artifacts.ListGenericArtifactsRequest) []string { + resp, listErr := e.client.ListGenericArtifacts(t.Context(), r) + require.NoError(t, listErr) + + out := make([]string, 0, len(resp.Items)) + for _, it := range resp.Items { + out = append(out, *it.DisplayName) + } + + return out + } + + base := func() artifacts.ListGenericArtifactsRequest { + return artifacts.ListGenericArtifactsRequest{CompartmentId: new(testCompartment), RepositoryId: repo.Id} + } + + tests := []struct { + name string + mut func(*artifacts.ListGenericArtifactsRequest) + want []string + }{ + { + "newest first", + func(*artifacts.ListGenericArtifactsRequest) {}, + []string{"docs/readme:1.0", "app/client:2.0", "app/server:1.1", "app/server:1.0"}, + }, + {"name asc", func(r *artifacts.ListGenericArtifactsRequest) { + r.SortBy = artifacts.ListGenericArtifactsSortByDisplayname + }, []string{"app/client:2.0", "app/server:1.0", "app/server:1.1", "docs/readme:1.0"}}, + { + "path prefix", + func(r *artifacts.ListGenericArtifactsRequest) { r.ArtifactPath = new("app/") }, + []string{"app/client:2.0", "app/server:1.1", "app/server:1.0"}, + }, + { + "version prefix", + func(r *artifacts.ListGenericArtifactsRequest) { r.Version = new("1.") }, + []string{"docs/readme:1.0", "app/server:1.1", "app/server:1.0"}, + }, + { + "display name", + func(r *artifacts.ListGenericArtifactsRequest) { r.DisplayName = new("app/client:2.0") }, + []string{"app/client:2.0"}, + }, + { + "sha256", + func(r *artifacts.ListGenericArtifactsRequest) { r.Sha256 = new(sha256Upper([]byte("docs/readme1.0"))) }, + []string{"docs/readme:1.0"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := base() + tt.mut(&r) + assert.Equal(t, tt.want, names(r)) + }) + } + + t.Run("pagination", func(t *testing.T) { + t.Parallel() + + var got []string + + var page *string + + for range 6 { + r := base() + r.Limit, r.Page = new(3), page + resp, pageErr := e.client.ListGenericArtifacts(t.Context(), r) + require.NoError(t, pageErr) + + for _, it := range resp.Items { + got = append(got, *it.DisplayName) + } + + if resp.OpcNextPage == nil { + break + } + + page = resp.OpcNextPage + } + + assert.Len(t, got, 4) + }) + + t.Run("wrong compartment", func(t *testing.T) { + t.Parallel() + + r := base() + r.CompartmentId = new(otherCompart) + assert.Empty(t, names(r)) + }) +} + +func TestDeleteGenericRepositoryRemovesArtifacts(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + repo := e.createGenericRepo(t, false) + art, err := e.upload(t, *repo.Id, "p", "1", []byte("x")) + require.NoError(t, err) + + _, err = e.client.DeleteRepository( + t.Context(), + artifacts.DeleteRepositoryRequest{RepositoryId: repo.Id, IfMatch: new("stale")}, + ) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteRepository(t.Context(), artifacts.DeleteRepositoryRequest{RepositoryId: repo.Id}) + require.NoError(t, err) + + _, err = e.client.GetGenericArtifact(t.Context(), artifacts.GetGenericArtifactRequest{ArtifactId: art.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} diff --git a/services/ociartifacts/handler.go b/services/ociartifacts/handler.go new file mode 100644 index 0000000000..69e6ba2dd5 --- /dev/null +++ b/services/ociartifacts/handler.go @@ -0,0 +1,357 @@ +// Package ociartifacts emulates OCI Artifacts and Container Registry: the 20160918 control plane (container +// repositories, images, signatures, container configuration, generic repositories and artifacts), the generic +// artifact content API and an OCIR-style Docker Registry v2 endpoint with token authentication, all on one +// dedicated listener. See PARITY.md. +package ociartifacts + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = 10 * time.Minute + idleTimeout = 120 * time.Second + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte + principal ociauth.Principal +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Artifacts and Container Registry. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), srvMu: lockmetrics.New("ociartifacts.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIArtifacts" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIArtifacts runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"repositoryId", "imageId", "imageSignatureId", "artifactId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ociartifacts: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) boolQuery(k string) *bool { + v, err := strconv.ParseBool(q.query(k)) + if err != nil { + return nil + } + + return &v +} + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + subtree := q.boolQuery("compartmentIdInSubtree") + + return ListOpts{ + SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n, + Subtree: subtree != nil && *subtree, + } +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) user() string { return q.principal.KeyID.User } + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + dec := json.NewDecoder(bytes.NewReader(q.body)) + dec.UseNumber() + + if err := dec.Decode(v); err != nil { + return newErr(http.StatusBadRequest, "CannotParseRequest", + "The request body is not valid JSON for this operation") + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ociartifacts: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIArtifacts requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + switch q.rt.op { + case opAdmin: + h.admin(q) + case opRegistry: + h.serveRegistry(q.w, q.r) + case opGetAccessToken: + h.serveToken(q.w, q.r) + default: + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + limit := int64(maxBodyBytes) + if q.rt.op == opPutGenericContent { + limit = maxArtifactBytes + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, limit)) + if err != nil { + return errTooLarge() + } + + q.body = body + + if q.principal, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth && verify, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// admin serves the emulator-only endpoints under /_gopherstack/: /oci/keys registers API public keys. +func (h *Handler) admin(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ociartifacts: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIArtifacts", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + + h.srvMu.Lock("StartWorker") + h.srv = srv + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ociartifacts", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ociartifacts: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ociartifacts: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener and releases the embedded registry. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv := h.srv + h.srv = nil + h.srvMu.Unlock() + + if srv != nil { + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ociartifacts: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ociartifacts: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() + } + + h.Backend.Close() +} diff --git a/services/ociartifacts/helpers_test.go b/services/ociartifacts/helpers_test.go new file mode 100644 index 0000000000..21b98b682d --- /dev/null +++ b/services/ociartifacts/helpers_test.go @@ -0,0 +1,261 @@ +package ociartifacts_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/artifacts" + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/genericartifactscontent" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/ociregistry/ocitest" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + otherCompart = "ocid1.compartment.oc1..other" + testNamespace = "gopherstack" + loginUser = testNamespace + "/alice@example.com" + loginToken = "s3cr3t-auth-token" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UTC().Truncate(time.Second).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *ociartifacts.InMemoryBackend + handler *ociartifacts.Handler + clock *fakeClock + client artifacts.ArtifactsClient + content genericartifactscontent.GenericArtifactsContentClient + base string +} + +type envOpts struct { + dataDir string + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := ociartifacts.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + be.SetContext(t.Context()) + be.SetDataDir(opts.dataDir) + + h := ociartifacts.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + prov := common.NewRawConfigurationProvider(testTenancy, testUser, ociartifacts.Region, kp.fprint, kp.privPEM, nil) + e := &env{backend: be, handler: h, clock: clock, base: fmt.Sprintf("http://localhost:%d", h.Port)} + + var err error + + e.client, err = artifacts.NewArtifactsClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.client.Host = e.base + + e.content, err = genericartifactscontent.NewGenericArtifactsContentClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.content.Host = e.base + + return e +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +func basic(user, pass string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) +} + +// registry returns a conformance client that logs in with Basic credentials. +func (e *env) registry(t *testing.T) ocitest.Client { + t.Helper() + + return ocitest.Client{T: t, Base: e.base, Prepare: func(r *http.Request) { + r.Header.Set("Authorization", basic(loginUser, loginToken)) + }} +} + +// anonymous returns a registry client without credentials. +func (e *env) anonymous(t *testing.T) ocitest.Client { + t.Helper() + + return ocitest.Client{T: t, Base: e.base} +} + +func (e *env) createRepo( + t *testing.T, + name string, + mutate func(*artifacts.CreateContainerRepositoryDetails), +) artifacts.ContainerRepository { + t.Helper() + + d := artifacts.CreateContainerRepositoryDetails{CompartmentId: new(testCompartment), DisplayName: new(name)} + if mutate != nil { + mutate(&d) + } + + resp, err := e.client.CreateContainerRepository(t.Context(), + artifacts.CreateContainerRepositoryRequest{CreateContainerRepositoryDetails: d}) + require.NoError(t, err) + + return resp.ContainerRepository +} + +func (e *env) listImages( + t *testing.T, + mutate func(*artifacts.ListContainerImagesRequest), +) []artifacts.ContainerImageSummary { + t.Helper() + + r := artifacts.ListContainerImagesRequest{CompartmentId: new(testCompartment)} + if mutate != nil { + mutate(&r) + } + + resp, err := e.client.ListContainerImages(t.Context(), r) + require.NoError(t, err) + + return resp.Items +} + +func (e *env) createGenericRepo(t *testing.T, immutable bool) artifacts.GenericRepository { + t.Helper() + + resp, err := e.client.CreateRepository(t.Context(), artifacts.CreateRepositoryRequest{ + CreateRepositoryDetails: artifacts.CreateGenericRepositoryDetails{ + CompartmentId: new(testCompartment), IsImmutable: new(immutable), DisplayName: new("generic-repo"), + }}) + require.NoError(t, err) + + repo, ok := resp.Repository.(artifacts.GenericRepository) + require.True(t, ok, "%T", resp.Repository) + + return repo +} + +func (e *env) upload( + t *testing.T, + repoID, path, version string, + body []byte, +) (genericartifactscontent.GenericArtifact, error) { + t.Helper() + + resp, err := e.content.PutGenericArtifactContentByPath( + t.Context(), + genericartifactscontent.PutGenericArtifactContentByPathRequest{ + RepositoryId: new(repoID), ArtifactPath: new(path), Version: new(version), + GenericArtifactContentBody: io.NopCloser(bytes.NewReader(body)), + }, + ) + if err != nil { + return genericartifactscontent.GenericArtifact{}, err + } + + return resp.GenericArtifact, nil +} + +func jsonBody(t *testing.T, v any) string { + t.Helper() + + out, err := json.Marshal(v) + require.NoError(t, err) + + return string(out) +} diff --git a/services/ociartifacts/images.go b/services/ociartifacts/images.go new file mode 100644 index 0000000000..3123d19855 --- /dev/null +++ b/services/ociartifacts/images.go @@ -0,0 +1,433 @@ +package ociartifacts + +import ( + "cmp" + "slices" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/ociregistry" +) + +const unknownVersion = "unknown" + +type updateTagsIn struct { + Tags +} + +type removeVersionIn struct { + Version string `json:"version"` +} + +type restoreImageIn struct { + Version *string `json:"version"` +} + +type lookupIn struct { + ImageURI string `json:"imageUri"` +} + +func (im *Image) latest() string { + if n := len(im.Versions); n > 0 { + return im.Versions[n-1].Version + } + + return "" +} + +func (im *Image) hasVersion(v string) bool { + return slices.ContainsFunc(im.Versions, func(x Version) bool { return x.Version == v }) +} + +func (b *InMemoryBackend) imageName(im *Image, repo *Repo) string { + if v := im.latest(); v != "" { + return repo.Name + ":" + v + } + + last := im.LastVersion + if last == "" { + last = unknownVersion + } + + return repo.Name + ":" + last + "@" + im.Digest +} + +func (im *Image) versionPtr() *string { + if v := im.latest(); v != "" { + return &v + } + + return nil +} + +func (b *InMemoryBackend) imageView(im *Image, repo *Repo) imageWire { + layers := make([]layerWire, 0, len(im.Layers)) + for _, l := range im.Layers { + layers = append(layers, layerWire{TimeCreated: l.TimeCreated, Digest: l.Digest, SizeInBytes: l.Size}) + } + + slices.SortFunc(layers, func(a, c layerWire) int { return cmp.Compare(a.Digest, c.Digest) }) + + versions := make([]versionWire, 0, len(im.Versions)) + for _, v := range im.Versions { + versions = append(versions, versionWire{TimeCreated: v.TimeCreated, CreatedBy: v.CreatedBy, Version: v.Version}) + } + + return imageWire{ + TimeCreated: im.TimeCreated, TimeLastPulled: im.TimeLastPulled, CompartmentID: repo.CompartmentID, + CreatedBy: im.CreatedBy, Digest: im.Digest, DisplayName: b.imageName(im, repo), ID: im.ID, + LifecycleState: im.State, RepositoryID: repo.ID, RepositoryName: repo.Name, Version: im.versionPtr(), + Layers: layers, Versions: versions, LayersSizeInBytes: im.LayersSize, PullCount: im.PullCount, + ManifestSizeInBytes: im.ManifestSize, FreeformTags: im.Tags.free(), DefinedTags: im.Tags.defined(), + SystemTags: map[string]map[string]any{}, + } +} + +func (b *InMemoryBackend) imageSummary(im *Image, repo *Repo) imageSummaryWire { + return imageSummaryWire{ + TimeCreated: im.TimeCreated, CompartmentID: repo.CompartmentID, Digest: im.Digest, + DisplayName: b.imageName(im, repo), ID: im.ID, LifecycleState: im.State, RepositoryID: repo.ID, + RepositoryName: repo.Name, Version: im.versionPtr(), FreeformTags: im.Tags.free(), + DefinedTags: im.Tags.defined(), SystemTags: map[string]map[string]any{}, + } +} + +func (b *InMemoryBackend) image(id string) (*Image, *Repo, *APIError) { + im, ok := b.images[id] + if !ok { + return nil, nil, errNotFound(id) + } + + repo, ok := b.repos[im.RepoID] + if !ok { + return nil, nil, errNotFound(id) + } + + return im, repo, nil +} + +func (b *InMemoryBackend) availableImage(id string) (*Image, *Repo, *APIError) { + im, repo, err := b.image(id) + if err != nil { + return nil, nil, err + } + + if im.State != stateAvailable { + return nil, nil, errNotFound(id) + } + + return im, repo, nil +} + +func (b *InMemoryBackend) imageByDigest(repoID, digest string) *Image { + for _, im := range b.images { + if im.RepoID == repoID && im.Digest == digest { + return im + } + } + + return nil +} + +// imageByRef resolves a tag or digest reference to the repository's image. +func (b *InMemoryBackend) imageByRef(repoID, ref string) *Image { + if ociregistry.IsDigest(ref) { + return b.imageByDigest(repoID, ref) + } + + for _, im := range b.images { + if im.RepoID == repoID && im.State == stateAvailable && im.hasVersion(ref) { + return im + } + } + + return nil +} + +type imageFilter struct { + isVersioned *bool + compartment string + name string + id string + repoID string + repoName string + version string + state string + digest string +} + +func (f imageFilter) match(b *InMemoryBackend, im *Image, repo *Repo, subtree bool) bool { + return b.inCompartment(repo.CompartmentID, f.compartment, subtree) && f.matchIdentity(im, repo) && + f.matchState(im) && f.matchNames(b, im, repo) && f.matchVersions(im) +} + +func (f imageFilter) matchIdentity(im *Image, repo *Repo) bool { + return (f.id == "" || f.id == im.ID) && (f.repoID == "" || f.repoID == repo.ID) && + (f.digest == "" || f.digest == im.Digest) +} + +func (f imageFilter) matchState(im *Image) bool { + if f.state == "" { + return im.State != stateDeleted + } + + return strings.EqualFold(f.state, im.State) +} + +func (f imageFilter) matchNames(b *InMemoryBackend, im *Image, repo *Repo) bool { + return (f.repoName == "" || matchWild(f.repoName, repo.Name)) && + (f.name == "" || f.name == b.imageName(im, repo)) +} + +func (f imageFilter) matchVersions(im *Image) bool { + if f.isVersioned != nil && *f.isVersioned != (len(im.Versions) > 0) { + return false + } + + return f.version == "" || + slices.ContainsFunc(im.Versions, func(v Version) bool { return matchWild(f.version, v.Version) }) +} + +// ListContainerImages lists the images of a compartment. +func (b *InMemoryBackend) ListContainerImages(f imageFilter, o ListOpts) (imageCollectionWire, string, error) { + if f.compartment == "" { + return imageCollectionWire{}, "", errMissing("compartmentId is required") + } + + b.mu.RLock("ListContainerImages") + defer b.mu.RUnlock() + + var all []*Image + + for _, im := range b.images { + if repo, ok := b.repos[im.RepoID]; ok && f.match(b, im, repo, o.Subtree) { + all = append(all, im) + } + } + + pg, remaining, err := paginate(all, o, func(im *Image) sortKey { + return sortKey{created: im.TimeCreated, name: b.imageName(im, b.repos[im.RepoID]), seq: im.Seq} + }) + if err != nil { + return imageCollectionWire{}, "", err + } + + out := imageCollectionWire{Items: make([]imageSummaryWire, 0, len(pg.Data)), RemainingItemsCount: remaining} + for _, im := range pg.Data { + out.Items = append(out.Items, b.imageSummary(im, b.repos[im.RepoID])) + } + + return out, pg.Next, nil +} + +// GetContainerImage returns an image (including DELETED ones) and its etag. +func (b *InMemoryBackend) GetContainerImage(id string) (imageWire, string, error) { + b.mu.RLock("GetContainerImage") + defer b.mu.RUnlock() + + im, repo, err := b.image(id) + if err != nil { + return imageWire{}, "", err + } + + return b.imageView(im, repo), im.Etag, nil +} + +// UpdateContainerImage changes an image's tags. +func (b *InMemoryBackend) UpdateContainerImage(id, ifMatch string, in updateTagsIn) (imageWire, string, error) { + b.mu.Lock("UpdateContainerImage") + defer b.mu.Unlock() + + im, repo, err := b.availableImage(id) + if err != nil { + return imageWire{}, "", err + } + + if err = checkEtag(im.Etag, ifMatch); err != nil { + return imageWire{}, "", err + } + + im.Tags.merge(in.Tags) + im.Etag = newEtag() + + return b.imageView(im, repo), im.Etag, nil +} + +// DeleteContainerImage marks an image DELETED; it stops resolving on pull until restored. +func (b *InMemoryBackend) DeleteContainerImage(id, ifMatch string) error { + b.mu.Lock("DeleteContainerImage") + defer b.mu.Unlock() + + im, _, err := b.availableImage(id) + if err != nil { + return err + } + + if err = checkEtag(im.Etag, ifMatch); err != nil { + return err + } + + im.State, im.Etag = stateDeleted, newEtag() + + return nil +} + +// RestoreContainerImage brings a DELETED image back, optionally under a new version. +func (b *InMemoryBackend) RestoreContainerImage(id, ifMatch string, in restoreImageIn) (imageWire, string, error) { + b.mu.Lock("RestoreContainerImage") + defer b.mu.Unlock() + + im, repo, err := b.image(id) + if err != nil { + return imageWire{}, "", err + } + + if err = checkEtag(im.Etag, ifMatch); err != nil { + return imageWire{}, "", err + } + + if im.State != stateDeleted { + return imageWire{}, "", errState("The container image is not deleted") + } + + version := str(in.Version) + if in.Version != nil { + if version == "" || strings.Contains(version, ":") { + return imageWire{}, "", errParam("version is not a valid tag") + } + + if owner := b.imageByRef(repo.ID, version); owner != nil && owner != im && repo.IsImmutable { + return imageWire{}, "", errConflict("version " + version + " already exists in an immutable repository") + } + } + + kept := im.Versions[:0:0] + + for _, v := range im.Versions { + if owner := b.imageByRef(repo.ID, v.Version); owner == nil || owner == im { + kept = append(kept, v) + } + } + + im.State, im.Versions, im.Etag = stateAvailable, kept, newEtag() + + if version != "" { + b.restoreTag(repo, im, version) + } + + return b.imageView(im, repo), im.Etag, nil +} + +func (b *InMemoryBackend) restoreTag(repo *Repo, im *Image, version string) { + b.moveTag(repo.ID, im, version, im.CreatedBy) + + app := b.registryLocked() + client := ociregistry.NewClient(app) + + m, err := client.GetManifest(b.ctx, b.regName(repo), im.Digest) + if err != nil { + return + } + + _, _ = client.PutManifest(b.ctx, b.regName(repo), version, m.MediaType, m.Body) +} + +// RemoveContainerVersion removes one version from an image. +func (b *InMemoryBackend) RemoveContainerVersion(id, ifMatch string, in removeVersionIn) (imageWire, string, error) { + if in.Version == "" { + return imageWire{}, "", errMissing("version is required") + } + + b.mu.Lock("RemoveContainerVersion") + defer b.mu.Unlock() + + im, repo, err := b.availableImage(id) + if err != nil { + return imageWire{}, "", err + } + + if err = checkEtag(im.Etag, ifMatch); err != nil { + return imageWire{}, "", err + } + + if !im.hasVersion(in.Version) { + return imageWire{}, "", errNotFound(in.Version) + } + + b.dropVersion(im, in.Version) + im.Etag = newEtag() + + _ = ociregistry.NewClient(b.registryLocked()).DeleteManifest(b.ctx, b.regName(repo), in.Version) + + return b.imageView(im, repo), im.Etag, nil +} + +func (b *InMemoryBackend) dropVersion(im *Image, version string) { + im.Versions = slices.DeleteFunc(im.Versions, func(v Version) bool { return v.Version == version }) + im.LastVersion = version + + if l := im.latest(); l != "" { + im.LastVersion = l + } +} + +// moveTag gives version to im, taking it from any other image of the repository. +func (b *InMemoryBackend) moveTag(repoID string, im *Image, version, by string) { + for _, other := range b.images { + if other != im && other.RepoID == repoID && other.hasVersion(version) { + b.dropVersion(other, version) + other.Etag = newEtag() + } + } + + im.Versions = slices.DeleteFunc(im.Versions, func(v Version) bool { return v.Version == version }) + im.Versions = append(im.Versions, Version{Version: version, CreatedBy: by, TimeCreated: b.nowUTC()}) + im.LastVersion = version +} + +// LookupContainerImageByURI finds an image by `namespace/repo:version` or `namespace/repo@digest`. +func (b *InMemoryBackend) LookupContainerImageByURI(uri string) (imageWire, string, error) { + b.mu.RLock("LookupContainerImageByURI") + defer b.mu.RUnlock() + + if uri == "" { + return imageWire{}, "", errMissing("imageUri is required") + } + + rest, ok := strings.CutPrefix(uri, b.namespace+"/") + if !ok { + return imageWire{}, "", errNotFound(uri) + } + + name, ref := rest, "" + + if i := strings.Index(rest, "@"); i >= 0 { + name, ref = rest[:i], rest[i+1:] + } else if i = strings.LastIndex(rest, ":"); i >= 0 && !strings.Contains(rest[i:], "/") { + name, ref = rest[:i], rest[i+1:] + } + + repo := b.repoByName(name) + if repo == nil || ref == "" { + return imageWire{}, "", errNotFound(uri) + } + + im := b.imageByRef(repo.ID, ref) + if im == nil || im.State != stateAvailable { + return imageWire{}, "", errNotFound(uri) + } + + return b.imageView(im, repo), im.Etag, nil +} + +func (b *InMemoryBackend) regName(r *Repo) string { return strings.ToLower(b.namespace) + "/" + r.Name } + +// purgeRepoManifests removes a repository's manifests from the embedded registry. +func (b *InMemoryBackend) purgeRepoManifests(r *Repo) { + client := ociregistry.NewClient(b.registryLocked()) + + for _, im := range b.images { + if im.RepoID == r.ID { + _ = client.DeleteManifest(b.ctx, b.regName(r), im.Digest) + } + } +} diff --git a/services/ociartifacts/leak_main_test.go b/services/ociartifacts/leak_main_test.go new file mode 100644 index 0000000000..779e693625 --- /dev/null +++ b/services/ociartifacts/leak_main_test.go @@ -0,0 +1,22 @@ +package ociartifacts_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, + goleak.IgnoreTopFunction("net/http.setRequestCancel.func4"), + // distribution's handlers.App starts a go-events Broadcaster it never stops. + goleak.IgnoreTopFunction("github.com/docker/go-events.(*Broadcaster).run"), + ) +} diff --git a/services/ociartifacts/misc_test.go b/services/ociartifacts/misc_test.go new file mode 100644 index 0000000000..23cb4c9cf3 --- /dev/null +++ b/services/ociartifacts/misc_test.go @@ -0,0 +1,291 @@ +package ociartifacts_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/oracle/oci-go-sdk/v65/artifacts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" +) + +type fakeConfig struct{ s ociartifacts.Settings } + +func (f fakeConfig) GetOCIArtifactsSettings() ociartifacts.Settings { return f.s } + +func TestProviderInit(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + ctx *service.AppContext + name string + wantPort int + wantAuth bool + }{ + {name: "nil context", wantErr: ociartifacts.ErrNilAppContext}, + {name: "defaults", ctx: &service.AppContext{}, wantPort: ociartifacts.DefaultPort}, + { + name: "configured", + ctx: &service.AppContext{Config: fakeConfig{ociartifacts.Settings{Port: 9999, EnforceAuth: true}}}, + wantPort: 9999, + wantAuth: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + p := &ociartifacts.Provider{} + assert.Equal(t, "OCIArtifacts", p.Name()) + + svc, err := p.Init(tt.ctx) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + + h, ok := svc.(*ociartifacts.Handler) + require.True(t, ok) + assert.Equal(t, tt.wantPort, h.Port) + assert.Equal(t, tt.wantAuth, h.EnforceAuth) + assert.Equal(t, "OCIArtifacts", h.Name()) + assert.Len(t, h.GetSupportedOperations(), 36) + assert.Zero(t, h.MatchPriority()) + h.Shutdown(t.Context()) + }) + } +} + +func TestRoutingMetadata(t *testing.T) { + t.Parallel() + + h := ociartifacts.NewHandler(ociartifacts.NewInMemoryBackend()) + e := echo.New() + + tests := []struct { + name string + method string + path string + wantOp string + wantRes string + wantMyRM bool + }{ + { + "get repo", + http.MethodGet, + "/20160918/container/repositories/ocid1.r", + "GetContainerRepository", + "ocid1.r", + false, + }, + { + "image restore", + http.MethodPost, + "/20160918/container/images/ocid1.i/actions/restore", + "RestoreContainerImage", + "ocid1.i", + false, + }, + { + "by path", + http.MethodGet, + "/20160918/generic/repositories/ocid1.g/artifactPaths/a/b/versions/1", + "GetGenericArtifactByPath", + "ocid1.g", + false, + }, + { + "by path escaped", + http.MethodGet, + "/20160918/generic/repositories/ocid1.g/artifactPaths/a%2Fb/versions/1", + "GetGenericArtifactByPath", + "ocid1.g", + false, + }, + { + "put content", + http.MethodPut, + "/20160918/generic/repositories/ocid1.g/artifactPaths/a/versions/1/content", + "PutGenericArtifactContentByPath", + "ocid1.g", + false, + }, + { + "by path wrong method", + http.MethodPatch, + "/20160918/generic/repositories/ocid1.g/artifactPaths/a/versions/1", + "Unknown", + "", + false, + }, + {"registry", http.MethodGet, "/v2/gopherstack/x/manifests/v1", "RegistryV2", "", false}, + {"ping", http.MethodGet, "/v2/", "RegistryV2", "", false}, + {"token", http.MethodGet, "/20180419/docker/token", "GetAccessToken", "", false}, + {"admin", http.MethodGet, "/_gopherstack/oci/keys", "Admin", "", false}, + {"unknown", http.MethodGet, "/nope", "Unknown", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := e.NewContext(httptest.NewRequest(tt.method, tt.path, nil), httptest.NewRecorder()) + assert.Equal(t, tt.wantOp, h.ExtractOperation(c)) + assert.Equal(t, tt.wantRes, h.ExtractResource(c)) + assert.False(t, h.RouteMatcher()(c), "the shared router must never match") + }) + } +} + +func TestAdminKeys(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true}) + + // An unregistered key is refused until it is registered through the admin endpoint. + _, err := e.client.GetContainerConfiguration( + t.Context(), + artifacts.GetContainerConfigurationRequest{CompartmentId: new(testCompartment)}, + ) + assert.Equal(t, http.StatusUnauthorized, svcErr(t, err).GetHTTPStatusCode()) + + kp := newKeyPair(t) + status, body := rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", + jsonBody(t, map[string]string{"tenancy": testTenancy, "user": testUser, "publicKey": kp.pubPEM})) + require.Equal(t, http.StatusCreated, status, body) + assert.Contains(t, body, kp.fprint) + + status, body = rawDo(t, http.MethodGet, e.base+"/_gopherstack/oci/keys", "") + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, testUser) + assert.NotContains(t, body, "PRIVATE") + + status, _ = rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", `{"tenancy":"t"}`) + assert.Equal(t, http.StatusBadRequest, status) + + status, _ = rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", + jsonBody(t, map[string]string{"tenancy": testTenancy, "user": testUser, "publicKey": "not a key"})) + assert.Equal(t, http.StatusBadRequest, status) + + status, _ = rawDo(t, http.MethodDelete, e.base+"/_gopherstack/oci/keys?keyId=missing", "") + assert.Equal(t, http.StatusNotFound, status) + + status, _ = rawDo(t, http.MethodPatch, e.base+"/_gopherstack/oci/keys", "") + assert.Equal(t, http.StatusNotFound, status) + + keyID := testTenancy + "/" + testUser + "/" + kp.fprint + status, _ = rawDo(t, http.MethodDelete, e.base+"/_gopherstack/oci/keys?keyId="+keyID, "") + assert.Equal(t, http.StatusNoContent, status) +} + +func TestCompartmentChecks(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + known := map[string]bool{testCompartment: true, otherCompart: true, "ocid1.compartment.oc1..child": true} + parent := map[string]string{"ocid1.compartment.oc1..child": testCompartment} + + e.backend.SetCompartmentChecker(func(id string) bool { return known[id] }) + e.backend.SetCompartmentTree(func(id, ancestor string) bool { return parent[id] == ancestor }) + + tests := []struct { + name string + compart string + want int + }{ + {"known", testCompartment, http.StatusOK}, + {"unknown", "ocid1.compartment.oc1..ghost", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.client.CreateContainerRepository(t.Context(), artifacts.CreateContainerRepositoryRequest{ + CompartmentId: new(tt.compart), DisplayName: new("c-" + tt.name)}) + if tt.want == http.StatusOK { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.want, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + child := e.createRepo(t, "in-child", func(d *artifacts.CreateContainerRepositoryDetails) { + d.CompartmentId = new("ocid1.compartment.oc1..child") + }) + + flat, err := e.client.ListContainerRepositories( + t.Context(), + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + + for _, it := range flat.Items { + assert.NotEqual(t, *child.Id, *it.Id) + } + + deep, err := e.client.ListContainerRepositories(t.Context(), artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(testCompartment), CompartmentIdInSubtree: new(true)}) + require.NoError(t, err) + + found := false + + for _, it := range deep.Items { + found = found || *it.Id == *child.Id + } + + assert.True(t, found, "subtree listing includes child compartments") + + _, err = e.client.ChangeContainerRepositoryCompartment( + t.Context(), + artifacts.ChangeContainerRepositoryCompartmentRequest{ + RepositoryId: child.Id, + CompartmentId: new("ocid1.compartment.oc1..ghost"), + }, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.GetContainerConfiguration(t.Context(), artifacts.GetContainerConfigurationRequest{ + CompartmentId: new("ocid1.compartment.oc1..ghost")}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestListValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := "?compartmentId=" + testCompartment + + tests := []struct { + name string + path string + }{ + {"bad sortBy", "/20160918/container/repositories" + q + "&sortBy=SIZE"}, + {"bad sortOrder", "/20160918/container/images" + q + "&sortOrder=UP"}, + {"limit too big", "/20160918/container/imageSignatures" + q + "&limit=5000"}, + {"negative limit", "/20160918/repositories" + q + "&limit=-1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, http.MethodGet, e.base+tt.path, "") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "InvalidParameter") + }) + } +} diff --git a/services/ociartifacts/models.go b/services/ociartifacts/models.go new file mode 100644 index 0000000000..ac2cad16d9 --- /dev/null +++ b/services/ociartifacts/models.go @@ -0,0 +1,317 @@ +package ociartifacts + +import "time" + +// Lifecycle states. +const ( + stateAvailable = "AVAILABLE" + stateDeleted = "DELETED" +) + +// Readme formats. +const ( + readmeMarkdown = "TEXT_MARKDOWN" + readmePlain = "TEXT_PLAIN" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +func (t *Tags) free() map[string]string { + if t.FreeformTags == nil { + return map[string]string{} + } + + return t.FreeformTags +} + +func (t *Tags) defined() map[string]map[string]any { + if t.DefinedTags == nil { + return map[string]map[string]any{} + } + + return t.DefinedTags +} + +// Readme is a container repository's README. +type Readme struct { + Content string `json:"content"` + Format string `json:"format"` +} + +// Repo is a container repository. +type Repo struct { + TimeCreated time.Time `json:"timeCreated"` + TimeLastPushed *time.Time `json:"timeLastPushed,omitempty"` + Readme *Readme `json:"readme,omitempty"` + Tags Tags `json:"tags"` + ID string `json:"id"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + CreatedBy string `json:"createdBy"` + Etag string `json:"etag"` + Seq int64 `json:"seq"` + IsImmutable bool `json:"isImmutable"` + IsPublic bool `json:"isPublic"` +} + +// Version is a tag of an image. +type Version struct { + TimeCreated time.Time `json:"timeCreated"` + Version string `json:"version"` + CreatedBy string `json:"createdBy"` +} + +// Layer is one blob of an image. +type Layer struct { + TimeCreated time.Time `json:"timeCreated"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// Image is a manifest pushed to a container repository. +type Image struct { + TimeCreated time.Time `json:"timeCreated"` + TimeLastPulled *time.Time `json:"timeLastPulled,omitempty"` + Tags Tags `json:"tags"` + ID string `json:"id"` + RepoID string `json:"repoId"` + Digest string `json:"digest"` + CreatedBy string `json:"createdBy"` + State string `json:"state"` + LastVersion string `json:"lastVersion,omitempty"` + Etag string `json:"etag"` + Versions []Version `json:"versions"` + Layers []Layer `json:"layers"` + LayersSize int64 `json:"layersSize"` + PullCount int64 `json:"pullCount"` + Seq int64 `json:"seq"` + ManifestSize int `json:"manifestSize"` +} + +// Signature is a container image signature. +type Signature struct { + TimeCreated time.Time `json:"timeCreated"` + Tags Tags `json:"tags"` + ID string `json:"id"` + ImageID string `json:"imageId"` + CompartmentID string `json:"compartmentId"` + CreatedBy string `json:"createdBy"` + KmsKeyID string `json:"kmsKeyId"` + KmsKeyVersionID string `json:"kmsKeyVersionId"` + Message string `json:"message"` + Signature string `json:"signature"` + Algorithm string `json:"signingAlgorithm"` + Etag string `json:"etag"` + Seq int64 `json:"seq"` +} + +// GenericRepo is a generic artifacts repository. +type GenericRepo struct { + TimeCreated time.Time `json:"timeCreated"` + Tags Tags `json:"tags"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CompartmentID string `json:"compartmentId"` + Etag string `json:"etag"` + Seq int64 `json:"seq"` + IsImmutable bool `json:"isImmutable"` +} + +// GenericArtifact is one uploaded generic artifact version. +type GenericArtifact struct { + TimeCreated time.Time `json:"timeCreated"` + Tags Tags `json:"tags"` + ID string `json:"id"` + RepoID string `json:"repoId"` + Path string `json:"path"` + Version string `json:"version"` + Sha256 string `json:"sha256"` + Etag string `json:"etag"` + Content []byte `json:"content"` + Seq int64 `json:"seq"` +} + +type readmeWire struct { + Content string `json:"content"` + Format string `json:"format"` +} + +type repoWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeLastPushed *time.Time `json:"timeLastPushed,omitempty"` + Readme *readmeWire `json:"readme,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags map[string]map[string]any `json:"definedTags"` + SystemTags map[string]map[string]any `json:"systemTags"` + CompartmentID string `json:"compartmentId"` + CreatedBy string `json:"createdBy"` + DisplayName string `json:"displayName"` + ID string `json:"id"` + LifecycleState string `json:"lifecycleState"` + Namespace string `json:"namespace"` + LayersSizeInBytes int64 `json:"layersSizeInBytes"` + BillableSizeInGBs int64 `json:"billableSizeInGBs"` + ImageCount int `json:"imageCount"` + LayerCount int `json:"layerCount"` + IsImmutable bool `json:"isImmutable"` + IsPublic bool `json:"isPublic"` +} + +type repoSummaryWire struct { + TimeCreated time.Time `json:"timeCreated"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags map[string]map[string]any `json:"definedTags"` + SystemTags map[string]map[string]any `json:"systemTags"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + ID string `json:"id"` + LifecycleState string `json:"lifecycleState"` + Namespace string `json:"namespace"` + LayersSizeInBytes int64 `json:"layersSizeInBytes"` + BillableSizeInGBs int64 `json:"billableSizeInGBs"` + ImageCount int `json:"imageCount"` + LayerCount int `json:"layerCount"` + IsPublic bool `json:"isPublic"` +} + +type repoCollectionWire struct { + Items []repoSummaryWire `json:"items"` + LayersSizeInBytes int64 `json:"layersSizeInBytes"` + ImageCount int `json:"imageCount"` + LayerCount int `json:"layerCount"` + RemainingItemsCount int `json:"remainingItemsCount"` + RepositoryCount int `json:"repositoryCount"` +} + +type versionWire struct { + TimeCreated time.Time `json:"timeCreated"` + CreatedBy string `json:"createdBy"` + Version string `json:"version"` +} + +type layerWire struct { + TimeCreated time.Time `json:"timeCreated"` + Digest string `json:"digest"` + SizeInBytes int64 `json:"sizeInBytes"` +} + +type imageWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeLastPulled *time.Time `json:"timeLastPulled,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags map[string]map[string]any `json:"definedTags"` + SystemTags map[string]map[string]any `json:"systemTags"` + CompartmentID string `json:"compartmentId"` + CreatedBy string `json:"createdBy"` + Digest string `json:"digest"` + DisplayName string `json:"displayName"` + ID string `json:"id"` + LifecycleState string `json:"lifecycleState"` + RepositoryID string `json:"repositoryId"` + RepositoryName string `json:"repositoryName"` + Version *string `json:"version,omitempty"` + Layers []layerWire `json:"layers"` + Versions []versionWire `json:"versions"` + LayersSizeInBytes int64 `json:"layersSizeInBytes"` + PullCount int64 `json:"pullCount"` + ManifestSizeInBytes int `json:"manifestSizeInBytes"` +} + +type imageSummaryWire struct { + TimeCreated time.Time `json:"timeCreated"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags map[string]map[string]any `json:"definedTags"` + SystemTags map[string]map[string]any `json:"systemTags"` + Version *string `json:"version,omitempty"` + CompartmentID string `json:"compartmentId"` + Digest string `json:"digest"` + DisplayName string `json:"displayName"` + ID string `json:"id"` + LifecycleState string `json:"lifecycleState"` + RepositoryID string `json:"repositoryId"` + RepositoryName string `json:"repositoryName"` +} + +type imageCollectionWire struct { + Items []imageSummaryWire `json:"items"` + RemainingItemsCount int `json:"remainingItemsCount"` +} + +type signatureWire struct { + TimeCreated time.Time `json:"timeCreated"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags map[string]map[string]any `json:"definedTags"` + SystemTags map[string]map[string]any `json:"systemTags"` + CompartmentID string `json:"compartmentId"` + CreatedBy string `json:"createdBy,omitempty"` + DisplayName string `json:"displayName"` + ID string `json:"id"` + ImageID string `json:"imageId"` + KmsKeyID string `json:"kmsKeyId"` + KmsKeyVersionID string `json:"kmsKeyVersionId"` + Message string `json:"message"` + Signature string `json:"signature"` + SigningAlgorithm string `json:"signingAlgorithm"` + LifecycleState string `json:"lifecycleState"` +} + +type signatureCollectionWire struct { + Items []signatureWire `json:"items"` + RemainingItemsCount int `json:"remainingItemsCount"` +} + +type configWire struct { + Namespace string `json:"namespace"` + IsRepositoryCreatedOnFirstPush bool `json:"isRepositoryCreatedOnFirstPush"` +} + +type genericRepoWire struct { + TimeCreated time.Time `json:"timeCreated"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags map[string]map[string]any `json:"definedTags"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + CompartmentID string `json:"compartmentId"` + Description string `json:"description"` + LifecycleState string `json:"lifecycleState"` + RepositoryType string `json:"repositoryType"` + IsImmutable bool `json:"isImmutable"` +} + +type genericRepoCollectionWire struct { + Items []genericRepoWire `json:"items"` +} + +type genericArtifactWire struct { + TimeCreated time.Time `json:"timeCreated"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags map[string]map[string]any `json:"definedTags"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + CompartmentID string `json:"compartmentId"` + RepositoryID string `json:"repositoryId"` + ArtifactPath string `json:"artifactPath"` + Version string `json:"version"` + Sha256 string `json:"sha256"` + LifecycleState string `json:"lifecycleState"` + SizeInBytes int64 `json:"sizeInBytes"` +} + +type genericArtifactCollectionWire struct { + Items []genericArtifactWire `json:"items"` +} diff --git a/services/ociartifacts/ops.go b/services/ociartifacts/ops.go new file mode 100644 index 0000000000..bfe48b8194 --- /dev/null +++ b/services/ociartifacts/ops.go @@ -0,0 +1,402 @@ +package ociartifacts + +import ( + "net/http" + "strconv" +) + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateContainerRepo: (*Handler).createContainerRepo, + opGetContainerRepo: (*Handler).getContainerRepo, + opListContainerRepos: (*Handler).listContainerRepos, + opUpdateContainerRepo: (*Handler).updateContainerRepo, + opDeleteContainerRepo: (*Handler).deleteContainerRepo, + opChangeContainerRepoC: (*Handler).changeContainerRepoCompartment, + opListContainerImages: (*Handler).listImages, + opGetContainerImage: (*Handler).getImage, + opDeleteContainerImage: (*Handler).deleteImage, + opUpdateContainerImage: (*Handler).updateImage, + opRestoreContainerImage: (*Handler).restoreImage, + opRemoveContainerVer: (*Handler).removeVersion, + opLookupImageByURI: (*Handler).lookupImage, + opCreateImageSignature: (*Handler).createSignature, + opGetImageSignature: (*Handler).getSignature, + opListImageSignatures: (*Handler).listSignatures, + opUpdateImageSignature: (*Handler).updateSignature, + opDeleteImageSignature: (*Handler).deleteSignature, + opGetContainerConfig: (*Handler).getConfig, + opUpdateContainerConfig: (*Handler).updateConfig, + opCreateRepository: (*Handler).createGenericRepo, + opGetRepository: (*Handler).getGenericRepo, + opListRepositories: (*Handler).listGenericRepos, + opUpdateRepository: (*Handler).updateGenericRepo, + opDeleteRepository: (*Handler).deleteGenericRepo, + opChangeRepositoryComp: (*Handler).changeGenericRepoCompartment, + opGetGenericArtifact: (*Handler).getArtifact, + opGetGenericByPath: (*Handler).getArtifactByPath, + opListGenericArtifacts: (*Handler).listArtifacts, + opUpdateGenericArtifact: (*Handler).updateArtifact, + opUpdateGenericByPath: (*Handler).updateArtifactByPath, + opDeleteGenericArtifact: (*Handler).deleteArtifact, + opDeleteGenericByPath: (*Handler).deleteArtifactByPath, + opPutGenericContent: (*Handler).putContent, + opGetGenericContent: (*Handler).getContent, + opGetGenericContentPath: (*Handler).getContentByPath, + } +} + +// respond writes a 200 JSON body with the etag, or the error. +func respond[T any](q *req, out T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, out, etag) +} + +// withBody decodes the request body into In, then runs call. +func withBody[In any](q *req, call func(in In) error) error { + var in In + if err := q.decode(&in); err != nil { + return err + } + + return call(in) +} + +func noContent(q *req, err error) error { + if err != nil { + return err + } + + q.w.WriteHeader(http.StatusNoContent) + + return nil +} + +func okEmpty(q *req, err error) error { + if err != nil { + return err + } + + q.w.WriteHeader(http.StatusOK) + + return nil +} + +func (h *Handler) createContainerRepo(q *req) error { + return withBody(q, func(in createRepoIn) error { + w, etag, err := h.Backend.CreateContainerRepository(in, q.user()) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) getContainerRepo(q *req) error { + w, etag, err := h.Backend.GetContainerRepository(q.param("repositoryId")) + + return respond(q, w, etag, err) +} + +func (h *Handler) listContainerRepos(q *req) error { + f := repoFilter{ + compartment: q.query("compartmentId"), id: q.query("repositoryId"), name: q.query("displayName"), + state: q.query("lifecycleState"), isPublic: q.boolQuery("isPublic"), + } + + out, next, err := h.Backend.ListContainerRepositories(f, q.listOpts()) + if err != nil { + return err + } + + q.nextPage(next) + + return q.json(http.StatusOK, out, "") +} + +func (h *Handler) updateContainerRepo(q *req) error { + return withBody(q, func(in updateRepoIn) error { + w, etag, err := h.Backend.UpdateContainerRepository(q.param("repositoryId"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) deleteContainerRepo(q *req) error { + return noContent(q, h.Backend.DeleteContainerRepository(q.param("repositoryId"), q.ifMatch())) +} + +func (h *Handler) changeContainerRepoCompartment(q *req) error { + return withBody(q, func(in changeCompartmentIn) error { + return okEmpty(q, h.Backend.ChangeContainerRepositoryCompartment(q.param("repositoryId"), q.ifMatch(), in)) + }) +} + +func (h *Handler) listImages(q *req) error { + f := imageFilter{ + isVersioned: q.boolQuery("isVersioned"), compartment: q.query("compartmentId"), name: q.query("displayName"), + id: q.query("imageId"), repoID: q.query("repositoryId"), repoName: q.query("repositoryName"), + version: q.query("version"), state: q.query("lifecycleState"), digest: q.query("imageDigest"), + } + + out, next, err := h.Backend.ListContainerImages(f, q.listOpts()) + if err != nil { + return err + } + + q.nextPage(next) + + return q.json(http.StatusOK, out, "") +} + +func (h *Handler) getImage(q *req) error { + w, etag, err := h.Backend.GetContainerImage(q.param("imageId")) + + return respond(q, w, etag, err) +} + +func (h *Handler) deleteImage(q *req) error { + return noContent(q, h.Backend.DeleteContainerImage(q.param("imageId"), q.ifMatch())) +} + +func (h *Handler) updateImage(q *req) error { + return withBody(q, func(in updateTagsIn) error { + w, etag, err := h.Backend.UpdateContainerImage(q.param("imageId"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) restoreImage(q *req) error { + var in restoreImageIn + + if len(q.body) > 0 { + if err := q.decode(&in); err != nil { + return err + } + } + + w, etag, err := h.Backend.RestoreContainerImage(q.param("imageId"), q.ifMatch(), in) + + return respond(q, w, etag, err) +} + +func (h *Handler) removeVersion(q *req) error { + return withBody(q, func(in removeVersionIn) error { + w, etag, err := h.Backend.RemoveContainerVersion(q.param("imageId"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) lookupImage(q *req) error { + return withBody(q, func(in lookupIn) error { + w, etag, err := h.Backend.LookupContainerImageByURI(in.ImageURI) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) createSignature(q *req) error { + return withBody(q, func(in createSignatureIn) error { + w, etag, err := h.Backend.CreateContainerImageSignature(in, q.user()) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) getSignature(q *req) error { + w, etag, err := h.Backend.GetContainerImageSignature(q.param("imageSignatureId")) + + return respond(q, w, etag, err) +} + +func (h *Handler) listSignatures(q *req) error { + f := signatureFilter{ + compartment: q.query("compartmentId"), imageID: q.query("imageId"), repoID: q.query("repositoryId"), + repoName: q.query("repositoryName"), digest: q.query("imageDigest"), name: q.query("displayName"), + kmsKeyID: q.query("kmsKeyId"), kmsVersion: q.query("kmsKeyVersionId"), algorithm: q.query("signingAlgorithm"), + } + + out, next, err := h.Backend.ListContainerImageSignatures(f, q.listOpts()) + if err != nil { + return err + } + + q.nextPage(next) + + return q.json(http.StatusOK, out, "") +} + +func (h *Handler) updateSignature(q *req) error { + return withBody(q, func(in updateTagsIn) error { + w, etag, err := h.Backend.UpdateContainerImageSignature(q.param("imageSignatureId"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) deleteSignature(q *req) error { + return noContent(q, h.Backend.DeleteContainerImageSignature(q.param("imageSignatureId"), q.ifMatch())) +} + +func (h *Handler) getConfig(q *req) error { + w, etag, err := h.Backend.GetContainerConfiguration(q.query("compartmentId")) + + return respond(q, w, etag, err) +} + +func (h *Handler) updateConfig(q *req) error { + return withBody(q, func(in updateConfigIn) error { + w, etag, err := h.Backend.UpdateContainerConfiguration(q.query("compartmentId"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) createGenericRepo(q *req) error { + return withBody(q, func(in createGenericRepoIn) error { + w, etag, err := h.Backend.CreateRepository(in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) getGenericRepo(q *req) error { + w, etag, err := h.Backend.GetRepository(q.param("repositoryId")) + + return respond(q, w, etag, err) +} + +func (h *Handler) listGenericRepos(q *req) error { + f := genericRepoFilter{ + isImmutable: q.boolQuery("isImmutable"), compartment: q.query("compartmentId"), id: q.query("id"), + name: q.query("displayName"), state: q.query("lifecycleState"), + } + + out, next, err := h.Backend.ListRepositories(f, q.listOpts()) + if err != nil { + return err + } + + q.nextPage(next) + + return q.json(http.StatusOK, out, "") +} + +func (h *Handler) updateGenericRepo(q *req) error { + return withBody(q, func(in updateGenericRepoIn) error { + w, etag, err := h.Backend.UpdateRepository(q.param("repositoryId"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) deleteGenericRepo(q *req) error { + return noContent(q, h.Backend.DeleteRepository(q.param("repositoryId"), q.ifMatch())) +} + +func (h *Handler) changeGenericRepoCompartment(q *req) error { + return withBody(q, func(in changeCompartmentIn) error { + return okEmpty(q, h.Backend.ChangeRepositoryCompartment(q.param("repositoryId"), q.ifMatch(), in)) + }) +} + +func (h *Handler) getArtifact(q *req) error { + w, etag, err := h.Backend.GetGenericArtifact(q.param("artifactId")) + + return respond(q, w, etag, err) +} + +func (h *Handler) getArtifactByPath(q *req) error { + w, etag, err := h.Backend.GetGenericArtifactByPath( + q.param("repositoryId"), + q.param("artifactPath"), + q.param("version"), + ) + + return respond(q, w, etag, err) +} + +func (h *Handler) listArtifacts(q *req) error { + f := genericArtifactFilter{ + compartment: q.query("compartmentId"), repoID: q.query("repositoryId"), id: q.query("id"), + name: q.query("displayName"), path: q.query("artifactPath"), version: q.query("version"), + sha256: q.query("sha256"), state: q.query("lifecycleState"), + } + + out, next, err := h.Backend.ListGenericArtifacts(f, q.listOpts()) + if err != nil { + return err + } + + q.nextPage(next) + + return q.json(http.StatusOK, out, "") +} + +func (h *Handler) updateArtifact(q *req) error { + return withBody(q, func(in updateTagsIn) error { + w, etag, err := h.Backend.UpdateGenericArtifact(q.param("artifactId"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) updateArtifactByPath(q *req) error { + return withBody(q, func(in updateTagsIn) error { + w, etag, err := h.Backend.UpdateGenericArtifactByPath( + q.param("repositoryId"), q.param("artifactPath"), q.param("version"), q.ifMatch(), in) + + return respond(q, w, etag, err) + }) +} + +func (h *Handler) deleteArtifact(q *req) error { + return noContent(q, h.Backend.DeleteGenericArtifact(q.param("artifactId"), q.ifMatch())) +} + +func (h *Handler) deleteArtifactByPath(q *req) error { + return noContent(q, h.Backend.DeleteGenericArtifactByPath( + q.param("repositoryId"), q.param("artifactPath"), q.param("version"), q.ifMatch())) +} + +func (h *Handler) putContent(q *req) error { + w, etag, err := h.Backend.PutGenericArtifactContentByPath( + q.param("repositoryId"), q.param("artifactPath"), q.param("version"), q.ifMatch(), q.body) + + return respond(q, w, etag, err) +} + +func (q *req) content(c GenericContent) error { + q.header("etag", c.Etag) + q.header("Content-Type", "application/octet-stream") + q.header("Content-Length", strconv.Itoa(len(c.Content))) + q.w.WriteHeader(http.StatusOK) + _, _ = q.w.Write(c.Content) + + return nil +} + +func (h *Handler) getContent(q *req) error { + c, err := h.Backend.GetGenericArtifactContent(q.param("artifactId")) + if err != nil { + return err + } + + return q.content(c) +} + +func (h *Handler) getContentByPath(q *req) error { + c, err := h.Backend.GetGenericArtifactContentByPath( + q.param("repositoryId"), + q.param("artifactPath"), + q.param("version"), + ) + if err != nil { + return err + } + + return q.content(c) +} diff --git a/services/ociartifacts/persistence.go b/services/ociartifacts/persistence.go new file mode 100644 index 0000000000..5571746919 --- /dev/null +++ b/services/ociartifacts/persistence.go @@ -0,0 +1,152 @@ +package ociartifacts + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Repos map[string]*Repo `json:"repos"` + Images map[string]*Image `json:"images"` + Signatures map[string]*Signature `json:"signatures"` + GenericRepos map[string]*GenericRepo `json:"genericRepos"` + Artifacts map[string]*GenericArtifact `json:"artifacts"` + ConfigEtag string `json:"configEtag"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` + CreateOnPush bool `json:"createOnPush"` +} + +// Snapshot serialises every resource and the registered API keys. Image blobs and manifests are not part of it: +// they live under the data directory when one is configured. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ociartifacts", backendSnapshot{ + Version: snapshotVersion, Repos: b.repos, Images: b.images, Signatures: b.sigs, GenericRepos: b.gens, + Artifacts: b.arts, ConfigEtag: b.configEtag, Auth: keys, Seq: b.seq, CreateOnPush: b.createOnPush, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ociartifacts", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ociartifacts: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := snap.normalize(); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.repos, b.images, b.sigs, b.gens, b.arts = snap.Repos, snap.Images, snap.Signatures, snap.GenericRepos, snap.Artifacts + b.configEtag, b.seq, b.createOnPush = snap.ConfigEtag, snap.Seq, snap.CreateOnPush + + if b.dataDir == "" && len(b.images) > 0 { + logger.Load(ctx). + WarnContext(ctx, "ociartifacts: image blobs were not persisted (no data dir), dropping image records") + + b.images, b.sigs = map[string]*Image{}, map[string]*Signature{} + } + + b.Auth.Load(snap.Auth) + + return nil +} + +func (s *backendSnapshot) normalize() error { + if s.Repos == nil { + s.Repos = map[string]*Repo{} + } + + if s.Images == nil { + s.Images = map[string]*Image{} + } + + if s.Signatures == nil { + s.Signatures = map[string]*Signature{} + } + + if s.GenericRepos == nil { + s.GenericRepos = map[string]*GenericRepo{} + } + + if s.Artifacts == nil { + s.Artifacts = map[string]*GenericArtifact{} + } + + if s.ConfigEtag == "" { + s.ConfigEtag = newEtag() + } + + return s.checkNull() +} + +func (s *backendSnapshot) checkNull() error { + for id, v := range s.Repos { + if v == nil { + return fmt.Errorf("%w: container repository %q", ErrSnapshotNull, id) + } + } + + for id, v := range s.Images { + if v == nil { + return fmt.Errorf("%w: image %q", ErrSnapshotNull, id) + } + } + + for id, v := range s.Signatures { + if v == nil { + return fmt.Errorf("%w: signature %q", ErrSnapshotNull, id) + } + } + + for id, v := range s.GenericRepos { + if v == nil { + return fmt.Errorf("%w: repository %q", ErrSnapshotNull, id) + } + } + + for id, v := range s.Artifacts { + if v == nil { + return fmt.Errorf("%w: artifact %q", ErrSnapshotNull, id) + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ociartifacts: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ociartifacts/persistence_test.go b/services/ociartifacts/persistence_test.go new file mode 100644 index 0000000000..d0f339c9ba --- /dev/null +++ b/services/ociartifacts/persistence_test.go @@ -0,0 +1,233 @@ +package ociartifacts_test + +import ( + "context" + "io" + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/artifacts" + "github.com/oracle/oci-go-sdk/v65/genericartifactscontent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/persistence" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" +) + +var _ persistence.Persistable = (*ociartifacts.Handler)(nil) + +func TestSnapshotRestoreWithBlobs(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + src := newEnv(t, envOpts{dataDir: dir, register: true}) + repo := src.createRepo(t, "persist/app", func(d *artifacts.CreateContainerRepositoryDetails) { + d.IsPublic = new(true) + d.FreeformTags = map[string]string{"k": "v"} + }) + img := src.registry(t).PushImage(testNamespace+"/persist/app", "v1") + + images := src.listImages(t, nil) + require.Len(t, images, 1) + + _, err := src.client.CreateContainerImageSignature(t.Context(), artifacts.CreateContainerImageSignatureRequest{ + CreateContainerImageSignatureDetails: sigDetails(images[0].Id)}) + require.NoError(t, err) + + gen := src.createGenericRepo(t, true) + _, err = src.upload(t, *gen.Id, "bin/tool", "1", []byte("tool bytes")) + require.NoError(t, err) + + _, err = src.client.UpdateContainerConfiguration(t.Context(), artifacts.UpdateContainerConfigurationRequest{ + CompartmentId: new(testCompartment), + IsRepositoryCreatedOnFirstPush: new(false), + }) + require.NoError(t, err) + + snap := src.handler.Snapshot(t.Context()) + require.NotEmpty(t, snap) + src.handler.Shutdown(context.Background()) + + dst := newEnv(t, envOpts{dataDir: dir}) + require.NoError(t, dst.handler.Restore(t.Context(), snap)) + + got, err := dst.client.GetContainerRepository( + t.Context(), + artifacts.GetContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + assert.Equal(t, "persist/app", *got.DisplayName) + assert.True(t, *got.IsPublic) + assert.Equal(t, map[string]string{"k": "v"}, got.FreeformTags) + assert.Equal(t, 1, *got.ImageCount) + + restored := dst.listImages(t, nil) + require.Len(t, restored, 1) + assert.Equal(t, *images[0].Id, *restored[0].Id) + assert.Equal(t, "v1", *restored[0].Version) + + pull := dst.registry(t).Do(http.MethodGet, "/v2/"+testNamespace+"/persist/app/manifests/v1", nil, nil) + require.Equal(t, http.StatusOK, pull.Status) + assert.Equal(t, img.Body, pull.Body) + + sigs, err := dst.client.ListContainerImageSignatures( + t.Context(), + artifacts.ListContainerImageSignaturesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Len(t, sigs.Items, 1) + + dl, err := dst.content.GetGenericArtifactContentByPath( + t.Context(), + genericartifactscontent.GetGenericArtifactContentByPathRequest{ + RepositoryId: gen.Id, ArtifactPath: new("bin/tool"), Version: new("1")}, + ) + require.NoError(t, err) + + defer dl.Content.Close() + + body, err := io.ReadAll(dl.Content) + require.NoError(t, err) + assert.Equal(t, "tool bytes", string(body)) + + cfg, err := dst.client.GetContainerConfiguration( + t.Context(), + artifacts.GetContainerConfigurationRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.False(t, *cfg.IsRepositoryCreatedOnFirstPush) + + _, err = dst.backend.Auth.Register(testTenancy, testUser, newKeyPair(t).pubPEM) + require.NoError(t, err) + assert.NotEmpty(t, dst.backend.Auth.Records()) +} + +func TestRestoreRestoresSigningKeys(t *testing.T) { + t.Parallel() + + src := newEnv(t, envOpts{register: true}) + snap := src.handler.Snapshot(t.Context()) + + dst := newEnv(t, envOpts{}) + require.NoError(t, dst.handler.Restore(t.Context(), snap)) + assert.Len(t, dst.backend.Auth.Records(), 1) +} + +func TestRestoreWithoutDataDirDropsImages(t *testing.T) { + t.Parallel() + + src := newEnv(t, envOpts{}) + repo := src.createRepo(t, "volatile", nil) + src.registry(t).PushImage(testNamespace+"/volatile", "v1") + require.Len(t, src.listImages(t, nil), 1) + + snap := src.handler.Snapshot(t.Context()) + + dst := newEnv(t, envOpts{}) + require.NoError(t, dst.handler.Restore(t.Context(), snap)) + + _, err := dst.client.GetContainerRepository( + t.Context(), + artifacts.GetContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + assert.Empty(t, dst.listImages(t, nil)) +} + +func TestRestoreRejectsBadSnapshots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + empty bool + }{ + {"not json", "{", true, false}, + {"null repo", `{"version":1,"repos":{"a":null}}`, true, false}, + {"null image", `{"version":1,"images":{"a":null}}`, true, false}, + {"null signature", `{"version":1,"signatures":{"a":null}}`, true, false}, + {"null generic repo", `{"version":1,"genericRepos":{"a":null}}`, true, false}, + {"null artifact", `{"version":1,"artifacts":{"a":null}}`, true, false}, + {"other version starts empty", `{"version":99,"repos":{"a":{"id":"a","name":"x"}}}`, false, true}, + {"empty maps", `{"version":1}`, false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "before", nil) + + err := e.handler.Restore(t.Context(), []byte(tt.data)) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + + list, lerr := e.client.ListContainerRepositories( + t.Context(), + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, lerr) + assert.Empty(t, list.Items) + }) + } +} + +func TestResetClearsEverything(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + e := newEnv(t, envOpts{dataDir: dir}) + e.createRepo(t, "gone", nil) + e.registry(t).PushImage(testNamespace+"/gone", "v1") + e.createGenericRepo(t, false) + + _, err := e.client.UpdateContainerConfiguration(t.Context(), artifacts.UpdateContainerConfigurationRequest{ + CompartmentId: new(testCompartment), + IsRepositoryCreatedOnFirstPush: new(false), + }) + require.NoError(t, err) + + e.handler.Reset() + + repos, err := e.client.ListContainerRepositories( + t.Context(), + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Empty(t, repos.Items) + + gens, err := e.client.ListRepositories( + t.Context(), + artifacts.ListRepositoriesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Empty(t, gens.Items) + + cfg, err := e.client.GetContainerConfiguration( + t.Context(), + artifacts.GetContainerConfigurationRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.True(t, *cfg.IsRepositoryCreatedOnFirstPush) + + // Blobs are gone with the repository: the same name starts empty. + assert.Equal(t, http.StatusNotFound, + e.registry(t).Do(http.MethodGet, "/v2/"+testNamespace+"/gone/manifests/v1", nil, nil).Status) + + c := e.registry(t) + c.PushImage(testNamespace+"/gone", "v2") + assert.Equal(t, http.StatusOK, c.Do(http.MethodGet, "/v2/"+testNamespace+"/gone/manifests/v2", nil, nil).Status) + assert.Equal( + t, + http.StatusNotFound, + c.Do(http.MethodGet, "/v2/"+testNamespace+"/gone/manifests/v1", nil, nil).Status, + ) +} diff --git a/services/ociartifacts/provider.go b/services/ociartifacts/provider.go new file mode 100644 index 0000000000..e1849e865e --- /dev/null +++ b/services/ociartifacts/provider.go @@ -0,0 +1,56 @@ +package ociartifacts + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ociartifacts: nil app context") + +// ConfigProvider extracts OCI Artifacts configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIArtifactsSettings() Settings +} + +// DataDirConfig locates the directory registry blobs are kept in; empty keeps them in memory. +type DataDirConfig interface { + GetRegistryDataDir() string +} + +// Provider implements service.Provider for OCI Artifacts. It runs on its own listener (see +// Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIArtifacts" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIArtifactsSettings() + } + + backend := NewInMemoryBackend() + if dc, ok := ctx.Config.(DataDirConfig); ok { + backend.SetDataDir(dc.GetRegistryDataDir()) + } + + if ctx.JanitorCtx != nil { + backend.SetContext(ctx.JanitorCtx) + } + + handler := NewHandler(backend) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ociartifacts/registry.go b/services/ociartifacts/registry.go new file mode 100644 index 0000000000..4dc292baf2 --- /dev/null +++ b/services/ociartifacts/registry.go @@ -0,0 +1,378 @@ +package ociartifacts + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/ociregistry" +) + +const ( + apiVersionHeader = "Docker-Distribution-Api-Version" + apiVersion = "registry/2.0" +) + +type regErrorBody struct { + Errors []regErrorEntry `json:"errors"` +} + +type regErrorEntry struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + raw, err := json.Marshal(v) + if err != nil { + status, raw = http.StatusInternalServerError, []byte(`{"errors":[{"code":"UNKNOWN","message":"encode"}]}`) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(raw) +} + +func writeRegError(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, regErrorBody{Errors: []regErrorEntry{{Code: code, Message: msg}}}) +} + +// principal is who a registry request is made by. +type principal struct { + user string + claims tokenClaims + scoped bool + anon bool +} + +// authenticate accepts a Bearer token from the token endpoint or Basic credentials (/:). +func (h *Handler) authenticate(r *http.Request) (principal, bool) { + auth := r.Header.Get("Authorization") + + switch { + case hasFold(auth, "Bearer "): + c, ok := h.Backend.ParseToken(strings.TrimSpace(auth[len("Bearer "):])) + if !ok { + return principal{}, false + } + + return principal{claims: c, user: c.Sub, scoped: true, anon: c.Anon}, true + case hasFold(auth, "Basic "): + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(auth[len("Basic "):])) + if err != nil { + return principal{}, false + } + + name, pass, ok := strings.Cut(string(raw), ":") + if !ok { + return principal{}, false + } + + user, valid := h.Backend.ValidLogin(name, pass) + + return principal{user: user}, valid + } + + return principal{}, false +} + +func hasFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) +} + +func scheme(r *http.Request) string { + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + return "https" + } + + return "http" +} + +func challenge(w http.ResponseWriter, r *http.Request, scope, errCode string) { + value := `Bearer realm="` + scheme(r) + `://` + r.Host + tokenPath + `",service="` + r.Host + `"` + + if scope != "" { + value += `,scope="` + scope + `"` + } + + if errCode != "" { + value += `,error="` + errCode + `"` + } + + w.Header().Set("WWW-Authenticate", value) + w.Header().Set(apiVersionHeader, apiVersion) + writeRegError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") +} + +// need is one access a registry request requires. +type need struct { + name string + action string +} + +func (n need) scope() string { + hint := n.action + if n.action == actionPush { + hint = "pull,push" + } + + return Scope{Name: n.name, Actions: strings.Split(hint, ",")}.String() +} + +func actionFor(method string) string { + switch method { + case http.MethodGet, http.MethodHead: + return actionPull + case http.MethodDelete: + return actionDel + } + + return actionPush +} + +// splitRegistryPath returns the repository name of /v2//{manifests,blobs,tags}/... +func splitRegistryPath(path string) (string, bool) { + rest := strings.TrimPrefix(path, registryPrefix+"/") + + for _, kind := range []string{"manifests", "blobs", "tags"} { + if i := strings.Index(rest, "/"+kind+"/"); i > 0 { + return rest[:i], true + } + } + + return "", false +} + +func (h *Handler) serveRegistry(w http.ResponseWriter, r *http.Request) { + w.Header().Set(apiVersionHeader, apiVersion) + + name, isRepo := splitRegistryPath(r.URL.Path) + needs := []need(nil) + + if isRepo { + needs = append(needs, need{name: name, action: actionFor(r.Method)}) + + if from := r.URL.Query().Get("from"); r.Method == http.MethodPost && from != "" { + needs = append(needs, need{name: from, action: actionPull}) + } + } + + who, ok := h.authenticate(r) + if !ok { + challenge(w, r, joinScopes(needs), "") + + return + } + + if !isRepo { + h.servePing(w, r) + + return + } + + h.serveRepoRequest(w, r, who, needs) +} + +func joinScopes(needs []need) string { + parts := make([]string, 0, len(needs)) + for _, n := range needs { + parts = append(parts, n.scope()) + } + + return strings.Join(parts, " ") +} + +func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) { + if p := strings.TrimSuffix(r.URL.Path, "/"); p == registryPrefix { + writeJSON(w, http.StatusOK, struct{}{}) + + return + } + + writeRegError(w, http.StatusNotFound, "UNSUPPORTED", "The requested registry API is not supported") +} + +func (h *Handler) serveRepoRequest( + w http.ResponseWriter, r *http.Request, who principal, needs []need, +) { + display, inNS := h.Backend.SplitRegistryName(needs[0].name) + if !inNS { + writeRegError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository name must start with the tenancy namespace") + + return + } + + acc, exists := h.Backend.RepoAccess(display) + action := needs[0].action + + if who.anon && (action != actionPull || !exists || !acc.Public) { + challenge(w, r, joinScopes(needs), "") + + return + } + + for _, n := range needs { + if who.scoped && !who.claims.allows(n.name, n.action) { + challenge(w, r, joinScopes(needs), "insufficient_scope") + + return + } + } + + if !exists && !h.admit(w, display, action, who) { + return + } + + app := h.Backend.registry() + ociregistry.ServeObserved(app, w, r, h.observer(app, r, display, who.user)) +} + +// admit handles a request for a repository that does not exist yet: a push creates it when the container +// configuration allows, anything else is unknown. +func (h *Handler) admit(w http.ResponseWriter, display, action string, who principal) bool { + if action != actionPush { + writeRegError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository name not known to registry") + + return false + } + + if ae := h.Backend.EnsureRepo(display, who.user); ae != nil { + if ae.Code == "InvalidParameter" { + writeRegError(w, http.StatusBadRequest, "NAME_INVALID", ae.Message) + } else { + writeRegError(w, ae.Status, ae.Code, ae.Message) + } + + return false + } + + return true +} + +func manifestDigest(r *http.Request) string { + if r.Method != http.MethodPut || r.Body == nil { + return "" + } + + raw, err := io.ReadAll(io.LimitReader(r.Body, maxManifest+1)) + if err != nil || len(raw) > maxManifest { + r.Body = io.NopCloser(bytes.NewReader(raw)) + + return "" + } + + r.Body = io.NopCloser(bytes.NewReader(raw)) + sum := sha256.Sum256(raw) + + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func (h *Handler) observer(app http.Handler, r *http.Request, display, user string) ociregistry.Observer { + regName := func(repo string) string { + d, _ := h.Backend.SplitRegistryName(repo) + + return d + } + + return ociregistry.Observer{ + Permit: func(w http.ResponseWriter, _, ref, op string) bool { + digest := "" + if op == ociregistry.OpWrite && !ociregistry.IsDigest(ref) { + digest = manifestDigest(r) + } + + switch h.Backend.PermitManifest(display, ref, op, digest, r.Method == http.MethodGet) { + case permitUnknown: + writeRegError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown") + + return false + case permitTagExists: + writeRegError(w, http.StatusConflict, "TAG_INVALID", + "The tag already exists in an immutable repository and cannot be overwritten") + + return false + } + + return true + }, + Put: func(repo, ref, digest string) { + var body []byte + + if m, err := ociregistry.NewClient(app).GetManifest(r.Context(), repo, digest); err == nil { + body = m.Body + } + + h.Backend.RecordPush(regName(repo), ref, digest, user, body) + }, + Delete: func(repo, ref string) { h.Backend.RecordDelete(regName(repo), ref) }, + } +} + +type tokenResponse struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` +} + +// serveToken is the Container Registry token endpoint (GetAccessToken, /20180419/docker/token): Basic +// credentials get a token for the scopes asked for, a request without credentials gets an anonymous pull token. +func (h *Handler) serveToken(w http.ResponseWriter, r *http.Request) { + user, anon := "", true + + if auth := r.Header.Get("Authorization"); auth != "" { + u, ok := h.basicUser(auth) + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="`+r.Host+`"`) + writeRegError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") + + return + } + + user, anon = u, false + } + + var scopes []Scope + + for _, raw := range r.URL.Query()["scope"] { + for field := range strings.FieldsSeq(raw) { + if s, ok := ParseScope(field); ok { + scopes = append(scopes, s) + } + } + } + + token, granted, ttl := h.Backend.IssueToken(user, anon, scopes) + parts := make([]string, 0, len(granted)) + + for _, s := range granted { + parts = append(parts, s.String()) + } + + writeJSON(w, http.StatusOK, tokenResponse{ + Token: token, AccessToken: token, Scope: strings.Join(parts, " "), ExpiresIn: ttl, + }) +} + +func (h *Handler) basicUser(auth string) (string, bool) { + if !hasFold(auth, "Basic ") { + return "", false + } + + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(auth[len("Basic "):])) + if err != nil { + return "", false + } + + name, pass, ok := strings.Cut(string(raw), ":") + if !ok { + return "", false + } + + return h.Backend.ValidLogin(name, pass) +} diff --git a/services/ociartifacts/registry_test.go b/services/ociartifacts/registry_test.go new file mode 100644 index 0000000000..5dbbf1fb00 --- /dev/null +++ b/services/ociartifacts/registry_test.go @@ -0,0 +1,908 @@ +package ociartifacts_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/artifacts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociregistry/ocitest" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" +) + +type tokenReply struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` +} + +func (e *env) token(t *testing.T, authz, scope string) (int, tokenReply) { + t.Helper() + + c := e.anonymous(t) + headers := map[string]string{} + + if authz != "" { + headers["Authorization"] = authz + } + + resp := c.Do(http.MethodGet, "/20180419/docker/token?service=localhost&scope="+scope, nil, headers) + + var out tokenReply + + if resp.Status == http.StatusOK { + require.NoError(t, json.Unmarshal(resp.Body, &out)) + } + + return resp.Status, out +} + +func (e *env) bearer(t *testing.T, token string) ocitest.Client { + t.Helper() + + return ocitest.Client{T: t, Base: e.base, Prepare: func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+token) + }} +} + +func TestRegistryConformance(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ocitest.Run(t, e.registry(t), testNamespace+"/", "", "") +} + +func TestRegistryBasicAuth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authz string + status int + }{ + {"none", "", http.StatusUnauthorized}, + {"valid", basic(loginUser, loginToken), http.StatusOK}, + {"federated user", basic(testNamespace+"/oracleidentitycloudservice/bob", loginToken), http.StatusOK}, + {"namespace case", basic("GopherStack/alice", loginToken), http.StatusOK}, + {"wrong namespace", basic("other/alice", loginToken), http.StatusUnauthorized}, + {"no namespace", basic("alice", loginToken), http.StatusUnauthorized}, + {"empty user", basic(testNamespace+"/", loginToken), http.StatusUnauthorized}, + {"empty token", basic(loginUser, ""), http.StatusUnauthorized}, + {"bad base64", "Basic !!!", http.StatusUnauthorized}, + {"bad bearer", "Bearer nope", http.StatusUnauthorized}, + {"unknown scheme", "Digest x", http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + headers := map[string]string{} + + if tt.authz != "" { + headers["Authorization"] = tt.authz + } + + resp := e.anonymous(t).Do(http.MethodGet, "/v2/", nil, headers) + assert.Equal(t, tt.status, resp.Status) + assert.Equal(t, "registry/2.0", resp.Header.Get("Docker-Distribution-Api-Version")) + + if tt.status == http.StatusUnauthorized { + assert.Contains( + t, + resp.Header.Get("WWW-Authenticate"), + `Bearer realm="`+e.base+`/20180419/docker/token"`, + ) + assert.Contains(t, string(resp.Body), "UNAUTHORIZED") + } else { + assert.JSONEq(t, "{}", string(resp.Body)) + } + + // A failed login must not echo the credentials. + assert.NotContains(t, string(resp.Body), loginToken) + }) + } +} + +func TestTokenEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authz string + scope string + wantScope string + wantCode int + }{ + {"login without scope", basic(loginUser, loginToken), "", "", http.StatusOK}, + { + "push scope", + basic(loginUser, loginToken), + "repository:gopherstack/app:pull,push", + "repository:gopherstack/app:pull,push", + http.StatusOK, + }, + {"anonymous pull", "", "repository:gopherstack/app:pull", "repository:gopherstack/app:pull", http.StatusOK}, + {"anonymous push dropped", "", "repository:gopherstack/app:push", "", http.StatusOK}, + { + "anonymous push narrowed", + "", + "repository:gopherstack/app:pull,push", + "repository:gopherstack/app:pull", + http.StatusOK, + }, + { + "bad credentials", + basic("nobody/alice", loginToken), + "repository:gopherstack/app:pull", + "", + http.StatusUnauthorized, + }, + {"empty token", basic(loginUser, ""), "", "", http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + status, reply := e.token(t, tt.authz, tt.scope) + require.Equal(t, tt.wantCode, status) + + if status != http.StatusOK { + return + } + + assert.NotEmpty(t, reply.Token) + assert.Equal(t, reply.Token, reply.AccessToken) + assert.Equal(t, 3600, reply.ExpiresIn) + assert.Equal(t, tt.wantScope, reply.Scope) + assert.NotContains(t, reply.Token, loginToken) + }) + } +} + +func TestTokenFlowPushPullAndScope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + _, push := e.token(t, basic(loginUser, loginToken), "repository:gopherstack/flow:pull,push") + pc := e.bearer(t, push.Token) + img := pc.PushImage(testNamespace+"/flow", "v1") + + _, pull := e.token(t, basic(loginUser, loginToken), "repository:gopherstack/flow:pull") + rc := e.bearer(t, pull.Token) + + got := rc.Do(http.MethodGet, "/v2/"+testNamespace+"/flow/manifests/v1", nil, nil) + require.Equal(t, http.StatusOK, got.Status) + assert.Equal(t, img.Body, got.Body) + + denied := rc.Do(http.MethodPost, "/v2/"+testNamespace+"/flow/blobs/uploads/", nil, nil) + assert.Equal(t, http.StatusUnauthorized, denied.Status) + assert.Contains(t, denied.Header.Get("WWW-Authenticate"), `error="insufficient_scope"`) + + other := rc.Do(http.MethodGet, "/v2/"+testNamespace+"/elsewhere/manifests/v1", nil, nil) + assert.Equal(t, http.StatusUnauthorized, other.Status) + + tampered := e.bearer(t, push.Token+"x") + assert.Equal(t, http.StatusUnauthorized, tampered.Do(http.MethodGet, "/v2/", nil, nil).Status) +} + +func TestTokenExpires(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + _, reply := e.token(t, basic(loginUser, loginToken), "repository:gopherstack/x:pull") + c := e.bearer(t, reply.Token) + + assert.Equal(t, http.StatusOK, c.Do(http.MethodGet, "/v2/", nil, nil).Status) + + e.clock.Advance(3601 * 1_000_000_000) + + assert.Equal(t, http.StatusUnauthorized, c.Do(http.MethodGet, "/v2/", nil, nil).Status) +} + +func TestAutoCreateOnFirstPush(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.registry(t) + c.PushImage(testNamespace+"/team/new-app", "v1") + + resp, err := e.client.ListContainerRepositories(t.Context(), artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(ociartifacts.DefaultTenancy), + }) + require.NoError(t, err) + require.Len(t, resp.Items, 1) + + repo := resp.Items[0] + assert.Equal(t, "team/new-app", *repo.DisplayName) + assert.Equal(t, 1, *repo.ImageCount) + assert.Equal(t, 1, *repo.LayerCount) + assert.Positive(t, *repo.LayersSizeInBytes) + assert.False(t, *repo.IsPublic) + + full, err := e.client.GetContainerRepository( + t.Context(), + artifacts.GetContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + assert.Equal(t, "alice@example.com", *full.CreatedBy) + assert.NotNil(t, full.TimeLastPushed) +} + +func TestFirstPushCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + repo string + wantCode string + wantStatus int + wantRepos int + createOff bool + }{ + {name: "creates when enabled", repo: testNamespace + "/fresh", wantStatus: http.StatusAccepted, wantRepos: 1}, + { + name: "denied when disabled", createOff: true, repo: testNamespace + "/fresh", + wantStatus: http.StatusForbidden, wantCode: "DENIED", + }, + { + name: "invalid name", repo: testNamespace + "/Fresh", + wantStatus: http.StatusBadRequest, wantCode: "NAME_INVALID", + }, + { + name: "wrong namespace", repo: "someone-else/fresh", + wantStatus: http.StatusNotFound, wantCode: "NAME_UNKNOWN", + }, + {name: "no namespace", repo: "fresh", wantStatus: http.StatusNotFound, wantCode: "NAME_UNKNOWN"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + if tt.createOff { + _, err := e.client.UpdateContainerConfiguration( + t.Context(), + artifacts.UpdateContainerConfigurationRequest{ + CompartmentId: new(testCompartment), + IsRepositoryCreatedOnFirstPush: new(false), + }, + ) + require.NoError(t, err) + } + + resp := e.registry(t).Do(http.MethodPost, "/v2/"+tt.repo+"/blobs/uploads/", nil, nil) + require.Equal(t, tt.wantStatus, resp.Status, string(resp.Body)) + + if tt.wantCode != "" { + assert.Contains(t, string(resp.Body), tt.wantCode) + } + + list, err := e.client.ListContainerRepositories(t.Context(), artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(ociartifacts.DefaultTenancy), + }) + require.NoError(t, err) + assert.Len(t, list.Items, tt.wantRepos) + }) + } + + t.Run("pull of unknown repo does not create it", func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + resp := e.registry(t).Do(http.MethodGet, "/v2/"+testNamespace+"/ghost/manifests/latest", nil, nil) + assert.Equal(t, http.StatusNotFound, resp.Status) + assert.Contains(t, string(resp.Body), "NAME_UNKNOWN") + + list, err := e.client.ListContainerRepositories(t.Context(), artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(ociartifacts.DefaultTenancy), + }) + require.NoError(t, err) + assert.Empty(t, list.Items) + }) + + t.Run("catalog is not offered", func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + assert.Equal(t, http.StatusNotFound, e.registry(t).Do(http.MethodGet, "/v2/_catalog", nil, nil).Status) + }) +} + +func TestImmutableRepository(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "locked", func(d *artifacts.CreateContainerRepositoryDetails) { d.IsImmutable = new(true) }) + + c := e.registry(t) + repo := testNamespace + "/locked" + first := c.PushImage(repo, "v1") + + // A different image under the same tag is rejected. + layer := c.PushBlob(repo, []byte("other layer"), 0) + cfg := c.PushBlob(repo, []byte(`{"architecture":"amd64","os":"linux"}`), 0) + other := manifestFor(t, cfg, layer, 11, 36) + + denied := c.Do( + http.MethodPut, + "/v2/"+repo+"/manifests/v1", + other, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ) + assert.Equal(t, http.StatusConflict, denied.Status) + assert.Contains(t, string(denied.Body), "TAG_INVALID") + + still := c.Do(http.MethodGet, "/v2/"+repo+"/manifests/v1", nil, nil) + assert.Equal(t, first.Body, still.Body) + + // The same image again is idempotent, a new tag and a digest push are allowed. + same := c.Do( + http.MethodPut, + "/v2/"+repo+"/manifests/v1", + first.Body, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ) + assert.Equal(t, http.StatusCreated, same.Status) + + fresh := c.Do( + http.MethodPut, + "/v2/"+repo+"/manifests/v2", + other, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ) + assert.Equal(t, http.StatusCreated, fresh.Status) + + byDigest := c.Do( + http.MethodPut, + "/v2/"+repo+"/manifests/"+ocitest.Digest(other), + other, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ) + assert.Equal(t, http.StatusCreated, byDigest.Status) + + assert.Len(t, e.listImages(t, nil), 2) +} + +func TestMutableRepositoryMovesTag(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "movable", nil) + + c := e.registry(t) + repo := testNamespace + "/movable" + c.PushImage(repo, "latest") + e.clock.Advance(1_000_000_000) + + layer := c.PushBlob(repo, []byte("newer layer"), 0) + cfg := c.PushBlob(repo, []byte(`{"architecture":"arm64","os":"linux"}`), 0) + newer := manifestFor(t, cfg, layer, 11, 37) + + put := c.Do( + http.MethodPut, + "/v2/"+repo+"/manifests/latest", + newer, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ) + require.Equal(t, http.StatusCreated, put.Status) + + images := e.listImages(t, nil) + require.Len(t, images, 2) + + byVersion := e.listImages(t, func(r *artifacts.ListContainerImagesRequest) { r.Version = new("latest") }) + require.Len(t, byVersion, 1) + assert.Equal(t, ocitest.Digest(newer), *byVersion[0].Digest) + + untagged := e.listImages(t, func(r *artifacts.ListContainerImagesRequest) { r.IsVersioned = new(false) }) + require.Len(t, untagged, 1) + assert.Nil(t, untagged[0].Version) + assert.Contains(t, *untagged[0].DisplayName, "movable:latest@sha256:") +} + +func TestPublicRepositoryAnonymousPull(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "open", func(d *artifacts.CreateContainerRepositoryDetails) { d.IsPublic = new(true) }) + e.createRepo(t, "closed", nil) + + rc := e.registry(t) + open := rc.PushImage(testNamespace+"/open", "v1") + rc.PushImage(testNamespace+"/closed", "v1") + + _, anon := e.token(t, "", "repository:gopherstack/open:pull,push") + ac := e.bearer(t, anon.Token) + + tests := []struct { + name string + client ocitest.Client + method string + path string + want int + }{ + {"anonymous pulls public manifest", ac, http.MethodGet, "/v2/gopherstack/open/manifests/v1", http.StatusOK}, + {"anonymous heads public manifest", ac, http.MethodHead, "/v2/gopherstack/open/manifests/v1", http.StatusOK}, + {"anonymous lists public tags", ac, http.MethodGet, "/v2/gopherstack/open/tags/list", http.StatusOK}, + {"anonymous cannot push", ac, http.MethodPost, "/v2/gopherstack/open/blobs/uploads/", http.StatusUnauthorized}, + { + "anonymous cannot delete", + ac, + http.MethodDelete, + "/v2/gopherstack/open/manifests/" + open.Digest, + http.StatusUnauthorized, + }, + { + "anonymous cannot pull private", + ac, + http.MethodGet, + "/v2/gopherstack/closed/manifests/v1", + http.StatusUnauthorized, + }, + { + "anonymous cannot probe unknown", + ac, + http.MethodGet, + "/v2/gopherstack/ghost/manifests/v1", + http.StatusUnauthorized, + }, + { + "no credentials at all", + e.anonymous(t), + http.MethodGet, + "/v2/gopherstack/open/manifests/v1", + http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.client.T = t + assert.Equal(t, tt.want, tt.client.Do(tt.method, tt.path, nil, nil).Status) + }) + } + + t.Run("anonymous blob pull", func(t *testing.T) { + t.Parallel() + + ac.T = t + resp := ac.Do(http.MethodGet, "/v2/gopherstack/open/blobs/"+ocitest.Digest(open.Layer), nil, nil) + require.Equal(t, http.StatusOK, resp.Status) + assert.Equal(t, open.Layer, resp.Body) + }) + + t.Run("making the repository private revokes access", func(t *testing.T) { + t.Parallel() + + e2 := newEnv(t, envOpts{}) + repo := e2.createRepo(t, "flip", func(d *artifacts.CreateContainerRepositoryDetails) { d.IsPublic = new(true) }) + e2.registry(t).PushImage(testNamespace+"/flip", "v1") + + _, tok := e2.token(t, "", "repository:gopherstack/flip:pull") + c := e2.bearer(t, tok.Token) + assert.Equal(t, http.StatusOK, c.Do(http.MethodGet, "/v2/gopherstack/flip/manifests/v1", nil, nil).Status) + + _, err := e2.client.UpdateContainerRepository(t.Context(), artifacts.UpdateContainerRepositoryRequest{ + RepositoryId: repo.Id, + IsPublic: new(false), + }) + require.NoError(t, err) + + assert.Equal( + t, + http.StatusUnauthorized, + c.Do(http.MethodGet, "/v2/gopherstack/flip/manifests/v1", nil, nil).Status, + ) + }) +} + +func TestImageListingAndDetails(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + repo := e.createRepo(t, "svc/web", nil) + c := e.registry(t) + name := testNamespace + "/svc/web" + + img := c.PushImage(name, "v1") + + // A second tag on the same manifest is one image with two versions. + e.clock.Advance(1_000_000_000) + + put := c.Do( + http.MethodPut, + "/v2/"+name+"/manifests/stable", + img.Body, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ) + require.Equal(t, http.StatusCreated, put.Status) + + images := e.listImages(t, nil) + require.Len(t, images, 1) + + sum := images[0] + assert.Equal(t, ocitest.Digest(img.Body), *sum.Digest) + assert.Equal(t, "svc/web:stable", *sum.DisplayName) + assert.Equal(t, "stable", *sum.Version) + assert.Equal(t, "svc/web", *sum.RepositoryName) + assert.Equal(t, *repo.Id, *sum.RepositoryId) + assert.Equal(t, testCompartment, *sum.CompartmentId) + assert.Equal(t, artifacts.ContainerImageLifecycleStateAvailable, sum.LifecycleState) + + full, err := e.client.GetContainerImage(t.Context(), artifacts.GetContainerImageRequest{ImageId: sum.Id}) + require.NoError(t, err) + require.Len(t, full.Versions, 2) + assert.Equal(t, "v1", *full.Versions[0].Version) + assert.Equal(t, "stable", *full.Versions[1].Version) + assert.Equal(t, "alice@example.com", *full.Versions[0].CreatedBy) + require.Len(t, full.Layers, 1) + assert.Equal(t, ocitest.Digest(img.Layer), *full.Layers[0].Digest) + assert.EqualValues(t, len(img.Layer), *full.Layers[0].SizeInBytes) + assert.EqualValues(t, len(img.Layer), *full.LayersSizeInBytes) + assert.Equal(t, len(img.Body), *full.ManifestSizeInBytes) + assert.Zero(t, *full.PullCount) + assert.NotNil(t, full.Etag) + + filters := []struct { + mut func(*artifacts.ListContainerImagesRequest) + name string + want int + }{ + {func(r *artifacts.ListContainerImagesRequest) { r.RepositoryId = repo.Id }, "repository id", 1}, + { + func(r *artifacts.ListContainerImagesRequest) { r.RepositoryId = new("ocid1.containerrepo.oc1..x") }, + "other repository id", + 0, + }, + { + func(r *artifacts.ListContainerImagesRequest) { r.RepositoryName = new("svc/*") }, + "repository name wildcard", + 1, + }, + {func(r *artifacts.ListContainerImagesRequest) { r.RepositoryName = new("other*") }, "repository name miss", 0}, + {func(r *artifacts.ListContainerImagesRequest) { r.Version = new("v1") }, "version exact", 1}, + {func(r *artifacts.ListContainerImagesRequest) { r.Version = new("st*") }, "version wildcard", 1}, + {func(r *artifacts.ListContainerImagesRequest) { r.ImageDigest = new(ocitest.Digest(img.Body)) }, "digest", 1}, + {func(r *artifacts.ListContainerImagesRequest) { r.DisplayName = new("svc/web:stable") }, "display name", 1}, + {func(r *artifacts.ListContainerImagesRequest) { r.ImageId = sum.Id }, "image id", 1}, + {func(r *artifacts.ListContainerImagesRequest) { r.IsVersioned = new(true) }, "versioned", 1}, + {func(r *artifacts.ListContainerImagesRequest) { r.IsVersioned = new(false) }, "unversioned", 0}, + {func(r *artifacts.ListContainerImagesRequest) { r.CompartmentId = new(otherCompart) }, "other compartment", 0}, + } + + for _, f := range filters { + t.Run(f.name, func(t *testing.T) { + t.Parallel() + + assert.Len(t, e.listImages(t, f.mut), f.want) + }) + } + + got, err := e.client.GetContainerRepository( + t.Context(), + artifacts.GetContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + assert.Equal(t, 1, *got.ImageCount) + assert.Equal(t, 1, *got.LayerCount) + assert.EqualValues(t, len(img.Layer), *got.LayersSizeInBytes) +} + +func TestPullCountAndLastPulled(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "pulls", nil) + + c := e.registry(t) + name := testNamespace + "/pulls" + c.PushImage(name, "v1") + + for range 3 { + require.Equal(t, http.StatusOK, c.Do(http.MethodGet, "/v2/"+name+"/manifests/v1", nil, nil).Status) + } + + // HEAD is not a pull. + require.Equal(t, http.StatusOK, c.Do(http.MethodHead, "/v2/"+name+"/manifests/v1", nil, nil).Status) + + images := e.listImages(t, nil) + require.Len(t, images, 1) + + full, err := e.client.GetContainerImage(t.Context(), artifacts.GetContainerImageRequest{ImageId: images[0].Id}) + require.NoError(t, err) + assert.EqualValues(t, 3, *full.PullCount) + require.NotNil(t, full.TimeLastPulled) + assert.WithinDuration(t, e.clock.Now(), full.TimeLastPulled.Time, time.Second) +} + +func TestImageDeleteRestoreRemoveVersion(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "lifecycle", nil) + + c := e.registry(t) + name := testNamespace + "/lifecycle" + c.PushImage(name, "v1") + + images := e.listImages(t, nil) + require.Len(t, images, 1) + + id := images[0].Id + digest := *images[0].Digest + + // Add a second tag, then remove it again. + body := c.Do(http.MethodGet, "/v2/"+name+"/manifests/v1", nil, nil).Body + require.Equal( + t, + http.StatusCreated, + c.Do( + http.MethodPut, + "/v2/"+name+"/manifests/extra", + body, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ).Status, + ) + + removed, err := e.client.RemoveContainerVersion(t.Context(), artifacts.RemoveContainerVersionRequest{ + ImageId: id, Version: new("extra"), + }) + require.NoError(t, err) + require.Len(t, removed.Versions, 1) + assert.Equal(t, "v1", *removed.Version) + assert.Equal(t, http.StatusNotFound, c.Do(http.MethodGet, "/v2/"+name+"/manifests/extra", nil, nil).Status, + "a removed version no longer resolves") + assert.Equal(t, http.StatusOK, c.Do(http.MethodGet, "/v2/"+name+"/manifests/v1", nil, nil).Status) + + _, err = e.client.RemoveContainerVersion(t.Context(), artifacts.RemoveContainerVersionRequest{ + ImageId: id, Version: new("nope"), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.RestoreContainerImage(t.Context(), artifacts.RestoreContainerImageRequest{ImageId: id}) + assert.Equal(t, "IncorrectState", svcErr(t, err).GetCode()) + + // Delete hides the image and stops pulls. + _, err = e.client.DeleteContainerImage( + t.Context(), + artifacts.DeleteContainerImageRequest{ImageId: id, IfMatch: new("stale")}, + ) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteContainerImage(t.Context(), artifacts.DeleteContainerImageRequest{ImageId: id}) + require.NoError(t, err) + + gone, err := e.client.GetContainerImage(t.Context(), artifacts.GetContainerImageRequest{ImageId: id}) + require.NoError(t, err) + assert.Equal(t, artifacts.ContainerImageLifecycleStateDeleted, gone.LifecycleState) + assert.Empty(t, e.listImages(t, nil)) + assert.Len( + t, + e.listImages(t, func(r *artifacts.ListContainerImagesRequest) { r.LifecycleState = new("DELETED") }), + 1, + ) + + for _, ref := range []string{"v1", digest} { + resp := c.Do(http.MethodGet, "/v2/"+name+"/manifests/"+ref, nil, nil) + assert.Equal(t, http.StatusNotFound, resp.Status, ref) + assert.Contains(t, string(resp.Body), "MANIFEST_UNKNOWN") + } + + _, err = e.client.DeleteContainerImage(t.Context(), artifacts.DeleteContainerImageRequest{ImageId: id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + repos, err := e.client.ListContainerRepositories(t.Context(), artifacts.ListContainerRepositoriesRequest{ + CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, repos.Items, 1) + assert.Zero(t, *repos.Items[0].ImageCount) + + // Restore under a new version. + restored, err := e.client.RestoreContainerImage(t.Context(), artifacts.RestoreContainerImageRequest{ + ImageId: id, Version: new("back"), + }) + require.NoError(t, err) + assert.Equal(t, artifacts.ContainerImageLifecycleStateAvailable, restored.LifecycleState) + assert.Equal(t, "back", *restored.Version) + + for _, ref := range []string{"back", "v1", digest} { + assert.Equal(t, http.StatusOK, c.Do(http.MethodGet, "/v2/"+name+"/manifests/"+ref, nil, nil).Status, ref) + } +} + +func TestLookupContainerImageByURI(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + img := e.registry(t).PushImage(testNamespace+"/team/lookup", "v1") + digest := ocitest.Digest(img.Body) + + tests := []struct { + name string + uri string + want int + }{ + {"by tag", testNamespace + "/team/lookup:v1", http.StatusOK}, + {"by digest", testNamespace + "/team/lookup@" + digest, http.StatusOK}, + {"unknown tag", testNamespace + "/team/lookup:v9", http.StatusNotFound}, + {"unknown repo", testNamespace + "/team/none:v1", http.StatusNotFound}, + {"wrong namespace", "other/team/lookup:v1", http.StatusNotFound}, + {"no reference", testNamespace + "/team/lookup", http.StatusNotFound}, + {"empty", "", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.client.LookupContainerImageByUri(t.Context(), artifacts.LookupContainerImageByUriRequest{ + ImageUri: new(tt.uri), + }) + if tt.want == http.StatusOK { + require.NoError(t, err) + assert.Equal(t, digest, *resp.Digest) + + return + } + + assert.Equal(t, tt.want, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} + +func TestUpdateImageTags(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "tagged", nil) + e.registry(t).PushImage(testNamespace+"/tagged", "v1") + + images := e.listImages(t, nil) + require.Len(t, images, 1) + + upd, err := e.client.UpdateContainerImage(t.Context(), artifacts.UpdateContainerImageRequest{ + ImageId: images[0].Id, + FreeformTags: map[string]string{"scan": "clean"}}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"scan": "clean"}, upd.FreeformTags) + + _, err = e.client.UpdateContainerImage(t.Context(), artifacts.UpdateContainerImageRequest{ + ImageId: images[0].Id, IfMatch: new("stale"), + UpdateContainerImageDetails: artifacts.UpdateContainerImageDetails{}, + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestRegistryDeleteUpdatesListing(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "dp", nil) + + c := e.registry(t) + name := testNamespace + "/dp" + img := c.PushImage(name, "v1") + + require.Len(t, e.listImages(t, nil), 1) + + del := c.Do(http.MethodDelete, "/v2/"+name+"/manifests/"+ocitest.Digest(img.Body), nil, nil) + require.Equal(t, http.StatusAccepted, del.Status, string(del.Body)) + assert.Empty(t, e.listImages(t, nil)) + + // Deleting from a repository that does not exist is unknown, and never creates it. + miss := c.Do(http.MethodDelete, "/v2/"+testNamespace+"/nothere/manifests/"+ocitest.Digest(img.Body), nil, nil) + assert.Equal(t, http.StatusNotFound, miss.Status) +} + +func TestDeleteRepositoryRemovesImages(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + repo := e.createRepo(t, "doomed", nil) + c := e.registry(t) + name := testNamespace + "/doomed" + img := c.PushImage(name, "v1") + + _, err := e.client.DeleteContainerRepository( + t.Context(), + artifacts.DeleteContainerRepositoryRequest{RepositoryId: repo.Id}, + ) + require.NoError(t, err) + + assert.Empty(t, e.listImages(t, nil)) + assert.Equal(t, http.StatusNotFound, c.Do(http.MethodGet, "/v2/"+name+"/manifests/v1", nil, nil).Status) + + // A new repository with the same name starts empty even though its name was used before. + e.createRepo(t, "doomed", nil) + assert.Equal( + t, + http.StatusNotFound, + c.Do(http.MethodGet, "/v2/"+name+"/manifests/"+ocitest.Digest(img.Body), nil, nil).Status, + ) +} + +func manifestFor(t *testing.T, cfgDigest, layerDigest string, layerSize, cfgSize int) []byte { + t.Helper() + + body, err := json.Marshal(map[string]any{ + "schemaVersion": 2, "mediaType": ocitest.ManifestMediaType, + "config": map[string]any{ + "mediaType": "application/vnd.docker.container.image.v1+json", "size": cfgSize, "digest": cfgDigest, + }, + "layers": []any{map[string]any{ + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "size": layerSize, "digest": layerDigest, + }}, + }) + require.NoError(t, err) + + return body +} + +func TestRegistryTagDeleteKeepsImage(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.createRepo(t, "untag", nil) + + c := e.registry(t) + name := testNamespace + "/untag" + img := c.PushImage(name, "v1") + + require.Equal( + t, + http.StatusCreated, + c.Do( + http.MethodPut, + "/v2/"+name+"/manifests/v2", + img.Body, + map[string]string{"Content-Type": ocitest.ManifestMediaType}, + ).Status, + ) + + del := c.Do(http.MethodDelete, "/v2/"+name+"/manifests/v2", nil, nil) + require.Equal(t, http.StatusAccepted, del.Status, string(del.Body)) + + images := e.listImages(t, nil) + require.Len(t, images, 1) + + full, err := e.client.GetContainerImage(t.Context(), artifacts.GetContainerImageRequest{ImageId: images[0].Id}) + require.NoError(t, err) + require.Len(t, full.Versions, 1) + assert.Equal(t, "v1", *full.Versions[0].Version) +} + +func TestCustomNamespace(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.backend.SetNamespace("TenantNS") + + c := ocitest.Client{T: t, Base: e.base, Prepare: func(r *http.Request) { + r.Header.Set("Authorization", basic("tenantns/alice", loginToken)) + }} + c.PushImage("tenantns/svc", "v1") + + resp, err := e.client.GetContainerConfiguration( + t.Context(), + artifacts.GetContainerConfigurationRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Equal(t, "TenantNS", *resp.Namespace) + assert.Equal(t, "TenantNS", e.backend.Namespace()) + + // The default namespace no longer logs in. + assert.Equal(t, http.StatusUnauthorized, e.registry(t).Do(http.MethodGet, "/v2/", nil, nil).Status) +} diff --git a/services/ociartifacts/repos.go b/services/ociartifacts/repos.go new file mode 100644 index 0000000000..fcf0f66c4c --- /dev/null +++ b/services/ociartifacts/repos.go @@ -0,0 +1,425 @@ +package ociartifacts + +import ( + "regexp" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + maxRepoName = 255 + bytesPerGB = 1_000_000_000 +) + +// repoNameRe is one path component of a repository name, as in the Docker distribution grammar. +var repoNameRe = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$`) + +type readmeIn struct { + Content *string `json:"content"` + Format string `json:"format"` +} + +type createRepoIn struct { + Tags + IsImmutable *bool `json:"isImmutable"` + IsPublic *bool `json:"isPublic"` + Readme *readmeIn `json:"readme"` + DisplayName string `json:"displayName"` + CompartmentID string `json:"compartmentId"` +} + +type updateRepoIn struct { + Tags + IsImmutable *bool `json:"isImmutable"` + IsPublic *bool `json:"isPublic"` + Readme *readmeIn `json:"readme"` +} + +type changeCompartmentIn struct { + CompartmentID string `json:"compartmentId"` +} + +func validRepoName(name string) *APIError { + if name == "" { + return errMissing("displayName is required") + } + + if len(name) > maxRepoName { + return errParam("displayName must be at most 255 characters") + } + + for part := range strings.SplitSeq(name, "/") { + if !repoNameRe.MatchString(part) { + return errParam("displayName must be lowercase path components of letters, digits, '.', '_' and '-' " + + "separated by '/'") + } + } + + return nil +} + +func toReadme(in *readmeIn) (*Readme, *APIError) { + if in == nil { + return nil, nil + } + + if in.Content == nil { + return nil, errMissing("readme.content is required") + } + + switch in.Format { + case readmeMarkdown, readmePlain: + return &Readme{Content: *in.Content, Format: in.Format}, nil + case "": + return nil, errMissing("readme.format is required") + } + + return nil, errParam("readme.format must be TEXT_MARKDOWN or TEXT_PLAIN") +} + +type repoStats struct { + images int + layers int + size int64 +} + +// stats totals the AVAILABLE images of a repository; shared layers count once. +func (b *InMemoryBackend) stats(repoID string) repoStats { + var s repoStats + + seen := map[string]struct{}{} + + for _, im := range b.images { + if im.RepoID != repoID || im.State != stateAvailable { + continue + } + + s.images++ + + for _, l := range im.Layers { + if _, dup := seen[l.Digest]; !dup { + seen[l.Digest] = struct{}{} + s.layers++ + s.size += l.Size + } + } + } + + return s +} + +func billableGBs(size int64) int64 { + return (size + bytesPerGB - 1) / bytesPerGB +} + +func (b *InMemoryBackend) repoView(r *Repo) repoWire { + s := b.stats(r.ID) + w := repoWire{ + TimeCreated: r.TimeCreated, TimeLastPushed: r.TimeLastPushed, CompartmentID: r.CompartmentID, + CreatedBy: r.CreatedBy, DisplayName: r.Name, ID: r.ID, LifecycleState: stateAvailable, Namespace: b.namespace, + LayersSizeInBytes: s.size, BillableSizeInGBs: billableGBs(s.size), ImageCount: s.images, LayerCount: s.layers, + IsImmutable: r.IsImmutable, IsPublic: r.IsPublic, FreeformTags: r.Tags.free(), DefinedTags: r.Tags.defined(), + SystemTags: map[string]map[string]any{}, + } + + if r.Readme != nil { + w.Readme = &readmeWire{Content: r.Readme.Content, Format: r.Readme.Format} + } + + return w +} + +func (b *InMemoryBackend) repoSummary(r *Repo) repoSummaryWire { + s := b.stats(r.ID) + + return repoSummaryWire{ + TimeCreated: r.TimeCreated, CompartmentID: r.CompartmentID, DisplayName: r.Name, ID: r.ID, + LifecycleState: stateAvailable, Namespace: b.namespace, LayersSizeInBytes: s.size, + BillableSizeInGBs: billableGBs(s.size), ImageCount: s.images, LayerCount: s.layers, IsPublic: r.IsPublic, + FreeformTags: r.Tags.free(), DefinedTags: r.Tags.defined(), SystemTags: map[string]map[string]any{}, + } +} + +func (b *InMemoryBackend) repoByName(name string) *Repo { + for _, r := range b.repos { + if strs.Equal(r.Name, name) { + return r + } + } + + return nil +} + +func (b *InMemoryBackend) repo(id string) (*Repo, *APIError) { + r, ok := b.repos[id] + if !ok { + return nil, errNotFound(id) + } + + return r, nil +} + +func (b *InMemoryBackend) newRepo(name, compartment, createdBy string) *Repo { + r := &Repo{ + ID: newOCID("containerrepo"), Name: name, CompartmentID: compartment, CreatedBy: createdBy, + TimeCreated: b.nowUTC(), Etag: newEtag(), Seq: b.nextSeq(), + } + b.repos[r.ID] = r + + return r +} + +// CreateContainerRepository creates a repository; createdBy is the signing user's OCID. +func (b *InMemoryBackend) CreateContainerRepository(in createRepoIn, createdBy string) (repoWire, string, error) { + readme, rerr := toReadme(in.Readme) + + b.mu.Lock("CreateContainerRepository") + defer b.mu.Unlock() + + if err := b.checkCompartment(in.CompartmentID); err != nil { + return repoWire{}, "", err + } + + if err := validRepoName(in.DisplayName); err != nil { + return repoWire{}, "", err + } + + if rerr != nil { + return repoWire{}, "", rerr + } + + if b.repoByName(in.DisplayName) != nil { + return repoWire{}, "", errConflict("A container repository named " + in.DisplayName + " already exists") + } + + r := b.newRepo(in.DisplayName, in.CompartmentID, createdBy) + r.IsImmutable, r.IsPublic = in.IsImmutable != nil && *in.IsImmutable, in.IsPublic != nil && *in.IsPublic + r.Readme, r.Tags = readme, in.Tags + + return b.repoView(r), r.Etag, nil +} + +// GetContainerRepository returns a repository and its etag. +func (b *InMemoryBackend) GetContainerRepository(id string) (repoWire, string, error) { + b.mu.RLock("GetContainerRepository") + defer b.mu.RUnlock() + + r, err := b.repo(id) + if err != nil { + return repoWire{}, "", err + } + + return b.repoView(r), r.Etag, nil +} + +type repoFilter struct { + isPublic *bool + compartment string + id string + name string + state string +} + +// ListContainerRepositories lists repositories of a compartment. +func (b *InMemoryBackend) ListContainerRepositories(f repoFilter, o ListOpts) (repoCollectionWire, string, error) { + if f.compartment == "" { + return repoCollectionWire{}, "", errMissing("compartmentId is required") + } + + b.mu.RLock("ListContainerRepositories") + defer b.mu.RUnlock() + + var all []*Repo + + for _, r := range b.repos { + if f.state != "" && !strings.EqualFold(f.state, stateAvailable) { + continue + } + + if b.inCompartment(r.CompartmentID, f.compartment, o.Subtree) && (f.id == "" || f.id == r.ID) && + (f.name == "" || strs.Equal(f.name, r.Name)) && (f.isPublic == nil || *f.isPublic == r.IsPublic) { + all = append(all, r) + } + } + + pg, remaining, err := paginate(all, o, func(r *Repo) sortKey { + return sortKey{created: r.TimeCreated, name: r.Name, seq: r.Seq} + }) + if err != nil { + return repoCollectionWire{}, "", err + } + + out := repoCollectionWire{Items: make([]repoSummaryWire, 0, len(pg.Data)), RemainingItemsCount: remaining} + + for _, r := range pg.Data { + out.Items = append(out.Items, b.repoSummary(r)) + } + + out.RepositoryCount = len(all) + b.addTotals(&out, all) + + return out, pg.Next, nil +} + +// addTotals fills the collection-wide image, layer and size counts over every matching repository. +func (b *InMemoryBackend) addTotals(out *repoCollectionWire, repos []*Repo) { + ids := map[string]struct{}{} + for _, r := range repos { + ids[r.ID] = struct{}{} + } + + seen := map[string]struct{}{} + + for _, im := range b.images { + if _, ok := ids[im.RepoID]; !ok || im.State != stateAvailable { + continue + } + + out.ImageCount++ + + for _, l := range im.Layers { + if _, dup := seen[l.Digest]; !dup { + seen[l.Digest] = struct{}{} + out.LayerCount++ + out.LayersSizeInBytes += l.Size + } + } + } +} + +// UpdateContainerRepository changes the mutable attributes of a repository. +func (b *InMemoryBackend) UpdateContainerRepository(id, ifMatch string, in updateRepoIn) (repoWire, string, error) { + readme, rerr := toReadme(in.Readme) + + b.mu.Lock("UpdateContainerRepository") + defer b.mu.Unlock() + + r, err := b.repo(id) + if err != nil { + return repoWire{}, "", err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return repoWire{}, "", err + } + + if rerr != nil { + return repoWire{}, "", rerr + } + + if in.IsImmutable != nil { + r.IsImmutable = *in.IsImmutable + } + + if in.IsPublic != nil { + r.IsPublic = *in.IsPublic + } + + if readme != nil { + r.Readme = readme + } + + r.Tags.merge(in.Tags) + r.Etag = newEtag() + + return b.repoView(r), r.Etag, nil +} + +// DeleteContainerRepository deletes a repository with its images and signatures, and their stored blobs. +func (b *InMemoryBackend) DeleteContainerRepository(id, ifMatch string) error { + b.mu.Lock("DeleteContainerRepository") + defer b.mu.Unlock() + + r, err := b.repo(id) + if err != nil { + return err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return err + } + + b.purgeRepoManifests(r) + + for imageID, im := range b.images { + if im.RepoID != r.ID { + continue + } + + for sid, s := range b.sigs { + if s.ImageID == imageID { + delete(b.sigs, sid) + } + } + + delete(b.images, imageID) + } + + delete(b.repos, id) + + return nil +} + +// ChangeContainerRepositoryCompartment moves a repository. +func (b *InMemoryBackend) ChangeContainerRepositoryCompartment(id, ifMatch string, in changeCompartmentIn) error { + b.mu.Lock("ChangeContainerRepositoryCompartment") + defer b.mu.Unlock() + + r, err := b.repo(id) + if err != nil { + return err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return err + } + + if err = b.checkCompartment(in.CompartmentID); err != nil { + return err + } + + r.CompartmentID, r.Etag = in.CompartmentID, newEtag() + + return nil +} + +type updateConfigIn struct { + IsRepositoryCreatedOnFirstPush *bool `json:"isRepositoryCreatedOnFirstPush"` +} + +// GetContainerConfiguration returns the tenancy's container configuration. +func (b *InMemoryBackend) GetContainerConfiguration(compartment string) (configWire, string, error) { + b.mu.RLock("GetContainerConfiguration") + defer b.mu.RUnlock() + + if err := b.checkCompartment(compartment); err != nil { + return configWire{}, "", err + } + + return configWire{Namespace: b.namespace, IsRepositoryCreatedOnFirstPush: b.createOnPush}, b.configEtag, nil +} + +// UpdateContainerConfiguration changes the container configuration. +func (b *InMemoryBackend) UpdateContainerConfiguration( + compartment, ifMatch string, in updateConfigIn, +) (configWire, string, error) { + b.mu.Lock("UpdateContainerConfiguration") + defer b.mu.Unlock() + + if err := b.checkCompartment(compartment); err != nil { + return configWire{}, "", err + } + + if err := checkEtag(b.configEtag, ifMatch); err != nil { + return configWire{}, "", err + } + + if in.IsRepositoryCreatedOnFirstPush != nil { + b.createOnPush = *in.IsRepositoryCreatedOnFirstPush + } + + b.configEtag = newEtag() + + return configWire{Namespace: b.namespace, IsRepositoryCreatedOnFirstPush: b.createOnPush}, b.configEtag, nil +} diff --git a/services/ociartifacts/route.go b/services/ociartifacts/route.go new file mode 100644 index 0000000000..ff97b9d993 --- /dev/null +++ b/services/ociartifacts/route.go @@ -0,0 +1,186 @@ +package ociartifacts + +import ( + "net/http" + "net/url" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateContainerRepo = "CreateContainerRepository" + opGetContainerRepo = "GetContainerRepository" + opListContainerRepos = "ListContainerRepositories" + opUpdateContainerRepo = "UpdateContainerRepository" + opDeleteContainerRepo = "DeleteContainerRepository" + opChangeContainerRepoC = "ChangeContainerRepositoryCompartment" + opListContainerImages = "ListContainerImages" + opGetContainerImage = "GetContainerImage" + opDeleteContainerImage = "DeleteContainerImage" + opUpdateContainerImage = "UpdateContainerImage" + opRestoreContainerImage = "RestoreContainerImage" + opRemoveContainerVer = "RemoveContainerVersion" + opLookupImageByURI = "LookupContainerImageByUri" + opCreateImageSignature = "CreateContainerImageSignature" + opGetImageSignature = "GetContainerImageSignature" + opListImageSignatures = "ListContainerImageSignatures" + opUpdateImageSignature = "UpdateContainerImageSignature" + opDeleteImageSignature = "DeleteContainerImageSignature" + opGetContainerConfig = "GetContainerConfiguration" + opUpdateContainerConfig = "UpdateContainerConfiguration" + opCreateRepository = "CreateRepository" + opGetRepository = "GetRepository" + opListRepositories = "ListRepositories" + opUpdateRepository = "UpdateRepository" + opDeleteRepository = "DeleteRepository" + opChangeRepositoryComp = "ChangeRepositoryCompartment" + opGetGenericArtifact = "GetGenericArtifact" + opGetGenericByPath = "GetGenericArtifactByPath" + opListGenericArtifacts = "ListGenericArtifacts" + opUpdateGenericArtifact = "UpdateGenericArtifact" + opUpdateGenericByPath = "UpdateGenericArtifactByPath" + opDeleteGenericArtifact = "DeleteGenericArtifact" + opDeleteGenericByPath = "DeleteGenericArtifactByPath" + opPutGenericContent = "PutGenericArtifactContentByPath" + opGetGenericContent = "GetGenericArtifactContent" + opGetGenericContentPath = "GetGenericArtifactContentByPath" + opGetAccessToken = "GetAccessToken" + opRegistry = "RegistryV2" + opAdmin = "Admin" + opUnknown = "Unknown" + adminPrefix = "/_gopherstack/" + registryPrefix = "/v2" + base = "/20160918" + tokenPath = "/20180419/docker/token" + containerRepoPath = base + "/container/repositories/{repositoryId}" + containerImagePath = base + "/container/images/{imageId}" + signaturePath = base + "/container/imageSignatures/{imageSignatureId}" + repositoryPath = base + "/repositories/{repositoryId}" + genericArtifactPath = base + "/generic/artifacts/{artifactId}" + genericRepoPrefix = base + "/generic/repositories/" + artifactPathsSegment = "/artifactPaths/" + versionsSegment = "/versions/" + contentSuffix = "/content" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/container/repositories", Op: opCreateContainerRepo}, + {Method: http.MethodGet, Path: base + "/container/repositories", Op: opListContainerRepos}, + {Method: http.MethodGet, Path: containerRepoPath, Op: opGetContainerRepo}, + {Method: http.MethodPut, Path: containerRepoPath, Op: opUpdateContainerRepo}, + {Method: http.MethodDelete, Path: containerRepoPath, Op: opDeleteContainerRepo}, + {Method: http.MethodPost, Path: containerRepoPath + "/actions/changeCompartment", Op: opChangeContainerRepoC}, + {Method: http.MethodGet, Path: base + "/container/images", Op: opListContainerImages}, + {Method: http.MethodPost, Path: base + "/container/images/actions/lookupImageByUri", Op: opLookupImageByURI}, + {Method: http.MethodGet, Path: containerImagePath, Op: opGetContainerImage}, + {Method: http.MethodPut, Path: containerImagePath, Op: opUpdateContainerImage}, + {Method: http.MethodDelete, Path: containerImagePath, Op: opDeleteContainerImage}, + {Method: http.MethodPost, Path: containerImagePath + "/actions/restore", Op: opRestoreContainerImage}, + {Method: http.MethodPost, Path: containerImagePath + "/actions/removeVersion", Op: opRemoveContainerVer}, + {Method: http.MethodPost, Path: base + "/container/imageSignatures", Op: opCreateImageSignature}, + {Method: http.MethodGet, Path: base + "/container/imageSignatures", Op: opListImageSignatures}, + {Method: http.MethodGet, Path: signaturePath, Op: opGetImageSignature}, + {Method: http.MethodPut, Path: signaturePath, Op: opUpdateImageSignature}, + {Method: http.MethodDelete, Path: signaturePath, Op: opDeleteImageSignature}, + {Method: http.MethodGet, Path: base + "/container/configuration", Op: opGetContainerConfig}, + {Method: http.MethodPut, Path: base + "/container/configuration", Op: opUpdateContainerConfig}, + {Method: http.MethodPost, Path: base + "/repositories", Op: opCreateRepository}, + {Method: http.MethodGet, Path: base + "/repositories", Op: opListRepositories}, + {Method: http.MethodGet, Path: repositoryPath, Op: opGetRepository}, + {Method: http.MethodPut, Path: repositoryPath, Op: opUpdateRepository}, + {Method: http.MethodDelete, Path: repositoryPath, Op: opDeleteRepository}, + {Method: http.MethodPost, Path: repositoryPath + "/actions/changeCompartment", Op: opChangeRepositoryComp}, + {Method: http.MethodGet, Path: base + "/generic/artifacts", Op: opListGenericArtifacts}, + {Method: http.MethodGet, Path: genericArtifactPath, Op: opGetGenericArtifact}, + {Method: http.MethodPut, Path: genericArtifactPath, Op: opUpdateGenericArtifact}, + {Method: http.MethodDelete, Path: genericArtifactPath, Op: opDeleteGenericArtifact}, + {Method: http.MethodGet, Path: genericArtifactPath + "/content", Op: opGetGenericContent}, + {Method: http.MethodGet, Path: tokenPath, Op: opGetAccessToken}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + + switch { + case strings.HasPrefix(path, adminPrefix): + return route{op: opAdmin} + case path == registryPrefix || strings.HasPrefix(path, registryPrefix+"/"): + return route{op: opRegistry} + } + + if rt, ok := parseGenericByPath(r.Method, path); ok { + return rt + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} + +// parseGenericByPath matches the by-path generic artifact routes. The SDK does not escape "/" in artifactPath, +// so the path is everything between /artifactPaths/ and the last /versions/. +func parseGenericByPath(method, escaped string) (route, bool) { + rest, ok := strings.CutPrefix(escaped, genericRepoPrefix) + if !ok { + return route{}, false + } + + repoID, rest, ok := strings.Cut(rest, artifactPathsSegment) + if !ok { + return route{}, false + } + + isContent := false + + if trimmed, found := strings.CutSuffix(rest, contentSuffix); found { + rest, isContent = trimmed, true + } + + i := strings.LastIndex(rest, versionsSegment) + if i < 0 { + return route{}, false + } + + params := map[string]string{} + + parts := map[string]string{ + "repositoryId": repoID, "artifactPath": rest[:i], "version": rest[i+len(versionsSegment):], + } + + for k, v := range parts { + dec, err := url.PathUnescape(v) + if err != nil || dec == "" { + return route{}, false + } + + params[k] = dec + } + + ops := map[string]string{ + http.MethodGet: opGetGenericByPath, + http.MethodPut: opUpdateGenericByPath, + http.MethodDelete: opDeleteGenericByPath, + } + if isContent { + ops = map[string]string{http.MethodGet: opGetGenericContentPath, http.MethodPut: opPutGenericContent} + } + + op, found := ops[method] + if !found { + return route{}, false + } + + return route{op: op, params: params}, true +} diff --git a/services/ociartifacts/settings.go b/services/ociartifacts/settings.go new file mode 100644 index 0000000000..2227c7bae2 --- /dev/null +++ b/services/ociartifacts/settings.go @@ -0,0 +1,13 @@ +package ociartifacts + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8166 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIArtifacts. +type Settings struct { + Port int `json:"port" env:"OCI_ARTIFACTS_PORT" default:"8166" name:"port" help:"Fixed TCP port for the dedicated OCI Artifacts and Container Registry listener (control plane, generic artifact content and Docker Registry v2); startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_ARTIFACTS_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned control-plane requests and signatures from API keys not registered with the emulator; off by default so any signing config works. Registry logins always need a namespace/user name and a non-empty auth token."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ociartifacts/signatures_test.go b/services/ociartifacts/signatures_test.go new file mode 100644 index 0000000000..2677c0e317 --- /dev/null +++ b/services/ociartifacts/signatures_test.go @@ -0,0 +1,280 @@ +package ociartifacts_test + +import ( + "encoding/base64" + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/artifacts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testKey = "ocid1.key.oc1.iad.abcdefghijklmnop" + testKeyVersion = "ocid1.keyversion.oc1.iad.qrstuvwxyz0123" +) + +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + +func (e *env) pushed(t *testing.T) artifacts.ContainerImageSummary { + t.Helper() + + e.createRepo(t, "signed", nil) + e.registry(t).PushImage(testNamespace+"/signed", "v1") + + images := e.listImages(t, nil) + require.Len(t, images, 1) + + return images[0] +} + +func sigDetails(imageID *string) artifacts.CreateContainerImageSignatureDetails { + return artifacts.CreateContainerImageSignatureDetails{ + CompartmentId: new(testCompartment), ImageId: imageID, KmsKeyId: new(testKey), + KmsKeyVersionId: new(testKeyVersion), Message: new(b64("payload")), Signature: new(b64("signature")), + SigningAlgorithm: artifacts.CreateContainerImageSignatureDetailsSigningAlgorithm256RsaPkcsPss, + } +} + +func TestCreateImageSignatureValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(d *artifacts.CreateContainerImageSignatureDetails) + wantCode string + wantHTTP int + }{ + {name: "valid"}, + {name: "sha224", mutate: func(d *artifacts.CreateContainerImageSignatureDetails) { + d.SigningAlgorithm = artifacts.CreateContainerImageSignatureDetailsSigningAlgorithm224RsaPkcsPss + }}, + {name: "sha512", mutate: func(d *artifacts.CreateContainerImageSignatureDetails) { + d.SigningAlgorithm = artifacts.CreateContainerImageSignatureDetailsSigningAlgorithm512RsaPkcsPss + }}, + {name: "empty algorithm", mutate: func(d *artifacts.CreateContainerImageSignatureDetails) { + d.SigningAlgorithm = "" + }, wantCode: "MissingParameter", wantHTTP: http.StatusBadRequest}, + {name: "message not base64", mutate: func(d *artifacts.CreateContainerImageSignatureDetails) { + d.Message = new("!!!") + }, wantCode: "InvalidParameter", wantHTTP: http.StatusBadRequest}, + {name: "signature not base64", mutate: func(d *artifacts.CreateContainerImageSignatureDetails) { + d.Signature = new("***") + }, wantCode: "InvalidParameter", wantHTTP: http.StatusBadRequest}, + {name: "no key", mutate: func(d *artifacts.CreateContainerImageSignatureDetails) { + d.KmsKeyId = new("") + }, wantCode: "MissingParameter", wantHTTP: http.StatusBadRequest}, + {name: "unknown image", mutate: func(d *artifacts.CreateContainerImageSignatureDetails) { + d.ImageId = new("ocid1.containerimage.oc1..nope") + }, wantCode: "NotAuthorizedOrNotFound", wantHTTP: http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + img := e.pushed(t) + + d := sigDetails(img.Id) + if tt.mutate != nil { + tt.mutate(&d) + } + + resp, err := e.client.CreateContainerImageSignature(t.Context(), + artifacts.CreateContainerImageSignatureRequest{CreateContainerImageSignatureDetails: d}) + if tt.wantCode != "" { + se := svcErr(t, err) + assert.Equal(t, tt.wantCode, se.GetCode()) + assert.Equal(t, tt.wantHTTP, se.GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + assert.Equal(t, artifacts.ContainerImageSignatureLifecycleStateAvailable, resp.LifecycleState) + assert.Equal(t, *img.Id, *resp.ImageId) + assert.Equal(t, string(d.SigningAlgorithm), string(resp.SigningAlgorithm)) + assert.Contains(t, *resp.DisplayName, "::"+string(d.SigningAlgorithm)+"::") + assert.Contains(t, *resp.DisplayName, "cdefghijklmnop"[len("cdefghijklmnop")-10:]) + }) + } +} + +func TestImageSignatureLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + img := e.pushed(t) + + created, err := e.client.CreateContainerImageSignature(t.Context(), artifacts.CreateContainerImageSignatureRequest{ + CreateContainerImageSignatureDetails: sigDetails(img.Id)}) + require.NoError(t, err) + assert.Equal(t, testUser, *created.CreatedBy) + + // The same signature again is a conflict. + _, err = e.client.CreateContainerImageSignature(t.Context(), artifacts.CreateContainerImageSignatureRequest{ + CreateContainerImageSignatureDetails: sigDetails(img.Id)}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + other := sigDetails(img.Id) + other.KmsKeyVersionId = new("ocid1.keyversion.oc1.iad.second0000000") + second, err := e.client.CreateContainerImageSignature(t.Context(), artifacts.CreateContainerImageSignatureRequest{ + CreateContainerImageSignatureDetails: other}) + require.NoError(t, err) + + got, err := e.client.GetContainerImageSignature(t.Context(), artifacts.GetContainerImageSignatureRequest{ + ImageSignatureId: created.Id}) + require.NoError(t, err) + assert.Equal(t, b64("signature"), *got.Signature) + + upd, err := e.client.UpdateContainerImageSignature(t.Context(), artifacts.UpdateContainerImageSignatureRequest{ + ImageSignatureId: created.Id, + FreeformTags: map[string]string{"verified": "yes"}, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{"verified": "yes"}, upd.FreeformTags) + + _, err = e.client.DeleteContainerImageSignature(t.Context(), artifacts.DeleteContainerImageSignatureRequest{ + ImageSignatureId: created.Id, IfMatch: new("stale")}) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteContainerImageSignature(t.Context(), artifacts.DeleteContainerImageSignatureRequest{ + ImageSignatureId: created.Id}) + require.NoError(t, err) + + _, err = e.client.GetContainerImageSignature( + t.Context(), + artifacts.GetContainerImageSignatureRequest{ImageSignatureId: created.Id}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + // Deleting the repository drops the remaining signature with its image. + repos, err := e.client.ListContainerRepositories( + t.Context(), + artifacts.ListContainerRepositoriesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + require.Len(t, repos.Items, 1) + + _, err = e.client.DeleteContainerRepository( + t.Context(), + artifacts.DeleteContainerRepositoryRequest{RepositoryId: repos.Items[0].Id}, + ) + require.NoError(t, err) + + _, err = e.client.GetContainerImageSignature( + t.Context(), + artifacts.GetContainerImageSignatureRequest{ImageSignatureId: second.Id}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestCreateImageSignatureRawAlgorithm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + alg string + }{ + {"unsupported", "SHA_256_RSA_PKCS1"}, + {"lowercase", "sha_256_rsa_pkcs_pss"}, + {"ecdsa", "SHA_256_ECDSA"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + img := e.pushed(t) + + body := jsonBody(t, map[string]string{ + "compartmentId": testCompartment, + "imageId": *img.Id, + "kmsKeyId": testKey, + "kmsKeyVersionId": testKeyVersion, + "message": b64("m"), + "signature": b64("s"), + "signingAlgorithm": tt.alg, + }) + status, out := rawDo(t, http.MethodPost, e.base+"/20160918/container/imageSignatures", body) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, out, "InvalidParameter") + }) + } +} + +func TestListImageSignatures(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + img := e.pushed(t) + + _, err := e.client.CreateContainerImageSignature(t.Context(), artifacts.CreateContainerImageSignatureRequest{ + CreateContainerImageSignatureDetails: sigDetails(img.Id)}) + require.NoError(t, err) + + other := sigDetails(img.Id) + other.KmsKeyVersionId = new("ocid1.keyversion.oc1.iad.second0000000") + second, err := e.client.CreateContainerImageSignature(t.Context(), artifacts.CreateContainerImageSignatureRequest{ + CreateContainerImageSignatureDetails: other}) + require.NoError(t, err) + + listTests := []struct { + mut func(*artifacts.ListContainerImageSignaturesRequest) + name string + want int + }{ + {nil, "all", 2}, + {func(r *artifacts.ListContainerImageSignaturesRequest) { r.ImageId = img.Id }, "image id", 2}, + { + func(r *artifacts.ListContainerImageSignaturesRequest) { r.ImageId = new("ocid1.containerimage.oc1..x") }, + "other image", + 0, + }, + { + func(r *artifacts.ListContainerImageSignaturesRequest) { r.KmsKeyVersionId = new(testKeyVersion) }, + "key version", + 1, + }, + { + func(r *artifacts.ListContainerImageSignaturesRequest) { r.RepositoryName = new("sig*") }, + "repository name", + 2, + }, + { + func(r *artifacts.ListContainerImageSignaturesRequest) { r.RepositoryName = new("zzz") }, + "repository name miss", + 0, + }, + {func(r *artifacts.ListContainerImageSignaturesRequest) { r.ImageDigest = img.Digest }, "digest", 2}, + {func(r *artifacts.ListContainerImageSignaturesRequest) { + r.SigningAlgorithm = artifacts.ListContainerImageSignaturesSigningAlgorithm256RsaPkcsPss + }, "algorithm", 2}, + {func(r *artifacts.ListContainerImageSignaturesRequest) { + r.SigningAlgorithm = artifacts.ListContainerImageSignaturesSigningAlgorithm512RsaPkcsPss + }, "algorithm miss", 0}, + { + func(r *artifacts.ListContainerImageSignaturesRequest) { r.DisplayName = second.DisplayName }, + "display name", + 1, + }, + } + + for _, tt := range listTests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := artifacts.ListContainerImageSignaturesRequest{CompartmentId: new(testCompartment)} + if tt.mut != nil { + tt.mut(&r) + } + + resp, listErr := e.client.ListContainerImageSignatures(t.Context(), r) + require.NoError(t, listErr) + assert.Len(t, resp.Items, tt.want) + }) + } +} diff --git a/services/ociartifacts/sigs.go b/services/ociartifacts/sigs.go new file mode 100644 index 0000000000..4c361138d8 --- /dev/null +++ b/services/ociartifacts/sigs.go @@ -0,0 +1,249 @@ +package ociartifacts + +import ( + "encoding/base64" + "slices" + "strings" +) + +const idSuffixLen = 10 + +type createSignatureIn struct { + Tags + CompartmentID string `json:"compartmentId"` + ImageID string `json:"imageId"` + KmsKeyID string `json:"kmsKeyId"` + KmsKeyVersionID string `json:"kmsKeyVersionId"` + Message string `json:"message"` + Signature string `json:"signature"` + SigningAlgorithm string `json:"signingAlgorithm"` +} + +//nolint:gochecknoglobals // immutable enum +var signingAlgorithms = []string{ + "SHA_224_RSA_PKCS_PSS", "SHA_256_RSA_PKCS_PSS", "SHA_384_RSA_PKCS_PSS", "SHA_512_RSA_PKCS_PSS", +} + +func lastN(s string, n int) string { + if len(s) <= n { + return s + } + + return s[len(s)-n:] +} + +func (s *Signature) displayName() string { + return lastN(s.KmsKeyID, idSuffixLen) + "::" + lastN(s.KmsKeyVersionID, idSuffixLen) + "::" + s.Algorithm + "::" + + lastN(s.ID, idSuffixLen) +} + +func (s *Signature) view() signatureWire { + return signatureWire{ + TimeCreated: s.TimeCreated, CompartmentID: s.CompartmentID, CreatedBy: s.CreatedBy, + DisplayName: s.displayName(), ID: s.ID, ImageID: s.ImageID, KmsKeyID: s.KmsKeyID, + KmsKeyVersionID: s.KmsKeyVersionID, Message: s.Message, Signature: s.Signature, + SigningAlgorithm: s.Algorithm, LifecycleState: stateAvailable, FreeformTags: s.Tags.free(), + DefinedTags: s.Tags.defined(), SystemTags: map[string]map[string]any{}, + } +} + +func validateSignature(in createSignatureIn) *APIError { + for name, v := range map[string]string{ + "compartmentId": in.CompartmentID, "imageId": in.ImageID, "kmsKeyId": in.KmsKeyID, + "kmsKeyVersionId": in.KmsKeyVersionID, "message": in.Message, "signature": in.Signature, + "signingAlgorithm": in.SigningAlgorithm, + } { + if v == "" { + return errMissing(name + " is required") + } + } + + alg := strings.ToUpper(in.SigningAlgorithm) + if alg != in.SigningAlgorithm || !slices.Contains(signingAlgorithms, alg) { + return errParam("signingAlgorithm must be one of " + strings.Join(signingAlgorithms, ", ")) + } + + for name, v := range map[string]string{"message": in.Message, "signature": in.Signature} { + if _, err := base64.StdEncoding.DecodeString(v); err != nil { + return errParam(name + " must be base64 encoded") + } + } + + return nil +} + +// CreateContainerImageSignature stores a signature for an image. +func (b *InMemoryBackend) CreateContainerImageSignature( + in createSignatureIn, createdBy string, +) (signatureWire, string, error) { + if err := validateSignature(in); err != nil { + return signatureWire{}, "", err + } + + b.mu.Lock("CreateContainerImageSignature") + defer b.mu.Unlock() + + if err := b.checkCompartment(in.CompartmentID); err != nil { + return signatureWire{}, "", err + } + + if _, _, err := b.availableImage(in.ImageID); err != nil { + return signatureWire{}, "", err + } + + for _, s := range b.sigs { + if s.ImageID == in.ImageID && s.KmsKeyID == in.KmsKeyID && s.KmsKeyVersionID == in.KmsKeyVersionID && + s.Algorithm == in.SigningAlgorithm && s.Signature == in.Signature && s.Message == in.Message { + return signatureWire{}, "", errConflict("The image already has an identical signature") + } + } + + s := &Signature{ + ID: newOCID("containerimagesignature"), ImageID: in.ImageID, CompartmentID: in.CompartmentID, + CreatedBy: createdBy, KmsKeyID: in.KmsKeyID, KmsKeyVersionID: in.KmsKeyVersionID, Message: in.Message, + Signature: in.Signature, Algorithm: in.SigningAlgorithm, TimeCreated: b.nowUTC(), Etag: newEtag(), + Seq: b.nextSeq(), Tags: in.Tags, + } + b.sigs[s.ID] = s + + return s.view(), s.Etag, nil +} + +func (b *InMemoryBackend) signature(id string) (*Signature, *APIError) { + s, ok := b.sigs[id] + if !ok { + return nil, errNotFound(id) + } + + return s, nil +} + +// GetContainerImageSignature returns a signature and its etag. +func (b *InMemoryBackend) GetContainerImageSignature(id string) (signatureWire, string, error) { + b.mu.RLock("GetContainerImageSignature") + defer b.mu.RUnlock() + + s, err := b.signature(id) + if err != nil { + return signatureWire{}, "", err + } + + return s.view(), s.Etag, nil +} + +type signatureFilter struct { + compartment string + imageID string + repoID string + repoName string + digest string + name string + kmsKeyID string + kmsVersion string + algorithm string +} + +func (f signatureFilter) match(b *InMemoryBackend, s *Signature, o ListOpts) bool { + return b.inCompartment(s.CompartmentID, f.compartment, o.Subtree) && f.matchOwn(s) && f.matchImage(b, s) +} + +func (f signatureFilter) matchOwn(s *Signature) bool { + return (f.imageID == "" || f.imageID == s.ImageID) && (f.kmsKeyID == "" || f.kmsKeyID == s.KmsKeyID) && + (f.kmsVersion == "" || f.kmsVersion == s.KmsKeyVersionID) && + (f.algorithm == "" || f.algorithm == s.Algorithm) && (f.name == "" || f.name == s.displayName()) +} + +func (f signatureFilter) matchImage(b *InMemoryBackend, s *Signature) bool { + if f.repoID == "" && f.repoName == "" && f.digest == "" { + return true + } + + im, ok := b.images[s.ImageID] + if !ok { + return false + } + + repo := b.repos[im.RepoID] + + return repo != nil && (f.repoID == "" || f.repoID == repo.ID) && (f.digest == "" || f.digest == im.Digest) && + (f.repoName == "" || matchWild(f.repoName, repo.Name)) +} + +// ListContainerImageSignatures lists the signatures of a compartment. +func (b *InMemoryBackend) ListContainerImageSignatures( + f signatureFilter, o ListOpts, +) (signatureCollectionWire, string, error) { + if f.compartment == "" { + return signatureCollectionWire{}, "", errMissing("compartmentId is required") + } + + if f.algorithm != "" && !slices.Contains(signingAlgorithms, f.algorithm) { + return signatureCollectionWire{}, "", errParam("signingAlgorithm is not a supported value") + } + + b.mu.RLock("ListContainerImageSignatures") + defer b.mu.RUnlock() + + var all []*Signature + + for _, s := range b.sigs { + if f.match(b, s, o) { + all = append(all, s) + } + } + + pg, remaining, err := paginate(all, o, func(s *Signature) sortKey { + return sortKey{created: s.TimeCreated, name: s.displayName(), seq: s.Seq} + }) + if err != nil { + return signatureCollectionWire{}, "", err + } + + out := signatureCollectionWire{Items: make([]signatureWire, 0, len(pg.Data)), RemainingItemsCount: remaining} + for _, s := range pg.Data { + out.Items = append(out.Items, s.view()) + } + + return out, pg.Next, nil +} + +// UpdateContainerImageSignature changes a signature's tags. +func (b *InMemoryBackend) UpdateContainerImageSignature( + id, ifMatch string, in updateTagsIn, +) (signatureWire, string, error) { + b.mu.Lock("UpdateContainerImageSignature") + defer b.mu.Unlock() + + s, err := b.signature(id) + if err != nil { + return signatureWire{}, "", err + } + + if err = checkEtag(s.Etag, ifMatch); err != nil { + return signatureWire{}, "", err + } + + s.Tags.merge(in.Tags) + s.Etag = newEtag() + + return s.view(), s.Etag, nil +} + +// DeleteContainerImageSignature deletes a signature. +func (b *InMemoryBackend) DeleteContainerImageSignature(id, ifMatch string) error { + b.mu.Lock("DeleteContainerImageSignature") + defer b.mu.Unlock() + + s, err := b.signature(id) + if err != nil { + return err + } + + if err = checkEtag(s.Etag, ifMatch); err != nil { + return err + } + + delete(b.sigs, id) + + return nil +} diff --git a/services/ociartifacts/store.go b/services/ociartifacts/store.go new file mode 100644 index 0000000000..db10945e47 --- /dev/null +++ b/services/ociartifacts/store.go @@ -0,0 +1,340 @@ +package ociartifacts + +import ( + "cmp" + "context" + "crypto/rand" + "maps" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/ociregistry" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "us-ashburn-1" + // DefaultNamespace is the tenancy namespace served unless overridden. + DefaultNamespace = "gopherstack" + // DefaultTenancy is the root compartment used for repositories created by a first push. + DefaultTenancy = "ocid1.tenancy.oc1..aaaaaaaagopherstackemulatedtenancy000000000000000000000000" + + defaultListLimit = 100 + maxListLimit = 1000 + ocidTailLen = 52 + secretBytes = 32 + registryDirName = "ociartifacts" + sortByName = "DISPLAYNAME" +) + +// InMemoryBackend holds every resource under one lock; image blobs live in an embedded registry. +type InMemoryBackend struct { + ctx context.Context //nolint:containedctx // lifetime of the lazily built registry + Auth *ociauth.Registry + compartment func(string) bool + within func(id, ancestor string) bool + mu *lockmetrics.RWMutex + now func() time.Time + raw http.Handler + app http.Handler + repos map[string]*Repo + images map[string]*Image + sigs map[string]*Signature + gens map[string]*GenericRepo + arts map[string]*GenericArtifact + namespace string + tenancy string + dataDir string + configEtag string + secret []byte + seq int64 + createOnPush bool +} + +// NewInMemoryBackend creates an empty backend that keeps image blobs in memory. +func NewInMemoryBackend() *InMemoryBackend { + secret := make([]byte, secretBytes) + _, _ = rand.Read(secret) // crypto/rand.Read never fails on supported platforms + + b := &InMemoryBackend{ + ctx: context.Background(), mu: lockmetrics.New("ociartifacts"), now: time.Now, Auth: ociauth.NewRegistry(), + namespace: DefaultNamespace, tenancy: DefaultTenancy, secret: secret, createOnPush: true, + configEtag: newEtag(), + } + b.initMaps() + + return b +} + +func (b *InMemoryBackend) initMaps() { + b.repos = map[string]*Repo{} + b.images = map[string]*Image{} + b.sigs = map[string]*Signature{} + b.gens = map[string]*GenericRepo{} + b.arts = map[string]*GenericArtifact{} +} + +// SetClock injects the time source for timestamps and token expiry. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetContext bounds the lifetime of the embedded registry. +func (b *InMemoryBackend) SetContext(ctx context.Context) { + b.mu.Lock("SetContext") + b.ctx = ctx + b.mu.Unlock() +} + +// SetDataDir keeps image blobs and manifests under dir; empty keeps them in memory. +func (b *InMemoryBackend) SetDataDir(dir string) { + b.mu.Lock("SetDataDir") + b.dataDir = dir + b.mu.Unlock() +} + +// SetNamespace overrides the tenancy namespace used in registry paths. +func (b *InMemoryBackend) SetNamespace(ns string) { + b.mu.Lock("SetNamespace") + b.namespace = ns + b.mu.Unlock() +} + +// SetTenancy sets the root compartment that owns repositories created by a first push. +func (b *InMemoryBackend) SetTenancy(id string) { + b.mu.Lock("SetTenancy") + b.tenancy = id + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// SetCompartmentTree tells the backend whether a compartment is nested below another (OCI Identity). +func (b *InMemoryBackend) SetCompartmentTree(fn func(id, ancestor string) bool) { + b.mu.Lock("SetCompartmentTree") + b.within = fn + b.mu.Unlock() +} + +// Namespace returns the tenancy namespace. +func (b *InMemoryBackend) Namespace() string { + b.mu.RLock("Namespace") + defer b.mu.RUnlock() + + return b.namespace +} + +// Reset clears all state and removes stored blobs. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.initMaps() + b.createOnPush, b.configEtag = true, newEtag() + b.releaseRegistry(true) + b.mu.Unlock() + b.Auth.Reset() +} + +// Close releases the embedded registry. +func (b *InMemoryBackend) Close() { + b.mu.Lock("Close") + b.releaseRegistry(false) + b.mu.Unlock() +} + +func (b *InMemoryBackend) releaseRegistry(purge bool) { + if b.raw != nil { + ociregistry.Shutdown(b.raw) + b.raw, b.app = nil, nil + } + + if purge && b.dataDir != "" { + _ = os.RemoveAll(filepath.Join(b.dataDir, registryDirName)) + } +} + +// registry returns the embedded Docker Registry v2 handler, building it on first use. +func (b *InMemoryBackend) registry() http.Handler { + b.mu.RLock("registry") + app := b.app + b.mu.RUnlock() + + if app != nil { + return app + } + + b.mu.Lock("registry") + defer b.mu.Unlock() + + return b.registryLocked() +} + +func (b *InMemoryBackend) registryLocked() http.Handler { + if b.app == nil { + dir := "" + if b.dataDir != "" { + dir = filepath.Join(b.dataDir, registryDirName) + } + + b.raw = ociregistry.New(b.ctx, ociregistry.Options{InstanceID: "gopherstack-ociartifacts", RootDir: dir}) + b.app = ociregistry.Limit(b.raw, 0) + } + + return b.app +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC().Truncate(time.Millisecond) } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func newEtag() string { return uuid.NewString() } + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +func (b *InMemoryBackend) inCompartment(have, want string, subtree bool) bool { + if have == want { + return true + } + + return subtree && b.within != nil && b.within(have, want) +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int + Subtree bool +} + +type sortKey struct { + created time.Time + name string + seq int64 +} + +// paginate sorts and slices items; it returns the page and how many matching items follow it. +func paginate[T any](items []T, o ListOpts, key func(T) sortKey) (page.Page[T], int, *APIError) { + by := strings.ToUpper(o.SortBy) + if by != "" && by != "TIMECREATED" && by != sortByName { + return page.Page[T]{}, 0, errParam("sortBy must be TIMECREATED or DISPLAYNAME") + } + + order := strings.ToUpper(o.Order) + if order != "" && order != "ASC" && order != "DESC" { + return page.Page[T]{}, 0, errParam("sortOrder must be ASC or DESC") + } + + if o.Limit < 0 || o.Limit > maxListLimit { + return page.Page[T]{}, 0, errParam("limit must be between 1 and 1000") + } + + if err := page.ValidateToken(o.Page); err != nil { + return page.Page[T]{}, 0, errParam("page is not a valid page token") + } + + desc := by != sortByName + if order != "" { + desc = order == "DESC" + } + + slices.SortStableFunc(items, func(a, c T) int { + ka, kc := key(a), key(c) + + var r int + if by == sortByName { + r = cmp.Or(cmp.Compare(ka.name, kc.name), cmp.Compare(ka.seq, kc.seq)) + } else { + r = cmp.Or(ka.created.Compare(kc.created), cmp.Compare(ka.seq, kc.seq)) + } + + if desc { + return -r + } + + return r + }) + + pg := page.New(items, o.Page, o.Limit, defaultListLimit) + remaining := max(0, len(items)-(page.DecodeToken(o.Page)+len(pg.Data))) + + return pg, remaining, nil +} + +// matchWild matches a filter value that may end in `*`. +func matchWild(pattern, value string) bool { + if prefix, ok := strings.CutSuffix(pattern, "*"); ok { + return strings.HasPrefix(value, prefix) + } + + return pattern == value +} + +func str(p *string) string { + if p == nil { + return "" + } + + return *p +} + +// Compartments lists the tenancy root and every compartment that holds a repository. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{b.tenancy: {}} + + for _, r := range b.repos { + seen[r.CompartmentID] = struct{}{} + } + + for _, g := range b.gens { + seen[g.CompartmentID] = struct{}{} + } + + return slices.Sorted(maps.Keys(seen)) +} diff --git a/services/ocicontainerengine/PARITY.md b/services/ocicontainerengine/PARITY.md new file mode 100644 index 0000000000..4f5a4f0870 --- /dev/null +++ b/services/ocicontainerengine/PARITY.md @@ -0,0 +1,46 @@ +--- +service: ocicontainerengine +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: 4b3a24b55 +last_audit_date: 2026-10-29 +overall: B +ops: + CreateCluster: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with opc-work-request-id and no body. CREATING then ACTIVE after the transition window on the injectable clock (OKE_ENGINE=docker: ACTIVE when the k3s API server answers). kubernetesVersion must be one of the supported versions (see GetClusterOptions); type BASIC_CLUSTER (default) or ENHANCED_CLUSTER; endpointConfig.subnetId, options.serviceLbSubnetIds and the VCN are checked against OCI Core when it runs in the same process (subnet must exist and sit in the cluster VCN; vcnId is derived from the subnet when omitted); kubernetesNetworkConfig pods/services CIDRs default to 10.244.0.0/16 and 10.96.0.0/16 and must not overlap; clusterPodNetworkOptions cniType FLANNEL_OVERLAY (default) or OCI_VCN_IP_NATIVE; kmsKeyId and imagePolicyConfig keys must be ENABLED Vault keys when Vault runs. OpenID Connect authentication and discovery options are rejected 400."} + GetCluster: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; availableKubernetesUpgrades, metadata work request ids, endpoints; DELETED clusters stay readable."} + ListClusters: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; name, repeated lifecycleState filters; sortBy ID, NAME, TIME_CREATED (default newest first); bare JSON array body with opc-next-page. DELETED clusters are listed only when lifecycleState asks for them."} + UpdateCluster: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request CLUSTER_UPDATE; cluster is UPDATING for the transition window and must be ACTIVE. name, kubernetesVersion (an entry of availableKubernetesUpgrades: newer, at most one minor ahead), BASIC to ENHANCED type change (reverse is 400), serviceLbConfig, persistentVolumeConfig, admissionControllerOptions, imagePolicyConfig and tags. If-Match gives 412 NoEtagMatch."} + DeleteCluster: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request CLUSTER_DELETE; DELETING then DELETED. Node pools are deleted with the cluster. Requires ACTIVE or FAILED. Engine container and ports are released."} + CreateKubeconfig: {wire: ok, errors: partial, state: ok, persist: ok, note: "Returns a YAML kubeconfig with the cluster endpoint, base64 CA and a static bearer token instead of the exec plugin (oci ce cluster generate-token) real OKE kubeconfigs use; the CA and token are generated at cluster creation and persisted, so repeated calls return identical credentials. In docker mode they are the k3s serving CA and the engine admin token. endpoint LEGACY_KUBERNETES, PUBLIC_ENDPOINT, PRIVATE_ENDPOINT (unavailable ones are 400; VCN_HOSTNAME is never available); unset prefers legacy, then public, then private. tokenVersion must be 2.0.0; expiration must be positive and is not enforced (the token does not expire)."} + ClusterMigrateToNativeVcn: {wire: ok, errors: partial, state: ok, persist: ok, note: "Only for legacy clusters (no endpointConfig); 202 with CLUSTER_UPDATE. decommissionDelayDuration is an ISO 8601 day duration up to P30D (default P30D). The legacy endpoint stays until the delay passes."} + GetClusterMigrateToNativeVcnStatus: {wire: ok, errors: ok, state: ok, persist: n/a, note: "NOT_STARTED, IN_PROGRESS, PENDING_DECOMMISSION (with timeDecommissionScheduled), COMPLETED."} + GetClusterOptions: {wire: ok, errors: ok, state: ok, persist: n/a, note: "clusterOptionId all; kubernetesVersions lists the newest patch per minor, or every supported patch with shouldListAllPatchVersions; clusterPodNetworkOptions lists both CNI types."} + CreateNodePool: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with NODEPOOL_CREATE; cluster must be ACTIVE (409 otherwise). nodeShape, nodeSourceDetails.imageId (or deprecated nodeImageName) and architecture compatibility are checked against the OCI Core shape and image catalogues; flexible shapes need nodeShapeConfig.ocpus (memory defaults to the shape's GB per OCPU). nodeConfigDetails.size (0 to 1000) with placementConfigs (availability domain + subnet in the cluster VCN, matching an AD-specific subnet) or the legacy subnetIds + quantityPerSubnet. kubernetesVersion defaults to the cluster version, may not be newer or more than three minors older. Pod network cniType must match the cluster; OCI_VCN_IP_NATIVE needs podSubnetIds. Nodes are created with unique private IPs from the placement subnet CIDR, spread over placements, CREATING then ACTIVE. secondaryVnics, primaryVnic, networkLaunchType and computeClusterId are rejected 400."} + GetNodePool: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; nodes with lifecycle state, availability domain, fault domain, subnet, private IP."} + ListNodePools: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; clusterId, name, lifecycleState filters; summaries without nodes."} + UpdateNodePool: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with NODEPOOL_UPDATE; pool UPDATING for the transition window. Name, version, labels, size (adds nodes to the least loaded placement or deletes the newest), placements, shape and shape config, image, ssh key, metadata, tags. If-Match gives 412."} + DeleteNodePool: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with NODEPOOL_DELETE; pool and nodes DELETING then DELETED."} + DeleteNode: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with a NODEPOOL_UPDATE work request (the operation type is unverified); isDecrementSize lowers nodeConfigDetails.size, otherwise a replacement node is created in the same placement."} + GetNodePoolOptions: {wire: ok, errors: ok, state: ok, persist: n/a, note: "nodePoolOptionId all or a cluster id (versions limited to what the cluster accepts); shapes and images from the OCI Core catalogue; nodePoolOsType (OL7, OL8, UBUNTU), nodePoolOsArch and nodePoolK8sVersion filters; sources carry sourceName and imageId. Empty shape and image lists without OCI Core."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS until the resource transition time, then SUCCEEDED (or FAILED when the docker engine could not start)."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; clusterId, resourceId, resourceType CLUSTER or NODEPOOL, repeated status filters; sortable; bare array body."} + ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Empty except for a failed engine start (code ClusterStartFailed)."} + ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Accepted and succeeded or failed entries."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-oke-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id. InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch and IncorrectState follow the OCI reference; the 400 used for references to unknown subnets, shapes, images and keys is unverified (the live service may answer 404)."} + lifecycle: {status: ok, note: "CREATING, UPDATING and DELETING last 2 seconds on the injectable backend clock and are applied on every request and by a one-minute sweeper."} + oci_core_integration: {status: ok, note: "internal/app wires a narrow Network interface onto the OCI Core backend (subnets, VCNs, shape and image catalogues) and kmsKeyId checks onto OCI Vault; standalone, subnet, image and key OCIDs are only shape-checked."} + docker_engine: {status: ok, note: "OKE_ENGINE=docker (--oci-oke-engine) reuses pkgs/k3sengine like EKS and GKE: CreateCluster starts a privileged single-node k3s container (image from the cluster version, OKE_K3S_IMAGE overrides), the cluster turns ACTIVE when the API server answers, endpoints, CA and the static admin token (OKE_CLUSTER_TOKEN, OKE_CLUSTER_HOST) come from it, delete and shutdown remove the container and release the port, and a restore relaunches every live cluster."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-oke-port / OCI_CONTAINER_ENGINE_PORT (default 8158); never on the shared AWS router; cli.go reserves the port in the shared pool."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} +items_still_open: + - "Virtual node pools and virtual nodes (CreateVirtualNodePool, GetVirtualNodePool, ListVirtualNodePools, UpdateVirtualNodePool, DeleteVirtualNodePool, GetVirtualNode, ListVirtualNodes) are not routed." + - "Add-ons (InstallAddon, GetAddon, ListAddons, UpdateAddon, DisableAddon, ListAddonOptions), workload mappings, pod shapes (ListPodShapes), credential rotation, public API endpoint decommission and endpoint-config updates (UpdateClusterEndpointConfig), RebootClusterNode, ReplaceBootVolumeClusterNode, DeleteWorkRequest and ExtendEndpointDecommissionRollbackDeadline are not routed." + - "Worker nodes are metadata: they get unique private IPs from the subnet CIDR and ocid1.instance identifiers but are not OCI Core instances and do not join the k3s cluster, so a docker-mode cluster has only the control plane node." + - "Kubeconfigs use a static bearer token, not the oci ce cluster generate-token exec plugin; tokenVersion and expiration are accepted but the token never expires." + - "Without OKE_ENGINE=docker the API server endpoints are documentation-range (or subnet CIDR) addresses nothing listens on; VCN_HOSTNAME endpoints and endpoint.vcnHostnameEndpoint are never populated." + - "OpenID Connect token authentication and discovery, secondaryVnics, primaryVnic, networkLaunchType, computeClusterId, preemptible/capacity-reservation scheduling effects, node eviction and cycling settings (stored and echoed only) are not applied." + - "Kubernetes version catalogue (v1.34.1, v1.34.2, v1.34.10, v1.35.2, v1.36.1, v1.36.4) is taken from the Oracle supported-versions page on 2026-10-29; the one-minor-ahead upgrade rule, the three-minor node pool skew and the node pool size cap of 1000 are recalled, not verified." + - "Default list ordering (newest first), the cascade deletion of node pools with their cluster, whether DELETED resources are listed by default, and the NODEPOOL_UPDATE operation type for DeleteNode are unverified against the live service." + - "opc-retry-token idempotency, service limits, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified." +--- diff --git a/services/ocicontainerengine/README.md b/services/ocicontainerengine/README.md new file mode 100644 index 0000000000..6127663a8c --- /dev/null +++ b/services/ocicontainerengine/README.md @@ -0,0 +1,31 @@ + +# OCI Container Engine for Kubernetes + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-29 (`4b3a24b55`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 20 (16 ok, 4 partial) | +| Feature families | 7 (6 ok, 1 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Virtual node pools and virtual nodes (CreateVirtualNodePool, GetVirtualNodePool, ListVirtualNodePools, UpdateVirtualNodePool, DeleteVirtualNodePool, GetVirtualNode, ListVirtualNodes) are not routed. +- Add-ons (InstallAddon, GetAddon, ListAddons, UpdateAddon, DisableAddon, ListAddonOptions), workload mappings, pod shapes (ListPodShapes), credential rotation, public API endpoint decommission and endpoint-config updates (UpdateClusterEndpointConfig), RebootClusterNode, ReplaceBootVolumeClusterNode, DeleteWorkRequest and ExtendEndpointDecommissionRollbackDeadline are not routed. +- Worker nodes are metadata: they get unique private IPs from the subnet CIDR and ocid1.instance identifiers but are not OCI Core instances and do not join the k3s cluster, so a docker-mode cluster has only the control plane node. +- Kubeconfigs use a static bearer token, not the oci ce cluster generate-token exec plugin; tokenVersion and expiration are accepted but the token never expires. +- Without OKE_ENGINE=docker the API server endpoints are documentation-range (or subnet CIDR) addresses nothing listens on; VCN_HOSTNAME endpoints and endpoint.vcnHostnameEndpoint are never populated. +- OpenID Connect token authentication and discovery, secondaryVnics, primaryVnic, networkLaunchType, computeClusterId, preemptible/capacity-reservation scheduling effects, node eviction and cycling settings (stored and echoed only) are not applied. +- Kubernetes version catalogue (v1.34.1, v1.34.2, v1.34.10, v1.35.2, v1.36.1, v1.36.4) is taken from the Oracle supported-versions page on 2026-10-29; the one-minor-ahead upgrade rule, the three-minor node pool skew and the node pool size cap of 1000 are recalled, not verified. +- Default list ordering (newest first), the cascade deletion of node pools with their cluster, whether DELETED resources are listed by default, and the NODEPOOL_UPDATE operation type for DeleteNode are unverified against the live service. +- opc-retry-token idempotency, service limits, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocicontainerengine/cluster_test.go b/services/ocicontainerengine/cluster_test.go new file mode 100644 index 0000000000..2aad123f36 --- /dev/null +++ b/services/ocicontainerengine/cluster_test.go @@ -0,0 +1,679 @@ +package ocicontainerengine_test + +import ( + "net/http" + "testing" + "time" + + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClusterLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + ctx := t.Context() + + created, err := e.ce.CreateCluster(ctx, ke.CreateClusterRequest{ + CreateClusterDetails: func() ke.CreateClusterDetails { + d := clusterDetails("life") + d.FreeformTags = map[string]string{"env": "test"} + d.Type = ke.ClusterTypeEnhancedCluster + + return d + }(), + }) + require.NoError(t, err) + + wr, err := e.ce.GetWorkRequest(ctx, ke.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestStatusInProgress, wr.Status) + assert.Equal(t, ke.WorkRequestOperationTypeClusterCreate, wr.OperationType) + + list, err := e.ce.ListClusters(ctx, ke.ListClustersRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, ke.ClusterLifecycleStateCreating, list.Items[0].LifecycleState) + + e.settle() + + got, err := e.ce.GetCluster(ctx, ke.GetClusterRequest{ClusterId: list.Items[0].Id}) + require.NoError(t, err) + c := got.Cluster + assert.Equal(t, ke.ClusterLifecycleStateActive, c.LifecycleState) + assert.Equal(t, ke.ClusterTypeEnhancedCluster, c.Type) + assert.Equal(t, "test", c.FreeformTags["env"]) + assert.Equal(t, testVersion, *c.KubernetesVersion) + assert.Equal(t, vcnA, *c.VcnId) + assert.Equal(t, []string{"v1.34.10", "v1.35.2"}, c.AvailableKubernetesUpgrades) + assert.Equal(t, "10.244.0.0/16", *c.Options.KubernetesNetworkConfig.PodsCidr) + assert.Equal(t, "10.96.0.0/16", *c.Options.KubernetesNetworkConfig.ServicesCidr) + assert.False(t, *c.Options.AddOns.IsTillerEnabled) + require.Len(t, c.ClusterPodNetworkOptions, 1) + assert.Equal(t, "10.0.3.5:6443", *c.Endpoints.PrivateEndpoint) + assert.Regexp(t, `^203\.0\.113\.\d+:6443$`, *c.Endpoints.PublicEndpoint) + assert.Nil(t, c.Endpoints.Kubernetes) + assert.Equal(t, created.OpcWorkRequestId, c.Metadata.CreatedByWorkRequestId) + require.NotNil(t, got.Etag) + + wr, err = e.ce.GetWorkRequest(ctx, ke.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestStatusSucceeded, wr.Status) + assert.Equal(t, ke.WorkRequestResourceActionTypeCreated, wr.Resources[0].ActionType) + assert.Equal(t, *c.Id, *wr.Resources[0].Identifier) + + _, err = e.ce.UpdateCluster(ctx, ke.UpdateClusterRequest{ + ClusterId: c.Id, IfMatch: new("stale"), + Name: new("renamed"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.ce.UpdateCluster(ctx, ke.UpdateClusterRequest{ + ClusterId: c.Id, IfMatch: got.Etag, + Name: new("renamed"), KubernetesVersion: new("v1.35.2"), + }) + require.NoError(t, err) + + mid, err := e.ce.GetCluster(ctx, ke.GetClusterRequest{ClusterId: c.Id}) + require.NoError(t, err) + assert.Equal(t, ke.ClusterLifecycleStateUpdating, mid.LifecycleState) + + _, err = e.ce.DeleteCluster(ctx, ke.DeleteClusterRequest{ClusterId: c.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "UPDATING clusters cannot be deleted") + + e.settle() + + after, err := e.ce.GetCluster(ctx, ke.GetClusterRequest{ClusterId: c.Id}) + require.NoError(t, err) + assert.Equal(t, ke.ClusterLifecycleStateActive, after.LifecycleState) + assert.Equal(t, "renamed", *after.Name) + assert.Equal(t, "v1.35.2", *after.KubernetesVersion) + assert.Equal(t, upd.OpcWorkRequestId, after.Metadata.UpdatedByWorkRequestId) + + del, err := e.ce.DeleteCluster(ctx, ke.DeleteClusterRequest{ClusterId: c.Id}) + require.NoError(t, err) + + mid, err = e.ce.GetCluster(ctx, ke.GetClusterRequest{ClusterId: c.Id}) + require.NoError(t, err) + assert.Equal(t, ke.ClusterLifecycleStateDeleting, mid.LifecycleState) + + e.settle() + + end, err := e.ce.GetCluster(ctx, ke.GetClusterRequest{ClusterId: c.Id}) + require.NoError(t, err) + assert.Equal(t, ke.ClusterLifecycleStateDeleted, end.LifecycleState) + assert.NotNil(t, end.Metadata.TimeDeleted) + assert.Equal(t, del.OpcWorkRequestId, end.Metadata.DeletedByWorkRequestId) + + gone, err := e.ce.ListClusters(ctx, ke.ListClustersRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, gone.Items, "deleted clusters are hidden unless asked for") + + withDeleted, err := e.ce.ListClusters(ctx, ke.ListClustersRequest{ + CompartmentId: new(testCompartment), + LifecycleState: []ke.ClusterLifecycleStateEnum{ke.ClusterLifecycleStateDeleted}, + }) + require.NoError(t, err) + assert.Len(t, withDeleted.Items, 1) + + ops, err := e.ce.ListWorkRequests(ctx, ke.ListWorkRequestsRequest{ + CompartmentId: new( + testCompartment, + ), + ResourceId: c.Id, + SortBy: ke.ListWorkRequestsSortByTimeAccepted, + SortOrder: ke.ListWorkRequestsSortOrderAsc, + }) + require.NoError(t, err) + require.Len(t, ops.Items, 3) + assert.Equal(t, ke.WorkRequestOperationTypeClusterCreate, ops.Items[0].OperationType) + assert.Equal(t, ke.WorkRequestOperationTypeClusterUpdate, ops.Items[1].OperationType) + assert.Equal(t, ke.WorkRequestOperationTypeClusterDelete, ops.Items[2].OperationType) +} + +func TestCreateClusterValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + mod func(*ke.CreateClusterDetails) + code string + status int + }{ + { + "missing compartment", + func(d *ke.CreateClusterDetails) { d.CompartmentId = nil }, + "MissingParameter", + 400, + }, + {"missing name", func(d *ke.CreateClusterDetails) { d.Name = nil }, "MissingParameter", 400}, + { + "missing version", + func(d *ke.CreateClusterDetails) { d.KubernetesVersion = nil }, + "MissingParameter", + 400, + }, + { + "bad version", + func(d *ke.CreateClusterDetails) { d.KubernetesVersion = new("v1.12.0") }, + "InvalidParameter", + 400, + }, + { + "unsupported patch", + func(d *ke.CreateClusterDetails) { d.KubernetesVersion = new("v1.34.9") }, + "InvalidParameter", + 400, + }, + {"unknown subnet", func(d *ke.CreateClusterDetails) { + d.EndpointConfig.SubnetId = new("ocid1.subnet.oc1.iad.nope") + }, "InvalidParameter", 400}, + { + "malformed subnet", + func(d *ke.CreateClusterDetails) { d.EndpointConfig.SubnetId = new("subnet") }, + "InvalidParameter", + 400, + }, + { + "subnet in other vcn", + func(d *ke.CreateClusterDetails) { d.EndpointConfig.SubnetId = new(subOther) }, + "InvalidParameter", + 400, + }, + { + "empty subnet", + func(d *ke.CreateClusterDetails) { d.EndpointConfig.SubnetId = nil }, + "MissingParameter", + 400, + }, + { + "unknown vcn", + func(d *ke.CreateClusterDetails) { d.VcnId = new("ocid1.vcn.oc1.iad.nope") }, + "InvalidParameter", + 400, + }, + {"lb subnet in other vcn", func(d *ke.CreateClusterDetails) { + d.Options = &ke.ClusterCreateOptions{ServiceLbSubnetIds: []string{subOther}} + }, "InvalidParameter", 400}, + {"overlapping cidrs", func(d *ke.CreateClusterDetails) { + d.Options = &ke.ClusterCreateOptions{ + KubernetesNetworkConfig: &ke.KubernetesNetworkConfig{ + PodsCidr: new("10.0.0.0/16"), ServicesCidr: new("10.0.1.0/24"), + }, + } + }, "InvalidParameter", 400}, + {"bad cidr", func(d *ke.CreateClusterDetails) { + d.Options = &ke.ClusterCreateOptions{ + KubernetesNetworkConfig: &ke.KubernetesNetworkConfig{ + PodsCidr: new("nope"), + }, + } + }, "InvalidParameter", 400}, + { + "missing key", + func(d *ke.CreateClusterDetails) { d.KmsKeyId = new("ocid1.key.oc1.iad.nope") }, + "InvalidParameter", + 400, + }, + { + "disabled key", + func(d *ke.CreateClusterDetails) { d.KmsKeyId = new(keyOff) }, + "InvalidParameter", + 400, + }, + {"bad cni", func(d *ke.CreateClusterDetails) { + d.ClusterPodNetworkOptions = []ke.ClusterPodNetworkOptionDetails{ + ke.FlannelOverlayClusterPodNetworkOptionDetails{}, + ke.OciVcnIpNativeClusterPodNetworkOptionDetails{}, + } + }, "InvalidParameter", 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + d := clusterDetails("v-" + tt.name) + tt.mod(&d) + + _, err := e.ce.CreateCluster(t.Context(), ke.CreateClusterRequest{CreateClusterDetails: d}) + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestCreateClusterAccepts(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + mod func(*ke.CreateClusterDetails) + check func(*testing.T, ke.Cluster) + name string + }{ + { + func(d *ke.CreateClusterDetails) { d.EndpointConfig = nil; d.VcnId = nil }, + func(t *testing.T, c ke.Cluster) { + t.Helper() + assert.Regexp(t, `^203\.0\.113\.\d+:6443$`, *c.Endpoints.Kubernetes) + assert.Nil(t, c.Endpoints.PrivateEndpoint) + }, + "legacy endpoint", + }, + { + func(d *ke.CreateClusterDetails) { d.VcnId = nil }, + func(t *testing.T, c ke.Cluster) { + t.Helper() + assert.Equal(t, vcnA, *c.VcnId) + }, + "vcn from subnet", + }, + {func(d *ke.CreateClusterDetails) { + d.Options = &ke.ClusterCreateOptions{ + ServiceLbSubnetIds: []string{subLB}, + KubernetesNetworkConfig: &ke.KubernetesNetworkConfig{PodsCidr: new("10.200.0.0/16")}, + } + }, func(t *testing.T, c ke.Cluster) { + t.Helper() + assert.Equal(t, []string{subLB}, c.Options.ServiceLbSubnetIds) + assert.Equal(t, "10.200.0.0/16", *c.Options.KubernetesNetworkConfig.PodsCidr) + assert.Equal(t, "10.96.0.0/16", *c.Options.KubernetesNetworkConfig.ServicesCidr) + }, "custom cidrs and lb"}, + {func(d *ke.CreateClusterDetails) { + d.KmsKeyId = new(keyOK) + d.ClusterPodNetworkOptions = []ke.ClusterPodNetworkOptionDetails{ + ke.OciVcnIpNativeClusterPodNetworkOptionDetails{}, + } + }, func(t *testing.T, c ke.Cluster) { + t.Helper() + assert.Equal(t, keyOK, *c.KmsKeyId) + _, ok := c.ClusterPodNetworkOptions[0].(ke.OciVcnIpNativeClusterPodNetworkOptionDetails) + assert.True(t, ok) + }, "native cni and key"}, + { + func(d *ke.CreateClusterDetails) { d.EndpointConfig.IsPublicIpEnabled = new(false) }, + func(t *testing.T, c ke.Cluster) { + t.Helper() + assert.Nil(t, c.Endpoints.PublicEndpoint) + assert.NotNil(t, c.Endpoints.PrivateEndpoint) + }, + "private endpoint only", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := e.mkCluster(t, "a-"+tt.name, tt.mod) + tt.check(t, c) + }) + } +} + +func TestClusterStateGuards(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + _, err := e.ce.CreateCluster( + ctx, + ke.CreateClusterRequest{CreateClusterDetails: clusterDetails("guard")}, + ) + require.NoError(t, err) + + list, err := e.ce.ListClusters(ctx, ke.ListClustersRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + id := list.Items[0].Id + + tests := []struct { + call func() error + name string + }{ + {func() error { + _, rerr := e.ce.UpdateCluster(ctx, ke.UpdateClusterRequest{ClusterId: id, + Name: new("x")}) + + return rerr + }, "update while creating"}, + {func() error { + _, rerr := e.ce.DeleteCluster(ctx, ke.DeleteClusterRequest{ClusterId: id}) + + return rerr + }, "delete while creating"}, + {func() error { + _, rerr := e.ce.CreateKubeconfig(ctx, ke.CreateKubeconfigRequest{ClusterId: id}) + + return rerr + }, "kubeconfig while creating"}, + {func() error { + _, rerr := e.ce.CreateNodePool( + ctx, + ke.CreateNodePoolRequest{CreateNodePoolDetails: poolDetails(id, "p")}, + ) + + return rerr + }, "node pool while creating"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + se := svcErr(t, tt.call()) + assert.Equal(t, http.StatusConflict, se.GetHTTPStatusCode()) + assert.Equal(t, "IncorrectState", se.GetCode()) + }) + } + + missing := []struct { + call func() error + name string + }{ + {func() error { + _, rerr := e.ce.GetCluster( + ctx, + ke.GetClusterRequest{ClusterId: new("ocid1.cluster.oc1.iad.nope")}, + ) + + return rerr + }, "get"}, + {func() error { + _, rerr := e.ce.DeleteCluster( + ctx, + ke.DeleteClusterRequest{ClusterId: new("ocid1.cluster.oc1.iad.nope")}, + ) + + return rerr + }, "delete"}, + {func() error { + _, rerr := e.ce.GetClusterMigrateToNativeVcnStatus( + ctx, + ke.GetClusterMigrateToNativeVcnStatusRequest{ + ClusterId: new("ocid1.cluster.oc1.iad.nope")}, + ) + + return rerr + }, "migrate status"}, + } + + for _, tt := range missing { + t.Run("missing "+tt.name, func(t *testing.T) { + t.Parallel() + + se := svcErr(t, tt.call()) + assert.Contains(t, []int{400, 404}, se.GetHTTPStatusCode()) + }) + } +} + +func TestUpdateClusterValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "upd", nil) + + tests := []struct { + name string + in ke.UpdateClusterDetails + }{ + {"downgrade", ke.UpdateClusterDetails{KubernetesVersion: new("v1.34.1")}}, + {"skip minor", ke.UpdateClusterDetails{KubernetesVersion: new("v1.36.4")}}, + {"unknown version", ke.UpdateClusterDetails{KubernetesVersion: new("v1.99.0")}}, + {"empty name", ke.UpdateClusterDetails{Name: new(" ")}}, + { + "basic to enhanced reverse", + ke.UpdateClusterDetails{Type: ke.ClusterTypeBasicCluster}, + }, + } + + // A BASIC cluster may become ENHANCED but not the reverse; the reverse case needs an ENHANCED cluster first. + _, err := e.ce.UpdateCluster(t.Context(), ke.UpdateClusterRequest{ + ClusterId: c.Id, Type: ke.ClusterTypeEnhancedCluster, + }) + require.NoError(t, err) + e.settle() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, rerr := e.ce.UpdateCluster(t.Context(), ke.UpdateClusterRequest{ + ClusterId: c.Id, UpdateClusterDetails: tt.in, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, rerr).GetHTTPStatusCode()) + }) + } +} + +func TestClusterListFilters(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a := e.mkCluster(t, "alpha", nil) + e.clock.Advance(1) + b := e.mkCluster(t, "bravo", nil) + + tests := []struct { + name string + req ke.ListClustersRequest + want []string + }{ + {"all newest first", ke.ListClustersRequest{}, []string{*b.Name, *a.Name}}, + { + "by name asc", + ke.ListClustersRequest{ + SortBy: ke.ListClustersSortByName, + SortOrder: ke.ListClustersSortOrderAsc, + }, + []string{"alpha", "bravo"}, + }, + {"name filter", ke.ListClustersRequest{Name: new("bravo")}, []string{"bravo"}}, + { + "state filter", + ke.ListClustersRequest{ + LifecycleState: []ke.ClusterLifecycleStateEnum{ + ke.ClusterLifecycleStateCreating, + }, + }, + nil, + }, + { + "limit", + ke.ListClustersRequest{ + Limit: new(1), + SortBy: ke.ListClustersSortByName, + SortOrder: ke.ListClustersSortOrderAsc, + }, + []string{"alpha"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.req.CompartmentId = new(testCompartment) + + resp, err := e.ce.ListClusters(t.Context(), tt.req) + require.NoError(t, err) + + var names []string + for _, c := range resp.Items { + names = append(names, *c.Name) + } + + assert.Equal(t, tt.want, names) + + if tt.req.Limit != nil { + require.NotNil(t, resp.OpcNextPage) + } + }) + } +} + +func TestMigrateToNativeVcn(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + legacy := e.mkCluster( + t, + "legacy", + func(d *ke.CreateClusterDetails) { d.EndpointConfig = nil; d.VcnId = new(vcnA) }, + ) + native := e.mkCluster(t, "native", nil) + + st, err := e.ce.GetClusterMigrateToNativeVcnStatus( + ctx, + ke.GetClusterMigrateToNativeVcnStatusRequest{ClusterId: legacy.Id}, + ) + require.NoError(t, err) + assert.Equal(t, ke.ClusterMigrateToNativeVcnStatusStateNotStarted, st.State) + + tests := []struct { + in ke.ClusterMigrateToNativeVcnDetails + id *string + name string + }{ + { + ke.ClusterMigrateToNativeVcnDetails{ + EndpointConfig: &ke.ClusterEndpointConfig{SubnetId: new(subRegional)}, + }, + native.Id, + "already native", + }, + {ke.ClusterMigrateToNativeVcnDetails{}, legacy.Id, "no endpoint config"}, + { + ke.ClusterMigrateToNativeVcnDetails{ + EndpointConfig: &ke.ClusterEndpointConfig{SubnetId: new(subOther)}, + }, + legacy.Id, + "subnet other vcn", + }, + { + ke.ClusterMigrateToNativeVcnDetails{ + EndpointConfig: &ke.ClusterEndpointConfig{ + SubnetId: new(subRegional), + }, + DecommissionDelayDuration: new("forever"), + }, + legacy.Id, + "bad duration", + }, + { + ke.ClusterMigrateToNativeVcnDetails{ + EndpointConfig: &ke.ClusterEndpointConfig{ + SubnetId: new(subRegional), + }, + DecommissionDelayDuration: new("P31D"), + }, + legacy.Id, + "duration too long", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, rerr := e.ce.ClusterMigrateToNativeVcn(ctx, ke.ClusterMigrateToNativeVcnRequest{ + ClusterId: tt.id, ClusterMigrateToNativeVcnDetails: tt.in, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, rerr).GetHTTPStatusCode()) + }) + } + + _, err = e.ce.ClusterMigrateToNativeVcn(ctx, ke.ClusterMigrateToNativeVcnRequest{ + ClusterId: legacy.Id, + EndpointConfig: &ke.ClusterEndpointConfig{ + SubnetId: new(subRegional), + }, + DecommissionDelayDuration: new("P2D"), + }) + require.NoError(t, err) + + st, err = e.ce.GetClusterMigrateToNativeVcnStatus( + ctx, + ke.GetClusterMigrateToNativeVcnStatusRequest{ClusterId: legacy.Id}, + ) + require.NoError(t, err) + assert.Equal(t, ke.ClusterMigrateToNativeVcnStatusStateInProgress, st.State) + + e.settle() + + st, err = e.ce.GetClusterMigrateToNativeVcnStatus( + ctx, + ke.GetClusterMigrateToNativeVcnStatusRequest{ClusterId: legacy.Id}, + ) + require.NoError(t, err) + assert.Equal(t, ke.ClusterMigrateToNativeVcnStatusStatePendingDecommission, st.State) + require.NotNil(t, st.TimeDecommissionScheduled) + + got, err := e.ce.GetCluster(ctx, ke.GetClusterRequest{ClusterId: legacy.Id}) + require.NoError(t, err) + assert.NotNil(t, got.Endpoints.PrivateEndpoint) + assert.NotNil(t, got.Endpoints.Kubernetes, "the legacy endpoint survives until decommission") + assert.Equal(t, subRegional, *got.EndpointConfig.SubnetId) + + e.clock.Advance(3 * 24 * time.Hour) + + st, err = e.ce.GetClusterMigrateToNativeVcnStatus( + ctx, + ke.GetClusterMigrateToNativeVcnStatusRequest{ClusterId: legacy.Id}, + ) + require.NoError(t, err) + assert.Equal(t, ke.ClusterMigrateToNativeVcnStatusStateCompleted, st.State) + + got, err = e.ce.GetCluster(ctx, ke.GetClusterRequest{ClusterId: legacy.Id}) + require.NoError(t, err) + assert.Nil(t, got.Endpoints.Kubernetes) +} + +func TestStandaloneWithoutCore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{noNet: true}) + + tests := []struct { + mod func(*ke.CreateClusterDetails) + name string + status int + }{ + {func(d *ke.CreateClusterDetails) { + d.EndpointConfig.SubnetId = new("ocid1.subnet.oc1.iad.any") + }, "unknown subnet accepted", 0}, + { + func(d *ke.CreateClusterDetails) { d.EndpointConfig.SubnetId = new("x") }, + "malformed subnet rejected", + 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + d := clusterDetails("s-" + tt.name) + tt.mod(&d) + + _, err := e.ce.CreateCluster(t.Context(), ke.CreateClusterRequest{CreateClusterDetails: d}) + if tt.status == 0 { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.status, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} diff --git a/services/ocicontainerengine/clusters.go b/services/ocicontainerengine/clusters.go new file mode 100644 index 0000000000..2a0fd16709 --- /dev/null +++ b/services/ocicontainerengine/clusters.go @@ -0,0 +1,833 @@ +package ocicontainerengine + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "net/netip" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + apiPort = "6443" + defaultPodsCIDR = "10.244.0.0/16" + defaultSvcCIDR = "10.96.0.0/16" + tokenBytes = 24 + maxDecommission = 30 + hoursPerDay = 24 +) + +var durationDaysRe = regexp.MustCompile(`^P(\d+)D$`) + +// CreateClusterIn is the CreateCluster request body. +type CreateClusterIn struct { + Tags + VcnID *string `json:"vcnId"` + KmsKeyID *string `json:"kmsKeyId"` + EndpointConfig *EndpointConfig `json:"endpointConfig"` + Options *ClusterOptions `json:"options"` + ImagePolicy *ImagePolicy `json:"imagePolicyConfig"` + Name string `json:"name"` + Compartment string `json:"compartmentId"` + Version string `json:"kubernetesVersion"` + Type string `json:"type"` + PodNetworks []PodNet `json:"clusterPodNetworkOptions"` +} + +// UpdateClusterIn is the UpdateCluster request body. +type UpdateClusterIn struct { + Tags + Name *string `json:"name"` + Version *string `json:"kubernetesVersion"` + Options *ClusterOptions `json:"options"` + ImagePolicy *ImagePolicy `json:"imagePolicyConfig"` + Type string `json:"type"` +} + +// MigrateIn is the ClusterMigrateToNativeVcn request body. +type MigrateIn struct { + EndpointConfig *EndpointConfig `json:"endpointConfig"` + Decommission *string `json:"decommissionDelayDuration"` +} + +type clusterMeta struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated *time.Time `json:"timeUpdated,omitempty"` + TimeDeleted *time.Time `json:"timeDeleted,omitempty"` + CreatedByWorkRequestID string `json:"createdByWorkRequestId,omitempty"` + UpdatedByWorkRequestID string `json:"updatedByWorkRequestId,omitempty"` + DeletedByWorkRequestID string `json:"deletedByWorkRequestId,omitempty"` +} + +// ClusterWire is the Cluster and ClusterSummary body. +type ClusterWire struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + KmsKeyID *string `json:"kmsKeyId,omitempty"` + EndpointConfig *EndpointConfig `json:"endpointConfig,omitempty"` + Endpoints *Endpoints `json:"endpoints,omitempty"` + Options ClusterOptions `json:"options"` + ImagePolicy ImagePolicy `json:"imagePolicyConfig"` + Metadata clusterMeta `json:"metadata"` + ID string `json:"id"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + VcnID string `json:"vcnId,omitempty"` + KubernetesVersion string `json:"kubernetesVersion"` + LifecycleState string `json:"lifecycleState"` + LifecycleDetails string `json:"lifecycleDetails,omitempty"` + Type string `json:"type"` + Upgrades []string `json:"availableKubernetesUpgrades"` + PodNetworks []PodNet `json:"clusterPodNetworkOptions"` +} + +func clusterView(c *Cluster, summary bool) ClusterWire { + w := ClusterWire{ + ID: c.ID, Name: c.Name, CompartmentID: c.CompartmentID, VcnID: c.VcnID, KubernetesVersion: c.Version, + EndpointConfig: c.EndpointConfig, Options: c.Options, ImagePolicy: c.ImagePolicy, + LifecycleState: c.State, LifecycleDetails: c.Details, Type: c.Type, PodNetworks: c.PodNetworks, + FreeformTags: c.Tags.FreeformTags, DefinedTags: c.Tags.DefinedTags, Upgrades: upgradesFrom(c.Version), + Metadata: clusterMeta{ + TimeCreated: c.TimeCreated, TimeUpdated: c.TimeUpdated, TimeDeleted: c.TimeDeleted, + CreatedByWorkRequestID: c.CreatedByWR, UpdatedByWorkRequestID: c.UpdatedByWR, + DeletedByWorkRequestID: c.DeletedByWR, + }, + } + if c.Endpoints != (Endpoints{}) { + ep := c.Endpoints + w.Endpoints = &ep + } + + if !summary && c.KmsKeyID != "" { + w.KmsKeyID = &c.KmsKeyID + } + + return w +} + +func (b *InMemoryBackend) checkSubnet(field, id, vcnID string) (SubnetInfo, *APIError) { + if !strings.HasPrefix(id, "ocid1.subnet.") { + return SubnetInfo{}, errParam(field + " must be the OCID of a subnet") + } + + if b.net == nil { + return SubnetInfo{ID: id, VcnID: vcnID}, nil + } + + s, ok := b.net.Subnet(id) + if !ok { + return SubnetInfo{}, errParam(field + " " + id + " does not reference an existing subnet") + } + + if vcnID != "" && s.VcnID != vcnID { + return SubnetInfo{}, errParam(field + " " + id + " is not in VCN " + vcnID) + } + + return s, nil +} + +func (b *InMemoryBackend) checkKey(field, keyID string) *APIError { + if !strings.HasPrefix(keyID, "ocid1.key.") { + return errParam(field + " must be the OCID of a Vault key") + } + + if b.keyChecker == nil { + return nil + } + + state, found := b.keyChecker(keyID) + + switch { + case !found: + return errParam(field + " " + keyID + " does not reference an existing key") + case state != "ENABLED": + return errParam(field + " " + keyID + " is in state " + state + "; the key must be ENABLED") + } + + return nil +} + +func validCIDR(field, v string) (netip.Prefix, *APIError) { + p, err := netip.ParsePrefix(v) + if err != nil || !p.Addr().Is4() || p.Masked() != p { + return p, errParam(field + " must be an IPv4 CIDR block such as 10.244.0.0/16") + } + + return p, nil +} + +func (b *InMemoryBackend) checkPodNets(nets []PodNet) ([]PodNet, *APIError) { + if len(nets) == 0 { + return []PodNet{{CniType: cniFlannel}}, nil + } + + if len(nets) > 1 { + return nil, errParam("clusterPodNetworkOptions must contain at most one entry") + } + + if nets[0].CniType != cniFlannel && nets[0].CniType != cniNative { + return nil, errParam("clusterPodNetworkOptions.cniType must be " + cniFlannel + " or " + cniNative) + } + + return []PodNet{{CniType: nets[0].CniType}}, nil +} + +// checkOptions validates the create options and fills the documented defaults. +func (b *InMemoryBackend) checkOptions(in *ClusterOptions, vcnID string) (ClusterOptions, *APIError) { + out := ClusterOptions{ + K8sNetwork: &NetworkConfig{PodsCidr: defaultPodsCIDR, ServicesCidr: defaultSvcCIDR}, + AddOns: &AddOns{IsKubernetesDashboardEnabled: new(false), IsTillerEnabled: new(false)}, + Admission: &Admission{IsPodSecurityPolicyEnabled: new(false)}, + ServiceLbSubnetID: []string{}, + } + + if in == nil { + return out, nil + } + + if len(in.OIDCAuth) > 0 || len(in.OIDCDiscovery) > 0 { + return out, errParam("OpenID Connect authentication and discovery are not supported by this emulator") + } + + for _, id := range in.ServiceLbSubnetID { + if _, aerr := b.checkSubnet("options.serviceLbSubnetIds", id, vcnID); aerr != nil { + return out, aerr + } + } + + out.ServiceLbSubnetID = slices.Clone(in.ServiceLbSubnetID) + if out.ServiceLbSubnetID == nil { + out.ServiceLbSubnetID = []string{} + } + + if aerr := mergeNetwork(&out, in.K8sNetwork); aerr != nil { + return out, aerr + } + + if in.AddOns != nil { + out.AddOns = &AddOns{ + IsKubernetesDashboardEnabled: def(in.AddOns.IsKubernetesDashboardEnabled, new(false)), + IsTillerEnabled: def(in.AddOns.IsTillerEnabled, new(false)), + } + } + + applyUpdatable(&out, in) + + return out, nil +} + +func mergeNetwork(out *ClusterOptions, in *NetworkConfig) *APIError { + if in == nil { + return nil + } + + pods, svc := def(in.PodsCidr, defaultPodsCIDR), def(in.ServicesCidr, defaultSvcCIDR) + + pp, aerr := validCIDR("options.kubernetesNetworkConfig.podsCidr", pods) + if aerr != nil { + return aerr + } + + sp, aerr := validCIDR("options.kubernetesNetworkConfig.servicesCidr", svc) + if aerr != nil { + return aerr + } + + if pp.Overlaps(sp) { + return errParam("options.kubernetesNetworkConfig podsCidr and servicesCidr must not overlap") + } + + out.K8sNetwork = &NetworkConfig{PodsCidr: pods, ServicesCidr: svc} + + return nil +} + +// applyUpdatable copies the option blocks UpdateCluster may change. +func applyUpdatable(out, in *ClusterOptions) { + if in.Admission != nil { + out.Admission = &Admission{IsPodSecurityPolicyEnabled: def(in.Admission.IsPodSecurityPolicyEnabled, new(false))} + } + + if in.PersistentVolume != nil { + out.PersistentVolume = in.PersistentVolume + } + + if in.ServiceLb != nil { + out.ServiceLb = in.ServiceLb + } +} + +func (b *InMemoryBackend) checkEndpointConfig(ec *EndpointConfig, vcnID string) (SubnetInfo, *APIError) { + if ec == nil { + return SubnetInfo{}, nil + } + + if ec.SubnetID == "" { + return SubnetInfo{}, errMissing("endpointConfig.subnetId is required") + } + + return b.checkSubnet("endpointConfig.subnetId", ec.SubnetID, vcnID) +} + +func (b *InMemoryBackend) checkImagePolicy(in *ImagePolicy) (ImagePolicy, *APIError) { + if in == nil { + return ImagePolicy{IsPolicyEnabled: new(false), KeyDetails: []KeyDetail{}}, nil + } + + for _, k := range in.KeyDetails { + if aerr := b.checkKey("imagePolicyConfig.keyDetails.kmsKeyId", k.KmsKeyID); aerr != nil { + return ImagePolicy{}, aerr + } + } + + out := ImagePolicy{IsPolicyEnabled: def(in.IsPolicyEnabled, new(false)), KeyDetails: slices.Clone(in.KeyDetails)} + if out.KeyDetails == nil { + out.KeyDetails = []KeyDetail{} + } + + return out, nil +} + +// hostIn returns the address offset hosts above the start of a CIDR block, or "" when it does not fit. +func hostIn(cidr string, offset int) string { + p, err := netip.ParsePrefix(cidr) + if err != nil { + return "" + } + + a := p.Masked().Addr() + for range offset { + a = a.Next() + } + + if !p.Contains(a) { + return "" + } + + return a.String() +} + +// documentationAddr hands out addresses from the RFC 5737 documentation ranges. +func documentationAddr(n int64) string { + bases := []string{"203.0.113.", "198.51.100.", "192.0.2."} + const hosts = 253 + + i := (n - 1) % (hosts * int64(len(bases))) + + return bases[i/hosts] + strconv.FormatInt(i%hosts+1, 10) +} + +// assignEndpoints gives a cluster API endpoints; the docker engine replaces them with the real server. +func (b *InMemoryBackend) assignEndpoints(c *Cluster, sub SubnetInfo) { + suffix := ":" + apiPort + + if c.EndpointConfig == nil { + c.Endpoints = Endpoints{Kubernetes: documentationAddr(b.nextSeq()) + suffix} + + return + } + + priv := hostIn(sub.CIDR, privateEndpointOffset+len(b.clusters)) + if priv == "" { + priv = documentationAddr(b.nextSeq()) + } + + c.Endpoints = Endpoints{PrivateEndpoint: priv + suffix} + if c.EndpointConfig.IsPublicIPEnabled != nil && *c.EndpointConfig.IsPublicIPEnabled { + c.Endpoints.PublicEndpoint = documentationAddr(b.nextSeq()) + suffix + } +} + +const privateEndpointOffset = 5 + +func newToken() string { + buf := make([]byte, tokenBytes) + _, _ = rand.Read(buf) + + return "gopherstack-oke-" + hex.EncodeToString(buf) +} + +func (b *InMemoryBackend) newIdentity(c *Cluster) *APIError { + ca, err := k3sengine.NewCA("gopherstack-oke-" + ocidTail(c.ID, longTail) + "-ca") + if err != nil { + return newErr(http.StatusInternalServerError, "InternalServerError", "Failed to generate the cluster CA") + } + + c.CAPEM, c.Token = string(ca.CertPEM), newToken() + + return nil +} + +// CreateCluster creates a cluster in CREATING state and returns its work request. +func (b *InMemoryBackend) CreateCluster(in CreateClusterIn) (string, error) { + b.mu.Lock("CreateCluster") + defer b.mu.Unlock() + + if aerr := firstErr(b.checkCompartment(in.Compartment), validateName(in.Name)); aerr != nil { + return "", aerr + } + + switch { + case in.Version == "": + return "", errMissing("kubernetesVersion is required") + case !isSupported(in.Version): + return "", errParam("kubernetesVersion " + in.Version + " is not supported; see GetClusterOptions") + case in.Type != "" && in.Type != typeBasic && in.Type != typeEnhanced: + return "", errParam("type must be " + typeBasic + " or " + typeEnhanced) + } + + c, sub, aerr := b.buildCluster(in) + if aerr != nil { + return "", aerr + } + + b.assignEndpoints(c, sub) + + if aerr = b.newIdentity(c); aerr != nil { + return "", aerr + } + + var finish *time.Time + if b.engine == nil { + finish = c.TransitionAt + } else { + c.TransitionAt = nil + } + + c.CreatedByWR = b.newWR(wrClusterCreate, c.CompartmentID, entityCluster, c.ID, actionCreated, finish) + b.clusters[c.ID] = c + + if b.engine != nil { + b.launchLocked(c, c.CreatedByWR) + } + + return c.CreatedByWR, nil +} + +func (b *InMemoryBackend) buildCluster(in CreateClusterIn) (*Cluster, SubnetInfo, *APIError) { + vcnID := "" + if in.VcnID != nil { + vcnID = *in.VcnID + if b.net != nil && !b.net.VCNExists(vcnID) { + return nil, SubnetInfo{}, errParam("vcnId " + vcnID + " does not reference an existing VCN") + } + } + + sub, aerr := b.checkEndpointConfig(in.EndpointConfig, vcnID) + if aerr != nil { + return nil, sub, aerr + } + + vcnID = def(vcnID, sub.VcnID) + + opts, aerr := b.checkOptions(in.Options, vcnID) + if aerr != nil { + return nil, sub, aerr + } + + pol, aerr := b.checkImagePolicy(in.ImagePolicy) + if aerr != nil { + return nil, sub, aerr + } + + nets, aerr := b.checkPodNets(in.PodNetworks) + if aerr != nil { + return nil, sub, aerr + } + + if in.KmsKeyID != nil { + if aerr = b.checkKey("kmsKeyId", *in.KmsKeyID); aerr != nil { + return nil, sub, aerr + } + } + + now := b.nowUTC() + c := &Cluster{ + ID: newOCID("cluster"), Name: in.Name, CompartmentID: in.Compartment, VcnID: vcnID, Version: in.Version, + Type: def(in.Type, typeBasic), State: stateCreating, TimeCreated: now, TransitionAt: b.transitionAt(), + EndpointConfig: in.EndpointConfig, Options: opts, ImagePolicy: pol, PodNetworks: nets, Tags: in.Tags, + Etag: newEtag(), Seq: b.nextSeq(), + } + + if in.KmsKeyID != nil { + c.KmsKeyID = *in.KmsKeyID + } + + return c, sub, nil +} + +func (b *InMemoryBackend) liveCluster(id string) (*Cluster, *APIError) { + c, ok := b.clusters[id] + if !ok { + return nil, errNotFound(id) + } + + return c, nil +} + +// GetCluster returns one cluster and its etag; DELETED clusters stay readable. +func (b *InMemoryBackend) GetCluster(id string) (ClusterWire, string, error) { + b.mu.Lock("GetCluster") + defer b.mu.Unlock() + + b.settleAll() + + c, aerr := b.liveCluster(id) + if aerr != nil { + return ClusterWire{}, "", aerr + } + + return clusterView(c, false), c.Etag, nil +} + +// ClusterListIn are the ListClusters filters. +type ClusterListIn struct { + Compartment string + Name string + States []string + ListOpts +} + +// ListClusters lists the clusters of a compartment; DELETED ones only when asked for by state. +func (b *InMemoryBackend) ListClusters(in ClusterListIn) (page.Page[ClusterWire], error) { + if aerr := validListOpts(in.ListOpts, "ID", "NAME", "TIME_CREATED"); aerr != nil { + return page.Page[ClusterWire]{}, aerr + } + + b.mu.Lock("ListClusters") + defer b.mu.Unlock() + + if aerr := b.checkCompartment(in.Compartment); aerr != nil { + return page.Page[ClusterWire]{}, aerr + } + + b.settleAll() + + var all []*Cluster + + for _, c := range b.clusters { + if c.CompartmentID != in.Compartment || (in.Name != "" && c.Name != in.Name) { + continue + } + + if len(in.States) == 0 && c.State == stateDeleted || len(in.States) > 0 && !containsFold(in.States, c.State) { + continue + } + + all = append(all, c) + } + + pg := paginate(all, in.ListOpts, func(c *Cluster) string { return c.ID }, func(c *Cluster) string { return c.Name }, + func(c *Cluster) (time.Time, int64) { return c.TimeCreated, c.Seq }) + + out := make([]ClusterWire, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, clusterView(c, true)) + } + + return page.Page[ClusterWire]{Data: out, Next: pg.Next}, nil +} + +func (b *InMemoryBackend) mutableCluster(id, ifMatch string) (*Cluster, *APIError) { + b.settleAll() + + c, aerr := b.liveCluster(id) + if aerr != nil { + return nil, aerr + } + + if aerr = checkEtag(c.Etag, ifMatch); aerr != nil { + return nil, aerr + } + + if c.State != stateActive && c.State != stateFailed { + return nil, errState("Cluster " + id + " is in state " + c.State + "; the operation requires ACTIVE") + } + + return c, nil +} + +// UpdateCluster changes name, version, type, options and tags; the cluster reports UPDATING meanwhile. +func (b *InMemoryBackend) UpdateCluster(id, ifMatch string, in UpdateClusterIn) (string, error) { + b.mu.Lock("UpdateCluster") + defer b.mu.Unlock() + + c, aerr := b.mutableCluster(id, ifMatch) + if aerr != nil { + return "", aerr + } + + if c.State != stateActive { + return "", errState("Cluster " + id + " is in state " + c.State + "; the operation requires ACTIVE") + } + + if aerr = b.checkClusterUpdate(c, in); aerr != nil { + return "", aerr + } + + if in.Name != nil { + c.Name = *in.Name + } + + if in.Version != nil { + c.Version = *in.Version + } + + if in.Type != "" { + c.Type = in.Type + } + + if in.Options != nil { + applyUpdatable(&c.Options, in.Options) + } + + if in.ImagePolicy != nil { + c.ImagePolicy, _ = b.checkImagePolicy(in.ImagePolicy) + } + + c.Tags.merge(in.Tags) + + return b.beginTransition(c, wrClusterUpdate, stateUpdating, actionUpdated), nil +} + +func (b *InMemoryBackend) checkClusterUpdate(c *Cluster, in UpdateClusterIn) *APIError { + if in.Name != nil { + if aerr := validateName(*in.Name); aerr != nil { + return aerr + } + } + + if in.Options != nil && (len(in.Options.OIDCAuth) > 0 || len(in.Options.OIDCDiscovery) > 0) { + return errParam("OpenID Connect authentication and discovery are not supported by this emulator") + } + + if in.ImagePolicy != nil { + if _, aerr := b.checkImagePolicy(in.ImagePolicy); aerr != nil { + return aerr + } + } + + return firstErr(checkTypeChange(c.Type, in.Type), checkUpgrade(c.Version, in.Version)) +} + +func checkTypeChange(cur, want string) *APIError { + switch { + case want != "" && want != typeBasic && want != typeEnhanced: + return errParam("type must be " + typeBasic + " or " + typeEnhanced) + case want == typeBasic && cur == typeEnhanced: + return errParam("An ENHANCED_CLUSTER cannot be changed to a BASIC_CLUSTER") + } + + return nil +} + +func checkUpgrade(cur string, want *string) *APIError { + if want != nil && *want != cur && !slices.Contains(upgradesFrom(cur), *want) { + return errParam("kubernetesVersion " + *want + " is not an available upgrade of " + cur) + } + + return nil +} + +// beginTransition moves a settled cluster into an interim state and records its work request. +func (b *InMemoryBackend) beginTransition(c *Cluster, op, state, action string) string { + now := b.nowUTC() + c.State, c.TimeUpdated, c.Etag = state, &now, newEtag() + + c.TransitionAt = b.transitionAt() + wr := b.newWR(op, c.CompartmentID, entityCluster, c.ID, action, c.TransitionAt) + if state == stateDeleting { + c.DeletedByWR = wr + } else { + c.UpdatedByWR = wr + } + + return wr +} + +// DeleteCluster deletes a cluster and its node pools; the cluster goes DELETING then DELETED. +func (b *InMemoryBackend) DeleteCluster(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteCluster") + defer b.mu.Unlock() + + c, aerr := b.mutableCluster(id, ifMatch) + if aerr != nil { + return "", aerr + } + + wr := b.beginTransition(c, wrClusterDelete, stateDeleting, actionDeleted) + + for _, np := range b.pools { + if np.ClusterID == id && np.State != stateDeleted { + b.markPoolDeleting(np, c.TransitionAt) + } + } + + b.reapLocked(id) + + return wr, nil +} + +// MigrateToNativeVcn moves a legacy cluster endpoint into a VCN subnet. +func (b *InMemoryBackend) MigrateToNativeVcn(id, ifMatch string, in MigrateIn) (string, error) { + b.mu.Lock("MigrateToNativeVcn") + defer b.mu.Unlock() + + c, aerr := b.mutableCluster(id, ifMatch) + if aerr != nil { + return "", aerr + } + + if aerr = b.checkMigrate(c, in); aerr != nil { + return "", aerr + } + + sub, _ := b.checkEndpointConfig(in.EndpointConfig, c.VcnID) + days := maxDecommission + + if in.Decommission != nil { + days, _ = strconv.Atoi(durationDaysRe.FindStringSubmatch(*in.Decommission)[1]) + } + + c.VcnID, c.EndpointConfig = def(c.VcnID, sub.VcnID), in.EndpointConfig + legacy := c.Endpoints.Kubernetes + + b.assignEndpoints(c, sub) + + if b.engine != nil { + engineEndpoints(c, legacy) + } + + c.Endpoints.Kubernetes = legacy + c.MigrateState = "IN_PROGRESS" + + sched := b.nowUTC().Add(b.transition + time.Duration(days)*hoursPerDay*time.Hour) + c.MigrateScheduled = &sched + + return b.beginTransition(c, wrClusterUpdate, stateUpdating, actionUpdated), nil +} + +func (b *InMemoryBackend) checkMigrate(c *Cluster, in MigrateIn) *APIError { + if c.EndpointConfig != nil && c.EndpointConfig.SubnetID != "" { + return errParam("Cluster " + c.ID + " already uses a VCN-native endpoint") + } + + if in.EndpointConfig == nil { + return errMissing("endpointConfig is required") + } + + if _, aerr := b.checkEndpointConfig(in.EndpointConfig, c.VcnID); aerr != nil { + return aerr + } + + if in.Decommission != nil { + m := durationDaysRe.FindStringSubmatch(*in.Decommission) + if m == nil { + return errParam("decommissionDelayDuration must be an ISO 8601 day duration such as P30D") + } + + if n, _ := strconv.Atoi(m[1]); n > maxDecommission { + return errParam("decommissionDelayDuration must be at most P30D") + } + } + + return nil +} + +// MigrateStatusWire is the GetClusterMigrateToNativeVcnStatus body. +type MigrateStatusWire struct { + TimeDecommissionScheduled *time.Time `json:"timeDecommissionScheduled,omitempty"` + State string `json:"state"` +} + +// MigrateStatus reports the endpoint migration state of a cluster. +func (b *InMemoryBackend) MigrateStatus(id string) (MigrateStatusWire, error) { + b.mu.Lock("MigrateStatus") + defer b.mu.Unlock() + + b.settleAll() + + c, aerr := b.liveCluster(id) + if aerr != nil { + return MigrateStatusWire{}, aerr + } + + return MigrateStatusWire{ + State: def(c.MigrateState, "NOT_STARTED"), + TimeDecommissionScheduled: c.MigrateScheduled, + }, nil +} + +func (h *Handler) createCluster(q *req) error { + var in CreateClusterIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateCluster(in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getCluster(q *req) error { + w, etag, err := h.Backend.GetCluster(q.param("clusterId")) + + return reply(q, w, etag, err) +} + +func (h *Handler) listClusters(q *req) error { + pg, err := h.Backend.ListClusters(ClusterListIn{ + Compartment: q.query("compartmentId"), Name: q.query("name"), States: q.queryAll("lifecycleState"), + ListOpts: q.listOpts(), + }) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateCluster(q *req) error { + var in UpdateClusterIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateCluster(q.param("clusterId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteCluster(q *req) error { + wr, err := h.Backend.DeleteCluster(q.param("clusterId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) migrateNativeVcn(q *req) error { + var in MigrateIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.MigrateToNativeVcn(q.param("clusterId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) migrateStatus(q *req) error { + w, err := h.Backend.MigrateStatus(q.param("clusterId")) + + return reply(q, w, "", err) +} + +func (h *Handler) getClusterOptions(q *req) error { + all, _ := strconv.ParseBool(q.query("shouldListAllPatchVersions")) + w, err := h.Backend.GetClusterOptions(q.param("clusterOptionId"), all) + + return reply(q, w, "", err) +} diff --git a/services/ocicontainerengine/docker_test.go b/services/ocicontainerengine/docker_test.go new file mode 100644 index 0000000000..ba5dcec1c4 --- /dev/null +++ b/services/ocicontainerengine/docker_test.go @@ -0,0 +1,183 @@ +package ocicontainerengine_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "io" + "net/http" + "os/exec" + "strings" + "sync" + "testing" + "time" + + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + + oke "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" +) + +const k3sStartWait = 6 * time.Minute + +type recordingRuntime struct { + container.Runtime + ids []string + mu sync.Mutex +} + +func (r *recordingRuntime) CreateAndStart(ctx context.Context, spec container.Spec) (string, error) { + id, err := r.Runtime.CreateAndStart(ctx, spec) + if err == nil { + r.mu.Lock() + r.ids = append(r.ids, id) + r.mu.Unlock() + } + + return id, err +} + +func (r *recordingRuntime) containerIDs() []string { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]string(nil), r.ids...) +} + +func dockerHasContainer(t *testing.T, id string) bool { + t.Helper() + + out, err := exec.CommandContext(t.Context(), "docker", "ps", "-aq", "--no-trunc", "--filter", "id="+id).Output() + require.NoError(t, err) + + return strings.TrimSpace(string(out)) != "" +} + +func kubeGet(t *testing.T, server, caB64, token, path string) (int, string) { + t.Helper() + + pemBytes, err := base64.StdEncoding.DecodeString(caB64) + require.NoError(t, err) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(pemBytes), "certificate-authority-data must be base64 PEM") + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + DisableKeepAlives: true, + } + defer tr.CloseIdleConnections() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server+path, nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := (&http.Client{Transport: tr}).Do(req) + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(body) +} + +// TestDockerClusterRealK3s starts a real k3s container and talks to its API server with the CA and token from +// CreateKubeconfig. It skips when no usable container runtime is present. +func TestDockerClusterRealK3s(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("k3s start is slow") + } + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + + rt, err := container.NewRuntime(container.Config{}) + if err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + if err = rt.Ping(t.Context()); err != nil { + t.Skipf("container daemon unreachable: %v", err) + } + + image, _ := k3sengine.Image("1.34", "1.34") + + probe, err := rt.CreateAndStart( + t.Context(), + container.Spec{Image: image, Entrypoint: []string{"/bin/true"}, Privileged: true}, + ) + if err != nil { + t.Skipf("privileged containers unavailable: %v", err) + } + + _ = rt.StopAndRemove(t.Context(), probe) + + rec := &recordingRuntime{Runtime: rt} + e := newEnv(t, envOpts{engine: func(b *oke.InMemoryBackend) { + require.NoError(t, b.EnableEngine(oke.EngineConfig{Runtime: rec, StartTimeout: k3sStartWait})) + }}) + + _, err = e.ce.CreateCluster( + t.Context(), + ke.CreateClusterRequest{CreateClusterDetails: clusterDetails("real")}, + ) + require.NoError(t, err) + + list, err := e.ce.ListClusters( + t.Context(), + ke.ListClustersRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + id := list.Items[0].Id + + require.Eventually(t, func() bool { + c, gerr := e.ce.GetCluster(t.Context(), ke.GetClusterRequest{ClusterId: id}) + require.NoError(t, gerr) + require.NotEqual(t, ke.ClusterLifecycleStateFailed, c.LifecycleState, c.LifecycleDetails) + + return c.LifecycleState == ke.ClusterLifecycleStateActive + }, k3sStartWait, time.Second) + + resp, err := e.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ClusterId: id}) + require.NoError(t, err) + + kc := readKubeconfig(t, resp) + require.Len(t, kc.Clusters, 1) + require.True( + t, + strings.HasPrefix(kc.Clusters[0].Cluster.Server, "https://127.0.0.1:"), + kc.Clusters[0].Cluster.Server, + ) + + token := kc.Users[0].User.Token + assert.Equal(t, e.backend.EngineToken(), token) + + code, body := kubeGet(t, kc.Clusters[0].Cluster.Server, kc.Clusters[0].Cluster.CA, token, "/api/v1/namespaces") + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, body, "kube-system") + + code, _ = kubeGet(t, kc.Clusters[0].Cluster.Server, kc.Clusters[0].Cluster.CA, "wrong-token", "/api/v1/namespaces") + assert.Equal(t, http.StatusUnauthorized, code) + + ids := rec.containerIDs() + require.Len(t, ids, 1) + require.True(t, dockerHasContainer(t, ids[0])) + + _, err = e.ce.DeleteCluster(t.Context(), ke.DeleteClusterRequest{ClusterId: id}) + require.NoError(t, err) + + require.Eventually(t, func() bool { return !dockerHasContainer(t, ids[0]) }, 2*time.Minute, time.Second) +} diff --git a/services/ocicontainerengine/engine.go b/services/ocicontainerengine/engine.go new file mode 100644 index 0000000000..96fa8f597c --- /dev/null +++ b/services/ocicontainerengine/engine.go @@ -0,0 +1,271 @@ +package ocicontainerengine + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "log/slog" + "sync" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const ( + joinTokenBytes = 16 + engineFallback = "1.34" +) + +// EngineConfig configures docker-backed clusters: each CreateCluster starts a single-node k3s container. +type EngineConfig struct { + Runtime k3sengine.Runtime + Ports *portalloc.Allocator + Logger *slog.Logger + Probe func(ctx context.Context, addr, token string) ([]byte, error) + Host string + Token string + Image string + StartTimeout time.Duration +} + +type liveCluster struct { + cancel context.CancelFunc + containerID string + lbHost []int + port int +} + +type clusterEngine struct { + core *k3sengine.Engine + live map[string]*liveCluster + image string + wg sync.WaitGroup +} + +// EnableEngine switches the backend to docker mode. Call it before serving. +func (b *InMemoryBackend) EnableEngine(cfg EngineConfig) error { + core, err := k3sengine.New(k3sengine.Config{ + Runtime: cfg.Runtime, Ports: cfg.Ports, Logger: cfg.Logger, Probe: cfg.Probe, Prefix: "oke", + Host: cfg.Host, Token: cfg.Token, StartTimeout: cfg.StartTimeout, + }) + if err != nil { + return err + } + + b.mu.Lock("EnableEngine") + defer b.mu.Unlock() + + b.engine = &clusterEngine{core: core, live: map[string]*liveCluster{}, image: cfg.Image} + + return nil +} + +// EngineToken is the cluster-admin bearer token of docker-engine clusters; empty without the engine. +func (b *InMemoryBackend) EngineToken() string { + b.mu.RLock("EngineToken") + defer b.mu.RUnlock() + + if b.engine == nil { + return "" + } + + return b.engine.core.Token() +} + +func (e *clusterEngine) imageFor(version string) string { + if e.image != "" { + return e.image + } + + img, _ := k3sengine.Image(engineImageVersion(version), engineFallback) + + return img +} + +func (e *clusterEngine) reap(lcs []*liveCluster) { + for _, lc := range lcs { + lc.cancel() + + if lc.containerID != "" { + e.wg.Go(func() { e.core.Remove(lc.containerID, append([]int{lc.port}, lc.lbHost...)...) }) + } + } +} + +func (b *InMemoryBackend) detachAllLocked() []*liveCluster { + if b.engine == nil { + return nil + } + + out := make([]*liveCluster, 0, len(b.engine.live)) + for k, lc := range b.engine.live { + out = append(out, lc) + delete(b.engine.live, k) + } + + return out +} + +// reapLocked releases the engine cluster of id; container removal runs in the background. +func (b *InMemoryBackend) reapLocked(id string) { + if b.engine == nil { + return + } + + if lc, ok := b.engine.live[id]; ok { + delete(b.engine.live, id) + b.engine.reap([]*liveCluster{lc}) + } +} + +func (b *InMemoryBackend) closeEngine() { + b.mu.Lock("closeEngine") + e := b.engine + lcs := b.detachAllLocked() + b.mu.Unlock() + + if e == nil { + return + } + + e.reap(lcs) + e.wg.Wait() + e.core.Close() +} + +func joinToken() string { + buf := make([]byte, joinTokenBytes) + _, _ = rand.Read(buf) + + return hex.EncodeToString(buf) +} + +// launchLocked registers the cluster with the engine and starts its container in the background. +func (b *InMemoryBackend) launchLocked(c *Cluster, wrID string) { + e := b.engine + ctx, cancel := context.WithCancel(b.runCtx) + lc := &liveCluster{cancel: cancel} + e.live[c.ID] = lc + id, image := c.ID, e.imageFor(c.Version) + + b.wg.Go(func() { b.runEngine(ctx, id, lc, image, wrID) }) +} + +func (b *InMemoryBackend) runEngine(ctx context.Context, id string, lc *liveCluster, image, wrID string) { + e := b.engine + + inst, err := e.core.Launch(ctx, k3sengine.Node{Name: ocidTail(id, longTail), Image: image, JoinToken: joinToken()}, + func(i *k3sengine.Instance) bool { return b.recordLive(id, lc, i) }) + if err != nil { + if !errors.Is(err, k3sengine.ErrSuperseded) { + b.failEngine(ctx, id, lc, wrID, err) + } + + return + } + + addr := e.core.Addr(inst.Port) + + ca, err := e.core.AwaitReady(ctx, addr, inst.CA.CertPEM) + if err != nil { + b.failEngine(ctx, id, lc, wrID, err) + + return + } + + b.readyEngine(id, lc, addr, ca, wrID) +} + +func (b *InMemoryBackend) recordLive(id string, lc *liveCluster, i *k3sengine.Instance) bool { + b.mu.Lock("recordLive") + defer b.mu.Unlock() + + if b.engine.live[id] != lc { + return false + } + + lc.containerID, lc.port, lc.lbHost = i.ContainerID, i.Port, i.LBHost + + return true +} + +// engineEndpoints points the cluster endpoints at the engine's API server. +func engineEndpoints(c *Cluster, addr string) { + if c.EndpointConfig == nil || c.EndpointConfig.SubnetID == "" { + c.Endpoints = Endpoints{Kubernetes: addr} + + return + } + + c.Endpoints.PrivateEndpoint, c.Endpoints.PublicEndpoint = addr, "" + if c.EndpointConfig.IsPublicIPEnabled != nil && *c.EndpointConfig.IsPublicIPEnabled { + c.Endpoints.PublicEndpoint = addr + } +} + +func (b *InMemoryBackend) readyEngine(id string, lc *liveCluster, addr string, ca []byte, wrID string) { + b.mu.Lock("readyEngine") + defer b.mu.Unlock() + + c, ok := b.clusters[id] + if !ok || b.engine.live[id] != lc { + return + } + + now := b.nowUTC() + engineEndpoints(c, addr) + c.CAPEM, c.Token = string(ca), b.engine.core.Token() + c.State, c.TransitionAt, c.Details = stateActive, nil, "" + + if wr, found := b.workReqs[wrID]; found { + wr.FinishAt = &now + } +} + +// failEngine marks a still-wanted cluster FAILED and releases its container. +func (b *InMemoryBackend) failEngine(ctx context.Context, id string, lc *liveCluster, wrID string, cause error) { + b.mu.Lock("failEngine") + + c, ok := b.clusters[id] + if !ok || b.engine.live[id] != lc { + b.mu.Unlock() + + return + } + + delete(b.engine.live, id) + + msg := "Cluster failed to start: " + cause.Error() + now := b.nowUTC() + c.State, c.Details, c.TransitionAt = stateFailed, msg, nil + + if wr, found := b.workReqs[wrID]; found { + wr.FinishAt, wr.Failure = &now, msg + } + + e := b.engine + b.mu.Unlock() + + e.core.Log(ctx).WarnContext(ctx, "ocicontainerengine: cluster failed to start", + "code", "CLUSTER_START_FAILED", "cluster", id, "error", cause) + e.reap([]*liveCluster{lc}) +} + +// relaunchLocked starts a fresh engine cluster for every restored cluster that still exists. +func (b *InMemoryBackend) relaunchLocked() { + if b.engine == nil { + return + } + + for _, c := range b.clusters { + if c.State == stateDeleted || c.State == stateDeleting || c.State == stateFailed { + continue + } + + c.State, c.TransitionAt = stateUpdating, nil + c.UpdatedByWR = b.newWR(wrClusterUpdate, c.CompartmentID, entityCluster, c.ID, actionUpdated, nil) + b.launchLocked(c, c.UpdatedByWR) + } +} diff --git a/services/ocicontainerengine/engine_test.go b/services/ocicontainerengine/engine_test.go new file mode 100644 index 0000000000..a1011af3ef --- /dev/null +++ b/services/ocicontainerengine/engine_test.go @@ -0,0 +1,310 @@ +package ocicontainerengine_test + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "sync" + "testing" + "time" + + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/k3sengine" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + + oke "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" +) + +const ( + testToken = "unit-test-cluster-token-0123" + testCA = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" + engineMax = 10 * time.Second + engineGap = 5 * time.Millisecond +) + +var errRuntimeDown = errors.New("runtime down") + +type fakeRuntime struct { + failErr error + specs []container.Spec + removed []string + mu sync.Mutex +} + +func (f *fakeRuntime) CreateAndStart(_ context.Context, spec container.Spec) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if f.failErr != nil { + return "", f.failErr + } + + f.specs = append(f.specs, spec) + + return "ctr-" + spec.Name, nil +} + +func (f *fakeRuntime) StopAndRemove(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.removed = append(f.removed, id) + + return nil +} + +func (f *fakeRuntime) created() []container.Spec { + f.mu.Lock() + defer f.mu.Unlock() + + return append([]container.Spec(nil), f.specs...) +} + +func (f *fakeRuntime) removedIDs() []string { + f.mu.Lock() + defer f.mu.Unlock() + + return append([]string(nil), f.removed...) +} + +type gate struct{ open chan struct{} } + +func (g *gate) probe(ctx context.Context, _, _ string) ([]byte, error) { + select { + case <-g.open: + return []byte(testCA), nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +type engineEnv struct { + *env + + rt *fakeRuntime + gate *gate + ports *portalloc.Allocator +} + +func newEngineEnv(t *testing.T, mod func(cfg *oke.EngineConfig, rt *fakeRuntime)) *engineEnv { + t.Helper() + + ports, err := portalloc.New(42000, 42100) + require.NoError(t, err) + + rt, g := &fakeRuntime{}, &gate{open: make(chan struct{})} + cfg := oke.EngineConfig{Runtime: rt, Ports: ports, Probe: g.probe, Token: testToken, StartTimeout: engineMax} + + if mod != nil { + mod(&cfg, rt) + } + + return &engineEnv{ + env: newEnv(t, envOpts{engine: func(b *oke.InMemoryBackend) { require.NoError(t, b.EnableEngine(cfg)) }}), + rt: rt, gate: g, ports: ports, + } +} + +func (e *engineEnv) cluster(t *testing.T, id *string) ke.GetClusterResponse { + t.Helper() + + got, err := e.ce.GetCluster(t.Context(), ke.GetClusterRequest{ClusterId: id}) + require.NoError(t, err) + + return got +} + +func (e *engineEnv) create(t *testing.T, name string) (*string, *string) { + t.Helper() + + resp, err := e.ce.CreateCluster( + t.Context(), + ke.CreateClusterRequest{CreateClusterDetails: clusterDetails(name)}, + ) + require.NoError(t, err) + + list, err := e.ce.ListClusters( + t.Context(), + ke.ListClustersRequest{CompartmentId: new(testCompartment), Name: new(name)}, + ) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + return list.Items[0].Id, resp.OpcWorkRequestId +} + +func TestEngineClusterLifecycle(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, nil) + id, wrID := e.create(t, "real") + + require.Eventually(t, func() bool { return len(e.rt.created()) == 1 }, engineMax, engineGap) + + spec := e.rt.created()[0] + assert.True(t, strings.HasPrefix(spec.Image, k3sengine.ImageRepo+":v1.34."), spec.Image) + assert.True(t, spec.Privileged) + assert.Contains(t, spec.Env, "GOPHERSTACK_EKS_ADMIN_AUTH="+testToken) + assert.Contains(t, spec.Name, "gopherstack-oke-") + + e.settle() + assert.Equal(t, ke.ClusterLifecycleStateCreating, e.cluster(t, id).LifecycleState, + "the cluster turns ACTIVE when the API server answers, not on the clock") + + wr, err := e.ce.GetWorkRequest(t.Context(), ke.GetWorkRequestRequest{WorkRequestId: wrID}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestStatusInProgress, wr.Status) + + close(e.gate.open) + + require.Eventually(t, func() bool { + return e.cluster(t, id).LifecycleState == ke.ClusterLifecycleStateActive + }, engineMax, engineGap) + + c := e.cluster(t, id).Cluster + assert.Regexp(t, `^127\.0\.0\.1:42\d{3}$`, *c.Endpoints.PrivateEndpoint) + assert.Equal(t, *c.Endpoints.PrivateEndpoint, *c.Endpoints.PublicEndpoint) + assert.Equal(t, testToken, e.backend.EngineToken()) + + wr, err = e.ce.GetWorkRequest(t.Context(), ke.GetWorkRequestRequest{WorkRequestId: wrID}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestStatusSucceeded, wr.Status) + + kc, err := e.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ClusterId: id}) + require.NoError(t, err) + + cfg := readKubeconfig(t, kc) + assert.Equal(t, "https://"+*c.Endpoints.PublicEndpoint, cfg.Clusters[0].Cluster.Server) + assert.Equal(t, testToken, cfg.Users[0].User.Token) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(testCA)), cfg.Clusters[0].Cluster.CA) + + _, err = e.ce.DeleteCluster(t.Context(), ke.DeleteClusterRequest{ClusterId: id}) + require.NoError(t, err) + + require.Eventually(t, func() bool { return len(e.rt.removedIDs()) == 1 }, engineMax, engineGap) + assert.Equal(t, "ctr-"+spec.Name, e.rt.removedIDs()[0]) +} + +func TestEngineStartFailures(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(cfg *oke.EngineConfig, rt *fakeRuntime) + name string + want string + }{ + { + name: "container start", want: "runtime down", + mod: func(_ *oke.EngineConfig, rt *fakeRuntime) { rt.failErr = errRuntimeDown }, + }, + { + name: "api server never ready", want: "API server not ready", + mod: func(cfg *oke.EngineConfig, _ *fakeRuntime) { + cfg.StartTimeout = 20 * time.Millisecond + cfg.Probe = func(context.Context, string, string) ([]byte, error) { return nil, errRuntimeDown } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, tt.mod) + id, wrID := e.create(t, "bad") + + require.Eventually(t, func() bool { + return e.cluster(t, id).LifecycleState == ke.ClusterLifecycleStateFailed + }, engineMax, engineGap) + + c := e.cluster(t, id) + assert.Contains(t, *c.LifecycleDetails, tt.want) + + wr, err := e.ce.GetWorkRequest(t.Context(), ke.GetWorkRequestRequest{WorkRequestId: wrID}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestStatusFailed, wr.Status) + + errs, err := e.ce.ListWorkRequestErrors( + t.Context(), + ke.ListWorkRequestErrorsRequest{CompartmentId: new(testCompartment), WorkRequestId: wrID}, + ) + require.NoError(t, err) + require.Len(t, errs.Items, 1) + assert.Contains(t, *errs.Items[0].Message, tt.want) + + _, err = e.ce.DeleteCluster(t.Context(), ke.DeleteClusterRequest{ClusterId: id}) + require.NoError(t, err, "FAILED clusters can be deleted") + }) + } +} + +func TestEngineRestoreRelaunches(t *testing.T) { + t.Parallel() + + e := newEngineEnv(t, nil) + close(e.gate.open) + + id, _ := e.create(t, "again") + + require.Eventually(t, func() bool { + return e.cluster(t, id).LifecycleState == ke.ClusterLifecycleStateActive + }, engineMax, engineGap) + + snap := e.backend.Snapshot(t.Context()) + require.NoError(t, e.backend.Restore(t.Context(), snap)) + + require.Eventually(t, func() bool { return len(e.rt.created()) == 2 }, engineMax, engineGap) + require.Eventually(t, func() bool { + c := e.cluster(t, id) + + return c.LifecycleState == ke.ClusterLifecycleStateActive && len(e.rt.removedIDs()) == 1 + }, engineMax, engineGap) +} + +func TestEngineConfigValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + want error + name string + cfg oke.EngineConfig + }{ + { + name: "bad host", + cfg: oke.EngineConfig{Host: "bad host!", Token: testToken}, + want: k3sengine.ErrInvalidConfig, + }, + {name: "short token", cfg: oke.EngineConfig{Token: "short"}, want: k3sengine.ErrInvalidConfig}, + { + name: "default token off host", + cfg: oke.EngineConfig{Host: "10.1.2.3"}, + want: k3sengine.ErrDefaultTokenExposed, + }, + {name: "explicit token off host", cfg: oke.EngineConfig{Host: "10.1.2.3", Token: testToken}}, + {name: "loopback default token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.cfg.Runtime = &fakeRuntime{} + b := oke.NewInMemoryBackend() + t.Cleanup(b.Close) + + err := b.EnableEngine(tt.cfg) + if tt.want == nil { + require.NoError(t, err) + + return + } + + require.ErrorIs(t, err, tt.want) + }) + } +} diff --git a/services/ocicontainerengine/errors.go b/services/ocicontainerengine/errors.go new file mode 100644 index 0000000000..16d714d598 --- /dev/null +++ b/services/ocicontainerengine/errors.go @@ -0,0 +1,43 @@ +package ocicontainerengine + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocicontainerengine: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } diff --git a/services/ocicontainerengine/handler.go b/services/ocicontainerengine/handler.go new file mode 100644 index 0000000000..82ccbad374 --- /dev/null +++ b/services/ocicontainerengine/handler.go @@ -0,0 +1,367 @@ +package ocicontainerengine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Minute + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Container Engine for Kubernetes (OKE). +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocicontainerengine.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIContainerEngine" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIContainerEngine runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"clusterId", "nodePoolId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocicontainerengine: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) queryAll(k string) []string { return q.r.URL.Query()[k] } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + if err := json.Unmarshal(q.body, v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocicontainerengine: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIContainerEngine requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocicontainerengine: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIContainerEngine", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ocicontainerengine", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocicontainerengine: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocicontainerengine: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + h.Backend.Close() + + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocicontainerengine: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocicontainerengine: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() + h.Backend.Close() +} diff --git a/services/ocicontainerengine/helpers_test.go b/services/ocicontainerengine/helpers_test.go new file mode 100644 index 0000000000..5d56c80776 --- /dev/null +++ b/services/ocicontainerengine/helpers_test.go @@ -0,0 +1,351 @@ +package ocicontainerengine_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + oke "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + + vcnA = "ocid1.vcn.oc1.iad.vcna" + vcnB = "ocid1.vcn.oc1.iad.vcnb" + subAD1 = "ocid1.subnet.oc1.iad.subad1" + subAD2 = "ocid1.subnet.oc1.iad.subad2" + subRegional = "ocid1.subnet.oc1.iad.subregional" + subLB = "ocid1.subnet.oc1.iad.sublb" + subOther = "ocid1.subnet.oc1.iad.subother" + ad1 = "Uocm:US-ASHBURN-AD-1" + ad2 = "Uocm:US-ASHBURN-AD-2" + imgOL8 = "ocid1.image.oc1.iad.ol8" + imgOL8Arm = "ocid1.image.oc1.iad.ol8arm" + imgUbuntu = "ocid1.image.oc1.iad.ubuntu" + shapeFlex = "VM.Standard.E4.Flex" + shapeFixed = "VM.Standard2.1" + shapeArm = "VM.Standard.A1.Flex" + testVersion = "v1.34.2" + keyOK = "ocid1.key.oc1.iad.keyok" + keyOff = "ocid1.key.oc1.iad.keyoff" +) + +type fakeNet struct{} + +func fakeSubnets() map[string]oke.SubnetInfo { + return map[string]oke.SubnetInfo{ + subAD1: {ID: subAD1, VcnID: vcnA, AvailabilityDomain: ad1, CIDR: "10.0.1.0/24"}, + subAD2: {ID: subAD2, VcnID: vcnA, AvailabilityDomain: ad2, CIDR: "10.0.2.0/24"}, + subRegional: {ID: subRegional, VcnID: vcnA, CIDR: "10.0.3.0/24"}, + subLB: {ID: subLB, VcnID: vcnA, CIDR: "10.0.4.0/24"}, + subOther: {ID: subOther, VcnID: vcnB, CIDR: "10.1.0.0/24"}, + } +} + +func fakeShapes() []oke.ShapeInfo { + return []oke.ShapeInfo{ + { + Name: shapeFlex, Arch: "x86_64", Flexible: true, MinOcpus: 1, MaxOcpus: 64, MaxMemoryGBs: 1024, + DefaultMemPerOcpu: 16, + }, + {Name: shapeFixed, Arch: "x86_64"}, + { + Name: shapeArm, Arch: "aarch64", Flexible: true, MinOcpus: 1, MaxOcpus: 76, MaxMemoryGBs: 472, + DefaultMemPerOcpu: 6, + }, + } +} + +func fakeImages() []oke.ImageInfo { + return []oke.ImageInfo{ + {ID: imgOL8, Name: "Oracle-Linux-8.10-2024.09.30-0", OS: "Oracle Linux", OSVersion: "8", Arch: "x86_64"}, + { + ID: imgOL8Arm, Name: "Oracle-Linux-8.10-aarch64-2024.09.30-0", OS: "Oracle Linux", OSVersion: "8", + Arch: "aarch64", + }, + { + ID: imgUbuntu, Name: "Canonical-Ubuntu-22.04-2024.09.30-0", OS: "Canonical Ubuntu", OSVersion: "22.04", + Arch: "x86_64", + }, + } +} + +func (fakeNet) Subnet(id string) (oke.SubnetInfo, bool) { + s, ok := fakeSubnets()[id] + + return s, ok +} + +func (fakeNet) VCNExists(id string) bool { return id == vcnA || id == vcnB } +func (fakeNet) Shapes() []oke.ShapeInfo { return fakeShapes() } +func (fakeNet) Images() []oke.ImageInfo { return fakeImages() } + +func (fakeNet) Shape(name string) (oke.ShapeInfo, bool) { + for _, s := range fakeShapes() { + if s.Name == name { + return s, true + } + } + + return oke.ShapeInfo{}, false +} + +func (fakeNet) Image(id string) (oke.ImageInfo, bool) { + for _, i := range fakeImages() { + if i.ID == id { + return i, true + } + } + + return oke.ImageInfo{}, false +} + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *oke.InMemoryBackend + handler *oke.Handler + clock *fakeClock + prov common.ConfigurationProvider + kp keyPair + ce ke.ContainerEngineClient + base string +} + +type envOpts struct { + engine func(*oke.InMemoryBackend) + enforce bool + register bool + noNet bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := oke.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + if !opts.noNet { + be.SetNetwork(fakeNet{}) + } + + be.SetKeyChecker(func(id string) (string, bool) { + switch id { + case keyOK: + return "ENABLED", true + case keyOff: + return "DISABLED", true + } + + return "", false + }) + + if opts.engine != nil { + opts.engine(be) + } + + h := oke.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{ + backend: be, handler: h, clock: clock, kp: kp, + prov: common.NewRawConfigurationProvider(testTenancy, testUser, oke.Region, kp.fprint, kp.privPEM, nil), + base: fmt.Sprintf("http://localhost:%d", h.Port), + } + + var err error + + e.ce, err = ke.NewContainerEngineClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.ce.Host = e.base + + return e +} + +// settle advances past the CREATING/UPDATING/DELETING transition. +func (e *env) settle() { e.clock.Advance(5 * time.Second) } + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +func clusterDetails(name string) ke.CreateClusterDetails { + return ke.CreateClusterDetails{ + Name: new(name), CompartmentId: new(testCompartment), VcnId: new(vcnA), KubernetesVersion: new(testVersion), + EndpointConfig: &ke.CreateClusterEndpointConfigDetails{ + SubnetId: new(subRegional), IsPublicIpEnabled: new(true), + }, + } +} + +// mkCluster creates a cluster and settles it to ACTIVE. +func (e *env) mkCluster( + t *testing.T, + name string, + mod func(*ke.CreateClusterDetails), +) ke.Cluster { + t.Helper() + + d := clusterDetails(name) + if mod != nil { + mod(&d) + } + + resp, err := e.ce.CreateCluster(t.Context(), ke.CreateClusterRequest{CreateClusterDetails: d}) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + e.settle() + + list, err := e.ce.ListClusters(t.Context(), ke.ListClustersRequest{ + CompartmentId: new(testCompartment), Name: new(name), + }) + require.NoError(t, err) + require.NotEmpty(t, list.Items) + + got, err := e.ce.GetCluster(t.Context(), ke.GetClusterRequest{ClusterId: list.Items[0].Id}) + require.NoError(t, err) + + return got.Cluster +} + +func poolDetails(clusterID *string, name string) ke.CreateNodePoolDetails { + return ke.CreateNodePoolDetails{ + CompartmentId: new(testCompartment), ClusterId: clusterID, Name: new(name), NodeShape: new(shapeFixed), + NodeSourceDetails: ke.NodeSourceViaImageDetails{ImageId: new(imgOL8)}, + NodeConfigDetails: &ke.CreateNodePoolNodeConfigDetails{ + Size: new(3), + PlacementConfigs: []ke.NodePoolPlacementConfigDetails{ + {AvailabilityDomain: new(ad1), SubnetId: new(subAD1)}, + {AvailabilityDomain: new(ad2), SubnetId: new(subAD2)}, + }, + }, + } +} + +func (e *env) mkPool( + t *testing.T, + clusterID *string, + name string, + mod func(*ke.CreateNodePoolDetails), +) ke.NodePool { + t.Helper() + + d := poolDetails(clusterID, name) + if mod != nil { + mod(&d) + } + + resp, err := e.ce.CreateNodePool(t.Context(), ke.CreateNodePoolRequest{CreateNodePoolDetails: d}) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + e.settle() + + list, err := e.ce.ListNodePools(t.Context(), ke.ListNodePoolsRequest{ + CompartmentId: new(testCompartment), ClusterId: clusterID, Name: new(name), + }) + require.NoError(t, err) + require.NotEmpty(t, list.Items) + + got, err := e.ce.GetNodePool(t.Context(), ke.GetNodePoolRequest{NodePoolId: list.Items[0].Id}) + require.NoError(t, err) + + return got.NodePool +} + +func quote(s string) string { + b, _ := json.Marshal(s) + + return string(b) +} diff --git a/services/ocicontainerengine/inproc.go b/services/ocicontainerengine/inproc.go new file mode 100644 index 0000000000..49ce7385af --- /dev/null +++ b/services/ocicontainerengine/inproc.go @@ -0,0 +1,26 @@ +package ocicontainerengine + +import "slices" + +// Compartments lists the distinct compartments that hold clusters. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, c := range b.clusters { + if c.State != stateDeleted { + seen[c.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} diff --git a/services/ocicontainerengine/kubeconfig.go b/services/ocicontainerengine/kubeconfig.go new file mode 100644 index 0000000000..f77247efba --- /dev/null +++ b/services/ocicontainerengine/kubeconfig.go @@ -0,0 +1,122 @@ +package ocicontainerengine + +import ( + "encoding/base64" + "net/http" + "strings" +) + +const ( + endpointLegacy = "LEGACY_KUBERNETES" + endpointPublic = "PUBLIC_ENDPOINT" + endpointPrivate = "PRIVATE_ENDPOINT" + endpointVcnHost = "VCN_HOSTNAME" + tokenVersion2 = "2.0.0" +) + +// KubeconfigIn is the CreateKubeconfig request body. +type KubeconfigIn struct { + TokenVersion *string `json:"tokenVersion"` + Expiration *int `json:"expiration"` + Endpoint string `json:"endpoint"` +} + +func pickEndpoint(c *Cluster, want string) (string, *APIError) { + ep := c.Endpoints + + switch want { + case endpointLegacy: + return ep.Kubernetes, nil + case endpointPublic: + return ep.PublicEndpoint, nil + case endpointPrivate: + return ep.PrivateEndpoint, nil + case endpointVcnHost: + return ep.VcnHostnameEndpoint, nil + case "": + for _, v := range []string{ep.Kubernetes, ep.PublicEndpoint, ep.PrivateEndpoint} { + if v != "" { + return v, nil + } + } + + return "", nil + } + + return "", errParam("endpoint must be one of " + strings.Join( + []string{endpointLegacy, endpointPublic, endpointPrivate, endpointVcnHost}, ", ")) +} + +// CreateKubeconfig renders a kubeconfig for the cluster. The emulator authenticates with a static bearer +// token instead of the `oci ce cluster generate-token` exec plugin real OKE kubeconfigs use. +func (b *InMemoryBackend) CreateKubeconfig(id string, in KubeconfigIn) ([]byte, error) { + b.mu.Lock("CreateKubeconfig") + defer b.mu.Unlock() + + b.settleAll() + + c, aerr := b.liveCluster(id) + if aerr != nil { + return nil, aerr + } + + if in.TokenVersion != nil && *in.TokenVersion != tokenVersion2 { + return nil, errParam("tokenVersion must be " + tokenVersion2) + } + + if in.Expiration != nil && *in.Expiration <= 0 { + return nil, errParam("expiration must be a positive number of seconds") + } + + if c.State != stateActive && c.State != stateUpdating { + return nil, errState("Cluster " + id + " is in state " + c.State + "; a kubeconfig requires ACTIVE") + } + + server, aerr := pickEndpoint(c, in.Endpoint) + if aerr != nil { + return nil, aerr + } + + if server == "" { + return nil, errParam("Cluster " + id + " has no " + def(in.Endpoint, "reachable") + " endpoint") + } + + return renderKubeconfig(c, server), nil +} + +func renderKubeconfig(c *Cluster, server string) []byte { + tail := ocidTail(c.ID, longTail) + ca := base64.StdEncoding.EncodeToString([]byte(c.CAPEM)) + + var sb strings.Builder + + sb.WriteString("apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n") + sb.WriteString(" certificate-authority-data: " + ca + "\n") + sb.WriteString(" server: https://" + server + "\n") + sb.WriteString(" name: cluster-" + tail + "\ncontexts:\n- context:\n") + sb.WriteString(" cluster: cluster-" + tail + "\n user: user-" + tail + "\n") + sb.WriteString(" name: context-" + tail + "\ncurrent-context: context-" + tail + "\npreferences: {}\nusers:\n") + sb.WriteString("- name: user-" + tail + "\n user:\n token: " + c.Token + "\n") + + return []byte(sb.String()) +} + +func (h *Handler) createKubeconfig(q *req) error { + var in KubeconfigIn + if len(q.body) > 0 { + if err := q.decode(&in); err != nil { + return err + } + } + + out, err := h.Backend.CreateKubeconfig(q.param("clusterId"), in) + if err != nil { + return err + } + + q.header("Content-Type", "application/yaml") + q.w.WriteHeader(http.StatusOK) + _, _ = q.w.Write(out) + + return nil +} diff --git a/services/ocicontainerengine/kubeconfig_test.go b/services/ocicontainerengine/kubeconfig_test.go new file mode 100644 index 0000000000..f7ab670b65 --- /dev/null +++ b/services/ocicontainerengine/kubeconfig_test.go @@ -0,0 +1,202 @@ +package ocicontainerengine_test + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "io" + "net/http" + "testing" + + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +type kubeconfig struct { + Kind string `yaml:"kind"` + CurrentContext string `yaml:"current-context"` + Clusters []struct { + Name string `yaml:"name"` + Cluster struct { + Server string `yaml:"server"` + CA string `yaml:"certificate-authority-data"` + } `yaml:"cluster"` + } `yaml:"clusters"` + Users []struct { + Name string `yaml:"name"` + User struct { + Token string `yaml:"token"` + } `yaml:"user"` + } `yaml:"users"` + Contexts []struct { + Name string `yaml:"name"` + Context struct { + Cluster string `yaml:"cluster"` + User string `yaml:"user"` + } `yaml:"context"` + } `yaml:"contexts"` +} + +func readKubeconfig(t *testing.T, resp ke.CreateKubeconfigResponse) kubeconfig { + t.Helper() + + defer resp.Content.Close() + + raw, err := io.ReadAll(resp.Content) + require.NoError(t, err) + + var kc kubeconfig + require.NoError(t, yaml.Unmarshal(raw, &kc)) + + return kc +} + +func TestCreateKubeconfig(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + native := e.mkCluster(t, "kc-native", nil) + private := e.mkCluster( + t, + "kc-private", + func(d *ke.CreateClusterDetails) { d.EndpointConfig.IsPublicIpEnabled = new(false) }, + ) + legacy := e.mkCluster(t, "kc-legacy", func(d *ke.CreateClusterDetails) { d.EndpointConfig = nil }) + + tests := []struct { + name string + cluster ke.Cluster + endpoint ke.CreateClusterKubeconfigContentDetailsEndpointEnum + wantServer string + wantErr int + }{ + {"default prefers public", native, "", "https://" + *native.Endpoints.PublicEndpoint, 0}, + { + "public", + native, + ke.CreateClusterKubeconfigContentDetailsEndpointPublicEndpoint, + "https://" + *native.Endpoints.PublicEndpoint, + 0, + }, + { + "private", + native, + ke.CreateClusterKubeconfigContentDetailsEndpointPrivateEndpoint, + "https://" + *native.Endpoints.PrivateEndpoint, + 0, + }, + {"default private only", private, "", "https://" + *private.Endpoints.PrivateEndpoint, 0}, + { + "no public endpoint", + private, + ke.CreateClusterKubeconfigContentDetailsEndpointPublicEndpoint, + "", + http.StatusBadRequest, + }, + { + "legacy", + legacy, + ke.CreateClusterKubeconfigContentDetailsEndpointLegacyKubernetes, + "https://" + *legacy.Endpoints.Kubernetes, + 0, + }, + {"legacy default", legacy, "", "https://" + *legacy.Endpoints.Kubernetes, 0}, + { + "native has no legacy", + native, + ke.CreateClusterKubeconfigContentDetailsEndpointLegacyKubernetes, + "", + http.StatusBadRequest, + }, + { + "no vcn hostname", + native, + ke.CreateClusterKubeconfigContentDetailsEndpointVcnHostname, + "", + http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ + ClusterId: tt.cluster.Id, + Endpoint: tt.endpoint, + }) + if tt.wantErr != 0 { + assert.Equal(t, tt.wantErr, svcErr(t, err).GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + + kc := readKubeconfig(t, resp) + assert.Equal(t, "Config", kc.Kind) + require.Len(t, kc.Clusters, 1) + require.Len(t, kc.Users, 1) + require.Len(t, kc.Contexts, 1) + assert.Equal(t, tt.wantServer, kc.Clusters[0].Cluster.Server) + assert.Equal(t, kc.Contexts[0].Name, kc.CurrentContext) + assert.Equal(t, kc.Clusters[0].Name, kc.Contexts[0].Context.Cluster) + assert.Equal(t, kc.Users[0].Name, kc.Contexts[0].Context.User) + assert.Regexp(t, `^gopherstack-oke-[0-9a-f]{48}$`, kc.Users[0].User.Token) + + caPEM, err := base64.StdEncoding.DecodeString(kc.Clusters[0].Cluster.CA) + require.NoError(t, err) + + block, _ := pem.Decode(caPEM) + require.NotNil(t, block) + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.True(t, cert.IsCA) + }) + } +} + +func TestKubeconfigStableAndValidated(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "kc-stable", nil) + + first, err := e.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ClusterId: c.Id}) + require.NoError(t, err) + + second, err := e.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ + ClusterId: c.Id, + TokenVersion: new("2.0.0"), Expiration: new(600), + }) + require.NoError(t, err) + assert.Equal(t, readKubeconfig(t, first), readKubeconfig(t, second), "CA and token are stable per cluster") + + tests := []struct { + name string + in ke.CreateClusterKubeconfigContentDetails + }{ + {"bad token version", ke.CreateClusterKubeconfigContentDetails{TokenVersion: new("1.0.0")}}, + {"bad expiration", ke.CreateClusterKubeconfigContentDetails{Expiration: new(0)}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, rerr := e.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ + ClusterId: c.Id, CreateClusterKubeconfigContentDetails: tt.in, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, rerr).GetHTTPStatusCode()) + }) + } + + _, err = e.ce.CreateKubeconfig( + t.Context(), + ke.CreateKubeconfigRequest{ClusterId: new("ocid1.cluster.oc1.iad.nope")}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} diff --git a/services/ocicontainerengine/leak_main_test.go b/services/ocicontainerengine/leak_main_test.go new file mode 100644 index 0000000000..79db27f2b3 --- /dev/null +++ b/services/ocicontainerengine/leak_main_test.go @@ -0,0 +1,18 @@ +package ocicontainerengine_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ocicontainerengine/lifecycle.go b/services/ocicontainerengine/lifecycle.go new file mode 100644 index 0000000000..2fd93894b0 --- /dev/null +++ b/services/ocicontainerengine/lifecycle.go @@ -0,0 +1,86 @@ +package ocicontainerengine + +import "time" + +const ( + migrateInProgress = "IN_PROGRESS" + migratePending = "PENDING_DECOMMISSION" + migrateCompleted = "COMPLETED" +) + +// Sweep applies due lifecycle transitions against the backend clock. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + b.settleAll() +} + +func due(t *time.Time, now time.Time) bool { return t != nil && !t.After(now) } + +// settleAll moves every resource whose transition window has passed to its final state; callers hold the lock. +func (b *InMemoryBackend) settleAll() { + now := b.nowUTC() + + for _, c := range b.clusters { + b.settleCluster(c, now) + } + + for _, np := range b.pools { + settlePool(np, now) + } +} + +func (b *InMemoryBackend) settleCluster(c *Cluster, now time.Time) { + if due(c.TransitionAt, now) { + at := *c.TransitionAt + c.TransitionAt = nil + + switch c.State { + case stateCreating, stateUpdating: + c.State = stateActive + if c.MigrateState == migrateInProgress { + c.MigrateState = migratePending + } + case stateDeleting: + c.State, c.TimeDeleted = stateDeleted, &at + } + } + + if c.MigrateState == migratePending && due(c.MigrateScheduled, now) { + c.MigrateState, c.Endpoints.Kubernetes = migrateCompleted, "" + } +} + +func settlePool(np *NodePool, now time.Time) { + if due(np.TransitionAt, now) { + np.TransitionAt = nil + + switch np.State { + case stateCreating, stateUpdating: + np.State = stateActive + case stateDeleting: + np.State = stateDeleted + } + } + + kept := np.Nodes[:0] + + for _, n := range np.Nodes { + if due(n.TransitionAt, now) { + n.TransitionAt = nil + + switch n.State { + case stateCreating, stateUpdating: + n.State = stateActive + case stateDeleting: + continue + } + } + + kept = append(kept, n) + } + + clear(np.Nodes[len(kept):]) + np.Nodes = kept +} diff --git a/services/ocicontainerengine/models.go b/services/ocicontainerengine/models.go new file mode 100644 index 0000000000..2790480923 --- /dev/null +++ b/services/ocicontainerengine/models.go @@ -0,0 +1,239 @@ +package ocicontainerengine + +import ( + "encoding/json" + "time" +) + +// Cluster, node pool, node and work request states. +const ( + stateActive = "ACTIVE" + stateCreating = "CREATING" + stateUpdating = "UPDATING" + stateDeleting = "DELETING" + stateDeleted = "DELETED" + stateFailed = "FAILED" +) + +// Cluster types and pod network (CNI) types. +const ( + typeBasic = "BASIC_CLUSTER" + typeEnhanced = "ENHANCED_CLUSTER" + cniFlannel = "FLANNEL_OVERLAY" + cniNative = "OCI_VCN_IP_NATIVE" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// EndpointConfig is the cluster API endpoint placement. +type EndpointConfig struct { + IsPublicIPEnabled *bool `json:"isPublicIpEnabled,omitempty"` + SecurityAttributes map[string]map[string]any `json:"securityAttributes,omitempty"` + SubnetID string `json:"subnetId,omitempty"` + NsgIDs []string `json:"nsgIds,omitempty"` +} + +// Endpoints are the reachable Kubernetes API server addresses. +type Endpoints struct { + Kubernetes string `json:"kubernetes,omitempty"` + PublicEndpoint string `json:"publicEndpoint,omitempty"` + PrivateEndpoint string `json:"privateEndpoint,omitempty"` + VcnHostnameEndpoint string `json:"vcnHostnameEndpoint,omitempty"` +} + +// NetworkConfig is kubernetesNetworkConfig. +type NetworkConfig struct { + PodsCidr string `json:"podsCidr,omitempty"` + ServicesCidr string `json:"servicesCidr,omitempty"` +} + +// AddOns is the options.addOns block. +type AddOns struct { + IsKubernetesDashboardEnabled *bool `json:"isKubernetesDashboardEnabled,omitempty"` + IsTillerEnabled *bool `json:"isTillerEnabled,omitempty"` +} + +// Admission is options.admissionControllerOptions. +type Admission struct { + IsPodSecurityPolicyEnabled *bool `json:"isPodSecurityPolicyEnabled,omitempty"` +} + +// TagCfg carries the tags applied to provisioned resources (persistent volumes, load balancers). +type TagCfg struct { + Tags + BackendNsgIDs []string `json:"backendNsgIds,omitempty"` +} + +// ClusterOptions is the cluster create and update options block. +type ClusterOptions struct { + K8sNetwork *NetworkConfig `json:"kubernetesNetworkConfig,omitempty"` + AddOns *AddOns `json:"addOns,omitempty"` + Admission *Admission `json:"admissionControllerOptions,omitempty"` + PersistentVolume *TagCfg `json:"persistentVolumeConfig,omitempty"` + ServiceLb *TagCfg `json:"serviceLbConfig,omitempty"` + OIDCAuth json.RawMessage `json:"openIdConnectTokenAuthenticationConfig,omitempty"` + OIDCDiscovery json.RawMessage `json:"openIdConnectDiscovery,omitempty"` + ServiceLbSubnetID []string `json:"serviceLbSubnetIds,omitempty"` + IPFamilies []string `json:"ipFamilies,omitempty"` +} + +// KeyDetail references an image signing key. +type KeyDetail struct { + KmsKeyID string `json:"kmsKeyId"` +} + +// ImagePolicy is imagePolicyConfig. +type ImagePolicy struct { + IsPolicyEnabled *bool `json:"isPolicyEnabled,omitempty"` + KeyDetails []KeyDetail `json:"keyDetails,omitempty"` +} + +// PodNet is a pod network option (cluster level: cniType only). +type PodNet struct { + MaxPodsPerNode *int `json:"maxPodsPerNode,omitempty"` + CniType string `json:"cniType"` + PodNsgIDs []string `json:"podNsgIds,omitempty"` + PodSubnetIDs []string `json:"podSubnetIds,omitempty"` +} + +// Cluster is a stored OKE cluster. +type Cluster struct { + TimeCreated time.Time `json:"timeCreated"` + Tags Tags `json:"tags"` + TimeUpdated *time.Time `json:"timeUpdated,omitempty"` + TimeDeleted *time.Time `json:"timeDeleted,omitempty"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + MigrateScheduled *time.Time `json:"migrateScheduled,omitempty"` + EndpointConfig *EndpointConfig `json:"endpointConfig,omitempty"` + Endpoints Endpoints `json:"endpoints"` + VcnID string `json:"vcnId,omitempty"` + Type string `json:"type"` + DeletedByWR string `json:"deletedByWr,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + UpdatedByWR string `json:"updatedByWr,omitempty"` + Version string `json:"version"` + KmsKeyID string `json:"kmsKeyId,omitempty"` + CreatedByWR string `json:"createdByWr"` + State string `json:"state"` + Details string `json:"details,omitempty"` + Etag string `json:"etag"` + CAPEM string `json:"caPem"` + Token string `json:"token"` + MigrateState string `json:"migrateState,omitempty"` + Options ClusterOptions `json:"options"` + ImagePolicy ImagePolicy `json:"imagePolicy"` + PodNetworks []PodNet `json:"podNetworks"` + Seq int64 `json:"seq"` +} + +// Placement is one nodeConfigDetails placement configuration. +type Placement struct { + PreemptibleConfig json.RawMessage `json:"preemptibleNodeConfig,omitempty"` + AvailabilityDom string `json:"availabilityDomain"` + SubnetID string `json:"subnetId"` + CapacityReserveID string `json:"capacityReservationId,omitempty"` + FaultDomains []string `json:"faultDomains,omitempty"` +} + +// NodeConfig is nodeConfigDetails. +type NodeConfig struct { + IsPvEncryption *bool `json:"isPvEncryptionInTransitEnabled,omitempty"` + PodNet *PodNet `json:"nodePoolPodNetworkOptionDetails,omitempty"` + Tags + KmsKeyID string `json:"kmsKeyId,omitempty"` + Placements []Placement `json:"placementConfigs"` + NsgIDs []string `json:"nsgIds,omitempty"` + Size int `json:"size"` +} + +// NodeSource is nodeSourceDetails. +type NodeSource struct { + BootVolumeGBs *int64 `json:"bootVolumeSizeInGBs,omitempty"` + SourceType string `json:"sourceType"` + ImageID string `json:"imageId"` +} + +// ShapeConfig is nodeShapeConfig. +type ShapeConfig struct { + Ocpus *float64 `json:"ocpus,omitempty"` + MemoryInGBs *float64 `json:"memoryInGBs,omitempty"` +} + +// KeyValue is an initial node label. +type KeyValue struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// Node is one worker node of a pool. +type Node struct { + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Tags Tags `json:"tags"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + AD string `json:"availabilityDomain"` + SubnetID string `json:"subnetId"` + FaultDomain string `json:"faultDomain"` + PrivateIP string `json:"privateIp,omitempty"` + State string `json:"state"` +} + +// NodePool is a stored node pool. +type NodePool struct { + TimeCreated time.Time `json:"timeCreated"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + NodeConfig *NodeConfig `json:"nodeConfig,omitempty"` + NodeSource *NodeSource `json:"nodeSource,omitempty"` + ShapeConfig *ShapeConfig `json:"shapeConfig,omitempty"` + QuantityPerSub *int `json:"quantityPerSubnet,omitempty"` + Tags Tags `json:"tags"` + NodeMetadata map[string]string `json:"nodeMetadata,omitempty"` + Eviction json.RawMessage `json:"eviction,omitempty"` + Cycling json.RawMessage `json:"cycling,omitempty"` + ID string `json:"id"` + ClusterID string `json:"clusterId"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + Version string `json:"version"` + Shape string `json:"shape"` + ImageName string `json:"imageName,omitempty"` + SSHKey string `json:"sshKey,omitempty"` + State string `json:"state"` + Etag string `json:"etag"` + Labels []KeyValue `json:"labels,omitempty"` + SubnetIDs []string `json:"subnetIds,omitempty"` + Nodes []*Node `json:"nodes"` + Seq int64 `json:"seq"` +} + +// WorkRequest records an asynchronous operation. FinishAt is nil while an engine start is pending. +type WorkRequest struct { + Accepted time.Time `json:"accepted"` + FinishAt *time.Time `json:"finishAt,omitempty"` + ID string `json:"id"` + Operation string `json:"operation"` + CompartmentID string `json:"compartmentId"` + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + EntityURI string `json:"entityUri"` + Action string `json:"action"` + Failure string `json:"failure,omitempty"` + Seq int64 `json:"seq"` +} diff --git a/services/ocicontainerengine/nodepool_test.go b/services/ocicontainerengine/nodepool_test.go new file mode 100644 index 0000000000..3ac9a4d619 --- /dev/null +++ b/services/ocicontainerengine/nodepool_test.go @@ -0,0 +1,558 @@ +package ocicontainerengine_test + +import ( + "net/http" + "testing" + + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNodePoolLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + c := e.mkCluster(t, "pools", nil) + + created, err := e.ce.CreateNodePool(ctx, ke.CreateNodePoolRequest{ + CreateNodePoolDetails: poolDetails(c.Id, "workers"), + }) + require.NoError(t, err) + + wr, err := e.ce.GetWorkRequest(ctx, ke.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestOperationTypeNodepoolCreate, wr.OperationType) + assert.Equal(t, ke.WorkRequestStatusInProgress, wr.Status) + + list, err := e.ce.ListNodePools(ctx, ke.ListNodePoolsRequest{ + CompartmentId: new(testCompartment), ClusterId: c.Id, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, ke.NodePoolLifecycleStateCreating, list.Items[0].LifecycleState) + + mid, err := e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: list.Items[0].Id}) + require.NoError(t, err) + require.Len(t, mid.Nodes, 3) + + for _, n := range mid.Nodes { + assert.Equal(t, ke.NodeLifecycleStateCreating, n.LifecycleState) + } + + e.settle() + + got, err := e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: list.Items[0].Id}) + require.NoError(t, err) + np := got.NodePool + assert.Equal(t, ke.NodePoolLifecycleStateActive, np.LifecycleState) + assert.Equal(t, testVersion, *np.KubernetesVersion, "defaults to the cluster version") + assert.Equal(t, imgOL8, *np.NodeImageId) + assert.Equal(t, "Oracle-Linux-8.10-2024.09.30-0", *np.NodeImageName) + assert.Equal(t, 3, *np.NodeConfigDetails.Size) + assert.ElementsMatch(t, []string{subAD1, subAD2}, np.SubnetIds) + require.Len(t, np.Nodes, 3) + + perAD := map[string]int{} + ips := map[string]bool{} + + for _, n := range np.Nodes { + assert.Equal(t, ke.NodeLifecycleStateActive, n.LifecycleState) + assert.Equal(t, testVersion, *n.KubernetesVersion) + assert.Equal(t, *np.Id, *n.NodePoolId) + assert.Contains(t, *n.Name, "oke-c") + assert.Regexp(t, `^10\.0\.[12]\.\d+$`, *n.PrivateIp) + ips[*n.PrivateIp] = true + perAD[*n.AvailabilityDomain]++ + } + + assert.Len(t, ips, 3, "node IPs are unique") + assert.Equal(t, map[string]int{ad1: 2, ad2: 1}, perAD, "nodes spread over placements") + + wr, err = e.ce.GetWorkRequest(ctx, ke.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestStatusSucceeded, wr.Status) + + // scale up to 5, then rename + _, err = e.ce.UpdateNodePool(ctx, ke.UpdateNodePoolRequest{ + NodePoolId: np.Id, + Name: new("renamed"), NodeConfigDetails: &ke.UpdateNodePoolNodeConfigDetails{Size: new(5)}, + InitialNodeLabels: []ke.KeyValue{{Key: new("tier"), Value: new("web")}}, + }) + require.NoError(t, err) + + got, err = e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + assert.Equal(t, ke.NodePoolLifecycleStateUpdating, got.LifecycleState) + assert.Len(t, got.Nodes, 5) + + e.settle() + + got, err = e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + assert.Equal(t, "renamed", *got.Name) + assert.Equal(t, "web", *got.InitialNodeLabels[0].Value) + assert.Len(t, got.Nodes, 5) + + // scale down to 2 + _, err = e.ce.UpdateNodePool(ctx, ke.UpdateNodePoolRequest{ + NodePoolId: np.Id, + NodeConfigDetails: &ke.UpdateNodePoolNodeConfigDetails{Size: new(2)}, + }) + require.NoError(t, err) + e.settle() + + got, err = e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + require.Len(t, got.Nodes, 2) + + // delete one node with decrement, then one with replacement + victim := got.Nodes[0] + _, err = e.ce.DeleteNode(ctx, ke.DeleteNodeRequest{ + NodePoolId: np.Id, NodeId: victim.Id, IsDecrementSize: new(true), + }) + require.NoError(t, err) + e.settle() + + got, err = e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + require.Len(t, got.Nodes, 1) + assert.Equal(t, 1, *got.NodeConfigDetails.Size) + assert.NotEqual(t, *victim.Id, *got.Nodes[0].Id) + + old := got.Nodes[0] + _, err = e.ce.DeleteNode(ctx, ke.DeleteNodeRequest{NodePoolId: np.Id, NodeId: old.Id}) + require.NoError(t, err) + e.settle() + + got, err = e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + require.Len(t, got.Nodes, 1, "the deleted node is replaced") + assert.NotEqual(t, *old.Id, *got.Nodes[0].Id) + assert.Equal(t, *old.AvailabilityDomain, *got.Nodes[0].AvailabilityDomain) + + _, err = e.ce.DeleteNode( + ctx, + ke.DeleteNodeRequest{NodePoolId: np.Id, NodeId: new("ocid1.instance.oc1.iad.nope")}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + del, err := e.ce.DeleteNodePool(ctx, ke.DeleteNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + + got, err = e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + assert.Equal(t, ke.NodePoolLifecycleStateDeleting, got.LifecycleState) + e.settle() + + got, err = e.ce.GetNodePool(ctx, ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + assert.Equal(t, ke.NodePoolLifecycleStateDeleted, got.LifecycleState) + assert.Empty(t, got.Nodes) + + wr, err = e.ce.GetWorkRequest(ctx, ke.GetWorkRequestRequest{WorkRequestId: del.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestOperationTypeNodepoolDelete, wr.OperationType) + assert.Equal(t, ke.WorkRequestStatusSucceeded, wr.Status) + + ops, err := e.ce.ListWorkRequests(ctx, ke.ListWorkRequestsRequest{ + CompartmentId: new( + testCompartment, + ), + ClusterId: c.Id, + ResourceType: ke.ListWorkRequestsResourceTypeNodepool, + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(ops.Items), 6) +} + +func TestClusterDeleteCascadesToNodePools(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "cascade", nil) + np := e.mkPool(t, c.Id, "p", nil) + + _, err := e.ce.DeleteCluster(t.Context(), ke.DeleteClusterRequest{ClusterId: c.Id}) + require.NoError(t, err) + + got, err := e.ce.GetNodePool(t.Context(), ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + assert.Equal(t, ke.NodePoolLifecycleStateDeleting, got.LifecycleState) + + e.settle() + + got, err = e.ce.GetNodePool(t.Context(), ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + assert.Equal(t, ke.NodePoolLifecycleStateDeleted, got.LifecycleState) +} + +func TestCreateNodePoolValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "val", nil) + + tests := []struct { + name string + mod func(*ke.CreateNodePoolDetails) + code string + http int + }{ + {"missing name", func(d *ke.CreateNodePoolDetails) { d.Name = nil }, "MissingParameter", 400}, + { + "missing shape", + func(d *ke.CreateNodePoolDetails) { d.NodeShape = nil }, + "MissingParameter", + 400, + }, + { + "unknown shape", + func(d *ke.CreateNodePoolDetails) { d.NodeShape = new("VM.Nope") }, + "InvalidParameter", + 400, + }, + { + "flex without config", + func(d *ke.CreateNodePoolDetails) { d.NodeShape = new(shapeFlex) }, + "MissingParameter", + 400, + }, + {"flex ocpus too big", func(d *ke.CreateNodePoolDetails) { + d.NodeShape = new(shapeFlex) + d.NodeShapeConfig = &ke.CreateNodeShapeConfigDetails{Ocpus: new(float32(65))} + }, "InvalidParameter", 400}, + {"flex memory too big", func(d *ke.CreateNodePoolDetails) { + d.NodeShape = new(shapeFlex) + d.NodeShapeConfig = &ke.CreateNodeShapeConfigDetails{ + Ocpus: new(float32(1)), + MemoryInGBs: new(float32(900)), + } + }, "InvalidParameter", 400}, + {"fixed with config", func(d *ke.CreateNodePoolDetails) { + d.NodeShapeConfig = &ke.CreateNodeShapeConfigDetails{Ocpus: new(float32(2))} + }, "InvalidParameter", 400}, + { + "no image", + func(d *ke.CreateNodePoolDetails) { d.NodeSourceDetails = nil }, + "MissingParameter", + 400, + }, + {"unknown image", func(d *ke.CreateNodePoolDetails) { + d.NodeSourceDetails = ke.NodeSourceViaImageDetails{ImageId: new("ocid1.image.oc1.iad.nope")} + }, "InvalidParameter", 400}, + {"arch mismatch", func(d *ke.CreateNodePoolDetails) { + d.NodeSourceDetails = ke.NodeSourceViaImageDetails{ImageId: new(imgOL8Arm)} + }, "InvalidParameter", 400}, + {"unknown image name", func(d *ke.CreateNodePoolDetails) { + d.NodeSourceDetails = nil + d.NodeImageName = new("nope") + }, "InvalidParameter", 400}, + {"no placements", func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.PlacementConfigs = []ke.NodePoolPlacementConfigDetails{} + }, "MissingParameter", 400}, + {"bad ad", func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.PlacementConfigs[0].AvailabilityDomain = new("AD-1") + }, "InvalidParameter", 400}, + {"unknown subnet", func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.PlacementConfigs[0].SubnetId = new("ocid1.subnet.oc1.iad.nope") + }, "InvalidParameter", 400}, + {"subnet other vcn", func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.PlacementConfigs[0].SubnetId = new(subOther) + }, "InvalidParameter", 400}, + {"subnet wrong ad", func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.PlacementConfigs[0].AvailabilityDomain = new(ad2) + }, "InvalidParameter", 400}, + { + "negative size", + func(d *ke.CreateNodePoolDetails) { d.NodeConfigDetails.Size = new(-1) }, + "InvalidParameter", + 400, + }, + { + "huge size", + func(d *ke.CreateNodePoolDetails) { d.NodeConfigDetails.Size = new(100000) }, + "InvalidParameter", + 400, + }, + { + "newer than cluster", + func(d *ke.CreateNodePoolDetails) { d.KubernetesVersion = new("v1.35.2") }, + "InvalidParameter", + 400, + }, + { + "malformed version", + func(d *ke.CreateNodePoolDetails) { d.KubernetesVersion = new("1.34") }, + "InvalidParameter", + 400, + }, + {"bad kms key", func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.KmsKeyId = new("ocid1.key.oc1.iad.nope") + }, "InvalidParameter", 400}, + {"cni mismatch", func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.NodePoolPodNetworkOptionDetails = ke.OciVcnIpNativeNodePoolPodNetworkOptionDetails{ + PodSubnetIds: []string{subLB}, + } + }, "InvalidParameter", 400}, + { + "no config or subnets", + func(d *ke.CreateNodePoolDetails) { d.NodeConfigDetails = nil }, + "MissingParameter", + 400, + }, + { + "both config and subnets", + func(d *ke.CreateNodePoolDetails) { d.SubnetIds = []string{subAD1} }, + "InvalidParameter", + 400, + }, + { + "unknown cluster", + func(d *ke.CreateNodePoolDetails) { d.ClusterId = new("ocid1.cluster.oc1.iad.nope") }, + "NotAuthorizedOrNotFound", + 404, + }, + { + "missing cluster", + func(d *ke.CreateNodePoolDetails) { d.ClusterId = nil }, + "MissingParameter", + 400, + }, + {"empty label key", func(d *ke.CreateNodePoolDetails) { + d.InitialNodeLabels = []ke.KeyValue{{Key: new("")}} + }, "InvalidParameter", 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + d := poolDetails(c.Id, "v-"+tt.name) + tt.mod(&d) + + _, err := e.ce.CreateNodePool(t.Context(), ke.CreateNodePoolRequest{CreateNodePoolDetails: d}) + se := svcErr(t, err) + assert.Equal(t, tt.http, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestCreateNodePoolAccepts(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "ok", nil) + native := e.mkCluster(t, "ok-native", func(d *ke.CreateClusterDetails) { + d.ClusterPodNetworkOptions = []ke.ClusterPodNetworkOptionDetails{ + ke.OciVcnIpNativeClusterPodNetworkOptionDetails{}, + } + }) + + tests := []struct { + cluster *string + mod func(*ke.CreateNodePoolDetails) + check func(*testing.T, ke.NodePool) + name string + }{ + {c.Id, func(d *ke.CreateNodePoolDetails) { + d.NodeShape = new(shapeFlex) + d.NodeShapeConfig = &ke.CreateNodeShapeConfigDetails{Ocpus: new(float32(2))} + }, func(t *testing.T, np ke.NodePool) { + t.Helper() + assert.InDelta(t, 2, *np.NodeShapeConfig.Ocpus, 0) + assert.InDelta(t, 32, *np.NodeShapeConfig.MemoryInGBs, 0, "16 GB per OCPU by default") + }, "flex shape"}, + {c.Id, func(d *ke.CreateNodePoolDetails) { + d.NodeShape = new(shapeArm) + d.NodeShapeConfig = &ke.CreateNodeShapeConfigDetails{Ocpus: new(float32(1))} + d.NodeSourceDetails = ke.NodeSourceViaImageDetails{ImageId: new(imgOL8Arm)} + }, func(t *testing.T, np ke.NodePool) { + t.Helper() + assert.InDelta(t, 6, *np.NodeShapeConfig.MemoryInGBs, 0) + }, "arm shape and image"}, + {c.Id, func(d *ke.CreateNodePoolDetails) { + d.NodeSourceDetails = nil + d.NodeImageName = new("Oracle-Linux-8.10-2024.09.30-0") + }, func(t *testing.T, np ke.NodePool) { + t.Helper() + assert.Equal(t, imgOL8, *np.NodeImageId) + }, "image by name"}, + { + c.Id, + func(d *ke.CreateNodePoolDetails) { d.KubernetesVersion = new("v1.34.1") }, + func(t *testing.T, np ke.NodePool) { + t.Helper() + assert.Equal(t, "v1.34.1", *np.KubernetesVersion) + assert.Equal(t, "v1.34.1", *np.Nodes[0].KubernetesVersion) + }, + "older version", + }, + {c.Id, func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails = nil + d.SubnetIds = []string{subAD1, subRegional} + d.QuantityPerSubnet = new(2) + }, func(t *testing.T, np ke.NodePool) { + t.Helper() + assert.Len(t, np.Nodes, 4) + assert.Equal(t, 2, *np.QuantityPerSubnet) + }, "legacy subnets"}, + { + c.Id, + func(d *ke.CreateNodePoolDetails) { d.NodeConfigDetails.Size = new(0) }, + func(t *testing.T, np ke.NodePool) { + t.Helper() + assert.Empty(t, np.Nodes) + }, + "zero size", + }, + {native.Id, func(d *ke.CreateNodePoolDetails) { + d.NodeConfigDetails.NodePoolPodNetworkOptionDetails = ke.OciVcnIpNativeNodePoolPodNetworkOptionDetails{ + PodSubnetIds: []string{subLB}, + MaxPodsPerNode: new(31), + } + }, func(t *testing.T, np ke.NodePool) { + t.Helper() + opt, ok := np.NodeConfigDetails.NodePoolPodNetworkOptionDetails.(ke.OciVcnIpNativeNodePoolPodNetworkOptionDetails) + require.True(t, ok) + assert.Equal(t, []string{subLB}, opt.PodSubnetIds) + }, "native cni"}, + {c.Id, func(d *ke.CreateNodePoolDetails) { + d.FreeformTags = map[string]string{"k": "v"} + d.NodeMetadata = map[string]string{"m": "1"} + d.SshPublicKey = new("ssh-rsa AAAA") + }, func(t *testing.T, np ke.NodePool) { + t.Helper() + assert.Equal(t, "v", np.FreeformTags["k"]) + assert.Equal(t, "1", np.NodeMetadata["m"]) + assert.Equal(t, "ssh-rsa AAAA", *np.SshPublicKey) + }, "tags and metadata"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + np := e.mkPool(t, tt.cluster, "a-"+tt.name, tt.mod) + tt.check(t, np) + }) + } +} + +func TestUpdateNodePoolValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "updval", nil) + np := e.mkPool(t, c.Id, "p", nil) + + tests := []struct { + in ke.UpdateNodePoolDetails + name string + }{ + {ke.UpdateNodePoolDetails{KubernetesVersion: new("v1.36.4")}, "newer version"}, + {ke.UpdateNodePoolDetails{Name: new("")}, "empty name"}, + {ke.UpdateNodePoolDetails{NodeShape: new("VM.Nope")}, "unknown shape"}, + { + ke.UpdateNodePoolDetails{ + NodeShape: new( + shapeArm, + ), + NodeShapeConfig: &ke.UpdateNodeShapeConfigDetails{Ocpus: new(float32(1))}, + }, + "arm shape on x86 image", + }, + { + ke.UpdateNodePoolDetails{ + NodeConfigDetails: &ke.UpdateNodePoolNodeConfigDetails{Size: new(-2)}, + }, + "negative size", + }, + {ke.UpdateNodePoolDetails{NodeConfigDetails: &ke.UpdateNodePoolNodeConfigDetails{ + PlacementConfigs: []ke.NodePoolPlacementConfigDetails{ + {AvailabilityDomain: new(ad1), SubnetId: new(subOther)}, + }, + }}, "bad placement"}, + {ke.UpdateNodePoolDetails{SubnetIds: []string{subAD1}}, "legacy subnets on config pool"}, + { + ke.UpdateNodePoolDetails{ + NodeSourceDetails: ke.NodeSourceViaImageDetails{ + ImageId: new("ocid1.image.oc1.iad.nope"), + }, + }, + "unknown image", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.ce.UpdateNodePool(t.Context(), ke.UpdateNodePoolRequest{ + NodePoolId: np.Id, UpdateNodePoolDetails: tt.in, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + _, err := e.ce.UpdateNodePool(t.Context(), ke.UpdateNodePoolRequest{ + NodePoolId: np.Id, IfMatch: new("stale"), Name: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestNodePoolListFilters(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c1 := e.mkCluster(t, "one", nil) + c2 := e.mkCluster(t, "two", nil) + e.mkPool(t, c1.Id, "a", nil) + e.mkPool(t, c2.Id, "b", nil) + + tests := []struct { + name string + req ke.ListNodePoolsRequest + want []string + }{ + { + "all", + ke.ListNodePoolsRequest{ + SortBy: ke.ListNodePoolsSortByName, + SortOrder: ke.ListNodePoolsSortOrderAsc, + }, + []string{"a", "b"}, + }, + {"by cluster", ke.ListNodePoolsRequest{ClusterId: c2.Id}, []string{"b"}}, + {"by name", ke.ListNodePoolsRequest{Name: new("a")}, []string{"a"}}, + { + "creating", + ke.ListNodePoolsRequest{ + LifecycleState: []ke.NodePoolLifecycleStateEnum{ + ke.NodePoolLifecycleStateCreating, + }, + }, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.req.CompartmentId = new(testCompartment) + + resp, err := e.ce.ListNodePools(t.Context(), tt.req) + require.NoError(t, err) + + var names []string + for _, np := range resp.Items { + names = append(names, *np.Name) + } + + assert.Equal(t, tt.want, names) + }) + } +} diff --git a/services/ocicontainerengine/nodepools.go b/services/ocicontainerengine/nodepools.go new file mode 100644 index 0000000000..6915f697c7 --- /dev/null +++ b/services/ocicontainerengine/nodepools.go @@ -0,0 +1,1076 @@ +package ocicontainerengine + +import ( + "encoding/binary" + "encoding/json" + "math" + "net/netip" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + maxPoolSize = 1000 + faultDomains = 3 + maxMemPerOcpu = 64 + maxLabelsPerSet = 100 +) + +var adRe = regexp.MustCompile(`^[A-Za-z0-9]{4}:[A-Z0-9-]+-AD-\d+$`) + +// NodeConfigIn is nodeConfigDetails on create and update. +type NodeConfigIn struct { + Tags + Size *int `json:"size"` + KmsKeyID *string `json:"kmsKeyId"` + IsPvEncryption *bool `json:"isPvEncryptionInTransitEnabled"` + ComputeClusterID *string `json:"computeClusterId"` + PodNet *PodNet `json:"nodePoolPodNetworkOptionDetails"` + Placements []Placement `json:"placementConfigs"` + NsgIDs []string `json:"nsgIds"` +} + +// CreateNodePoolIn is the CreateNodePool request body. +type CreateNodePoolIn struct { + Tags + Version *string `json:"kubernetesVersion"` + ImageName *string `json:"nodeImageName"` + Source *NodeSource `json:"nodeSourceDetails"` + ShapeConfig *ShapeConfig `json:"nodeShapeConfig"` + SSHKey *string `json:"sshPublicKey"` + Quantity *int `json:"quantityPerSubnet"` + NodeConfig *NodeConfigIn `json:"nodeConfigDetails"` + NodeMetadata map[string]string `json:"nodeMetadata"` + Eviction json.RawMessage `json:"nodeEvictionNodePoolSettings"` + Cycling json.RawMessage `json:"nodePoolCyclingDetails"` + SecondaryVnics json.RawMessage `json:"secondaryVnics"` + PrimaryVnic json.RawMessage `json:"primaryVnic"` + Compartment string `json:"compartmentId"` + ClusterID string `json:"clusterId"` + Name string `json:"name"` + Shape string `json:"nodeShape"` + LaunchType string `json:"networkLaunchType"` + Labels []KeyValue `json:"initialNodeLabels"` + SubnetIDs []string `json:"subnetIds"` +} + +// UpdateNodePoolIn is the UpdateNodePool request body. +type UpdateNodePoolIn struct { + Tags + Name *string `json:"name"` + Version *string `json:"kubernetesVersion"` + Quantity *int `json:"quantityPerSubnet"` + NodeConfig *NodeConfigIn `json:"nodeConfigDetails"` + Source *NodeSource `json:"nodeSourceDetails"` + ShapeConfig *ShapeConfig `json:"nodeShapeConfig"` + SSHKey *string `json:"sshPublicKey"` + Shape *string `json:"nodeShape"` + NodeMetadata map[string]string `json:"nodeMetadata"` + Eviction json.RawMessage `json:"nodeEvictionNodePoolSettings"` + Cycling json.RawMessage `json:"nodePoolCyclingDetails"` + SecondaryVnics json.RawMessage `json:"secondaryVnics"` + PrimaryVnic json.RawMessage `json:"primaryVnic"` + LaunchType string `json:"networkLaunchType"` + Labels []KeyValue `json:"initialNodeLabels"` + SubnetIDs []string `json:"subnetIds"` +} + +// NodeWire is a Node body. +type NodeWire struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + KubernetesVersion string `json:"kubernetesVersion"` + AvailabilityDomain string `json:"availabilityDomain"` + SubnetID string `json:"subnetId"` + NodePoolID string `json:"nodePoolId"` + FaultDomain string `json:"faultDomain"` + PrivateIP string `json:"privateIp,omitempty"` + LifecycleState string `json:"lifecycleState"` +} + +// NodePoolWire is the NodePool and NodePoolSummary body. +type NodePoolWire struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + NodeMetadata map[string]string `json:"nodeMetadata,omitempty"` + NodeShapeConfig *ShapeConfig `json:"nodeShapeConfig,omitempty"` + NodeSource *nodeSourceOption `json:"nodeSource,omitempty"` + NodeSourceDetails *NodeSource `json:"nodeSourceDetails,omitempty"` + QuantityPerSubnet *int `json:"quantityPerSubnet,omitempty"` + NodeConfigDetails *NodeConfig `json:"nodeConfigDetails,omitempty"` + Eviction json.RawMessage `json:"nodeEvictionNodePoolSettings,omitempty"` + Cycling json.RawMessage `json:"nodePoolCyclingDetails,omitempty"` + Nodes []NodeWire `json:"nodes,omitempty"` + ID string `json:"id"` + ClusterID string `json:"clusterId"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + KubernetesVersion string `json:"kubernetesVersion"` + NodeShape string `json:"nodeShape"` + NodeImageID string `json:"nodeImageId,omitempty"` + NodeImageName string `json:"nodeImageName,omitempty"` + SSHPublicKey string `json:"sshPublicKey,omitempty"` + LifecycleState string `json:"lifecycleState"` + InitialNodeLabels []KeyValue `json:"initialNodeLabels"` + SubnetIDs []string `json:"subnetIds"` +} + +func nodePoolView(np *NodePool, summary bool) NodePoolWire { + w := NodePoolWire{ + ID: np.ID, ClusterID: np.ClusterID, CompartmentID: np.CompartmentID, Name: np.Name, + KubernetesVersion: np.Version, NodeShape: np.Shape, NodeShapeConfig: np.ShapeConfig, + NodeSourceDetails: np.NodeSource, NodeImageName: np.ImageName, SSHPublicKey: np.SSHKey, + LifecycleState: np.State, InitialNodeLabels: np.Labels, NodeMetadata: np.NodeMetadata, + QuantityPerSubnet: np.QuantityPerSub, SubnetIDs: np.SubnetIDs, Eviction: np.Eviction, Cycling: np.Cycling, + FreeformTags: np.Tags.FreeformTags, DefinedTags: np.Tags.DefinedTags, + } + if w.InitialNodeLabels == nil { + w.InitialNodeLabels = []KeyValue{} + } + + if w.SubnetIDs == nil { + w.SubnetIDs = []string{} + } + + if np.NodeSource != nil { + w.NodeImageID = np.NodeSource.ImageID + w.NodeSource = &nodeSourceOption{ + SourceType: sourceImage, + SourceName: np.ImageName, + ImageID: np.NodeSource.ImageID, + } + } + + if np.NodeConfig != nil { + w.NodeConfigDetails = np.NodeConfig + for _, p := range np.NodeConfig.Placements { + if !slices.Contains(w.SubnetIDs, p.SubnetID) { + w.SubnetIDs = append(w.SubnetIDs, p.SubnetID) + } + } + } + + if !summary { + w.Nodes = make([]NodeWire, 0, len(np.Nodes)) + for _, n := range np.Nodes { + w.Nodes = append(w.Nodes, NodeWire{ + ID: n.ID, Name: n.Name, KubernetesVersion: n.Version, AvailabilityDomain: n.AD, SubnetID: n.SubnetID, + NodePoolID: np.ID, FaultDomain: n.FaultDomain, PrivateIP: n.PrivateIP, LifecycleState: n.State, + FreeformTags: n.Tags.FreeformTags, DefinedTags: n.Tags.DefinedTags, + }) + } + } + + return w +} + +func rejectUnsupported(secondary, primary json.RawMessage, launch string) *APIError { + if len(secondary) > 0 && string(secondary) != "null" && string(secondary) != "[]" { + return errParam("secondaryVnics are not supported by this emulator") + } + + if len(primary) > 0 && string(primary) != "null" { + return errParam("primaryVnic is not supported by this emulator") + } + + if launch != "" { + return errParam("networkLaunchType is not supported by this emulator") + } + + return nil +} + +// checkShape validates a node shape and its shapeConfig and returns the resolved config. +func (b *InMemoryBackend) checkShape(name string, cfg *ShapeConfig) (ShapeInfo, *ShapeConfig, *APIError) { + if strings.TrimSpace(name) == "" { + return ShapeInfo{}, nil, errMissing("nodeShape is required") + } + + if b.net == nil { + return ShapeInfo{Name: name}, cfg, nil + } + + sh, ok := b.net.Shape(name) + if !ok { + return ShapeInfo{}, nil, errParam("nodeShape " + name + " is not a known compute shape") + } + + if !sh.Flexible { + if cfg != nil && (cfg.Ocpus != nil || cfg.MemoryInGBs != nil) { + return sh, nil, errParam("nodeShapeConfig is only valid for flexible shapes, " + name + " is not flexible") + } + + return sh, nil, nil + } + + resolved, aerr := flexConfig(sh, cfg) + + return sh, resolved, aerr +} + +// flexConfig validates a flexible shape's ocpus and memory and fills the default memory. +func flexConfig(sh ShapeInfo, cfg *ShapeConfig) (*ShapeConfig, *APIError) { + if cfg == nil || cfg.Ocpus == nil { + return nil, errMissing("nodeShapeConfig.ocpus is required for the flexible shape " + sh.Name) + } + + o := *cfg.Ocpus + if o < sh.MinOcpus || o > sh.MaxOcpus || o != math.Trunc(o) { + return nil, errParam("nodeShapeConfig.ocpus must be a whole number between " + + strconv.FormatFloat(sh.MinOcpus, 'f', -1, 64) + " and " + strconv.FormatFloat(sh.MaxOcpus, 'f', -1, 64)) + } + + mem := o * sh.DefaultMemPerOcpu + if cfg.MemoryInGBs != nil { + mem = *cfg.MemoryInGBs + } + + if mem < max(1, o) || mem > min(sh.MaxMemoryGBs, o*maxMemPerOcpu) { + return nil, errParam("nodeShapeConfig.memoryInGBs is outside the range allowed for " + sh.Name) + } + + return &ShapeConfig{Ocpus: &o, MemoryInGBs: &mem}, nil +} + +// checkSource resolves the node image and verifies it runs on the shape's architecture. +func (b *InMemoryBackend) checkSource(src *NodeSource, name *string, sh ShapeInfo) (*NodeSource, string, *APIError) { + out, aerr := b.resolveSource(src, name) + if aerr != nil { + return nil, "", aerr + } + + if out.BootVolumeGBs != nil && *out.BootVolumeGBs < 1 { + return nil, "", errParam("nodeSourceDetails.bootVolumeSizeInGBs must be positive") + } + + if !strings.HasPrefix(out.ImageID, "ocid1.image.") { + return nil, "", errParam("nodeSourceDetails.imageId must be the OCID of an image") + } + + imgName, aerr := b.verifyImage(out.ImageID, sh) + if aerr != nil { + return nil, "", aerr + } + + return &out, imgName, nil +} + +func (b *InMemoryBackend) resolveSource(src *NodeSource, name *string) (NodeSource, *APIError) { + switch { + case src != nil: + if src.SourceType != "" && src.SourceType != sourceImage { + return NodeSource{}, errParam("nodeSourceDetails.sourceType must be IMAGE") + } + + out := *src + out.SourceType = sourceImage + + return out, nil + case name != nil && *name != "": + id := "" + + if b.net != nil { + for _, img := range b.net.Images() { + if img.Name == *name { + id = img.ID + } + } + + if id == "" { + return NodeSource{}, errParam("nodeImageName " + *name + " does not match a known image") + } + } + + return NodeSource{SourceType: sourceImage, ImageID: id}, nil + } + + return NodeSource{}, errMissing("nodeSourceDetails is required") +} + +// verifyImage checks the image exists in OCI Core and matches the shape architecture; it returns the image name. +func (b *InMemoryBackend) verifyImage(id string, sh ShapeInfo) (string, *APIError) { + if b.net == nil { + return "", nil + } + + img, ok := b.net.Image(id) + if !ok { + return "", errParam("nodeSourceDetails.imageId " + id + " does not reference an existing image") + } + + if sh.Arch != "" && img.Arch != sh.Arch { + return "", errParam( + "image " + img.Name + " (" + img.Arch + ") cannot run on shape " + sh.Name + " (" + sh.Arch + ")", + ) + } + + return img.Name, nil +} + +func (b *InMemoryBackend) checkPlacements(ps []Placement, vcnID string) *APIError { + if len(ps) == 0 { + return errMissing("nodeConfigDetails.placementConfigs is required") + } + + for _, p := range ps { + if !adRe.MatchString(p.AvailabilityDom) { + return errParam( + "placementConfigs.availabilityDomain " + p.AvailabilityDom + " is not a valid availability domain", + ) + } + + sub, aerr := b.checkSubnet("placementConfigs.subnetId", p.SubnetID, vcnID) + if aerr != nil { + return aerr + } + + if sub.AvailabilityDomain != "" && !strings.EqualFold(sub.AvailabilityDomain, p.AvailabilityDom) { + return errParam("subnet " + p.SubnetID + " is in availability domain " + sub.AvailabilityDomain) + } + } + + return nil +} + +// buildNodeConfig validates nodeConfigDetails and merges it over the current one. +func (b *InMemoryBackend) buildNodeConfig(in *NodeConfigIn, cur *NodeConfig, c *Cluster) (*NodeConfig, *APIError) { + if in.ComputeClusterID != nil { + return nil, errParam("computeClusterId is not supported by this emulator") + } + + out := NodeConfig{Placements: []Placement{}} + if cur != nil { + out = *cur + } + + if in.Size != nil { + if *in.Size < 0 || *in.Size > maxPoolSize { + return nil, errParam("nodeConfigDetails.size must be between 0 and " + strconv.Itoa(maxPoolSize)) + } + + out.Size = *in.Size + } else if cur == nil { + return nil, errMissing("nodeConfigDetails.size is required") + } + + if in.Placements != nil || cur == nil { + if aerr := b.checkPlacements(in.Placements, c.VcnID); aerr != nil { + return nil, aerr + } + + out.Placements = in.Placements + } + + if in.KmsKeyID != nil { + if aerr := b.checkKey("nodeConfigDetails.kmsKeyId", *in.KmsKeyID); aerr != nil { + return nil, aerr + } + + out.KmsKeyID = *in.KmsKeyID + } + + if in.NsgIDs != nil { + out.NsgIDs = in.NsgIDs + } + + if in.IsPvEncryption != nil { + out.IsPvEncryption = in.IsPvEncryption + } + + out.Tags.merge(in.Tags) + + return b.applyPodNet(&out, in.PodNet, c) +} + +func (b *InMemoryBackend) applyPodNet(out *NodeConfig, in *PodNet, c *Cluster) (*NodeConfig, *APIError) { + clusterCNI := c.PodNetworks[0].CniType + + if in == nil { + if out.PodNet == nil { + out.PodNet = &PodNet{CniType: clusterCNI} + } + + if clusterCNI == cniNative && len(out.PodNet.PodSubnetIDs) == 0 { + return nil, errMissing( + "nodeConfigDetails.nodePoolPodNetworkOptionDetails.podSubnetIds is required for " + cniNative, + ) + } + + return out, nil + } + + if in.CniType != clusterCNI { + return nil, errParam("nodePoolPodNetworkOptionDetails.cniType must match the cluster CNI " + clusterCNI) + } + + if in.CniType == cniNative { + if len(in.PodSubnetIDs) == 0 { + return nil, errMissing("nodePoolPodNetworkOptionDetails.podSubnetIds is required for " + cniNative) + } + + for _, id := range in.PodSubnetIDs { + if _, aerr := b.checkSubnet("podSubnetIds", id, c.VcnID); aerr != nil { + return nil, aerr + } + } + } + + out.PodNet = in + + return out, nil +} + +func (b *InMemoryBackend) checkLegacy(ids []string, qty *int, vcnID string) *APIError { + if len(ids) == 0 { + return errMissing("nodeConfigDetails or subnetIds is required") + } + + if qty == nil || *qty < 0 || *qty > maxPoolSize { + return errParam("quantityPerSubnet must be between 0 and " + strconv.Itoa(maxPoolSize)) + } + + for _, id := range ids { + if _, aerr := b.checkSubnet("subnetIds", id, vcnID); aerr != nil { + return aerr + } + } + + return nil +} + +func checkLabels(ls []KeyValue) *APIError { + if len(ls) > maxLabelsPerSet { + return errParam("initialNodeLabels must have at most 100 entries") + } + + for _, l := range ls { + if l.Key == "" { + return errParam("initialNodeLabels.key is required") + } + } + + return nil +} + +// activeCluster returns the cluster a node pool operation targets; it must be ACTIVE. +func (b *InMemoryBackend) activeCluster(id string) (*Cluster, *APIError) { + if id == "" { + return nil, errMissing("clusterId is required") + } + + c, ok := b.clusters[id] + if !ok || c.State == stateDeleted { + return nil, errNotFound(id) + } + + if c.State != stateActive { + return nil, errState("Cluster " + id + " is in state " + c.State + "; the operation requires ACTIVE") + } + + return c, nil +} + +// CreateNodePool creates a node pool and its nodes; both start CREATING. +func (b *InMemoryBackend) CreateNodePool(in CreateNodePoolIn) (string, error) { + b.mu.Lock("CreateNodePool") + defer b.mu.Unlock() + + b.settleAll() + + if aerr := firstErr(b.checkCompartment(in.Compartment), validateName(in.Name), + rejectUnsupported(in.SecondaryVnics, in.PrimaryVnic, in.LaunchType), checkLabels(in.Labels)); aerr != nil { + return "", aerr + } + + c, aerr := b.activeCluster(in.ClusterID) + if aerr != nil { + return "", aerr + } + + np, aerr := b.buildPool(in, c) + if aerr != nil { + return "", aerr + } + + np.Etag, np.Seq = newEtag(), b.nextSeq() + np.TransitionAt = b.transitionAt() + b.pools[np.ID] = np + b.growPool(np, c, b.targetSize(np)) + + return b.newWR(wrNodePoolCreate, np.CompartmentID, entityNodePool, np.ID, actionCreated, np.TransitionAt), nil +} + +func (b *InMemoryBackend) buildPool(in CreateNodePoolIn, c *Cluster) (*NodePool, *APIError) { + ver := def(ptrStr(in.Version), c.Version) + if aerr := poolVersionOK(ver, c.Version); aerr != nil { + return nil, aerr + } + + sh, scfg, aerr := b.checkShape(in.Shape, in.ShapeConfig) + if aerr != nil { + return nil, aerr + } + + src, imgName, aerr := b.checkSource(in.Source, in.ImageName, sh) + if aerr != nil { + return nil, aerr + } + + np := &NodePool{ + ID: newOCID("nodepool"), ClusterID: c.ID, CompartmentID: in.Compartment, Name: in.Name, Version: ver, + Shape: in.Shape, ShapeConfig: scfg, NodeSource: src, ImageName: imgName, State: stateCreating, + TimeCreated: b.nowUTC(), Labels: in.Labels, NodeMetadata: in.NodeMetadata, Tags: in.Tags, + Eviction: in.Eviction, Cycling: in.Cycling, SSHKey: ptrStr(in.SSHKey), + } + + switch { + case in.NodeConfig != nil && len(in.SubnetIDs) > 0: + return nil, errParam("nodeConfigDetails and subnetIds are mutually exclusive") + case in.NodeConfig != nil: + if np.NodeConfig, aerr = b.buildNodeConfig(in.NodeConfig, nil, c); aerr != nil { + return nil, aerr + } + default: + if aerr = b.checkLegacy(in.SubnetIDs, in.Quantity, c.VcnID); aerr != nil { + return nil, aerr + } + + np.SubnetIDs, np.QuantityPerSub = in.SubnetIDs, in.Quantity + } + + return np, nil +} + +func ptrStr(s *string) string { + if s == nil { + return "" + } + + return *s +} + +// slot is one node placement target. +type slot struct{ ad, subnet string } + +func (np *NodePool) slots(b *InMemoryBackend) []slot { + if np.NodeConfig != nil { + out := make([]slot, 0, len(np.NodeConfig.Placements)) + for _, p := range np.NodeConfig.Placements { + out = append(out, slot{p.AvailabilityDom, p.SubnetID}) + } + + return out + } + + out := make([]slot, 0, len(np.SubnetIDs)) + + for _, id := range np.SubnetIDs { + ad := "" + if b.net != nil { + if s, ok := b.net.Subnet(id); ok { + ad = s.AvailabilityDomain + } + } + + out = append(out, slot{def(ad, "Uocm:US-ASHBURN-AD-1"), id}) + } + + return out +} + +func (b *InMemoryBackend) targetSize(np *NodePool) int { + if np.NodeConfig != nil { + return np.NodeConfig.Size + } + + return *np.QuantityPerSub * len(np.SubnetIDs) +} + +func liveNodes(np *NodePool) []*Node { + return slices.DeleteFunc(slices.Clone(np.Nodes), func(n *Node) bool { return n.State == stateDeleting }) +} + +// growPool adds nodes, always to the least populated placement, until the pool has size live nodes. +func (b *InMemoryBackend) growPool(np *NodePool, c *Cluster, size int) { + slots := np.slots(b) + if len(slots) == 0 { + return + } + + for len(liveNodes(np)) < size { + np.Nodes = append(np.Nodes, b.newNode(np, c, b.leastLoaded(np, slots))) + } +} + +func (b *InMemoryBackend) leastLoaded(np *NodePool, slots []slot) slot { + best, bestN := slots[0], math.MaxInt + + for _, s := range slots { + n := 0 + + for _, nd := range liveNodes(np) { + if nd.AD == s.ad && nd.SubnetID == s.subnet { + n++ + } + } + + if n < bestN { + best, bestN = s, n + } + } + + return best +} + +func (b *InMemoryBackend) newNode(np *NodePool, c *Cluster, s slot) *Node { + id := newOCID("instance") + idx := len(np.Nodes) + + fd := "FAULT-DOMAIN-" + strconv.Itoa(idx%faultDomains+1) + if np.NodeConfig != nil { + for _, p := range np.NodeConfig.Placements { + if p.AvailabilityDom == s.ad && p.SubnetID == s.subnet && len(p.FaultDomains) > 0 { + fd = p.FaultDomains[idx%len(p.FaultDomains)] + } + } + } + + return &Node{ + ID: id, + Name: "oke-c" + ocidTail( + c.ID, + longTail, + ) + "-n" + ocidTail( + np.ID, + longTail, + ) + "-s" + ocidTail( + s.subnet, + longTail, + ) + + "-" + ocidTail( + id, + shortTail, + ), + Version: np.Version, + AD: s.ad, + SubnetID: s.subnet, + FaultDomain: fd, + PrivateIP: b.allocIP(s.subnet), + State: stateCreating, + TransitionAt: np.TransitionAt, + Tags: np.Tags, + } +} + +// allocIP hands out the highest free host address of the subnet, or "" when the subnet is unknown. +func (b *InMemoryBackend) allocIP(subnetID string) string { + if b.net == nil { + return "" + } + + sub, ok := b.net.Subnet(subnetID) + if !ok { + return "" + } + + p, err := netip.ParsePrefix(sub.CIDR) + if err != nil || !p.Addr().Is4() { + return "" + } + + used := map[string]bool{} + + for _, np := range b.pools { + for _, n := range np.Nodes { + used[n.PrivateIP] = true + } + } + + a4 := p.Masked().Addr().As4() + base := binary.BigEndian.Uint32(a4[:]) + last := base | (^uint32(0) >> p.Bits()) + + for ip := last - 1; ip > base+privateEndpointOffset*2; ip-- { + binary.BigEndian.PutUint32(a4[:], ip) + + if s := netip.AddrFrom4(a4).String(); !used[s] { + return s + } + } + + return "" +} + +func (b *InMemoryBackend) pool(id string) (*NodePool, *APIError) { + np, ok := b.pools[id] + if !ok { + return nil, errNotFound(id) + } + + return np, nil +} + +// GetNodePool returns one node pool and its etag; DELETED pools stay readable. +func (b *InMemoryBackend) GetNodePool(id string) (NodePoolWire, string, error) { + b.mu.Lock("GetNodePool") + defer b.mu.Unlock() + + b.settleAll() + + np, aerr := b.pool(id) + if aerr != nil { + return NodePoolWire{}, "", aerr + } + + return nodePoolView(np, false), np.Etag, nil +} + +// PoolListIn are the ListNodePools filters. +type PoolListIn struct { + Compartment string + ClusterID string + Name string + States []string + ListOpts +} + +// ListNodePools lists node pools; DELETED ones only when asked for by state. +func (b *InMemoryBackend) ListNodePools(in PoolListIn) (page.Page[NodePoolWire], error) { + if aerr := validListOpts(in.ListOpts, "ID", "NAME", "TIME_CREATED"); aerr != nil { + return page.Page[NodePoolWire]{}, aerr + } + + b.mu.Lock("ListNodePools") + defer b.mu.Unlock() + + if aerr := b.checkCompartment(in.Compartment); aerr != nil { + return page.Page[NodePoolWire]{}, aerr + } + + b.settleAll() + + var all []*NodePool + + for _, np := range b.pools { + if np.CompartmentID != in.Compartment || (in.ClusterID != "" && np.ClusterID != in.ClusterID) || + (in.Name != "" && np.Name != in.Name) { + continue + } + + if len(in.States) == 0 && np.State == stateDeleted || len(in.States) > 0 && !containsFold(in.States, np.State) { + continue + } + + all = append(all, np) + } + + pg := paginate( + all, + in.ListOpts, + func(n *NodePool) string { return n.ID }, + func(n *NodePool) string { return n.Name }, + func(n *NodePool) (time.Time, int64) { return n.TimeCreated, n.Seq }, + ) + + out := make([]NodePoolWire, 0, len(pg.Data)) + for _, np := range pg.Data { + out = append(out, nodePoolView(np, true)) + } + + return page.Page[NodePoolWire]{Data: out, Next: pg.Next}, nil +} + +func (b *InMemoryBackend) mutablePool(id, ifMatch string) (*NodePool, *Cluster, *APIError) { + b.settleAll() + + np, aerr := b.pool(id) + if aerr != nil { + return nil, nil, aerr + } + + if aerr = checkEtag(np.Etag, ifMatch); aerr != nil { + return nil, nil, aerr + } + + if np.State != stateActive { + return nil, nil, errState("Node pool " + id + " is in state " + np.State + "; the operation requires ACTIVE") + } + + return np, b.clusters[np.ClusterID], nil +} + +func (b *InMemoryBackend) touchPool(np *NodePool, op string) string { + np.State, np.Etag, np.TransitionAt = stateUpdating, newEtag(), b.transitionAt() + + return b.newWR(op, np.CompartmentID, entityNodePool, np.ID, actionUpdated, np.TransitionAt) +} + +// UpdateNodePool changes a node pool; size changes add or remove nodes. +func (b *InMemoryBackend) UpdateNodePool(id, ifMatch string, in UpdateNodePoolIn) (string, error) { + b.mu.Lock("UpdateNodePool") + defer b.mu.Unlock() + + np, c, aerr := b.mutablePool(id, ifMatch) + if aerr != nil { + return "", aerr + } + + next := *np + if aerr = firstErr(rejectUnsupported(in.SecondaryVnics, in.PrimaryVnic, in.LaunchType), checkLabels(in.Labels), + b.applyPoolUpdate(&next, in, c)); aerr != nil { + return "", aerr + } + + *np = next + wr := b.touchPool(np, wrNodePoolUpdate) + + if c != nil { + b.shrinkPool(np, b.targetSize(np)) + b.growPool(np, c, b.targetSize(np)) + } + + return wr, nil +} + +func (b *InMemoryBackend) applyPoolUpdate(np *NodePool, in UpdateNodePoolIn, c *Cluster) *APIError { + if in.Name != nil { + if aerr := validateName(*in.Name); aerr != nil { + return aerr + } + + np.Name = *in.Name + } + + if in.Version != nil { + if aerr := poolVersionOK(*in.Version, c.Version); aerr != nil { + return aerr + } + + np.Version = *in.Version + } + + if aerr := b.applyPoolCompute(np, in); aerr != nil { + return aerr + } + + if aerr := b.applyPoolPlacement(np, in, c); aerr != nil { + return aerr + } + + np.Labels = orSlice(in.Labels, np.Labels) + np.NodeMetadata = orMap(in.NodeMetadata, np.NodeMetadata) + np.Eviction = orSlice(in.Eviction, np.Eviction) + np.Cycling = orSlice(in.Cycling, np.Cycling) + + if in.SSHKey != nil { + np.SSHKey = *in.SSHKey + } + + np.Tags.merge(in.Tags) + + return nil +} + +func (b *InMemoryBackend) applyPoolCompute(np *NodePool, in UpdateNodePoolIn) *APIError { + if in.Shape != nil || in.ShapeConfig != nil { + name := def(ptrStr(in.Shape), np.Shape) + + cfg := in.ShapeConfig + if cfg == nil && in.Shape == nil { + cfg = np.ShapeConfig + } + + sh, resolved, aerr := b.checkShape(name, cfg) + if aerr != nil { + return aerr + } + + if aerr = b.checkArch(np, sh, in.Source); aerr != nil { + return aerr + } + + np.Shape, np.ShapeConfig = name, resolved + } + + if in.Source != nil { + sh, _, _ := b.checkShape(np.Shape, np.ShapeConfig) + + src, imgName, aerr := b.checkSource(in.Source, nil, sh) + if aerr != nil { + return aerr + } + + np.NodeSource, np.ImageName = src, imgName + } + + return nil +} + +func (b *InMemoryBackend) checkArch(np *NodePool, sh ShapeInfo, src *NodeSource) *APIError { + if src != nil || np.NodeSource == nil { + return nil + } + + _, _, aerr := b.checkSource(np.NodeSource, nil, sh) + + return aerr +} + +func (b *InMemoryBackend) applyPoolPlacement(np *NodePool, in UpdateNodePoolIn, c *Cluster) *APIError { + switch { + case in.NodeConfig != nil && np.NodeConfig == nil: + return errParam("nodeConfigDetails cannot be added to a node pool created with subnetIds") + case in.NodeConfig != nil: + cfg, aerr := b.buildNodeConfig(in.NodeConfig, np.NodeConfig, c) + if aerr != nil { + return aerr + } + + np.NodeConfig = cfg + case len(in.SubnetIDs) > 0 || in.Quantity != nil: + if np.NodeConfig != nil { + return errParam("subnetIds and quantityPerSubnet cannot be set on a node pool using nodeConfigDetails") + } + + ids, qty := orSlice(in.SubnetIDs, np.SubnetIDs), orPtr(in.Quantity, np.QuantityPerSub) + if aerr := b.checkLegacy(ids, qty, c.VcnID); aerr != nil { + return aerr + } + + np.SubnetIDs, np.QuantityPerSub = ids, qty + } + + return nil +} + +// shrinkPool marks the surplus nodes DELETING, newest first. +func (b *InMemoryBackend) shrinkPool(np *NodePool, size int) { + live := liveNodes(np) + for i := len(live) - 1; i >= size && i >= 0; i-- { + live[i].State, live[i].TransitionAt = stateDeleting, np.TransitionAt + } +} + +func (b *InMemoryBackend) markPoolDeleting(np *NodePool, at *time.Time) { + np.State, np.TransitionAt, np.Etag = stateDeleting, at, newEtag() + + for _, n := range np.Nodes { + n.State, n.TransitionAt = stateDeleting, at + } +} + +// DeleteNodePool deletes a node pool and its nodes. +func (b *InMemoryBackend) DeleteNodePool(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteNodePool") + defer b.mu.Unlock() + + np, _, aerr := b.mutablePool(id, ifMatch) + if aerr != nil { + return "", aerr + } + + b.markPoolDeleting(np, b.transitionAt()) + + return b.newWR(wrNodePoolDelete, np.CompartmentID, entityNodePool, np.ID, actionDeleted, np.TransitionAt), nil +} + +// DeleteNode removes one node; without decrement a replacement is created. +func (b *InMemoryBackend) DeleteNode(poolID, nodeID, ifMatch string, decrement bool) (string, error) { + b.mu.Lock("DeleteNode") + defer b.mu.Unlock() + + np, c, aerr := b.mutablePool(poolID, ifMatch) + if aerr != nil { + return "", aerr + } + + i := slices.IndexFunc(np.Nodes, func(n *Node) bool { return n.ID == nodeID && n.State != stateDeleting }) + if i < 0 { + return "", errNotFound(nodeID) + } + + if decrement && np.NodeConfig == nil { + return "", errParam("isDecrementSize requires a node pool that uses nodeConfigDetails") + } + + victim := np.Nodes[i] + wr := b.touchPool(np, wrNodePoolUpdate) + victim.State, victim.TransitionAt = stateDeleting, np.TransitionAt + + if decrement { + np.NodeConfig.Size-- + + return wr, nil + } + + np.Nodes = append(np.Nodes, b.newNode(np, c, slot{victim.AD, victim.SubnetID})) + + return wr, nil +} + +func (h *Handler) createNodePool(q *req) error { + var in CreateNodePoolIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateNodePool(in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getNodePool(q *req) error { + w, etag, err := h.Backend.GetNodePool(q.param("nodePoolId")) + + return reply(q, w, etag, err) +} + +func (h *Handler) listNodePools(q *req) error { + pg, err := h.Backend.ListNodePools(PoolListIn{ + Compartment: q.query("compartmentId"), ClusterID: q.query("clusterId"), Name: q.query("name"), + States: q.queryAll("lifecycleState"), ListOpts: q.listOpts(), + }) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateNodePool(q *req) error { + var in UpdateNodePoolIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateNodePool(q.param("nodePoolId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteNodePool(q *req) error { + wr, err := h.Backend.DeleteNodePool(q.param("nodePoolId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteNode(q *req) error { + dec, _ := strconv.ParseBool(q.query("isDecrementSize")) + wr, err := h.Backend.DeleteNode(q.param("nodePoolId"), q.param("nodeId"), q.ifMatch(), dec) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getNodePoolOptions(q *req) error { + all, _ := strconv.ParseBool(q.query("shouldListAllPatchVersions")) + w, err := h.Backend.GetNodePoolOptions(NodePoolOptionsIn{ + ID: q.param("nodePoolOptionId"), OSType: q.query("nodePoolOsType"), OSArch: q.query("nodePoolOsArch"), + K8sVersion: q.query("nodePoolK8sVersion"), AllPatches: all, + }) + + return reply(q, w, "", err) +} diff --git a/services/ocicontainerengine/ops.go b/services/ocicontainerengine/ops.go new file mode 100644 index 0000000000..e5df407284 --- /dev/null +++ b/services/ocicontainerengine/ops.go @@ -0,0 +1,55 @@ +package ocicontainerengine + +import ( + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateCluster: (*Handler).createCluster, opGetCluster: (*Handler).getCluster, + opListClusters: (*Handler).listClusters, opUpdateCluster: (*Handler).updateCluster, + opDeleteCluster: (*Handler).deleteCluster, opCreateKubeconfig: (*Handler).createKubeconfig, + opMigrateNativeVcn: (*Handler).migrateNativeVcn, opMigrateStatus: (*Handler).migrateStatus, + opGetClusterOptions: (*Handler).getClusterOptions, + opCreateNodePool: (*Handler).createNodePool, opGetNodePool: (*Handler).getNodePool, + opListNodePools: (*Handler).listNodePools, opUpdateNodePool: (*Handler).updateNodePool, + opDeleteNodePool: (*Handler).deleteNodePool, opDeleteNode: (*Handler).deleteNode, + opGetNodePoolOptions: (*Handler).getNodePoolOptions, + opGetWorkRequest: (*Handler).getWorkRequest, opListWorkRequests: (*Handler).listWorkRequests, + opListWorkReqErrors: (*Handler).listWorkRequestErrors, opListWorkReqLogs: (*Handler).listWorkRequestLogs, + } +} + +func reply[T any](q *req, w T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func acceptedWR(q *req, wr string, err error) error { + if err != nil { + return err + } + + return q.accepted(wr) +} + +// collectionReply writes a bare JSON array, the shape the containerengine SDK decodes list bodies from. +func collectionReply[T any](q *req, pg page.Page[T], err error) error { + if err != nil { + return err + } + + q.nextPage(pg.Next) + + data := pg.Data + if data == nil { + data = []T{} + } + + return q.json(http.StatusOK, data, "") +} diff --git a/services/ocicontainerengine/options.go b/services/ocicontainerengine/options.go new file mode 100644 index 0000000000..9069d0f88f --- /dev/null +++ b/services/ocicontainerengine/options.go @@ -0,0 +1,126 @@ +package ocicontainerengine + +import ( + "slices" + "strings" +) + +const optionsAll = "all" + +// ClusterOptionsWire is the GetClusterOptions body. +type ClusterOptionsWire struct { + KubernetesVersions []string `json:"kubernetesVersions"` + ClusterPodNetworkOptions []PodNet `json:"clusterPodNetworkOptions"` +} + +type nodeSourceOption struct { + SourceType string `json:"sourceType"` + SourceName string `json:"sourceName"` + ImageID string `json:"imageId"` +} + +// NodePoolOptionsWire is the GetNodePoolOptions body. +type NodePoolOptionsWire struct { + KubernetesVersions []string `json:"kubernetesVersions"` + Shapes []string `json:"shapes"` + Images []string `json:"images"` + Sources []nodeSourceOption `json:"sources"` +} + +// GetClusterOptions returns the creatable Kubernetes versions and pod network types. +func (b *InMemoryBackend) GetClusterOptions(id string, allPatches bool) (ClusterOptionsWire, error) { + if id != optionsAll { + return ClusterOptionsWire{}, errNotFound(id) + } + + return ClusterOptionsWire{ + KubernetesVersions: listVersions(allPatches), + ClusterPodNetworkOptions: []PodNet{{CniType: cniFlannel}, {CniType: cniNative}}, + }, nil +} + +// NodePoolOptionsIn are the GetNodePoolOptions filters. +type NodePoolOptionsIn struct { + ID string + OSType string + OSArch string + K8sVersion string + AllPatches bool +} + +func archMatches(want, have string) bool { + switch strings.ToUpper(want) { + case "": + return true + case "X86_64": + return have == "x86_64" + case "AARCH64": + return have == "aarch64" + } + + return false +} + +func osMatches(want string, img ImageInfo) bool { + switch strings.ToUpper(want) { + case "UBUNTU": + return img.OS == "Canonical Ubuntu" + case "OL7": + return img.OS == osOracleLinux && img.OSVersion == "7" + case "OL8": + return img.OS == osOracleLinux && img.OSVersion == "8" + } + + return img.OS == osOracleLinux +} + +// GetNodePoolOptions returns the shapes, images and versions a node pool can use. +func (b *InMemoryBackend) GetNodePoolOptions(in NodePoolOptionsIn) (NodePoolOptionsWire, error) { + b.mu.RLock("GetNodePoolOptions") + defer b.mu.RUnlock() + + versions := listVersions(in.AllPatches) + + if in.ID != optionsAll { + c, ok := b.clusters[in.ID] + if !ok || c.State == stateDeleted { + return NodePoolOptionsWire{}, errNotFound(in.ID) + } + + versions = slices.DeleteFunc(slices.Clone(supportedVersions), func(v string) bool { + return poolVersionOK(v, c.Version) != nil + }) + } + + if in.K8sVersion != "" { + versions = slices.DeleteFunc(versions, func(v string) bool { return v != in.K8sVersion }) + } + + out := NodePoolOptionsWire{ + KubernetesVersions: versions, Shapes: []string{}, Images: []string{}, Sources: []nodeSourceOption{}, + } + + if b.net == nil { + return out, nil + } + + for _, s := range b.net.Shapes() { + if archMatches(in.OSArch, s.Arch) { + out.Shapes = append(out.Shapes, s.Name) + } + } + + for _, img := range b.net.Images() { + if !archMatches(in.OSArch, img.Arch) || !osMatches(in.OSType, img) { + continue + } + + out.Images = append(out.Images, img.Name) + out.Sources = append( + out.Sources, + nodeSourceOption{SourceType: sourceImage, SourceName: img.Name, ImageID: img.ID}, + ) + } + + return out, nil +} diff --git a/services/ocicontainerengine/options_test.go b/services/ocicontainerengine/options_test.go new file mode 100644 index 0000000000..9edbbf7f0e --- /dev/null +++ b/services/ocicontainerengine/options_test.go @@ -0,0 +1,146 @@ +package ocicontainerengine_test + +import ( + "net/http" + "testing" + + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetClusterOptions(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + all *bool + want []string + }{ + {"latest patch per minor", nil, []string{"v1.34.10", "v1.35.2", "v1.36.4"}}, + {"all patches", new(true), []string{"v1.34.1", "v1.34.2", "v1.34.10", "v1.35.2", "v1.36.1", "v1.36.4"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.ce.GetClusterOptions(t.Context(), ke.GetClusterOptionsRequest{ + ClusterOptionId: new("all"), ShouldListAllPatchVersions: tt.all, + }) + require.NoError(t, err) + assert.Equal(t, tt.want, resp.KubernetesVersions) + require.Len(t, resp.ClusterPodNetworkOptions, 2) + }) + } + + _, err := e.ce.GetClusterOptions( + t.Context(), + ke.GetClusterOptionsRequest{ClusterOptionId: new("nope")}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestGetNodePoolOptions(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "opts", nil) + + tests := []struct { + name string + req ke.GetNodePoolOptionsRequest + wantShapes []string + wantImages []string + wantVers []string + }{ + { + "all", ke.GetNodePoolOptionsRequest{NodePoolOptionId: new("all")}, + []string{shapeFlex, shapeFixed, shapeArm}, []string{fakeImages()[0].Name, fakeImages()[1].Name}, + []string{"v1.34.10", "v1.35.2", "v1.36.4"}, + }, + { + "arm only", ke.GetNodePoolOptionsRequest{ + NodePoolOptionId: new("all"), NodePoolOsArch: ke.GetNodePoolOptionsNodePoolOsArchAarch64}, + []string{shapeArm}, []string{fakeImages()[1].Name}, []string{"v1.34.10", "v1.35.2", "v1.36.4"}, + }, + { + "ubuntu", + ke.GetNodePoolOptionsRequest{ + NodePoolOptionId: new("all"), NodePoolOsType: ke.GetNodePoolOptionsNodePoolOsTypeUbuntu}, + []string{ + shapeFlex, + shapeFixed, + shapeArm, + }, + []string{fakeImages()[2].Name}, + []string{"v1.34.10", "v1.35.2", "v1.36.4"}, + }, + { + "ol7 has none", ke.GetNodePoolOptionsRequest{ + NodePoolOptionId: new("all"), NodePoolOsType: ke.GetNodePoolOptionsNodePoolOsTypeOl7}, + []string{shapeFlex, shapeFixed, shapeArm}, []string{}, []string{"v1.34.10", "v1.35.2", "v1.36.4"}, + }, + { + "for cluster", ke.GetNodePoolOptionsRequest{NodePoolOptionId: c.Id}, + []string{shapeFlex, shapeFixed, shapeArm}, []string{fakeImages()[0].Name, fakeImages()[1].Name}, + []string{"v1.34.1", "v1.34.2"}, + }, + { + "version filter", + ke.GetNodePoolOptionsRequest{NodePoolOptionId: new("all"), NodePoolK8sVersion: new("v1.35.2")}, + []string{ + shapeFlex, + shapeFixed, + shapeArm, + }, + []string{fakeImages()[0].Name, fakeImages()[1].Name}, + []string{"v1.35.2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.ce.GetNodePoolOptions(t.Context(), tt.req) + require.NoError(t, err) + assert.Equal(t, tt.wantShapes, resp.Shapes) + assert.Equal(t, tt.wantImages, resp.Images) + assert.Equal(t, tt.wantVers, resp.KubernetesVersions) + assert.Len(t, resp.Sources, len(tt.wantImages)) + }) + } + + resp, err := e.ce.GetNodePoolOptions( + t.Context(), + ke.GetNodePoolOptionsRequest{NodePoolOptionId: new("all")}, + ) + require.NoError(t, err) + + src, ok := resp.Sources[0].(ke.NodeSourceViaImageOption) + require.True(t, ok) + assert.Equal(t, imgOL8, *src.ImageId) + + _, err = e.ce.GetNodePoolOptions( + t.Context(), + ke.GetNodePoolOptionsRequest{NodePoolOptionId: new("ocid1.cluster.oc1.iad.nope")}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestNodePoolOptionsStandalone(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{noNet: true}) + + resp, err := e.ce.GetNodePoolOptions( + t.Context(), + ke.GetNodePoolOptionsRequest{NodePoolOptionId: new("all")}, + ) + require.NoError(t, err) + assert.Empty(t, resp.Shapes) + assert.NotEmpty(t, resp.KubernetesVersions) +} diff --git a/services/ocicontainerengine/persistence.go b/services/ocicontainerengine/persistence.go new file mode 100644 index 0000000000..01ce160381 --- /dev/null +++ b/services/ocicontainerengine/persistence.go @@ -0,0 +1,114 @@ +package ocicontainerengine + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Clusters map[string]*Cluster `json:"clusters"` + Pools map[string]*NodePool `json:"nodePools"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocicontainerengine", backendSnapshot{ + Version: snapshotVersion, Clusters: b.clusters, Pools: b.pools, WorkReqs: b.workReqs, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot and relaunches engine clusters. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocicontainerengine", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx). + WarnContext(ctx, "ocicontainerengine: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.stop() + b.wg.Wait() + + b.mu.Lock("Restore") + lcs := b.detachAllLocked() + b.clusters, b.pools, b.workReqs, b.seq = snap.Clusters, snap.Pools, snap.WorkReqs, snap.Seq + b.runCtx, b.stop = context.WithCancel(context.Background()) + b.relaunchLocked() + e := b.engine + b.mu.Unlock() + + if e != nil { + e.reap(lcs) + } + + b.Auth.Load(snap.Auth) + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{ + nullCheck("cluster", &snap.Clusters), nullCheck("node pool", &snap.Pools), + nullCheck("work request", &snap.WorkReqs), + } { + if err != nil { + return err + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ocicontainerengine: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ocicontainerengine/persistence_test.go b/services/ocicontainerengine/persistence_test.go new file mode 100644 index 0000000000..1cc71f7205 --- /dev/null +++ b/services/ocicontainerengine/persistence_test.go @@ -0,0 +1,294 @@ +package ocicontainerengine_test + +import ( + "net/http" + "testing" + + ke "github.com/oracle/oci-go-sdk/v65/containerengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oke "github.com/blackbirdworks/gopherstack/services/ocicontainerengine" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + c := e.mkCluster(t, "persist", nil) + np := e.mkPool(t, c.Id, "pool", nil) + + kc, err := e.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ClusterId: c.Id}) + require.NoError(t, err) + + want := readKubeconfig(t, kc) + + snap := e.backend.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + restored := newEnv(t, envOpts{}) + require.NoError(t, restored.backend.Restore(t.Context(), snap)) + restored.clock.ns.Store(e.clock.ns.Load()) + + gotC, err := restored.ce.GetCluster(t.Context(), ke.GetClusterRequest{ClusterId: c.Id}) + require.NoError(t, err) + assert.Equal(t, "persist", *gotC.Name) + assert.Equal(t, ke.ClusterLifecycleStateActive, gotC.LifecycleState) + assert.Equal(t, *c.Endpoints.PrivateEndpoint, *gotC.Endpoints.PrivateEndpoint) + + gotP, err := restored.ce.GetNodePool(t.Context(), ke.GetNodePoolRequest{NodePoolId: np.Id}) + require.NoError(t, err) + assert.Len(t, gotP.Nodes, 3) + + wr, err := restored.ce.GetWorkRequest( + t.Context(), + ke.GetWorkRequestRequest{WorkRequestId: c.Metadata.CreatedByWorkRequestId}, + ) + require.NoError(t, err) + assert.Equal(t, ke.WorkRequestStatusSucceeded, wr.Status) + + again, err := restored.ce.CreateKubeconfig(t.Context(), ke.CreateKubeconfigRequest{ClusterId: c.Id}) + require.NoError(t, err) + assert.Equal(t, want, readKubeconfig(t, again), "CA and token survive a restore") + + restored.backend.Reset() + + _, err = restored.ce.GetCluster(t.Context(), ke.GetClusterRequest{ClusterId: c.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestRestoreRejectsBadSnapshots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + }{ + {"null cluster", `{"version":1,"clusters":{"a":null}}`, true}, + {"null node pool", `{"version":1,"nodePools":{"a":null}}`, true}, + {"null work request", `{"version":1,"workRequests":{"a":null}}`, true}, + {"old version starts empty", `{"version":99}`, false}, + {"empty maps initialised", `{"version":1}`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := oke.NewInMemoryBackend() + err := b.Restore(t.Context(), []byte(tt.data)) + + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + + _, _, err = b.GetCluster("ocid1.cluster.oc1.iad.x") + require.Error(t, err) + }) + } +} + +func TestErrorEnvelope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + method string + path string + body string + code string + status int + }{ + { + "missing cluster", + http.MethodGet, + "/20180222/clusters/ocid1.cluster.oc1.iad.nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + {"bad json", http.MethodPost, "/20180222/clusters", "{", "CannotParseRequest", 400}, + {"no body", http.MethodPost, "/20180222/clusters", "", "MissingParameter", 400}, + { + "no compartment", + http.MethodPost, + "/20180222/clusters", + `{"name":"x","kubernetesVersion":"v1.34.2"}`, + "MissingParameter", + 400, + }, + {"list needs compartment", http.MethodGet, "/20180222/clusters", "", "MissingParameter", 400}, + {"bad sort", http.MethodGet, "/20180222/clusters?compartmentId=c&sortBy=BOGUS", "", "InvalidParameter", 400}, + { + "bad sort order", + http.MethodGet, + "/20180222/nodePools?compartmentId=c&sortOrder=UP", + "", + "InvalidParameter", + 400, + }, + {"unknown route", http.MethodGet, "/20180222/nothing", "", "NotFound", 404}, + {"missing work request", http.MethodGet, "/20180222/workRequests/nope", "", "NotAuthorizedOrNotFound", 404}, + { + "missing work request errors", + http.MethodGet, + "/20180222/workRequests/nope/errors", + "", + "NotAuthorizedOrNotFound", + 404, + }, + { + "missing work request logs", + http.MethodGet, + "/20180222/workRequests/nope/logs", + "", + "NotAuthorizedOrNotFound", + 404, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.status, status) + assert.Contains(t, body, `"code":"`+tt.code+`"`) + assert.Contains(t, body, `"message"`) + }) + } +} + +func TestAuth(t *testing.T) { + t.Parallel() + + tests := []struct { + opts envOpts + name string + wantCode int + raw bool + }{ + {envOpts{enforce: true, register: true}, "signed with registered key", http.StatusOK, false}, + {envOpts{enforce: true}, "unregistered key rejected", http.StatusUnauthorized, false}, + {envOpts{enforce: true, register: true}, "unsigned rejected", http.StatusUnauthorized, true}, + {envOpts{}, "unsigned accepted when not enforcing", http.StatusBadRequest, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, tt.opts) + + if tt.raw { + status, _ := rawDo(t, http.MethodGet, e.base+"/20180222/clusters", "") + assert.Equal(t, tt.wantCode, status) + + return + } + + _, err := e.ce.ListClusters( + t.Context(), + ke.ListClustersRequest{CompartmentId: new(testCompartment)}, + ) + if tt.wantCode == http.StatusOK { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.wantCode, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} + +func TestAdminKeyEndpoint(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + status, _ := rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", + `{"tenancy":"`+testTenancy+`","user":"`+testUser+`","publicKey":`+quote(e.kp.pubPEM)+`}`) + assert.Equal(t, http.StatusCreated, status) + + status, body := rawDo(t, http.MethodGet, e.base+"/_gopherstack/oci/keys", "") + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, testTenancy) +} + +func TestWorkRequestSurface(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + created, err := e.ce.CreateCluster( + ctx, + ke.CreateClusterRequest{CreateClusterDetails: clusterDetails("wr")}, + ) + require.NoError(t, err) + + logs, err := e.ce.ListWorkRequestLogs( + ctx, + ke.ListWorkRequestLogsRequest{ + CompartmentId: new(testCompartment), + WorkRequestId: created.OpcWorkRequestId, + }, + ) + require.NoError(t, err) + assert.Len(t, logs.Items, 1) + + e.settle() + + logs, err = e.ce.ListWorkRequestLogs( + ctx, + ke.ListWorkRequestLogsRequest{ + CompartmentId: new(testCompartment), + WorkRequestId: created.OpcWorkRequestId, + }, + ) + require.NoError(t, err) + require.Len(t, logs.Items, 2) + assert.Contains(t, *logs.Items[1].Message, "CLUSTER_CREATE succeeded") + + errs, err := e.ce.ListWorkRequestErrors( + ctx, + ke.ListWorkRequestErrorsRequest{ + CompartmentId: new(testCompartment), + WorkRequestId: created.OpcWorkRequestId, + }, + ) + require.NoError(t, err) + assert.Empty(t, errs.Items) + + tests := []struct { + name string + status []string + want int + }{ + {"succeeded", []string{"SUCCEEDED"}, 1}, + {"failed", []string{"FAILED"}, 0}, + {"either", []string{"FAILED", "succeeded"}, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, rerr := e.ce.ListWorkRequests(ctx, ke.ListWorkRequestsRequest{ + CompartmentId: new(testCompartment), Status: tt.status, + }) + require.NoError(t, rerr) + assert.Len(t, resp.Items, tt.want) + }) + } + + status, _ := rawDo(t, http.MethodGet, e.base+"/20180222/workRequests", "") + assert.Equal(t, http.StatusBadRequest, status) +} diff --git a/services/ocicontainerengine/provider.go b/services/ocicontainerengine/provider.go new file mode 100644 index 0000000000..dc71429892 --- /dev/null +++ b/services/ocicontainerengine/provider.go @@ -0,0 +1,76 @@ +package ocicontainerengine + +import ( + "errors" + "os" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocicontainerengine: nil app context") + +// ConfigProvider extracts OKE configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIContainerEngineSettings() Settings +} + +// EngineConfigProvider exposes the engine mode (stub or docker). +type EngineConfigProvider interface { + GetOCIContainerEngineEngine() string +} + +// Provider implements service.Provider for OKE. It runs on its own listener (see Handler.StartWorker) +// and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIContainerEngine" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIContainerEngineSettings() + } + + backend := NewInMemoryBackend() + if ec, ok := ctx.Config.(EngineConfigProvider); ok && ec.GetOCIContainerEngineEngine() == EngineDocker { + enableDockerEngine(ctx, backend) + } + + handler := NewHandler(backend) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} + +func enableDockerEngine(ctx *service.AppContext, backend *InMemoryBackend) { + rt, err := container.NewRuntime(container.Config{Logger: ctx.Logger}) + if err != nil { + ctx.Logger.Warn("OKE: container runtime unavailable; clusters stay metadata-only", "error", err) + + return + } + + err = backend.EnableEngine(EngineConfig{ + Runtime: rt, + Ports: ctx.PortAlloc, + Logger: ctx.Logger, + Host: os.Getenv("OKE_CLUSTER_HOST"), + Token: os.Getenv("OKE_CLUSTER_TOKEN"), + Image: os.Getenv("OKE_K3S_IMAGE"), + }) + if err != nil { + ctx.Logger.Warn("OKE: invalid cluster engine configuration; clusters stay metadata-only", "error", err) + _ = rt.Close() + } +} diff --git a/services/ocicontainerengine/route.go b/services/ocicontainerengine/route.go new file mode 100644 index 0000000000..6c59baad3c --- /dev/null +++ b/services/ocicontainerengine/route.go @@ -0,0 +1,83 @@ +package ocicontainerengine + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateCluster = "CreateCluster" + opGetCluster = "GetCluster" + opListClusters = "ListClusters" + opUpdateCluster = "UpdateCluster" + opDeleteCluster = "DeleteCluster" + opCreateKubeconfig = "CreateKubeconfig" + opMigrateNativeVcn = "ClusterMigrateToNativeVcn" + opMigrateStatus = "GetClusterMigrateToNativeVcnStatus" + opGetClusterOptions = "GetClusterOptions" + opCreateNodePool = "CreateNodePool" + opGetNodePool = "GetNodePool" + opListNodePools = "ListNodePools" + opUpdateNodePool = "UpdateNodePool" + opDeleteNodePool = "DeleteNodePool" + opDeleteNode = "DeleteNode" + opGetNodePoolOptions = "GetNodePoolOptions" + opGetWorkRequest = "GetWorkRequest" + opListWorkRequests = "ListWorkRequests" + opListWorkReqErrors = "ListWorkRequestErrors" + opListWorkReqLogs = "ListWorkRequestLogs" + opAdminKeys = "ManageAPIKeys" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + base = "/20180222" + clusterPath = base + "/clusters/{clusterId}" + nodePoolPath = base + "/nodePools/{nodePoolId}" + workRequestPath = base + "/workRequests/{workRequestId}" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/clusters", Op: opCreateCluster}, + {Method: http.MethodGet, Path: base + "/clusters", Op: opListClusters}, + {Method: http.MethodGet, Path: clusterPath, Op: opGetCluster}, + {Method: http.MethodPut, Path: clusterPath, Op: opUpdateCluster}, + {Method: http.MethodDelete, Path: clusterPath, Op: opDeleteCluster}, + {Method: http.MethodPost, Path: clusterPath + "/kubeconfig/content", Op: opCreateKubeconfig}, + {Method: http.MethodPost, Path: clusterPath + "/actions/migrateToNativeVcn", Op: opMigrateNativeVcn}, + {Method: http.MethodGet, Path: clusterPath + "/migrateToNativeVcnStatus", Op: opMigrateStatus}, + {Method: http.MethodGet, Path: base + "/clusterOptions/{clusterOptionId}", Op: opGetClusterOptions}, + {Method: http.MethodPost, Path: base + "/nodePools", Op: opCreateNodePool}, + {Method: http.MethodGet, Path: base + "/nodePools", Op: opListNodePools}, + {Method: http.MethodGet, Path: nodePoolPath, Op: opGetNodePool}, + {Method: http.MethodPut, Path: nodePoolPath, Op: opUpdateNodePool}, + {Method: http.MethodDelete, Path: nodePoolPath, Op: opDeleteNodePool}, + {Method: http.MethodDelete, Path: nodePoolPath + "/node/{nodeId}", Op: opDeleteNode}, + {Method: http.MethodGet, Path: base + "/nodePoolOptions/{nodePoolOptionId}", Op: opGetNodePoolOptions}, + {Method: http.MethodGet, Path: base + "/workRequests", Op: opListWorkRequests}, + {Method: http.MethodGet, Path: workRequestPath, Op: opGetWorkRequest}, + {Method: http.MethodGet, Path: workRequestPath + "/errors", Op: opListWorkReqErrors}, + {Method: http.MethodGet, Path: workRequestPath + "/logs", Op: opListWorkReqLogs}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocicontainerengine/settings.go b/services/ocicontainerengine/settings.go new file mode 100644 index 0000000000..092b057d37 --- /dev/null +++ b/services/ocicontainerengine/settings.go @@ -0,0 +1,20 @@ +package ocicontainerengine + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8158 + +// Engine modes (OKE_ENGINE). +const ( + EngineStub = "stub" + EngineDocker = "docker" +) + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIContainerEngine. +type Settings struct { + Engine string `json:"engine" env:"OKE_ENGINE" default:"stub" name:"engine" help:"OKE engine mode: stub (control plane only) or docker (real single-node k3s cluster per CreateCluster)."` //nolint:lll // config struct tags are intentionally verbose + Port int `json:"port" env:"OCI_CONTAINER_ENGINE_PORT" default:"8158" name:"port" help:"Fixed TCP port for the dedicated OCI Container Engine for Kubernetes (OKE) listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_CONTAINER_ENGINE_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ocicontainerengine/store.go b/services/ocicontainerengine/store.go new file mode 100644 index 0000000000..4c1dd5c3a2 --- /dev/null +++ b/services/ocicontainerengine/store.go @@ -0,0 +1,337 @@ +package ocicontainerengine + +import ( + "cmp" + "context" + "slices" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + // Region is the emulated region name. + Region = "us-ashburn-1" + regionKey = "iad" + defaultListLimit = 100 + ocidTailLen = 52 + shortTail = 5 + longTail = 11 + sourceImage = "IMAGE" + osOracleLinux = "Oracle Linux" + + defaultTransition = 2 * time.Second + maxName = 255 +) + +// SubnetInfo is the slice of a subnet the service validates references against. +type SubnetInfo struct { + ID string + VcnID string + AvailabilityDomain string + CIDR string +} + +// ShapeInfo is the slice of a compute shape node pools validate against. +type ShapeInfo struct { + Name string + Arch string + Flexible bool + MinOcpus float64 + MaxOcpus float64 + MaxMemoryGBs float64 + DefaultMemPerOcpu float64 +} + +// ImageInfo is the slice of a compute image node pools validate against. +type ImageInfo struct { + ID string + Name string + OS string + OSVersion string + Arch string +} + +// Network is the narrow view of OCI Core (VCNs, subnets, shapes, images) the service needs. +type Network interface { + Subnet(id string) (SubnetInfo, bool) + VCNExists(id string) bool + Shape(name string) (ShapeInfo, bool) + Shapes() []ShapeInfo + Image(id string) (ImageInfo, bool) + Images() []ImageInfo +} + +// KeyChecker reports the lifecycle state of a KMS key OCID and whether it exists. +type KeyChecker func(keyID string) (state string, found bool) + +// InMemoryBackend holds every cluster, node pool and work request under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + clusters map[string]*Cluster + pools map[string]*NodePool + workReqs map[string]*WorkRequest + Auth *ociauth.Registry + net Network + keyChecker KeyChecker + compartment func(string) bool + engine *clusterEngine + wg *sync.WaitGroup + runCtx context.Context //nolint:containedctx // engine goroutines outlive any single request + stop context.CancelFunc + transition time.Duration + seq int64 +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + ctx, stop := context.WithCancel(context.Background()) + b := &InMemoryBackend{ + mu: lockmetrics.New("ocicontainerengine"), now: time.Now, transition: defaultTransition, + wg: &sync.WaitGroup{}, runCtx: ctx, stop: stop, + } + b.resetMaps() + b.Auth = ociauth.NewRegistry() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.clusters, b.pools, b.workReqs = map[string]*Cluster{}, map[string]*NodePool{}, map[string]*WorkRequest{} +} + +// SetClock injects the time source used for lifecycle transitions. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTransition sets how long CREATING, UPDATING and DELETING states last. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetNetwork wires subnet, shape and image validation against OCI Core; nil disconnects. +func (b *InMemoryBackend) SetNetwork(n Network) { + b.mu.Lock("SetNetwork") + b.net = n + b.mu.Unlock() +} + +// SetKeyChecker wires validation of kmsKeyId references against a Vault backend. +func (b *InMemoryBackend) SetKeyChecker(fn KeyChecker) { + b.mu.Lock("SetKeyChecker") + b.keyChecker = fn + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// Reset clears all state and tears down engine clusters. +func (b *InMemoryBackend) Reset() { + b.stop() + b.wg.Wait() + + b.mu.Lock("Reset") + lcs := b.detachAllLocked() + b.resetMaps() + b.runCtx, b.stop = context.WithCancel(context.Background()) + e := b.engine + b.mu.Unlock() + + if e != nil { + e.reap(lcs) + } + + b.Auth.Reset() +} + +// Close stops engine goroutines and releases cluster containers. +func (b *InMemoryBackend) Close() { + b.stop() + b.wg.Wait() + b.closeEngine() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + regionKey + "." + awsid.NewN("", ocidTailLen) +} + +func ocidTail(id string, n int) string { + if len(id) <= n { + return id + } + + return id[len(id)-n:] +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) transitionAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func validListOpts(o ListOpts, sorts ...string) *APIError { + if o.SortBy != "" && !slices.Contains(sorts, strings.ToUpper(o.SortBy)) { + return errParam("sortBy must be one of " + strings.Join(sorts, ", ")) + } + + if o.Order != "" && !strings.EqualFold(o.Order, "ASC") && !strings.EqualFold(o.Order, "DESC") { + return errParam("sortOrder must be ASC or DESC") + } + + return nil +} + +func paginate[T any]( + items []T, o ListOpts, id, name func(T) string, created func(T) (time.Time, int64), +) page.Page[T] { + desc := true + + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + by := strings.ToUpper(o.SortBy) + + slices.SortStableFunc(items, func(a, c T) int { + var r int + + switch by { + case "ID": + r = cmp.Compare(id(a), id(c)) + case "NAME": + r = cmp.Compare(name(a), name(c)) + default: + ta, sa := created(a) + tc, sc := created(c) + r = cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +func def[T comparable](v, d T) T { + var zero T + if v == zero { + return d + } + + return v +} + +func firstErr(errs ...*APIError) *APIError { + for _, e := range errs { + if e != nil { + return e + } + } + + return nil +} + +func validateName(name string) *APIError { + if strings.TrimSpace(name) == "" { + return errMissing("name is required") + } + + if len(name) > maxName { + return errParam("name must be at most 255 characters") + } + + return nil +} + +func equalFoldStr(a, b string) bool { return strings.EqualFold(a, b) } + +func containsFold(list []string, v string) bool { + return slices.ContainsFunc(list, func(s string) bool { return strings.EqualFold(s, v) }) +} + +func orSlice[S ~[]E, E any](v, d S) S { + if v != nil { + return v + } + + return d +} + +func orMap[M ~map[K]V, K comparable, V any](v, d M) M { + if v != nil { + return v + } + + return d +} + +func orPtr[T any](v, d *T) *T { + if v != nil { + return v + } + + return d +} diff --git a/services/ocicontainerengine/versions.go b/services/ocicontainerengine/versions.go new file mode 100644 index 0000000000..25cfe0dc12 --- /dev/null +++ b/services/ocicontainerengine/versions.go @@ -0,0 +1,111 @@ +package ocicontainerengine + +import ( + "cmp" + "regexp" + "slices" + "strconv" +) + +const maxMinorSkew = 3 + +var versionRe = regexp.MustCompile(`^v1\.(\d+)\.(\d+)$`) + +// supportedVersions are the Kubernetes versions OKE lists as supported for new clusters, oldest first. +// +//nolint:gochecknoglobals // immutable version catalogue +var supportedVersions = []string{"v1.34.1", "v1.34.2", "v1.34.10", "v1.35.2", "v1.36.1", "v1.36.4"} + +type kver struct{ minor, patch int } + +func parseVersion(s string) (kver, bool) { + m := versionRe.FindStringSubmatch(s) + if m == nil { + return kver{}, false + } + + minor, _ := strconv.Atoi(m[1]) + patch, _ := strconv.Atoi(m[2]) + + return kver{minor, patch}, true +} + +func (v kver) cmp(o kver) int { + return cmp.Or(cmp.Compare(v.minor, o.minor), cmp.Compare(v.patch, o.patch)) +} + +func isSupported(v string) bool { return slices.Contains(supportedVersions, v) } + +// listVersions returns every supported version, or only the newest patch of each minor. +func listVersions(all bool) []string { + if all { + return slices.Clone(supportedVersions) + } + + var out []string + + for i, v := range supportedVersions { + pv, _ := parseVersion(v) + + if i+1 < len(supportedVersions) { + if nv, _ := parseVersion(supportedVersions[i+1]); nv.minor == pv.minor { + continue + } + } + + out = append(out, v) + } + + return out +} + +// upgradesFrom lists the supported versions newer than v that are at most one minor ahead. +func upgradesFrom(v string) []string { + cur, ok := parseVersion(v) + if !ok { + return []string{} + } + + out := []string{} + + for _, s := range supportedVersions { + if sv, _ := parseVersion(s); sv.cmp(cur) > 0 && sv.minor <= cur.minor+1 { + out = append(out, s) + } + } + + return out +} + +// poolVersionOK reports whether a node pool may run pool under a control plane at cluster. +func poolVersionOK(pool, cluster string) *APIError { + pv, ok := parseVersion(pool) + if !ok { + return errParam( + "kubernetesVersion " + pool + " is not a valid Kubernetes version (expected vMAJOR.MINOR.PATCH)", + ) + } + + cv, _ := parseVersion(cluster) + + switch { + case pv.minor > cv.minor || (pv.minor == cv.minor && pv.patch > cv.patch): + return errParam( + "The node pool Kubernetes version " + pool + " cannot be newer than the cluster version " + cluster, + ) + case cv.minor-pv.minor > maxMinorSkew: + return errParam( + "The node pool Kubernetes version " + pool + " is more than three minor versions behind the cluster", + ) + } + + return nil +} + +func engineImageVersion(v string) string { + if len(v) > 0 && v[0] == 'v' { + return v[1:] + } + + return v +} diff --git a/services/ocicontainerengine/workreq.go b/services/ocicontainerengine/workreq.go new file mode 100644 index 0000000000..f1aa1f2786 --- /dev/null +++ b/services/ocicontainerengine/workreq.go @@ -0,0 +1,258 @@ +package ocicontainerengine + +import ( + "net/http" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + entityCluster = "cluster" + entityNodePool = "nodepool" + wrStatusPending = "IN_PROGRESS" + actionCreated = "CREATED" + actionUpdated = "UPDATED" + actionDeleted = "DELETED" +) + +// Work request operation types. +const ( + wrClusterCreate = "CLUSTER_CREATE" + wrClusterUpdate = "CLUSTER_UPDATE" + wrClusterDelete = "CLUSTER_DELETE" + wrNodePoolCreate = "NODEPOOL_CREATE" + wrNodePoolUpdate = "NODEPOOL_UPDATE" + wrNodePoolDelete = "NODEPOOL_DELETE" +) + +type wrResourceWire struct { + EntityType string `json:"entityType"` + ActionType string `json:"actionType"` + Identifier string `json:"identifier"` + EntityURI string `json:"entityUri,omitempty"` +} + +// WorkRequestWire is the WorkRequest and WorkRequestSummary body. +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted *time.Time `json:"timeStarted,omitempty"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + OperationType string `json:"operationType"` + Status string `json:"status"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Resources []wrResourceWire `json:"resources"` +} + +type wrLogWire struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +type wrErrorWire struct { + Timestamp time.Time `json:"timestamp"` + Code string `json:"code"` + Message string `json:"message"` +} + +// newWR records a work request; finish is nil while completion waits on the engine. +func (b *InMemoryBackend) newWR(op, compartment, entityType, id, action string, finish *time.Time) string { + wr := &WorkRequest{ + ID: newOCID("clustersworkrequest"), Operation: op, CompartmentID: compartment, EntityType: entityType, + EntityID: id, EntityURI: "/" + entityPath(entityType) + "/" + id, Action: action, Accepted: b.nowUTC(), + FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr.ID +} + +func entityPath(entityType string) string { + if entityType == entityNodePool { + return "nodePools" + } + + return "clusters" +} + +func (wr *WorkRequest) finished(now time.Time) bool { + return wr.Failure != "" || (wr.FinishAt != nil && !wr.FinishAt.After(now)) +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + started := wr.Accepted + out := WorkRequestWire{ + OperationType: wr.Operation, ID: wr.ID, CompartmentID: wr.CompartmentID, TimeAccepted: wr.Accepted, + Status: wrStatusPending, TimeStarted: &started, + Resources: []wrResourceWire{{ + EntityType: wr.EntityType, ActionType: wrStatusPending, Identifier: wr.EntityID, EntityURI: wr.EntityURI, + }}, + } + + if !wr.finished(b.nowUTC()) { + return out + } + + out.TimeFinished = wr.FinishAt + + if wr.Failure != "" { + out.Status, out.Resources[0].ActionType = "FAILED", "FAILED" + + return out + } + + out.Status, out.Resources[0].ActionType = "SUCCEEDED", wr.Action + + return out +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// WRListIn are the ListWorkRequests filters. +type WRListIn struct { + Compartment string + ClusterID string + ResourceID string + ResourceType string + Statuses []string + ListOpts +} + +// ListWorkRequests lists the work requests of a compartment. +func (b *InMemoryBackend) ListWorkRequests(in WRListIn) (page.Page[WorkRequestWire], error) { + if aerr := firstErr( + b.checkCompartmentLocked(in.Compartment), + validListOpts(in.ListOpts, "ID", "OPERATION_TYPE", "STATUS", "TIME_ACCEPTED", "TIME_STARTED", "TIME_FINISHED"), + ); aerr != nil { + return page.Page[WorkRequestWire]{}, aerr + } + + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + var all []*WorkRequest + + for _, wr := range b.workReqs { + if wr.CompartmentID != in.Compartment || (in.ResourceID != "" && wr.EntityID != in.ResourceID) || + (in.ResourceType != "" && !equalFoldStr(wr.EntityType, in.ResourceType)) || + (in.ClusterID != "" && !b.wrInCluster(wr, in.ClusterID)) { + continue + } + + if len(in.Statuses) > 0 && !containsFold(in.Statuses, b.wrView(wr).Status) { + continue + } + + all = append(all, wr) + } + + pg := paginate(all, in.ListOpts, func(w *WorkRequest) string { return w.ID }, + func(w *WorkRequest) string { return w.Operation }, + func(w *WorkRequest) (time.Time, int64) { return w.Accepted, w.Seq }) + + out := make([]WorkRequestWire, 0, len(pg.Data)) + for _, wr := range pg.Data { + out = append(out, b.wrView(wr)) + } + + return page.Page[WorkRequestWire]{Data: out, Next: pg.Next}, nil +} + +func (b *InMemoryBackend) wrInCluster(wr *WorkRequest, clusterID string) bool { + if wr.EntityID == clusterID { + return true + } + + np, ok := b.pools[wr.EntityID] + + return ok && np.ClusterID == clusterID +} + +func (b *InMemoryBackend) checkCompartmentLocked(id string) *APIError { + b.mu.RLock("checkCompartment") + defer b.mu.RUnlock() + + return b.checkCompartment(id) +} + +// WorkRequestLogs returns the log entries of a work request. +func (b *InMemoryBackend) WorkRequestLogs(id string, o ListOpts) (page.Page[wrLogWire], error) { + b.mu.RLock("WorkRequestLogs") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrLogWire]{}, errNotFound(id) + } + + logs := []wrLogWire{{Timestamp: wr.Accepted, Message: wr.Operation + " accepted"}} + + switch { + case wr.Failure != "": + logs = append(logs, wrLogWire{Timestamp: *wr.FinishAt, Message: wr.Operation + " failed: " + wr.Failure}) + case wr.finished(b.nowUTC()): + logs = append(logs, wrLogWire{Timestamp: *wr.FinishAt, Message: wr.Operation + " succeeded"}) + } + + return page.New(logs, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestErrors returns the errors of a work request. +func (b *InMemoryBackend) WorkRequestErrors(id string) ([]wrErrorWire, error) { + b.mu.RLock("WorkRequestErrors") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return nil, errNotFound(id) + } + + out := []wrErrorWire{} + if wr.Failure != "" { + out = append(out, wrErrorWire{Timestamp: *wr.FinishAt, Code: "ClusterStartFailed", Message: wr.Failure}) + } + + return out, nil +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + + return reply(q, w, "", err) +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(WRListIn{ + Compartment: q.query("compartmentId"), ClusterID: q.query("clusterId"), ResourceID: q.query("resourceId"), + ResourceType: q.query("resourceType"), Statuses: q.queryAll("status"), ListOpts: q.listOpts(), + }) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestLogs(q *req) error { + pg, err := h.Backend.WorkRequestLogs(q.param("workRequestId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestErrors(q *req) error { + errs, err := h.Backend.WorkRequestErrors(q.param("workRequestId")) + if err != nil { + return err + } + + return q.json(http.StatusOK, errs, "") +} diff --git a/services/ocicontainerinstances/PARITY.md b/services/ocicontainerinstances/PARITY.md new file mode 100644 index 0000000000..0e0f9c75d8 --- /dev/null +++ b/services/ocicontainerinstances/PARITY.md @@ -0,0 +1,43 @@ +--- +service: ocicontainerinstances +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests; field names, enums and paths come from its containerinstances package +last_audit_commit: 433c4a138 +last_audit_date: 2026-10-31 +overall: B +ops: + CreateContainerInstance: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with the ContainerInstance body (SDK CreateContainerInstanceResponse), etag and opc-work-request-id; opc-retry-token returns the original instance and work request. Requires compartmentId (checked through OCI Identity when linked), availabilityDomain, shape, shapeConfig.ocpus, 1-32 containers (cap unverified) with imageUrl, exactly one VNIC (Oracle lists max 1 VNIC) whose subnet must exist in OCI Core when linked. containerRestartPolicy defaults to ALWAYS. Volumes EMPTYDIR (EPHEMERAL_STORAGE or MEMORY) and CONFIGFILE, up to 32 (SDK doc); OCI_FSS_FILE_SYSTEM is rejected. Mounts, config file names and paths are validated against traversal. Health checks HTTP and TCP with failureAction KILL or NONE. imagePullSecrets BASIC and VAULT. CREATING until the work request finishes, then ACTIVE (FAILED with lifecycleDetails when the engine cannot start the containers)."} + GetContainerInstance: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Full model with etag. imagePullSecrets return registryEndpoint (and secretId for VAULT) only; BASIC usernames and passwords are never returned, as the SDK response model has no such fields. DELETED instances stay readable."} + ListContainerInstances: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; lifecycleState, displayName, availabilityDomain; sortBy timeCreated (newest first) or displayName, sortOrder, limit, opc-next-page; body is {items}."} + UpdateContainerInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName and tags, applied at once; 202 with a work request that finishes after the transition time; If-Match mismatch is 412 NoEtagMatch; 409 IncorrectState while DELETING or DELETED."} + DeleteContainerInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "From ACTIVE, INACTIVE or FAILED: DELETING then DELETED; docker engine removes the containers and volumes first."} + StartContainerInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "From INACTIVE: UPDATING then ACTIVE; 409 IncorrectState otherwise. The docker engine re-creates the containers."} + StopContainerInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "From ACTIVE: UPDATING then INACTIVE; containers become INACTIVE. gracefulShutdownTimeoutInSeconds is stored but docker uses its own fixed stop timeout."} + RestartContainerInstance: {wire: ok, errors: ok, state: ok, persist: ok, note: "From ACTIVE: UPDATING then ACTIVE; the docker engine stops and re-creates every container."} + ChangeContainerInstanceCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "Body compartmentId, checked through OCI Identity when linked; the work request moves with the new compartment."} + ListContainerInstanceShapes: {wire: ok, errors: ok, state: ok, persist: n/a, note: "CI.Standard.A1.Flex, E3.Flex, E4.Flex and E5.Flex with ocpuOptions, memoryOptions and networkingBandwidthOptions. shapeConfig is validated against them: ocpus within the shape range, memory between max(1, ocpus) GB and min(64 per OCPU, shape maximum), default memory per OCPU 16 (E3/E4), 6 (A1), 12 (E5)."} + GetContainer: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Command, arguments, workingDirectory, environmentVariables, volumeMounts, resourceConfig, healthChecks with live status and statusDetails, exitCode, timeTerminated and containerRestartAttemptCount from the engine."} + ListContainers: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; containerInstanceId, lifecycleState, displayName, availabilityDomain filters; body is {items}."} + UpdateContainer: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName (unique inside the instance) and tags; work request UPDATE_CONTAINER."} + RetrieveLogs: {wire: ok, errors: partial, state: ok, persist: n/a, note: "Docker engine: stdout and stderr (merged, stdout first) of the live container, the last run's logs after an exit or a stop, and with isPrevious the logs of the run before the last restart; capped to the last 20 MiB (Oracle documents 20 MB of retained log files per container). State engine: 409 IncorrectState, because no container ever ran."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS then SUCCEEDED (or FAILED); percentComplete from the injectable clock for state-engine work, 50 until the docker engine finishes. Resources carry entityType containerInstance or container, actionType and entityUri."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; workRequestId, status, resourceId, availabilityDomain; newest first, paged."} + ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "One entry for a FAILED work request with the engine failure."} + ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "accepted, in progress and finished lines."} +families: + auth: {status: partial, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-container-instances-enforce-auth rejects unsigned requests."} + error_envelope: {status: ok, note: "{code,message} with opc-request-id on every response: InvalidParameter, MissingParameter, CannotParseRequest, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch, IncorrectState."} + engine_state: {status: ok, note: "Default engine (--oci-container-instances-engine=state): lifecycle only, on the injectable clock; no container runs, RetrieveLogs is 409."} + engine_docker: {status: partial, note: "--oci-container-instances-engine=docker runs every container in the container runtime (pkgs/container via pkgs/gcpserve). command maps to the entrypoint and arguments to the command; resourceConfig vcpusLimit and memoryLimitInGBs map to docker NanoCPUs and memory limits; environment variables, workingDirectory, security context runAsUser and runAsGroup, CONFIGFILE and EMPTYDIR volumes (host scratch directories, tmpfs-like /dev/shm for MEMORY), subPath, read-only mounts and dnsConfig (a read-only /etc/resolv.conf) are applied. containerRestartPolicy ALWAYS, NEVER and ON_FAILURE are enforced with a 10s doubling back-off (max 5m). Images are pulled when absent, or always when a matching imagePullSecret exists; BASIC credentials go to docker pull, VAULT secrets are read from OCI Vault (JSON {username,password})."} + health_checks: {status: partial, note: "HTTP and TCP probes dial only the container's loopback-published port (127.0.0.1) and never use TLS; HTTP succeeds on 200-399; TCP succeeds when the connection stays open for 250 ms, because docker's userland proxy accepts connections on a published port even when nothing listens inside the container. Status is UNKNOWN until initialDelayInSeconds passes, HEALTHY after successThreshold successes and UNHEALTHY after failureThreshold failures; failureAction KILL stops the container so the restart policy applies, NONE only records the status. Defaults interval 10, failureThreshold 3, successThreshold 1, timeout 1 (unverified)."} + lifecycle: {status: ok, note: "When every container has exited for good (restart policy NEVER, or ON_FAILURE after success) the docker engine moves the instance to INACTIVE. After a restart of the emulator running instances come back INACTIVE (containers do not survive); start them again."} + persistence: {status: ok, note: "Snapshot carries instances with containers, volumes (config file data included), health checks, image pull secrets (BASIC passwords included, never returned by the API), work requests, the retry tokens, the sequence and API keys."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-container-instances-port / OCI_CONTAINER_INSTANCES_PORT (default 8167); never on the shared AWS router; cli.go reserves the port in the allocator. testcontainers key containerinstances."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all backend state, a second guards the engine's runs; container, environment and secret values are never logged."} +items_still_open: + - "Unverified, chosen by the emulator: the 32 container cap; the health check defaults; default memory per OCPU; the E3 limits (mirrors E4, the Oracle shape page lists E4, E5 and A1 only) and all processorDescription strings; ocid kinds computecontainerinstance, computecontainer and computecontainerinstanceworkrequest; 409 IncorrectState when stopping, starting or restarting from the wrong state; UPDATING as the state during stop, start and restart; INACTIVE once all containers have terminated; 409 for RetrieveLogs with the state engine; gracefulShutdownTimeoutInSeconds is accepted without an upper bound." + - "Only one VNIC and no ocicore VNIC resource: vnicId is a generated OCID that OCI Core does not know, no private IP is assigned, isPublicIpAssigned, hostnameLabel, nsgIds and privateIp are accepted but unused, and containers have no network of their own beyond docker's default." + - "OCI_FSS_FILE_SYSTEM volumes are rejected. securityContext is stored and returned but only runAsUser and runAsGroup are enforced; fsGroup, capabilities, isRootFileSystemReadonly and isNonRootUserCheckEnabled are ignored. isResourcePrincipalDisabled is stored, no resource principal is provided to the containers." + - "gracefulShutdownTimeoutInSeconds is not enforced: docker stops use the runtime's fixed timeout. Memory-backed EMPTYDIR volumes use a directory under /dev/shm rather than a per-instance tmpfs." + - "VAULT image pull secrets expect the secret content to be JSON {\"username\":...,\"password\":...}; Oracle's exact secret format is unverified. Pull secrets only apply to the registry endpoint they name." + - "Containers are re-created from their image on every start and restart, so a restart policy restart loses the container's filesystem; only the previous run's logs are kept. Docker log files are not rotated per Oracle's 20 MB retention." + - "Compute shape quotas, availability-domain and fault-domain validation, tenancy limits, IAM policies, systemTags and the definedTags namespace check are not applied." diff --git a/services/ocicontainerinstances/README.md b/services/ocicontainerinstances/README.md new file mode 100644 index 0000000000..869d4c6fd4 --- /dev/null +++ b/services/ocicontainerinstances/README.md @@ -0,0 +1,29 @@ + +# OCI Container Instances + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-31 (`433c4a138`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 18 (16 ok, 2 partial) | +| Feature families | 9 (6 ok, 3 partial) | +| Known gaps | 7 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Unverified, chosen by the emulator: the 32 container cap; the health check defaults; default memory per OCPU; the E3 limits (mirrors E4, the Oracle shape page lists E4, E5 and A1 only) and all processorDescription strings; ocid kinds computecontainerinstance, computecontainer and computecontainerinstanceworkrequest; 409 IncorrectState when stopping, starting or restarting from the wrong state; UPDATING as the state during stop, start and restart; INACTIVE once all containers have terminated; 409 for RetrieveLogs with the state engine; gracefulShutdownTimeoutInSeconds is accepted without an upper bound. +- Only one VNIC and no ocicore VNIC resource: vnicId is a generated OCID that OCI Core does not know, no private IP is assigned, isPublicIpAssigned, hostnameLabel, nsgIds and privateIp are accepted but unused, and containers have no network of their own beyond docker's default. +- OCI_FSS_FILE_SYSTEM volumes are rejected. securityContext is stored and returned but only runAsUser and runAsGroup are enforced; fsGroup, capabilities, isRootFileSystemReadonly and isNonRootUserCheckEnabled are ignored. isResourcePrincipalDisabled is stored, no resource principal is provided to the containers. +- gracefulShutdownTimeoutInSeconds is not enforced: docker stops use the runtime's fixed timeout. Memory-backed EMPTYDIR volumes use a directory under /dev/shm rather than a per-instance tmpfs. +- VAULT image pull secrets expect the secret content to be JSON {"username":...,"password":...}; Oracle's exact secret format is unverified. Pull secrets only apply to the registry endpoint they name. +- Containers are re-created from their image on every start and restart, so a restart policy restart loses the container's filesystem; only the previous run's logs are kept. Docker log files are not rotated per Oracle's 20 MB retention. +- Compute shape quotas, availability-domain and fault-domain validation, tenancy limits, IAM policies, systemTags and the definedTags namespace check are not applied. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocicontainerinstances/catalog.go b/services/ocicontainerinstances/catalog.go new file mode 100644 index 0000000000..506657efd9 --- /dev/null +++ b/services/ocicontainerinstances/catalog.go @@ -0,0 +1,57 @@ +package ocicontainerinstances + +import "slices" + +type shapeDef struct { + Name string + Processor string + MinOcpus float32 + MaxOcpus float32 + MaxMemory float32 + DefPerOcpu float32 + MaxPerOcpu float32 + MaxBandwith float32 +} + +const ( + minMemoryGB = 1 + minPerOcpuGB = 1 + gbpsPerOcpu = 1 + maxPerOcpuGB = 64 + shapeE4 = "CI.Standard.E4.Flex" + shapeE3 = "CI.Standard.E3.Flex" + shapeA1 = "CI.Standard.A1.Flex" + shapeE5 = "CI.Standard.E5.Flex" + e4MaxMemory = 1024 + a1MaxMemory = 488 + e5MaxMemory = 1504 + a1MaxOcpus = 76 + e5MaxOcpus = 94 + defaultMemE = 16 + defaultMemA1 = 6 + defaultMemE5 = 12 + bandwidthLow = 40 + bandwidthE5Max = 100 + e4MaxOcpus = 64 +) + +// shapeCatalog: E4/A1/E5 limits are from the Oracle "Container Instance Shapes" page; E3 mirrors E4 (unverified). +func shapeCatalog() []shapeDef { + return []shapeDef{ + {shapeA1, "3.0 GHz Ampere Altra", 1, a1MaxOcpus, a1MaxMemory, defaultMemA1, maxPerOcpuGB, bandwidthLow}, + {shapeE3, "2.25 GHz AMD EPYC 7742", 1, e4MaxOcpus, e4MaxMemory, defaultMemE, maxPerOcpuGB, bandwidthLow}, + {shapeE4, "2.55 GHz AMD EPYC 7J13", 1, e4MaxOcpus, e4MaxMemory, defaultMemE, maxPerOcpuGB, bandwidthLow}, + {shapeE5, "2.7 GHz AMD EPYC 9J14", 1, e5MaxOcpus, e5MaxMemory, defaultMemE5, maxPerOcpuGB, bandwidthE5Max}, + } +} + +func lookupShape(name string) (shapeDef, bool) { + i := slices.IndexFunc(shapeCatalog(), func(s shapeDef) bool { return s.Name == name }) + if i < 0 { + return shapeDef{}, false + } + + return shapeCatalog()[i], true +} + +func (s shapeDef) bandwidth(ocpus float32) float32 { return min(ocpus*gbpsPerOcpu, s.MaxBandwith) } diff --git a/services/ocicontainerinstances/control_test.go b/services/ocicontainerinstances/control_test.go new file mode 100644 index 0000000000..8a69a2b67a --- /dev/null +++ b/services/ocicontainerinstances/control_test.go @@ -0,0 +1,701 @@ +package ocicontainerinstances_test + +import ( + "io" + "net/http" + "strings" + "testing" + + ci "github.com/oracle/oci-go-sdk/v65/containerinstances" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + svc "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" +) + +func TestCreateValidation(t *testing.T) { + t.Parallel() + + manyContainers := func(d *ci.CreateContainerInstanceDetails) { + d.Containers = nil + for range 33 { + d.Containers = append(d.Containers, ci.CreateContainerDetails{ImageUrl: new("alpine")}) + } + } + + tests := []struct { + mutate func(*ci.CreateContainerInstanceDetails) + name string + wantCode string + wantHTTP int + }{ + {func(d *ci.CreateContainerInstanceDetails) { d.Shape = new("CI.Nope") }, "bad-shape", "InvalidParameter", 400}, + { + func(d *ci.CreateContainerInstanceDetails) { d.ShapeConfig.Ocpus = new(float32(65)) }, + "ocpus-high", + "InvalidParameter", + 400, + }, + { + func(d *ci.CreateContainerInstanceDetails) { d.ShapeConfig.Ocpus = new(float32(0)) }, + "ocpus-low", + "InvalidParameter", + 400, + }, + { + func(d *ci.CreateContainerInstanceDetails) { d.ShapeConfig.MemoryInGBs = new(float32(1)) }, + "memory-low", + "InvalidParameter", + 400, + }, + { + func(d *ci.CreateContainerInstanceDetails) { d.ShapeConfig.MemoryInGBs = new(float32(2000)) }, + "memory-high", + "InvalidParameter", + 400, + }, + {func(d *ci.CreateContainerInstanceDetails) { + d.Shape = new("CI.Standard.A1.Flex") + d.ShapeConfig.Ocpus = new(float32(77)) + }, "a1-ocpus-high", "InvalidParameter", 400}, + { + func(d *ci.CreateContainerInstanceDetails) { d.Containers = []ci.CreateContainerDetails{} }, + "no-containers", + "MissingParameter", + 400, + }, + {manyContainers, "too-many-containers", "InvalidParameter", 400}, + { + func(d *ci.CreateContainerInstanceDetails) { d.Containers[0].ImageUrl = new("") }, + "no-image", + "MissingParameter", + 400, + }, + { + func(d *ci.CreateContainerInstanceDetails) { d.Vnics = []ci.CreateContainerVnicDetails{} }, + "no-vnics", + "MissingParameter", + 400, + }, + {func(d *ci.CreateContainerInstanceDetails) { + d.Vnics = append(d.Vnics, ci.CreateContainerVnicDetails{SubnetId: new(testSubnet)}) + }, "two-vnics", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Vnics[0].SubnetId = new("ocid1.subnet.oc1..gone") + }, "unknown-subnet", "NotAuthorizedOrNotFound", 404}, + {func(d *ci.CreateContainerInstanceDetails) { + d.CompartmentId = new("ocid1.compartment.oc1..gone") + }, "unknown-compartment", "NotAuthorizedOrNotFound", 404}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Containers[0].VolumeMounts = []ci.CreateVolumeMountDetails{ + {MountPath: new("/x"), VolumeName: new("none")}, + } + }, "mount-missing-volume", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Volumes = []ci.CreateContainerVolumeDetails{ + ci.CreateContainerEmptyDirVolumeDetails{Name: new("v")}, + ci.CreateContainerEmptyDirVolumeDetails{Name: new("v")}, + } + }, "duplicate-volume", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Volumes = []ci.CreateContainerVolumeDetails{ci.CreateContainerConfigFileVolumeDetails{ + Name: new("cfg"), Configs: []ci.ContainerConfigFile{{FileName: new("../x"), Data: []byte("a")}}, + }} + }, "configfile-traversal", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Volumes = []ci.CreateContainerVolumeDetails{ + ci.CreateContainerConfigFileVolumeDetails{ + Name: new( + "cfg", + ), + Configs: []ci.ContainerConfigFile{{FileName: new("a"), Path: new("../up"), Data: []byte("a")}}, + }, + } + }, "configfile-path-traversal", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Containers[0].HealthChecks = []ci.CreateContainerHealthCheckDetails{ + ci.CreateContainerTcpHealthCheckDetails{Port: new(0)}, + } + }, "healthcheck-port", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Containers[0].HealthChecks = []ci.CreateContainerHealthCheckDetails{ + ci.CreateContainerHttpHealthCheckDetails{Port: new(80), Path: new("health")}, + } + }, "healthcheck-path", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.Containers[0].ResourceConfig = &ci.CreateContainerResourceConfigDetails{VcpusLimit: new(float32(3))} + }, "container-vcpus-over-shape", "InvalidParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.ImagePullSecrets = []ci.CreateImagePullSecretDetails{ + ci.CreateBasicImagePullSecretDetails{ + RegistryEndpoint: new("r.io"), + Username: new("u"), + Password: new(""), + }, + } + }, "basic-secret-no-password", "MissingParameter", 400}, + {func(d *ci.CreateContainerInstanceDetails) { + d.DnsConfig = &ci.CreateContainerDnsConfigDetails{Nameservers: []string{"not-an-ip"}} + }, "dns-bad-nameserver", "InvalidParameter", 400}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{configure: func(b *svc.InMemoryBackend) { + b.SetCompartmentChecker(func(id string) bool { return id == testCompartment || id == otherCompart }) + b.SetSubnetChecker(func(id string) bool { return id == testSubnet }) + }}) + + _, err := e.create(t, createOpts{mutate: tc.mutate}) + se := svcErr(t, err) + assert.Equal(t, tc.wantHTTP, se.GetHTTPStatusCode()) + assert.Equal(t, tc.wantCode, se.GetCode()) + assert.NotEmpty(t, se.GetOpcRequestID()) + }) + } +} + +func TestCreateAndStateMachine(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + d.FreeformTags = map[string]string{"env": "dev"} + d.Containers[0].DisplayName = new("app") + d.Containers[0].EnvironmentVariables = map[string]string{"A": "1"} + d.Containers[0].Command = []string{"sh"} + d.Containers[0].Arguments = []string{"-c", "true"} + d.Containers[0].WorkingDirectory = new("/tmp") + }}) + + require.NotNil(t, resp.OpcWorkRequestId) + require.NotNil(t, resp.Etag) + assert.Equal(t, ci.ContainerInstanceLifecycleStateCreating, resp.LifecycleState) + assert.Equal(t, ci.ContainerInstanceContainerRestartPolicyAlways, resp.ContainerRestartPolicy) + + id := resp.Id + create := e.wr(t, resp.OpcWorkRequestId) + assert.Equal(t, ci.OperationStatusInProgress, create.Status) + assert.Equal(t, ci.OperationTypeCreateContainerInstance, create.OperationType) + require.Len(t, create.Resources, 1) + assert.Equal(t, id, create.Resources[0].Identifier) + assert.Equal(t, ci.ActionTypeCreated, create.Resources[0].ActionType) + + cur := e.get(t, id) + assert.Equal(t, ci.ContainerInstanceLifecycleStateCreating, cur.LifecycleState) + + e.settle() + + cur = e.get(t, id) + assert.Equal(t, ci.ContainerInstanceLifecycleStateActive, cur.LifecycleState) + assert.Equal(t, 1, *cur.ContainerCount) + assert.InDelta(t, 2, *cur.ShapeConfig.Ocpus, 0) + assert.InDelta(t, 8, *cur.ShapeConfig.MemoryInGBs, 0) + assert.InDelta(t, 2, *cur.ShapeConfig.NetworkingBandwidthInGbps, 0) + assert.NotEmpty(t, *cur.ShapeConfig.ProcessorDescription) + require.Len(t, cur.Vnics, 1) + assert.True(t, strings.HasPrefix(*cur.Vnics[0].VnicId, "ocid1.vnic.")) + assert.Equal(t, "dev", cur.FreeformTags["env"]) + assert.Equal(t, ci.OperationStatusSucceeded, e.wr(t, resp.OpcWorkRequestId).Status) + + ctrs, err := e.client.ListContainers(t.Context(), ci.ListContainersRequest{ + CompartmentId: new(testCompartment), ContainerInstanceId: id, + }) + require.NoError(t, err) + require.Len(t, ctrs.Items, 1) + assert.Equal(t, ci.ContainerLifecycleStateActive, ctrs.Items[0].LifecycleState) + + got, err := e.client.GetContainer(t.Context(), ci.GetContainerRequest{ContainerId: ctrs.Items[0].Id}) + require.NoError(t, err) + assert.Equal(t, "app", *got.DisplayName) + assert.Equal(t, map[string]string{"A": "1"}, got.EnvironmentVariables) + assert.Equal(t, []string{"sh"}, got.Command) + assert.Equal(t, []string{"-c", "true"}, got.Arguments) + assert.Equal(t, "/tmp", *got.WorkingDirectory) + + step := func(name string, do func() (*string, error), mid, end ci.ContainerInstanceLifecycleStateEnum) { + t.Helper() + + wrID, derr := do() + require.NoError(t, derr, name) + assert.Equal(t, mid, e.get(t, id).LifecycleState, name) + e.settle() + assert.Equal(t, end, e.get(t, id).LifecycleState, name) + assert.Equal(t, ci.OperationStatusSucceeded, e.wr(t, wrID).Status, name) + } + + step("stop", func() (*string, error) { + r, serr := e.client.StopContainerInstance(t.Context(), ci.StopContainerInstanceRequest{ContainerInstanceId: id}) + + return r.OpcWorkRequestId, serr + }, ci.ContainerInstanceLifecycleStateUpdating, ci.ContainerInstanceLifecycleStateInactive) + + got, err = e.client.GetContainer(t.Context(), ci.GetContainerRequest{ContainerId: ctrs.Items[0].Id}) + require.NoError(t, err) + assert.Equal(t, ci.ContainerLifecycleStateInactive, got.LifecycleState) + + _, err = e.client.StopContainerInstance(t.Context(), ci.StopContainerInstanceRequest{ContainerInstanceId: id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + step("start", func() (*string, error) { + r, serr := e.client.StartContainerInstance( + t.Context(), + ci.StartContainerInstanceRequest{ContainerInstanceId: id}, + ) + + return r.OpcWorkRequestId, serr + }, ci.ContainerInstanceLifecycleStateUpdating, ci.ContainerInstanceLifecycleStateActive) + + _, err = e.client.StartContainerInstance(t.Context(), ci.StartContainerInstanceRequest{ContainerInstanceId: id}) + assert.Equal(t, "IncorrectState", svcErr(t, err).GetCode()) + + step("restart", func() (*string, error) { + r, serr := e.client.RestartContainerInstance( + t.Context(), + ci.RestartContainerInstanceRequest{ContainerInstanceId: id}, + ) + + return r.OpcWorkRequestId, serr + }, ci.ContainerInstanceLifecycleStateUpdating, ci.ContainerInstanceLifecycleStateActive) + + step("delete", func() (*string, error) { + r, serr := e.client.DeleteContainerInstance( + t.Context(), + ci.DeleteContainerInstanceRequest{ContainerInstanceId: id}, + ) + + return r.OpcWorkRequestId, serr + }, ci.ContainerInstanceLifecycleStateDeleting, ci.ContainerInstanceLifecycleStateDeleted) + + _, err = e.client.UpdateContainerInstance(t.Context(), ci.UpdateContainerInstanceRequest{ + ContainerInstanceId: id, DisplayName: new("x"), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestUpdateMoveAndIfMatch(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{configure: func(b *svc.InMemoryBackend) { + b.SetCompartmentChecker(func(id string) bool { return id == testCompartment || id == otherCompart }) + }}) + resp := e.mustCreate(t, createOpts{}) + e.settle() + + id := resp.Id + cur, err := e.client.GetContainerInstance(t.Context(), ci.GetContainerInstanceRequest{ContainerInstanceId: id}) + require.NoError(t, err) + + _, err = e.client.UpdateContainerInstance(t.Context(), ci.UpdateContainerInstanceRequest{ + ContainerInstanceId: id, IfMatch: new("stale"), + DisplayName: new("n"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.client.UpdateContainerInstance(t.Context(), ci.UpdateContainerInstanceRequest{ + ContainerInstanceId: id, IfMatch: cur.Etag, + DisplayName: new("renamed"), FreeformTags: map[string]string{"k": "v"}, + }) + require.NoError(t, err) + assert.Equal(t, ci.OperationTypeUpdateContainerInstance, e.wr(t, upd.OpcWorkRequestId).OperationType) + + after := e.get(t, id) + assert.Equal(t, "renamed", *after.DisplayName) + assert.Equal(t, map[string]string{"k": "v"}, after.FreeformTags) + assert.NotNil(t, after.TimeUpdated) + + mv, err := e.client.ChangeContainerInstanceCompartment(t.Context(), ci.ChangeContainerInstanceCompartmentRequest{ + ContainerInstanceId: id, + CompartmentId: new(otherCompart), + }) + require.NoError(t, err) + assert.Equal(t, ci.OperationTypeMoveContainerInstance, e.wr(t, mv.OpcWorkRequestId).OperationType) + assert.Equal(t, otherCompart, *e.get(t, id).CompartmentId) + + _, err = e.client.ChangeContainerInstanceCompartment(t.Context(), ci.ChangeContainerInstanceCompartmentRequest{ + ContainerInstanceId: id, + CompartmentId: new("ocid1.compartment.oc1..gone"), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.GetContainerInstance( + t.Context(), + ci.GetContainerInstanceRequest{ContainerInstanceId: new("ocid1.x.oc1..none")}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestListFiltersAndPaging(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + for _, n := range []string{"b", "a", "c"} { + e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { d.DisplayName = new(n) }}) + } + + e.settle() + + other := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + d.CompartmentId = new(otherCompart) + }}) + e.settle() + _, err := e.client.StopContainerInstance( + t.Context(), + ci.StopContainerInstanceRequest{ContainerInstanceId: other.Id}, + ) + require.NoError(t, err) + e.settle() + + list := func(r ci.ListContainerInstancesRequest) ci.ListContainerInstancesResponse { + t.Helper() + + if r.CompartmentId == nil { + r.CompartmentId = new(testCompartment) + } + + out, lerr := e.client.ListContainerInstances(t.Context(), r) + require.NoError(t, lerr) + + return out + } + + assert.Len(t, list(ci.ListContainerInstancesRequest{}).Items, 3) + assert.Len(t, list(ci.ListContainerInstancesRequest{DisplayName: new("a")}).Items, 1) + assert.Empty( + t, + list(ci.ListContainerInstancesRequest{LifecycleState: ci.ContainerInstanceLifecycleStateInactive}).Items, + ) + assert.Len(t, list(ci.ListContainerInstancesRequest{ + CompartmentId: new(otherCompart), LifecycleState: ci.ContainerInstanceLifecycleStateInactive, + }).Items, 1) + + var names []string + + var pageTok *string + + for { + out := list(ci.ListContainerInstancesRequest{ + Limit: new(2), Page: pageTok, SortBy: ci.ListContainerInstancesSortByDisplayname, + SortOrder: ci.ListContainerInstancesSortOrderAsc, + }) + for _, it := range out.Items { + names = append(names, *it.DisplayName) + } + + if out.OpcNextPage == nil { + break + } + + pageTok = out.OpcNextPage + } + + assert.Equal(t, []string{"a", "b", "c"}, names) + + code, body := rawGet(t, e.base+"/20210415/containerInstances") + assert.Equal(t, http.StatusBadRequest, code) + assert.Contains(t, body, "MissingParameter") +} + +func TestRetryTokenIsIdempotent(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + first := e.mustCreate(t, createOpts{token: "tok-1"}) + second := e.mustCreate(t, createOpts{token: "tok-1"}) + third := e.mustCreate(t, createOpts{token: "tok-2"}) + + assert.Equal(t, *first.Id, *second.Id) + assert.Equal(t, *first.OpcWorkRequestId, *second.OpcWorkRequestId) + assert.NotEqual(t, *first.Id, *third.Id) +} + +func TestContainersUpdateAndNames(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + d.Containers = []ci.CreateContainerDetails{ + {ImageUrl: new("alpine"), DisplayName: new("one")}, + {ImageUrl: new("alpine")}, + } + }}) + e.settle() + + list, err := e.client.ListContainers(t.Context(), ci.ListContainersRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 2) + + var one, two ci.ContainerSummary + + for _, c := range list.Items { + if *c.DisplayName == "one" { + one = c + } else { + two = c + } + } + + assert.Equal(t, "web-container-2", *two.DisplayName) + assert.Equal(t, *resp.Id, *two.ContainerInstanceId) + + _, err = e.client.UpdateContainer(t.Context(), ci.UpdateContainerRequest{ + ContainerId: two.Id, + DisplayName: new("ONE"), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.client.UpdateContainer(t.Context(), ci.UpdateContainerRequest{ + ContainerId: one.Id, + DisplayName: new("uno"), FreeformTags: map[string]string{"t": "1"}, + }) + require.NoError(t, err) + assert.Equal(t, ci.OperationTypeUpdateContainer, e.wr(t, upd.OpcWorkRequestId).OperationType) + + got, err := e.client.GetContainer(t.Context(), ci.GetContainerRequest{ContainerId: one.Id}) + require.NoError(t, err) + assert.Equal(t, "uno", *got.DisplayName) + assert.Equal(t, "1", got.FreeformTags["t"]) +} + +func TestShapesCatalog(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + out, err := e.client.ListContainerInstanceShapes(t.Context(), ci.ListContainerInstanceShapesRequest{ + CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + + byName := map[string]ci.ContainerInstanceShapeSummary{} + for _, s := range out.Items { + byName[*s.Name] = s + } + + for _, n := range []string{"CI.Standard.E4.Flex", "CI.Standard.E3.Flex", "CI.Standard.A1.Flex"} { + s, ok := byName[n] + require.True(t, ok, n) + assert.InDelta(t, 1, *s.OcpuOptions.Min, 0) + assert.Greater(t, *s.OcpuOptions.Max, float32(1)) + assert.Greater(t, *s.MemoryOptions.MaxInGBs, *s.MemoryOptions.MinInGBs) + assert.NotEmpty(t, *s.ProcessorDescription) + } + + assert.InDelta(t, 64, *byName["CI.Standard.E4.Flex"].OcpuOptions.Max, 0) + assert.InDelta(t, 76, *byName["CI.Standard.A1.Flex"].OcpuOptions.Max, 0) +} + +func TestMemoryDefaultsFromShape(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + d.ShapeConfig = &ci.CreateContainerInstanceShapeConfigDetails{Ocpus: new(float32(4))} + }}) + + assert.InDelta(t, 64, *resp.ShapeConfig.MemoryInGBs, 0) +} + +func TestImagePullSecretsNeverReturned(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + d.ImagePullSecrets = []ci.CreateImagePullSecretDetails{ + ci.CreateBasicImagePullSecretDetails{ + RegistryEndpoint: new("localhost:8166"), Username: new("ns/user"), + Password: new("s3cr3t-token"), + }, + } + }}) + + require.Len(t, resp.ImagePullSecrets, 1) + + basic, ok := resp.ImagePullSecrets[0].(ci.BasicImagePullSecret) + require.True(t, ok) + assert.Equal(t, "localhost:8166", *basic.RegistryEndpoint) + + code, body := rawGet(t, e.base+"/20210415/containerInstances/"+*resp.Id) + assert.Equal(t, http.StatusOK, code) + assert.NotContains(t, body, "s3cr3t-token") + assert.NotContains(t, body, "ns/user") +} + +func TestWorkRequestListErrorsAndLogs(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a := e.mustCreate(t, createOpts{}) + e.settle() + + _, err := e.client.StopContainerInstance(t.Context(), ci.StopContainerInstanceRequest{ContainerInstanceId: a.Id}) + require.NoError(t, err) + + list, err := e.client.ListWorkRequests(t.Context(), ci.ListWorkRequestsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 2) + assert.Equal(t, ci.OperationTypeStopContainerInstance, list.Items[0].OperationType) + + inProgress, err := e.client.ListWorkRequests(t.Context(), ci.ListWorkRequestsRequest{ + CompartmentId: new(testCompartment), Status: ci.ListWorkRequestsStatusInProgress, + }) + require.NoError(t, err) + assert.Len(t, inProgress.Items, 1) + + byRes, err := e.client.ListWorkRequests(t.Context(), ci.ListWorkRequestsRequest{ + CompartmentId: new(testCompartment), ResourceId: a.Id, Limit: new(1), + }) + require.NoError(t, err) + assert.Len(t, byRes.Items, 1) + assert.NotNil(t, byRes.OpcNextPage) + + e.settle() + + logs, err := e.client.ListWorkRequestLogs( + t.Context(), + ci.ListWorkRequestLogsRequest{WorkRequestId: a.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(logs.Items), 2) + + errs, err := e.client.ListWorkRequestErrors( + t.Context(), + ci.ListWorkRequestErrorsRequest{WorkRequestId: a.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Empty(t, errs.Items) + + _, err = e.client.GetWorkRequest(t.Context(), ci.GetWorkRequestRequest{WorkRequestId: new("ocid1.x.oc1..none")}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestRetrieveLogsNeedsDockerEngine(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mustCreate(t, createOpts{}) + e.settle() + + list, err := e.client.ListContainers(t.Context(), ci.ListContainersRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + _, err = e.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ContainerId: list.Items[0].Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ContainerId: new("ocid1.x.oc1..none")}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestSignatureEnforcement(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + + _, err := e.create(t, createOpts{}) + require.NoError(t, err) + + other := e.newClient(t, newKeyPair(t)) + _, err = other.ListContainerInstances( + t.Context(), + ci.ListContainerInstancesRequest{CompartmentId: new(testCompartment)}, + ) + assert.Equal(t, http.StatusUnauthorized, svcErr(t, err).GetHTTPStatusCode()) + + code, _ := rawGet(t, e.base+"/20210415/containerInstances?compartmentId="+testCompartment) + assert.Equal(t, http.StatusUnauthorized, code) +} + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + src := newEnv(t, envOpts{}) + created := src.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + d.Volumes = []ci.CreateContainerVolumeDetails{ci.CreateContainerConfigFileVolumeDetails{ + Name: new("cfg"), Configs: []ci.ContainerConfigFile{{FileName: new("a.txt"), Data: []byte("hello")}}, + }} + d.Containers[0].VolumeMounts = []ci.CreateVolumeMountDetails{{MountPath: new("/cfg"), VolumeName: new("cfg")}} + }}) + src.settle() + + snap := src.backend.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + dst := newEnv(t, envOpts{}) + require.NoError(t, dst.backend.Restore(t.Context(), snap)) + + got := dst.get(t, created.Id) + assert.Equal(t, ci.ContainerInstanceLifecycleStateActive, got.LifecycleState) + require.Len(t, got.Volumes, 1) + assert.Equal(t, "web", *got.DisplayName) + assert.Equal(t, ci.OperationStatusSucceeded, dst.wr(t, created.OpcWorkRequestId).Status) + + ctrs, err := dst.client.ListContainers(t.Context(), ci.ListContainersRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Len(t, ctrs.Items, 1) + + require.Error(t, dst.backend.Restore(t.Context(), []byte(`{"version":1,"instances":{"x":null}}`))) +} + +func TestResetClearsState(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mustCreate(t, createOpts{}) + e.backend.Reset() + + out, err := e.client.ListContainerInstances( + t.Context(), + ci.ListContainerInstancesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Empty(t, out.Items) +} + +func TestRawRequestErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantCode string + }{ + { + "bad-restart-policy", + `{"compartmentId":"c","availabilityDomain":"ad","shape":"CI.Standard.E4.Flex","shapeConfig":{"ocpus":1},` + + `"containerRestartPolicy":"SOMETIMES","containers":[{"imageUrl":"a"}],"vnics":[{"subnetId":"s"}]}`, + "InvalidParameter", + }, + {"not-json", `{`, "CannotParseRequest"}, + {"empty-body", ``, "MissingParameter"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, e.base+"/20210415/containerInstances", + strings.NewReader(tc.body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Contains(t, string(out), tc.wantCode) + assert.NotEmpty(t, resp.Header.Get("Opc-Request-Id")) + }) + } +} diff --git a/services/ocicontainerinstances/docker_test.go b/services/ocicontainerinstances/docker_test.go new file mode 100644 index 0000000000..0fc17964fe --- /dev/null +++ b/services/ocicontainerinstances/docker_test.go @@ -0,0 +1,363 @@ +package ocicontainerinstances_test + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" + "testing" + "time" + + ci "github.com/oracle/oci-go-sdk/v65/containerinstances" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" + "github.com/blackbirdworks/gopherstack/services/ociartifacts" + svc "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" +) + +const ( + dockerWait = 2 * time.Minute + testImage = "alpine:latest" + registryNS = "gopherstack" + registryTok = "r3gistry-t0ken" +) + +// newRealEnv wires the service to the local container runtime; it skips when none is reachable. +func newRealEnv(t *testing.T) *env { + t.Helper() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + + lr := gcpserve.NewLazyRuntime(nil, "ocicontainerinstances.test.runtime") + t.Cleanup(func() { _ = lr.Close() }) + + if _, err := lr.Get(); err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + if out, err := exec.CommandContext(t.Context(), "docker", "image", "inspect", testImage). + CombinedOutput(); err != nil { + t.Skipf("%s is not present locally: %s", testImage, out) + } + + eng := svc.NewEngine(func() (svc.Runtime, error) { + r, err := lr.Get() + if err != nil { + return nil, err + } + + return svc.NewDockerRuntime(r), nil + }) + eng.SetTiming(nil, func(int) time.Duration { return 200 * time.Millisecond }, nil) + + e := newEnv(t, envOpts{configure: func(b *svc.InMemoryBackend) { b.SetEngine(eng) }}) + t.Cleanup(e.backend.Close) + + return e +} + +func (e *env) awaitState(t *testing.T, id *string, want ci.ContainerInstanceLifecycleStateEnum) ci.ContainerInstance { + t.Helper() + + var got ci.ContainerInstance + + require.Eventually(t, func() bool { + got = e.get(t, id) + + return got.LifecycleState == want + }, dockerWait, 100*time.Millisecond, "want %s, last %s (%v)", want, got.LifecycleState, got.LifecycleDetails) + + return got +} + +func (e *env) firstContainer(t *testing.T, instID *string) ci.Container { + t.Helper() + + list, err := e.client.ListContainers(t.Context(), ci.ListContainersRequest{ + CompartmentId: new(testCompartment), ContainerInstanceId: instID, + }) + require.NoError(t, err) + require.NotEmpty(t, list.Items) + + got, err := e.client.GetContainer(t.Context(), ci.GetContainerRequest{ContainerId: list.Items[0].Id}) + require.NoError(t, err) + + return got.Container +} + +func (e *env) logsOf(t *testing.T, ctrID *string) string { + t.Helper() + + resp, err := e.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ContainerId: ctrID}) + require.NoError(t, err) + + defer resp.Content.Close() + + b, err := io.ReadAll(resp.Content) + require.NoError(t, err) + + return string(b) +} + +func shell(script string) func(*ci.CreateContainerInstanceDetails) { + return func(d *ci.CreateContainerInstanceDetails) { + d.Containers[0].ImageUrl = new(testImage) + d.Containers[0].Command = []string{"sh", "-c", script} + } +} + +func TestDockerRunToCompletion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + script string + policy ci.ContainerInstanceContainerRestartPolicyEnum + wantLogs []string + wantExit int + }{ + { + name: "never-success", script: "echo hello-$GREETING; echo oops >&2", wantExit: 0, + policy: ci.ContainerInstanceContainerRestartPolicyNever, wantLogs: []string{"hello-world", "oops"}, + }, + { + name: "never-failure", script: "echo failing; exit 3", wantExit: 3, + policy: ci.ContainerInstanceContainerRestartPolicyNever, wantLogs: []string{"failing"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newRealEnv(t) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + shell(tc.script)(d) + d.ContainerRestartPolicy = tc.policy + d.Containers[0].EnvironmentVariables = map[string]string{"GREETING": "world"} + }}) + + e.awaitState(t, resp.Id, ci.ContainerInstanceLifecycleStateInactive) + + ctr := e.firstContainer(t, resp.Id) + assert.Equal(t, ci.ContainerLifecycleStateInactive, ctr.LifecycleState) + require.NotNil(t, ctr.ExitCode) + assert.Equal(t, tc.wantExit, *ctr.ExitCode) + assert.Zero(t, *ctr.ContainerRestartAttemptCount) + + logs := e.logsOf(t, ctr.Id) + for _, want := range tc.wantLogs { + assert.Contains(t, logs, want) + } + }) + } +} + +func TestDockerOnFailureRestarts(t *testing.T) { + t.Parallel() + + e := newRealEnv(t) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + shell("echo attempt; exit 1")(d) + d.ContainerRestartPolicy = ci.ContainerInstanceContainerRestartPolicyOnFailure + }}) + + require.Eventually(t, func() bool { + n := e.firstContainer(t, resp.Id).ContainerRestartAttemptCount + + return n != nil && *n >= 2 + }, dockerWait, 100*time.Millisecond) + + assert.Equal(t, ci.ContainerInstanceLifecycleStateActive, e.get(t, resp.Id).LifecycleState) + + _, err := e.client.DeleteContainerInstance( + t.Context(), + ci.DeleteContainerInstanceRequest{ContainerInstanceId: resp.Id}, + ) + require.NoError(t, err) + e.awaitState(t, resp.Id, ci.ContainerInstanceLifecycleStateDeleted) +} + +func TestDockerVolumesAndLimits(t *testing.T) { + t.Parallel() + + e := newRealEnv(t) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + shell("cat /etc/app/app.conf; cat /etc/app/sub/n.conf; echo scratch > /scratch/f && cat /scratch/f; " + + "(cat /sys/fs/cgroup/memory.max || cat /sys/fs/cgroup/memory/memory.limit_in_bytes) 2>/dev/null; " + + "cat /etc/resolv.conf; echo done")(d) + d.ContainerRestartPolicy = ci.ContainerInstanceContainerRestartPolicyNever + d.Containers[0].ResourceConfig = &ci.CreateContainerResourceConfigDetails{ + VcpusLimit: new(float32(0.5)), + MemoryLimitInGBs: new(float32(0.25)), + } + d.Containers[0].VolumeMounts = []ci.CreateVolumeMountDetails{ + {MountPath: new("/etc/app"), VolumeName: new("cfg"), IsReadOnly: new(true)}, + {MountPath: new("/scratch"), VolumeName: new("tmp")}, + } + d.Volumes = []ci.CreateContainerVolumeDetails{ + ci.CreateContainerConfigFileVolumeDetails{Name: new("cfg"), Configs: []ci.ContainerConfigFile{ + {FileName: new("app.conf"), Data: []byte("mode=docker-test\n")}, + {FileName: new("n.conf"), Path: new("sub"), Data: []byte("nested-ok\n")}, + }}, + ci.CreateContainerEmptyDirVolumeDetails{Name: new("tmp")}, + } + d.DnsConfig = &ci.CreateContainerDnsConfigDetails{ + Nameservers: []string{"10.20.30.40"}, + Searches: []string{"ci.example"}, + } + }}) + + e.awaitState(t, resp.Id, ci.ContainerInstanceLifecycleStateInactive) + + ctr := e.firstContainer(t, resp.Id) + require.NotNil(t, ctr.ExitCode) + assert.Zero(t, *ctr.ExitCode) + + logs := e.logsOf(t, ctr.Id) + for _, want := range []string{ + "mode=docker-test", "nested-ok", "scratch", "268435456", "nameserver 10.20.30.40", "search ci.example", "done", + } { + assert.Contains(t, logs, want) + } +} + +const httpServer = `while true; do ` + + `printf "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok\n" | nc -l -p 8080; done` + +func TestDockerHealthChecks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + script string + check ci.CreateContainerHealthCheckDetails + want ci.ContainerHealthCheckStatusEnum + }{ + { + name: "http-healthy", script: httpServer, want: ci.ContainerHealthCheckStatusHealthy, + check: ci.CreateContainerHttpHealthCheckDetails{ + Port: new(8080), Path: new("/"), IntervalInSeconds: new(1), + TimeoutInSeconds: new(2), + }, + }, + { + name: "tcp-healthy", script: httpServer, want: ci.ContainerHealthCheckStatusHealthy, + check: ci.CreateContainerTcpHealthCheckDetails{ + Port: new(8080), IntervalInSeconds: new(1), TimeoutInSeconds: new(2), + }, + }, + { + name: "http-unhealthy", script: "sleep 600", want: ci.ContainerHealthCheckStatusUnhealthy, + check: ci.CreateContainerHttpHealthCheckDetails{ + Port: new(8080), Path: new("/"), IntervalInSeconds: new(1), TimeoutInSeconds: new(1), + FailureThreshold: new(1), FailureAction: ci.ContainerHealthCheckFailureActionNone, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + e := newRealEnv(t) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + shell(tc.script)(d) + d.Containers[0].HealthChecks = []ci.CreateContainerHealthCheckDetails{tc.check} + }}) + e.awaitState(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + + require.Eventually(t, func() bool { + hc := e.firstContainer(t, resp.Id).HealthChecks + + return len(hc) == 1 && hc[0].GetStatus() == tc.want + }, dockerWait, 200*time.Millisecond) + }) + } +} + +func TestDockerUnhealthyContainerIsKilledAndRestarted(t *testing.T) { + t.Parallel() + + e := newRealEnv(t) + resp := e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + shell("sleep 600")(d) + d.ContainerRestartPolicy = ci.ContainerInstanceContainerRestartPolicyAlways + d.Containers[0].HealthChecks = []ci.CreateContainerHealthCheckDetails{ + ci.CreateContainerTcpHealthCheckDetails{ + Port: new(9999), IntervalInSeconds: new(1), TimeoutInSeconds: new(1), + FailureThreshold: new(1), + }, + } + }}) + + require.Eventually(t, func() bool { + n := e.firstContainer(t, resp.Id).ContainerRestartAttemptCount + + return n != nil && *n >= 1 + }, dockerWait, 200*time.Millisecond) +} + +// TestDockerPullsFromEmulatorRegistry pushes an image to the emulator's OCI registry and has a container instance +// pull it with a BASIC image pull secret; a wrong token must fail the instance. +func TestDockerPullsFromEmulatorRegistry(t *testing.T) { + t.Parallel() + + e := newRealEnv(t) + + art := ociartifacts.NewInMemoryBackend() + art.SetNamespace(registryNS) + + ah := ociartifacts.NewHandler(art) + ah.Port = 0 + + require.NoError(t, ah.StartWorker(t.Context())) + t.Cleanup(func() { ah.Shutdown(context.Background()) }) + + endpoint := fmt.Sprintf("localhost:%d", ah.Port) + ref := fmt.Sprintf("%s/%s/ci-pull:1", endpoint, registryNS) + user := registryNS + "/tester@example.com" + cfg := t.TempDir() + + docker := func(stdin string, args ...string) { + t.Helper() + + cmd := exec.CommandContext(t.Context(), "docker", append([]string{"--config", cfg}, args...)...) + cmd.Stdin = strings.NewReader(stdin) + + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + } + + docker(registryTok, "login", endpoint, "-u", user, "--password-stdin") + docker("", "tag", testImage, ref) + docker("", "push", ref) + t.Cleanup(func() { _ = exec.CommandContext(context.Background(), "docker", "rmi", ref).Run() }) + docker("", "rmi", ref) + + pullWith := func(login string) ci.CreateContainerInstanceResponse { + return e.mustCreate(t, createOpts{mutate: func(d *ci.CreateContainerInstanceDetails) { + d.Containers[0].ImageUrl = new(ref) + d.Containers[0].Command = []string{"sh", "-c", "echo pulled-from-registry"} + d.ContainerRestartPolicy = ci.ContainerInstanceContainerRestartPolicyNever + d.ImagePullSecrets = []ci.CreateImagePullSecretDetails{ci.CreateBasicImagePullSecretDetails{ + RegistryEndpoint: new(endpoint), Username: new(login), Password: new(registryTok), + }} + }}) + } + + good := pullWith(user) + e.awaitState(t, good.Id, ci.ContainerInstanceLifecycleStateInactive) + assert.Contains(t, e.logsOf(t, e.firstContainer(t, good.Id).Id), "pulled-from-registry") + + bad := pullWith("other-tenancy/mallory") + failed := e.awaitState(t, bad.Id, ci.ContainerInstanceLifecycleStateFailed) + assert.Contains(t, *failed.LifecycleDetails, "pull image") + assert.NotContains(t, *failed.LifecycleDetails, registryTok) + assert.Equal(t, ci.OperationStatusFailed, e.wr(t, bad.OpcWorkRequestId).Status) +} diff --git a/services/ocicontainerinstances/engine.go b/services/ocicontainerinstances/engine.go new file mode 100644 index 0000000000..b6723309e8 --- /dev/null +++ b/services/ocicontainerinstances/engine.go @@ -0,0 +1,648 @@ +package ocicontainerinstances + +import ( + "context" + "errors" + "fmt" + "maps" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/registryauth" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + teardownTimeout = 20 * time.Second + backoffBase = 10 * time.Second + backoffMax = 5 * time.Minute + startTimeout = 10 * time.Minute + nanoPerCPU = 1e9 + bytesPerGB = 1 << 30 + nonceLen = 6 + killedExitCode = 137 + maxLogBytes = 20 << 20 + httpOKMin = 200 + httpOKMax = 399 +) + +var ( + errEngineDisabled = errors.New("the container engine is disabled") + errNoPreviousRun = errors.New("the container has not been restarted, so there is no previous run") + errStartTimeout = errors.New("containers did not start in time") +) + +var errNoLogsYet = errors.New("the container has no logs: it never started, or its instance was deleted") + +// Sink is the backend side the engine reports container state to. +type Sink interface { + Mutate(instID string, fn func(*Instance)) + Now() time.Time +} + +// Timer returns a channel that fires after d and a function that releases it. +type Timer func(d time.Duration) (<-chan time.Time, func()) + +// Prober checks one health check against the container's loopback-published port. +type Prober func(ctx context.Context, hc HealthCheck, hostPort int) error + +func realTimer(d time.Duration) (<-chan time.Time, func()) { + t := time.NewTimer(d) + + return t.C, func() { t.Stop() } +} + +type run struct { + ctx context.Context + rt Runtime + cancel context.CancelFunc + scratch *scratch + hostPorts map[string]int + docker map[string]string + prev map[string]string + killed map[string]bool + inst *Instance + creds []Credential + wg sync.WaitGroup +} + +// Engine runs container instances in the container runtime. +type Engine struct { + sink Sink + ports gcpserve.PortPool + timer Timer + backoff func(restarts int) time.Duration + probe Prober + runtime func() (Runtime, error) + ctx context.Context //nolint:containedctx // engine lifetime + cancel context.CancelFunc + mu *lockmetrics.RWMutex + runs map[string]*run + last map[string]string + onClose func() + nonce string + startup time.Duration + wg sync.WaitGroup + seq atomic.Uint64 +} + +// NewEngine creates an engine; runtime is dialled on first use. +func NewEngine(runtime func() (Runtime, error)) *Engine { + ctx, cancel := context.WithCancel(context.Background()) + + return &Engine{ + ctx: ctx, cancel: cancel, runtime: runtime, timer: realTimer, probe: probeHealth, backoff: defaultBackoff, + mu: lockmetrics.New("ocicontainerinstances.engine"), runs: map[string]*run{}, last: map[string]string{}, + nonce: awsid.NewN("", nonceLen), startup: startTimeout, + } +} + +func defaultBackoff(restarts int) time.Duration { + d := backoffBase + + for i := 1; i < restarts && d < backoffMax; i++ { + d *= 2 + } + + return min(d, backoffMax) +} + +// SetPortPool wires the host port source for health check ports. +func (e *Engine) SetPortPool(p gcpserve.PortPool) { + e.mu.Lock("SetPortPool") + e.ports = p + e.mu.Unlock() +} + +// SetTiming overrides the timer, restart backoff and prober; for tests. +func (e *Engine) SetTiming(t Timer, backoff func(int) time.Duration, p Prober) { + e.mu.Lock("SetTiming") + defer e.mu.Unlock() + + if t != nil { + e.timer = t + } + + if backoff != nil { + e.backoff = backoff + } + + if p != nil { + e.probe = p + } +} + +// Launch (re)creates the instance's containers; done gets nil once every container started, or the failure. +func (e *Engine) Launch( + inst *Instance, creds func([]PullSecret) ([]Credential, error), done func(error), +) { + e.wg.Go(func() { + e.halt(inst.ID) + done(e.start(inst, creds)) + }) +} + +// Relaunch stops the instance's containers and starts them again. +func (e *Engine) Relaunch(inst *Instance, creds func([]PullSecret) ([]Credential, error), done func(error)) { + e.Launch(inst, creds, done) +} + +func (e *Engine) start(inst *Instance, credsFn func([]PullSecret) ([]Credential, error)) error { + creds, err := credsFn(inst.PullSecrets) + if err != nil { + return fmt.Errorf("image pull secrets: %w", err) + } + + e.mu.RLock("start") + source := e.runtime + e.mu.RUnlock() + + if source == nil { + return errEngineDisabled + } + + rt, err := source() + if err != nil { + return fmt.Errorf("the container runtime is unavailable: %w", err) + } + + r := &run{ + rt: rt, inst: inst, creds: creds, hostPorts: map[string]int{}, docker: map[string]string{}, + prev: map[string]string{}, killed: map[string]bool{}, + } + r.ctx, r.cancel = context.WithCancel(e.ctx) + + e.mu.Lock("start") + e.runs[inst.ID] = r + for _, c := range inst.Containers { + delete(e.last, c.ID) + } + e.mu.Unlock() + + if err = e.prepare(r); err != nil { + e.halt(inst.ID) + + return err + } + + results := make(chan error, len(inst.Containers)) + + for i := range inst.Containers { + r.wg.Go(func() { e.supervise(r, i, results) }) + } + + timeout, stop := e.timer(e.startup) + defer stop() + + for range inst.Containers { + select { + case err = <-results: + case <-timeout: + err = fmt.Errorf("%w (%s)", errStartTimeout, e.startup) + case <-r.ctx.Done(): + return r.ctx.Err() + } + + if err != nil { + e.halt(inst.ID) + + return err + } + } + + return nil +} + +func (e *Engine) prepare(r *run) error { + sc, err := newScratch() + if err != nil { + return err + } + + r.scratch = sc + if err = sc.fill(r.inst.Volumes, r.inst.DNS); err != nil { + return err + } + + e.mu.RLock("prepare") + pool := e.ports + e.mu.RUnlock() + + for _, c := range r.inst.Containers { + for _, h := range c.Health { + key := c.ID + ":" + strconv.Itoa(h.Port) + if _, ok := r.hostPorts[key]; ok { + continue + } + + hp, perr := gcpserve.AcquirePort(r.ctx, pool, "ocicontainerinstances") + if perr != nil { + return fmt.Errorf("allocate host port for health check port %d: %w", h.Port, perr) + } + + r.hostPorts[key] = hp + } + } + + return nil +} + +func (e *Engine) spec(r *run, c *Container) container.Spec { + sp := container.Spec{ + Image: c.ImageURL, WorkingDir: c.WorkingDir, Cmd: c.Args, Mounts: r.scratch.binds(c.Mounts), + User: securityUser(c.Security), + Name: "gopherstack-oci-ci-" + e.nonce + "-" + strconv.FormatUint(e.seq.Add(1), 10), + } + + if sp.User == "" { + sp.User = securityUser(r.inst.Security) + } + + if len(c.Command) > 0 { + sp.Entrypoint = c.Command + } + + for k, v := range c.Env { + sp.Env = append(sp.Env, k+"="+v) + } + + if rc := c.Resource; rc != nil { + if rc.VcpusLimit != nil { + sp.NanoCPUs = int64(float64(*rc.VcpusLimit) * nanoPerCPU) + } + + if rc.MemoryLimitInGBs != nil { + sp.MemoryBytes = int64(float64(*rc.MemoryLimitInGBs) * bytesPerGB) + } + } + + seen := map[int]bool{} + + for _, h := range c.Health { + if seen[h.Port] { + continue + } + + seen[h.Port] = true + hp := r.hostPorts[c.ID+":"+strconv.Itoa(h.Port)] + sp.Ports = append(sp.Ports, container.PortSpec(container.LoopbackHost, strconv.Itoa(hp), strconv.Itoa(h.Port))) + } + + return sp +} + +func credentialFor(ref string, creds []Credential) *Credential { + host := strs.Fold(registryauth.HostOf(ref)) + + for _, c := range creds { + if strs.Fold(strings.TrimSuffix(c.Endpoint, "/")) == host { + return &c + } + } + + return nil +} + +func (e *Engine) launchOne(r *run, c *Container) (string, error) { + if err := r.rt.Pull(r.ctx, c.ImageURL, credentialFor(c.ImageURL, r.creds)); err != nil { + return "", fmt.Errorf("pull image %q: %w", c.ImageURL, err) + } + + e.mu.Lock("launchOne") + spec := e.spec(r, c) + limit := e.startup + e.mu.Unlock() + + // not r.ctx: cancelling mid-create can orphan a container the daemon still creates + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.ctx), limit) + defer cancel() + + id, err := r.rt.Start(ctx, spec) + if err != nil { + return "", fmt.Errorf("start container: %w", err) + } + + return id, nil +} + +func restartWanted(policy string, exit int) bool { + switch policy { + case restartAlways: + return true + case restartOnFailure: + return exit != 0 + } + + return false +} + +// supervise runs one container and applies the restart policy until it ends or the instance is halted. +func (e *Engine) supervise(r *run, idx int, results chan<- error) { + c := r.inst.Containers[idx] + first, restarts := true, 0 + + for { + id, err := e.launchOne(r, c) + if first { + results <- err + + first = false + } + + if err != nil { + e.markFailed(r, c, err) + + return + } + + e.mu.Lock("supervise") + r.docker[c.ID] = id + e.mu.Unlock() + + e.markRunning(r, c, restarts) + + stopHealth := e.watchHealth(r, c) + code, werr := r.rt.Wait(r.ctx, id) + + stopHealth() + + if r.ctx.Err() != nil { + return + } + + e.mu.RLock("supervise") + killed := r.killed[c.ID] + e.mu.RUnlock() + + if werr != nil && !killed { + e.markExited(r, c, 0, werr) + + return + } + + if killed { + code = killedExitCode + } + + restart := restartWanted(r.inst.Restart, code) + e.markExited(r, c, code, nil) + + if !restart { + e.markInstanceIfDone(r) + + return + } + + restarts++ + + e.removeContainer(r, c, id) + + if !e.sleep(r, e.backoff(restarts)) { + return + } + } +} + +func (e *Engine) sleep(r *run, d time.Duration) bool { + if d <= 0 { + return r.ctx.Err() == nil + } + + ch, stop := e.timer(d) + defer stop() + + select { + case <-ch: + return true + case <-r.ctx.Done(): + return false + } +} + +func (e *Engine) markRunning(r *run, c *Container, restarts int) { + now := e.sink.Now() + + e.sink.Mutate(r.inst.ID, func(in *Instance) { + if cc := findCtr(in, c.ID); cc != nil { + cc.State, cc.Details, cc.ExitCode, cc.Terminated, cc.Updated = stateActive, "", nil, nil, &now + cc.RestartCount = restarts + + for _, h := range cc.Health { + h.Status, h.StatusDetails, h.LastChecked = healthUnknown, "", nil + } + } + }) +} + +func (e *Engine) markFailed(r *run, c *Container, err error) { + now := e.sink.Now() + + e.sink.Mutate(r.inst.ID, func(in *Instance) { + if cc := findCtr(in, c.ID); cc != nil { + cc.State, cc.Details, cc.Updated = stateFailed, err.Error(), &now + } + }) +} + +func (e *Engine) markExited(r *run, c *Container, code int, werr error) { + now := e.sink.Now() + + e.sink.Mutate(r.inst.ID, func(in *Instance) { + cc := findCtr(in, c.ID) + if cc == nil { + return + } + + cc.State, cc.Updated, cc.Terminated = stateInactive, &now, &now + + if werr != nil { + cc.Details = werr.Error() + + return + } + + cc.ExitCode = &code + cc.Details = "Exited with code " + strconv.Itoa(code) + }) +} + +// markInstanceIfDone moves the instance to INACTIVE once every container has terminated for good. +func (e *Engine) markInstanceIfDone(r *run) { + e.sink.Mutate(r.inst.ID, func(in *Instance) { + if in.State != stateActive { + return + } + + for _, c := range in.Containers { + if c.State != stateInactive { + return + } + } + + in.State, in.Details, in.Etag = stateInactive, "All containers have terminated", newEtag() + }) +} + +func findCtr(in *Instance, id string) *Container { + for _, c := range in.Containers { + if c.ID == id { + return c + } + } + + return nil +} + +func (e *Engine) removeContainer(r *run, c *Container, id string) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.ctx), teardownTimeout) + defer cancel() + + logs, _ := r.rt.Logs(ctx, id) + + e.mu.Lock("removeContainer") + if logs != "" { + r.prev[c.ID] = tailBytes(logs) + } + + delete(r.docker, c.ID) + e.mu.Unlock() + + if err := r.rt.Remove(ctx, id); err != nil { + logger.Load(ctx). + WarnContext(ctx, "ocicontainerinstances: container removal failed", "container", id, "error", err) + } +} + +func tailBytes(s string) string { + if len(s) > maxLogBytes { + return s[len(s)-maxLogBytes:] + } + + return s +} + +// halt stops the instance's containers and frees its volumes and ports; it keeps their last logs. +func (e *Engine) halt(instID string) { + e.mu.Lock("halt") + r := e.runs[instID] + delete(e.runs, instID) + e.mu.Unlock() + + if r == nil { + return + } + + r.cancel() + r.wg.Wait() + + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.ctx), teardownTimeout) + defer cancel() + + e.mu.Lock("halt") + ids := make(map[string]string, len(r.docker)) + maps.Copy(ids, r.docker) + e.mu.Unlock() + + for ctr, id := range ids { + logs, _ := r.rt.Logs(ctx, id) + + e.mu.Lock("halt") + e.last[ctr] = tailBytes(logs) + e.mu.Unlock() + + if err := r.rt.Remove(ctx, id); err != nil { + logger.Load(ctx).WarnContext(ctx, "ocicontainerinstances: container removal failed", + "container", id, "error", err) + } + } + + e.mu.RLock("halt") + pool := e.ports + e.mu.RUnlock() + + for _, hp := range r.hostPorts { + gcpserve.ReleasePort(pool, hp) + } + + r.scratch.removeAll() +} + +// HaltAsync stops the instance's containers in the background and then calls done. +func (e *Engine) HaltAsync(instID string, done func()) { + e.wg.Go(func() { + e.halt(instID) + done() + }) +} + +// HaltAll stops every instance. +func (e *Engine) HaltAll() { + e.mu.RLock("HaltAll") + ids := make([]string, 0, len(e.runs)) + for id := range e.runs { + ids = append(ids, id) + } + e.mu.RUnlock() + + for _, id := range ids { + e.halt(id) + } +} + +// Close stops every instance and the engine. +func (e *Engine) Close() { + e.cancel() + e.wg.Wait() + e.HaltAll() + + if e.onClose != nil { + e.onClose() + } +} + +// Logs returns the container's stdout and stderr, from the previous run when previous is set. +func (e *Engine) Logs(instID, ctrID string, previous bool) (string, error) { + e.mu.RLock("Logs") + r := e.runs[instID] + + var ( + id string + prev string + rt Runtime + ok bool + ) + + if r != nil { + id, rt = r.docker[ctrID], r.rt + prev, ok = r.prev[ctrID] + } + + last, haveLast := e.last[ctrID] + e.mu.RUnlock() + + switch { + case previous && ok: + return prev, nil + case previous: + return "", errNoPreviousRun + case id != "" && rt != nil: + ctx, cancel := context.WithTimeout(e.ctx, teardownTimeout) + defer cancel() + + out, err := rt.Logs(ctx, id) + + return tailBytes(out), err + case haveLast: + return last, nil + } + + return "", errNoLogsYet +} diff --git a/services/ocicontainerinstances/engine_test.go b/services/ocicontainerinstances/engine_test.go new file mode 100644 index 0000000000..5d9bac7af4 --- /dev/null +++ b/services/ocicontainerinstances/engine_test.go @@ -0,0 +1,629 @@ +package ocicontainerinstances_test + +import ( + "context" + "errors" + "net/http" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + ci "github.com/oracle/oci-go-sdk/v65/containerinstances" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + svc "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" +) + +const waitFor = 10 * time.Second + +var ( + errManifest = errors.New("manifest unknown") + errBoom = errors.New("boom") + errRefused = errors.New("refused") + errGone = errors.New("gone") +) + +type pullCall struct { + cred *svc.Credential + ref string +} + +// fakeRuntime is a scripted container runtime: containers run until the test (or Kill) ends them. +type fakeRuntime struct { + pullErr error + exits map[string]chan int + logs map[string]string + pulls []pullCall + specs []container.Spec + removed []string + mu sync.Mutex +} + +func newFakeRuntime() *fakeRuntime { + return &fakeRuntime{exits: map[string]chan int{}, logs: map[string]string{}} +} + +func (f *fakeRuntime) Pull(_ context.Context, ref string, cred *svc.Credential) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.pulls = append(f.pulls, pullCall{ref: ref, cred: cred}) + + return f.pullErr +} + +func (f *fakeRuntime) Start(_ context.Context, spec container.Spec) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + id := "c" + strconv.Itoa(len(f.specs)+1) + f.specs = append(f.specs, spec) + f.exits[id] = make(chan int, 1) + f.logs[id] = "output of " + id + + return id, nil +} + +func (f *fakeRuntime) Wait(ctx context.Context, id string) (int, error) { + f.mu.Lock() + ch := f.exits[id] + f.mu.Unlock() + + select { + case code := <-ch: + return code, nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (f *fakeRuntime) Logs(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.logs[id], nil +} + +func (f *fakeRuntime) Kill(_ context.Context, id string) error { + f.exit(id, 137) + + return nil +} + +func (f *fakeRuntime) Remove(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.removed = append(f.removed, id) + + return nil +} + +func (f *fakeRuntime) exit(id string, code int) { + f.mu.Lock() + ch := f.exits[id] + f.mu.Unlock() + + select { + case ch <- code: + default: + } +} + +func (f *fakeRuntime) started(t *testing.T, n int) []container.Spec { + t.Helper() + + require.Eventually(t, func() bool { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.specs) >= n + }, waitFor, time.Millisecond) + + f.mu.Lock() + defer f.mu.Unlock() + + return append([]container.Spec(nil), f.specs...) +} + +func (f *fakeRuntime) startCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.specs) +} + +type dockerEnv struct { + *env + rt *fakeRuntime + engine *svc.Engine + probes chan error +} + +// instantTimer fires short timers at once and never fires the long startup bound. +func instantTimer(d time.Duration) (<-chan time.Time, func()) { + ch := make(chan time.Time, 1) + if d < time.Minute { + ch <- time.Time{} + } + + return ch, func() {} +} + +func newDockerEnv(t *testing.T, configure func(*svc.InMemoryBackend)) *dockerEnv { + t.Helper() + + rt := newFakeRuntime() + probes := make(chan error) + eng := svc.NewEngine(func() (svc.Runtime, error) { return rt, nil }) + eng.SetTiming(instantTimer, func(int) time.Duration { return 0 }, + func(ctx context.Context, _ svc.HealthCheck, _ int) error { + select { + case err := <-probes: + return err + case <-ctx.Done(): + return ctx.Err() + } + }) + + e := newEnv(t, envOpts{configure: func(b *svc.InMemoryBackend) { + b.SetEngine(eng) + + if configure != nil { + configure(b) + } + }}) + t.Cleanup(e.backend.Close) + + return &dockerEnv{env: e, rt: rt, engine: eng, probes: probes} +} + +func (d *dockerEnv) await(t *testing.T, id *string, state ci.ContainerInstanceLifecycleStateEnum) ci.ContainerInstance { + t.Helper() + + var got ci.ContainerInstance + + require.Eventually(t, func() bool { + got = d.get(t, id) + + return got.LifecycleState == state + }, waitFor, time.Millisecond, "want %s, last %s", state, got.LifecycleState) + + return got +} + +func (d *dockerEnv) container(t *testing.T, instID *string) ci.Container { + t.Helper() + + list, err := d.client.ListContainers(t.Context(), ci.ListContainersRequest{ + CompartmentId: new(testCompartment), ContainerInstanceId: instID, + }) + require.NoError(t, err) + require.NotEmpty(t, list.Items) + + got, err := d.client.GetContainer(t.Context(), ci.GetContainerRequest{ContainerId: list.Items[0].Id}) + require.NoError(t, err) + + return got.Container +} + +func TestEngineEngineStartsContainers(t *testing.T) { + t.Parallel() + + d := newDockerEnv(t, nil) + resp := d.mustCreate(t, createOpts{mutate: func(c *ci.CreateContainerInstanceDetails) { + c.Containers[0].ImageUrl = new("localhost:8166/ns/app:1") + c.Containers[0].Command = []string{"/bin/app"} + c.Containers[0].Arguments = []string{"--flag"} + c.Containers[0].EnvironmentVariables = map[string]string{"GREETING": "hi"} + c.Containers[0].WorkingDirectory = new("/srv") + c.Containers[0].ResourceConfig = &ci.CreateContainerResourceConfigDetails{ + VcpusLimit: new(float32(1.5)), + MemoryLimitInGBs: new(float32(2)), + } + c.Containers[0].VolumeMounts = []ci.CreateVolumeMountDetails{ + {MountPath: new("/etc/app"), VolumeName: new("cfg"), IsReadOnly: new(true)}, + {MountPath: new("/scratch"), VolumeName: new("tmp")}, + } + c.Volumes = []ci.CreateContainerVolumeDetails{ + ci.CreateContainerConfigFileVolumeDetails{Name: new("cfg"), Configs: []ci.ContainerConfigFile{ + {FileName: new("app.conf"), Data: []byte("mode=test")}, + {FileName: new("nested.conf"), Path: new("sub/dir"), Data: []byte("deep")}, + }}, + ci.CreateContainerEmptyDirVolumeDetails{Name: new("tmp")}, + } + c.ImagePullSecrets = []ci.CreateImagePullSecretDetails{ci.CreateBasicImagePullSecretDetails{ + RegistryEndpoint: new("localhost:8166"), Username: new("ns/user"), Password: new("tok"), + }} + c.DnsConfig = &ci.CreateContainerDnsConfigDetails{ + Nameservers: []string{"10.0.0.2"}, + Searches: []string{"corp.example"}, + } + }}) + + got := d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + assert.Equal(t, ci.OperationStatusSucceeded, d.wr(t, resp.OpcWorkRequestId).Status) + assert.Empty(t, got.LifecycleDetails) + + specs := d.rt.started(t, 1) + sp := specs[0] + assert.Equal(t, "localhost:8166/ns/app:1", sp.Image) + assert.Equal(t, []string{"/bin/app"}, sp.Entrypoint) + assert.Equal(t, []string{"--flag"}, sp.Cmd) + assert.Equal(t, []string{"GREETING=hi"}, sp.Env) + assert.Equal(t, "/srv", sp.WorkingDir) + assert.Equal(t, int64(1_500_000_000), sp.NanoCPUs) + assert.Equal(t, int64(2<<30), sp.MemoryBytes) + + d.rt.mu.Lock() + pull := d.rt.pulls[0] + d.rt.mu.Unlock() + + require.NotNil(t, pull.cred) + assert.Equal(t, "ns/user", pull.cred.Username) + assert.Equal(t, "tok", pull.cred.Password) + + var cfgDir, resolv string + + for _, m := range sp.Mounts { + switch { + case strings.HasSuffix(m, ":/etc/app:ro"): + cfgDir = strings.TrimSuffix(m, ":/etc/app:ro") + case strings.HasSuffix(m, ":/etc/resolv.conf:ro"): + resolv = strings.TrimSuffix(m, ":/etc/resolv.conf:ro") + } + } + + require.NotEmpty(t, cfgDir) + + conf, err := os.ReadFile(cfgDir + "/app.conf") + require.NoError(t, err) + assert.Equal(t, "mode=test", string(conf)) + + deep, err := os.ReadFile(cfgDir + "/sub/dir/nested.conf") + require.NoError(t, err) + assert.Equal(t, "deep", string(deep)) + + dns, err := os.ReadFile(resolv) + require.NoError(t, err) + assert.Equal(t, "nameserver 10.0.0.2\nsearch corp.example\n", string(dns)) + + assert.Len(t, sp.Mounts, 3) + + ctr := d.container(t, resp.Id) + assert.Equal(t, ci.ContainerLifecycleStateActive, ctr.LifecycleState) + + logs, err := d.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ContainerId: ctr.Id}) + require.NoError(t, err) + + body := make([]byte, 64) + n, _ := logs.Content.Read(body) + assert.Equal(t, "output of c1", string(body[:n])) + + _, err = d.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ContainerId: ctr.Id, IsPrevious: new(true)}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = d.client.DeleteContainerInstance( + t.Context(), + ci.DeleteContainerInstanceRequest{ContainerInstanceId: resp.Id}, + ) + require.NoError(t, err) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateDeleted) + + d.rt.mu.Lock() + assert.Equal(t, []string{"c1"}, d.rt.removed) + d.rt.mu.Unlock() + + _, statErr := os.Stat(cfgDir) + assert.True(t, os.IsNotExist(statErr), "volume directories are removed with the instance") +} + +func TestEngineRestartPolicy(t *testing.T) { + t.Parallel() + + never := ci.ContainerInstanceContainerRestartPolicyNever + onFailure := ci.ContainerInstanceContainerRestartPolicyOnFailure + always := ci.ContainerInstanceContainerRestartPolicyAlways + inactive := ci.ContainerInstanceLifecycleStateInactive + active := ci.ContainerInstanceLifecycleStateActive + + tests := []struct { + name string + policy ci.ContainerInstanceContainerRestartPolicyEnum + wantInstance ci.ContainerInstanceLifecycleStateEnum + exit int + wantRestart bool + }{ + {name: "never-success", policy: never, exit: 0, wantInstance: inactive}, + {name: "never-failure", policy: never, exit: 3, wantInstance: inactive}, + {name: "on-failure-success", policy: onFailure, exit: 0, wantInstance: inactive}, + {name: "on-failure-failure", policy: onFailure, exit: 3, wantRestart: true, wantInstance: active}, + {name: "always-success", policy: always, exit: 0, wantRestart: true, wantInstance: active}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + d := newDockerEnv(t, nil) + resp := d.mustCreate(t, createOpts{mutate: func(c *ci.CreateContainerInstanceDetails) { + c.ContainerRestartPolicy = tc.policy + }}) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + d.rt.started(t, 1) + d.rt.exit("c1", tc.exit) + + if tc.wantRestart { + d.rt.started(t, 2) + + require.Eventually(t, func() bool { + return d.container(t, resp.Id).ContainerRestartAttemptCount != nil && + *d.container(t, resp.Id).ContainerRestartAttemptCount == 1 + }, waitFor, time.Millisecond) + + assert.Equal(t, tc.wantInstance, d.get(t, resp.Id).LifecycleState) + + prev, err := d.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ + ContainerId: d.container(t, resp.Id).Id, IsPrevious: new(true), + }) + require.NoError(t, err) + + b := make([]byte, 64) + n, _ := prev.Content.Read(b) + assert.Equal(t, "output of c1", string(b[:n])) + + return + } + + d.await(t, resp.Id, tc.wantInstance) + + ctr := d.container(t, resp.Id) + assert.Equal(t, ci.ContainerLifecycleStateInactive, ctr.LifecycleState) + require.NotNil(t, ctr.ExitCode) + assert.Equal(t, tc.exit, *ctr.ExitCode) + assert.NotNil(t, ctr.TimeTerminated) + assert.Equal(t, 1, d.rt.startCount()) + + logs, err := d.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ContainerId: ctr.Id}) + require.NoError(t, err, "logs of an exited container stay readable") + + b := make([]byte, 64) + n, _ := logs.Content.Read(b) + assert.Equal(t, "output of c1", string(b[:n])) + }) + } +} + +func TestEngineStartFailure(t *testing.T) { + t.Parallel() + + d := newDockerEnv(t, nil) + d.rt.pullErr = errManifest + + resp := d.mustCreate(t, createOpts{}) + got := d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateFailed) + assert.Contains(t, *got.LifecycleDetails, "manifest unknown") + + wr := d.wr(t, resp.OpcWorkRequestId) + assert.Equal(t, ci.OperationStatusFailed, wr.Status) + + errs, err := d.client.ListWorkRequestErrors( + t.Context(), + ci.ListWorkRequestErrorsRequest{WorkRequestId: resp.OpcWorkRequestId}, + ) + require.NoError(t, err) + require.Len(t, errs.Items, 1) + assert.Contains(t, *errs.Items[0].Message, "manifest unknown") + + assert.Equal(t, ci.ContainerLifecycleStateFailed, d.container(t, resp.Id).LifecycleState) + + _, err = d.client.DeleteContainerInstance( + t.Context(), + ci.DeleteContainerInstanceRequest{ContainerInstanceId: resp.Id}, + ) + require.NoError(t, err) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateDeleted) +} + +func TestEngineStopStartRestart(t *testing.T) { + t.Parallel() + + d := newDockerEnv(t, nil) + resp := d.mustCreate(t, createOpts{}) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + d.rt.started(t, 1) + + _, err := d.client.StopContainerInstance(t.Context(), ci.StopContainerInstanceRequest{ContainerInstanceId: resp.Id}) + require.NoError(t, err) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateInactive) + assert.Equal(t, ci.ContainerLifecycleStateInactive, d.container(t, resp.Id).LifecycleState) + + logs, err := d.client.RetrieveLogs(t.Context(), ci.RetrieveLogsRequest{ContainerId: d.container(t, resp.Id).Id}) + require.NoError(t, err, "last logs stay readable after a stop") + _ = logs.Content.Close() + + _, err = d.client.StartContainerInstance( + t.Context(), + ci.StartContainerInstanceRequest{ContainerInstanceId: resp.Id}, + ) + require.NoError(t, err) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + d.rt.started(t, 2) + + _, err = d.client.RestartContainerInstance( + t.Context(), + ci.RestartContainerInstanceRequest{ContainerInstanceId: resp.Id}, + ) + require.NoError(t, err) + d.rt.started(t, 3) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + + d.rt.mu.Lock() + assert.Equal(t, []string{"c1", "c2"}, d.rt.removed) + d.rt.mu.Unlock() +} + +func TestEngineHealthChecks(t *testing.T) { + t.Parallel() + + d := newDockerEnv(t, nil) + resp := d.mustCreate(t, createOpts{mutate: func(c *ci.CreateContainerInstanceDetails) { + c.Containers[0].HealthChecks = []ci.CreateContainerHealthCheckDetails{ + ci.CreateContainerHttpHealthCheckDetails{ + Port: new(8080), Path: new("/healthz"), FailureThreshold: new(2), SuccessThreshold: new(1), + Name: new("web"), + }, + } + }}) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + + specs := d.rt.started(t, 1) + require.Len(t, specs[0].Ports, 1) + assert.True(t, strings.HasPrefix(specs[0].Ports[0], "127.0.0.1:"), "health check ports publish on loopback only") + assert.True(t, strings.HasSuffix(specs[0].Ports[0], ":8080")) + + status := func() ci.ContainerHealthCheckStatusEnum { + hc := d.container(t, resp.Id).HealthChecks + require.Len(t, hc, 1) + + return hc[0].GetStatus() + } + + assert.Equal(t, ci.ContainerHealthCheckStatusUnknown, status()) + + d.probes <- nil + + require.Eventually( + t, + func() bool { return status() == ci.ContainerHealthCheckStatusHealthy }, + waitFor, + time.Millisecond, + ) + + d.probes <- errBoom + + d.probes <- errBoom + + d.rt.started(t, 2) + + require.Eventually(t, func() bool { + c := d.container(t, resp.Id) + + return c.ContainerRestartAttemptCount != nil && *c.ContainerRestartAttemptCount == 1 + }, waitFor, time.Millisecond, "an unhealthy KILL check kills the container and the restart policy restarts it") + + d.rt.mu.Lock() + assert.Contains(t, d.rt.removed, "c1") + d.rt.mu.Unlock() +} + +func TestEngineHealthCheckActionNone(t *testing.T) { + t.Parallel() + + d := newDockerEnv(t, nil) + resp := d.mustCreate(t, createOpts{mutate: func(c *ci.CreateContainerInstanceDetails) { + c.Containers[0].HealthChecks = []ci.CreateContainerHealthCheckDetails{ + ci.CreateContainerTcpHealthCheckDetails{ + Port: new(9000), FailureThreshold: new(1), FailureAction: ci.ContainerHealthCheckFailureActionNone, + }, + } + }}) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + d.rt.started(t, 1) + + d.probes <- errRefused + + require.Eventually(t, func() bool { + hc := d.container(t, resp.Id).HealthChecks + + return len(hc) == 1 && hc[0].GetStatus() == ci.ContainerHealthCheckStatusUnhealthy + }, waitFor, time.Millisecond) + + d.probes <- nil + + require.Eventually(t, func() bool { + return d.container(t, resp.Id).HealthChecks[0].GetStatus() == ci.ContainerHealthCheckStatusHealthy + }, waitFor, time.Millisecond) + + assert.Equal(t, 1, d.rt.startCount(), "failureAction NONE never restarts the container") +} + +func TestEngineVaultPullSecret(t *testing.T) { + t.Parallel() + + tests := []struct { + read svc.SecretReader + name string + wantHTTP int + }{ + { + name: "valid", + read: func(string) (string, error) { return `{"username":"u","password":"p"}`, nil }, + }, + { + name: "unreadable-secret", + read: func(string) (string, error) { return "", errGone }, + wantHTTP: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + d := newDockerEnv(t, func(b *svc.InMemoryBackend) { b.SetSecretReader(tc.read) }) + + resp, err := d.create(t, createOpts{mutate: func(c *ci.CreateContainerInstanceDetails) { + c.ImagePullSecrets = []ci.CreateImagePullSecretDetails{ci.CreateVaultImagePullSecretDetails{ + RegistryEndpoint: new("docker.io"), SecretId: new("ocid1.vaultsecret.oc1..s"), + }} + }}) + if tc.wantHTTP != 0 { + assert.Equal(t, tc.wantHTTP, svcErr(t, err).GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + d.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + + d.rt.mu.Lock() + defer d.rt.mu.Unlock() + + require.NotNil(t, d.rt.pulls[0].cred) + assert.Equal(t, "u", d.rt.pulls[0].cred.Username) + assert.Equal(t, "p", d.rt.pulls[0].cred.Password) + }) + } +} + +func TestEngineRestoreLeavesInstancesInactive(t *testing.T) { + t.Parallel() + + src := newDockerEnv(t, nil) + resp := src.mustCreate(t, createOpts{}) + src.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + + snap := src.backend.Snapshot(t.Context()) + + dst := newDockerEnv(t, nil) + require.NoError(t, dst.backend.Restore(t.Context(), snap)) + + got := dst.get(t, resp.Id) + assert.Equal(t, ci.ContainerInstanceLifecycleStateInactive, got.LifecycleState) + assert.Contains(t, *got.LifecycleDetails, "Restored from a snapshot") + assert.Equal(t, ci.ContainerLifecycleStateInactive, dst.container(t, resp.Id).LifecycleState) + + _, err := dst.client.StartContainerInstance( + t.Context(), + ci.StartContainerInstanceRequest{ContainerInstanceId: resp.Id}, + ) + require.NoError(t, err) + dst.await(t, resp.Id, ci.ContainerInstanceLifecycleStateActive) + dst.rt.started(t, 1) +} diff --git a/services/ocicontainerinstances/errors.go b/services/ocicontainerinstances/errors.go new file mode 100644 index 0000000000..b5b8d6a8e2 --- /dev/null +++ b/services/ocicontainerinstances/errors.go @@ -0,0 +1,43 @@ +package ocicontainerinstances + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocicontainerinstances: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } diff --git a/services/ocicontainerinstances/handler.go b/services/ocicontainerinstances/handler.go new file mode 100644 index 0000000000..e20e9b677a --- /dev/null +++ b/services/ocicontainerinstances/handler.go @@ -0,0 +1,373 @@ +package ocicontainerinstances + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Second + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Container Instances on its own listener. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocicontainerinstances.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIContainerInstances" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIContainerInstances runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"containerInstanceId", "containerId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocicontainerinstances: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) collection(items any, next string) error { + q.nextPage(next) + + return q.json(http.StatusOK, map[string]any{"items": items}, "") +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + dec := json.NewDecoder(bytes.NewReader(q.body)) + dec.UseNumber() + + if err := dec.Decode(v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocicontainerinstances: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIContainerInstances requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocicontainerinstances: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIContainerInstances", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ocicontainerinstances", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocicontainerinstances: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocicontainerinstances: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocicontainerinstances: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocicontainerinstances: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() + h.Backend.Close() +} diff --git a/services/ocicontainerinstances/health.go b/services/ocicontainerinstances/health.go new file mode 100644 index 0000000000..0815547a66 --- /dev/null +++ b/services/ocicontainerinstances/health.go @@ -0,0 +1,216 @@ +package ocicontainerinstances + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "sync" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const tcpSettle = 250 * time.Millisecond + +var errBadStatus = errors.New("unexpected HTTP status") + +// probeHealth dials the container's loopback-published port; HTTP checks accept 200-399 and never use TLS. +func probeHealth(ctx context.Context, hc HealthCheck, hostPort int) error { + ctx, cancel := context.WithTimeout(ctx, time.Duration(hc.Timeout)*time.Second) + defer cancel() + + addr := net.JoinHostPort(container.LoopbackHost, strconv.Itoa(hostPort)) + + if hc.Type == hcTCP { + var d net.Dialer + + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("tcp connect: %w", err) + } + + defer conn.Close() + + return tcpStillOpen(conn) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+hc.Path, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + for _, h := range hc.Headers { + if http.CanonicalHeaderKey(h.Name) == "Host" { + req.Host = h.Value + + continue + } + + req.Header.Set(h.Name, h.Value) + } + + tr := httpclient.NewTransport() + tr.DisableKeepAlives = true + + defer tr.CloseIdleConnections() + + client := &http.Client{ + Transport: tr, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("http request: %w", err) + } + + _ = resp.Body.Close() + + if resp.StatusCode < httpOKMin || resp.StatusCode > httpOKMax { + return fmt.Errorf("%w: %d", errBadStatus, resp.StatusCode) + } + + return nil +} + +// watchHealth starts one probe loop per health check and returns a function that stops them and waits. +func (e *Engine) watchHealth(r *run, c *Container) func() { + if len(c.Health) == 0 { + return func() {} + } + + ctx, cancel := context.WithCancel(r.ctx) + wg := &sync.WaitGroup{} + + for i, h := range c.Health { + hp := r.hostPorts[c.ID+":"+strconv.Itoa(h.Port)] + + wg.Go(func() { e.probeLoop(ctx, r, c, i, *h, hp) }) + } + + return func() { + cancel() + wg.Wait() + } +} + +func (e *Engine) wait(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return ctx.Err() == nil + } + + e.mu.RLock("wait") + timer := e.timer + e.mu.RUnlock() + + ch, stop := timer(d) + defer stop() + + select { + case <-ch: + return true + case <-ctx.Done(): + return false + } +} + +func (e *Engine) probeLoop(ctx context.Context, r *run, c *Container, idx int, hc HealthCheck, hostPort int) { + e.mu.RLock("probeLoop") + probe := e.probe + e.mu.RUnlock() + + if !e.wait(ctx, time.Duration(hc.InitialDelay)*time.Second) { + return + } + + var good, bad int + + for { + err := probe(ctx, hc, hostPort) + if ctx.Err() != nil { + return + } + + status, details := "", "" + + if err == nil { + good, bad = good+1, 0 + if good >= hc.SuccessThresh { + status = healthHealthy + } + } else { + good, bad = 0, bad+1 + if bad >= hc.FailureThresh { + status, details = healthUnhealty, err.Error() + } + } + + e.recordHealth(r, c, idx, status, details) + + if status == healthUnhealty && hc.FailureAction == actionKill { + e.kill(r, c) + + return + } + + if !e.wait(ctx, time.Duration(hc.Interval)*time.Second) { + return + } + } +} + +func (e *Engine) recordHealth(r *run, c *Container, idx int, status, details string) { + now := e.sink.Now() + + e.sink.Mutate(r.inst.ID, func(in *Instance) { + cc := findCtr(in, c.ID) + if cc == nil || idx >= len(cc.Health) { + return + } + + h := cc.Health[idx] + h.LastChecked = &now + + if status != "" { + h.Status, h.StatusDetails = status, details + } + }) +} + +// kill stops the container after a failed health check; supervise then applies the restart policy. +func (e *Engine) kill(r *run, c *Container) { + e.mu.Lock("kill") + id := r.docker[c.ID] + r.killed[c.ID] = true + e.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.ctx), teardownTimeout) + defer cancel() + + if err := r.rt.Kill(ctx, id); err != nil { + logger.Load(ctx).WarnContext(ctx, "ocicontainerinstances: killing unhealthy container failed", + "container", id, "error", err) + } +} + +// tcpStillOpen tells a listening container from docker's userland proxy, which accepts the connection on a +// published port even when nothing listens inside and then closes it at once. +func tcpStillOpen(conn net.Conn) error { + _ = conn.SetReadDeadline(time.Now().Add(tcpSettle)) + + var one [1]byte + + _, err := conn.Read(one[:]) + + var ne net.Error + if err == nil || (errors.As(err, &ne) && ne.Timeout()) { + return nil + } + + return fmt.Errorf("connection closed straight after connect: %w", err) +} diff --git a/services/ocicontainerinstances/helpers_test.go b/services/ocicontainerinstances/helpers_test.go new file mode 100644 index 0000000000..3fda425f82 --- /dev/null +++ b/services/ocicontainerinstances/helpers_test.go @@ -0,0 +1,223 @@ +package ocicontainerinstances_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + ci "github.com/oracle/oci-go-sdk/v65/containerinstances" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + svc "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + otherCompart = "ocid1.compartment.oc1..other" + testSubnet = "ocid1.subnet.oc1..subnet" + testAD = "Uocm:US-ASHBURN-AD-1" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UTC().Truncate(time.Second).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *svc.InMemoryBackend + handler *svc.Handler + clock *fakeClock + client ci.ContainerInstanceClient + kp keyPair + base string +} + +type envOpts struct { + configure func(*svc.InMemoryBackend) + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := svc.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + if opts.configure != nil { + opts.configure(be) + } + + h := svc.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{backend: be, handler: h, clock: clock, kp: kp, base: fmt.Sprintf("http://localhost:%d", h.Port)} + e.client = e.newClient(t, kp) + + return e +} + +func (e *env) newClient(t *testing.T, kp keyPair) ci.ContainerInstanceClient { + t.Helper() + + prov := common.NewRawConfigurationProvider(testTenancy, testUser, svc.Region, kp.fprint, kp.privPEM, nil) + + c, err := ci.NewContainerInstanceClientWithConfigurationProvider(prov) + require.NoError(t, err) + + c.Host = e.base + + return c +} + +// settle completes pending work requests and lifecycle transitions. +func (e *env) settle() { + e.clock.Advance(5 * time.Second) + e.backend.Sweep() +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawGet(t *testing.T, url string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, http.NoBody) + require.NoError(t, err) + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +type createOpts struct { + mutate func(*ci.CreateContainerInstanceDetails) + token string +} + +func baseDetails() ci.CreateContainerInstanceDetails { + return ci.CreateContainerInstanceDetails{ + CompartmentId: new(testCompartment), + AvailabilityDomain: new(testAD), + Shape: new("CI.Standard.E4.Flex"), + DisplayName: new("web"), + ShapeConfig: &ci.CreateContainerInstanceShapeConfigDetails{ + Ocpus: new(float32(2)), + MemoryInGBs: new(float32(8)), + }, + Containers: []ci.CreateContainerDetails{{ImageUrl: new("docker.io/library/alpine:3.21")}}, + Vnics: []ci.CreateContainerVnicDetails{{SubnetId: new(testSubnet)}}, + } +} + +func (e *env) create(t *testing.T, o createOpts) (ci.CreateContainerInstanceResponse, error) { + t.Helper() + + d := baseDetails() + if o.mutate != nil { + o.mutate(&d) + } + + req := ci.CreateContainerInstanceRequest{CreateContainerInstanceDetails: d} + if o.token != "" { + req.OpcRetryToken = new(o.token) + } + + return e.client.CreateContainerInstance(t.Context(), req) +} + +func (e *env) mustCreate(t *testing.T, o createOpts) ci.CreateContainerInstanceResponse { + t.Helper() + + resp, err := e.create(t, o) + require.NoError(t, err) + + return resp +} + +func (e *env) get(t *testing.T, id *string) ci.ContainerInstance { + t.Helper() + + resp, err := e.client.GetContainerInstance(t.Context(), ci.GetContainerInstanceRequest{ContainerInstanceId: id}) + require.NoError(t, err) + + return resp.ContainerInstance +} + +func (e *env) wr(t *testing.T, id *string) ci.WorkRequest { + t.Helper() + + resp, err := e.client.GetWorkRequest(t.Context(), ci.GetWorkRequestRequest{WorkRequestId: id}) + require.NoError(t, err) + + return resp.WorkRequest +} diff --git a/services/ocicontainerinstances/inproc.go b/services/ocicontainerinstances/inproc.go new file mode 100644 index 0000000000..986e3bf807 --- /dev/null +++ b/services/ocicontainerinstances/inproc.go @@ -0,0 +1,31 @@ +package ocicontainerinstances + +import ( + "maps" + "slices" +) + +// Compartments lists the distinct compartments that hold container instances. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + for _, in := range b.instances { + seen[in.Compartment] = struct{}{} + } + + return slices.Sorted(maps.Keys(seen)) +} + +// EngineMode reports the active engine: EngineDocker or EngineState. +func (b *InMemoryBackend) EngineMode() string { + b.mu.RLock("EngineMode") + defer b.mu.RUnlock() + + if b.engine != nil { + return EngineDocker + } + + return EngineState +} diff --git a/services/ocicontainerinstances/instances.go b/services/ocicontainerinstances/instances.go new file mode 100644 index 0000000000..00f1c5a148 --- /dev/null +++ b/services/ocicontainerinstances/instances.go @@ -0,0 +1,585 @@ +package ocicontainerinstances + +import ( + "encoding/json" + "slices" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// Operation types recorded on work requests. +const ( + wrCreate = "CREATE_CONTAINER_INSTANCE" + wrUpdate = "UPDATE_CONTAINER_INSTANCE" + wrDelete = "DELETE_CONTAINER_INSTANCE" + wrMove = "MOVE_CONTAINER_INSTANCE" + wrStart = "START_CONTAINER_INSTANCE" + wrStop = "STOP_CONTAINER_INSTANCE" + wrRestart = "RESTART_CONTAINER_INSTANCE" + wrUpdCtr = "UPDATE_CONTAINER" +) + +func (in *Instance) clone() *Instance { + raw, err := json.Marshal(in) + if err != nil { + return in + } + + out := &Instance{} + if json.Unmarshal(raw, out) != nil { + return in + } + + return out +} + +func (b *InMemoryBackend) liveInstance(id string) (*Instance, *APIError) { + in, ok := b.instances[id] + if !ok { + return nil, errNotFound(id) + } + + return in, nil +} + +// CreateInstance registers a container instance and starts its work request. +func (b *InMemoryBackend) CreateInstance(in createIn, token string) (instanceWire, string, string, error) { + if aerr := b.checkVaultSecrets(in.Secrets); aerr != nil { + return instanceWire{}, "", "", aerr + } + + b.mu.Lock("CreateInstance") + + if token != "" { + for _, ex := range b.instances { + if ex.RetryToken == token { + w, etag, wr := instanceView(ex), ex.Etag, b.wrFor(ex.ID, wrCreate) + b.mu.Unlock() + + return w, wr, etag, nil + } + } + } + + inst, aerr := b.buildInstance(in) + if aerr != nil { + b.mu.Unlock() + + return instanceWire{}, "", "", aerr + } + + inst.RetryToken = token + b.instances[inst.ID] = inst + + for _, c := range inst.Containers { + b.containers[c.ID] = inst.ID + } + + wr := b.newWR(inst, wrCreate, "CREATED", nil) + eng := b.engine + + if eng == nil { + inst.Target, inst.TransitionAt = stateActive, b.finishAt() + wr.FinishAt = inst.TransitionAt + } + + view, etag, snap := instanceView(inst), inst.Etag, inst.clone() + b.mu.Unlock() + + if eng != nil { + b.launch(snap, wr.ID) + } + + return view, wr.ID, etag, nil +} + +func (b *InMemoryBackend) wrFor(instID, typ string) string { + var best *WorkRequest + + for _, wr := range b.workReqs { + if wr.ResourceID == instID && wr.Type == typ && (best == nil || wr.Seq > best.Seq) { + best = wr + } + } + + if best == nil { + return "" + } + + return best.ID +} + +func (b *InMemoryBackend) checkVaultSecrets(secrets []createSecretIn) *APIError { + b.mu.RLock("checkVaultSecrets") + read := b.secrets + b.mu.RUnlock() + + if read == nil { + return nil + } + + for _, s := range secrets { + if s.SecretType != secretVault || s.SecretID == "" { + continue + } + + if _, err := read(s.SecretID); err != nil { + return errNotFound(s.SecretID) + } + } + + return nil +} + +// launch pulls and starts the instance's containers in the engine and completes the work request. +func (b *InMemoryBackend) launch(snap *Instance, wrID string) { + b.mu.RLock("launch") + eng := b.engine + b.mu.RUnlock() + + eng.Launch(snap, b.resolveCreds, func(err error) { b.complete(snap.ID, wrID, stateActive, err) }) +} + +// resolveCreds turns an instance's pull secrets into registry credentials, reading VAULT secrets from OCI Vault. +func (b *InMemoryBackend) resolveCreds(secrets []PullSecret) ([]Credential, error) { + b.mu.RLock("resolveCreds") + read := b.secrets + b.mu.RUnlock() + + out := make([]Credential, 0, len(secrets)) + + for _, s := range secrets { + if s.Type == secretBasic { + out = append(out, Credential{Endpoint: s.Endpoint, Username: s.Username, Password: s.Password}) + + continue + } + + if read == nil { + return nil, errVaultUnavailable + } + + raw, err := read(s.SecretID) + if err != nil { + return nil, err + } + + var auth struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if json.Unmarshal([]byte(raw), &auth) != nil || auth.Username == "" || auth.Password == "" { + return nil, errVaultSecretShape + } + + out = append(out, Credential{Endpoint: s.Endpoint, Username: auth.Username, Password: auth.Password}) + } + + return out, nil +} + +// complete finishes an engine-driven transition. +func (b *InMemoryBackend) complete(instID, wrID, target string, err error) { + b.mu.Lock("complete") + defer b.mu.Unlock() + + inst, ok := b.instances[instID] + if !ok || inst.State == stateDeleted { + return + } + + now := b.nowUTC() + wr := b.workReqs[wrID] + + if err != nil { + b.setState(inst, stateFailed, err.Error()) + + if wr != nil { + wr.Status, wr.FinishedAt, wr.ErrCode, wr.ErrMsg = wrFailed, &now, "ContainerInstanceFailed", err.Error() + } + } else { + b.setState(inst, target, "") + + if wr != nil { + wr.Status, wr.FinishedAt = wrSucceeded, &now + } + } + + inst.Etag = newEtag() +} + +// GetInstance returns one container instance with its etag. +func (b *InMemoryBackend) GetInstance(id string) (instanceWire, string, error) { + b.mu.RLock("GetInstance") + defer b.mu.RUnlock() + + in, aerr := b.liveInstance(id) + if aerr != nil { + return instanceWire{}, "", aerr + } + + return instanceView(in), in.Etag, nil +} + +// InstanceFilter selects container instances in a list. +type InstanceFilter struct { + Compartment string + State string + DisplayName string + AD string +} + +// ListInstances lists container instances of a compartment. +func (b *InMemoryBackend) ListInstances(f InstanceFilter, o ListOpts) (page.Page[instanceSummaryWire], error) { + b.mu.RLock("ListInstances") + defer b.mu.RUnlock() + + if aerr := b.checkCompartment(f.Compartment); aerr != nil { + return page.Page[instanceSummaryWire]{}, aerr + } + + var all []*Instance + + for _, in := range b.instances { + if in.Compartment == f.Compartment && (f.State == "" || in.State == f.State) && + (f.DisplayName == "" || in.DisplayName == f.DisplayName) && (f.AD == "" || in.AD == f.AD) { + all = append(all, in) + } + } + + pg := paginate(all, o, func(i *Instance) string { return i.DisplayName }, + func(i *Instance) (time.Time, int64) { return i.Created, 0 }) + + out := make([]instanceSummaryWire, 0, len(pg.Data)) + for _, in := range pg.Data { + out = append(out, summaryOf(in)) + } + + return page.Page[instanceSummaryWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateInstance changes the display name and tags. +func (b *InMemoryBackend) UpdateInstance(id, ifMatch string, u updateIn) (string, error) { + b.mu.Lock("UpdateInstance") + defer b.mu.Unlock() + + in, aerr := b.liveInstance(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(in.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if in.State == stateDeleting || in.State == stateDeleted { + return "", errState("container instance is " + in.State) + } + + if u.DisplayName != nil { + in.DisplayName = *u.DisplayName + } + + applyTags(&in.Freeform, &in.Defined, u) + + now := b.nowUTC() + in.Updated, in.Etag = &now, newEtag() + + return b.newWR(in, wrUpdate, actionUpdated, b.finishAt()).ID, nil +} + +func applyTags(free *map[string]string, defined *map[string]any, u updateIn) { + if u.Freeform != nil { + *free = u.Freeform + } + + if u.Defined != nil { + *defined = u.Defined + } +} + +// ChangeCompartment moves the instance to another compartment. +func (b *InMemoryBackend) ChangeCompartment(id, ifMatch, compartment string) (string, error) { + b.mu.Lock("ChangeCompartment") + defer b.mu.Unlock() + + in, aerr := b.liveInstance(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(in.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if aerr = b.checkCompartment(compartment); aerr != nil { + return "", aerr + } + + if in.State == stateDeleting || in.State == stateDeleted { + return "", errState("container instance is " + in.State) + } + + in.Compartment, in.Etag = compartment, newEtag() + now := b.nowUTC() + in.Updated = &now + + return b.newWR(in, wrMove, actionUpdated, b.finishAt()).ID, nil +} + +type transition struct { + typ string + action string + mid string + target string + allowed []string +} + +const actionUpdated = "UPDATED" + +func transStop() transition { + return transition{wrStop, actionUpdated, stateUpdating, stateInactive, []string{stateActive}} +} + +func transStart() transition { + return transition{wrStart, actionUpdated, stateUpdating, stateActive, []string{stateInactive}} +} + +func transRestart() transition { + return transition{wrRestart, actionUpdated, stateUpdating, stateActive, []string{stateActive}} +} + +func transDelete() transition { + return transition{ + wrDelete, + "DELETED", + stateDeleting, + stateDeleted, + []string{stateActive, stateInactive, stateFailed}, + } +} + +// Stop stops a running container instance. +func (b *InMemoryBackend) Stop(id, ifMatch string) (string, error) { + return b.transitionInstance(id, ifMatch, transStop()) +} + +// Start starts a stopped container instance. +func (b *InMemoryBackend) Start(id, ifMatch string) (string, error) { + return b.transitionInstance(id, ifMatch, transStart()) +} + +// Restart restarts a running container instance. +func (b *InMemoryBackend) Restart(id, ifMatch string) (string, error) { + return b.transitionInstance(id, ifMatch, transRestart()) +} + +// DeleteInstance deletes a container instance. +func (b *InMemoryBackend) DeleteInstance(id, ifMatch string) (string, error) { + return b.transitionInstance(id, ifMatch, transDelete()) +} + +func (b *InMemoryBackend) transitionInstance(id, ifMatch string, t transition) (string, error) { + b.mu.Lock("transitionInstance") + + in, aerr := b.liveInstance(id) + if aerr == nil { + aerr = checkEtag(in.Etag, ifMatch) + } + + if aerr == nil && !slices.Contains(t.allowed, in.State) { + aerr = errState( + "container instance " + id + " is " + in.State + "; it must be " + strings.Join(t.allowed, " or "), + ) + } + + if aerr != nil { + b.mu.Unlock() + + return "", aerr + } + + wr := b.newWR(in, t.typ, t.action, nil) + eng := b.engine + b.setState(in, t.mid, "") + in.Etag = newEtag() + + if eng == nil { + in.Target, in.TransitionAt = t.target, b.finishAt() + wr.FinishAt = in.TransitionAt + b.mu.Unlock() + + return wr.ID, nil + } + + snap := in.clone() + b.mu.Unlock() + + b.runEngineTransition(snap, wr.ID, t) + + return wr.ID, nil +} + +func (b *InMemoryBackend) runEngineTransition(snap *Instance, wrID string, t transition) { + b.mu.RLock("runEngineTransition") + eng := b.engine + b.mu.RUnlock() + + switch t.typ { + case wrStart: + eng.Launch(snap, b.resolveCreds, func(err error) { b.complete(snap.ID, wrID, t.target, err) }) + case wrRestart: + eng.Relaunch(snap, b.resolveCreds, func(err error) { b.complete(snap.ID, wrID, t.target, err) }) + default: + eng.HaltAsync(snap.ID, func() { b.complete(snap.ID, wrID, t.target, nil) }) + } +} + +// GetContainer returns one container. +func (b *InMemoryBackend) GetContainer(id string) (containerWire, error) { + b.mu.RLock("GetContainer") + defer b.mu.RUnlock() + + in, c := b.findContainer(id) + if c == nil { + return containerWire{}, errNotFound(id) + } + + return containerView(in, c), nil +} + +func (b *InMemoryBackend) findContainer(id string) (*Instance, *Container) { + in, ok := b.instances[b.containers[id]] + if !ok { + return nil, nil + } + + for _, c := range in.Containers { + if c.ID == id { + return in, c + } + } + + return nil, nil +} + +// ContainerFilter selects containers in a list. +type ContainerFilter struct { + Compartment string + State string + DisplayName string + InstanceID string + AD string +} + +// ListContainers lists containers of a compartment. +func (b *InMemoryBackend) ListContainers(f ContainerFilter, o ListOpts) (page.Page[containerSummaryWire], error) { + b.mu.RLock("ListContainers") + defer b.mu.RUnlock() + + if aerr := b.checkCompartment(f.Compartment); aerr != nil { + return page.Page[containerSummaryWire]{}, aerr + } + + type pair struct { + in *Instance + c *Container + } + + var all []pair + + for _, in := range b.instances { + if in.Compartment != f.Compartment || (f.InstanceID != "" && in.ID != f.InstanceID) || + (f.AD != "" && in.AD != f.AD) { + continue + } + + for _, c := range in.Containers { + if (f.State == "" || c.State == f.State) && (f.DisplayName == "" || c.DisplayName == f.DisplayName) { + all = append(all, pair{in, c}) + } + } + } + + pg := paginate(all, o, func(p pair) string { return p.c.DisplayName }, + func(p pair) (time.Time, int64) { return p.c.Created, 0 }) + + out := make([]containerSummaryWire, 0, len(pg.Data)) + for _, p := range pg.Data { + out = append(out, containerSummaryOf(p.in, p.c)) + } + + return page.Page[containerSummaryWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateContainer changes a container's display name and tags. +func (b *InMemoryBackend) UpdateContainer(id, ifMatch string, u updateIn) (string, error) { + b.mu.Lock("UpdateContainer") + defer b.mu.Unlock() + + in, c := b.findContainer(id) + if c == nil { + return "", errNotFound(id) + } + + if aerr := checkEtag(in.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if in.State == stateDeleting || in.State == stateDeleted { + return "", errState("container instance is " + in.State) + } + + if u.DisplayName != nil { + if slices.ContainsFunc(in.Containers, func(o *Container) bool { + return o.ID != c.ID && strs.Equal(o.DisplayName, *u.DisplayName) + }) { + return "", errParam("a container named " + *u.DisplayName + " already exists in this instance") + } + + c.DisplayName = *u.DisplayName + } + + applyTags(&c.Freeform, &c.Defined, u) + + now := b.nowUTC() + c.Updated = &now + + wr := b.newWR(in, wrUpdCtr, actionUpdated, b.finishAt()) + wr.ResourceID, wr.ResourceType = c.ID, "container" + + return wr.ID, nil +} + +// RetrieveLogs returns the container's stdout and stderr. +func (b *InMemoryBackend) RetrieveLogs(id string, previous bool) (string, error) { + b.mu.RLock("RetrieveLogs") + in, c := b.findContainer(id) + eng := b.engine + + var instID, ctrID, state string + if c != nil { + instID, ctrID, state = in.ID, c.ID, in.State + } + b.mu.RUnlock() + + switch { + case c == nil: + return "", errNotFound(id) + case eng == nil: + return "", errState("container logs need the docker engine (--oci-container-instances-engine=docker)") + case state == stateCreating: + return "", errState("container instance is still being created") + } + + out, err := eng.Logs(instID, ctrID, previous) + if err != nil { + return "", errState(err.Error()) + } + + return out, nil +} diff --git a/services/ocicontainerinstances/leak_main_test.go b/services/ocicontainerinstances/leak_main_test.go new file mode 100644 index 0000000000..510277a572 --- /dev/null +++ b/services/ocicontainerinstances/leak_main_test.go @@ -0,0 +1,20 @@ +package ocicontainerinstances_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The embedded OCI registry used by the pull test keeps a notification broadcaster for its lifetime. + testleak.VerifyTestMain(m, + goleak.IgnoreTopFunction("net/http.setRequestCancel.func4"), + goleak.IgnoreTopFunction("github.com/docker/go-events.(*Broadcaster).run")) +} diff --git a/services/ocicontainerinstances/models.go b/services/ocicontainerinstances/models.go new file mode 100644 index 0000000000..979f7b29e4 --- /dev/null +++ b/services/ocicontainerinstances/models.go @@ -0,0 +1,196 @@ +package ocicontainerinstances + +import ( + "encoding/json" + "time" +) + +// Lifecycle states shared by container instances and containers. +const ( + stateCreating = "CREATING" + stateUpdating = "UPDATING" + stateActive = "ACTIVE" + stateInactive = "INACTIVE" + stateDeleting = "DELETING" + stateDeleted = "DELETED" + stateFailed = "FAILED" +) + +// Restart policies. +const ( + restartAlways = "ALWAYS" + restartNever = "NEVER" + restartOnFailure = "ON_FAILURE" +) + +// Volume, health check and pull secret kinds. +const ( + volEmptyDir = "EMPTYDIR" + volConfigFile = "CONFIGFILE" + hcHTTP = "HTTP" + hcTCP = "TCP" + secretBasic = "BASIC" + secretVault = "VAULT" + healthHealthy = "HEALTHY" + healthUnhealty = "UNHEALTHY" + healthUnknown = "UNKNOWN" + actionKill = "KILL" + actionNone = "NONE" + backingMemory = "MEMORY" + backingEphem = "EPHEMERAL_STORAGE" +) + +// ConfigFile is one file of a CONFIGFILE volume; Data is base64. +type ConfigFile struct { + FileName string `json:"fileName"` + Data string `json:"data"` + Path string `json:"path,omitempty"` +} + +// Volume is a container instance volume. +type Volume struct { + Name string `json:"name"` + Type string `json:"type"` + BackingStore string `json:"backingStore,omitempty"` + Configs []ConfigFile `json:"configs,omitempty"` +} + +// VolumeMount mounts a volume into a container. +type VolumeMount struct { + ReadOnly *bool `json:"isReadOnly,omitempty"` + Partition *int `json:"partition,omitempty"` + MountPath string `json:"mountPath"` + VolumeName string `json:"volumeName"` + SubPath string `json:"subPath,omitempty"` +} + +// HealthHeader is an HTTP health check header. +type HealthHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// HealthCheck is a container health check and its observed status. +type HealthCheck struct { + LastChecked *time.Time `json:"lastChecked,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type"` + Path string `json:"path,omitempty"` + Status string `json:"status"` + StatusDetails string `json:"statusDetails,omitempty"` + FailureAction string `json:"failureAction"` + Headers []HealthHeader `json:"headers,omitempty"` + Port int `json:"port"` + InitialDelay int `json:"initialDelayInSeconds"` + Interval int `json:"intervalInSeconds"` + FailureThresh int `json:"failureThreshold"` + SuccessThresh int `json:"successThreshold"` + Timeout int `json:"timeoutInSeconds"` +} + +// ResourceConfig caps a container's resources. +type ResourceConfig struct { + VcpusLimit *float32 `json:"vcpusLimit,omitempty"` + MemoryLimitInGBs *float32 `json:"memoryLimitInGBs,omitempty"` +} + +// Container is one container of a container instance. +type Container struct { + Created time.Time `json:"timeCreated"` + Updated *time.Time `json:"timeUpdated,omitempty"` + Terminated *time.Time `json:"timeTerminated,omitempty"` + ExitCode *int `json:"exitCode,omitempty"` + Resource *ResourceConfig `json:"resourceConfig,omitempty"` + Env map[string]string `json:"environmentVariables,omitempty"` + Freeform map[string]string `json:"freeformTags,omitempty"` + Defined map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + ImageURL string `json:"imageUrl"` + State string `json:"lifecycleState"` + Details string `json:"lifecycleDetails,omitempty"` + WorkingDir string `json:"workingDirectory,omitempty"` + Security json.RawMessage `json:"securityContext,omitempty"` + Command []string `json:"command,omitempty"` + Args []string `json:"arguments,omitempty"` + Mounts []VolumeMount `json:"volumeMounts,omitempty"` + Health []*HealthCheck `json:"healthChecks,omitempty"` + RestartCount int `json:"restartCount"` + NoResourcePrn bool `json:"isResourcePrincipalDisabled"` +} + +// PullSecret is an image pull secret; BASIC credentials never leave the backend. +type PullSecret struct { + Type string `json:"type"` + Endpoint string `json:"registryEndpoint"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + SecretID string `json:"secretId,omitempty"` +} + +// DNSConfig is a container instance DNS configuration. +type DNSConfig struct { + Nameservers []string `json:"nameservers,omitempty"` + Searches []string `json:"searches,omitempty"` + Options []string `json:"options,omitempty"` +} + +// Vnic is a container instance VNIC. +type Vnic struct { + VnicID string `json:"vnicId"` + SubnetID string `json:"subnetId"` +} + +// ShapeConfig is the compute size of an instance. +type ShapeConfig struct { + Processor string `json:"processorDescription"` + Ocpus float32 `json:"ocpus"` + Memory float32 `json:"memoryInGBs"` + Bandwidth float32 `json:"networkingBandwidthInGbps"` +} + +// Instance is a container instance. +type Instance struct { + Created time.Time `json:"timeCreated"` + Updated *time.Time `json:"timeUpdated,omitempty"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Graceful *int64 `json:"gracefulShutdownTimeoutInSeconds,omitempty"` + DNS *DNSConfig `json:"dnsConfig,omitempty"` + Freeform map[string]string `json:"freeformTags,omitempty"` + Defined map[string]any `json:"definedTags,omitempty"` + FaultDomain string `json:"faultDomain,omitempty"` + RetryToken string `json:"retryToken,omitempty"` + DisplayName string `json:"displayName"` + Compartment string `json:"compartmentId"` + AD string `json:"availabilityDomain"` + ID string `json:"id"` + State string `json:"lifecycleState"` + Details string `json:"lifecycleDetails,omitempty"` + Shape string `json:"shape"` + Restart string `json:"containerRestartPolicy"` + Etag string `json:"etag"` + Target string `json:"target,omitempty"` + Security json.RawMessage `json:"securityContext,omitempty"` + Containers []*Container `json:"containers"` + Volumes []Volume `json:"volumes,omitempty"` + Vnics []Vnic `json:"vnics"` + PullSecrets []PullSecret `json:"imagePullSecrets,omitempty"` + ShapeConfig ShapeConfig `json:"shapeConfig"` +} + +// WorkRequest is an asynchronous operation record. +type WorkRequest struct { + Accepted time.Time `json:"timeAccepted"` + FinishAt *time.Time `json:"finishAt,omitempty"` + FinishedAt *time.Time `json:"timeFinished,omitempty"` + ID string `json:"id"` + Type string `json:"operationType"` + Compartment string `json:"compartmentId"` + ResourceID string `json:"resourceId"` + ResourceType string `json:"resourceType"` + Action string `json:"actionType"` + Status string `json:"status"` + ErrCode string `json:"errorCode,omitempty"` + ErrMsg string `json:"errorMessage,omitempty"` + Seq int64 `json:"seq"` +} diff --git a/services/ocicontainerinstances/ops.go b/services/ocicontainerinstances/ops.go new file mode 100644 index 0000000000..c53fd4ceb3 --- /dev/null +++ b/services/ocicontainerinstances/ops.go @@ -0,0 +1,195 @@ +package ocicontainerinstances + +import ( + "net/http" + "strings" +) + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreate: (*Handler).createInstance, opGet: (*Handler).getInstance, opList: (*Handler).listInstances, + opUpdate: (*Handler).updateInstance, opDelete: act(func(b *InMemoryBackend, id, m string) (string, error) { + return b.DeleteInstance(id, m) + }), + opStart: act((*InMemoryBackend).Start), + opStop: act((*InMemoryBackend).Stop), + opRestart: act((*InMemoryBackend).Restart), + opChangeComp: (*Handler).changeCompartment, opListShapes: (*Handler).listShapes, + opGetContainer: (*Handler).getContainer, opListContainer: (*Handler).listContainers, + opUpdContainer: (*Handler).updateContainer, opRetrieveLogs: (*Handler).retrieveLogs, + opGetWR: (*Handler).getWorkRequest, opListWR: (*Handler).listWorkRequests, + opListWRErrors: (*Handler).listWRErrors, opListWRLogs: (*Handler).listWRLogs, + } +} + +func act(fn func(b *InMemoryBackend, id, ifMatch string) (string, error)) opFunc { + return func(h *Handler, q *req) error { + wr, err := fn(h.Backend, q.param("containerInstanceId"), q.ifMatch()) + if err != nil { + return err + } + + return q.accepted(wr) + } +} + +func (h *Handler) createInstance(q *req) error { + var in createIn + if err := q.decode(&in); err != nil { + return err + } + + view, wr, etag, err := h.Backend.CreateInstance(in, q.r.Header.Get("Opc-Retry-Token")) + if err != nil { + return err + } + + q.header("opc-work-request-id", wr) + + return q.json(http.StatusAccepted, view, etag) +} + +func (h *Handler) getInstance(q *req) error { + w, etag, err := h.Backend.GetInstance(q.param("containerInstanceId")) + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func (h *Handler) listInstances(q *req) error { + pg, err := h.Backend.ListInstances(InstanceFilter{ + Compartment: q.query("compartmentId"), State: q.query("lifecycleState"), + DisplayName: q.query("displayName"), AD: q.query("availabilityDomain"), + }, q.listOpts()) + if err != nil { + return err + } + + return q.collection(pg.Data, pg.Next) +} + +func (h *Handler) updateInstance(q *req) error { + var in updateIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateInstance(q.param("containerInstanceId"), q.ifMatch(), in) + if err != nil { + return err + } + + return q.accepted(wr) +} + +func (h *Handler) changeCompartment(q *req) error { + var in compartmentIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.ChangeCompartment(q.param("containerInstanceId"), q.ifMatch(), in.Compartment) + if err != nil { + return err + } + + return q.accepted(wr) +} + +func (h *Handler) listShapes(q *req) error { + pg, err := h.Backend.ListShapes(q.query("compartmentId"), q.listOpts()) + if err != nil { + return err + } + + return q.collection(pg.Data, pg.Next) +} + +func (h *Handler) getContainer(q *req) error { + w, err := h.Backend.GetContainer(q.param("containerId")) + if err != nil { + return err + } + + return q.json(http.StatusOK, w, "") +} + +func (h *Handler) listContainers(q *req) error { + pg, err := h.Backend.ListContainers(ContainerFilter{ + Compartment: q.query("compartmentId"), State: q.query("lifecycleState"), DisplayName: q.query("displayName"), + InstanceID: q.query("containerInstanceId"), AD: q.query("availabilityDomain"), + }, q.listOpts()) + if err != nil { + return err + } + + return q.collection(pg.Data, pg.Next) +} + +func (h *Handler) updateContainer(q *req) error { + var in updateIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateContainer(q.param("containerId"), q.ifMatch(), in) + if err != nil { + return err + } + + return q.accepted(wr) +} + +func (h *Handler) retrieveLogs(q *req) error { + out, err := h.Backend.RetrieveLogs(q.param("containerId"), strings.EqualFold(q.query("isPrevious"), "true")) + if err != nil { + return err + } + + q.header("Content-Type", "application/octet-stream") + q.w.WriteHeader(http.StatusOK) + _, _ = q.w.Write([]byte(out)) + + return nil +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + if err != nil { + return err + } + + return q.json(http.StatusOK, w, "") +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(WorkRequestFilter{ + Compartment: q.query("compartmentId"), ID: q.query("workRequestId"), Status: q.query("status"), + ResourceID: q.query("resourceId"), AD: q.query("availabilityDomain"), + }, q.listOpts()) + if err != nil { + return err + } + + return q.collection(pg.Data, pg.Next) +} + +func (h *Handler) listWRErrors(q *req) error { + pg, err := h.Backend.WorkRequestErrors(q.param("workRequestId"), q.listOpts()) + if err != nil { + return err + } + + return q.collection(pg.Data, pg.Next) +} + +func (h *Handler) listWRLogs(q *req) error { + pg, err := h.Backend.WorkRequestLogs(q.param("workRequestId"), q.listOpts()) + if err != nil { + return err + } + + return q.collection(pg.Data, pg.Next) +} diff --git a/services/ocicontainerinstances/persistence.go b/services/ocicontainerinstances/persistence.go new file mode 100644 index 0000000000..a72552b6b7 --- /dev/null +++ b/services/ocicontainerinstances/persistence.go @@ -0,0 +1,124 @@ +package ocicontainerinstances + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Instances map[string]*Instance `json:"instances"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys and image pull secrets. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocicontainerinstances", backendSnapshot{ + Version: snapshotVersion, Instances: b.instances, WorkReqs: b.workReqs, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state. Containers do not survive a restart: instances that were running come back INACTIVE. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocicontainerinstances", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx). + WarnContext(ctx, "ocicontainerinstances: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.mu.Lock("Restore") + b.instances, b.workReqs, b.seq = snap.Instances, snap.WorkReqs, snap.Seq + b.containers = map[string]string{} + + for id, in := range b.instances { + for _, c := range in.Containers { + b.containers[c.ID] = id + } + + b.settleRestored(in) + } + + for _, wr := range b.workReqs { + if wr.Status == wrInProgress && wr.FinishAt == nil { + now := b.nowUTC() + wr.Status, wr.FinishedAt, wr.ErrCode, wr.ErrMsg = wrFailed, &now, "Interrupted", "restored from a snapshot" + } + } + b.mu.Unlock() + b.Auth.Load(snap.Auth) + + return nil +} + +// settleRestored resolves engine-driven states that cannot continue after a restart. Callers hold the write lock. +func (b *InMemoryBackend) settleRestored(in *Instance) { + if b.engine == nil || in.TransitionAt != nil { + return + } + + switch in.State { + case stateActive, stateUpdating: + b.setState(in, stateInactive, "Restored from a snapshot; start the container instance to run its containers") + case stateCreating: + b.setState(in, stateFailed, "Restored from a snapshot before the containers started") + case stateDeleting: + b.setState(in, stateDeleted, "") + } +} + +func checkSnapshot(snap *backendSnapshot) error { + if snap.Instances == nil { + snap.Instances = map[string]*Instance{} + } + + if snap.WorkReqs == nil { + snap.WorkReqs = map[string]*WorkRequest{} + } + + for id, in := range snap.Instances { + if in == nil { + return fmt.Errorf("%w: instance %q", ErrSnapshotNull, id) + } + + for _, c := range in.Containers { + if c == nil { + return fmt.Errorf("%w: container of %q", ErrSnapshotNull, id) + } + } + } + + for id, wr := range snap.WorkReqs { + if wr == nil { + return fmt.Errorf("%w: work request %q", ErrSnapshotNull, id) + } + } + + return nil +} diff --git a/services/ocicontainerinstances/provider.go b/services/ocicontainerinstances/provider.go new file mode 100644 index 0000000000..a4b28fcdb0 --- /dev/null +++ b/services/ocicontainerinstances/provider.go @@ -0,0 +1,59 @@ +package ocicontainerinstances + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocicontainerinstances: nil app context") + +// ConfigProvider extracts OCI Container Instances configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIContainerInstancesSettings() Settings +} + +// Provider implements service.Provider for OCI Container Instances. It runs on its own listener +// (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIContainerInstances" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIContainerInstancesSettings() + } + + backend := NewInMemoryBackend() + + if settings.Engine == EngineDocker { + lr := gcpserve.NewLazyRuntime(ctx.Logger, "ocicontainerinstances.runtime") + eng := NewEngine(func() (Runtime, error) { + r, err := lr.Get() + if err != nil { + return nil, err + } + + return NewDockerRuntime(r), nil + }) + eng.onClose = func() { _ = lr.Close() } + backend.SetEngine(eng) + } + + handler := NewHandler(backend) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ocicontainerinstances/route.go b/services/ocicontainerinstances/route.go new file mode 100644 index 0000000000..a57a6d969d --- /dev/null +++ b/services/ocicontainerinstances/route.go @@ -0,0 +1,78 @@ +package ocicontainerinstances + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreate = "CreateContainerInstance" + opGet = "GetContainerInstance" + opList = "ListContainerInstances" + opUpdate = "UpdateContainerInstance" + opDelete = "DeleteContainerInstance" + opStart = "StartContainerInstance" + opStop = "StopContainerInstance" + opRestart = "RestartContainerInstance" + opChangeComp = "ChangeContainerInstanceCompartment" + opListShapes = "ListContainerInstanceShapes" + opGetContainer = "GetContainer" + opListContainer = "ListContainers" + opUpdContainer = "UpdateContainer" + opRetrieveLogs = "RetrieveLogs" + opGetWR = "GetWorkRequest" + opListWR = "ListWorkRequests" + opListWRErrors = "ListWorkRequestErrors" + opListWRLogs = "ListWorkRequestLogs" + opAdminKeys = "ManageAPIKeys" + opUnknown = "Unknown" + adminPrefix = "/_gopherstack/" + instPath = apiBase + "/containerInstances/{containerInstanceId}" + ctrPath = apiBase + "/containers/{containerId}" + wrPath = apiBase + "/workRequests/{workRequestId}" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: apiBase + "/containerInstances", Op: opCreate}, + {Method: http.MethodGet, Path: apiBase + "/containerInstances", Op: opList}, + {Method: http.MethodGet, Path: instPath, Op: opGet}, + {Method: http.MethodPut, Path: instPath, Op: opUpdate}, + {Method: http.MethodDelete, Path: instPath, Op: opDelete}, + {Method: http.MethodPost, Path: instPath + "/actions/start", Op: opStart}, + {Method: http.MethodPost, Path: instPath + "/actions/stop", Op: opStop}, + {Method: http.MethodPost, Path: instPath + "/actions/restart", Op: opRestart}, + {Method: http.MethodPost, Path: instPath + "/actions/changeCompartment", Op: opChangeComp}, + {Method: http.MethodGet, Path: apiBase + "/containerInstanceShapes", Op: opListShapes}, + {Method: http.MethodGet, Path: apiBase + "/containers", Op: opListContainer}, + {Method: http.MethodGet, Path: ctrPath, Op: opGetContainer}, + {Method: http.MethodPut, Path: ctrPath, Op: opUpdContainer}, + {Method: http.MethodPost, Path: ctrPath + "/actions/retrieveLogs", Op: opRetrieveLogs}, + {Method: http.MethodGet, Path: apiBase + "/workRequests", Op: opListWR}, + {Method: http.MethodGet, Path: wrPath, Op: opGetWR}, + {Method: http.MethodGet, Path: wrPath + "/errors", Op: opListWRErrors}, + {Method: http.MethodGet, Path: wrPath + "/logs", Op: opListWRLogs}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, adminPrefix) { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocicontainerinstances/runtime.go b/services/ocicontainerinstances/runtime.go new file mode 100644 index 0000000000..27263858ca --- /dev/null +++ b/services/ocicontainerinstances/runtime.go @@ -0,0 +1,120 @@ +package ocicontainerinstances + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/gcpserve" +) + +var ( + errVaultUnavailable = errors.New("VAULT image pull secrets need OCI Vault, which is not available") + errVaultSecretShape = errors.New(`the Vault secret must be JSON like {"username":"...","password":"..."}`) + errNoExitStatus = errors.New("the container runtime cannot report exit status") + errNoLogs = errors.New("the container runtime cannot read logs") + errNoCredPull = errors.New("the container runtime cannot pull with registry credentials") +) + +// Credential is a registry login resolved from an image pull secret. +type Credential struct { + Endpoint string + Username string + Password string +} + +// Runtime is the slice of the container runtime Container Instances need. +type Runtime interface { + // Pull fetches ref when it is absent locally or when cred is set. + Pull(ctx context.Context, ref string, cred *Credential) error + Start(ctx context.Context, spec container.Spec) (string, error) + // Wait blocks until the container exits and returns its exit code. + Wait(ctx context.Context, id string) (int, error) + Logs(ctx context.Context, id string) (string, error) + // Kill stops the container but keeps it, so Wait reports its exit code and logs stay readable. + Kill(ctx context.Context, id string) error + Remove(ctx context.Context, id string) error +} + +type credPuller interface { + PullImageWithCredentials(ctx context.Context, ref, user, pass string) error +} + +type imagePuller interface { + HasImage(ctx context.Context, ref string) (bool, error) + PullImage(ctx context.Context, ref string) error +} + +type stopper interface { + StopContainer(ctx context.Context, containerID string) error +} + +type dockerRuntime struct{ r gcpserve.Runner } + +// NewDockerRuntime wraps a pkgs/container runtime. +func NewDockerRuntime(r gcpserve.Runner) Runtime { return dockerRuntime{r: r} } + +func (d dockerRuntime) Pull(ctx context.Context, ref string, cred *Credential) error { + if cred != nil { + cp, ok := d.r.(credPuller) + if !ok { + return errNoCredPull + } + + return cp.PullImageWithCredentials(ctx, ref, cred.Username, cred.Password) + } + + ip, ok := d.r.(imagePuller) + if !ok { + return nil + } + + has, err := ip.HasImage(ctx, ref) + if err != nil || has { + return err + } + + return ip.PullImage(ctx, ref) +} + +func (d dockerRuntime) Start(ctx context.Context, spec container.Spec) (string, error) { + return d.r.CreateAndStart(ctx, spec) +} + +func (d dockerRuntime) Wait(ctx context.Context, id string) (int, error) { + w, ok := d.r.(container.ExitWaiter) + if !ok { + return 0, errNoExitStatus + } + + return w.WaitExit(ctx, id) +} + +func (d dockerRuntime) Logs(ctx context.Context, id string) (string, error) { + lr, ok := d.r.(container.LogReader) + if !ok { + return "", errNoLogs + } + + var out, errOut bytes.Buffer + + if err := lr.ReadLogs(ctx, id, "", false, &out, &errOut); err != nil { + return "", fmt.Errorf("read logs: %w", err) + } + + return out.String() + errOut.String(), nil +} + +func (d dockerRuntime) Kill(ctx context.Context, id string) error { + if s, ok := d.r.(stopper); ok { + return s.StopContainer(ctx, id) + } + + return d.r.StopAndRemove(ctx, id) +} + +func (d dockerRuntime) Remove(ctx context.Context, id string) error { + return d.r.StopAndRemove(ctx, id) +} diff --git a/services/ocicontainerinstances/settings.go b/services/ocicontainerinstances/settings.go new file mode 100644 index 0000000000..d981e52567 --- /dev/null +++ b/services/ocicontainerinstances/settings.go @@ -0,0 +1,20 @@ +package ocicontainerinstances + +// DefaultPort is the dedicated control-plane listener's default TCP port. +const DefaultPort = 8167 + +// Engine modes (--oci-container-instances-engine). +const ( + EngineState = "state" + EngineDocker = "docker" +) + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIContainerInstances. +type Settings struct { + Engine string `json:"engine" env:"OCI_CONTAINER_INSTANCES_ENGINE" default:"state" name:"oci-container-instances-engine" help:"Container Instances engine: state (lifecycle only, no containers run) or docker (run the containers in the container runtime)." enum:"state,docker"` //nolint:lll // config struct tags are intentionally verbose + Port int `json:"port" env:"OCI_CONTAINER_INSTANCES_PORT" default:"8167" name:"oci-container-instances-port" help:"Fixed TCP port for the dedicated OCI Container Instances listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_CONTAINER_INSTANCES_ENFORCE_AUTH" name:"oci-container-instances-enforce-auth" help:"Reject unsigned control-plane requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort, Engine: EngineState} } diff --git a/services/ocicontainerinstances/store.go b/services/ocicontainerinstances/store.go new file mode 100644 index 0000000000..44ac0a14b7 --- /dev/null +++ b/services/ocicontainerinstances/store.go @@ -0,0 +1,278 @@ +package ocicontainerinstances + +import ( + "cmp" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "iad" + defaultListLimit = 100 + ocidTailLen = 52 + defaultTransit = 2 * time.Second + wrInProgress = "IN_PROGRESS" + wrSucceeded = "SUCCEEDED" + wrFailed = "FAILED" +) + +// SecretReader returns the decoded content of a Vault secret. +type SecretReader func(secretID string) (string, error) + +// InMemoryBackend holds every container instance and work request under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + instances map[string]*Instance + containers map[string]string + workReqs map[string]*WorkRequest + Auth *ociauth.Registry + compartment func(string) bool + subnet func(string) bool + secrets SecretReader + engine *Engine + transition time.Duration + seq int64 +} + +// NewInMemoryBackend creates an empty backend running in state-only mode. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{ + mu: lockmetrics.New("ocicontainerinstances"), now: time.Now, transition: defaultTransit, + Auth: ociauth.NewRegistry(), + } + b.resetMaps() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.instances, b.containers, b.workReqs = map[string]*Instance{}, map[string]string{}, map[string]*WorkRequest{} +} + +// SetClock injects the time source used for lifecycle transitions. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTransition sets how long asynchronous state-only operations take to complete. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// SetSubnetChecker validates VNIC subnetIds against another service (OCI Core). +func (b *InMemoryBackend) SetSubnetChecker(fn func(string) bool) { + b.mu.Lock("SetSubnetChecker") + b.subnet = fn + b.mu.Unlock() +} + +// SetSecretReader resolves VAULT image pull secrets through OCI Vault. +func (b *InMemoryBackend) SetSecretReader(fn SecretReader) { + b.mu.Lock("SetSecretReader") + b.secrets = fn + b.mu.Unlock() +} + +// SetEngine switches the backend to docker mode. +func (b *InMemoryBackend) SetEngine(e *Engine) { + b.mu.Lock("SetEngine") + b.engine = e + b.mu.Unlock() + e.sink = b +} + +// Reset clears all state and stops every running container. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + e := b.engine + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() + + if e != nil { + e.HaltAll() + } +} + +// Close stops every running container. +func (b *InMemoryBackend) Close() { + b.mu.RLock("Close") + e := b.engine + b.mu.RUnlock() + + if e != nil { + e.Close() + } +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +// Now returns the backend clock (Sink). +func (b *InMemoryBackend) Now() time.Time { + b.mu.RLock("Now") + defer b.mu.RUnlock() + + return b.nowUTC() +} + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate[T any](items []T, o ListOpts, name func(T) string, created func(T) (time.Time, int64)) page.Page[T] { + byName := strings.EqualFold(o.SortBy, "displayName") + desc := !byName + + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + slices.SortStableFunc(items, func(a, c T) int { + var r int + if byName { + r = cmp.Compare(strs.Fold(name(a)), strs.Fold(name(c))) + } else { + ta, sa := created(a) + tc, sc := created(c) + r = cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +// Sweep applies due time-based transitions against the backend clock. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + now := b.nowUTC() + + for _, in := range b.instances { + if in.TransitionAt == nil || in.TransitionAt.After(now) { + continue + } + + b.setState(in, in.Target, "") + in.Target, in.TransitionAt, in.Etag = "", nil, newEtag() + } + + for _, wr := range b.workReqs { + if wr.Status == wrInProgress && wr.FinishAt != nil && !wr.FinishAt.After(now) { + wr.Status, wr.FinishedAt = wrSucceeded, wr.FinishAt + } + } +} + +// setState moves the instance and, unless the engine drives them, its containers. Callers hold the write lock. +func (b *InMemoryBackend) setState(in *Instance, state, details string) { + in.State, in.Details = state, details + now := b.nowUTC() + + for _, c := range in.Containers { + if b.engine != nil && state == stateActive { + continue + } + + if c.State == stateInactive && state == stateInactive { + continue + } + + c.State, c.Details, c.Updated = state, details, &now + } +} + +// Mutate runs fn on the instance under the write lock (Sink). +func (b *InMemoryBackend) Mutate(id string, fn func(*Instance)) { + b.mu.Lock("Mutate") + defer b.mu.Unlock() + + if in, ok := b.instances[id]; ok { + fn(in) + } +} + +// newWR records a work request. Callers hold the write lock. +func (b *InMemoryBackend) newWR(in *Instance, typ, action string, finish *time.Time) *WorkRequest { + wr := &WorkRequest{ + ID: newOCID("computecontainerinstanceworkrequest"), Type: typ, Compartment: in.Compartment, ResourceID: in.ID, + ResourceType: "containerInstance", Action: action, Accepted: b.nowUTC(), Status: wrInProgress, + FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr +} + +func (b *InMemoryBackend) finishAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} diff --git a/services/ocicontainerinstances/validate.go b/services/ocicontainerinstances/validate.go new file mode 100644 index 0000000000..4189af447c --- /dev/null +++ b/services/ocicontainerinstances/validate.go @@ -0,0 +1,446 @@ +package ocicontainerinstances + +import ( + "cmp" + "encoding/base64" + "encoding/json" + "fmt" + "net/netip" + "path" + "slices" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + maxContainers = 32 + maxVolumes = 32 + maxPort = 65535 + defInterval = 10 + defFailure = 3 + defSuccess = 1 + defTimeout = 1 + maxVnics = 1 +) + +func (b *InMemoryBackend) buildInstance(in createIn) (*Instance, *APIError) { + if aerr := b.checkCompartment(in.Compartment); aerr != nil { + return nil, aerr + } + + if in.AD == "" || in.Shape == "" { + return nil, errMissing("availabilityDomain and shape are required") + } + + sc, aerr := buildShapeConfig(in) + if aerr != nil { + return nil, aerr + } + + restart := in.Restart + if restart == "" { + restart = restartAlways + } + + if !slices.Contains([]string{restartAlways, restartNever, restartOnFailure}, restart) { + return nil, errParam("containerRestartPolicy must be ALWAYS, NEVER or ON_FAILURE") + } + + now := b.nowUTC() + inst := &Instance{ + ID: newOCID("computecontainerinstance"), DisplayName: in.DisplayName, Compartment: in.Compartment, + AD: in.AD, FaultDomain: in.FaultDomain, State: stateCreating, Shape: in.Shape, ShapeConfig: sc, + Restart: restart, Created: now, Etag: newEtag(), Graceful: in.Graceful, Freeform: in.Freeform, + Defined: in.Defined, Security: in.SecurityCtx, DNS: in.DNS, + } + + if inst.DisplayName == "" { + inst.DisplayName = "containerinstance" + now.Format("20060102150405") + } + + if aerr = b.buildParts(inst, in); aerr != nil { + return nil, aerr + } + + return inst, nil +} + +func buildShapeConfig(in createIn) (ShapeConfig, *APIError) { + shape, ok := lookupShape(in.Shape) + if !ok { + return ShapeConfig{}, errParam("shape " + in.Shape + " is not a supported container instance shape") + } + + if in.ShapeConfig == nil || in.ShapeConfig.Ocpus == nil { + return ShapeConfig{}, errMissing("shapeConfig.ocpus is required") + } + + ocpus := *in.ShapeConfig.Ocpus + if ocpus < shape.MinOcpus || ocpus > shape.MaxOcpus { + return ShapeConfig{}, errParam(fmt.Sprintf("shapeConfig.ocpus must be between %g and %g for %s", + shape.MinOcpus, shape.MaxOcpus, shape.Name)) + } + + mem := ocpus * shape.DefPerOcpu + if in.ShapeConfig.MemoryInGBs != nil { + mem = *in.ShapeConfig.MemoryInGBs + } + + lo, hi := max(float32(minMemoryGB), ocpus*minPerOcpuGB), min(ocpus*shape.MaxPerOcpu, shape.MaxMemory) + if in.ShapeConfig.MemoryInGBs == nil { + mem = min(mem, hi) + } + + if mem < lo || mem > hi { + return ShapeConfig{}, errParam(fmt.Sprintf("shapeConfig.memoryInGBs must be between %g and %g for %g OCPUs", + lo, hi, ocpus)) + } + + return ShapeConfig{Processor: shape.Processor, Ocpus: ocpus, Memory: mem, Bandwidth: shape.bandwidth(ocpus)}, nil +} + +func (b *InMemoryBackend) buildParts(inst *Instance, in createIn) *APIError { + if in.Graceful != nil && *in.Graceful < 0 { + return errParam("gracefulShutdownTimeoutInSeconds must not be negative") + } + + if aerr := checkSecurity(in.SecurityCtx); aerr != nil { + return aerr + } + + if aerr := checkDNS(in.DNS); aerr != nil { + return aerr + } + + if aerr := b.buildVnics(inst, in.Vnics); aerr != nil { + return aerr + } + + if aerr := buildVolumes(inst, in.Volumes); aerr != nil { + return aerr + } + + if aerr := buildSecrets(inst, in.Secrets); aerr != nil { + return aerr + } + + return b.buildContainers(inst, in.Containers) +} + +func (b *InMemoryBackend) buildVnics(inst *Instance, vnics []createVnicIn) *APIError { + if len(vnics) == 0 { + return errMissing("vnics is required and needs one entry") + } + + if len(vnics) > maxVnics { + return errParam("a container instance supports exactly one VNIC") + } + + for _, v := range vnics { + if v.SubnetID == "" { + return errMissing("vnics.subnetId is required") + } + + if b.subnet != nil && !b.subnet(v.SubnetID) { + return errNotFound(v.SubnetID) + } + + inst.Vnics = append(inst.Vnics, Vnic{VnicID: newOCID("vnic"), SubnetID: v.SubnetID}) + } + + return nil +} + +func checkSecurity(raw json.RawMessage) *APIError { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var sc struct { + Type string `json:"securityContextType"` + } + + if err := json.Unmarshal(raw, &sc); err != nil || sc.Type != "LINUX" { + return errParam("securityContext.securityContextType must be LINUX") + } + + return nil +} + +func checkDNS(d *DNSConfig) *APIError { + if d == nil { + return nil + } + + for _, ns := range d.Nameservers { + if _, err := netip.ParseAddr(ns); err != nil { + return errParam("dnsConfig.nameservers must be IP addresses: " + ns) + } + } + + return nil +} + +func buildVolumes(inst *Instance, vols []createVolumeIn) *APIError { + if len(vols) > maxVolumes { + return errParam(fmt.Sprintf("a container instance supports up to %d volumes", maxVolumes)) + } + + seen := map[string]bool{} + + for _, v := range vols { + if v.Name == "" || seen[v.Name] { + return errParam("volume names are required and must be unique") + } + + seen[v.Name] = true + vol := Volume{Name: v.Name, Type: v.VolumeType} + + switch v.VolumeType { + case volEmptyDir: + if v.BackingStore != "" && v.BackingStore != backingMemory && v.BackingStore != backingEphem { + return errParam("volume backingStore must be EPHEMERAL_STORAGE or MEMORY") + } + + vol.BackingStore = v.BackingStore + case volConfigFile: + if aerr := checkConfigs(v.Name, v.Configs); aerr != nil { + return aerr + } + + vol.Configs = v.Configs + default: + return errParam("volumeType " + v.VolumeType + " is not supported; use EMPTYDIR or CONFIGFILE") + } + + inst.Volumes = append(inst.Volumes, vol) + } + + return nil +} + +func checkConfigs(vol string, cfgs []ConfigFile) *APIError { + seen := map[string]bool{} + + for _, c := range cfgs { + if c.FileName == "" || strings.ContainsAny(c.FileName, "/\\") || c.FileName == "." || c.FileName == ".." { + return errParam("volume " + vol + ": config fileName must be a plain file name") + } + + if _, err := base64.StdEncoding.DecodeString(c.Data); err != nil { + return errParam("volume " + vol + ": config data for " + c.FileName + " must be base64") + } + + if c.Path != "" && !relClean(c.Path) { + return errParam("volume " + vol + ": config path must be relative and stay inside the volume") + } + + key := path.Join(c.Path, c.FileName) + if seen[key] { + return errParam("volume " + vol + ": duplicate config file " + key) + } + + seen[key] = true + } + + return nil +} + +func relClean(p string) bool { + return !path.IsAbs(p) && !strings.Contains(p, "\\") && path.Clean(p) != ".." && + !strings.HasPrefix(path.Clean(p), "../") +} + +func buildSecrets(inst *Instance, secrets []createSecretIn) *APIError { + seen := map[string]bool{} + + for _, s := range secrets { + if s.Endpoint == "" { + return errMissing("imagePullSecrets.registryEndpoint is required") + } + + if seen[strs.Fold(s.Endpoint)] { + return errParam("duplicate imagePullSecrets for registry " + s.Endpoint) + } + + seen[strs.Fold(s.Endpoint)] = true + + switch s.SecretType { + case secretBasic: + if s.Username == "" || s.Password == "" { + return errMissing("BASIC imagePullSecrets need username and password") + } + case secretVault: + if s.SecretID == "" { + return errMissing("VAULT imagePullSecrets need secretId") + } + default: + return errParam("imagePullSecrets.secretType must be BASIC or VAULT") + } + + inst.PullSecrets = append(inst.PullSecrets, PullSecret{ + Type: s.SecretType, Endpoint: s.Endpoint, Username: s.Username, Password: s.Password, SecretID: s.SecretID, + }) + } + + return nil +} + +func (b *InMemoryBackend) buildContainers(inst *Instance, cs []createContainerIn) *APIError { + if len(cs) == 0 { + return errMissing("containers is required and needs at least one entry") + } + + if len(cs) > maxContainers { + return errParam(fmt.Sprintf("a container instance supports up to %d containers", maxContainers)) + } + + names := map[string]bool{} + + for i, in := range cs { + c, aerr := buildContainer(inst, in, i) + if aerr != nil { + return aerr + } + + if names[strs.Fold(c.DisplayName)] { + return errParam("duplicate container displayName " + c.DisplayName) + } + + names[strs.Fold(c.DisplayName)] = true + inst.Containers = append(inst.Containers, c) + } + + return nil +} + +func buildContainer(inst *Instance, in createContainerIn, idx int) (*Container, *APIError) { + if in.ImageURL == "" { + return nil, errMissing("containers.imageUrl is required") + } + + if in.WorkingDir != "" && !path.IsAbs(in.WorkingDir) { + return nil, errParam("containers.workingDirectory must be an absolute path") + } + + if aerr := checkSecurity(in.SecurityCtx); aerr != nil { + return nil, aerr + } + + if aerr := checkResources(inst, in.ResourceConfig); aerr != nil { + return nil, aerr + } + + name := in.DisplayName + if name == "" { + name = fmt.Sprintf("%s-container-%d", inst.DisplayName, idx+1) + } + + c := &Container{ + ID: newOCID("computecontainer"), DisplayName: name, ImageURL: in.ImageURL, State: stateCreating, + Created: inst.Created, Command: in.Command, Args: in.Arguments, WorkingDir: in.WorkingDir, Env: in.Env, + Freeform: in.Freeform, Defined: in.Defined, Security: in.SecurityCtx, Mounts: in.VolumeMounts, + Resource: in.ResourceConfig, NoResourcePrn: in.NoResourcePrn != nil && *in.NoResourcePrn, + } + + if aerr := checkMounts(inst, c.Mounts); aerr != nil { + return nil, aerr + } + + for _, h := range in.HealthChecks { + hc, aerr := buildCheck(h) + if aerr != nil { + return nil, aerr + } + + c.Health = append(c.Health, hc) + } + + return c, nil +} + +func checkResources(inst *Instance, rc *ResourceConfig) *APIError { + if rc == nil { + return nil + } + + if rc.VcpusLimit != nil && (*rc.VcpusLimit <= 0 || *rc.VcpusLimit > inst.ShapeConfig.Ocpus) { + return errParam("containers.resourceConfig.vcpusLimit must be positive and within the instance ocpus") + } + + if rc.MemoryLimitInGBs != nil && (*rc.MemoryLimitInGBs <= 0 || *rc.MemoryLimitInGBs > inst.ShapeConfig.Memory) { + return errParam("containers.resourceConfig.memoryLimitInGBs must be positive and within the instance memory") + } + + return nil +} + +func checkMounts(inst *Instance, mounts []VolumeMount) *APIError { + seen := map[string]bool{} + + for _, m := range mounts { + if !slices.ContainsFunc(inst.Volumes, func(v Volume) bool { return v.Name == m.VolumeName }) { + return errParam("volumeMounts.volumeName " + m.VolumeName + " does not match any volume") + } + + if !path.IsAbs(m.MountPath) || seen[path.Clean(m.MountPath)] { + return errParam("volumeMounts.mountPath must be an absolute path, unique within the container") + } + + seen[path.Clean(m.MountPath)] = true + + if m.SubPath != "" && !relClean(m.SubPath) { + return errParam("volumeMounts.subPath must be relative and stay inside the volume") + } + } + + return nil +} + +func buildCheck(h createCheckIn) (*HealthCheck, *APIError) { + if h.Type != hcHTTP && h.Type != hcTCP { + return nil, errParam("healthChecks.healthCheckType must be HTTP or TCP") + } + + if h.Port == nil || *h.Port < 1 || *h.Port > maxPort { + return nil, errParam("healthChecks.port must be between 1 and 65535") + } + + if h.Type == hcHTTP && !strings.HasPrefix(h.Path, "/") { + return nil, errParam("HTTP healthChecks.path must start with /") + } + + hc := &HealthCheck{ + Name: h.Name, Type: h.Type, Port: *h.Port, Path: h.Path, Headers: h.Headers, Status: healthUnknown, + FailureAction: cmp.Or(h.FailureAction, actionKill), Interval: orDefault(h.Interval, defInterval), + FailureThresh: orDefault(h.FailureThresh, defFailure), SuccessThresh: orDefault(h.SuccessThresh, defSuccess), + Timeout: orDefault(h.Timeout, defTimeout), InitialDelay: orDefault(h.InitialDelay, 0), + } + + if aerr := checkCheckBounds(hc); aerr != nil { + return nil, aerr + } + + return hc, nil +} + +func checkCheckBounds(hc *HealthCheck) *APIError { + if hc.FailureAction != actionKill && hc.FailureAction != actionNone { + return errParam("healthChecks.failureAction must be KILL or NONE") + } + + if hc.Interval < 1 || hc.Timeout < 1 || hc.FailureThresh < 1 || hc.SuccessThresh < 1 || hc.InitialDelay < 0 { + return errParam("healthChecks intervals, timeout and thresholds must be positive") + } + + return nil +} + +func orDefault(p *int, def int) int { + if p == nil { + return def + } + + return *p +} diff --git a/services/ocicontainerinstances/volumes.go b/services/ocicontainerinstances/volumes.go new file mode 100644 index 0000000000..cd44c26fc7 --- /dev/null +++ b/services/ocicontainerinstances/volumes.go @@ -0,0 +1,185 @@ +package ocicontainerinstances + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "strings" +) + +const ( + scratchPerm = 0o777 + filePerm = 0o666 + shmDir = "/dev/shm" +) + +// scratch holds the host directories backing one instance's volumes. +type scratch struct { + root string + dirs map[string]string + dns string +} + +func newScratch() (*scratch, error) { + root, err := os.MkdirTemp("", "gopherstack-oci-ci-") + if err != nil { + return nil, fmt.Errorf("create scratch dir: %w", err) + } + + if err = os.Chmod(root, scratchPerm); err != nil { + _ = os.RemoveAll(root) + + return nil, fmt.Errorf("chmod scratch dir: %w", err) + } + + return &scratch{root: root, dirs: map[string]string{}}, nil +} + +func (s *scratch) remove() { + if s != nil { + _ = os.RemoveAll(s.root) + } +} + +// fill creates each volume's directory and writes CONFIGFILE contents. +func (s *scratch) fill(vols []Volume, dns *DNSConfig) error { + for i, v := range vols { + dir := filepath.Join(s.root, "vol"+strconv.Itoa(i)) + if v.Type == volEmptyDir && v.BackingStore == backingMemory { + if d, err := os.MkdirTemp(shmDir, "gopherstack-oci-ci-"); err == nil { + dir = d + } + } + + if err := os.MkdirAll(dir, scratchPerm); err != nil { + return fmt.Errorf("create volume %q: %w", v.Name, err) + } + + _ = os.Chmod(dir, scratchPerm) + s.dirs[v.Name] = dir + + if err := writeConfigs(dir, v.Configs); err != nil { + return fmt.Errorf("volume %q: %w", v.Name, err) + } + } + + if dns != nil { + s.dns = filepath.Join(s.root, "resolv.conf") + + if err := os.WriteFile(s.dns, []byte(resolvConf(dns)), filePerm); err != nil { + return fmt.Errorf("write resolv.conf: %w", err) + } + } + + return nil +} + +func (s *scratch) removeAll() { + for _, d := range s.dirs { + if strings.HasPrefix(d, shmDir) { + _ = os.RemoveAll(d) + } + } + + s.remove() +} + +func writeConfigs(dir string, cfgs []ConfigFile) error { + if len(cfgs) == 0 { + return nil + } + + root, err := os.OpenRoot(dir) + if err != nil { + return fmt.Errorf("open volume dir: %w", err) + } + defer root.Close() + + for _, c := range cfgs { + data, derr := base64.StdEncoding.DecodeString(c.Data) + if derr != nil { + return fmt.Errorf("decode %s: %w", c.FileName, derr) + } + + rel := path.Join(c.Path, c.FileName) + if d := path.Dir(rel); d != "." { + if err = root.MkdirAll(d, scratchPerm); err != nil { + return fmt.Errorf("create %s: %w", d, err) + } + } + + if err = root.WriteFile(rel, data, filePerm); err != nil { + return fmt.Errorf("write %s: %w", rel, err) + } + } + + return nil +} + +func resolvConf(d *DNSConfig) string { + var sb strings.Builder + + for _, ns := range d.Nameservers { + sb.WriteString("nameserver " + ns + "\n") + } + + if len(d.Searches) > 0 { + sb.WriteString("search " + strings.Join(d.Searches, " ") + "\n") + } + + if len(d.Options) > 0 { + sb.WriteString("options " + strings.Join(d.Options, " ") + "\n") + } + + return sb.String() +} + +// binds returns the docker bind mounts for a container's volume mounts and the instance DNS file. +func (s *scratch) binds(mounts []VolumeMount) []string { + var out []string + + for _, m := range mounts { + src := s.dirs[m.VolumeName] + if m.SubPath != "" { + src = filepath.Join(src, filepath.FromSlash(m.SubPath)) + _ = os.MkdirAll(src, scratchPerm) + _ = os.Chmod(src, scratchPerm) + } + + bind := src + ":" + m.MountPath + if m.ReadOnly != nil && *m.ReadOnly { + bind += ":ro" + } + + out = append(out, bind) + } + + if s.dns != "" { + out = append(out, s.dns+":/etc/resolv.conf:ro") + } + + return out +} + +// securityUser maps a LINUX security context to docker's uid[:gid] user and the fsGroup supplementary group. +func securityUser(raw json.RawMessage) string { + var sc struct { + User *int `json:"runAsUser"` + Group *int `json:"runAsGroup"` + } + + if len(raw) == 0 || json.Unmarshal(raw, &sc) != nil || sc.User == nil { + return "" + } + + u := strconv.Itoa(*sc.User) + if sc.Group != nil { + u += ":" + strconv.Itoa(*sc.Group) + } + + return u +} diff --git a/services/ocicontainerinstances/wire.go b/services/ocicontainerinstances/wire.go new file mode 100644 index 0000000000..ef59865aee --- /dev/null +++ b/services/ocicontainerinstances/wire.go @@ -0,0 +1,263 @@ +package ocicontainerinstances + +import ( + "encoding/json" + "time" +) + +type ( + createShapeConfigIn struct { + Ocpus *float32 `json:"ocpus"` + MemoryInGBs *float32 `json:"memoryInGBs"` + } + createVnicIn struct { + SubnetID string `json:"subnetId"` + DisplayName string `json:"displayName"` + } + createVolumeIn struct { + Name string `json:"name"` + VolumeType string `json:"volumeType"` + BackingStore string `json:"backingStore"` + Configs []ConfigFile `json:"configs"` + } + createSecretIn struct { + SecretType string `json:"secretType"` + Endpoint string `json:"registryEndpoint"` + Username string `json:"username"` + Password string `json:"password"` + SecretID string `json:"secretId"` + } + createCheckIn struct { + Port *int `json:"port"` + InitialDelay *int `json:"initialDelayInSeconds"` + Interval *int `json:"intervalInSeconds"` + FailureThresh *int `json:"failureThreshold"` + SuccessThresh *int `json:"successThreshold"` + Timeout *int `json:"timeoutInSeconds"` + Name string `json:"name"` + Type string `json:"healthCheckType"` + Path string `json:"path"` + FailureAction string `json:"failureAction"` + Headers []HealthHeader `json:"headers"` + } + createContainerIn struct { + ResourceConfig *ResourceConfig `json:"resourceConfig"` + NoResourcePrn *bool `json:"isResourcePrincipalDisabled"` + Env map[string]string `json:"environmentVariables"` + Freeform map[string]string `json:"freeformTags"` + Defined map[string]any `json:"definedTags"` + ImageURL string `json:"imageUrl"` + DisplayName string `json:"displayName"` + WorkingDir string `json:"workingDirectory"` + SecurityCtx json.RawMessage `json:"securityContext"` + Command []string `json:"command"` + Arguments []string `json:"arguments"` + VolumeMounts []VolumeMount `json:"volumeMounts"` + HealthChecks []createCheckIn `json:"healthChecks"` + } + createIn struct { + ShapeConfig *createShapeConfigIn `json:"shapeConfig"` + DNS *DNSConfig `json:"dnsConfig"` + Graceful *int64 `json:"gracefulShutdownTimeoutInSeconds"` + Freeform map[string]string `json:"freeformTags"` + Defined map[string]any `json:"definedTags"` + SecurityCtx json.RawMessage `json:"securityContext"` + Compartment string `json:"compartmentId"` + AD string `json:"availabilityDomain"` + Shape string `json:"shape"` + DisplayName string `json:"displayName"` + FaultDomain string `json:"faultDomain"` + Restart string `json:"containerRestartPolicy"` + Containers []createContainerIn `json:"containers"` + Vnics []createVnicIn `json:"vnics"` + Volumes []createVolumeIn `json:"volumes"` + Secrets []createSecretIn `json:"imagePullSecrets"` + } + updateIn struct { + DisplayName *string `json:"displayName"` + Freeform map[string]string `json:"freeformTags"` + Defined map[string]any `json:"definedTags"` + } + compartmentIn struct { + Compartment string `json:"compartmentId"` + } +) + +type containerRefWire struct { + ContainerID string `json:"containerId"` + DisplayName string `json:"displayName"` +} + +type vnicWire struct { + VnicID string `json:"vnicId"` +} + +type shapeConfigWire struct { + Processor string `json:"processorDescription"` + Ocpus float32 `json:"ocpus"` + Memory float32 `json:"memoryInGBs"` + Bandwidth float32 `json:"networkingBandwidthInGbps"` +} + +type instanceSummaryWire struct { + TimeCreated time.Time `json:"timeCreated"` + Defined map[string]any `json:"definedTags,omitempty"` + TimeUpdated *time.Time `json:"timeUpdated,omitempty"` + Graceful *int64 `json:"gracefulShutdownTimeoutInSeconds,omitempty"` + Freeform map[string]string `json:"freeformTags,omitempty"` + DisplayName string `json:"displayName"` + ID string `json:"id"` + Compartment string `json:"compartmentId"` + AD string `json:"availabilityDomain"` + FaultDomain string `json:"faultDomain,omitempty"` + State string `json:"lifecycleState"` + Details string `json:"lifecycleDetails,omitempty"` + Shape string `json:"shape"` + Restart string `json:"containerRestartPolicy"` + Security json.RawMessage `json:"securityContext,omitempty"` + ShapeConfig shapeConfigWire `json:"shapeConfig"` + Count int `json:"containerCount"` + VolumeCount int `json:"volumeCount"` +} + +type instanceWire struct { + DNS *DNSConfig `json:"dnsConfig,omitempty"` + Containers []containerRefWire `json:"containers"` + Vnics []vnicWire `json:"vnics"` + Volumes []map[string]any `json:"volumes"` + Secrets []map[string]any `json:"imagePullSecrets"` + instanceSummaryWire +} + +func summaryOf(i *Instance) instanceSummaryWire { + return instanceSummaryWire{ + ID: i.ID, DisplayName: i.DisplayName, Compartment: i.Compartment, AD: i.AD, FaultDomain: i.FaultDomain, + State: i.State, Details: i.Details, Shape: i.Shape, Restart: i.Restart, TimeCreated: i.Created, + TimeUpdated: i.Updated, ShapeConfig: shapeConfigWire(i.ShapeConfig), Count: len(i.Containers), + VolumeCount: len(i.Volumes), Graceful: i.Graceful, Freeform: i.Freeform, Defined: i.Defined, + Security: i.Security, + } +} + +func instanceView(i *Instance) instanceWire { + w := instanceWire{ + instanceSummaryWire: summaryOf(i), DNS: i.DNS, Containers: make([]containerRefWire, 0, len(i.Containers)), + Vnics: make([]vnicWire, 0, len(i.Vnics)), Volumes: make([]map[string]any, 0, len(i.Volumes)), + Secrets: make([]map[string]any, 0, len(i.PullSecrets)), + } + + for _, c := range i.Containers { + w.Containers = append(w.Containers, containerRefWire{ContainerID: c.ID, DisplayName: c.DisplayName}) + } + + for _, v := range i.Vnics { + w.Vnics = append(w.Vnics, vnicWire{VnicID: v.VnicID}) + } + + for _, v := range i.Volumes { + w.Volumes = append(w.Volumes, volumeView(v)) + } + + for _, s := range i.PullSecrets { + m := map[string]any{"secretType": s.Type, "registryEndpoint": s.Endpoint} + if s.Type == secretVault { + m["secretId"] = s.SecretID + } + + w.Secrets = append(w.Secrets, m) + } + + return w +} + +func volumeView(v Volume) map[string]any { + m := map[string]any{"name": v.Name, "volumeType": v.Type} + + switch v.Type { + case volEmptyDir: + if v.BackingStore != "" { + m["backingStore"] = v.BackingStore + } + case volConfigFile: + cfgs := make([]ConfigFile, 0, len(v.Configs)) + cfgs = append(cfgs, v.Configs...) + m["configs"] = cfgs + } + + return m +} + +type checkWire struct { + Name string `json:"name,omitempty"` + Type string `json:"healthCheckType"` + Path string `json:"path,omitempty"` + Status string `json:"status"` + StatusDetails string `json:"statusDetails,omitempty"` + FailureAction string `json:"failureAction"` + Headers []HealthHeader `json:"headers,omitempty"` + Port int `json:"port"` + InitialDelay int `json:"initialDelayInSeconds"` + Interval int `json:"intervalInSeconds"` + FailureThresh int `json:"failureThreshold"` + SuccessThresh int `json:"successThreshold"` + Timeout int `json:"timeoutInSeconds"` +} + +type containerSummaryWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated *time.Time `json:"timeUpdated,omitempty"` + Resource *ResourceConfig `json:"resourceConfig,omitempty"` + Freeform map[string]string `json:"freeformTags,omitempty"` + Defined map[string]any `json:"definedTags,omitempty"` + Compartment string `json:"compartmentId"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + AD string `json:"availabilityDomain"` + FaultDomain string `json:"faultDomain,omitempty"` + State string `json:"lifecycleState"` + Details string `json:"lifecycleDetails,omitempty"` + InstanceID string `json:"containerInstanceId"` + ImageURL string `json:"imageUrl"` + Security json.RawMessage `json:"securityContext,omitempty"` + NoResource bool `json:"isResourcePrincipalDisabled"` +} + +type containerWire struct { + TimeTerminated *time.Time `json:"timeTerminated,omitempty"` + ExitCode *int `json:"exitCode,omitempty"` + Env map[string]string `json:"environmentVariables,omitempty"` + WorkingDir string `json:"workingDirectory,omitempty"` + Command []string `json:"command,omitempty"` + Arguments []string `json:"arguments,omitempty"` + VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"` + HealthChecks []checkWire `json:"healthChecks,omitempty"` + containerSummaryWire + RestartCount int `json:"containerRestartAttemptCount"` +} + +func containerSummaryOf(i *Instance, c *Container) containerSummaryWire { + return containerSummaryWire{ + ID: c.ID, DisplayName: c.DisplayName, Compartment: i.Compartment, AD: i.AD, FaultDomain: i.FaultDomain, + State: c.State, Details: c.Details, InstanceID: i.ID, ImageURL: c.ImageURL, TimeCreated: c.Created, + TimeUpdated: c.Updated, Resource: c.Resource, Freeform: c.Freeform, Defined: c.Defined, Security: c.Security, + NoResource: c.NoResourcePrn, + } +} + +func containerView(i *Instance, c *Container) containerWire { + w := containerWire{ + containerSummaryWire: containerSummaryOf(i, c), TimeTerminated: c.Terminated, ExitCode: c.ExitCode, + Env: c.Env, WorkingDir: c.WorkingDir, Command: c.Command, Arguments: c.Args, VolumeMounts: c.Mounts, + RestartCount: c.RestartCount, + } + + for _, h := range c.Health { + w.HealthChecks = append(w.HealthChecks, checkWire{ + Name: h.Name, Type: h.Type, Path: h.Path, Status: h.Status, StatusDetails: h.StatusDetails, + FailureAction: h.FailureAction, Headers: h.Headers, Port: h.Port, InitialDelay: h.InitialDelay, + Interval: h.Interval, FailureThresh: h.FailureThresh, SuccessThresh: h.SuccessThresh, Timeout: h.Timeout, + }) + } + + return w +} diff --git a/services/ocicontainerinstances/worker_port_test.go b/services/ocicontainerinstances/worker_port_test.go new file mode 100644 index 0000000000..9380e9d84d --- /dev/null +++ b/services/ocicontainerinstances/worker_port_test.go @@ -0,0 +1,39 @@ +package ocicontainerinstances_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + svc "github.com/blackbirdworks/gopherstack/services/ocicontainerinstances" +) + +func TestStartWorkerBindsEphemeralPort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port int + }{ + {name: "port zero", port: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := svc.NewHandler(svc.NewInMemoryBackend()) + h.Port = tt.port + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(t.Context()) }) + + assert.Positive(t, h.Port) + assert.NotEqual(t, svc.DefaultPort, h.Port) + + busy := svc.NewHandler(svc.NewInMemoryBackend()) + busy.Port = h.Port + require.Error(t, busy.StartWorker(t.Context())) + }) + } +} diff --git a/services/ocicontainerinstances/workreq.go b/services/ocicontainerinstances/workreq.go new file mode 100644 index 0000000000..6d70d05f77 --- /dev/null +++ b/services/ocicontainerinstances/workreq.go @@ -0,0 +1,225 @@ +package ocicontainerinstances + +import ( + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + apiBase = "/20210415" + percentMid = 50 + percentDone = 100 + percentMax = 99 + percentFactor = 100 +) + +type wrResourceWire struct { + EntityType string `json:"entityType"` + Action string `json:"actionType"` + Identifier string `json:"identifier"` + EntityURI string `json:"entityUri"` +} + +// WorkRequestWire is the wire form of a work request. +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted time.Time `json:"timeStarted"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + Type string `json:"operationType"` + Status string `json:"status"` + ID string `json:"id"` + Compartment string `json:"compartmentId"` + Resources []wrResourceWire `json:"resources"` + Percent float32 `json:"percentComplete"` +} + +type wrErrorWire struct { + Timestamp time.Time `json:"timestamp"` + Code string `json:"code"` + Message string `json:"message"` +} + +type wrLogWire struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + uri := apiBase + "/containerInstances/" + wr.ResourceID + if wr.ResourceType == "container" { + uri = apiBase + "/containers/" + wr.ResourceID + } + + pct := float32(percentMid) + + switch { + case wr.FinishedAt != nil: + pct = percentDone + case wr.FinishAt != nil: + total := wr.FinishAt.Sub(wr.Accepted) + if total > 0 { + pct = min(percentMax, float32(b.nowUTC().Sub(wr.Accepted))*percentFactor/float32(total)) + } + } + + return WorkRequestWire{ + ID: wr.ID, Type: wr.Type, Status: wr.Status, Compartment: wr.Compartment, Percent: max(pct, 0), + TimeAccepted: wr.Accepted, TimeStarted: wr.Accepted, TimeFinished: wr.FinishedAt, + Resources: []wrResourceWire{{ + EntityType: wr.ResourceType, Action: wr.Action, Identifier: wr.ResourceID, EntityURI: uri, + }}, + } +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// WorkRequestFilter selects work requests in a list. +type WorkRequestFilter struct { + Compartment string + ID string + Status string + ResourceID string + AD string +} + +// ListWorkRequests lists the work requests of a compartment, newest first. +func (b *InMemoryBackend) ListWorkRequests(f WorkRequestFilter, o ListOpts) (page.Page[WorkRequestWire], error) { + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + if aerr := b.checkCompartment(f.Compartment); aerr != nil { + return page.Page[WorkRequestWire]{}, aerr + } + + var all []*WorkRequest + + for _, wr := range b.workReqs { + if wr.Compartment != f.Compartment || (f.ID != "" && wr.ID != f.ID) || + (f.Status != "" && wr.Status != f.Status) || (f.ResourceID != "" && wr.ResourceID != f.ResourceID) { + continue + } + + if in := b.instances[wr.ResourceID]; f.AD != "" && (in == nil || in.AD != f.AD) { + continue + } + + all = append(all, wr) + } + + pg := paginate( + all, + ListOpts{Page: o.Page, Limit: o.Limit, Order: o.Order}, + func(w *WorkRequest) string { return w.ID }, + func(w *WorkRequest) (time.Time, int64) { return w.Accepted, w.Seq }, + ) + + out := make([]WorkRequestWire, 0, len(pg.Data)) + for _, wr := range pg.Data { + out = append(out, b.wrView(wr)) + } + + return page.Page[WorkRequestWire]{Data: out, Next: pg.Next}, nil +} + +// WorkRequestErrors lists the errors of a failed work request. +func (b *InMemoryBackend) WorkRequestErrors(id string, o ListOpts) (page.Page[wrErrorWire], error) { + b.mu.RLock("WorkRequestErrors") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrErrorWire]{}, errNotFound(id) + } + + all := []wrErrorWire{} + if wr.Status == wrFailed && wr.FinishedAt != nil { + all = append(all, wrErrorWire{Timestamp: *wr.FinishedAt, Code: wr.ErrCode, Message: wr.ErrMsg}) + } + + return page.New(all, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestLogs lists the progress log of a work request, oldest first. +func (b *InMemoryBackend) WorkRequestLogs(id string, o ListOpts) (page.Page[wrLogWire], error) { + b.mu.RLock("WorkRequestLogs") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrLogWire]{}, errNotFound(id) + } + + all := []wrLogWire{ + {Timestamp: wr.Accepted, Message: wr.Type + " accepted"}, + {Timestamp: wr.Accepted, Message: wr.Type + " in progress"}, + } + + if wr.FinishedAt != nil { + msg := wr.Type + " succeeded" + if wr.Status == wrFailed { + msg = wr.Type + " failed: " + wr.ErrMsg + } + + all = append(all, wrLogWire{Timestamp: *wr.FinishedAt, Message: msg}) + } + + return page.New(all, o.Page, o.Limit, defaultListLimit), nil +} + +// ShapeWire is the wire form of a container instance shape. +type ShapeWire struct { + Name string `json:"name"` + Processor string `json:"processorDescription"` + Ocpu struct { + Min float32 `json:"min"` + Max float32 `json:"max"` + } `json:"ocpuOptions"` + Memory struct { + MinInGBs float32 `json:"minInGBs"` + MaxInGBs float32 `json:"maxInGBs"` + DefPerOcpu float32 `json:"defaultPerOcpuInGBs"` + MinPerOcpu float32 `json:"minPerOcpuInGBs"` + MaxPerOcpu float32 `json:"maxPerOcpuInGBs"` + } `json:"memoryOptions"` + Bandwidth struct { + Min float32 `json:"minInGbps"` + Max float32 `json:"maxInGbps"` + DefPerOcpu float32 `json:"defaultPerOcpuInGbps"` + } `json:"networkingBandwidthOptions"` +} + +// ListShapes lists the container instance shapes. +func (b *InMemoryBackend) ListShapes(compartment string, o ListOpts) (page.Page[ShapeWire], error) { + b.mu.RLock("ListShapes") + err := b.checkCompartment(compartment) + b.mu.RUnlock() + + if err != nil { + return page.Page[ShapeWire]{}, err + } + + var all []ShapeWire + + for _, s := range shapeCatalog() { + w := ShapeWire{Name: s.Name, Processor: s.Processor} + w.Ocpu.Min, w.Ocpu.Max = s.MinOcpus, s.MaxOcpus + w.Memory.MinInGBs, w.Memory.MaxInGBs, w.Memory.DefPerOcpu = minMemoryGB, s.MaxMemory, s.DefPerOcpu + w.Memory.MinPerOcpu, w.Memory.MaxPerOcpu = minPerOcpuGB, s.MaxPerOcpu + w.Bandwidth.Min, w.Bandwidth.Max, w.Bandwidth.DefPerOcpu = gbpsPerOcpu, s.MaxBandwith, gbpsPerOcpu + all = append(all, w) + } + + return page.New(all, o.Page, o.Limit, defaultListLimit), nil +} diff --git a/services/ocicore/handler.go b/services/ocicore/handler.go index e7e94ac5df..8810659825 100644 --- a/services/ocicore/handler.go +++ b/services/ocicore/handler.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "strings" "sync" @@ -18,6 +17,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -184,7 +184,12 @@ func (h *Handler) fail(q *req, err error) { } // Handler returns the Echo handler for all OCI Core Services requests. -func (h *Handler) Handler() echo.HandlerFunc { +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { return func(c *echo.Context) error { q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} q.header("opc-request-id", strings.ToUpper(uuid.NewString())) @@ -197,7 +202,7 @@ func (h *Handler) Handler() echo.HandlerFunc { h.Backend.Sweep() - if err := h.serve(q); err != nil { + if err := h.serve(q, verify); err != nil { h.fail(q, err) } @@ -205,7 +210,7 @@ func (h *Handler) Handler() echo.HandlerFunc { } } -func (h *Handler) serve(q *req) error { +func (h *Handler) serve(q *req, verify bool) error { fn, ok := h.ops[q.rt.op] if !ok { return errNoRoute() @@ -218,10 +223,12 @@ func (h *Handler) serve(q *req) error { q.body = body - if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ - Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, - }); err != nil { - return errAuth("The required information to complete authentication was not provided or was incorrect.") + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } } return fn(h, q) @@ -274,13 +281,13 @@ func (h *Handler) adminKeys(q *req) { // StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. func (h *Handler) StartWorker(ctx context.Context) error { - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { return fmt.Errorf("ocicore: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) e.Any("/*", telemetry.WrapEchoHandler("OCICore", h.Handler(), h)) diff --git a/services/ocicore/helpers_test.go b/services/ocicore/helpers_test.go index 9031b04789..67eb5ffe73 100644 --- a/services/ocicore/helpers_test.go +++ b/services/ocicore/helpers_test.go @@ -7,7 +7,6 @@ import ( "crypto/x509" "encoding/pem" "fmt" - "net" "sync/atomic" "testing" "time" @@ -77,20 +76,6 @@ type env struct { base string } -func freePort(t *testing.T) int { - t.Helper() - - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - defer l.Close() - - tcp, ok := l.Addr().(*net.TCPAddr) - require.True(t, ok) - - return tcp.Port -} - func newEnv(t *testing.T, enforce bool) *env { t.Helper() @@ -99,7 +84,7 @@ func newEnv(t *testing.T, enforce bool) *env { be.SetClock(clock.Now) h := oci.NewHandler(be) - h.Port = freePort(t) + h.Port = 0 h.EnforceAuth = enforce require.NoError(t, h.StartWorker(t.Context())) diff --git a/services/ocicore/inproc.go b/services/ocicore/inproc.go new file mode 100644 index 0000000000..a79e5233d9 --- /dev/null +++ b/services/ocicore/inproc.go @@ -0,0 +1,26 @@ +package ocicore + +import "slices" + +// Compartments lists the distinct compartments that hold live resources. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, r := range b.res { + if live(r) { + seen[r.base().CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} diff --git a/services/ocicore/refs.go b/services/ocicore/refs.go new file mode 100644 index 0000000000..c7c4c2c652 --- /dev/null +++ b/services/ocicore/refs.go @@ -0,0 +1,126 @@ +package ocicore + +// SubnetRef is the read-only view of a subnet other emulators validate references against. +type SubnetRef struct { + ID string + VcnID string + CompartmentID string + AvailabilityDomain string + CidrBlock string + State string +} + +// ShapeRef is the read-only view of a compute shape. +type ShapeRef struct { + Name string + Arch string + Flexible bool + MinOcpus float64 + MaxOcpus float64 + MaxMemoryGBs float64 + DefaultMemPerOcpu float64 +} + +// ImageRef is the read-only view of a platform or custom image. +type ImageRef struct { + ID string + Name string + OS string + OSVersion string + Arch string + State string +} + +// LookupSubnet returns a live subnet. +func (b *InMemoryBackend) LookupSubnet(id string) (SubnetRef, bool) { + b.mu.RLock("LookupSubnet") + defer b.mu.RUnlock() + + s, ok := b.res[id].(*Subnet) + if !ok || !live(s) { + return SubnetRef{}, false + } + + ref := SubnetRef{ + ID: s.ID, VcnID: s.VcnID, CompartmentID: s.CompartmentID, CidrBlock: s.CidrBlock, State: s.LifecycleState, + } + if s.AvailabilityDomain != nil { + ref.AvailabilityDomain = *s.AvailabilityDomain + } + + return ref, true +} + +// VcnExists reports whether a live VCN has the id. +func (b *InMemoryBackend) VcnExists(id string) bool { + b.mu.RLock("VcnExists") + defer b.mu.RUnlock() + + v, ok := b.res[id].(*Vcn) + + return ok && live(v) +} + +func (s shapeDef) ref() ShapeRef { + return ShapeRef{ + Name: s.name, Arch: s.arch, Flexible: s.flexible, MinOcpus: s.minOcpus, MaxOcpus: s.maxOcpus, + MaxMemoryGBs: s.maxMemGB, DefaultMemPerOcpu: s.defaultPerOcpu(), + } +} + +// LookupShape returns a catalogue shape by name. +func LookupShape(name string) (ShapeRef, bool) { + s, ok := shapeByName(name) + if !ok { + return ShapeRef{}, false + } + + return s.ref(), true +} + +// CatalogShapes lists every catalogue shape. +func CatalogShapes() []ShapeRef { + out := make([]ShapeRef, 0, len(shapeDefs)) + for _, s := range shapeDefs { + out = append(out, s.ref()) + } + + return out +} + +func imageRef(m *Image) ImageRef { + arch := "" + if m.Internal != nil { + arch = m.Internal.Arch + } + + return ImageRef{ + ID: m.ID, Name: m.DisplayName, OS: m.OperatingSystem, OSVersion: m.OperatingSystemVersion, Arch: arch, + State: m.LifecycleState, + } +} + +// LookupImage returns a platform or custom image. +func (b *InMemoryBackend) LookupImage(id string) (ImageRef, bool) { + b.mu.RLock("LookupImage") + defer b.mu.RUnlock() + + m, aerr := b.findImage(id) + if aerr != nil { + return ImageRef{}, false + } + + return imageRef(m), true +} + +// CatalogImages lists the platform images. +func CatalogImages() []ImageRef { + imgs := catalogImages() + out := make([]ImageRef, 0, len(imgs)) + + for _, m := range imgs { + out = append(out, imageRef(m)) + } + + return out +} diff --git a/services/ocicore/refs_test.go b/services/ocicore/refs_test.go new file mode 100644 index 0000000000..fe8baa692a --- /dev/null +++ b/services/ocicore/refs_test.go @@ -0,0 +1,111 @@ +package ocicore_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ocicore" +) + +func TestLookupSubnetAndVcn(t *testing.T) { + t.Parallel() + + e := newEnv(t, false) + v := e.mkVcn(t, "10.0.0.0/16", "refs") + s := e.mkSubnet(t, v, "10.0.1.0/24") + + got, ok := e.backend.LookupSubnet(*s.Id) + require.True(t, ok) + assert.Equal(t, *v.Id, got.VcnID) + assert.Equal(t, "10.0.1.0/24", got.CidrBlock) + + subnetKnown := func(id string) bool { + _, found := e.backend.LookupSubnet(id) + + return found + } + + tests := []struct { + fn func() bool + name string + want bool + }{ + {func() bool { return subnetKnown(*s.Id) }, "known subnet", true}, + {func() bool { return subnetKnown("ocid1.subnet.oc1.iad.nope") }, "unknown subnet", false}, + {func() bool { return subnetKnown(*v.Id) }, "vcn is not a subnet", false}, + {func() bool { return e.backend.VcnExists(*v.Id) }, "known vcn", true}, + {func() bool { return e.backend.VcnExists(*s.Id) }, "subnet is not a vcn", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, tt.fn()) + }) + } +} + +func TestLookupCatalogue(t *testing.T) { + t.Parallel() + + e := newEnv(t, false) + + imgs := oci.CatalogImages() + require.NotEmpty(t, imgs) + + tests := []struct { + check func(t *testing.T) + name string + }{ + {name: "flexible shape", check: func(t *testing.T) { + t.Helper() + + s, ok := oci.LookupShape("VM.Standard.E4.Flex") + require.True(t, ok) + assert.True(t, s.Flexible) + assert.Equal(t, "x86_64", s.Arch) + assert.InDelta(t, 16, s.DefaultMemPerOcpu, 0) + }}, + {name: "fixed shape", check: func(t *testing.T) { + t.Helper() + + s, ok := oci.LookupShape("VM.Standard2.1") + require.True(t, ok) + assert.False(t, s.Flexible) + }}, + {name: "unknown shape", check: func(t *testing.T) { + t.Helper() + + _, ok := oci.LookupShape("VM.Nope") + assert.False(t, ok) + }}, + {name: "shape list", check: func(t *testing.T) { + t.Helper() + assert.Len(t, oci.CatalogShapes(), 9) + }}, + {name: "platform image", check: func(t *testing.T) { + t.Helper() + + got, ok := e.backend.LookupImage(imgs[0].ID) + require.True(t, ok) + assert.Equal(t, imgs[0], got) + assert.Equal(t, "Oracle Linux", got.OS) + }}, + {name: "unknown image", check: func(t *testing.T) { + t.Helper() + + _, ok := e.backend.LookupImage("ocid1.image.oc1.iad.nope") + assert.False(t, ok) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.check(t) + }) + } +} diff --git a/services/ocicore/service_test.go b/services/ocicore/service_test.go index 70d602b7b5..2fb8a6423f 100644 --- a/services/ocicore/service_test.go +++ b/services/ocicore/service_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oci "github.com/blackbirdworks/gopherstack/services/ocicore" ) @@ -98,7 +99,7 @@ func TestUnknownRoute(t *testing.T) { e := newEnv(t, false) - resp, err := http.Get(e.base + "/20160918/nothing") + resp, err := testclient.New(t).Get(e.base + "/20160918/nothing") require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusNotFound, resp.StatusCode) diff --git a/services/ocicore/vcn_test.go b/services/ocicore/vcn_test.go index ff2dc1ccc4..5f3f49cb53 100644 --- a/services/ocicore/vcn_test.go +++ b/services/ocicore/vcn_test.go @@ -8,6 +8,8 @@ import ( "github.com/oracle/oci-go-sdk/v65/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestVcnCreate(t *testing.T) { @@ -267,7 +269,7 @@ func TestVcnListing(t *testing.T) { require.NoError(t, err) assert.Empty(t, names(r)) - resp, err := http.Get(e.base + "/20160918/vcns") + resp, err := testclient.New(t).Get(e.base + "/20160918/vcns") require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) diff --git a/services/ocievents/PARITY.md b/services/ocievents/PARITY.md new file mode 100644 index 0000000000..a2a60b662f --- /dev/null +++ b/services/ocievents/PARITY.md @@ -0,0 +1,32 @@ +--- +service: ocievents +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: 861afe5af +last_audit_date: 2026-10-29 +overall: B +ops: + CreateRule: {wire: ok, errors: partial, state: ok, persist: ok, note: "200 with the Rule body and etag. displayName, isEnabled, condition, compartmentId and 1 to 10 actions are required (docs.oracle.com/en-us/iaas/Content/Events/Task/managingrules.htm: at least one action, at most 10; action targets must already exist, so unknown topics and streams are 404 when Notifications or Streaming are wired). ONS needs topicId, OSS streamId, FAAS functionId. 50 live rules per tenancy (Events overview, docs.oracle.com/en-us/iaas/Content/Events/Concepts/eventsoverview.htm) then 400 LimitExceeded. The rule is ACTIVE at once, INACTIVE when isEnabled is false."} + GetRule: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; DELETED rules are still readable with lifecycleState DELETED."} + ListRules: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; bare JSON array of RuleSummary (as the SDK deserializes it); displayName and lifecycleState filters; sortBy TIME_CREATED (default, newest first), ID, DISPLAY_NAME; sortOrder; limit and opc-next-page. DELETED rules are hidden unless filtered for."} + UpdateRule: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 with the Rule body and etag. displayName, description, isEnabled, condition, actions (replaces the whole list; new action ids), tags. If-Match mismatch is 412 NoEtagMatch."} + DeleteRule: {wire: ok, errors: ok, state: ok, persist: ok, note: "204. The rule becomes DELETED immediately and stops matching events; deleting it again is 404."} + ChangeRuleCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 without a body; target compartment validated against OCI Identity when linked."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-events-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id: InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch, LimitExceeded, CannotParseRequest (OCI API error reference, docs.oracle.com/en-us/iaas/Content/API/References/apierrors.htm)."} + condition: {status: partial, note: "Matching Events with Filters (docs.oracle.com/en-us/iaas/Content/Events/Concepts/filterevents.htm): the condition is a JSON object string; {} matches every event; fields not mentioned are ignored; a listed field must exist in the event with the same nesting; arrays match when any value matches; separate fields combine with AND; * is a wildcard in values only (start, middle or end), a lone * matches any value but not null. Rejected at create/update with 400 InvalidParameter: invalid JSON, non-object, trailing data, empty arrays, null, nested arrays or objects inside arrays, * in a field name."} + event_envelope: {status: partial, note: "Events carry eventType, cloudEventsVersion 0.1, eventTypeVersion 2.0, source, eventTime, contentType application/json, eventID (UUID), extensions.compartmentId and data {compartmentId, resourceName, resourceId, availabilityDomain, additionalDetails}, shaped after the examples in Services that Produce Events (docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm). The task brief said CloudEvents 1.0; the documented examples say 0.1, so 0.1 is emitted. data.compartmentName is omitted: the emulator does not resolve names."} + producers: {status: partial, note: "Emitted through narrow sink hooks wired in internal/app. Object Storage (only for buckets with objectEventsEnabled, set on create or update bucket): com.oraclecloud.objectstorage.createobject (new name), updateobject (overwrite), deleteobject, from PutObject, CommitUploadedParts, CopyObject destination and DeleteObject/BatchDeleteObjects; additionalDetails eTag, namespace, archivalState (null), bucketName, bucketId; resourceId empty and availabilityDomain all as in the documented example. Functions: com.oraclecloud.functions.createapplication, deleteapplication, createfunction, deletefunction (resourceName, resourceId). Event type names are from the Services that Produce Events page."} + scope: {status: partial, note: "A rule matches events from its compartment and child compartments (managingrules.htm); nesting comes from OCI Identity when linked, otherwise only the exact compartment matches. The tenancy root rule matches everything."} + delivery: {status: partial, note: "Each matching enabled rule delivers to each enabled action once, on a goroutine: ONS publishes the event JSON as a JSON message to the topic through ocinotifications (its own subscription retry then applies), OSS appends the event JSON as one message (no key) to the stream through ocistreaming, FAAS invokes the function with the event JSON as body and content-type application/json through ocifunctions (a non-2xx status is a failure). Events does no retry of its own: every attempt is recorded in an in-memory log of the last 1000 deliveries (PENDING, DELIVERED, FAILED with error), readable at GET /_gopherstack/oci/events/deliveries on the Events port, and a failed delivery is not repeated. The log is not persisted."} + lifecycle: {status: partial, note: "Rules change state immediately (no CREATING/UPDATING window); the injectable clock only stamps timeCreated and delivery records."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-events-port / OCI_EVENTS_PORT (default 8164); never on the shared AWS router; cli.go reserves the port in the shared pool."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} +items_still_open: + - "Event producers are limited to Object Storage objects and Functions applications and functions. Other services (including NoSQL, whose event names were not found in a reachable primary source) emit nothing; Functions update events, Object Storage bucket events and RenameObject do not emit." + - "Filter tags (matching on resource defined or freeform tags) and data.compartmentName are not produced, so conditions on them never match." + - "Unverified, chosen by the emulator: case-sensitive value comparison; numbers and booleans compared by their text form; unknown top-level condition keys are accepted and matched against the whole envelope; no rejection of conditions that can never match; rule displayName limit of 255 and no uniqueness rule." + - "Unverified, from memory: the 400 LimitExceeded code for the 50-rule limit, the eventrule/eventaction OCID prefixes, rules going ACTIVE with no CREATING state, 404 for a missing action target, lifecycleMessage always empty, and DELETE answering 204." + - "Delivery is best effort and unordered across events with no retry on Events' side; Oracle's real at-least-once and retry behaviour is not documented on the pages reached and is not reproduced. Delivery to ONS, OSS and FAAS in other compartments is not restricted." + - "opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. systemTags are never set. The dashboard has no OCI Events page." +--- diff --git a/services/ocievents/README.md b/services/ocievents/README.md new file mode 100644 index 0000000000..be51e2fb1b --- /dev/null +++ b/services/ocievents/README.md @@ -0,0 +1,28 @@ + +# OCI Events + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-29 (`861afe5af`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 6 (5 ok, 1 partial) | +| Feature families | 10 (3 ok, 7 partial) | +| Known gaps | 6 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Event producers are limited to Object Storage objects and Functions applications and functions. Other services (including NoSQL, whose event names were not found in a reachable primary source) emit nothing; Functions update events, Object Storage bucket events and RenameObject do not emit. +- Filter tags (matching on resource defined or freeform tags) and data.compartmentName are not produced, so conditions on them never match. +- Unverified, chosen by the emulator: case-sensitive value comparison; numbers and booleans compared by their text form; unknown top-level condition keys are accepted and matched against the whole envelope; no rejection of conditions that can never match; rule displayName limit of 255 and no uniqueness rule. +- Unverified, from memory: the 400 LimitExceeded code for the 50-rule limit, the eventrule/eventaction OCID prefixes, rules going ACTIVE with no CREATING state, 404 for a missing action target, lifecycleMessage always empty, and DELETE answering 204. +- Delivery is best effort and unordered across events with no retry on Events' side; Oracle's real at-least-once and retry behaviour is not documented on the pages reached and is not reproduced. Delivery to ONS, OSS and FAAS in other compartments is not restricted. +- opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. systemTags are never set. The dashboard has no OCI Events page. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocievents/condition.go b/services/ocievents/condition.go new file mode 100644 index 0000000000..3082564441 --- /dev/null +++ b/services/ocievents/condition.go @@ -0,0 +1,163 @@ +package ocievents + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" +) + +// parseCondition decodes a rule condition: a JSON object whose leaves are strings, numbers, booleans or +// non-empty arrays of those (https://docs.oracle.com/en-us/iaas/Content/Events/Concepts/filterevents.htm). +func parseCondition(s string) (map[string]any, *APIError) { + if strings.TrimSpace(s) == "" { + return nil, errMissing("condition is required") + } + + dec := json.NewDecoder(bytes.NewReader([]byte(s))) + dec.UseNumber() + + var v any + if err := dec.Decode(&v); err != nil { + return nil, errParam("condition is not valid JSON: " + err.Error()) + } + + if dec.More() { + return nil, errParam("condition must be a single JSON object") + } + + obj, ok := v.(map[string]any) + if !ok { + return nil, errParam("condition must be a JSON object") + } + + if err := validateNode("condition", obj); err != nil { + return nil, err + } + + return obj, nil +} + +func validateNode(path string, v any) *APIError { + switch t := v.(type) { + case map[string]any: + for k, child := range t { + if strings.Contains(k, "*") { + return errParam( + fmt.Sprintf("%s: wildcards are allowed in values only, not in the field name %q", path, k), + ) + } + + if err := validateNode(path+"."+k, child); err != nil { + return err + } + } + case []any: + if len(t) == 0 { + return errParam(path + ": an array must list at least one value") + } + + for _, el := range t { + if !isScalar(el) { + return errParam(path + ": array elements must be strings, numbers or booleans") + } + } + case string, json.Number, bool: + default: + return errParam(path + ": null is not a valid filter value") + } + + return nil +} + +func isScalar(v any) bool { + switch v.(type) { + case string, json.Number, float64, bool: + return true + default: + return false + } +} + +func scalarString(v any) string { + switch t := v.(type) { + case string: + return t + case json.Number: + return t.String() + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case bool: + return strconv.FormatBool(t) + default: + return "" + } +} + +// matchCondition reports whether every field of cond appears in event with a matching value. An empty +// condition matches everything. +func matchCondition(cond, event map[string]any) bool { + for k, c := range cond { + e, ok := event[k] + if !ok || !matchValue(c, e) { + return false + } + } + + return true +} + +func matchValue(c, e any) bool { + switch ev := e.(type) { + case nil: + return false + case []any: + return slicesAny(ev, func(el any) bool { return matchValue(c, el) }) + case map[string]any: + cm, ok := c.(map[string]any) + + return ok && matchCondition(cm, ev) + } + + if cs, ok := c.([]any); ok { + return slicesAny(cs, func(el any) bool { return matchValue(el, e) }) + } + + if !isScalar(c) { + return false + } + + return wildcardMatch(scalarString(c), scalarString(e)) +} + +func slicesAny(list []any, fn func(any) bool) bool { + return slices.ContainsFunc(list, fn) +} + +// wildcardMatch matches s against pattern where * stands for any run of characters (including none). +func wildcardMatch(pattern, s string) bool { + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == s + } + + if !strings.HasPrefix(s, parts[0]) { + return false + } + + s = s[len(parts[0]):] + last := parts[len(parts)-1] + + for _, mid := range parts[1 : len(parts)-1] { + i := strings.Index(s, mid) + if i < 0 { + return false + } + + s = s[i+len(mid):] + } + + return strings.HasSuffix(s, last) +} diff --git a/services/ocievents/delivery_test.go b/services/ocievents/delivery_test.go new file mode 100644 index 0000000000..e6c6617746 --- /dev/null +++ b/services/ocievents/delivery_test.go @@ -0,0 +1,531 @@ +package ocievents_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/oracle/oci-go-sdk/v65/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ocievents "github.com/blackbirdworks/gopherstack/services/ocievents" + ocinotifications "github.com/blackbirdworks/gopherstack/services/ocinotifications" + ociobjectstorage "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" + ocistreaming "github.com/blackbirdworks/gopherstack/services/ocistreaming" +) + +type notifier struct { + b *ocinotifications.InMemoryBackend +} + +func (n notifier) TopicActive(id string) bool { return n.b.TopicActive(id) } + +func (n notifier) PublishEvent(topicID, body string) error { + _, err := n.b.PublishMessage(topicID, "JSON", ocinotifications.PublishIn{Body: &body}) + + return err +} + +type streamer struct{ b *ocistreaming.InMemoryBackend } + +func (s streamer) StreamActive(id string) bool { return s.b.StreamActive(id) } + +func (s streamer) AppendMessage(id string, key, value []byte) error { + return s.b.AppendMessage(id, key, value) +} + +func eventAt(compartment, eventType, name string) ocievents.Event { + return ocievents.Event{ + EventType: eventType, + Source: "objectstorage", + CompartmentID: compartment, + ResourceName: name, + AdditionalDetails: map[string]any{ + "bucketName": "my_bucket", + "publicAccessType": "NoPublicAccess", + "archivalState": nil, + }, + } +} + +func TestFilterMatching(t *testing.T) { + t.Parallel() + + const create = "com.oraclecloud.objectstorage.createbucket" + + tests := []struct { + name string + condition string + event ocievents.Event + want bool + }{ + {"empty matches all", `{}`, eventAt(testCompartment, create, "b"), true}, + {"exact type", `{"eventType":"` + create + `"}`, eventAt(testCompartment, create, "b"), true}, + { + "other type", + `{"eventType":"com.oraclecloud.objectstorage.deletebucket"}`, + eventAt(testCompartment, create, "b"), + false, + }, + {"type list", `{"eventType":["x.y","` + create + `"]}`, eventAt(testCompartment, create, "b"), true}, + {"type list miss", `{"eventType":["x.y","z.w"]}`, eventAt(testCompartment, create, "b"), false}, + { + "mid wildcard", + `{"eventType":"com.oraclecloud.objectstorage.*bucket"}`, + eventAt(testCompartment, create, "b"), + true, + }, + {"suffix wildcard", `{"eventType":"com.oraclecloud.*"}`, eventAt(testCompartment, create, "b"), true}, + {"prefix wildcard", `{"eventType":"*.createbucket"}`, eventAt(testCompartment, create, "b"), true}, + { + "wildcard miss", + `{"eventType":"com.oraclecloud.objectstorage.*object"}`, + eventAt(testCompartment, create, "b"), + false, + }, + { + "data resourceName", + `{"data":{"resourceName":"my_bucket"}}`, + eventAt(testCompartment, create, "my_bucket"), + true, + }, + { + "data resourceName prefix", + `{"data":{"resourceName":"my_bucket*"}}`, + eventAt(testCompartment, create, "my_bucket_2"), + true, + }, + { + "data resourceName miss", + `{"data":{"resourceName":"my_bucket"}}`, + eventAt(testCompartment, create, "other"), + false, + }, + {"data list", `{"data":{"resourceName":["a","b","c"]}}`, eventAt(testCompartment, create, "b"), true}, + { + "nested detail", + `{"data":{"additionalDetails":{"publicAccessType":"NoPublicAccess"}}}`, + eventAt(testCompartment, create, "b"), + true, + }, + { + "nested detail miss", + `{"data":{"additionalDetails":{"publicAccessType":"PublicAccess"}}}`, + eventAt(testCompartment, create, "b"), + false, + }, + { + "star matches value", + `{"data":{"additionalDetails":{"publicAccessType":"*"}}}`, + eventAt(testCompartment, create, "b"), + true, + }, + { + "star does not match null", + `{"data":{"additionalDetails":{"archivalState":"*"}}}`, + eventAt(testCompartment, create, "b"), + false, + }, + {"missing field", `{"data":{"additionalDetails":{"nope":"*"}}}`, eventAt(testCompartment, create, "b"), false}, + { + "wrong nesting", + `{"additionalDetails":{"publicAccessType":"NoPublicAccess"}}`, + eventAt(testCompartment, create, "b"), + false, + }, + { + "and across fields", + `{"eventType":"` + create + `","data":{"resourceName":"zzz"}}`, + eventAt(testCompartment, create, "b"), + false, + }, + {"envelope source", `{"source":"objectstorage"}`, eventAt(testCompartment, create, "b"), true}, + {"other compartment", `{}`, eventAt(otherCompart, create, "b"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + fn := &fakeFunctions{} + e.backend.SetFunctionInvoker(fn) + e.createRule(t, "r", tt.condition, faasAction()) + + e.backend.Emit(tt.event) + + got := fn.invoked() + if !tt.want { + assert.Empty(t, got) + + return + } + + require.Len(t, got, 1) + + var env map[string]any + require.NoError(t, json.Unmarshal([]byte(got[0]), &env)) + assert.Equal(t, tt.event.EventType, env["eventType"]) + assert.Equal(t, "0.1", env["cloudEventsVersion"]) + assert.Equal(t, "2.0", env["eventTypeVersion"]) + assert.Equal(t, "application/json", env["contentType"]) + assert.NotEmpty(t, env["eventID"]) + assert.NotEmpty(t, env["eventTime"]) + }) + } +} + +func TestCompartmentScope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + fn := &fakeFunctions{} + e.backend.SetFunctionInvoker(fn) + e.backend.SetCompartmentTree(func(id, ancestor string) bool { + return id == "ocid1.compartment.oc1..child" && ancestor == testCompartment + }) + e.createRule(t, "r", matchAll, faasAction()) + + e.backend.Emit(eventAt("ocid1.compartment.oc1..child", "a.b", "x")) + e.backend.Emit(eventAt(otherCompart, "a.b", "x")) + + assert.Len(t, fn.invoked(), 1) +} + +func TestDisabledNotDelivered(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + fn := &fakeFunctions{} + e.backend.SetFunctionInvoker(fn) + + off := events.CreateFaaSActionDetails{IsEnabled: new(false), FunctionId: new(testFunction)} + rule := e.createRule(t, "actions", matchAll, off, faasAction()) + e.createRule(t, "never", matchAll, off) + + e.backend.Emit(eventAt(testCompartment, "a.b", "x")) + assert.Len(t, fn.invoked(), 1, "only the enabled action of the first rule") + + _, err := e.client.UpdateRule(t.Context(), events.UpdateRuleRequest{ + RuleId: rule.Id, IsEnabled: new(false), + }) + require.NoError(t, err) + + e.backend.Emit(eventAt(testCompartment, "a.b", "x")) + assert.Len(t, fn.invoked(), 1, "disabled rule") + + _, err = e.client.UpdateRule(t.Context(), events.UpdateRuleRequest{ + RuleId: rule.Id, IsEnabled: new(true), + }) + require.NoError(t, err) + + _, err = e.client.DeleteRule(t.Context(), events.DeleteRuleRequest{RuleId: rule.Id}) + require.NoError(t, err) + + e.backend.Emit(eventAt(testCompartment, "a.b", "x")) + assert.Len(t, fn.invoked(), 1, "deleted rule") +} + +func TestDeliveryFailureRecordedOnce(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wire func(e *env, fn *fakeFunctions) + action events.ActionDetails + want string + }{ + {"function error", func(e *env, fn *fakeFunctions) { + fn.err = assert.AnError + e.backend.SetFunctionInvoker(fn) + }, faasAction(), assert.AnError.Error()}, + {"no notifier", func(*env, *fakeFunctions) {}, onsAction(), "not available"}, + {"no streamer", func(*env, *fakeFunctions) {}, ossAction(), "not available"}, + {"no invoker", func(*env, *fakeFunctions) {}, faasAction(), "not available"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + fn := &fakeFunctions{} + tt.wire(e, fn) + e.createRule(t, "r", matchAll, tt.action) + + e.backend.Emit(eventAt(testCompartment, "a.b", "x")) + + ds := e.backend.Deliveries() + require.Len(t, ds, 1) + assert.Equal(t, "FAILED", ds[0].Status) + assert.Contains(t, ds[0].Error, tt.want) + assert.LessOrEqual(t, len(fn.invoked()), 1, "no retry") + + status, body := rawDo(t, http.MethodGet, e.base+"/_gopherstack/oci/events/deliveries", "") + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, "FAILED") + }) + } +} + +type pipeline struct { + *env + objects *ociobjectstorage.InMemoryBackend + ons *ocinotifications.InMemoryBackend + streams *ocistreaming.InMemoryBackend + fn *fakeFunctions + sink chan string + topic string + stream string +} + +func newPipeline(t *testing.T) *pipeline { + t.Helper() + + p := &pipeline{env: newEnv(t, envOpts{}), sink: make(chan string, 16), fn: &fakeFunctions{}} + + p.objects = ociobjectstorage.NewInMemoryBackend() + p.objects.SetClock(p.clock.Now) + p.objects.SetObjectEventSink(func(e ociobjectstorage.ObjectEvent) { + p.backend.Emit(ocievents.Event{ + Time: e.Time, EventType: e.EventType, Source: "objectstorage", CompartmentID: e.CompartmentID, + ResourceName: e.ObjectName, AdditionalDetails: map[string]any{ + "eTag": e.ETag, "namespace": e.Namespace, "archivalState": nil, "bucketName": e.BucketName, + "bucketId": e.BucketID, + }, + }) + }) + + p.ons = ocinotifications.NewInMemoryBackend() + p.ons.SetClock(p.clock.Now) + p.ons.SetTransition(0) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 1<<16) + n, _ := r.Body.Read(buf) + p.sink <- r.Header.Get("X-Oci-Ns-Messagetype") + "|" + string(buf[:n]) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + topic, err := p.ons.CreateTopic(ocinotifications.CreateTopicIn{Name: "ev", Compartment: testCompartment}, "") + require.NoError(t, err) + + p.topic = topic.TopicID + + sub, err := p.ons.CreateSubscription(ocinotifications.CreateSubscriptionIn{ + TopicID: p.topic, Compartment: testCompartment, Protocol: "HTTPS", Endpoint: srv.URL, + }, "http://localhost") + require.NoError(t, err) + + conf := p.ons.Deliveries("", sub.ID) + require.NotEmpty(t, conf) + + u, err := url.Parse(conf[0].ConfirmationURL) + require.NoError(t, err) + + _, err = p.ons.ConfirmSubscription(sub.ID, u.Query().Get("token"), "HTTPS") + require.NoError(t, err) + + p.streams = ocistreaming.NewInMemoryBackend() + p.streams.SetClock(p.clock.Now) + p.streams.SetTransition(0) + + st, _, _, err := p.streams.CreateStream(ocistreaming.CreateStreamIn{ + Name: "ev", Compartment: testCompartment, Partitions: new(1), + }, "http://localhost") + require.NoError(t, err) + + p.stream = st.ID + p.streams.Sweep() + + p.backend.SetNotifier(notifier{p.ons}) + p.backend.SetStreamer(streamer{p.streams}) + p.backend.SetFunctionInvoker(p.fn) + + return p +} + +func (p *pipeline) bucket(t *testing.T, name string, events *bool) { + t.Helper() + + _, err := p.objects.CreateBucket(ociobjectstorage.DefaultNamespace, ociobjectstorage.CreateBucketInput{ + Name: name, CompartmentID: testCompartment, ObjectEventsEnabled: events, + }, "") + require.NoError(t, err) +} + +func (p *pipeline) put(t *testing.T, bucket, name string) { + t.Helper() + + _, err := p.objects.PutObject(ociobjectstorage.DefaultNamespace, bucket, name, []byte("data"), + ociobjectstorage.ObjectMeta{}, ociobjectstorage.Preconds{}, "") + require.NoError(t, err) +} + +func (p *pipeline) streamed(t *testing.T) []map[string]any { + t.Helper() + + cur, err := p.streams.CreateCursor(p.stream, ocistreaming.CreateCursorIn{Type: "TRIM_HORIZON", Partition: "0"}) + require.NoError(t, err) + + msgs, _, err := p.streams.GetMessages(p.stream, cur.Value, 100) + require.NoError(t, err) + + out := make([]map[string]any, 0, len(msgs)) + + for _, m := range msgs { + var env map[string]any + require.NoError(t, json.Unmarshal(m.Value, &env)) + + out = append(out, env) + } + + return out +} + +func TestObjectEventsDelivered(t *testing.T) { + t.Parallel() + + p := newPipeline(t) + p.bucket(t, "on", new(true)) + p.bucket(t, "off", nil) + + p.createRule( + t, + "all", + `{"eventType":"com.oraclecloud.objectstorage.*object","data":{"additionalDetails":{"bucketName":"on"}}}`, + events.CreateNotificationServiceActionDetails{IsEnabled: new(true), TopicId: &p.topic}, + events.CreateStreamingServiceActionDetails{IsEnabled: new(true), StreamId: &p.stream}, + faasAction(), + ) + + p.put(t, "off", "ignored") + assert.Empty(t, p.backend.Deliveries(), "bucket without objectEventsEnabled emits nothing") + + p.put(t, "on", "v1/log.txt") + p.put(t, "on", "v1/log.txt") + + _, err := p.objects.DeleteObject(ociobjectstorage.DefaultNamespace, "on", "v1/log.txt", "", "") + require.NoError(t, err) + + wantTypes := []string{ + "com.oraclecloud.objectstorage.createobject", "com.oraclecloud.objectstorage.updateobject", + "com.oraclecloud.objectstorage.deleteobject", + } + + streamed := p.streamed(t) + require.Len(t, streamed, 3) + + invoked := p.fn.invoked() + require.Len(t, invoked, 3) + + for i, want := range wantTypes { + assert.Equal(t, want, streamed[i]["eventType"]) + + data, ok := streamed[i]["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "v1/log.txt", data["resourceName"]) + assert.Equal(t, testCompartment, data["compartmentId"]) + assert.Equal(t, "all", data["availabilityDomain"]) + + details, ok := data["additionalDetails"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "on", details["bucketName"]) + assert.Equal(t, ociobjectstorage.DefaultNamespace, details["namespace"]) + + var fromFn map[string]any + require.NoError(t, json.Unmarshal([]byte(invoked[i]), &fromFn)) + assert.Equal(t, streamed[i]["eventID"], fromFn["eventID"]) + } + + for _, d := range p.backend.Deliveries() { + assert.Equal(t, "DELIVERED", d.Status, d.Error) + } + + p.ons.DeliverDue(t.Context(), func(fn func()) { fn() }) + + got := make([]string, 0, 3) + for range 3 { + got = append(got, <-p.sink) + } + + for i, want := range wantTypes { + assert.Contains(t, got[i], "Notification|") + assert.Contains(t, got[i], want) + } +} + +func TestActionTargetMustExist(t *testing.T) { + t.Parallel() + + p := newPipeline(t) + + tests := []struct { + action events.ActionDetails + name string + }{ + { + name: "topic", + action: events.CreateNotificationServiceActionDetails{ + IsEnabled: new(true), + TopicId: new("ocid1.onstopic.oc1..nope"), + }, + }, + { + name: "stream", + action: events.CreateStreamingServiceActionDetails{ + IsEnabled: new(true), + StreamId: new("ocid1.stream.oc1..nope"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := p.client.CreateRule(t.Context(), events.CreateRuleRequest{ + DisplayName: new( + "x", + ), + IsEnabled: new(true), + Condition: new(matchAll), + CompartmentId: new(testCompartment), + Actions: &events.ActionDetailsList{Actions: []events.ActionDetails{tt.action}}, + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + created := e.createRule(t, "persist", `{"eventType":"a.b"}`, faasAction()) + + snap := e.handler.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + other := ocievents.NewInMemoryBackend() + other.SetClock(e.clock.Now) + other.SetDispatcher(func(fn func()) { fn() }) + + fn := &fakeFunctions{} + other.SetFunctionInvoker(fn) + require.NoError(t, ocievents.NewHandler(other).Restore(t.Context(), snap)) + + got, _, err := other.GetRule(*created.Id) + require.NoError(t, err) + assert.Equal(t, "persist", got.DisplayName) + assert.JSONEq(t, `{"eventType":"a.b"}`, got.Condition) + + other.Emit(eventAt(testCompartment, "a.b", "x")) + assert.Len(t, fn.invoked(), 1) + + require.Error(t, ocievents.NewHandler(other).Restore(t.Context(), []byte(`{"rules":{"x":null},"version":1}`))) +} diff --git a/services/ocievents/emit.go b/services/ocievents/emit.go new file mode 100644 index 0000000000..b126f77024 --- /dev/null +++ b/services/ocievents/emit.go @@ -0,0 +1,257 @@ +package ocievents + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" +) + +// Delivery statuses. +const ( + deliveryPending = "PENDING" + deliveryDelivered = "DELIVERED" + deliveryFailed = "FAILED" +) + +const ( + cloudEventsVersion = "0.1" + eventTypeVersion = "2.0" + deliveryTimeout = 30 * time.Second + eventTimeLayout = "2006-01-02T15:04:05.000Z" +) + +var errNoTarget = errors.New("the target service is not available") + +// Event is what a service reports; the backend wraps it in the OCI event envelope. +type Event struct { + Time time.Time + AdditionalDetails map[string]any + EventType string + Source string + CompartmentID string + ResourceName string + ResourceID string + AvailabilityDomain string +} + +type eventData struct { + AdditionalDetails map[string]any `json:"additionalDetails"` + CompartmentID string `json:"compartmentId"` + ResourceName string `json:"resourceName"` + ResourceID string `json:"resourceId"` + AvailabilityDomain string `json:"availabilityDomain"` +} + +type envelope struct { + Extensions map[string]string `json:"extensions"` + Data eventData `json:"data"` + EventType string `json:"eventType"` + CloudEventsVersion string `json:"cloudEventsVersion"` + EventTypeVersion string `json:"eventTypeVersion"` + Source string `json:"source"` + EventTime string `json:"eventTime"` + ContentType string `json:"contentType"` + EventID string `json:"eventID"` +} + +// Delivery records one attempt to hand an event to a rule action; failed attempts are not retried. +type Delivery struct { + Time time.Time `json:"time"` + EventID string `json:"eventId"` + EventType string `json:"eventType"` + RuleID string `json:"ruleId"` + ActionID string `json:"actionId"` + ActionType string `json:"actionType"` + TargetID string `json:"targetId"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +type job struct { + rec *Delivery + ruleID string + action Action + body []byte +} + +func buildEnvelope(ev Event, id string) ([]byte, map[string]any, error) { + details := ev.AdditionalDetails + if details == nil { + details = map[string]any{} + } + + ad := ev.AvailabilityDomain + if ad == "" { + ad = "all" + } + + raw, err := json.Marshal(envelope{ + EventType: ev.EventType, CloudEventsVersion: cloudEventsVersion, EventTypeVersion: eventTypeVersion, + Source: ev.Source, EventTime: ev.Time.UTC().Format(eventTimeLayout), ContentType: "application/json", + EventID: id, Extensions: map[string]string{"compartmentId": ev.CompartmentID}, + Data: eventData{ + CompartmentID: ev.CompartmentID, ResourceName: ev.ResourceName, ResourceID: ev.ResourceID, + AvailabilityDomain: ad, AdditionalDetails: details, + }, + }) + if err != nil { + return nil, nil, err //nolint:wrapcheck // plain marshal failure + } + + var generic map[string]any + if err = json.Unmarshal(raw, &generic); err != nil { + return nil, nil, err //nolint:wrapcheck // plain unmarshal failure + } + + return raw, generic, nil +} + +func targetOf(a *Action) string { + switch a.ActionType { + case actionONS: + return a.TopicID + case actionOSS: + return a.StreamID + default: + return a.FunctionID + } +} + +// Emit matches an event against every enabled rule that covers its compartment and delivers it to the +// enabled actions of each matching rule. +func (b *InMemoryBackend) Emit(ev Event) { + if ev.Time.IsZero() { + ev.Time = b.clockNow() + } + + id := uuid.NewString() + + raw, generic, err := buildEnvelope(ev, id) + if err != nil { + return + } + + jobs, spawn := b.plan(ev, id, raw, generic) + + for _, j := range jobs { + spawn(func() { b.finish(j.rec, b.deliver(j)) }) + } +} + +func (b *InMemoryBackend) clockNow() time.Time { + b.mu.RLock("clockNow") + defer b.mu.RUnlock() + + return b.nowUTC() +} + +func (b *InMemoryBackend) plan(ev Event, id string, raw []byte, generic map[string]any) ([]job, func(func())) { + b.mu.Lock("Emit") + defer b.mu.Unlock() + + var jobs []job + + for _, r := range b.sortedRules() { + if r.State != stateActive || !r.IsEnabled || !b.inScope(ev.CompartmentID, r.CompartmentID) { + continue + } + + cond, perr := parseCondition(r.Condition) + if perr != nil || !matchCondition(cond, generic) { + continue + } + + for _, a := range r.Actions { + if !a.IsEnabled { + continue + } + + rec := &Delivery{ + Time: b.nowUTC(), EventID: id, EventType: ev.EventType, RuleID: r.ID, ActionID: a.ID, + ActionType: a.ActionType, TargetID: targetOf(a), Status: deliveryPending, + } + b.deliveries = append(b.deliveries, rec) + jobs = append(jobs, job{rec: rec, action: *a, body: raw, ruleID: r.ID}) + } + } + + if over := len(b.deliveries) - maxDeliveries; over > 0 { + b.deliveries = b.deliveries[over:] + } + + return jobs, b.spawn +} + +func (b *InMemoryBackend) sortedRules() []*Rule { + all := make([]*Rule, 0, len(b.rules)) + for _, r := range b.rules { + all = append(all, r) + } + + return paginate(all, ListOpts{Order: "ASC", Limit: len(all) + 1}).Data +} + +func (b *InMemoryBackend) targets() (Notifier, Streamer, FunctionInvoker) { + b.mu.RLock("targets") + defer b.mu.RUnlock() + + return b.notifier, b.streamer, b.invoker +} + +func (b *InMemoryBackend) deliver(j job) error { + ons, oss, faas := b.targets() + body := string(j.body) + + switch j.action.ActionType { + case actionONS: + if ons == nil { + return errNoTarget + } + + return ons.PublishEvent(j.action.TopicID, body) + case actionOSS: + if oss == nil { + return errNoTarget + } + + return oss.AppendMessage(j.action.StreamID, nil, j.body) + default: + if faas == nil { + return errNoTarget + } + + ctx, cancel := context.WithTimeout(context.Background(), deliveryTimeout) + defer cancel() + + return faas.InvokeFunction(ctx, j.action.FunctionID, j.body) + } +} + +func (b *InMemoryBackend) finish(rec *Delivery, err error) { + b.mu.Lock("finishDelivery") + defer b.mu.Unlock() + + if err != nil { + rec.Status, rec.Error = deliveryFailed, err.Error() + + return + } + + rec.Status = deliveryDelivered +} + +// Deliveries returns the delivery log, oldest first. +func (b *InMemoryBackend) Deliveries() []Delivery { + b.mu.RLock("Deliveries") + defer b.mu.RUnlock() + + out := make([]Delivery, 0, len(b.deliveries)) + for _, d := range b.deliveries { + out = append(out, *d) + } + + return out +} diff --git a/services/ocievents/errors.go b/services/ocievents/errors.go new file mode 100644 index 0000000000..ff72224ee1 --- /dev/null +++ b/services/ocievents/errors.go @@ -0,0 +1,43 @@ +package ocievents + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocievents: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errLimit(msg string) *APIError { return newErr(http.StatusBadRequest, "LimitExceeded", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } diff --git a/services/ocievents/handler.go b/services/ocievents/handler.go new file mode 100644 index 0000000000..80c8e93608 --- /dev/null +++ b/services/ocievents/handler.go @@ -0,0 +1,352 @@ +package ocievents + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Events: rules and the event delivery log. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocievents.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIEvents" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIEvents runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"ruleId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocievents: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) ruleID() string { return q.rt.params["ruleId"] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + dec := json.NewDecoder(bytes.NewReader(q.body)) + dec.UseNumber() + + if err := dec.Decode(v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocievents: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIEvents requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdmin { + h.admin(q) + + return nil + } + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// admin serves the emulator-only endpoints under /_gopherstack/: /oci/keys registers API public keys and +// /oci/events/deliveries reports the event delivery log. +func (h *Handler) admin(q *req) { + if strings.HasSuffix(q.r.URL.Path, "/events/deliveries") && q.r.Method == http.MethodGet { + _ = q.json(http.StatusOK, h.Backend.Deliveries(), "") + + return + } + + h.adminKeys(q) +} + +// adminKeys registers API public keys; once registered, requests signed with that keyId are verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocievents: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIEvents", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ocievents", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocievents: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocievents: listener stopped", "error", serveErr) + } + }) + + return nil +} + +// Shutdown stops the listener and waits for in-flight deliveries. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocievents: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocievents: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() + h.Backend.Wait() +} diff --git a/services/ocievents/helpers_test.go b/services/ocievents/helpers_test.go new file mode 100644 index 0000000000..ae76c111db --- /dev/null +++ b/services/ocievents/helpers_test.go @@ -0,0 +1,197 @@ +package ocievents_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/events" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + ocievents "github.com/blackbirdworks/gopherstack/services/ocievents" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + otherCompart = "ocid1.compartment.oc1..other" + testTopic = "ocid1.onstopic.oc1.iad.topic1" + testStream = "ocid1.stream.oc1.iad.stream1" + testFunction = "ocid1.fnfunc.oc1.iad.fn1" + matchAll = "{}" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UTC().Truncate(time.Second).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *ocievents.InMemoryBackend + handler *ocievents.Handler + clock *fakeClock + client events.EventsClient + base string +} + +type envOpts struct { + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := ocievents.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + be.SetDispatcher(func(fn func()) { fn() }) + + h := ocievents.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + prov := common.NewRawConfigurationProvider(testTenancy, testUser, ocievents.Region, kp.fprint, kp.privPEM, nil) + e := &env{backend: be, handler: h, clock: clock, base: fmt.Sprintf("http://localhost:%d", h.Port)} + + var err error + + e.client, err = events.NewEventsClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.client.Host = e.base + + return e +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +func onsAction() events.CreateNotificationServiceActionDetails { + return events.CreateNotificationServiceActionDetails{IsEnabled: new(true), TopicId: new(testTopic)} +} + +func ossAction() events.CreateStreamingServiceActionDetails { + return events.CreateStreamingServiceActionDetails{IsEnabled: new(true), StreamId: new(testStream)} +} + +func faasAction() events.CreateFaaSActionDetails { + return events.CreateFaaSActionDetails{IsEnabled: new(true), FunctionId: new(testFunction)} +} + +func (e *env) createRule(t *testing.T, name, condition string, acts ...events.ActionDetails) events.CreateRuleResponse { + t.Helper() + + resp, err := e.client.CreateRule(t.Context(), events.CreateRuleRequest{ + DisplayName: new(name), IsEnabled: new(true), Condition: new(condition), CompartmentId: new(testCompartment), + Actions: &events.ActionDetailsList{Actions: acts}}) + require.NoError(t, err) + + return resp +} + +// fakeFunctions records invocations and fails them on demand. +type fakeFunctions struct { + err error + bodies []string + mu sync.Mutex +} + +func (f *fakeFunctions) FunctionActive(string) bool { return true } + +func (f *fakeFunctions) InvokeFunction(_ context.Context, _ string, body []byte) error { + f.mu.Lock() + defer f.mu.Unlock() + + f.bodies = append(f.bodies, string(body)) + + return f.err +} + +func (f *fakeFunctions) invoked() []string { + f.mu.Lock() + defer f.mu.Unlock() + + return append([]string(nil), f.bodies...) +} diff --git a/services/ocievents/inproc.go b/services/ocievents/inproc.go new file mode 100644 index 0000000000..c57dade3a9 --- /dev/null +++ b/services/ocievents/inproc.go @@ -0,0 +1,26 @@ +package ocievents + +import "slices" + +// Compartments lists the distinct compartments that hold rules. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, r := range b.rules { + if r.State != stateDeleted { + seen[r.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} diff --git a/services/ocievents/leak_main_test.go b/services/ocievents/leak_main_test.go new file mode 100644 index 0000000000..98abfcbdb1 --- /dev/null +++ b/services/ocievents/leak_main_test.go @@ -0,0 +1,18 @@ +package ocievents_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ocievents/models.go b/services/ocievents/models.go new file mode 100644 index 0000000000..4569f4315d --- /dev/null +++ b/services/ocievents/models.go @@ -0,0 +1,117 @@ +package ocievents + +import "time" + +// Rule lifecycle states. +const ( + stateActive = "ACTIVE" + stateInactive = "INACTIVE" + stateDeleted = "DELETED" +) + +// Action types. +const ( + actionONS = "ONS" + actionOSS = "OSS" + actionFAAS = "FAAS" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// Action is one destination of a rule. +type Action struct { + ID string `json:"id"` + ActionType string `json:"actionType"` + State string `json:"lifecycleState"` + Message string `json:"lifecycleMessage"` + Description string `json:"description,omitempty"` + TopicID string `json:"topicId,omitempty"` + StreamID string `json:"streamId,omitempty"` + FunctionID string `json:"functionId,omitempty"` + IsEnabled bool `json:"isEnabled"` +} + +// ActionList is the wire wrapper of a rule's actions. +type ActionList struct { + Actions []*Action `json:"actions"` +} + +// Rule is an Events rule. +type Rule struct { + TimeCreated time.Time `json:"timeCreated"` + Tags Tags `json:"tags"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + Condition string `json:"condition"` + CompartmentID string `json:"compartmentId"` + State string `json:"state"` + Etag string `json:"etag"` + Actions []*Action `json:"actions"` + Seq int64 `json:"seq"` + IsEnabled bool `json:"isEnabled"` +} + +// RuleWire is the wire form of a rule. +type RuleWire struct { + TimeCreated time.Time `json:"timeCreated"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + Condition string `json:"condition"` + CompartmentID string `json:"compartmentId"` + LifecycleState string `json:"lifecycleState"` + LifecycleMessage string `json:"lifecycleMessage"` + Actions ActionList `json:"actions"` + IsEnabled bool `json:"isEnabled"` +} + +// RuleSummaryWire is the wire form of a rule in a list. +type RuleSummaryWire struct { + TimeCreated time.Time `json:"timeCreated"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + Condition string `json:"condition"` + CompartmentID string `json:"compartmentId"` + LifecycleState string `json:"lifecycleState"` + IsEnabled bool `json:"isEnabled"` +} + +func ruleView(r *Rule) RuleWire { + acts := make([]*Action, len(r.Actions)) + copy(acts, r.Actions) + + return RuleWire{ + ID: r.ID, DisplayName: r.DisplayName, Description: r.Description, Condition: r.Condition, + CompartmentID: r.CompartmentID, LifecycleState: r.State, IsEnabled: r.IsEnabled, + Actions: ActionList{Actions: acts}, TimeCreated: r.TimeCreated, + FreeformTags: r.Tags.FreeformTags, DefinedTags: r.Tags.DefinedTags, + } +} + +func summaryView(r *Rule) RuleSummaryWire { + return RuleSummaryWire{ + ID: r.ID, DisplayName: r.DisplayName, Description: r.Description, Condition: r.Condition, + CompartmentID: r.CompartmentID, LifecycleState: r.State, IsEnabled: r.IsEnabled, + TimeCreated: r.TimeCreated, FreeformTags: r.Tags.FreeformTags, DefinedTags: r.Tags.DefinedTags, + } +} diff --git a/services/ocievents/ops.go b/services/ocievents/ops.go new file mode 100644 index 0000000000..1d2361bb0a --- /dev/null +++ b/services/ocievents/ops.go @@ -0,0 +1,87 @@ +package ocievents + +import ( + "net/http" +) + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateRule: (*Handler).createRule, opGetRule: (*Handler).getRule, opListRules: (*Handler).listRules, + opUpdateRule: (*Handler).updateRule, opDeleteRule: (*Handler).deleteRule, + opChangeRuleCompart: (*Handler).changeRuleCompartment, + } +} + +func (h *Handler) createRule(q *req) error { + var in createRuleIn + if err := q.decode(&in); err != nil { + return err + } + + w, etag, err := h.Backend.CreateRule(in) + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func (h *Handler) getRule(q *req) error { + w, etag, err := h.Backend.GetRule(q.ruleID()) + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func (h *Handler) listRules(q *req) error { + pg, err := h.Backend.ListRules(q.query("compartmentId"), q.query("displayName"), q.query("lifecycleState"), + q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, pg.Data, "") +} + +func (h *Handler) updateRule(q *req) error { + var in updateRuleIn + if err := q.decode(&in); err != nil { + return err + } + + w, etag, err := h.Backend.UpdateRule(q.ruleID(), q.ifMatch(), in) + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func (h *Handler) deleteRule(q *req) error { + if err := h.Backend.DeleteRule(q.ruleID(), q.ifMatch()); err != nil { + return err + } + + q.w.WriteHeader(http.StatusNoContent) + + return nil +} + +func (h *Handler) changeRuleCompartment(q *req) error { + var in changeCompartmentIn + if err := q.decode(&in); err != nil { + return err + } + + if err := h.Backend.ChangeRuleCompartment(q.ruleID(), q.ifMatch(), in); err != nil { + return err + } + + q.w.WriteHeader(http.StatusOK) + + return nil +} diff --git a/services/ocievents/persistence.go b/services/ocievents/persistence.go new file mode 100644 index 0000000000..07975887d0 --- /dev/null +++ b/services/ocievents/persistence.go @@ -0,0 +1,79 @@ +package ocievents + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Rules map[string]*Rule `json:"rules"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the rules and registered API keys; the delivery log is not persisted. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocievents", backendSnapshot{ + Version: snapshotVersion, Rules: b.rules, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocievents", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ocievents: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if snap.Rules == nil { + snap.Rules = map[string]*Rule{} + } + + for id, r := range snap.Rules { + if r == nil { + return fmt.Errorf("%w: rule %q", ErrSnapshotNull, id) + } + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.rules, b.seq, b.deliveries = snap.Rules, snap.Seq, nil + b.Auth.Load(snap.Auth) + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ocievents: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ocievents/provider.go b/services/ocievents/provider.go new file mode 100644 index 0000000000..80d1cd756b --- /dev/null +++ b/services/ocievents/provider.go @@ -0,0 +1,42 @@ +package ocievents + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocievents: nil app context") + +// ConfigProvider extracts OCI Events configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIEventsSettings() Settings +} + +// Provider implements service.Provider for OCI Events. It runs on its own listener +// (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIEvents" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIEventsSettings() + } + + handler := NewHandler(NewInMemoryBackend()) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ocievents/route.go b/services/ocievents/route.go new file mode 100644 index 0000000000..320d99819e --- /dev/null +++ b/services/ocievents/route.go @@ -0,0 +1,53 @@ +package ocievents + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateRule = "CreateRule" + opGetRule = "GetRule" + opListRules = "ListRules" + opUpdateRule = "UpdateRule" + opDeleteRule = "DeleteRule" + opChangeRuleCompart = "ChangeRuleCompartment" + opAdmin = "Admin" + opUnknown = "Unknown" + adminPrefix = "/_gopherstack/" + base = "/20181201" + rulePath = base + "/rules/{ruleId}" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/rules", Op: opCreateRule}, + {Method: http.MethodGet, Path: base + "/rules", Op: opListRules}, + {Method: http.MethodGet, Path: rulePath, Op: opGetRule}, + {Method: http.MethodPut, Path: rulePath, Op: opUpdateRule}, + {Method: http.MethodDelete, Path: rulePath, Op: opDeleteRule}, + {Method: http.MethodPost, Path: rulePath + "/actions/changeCompartment", Op: opChangeRuleCompart}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, adminPrefix) { + return route{op: opAdmin} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocievents/rules.go b/services/ocievents/rules.go new file mode 100644 index 0000000000..425fd65bc0 --- /dev/null +++ b/services/ocievents/rules.go @@ -0,0 +1,375 @@ +package ocievents + +import ( + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +type actionIn struct { + IsEnabled *bool `json:"isEnabled"` + Description *string `json:"description"` + TopicID *string `json:"topicId"` + StreamID *string `json:"streamId"` + FunctionID *string `json:"functionId"` + ActionType string `json:"actionType"` +} + +type actionsIn struct { + Actions []actionIn `json:"actions"` +} + +type createRuleIn struct { + Tags + IsEnabled *bool `json:"isEnabled"` + Actions *actionsIn `json:"actions"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Condition string `json:"condition"` + CompartmentID string `json:"compartmentId"` +} + +type updateRuleIn struct { + Tags + DisplayName *string `json:"displayName"` + Description *string `json:"description"` + IsEnabled *bool `json:"isEnabled"` + Condition *string `json:"condition"` + Actions *actionsIn `json:"actions"` +} + +type changeCompartmentIn struct { + CompartmentID string `json:"compartmentId"` +} + +func str(p *string) string { + if p == nil { + return "" + } + + return *p +} + +func stateFor(enabled bool) string { + if enabled { + return stateActive + } + + return stateInactive +} + +func validName(name string) *APIError { + if name == "" { + return errMissing("displayName is required") + } + + if len(name) > maxDisplayName { + return errParam("displayName must be at most 255 characters") + } + + return nil +} + +// buildAction validates one action and checks that its target exists in the wired service. +func (b *InMemoryBackend) buildAction(in actionIn) (*Action, *APIError) { + if in.IsEnabled == nil { + return nil, errMissing("every action requires isEnabled") + } + + a := &Action{ + ID: newOCID("eventaction"), ActionType: strings.ToUpper(in.ActionType), IsEnabled: *in.IsEnabled, + State: stateFor(*in.IsEnabled), Description: str(in.Description), + } + + switch a.ActionType { + case actionONS: + a.TopicID = str(in.TopicID) + if a.TopicID == "" { + return nil, errMissing("topicId is required for an ONS action") + } + + if b.notifier != nil && !b.notifier.TopicActive(a.TopicID) { + return nil, errNotFound(a.TopicID) + } + case actionOSS: + a.StreamID = str(in.StreamID) + if a.StreamID == "" { + return nil, errMissing("streamId is required for an OSS action") + } + + if b.streamer != nil && !b.streamer.StreamActive(a.StreamID) { + return nil, errNotFound(a.StreamID) + } + case actionFAAS: + a.FunctionID = str(in.FunctionID) + if a.FunctionID == "" { + return nil, errMissing("functionId is required for a FAAS action") + } + + if b.invoker != nil && !b.invoker.FunctionActive(a.FunctionID) { + return nil, errNotFound(a.FunctionID) + } + default: + return nil, errParam("actionType must be ONS, OSS or FAAS") + } + + return a, nil +} + +func (b *InMemoryBackend) buildActions(in *actionsIn) ([]*Action, *APIError) { + if in == nil || len(in.Actions) == 0 { + return nil, errMissing("a rule requires at least one action") + } + + if len(in.Actions) > maxActions { + return nil, errLimit("a rule can have at most 10 actions") + } + + out := make([]*Action, 0, len(in.Actions)) + + for _, ai := range in.Actions { + a, err := b.buildAction(ai) + if err != nil { + return nil, err + } + + out = append(out, a) + } + + return out, nil +} + +func (b *InMemoryBackend) liveRules() int { + n := 0 + + for _, r := range b.rules { + if r.State != stateDeleted { + n++ + } + } + + return n +} + +// CreateRule creates a rule. +func (b *InMemoryBackend) CreateRule(in createRuleIn) (RuleWire, string, error) { + b.mu.Lock("CreateRule") + defer b.mu.Unlock() + + if err := b.checkCompartment(in.CompartmentID); err != nil { + return RuleWire{}, "", err + } + + if err := validName(in.DisplayName); err != nil { + return RuleWire{}, "", err + } + + if in.IsEnabled == nil { + return RuleWire{}, "", errMissing("isEnabled is required") + } + + if _, err := parseCondition(in.Condition); err != nil { + return RuleWire{}, "", err + } + + acts, err := b.buildActions(in.Actions) + if err != nil { + return RuleWire{}, "", err + } + + if b.liveRules() >= maxRules { + return RuleWire{}, "", errLimit("the tenancy already has the maximum of 50 rules") + } + + r := &Rule{ + ID: newOCID("eventrule"), DisplayName: in.DisplayName, Description: in.Description, Condition: in.Condition, + CompartmentID: in.CompartmentID, IsEnabled: *in.IsEnabled, State: stateFor(*in.IsEnabled), Actions: acts, + TimeCreated: b.nowUTC(), Etag: newEtag(), Tags: in.Tags, Seq: b.nextSeq(), + } + b.rules[r.ID] = r + + return ruleView(r), r.Etag, nil +} + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func (b *InMemoryBackend) rule(id string) (*Rule, *APIError) { + r, ok := b.rules[id] + if !ok { + return nil, errNotFound(id) + } + + return r, nil +} + +func (b *InMemoryBackend) activeRule(id string) (*Rule, *APIError) { + r, err := b.rule(id) + if err != nil { + return nil, err + } + + if r.State == stateDeleted { + return nil, errNotFound(id) + } + + return r, nil +} + +// GetRule returns one rule and its etag; a deleted rule is still reported with state DELETED. +func (b *InMemoryBackend) GetRule(id string) (RuleWire, string, error) { + b.mu.RLock("GetRule") + defer b.mu.RUnlock() + + r, err := b.rule(id) + if err != nil { + return RuleWire{}, "", err + } + + return ruleView(r), r.Etag, nil +} + +// ListRules lists the rules of a compartment; DELETED rules are hidden unless filtered for. +func (b *InMemoryBackend) ListRules(compartment, name, state string, o ListOpts) (page.Page[RuleSummaryWire], error) { + if compartment == "" { + return page.Page[RuleSummaryWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListRules") + defer b.mu.RUnlock() + + var all []*Rule + + for _, r := range b.rules { + stateOK := (state == "" && r.State != stateDeleted) || (state != "" && strings.EqualFold(r.State, state)) + if r.CompartmentID == compartment && stateOK && (name == "" || strs.Equal(r.DisplayName, name)) { + all = append(all, r) + } + } + + pg := paginate(all, o) + out := make([]RuleSummaryWire, 0, len(pg.Data)) + + for _, r := range pg.Data { + out = append(out, summaryView(r)) + } + + return page.Page[RuleSummaryWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateRule changes the mutable attributes of a rule. +func (b *InMemoryBackend) UpdateRule(id, ifMatch string, in updateRuleIn) (RuleWire, string, error) { + b.mu.Lock("UpdateRule") + defer b.mu.Unlock() + + r, err := b.activeRule(id) + if err != nil { + return RuleWire{}, "", err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return RuleWire{}, "", err + } + + if err = b.applyUpdate(r, in); err != nil { + return RuleWire{}, "", err + } + + r.Etag = newEtag() + + return ruleView(r), r.Etag, nil +} + +func (b *InMemoryBackend) applyUpdate(r *Rule, in updateRuleIn) *APIError { + if in.DisplayName != nil { + if err := validName(*in.DisplayName); err != nil { + return err + } + } + + if in.Condition != nil { + if _, err := parseCondition(*in.Condition); err != nil { + return err + } + } + + var acts []*Action + + if in.Actions != nil { + var err *APIError + + if acts, err = b.buildActions(in.Actions); err != nil { + return err + } + } + + if in.DisplayName != nil { + r.DisplayName = *in.DisplayName + } + + if in.Description != nil { + r.Description = *in.Description + } + + if in.Condition != nil { + r.Condition = *in.Condition + } + + if in.IsEnabled != nil { + r.IsEnabled, r.State = *in.IsEnabled, stateFor(*in.IsEnabled) + } + + if acts != nil { + r.Actions = acts + } + + r.Tags.merge(in.Tags) + + return nil +} + +// DeleteRule deletes a rule; it stays readable with state DELETED. +func (b *InMemoryBackend) DeleteRule(id, ifMatch string) error { + b.mu.Lock("DeleteRule") + defer b.mu.Unlock() + + r, err := b.activeRule(id) + if err != nil { + return err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return err + } + + r.State, r.IsEnabled = stateDeleted, false + + return nil +} + +// ChangeRuleCompartment moves a rule. +func (b *InMemoryBackend) ChangeRuleCompartment(id, ifMatch string, in changeCompartmentIn) error { + b.mu.Lock("ChangeRuleCompartment") + defer b.mu.Unlock() + + r, err := b.activeRule(id) + if err != nil { + return err + } + + if err = checkEtag(r.Etag, ifMatch); err != nil { + return err + } + + if err = b.checkCompartment(in.CompartmentID); err != nil { + return err + } + + r.CompartmentID, r.Etag = in.CompartmentID, newEtag() + + return nil +} diff --git a/services/ocievents/rules_test.go b/services/ocievents/rules_test.go new file mode 100644 index 0000000000..d9e05e0808 --- /dev/null +++ b/services/ocievents/rules_test.go @@ -0,0 +1,292 @@ +package ocievents_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRuleLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + created := e.createRule(t, "r1", `{"eventType":"com.oraclecloud.objectstorage.createobject"}`, + onsAction(), ossAction(), faasAction()) + + require.NotNil(t, created.Etag) + assert.Equal(t, events.RuleLifecycleStateActive, created.LifecycleState) + require.Len(t, created.Actions.Actions, 3) + + ons, ok := created.Actions.Actions[0].(events.NotificationServiceAction) + require.True(t, ok) + assert.Equal(t, testTopic, *ons.TopicId) + assert.NotEmpty(t, *ons.Id) + + id := created.Id + + got, err := e.client.GetRule(t.Context(), events.GetRuleRequest{RuleId: id}) + require.NoError(t, err) + assert.Equal(t, "r1", *got.DisplayName) + assert.Equal(t, testCompartment, *got.CompartmentId) + + upd, err := e.client.UpdateRule(t.Context(), events.UpdateRuleRequest{ + RuleId: id, IfMatch: created.Etag, + IsEnabled: new(false), DisplayName: new("r2"), Actions: &events.ActionDetailsList{ + Actions: []events.ActionDetails{faasAction()}, + }, + }) + require.NoError(t, err) + assert.Equal(t, events.RuleLifecycleStateInactive, upd.LifecycleState) + assert.Equal(t, "r2", *upd.DisplayName) + assert.Len(t, upd.Actions.Actions, 1) + + _, err = e.client.UpdateRule(t.Context(), events.UpdateRuleRequest{ + RuleId: id, IfMatch: created.Etag, Description: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.ChangeRuleCompartment(t.Context(), events.ChangeRuleCompartmentRequest{ + RuleId: id, CompartmentId: new(otherCompart), + }) + require.NoError(t, err) + + moved, err := e.client.GetRule(t.Context(), events.GetRuleRequest{RuleId: id}) + require.NoError(t, err) + assert.Equal(t, otherCompart, *moved.CompartmentId) + + _, err = e.client.DeleteRule(t.Context(), events.DeleteRuleRequest{RuleId: id}) + require.NoError(t, err) + + gone, err := e.client.GetRule(t.Context(), events.GetRuleRequest{RuleId: id}) + require.NoError(t, err) + assert.Equal(t, events.RuleLifecycleStateDeleted, gone.LifecycleState) + + _, err = e.client.DeleteRule(t.Context(), events.DeleteRuleRequest{RuleId: id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.GetRule(t.Context(), events.GetRuleRequest{RuleId: new("ocid1.eventrule.oc1..nope")}) + assert.Equal(t, "NotAuthorizedOrNotFound", svcErr(t, err).GetCode()) +} + +func TestListRules(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + for _, n := range []string{"b", "a", "c"} { + e.createRule(t, n, matchAll, onsAction()) + e.clock.Advance(1e9) + } + + tests := []struct { + name string + req events.ListRulesRequest + want []string + }{ + {"default newest first", events.ListRulesRequest{}, []string{"c", "a", "b"}}, + {"by name", events.ListRulesRequest{ + SortBy: events.ListRulesSortByDisplayName, SortOrder: events.ListRulesSortOrderAsc, + }, []string{"a", "b", "c"}}, + {"name filter", events.ListRulesRequest{DisplayName: new("A")}, []string{"a"}}, + {"inactive", events.ListRulesRequest{LifecycleState: events.RuleLifecycleStateInactive}, nil}, + {"limit", events.ListRulesRequest{Limit: new(2)}, []string{"c", "a"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.req.CompartmentId = new(testCompartment) + + resp, err := e.client.ListRules(t.Context(), tt.req) + require.NoError(t, err) + + var names []string + for _, r := range resp.Items { + names = append(names, *r.DisplayName) + } + + assert.Equal(t, tt.want, names) + }) + } + + first, err := e.client.ListRules( + t.Context(), + events.ListRulesRequest{CompartmentId: new(testCompartment), Limit: new(2)}, + ) + require.NoError(t, err) + require.NotNil(t, first.OpcNextPage) + + rest, err := e.client.ListRules(t.Context(), events.ListRulesRequest{ + CompartmentId: new(testCompartment), Limit: new(2), Page: first.OpcNextPage, + }) + require.NoError(t, err) + assert.Len(t, rest.Items, 1) + + status, body := rawDo(t, http.MethodGet, e.base+"/20181201/rules", "") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") +} + +func TestConditionValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + condition string + wantCode string + }{ + {"empty object", `{}`, ""}, + {"event type string", `{"eventType":"com.oraclecloud.objectstorage.createobject"}`, ""}, + {"event type list", `{"eventType":["a.b","c.d"]}`, ""}, + {"wildcard value", `{"eventType":"com.oraclecloud.objectstorage.*bucket"}`, ""}, + {"nested data", `{"data":{"additionalDetails":{"bucketName":["x","y*"]}}}`, ""}, + {"number and bool", `{"data":{"a":1,"b":true}}`, ""}, + {"blank", ``, "MissingParameter"}, + {"not json", `{eventType`, "InvalidParameter"}, + {"array", `["a"]`, "InvalidParameter"}, + {"string", `"a"`, "InvalidParameter"}, + {"trailing data", `{} {}`, "InvalidParameter"}, + {"empty array", `{"eventType":[]}`, "InvalidParameter"}, + {"null value", `{"eventType":null}`, "InvalidParameter"}, + {"nested array", `{"eventType":[["a"]]}`, "InvalidParameter"}, + {"object in array", `{"eventType":[{"a":"b"}]}`, "InvalidParameter"}, + {"wildcard key", `{"data":{"res*":"x"}}`, "InvalidParameter"}, + } + + e := newEnv(t, envOpts{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.client.CreateRule(t.Context(), events.CreateRuleRequest{ + DisplayName: new("c"), IsEnabled: new(true), Condition: new(tt.condition), + CompartmentId: new(testCompartment), + Actions: &events.ActionDetailsList{Actions: []events.ActionDetails{onsAction()}}}) + if tt.wantCode == "" { + require.NoError(t, err) + + return + } + + se := svcErr(t, err) + assert.Equal(t, tt.wantCode, se.GetCode()) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + }) + } +} + +func validRuleBody() map[string]any { + return map[string]any{ + "displayName": "n", "isEnabled": true, "condition": "{}", "compartmentId": "c", + "actions": map[string]any{"actions": []any{ + map[string]any{"actionType": "ONS", "isEnabled": true, "topicId": "t"}, + }}, + } +} + +func setActions(m map[string]any, acts ...map[string]any) { + list := make([]any, 0, len(acts)) + for _, a := range acts { + list = append(list, a) + } + + m["actions"] = map[string]any{"actions": list} +} + +func TestCreateRuleValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mutate func(m map[string]any) + name string + wantCode string + }{ + {func(m map[string]any) { setActions(m) }, "no actions", "MissingParameter"}, + {func(m map[string]any) { delete(m, "actions") }, "actions absent", "MissingParameter"}, + {func(m map[string]any) { + acts := make([]map[string]any, 11) + for i := range acts { + acts[i] = map[string]any{"actionType": "ONS", "isEnabled": true, "topicId": "t"} + } + + setActions(m, acts...) + }, "eleven actions", "LimitExceeded"}, + {func(m map[string]any) { + setActions(m, map[string]any{"actionType": "SMS", "isEnabled": true}) + }, "bad type", "InvalidParameter"}, + {func(m map[string]any) { + setActions(m, map[string]any{"actionType": "ONS", "isEnabled": true}) + }, "ons without topic", "MissingParameter"}, + {func(m map[string]any) { + setActions(m, map[string]any{"actionType": "OSS", "isEnabled": true}) + }, "oss without stream", "MissingParameter"}, + {func(m map[string]any) { + setActions(m, map[string]any{"actionType": "FAAS", "isEnabled": true}) + }, "faas without function", "MissingParameter"}, + {func(m map[string]any) { + setActions(m, map[string]any{"actionType": "ONS", "topicId": "t"}) + }, "action without isEnabled", "MissingParameter"}, + {func(m map[string]any) { delete(m, "displayName") }, "no name", "MissingParameter"}, + {func(m map[string]any) { delete(m, "isEnabled") }, "no isEnabled", "MissingParameter"}, + {func(m map[string]any) { delete(m, "compartmentId") }, "no compartment", "MissingParameter"}, + {func(m map[string]any) { m["condition"] = "" }, "blank condition", "MissingParameter"}, + } + + e := newEnv(t, envOpts{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + m := validRuleBody() + tt.mutate(m) + + raw, err := json.Marshal(m) + require.NoError(t, err) + + status, body := rawDo(t, http.MethodPost, e.base+"/20181201/rules", string(raw)) + assert.Equal(t, http.StatusBadRequest, status, body) + assert.Contains(t, body, fmt.Sprintf("%q", tt.wantCode)) + }) + } + + status, body := rawDo(t, http.MethodPost, e.base+"/20181201/rules", "nope") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "CannotParseRequest") +} + +func TestRuleLimit(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + for i := range 50 { + e.createRule(t, fmt.Sprintf("r%d", i), matchAll, onsAction()) + } + + _, err := e.client.CreateRule(t.Context(), events.CreateRuleRequest{ + DisplayName: new("extra"), IsEnabled: new(true), Condition: new(matchAll), CompartmentId: new(testCompartment), + Actions: &events.ActionDetailsList{Actions: []events.ActionDetails{onsAction()}}}) + assert.Equal(t, "LimitExceeded", svcErr(t, err).GetCode()) +} + +func TestAuthEnforced(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true}) + + status, _ := rawDo(t, http.MethodGet, e.base+"/20181201/rules?compartmentId=c", "") + assert.Equal(t, http.StatusUnauthorized, status) + + _, err := e.client.ListRules(t.Context(), events.ListRulesRequest{CompartmentId: new(testCompartment)}) + assert.Equal(t, http.StatusUnauthorized, svcErr(t, err).GetHTTPStatusCode()) + + reg := newEnv(t, envOpts{enforce: true, register: true}) + _, err = reg.client.ListRules(t.Context(), events.ListRulesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) +} diff --git a/services/ocievents/settings.go b/services/ocievents/settings.go new file mode 100644 index 0000000000..9e206d60a5 --- /dev/null +++ b/services/ocievents/settings.go @@ -0,0 +1,13 @@ +package ocievents + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8164 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIEvents. +type Settings struct { + Port int `json:"port" env:"OCI_EVENTS_PORT" default:"8164" name:"port" help:"Fixed TCP port for the dedicated OCI Events listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_EVENTS_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned control-plane requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ocievents/store.go b/services/ocievents/store.go new file mode 100644 index 0000000000..b59741c521 --- /dev/null +++ b/services/ocievents/store.go @@ -0,0 +1,207 @@ +package ocievents + +import ( + "cmp" + "context" + "slices" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "us-ashburn-1" + defaultListLimit = 100 + ocidTailLen = 52 + maxActions = 10 + maxRules = 50 + maxDisplayName = 255 + maxDeliveries = 1000 +) + +// Notifier publishes an event message to an ONS topic; wired to OCI Notifications in internal/app. +type Notifier interface { + TopicActive(id string) bool + PublishEvent(topicID, body string) error +} + +// Streamer appends an event message to a stream; wired to OCI Streaming in internal/app. +type Streamer interface { + StreamActive(id string) bool + AppendMessage(streamID string, key, value []byte) error +} + +// FunctionInvoker invokes a function with an event as its payload; wired to OCI Functions in internal/app. +type FunctionInvoker interface { + FunctionActive(id string) bool + InvokeFunction(ctx context.Context, functionID string, body []byte) error +} + +// InMemoryBackend holds every rule under one lock. +type InMemoryBackend struct { + notifier Notifier + invoker FunctionInvoker + streamer Streamer + within func(id, ancestor string) bool + Auth *ociauth.Registry + compartment func(string) bool + mu *lockmetrics.RWMutex + rules map[string]*Rule + now func() time.Time + spawn func(func()) + deliveries []*Delivery + wg sync.WaitGroup + seq int64 +} + +// NewInMemoryBackend creates an empty backend that delivers events on goroutines tracked by Wait. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{mu: lockmetrics.New("ocievents"), now: time.Now, Auth: ociauth.NewRegistry()} + b.rules = map[string]*Rule{} + b.spawn = func(fn func()) { b.wg.Go(fn) } + + return b +} + +// Wait blocks until every in-flight delivery has finished. +func (b *InMemoryBackend) Wait() { b.wg.Wait() } + +// SetClock injects the time source for timestamps and delivery records. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetDispatcher replaces how deliveries are run; tests pass a function that runs them inline. +func (b *InMemoryBackend) SetDispatcher(fn func(func())) { + b.mu.Lock("SetDispatcher") + b.spawn = fn + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// SetCompartmentTree tells the backend whether a compartment is nested below another (OCI Identity). +func (b *InMemoryBackend) SetCompartmentTree(fn func(id, ancestor string) bool) { + b.mu.Lock("SetCompartmentTree") + b.within = fn + b.mu.Unlock() +} + +// SetNotifier wires the ONS action target. +func (b *InMemoryBackend) SetNotifier(n Notifier) { + b.mu.Lock("SetNotifier") + b.notifier = n + b.mu.Unlock() +} + +// SetStreamer wires the OSS action target. +func (b *InMemoryBackend) SetStreamer(s Streamer) { + b.mu.Lock("SetStreamer") + b.streamer = s + b.mu.Unlock() +} + +// SetFunctionInvoker wires the FAAS action target. +func (b *InMemoryBackend) SetFunctionInvoker(f FunctionInvoker) { + b.mu.Lock("SetFunctionInvoker") + b.invoker = f + b.mu.Unlock() +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.rules, b.deliveries = map[string]*Rule{}, nil + b.mu.Unlock() + b.Auth.Reset() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func newEtag() string { return uuid.NewString() } + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +func (b *InMemoryBackend) inScope(eventCompartment, ruleCompartment string) bool { + if eventCompartment == ruleCompartment { + return true + } + + return b.within != nil && b.within(eventCompartment, ruleCompartment) +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate(items []*Rule, o ListOpts) page.Page[*Rule] { + desc := !strings.EqualFold(o.SortBy, "DISPLAY_NAME") && !strings.EqualFold(o.SortBy, "ID") + if strings.EqualFold(o.Order, "ASC") { + desc = false + } else if strings.EqualFold(o.Order, "DESC") { + desc = true + } + + slices.SortStableFunc(items, func(a, c *Rule) int { + var r int + + switch strings.ToUpper(o.SortBy) { + case "DISPLAY_NAME": + r = cmp.Compare(strs.Fold(a.DisplayName), strs.Fold(c.DisplayName)) + case "ID": + r = cmp.Compare(a.ID, c.ID) + default: + r = cmp.Or(a.TimeCreated.Compare(c.TimeCreated), cmp.Compare(a.Seq, c.Seq)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} diff --git a/services/ocifunctions/apps.go b/services/ocifunctions/apps.go index 9d0d453374..38d4f86b1f 100644 --- a/services/ocifunctions/apps.go +++ b/services/ocifunctions/apps.go @@ -194,6 +194,8 @@ func (b *InMemoryBackend) CreateApplication(in appIn) (appWire, string, string, return appWire{}, "", "", aerr } + defer b.flushEvents() + b.mu.Lock("CreateApplication") defer b.mu.Unlock() @@ -213,6 +215,7 @@ func (b *InMemoryBackend) CreateApplication(in appIn) (appWire, string, string, } b.apps[a.ID] = a + b.recordEvent(EventCreateApplication, a.CompartmentID, a.Name, a.ID) wr := b.newWR("CREATE_APPLICATION", a.CompartmentID, "application", a.ID, "CREATED", "/applications/"+a.ID, *a.TransitionAt) @@ -314,6 +317,8 @@ func cmpPtr[T any](in, cur *T) *T { // DeleteApplication moves an empty application to DELETING. func (b *InMemoryBackend) DeleteApplication(id, ifMatch string) (string, error) { + defer b.flushEvents() + b.mu.Lock("DeleteApplication") defer b.mu.Unlock() @@ -333,6 +338,7 @@ func (b *InMemoryBackend) DeleteApplication(id, ifMatch string) (string, error) } a.State, a.TransitionAt, a.Etag = stateDeleting, b.transitionAt(), newEtag() + b.recordEvent(EventDeleteApplication, a.CompartmentID, a.Name, a.ID) return b.newWR("DELETE_APPLICATION", a.CompartmentID, "application", a.ID, "DELETED", "/applications/"+a.ID, *a.TransitionAt), nil diff --git a/services/ocifunctions/events.go b/services/ocifunctions/events.go new file mode 100644 index 0000000000..579240da5f --- /dev/null +++ b/services/ocifunctions/events.go @@ -0,0 +1,63 @@ +package ocifunctions + +import "time" + +// Event types (https://docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm). +const ( + EventCreateApplication = "com.oraclecloud.functions.createapplication" + EventDeleteApplication = "com.oraclecloud.functions.deleteapplication" + EventCreateFunction = "com.oraclecloud.functions.createfunction" + EventDeleteFunction = "com.oraclecloud.functions.deletefunction" +) + +// ResourceEvent describes an application or function lifecycle change. +type ResourceEvent struct { + Time time.Time + EventType string + CompartmentID string + ResourceName string + ResourceID string +} + +// SetEventSink installs the receiver of resource events; it is called outside the backend lock. +func (b *InMemoryBackend) SetEventSink(fn func(ResourceEvent)) { + b.mu.Lock("SetEventSink") + b.sink = fn + b.mu.Unlock() +} + +// recordEvent queues an event while the lock is held; flushEvents delivers it after unlock. +func (b *InMemoryBackend) recordEvent(eventType, compartment, name, id string) { + if b.sink == nil { + return + } + + b.pending = append(b.pending, ResourceEvent{ + Time: b.nowUTC(), EventType: eventType, CompartmentID: compartment, ResourceName: name, ResourceID: id, + }) +} + +func (b *InMemoryBackend) flushEvents() { + b.mu.Lock("flushEvents") + evs, sink := b.pending, b.sink + b.pending = nil + b.mu.Unlock() + + if sink == nil { + return + } + + for _, e := range evs { + sink(e) + } +} + +// FunctionActive reports whether id is a function that is not deleted or being deleted. +func (b *InMemoryBackend) FunctionActive(id string) bool { + b.mu.RLock("FunctionActive") + defer b.mu.RUnlock() + + _, aerr := b.activeFn(id) + + return aerr == nil +} diff --git a/services/ocifunctions/events_test.go b/services/ocifunctions/events_test.go new file mode 100644 index 0000000000..f85428cbfb --- /dev/null +++ b/services/ocifunctions/events_test.go @@ -0,0 +1,60 @@ +package ocifunctions_test + +import ( + "sync" + "testing" + + "github.com/oracle/oci-go-sdk/v65/functions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ocifunctions" +) + +func TestResourceEvents(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + var ( + mu sync.Mutex + got []oci.ResourceEvent + ) + + e.backend.SetEventSink(func(ev oci.ResourceEvent) { + mu.Lock() + got = append(got, ev) + mu.Unlock() + }) + + app := e.mkApp(t, "evapp", nil) + fn := e.mkFn(t, app, "evfn", nil) + + _, err := e.mgmt.DeleteFunction(t.Context(), functions.DeleteFunctionRequest{FunctionId: fn.Id}) + require.NoError(t, err) + e.settle() + + _, err = e.mgmt.DeleteApplication(t.Context(), functions.DeleteApplicationRequest{ApplicationId: app.Id}) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + + wantTypes := []string{ + oci.EventCreateApplication, + oci.EventCreateFunction, + oci.EventDeleteFunction, + oci.EventDeleteApplication, + } + wantNames := []string{"evapp", "evfn", "evfn", "evapp"} + wantIDs := []string{*app.Id, *fn.Id, *fn.Id, *app.Id} + + require.Len(t, got, len(wantTypes)) + + for i := range wantTypes { + assert.Equal(t, wantTypes[i], got[i].EventType) + assert.Equal(t, wantNames[i], got[i].ResourceName) + assert.Equal(t, wantIDs[i], got[i].ResourceID) + assert.Equal(t, testCompartment, got[i].CompartmentID) + } +} diff --git a/services/ocifunctions/exec.go b/services/ocifunctions/exec.go index 69943d7079..096338c3b7 100644 --- a/services/ocifunctions/exec.go +++ b/services/ocifunctions/exec.go @@ -70,9 +70,9 @@ var dropForward = map[string]bool{ // execPlan is everything needed to run one function, captured under the backend lock. type execPlan struct { - fnID, fnName, appName, image, sig string - env []string - timeout, detached time.Duration + fnID, fnName, appID, appName, image, sig string + env []string + timeout, detached time.Duration } // InvokeIn is one invocation request. @@ -678,7 +678,7 @@ func (b *InMemoryBackend) plan(fnID string) (execPlan, *APIError) { unit := b.exec.timeoutUnit() ref := imageRef(f.Image, f.ImageDigest) p := execPlan{ - fnID: f.ID, fnName: f.Name, appName: app.Name, image: ref, env: env, sig: execSig(ref, env), + fnID: f.ID, fnName: f.Name, appID: app.ID, appName: app.Name, image: ref, env: env, sig: execSig(ref, env), timeout: time.Duration(f.Timeout) * unit, } @@ -706,7 +706,10 @@ func (b *InMemoryBackend) Invoke(ctx context.Context, fnID string, in InvokeIn) return b.invokeDetached(p, in) } + start := b.nowUTC() out, aerr := b.exec.run(ctx, p, in) + b.observe(p, start, out.CallID, out.Status, aerr) + if aerr != nil { return InvokeOut{}, aerr } @@ -733,7 +736,11 @@ func (b *InMemoryBackend) invokeDetached(p execPlan, in InvokeIn) (InvokeOut, er p.timeout = p.detached - if _, aerr := e.run(ctx, p, in); aerr != nil { + start := b.nowUTC() + res, aerr := e.run(ctx, p, in) + b.observe(p, start, res.CallID, res.Status, aerr) + + if aerr != nil { logger.Load(ctx).WarnContext(ctx, "ocifunctions: detached invocation failed", "function", p.fnID, "code", aerr.Code, "message", aerr.Message) } diff --git a/services/ocifunctions/funcs.go b/services/ocifunctions/funcs.go index 880a99a901..ba0f50f5c0 100644 --- a/services/ocifunctions/funcs.go +++ b/services/ocifunctions/funcs.go @@ -179,6 +179,8 @@ func (b *InMemoryBackend) CreateFunction(in fnIn, endpoint string) (fnWire, stri return fnWire{}, "", "", aerr } + defer b.flushEvents() + b.mu.Lock("CreateFunction") defer b.mu.Unlock() @@ -208,6 +210,7 @@ func (b *InMemoryBackend) CreateFunction(in fnIn, endpoint string) (fnWire, stri } b.funcs[f.ID] = f + b.recordEvent(EventCreateFunction, f.CompartmentID, f.Name, f.ID) wr := b.newWR("CREATE_FUNCTION", f.CompartmentID, "function", f.ID, "CREATED", "/functions/"+f.ID, *f.TransitionAt) return b.fnView(f, endpoint, false), f.Etag, wr, nil @@ -336,6 +339,8 @@ func applyFnUpdate(f *Function, in fnIn) { // DeleteFunction moves a function to DELETING and removes its warm containers. func (b *InMemoryBackend) DeleteFunction(id, ifMatch string) (string, error) { + defer b.flushEvents() + b.mu.Lock("DeleteFunction") f, aerr := b.activeFn(id) @@ -351,6 +356,7 @@ func (b *InMemoryBackend) DeleteFunction(id, ifMatch string) (string, error) { f.State, f.TransitionAt, f.Etag = stateDeleting, b.transitionAt(), newEtag() wr := b.newWR("DELETE_FUNCTION", f.CompartmentID, "function", f.ID, "DELETED", "/functions/"+f.ID, *f.TransitionAt) + b.recordEvent(EventDeleteFunction, f.CompartmentID, f.Name, f.ID) b.mu.Unlock() b.exec.dropFunction(id) diff --git a/services/ocifunctions/handler.go b/services/ocifunctions/handler.go index 034ecff4bd..dad9738a7d 100644 --- a/services/ocifunctions/handler.go +++ b/services/ocifunctions/handler.go @@ -19,6 +19,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -290,13 +291,13 @@ func (h *Handler) adminKeys(q *req) { // StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. func (h *Handler) StartWorker(ctx context.Context) error { - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { return fmt.Errorf("ocifunctions: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) e.Any("/*", telemetry.WrapEchoHandler("OCIFunctions", h.Handler(), h)) diff --git a/services/ocifunctions/helpers_test.go b/services/ocifunctions/helpers_test.go index 9c9bc50af2..25b9ddb207 100644 --- a/services/ocifunctions/helpers_test.go +++ b/services/ocifunctions/helpers_test.go @@ -8,7 +8,6 @@ import ( "encoding/pem" "fmt" "io" - "net" "net/http" "strings" "sync/atomic" @@ -20,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oci "github.com/blackbirdworks/gopherstack/services/ocifunctions" ) @@ -84,20 +84,6 @@ type envOpts struct { register bool } -func freePort(t *testing.T) int { - t.Helper() - - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - defer l.Close() - - tcp, ok := l.Addr().(*net.TCPAddr) - require.True(t, ok) - - return tcp.Port -} - func newEnv(t *testing.T, opts envOpts) *env { t.Helper() @@ -110,7 +96,7 @@ func newEnv(t *testing.T, opts envOpts) *env { } h := oci.NewHandler(be) - h.Port = freePort(t) + h.Port = 0 h.EnforceAuth = opts.enforce require.NoError(t, h.StartWorker(t.Context())) @@ -210,7 +196,7 @@ func rawDo(t *testing.T, method, url, body string, hdr map[string]string) (int, req.Header.Set(k, v) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ocifunctions/observe.go b/services/ocifunctions/observe.go new file mode 100644 index 0000000000..056a2bf846 --- /dev/null +++ b/services/ocifunctions/observe.go @@ -0,0 +1,50 @@ +package ocifunctions + +import "time" + +// InvokeEvent describes one finished function invocation. +type InvokeEvent struct { + Time time.Time + AppID string + AppName string + FunctionID string + FunctionName string + CallID string + ErrorCode string + ErrorMessage string + Duration time.Duration + Status int +} + +// InvokeObserver receives every finished invocation, successful or not. +type InvokeObserver func(InvokeEvent) + +// SetInvokeObserver registers the observer; nil removes it. +func (b *InMemoryBackend) SetInvokeObserver(fn InvokeObserver) { + if fn == nil { + b.observer.Store(nil) + + return + } + + b.observer.Store(&fn) +} + +func (b *InMemoryBackend) observe(p execPlan, start time.Time, callID string, status int, aerr *APIError) { + fn := b.observer.Load() + if fn == nil { + return + } + + now := b.nowUTC() + ev := InvokeEvent{ + Time: now, AppID: p.appID, AppName: p.appName, FunctionID: p.fnID, FunctionName: p.fnName, + CallID: callID, Status: status, Duration: now.Sub(start), + } + + if aerr != nil { + ev.Status, ev.ErrorCode, ev.ErrorMessage = aerr.Status, aerr.Code, aerr.Message + } + + (*fn)(ev) +} diff --git a/services/ocifunctions/observe_test.go b/services/ocifunctions/observe_test.go new file mode 100644 index 0000000000..085ca160ba --- /dev/null +++ b/services/ocifunctions/observe_test.go @@ -0,0 +1,64 @@ +package ocifunctions_test + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ocifunctions" +) + +func TestInvokeObserver(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + observed bool + wantApp bool + }{ + {"observer registered", true, true}, + {"no observer", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ie := newInvokeEnv(t, nil, nil) + + var ( + mu sync.Mutex + events []oci.InvokeEvent + ) + + if tt.observed { + ie.backend.SetInvokeObserver(func(ev oci.InvokeEvent) { + mu.Lock() + events = append(events, ev) + mu.Unlock() + }) + } + + _, _, err := ie.invoke(t, "hello", nil) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + + if !tt.wantApp { + assert.Empty(t, events) + + return + } + + require.Len(t, events, 1) + assert.Equal(t, *ie.app.Id, events[0].AppID) + assert.Equal(t, *ie.fn.Id, events[0].FunctionID) + assert.Equal(t, "fn", events[0].FunctionName) + assert.Equal(t, 200, events[0].Status) + assert.NotEmpty(t, events[0].CallID) + }) + } +} diff --git a/services/ocifunctions/store.go b/services/ocifunctions/store.go index 370c89d524..3f43d44492 100644 --- a/services/ocifunctions/store.go +++ b/services/ocifunctions/store.go @@ -29,11 +29,14 @@ const ( type InMemoryBackend struct { mu *lockmetrics.RWMutex clock atomic.Pointer[func() time.Time] + observer atomic.Pointer[InvokeObserver] apps map[string]*Application funcs map[string]*Function workReqs map[string]*WorkRequest Auth *ociauth.Registry exec *executor + sink func(ResourceEvent) + pending []ResourceEvent transition time.Duration seq int64 } diff --git a/services/ociidentity/auth_e2e_test.go b/services/ociidentity/auth_e2e_test.go index b0cdbe8199..adf013cdee 100644 --- a/services/ociidentity/auth_e2e_test.go +++ b/services/ociidentity/auth_e2e_test.go @@ -21,7 +21,7 @@ func storageEnv(t *testing.T, e *env, link bool) string { be := oss.NewInMemoryBackend() h := oss.NewHandler(be) - h.Port = freePort(t) + h.Port = 0 h.EnforceAuth = true if link { diff --git a/services/ociidentity/handler.go b/services/ociidentity/handler.go index b3d85d7b76..fd731fe458 100644 --- a/services/ociidentity/handler.go +++ b/services/ociidentity/handler.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "strings" "sync" @@ -18,6 +17,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -273,13 +273,13 @@ func (h *Handler) adminKeys(q *req) { // StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. func (h *Handler) StartWorker(ctx context.Context) error { - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { return fmt.Errorf("ociidentity: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) e.Any("/*", telemetry.WrapEchoHandler("OCIIdentity", h.Handler(), h)) diff --git a/services/ociidentity/handler_test.go b/services/ociidentity/handler_test.go index e32d552c5f..154320a3f2 100644 --- a/services/ociidentity/handler_test.go +++ b/services/ociidentity/handler_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oci "github.com/blackbirdworks/gopherstack/services/ociidentity" ) @@ -20,7 +21,7 @@ func rawDo(t *testing.T, method, url string, body []byte) (*http.Response, []byt req, err := http.NewRequestWithContext(context.Background(), method, url, bytes.NewReader(body)) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ociidentity/helpers_test.go b/services/ociidentity/helpers_test.go index 01521c3254..ecd62f3fee 100644 --- a/services/ociidentity/helpers_test.go +++ b/services/ociidentity/helpers_test.go @@ -7,7 +7,6 @@ import ( "crypto/x509" "encoding/pem" "fmt" - "net" "sync/atomic" "testing" "time" @@ -71,20 +70,6 @@ type env struct { base string } -func freePort(t *testing.T) int { - t.Helper() - - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - defer l.Close() - - tcp, ok := l.Addr().(*net.TCPAddr) - require.True(t, ok) - - return tcp.Port -} - // newEnv starts an Identity listener; the client signs with a throwaway key as a made-up user. func newEnv(t *testing.T, enforce bool) *env { t.Helper() @@ -94,7 +79,7 @@ func newEnv(t *testing.T, enforce bool) *env { be.SetClock(clock.Now) h := oci.NewHandler(be) - h.Port = freePort(t) + h.Port = 0 h.EnforceAuth = enforce require.NoError(t, h.StartWorker(t.Context())) diff --git a/services/ociidentity/lookup.go b/services/ociidentity/lookup.go index 3ae825b76e..242b812681 100644 --- a/services/ociidentity/lookup.go +++ b/services/ociidentity/lookup.go @@ -13,3 +13,30 @@ func (b *InMemoryBackend) CompartmentActive(id string) bool { return ok && c.LifecycleState == stateActive } + +// CompartmentWithin reports whether id equals ancestor or is nested below it; the tenancy contains everything. +func (b *InMemoryBackend) CompartmentWithin(id, ancestor string) bool { + b.mu.RLock("CompartmentWithin") + defer b.mu.RUnlock() + + if ancestor == TenancyID { + return true + } + + for hops := 0; id != "" && hops <= maxCompartmentHops; hops++ { + if id == ancestor { + return true + } + + c, ok := b.compartment[id] + if !ok { + return false + } + + id = c.CompartmentID + } + + return false +} + +const maxCompartmentHops = 64 diff --git a/services/ociidentity/within_test.go b/services/ociidentity/within_test.go new file mode 100644 index 0000000000..c03f0af3ed --- /dev/null +++ b/services/ociidentity/within_test.go @@ -0,0 +1,53 @@ +package ociidentity_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ociidentity" +) + +func TestCompartmentWithin(t *testing.T) { + t.Parallel() + + b := oci.NewInMemoryBackend() + + parent, _, err := b.CreateCompartment( + oci.CreateCompartmentIn{CompartmentID: oci.TenancyID, Name: "parent", Description: "d"}, + ) + require.NoError(t, err) + + child, _, err := b.CreateCompartment( + oci.CreateCompartmentIn{CompartmentID: parent.ID, Name: "child", Description: "d"}, + ) + require.NoError(t, err) + + other, _, err := b.CreateCompartment( + oci.CreateCompartmentIn{CompartmentID: oci.TenancyID, Name: "other", Description: "d"}, + ) + require.NoError(t, err) + + tests := []struct { + name string + id string + ancestor string + want bool + }{ + {"self", parent.ID, parent.ID, true}, + {"child of parent", child.ID, parent.ID, true}, + {"parent of child", parent.ID, child.ID, false}, + {"siblings", other.ID, parent.ID, false}, + {"anything in tenancy", child.ID, oci.TenancyID, true}, + {"unknown", "ocid1.compartment.oc1..nope", parent.ID, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, b.CompartmentWithin(tt.id, tt.ancestor)) + }) + } +} diff --git a/services/ociloadbalancer/PARITY.md b/services/ociloadbalancer/PARITY.md new file mode 100644 index 0000000000..3f799b832a --- /dev/null +++ b/services/ociloadbalancer/PARITY.md @@ -0,0 +1,96 @@ +--- +service: ociloadbalancer +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every control-plane operation in the service tests +last_audit_commit: aebf6f27d +last_audit_date: 2026-10-30 +overall: B +ops: + CreateLoadBalancer: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with opc-work-request-id and no body (SDK CreateLoadBalancerResponse has none). shapeName in 10Mbps/10Mbps-Micro/100Mbps/400Mbps/8000Mbps/flexible; flexible needs shapeDetails 10 <= min <= max <= 8000 Mbps, fixed shapes reject shapeDetails; 1-2 subnetIds (1 when private), checked and used for the private address through OCI Core when wired; ipAddresses assigned; nested listeners, hostnames, backendSets, certificates, sslCipherSuites, pathRouteSets and ruleSets validated like the individual calls. reservedIps, ipMode other than IPV4 and ipv6SubnetCidr are rejected."} + GetLoadBalancer: {wire: ok, errors: ok, state: ok, persist: ok, note: "Full model with all sub-resources and an etag header; listeners carry the gopherstack-only gopherstackAddress field while serving. DELETED load balancers stay readable."} + ListLoadBalancers: {wire: ok, errors: ok, state: ok, persist: ok, note: "compartmentId required; displayName, lifecycleState and detail=simple; sortBy TIMECREATED/DISPLAYNAME, sortOrder, limit, opc-next-page."} + UpdateLoadBalancer: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName, delete protection, request id settings, tags; If-Match gives 412; 409 IncorrectState unless ACTIVE; 409 Conflict while another work request on the load balancer is unfinished."} + DeleteLoadBalancer: {wire: ok, errors: ok, state: ok, persist: ok, note: "DELETING then DELETED; 409 while delete protection is on."} + ChangeLoadBalancerCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "Body compartmentId; work request."} + UpdateLoadBalancerShape: {wire: ok, errors: ok, state: ok, persist: ok, note: "Same shape validation as create."} + UpdateNetworkSecurityGroups: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces networkSecurityGroupIds; the ids are not checked."} + ListShapes: {wire: ok, errors: ok, state: ok, persist: ok, note: "Static shape catalogue."} + ListPolicies: {wire: ok, errors: ok, state: ok, persist: ok, note: "ROUND_ROBIN, LEAST_CONNECTIONS, IP_HASH."} + ListProtocols: {wire: ok, errors: ok, state: ok, persist: ok, note: "HTTP, HTTP2, TCP."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS until the transition time on the injectable clock, then SUCCEEDED; errorDetails always empty."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: ok, note: "Per load balancer, newest first, paged."} + ListLoadBalancerHealths: {wire: ok, errors: ok, state: ok, persist: ok, note: "Per compartment summaries from live health state."} + GetLoadBalancerHealth: {wire: ok, errors: ok, state: ok, persist: ok, note: "Aggregated from backend set health after running due checks."} + CreateBackendSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "policy, healthChecker (HTTP/TCP, defaults port 0=backend port, returnCode 200, retries 3, timeout 3000, interval 10000, urlPath /), backends, backendMaxConnections, sslConfiguration (certificateName bundle), app-cookie and LB-cookie session persistence (mutually exclusive). Names are letters, digits, - and _."} + GetBackendSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "With etag header."} + ListBackendSets: {wire: ok, errors: ok, state: ok, persist: ok, note: "Sorted by name."} + UpdateBackendSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "Full replacement including backends."} + DeleteBackendSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 Conflict while a listener, path route set or routing policy references it."} + GetBackendSetHealth: {wire: ok, errors: ok, state: ok, persist: ok, note: "OK when all backends are OK, CRITICAL when none is healthy and one is critical, WARNING for a mix, UNKNOWN when nothing is checked yet or the set is empty."} + GetHealthChecker: {wire: ok, errors: ok, state: ok, persist: ok, note: "Returns the stored health checker."} + UpdateHealthChecker: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces the health checker; invalid regex 400."} + CreateBackend: {wire: ok, errors: ok, state: ok, persist: ok, note: "Name is ip:port; weight>=1 default 1, backup, drain, offline, maxConnections; 409 on duplicate."} + GetBackend: {wire: ok, errors: ok, state: ok, persist: ok, note: "By ip:port."} + ListBackends: {wire: ok, errors: ok, state: ok, persist: ok, note: "In creation order."} + UpdateBackend: {wire: ok, errors: ok, state: ok, persist: ok, note: "weight, backup, drain, offline, maxConnections."} + DeleteBackend: {wire: ok, errors: ok, state: ok, persist: ok, note: "Removes the backend."} + GetBackendHealth: {wire: ok, errors: ok, state: ok, persist: ok, note: "Runs due checks first; healthCheckResults has one entry from the first subnet and address of the load balancer; offline backends report UNKNOWN with result OFFLINE."} + CreateListener: {wire: ok, errors: ok, state: ok, persist: ok, note: "HTTP, HTTP2 (needs sslConfiguration) or TCP; defaultBackendSetName, hostnameNames, pathRouteSetName xor routingPolicyName, ruleSetNames, sslConfiguration (certificateName with a private key, protocols TLSv1.2/TLSv1.3, cipherSuiteName, verifyPeerCertificate for client certificates), connectionConfiguration.idleTimeout. Listeners share a port only with disjoint hostnames (409). backendTcpProxyProtocol* and certificateIds are rejected."} + UpdateListener: {wire: ok, errors: ok, state: ok, persist: ok, note: "Full replacement."} + DeleteListener: {wire: ok, errors: ok, state: ok, persist: ok, note: "Closes the data-plane socket when it was the last listener on the port."} + ListListenerRules: {wire: ok, errors: ok, state: ok, persist: ok, note: "Rules of the listener's rule sets with their set names."} + CreateHostname: {wire: ok, errors: ok, state: ok, persist: ok, note: "Wildcard prefix *. and suffix .* allowed."} + GetHostname: {wire: ok, errors: ok, state: ok, persist: ok, note: "By name."} + ListHostnames: {wire: ok, errors: ok, state: ok, persist: ok, note: "Sorted."} + UpdateHostname: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces hostname."} + DeleteHostname: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 while a listener uses it."} + CreatePathRouteSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "EXACT_MATCH, FORCE_LONGEST_PREFIX_MATCH, PREFIX_MATCH, SUFFIX_MATCH routes to existing backend sets."} + GetPathRouteSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "By name."} + ListPathRouteSets: {wire: ok, errors: ok, state: ok, persist: ok, note: "Sorted."} + UpdatePathRouteSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces routes."} + DeletePathRouteSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 while a listener uses it."} + CreateRoutingPolicy: {wire: ok, errors: partial, state: ok, persist: ok, note: "conditionLanguageVersion V1; condition subset: all()/any(), http.request.url.path, url.query[k], headers[k], cookies[k], method with eq ne sw ew co and (i '..') case-insensitive literals; one FORWARD_TO_BACKENDSET action per rule."} + GetRoutingPolicy: {wire: ok, errors: ok, state: ok, persist: ok, note: "Actions decode as ForwardToBackendSet."} + ListRoutingPolicies: {wire: ok, errors: ok, state: ok, persist: ok, note: "Sorted."} + UpdateRoutingPolicy: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces rules."} + DeleteRoutingPolicy: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 while a listener uses it."} + CreateRuleSet: {wire: ok, errors: partial, state: ok, persist: ok, note: "ADD/EXTEND/REMOVE_HTTP_REQUEST_HEADER and RESPONSE variants, ALLOW (SOURCE_IP_ADDRESS), REDIRECT, CONTROL_ACCESS_USING_HTTP_METHODS and HTTP_HEADER (httpLargeHeaderSizeInKB). IP_BASED_MAX_CONNECTIONS, SOURCE_VCN_* conditions and areInvalidCharactersAllowed are rejected."} + GetRuleSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "Polymorphic items decode into the SDK rule types."} + ListRuleSets: {wire: ok, errors: ok, state: ok, persist: ok, note: "Sorted."} + UpdateRuleSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces items."} + DeleteRuleSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 while a listener uses it."} + CreateSSLCipherSuite: {wire: ok, errors: partial, state: ok, persist: ok, note: "Custom suites of OpenSSL cipher names that Go's crypto/tls implements; the oci- prefix is reserved."} + GetSSLCipherSuite: {wire: ok, errors: ok, state: ok, persist: ok, note: "By name."} + ListSSLCipherSuites: {wire: ok, errors: ok, state: ok, persist: ok, note: "Custom suites only."} + UpdateSSLCipherSuite: {wire: ok, errors: ok, state: ok, persist: ok, note: "Replaces ciphers."} + DeleteSSLCipherSuite: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 while a listener or backend set uses it."} + CreateCertificate: {wire: ok, errors: ok, state: ok, persist: ok, note: "PEM public certificate chain, optional CA bundle, private key checked as a matching pair with crypto/tls; encrypted keys are rejected. The key is held in memory for TLS termination only: never returned, never persisted."} + ListCertificates: {wire: ok, errors: ok, state: ok, persist: ok, note: "Names, public certificate and CA bundle."} + DeleteCertificate: {wire: ok, errors: ok, state: ok, persist: ok, note: "409 while referenced."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for keys registered via POST /_gopherstack/oci/keys or OCI Identity; --oci-loadbalancer-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "{code,message} with opc-request-id: NotAuthenticated, NotAuthorizedOrNotFound, InvalidParameter, MissingParameter, Conflict, IncorrectState, NoEtagMatch (OCI API error reference). Which of 409 Conflict and IncorrectState Oracle uses per case is from memory."} + async_work_requests: {status: partial, note: "Every mutation answers 202 with opc-work-request-id and finishes after 2 s on the injectable clock; the change is visible immediately. A second mutation while one is unfinished is 409 Conflict (Oracle documents work-request based asynchronous LB operations; the exact conflict code is from memory)."} + data_plane: {status: partial, note: "Each active load balancer port gets a loopback listener on an ephemeral host port (127.0.0.1:0), surfaced as gopherstackAddress on listeners in GetLoadBalancer and by GET /_gopherstack/loadbalancer/endpoints. HTTP is proxied with httputil.ReverseProxy (X-Forwarded-*, Host preserved, WebSocket upgrades), TCP by a byte pipe with idle deadlines (idleTimeout, default 60 s HTTP / 300 s TCP). 503 when no backend is usable, 502/504 on backend failure, 404 when no listener on the port matches the host."} + balancing: {status: partial, note: "ROUND_ROBIN is smooth weighted round robin, LEAST_CONNECTIONS picks the lowest active/weight, IP_HASH hashes the client address over usable backends. Offline and unhealthy backends are skipped, drained ones only serve persistent sessions, backups serve only when no regular backend is usable, maxConnections limits are honoured. Exact Oracle algorithms are from memory."} + health_checks: {status: partial, note: "HTTP (status, body regex) and TCP checks driven by intervalInMillis on the injectable clock, run before routing and by a 1 s ticker; a backend turns CRITICAL after retries+1 consecutive failures and OK again after retries+1 successes (the exact retries semantics are from memory). New backends are UNKNOWN and routable until checked. Health is runtime state and is not persisted."} + routing: {status: partial, note: "Order: rule-set request rules, then routing policy (first matching rule) or path route set (EXACT, then longest FORCE_LONGEST_PREFIX, then first PREFIX/SUFFIX in list order; case-insensitive), else the default backend set. Hostname matching: exact, then wildcard, then listeners without hostnames. Precedence details are from memory."} + rule_sets: {status: partial, note: "Header add appends, extend wraps an existing value, ALLOW rules deny with 403 when none matches the client address, REDIRECT matches any condition and supports {protocol} {host} {port} {path} {query} tokens (default 302), CONTROL_ACCESS answers statusCode (default 405). Semantics of multiple conditions and duplicate headers are from memory."} + session_persistence: {status: partial, note: "LB cookie persistence sets X-Oracle-BMC-LBS-Route (or cookieName) with path/domain/maxAge/secure/httpOnly; application cookie persistence adds the route cookie when the backend sets cookieName. disableFallback answers 502 when the sticky backend is unusable."} + tls: {status: partial, note: "Listeners terminate TLS (HTTP, HTTP2 with ALPN h2, TCP) with the uploaded certificate selected by SNI, protocols TLSv1.2/TLSv1.3 (default TLSv1.2 only), custom cipher suites and optional client certificate verification against the bundle CA. Built-in oci-* suite names are accepted as references but their cipher lists are not modelled."} + backend_tls: {status: partial, note: "Backend sets with sslConfiguration use TLS to backends and for HTTPS health checks (isForcePlainText=false). The peer chain and IP SAN are always verified against the certificateName bundle (CA plus public certificate) or system roots; there is no InsecureSkipVerify, so verifyPeerCertificate=false does NOT disable verification (stricter than Oracle). verifyPeerCertificate=true requires a bundle."} + ssrf_guard: {status: ok, note: "All backend dials, probes and TLS handshakes use pkgs/httpclient.LoopbackDialer, which checks the resolved peer address and refuses anything outside 127.0.0.0/8 and ::1 unless --oci-loadbalancer-allow-external-backends / OCI_LOADBALANCER_ALLOW_EXTERNAL_BACKENDS is set; proxy environment variables are ignored."} + lifecycle: {status: ok, note: "CREATING, DELETING last 2 s; Sweep runs on every request and every second; listeners open when ACTIVE and close on DELETING."} + persistence: {status: partial, note: "Load balancers (without certificate private keys) and work requests are in the snapshot; listeners reopen on restore. TLS listeners cannot terminate after a restore until the certificate is recreated."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-loadbalancer-port (default 8165); never on the shared AWS router; cli.go reserves the port."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards resource state, a second one the data-plane runtime state."} +items_still_open: + - "Cipher suites ECDHE-RSA-AES128-SHA256 and ECDHE-ECDSA-AES128-SHA256 are accepted and stored by the control plane but not served by the data plane: Go classes them as insecure (tls.InsecureCipherSuites)." + - "Not routed: CancelWorkRequest and tag/lock APIs; the shape catalogue is static. reservedIps, ipv6SubnetCidr and ipMode other than IPV4 are rejected." + - "Rejected with 400: IP_BASED_MAX_CONNECTIONS, SOURCE_VCN_ID and SOURCE_VCN_IP_ADDRESS conditions, areInvalidCharactersAllowed, backendTcpProxyProtocolVersion/Options, certificateIds and trustedCertificateAuthorityIds (OCI Certificates service), passphrase-protected private keys, TLS protocols below 1.2." + - "verifyPeerCertificate=false on a backend set does not turn verification off (repo rule: no InsecureSkipVerify); backends with self-signed certificates need their certificate uploaded as the bundle." + - "Unverified, from memory: flexible shape bounds (10-8000 Mbps), name character rules, retries/threshold semantics, health status aggregation, 409 code for in-flight work requests, listener port sharing by hostname, path route and rule precedence, ALLOW/REDIRECT multiple-condition semantics, header add semantics, HTTP 404 for non-matching virtual hosts, pathRouteSetName and routingPolicyName being mutually exclusive, the built-in cipher suite names, the routing-policy condition grammar subset." + - "Addresses: public and private load balancers get an address from the emulator pool 198.18.0.0/15 (public) or the OCI Core subnet CIDR when wired; no real address is reachable, the data plane is on loopback ephemeral ports only. In containers those ports are not published; use the endpoints admin route from inside the container." + - "Health state is not persisted; backend maxConnections and balancing cursors are per process. No PROXY protocol, no SNI-based backend selection, no OCSP or session-ticket handling, no per-listener access logging." + - "opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. Private keys of certificates are not persisted, so TLS listeners need the certificate recreated after a restore." + - "ociapigateway still carries its own copy of the loopback dial guard instead of pkgs/httpclient.LoopbackDialer." +--- diff --git a/services/ociloadbalancer/README.md b/services/ociloadbalancer/README.md new file mode 100644 index 0000000000..ae652884b2 --- /dev/null +++ b/services/ociloadbalancer/README.md @@ -0,0 +1,31 @@ + +# OCI Load Balancer + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-30 (`aebf6f27d`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 61 (57 ok, 4 partial) | +| Feature families | 16 (5 ok, 11 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Cipher suites ECDHE-RSA-AES128-SHA256 and ECDHE-ECDSA-AES128-SHA256 are accepted and stored by the control plane but not served by the data plane: Go classes them as insecure (tls.InsecureCipherSuites). +- Not routed: CancelWorkRequest and tag/lock APIs; the shape catalogue is static. reservedIps, ipv6SubnetCidr and ipMode other than IPV4 are rejected. +- Rejected with 400: IP_BASED_MAX_CONNECTIONS, SOURCE_VCN_ID and SOURCE_VCN_IP_ADDRESS conditions, areInvalidCharactersAllowed, backendTcpProxyProtocolVersion/Options, certificateIds and trustedCertificateAuthorityIds (OCI Certificates service), passphrase-protected private keys, TLS protocols below 1.2. +- verifyPeerCertificate=false on a backend set does not turn verification off (repo rule: no InsecureSkipVerify); backends with self-signed certificates need their certificate uploaded as the bundle. +- Unverified, from memory: flexible shape bounds (10-8000 Mbps), name character rules, retries/threshold semantics, health status aggregation, 409 code for in-flight work requests, listener port sharing by hostname, path route and rule precedence, ALLOW/REDIRECT multiple-condition semantics, header add semantics, HTTP 404 for non-matching virtual hosts, pathRouteSetName and routingPolicyName being mutually exclusive, the built-in cipher suite names, the routing-policy condition grammar subset. +- Addresses: public and private load balancers get an address from the emulator pool 198.18.0.0/15 (public) or the OCI Core subnet CIDR when wired; no real address is reachable, the data plane is on loopback ephemeral ports only. In containers those ports are not published; use the endpoints admin route from inside the container. +- Health state is not persisted; backend maxConnections and balancing cursors are per process. No PROXY protocol, no SNI-based backend selection, no OCSP or session-ticket handling, no per-listener access logging. +- opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. Private keys of certificates are not persisted, so TLS listeners need the certificate recreated after a restore. +- ociapigateway still carries its own copy of the loopback dial guard instead of pkgs/httpclient.LoopbackDialer. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ociloadbalancer/build.go b/services/ociloadbalancer/build.go new file mode 100644 index 0000000000..9cda9abf66 --- /dev/null +++ b/services/ociloadbalancer/build.go @@ -0,0 +1,493 @@ +package ociloadbalancer + +import ( + "net" + "net/http" + "net/netip" + "regexp" + "slices" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + maxNameLen = 255 + maxPort = 65535 + maxCookieAge = 31536000 + defaultReturn = http.StatusOK + defaultRetries = 3 + defaultTimeout = 3000 + defaultInterval = 10000 + maxRetries = 10 +) + +var ( + nameRE = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + hostnameRE = regexp.MustCompile( + `^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(\.\*)?$`, + ) + tokenRE = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +) + +func checkName(kind, name string) *APIError { + if name == "" { + return errMissing(kind + " name is required") + } + + if len(name) > maxNameLen || !nameRE.MatchString(name) { + return errParam(kind + " name may contain only letters, digits, dashes and underscores") + } + + return nil +} + +type backendIn struct { + Port *int `json:"port"` + Weight *int `json:"weight"` + MaxConnections *int `json:"maxConnections"` + Backup *bool `json:"backup"` + Drain *bool `json:"drain"` + Offline *bool `json:"offline"` + IPAddress string `json:"ipAddress"` +} + +type healthCheckerIn struct { + URLPath *string `json:"urlPath"` + Port *int `json:"port"` + ReturnCode *int `json:"returnCode"` + Retries *int `json:"retries"` + TimeoutInMillis *int `json:"timeoutInMillis"` + IntervalInMillis *int `json:"intervalInMillis"` + ResponseBodyRegex *string `json:"responseBodyRegex"` + IsForcePlainText *bool `json:"isForcePlainText"` + Protocol string `json:"protocol"` +} + +type backendSetIn struct { + HealthChecker *healthCheckerIn `json:"healthChecker"` + BackendMaxConnections *int `json:"backendMaxConnections"` + SSL *SSLConfig `json:"sslConfiguration"` + SessionPersistence *SessionPersistence `json:"sessionPersistenceConfiguration"` + LBCookiePersistence *LBCookiePersistence `json:"lbCookieSessionPersistenceConfiguration"` + Name string `json:"name"` + Policy string `json:"policy"` + Backends []backendIn `json:"backends"` +} + +func intOr(v *int, def int) int { + if v == nil { + return def + } + + return *v +} + +func boolOr(v *bool) bool { return v != nil && *v } + +func backendName(ip string, port int) string { return net.JoinHostPort(ip, itoa(port)) } + +func buildBackend(in backendIn) (Backend, *APIError) { + addr, err := netip.ParseAddr(in.IPAddress) + if err != nil { + return Backend{}, errParam("backend ipAddress " + in.IPAddress + " is not a valid IP address") + } + + if in.Port == nil || *in.Port < 1 || *in.Port > maxPort { + return Backend{}, errParam("backend port must be between 1 and 65535") + } + + w := intOr(in.Weight, 1) + if w < 1 { + return Backend{}, errParam("backend weight must be at least 1") + } + + if in.MaxConnections != nil && *in.MaxConnections < 1 { + return Backend{}, errParam("backend maxConnections must be at least 1") + } + + ip := addr.String() + + return Backend{ + Name: backendName(ip, *in.Port), IPAddress: ip, Port: *in.Port, Weight: w, MaxConnections: in.MaxConnections, + Backup: boolOr(in.Backup), Drain: boolOr(in.Drain), Offline: boolOr(in.Offline), + }, nil +} + +func buildHealthChecker(in *healthCheckerIn) (HealthChecker, *APIError) { + if in == nil { + return HealthChecker{}, errMissing("healthChecker is required") + } + + hc := HealthChecker{ + Protocol: strings.ToUpper( + in.Protocol, + ), + Port: intOr(in.Port, 0), + ReturnCode: intOr(in.ReturnCode, defaultReturn), + Retries: intOr(in.Retries, defaultRetries), + TimeoutInMillis: intOr(in.TimeoutInMillis, defaultTimeout), + IntervalInMillis: intOr(in.IntervalInMillis, defaultInterval), + IsForcePlainText: boolOr(in.IsForcePlainText), + } + + if in.ResponseBodyRegex != nil { + hc.ResponseBodyRegex = *in.ResponseBodyRegex + } + + if hc.Protocol != protoHTTP && hc.Protocol != protoTCP { + return HealthChecker{}, errParam("healthChecker protocol must be HTTP or TCP") + } + + if hc.validate() != nil { + return HealthChecker{}, hc.validate() + } + + if hc.Protocol == protoHTTP { + hc.URLPath = "/" + if in.URLPath != nil { + hc.URLPath = *in.URLPath + } + } + + return hc, nil +} + +// validate reports why a health checker's numeric fields are out of range. +func (hc HealthChecker) validate() *APIError { + switch { + case hc.Port < 0 || hc.Port > maxPort: + return errParam("healthChecker port must be between 0 and 65535") + case hc.Retries < 0 || hc.Retries > maxRetries: + return errParam("healthChecker retries must be between 0 and 10") + case hc.TimeoutInMillis < 1 || hc.IntervalInMillis < 1: + return errParam("healthChecker timeoutInMillis and intervalInMillis must be positive") + case hc.ReturnCode < http.StatusContinue || hc.ReturnCode > 599: + return errParam("healthChecker returnCode must be an HTTP status code") + } + + if hc.ResponseBodyRegex != "" { + if _, err := regexp.Compile(hc.ResponseBodyRegex); err != nil { + return errParam("healthChecker responseBodyRegex is not a valid regular expression") + } + } + + return nil +} + +func buildBackendSet(name string, in *backendSetIn) (*BackendSet, *APIError) { + if err := checkName("backend set", name); err != nil { + return nil, err + } + + if !slices.Contains(policyNames, in.Policy) { + return nil, errParam("policy must be one of " + strings.Join(policyNames, ", ")) + } + + hc, err := buildHealthChecker(in.HealthChecker) + if err != nil { + return nil, err + } + + if in.BackendMaxConnections != nil && *in.BackendMaxConnections < 1 { + return nil, errParam("backendMaxConnections must be at least 1") + } + + if in.SessionPersistence != nil && in.LBCookiePersistence != nil { + return nil, errParam( + "sessionPersistenceConfiguration and lbCookieSessionPersistenceConfiguration are mutually exclusive", + ) + } + + if err = checkPersistence(in); err != nil { + return nil, err + } + + bs := &BackendSet{ + Name: name, Policy: in.Policy, HealthChecker: hc, BackendMaxConnections: in.BackendMaxConnections, + SSL: in.SSL, SessionPersistence: in.SessionPersistence, LBCookiePersistence: in.LBCookiePersistence, + Backends: []Backend{}, + } + + for _, bi := range in.Backends { + be, berr := buildBackend(bi) + if berr != nil { + return nil, berr + } + + if slices.ContainsFunc(bs.Backends, func(o Backend) bool { return o.Name == be.Name }) { + return nil, errParam("duplicate backend " + be.Name) + } + + bs.Backends = append(bs.Backends, be) + } + + return bs, nil +} + +func checkPersistence(in *backendSetIn) *APIError { + if sp := in.SessionPersistence; sp != nil && sp.CookieName == "" { + return errMissing("sessionPersistenceConfiguration cookieName is required") + } + + if lc := in.LBCookiePersistence; lc != nil { + if lc.MaxAgeInSeconds != nil && (*lc.MaxAgeInSeconds < 0 || *lc.MaxAgeInSeconds > maxCookieAge) { + return errParam("maxAgeInSeconds is out of range") + } + } + + return nil +} + +type listenerIn struct { + SSL *SSLConfig `json:"sslConfiguration"` + Connection *ConnectionConfig `json:"connectionConfiguration"` + Name string `json:"name"` + DefaultSet string `json:"defaultBackendSetName"` + Protocol string `json:"protocol"` + PathRoutes string `json:"pathRouteSetName"` + Policy string `json:"routingPolicyName"` + Port *int `json:"port"` + Hostnames []string `json:"hostnameNames"` + RuleSets []string `json:"ruleSetNames"` +} + +func buildListener(name string, in *listenerIn) (*Listener, *APIError) { + if err := checkName("listener", name); err != nil { + return nil, err + } + + proto := strings.ToUpper(in.Protocol) + if !slices.Contains(protocolNames, proto) { + return nil, errParam("protocol must be one of " + strings.Join(protocolNames, ", ")) + } + + if in.Port == nil || *in.Port < 1 || *in.Port > maxPort { + return nil, errParam("listener port must be between 1 and 65535") + } + + if in.DefaultSet == "" { + return nil, errMissing("defaultBackendSetName is required") + } + + l := &Listener{ + Name: name, DefaultSet: in.DefaultSet, Protocol: proto, Port: *in.Port, Hostnames: in.Hostnames, + PathRoutes: in.PathRoutes, Policy: in.Policy, RuleSets: in.RuleSets, SSL: in.SSL, Connection: in.Connection, + } + + if err := checkListenerShape(l); err != nil { + return nil, err + } + + return l, nil +} + +func checkListenerShape(l *Listener) *APIError { + if l.Protocol == protoHTTP2 && l.SSL == nil { + return errParam("an HTTP2 listener requires sslConfiguration") + } + + if l.Protocol == protoTCP && (len(l.Hostnames) > 0 || l.PathRoutes != "" || l.Policy != "" || len(l.RuleSets) > 0) { + return errParam( + "hostnameNames, pathRouteSetName, routingPolicyName and ruleSetNames apply to HTTP listeners only", + ) + } + + if l.PathRoutes != "" && l.Policy != "" { + return errParam("a listener cannot use both pathRouteSetName and routingPolicyName") + } + + if l.SSL != nil { + if err := checkListenerSSL(l.SSL); err != nil { + return err + } + } + + return checkConnection(l.Connection) +} + +func checkConnection(c *ConnectionConfig) *APIError { + if c == nil { + return nil + } + + if c.IdleTimeout < 1 { + return errParam("connectionConfiguration idleTimeout must be positive") + } + + if c.BackendTCPProxyProtocolVersion != nil || len(c.BackendTCPProxyProtocolOptions) > 0 { + return errUnsupported("backend TCP proxy protocol") + } + + return nil +} + +func checkListenerSSL(s *SSLConfig) *APIError { + if len(s.CertificateIDs) > 0 || len(s.TrustedCertificateAuthorityIDs) > 0 { + return errUnsupported("certificateIds and trustedCertificateAuthorityIds (OCI Certificates service)") + } + + if s.CertificateName == "" { + return errMissing("sslConfiguration certificateName is required") + } + + return checkTLSVersions(s) +} + +func checkTLSVersions(s *SSLConfig) *APIError { + for _, p := range s.Protocols { + if p != "TLSv1.2" && p != "TLSv1.3" { + return errUnsupported("TLS protocol " + p + " (only TLSv1.2 and TLSv1.3)") + } + } + + return nil +} + +type hostnameIn struct { + Name string `json:"name"` + Hostname string `json:"hostname"` +} + +func checkHostname(h string) *APIError { + if h == "" || len(h) > maxNameLen || !hostnameRE.MatchString(h) { + return errParam("hostname " + h + " is not a valid virtual hostname") + } + + return nil +} + +type pathRouteSetIn struct { + Name string `json:"name"` + PathRoutes []PathRoute `json:"pathRoutes"` +} + +func buildPathRouteSet(name string, in *pathRouteSetIn) (*PathRouteSet, *APIError) { + if err := checkName("path route set", name); err != nil { + return nil, err + } + + if len(in.PathRoutes) == 0 { + return nil, errMissing("pathRoutes is required") + } + + for _, r := range in.PathRoutes { + if r.Path == "" || r.BackendSetName == "" { + return nil, errMissing("each path route needs path and backendSetName") + } + + if !slices.Contains([]string{matchExact, matchLongest, matchPrefix, matchSuffix}, r.PathMatchType.MatchType) { + return nil, errParam( + "pathMatchType.matchType must be EXACT_MATCH, FORCE_LONGEST_PREFIX_MATCH, PREFIX_MATCH or SUFFIX_MATCH", + ) + } + } + + return &PathRouteSet{Name: name, PathRoutes: in.PathRoutes}, nil +} + +type routingPolicyIn struct { + Name string `json:"name"` + ConditionLanguageVersion string `json:"conditionLanguageVersion"` + Rules []RoutingRule `json:"rules"` +} + +func buildRoutingPolicy(name string, in *routingPolicyIn, keepVersion string) (*RoutingPolicy, *APIError) { + if err := checkName("routing policy", name); err != nil { + return nil, err + } + + version := in.ConditionLanguageVersion + if version == "" { + version = keepVersion + } + + if version != "V1" { + return nil, errParam("conditionLanguageVersion must be V1") + } + + if len(in.Rules) == 0 { + return nil, errMissing("rules is required") + } + + seen := map[string]bool{} + + for _, r := range in.Rules { + if err := checkName("routing rule", r.Name); err != nil { + return nil, err + } + + if seen[strs.Fold(r.Name)] { + return nil, errParam("duplicate routing rule " + r.Name) + } + + seen[strs.Fold(r.Name)] = true + + if _, err := parseCondition(r.Condition); err != nil { + return nil, errParam("rule " + r.Name + ": " + err.Error()) + } + + if len(r.Actions) != 1 || r.Actions[0].Name != "FORWARD_TO_BACKENDSET" || r.Actions[0].BackendSetName == "" { + return nil, errParam( + "rule " + r.Name + " needs exactly one FORWARD_TO_BACKENDSET action with backendSetName", + ) + } + } + + return &RoutingPolicy{Name: name, ConditionLanguageVersion: version, Rules: in.Rules}, nil +} + +type ruleSetIn struct { + Name string `json:"name"` + Items []Rule `json:"items"` +} + +func buildRuleSet(name string, in *ruleSetIn) (*RuleSet, *APIError) { + if err := checkName("rule set", name); err != nil { + return nil, err + } + + if len(in.Items) == 0 { + return nil, errMissing("items is required") + } + + items := make([]Rule, 0, len(in.Items)) + + for _, r := range in.Items { + nr, err := normalizeRule(r) + if err != nil { + return nil, err + } + + items = append(items, nr) + } + + return &RuleSet{Name: name, Items: items}, nil +} + +type cipherSuiteIn struct { + Name string `json:"name"` + Ciphers []string `json:"ciphers"` +} + +func buildCipherSuite(name string, in *cipherSuiteIn) (*SSLCipherSuite, *APIError) { + if err := checkName("SSL cipher suite", name); err != nil { + return nil, err + } + + if strings.HasPrefix(name, "oci-") { + return nil, errParam("the oci- prefix is reserved for built-in cipher suites") + } + + if len(in.Ciphers) == 0 { + return nil, errMissing("ciphers is required") + } + + for _, c := range in.Ciphers { + if _, ok := cipherIDs[c]; !ok { + return nil, errParam("cipher " + c + " is not supported by this emulator") + } + } + + return &SSLCipherSuite{Name: name, Ciphers: in.Ciphers}, nil +} diff --git a/services/ociloadbalancer/catalog.go b/services/ociloadbalancer/catalog.go new file mode 100644 index 0000000000..d6cf3ff98b --- /dev/null +++ b/services/ociloadbalancer/catalog.go @@ -0,0 +1,58 @@ +package ociloadbalancer + +import ( + "crypto/tls" + "slices" + "strconv" +) + +const ( + protoHTTP = "HTTP" + protoHTTP2 = "HTTP2" + protoTCP = "TCP" + + policyRoundRobin = "ROUND_ROBIN" + policyLeastConns = "LEAST_CONNECTIONS" + policyIPHash = "IP_HASH" + + shapeFlexible = "flexible" +) + +func itoa(n int) string { return strconv.Itoa(n) } + +//nolint:gochecknoglobals // immutable reference data +var ( + policyNames = []string{policyRoundRobin, policyLeastConns, policyIPHash} + protocolNames = []string{protoHTTP, protoHTTP2, protoTCP} + shapeNames = []string{"10Mbps", "10Mbps-Micro", "100Mbps", "400Mbps", "8000Mbps", shapeFlexible} + + builtinSuites = []string{ + "oci-default-ssl-cipher-suite-v1", "oci-modern-ssl-cipher-suite-v1", "oci-compatible-ssl-cipher-suite-v1", + "oci-wider-compatible-ssl-cipher-suite-v1", "oci-customized-ssl-cipher-suite", + "oci-default-http2-ssl-cipher-suite-v1", "oci-default-http2-tls-13-ssl-cipher-suite-v1", + "oci-default-http2-tls-12-13-ssl-cipher-suite-v1", "oci-tls-13-recommended-ssl-cipher-suite-v1", + } + + cipherIDs = map[string]uint16{ + "ECDHE-RSA-AES128-GCM-SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + "ECDHE-RSA-AES256-GCM-SHA384": tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + "ECDHE-ECDSA-AES128-GCM-SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + "ECDHE-ECDSA-AES256-GCM-SHA384": tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + "ECDHE-RSA-CHACHA20-POLY1305": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + "ECDHE-ECDSA-CHACHA20-POLY1305": tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + "ECDHE-RSA-AES128-SHA": tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + "ECDHE-RSA-AES256-SHA": tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + "ECDHE-ECDSA-AES128-SHA": tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + "ECDHE-ECDSA-AES256-SHA": tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + "AES128-GCM-SHA256": tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + "AES256-GCM-SHA384": tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + "AES128-SHA": tls.TLS_RSA_WITH_AES_128_CBC_SHA, + "AES256-SHA": tls.TLS_RSA_WITH_AES_256_CBC_SHA, + "ECDHE-RSA-AES128-SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + "ECDHE-ECDSA-AES128-SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, + } +) + +func isBuiltinSuite(name string) bool { + return slices.Contains(builtinSuites, name) +} diff --git a/services/ociloadbalancer/certs.go b/services/ociloadbalancer/certs.go new file mode 100644 index 0000000000..c5bc9f095b --- /dev/null +++ b/services/ociloadbalancer/certs.go @@ -0,0 +1,138 @@ +package ociloadbalancer + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "strings" +) + +type certIn struct { + Name string `json:"certificateName"` + Passphrase string `json:"passphrase"` + PrivateKey string `json:"privateKey"` + Public string `json:"publicCertificate"` + CA string `json:"caCertificate"` +} + +func countPEMCerts(data string) (int, bool) { + rest, n := []byte(data), 0 + + for { + var blk *pem.Block + + blk, rest = pem.Decode(rest) + if blk == nil { + return n, true + } + + if blk.Type != "CERTIFICATE" { + return n, false + } + + if _, err := x509.ParseCertificate(blk.Bytes); err != nil { + return n, false + } + + n++ + } +} + +func caChain(ca string) [][]byte { + rest, out := []byte(ca), [][]byte{} + + for { + var blk *pem.Block + + blk, rest = pem.Decode(rest) + if blk == nil { + return out + } + + out = append(out, blk.Bytes) + } +} + +func encryptedKey(key string) bool { + blk, _ := pem.Decode([]byte(key)) + + return blk != nil && + (blk.Type == "ENCRYPTED PRIVATE KEY" || strings.Contains(blk.Headers["Proc-Type"], "ENCRYPTED")) +} + +// buildCertificate validates a certificate bundle. The returned TLS key pair (nil without a private key) is +// kept in memory for listener TLS termination; the private key is never stored in the resource or returned. +func buildCertificate(in *certIn) (*Certificate, *tls.Certificate, *APIError) { + if err := checkName("certificate", in.Name); err != nil { + return nil, nil, err + } + + if in.Public == "" && in.CA == "" { + return nil, nil, errMissing("publicCertificate or caCertificate is required") + } + + if n, ok := countPEMCerts(in.CA); in.CA != "" && (!ok || n == 0) { + return nil, nil, errParam("caCertificate is not a valid PEM certificate bundle") + } + + if in.Public == "" { + if in.PrivateKey != "" { + return nil, nil, errParam("privateKey requires publicCertificate") + } + + return &Certificate{Name: in.Name, CA: in.CA}, nil, nil + } + + if n, ok := countPEMCerts(in.Public); !ok || n == 0 { + return nil, nil, errParam("publicCertificate is not a valid PEM certificate") + } + + cert := &Certificate{Name: in.Name, Public: in.Public, CA: in.CA} + if in.PrivateKey == "" { + return cert, nil, nil + } + + if encryptedKey(in.PrivateKey) { + return nil, nil, errUnsupported("passphrase-protected private keys") + } + + pair, err := tls.X509KeyPair([]byte(in.Public), []byte(in.PrivateKey)) + if err != nil { + return nil, nil, errParam("publicCertificate and privateKey are not a matching PEM pair: " + err.Error()) + } + + if len(pair.Certificate) == 1 { + pair.Certificate = append(pair.Certificate, caChain(in.CA)...) + } + + return cert, &pair, nil +} + +func (b *InMemoryBackend) createCertificate(lb *LoadBalancer, in *certIn) *APIError { + if _, dup := lb.Certificates[in.Name]; dup { + return errConflict("certificate " + in.Name + " already exists") + } + + cert, key, err := buildCertificate(in) + if err != nil { + return err + } + + lb.Certificates[in.Name] = cert + + if key != nil { + b.keys[lb.ID+"/"+in.Name] = key + } + + return nil +} + +func (b *InMemoryBackend) deleteCertificate(lb *LoadBalancer, name string) *APIError { + if _, ok := lb.Certificates[name]; !ok { + return errNotFound(name) + } + + delete(lb.Certificates, name) + + return nil +} diff --git a/services/ociloadbalancer/ciphers_internal_test.go b/services/ociloadbalancer/ciphers_internal_test.go new file mode 100644 index 0000000000..043ee3df40 --- /dev/null +++ b/services/ociloadbalancer/ciphers_internal_test.go @@ -0,0 +1,23 @@ +package ociloadbalancer + +import ( + "crypto/tls" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestApplyCiphersSkipsInsecureSuites(t *testing.T) { + t.Parallel() + + lb := &LoadBalancer{SSLCipherSuites: map[string]*SSLCipherSuite{ + "s": {Name: "s", Ciphers: []string{ + "ECDHE-RSA-AES128-SHA256", "ECDHE-ECDSA-AES128-SHA256", "ECDHE-RSA-AES128-GCM-SHA256", + }}, + }} + + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + applyCiphers(cfg, lb, "s") + + assert.Equal(t, []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, cfg.CipherSuites) +} diff --git a/services/ociloadbalancer/control_test.go b/services/ociloadbalancer/control_test.go new file mode 100644 index 0000000000..70642820a3 --- /dev/null +++ b/services/ociloadbalancer/control_test.go @@ -0,0 +1,435 @@ +package ociloadbalancer_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/loadbalancer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.backend.SetCompartmentChecker(func(id string) bool { return id == testCompartment }) + + tests := []struct { + mutate func(d *loadbalancer.CreateLoadBalancerDetails) + name string + code string + status int + }{ + { + func(d *loadbalancer.CreateLoadBalancerDetails) { d.ShapeDetails = nil }, + "flexible needs details", + "MissingParameter", + 400, + }, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.ShapeDetails = &loadbalancer.ShapeDetails{ + MinimumBandwidthInMbps: new(50), + MaximumBandwidthInMbps: new(10), + } + }, "min above max", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.ShapeDetails = &loadbalancer.ShapeDetails{ + MinimumBandwidthInMbps: new(10), + MaximumBandwidthInMbps: new(9000), + } + }, "max above limit", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.ShapeDetails = &loadbalancer.ShapeDetails{MinimumBandwidthInMbps: new(5), MaximumBandwidthInMbps: new(10)} + }, "min below limit", "InvalidParameter", 400}, + { + func(d *loadbalancer.CreateLoadBalancerDetails) { d.ShapeName = new("100Mbps") }, + "fixed shape with details", + "InvalidParameter", + 400, + }, + { + func(d *loadbalancer.CreateLoadBalancerDetails) { d.ShapeName = new("huge") }, + "unknown shape", + "InvalidParameter", + 400, + }, + { + func(d *loadbalancer.CreateLoadBalancerDetails) { d.SubnetIds = []string{} }, + "no subnets", + "MissingParameter", + 400, + }, + { + func(d *loadbalancer.CreateLoadBalancerDetails) { d.SubnetIds = []string{"a", "b", "c"} }, + "three subnets", + "InvalidParameter", + 400, + }, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.IsPrivate = new(true) + d.SubnetIds = []string{"a", "b"} + }, "private two subnets", "InvalidParameter", 400}, + { + func(d *loadbalancer.CreateLoadBalancerDetails) { d.CompartmentId = new(otherCompart) }, + "unknown compartment", + "NotAuthorizedOrNotFound", + 404, + }, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.ReservedIps = []loadbalancer.ReservedIp{{Id: new("x")}} + }, "reserved ips", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.BackendSets = map[string]loadbalancer.BackendSetDetails{ + "bad name": {Policy: new("ROUND_ROBIN"), HealthChecker: hc()}, + } + }, "backend set name with space", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.BackendSets = map[string]loadbalancer.BackendSetDetails{ + "bs": {Policy: new("RANDOM"), HealthChecker: hc()}, + } + }, "unknown policy", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": {Policy: new("ROUND_ROBIN"), + HealthChecker: &loadbalancer.HealthCheckerDetails{Protocol: new("ICMP")}}} + }, "unknown health protocol", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": { + DefaultBackendSetName: new("missing"), Port: new(80), Protocol: new("HTTP"), + }} + }, "listener unknown backend set", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.BackendSets = map[string]loadbalancer.BackendSetDetails{ + "bs": {Policy: new("ROUND_ROBIN"), HealthChecker: hc()}, + } + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": { + DefaultBackendSetName: new("bs"), Port: new(80), Protocol: new("HTTP2"), + }} + }, "http2 needs tls", "InvalidParameter", 400}, + {func(d *loadbalancer.CreateLoadBalancerDetails) { + d.BackendSets = map[string]loadbalancer.BackendSetDetails{ + "bs": {Policy: new("ROUND_ROBIN"), HealthChecker: hc()}, + } + d.Listeners = map[string]loadbalancer.ListenerDetails{ + "a": {DefaultBackendSetName: new("bs"), Port: new(80), Protocol: new("HTTP")}, + "b": {DefaultBackendSetName: new("bs"), Port: new(80), Protocol: new("HTTP")}, + } + }, "duplicate listener port", "Conflict", 409}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + d := baseDetails(tt.name) + tt.mutate(&d) + + _, err := e.lb.CreateLoadBalancer( + t.Context(), + loadbalancer.CreateLoadBalancerRequest{CreateLoadBalancerDetails: d}, + ) + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestLifecycleAndWorkRequests(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.backend.SetSubnetResolver(func(id string) (string, bool) { return "10.0.1.0/24", id == testSubnet }) + + d := baseDetails("lifecycle") + d.IsPrivate = new(true) + d.FreeformTags = map[string]string{"env": "test"} + id, wrID := e.create(t, d) + + got := e.get(t, id) + assert.Equal(t, loadbalancer.LoadBalancerLifecycleStateCreating, got.LifecycleState) + require.Len(t, got.IpAddresses, 1) + assert.Contains(t, *got.IpAddresses[0].IpAddress, "10.0.1.") + assert.False(t, *got.IpAddresses[0].IsPublic) + assert.NotNil(t, got.ETag) + + wr, err := e.lb.GetWorkRequest(t.Context(), loadbalancer.GetWorkRequestRequest{WorkRequestId: &wrID}) + require.NoError(t, err) + assert.Equal(t, loadbalancer.WorkRequestLifecycleStateInProgress, wr.LifecycleState) + assert.Equal(t, "CreateLoadBalancer", *wr.Type) + assert.Equal(t, id, *wr.LoadBalancerId) + + _, err = e.lb.UpdateLoadBalancer(t.Context(), loadbalancer.UpdateLoadBalancerRequest{ + LoadBalancerId: &id, DisplayName: new("x"), + }) + assert.Equal(t, "IncorrectState", svcErr(t, err).GetCode()) + + e.settle() + + got = e.get(t, id) + assert.Equal(t, loadbalancer.LoadBalancerLifecycleStateActive, got.LifecycleState) + + wr, err = e.lb.GetWorkRequest(t.Context(), loadbalancer.GetWorkRequestRequest{WorkRequestId: &wrID}) + require.NoError(t, err) + assert.Equal(t, loadbalancer.WorkRequestLifecycleStateSucceeded, wr.LifecycleState) + assert.NotNil(t, wr.TimeFinished) + + { // update + _, uerr := e.lb.UpdateLoadBalancer(t.Context(), loadbalancer.UpdateLoadBalancerRequest{ + LoadBalancerId: &id, IfMatch: new("stale"), + DisplayName: new("renamed"), + }) + require.Error(t, uerr) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, uerr).GetHTTPStatusCode()) + + resp, uerr := e.lb.UpdateLoadBalancer(t.Context(), loadbalancer.UpdateLoadBalancerRequest{ + LoadBalancerId: &id, IfMatch: got.ETag, + DisplayName: new("renamed"), IsDeleteProtectionEnabled: new(true), + }) + require.NoError(t, uerr) + assert.NotEmpty(t, *resp.OpcWorkRequestId) + + _, uerr = e.lb.UpdateLoadBalancer(t.Context(), loadbalancer.UpdateLoadBalancerRequest{ + LoadBalancerId: &id, DisplayName: new("again"), + }) + assert.Equal(t, "Conflict", svcErr(t, uerr).GetCode()) + + e.settle() + + after := e.get(t, id) + assert.Equal(t, "renamed", *after.DisplayName) + assert.NotEqual(t, *got.ETag, *after.ETag) + assert.Equal(t, "test", after.FreeformTags["env"]) + } + + { // delete protection then delete + _, derr := e.lb.DeleteLoadBalancer(t.Context(), loadbalancer.DeleteLoadBalancerRequest{LoadBalancerId: &id}) + assert.Equal(t, http.StatusConflict, svcErr(t, derr).GetHTTPStatusCode()) + + _, derr = e.lb.UpdateLoadBalancer(t.Context(), loadbalancer.UpdateLoadBalancerRequest{ + LoadBalancerId: &id, IsDeleteProtectionEnabled: new(false), + }) + require.NoError(t, derr) + e.settle() + + _, derr = e.lb.DeleteLoadBalancer(t.Context(), loadbalancer.DeleteLoadBalancerRequest{LoadBalancerId: &id}) + require.NoError(t, derr) + assert.Equal(t, loadbalancer.LoadBalancerLifecycleStateDeleting, e.get(t, id).LifecycleState) + + e.settle() + assert.Equal(t, loadbalancer.LoadBalancerLifecycleStateDeleted, e.get(t, id).LifecycleState) + + list, lerr := e.lb.ListLoadBalancers( + t.Context(), + loadbalancer.ListLoadBalancersRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, lerr) + assert.Empty(t, list.Items) + + list, lerr = e.lb.ListLoadBalancers(t.Context(), loadbalancer.ListLoadBalancersRequest{ + CompartmentId: new(testCompartment), LifecycleState: loadbalancer.LoadBalancerLifecycleStateDeleted, + }) + require.NoError(t, lerr) + assert.Len(t, list.Items, 1) + + wrs, lerr := e.lb.ListWorkRequests(t.Context(), loadbalancer.ListWorkRequestsRequest{LoadBalancerId: &id}) + require.NoError(t, lerr) + assert.GreaterOrEqual(t, len(wrs.Items), 4) + } +} + +func TestListFiltersAndCatalogs(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkLB(t, baseDetails("alpha")) + e.mkLB(t, baseDetails("beta")) + + other := baseDetails("gamma") + other.CompartmentId = new(otherCompart) + e.mkLB(t, other) + + t.Run("by compartment", func(t *testing.T) { + t.Parallel() + + resp, err := e.lb.ListLoadBalancers(t.Context(), loadbalancer.ListLoadBalancersRequest{ + CompartmentId: new(testCompartment), SortBy: loadbalancer.ListLoadBalancersSortByDisplayname, + SortOrder: loadbalancer.ListLoadBalancersSortOrderAsc, + }) + require.NoError(t, err) + require.Len(t, resp.Items, 2) + assert.Equal(t, "alpha", *resp.Items[0].DisplayName) + assert.Equal(t, "beta", *resp.Items[1].DisplayName) + }) + + t.Run("by name and paging", func(t *testing.T) { + t.Parallel() + + resp, err := e.lb.ListLoadBalancers(t.Context(), loadbalancer.ListLoadBalancersRequest{ + CompartmentId: new(testCompartment), DisplayName: new("beta"), + }) + require.NoError(t, err) + assert.Len(t, resp.Items, 1) + + resp, err = e.lb.ListLoadBalancers(t.Context(), loadbalancer.ListLoadBalancersRequest{ + CompartmentId: new(testCompartment), Limit: new(int64(1)), + }) + require.NoError(t, err) + assert.Len(t, resp.Items, 1) + assert.NotNil(t, resp.OpcNextPage) + }) + + t.Run("missing compartment", func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, e.base+"/20170115/loadBalancers") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") + }) + + t.Run("catalogs", func(t *testing.T) { + t.Parallel() + + shapes, err := e.lb.ListShapes(t.Context(), loadbalancer.ListShapesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + names := make([]string, 0, len(shapes.Items)) + for _, s := range shapes.Items { + names = append(names, *s.Name) + } + + assert.Contains(t, names, "flexible") + + pol, err := e.lb.ListPolicies( + t.Context(), + loadbalancer.ListPoliciesRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Len(t, pol.Items, 3) + + proto, err := e.lb.ListProtocols( + t.Context(), + loadbalancer.ListProtocolsRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Len(t, proto.Items, 3) + }) + + t.Run("unknown load balancer", func(t *testing.T) { + t.Parallel() + + _, err := e.lb.GetLoadBalancer(t.Context(), loadbalancer.GetLoadBalancerRequest{LoadBalancerId: new("nope")}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + }) +} + +func TestShapeAndNSGUpdates(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + id := e.mkLB(t, baseDetails("shape")) + + tests := []struct { + details loadbalancer.UpdateLoadBalancerShapeDetails + name string + wantErr bool + }{ + {name: "widen flexible", details: loadbalancer.UpdateLoadBalancerShapeDetails{ + ShapeName: new("flexible"), + ShapeDetails: &loadbalancer.ShapeDetails{MinimumBandwidthInMbps: new(20), MaximumBandwidthInMbps: new(500)}, + }}, + {name: "bad flexible", wantErr: true, details: loadbalancer.UpdateLoadBalancerShapeDetails{ + ShapeName: new("flexible"), + ShapeDetails: &loadbalancer.ShapeDetails{MinimumBandwidthInMbps: new(20), MaximumBandwidthInMbps: new(10)}, + }}, + {name: "fixed shape", details: loadbalancer.UpdateLoadBalancerShapeDetails{ShapeName: new("400Mbps")}}, + } + + for _, tt := range tests { + e.settle() + + _, err := e.lb.UpdateLoadBalancerShape(t.Context(), loadbalancer.UpdateLoadBalancerShapeRequest{ + LoadBalancerId: &id, UpdateLoadBalancerShapeDetails: tt.details, + }) + if tt.wantErr { + require.Error(t, err, tt.name) + + continue + } + + require.NoError(t, err, tt.name) + e.settle() + assert.Equal(t, *tt.details.ShapeName, *e.get(t, id).ShapeName, tt.name) + } + + e.settle() + + _, err := e.lb.UpdateNetworkSecurityGroups(t.Context(), loadbalancer.UpdateNetworkSecurityGroupsRequest{ + LoadBalancerId: &id, + NetworkSecurityGroupIds: []string{"nsg1"}, + }) + require.NoError(t, err) + e.settle() + assert.Equal(t, []string{"nsg1"}, e.get(t, id).NetworkSecurityGroupIds) + + e.settle() + + _, err = e.lb.ChangeLoadBalancerCompartment(t.Context(), loadbalancer.ChangeLoadBalancerCompartmentRequest{ + LoadBalancerId: &id, CompartmentId: new(otherCompart), + }) + require.NoError(t, err) + e.settle() + assert.Equal(t, otherCompart, *e.get(t, id).CompartmentId) +} + +func TestAuthEnforcement(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts envOpts + wantCode int + }{ + {"registered key accepted", envOpts{enforce: true, register: true}, http.StatusOK}, + {"unregistered key rejected", envOpts{enforce: true}, http.StatusUnauthorized}, + {"enforcement off accepts any", envOpts{}, http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, tt.opts) + _, err := e.lb.ListLoadBalancers( + t.Context(), + loadbalancer.ListLoadBalancersRequest{CompartmentId: new(testCompartment)}, + ) + + if tt.wantCode == http.StatusOK { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.wantCode, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + t.Run("unsigned", func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + status, body := rawDo(t, e.base+"/20170115/loadBalancers?compartmentId=x") + assert.Equal(t, http.StatusUnauthorized, status) + assert.Contains(t, body, "NotAuthenticated") + }) + + t.Run("unknown route", func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + status, body := rawDo(t, e.base+"/20170115/nothing") + assert.Equal(t, http.StatusNotFound, status) + assert.Contains(t, body, "NotFound") + }) +} diff --git a/services/ociloadbalancer/dataplane.go b/services/ociloadbalancer/dataplane.go new file mode 100644 index 0000000000..423f220c49 --- /dev/null +++ b/services/ociloadbalancer/dataplane.go @@ -0,0 +1,588 @@ +package ociloadbalancer + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + loopbackHost = "127.0.0.1" + defaultHTTPIdle = 60 + defaultTCPIdle = 300 + kindHTTP = "http" + kindTCP = "tcp" +) + +// portSpec is what a data-plane port must look like for the current load balancer configuration. +type portSpec struct { + lbID string + kind string + port int + tls bool +} + +func (s portSpec) key() string { return s.lbID + "|" + strconv.Itoa(s.port) } + +type portListener struct { + ln net.Listener + srv *http.Server + conns map[net.Conn]struct{} + stop context.CancelFunc + addr string + spec portSpec + wg sync.WaitGroup +} + +type transportEntry struct { + rt *http.Transport + fingerprint string +} + +// dataPlane owns the listening sockets and the runtime state (health, counters, balancing cursors). +type dataPlane struct { + b *InMemoryBackend + mu *lockmetrics.RWMutex + syncMu *lockmetrics.RWMutex + ports map[string]*portListener + health map[string]*hstate + conns map[string]*atomic.Int64 + cursors map[string]int + transports map[string]*transportEntry + dialer *net.Dialer + baseCtx context.Context //nolint:containedctx // process-lifetime context for accepted connections +} + +func newDataPlane(b *InMemoryBackend) *dataPlane { + d := &dataPlane{ + b: b, mu: lockmetrics.New("ociloadbalancer.dataplane"), syncMu: lockmetrics.New("ociloadbalancer.sync"), + baseCtx: context.Background(), + } + d.dialer = httpclient.LoopbackDialer(b.externalAllowed, ErrDestinationNotPermitted) + d.initMaps() + + return d +} + +func (d *dataPlane) initMaps() { + d.ports, d.health = map[string]*portListener{}, map[string]*hstate{} + d.conns, d.cursors = map[string]*atomic.Int64{}, map[string]int{} + d.transports = map[string]*transportEntry{} +} + +// reset closes every listener and clears runtime state. +func (d *dataPlane) reset() { + d.syncMu.Lock("reset") + defer d.syncMu.Unlock() + + d.mu.Lock("reset") + open := d.ports + trs := d.transports + d.initMaps() + d.mu.Unlock() + + for _, p := range open { + d.closePort(p) + } + + for _, t := range trs { + t.rt.CloseIdleConnections() + } +} + +// address returns the host:port a load balancer listener port is reachable on, or "" while it is not serving. +func (d *dataPlane) address(lbID string, port int) string { + d.mu.RLock("address") + defer d.mu.RUnlock() + + if p := d.ports[portSpec{lbID: lbID, port: port}.key()]; p != nil { + return p.addr + } + + return "" +} + +// Endpoint is one serving data-plane listener. +type Endpoint struct { + LoadBalancerID string `json:"loadBalancerId"` + Listener string `json:"listener"` + Protocol string `json:"protocol"` + Address string `json:"address"` + Port int `json:"port"` +} + +// Endpoints lists the loopback addresses the active listeners are reachable on. +func (b *InMemoryBackend) Endpoints() []Endpoint { + b.mu.RLock("Endpoints") + defer b.mu.RUnlock() + + out := []Endpoint{} + + for _, lb := range b.lbs { + for _, l := range lb.Listeners { + if addr := b.dp.address(lb.ID, l.Port); addr != "" { + out = append(out, Endpoint{ + LoadBalancerID: lb.ID, Listener: l.Name, Protocol: l.Protocol, Address: addr, Port: l.Port, + }) + } + } + } + + return out +} + +func (b *InMemoryBackend) desiredPorts() map[string]portSpec { + b.mu.RLock("desiredPorts") + defer b.mu.RUnlock() + + want := map[string]portSpec{} + + for _, lb := range b.lbs { + if lb.State != stateActive { + continue + } + + for _, l := range lb.Listeners { + s := portSpec{lbID: lb.ID, port: l.Port, tls: l.SSL != nil, kind: kindHTTP} + if l.Protocol == protoTCP { + s.kind = kindTCP + } + + want[s.key()] = s + } + } + + return want +} + +// sync opens and closes listening sockets so they match the active load balancers. +func (d *dataPlane) sync() { + want := d.b.desiredPorts() + + d.syncMu.Lock("sync") + defer d.syncMu.Unlock() + + d.mu.Lock("sync") + + var stale []*portListener + + for k, p := range d.ports { + if w, ok := want[k]; !ok || w != p.spec { + stale = append(stale, p) + delete(d.ports, k) + } + } + + var open []portSpec + + for k, w := range want { + if d.ports[k] == nil { + open = append(open, w) + } + } + + d.mu.Unlock() + + for _, p := range stale { + d.closePort(p) + } + + for _, w := range open { + p, err := d.open(w) + if err != nil { + logger.Load(d.baseCtx). + ErrorContext(d.baseCtx, "ociloadbalancer: open listener", "port", w.port, "error", err) + + continue + } + + d.mu.Lock("sync") + d.ports[w.key()] = p + d.mu.Unlock() + } + + d.prune() +} + +// prune forgets runtime state of load balancers that are no longer active. +func (d *dataPlane) prune() { + live := d.b.liveIDs() + + d.mu.Lock("prune") + defer d.mu.Unlock() + + for k := range d.health { + if !live[lbOfKey(k)] { + delete(d.health, k) + } + } + + for k := range d.conns { + if !live[lbOfKey(k)] { + delete(d.conns, k) + } + } + + for k := range d.cursors { + if !live[lbOfKey(k)] { + delete(d.cursors, k) + } + } + + for k, t := range d.transports { + if !live[lbOfKey(k)] { + t.rt.CloseIdleConnections() + delete(d.transports, k) + } + } +} + +func lbOfKey(k string) string { + if before, _, ok := strings.Cut(k, "|"); ok { + return before + } + + return k +} + +func (b *InMemoryBackend) liveIDs() map[string]bool { + b.mu.RLock("liveIDs") + defer b.mu.RUnlock() + + out := make(map[string]bool, len(b.lbs)) + + for id, lb := range b.lbs { + if lb.State == stateActive { + out[id] = true + } + } + + return out +} + +func (d *dataPlane) open(s portSpec) (*portListener, error) { + var lc net.ListenConfig + + ln, err := lc.Listen(d.baseCtx, "tcp", net.JoinHostPort(loopbackHost, "0")) + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + + ctx, cancel := context.WithCancel(d.baseCtx) + p := &portListener{ln: ln, addr: ln.Addr().String(), spec: s, stop: cancel, conns: map[net.Conn]struct{}{}} + + if s.kind == kindHTTP { + d.serveHTTPPort(p) + + return p, nil + } + + var accept = ln + if s.tls { + accept = tls.NewListener( + ln, + &tls.Config{GetConfigForClient: d.frontTLS(s.lbID, s.port), MinVersion: tls.VersionTLS12}, + ) + } + + p.wg.Go(func() { d.acceptTCP(ctx, p, accept) }) + + return p, nil +} + +func (d *dataPlane) serveHTTPPort(p *portListener) { + s := p.spec + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { d.serveHTTP(w, r, s) }), + ReadHeaderTimeout: readHeaderTimeout, + ErrorLog: nil, + } + p.srv = srv + + p.wg.Go(func() { + var err error + if s.tls { + srv.TLSConfig = &tls.Config{GetConfigForClient: d.frontTLS(s.lbID, s.port), MinVersion: tls.VersionTLS12} + err = srv.ServeTLS(p.ln, "", "") + } else { + err = srv.Serve(p.ln) + } + + if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) { + logger.Load(d.baseCtx).ErrorContext(d.baseCtx, "ociloadbalancer: listener stopped", "error", err) + } + }) +} + +func (d *dataPlane) closePort(p *portListener) { + p.stop() + + if p.srv != nil { + _ = p.srv.Close() + } + + _ = p.ln.Close() + d.closeConns(p) + p.wg.Wait() +} + +// closeConns closes every accepted TCP connection of a port. +func (d *dataPlane) closeConns(p *portListener) { + d.mu.Lock("closeConns") + conns := make([]net.Conn, 0, len(p.conns)) + + for c := range p.conns { + conns = append(conns, c) + } + + d.mu.Unlock() + + for _, c := range conns { + _ = c.Close() + } +} + +func (d *dataPlane) track(p *portListener, c net.Conn) { + d.mu.Lock("track") + p.conns[c] = struct{}{} + d.mu.Unlock() +} + +func (d *dataPlane) untrack(p *portListener, c net.Conn) { + d.mu.Lock("untrack") + delete(p.conns, c) + d.mu.Unlock() +} + +// counter returns the live connection counter for a backend. +func (d *dataPlane) counter(key string) *atomic.Int64 { + d.mu.RLock("counter") + c := d.conns[key] + d.mu.RUnlock() + + if c != nil { + return c + } + + d.mu.Lock("counter") + defer d.mu.Unlock() + + if c = d.conns[key]; c == nil { + c = &atomic.Int64{} + d.conns[key] = c + } + + return c +} + +// transportFor returns the pooled transport for a backend set, rebuilt when its TLS configuration changes. +// Callers hold the backend read lock. +func (d *dataPlane) transportFor(lb *LoadBalancer, bs *BackendSet) *http.Transport { + key := lb.ID + "|" + bs.Name + fp := transportFingerprint(lb, bs) + + d.mu.RLock("transportFor") + cur := d.transports[key] + d.mu.RUnlock() + + if cur != nil && cur.fingerprint == fp { + return cur.rt + } + + tr := httpclient.NewTransport() + tr.Proxy, tr.DialContext, tr.ForceAttemptHTTP2 = nil, d.dialer.DialContext, false + tr.TLSClientConfig = d.b.backendTLS(lb, bs.SSL) + + d.mu.Lock("transportFor") + old := d.transports[key] + d.transports[key] = &transportEntry{rt: tr, fingerprint: fp} + d.mu.Unlock() + + if old != nil { + old.rt.CloseIdleConnections() + } + + return tr +} + +func transportFingerprint(lb *LoadBalancer, bs *BackendSet) string { + if bs.SSL == nil { + return "plain" + } + + fp := fmt.Sprintf("%v|%v|%s|%v|%v", boolOr(bs.SSL.VerifyPeerCertificate), bs.SSL.Protocols, bs.SSL.CipherSuiteName, + bs.SSL.CertificateName, len(lb.Certificates)) + if c := lb.Certificates[bs.SSL.CertificateName]; c != nil { + fp += "|" + c.CA + "|" + c.Public + } + + if cs := lb.SSLCipherSuites[bs.SSL.CipherSuiteName]; cs != nil { + fp += fmt.Sprint(cs.Ciphers) + } + + return fp +} + +var errBackendTLS = errors.New("backend TLS configuration") + +// backendTLS builds the TLS client configuration for connections to a backend set. The peer chain and its IP +// address are always verified (never InsecureSkipVerify): against the bundle named by certificateName when one +// is given, otherwise against the system roots. +func (b *InMemoryBackend) backendTLS(lb *LoadBalancer, s *SSLConfig) *tls.Config { + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + if s == nil { + return cfg + } + + applyProtocols(cfg, s.Protocols) + applyCiphers(cfg, lb, s.CipherSuiteName) + + c := lb.Certificates[s.CertificateName] + if c == nil { + return cfg + } + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM([]byte(c.CA)) + pool.AppendCertsFromPEM([]byte(c.Public)) + cfg.RootCAs = pool + + if key := b.keys[lb.ID+"/"+s.CertificateName]; key != nil { + cfg.Certificates = []tls.Certificate{*key} + } + + return cfg +} + +func applyProtocols(cfg *tls.Config, protocols []string) { + if len(protocols) == 0 { + return + } + + cfg.MinVersion, cfg.MaxVersion = tls.VersionTLS13, tls.VersionTLS12 + + for _, p := range protocols { + if p == "TLSv1.2" && cfg.MinVersion > tls.VersionTLS12 { + cfg.MinVersion = tls.VersionTLS12 + } + + if p == "TLSv1.3" { + cfg.MaxVersion = tls.VersionTLS13 + } + } + + if cfg.MaxVersion < cfg.MinVersion { + cfg.MaxVersion = cfg.MinVersion + } +} + +func applyCiphers(cfg *tls.Config, lb *LoadBalancer, suite string) { + cs := lb.SSLCipherSuites[suite] + if cs == nil { + return + } + + for _, c := range cs.Ciphers { + id, ok := secureSuiteID(cipherIDs[c]) + if !ok { + logger.Load(context.Background()).Debug("ociloadbalancer: insecure cipher not served", "cipher", c) + + continue + } + + cfg.CipherSuites = append(cfg.CipherSuites, id) + } +} + +func secureSuiteID(id uint16) (uint16, bool) { + for _, s := range tls.CipherSuites() { + if s.ID == id { + return s.ID, true + } + } + + return 0, false +} + +// frontTLS returns the per-handshake TLS configuration of a listener port: the listener is chosen by SNI and +// serves its certificate bundle, protocols, cipher suite and ALPN. +func (d *dataPlane) frontTLS(lbID string, port int) func(*tls.ClientHelloInfo) (*tls.Config, error) { + return func(hi *tls.ClientHelloInfo) (*tls.Config, error) { + lb := d.b.activeLB(lbID) + if lb == nil { + return nil, errBackendTLS + } + + l := pickListener(lb, port, hi.ServerName) + if l == nil || l.SSL == nil { + return nil, errBackendTLS + } + + d.b.mu.RLock("frontTLS") + key := d.b.certKey(lbID, l.SSL.CertificateName) + d.b.mu.RUnlock() + + if key == nil { + return nil, fmt.Errorf("%w: certificate %s has no private key", errBackendTLS, l.SSL.CertificateName) + } + + cfg := &tls.Config{ + Certificates: []tls.Certificate{*key}, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"http/1.1"}, + } + if l.Protocol == protoHTTP2 { + cfg.NextProtos = []string{"h2", "http/1.1"} + } + + if l.Protocol == protoTCP { + cfg.NextProtos = nil + } + + if len(l.SSL.Protocols) == 0 { + cfg.MaxVersion = tls.VersionTLS12 + } + + applyProtocols(cfg, l.SSL.Protocols) + applyCiphers(cfg, lb, l.SSL.CipherSuiteName) + applyClientAuth(cfg, lb, l.SSL) + + return cfg, nil + } +} + +func applyClientAuth(cfg *tls.Config, lb *LoadBalancer, s *SSLConfig) { + if !boolOr(s.VerifyPeerCertificate) { + return + } + + if c := lb.Certificates[s.CertificateName]; c != nil { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM([]byte(c.CA)) + cfg.ClientCAs, cfg.ClientAuth = pool, tls.RequireAndVerifyClientCert + } +} + +// activeLB returns the active load balancer, which is immutable once published. +func (b *InMemoryBackend) activeLB(id string) *LoadBalancer { + b.mu.RLock("activeLB") + defer b.mu.RUnlock() + + if lb := b.lbs[id]; lb != nil && lb.State == stateActive { + return lb + } + + return nil +} diff --git a/services/ociloadbalancer/dataplane_test.go b/services/ociloadbalancer/dataplane_test.go new file mode 100644 index 0000000000..ed58a04c12 --- /dev/null +++ b/services/ociloadbalancer/dataplane_test.go @@ -0,0 +1,710 @@ +package ociloadbalancer_test + +import ( + "crypto/tls" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/loadbalancer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const httpPort = 80 + +func listener() loadbalancer.ListenerDetails { + return loadbalancer.ListenerDetails{DefaultBackendSetName: new("bs"), Port: new(httpPort), Protocol: new("HTTP")} +} + +func tally(t *testing.T, e *env, lbID string, n int) map[string]int { + t.Helper() + + out := map[string]int{} + + for range n { + r := e.call(t, lbID, httpPort, "", "/x", nil) + require.Equal(t, http.StatusOK, r.status) + out[r.header.Get("X-Backend")]++ + } + + return out +} + +func TestBalancingPolicies(t *testing.T) { + t.Parallel() + + tests := []struct { + want map[string]int + name string + policy string + wa int + wb int + n int + distinct int + }{ + {name: "round robin", policy: "ROUND_ROBIN", wa: 1, wb: 1, n: 6, want: map[string]int{"a": 3, "b": 3}}, + {name: "weighted round robin", policy: "ROUND_ROBIN", wa: 3, wb: 1, n: 8, want: map[string]int{"a": 6, "b": 2}}, + {name: "ip hash", policy: "IP_HASH", wa: 1, wb: 1, n: 5, distinct: 1}, + {name: "least connections", policy: "LEAST_CONNECTIONS", wa: 1, wb: 1, n: 4, distinct: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a, b := startBackend(t, "a"), startBackend(t, "b") + set := backendSet(tt.policy, a, b) + set.Backends[0].Weight, set.Backends[1].Weight = new(tt.wa), new(tt.wb) + + d := baseDetails(tt.name) + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": set} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id := e.mkLB(t, d) + + got := tally(t, e, id, tt.n) + if tt.want != nil { + assert.Equal(t, tt.want, got) + + return + } + + assert.LessOrEqual(t, len(got), tt.distinct) + assert.Equal(t, tt.n, got["a"]+got["b"]) + }) + } +} + +func TestHealthCheckFailover(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a, b := startBackend(t, "a"), startBackend(t, "b") + d := baseDetails("failover") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": backendSet("ROUND_ROBIN", a, b)} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id := e.mkLB(t, d) + + assert.Equal(t, map[string]int{"a": 2, "b": 2}, tally(t, e, id, 4)) + + health := func() loadbalancer.BackendSetHealthStatusEnum { + resp, err := e.lb.GetBackendSetHealth(t.Context(), loadbalancer.GetBackendSetHealthRequest{ + LoadBalancerId: &id, BackendSetName: new("bs"), + }) + require.NoError(t, err) + + return resp.Status + } + + assert.Equal(t, loadbalancer.BackendSetHealthStatusOk, health()) + + a.healthy.Store(false) + e.clock.Advance(2 * time.Second) + + assert.Equal(t, map[string]int{"b": 4}, tally(t, e, id, 4)) + assert.Equal(t, loadbalancer.BackendSetHealthStatusWarning, health()) + + bname := a.ip() + ":" + itoa(a.port()) + bh, err := e.lb.GetBackendHealth(t.Context(), loadbalancer.GetBackendHealthRequest{ + LoadBalancerId: &id, BackendSetName: new("bs"), BackendName: &bname, + }) + require.NoError(t, err) + assert.Equal(t, loadbalancer.BackendHealthStatusCritical, bh.Status) + require.Len(t, bh.HealthCheckResults, 1) + assert.Equal( + t, + loadbalancer.HealthCheckResultHealthCheckStatusInvalidStatusCode, + bh.HealthCheckResults[0].HealthCheckStatus, + ) + + lbh, err := e.lb.GetLoadBalancerHealth(t.Context(), loadbalancer.GetLoadBalancerHealthRequest{LoadBalancerId: &id}) + require.NoError(t, err) + assert.Equal(t, loadbalancer.LoadBalancerHealthStatusWarning, lbh.Status) + assert.Equal(t, []string{"bs"}, lbh.WarningStateBackendSetNames) + + summaries, err := e.lb.ListLoadBalancerHealths( + t.Context(), + loadbalancer.ListLoadBalancerHealthsRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + require.Len(t, summaries.Items, 1) + + a.healthy.Store(true) + e.clock.Advance(2 * time.Second) + + assert.Equal(t, map[string]int{"a": 2, "b": 2}, tally(t, e, id, 4)) + assert.Equal(t, loadbalancer.BackendSetHealthStatusOk, health()) + + b.srv.Close() + a.srv.Close() + e.clock.Advance(2 * time.Second) + + r := e.call(t, id, httpPort, "", "/x", nil) + assert.Equal(t, http.StatusServiceUnavailable, r.status) + assert.Equal(t, loadbalancer.BackendSetHealthStatusCritical, health()) +} + +func TestBackupDrainOffline(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a, b, c := startBackend(t, "a"), startBackend(t, "b"), startBackend(t, "c") + set := backendSet("ROUND_ROBIN", a, b, c) + set.Backends[1].Backup = new(true) + set.Backends[2].Offline = new(true) + + d := baseDetails("roles") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": set} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id := e.mkLB(t, d) + + assert.Equal(t, map[string]int{"a": 3}, tally(t, e, id, 3)) + + a.healthy.Store(false) + e.clock.Advance(2 * time.Second) + assert.Equal(t, map[string]int{"b": 3}, tally(t, e, id, 3)) +} + +func TestRoutingByPathPolicyAndHostname(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + web, api, admin := startBackend(t, "web"), startBackend(t, "api"), startBackend(t, "admin") + d := baseDetails("routes") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{ + "web": backendSet( + "ROUND_ROBIN", + web, + ), + "api": backendSet("ROUND_ROBIN", api), + "admin": backendSet("ROUND_ROBIN", admin), + } + d.Hostnames = map[string]loadbalancer.HostnameDetails{ + "hApi": {Name: new("hApi"), Hostname: new("api.example.com")}, + "hAdmin": {Name: new("hAdmin"), Hostname: new("*.admin.example.com")}, + } + d.PathRouteSets = map[string]loadbalancer.PathRouteSetDetails{ + "paths": {PathRoutes: []loadbalancer.PathRoute{ + prefix("/api", "api"), + { + Path: new("/exact"), + BackendSetName: new("admin"), + PathMatchType: &loadbalancer.PathMatchType{MatchType: loadbalancer.PathMatchTypeMatchTypeExactMatch}, + }, + { + Path: new(".css"), + BackendSetName: new("admin"), + PathMatchType: &loadbalancer.PathMatchType{MatchType: loadbalancer.PathMatchTypeMatchTypeSuffixMatch}, + }, + }}, + } + d.Listeners = map[string]loadbalancer.ListenerDetails{ + "vhostApi": { + DefaultBackendSetName: new("api"), + Port: new(81), + Protocol: new("HTTP"), + HostnameNames: []string{"hApi"}, + }, + "vhostAdmin": { + DefaultBackendSetName: new("admin"), + Port: new(81), + Protocol: new("HTTP"), + HostnameNames: []string{"hAdmin"}, + }, + "paths": { + DefaultBackendSetName: new("web"), + Port: new(80), + Protocol: new("HTTP"), + PathRouteSetName: new("paths"), + }, + } + id := e.mkLB(t, d) + + tests := []struct { + name string + host string + path string + want string + port int + status int + }{ + {name: "default set", port: 80, host: "", path: "/home", want: "web", status: 200}, + {name: "prefix route", port: 80, host: "", path: "/API/v1", want: "api", status: 200}, + {name: "exact route", port: 80, host: "", path: "/exact", want: "admin", status: 200}, + {name: "suffix route", port: 80, host: "", path: "/site/app.css", want: "admin", status: 200}, + {name: "exact miss falls to default", port: 80, host: "", path: "/exact/more", want: "web", status: 200}, + {name: "exact hostname", port: 81, host: "api.example.com", path: "/", want: "api", status: 200}, + {name: "wildcard hostname", port: 81, host: "x.admin.example.com", path: "/", want: "admin", status: 200}, + {name: "hostname with port", port: 81, host: "api.example.com:81", path: "/", want: "api", status: 200}, + {name: "unmatched hostname", port: 81, host: "other.example.com", path: "/", want: "", status: 404}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := e.call(t, id, tt.port, tt.host, tt.path, nil) + assert.Equal(t, tt.status, r.status) + + if tt.want != "" { + assert.Equal(t, tt.want, r.header.Get("X-Backend")) + } + }) + } + + t.Run("routing policy", func(t *testing.T) { + e.settle() + + _, err := e.lb.CreateRoutingPolicy(t.Context(), loadbalancer.CreateRoutingPolicyRequest{ + LoadBalancerId: &id, + Name: new( + "rp", + ), + ConditionLanguageVersion: loadbalancer.CreateRoutingPolicyDetailsConditionLanguageVersionV1, + Rules: []loadbalancer.RoutingRule{ + { + Name: new("byHeader"), + Condition: new( + "any(http.request.headers[(i 'X-Tier')] eq 'gold', http.request.url.query['tier'] eq 'gold')", + ), + Actions: []loadbalancer.Action{loadbalancer.ForwardToBackendSet{BackendSetName: new("api")}}, + }, + { + Name: new("byPath"), + Condition: new("all(http.request.url.path ew '.png', http.request.method eq 'GET')"), + Actions: []loadbalancer.Action{loadbalancer.ForwardToBackendSet{BackendSetName: new("admin")}}, + }, + }, + }) + require.NoError(t, err) + e.settle() + + _, err = e.lb.CreateListener(t.Context(), loadbalancer.CreateListenerRequest{ + LoadBalancerId: &id, + Name: new( + "policy", + ), + DefaultBackendSetName: new("web"), + Port: new(82), + Protocol: new("HTTP"), + RoutingPolicyName: new("rp"), + }) + require.NoError(t, err) + e.settle() + + tests := []struct { + path string + headers map[string]string + want string + }{ + {"/", map[string]string{"x-tier": "gold"}, "api"}, + {"/?tier=gold", nil, "api"}, + {"/logo.png", nil, "admin"}, + {"/logo.png", map[string]string{"X-Tier": "gold"}, "api"}, + {"/other", nil, "web"}, + } + for _, c := range tests { + r := e.call(t, id, 82, "", c.path, c.headers) + assert.Equal(t, c.want, r.header.Get("X-Backend"), c.path) + } + }) +} + +func TestRuleSets(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a := startBackend(t, "a") + d := baseDetails("rules") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": backendSet("ROUND_ROBIN", a)} + d.RuleSets = map[string]loadbalancer.RuleSetDetails{ + "headers": {Items: []loadbalancer.Rule{ + loadbalancer.AddHttpRequestHeaderRule{Header: new("X-Added"), Value: new("yes")}, + loadbalancer.ExtendHttpRequestHeaderValueRule{Header: new("X-Added"), Prefix: new("<"), Suffix: new(">")}, + loadbalancer.AddHttpResponseHeaderRule{Header: new("X-Resp"), Value: new("lb")}, + loadbalancer.RemoveHttpResponseHeaderRule{Header: new("Set-Cookie")}, + }}, + "redirects": {Items: []loadbalancer.Rule{ + loadbalancer.RedirectRule{ + Conditions: []loadbalancer.RuleCondition{loadbalancer.PathMatchCondition{ + AttributeValue: new("/old"), Operator: loadbalancer.PathMatchConditionOperatorPrefixMatch, + }}, + ResponseCode: new(301), + RedirectUri: &loadbalancer.RedirectUri{ + Protocol: new("https"), + Host: new("new.example.com"), + Port: new(8443), + Path: new("/moved/{path}"), + }, + }, + }}, + "methods": {Items: []loadbalancer.Rule{ + loadbalancer.ControlAccessUsingHttpMethodsRule{ + AllowedMethods: []string{"GET", "HEAD"}, + StatusCode: new(405), + }, + }}, + "allow": {Items: []loadbalancer.Rule{ + loadbalancer.AllowRule{ + Conditions: []loadbalancer.RuleCondition{ + loadbalancer.SourceIpAddressCondition{AttributeValue: new("10.0.0.0/8")}, + }, + }, + }}, + "allowLocal": {Items: []loadbalancer.Rule{ + loadbalancer.AllowRule{ + Conditions: []loadbalancer.RuleCondition{ + loadbalancer.SourceIpAddressCondition{AttributeValue: new("127.0.0.0/8")}, + }, + }, + }}, + } + mk := func(port int, sets ...string) loadbalancer.ListenerDetails { + l := listener() + l.Port, l.RuleSetNames = new(port), sets + + return l + } + d.Listeners = map[string]loadbalancer.ListenerDetails{ + "headers": mk(80, "headers"), "redirect": mk(81, "redirects"), "methods": mk(82, "methods"), + "deny": mk(83, "allow"), "permit": mk(84, "allowLocal"), + } + id := e.mkLB(t, d) + + t.Run("headers", func(t *testing.T) { + t.Parallel() + + r := e.call(t, id, 80, "", "/x", nil) + assert.Equal(t, 200, r.status) + assert.Equal(t, "", r.header.Get("Echo-X-Added")) + assert.Equal(t, "lb", r.header.Get("X-Resp")) + assert.Empty(t, r.header.Get("Set-Cookie")) + assert.NotEmpty(t, r.header.Get("Echo-Xff")) + }) + + t.Run("redirect", func(t *testing.T) { + t.Parallel() + + r := e.call(t, id, 81, "lb.example.com", "/old/page", nil) + assert.Equal(t, 301, r.status) + assert.Equal(t, "https://new.example.com:8443/moved/old/page", r.header.Get("Location")) + + r = e.call(t, id, 81, "", "/keep", nil) + assert.Equal(t, 200, r.status) + }) + + t.Run("methods", func(t *testing.T) { + t.Parallel() + + r := e.do(t, "http://"+e.addr(t, id, 82)+"/x", "", http.MethodPost, nil, nil) + assert.Equal(t, 405, r.status) + + r = e.do(t, "http://"+e.addr(t, id, 82)+"/x", "", http.MethodGet, nil, nil) + assert.Equal(t, 200, r.status) + }) + + t.Run("allow", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, 403, e.call(t, id, 83, "", "/x", nil).status) + assert.Equal(t, 200, e.call(t, id, 84, "", "/x", nil).status) + }) +} + +func TestTLSTermination(t *testing.T) { + t.Parallel() + + certPEM, keyPEM := genCert(t) + e := newEnv(t, envOpts{}) + a := startBackend(t, "a") + d := baseDetails("tls") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": backendSet("ROUND_ROBIN", a)} + d.Certificates = map[string]loadbalancer.CertificateDetails{ + "cert": { + CertificateName: new("cert"), + PublicCertificate: new(string(certPEM)), + PrivateKey: new(string(keyPEM)), + }, + } + ssl := &loadbalancer.SslConfigurationDetails{CertificateName: new("cert")} + d.Listeners = map[string]loadbalancer.ListenerDetails{ + "https": {DefaultBackendSetName: new("bs"), Port: new(443), Protocol: new("HTTP"), SslConfiguration: ssl}, + "h2": {DefaultBackendSetName: new("bs"), Port: new(444), Protocol: new("HTTP2"), SslConfiguration: ssl}, + "tcptls": {DefaultBackendSetName: new("bs"), Port: new(445), Protocol: new("TCP"), SslConfiguration: ssl}, + } + id := e.mkLB(t, d) + + t.Run("https terminates and forwards plain", func(t *testing.T) { + t.Parallel() + + r := e.do( + t, + "https://"+e.addr(t, id, 443)+"/secure", + "", + http.MethodGet, + nil, + tlsClient(t, certPEM), + ) + assert.Equal(t, 200, r.status) + assert.Equal(t, "a /secure", r.body) + }) + + t.Run("untrusted client rejected", func(t *testing.T) { + t.Parallel() + + other, _ := genCert(t) + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodGet, + "https://"+e.addr(t, id, 443)+"/", + http.NoBody, + ) + require.NoError(t, err) + + _, err = tlsClient(t, other).Do(req) + require.Error(t, err) + }) + + t.Run("http2 negotiated", func(t *testing.T) { + t.Parallel() + + c := tlsClient(t, certPEM) + tr, ok := c.Transport.(*http.Transport) + require.True(t, ok) + + tr.ForceAttemptHTTP2 = true + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodGet, + "https://"+e.addr(t, id, 444)+"/", + http.NoBody, + ) + require.NoError(t, err) + + resp, err := c.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + _, _ = io.Copy(io.Discard, resp.Body) + assert.Equal(t, 2, resp.ProtoMajor) + }) + + t.Run("tls terminated tcp", func(t *testing.T) { + t.Parallel() + + pool := tlsClient(t, certPEM).Transport.(*http.Transport).TLSClientConfig + conn, err := tls.Dial("tcp", e.addr(t, id, 445), pool) + require.NoError(t, err) + + defer conn.Close() + + _, err = io.WriteString(conn, "GET /raw HTTP/1.0\r\n\r\n") + require.NoError(t, err) + + body, _ := io.ReadAll(conn) + assert.Contains(t, string(body), "a /raw") + }) + + t.Run("no private key keeps listener unusable", func(t *testing.T) { + t.Parallel() + + snap := e.backend.Snapshot(t.Context()) + assert.NotContains(t, string(snap), "PRIVATE KEY") + }) +} + +func TestBackendTLSVerification(t *testing.T) { + t.Parallel() + + good, goodKey := genCert(t) + wrong, _ := genCert(t) + pair, err := tls.X509KeyPair(good, goodKey) + require.NoError(t, err) + + tests := []struct { + name string + ca string + status int + }{ + {"trusted bundle", string(good), http.StatusOK}, + {"wrong bundle", string(wrong), http.StatusServiceUnavailable}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + be := startTLSBackend(t, "secure", pair) + set := backendSet("ROUND_ROBIN", be) + set.SslConfiguration = &loadbalancer.SslConfigurationDetails{ + CertificateName: new("ca"), VerifyPeerCertificate: new(true), + } + + d := baseDetails("backend-tls") + d.Certificates = map[string]loadbalancer.CertificateDetails{ + "ca": {CertificateName: new("ca"), CaCertificate: new(tt.ca)}, + } + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": set} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id := e.mkLB(t, d) + + r := e.call(t, id, httpPort, "", "/tls", nil) + assert.Equal(t, tt.status, r.status) + }) + } +} + +func TestExternalBackendRefused(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + set := loadbalancer.BackendSetDetails{ + Policy: new("ROUND_ROBIN"), HealthChecker: hc(), + Backends: []loadbalancer.BackendDetails{{IpAddress: new("192.0.2.1"), Port: new(80)}}, + } + d := baseDetails("external") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": set} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id := e.mkLB(t, d) + + start := time.Now() + r := e.call(t, id, httpPort, "", "/", nil) + assert.Equal(t, http.StatusServiceUnavailable, r.status) + assert.Less(t, time.Since(start), 5*time.Second) + + bname := "192.0.2.1:80" + bh, err := e.lb.GetBackendHealth(t.Context(), loadbalancer.GetBackendHealthRequest{ + LoadBalancerId: &id, BackendSetName: new("bs"), BackendName: &bname, + }) + require.NoError(t, err) + assert.Equal(t, loadbalancer.BackendHealthStatusCritical, bh.Status) + assert.Equal( + t, + loadbalancer.HealthCheckResultHealthCheckStatusConnectFailed, + bh.HealthCheckResults[0].HealthCheckStatus, + ) +} + +func TestUnhealthyButUndecidedBackendIsDialled(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + set := backendSet("ROUND_ROBIN") + set.Backends = []loadbalancer.BackendDetails{{IpAddress: new("192.0.2.1"), Port: new(80)}} + set.HealthChecker.Retries = new(3) + + d := baseDetails("undecided") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": set} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id := e.mkLB(t, d) + + r := e.call(t, id, httpPort, "", "/", nil) + assert.Equal(t, http.StatusBadGateway, r.status, "the dial guard refuses the external peer") +} + +func TestTCPListener(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a := startBackend(t, "a") + set := backendSet("ROUND_ROBIN", a) + set.HealthChecker = &loadbalancer.HealthCheckerDetails{ + Protocol: new("TCP"), + Retries: new(0), + IntervalInMillis: new(1000), + TimeoutInMillis: new(1000), + } + + d := baseDetails("tcp") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": set} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": { + DefaultBackendSetName: new("bs"), Port: new(7000), Protocol: new("TCP"), + ConnectionConfiguration: &loadbalancer.ConnectionConfiguration{IdleTimeout: new(int64(30))}, + }} + id := e.mkLB(t, d) + + conn, err := net.Dial("tcp", e.addr(t, id, 7000)) + require.NoError(t, err) + + defer conn.Close() + + _, err = io.WriteString(conn, "GET /tcp HTTP/1.0\r\n\r\n") + require.NoError(t, err) + + body, _ := io.ReadAll(conn) + assert.True(t, strings.HasSuffix(string(body), "a /tcp"), string(body)) +} + +func TestSessionPersistenceAndRequestID(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a, b := startBackend(t, "a"), startBackend(t, "b") + set := backendSet("ROUND_ROBIN", a, b) + set.LbCookieSessionPersistenceConfiguration = &loadbalancer.LbCookieSessionPersistenceConfigurationDetails{} + + d := baseDetails("sticky") + d.IsRequestIdEnabled = new(true) + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": set} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id := e.mkLB(t, d) + + first := e.call(t, id, httpPort, "", "/", nil) + cookie := "" + + for _, c := range first.header.Values("Set-Cookie") { + if strings.HasPrefix(c, "X-Oracle-BMC-LBS-Route=") { + cookie = strings.SplitN(c, ";", 2)[0] + } + } + + require.NotEmpty(t, cookie) + + for range 4 { + r := e.call(t, id, httpPort, "", "/", map[string]string{"Cookie": cookie}) + assert.Equal(t, first.header.Get("X-Backend"), r.header.Get("X-Backend")) + } +} + +func TestLifecycleOfListeners(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a := startBackend(t, "a") + d := baseDetails("serving") + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": backendSet("ROUND_ROBIN", a)} + d.Listeners = map[string]loadbalancer.ListenerDetails{"l": listener()} + id, _ := e.create(t, d) + + assert.Empty(t, e.backend.Endpoints(), "creating load balancers do not serve") + + e.settle() + require.Len(t, e.backend.Endpoints(), 1) + + { // admin endpoint + status, body := rawDo(t, e.base+"/_gopherstack/loadbalancer/endpoints") + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, id) + assert.Contains(t, body, "127.0.0.1:") + } + + { // listener address in load balancer view + status, body := rawDo(t, e.base+"/20170115/loadBalancers/"+id) + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, "gopherstackAddress") + } + + { // delete stops serving + _, err := e.lb.DeleteLoadBalancer(t.Context(), loadbalancer.DeleteLoadBalancerRequest{LoadBalancerId: &id}) + require.NoError(t, err) + + e.settle() + assert.Empty(t, e.backend.Endpoints()) + } +} diff --git a/services/ociloadbalancer/errors.go b/services/ociloadbalancer/errors.go new file mode 100644 index 0000000000..2d6df7966a --- /dev/null +++ b/services/ociloadbalancer/errors.go @@ -0,0 +1,54 @@ +package ociloadbalancer + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ociloadbalancer: null entry in snapshot") + +// ErrDestinationNotPermitted is returned when a backend resolves outside the permitted network range. +var ErrDestinationNotPermitted = errors.New( + "ociloadbalancer: destination is outside loopback and external backends are not allowed", +) + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errConflict(msg string) *APIError { return newErr(http.StatusConflict, "Conflict", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } + +func errUnsupported(what string) *APIError { + return newErr(http.StatusBadRequest, "InvalidParameter", what+" is not supported by this emulator") +} diff --git a/services/ociloadbalancer/handler.go b/services/ociloadbalancer/handler.go new file mode 100644 index 0000000000..92581c3156 --- /dev/null +++ b/services/ociloadbalancer/handler.go @@ -0,0 +1,374 @@ +package ociloadbalancer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Second + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Load Balancer: the control plane on its own listener; the backend runs the data plane. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ociloadbalancer.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCILoadBalancer" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCILoadBalancer runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"loadBalancerId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ociloadbalancer: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + dec := json.NewDecoder(bytes.NewReader(q.body)) + dec.UseNumber() + + if err := dec.Decode(v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ociloadbalancer: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCILoadBalancer requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if q.rt.op == opAdminEnds { + _ = q.json(http.StatusOK, h.Backend.Endpoints(), "") + + return nil + } + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ociloadbalancer: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCILoadBalancer", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ociloadbalancer", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ociloadbalancer: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ociloadbalancer: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + h.Backend.RefreshHealth("", "") + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ociloadbalancer: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ociloadbalancer: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() + h.Backend.Close() +} diff --git a/services/ociloadbalancer/health.go b/services/ociloadbalancer/health.go new file mode 100644 index 0000000000..9100089879 --- /dev/null +++ b/services/ociloadbalancer/health.go @@ -0,0 +1,475 @@ +package ociloadbalancer + +import ( + "context" + "errors" + "io" + "maps" + "net" + "net/http" + "regexp" + "slices" + "time" +) + +// Health statuses and check results. +const ( + hOK = "OK" + hWarning = "WARNING" + hCritical = "CRITICAL" + hUnknown = "UNKNOWN" + rOK = "OK" + rStatus = "INVALID_STATUS_CODE" + rTimeout = "TIMED_OUT" + rRegex = "REGEX_MISMATCH" + rConnect = "CONNECT_FAILED" + rIO = "IO_ERROR" + rOffline = "OFFLINE" + rUnknown = "UNKNOWN" + maxProbeIO = 1 << 20 +) + +// hstate is the runtime health of one backend. +type hstate struct { + lastTime time.Time + nextDue time.Time + status string + lastResult string + okRun int + failRun int + claimed bool +} + +func newHState() *hstate { return &hstate{status: hUnknown, lastResult: rUnknown} } + +func (s *hstate) record(result string, retries int, at time.Time) { + s.lastResult, s.lastTime = result, at + threshold := retries + 1 + + if result == rOK { + s.okRun, s.failRun = s.okRun+1, 0 + if s.status != hCritical || s.okRun >= threshold { + s.status = hOK + } + + return + } + + s.failRun, s.okRun = s.failRun+1, 0 + if s.failRun >= threshold { + s.status = hCritical + } else if s.status != hCritical { + s.status = hWarning + } +} + +type probeTarget struct { + rt http.RoundTripper + dialer func(ctx context.Context, network, addr string) (net.Conn, error) + key string + be Backend + hc HealthChecker + retries int + tlsOn bool +} + +func healthKey(lbID, set, backend string) string { return lbID + "|" + set + "|" + backend } + +// RefreshHealth runs every health check of a backend set that is due on the backend clock. +func (b *InMemoryBackend) RefreshHealth(lbID, set string) { b.dp.runDueChecks(lbID, set) } + +func (d *dataPlane) collectTargets(lbID, set string) []probeTarget { + d.b.mu.RLock("collectTargets") + defer d.b.mu.RUnlock() + + var out []probeTarget + + for id, lb := range d.b.lbs { + if lb.State != stateActive || (lbID != "" && id != lbID) { + continue + } + + for name, bs := range lb.BackendSets { + if set != "" && name != set { + continue + } + + rt := d.transportFor(lb, bs) + + for _, be := range bs.Backends { + out = append(out, probeTarget{ + rt: rt, be: be, key: healthKey(id, name, be.Name), hc: bs.HealthChecker, tlsOn: bs.SSL != nil, + retries: bs.HealthChecker.Retries, dialer: d.dialer.DialContext, + }) + } + } + } + + return out +} + +// runDueChecks probes the backends whose check interval has elapsed. Offline backends are not probed. +func (d *dataPlane) runDueChecks(lbID, set string) { + targets := d.collectTargets(lbID, set) + if len(targets) == 0 { + return + } + + now := d.b.clock() + + var run []probeTarget + + d.mu.Lock("runDueChecks") + + for _, t := range targets { + st := d.health[t.key] + if st == nil { + st = newHState() + d.health[t.key] = st + } + + if t.be.Offline { + st.status, st.lastResult, st.lastTime = hUnknown, rOffline, now + + continue + } + + if st.claimed || now.Before(st.nextDue) { + continue + } + + st.claimed = true + st.nextDue = now.Add(time.Duration(t.hc.IntervalInMillis) * time.Millisecond) + run = append(run, t) + } + + d.mu.Unlock() + + done := make(chan struct{}, len(run)) + + for _, t := range run { + go func() { + res := probe(t) + + d.mu.Lock("recordHealth") + if st := d.health[t.key]; st != nil { + st.record(res, t.retries, now) + st.claimed = false + } + d.mu.Unlock() + + done <- struct{}{} + }() + } + + for range run { + <-done + } +} + +func classify(err error) string { + var ne net.Error + + switch { + case errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()): + return rTimeout + case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): + return rIO + } + + var op *net.OpError + if errors.As(err, &op) && op.Op == "dial" { + return rConnect + } + + return rIO +} + +func probe(t probeTarget) string { + port := t.hc.Port + if port == 0 { + port = t.be.Port + } + + addr := net.JoinHostPort(t.be.IPAddress, itoa(port)) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.hc.TimeoutInMillis)*time.Millisecond) + + defer cancel() + + if t.hc.Protocol == protoTCP { + conn, err := t.dialer(ctx, "tcp", addr) + if err != nil { + return classify(err) + } + + _ = conn.Close() + + return rOK + } + + scheme := schemeHTTP + if t.tlsOn && !t.hc.IsForcePlainText { + scheme = schemeHTTPS + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, scheme+"://"+addr+t.hc.URLPath, http.NoBody) + if err != nil { + return rIO + } + + resp, err := (&http.Client{ + Transport: t.rt, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }).Do(req) + if err != nil { + return classify(err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxProbeIO)) + if err != nil { + return classify(err) + } + + if resp.StatusCode != t.hc.ReturnCode { + return rStatus + } + + if t.hc.ResponseBodyRegex != "" { + if re, rerr := regexp.Compile(t.hc.ResponseBodyRegex); rerr != nil || !re.Match(body) { + return rRegex + } + } + + return rOK +} + +func (d *dataPlane) statusOf(lbID, set string, be Backend) (string, string, time.Time) { + if be.Offline { + return hUnknown, rOffline, time.Time{} + } + + d.mu.RLock("statusOf") + defer d.mu.RUnlock() + + st := d.health[healthKey(lbID, set, be.Name)] + if st == nil { + return hUnknown, rUnknown, time.Time{} + } + + return st.status, st.lastResult, st.lastTime +} + +type healthCheckResultWire struct { + Timestamp time.Time `json:"timestamp"` + SubnetID string `json:"subnetId"` + SourceIPAddress string `json:"sourceIpAddress"` + HealthCheckStatus string `json:"healthCheckStatus"` +} + +// BackendHealthWire is the wire form of a backend's health. +type BackendHealthWire struct { + Status string `json:"status"` + HealthCheckResults []healthCheckResultWire `json:"healthCheckResults"` +} + +// BackendSetHealthWire is the wire form of a backend set's health. +type BackendSetHealthWire struct { + Status string `json:"status"` + WarningStateBackendNames []string `json:"warningStateBackendNames"` + CriticalStateBackendNames []string `json:"criticalStateBackendNames"` + UnknownStateBackendNames []string `json:"unknownStateBackendNames"` + TotalBackendCount int `json:"totalBackendCount"` +} + +// LoadBalancerHealthWire is the wire form of a load balancer's health. +type LoadBalancerHealthWire struct { + Status string `json:"status"` + WarningStateBackendSetNames []string `json:"warningStateBackendSetNames"` + CriticalStateBackendSetNames []string `json:"criticalStateBackendSetNames"` + UnknownStateBackendSetNames []string `json:"unknownStateBackendSetNames"` + TotalBackendSetCount int `json:"totalBackendSetCount"` +} + +// aggregate combines member statuses: OK only when all are OK, CRITICAL when none is OK and one is critical. +func aggregate(ok, warn, crit, unknown int) string { + total := ok + warn + crit + unknown + + switch { + case total == 0 || unknown == total: + return hUnknown + case ok == total: + return hOK + case ok == 0 && warn == 0 && crit > 0: + return hCritical + case warn == 0 && crit == 0: + return hUnknown + default: + return hWarning + } +} + +func (d *dataPlane) setHealth(lb *LoadBalancer, bs *BackendSet) BackendSetHealthWire { + out := BackendSetHealthWire{ + WarningStateBackendNames: []string{}, + CriticalStateBackendNames: []string{}, + UnknownStateBackendNames: []string{}, + TotalBackendCount: len(bs.Backends), + } + + var ok int + + for _, be := range bs.Backends { + st, _, _ := d.statusOf(lb.ID, bs.Name, be) + + switch st { + case hOK: + ok++ + case hWarning: + out.WarningStateBackendNames = append(out.WarningStateBackendNames, be.Name) + case hCritical: + out.CriticalStateBackendNames = append(out.CriticalStateBackendNames, be.Name) + default: + out.UnknownStateBackendNames = append(out.UnknownStateBackendNames, be.Name) + } + } + + out.Status = aggregate(ok, len(out.WarningStateBackendNames), len(out.CriticalStateBackendNames), + len(out.UnknownStateBackendNames)) + + return out +} + +func (d *dataPlane) lbHealth(lb *LoadBalancer) LoadBalancerHealthWire { + out := LoadBalancerHealthWire{ + WarningStateBackendSetNames: []string{}, CriticalStateBackendSetNames: []string{}, + UnknownStateBackendSetNames: []string{}, TotalBackendSetCount: len(lb.BackendSets), + } + + var ok int + + for _, name := range slices.Sorted(maps.Keys(lb.BackendSets)) { + switch d.setHealth(lb, lb.BackendSets[name]).Status { + case hOK: + ok++ + case hWarning: + out.WarningStateBackendSetNames = append(out.WarningStateBackendSetNames, name) + case hCritical: + out.CriticalStateBackendSetNames = append(out.CriticalStateBackendSetNames, name) + default: + out.UnknownStateBackendSetNames = append(out.UnknownStateBackendSetNames, name) + } + } + + out.Status = aggregate(ok, len(out.WarningStateBackendSetNames), len(out.CriticalStateBackendSetNames), + len(out.UnknownStateBackendSetNames)) + + return out +} + +func healthOps() map[string]opFunc { + return map[string]opFunc{ + opGetBEHealth: (*Handler).getBackendHealth, opGetBSHealth: (*Handler).getBackendSetHealth, + opGetLBHealth: (*Handler).getLBHealth, opListHealths: (*Handler).listLBHealths, + } +} + +func (h *Handler) getBackendHealth(q *req) error { + lbID, set := q.param("loadBalancerId"), q.param("backendSetName") + h.Backend.RefreshHealth(lbID, set) + + return readSub(h, q, func(lb *LoadBalancer) (BackendHealthWire, *APIError) { + bs, err := lookup(lb.BackendSets, set) + if err != nil { + return BackendHealthWire{}, err + } + + i := findBackend(bs, q.param("backendName")) + if i < 0 { + return BackendHealthWire{}, errNotFound(q.param("backendName")) + } + + st, res, at := h.Backend.dp.statusOf(lb.ID, set, bs.Backends[i]) + if at.IsZero() { + at = h.Backend.nowUTC() + } + + sub, ip := "", "" + if len(lb.SubnetIDs) > 0 { + sub = lb.SubnetIDs[0] + } + + if len(lb.IPAddresses) > 0 { + ip = lb.IPAddresses[0].IPAddress + } + + return BackendHealthWire{Status: st, HealthCheckResults: []healthCheckResultWire{{ + Timestamp: at, SubnetID: sub, SourceIPAddress: ip, HealthCheckStatus: res, + }}}, nil + }) +} + +func (h *Handler) getBackendSetHealth(q *req) error { + set := q.param("backendSetName") + h.Backend.RefreshHealth(q.param("loadBalancerId"), set) + + return readSub(h, q, func(lb *LoadBalancer) (BackendSetHealthWire, *APIError) { + bs, err := lookup(lb.BackendSets, set) + if err != nil { + return BackendSetHealthWire{}, err + } + + return h.Backend.dp.setHealth(lb, bs), nil + }) +} + +func (h *Handler) getLBHealth(q *req) error { + h.Backend.RefreshHealth(q.param("loadBalancerId"), "") + + return readSub(h, q, func(lb *LoadBalancer) (LoadBalancerHealthWire, *APIError) { + return h.Backend.dp.lbHealth(lb), nil + }) +} + +type lbHealthSummary struct { + LoadBalancerID string `json:"loadBalancerId"` + Status string `json:"status"` +} + +func (h *Handler) listLBHealths(q *req) error { + compartment := q.query("compartmentId") + if compartment == "" { + return errMissing("compartmentId is required") + } + + b := h.Backend + + b.mu.RLock("ListLoadBalancerHealths") + + var lbs []*LoadBalancer + + for _, lb := range b.lbs { + if lb.CompartmentID == compartment && lb.State != stateDeleted { + lbs = append(lbs, lb) + } + } + + b.mu.RUnlock() + + for _, lb := range lbs { + b.RefreshHealth(lb.ID, "") + } + + o := q.listOpts() + pg := paginate(lbs, ListOpts{Page: o.Page, Limit: o.Limit}, func(l *LoadBalancer) string { return l.DisplayName }, + func(l *LoadBalancer) (time.Time, int64) { return l.TimeCreated, 0 }) + + out := make([]lbHealthSummary, 0, len(pg.Data)) + for _, lb := range pg.Data { + out = append(out, lbHealthSummary{LoadBalancerID: lb.ID, Status: b.dp.lbHealth(lb).Status}) + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, out, "") +} diff --git a/services/ociloadbalancer/helpers_test.go b/services/ociloadbalancer/helpers_test.go new file mode 100644 index 0000000000..ac949ef62f --- /dev/null +++ b/services/ociloadbalancer/helpers_test.go @@ -0,0 +1,381 @@ +package ociloadbalancer_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/loadbalancer" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/devtls" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + ociloadbalancer "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + otherCompart = "ocid1.compartment.oc1..other" + testSubnet = "ocid1.subnet.oc1..subnet" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UTC().Truncate(time.Second).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *ociloadbalancer.InMemoryBackend + handler *ociloadbalancer.Handler + clock *fakeClock + lb loadbalancer.LoadBalancerClient + base string + port int +} + +type envOpts struct { + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := ociloadbalancer.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + h := ociloadbalancer.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + prov := common.NewRawConfigurationProvider( + testTenancy, + testUser, + ociloadbalancer.Region, + kp.fprint, + kp.privPEM, + nil, + ) + e := &env{backend: be, handler: h, clock: clock, port: h.Port, base: fmt.Sprintf("http://localhost:%d", h.Port)} + + var err error + + e.lb, err = loadbalancer.NewLoadBalancerClientWithConfigurationProvider(prov) + require.NoError(t, err) + + e.lb.Host = e.base + + return e +} + +// settle completes pending work requests and lifecycle transitions. +func (e *env) settle() { + e.clock.Advance(5 * time.Second) + e.backend.Sweep() +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, url string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, http.NoBody) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +// testBackend is a loopback HTTP backend that identifies itself and can be made unhealthy. +type testBackend struct { + srv *httptest.Server + name string + healthy atomic.Bool +} + +func (b *testBackend) ip() string { + host, _, _ := net.SplitHostPort(b.srv.Listener.Addr().String()) + + return host +} + +func (b *testBackend) port() int { + _, p, _ := net.SplitHostPort(b.srv.Listener.Addr().String()) + + var n int + + _, _ = fmt.Sscanf(p, "%d", &n) + + return n +} + +func (b *testBackend) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + if !b.healthy.Load() { + w.WriteHeader(http.StatusInternalServerError) + } + + return + } + + w.Header().Set("X-Backend", b.name) + w.Header().Set("Echo-X-Added", r.Header.Get("X-Added")) + w.Header().Set("Echo-Host", r.Host) + w.Header().Set("Echo-Xff", r.Header.Get("X-Forwarded-For")) + w.Header().Set("Set-Cookie", "sid=abc; Path=/") + _, _ = io.WriteString(w, b.name+" "+r.URL.Path) + }) +} + +func startBackend(t *testing.T, name string) *testBackend { + t.Helper() + + b := &testBackend{name: name} + b.healthy.Store(true) + b.srv = httptest.NewServer(b.handler()) + t.Cleanup(b.srv.Close) + + return b +} + +func startTLSBackend(t *testing.T, name string, cert tls.Certificate) *testBackend { + t.Helper() + + b := &testBackend{name: name} + b.healthy.Store(true) + b.srv = httptest.NewUnstartedServer(b.handler()) + b.srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12} + b.srv.StartTLS() + t.Cleanup(b.srv.Close) + + return b +} + +func (b *testBackend) detail() loadbalancer.BackendDetails { + return loadbalancer.BackendDetails{IpAddress: new(b.ip()), Port: new(b.port())} +} + +// hc is a fast health checker: one failed check marks a backend critical. +func hc() *loadbalancer.HealthCheckerDetails { + return &loadbalancer.HealthCheckerDetails{ + Protocol: new( + "HTTP", + ), + UrlPath: new("/health"), + Retries: new(0), + IntervalInMillis: new(1000), + TimeoutInMillis: new(1000), + } +} + +func backendSet(policy string, bs ...*testBackend) loadbalancer.BackendSetDetails { + d := loadbalancer.BackendSetDetails{Policy: new(policy), HealthChecker: hc()} + for _, b := range bs { + d.Backends = append(d.Backends, b.detail()) + } + + return d +} + +func baseDetails(name string) loadbalancer.CreateLoadBalancerDetails { + return loadbalancer.CreateLoadBalancerDetails{ + CompartmentId: new(testCompartment), DisplayName: new(name), ShapeName: new("flexible"), + ShapeDetails: &loadbalancer.ShapeDetails{MinimumBandwidthInMbps: new(10), MaximumBandwidthInMbps: new(100)}, + SubnetIds: []string{testSubnet}, + } +} + +func (e *env) create(t *testing.T, d loadbalancer.CreateLoadBalancerDetails) (string, string) { + t.Helper() + + resp, err := e.lb.CreateLoadBalancer( + t.Context(), + loadbalancer.CreateLoadBalancerRequest{CreateLoadBalancerDetails: d}, + ) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + + wr, err := e.lb.GetWorkRequest( + t.Context(), + loadbalancer.GetWorkRequestRequest{WorkRequestId: resp.OpcWorkRequestId}, + ) + require.NoError(t, err) + + return *wr.LoadBalancerId, *resp.OpcWorkRequestId +} + +// mkLB creates a load balancer and settles it to ACTIVE. +func (e *env) mkLB(t *testing.T, d loadbalancer.CreateLoadBalancerDetails) string { + t.Helper() + + id, _ := e.create(t, d) + e.settle() + + return id +} + +func (e *env) get(t *testing.T, id string) loadbalancer.GetLoadBalancerResponse { + t.Helper() + + resp, err := e.lb.GetLoadBalancer(t.Context(), loadbalancer.GetLoadBalancerRequest{LoadBalancerId: &id}) + require.NoError(t, err) + + return resp +} + +func (e *env) addr(t *testing.T, lbID string, port int) string { + t.Helper() + + for _, ep := range e.backend.Endpoints() { + if ep.LoadBalancerID == lbID && ep.Port == port { + return ep.Address + } + } + + require.FailNow(t, "listener not serving", "lb %s port %d", lbID, port) + + return "" +} + +type dataResp struct { + header http.Header + body string + status int +} + +// call sends a request to a load balancer listener; host overrides the Host header. +func (e *env) call(t *testing.T, lbID string, port int, host, path string, headers map[string]string) dataResp { + t.Helper() + + return e.do(t, "http://"+e.addr(t, lbID, port)+path, host, http.MethodGet, headers, nil) +} + +func (e *env) do(t *testing.T, url, host, method string, headers map[string]string, client *http.Client) dataResp { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, http.NoBody) + require.NoError(t, err) + + if host != "" { + req.Host = host + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + if client == nil { + client = testclient.New(t) + } + + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + resp, err := client.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return dataResp{status: resp.StatusCode, body: string(out), header: resp.Header} +} + +func tlsClient(t *testing.T, certPEM []byte) *http.Client { + t.Helper() + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(certPEM)) + + tr := httpclient.NewTransport() + tr.TLSClientConfig = &tls.Config{RootCAs: pool, ServerName: "localhost", MinVersion: tls.VersionTLS12} + c := &http.Client{Transport: tr} + t.Cleanup(c.CloseIdleConnections) + + return c +} + +func genCert(t *testing.T) ([]byte, []byte) { + t.Helper() + + c, k, err := devtls.GenerateSelfSignedCertPEM("localhost") + require.NoError(t, err) + + return c, k +} diff --git a/services/ociloadbalancer/inproc.go b/services/ociloadbalancer/inproc.go new file mode 100644 index 0000000000..b6acd357c3 --- /dev/null +++ b/services/ociloadbalancer/inproc.go @@ -0,0 +1,69 @@ +package ociloadbalancer + +import "slices" + +// Compartments lists the distinct compartments that hold load balancers. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, lb := range b.lbs { + if lb.State != stateDeleted { + seen[lb.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} + +// LoadBalancerState returns the lifecycle state of a load balancer. +func (b *InMemoryBackend) LoadBalancerState(id string) (string, bool) { + b.mu.RLock("LoadBalancerState") + defer b.mu.RUnlock() + + lb, ok := b.lbs[id] + if !ok || lb.State == stateDeleted { + return "", false + } + + return lb.State, true +} + +// Target is the data-plane endpoint of a serving listener. +type Target struct { + Address string + Protocol string + TLS bool +} + +// ListenerTarget returns the loopback data-plane address of a serving listener of an ACTIVE load balancer. +func (b *InMemoryBackend) ListenerTarget(lbID, listener string) (Target, bool) { + b.mu.RLock("ListenerTarget") + defer b.mu.RUnlock() + + lb, ok := b.lbs[lbID] + if !ok || lb.State != stateActive { + return Target{}, false + } + + l, ok := lb.Listeners[listener] + if !ok { + return Target{}, false + } + + addr := b.dp.address(lb.ID, l.Port) + if addr == "" { + return Target{}, false + } + + return Target{Address: addr, Protocol: l.Protocol, TLS: l.SSL != nil}, true +} diff --git a/services/ociloadbalancer/lb.go b/services/ociloadbalancer/lb.go new file mode 100644 index 0000000000..45cd4b47ee --- /dev/null +++ b/services/ociloadbalancer/lb.go @@ -0,0 +1,760 @@ +package ociloadbalancer + +import ( + "crypto/tls" + "slices" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + minFlexMbps = 10 + maxFlexMbps = 8000 + maxSubnets = 2 + minSharing = 2 +) + +type createLBIn struct { + Tags + ShapeDetails *ShapeDetails `json:"shapeDetails"` + IsPrivate *bool `json:"isPrivate"` + DeleteProtected *bool `json:"isDeleteProtectionEnabled"` + RequestIDOn *bool `json:"isRequestIdEnabled"` + Listeners map[string]*listenerIn `json:"listeners"` + Hostnames map[string]*hostnameIn `json:"hostnames"` + BackendSets map[string]*backendSetIn `json:"backendSets"` + Certificates map[string]*certIn `json:"certificates"` + SSLCipherSuites map[string]*cipherSuiteIn `json:"sslCipherSuites"` + PathRouteSets map[string]*pathRouteSetIn `json:"pathRouteSets"` + RuleSets map[string]*ruleSetIn `json:"ruleSets"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + ShapeName string `json:"shapeName"` + IPMode string `json:"ipMode"` + Ipv6SubnetCidr string `json:"ipv6SubnetCidr"` + RequestIDHeader string `json:"requestIdHeader"` + SubnetIDs []string `json:"subnetIds"` + NSGs []string `json:"networkSecurityGroupIds"` + ReservedIPs []ReservedIP `json:"reservedIps"` +} + +type updateLBIn struct { + Tags + DisplayName *string `json:"displayName"` + DeleteProtected *bool `json:"isDeleteProtectionEnabled"` + RequestIDOn *bool `json:"isRequestIdEnabled"` + RequestIDHeader *string `json:"requestIdHeader"` + IPMode string `json:"ipMode"` + Ipv6SubnetCidr *string `json:"ipv6SubnetCidr"` + ReservedIPs []ReservedIP `json:"reservedIps"` +} + +type updateShapeIn struct { + ShapeDetails *ShapeDetails `json:"shapeDetails"` + ShapeName string `json:"shapeName"` +} + +type nsgIn struct { + NSGs []string `json:"networkSecurityGroupIds"` +} + +type changeCompartmentIn struct { + CompartmentID string `json:"compartmentId"` +} + +func checkShape(name string, d *ShapeDetails) *APIError { + if name == "" { + return errMissing("shapeName is required") + } + + if !slices.Contains(shapeNames, name) { + return errParam("shapeName must be one of " + strings.Join(shapeNames, ", ")) + } + + if name != shapeFlexible { + if d != nil { + return errParam("shapeDetails applies to the flexible shape only") + } + + return nil + } + + if d == nil { + return errMissing("shapeDetails is required for the flexible shape") + } + + if d.Minimum < minFlexMbps || d.Maximum > maxFlexMbps || d.Minimum > d.Maximum { + return errParam( + "flexible shape bandwidth must satisfy 10 <= minimumBandwidthInMbps <= maximumBandwidthInMbps <= 8000", + ) + } + + return nil +} + +func (b *InMemoryBackend) checkSubnets(ids []string, private bool) (string, *APIError) { + if len(ids) == 0 { + return "", errMissing("subnetIds is required") + } + + if len(ids) > maxSubnets || (private && len(ids) > 1) { + return "", errParam("a public load balancer takes one or two subnets and a private one takes one") + } + + cidr := "" + + for _, id := range ids { + if b.subnet == nil { + continue + } + + c, ok := b.subnet(id) + if !ok { + return "", errNotFound(id) + } + + if cidr == "" { + cidr = c + } + } + + return cidr, nil +} + +func (b *InMemoryBackend) validateCreate(in *createLBIn) (string, *APIError) { + if err := b.checkCompartment(in.CompartmentID); err != nil { + return "", err + } + + if in.DisplayName == "" { + return "", errMissing("displayName is required") + } + + if err := checkShape(in.ShapeName, in.ShapeDetails); err != nil { + return "", err + } + + if len(in.ReservedIPs) > 0 { + return "", errUnsupported("reservedIps") + } + + if in.IPMode != "" && in.IPMode != "IPV4" { + return "", errUnsupported("ipMode " + in.IPMode) + } + + if in.Ipv6SubnetCidr != "" { + return "", errUnsupported("ipv6SubnetCidr") + } + + return b.checkSubnets(in.SubnetIDs, boolOr(in.IsPrivate)) +} + +func (b *InMemoryBackend) buildNested(lb *LoadBalancer, in *createLBIn) *APIError { + for name, c := range in.Certificates { + c.Name = name + + cert, key, err := buildCertificate(c) + if err != nil { + return err + } + + lb.Certificates[name] = cert + + if key != nil { + b.keys[lb.ID+"/"+name] = key + } + } + + return buildNestedConfig(lb, in) +} + +func buildNestedConfig(lb *LoadBalancer, in *createLBIn) *APIError { + for name, s := range in.SSLCipherSuites { + cs, err := buildCipherSuite(name, s) + if err != nil { + return err + } + + lb.SSLCipherSuites[name] = cs + } + + for name, h := range in.Hostnames { + if err := checkName("hostname", name); err != nil { + return err + } + + if err := checkHostname(h.Hostname); err != nil { + return err + } + + lb.Hostnames[name] = &Hostname{Name: name, Hostname: h.Hostname} + } + + for name, s := range in.BackendSets { + bs, err := buildBackendSet(name, s) + if err != nil { + return err + } + + lb.BackendSets[name] = bs + } + + for name, s := range in.PathRouteSets { + ps, err := buildPathRouteSet(name, s) + if err != nil { + return err + } + + lb.PathRouteSets[name] = ps + } + + for name, s := range in.RuleSets { + rs, err := buildRuleSet(name, s) + if err != nil { + return err + } + + lb.RuleSets[name] = rs + } + + for name, l := range in.Listeners { + li, err := buildListener(name, l) + if err != nil { + return err + } + + lb.Listeners[name] = li + } + + return nil +} + +func newLB() *LoadBalancer { + return &LoadBalancer{ + Listeners: map[string]*Listener{}, + Hostnames: map[string]*Hostname{}, + SSLCipherSuites: map[string]*SSLCipherSuite{}, + Certificates: map[string]*Certificate{}, + BackendSets: map[string]*BackendSet{}, + PathRouteSets: map[string]*PathRouteSet{}, + RuleSets: map[string]*RuleSet{}, + RoutingPolicies: map[string]*RoutingPolicy{}, + } +} + +// CreateLoadBalancer creates a load balancer in CREATING state and returns its work request id. +func (b *InMemoryBackend) CreateLoadBalancer(in createLBIn) (string, error) { + b.mu.Lock("CreateLoadBalancer") + wr, err := b.createLB(&in) + b.mu.Unlock() + + if err != nil { + return "", err + } + + b.dp.sync() + + return wr, nil +} + +func (b *InMemoryBackend) createLB(in *createLBIn) (string, error) { + cidr, err := b.validateCreate(in) + if err != nil { + return "", err + } + + now := b.nowUTC() + lb := newLB() + lb.ID, lb.CompartmentID, lb.DisplayName = newOCID("loadbalancer"), in.CompartmentID, in.DisplayName + lb.ShapeName, lb.ShapeDetails, lb.SubnetIDs, lb.NSGs = in.ShapeName, in.ShapeDetails, in.SubnetIDs, in.NSGs + lb.IsPrivate, lb.DeleteProtected, lb.RequestIDOn = boolOr( + in.IsPrivate, + ), boolOr( + in.DeleteProtected, + ), boolOr( + in.RequestIDOn, + ) + lb.RequestIDHeader, lb.Tags, lb.State = in.RequestIDHeader, in.Tags, stateCreating + lb.TimeCreated, lb.TransitionAt, lb.Etag = now, b.transitionAt(), newEtag() + + if lb.RequestIDOn && lb.RequestIDHeader == "" { + lb.RequestIDHeader = defaultRequestIDHeader + } + + if perr := b.buildNested(lb, in); perr != nil { + b.dropKeys(lb.ID) + + return "", perr + } + + if verr := b.validateLB(lb); verr != nil { + b.dropKeys(lb.ID) + + return "", verr + } + + lb.IPAddresses = []IPAddress{{IPAddress: b.allocIP(cidrFor(lb, cidr)), IsPublic: !lb.IsPrivate}} + lb.BusyUntil = *lb.TransitionAt + b.lbs[lb.ID] = lb + + return b.newWR(lb, "CreateLoadBalancer", *lb.TransitionAt), nil +} + +func cidrFor(lb *LoadBalancer, cidr string) string { + if lb.IsPrivate { + return cidr + } + + return "" +} + +// Mutate applies fn to a copy of the load balancer, validates the result, publishes it and returns the +// id of the work request that tracks the change. +func (b *InMemoryBackend) Mutate( + id, ifMatch, wrType string, refConflict bool, fn func(lb *LoadBalancer) *APIError, +) (string, error) { + b.mu.Lock("Mutate") + wr, err := b.mutate(id, ifMatch, wrType, refConflict, fn) + b.reapKeys(id) + b.mu.Unlock() + + if err != nil { + return "", err + } + + b.dp.sync() + + return wr, nil +} + +func (b *InMemoryBackend) liveLB(id string) (*LoadBalancer, *APIError) { + lb, ok := b.lbs[id] + if !ok { + return nil, errNotFound(id) + } + + return lb, nil +} + +func (b *InMemoryBackend) mutate( + id, ifMatch, wrType string, refConflict bool, fn func(lb *LoadBalancer) *APIError, +) (string, error) { + lb, err := b.liveLB(id) + if err != nil { + return "", err + } + + if lb.State == stateDeleted { + return "", errNotFound(id) + } + + if eerr := checkEtag(lb.Etag, ifMatch); eerr != nil { + return "", eerr + } + + if lb.State != stateActive { + return "", errState("load balancer " + id + " is in state " + lb.State) + } + + now := b.nowUTC() + if lb.BusyUntil.After(now) { + return "", errConflict("another operation is in progress on load balancer " + id) + } + + next := lb.clone() + if ferr := fn(next); ferr != nil { + return "", ferr + } + + if verr := b.validateLB(next); verr != nil { + if refConflict { + return "", errConflict(verr.Message) + } + + return "", verr + } + + finish := now.Add(b.transition) + next.Etag, next.BusyUntil = newEtag(), finish + b.lbs[id] = next + + return b.newWR(next, wrType, finish), nil +} + +// validateLB checks every cross-reference of a load balancer. +func (b *InMemoryBackend) validateLB(lb *LoadBalancer) *APIError { + if err := validateSets(lb); err != nil { + return err + } + + if err := validateRoutes(lb); err != nil { + return err + } + + for _, l := range lb.Listeners { + if err := validateListenerRefs(lb, l); err != nil { + return err + } + } + + return checkPortSharing(lb) +} + +func validateSets(lb *LoadBalancer) *APIError { + for name, bs := range lb.BackendSets { + if bs.SSL != nil { + if err := checkBackendSSL(lb, bs.SSL); err != nil { + return err + } + } + + if bs.Name != name { + return errParam("backend set name mismatch for " + name) + } + } + + return nil +} + +func validateRoutes(lb *LoadBalancer) *APIError { + for _, ps := range lb.PathRouteSets { + for _, r := range ps.PathRoutes { + if _, ok := lb.BackendSets[r.BackendSetName]; !ok { + return errParam("path route set " + ps.Name + " references unknown backend set " + r.BackendSetName) + } + } + } + + for _, rp := range lb.RoutingPolicies { + for _, r := range rp.Rules { + if _, ok := lb.BackendSets[r.Actions[0].BackendSetName]; !ok { + return errParam( + "routing policy " + rp.Name + " references unknown backend set " + r.Actions[0].BackendSetName, + ) + } + } + } + + return nil +} + +func checkBackendSSL(lb *LoadBalancer, s *SSLConfig) *APIError { + if len(s.CertificateIDs) > 0 || len(s.TrustedCertificateAuthorityIDs) > 0 { + return errUnsupported("certificateIds and trustedCertificateAuthorityIds (OCI Certificates service)") + } + + if err := checkTLSVersions(s); err != nil { + return err + } + + if s.CertificateName != "" { + if _, ok := lb.Certificates[s.CertificateName]; !ok { + return errParam("backend set references unknown certificate " + s.CertificateName) + } + } + + if boolOr(s.VerifyPeerCertificate) && s.CertificateName == "" { + return errParam("verifyPeerCertificate requires a certificateName bundle holding the CA to trust") + } + + return checkCipherRef(lb, s.CipherSuiteName) +} + +func checkCipherRef(lb *LoadBalancer, name string) *APIError { + if name == "" || isBuiltinSuite(name) { + return nil + } + + if _, ok := lb.SSLCipherSuites[name]; !ok { + return errParam("unknown cipher suite " + name) + } + + return nil +} + +func validateListenerRefs(lb *LoadBalancer, l *Listener) *APIError { + if _, ok := lb.BackendSets[l.DefaultSet]; !ok { + return errParam("listener " + l.Name + " references unknown backend set " + l.DefaultSet) + } + + for _, h := range l.Hostnames { + if _, ok := lb.Hostnames[h]; !ok { + return errParam("listener " + l.Name + " references unknown hostname " + h) + } + } + + if l.PathRoutes != "" { + if _, ok := lb.PathRouteSets[l.PathRoutes]; !ok { + return errParam("listener " + l.Name + " references unknown path route set " + l.PathRoutes) + } + } + + if l.Policy != "" { + if _, ok := lb.RoutingPolicies[l.Policy]; !ok { + return errParam("listener " + l.Name + " references unknown routing policy " + l.Policy) + } + } + + for _, r := range l.RuleSets { + if _, ok := lb.RuleSets[r]; !ok { + return errParam("listener " + l.Name + " references unknown rule set " + r) + } + } + + if l.SSL != nil { + if _, ok := lb.Certificates[l.SSL.CertificateName]; !ok { + return errParam("listener " + l.Name + " references unknown certificate " + l.SSL.CertificateName) + } + + return checkCipherRef(lb, l.SSL.CipherSuiteName) + } + + return nil +} + +// checkPortSharing lets several listeners share a port only when each is bound to disjoint virtual hostnames. +func checkPortSharing(lb *LoadBalancer) *APIError { + byPort := map[int][]*Listener{} + for _, l := range lb.Listeners { + byPort[l.Port] = append(byPort[l.Port], l) + } + + for port, ls := range byPort { + if len(ls) < minSharing { + continue + } + + seen := map[string]bool{} + + for _, l := range ls { + if len(l.Hostnames) == 0 || (l.Protocol == protoTCP) != (ls[0].Protocol == protoTCP) || + (l.SSL == nil) != (ls[0].SSL == nil) { + return errConflict("port " + itoa(port) + " is already used by another listener") + } + + for _, h := range l.Hostnames { + name := strs.Fold(lb.Hostnames[h].Hostname) + if seen[name] { + return errConflict("hostname " + name + " is already bound to a listener on port " + itoa(port)) + } + + seen[name] = true + } + } + } + + return nil +} + +// UpdateLoadBalancer changes mutable load balancer properties. +func (b *InMemoryBackend) UpdateLoadBalancer(id, ifMatch string, in updateLBIn) (string, error) { + return b.Mutate(id, ifMatch, "UpdateLoadBalancer", false, func(lb *LoadBalancer) *APIError { + if in.IPMode != "" && in.IPMode != "IPV4" || in.Ipv6SubnetCidr != nil || len(in.ReservedIPs) > 0 { + return errUnsupported("ipMode, ipv6SubnetCidr and reservedIps changes") + } + + if in.DisplayName != nil { + if *in.DisplayName == "" { + return errParam("displayName cannot be empty") + } + + lb.DisplayName = *in.DisplayName + } + + if in.DeleteProtected != nil { + lb.DeleteProtected = *in.DeleteProtected + } + + if in.RequestIDOn != nil { + lb.RequestIDOn = *in.RequestIDOn + } + + if in.RequestIDHeader != nil { + lb.RequestIDHeader = *in.RequestIDHeader + } + + if lb.RequestIDOn && lb.RequestIDHeader == "" { + lb.RequestIDHeader = defaultRequestIDHeader + } + + lb.Tags.merge(in.Tags) + + return nil + }) +} + +// UpdateShape changes the load balancer shape. +func (b *InMemoryBackend) UpdateShape(id, ifMatch string, in updateShapeIn) (string, error) { + return b.Mutate(id, ifMatch, "UpdateLoadBalancerShape", false, func(lb *LoadBalancer) *APIError { + if err := checkShape(in.ShapeName, in.ShapeDetails); err != nil { + return err + } + + lb.ShapeName, lb.ShapeDetails = in.ShapeName, in.ShapeDetails + + return nil + }) +} + +// UpdateNSGs replaces the network security groups. +func (b *InMemoryBackend) UpdateNSGs(id, ifMatch string, in nsgIn) (string, error) { + return b.Mutate(id, ifMatch, "UpdateNetworkSecurityGroups", false, func(lb *LoadBalancer) *APIError { + lb.NSGs = in.NSGs + + return nil + }) +} + +// ChangeCompartment moves a load balancer to another compartment. +func (b *InMemoryBackend) ChangeCompartment(id, ifMatch string, in changeCompartmentIn) (string, error) { + return b.Mutate(id, ifMatch, "ChangeLoadBalancerCompartment", false, func(lb *LoadBalancer) *APIError { + if err := b.checkCompartment(in.CompartmentID); err != nil { + return err + } + + lb.CompartmentID = in.CompartmentID + + return nil + }) +} + +// DeleteLoadBalancer starts deleting a load balancer. +func (b *InMemoryBackend) DeleteLoadBalancer(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteLoadBalancer") + + lb, err := b.liveLB(id) + if err == nil && lb.State == stateDeleted { + err = errNotFound(id) + } + + if err == nil { + err = checkEtag(lb.Etag, ifMatch) + } + + if err == nil && lb.DeleteProtected { + err = errConflict("load balancer " + id + " has delete protection enabled") + } + + if err == nil && lb.State != stateActive { + err = errState("load balancer " + id + " is in state " + lb.State) + } + + if err == nil && lb.BusyUntil.After(b.nowUTC()) { + err = errConflict("another operation is in progress on load balancer " + id) + } + + var wr string + + if err == nil { + next := lb.clone() + next.State, next.TransitionAt, next.Etag = stateDeleting, b.transitionAt(), newEtag() + next.BusyUntil = *next.TransitionAt + b.lbs[id] = next + wr = b.newWR(next, "DeleteLoadBalancer", *next.TransitionAt) + } + + b.mu.Unlock() + + if err != nil { + return "", err + } + + b.dp.sync() + + return wr, nil +} + +// LoadBalancerWire is the wire form of a load balancer. +type LoadBalancerWire = LoadBalancer + +func (b *InMemoryBackend) view(lb *LoadBalancer, full bool) *LoadBalancerWire { + c := *lb + c.Etag, c.BusyUntil, c.TransitionAt = "", time.Time{}, nil + + if !full { + c.Listeners, c.Hostnames, c.SSLCipherSuites, c.Certificates = nil, nil, nil, nil + c.BackendSets, c.PathRouteSets, c.RuleSets, c.RoutingPolicies = nil, nil, nil, nil + + return &c + } + + c.Listeners = make(map[string]*Listener, len(lb.Listeners)) + for n, l := range lb.Listeners { + lc := *l + lc.Address = b.dp.address(lb.ID, l.Port) + c.Listeners[n] = &lc + } + + return &c +} + +// GetLoadBalancer returns one load balancer and its etag. +func (b *InMemoryBackend) GetLoadBalancer(id string) (*LoadBalancerWire, string, error) { + b.mu.RLock("GetLoadBalancer") + defer b.mu.RUnlock() + + lb, err := b.liveLB(id) + if err != nil { + return nil, "", err + } + + return b.view(lb, true), lb.Etag, nil +} + +// ListLoadBalancers lists the load balancers of a compartment. +func (b *InMemoryBackend) ListLoadBalancers( + compartment, name, state string, full bool, o ListOpts, +) (page.Page[*LoadBalancerWire], error) { + if compartment == "" { + return page.Page[*LoadBalancerWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListLoadBalancers") + defer b.mu.RUnlock() + + var all []*LoadBalancer + + for _, lb := range b.lbs { + if lb.CompartmentID != compartment || (name != "" && lb.DisplayName != name) { + continue + } + + if (state == "" && lb.State == stateDeleted) || (state != "" && !strings.EqualFold(lb.State, state)) { + continue + } + + all = append(all, lb) + } + + pg := paginate(all, o, func(l *LoadBalancer) string { return l.DisplayName }, + func(l *LoadBalancer) (time.Time, int64) { return l.TimeCreated, 0 }) + + out := make([]*LoadBalancerWire, 0, len(pg.Data)) + for _, lb := range pg.Data { + out = append(out, b.view(lb, full)) + } + + return page.Page[*LoadBalancerWire]{Data: out, Next: pg.Next}, nil +} + +// certKey returns the in-memory TLS key pair for a certificate bundle. Callers hold the lock. +func (b *InMemoryBackend) certKey(lbID, name string) *tls.Certificate { return b.keys[lbID+"/"+name] } + +// reapKeys drops in-memory TLS keys whose certificate is not part of the stored load balancer. +func (b *InMemoryBackend) reapKeys(lbID string) { + lb := b.lbs[lbID] + + for k := range b.keys { + name, ok := strings.CutPrefix(k, lbID+"/") + if !ok { + continue + } + + if lb == nil || lb.Certificates[name] == nil { + delete(b.keys, k) + } + } +} diff --git a/services/ociloadbalancer/leak_main_test.go b/services/ociloadbalancer/leak_main_test.go new file mode 100644 index 0000000000..1f321ae539 --- /dev/null +++ b/services/ociloadbalancer/leak_main_test.go @@ -0,0 +1,17 @@ +package ociloadbalancer_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ociloadbalancer/models.go b/services/ociloadbalancer/models.go new file mode 100644 index 0000000000..8ac99c422f --- /dev/null +++ b/services/ociloadbalancer/models.go @@ -0,0 +1,285 @@ +package ociloadbalancer + +import "time" + +// Load balancer lifecycle states. +const ( + stateCreating = "CREATING" + stateActive = "ACTIVE" + stateDeleting = "DELETING" + stateDeleted = "DELETED" +) + +// Tags are the shared tag sets of a load balancer. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + SecurityAttributes map[string]map[string]any `json:"securityAttributes,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } + + if in.SecurityAttributes != nil { + t.SecurityAttributes = in.SecurityAttributes + } +} + +// ShapeDetails are the flexible-shape bandwidth bounds in Mbps. +type ShapeDetails struct { + Minimum int `json:"minimumBandwidthInMbps"` + Maximum int `json:"maximumBandwidthInMbps"` +} + +// ReservedIP references a reserved public IP. +type ReservedIP struct { + ID string `json:"id,omitempty"` +} + +// IPAddress is an address assigned to a load balancer. +type IPAddress struct { + ReservedIP *ReservedIP `json:"reservedIp,omitempty"` + IPAddress string `json:"ipAddress"` + IsPublic bool `json:"isPublic"` +} + +// SSLConfig is a listener or backend set TLS configuration. +type SSLConfig struct { + VerifyDepth *int `json:"verifyDepth,omitempty"` + VerifyPeerCertificate *bool `json:"verifyPeerCertificate,omitempty"` + HasSessionResumption *bool `json:"hasSessionResumption,omitempty"` + CertificateName string `json:"certificateName,omitempty"` + CipherSuiteName string `json:"cipherSuiteName,omitempty"` + ServerOrderPreference string `json:"serverOrderPreference,omitempty"` + TrustedCertificateAuthorityIDs []string `json:"trustedCertificateAuthorityIds,omitempty"` + CertificateIDs []string `json:"certificateIds,omitempty"` + Protocols []string `json:"protocols,omitempty"` +} + +// ConnectionConfig tunes listener connections. +type ConnectionConfig struct { + BackendTCPProxyProtocolVersion *int `json:"backendTcpProxyProtocolVersion,omitempty"` + BackendTCPProxyProtocolOptions []string `json:"backendTcpProxyProtocolOptions,omitempty"` + IdleTimeout int64 `json:"idleTimeout"` +} + +// Listener accepts client connections. +type Listener struct { + SSL *SSLConfig `json:"sslConfiguration,omitempty"` + Connection *ConnectionConfig `json:"connectionConfiguration,omitempty"` + Name string `json:"name"` + DefaultSet string `json:"defaultBackendSetName"` + Protocol string `json:"protocol"` + PathRoutes string `json:"pathRouteSetName,omitempty"` + Policy string `json:"routingPolicyName,omitempty"` + Address string `json:"gopherstackAddress,omitempty"` + Hostnames []string `json:"hostnameNames,omitempty"` + RuleSets []string `json:"ruleSetNames,omitempty"` + Port int `json:"port"` +} + +// Backend is a backend server. +type Backend struct { + MaxConnections *int `json:"maxConnections,omitempty"` + Name string `json:"name"` + IPAddress string `json:"ipAddress"` + Port int `json:"port"` + Weight int `json:"weight"` + Drain bool `json:"drain"` + Backup bool `json:"backup"` + Offline bool `json:"offline"` +} + +// HealthChecker is a backend set health check configuration. +type HealthChecker struct { + Protocol string `json:"protocol"` + URLPath string `json:"urlPath,omitempty"` + ResponseBodyRegex string `json:"responseBodyRegex"` + Port int `json:"port"` + ReturnCode int `json:"returnCode"` + Retries int `json:"retries"` + TimeoutInMillis int `json:"timeoutInMillis"` + IntervalInMillis int `json:"intervalInMillis"` + IsForcePlainText bool `json:"isForcePlainText"` +} + +// SessionPersistence is application-cookie session persistence. +type SessionPersistence struct { + DisableFallback *bool `json:"disableFallback,omitempty"` + CookieName string `json:"cookieName"` +} + +// LBCookiePersistence is load-balancer-inserted cookie session persistence. +type LBCookiePersistence struct { + DisableFallback *bool `json:"disableFallback,omitempty"` + MaxAgeInSeconds *int `json:"maxAgeInSeconds,omitempty"` + IsSecure *bool `json:"isSecure,omitempty"` + IsHTTPOnly *bool `json:"isHttpOnly,omitempty"` + CookieName string `json:"cookieName,omitempty"` + Domain string `json:"domain,omitempty"` + Path string `json:"path,omitempty"` +} + +// BackendSet groups backends under one balancing policy and health check. +type BackendSet struct { + SSL *SSLConfig `json:"sslConfiguration,omitempty"` + SessionPersistence *SessionPersistence `json:"sessionPersistenceConfiguration,omitempty"` + LBCookiePersistence *LBCookiePersistence `json:"lbCookieSessionPersistenceConfiguration,omitempty"` + BackendMaxConnections *int `json:"backendMaxConnections,omitempty"` + Name string `json:"name"` + Policy string `json:"policy"` + Backends []Backend `json:"backends"` + HealthChecker HealthChecker `json:"healthChecker"` +} + +// Hostname is a virtual hostname. +type Hostname struct { + Name string `json:"name"` + Hostname string `json:"hostname"` +} + +// MatchType is a path match type wrapper. +type MatchType struct { + MatchType string `json:"matchType"` +} + +// PathRoute routes a path to a backend set. +type PathRoute struct { + Path string `json:"path"` + BackendSetName string `json:"backendSetName"` + PathMatchType MatchType `json:"pathMatchType"` +} + +// PathRouteSet is an ordered list of path routes. +type PathRouteSet struct { + Name string `json:"name"` + PathRoutes []PathRoute `json:"pathRoutes"` +} + +// RoutingAction is a routing rule action. +type RoutingAction struct { + Name string `json:"name"` + BackendSetName string `json:"backendSetName"` +} + +// RoutingRule is a conditional routing rule. +type RoutingRule struct { + Name string `json:"name"` + Condition string `json:"condition"` + Actions []RoutingAction `json:"actions"` +} + +// RoutingPolicy is an ordered list of routing rules. +type RoutingPolicy struct { + Name string `json:"name"` + ConditionLanguageVersion string `json:"conditionLanguageVersion"` + Rules []RoutingRule `json:"rules"` +} + +// RuleCondition is a rule match condition. +type RuleCondition struct { + AttributeName string `json:"attributeName"` + AttributeValue string `json:"attributeValue"` + Operator string `json:"operator,omitempty"` +} + +// RedirectURI is the target of a REDIRECT rule. +type RedirectURI struct { + Protocol *string `json:"protocol,omitempty"` + Host *string `json:"host,omitempty"` + Port *int `json:"port,omitempty"` + Path *string `json:"path,omitempty"` + Query *string `json:"query,omitempty"` +} + +// IPMaxConnections caps connections for source addresses. +type IPMaxConnections struct { + IPAddresses []string `json:"ipAddresses"` + MaxConnections int `json:"maxConnections"` +} + +// Rule is one rule set item; the fields in use depend on Action. +type Rule struct { + ResponseCode *int `json:"responseCode,omitempty"` + StatusCode *int `json:"statusCode,omitempty"` + RedirectURI *RedirectURI `json:"redirectUri,omitempty"` + DefaultMaxConnections *int `json:"defaultMaxConnections,omitempty"` + HTTPLargeHeaderSizeKB *int `json:"httpLargeHeaderSizeInKB,omitempty"` + InvalidCharsAllowed *bool `json:"areInvalidCharactersAllowed,omitempty"` + Prefix *string `json:"prefix,omitempty"` + Suffix *string `json:"suffix,omitempty"` + Description *string `json:"description,omitempty"` + Action string `json:"action"` + Header string `json:"header,omitempty"` + Value *string `json:"value,omitempty"` + Conditions []RuleCondition `json:"conditions,omitempty"` + AllowedMethods []string `json:"allowedMethods,omitempty"` + IPMaxConnections []IPMaxConnections `json:"ipMaxConnections,omitempty"` +} + +// RuleSet is an ordered list of rules. +type RuleSet struct { + Name string `json:"name"` + Items []Rule `json:"items"` +} + +// SSLCipherSuite is a custom cipher suite. +type SSLCipherSuite struct { + Name string `json:"name"` + Ciphers []string `json:"ciphers"` +} + +// Certificate is a certificate bundle; its private key is held in memory only and never returned. +type Certificate struct { + Name string `json:"certificateName"` + Public string `json:"publicCertificate"` + CA string `json:"caCertificate"` +} + +// LoadBalancer is a load balancer with all of its sub-resources. +type LoadBalancer struct { + Tags + TimeCreated time.Time `json:"timeCreated"` + BusyUntil time.Time `json:"busyUntil,omitzero"` + ShapeDetails *ShapeDetails `json:"shapeDetails,omitempty"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Listeners map[string]*Listener `json:"listeners,omitempty"` + Hostnames map[string]*Hostname `json:"hostnames,omitempty"` + SSLCipherSuites map[string]*SSLCipherSuite `json:"sslCipherSuites,omitempty"` + Certificates map[string]*Certificate `json:"certificates,omitempty"` + BackendSets map[string]*BackendSet `json:"backendSets,omitempty"` + PathRouteSets map[string]*PathRouteSet `json:"pathRouteSets,omitempty"` + RuleSets map[string]*RuleSet `json:"ruleSets,omitempty"` + RoutingPolicies map[string]*RoutingPolicy `json:"routingPolicies,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + State string `json:"lifecycleState"` + ShapeName string `json:"shapeName"` + IPMode string `json:"ipMode,omitempty"` + RequestIDHeader string `json:"requestIdHeader,omitempty"` + Etag string `json:"etag,omitempty"` + SubnetIDs []string `json:"subnetIds"` + NSGs []string `json:"networkSecurityGroupIds"` + IPAddresses []IPAddress `json:"ipAddresses"` + IsPrivate bool `json:"isPrivate"` + DeleteProtected bool `json:"isDeleteProtectionEnabled"` + RequestIDOn bool `json:"isRequestIdEnabled"` +} + +// WorkRequest records an asynchronous load balancer operation. +type WorkRequest struct { + Accepted time.Time `json:"accepted"` + FinishAt time.Time `json:"finishAt"` + ID string `json:"id"` + LoadBalancerID string `json:"loadBalancerId"` + CompartmentID string `json:"compartmentId"` + Type string `json:"type"` + Seq int64 `json:"seq"` +} diff --git a/services/ociloadbalancer/ops.go b/services/ociloadbalancer/ops.go new file mode 100644 index 0000000000..a46df4c6be --- /dev/null +++ b/services/ociloadbalancer/ops.go @@ -0,0 +1,258 @@ +package ociloadbalancer + +import ( + "maps" + "net/http" + "slices" +) + +func buildOps() map[string]opFunc { + m := map[string]opFunc{} + + for _, set := range []map[string]opFunc{lbOps(), backendSetOps(), subresourceOps(), healthOps()} { + maps.Copy(m, set) + } + + return m +} + +func lbOps() map[string]opFunc { + return map[string]opFunc{ + opCreateLB: (*Handler).createLB, opGetLB: (*Handler).getLB, opListLB: (*Handler).listLBs, + opUpdateLB: (*Handler).updateLB, opDeleteLB: (*Handler).deleteLB, opChangeComp: (*Handler).changeCompartment, + opUpdateShape: (*Handler).updateShape, opUpdateNSG: (*Handler).updateNSGs, + opListShapes: staticList(shapeNames), opListPolicies: staticList(policyNames), + opListProtocols: staticList(protocolNames), opGetWR: (*Handler).getWorkRequest, + opListWR: (*Handler).listWorkRequests, + } +} + +func staticList(names []string) opFunc { + return func(_ *Handler, q *req) error { + out := make([]map[string]string, 0, len(names)) + for _, n := range names { + out = append(out, map[string]string{"name": n}) + } + + return q.json(http.StatusOK, out, "") + } +} + +func (h *Handler) createLB(q *req) error { + var in createLBIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateLoadBalancer(in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getLB(q *req) error { + lb, etag, err := h.Backend.GetLoadBalancer(q.param("loadBalancerId")) + if err != nil { + return err + } + + return q.json(http.StatusOK, lb, etag) +} + +func (h *Handler) listLBs(q *req) error { + pg, err := h.Backend.ListLoadBalancers( + q.query("compartmentId"), q.query("displayName"), q.query("lifecycleState"), + !equalFold(q.query("detail"), "simple"), q.listOpts(), + ) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, pg.Data, "") +} + +func (h *Handler) updateLB(q *req) error { + return withBody(q, func(in updateLBIn) (string, error) { + return h.Backend.UpdateLoadBalancer(q.param("loadBalancerId"), q.ifMatch(), in) + }) +} + +func (h *Handler) updateShape(q *req) error { + return withBody(q, func(in updateShapeIn) (string, error) { + return h.Backend.UpdateShape(q.param("loadBalancerId"), q.ifMatch(), in) + }) +} + +func (h *Handler) updateNSGs(q *req) error { + return withBody(q, func(in nsgIn) (string, error) { + return h.Backend.UpdateNSGs(q.param("loadBalancerId"), q.ifMatch(), in) + }) +} + +func (h *Handler) changeCompartment(q *req) error { + return withBody(q, func(in changeCompartmentIn) (string, error) { + return h.Backend.ChangeCompartment(q.param("loadBalancerId"), q.ifMatch(), in) + }) +} + +func (h *Handler) deleteLB(q *req) error { + wr, err := h.Backend.DeleteLoadBalancer(q.param("loadBalancerId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + if err != nil { + return err + } + + return q.json(http.StatusOK, w, "") +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(q.param("loadBalancerId"), q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, pg.Data, "") +} + +// withBody decodes the request body, runs the operation and answers 202 with its work request id. +func withBody[I any](q *req, fn func(in I) (string, error)) error { + var in I + if err := q.decode(&in); err != nil { + return err + } + + wr, err := fn(in) + + return acceptedWR(q, wr, err) +} + +func acceptedWR(q *req, wr string, err error) error { + if err != nil { + return err + } + + return q.accepted(wr) +} + +// mutateBody decodes the body and applies fn to the addressed load balancer. +func mutateBody[I any]( + h *Handler, + q *req, + typ string, + fn func(b *InMemoryBackend, lb *LoadBalancer, in *I) *APIError, +) error { + return withBody(q, func(in I) (string, error) { + return h.Backend.Mutate(q.param("loadBalancerId"), q.ifMatch(), typ, false, func(lb *LoadBalancer) *APIError { + return fn(h.Backend, lb, &in) + }) + }) +} + +// mutateDelete applies fn to the addressed load balancer; dangling references answer 409. +func mutateDelete(h *Handler, q *req, typ string, fn func(b *InMemoryBackend, lb *LoadBalancer) *APIError) error { + wr, err := h.Backend.Mutate(q.param("loadBalancerId"), q.ifMatch(), typ, true, func(lb *LoadBalancer) *APIError { + return fn(h.Backend, lb) + }) + + return acceptedWR(q, wr, err) +} + +// readSub answers with one sub-resource of the addressed load balancer. +func readSub[T any](h *Handler, q *req, pick func(lb *LoadBalancer) (T, *APIError)) error { + var ( + out T + etag string + ) + + err := h.Backend.Read(q.param("loadBalancerId"), func(lb *LoadBalancer) *APIError { + v, perr := pick(lb) + out, etag = v, lb.Etag + + return perr + }) + if err != nil { + return err + } + + return q.json(http.StatusOK, out, etag) +} + +func lookup[T any](m map[string]*T, name string) (*T, *APIError) { + v, ok := m[name] + if !ok { + return nil, errNotFound(name) + } + + return v, nil +} + +func sortedValues[T any](m map[string]*T) []*T { + keys := slices.Sorted(maps.Keys(m)) + out := make([]*T, 0, len(keys)) + + for _, k := range keys { + out = append(out, m[k]) + } + + return out +} + +// getByName builds a Get operation for a map-held sub-resource addressed by a path parameter. +func getByName[T any](param string, m func(*LoadBalancer) map[string]*T) opFunc { + return func(h *Handler, q *req) error { + return readSub(h, q, func(lb *LoadBalancer) (*T, *APIError) { return lookup(m(lb), q.param(param)) }) + } +} + +// listAll builds a List operation for a map-held sub-resource. +func listAll[T any](m func(*LoadBalancer) map[string]*T) opFunc { + return func(h *Handler, q *req) error { + return readSub(h, q, func(lb *LoadBalancer) ([]*T, *APIError) { return sortedValues(m(lb)), nil }) + } +} + +// deleteByName builds a Delete operation for a map-held sub-resource. +func deleteByName[T any](typ, param string, m func(*LoadBalancer) map[string]*T) opFunc { + return func(h *Handler, q *req) error { + name := q.param(param) + + return mutateDelete(h, q, typ, func(_ *InMemoryBackend, lb *LoadBalancer) *APIError { + if _, err := lookup(m(lb), name); err != nil { + return err + } + + delete(m(lb), name) + + return nil + }) + } +} + +// Read runs fn against a load balancer under the read lock. +func (b *InMemoryBackend) Read(id string, fn func(lb *LoadBalancer) *APIError) error { + b.mu.RLock("Read") + defer b.mu.RUnlock() + + lb, err := b.liveLB(id) + if err != nil { + return err + } + + if lb.State == stateDeleted { + return errNotFound(id) + } + + if ferr := fn(lb); ferr != nil { + return ferr + } + + return nil +} diff --git a/services/ociloadbalancer/persistence.go b/services/ociloadbalancer/persistence.go new file mode 100644 index 0000000000..94abb16066 --- /dev/null +++ b/services/ociloadbalancer/persistence.go @@ -0,0 +1,138 @@ +package ociloadbalancer + +import ( + "context" + "crypto/tls" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + LoadBalancers map[string]*LoadBalancer `json:"loadBalancers"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` + IPSeq int64 `json:"ipSeq"` +} + +// Snapshot serialises the backend state, including registered API keys. TLS private keys are never persisted. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ociloadbalancer", backendSnapshot{ + Version: snapshotVersion, LoadBalancers: b.lbs, WorkReqs: b.workReqs, Auth: keys, Seq: b.seq, IPSeq: b.ipSeq, + }) +} + +// Restore loads backend state from a snapshot and reopens the data-plane listeners of active load balancers. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ociloadbalancer", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ociloadbalancer: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.dp.reset() + b.mu.Lock("Restore") + b.lbs, b.workReqs, b.seq, b.ipSeq = snap.LoadBalancers, snap.WorkReqs, snap.Seq, snap.IPSeq + b.keys = map[string]*tls.Certificate{} + b.mu.Unlock() + b.Auth.Load(snap.Auth) + b.dp.sync() + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + if err := nullCheck("load balancer", &snap.LoadBalancers); err != nil { + return err + } + + for _, lb := range snap.LoadBalancers { + lb.fillMaps() + } + + return nullCheck("work request", &snap.WorkReqs) +} + +func (lb *LoadBalancer) fillMaps() { + if lb.Listeners == nil { + lb.Listeners = map[string]*Listener{} + } + + if lb.Hostnames == nil { + lb.Hostnames = map[string]*Hostname{} + } + + if lb.SSLCipherSuites == nil { + lb.SSLCipherSuites = map[string]*SSLCipherSuite{} + } + + if lb.Certificates == nil { + lb.Certificates = map[string]*Certificate{} + } + + if lb.BackendSets == nil { + lb.BackendSets = map[string]*BackendSet{} + } + + if lb.PathRouteSets == nil { + lb.PathRouteSets = map[string]*PathRouteSet{} + } + + if lb.RuleSets == nil { + lb.RuleSets = map[string]*RuleSet{} + } + + if lb.RoutingPolicies == nil { + lb.RoutingPolicies = map[string]*RoutingPolicy{} + } +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ociloadbalancer: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ociloadbalancer/persistence_test.go b/services/ociloadbalancer/persistence_test.go new file mode 100644 index 0000000000..55643d3d97 --- /dev/null +++ b/services/ociloadbalancer/persistence_test.go @@ -0,0 +1,124 @@ +package ociloadbalancer_test + +import ( + "crypto/tls" + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/loadbalancer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + ociloadbalancer "github.com/blackbirdworks/gopherstack/services/ociloadbalancer" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + certPEM, keyPEM := genCert(t) + src := newEnv(t, envOpts{}) + a := startBackend(t, "a") + d := baseDetails("persist") + d.FreeformTags = map[string]string{"k": "v"} + d.BackendSets = map[string]loadbalancer.BackendSetDetails{"bs": backendSet("ROUND_ROBIN", a)} + d.Certificates = map[string]loadbalancer.CertificateDetails{ + "cert": { + CertificateName: new("cert"), + PublicCertificate: new(string(certPEM)), + PrivateKey: new(string(keyPEM)), + }, + } + d.Listeners = map[string]loadbalancer.ListenerDetails{ + "plain": listener(), + "tls": { + DefaultBackendSetName: new("bs"), Port: new(443), Protocol: new("HTTP"), + SslConfiguration: &loadbalancer.SslConfigurationDetails{CertificateName: new("cert")}, + }, + } + id := src.mkLB(t, d) + snap := src.backend.Snapshot(t.Context()) + + dst := newEnv(t, envOpts{}) + require.NoError(t, dst.backend.Restore(t.Context(), snap)) + + got := dst.get(t, id) + assert.Equal(t, "persist", *got.DisplayName) + assert.Equal(t, "v", got.FreeformTags["k"]) + assert.Equal(t, loadbalancer.LoadBalancerLifecycleStateActive, got.LifecycleState) + assert.Contains(t, got.Certificates, "cert") + + { // listeners reopen and route + r := dst.call(t, id, httpPort, "", "/again", nil) + assert.Equal(t, http.StatusOK, r.status) + assert.Equal(t, "a /again", r.body) + } + + { // private keys are not restored + conn, dialErr := tls.Dial( + "tcp", + dst.addr(t, id, 443), + tlsClient(t, certPEM).Transport.(*http.Transport).TLSClientConfig, + ) + if dialErr == nil { + _ = conn.Close() + } + + require.Error(t, dialErr) + } + + { // work requests survive + wrs, err := dst.lb.ListWorkRequests(t.Context(), loadbalancer.ListWorkRequestsRequest{LoadBalancerId: &id}) + require.NoError(t, err) + assert.NotEmpty(t, wrs.Items) + } + + { // restore of garbage fails + require.Error(t, dst.backend.Restore(t.Context(), []byte("{"))) + } + + { // reset closes listeners + dst.backend.Reset() + assert.Empty(t, dst.backend.Endpoints()) + _, err := dst.lb.GetLoadBalancer(t.Context(), loadbalancer.GetLoadBalancerRequest{LoadBalancerId: &id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + } +} + +type cfgStub struct{ s ociloadbalancer.Settings } + +func (c cfgStub) GetOCILoadBalancerSettings() ociloadbalancer.Settings { return c.s } + +func TestProviderInit(t *testing.T) { + t.Parallel() + + p := &ociloadbalancer.Provider{} + + _, err := p.Init(nil) + require.ErrorIs(t, err, ociloadbalancer.ErrNilAppContext) + + tests := []struct { + cfg any + name string + port int + }{ + {name: "defaults", port: ociloadbalancer.DefaultPort}, + {name: "from config", cfg: cfgStub{ociloadbalancer.Settings{Port: 9999, EnforceAuth: true}}, port: 9999}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + reg, initErr := p.Init(&service.AppContext{Config: tt.cfg}) + require.NoError(t, initErr) + + h, ok := reg.(*ociloadbalancer.Handler) + require.True(t, ok) + assert.Equal(t, tt.port, h.Port) + assert.Equal(t, "OCILoadBalancer", h.Name()) + assert.NotEmpty(t, h.GetSupportedOperations()) + assert.Equal(t, 8165, ociloadbalancer.DefaultPort) + }) + } +} diff --git a/services/ociloadbalancer/provider.go b/services/ociloadbalancer/provider.go new file mode 100644 index 0000000000..8ea43d8363 --- /dev/null +++ b/services/ociloadbalancer/provider.go @@ -0,0 +1,45 @@ +package ociloadbalancer + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ociloadbalancer: nil app context") + +// ConfigProvider extracts OCI Load Balancer configuration from the AppContext Config. +type ConfigProvider interface { + GetOCILoadBalancerSettings() Settings +} + +// Provider implements service.Provider for OCI Load Balancer. It runs on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCILoadBalancer" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCILoadBalancerSettings() + } + + backend := NewInMemoryBackend() + backend.SetAllowExternal(settings.AllowExternalBackend) + + handler := NewHandler(backend) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ociloadbalancer/proxy.go b/services/ociloadbalancer/proxy.go new file mode 100644 index 0000000000..66b14d87c7 --- /dev/null +++ b/services/ociloadbalancer/proxy.go @@ -0,0 +1,609 @@ +package ociloadbalancer + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "hash/fnv" + "io" + "net" + "net/http" + "net/http/httputil" + "net/netip" + "slices" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + defaultRequestIDHeader = "X-Request-Id" + routeCookie = "X-Oracle-BMC-LBS-Route" + dialTimeout = 10 * time.Second + pipeDirections = 2 +) + +func hostOnly(h string) string { + if host, _, err := net.SplitHostPort(h); err == nil { + return host + } + + return strings.Trim(h, "[]") +} + +func hostMatches(pattern, host string) bool { + p, h := strs.Fold(pattern), strs.Fold(host) + + switch { + case strings.HasPrefix(p, "*."): + return strings.HasSuffix(h, p[1:]) && len(h) > len(p)-1 + case strings.HasSuffix(p, ".*"): + return strings.HasPrefix(h, p[:len(p)-1]) && len(h) > len(p)-1 + default: + return p == h + } +} + +// listenersOn returns the listeners bound to a port, ordered by name. +func listenersOn(lb *LoadBalancer, port int) []*Listener { + var out []*Listener + + for _, l := range lb.Listeners { + if l.Port == port { + out = append(out, l) + } + } + + slices.SortFunc(out, func(a, b *Listener) int { return strings.Compare(a.Name, b.Name) }) + + return out +} + +// pickListener chooses the listener on a port for a virtual host: an exact hostname match, then a wildcard +// match, then a listener without hostnames. +func pickListener(lb *LoadBalancer, port int, host string) *Listener { + group := listenersOn(lb, port) + + var wild, open *Listener + + for _, l := range group { + if len(l.Hostnames) == 0 { + open = l + + continue + } + + for _, hn := range l.Hostnames { + pat := lb.Hostnames[hn].Hostname + if strs.Equal(pat, host) { + return l + } + + if wild == nil && hostMatches(pat, host) { + wild = l + } + } + } + + if wild != nil { + return wild + } + + return open +} + +func (d *dataPlane) selectSet(lb *LoadBalancer, l *Listener, r *http.Request) string { + if l.Policy != "" { + cc := newCondCtx(r) + + for _, rule := range lb.RoutingPolicies[l.Policy].Rules { + if n, err := parseCondition(rule.Condition); err == nil && n.eval(cc) { + return rule.Actions[0].BackendSetName + } + } + + return l.DefaultSet + } + + if l.PathRoutes != "" { + if name, ok := routePath(lb.PathRouteSets[l.PathRoutes], r.URL.Path); ok { + return name + } + } + + return l.DefaultSet +} + +func routeValue(be Backend) string { + h := fnv.New64a() + _, _ = h.Write([]byte(be.Name)) + + return strconv.FormatUint(h.Sum64(), 16) +} + +func ruleSetsOf(lb *LoadBalancer, l *Listener) []*RuleSet { + out := make([]*RuleSet, 0, len(l.RuleSets)) + for _, n := range l.RuleSets { + out = append(out, lb.RuleSets[n]) + } + + return out +} + +func (d *dataPlane) serveHTTP(w http.ResponseWriter, r *http.Request, s portSpec) { + lb := d.b.activeLB(s.lbID) + if lb == nil { + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + + return + } + + l := pickListener(lb, s.port, hostOnly(r.Host)) + if l == nil { + http.Error(w, "Not Found", http.StatusNotFound) + + return + } + + sc := reqScope{scheme: schemeHTTP, port: s.port, ip: clientAddr(r)} + if s.tls { + sc.scheme = schemeHTTPS + } + + if lb.RequestIDOn && r.Header.Get(lb.RequestIDHeader) == "" { + r.Header.Set(lb.RequestIDHeader, uuid.NewString()) + } + + reply, respRules := applyRequestRules(ruleSetsOf(lb, l), r, sc) + if reply != nil { + if reply.location != "" { + w.Header().Set("Location", reply.location) + } + + w.WriteHeader(reply.status) + + return + } + + setName := d.selectSet(lb, l, r) + d.runDueChecks(lb.ID, setName) + bs := lb.BackendSets[setName] + + ch, status := d.choose(lb, bs, sc.ip, r) + if ch == nil { + http.Error(w, http.StatusText(status), status) + + return + } + + defer ch.release() + + d.proxy(w, r, lb, l, ch, respRules) +} + +func (d *dataPlane) proxy( + w http.ResponseWriter, + r *http.Request, + lb *LoadBalancer, + l *Listener, + ch *chosen, + respRules []Rule, +) { + scheme := schemeHTTP + if ch.set.SSL != nil { + scheme = schemeHTTPS + } + + target := scheme + "://" + net.JoinHostPort(ch.be.IPAddress, itoa(ch.be.Port)) + rt := d.transportForRequest(lb, ch.set) + + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme, pr.Out.URL.Host = scheme, strings.TrimPrefix(target, scheme+"://") + pr.Out.Host = pr.In.Host + pr.SetXForwarded() + }, + Transport: idleTransport{rt: rt, idle: idleFor(l)}, + ModifyResponse: func(resp *http.Response) error { + applyResponseRules(resp.Header, respRules) + stickCookie(resp, ch) + + return nil + }, + ErrorHandler: func(rw http.ResponseWriter, _ *http.Request, err error) { + status := http.StatusBadGateway + if errors.Is(err, context.DeadlineExceeded) || isTimeout(err) { + status = http.StatusGatewayTimeout + } + + http.Error(rw, http.StatusText(status), status) + }, + } + + rp.ServeHTTP(w, r) +} + +func (d *dataPlane) transportForRequest(lb *LoadBalancer, bs *BackendSet) *http.Transport { + d.b.mu.RLock("transportForRequest") + defer d.b.mu.RUnlock() + + return d.transportFor(lb, bs) +} + +func idleFor(l *Listener) time.Duration { + if l.Connection != nil { + return time.Duration(l.Connection.IdleTimeout) * time.Second + } + + return defaultHTTPIdle * time.Second +} + +func isTimeout(err error) bool { + var ne net.Error + + return errors.As(err, &ne) && ne.Timeout() +} + +// idleTransport fails a request whose backend sends no response headers within idle. +type idleTransport struct { + rt http.RoundTripper + idle time.Duration +} + +func (t idleTransport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx, cancel := context.WithCancel(req.Context()) + + var fired atomic.Bool + + timer := time.AfterFunc(t.idle, func() { + fired.Store(true) + cancel() + }) + + resp, err := t.rt.RoundTrip(req.WithContext(ctx)) + timer.Stop() + + if err != nil { + cancel() + + if fired.Load() { + return nil, context.DeadlineExceeded + } + + return nil, err + } + + resp.Body = &cancelBody{ReadCloser: resp.Body, cancel: cancel} + + return resp, nil +} + +type cancelBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (c *cancelBody) Close() error { + err := c.ReadCloser.Close() + c.cancel() + + return err +} + +// chosen is the backend a request or connection was assigned to. +type chosen struct { + set *BackendSet + release func() + be Backend + setCook bool +} + +func (d *dataPlane) available(lb *LoadBalancer, bs *BackendSet, be Backend) bool { + if be.Offline { + return false + } + + if st, _, _ := d.statusOf(lb.ID, bs.Name, be); st == hCritical { + return false + } + + limit := be.MaxConnections + if limit == nil { + limit = bs.BackendMaxConnections + } + + return limit == nil || d.counter(healthKey(lb.ID, bs.Name, be.Name)).Load() < int64(*limit) +} + +// choose picks a backend: the session-persistent one if usable, otherwise per the set policy among healthy +// backends, falling back to backups only when no regular backend is usable. The status is the failure code. +func (d *dataPlane) choose(lb *LoadBalancer, bs *BackendSet, ip netip.Addr, r *http.Request) (*chosen, int) { + var regular, backup, drained []Backend + + for _, be := range bs.Backends { + if !d.available(lb, bs, be) { + continue + } + + switch { + case be.Drain: + drained = append(drained, be) + case be.Backup: + backup = append(backup, be) + default: + regular = append(regular, be) + } + } + + var pick *Backend + + if r != nil { + pick = stickyBackend(bs, r, slices.Concat(regular, backup, drained)) + if pick == nil && stickyCookie(bs, r) != "" && bs.LBCookiePersistence != nil && + boolOr(bs.LBCookiePersistence.DisableFallback) { + return nil, http.StatusBadGateway + } + } + + if pick == nil { + pool := regular + if len(pool) == 0 { + pool = backup + } + + if len(pool) == 0 { + return nil, http.StatusServiceUnavailable + } + + pick = d.byPolicy(lb, bs, pool, ip) + } + + cnt := d.counter(healthKey(lb.ID, bs.Name, pick.Name)) + cnt.Add(1) + + return &chosen{be: *pick, set: bs, release: func() { cnt.Add(-1) }, setCook: bs.LBCookiePersistence != nil}, 0 +} + +func stickyCookie(bs *BackendSet, r *http.Request) string { + name := routeCookie + if bs.LBCookiePersistence != nil && bs.LBCookiePersistence.CookieName != "" { + name = bs.LBCookiePersistence.CookieName + } + + if bs.LBCookiePersistence == nil && bs.SessionPersistence == nil { + return "" + } + + if c, err := r.Cookie(name); err == nil { + return c.Value + } + + return "" +} + +func stickyBackend(bs *BackendSet, r *http.Request, usable []Backend) *Backend { + v := stickyCookie(bs, r) + if v == "" { + return nil + } + + for i := range usable { + if routeValue(usable[i]) == v { + return &usable[i] + } + } + + return nil +} + +func (d *dataPlane) byPolicy(lb *LoadBalancer, bs *BackendSet, pool []Backend, ip netip.Addr) *Backend { + switch bs.Policy { + case policyIPHash: + h := fnv.New32a() + _, _ = h.Write([]byte(ip.String())) + + return &pool[int(h.Sum32())%len(pool)] + case policyLeastConns: + best, bestLoad := 0, 0.0 + + for i, be := range pool { + load := float64(d.counter(healthKey(lb.ID, bs.Name, be.Name)).Load()) / float64(be.Weight) + if i == 0 || load < bestLoad { + best, bestLoad = i, load + } + } + + return &pool[best] + default: + return d.weightedRoundRobin(lb, bs, pool) + } +} + +// weightedRoundRobin is smooth weighted round robin: each backend accrues its weight per pick and the leader +// pays the total. +func (d *dataPlane) weightedRoundRobin(lb *LoadBalancer, bs *BackendSet, pool []Backend) *Backend { + d.mu.Lock("weightedRoundRobin") + defer d.mu.Unlock() + + total, best := 0, 0 + + for i, be := range pool { + k := healthKey(lb.ID, bs.Name, be.Name) + d.cursors[k] += be.Weight + total += be.Weight + + if d.cursors[k] > d.cursors[healthKey(lb.ID, bs.Name, pool[best].Name)] { + best = i + } + } + + d.cursors[healthKey(lb.ID, bs.Name, pool[best].Name)] -= total + + return &pool[best] +} + +// stickCookie adds the load balancer's route cookie to a response for session persistence. +func stickCookie(resp *http.Response, ch *chosen) { + switch { + case ch.set.LBCookiePersistence != nil: + lc := ch.set.LBCookiePersistence + name := routeCookie + + if lc.CookieName != "" { + name = lc.CookieName + } + + ck := &http.Cookie{ //nolint:gosec // attributes come from the listener configuration + Name: name, Value: routeValue(ch.be), Path: "/", Domain: lc.Domain, Secure: boolOr(lc.IsSecure), + HttpOnly: boolOr(lc.IsHTTPOnly), SameSite: http.SameSiteLaxMode, + } + if lc.Path != "" { + ck.Path = lc.Path + } + + if lc.MaxAgeInSeconds != nil { + ck.MaxAge = *lc.MaxAgeInSeconds + } + + resp.Header.Add("Set-Cookie", ck.String()) + case ch.set.SessionPersistence != nil: + for _, ck := range resp.Cookies() { + if ck.Name == ch.set.SessionPersistence.CookieName { + resp.Header.Add( + "Set-Cookie", + (&http.Cookie{ //nolint:gosec // route cookie is opaque and path-scoped + Name: routeCookie, Value: routeValue(ch.be), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, + }).String(), + ) + + return + } + } + } +} + +// idleConn refreshes a connection deadline before every read and write. +type idleConn struct { + net.Conn + idle time.Duration +} + +func (c idleConn) Read(p []byte) (int, error) { + _ = c.SetReadDeadline(time.Now().Add(c.idle)) + + return c.Conn.Read(p) +} + +func (c idleConn) Write(p []byte) (int, error) { + _ = c.SetWriteDeadline(time.Now().Add(c.idle)) + + return c.Conn.Write(p) +} + +func pipe(a, b net.Conn, idle time.Duration) { + done := make(chan struct{}, pipeDirections) + ia, ib := idleConn{Conn: a, idle: idle}, idleConn{Conn: b, idle: idle} + + go func() { _, _ = io.Copy(ib, ia); done <- struct{}{} }() + go func() { _, _ = io.Copy(ia, ib); done <- struct{}{} }() + + <-done + _ = a.Close() + _ = b.Close() + <-done +} + +func (d *dataPlane) acceptTCP(ctx context.Context, p *portListener, ln net.Listener) { + for { + c, err := ln.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return + } + + continue + } + + d.track(p, c) + p.wg.Go(func() { + defer d.untrack(p, c) + defer c.Close() + + d.handleTCP(ctx, p.spec, c) + }) + } +} + +func (d *dataPlane) handleTCP(ctx context.Context, s portSpec, c net.Conn) { + lb := d.b.activeLB(s.lbID) + if lb == nil { + return + } + + l := pickListener(lb, s.port, "") + if l == nil { + return + } + + bs := lb.BackendSets[l.DefaultSet] + d.runDueChecks(lb.ID, bs.Name) + + ip := netip.Addr{} + if ta, ok := c.RemoteAddr().(*net.TCPAddr); ok { + ip = ta.AddrPort().Addr().Unmap() + } + + ch, _ := d.choose(lb, bs, ip, nil) + if ch == nil { + return + } + + defer ch.release() + + up, err := d.dialBackend(ctx, lb, bs, ch.be) + if err != nil { + return + } + + idle := defaultTCPIdle + if l.Connection != nil { + idle = int(l.Connection.IdleTimeout) + } + + pipe(c, up, time.Duration(idle)*time.Second) +} + +func (d *dataPlane) dialBackend(ctx context.Context, lb *LoadBalancer, bs *BackendSet, be Backend) (net.Conn, error) { + ctx, cancel := context.WithTimeout(ctx, dialTimeout) + defer cancel() + + conn, err := d.dialer.DialContext(ctx, "tcp", net.JoinHostPort(be.IPAddress, itoa(be.Port))) + if err != nil { + return nil, fmt.Errorf("dial backend: %w", err) + } + + if bs.SSL == nil { + return conn, nil + } + + d.b.mu.RLock("dialBackend") + cfg := d.b.backendTLS(lb, bs.SSL) + d.b.mu.RUnlock() + + cfg.ServerName = be.IPAddress + tc := tls.Client(conn, cfg) + + if err = tc.HandshakeContext(ctx); err != nil { + _ = conn.Close() + + return nil, fmt.Errorf("backend handshake: %w", err) + } + + return tc, nil +} diff --git a/services/ociloadbalancer/route.go b/services/ociloadbalancer/route.go new file mode 100644 index 0000000000..d1ba679920 --- /dev/null +++ b/services/ociloadbalancer/route.go @@ -0,0 +1,233 @@ +package ociloadbalancer + +import ( + "net/http" + "slices" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opUnknown = "Unknown" + opAdminKeys = "ManageAPIKeys" + opAdminEnds = "ListDataPlaneEndpoints" + adminPrefix = "_gopherstack" + base = "/20170115" + lbPath = base + "/loadBalancers/{loadBalancerId}" + bsPath = lbPath + "/backendSets/{backendSetName}" + bePath = bsPath + "/backends/{backendName}" +) + +const ( + opCreateLB = "CreateLoadBalancer" + opGetLB = "GetLoadBalancer" + opListLB = "ListLoadBalancers" + opUpdateLB = "UpdateLoadBalancer" + opDeleteLB = "DeleteLoadBalancer" + opChangeComp = "ChangeLoadBalancerCompartment" + opUpdateShape = "UpdateLoadBalancerShape" + opUpdateNSG = "UpdateNetworkSecurityGroups" + opListShapes = "ListShapes" + opListPolicies = "ListPolicies" + opListProtocols = "ListProtocols" + opGetWR = "GetWorkRequest" + opListWR = "ListWorkRequests" + opListHealths = "ListLoadBalancerHealths" + opGetLBHealth = "GetLoadBalancerHealth" + opCreateBS = "CreateBackendSet" + opGetBS = "GetBackendSet" + opListBS = "ListBackendSets" + opUpdateBS = "UpdateBackendSet" + opDeleteBS = "DeleteBackendSet" + opGetBSHealth = "GetBackendSetHealth" + opGetHC = "GetHealthChecker" + opUpdateHC = "UpdateHealthChecker" + opCreateBE = "CreateBackend" + opGetBE = "GetBackend" + opListBE = "ListBackends" + opUpdateBE = "UpdateBackend" + opDeleteBE = "DeleteBackend" + opGetBEHealth = "GetBackendHealth" + opCreateListener = "CreateListener" + opUpdateListener = "UpdateListener" + opDeleteListener = "DeleteListener" + opListListenerRules = "ListListenerRules" + opCreateHostname = "CreateHostname" + opGetHostname = "GetHostname" + opListHostnames = "ListHostnames" + opUpdateHostname = "UpdateHostname" + opDeleteHostname = "DeleteHostname" + opCreatePRS = "CreatePathRouteSet" + opGetPRS = "GetPathRouteSet" + opListPRS = "ListPathRouteSets" + opUpdatePRS = "UpdatePathRouteSet" + opDeletePRS = "DeletePathRouteSet" + opCreateRP = "CreateRoutingPolicy" + opGetRP = "GetRoutingPolicy" + opListRP = "ListRoutingPolicies" + opUpdateRP = "UpdateRoutingPolicy" + opDeleteRP = "DeleteRoutingPolicy" + opCreateRS = "CreateRuleSet" + opGetRS = "GetRuleSet" + opListRS = "ListRuleSets" + opUpdateRS = "UpdateRuleSet" + opDeleteRS = "DeleteRuleSet" + opCreateSuite = "CreateSSLCipherSuite" + opGetSuite = "GetSSLCipherSuite" + opListSuites = "ListSSLCipherSuites" + opUpdateSuite = "UpdateSSLCipherSuite" + opDeleteSuite = "DeleteSSLCipherSuite" + opCreateCert = "CreateCertificate" + opListCerts = "ListCertificates" + opDeleteCert = "DeleteCertificate" +) + +func sub(coll, param string) string { return lbPath + "/" + coll + "/{" + param + "}" } + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New(buildRoutes()) + +func buildRoutes() []restroute.Route { + return slices.Concat(lbRoutes(), subRoutes()) +} + +func lbRoutes() []restroute.Route { + g, p, u, d := http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete + + return []restroute.Route{ + {Method: p, Path: base + "/loadBalancers", Op: opCreateLB}, + {Method: g, Path: base + "/loadBalancers", Op: opListLB}, + {Method: g, Path: lbPath, Op: opGetLB}, + {Method: u, Path: lbPath, Op: opUpdateLB}, + {Method: d, Path: lbPath, Op: opDeleteLB}, + {Method: p, Path: lbPath + "/changeCompartment", Op: opChangeComp}, + {Method: u, Path: lbPath + "/updateShape", Op: opUpdateShape}, + {Method: u, Path: lbPath + "/networkSecurityGroups", Op: opUpdateNSG}, + {Method: g, Path: base + "/loadBalancerShapes", Op: opListShapes}, + {Method: g, Path: base + "/loadBalancerPolicies", Op: opListPolicies}, + {Method: g, Path: base + "/loadBalancerProtocols", Op: opListProtocols}, + {Method: g, Path: base + "/loadBalancerHealths", Op: opListHealths}, + {Method: g, Path: lbPath + "/health", Op: opGetLBHealth}, + {Method: g, Path: base + "/loadBalancerWorkRequests/{workRequestId}", Op: opGetWR}, + {Method: g, Path: lbPath + "/workRequests", Op: opListWR}, + { + Method: p, + Path: lbPath + "/backendSets", + Op: opCreateBS, + }, + {Method: g, Path: lbPath + "/backendSets", Op: opListBS}, + {Method: g, Path: bsPath, Op: opGetBS}, + {Method: u, Path: bsPath, Op: opUpdateBS}, + {Method: d, Path: bsPath, Op: opDeleteBS}, + {Method: g, Path: bsPath + "/health", Op: opGetBSHealth}, + { + Method: g, + Path: bsPath + "/healthChecker", + Op: opGetHC, + }, + {Method: u, Path: bsPath + "/healthChecker", Op: opUpdateHC}, + {Method: p, Path: bsPath + "/backends", Op: opCreateBE}, + {Method: g, Path: bsPath + "/backends", Op: opListBE}, + {Method: g, Path: bePath, Op: opGetBE}, + {Method: u, Path: bePath, Op: opUpdateBE}, + {Method: d, Path: bePath, Op: opDeleteBE}, + {Method: g, Path: bePath + "/health", Op: opGetBEHealth}, + } +} + +func subRoutes() []restroute.Route { + g, p, u, d := http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete + + return []restroute.Route{ + {Method: p, Path: lbPath + "/listeners", Op: opCreateListener}, + {Method: u, Path: sub("listeners", "listenerName"), Op: opUpdateListener}, + {Method: d, Path: sub("listeners", "listenerName"), Op: opDeleteListener}, + {Method: g, Path: sub("listeners", "listenerName") + "/rules", Op: opListListenerRules}, + { + Method: p, + Path: lbPath + "/hostnames", + Op: opCreateHostname, + }, + {Method: g, Path: lbPath + "/hostnames", Op: opListHostnames}, + { + Method: g, + Path: sub("hostnames", "name"), + Op: opGetHostname, + }, + {Method: u, Path: sub("hostnames", "name"), Op: opUpdateHostname}, + {Method: d, Path: sub("hostnames", "name"), Op: opDeleteHostname}, + { + Method: p, + Path: lbPath + "/pathRouteSets", + Op: opCreatePRS, + }, + {Method: g, Path: lbPath + "/pathRouteSets", Op: opListPRS}, + {Method: g, Path: sub("pathRouteSets", "pathRouteSetName"), Op: opGetPRS}, + {Method: u, Path: sub("pathRouteSets", "pathRouteSetName"), Op: opUpdatePRS}, + {Method: d, Path: sub("pathRouteSets", "pathRouteSetName"), Op: opDeletePRS}, + { + Method: p, + Path: lbPath + "/routingPolicies", + Op: opCreateRP, + }, + {Method: g, Path: lbPath + "/routingPolicies", Op: opListRP}, + {Method: g, Path: sub("routingPolicies", "routingPolicyName"), Op: opGetRP}, + {Method: u, Path: sub("routingPolicies", "routingPolicyName"), Op: opUpdateRP}, + {Method: d, Path: sub("routingPolicies", "routingPolicyName"), Op: opDeleteRP}, + {Method: p, Path: lbPath + "/ruleSets", Op: opCreateRS}, + {Method: g, Path: lbPath + "/ruleSets", Op: opListRS}, + { + Method: g, + Path: sub("ruleSets", "ruleSetName"), + Op: opGetRS, + }, + {Method: u, Path: sub("ruleSets", "ruleSetName"), Op: opUpdateRS}, + {Method: d, Path: sub("ruleSets", "ruleSetName"), Op: opDeleteRS}, + { + Method: p, + Path: lbPath + "/sslCipherSuites", + Op: opCreateSuite, + }, + {Method: g, Path: lbPath + "/sslCipherSuites", Op: opListSuites}, + { + Method: g, + Path: sub("sslCipherSuites", "name"), + Op: opGetSuite, + }, + {Method: u, Path: sub("sslCipherSuites", "name"), Op: opUpdateSuite}, + {Method: d, Path: sub("sslCipherSuites", "name"), Op: opDeleteSuite}, + { + Method: p, + Path: lbPath + "/certificates", + Op: opCreateCert, + }, + {Method: g, Path: lbPath + "/certificates", Op: opListCerts}, + {Method: d, Path: sub("certificates", "certificateName"), Op: opDeleteCert}, + } +} + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + if strings.HasSuffix(path, "/endpoints") { + return route{op: opAdminEnds} + } + + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ociloadbalancer/routing.go b/services/ociloadbalancer/routing.go new file mode 100644 index 0000000000..9c8aca25df --- /dev/null +++ b/services/ociloadbalancer/routing.go @@ -0,0 +1,369 @@ +package ociloadbalancer + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" +) + +var errCondition = errors.New("invalid routing condition") + +// condCtx is the request view a routing condition is evaluated against. +type condCtx struct { + query url.Values + header http.Header + cookies map[string]string + path string + method string +} + +func newCondCtx(r *http.Request) *condCtx { + c := &condCtx{ + path: r.URL.Path, query: r.URL.Query(), header: r.Header, method: r.Method, cookies: map[string]string{}, + } + for _, ck := range r.Cookies() { + c.cookies[ck.Name] = ck.Value + } + + return c +} + +type condNode interface { + eval(c *condCtx) bool +} + +type aggNode struct { + kids []condNode + all bool +} + +func (n aggNode) eval(c *condCtx) bool { + for _, k := range n.kids { + if k.eval(c) != n.all { + return !n.all + } + } + + return n.all +} + +type literal struct { + text string + nocase bool +} + +type cmpNode struct { + attr string + op string + rhs literal + key literal +} + +func fold(l literal, s string) string { + if l.nocase { + return strings.ToLower(s) + } + + return s +} + +func (n cmpNode) value(c *condCtx) (string, bool) { + switch n.attr { + case "path": + return c.path, true + case "method": + return c.method, true + case "query": + for k, v := range c.query { + if fold(n.key, k) == fold(n.key, n.key.text) && len(v) > 0 { + return v[0], true + } + } + case "headers": + for k, v := range c.header { + if fold(literal{nocase: true}, k) == strings.ToLower(n.key.text) && len(v) > 0 { + return v[0], true + } + } + case "cookies": + for k, v := range c.cookies { + if fold(n.key, k) == fold(n.key, n.key.text) { + return v, true + } + } + } + + return "", false +} + +func (n cmpNode) eval(c *condCtx) bool { + v, ok := n.value(c) + if !ok { + return false + } + + l, r := fold(n.rhs, v), fold(n.rhs, n.rhs.text) + + switch n.op { + case "eq": + return l == r + case "ne": + return l != r + case "sw": + return strings.HasPrefix(l, r) + case "ew": + return strings.HasSuffix(l, r) + default: + return strings.Contains(l, r) + } +} + +type condParser struct { + src string + pos int +} + +func condErr(format string, args ...any) error { + return fmt.Errorf("%w: %s", errCondition, fmt.Sprintf(format, args...)) +} + +// parseCondition parses the V1 routing policy condition subset: all()/any() aggregates over comparisons of +// http.request.url.path, .url.query[k], .headers[k], .cookies[k] and .method with eq, ne, sw, ew and co. +func parseCondition(s string) (condNode, error) { + p := &condParser{src: s} + + n, err := p.expr() + if err != nil { + return nil, err + } + + p.skip() + + if p.pos != len(p.src) { + return nil, condErr("unexpected text at offset %d", p.pos) + } + + return n, nil +} + +func (p *condParser) skip() { + for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t' || p.src[p.pos] == '\n') { + p.pos++ + } +} + +func (p *condParser) eat(tok string) bool { + p.skip() + + if strings.HasPrefix(p.src[p.pos:], tok) { + p.pos += len(tok) + + return true + } + + return false +} + +func (p *condParser) word() string { + p.skip() + + start := p.pos + for p.pos < len(p.src) && (p.src[p.pos] == '.' || p.src[p.pos] == '_' || isAlnum(p.src[p.pos])) { + p.pos++ + } + + return p.src[start:p.pos] +} + +func isAlnum(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' +} + +func (p *condParser) expr() (condNode, error) { + save := p.pos + w := p.word() + + if w == "all" || w == "any" { + if !p.eat("(") { + return nil, condErr("expected ( after %s", w) + } + + agg := aggNode{all: w == "all"} + + for { + k, err := p.expr() + if err != nil { + return nil, err + } + + agg.kids = append(agg.kids, k) + + if p.eat(",") { + continue + } + + if !p.eat(")") { + return nil, condErr("expected , or ) at offset %d", p.pos) + } + + return agg, nil + } + } + + p.pos = save + + return p.comparison() +} + +func (p *condParser) comparison() (condNode, error) { + attr := p.word() + n := cmpNode{} + + switch attr { + case "http.request.url.path": + n.attr = "path" + case "http.request.method": + n.attr = "method" + case "http.request.url.query", "http.request.headers", "http.request.cookies": + n.attr = attr[strings.LastIndexByte(attr, '.')+1:] + + if !p.eat("[") { + return nil, condErr("%s needs a [key]", attr) + } + + key, err := p.literal() + if err != nil { + return nil, err + } + + if !p.eat("]") { + return nil, condErr("expected ] at offset %d", p.pos) + } + + n.key = key + default: + return nil, condErr("unknown attribute %q", attr) + } + + op := p.word() + switch op { + case "eq", "ne", "sw", "ew", "co": + n.op = op + default: + return nil, condErr("unknown operator %q", op) + } + + rhs, err := p.literal() + if err != nil { + return nil, err + } + + n.rhs = rhs + + return n, nil +} + +func (p *condParser) literal() (literal, error) { + if p.eat("(") { + if p.word() != "i" { + return literal{}, condErr("expected (i 'text')") + } + + s, err := p.quoted() + if err != nil { + return literal{}, err + } + + if !p.eat(")") { + return literal{}, condErr("expected ) at offset %d", p.pos) + } + + return literal{text: s, nocase: true}, nil + } + + s, err := p.quoted() + + return literal{text: s}, err +} + +func (p *condParser) quoted() (string, error) { + p.skip() + + if p.pos >= len(p.src) || p.src[p.pos] != '\'' { + return "", condErr("expected quoted string at offset %d", p.pos) + } + + p.pos++ + + var sb strings.Builder + + for p.pos < len(p.src) { + c := p.src[p.pos] + p.pos++ + + switch { + case c == '\\' && p.pos < len(p.src): + sb.WriteByte(p.src[p.pos]) + p.pos++ + case c == '\'': + return sb.String(), nil + default: + sb.WriteByte(c) + } + } + + return "", condErr("unterminated string") +} + +const ( + matchExact = "EXACT_MATCH" + matchLongest = "FORCE_LONGEST_PREFIX_MATCH" + matchPrefix = "PREFIX_MATCH" + matchSuffix = "SUFFIX_MATCH" +) + +func pathMatches(route PathRoute, path string) bool { + p, r := strings.ToLower(path), strings.ToLower(route.Path) + + switch route.PathMatchType.MatchType { + case matchExact: + return p == r + case matchSuffix: + return strings.HasSuffix(p, r) + default: + return strings.HasPrefix(p, r) + } +} + +// routePath picks the backend set for path: EXACT_MATCH first, then the longest FORCE_LONGEST_PREFIX_MATCH, +// then the first PREFIX_MATCH or SUFFIX_MATCH in list order. Matching ignores case. +func routePath(set *PathRouteSet, path string) (string, bool) { + for _, r := range set.PathRoutes { + if r.PathMatchType.MatchType == matchExact && pathMatches(r, path) { + return r.BackendSetName, true + } + } + + best, bestLen := "", -1 + + for _, r := range set.PathRoutes { + if r.PathMatchType.MatchType == matchLongest && pathMatches(r, path) && len(r.Path) > bestLen { + best, bestLen = r.BackendSetName, len(r.Path) + } + } + + if bestLen >= 0 { + return best, true + } + + for _, r := range set.PathRoutes { + if (r.PathMatchType.MatchType == matchPrefix || r.PathMatchType.MatchType == matchSuffix) && + pathMatches(r, path) { + return r.BackendSetName, true + } + } + + return "", false +} diff --git a/services/ociloadbalancer/rules.go b/services/ociloadbalancer/rules.go new file mode 100644 index 0000000000..5b807bc20b --- /dev/null +++ b/services/ociloadbalancer/rules.go @@ -0,0 +1,388 @@ +package ociloadbalancer + +import ( + "net" + "net/http" + "net/netip" + "slices" + "strconv" + "strings" +) + +// Rule actions. +const ( + actAddReqHeader = "ADD_HTTP_REQUEST_HEADER" + actAddRespHeader = "ADD_HTTP_RESPONSE_HEADER" + actExtendReqHdr = "EXTEND_HTTP_REQUEST_HEADER_VALUE" + actExtendRespHdr = "EXTEND_HTTP_RESPONSE_HEADER_VALUE" + actRemoveReqHdr = "REMOVE_HTTP_REQUEST_HEADER" + actRemoveRespHdr = "REMOVE_HTTP_RESPONSE_HEADER" + actAllow = "ALLOW" + actRedirect = "REDIRECT" + actControlAccess = "CONTROL_ACCESS_USING_HTTP_METHODS" + actHTTPHeader = "HTTP_HEADER" + actIPMaxConns = "IP_BASED_MAX_CONNECTIONS" + condSourceIP = "SOURCE_IP_ADDRESS" + condPath = "PATH" + defaultRedirect = http.StatusFound + defaultDeniedCode = http.StatusMethodNotAllowed + statusRangeStart = 400 + statusRangeEnd = 599 + forbiddenStatus = http.StatusForbidden + defaultHTTPPort = 80 + defaultHTTPSPort = 443 + schemeHTTPS = "https" + schemeHTTP = "http" + redirectTokenQuery = "{query}" +) + +//nolint:gochecknoglobals // immutable +var restrictedHeaders = []string{ + "host", + "content-length", + "transfer-encoding", + "connection", + "upgrade", + "te", +} + +func checkHeaderName(h string) *APIError { + if h == "" { + return errMissing("rule header is required") + } + + if !tokenRE.MatchString(h) { + return errParam("rule header " + h + " is not a valid header name") + } + + if slices.Contains(restrictedHeaders, strings.ToLower(h)) { + return errParam("rule header " + h + " cannot be modified") + } + + return nil +} + +// normalizeRule validates a rule set item and applies its defaults. +func normalizeRule(r Rule) (Rule, *APIError) { + switch r.Action { + case actAddReqHeader, actAddRespHeader: + if r.Value == nil { + return r, errMissing(r.Action + " needs header and value") + } + + return r, checkHeaderName(r.Header) + case actExtendReqHdr, actExtendRespHdr: + if r.Prefix == nil && r.Suffix == nil { + return r, errMissing(r.Action + " needs prefix or suffix") + } + + return r, checkHeaderName(r.Header) + case actRemoveReqHdr, actRemoveRespHdr: + return r, checkHeaderName(r.Header) + case actAllow: + return r, checkConditions(r, false) + case actRedirect: + return normalizeRedirect(r) + case actControlAccess: + return normalizeControlAccess(r) + case actHTTPHeader: + return normalizeHTTPHeader(r) + case actIPMaxConns: + return r, errUnsupported(actIPMaxConns) + default: + return r, errParam("unknown rule action " + r.Action) + } +} + +func normalizeRedirect(r Rule) (Rule, *APIError) { + if err := checkConditions(r, true); err != nil { + return r, err + } + + code := defaultRedirect + if r.ResponseCode != nil { + code = *r.ResponseCode + } + + if !slices.Contains([]int{301, 302, 303, 307, 308}, code) { + return r, errParam("REDIRECT responseCode must be 301, 302, 303, 307 or 308") + } + + r.ResponseCode = &code + + if r.RedirectURI == nil { + return r, errMissing("REDIRECT needs redirectUri") + } + + if p := r.RedirectURI.Protocol; p != nil && + !slices.Contains([]string{schemeHTTP, schemeHTTPS, "{protocol}"}, strings.ToLower(*p)) { + return r, errParam("redirectUri protocol must be http, https or {protocol}") + } + + if p := r.RedirectURI.Port; p != nil && (*p < 1 || *p > maxPort) { + return r, errParam("redirectUri port must be between 1 and 65535") + } + + return r, nil +} + +func normalizeControlAccess(r Rule) (Rule, *APIError) { + if len(r.AllowedMethods) == 0 { + return r, errMissing(actControlAccess + " needs allowedMethods") + } + + for i, m := range r.AllowedMethods { + r.AllowedMethods[i] = strings.ToUpper(m) + if !tokenRE.MatchString(m) { + return r, errParam("allowedMethods contains an invalid method " + m) + } + } + + code := defaultDeniedCode + if r.StatusCode != nil { + code = *r.StatusCode + } + + if code < statusRangeStart || code > statusRangeEnd { + return r, errParam("statusCode must be a 4xx or 5xx status") + } + + r.StatusCode = &code + + return r, nil +} + +func normalizeHTTPHeader(r Rule) (Rule, *APIError) { + if boolOr(r.InvalidCharsAllowed) { + return r, errUnsupported("areInvalidCharactersAllowed") + } + + if r.HTTPLargeHeaderSizeKB != nil && !slices.Contains([]int{8, 16, 32, 64}, *r.HTTPLargeHeaderSizeKB) { + return r, errParam("httpLargeHeaderSizeInKB must be 8, 16, 32 or 64") + } + + return r, nil +} + +func checkConditions(r Rule, allowPath bool) *APIError { + if len(r.Conditions) == 0 { + return errMissing(r.Action + " needs conditions") + } + + for _, c := range r.Conditions { + switch c.AttributeName { + case condSourceIP: + if !validSource(c.AttributeValue) { + return errParam("SOURCE_IP_ADDRESS value " + c.AttributeValue + " is not an IP address or CIDR") + } + case condPath: + if !allowPath { + return errParam(r.Action + " does not accept PATH conditions") + } + + if !slices.Contains([]string{matchExact, matchLongest, matchPrefix, matchSuffix}, c.Operator) || + c.AttributeValue == "" { + return errParam("PATH conditions need attributeValue and a match operator") + } + case "SOURCE_VCN_ID", "SOURCE_VCN_IP_ADDRESS": + return errUnsupported("condition " + c.AttributeName + " (no VCN context at the listener)") + default: + return errParam("unknown condition attributeName " + c.AttributeName) + } + } + + return nil +} + +func validSource(v string) bool { + if _, err := netip.ParsePrefix(v); err == nil { + return true + } + + _, err := netip.ParseAddr(v) + + return err == nil +} + +func sourceMatches(v string, ip netip.Addr) bool { + if p, err := netip.ParsePrefix(v); err == nil { + return p.Contains(ip) + } + + a, err := netip.ParseAddr(v) + + return err == nil && a == ip +} + +func conditionsMatch(conds []RuleCondition, r *http.Request, ip netip.Addr) bool { + for _, c := range conds { + switch c.AttributeName { + case condSourceIP: + if sourceMatches(c.AttributeValue, ip) { + return true + } + case condPath: + if pathMatches( + PathRoute{Path: c.AttributeValue, PathMatchType: MatchType{MatchType: c.Operator}}, + r.URL.Path, + ) { + return true + } + } + } + + return false +} + +// reqScope describes the client connection a request arrived on. +type reqScope struct { + ip netip.Addr + scheme string + port int +} + +func clientAddr(r *http.Request) netip.Addr { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + a, _ := netip.ParseAddr(host) + + return a.Unmap() +} + +// directReply is a response the load balancer produces itself. +type directReply struct { + location string + status int +} + +// applyRequestRules runs the listener's request-phase rules in order and returns a direct reply when a rule +// short-circuits the request. Response-phase rules are returned for the caller to apply. +func applyRequestRules(sets []*RuleSet, r *http.Request, sc reqScope) (*directReply, []Rule) { + var items, resp []Rule + + for _, s := range sets { + items = append(items, s.Items...) + } + + if !allowedByRules(items, r, sc.ip) { + return &directReply{status: forbiddenStatus}, nil + } + + for _, it := range items { + switch it.Action { + case actControlAccess: + if !slices.Contains(it.AllowedMethods, r.Method) { + return &directReply{status: *it.StatusCode}, nil + } + case actRedirect: + if conditionsMatch(it.Conditions, r, sc.ip) { + return &directReply{status: *it.ResponseCode, location: redirectTarget(it.RedirectURI, r, sc)}, nil + } + case actAddReqHeader: + r.Header.Add(it.Header, *it.Value) + case actRemoveReqHdr: + r.Header.Del(it.Header) + case actExtendReqHdr: + extendHeader(r.Header, it) + case actAddRespHeader, actRemoveRespHdr, actExtendRespHdr: + resp = append(resp, it) + } + } + + return nil, resp +} + +// allowedByRules reports whether the request passes the ALLOW rules: with none present everything passes. +func allowedByRules(items []Rule, r *http.Request, ip netip.Addr) bool { + allows, allowed := 0, false + + for _, it := range items { + if it.Action == actAllow { + allows++ + + allowed = allowed || conditionsMatch(it.Conditions, r, ip) + } + } + + return allows == 0 || allowed +} + +func extendHeader(h http.Header, it Rule) { + cur := h.Values(it.Header) + if len(cur) == 0 { + return + } + + pre, suf := "", "" + if it.Prefix != nil { + pre = *it.Prefix + } + + if it.Suffix != nil { + suf = *it.Suffix + } + + h.Set(it.Header, pre+strings.Join(cur, ",")+suf) +} + +func applyResponseRules(h http.Header, rules []Rule) { + for _, it := range rules { + switch it.Action { + case actAddRespHeader: + h.Add(it.Header, *it.Value) + case actRemoveRespHdr: + h.Del(it.Header) + case actExtendRespHdr: + extendHeader(h, it) + } + } +} + +func tokenOr(p *string, def string) string { + if p == nil { + return def + } + + return *p +} + +func redirectTarget(u *RedirectURI, r *http.Request, sc reqScope) string { + host := r.Host + if h, _, err := net.SplitHostPort(r.Host); err == nil { + host = h + } + + repl := strings.NewReplacer( + "{protocol}", sc.scheme, "{host}", host, "{port}", strconv.Itoa(sc.port), + "{path}", strings.TrimPrefix(r.URL.Path, "/"), "{query}", r.URL.RawQuery, + ) + + scheme := strings.ToLower(repl.Replace(tokenOr(u.Protocol, "{protocol}"))) + port := repl.Replace(tokenOr(nil, "{port}")) + + if u.Port != nil { + port = strconv.Itoa(*u.Port) + } + + h := repl.Replace(tokenOr(u.Host, "{host}")) + if (scheme != schemeHTTP || port != strconv.Itoa(defaultHTTPPort)) && + (scheme != schemeHTTPS || port != strconv.Itoa(defaultHTTPSPort)) { + h = net.JoinHostPort(strings.Trim(h, "[]"), port) + } + + path := repl.Replace(tokenOr(u.Path, "/{path}")) + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + out := scheme + "://" + h + path + + if q := strings.TrimPrefix(repl.Replace(tokenOr(u.Query, redirectTokenQuery)), "?"); q != "" { + out += "?" + q + } + + return out +} diff --git a/services/ociloadbalancer/settings.go b/services/ociloadbalancer/settings.go new file mode 100644 index 0000000000..86969a0561 --- /dev/null +++ b/services/ociloadbalancer/settings.go @@ -0,0 +1,14 @@ +package ociloadbalancer + +// DefaultPort is the dedicated control-plane listener's default TCP port. +const DefaultPort = 8165 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCILoadBalancer. +type Settings struct { + Port int `json:"port" env:"OCI_LOADBALANCER_PORT" default:"8165" name:"port" help:"Fixed TCP port for the dedicated OCI Load Balancer control-plane listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_LOADBALANCER_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned control-plane requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose + AllowExternalBackend bool `json:"allowExternalBackend" env:"OCI_LOADBALANCER_ALLOW_EXTERNAL_BACKENDS" name:"allow-external-backends" help:"Let load balancer listeners forward to, and health-check, backends outside loopback; by default the data plane only dials 127.0.0.0/8 and ::1."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ociloadbalancer/store.go b/services/ociloadbalancer/store.go new file mode 100644 index 0000000000..baa3fdb44d --- /dev/null +++ b/services/ociloadbalancer/store.go @@ -0,0 +1,276 @@ +package ociloadbalancer + +import ( + "cmp" + "crypto/tls" + "encoding/binary" + "encoding/json" + "net/netip" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "iad" + defaultListLimit = 100 + ocidTailLen = 52 + defaultTransit = 2 * time.Second + publicPoolBase = "198.18.0.0/15" + reservedHosts = 4 + addrBits = 32 +) + +// InMemoryBackend holds every load balancer and work request under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + lbs map[string]*LoadBalancer + workReqs map[string]*WorkRequest + keys map[string]*tls.Certificate + Auth *ociauth.Registry + compartment func(string) bool + subnet func(string) (string, bool) + dp *dataPlane + transition time.Duration + seq int64 + ipSeq int64 + allowExternal bool +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{mu: lockmetrics.New("ociloadbalancer"), now: time.Now, transition: defaultTransit} + b.resetMaps() + b.Auth = ociauth.NewRegistry() + b.dp = newDataPlane(b) + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.lbs, b.workReqs, b.keys = map[string]*LoadBalancer{}, map[string]*WorkRequest{}, map[string]*tls.Certificate{} +} + +// SetClock injects the time source used for lifecycle transitions and health checks. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTransition sets how long asynchronous operations take to complete. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// SetSubnetResolver validates subnetIds against another service (OCI Core) and returns each subnet's CIDR. +func (b *InMemoryBackend) SetSubnetResolver(fn func(string) (string, bool)) { + b.mu.Lock("SetSubnetResolver") + b.subnet = fn + b.mu.Unlock() +} + +// SetAllowExternal lets the data plane reach non-loopback backends. +func (b *InMemoryBackend) SetAllowExternal(allow bool) { + b.mu.Lock("SetAllowExternal") + b.allowExternal = allow + b.mu.Unlock() +} + +func (b *InMemoryBackend) externalAllowed() bool { + b.mu.RLock("externalAllowed") + defer b.mu.RUnlock() + + return b.allowExternal +} + +// Reset clears all state and stops every data-plane listener. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() + b.dp.reset() +} + +// Close stops every data-plane listener. +func (b *InMemoryBackend) Close() { b.dp.reset() } + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) clock() time.Time { + b.mu.RLock("clock") + defer b.mu.RUnlock() + + return b.nowUTC() +} + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +func (b *InMemoryBackend) transitionAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} + +// clone deep-copies a load balancer; stored load balancers are immutable once published. +func (lb *LoadBalancer) clone() *LoadBalancer { + raw, err := json.Marshal(lb) + if err != nil { + return lb + } + + out := &LoadBalancer{} + if json.Unmarshal(raw, out) != nil { + return lb + } + + out.fillMaps() + + return out +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate[T any](items []T, o ListOpts, name func(T) string, created func(T) (time.Time, int64)) page.Page[T] { + byName := strings.EqualFold(o.SortBy, "displayName") + desc := !byName + + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + slices.SortStableFunc(items, func(a, c T) int { + var r int + if byName { + r = cmp.Compare(strs.Fold(name(a)), strs.Fold(name(c))) + } else { + ta, sa := created(a) + tc, sc := created(c) + r = cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +// Sweep applies lifecycle transitions against the backend clock and reconciles data-plane listeners. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + + now := b.nowUTC() + for id, lb := range b.lbs { + if lb.TransitionAt == nil || lb.TransitionAt.After(now) { + continue + } + + next := lb.clone() + next.TransitionAt = nil + + if lb.State == stateDeleting { + next.State = stateDeleted + b.dropKeys(id) + } else { + next.State = stateActive + } + + b.lbs[id] = next + } + + b.mu.Unlock() + b.dp.sync() +} + +func (b *InMemoryBackend) dropKeys(lbID string) { + for k := range b.keys { + if strings.HasPrefix(k, lbID+"/") { + delete(b.keys, k) + } + } +} + +// allocIP assigns the next address from the subnet CIDR, or from the emulator pool when none is known. +func (b *InMemoryBackend) allocIP(cidr string) string { + b.ipSeq++ + + prefix, err := netip.ParsePrefix(cidr) + if err != nil || !prefix.Addr().Is4() { + prefix = netip.MustParsePrefix(publicPoolBase) + } + + hosts := int64(1) << (addrBits - prefix.Bits()) + if hosts <= reservedHosts+1 { + return prefix.Masked().Addr().String() + } + + off := reservedHosts + (b.ipSeq-1)%(hosts-reservedHosts-1) + addr := prefix.Masked().Addr() + raw := addr.As4() + binary.BigEndian.PutUint32(raw[:], binary.BigEndian.Uint32(raw[:])+uint32(off)) //nolint:gosec // off < 2^32 + + return netip.AddrFrom4(raw).String() +} diff --git a/services/ociloadbalancer/subres.go b/services/ociloadbalancer/subres.go new file mode 100644 index 0000000000..738aaec51e --- /dev/null +++ b/services/ociloadbalancer/subres.go @@ -0,0 +1,608 @@ +package ociloadbalancer + +import ( + "slices" + "strings" +) + +func equalFold(a, b string) bool { return strings.EqualFold(a, b) } + +func subresourceOps() map[string]opFunc { + return map[string]opFunc{ + opCreateListener: (*Handler).createListener, + opUpdateListener: (*Handler).updateListener, + opDeleteListener: deleteByName( + "DeleteListener", + "listenerName", + func(lb *LoadBalancer) map[string]*Listener { return lb.Listeners }, + ), + opListListenerRules: (*Handler).listListenerRules, + + opCreateHostname: (*Handler).createHostname, + opUpdateHostname: (*Handler).updateHostname, + opGetHostname: getByName("name", func(lb *LoadBalancer) map[string]*Hostname { return lb.Hostnames }), + opListHostnames: listAll(func(lb *LoadBalancer) map[string]*Hostname { return lb.Hostnames }), + opDeleteHostname: deleteByName( + "DeleteHostname", + "name", + func(lb *LoadBalancer) map[string]*Hostname { return lb.Hostnames }, + ), + + opCreatePRS: (*Handler).createPathRouteSet, + opUpdatePRS: (*Handler).updatePathRouteSet, + opGetPRS: getByName( + "pathRouteSetName", + func(lb *LoadBalancer) map[string]*PathRouteSet { return lb.PathRouteSets }, + ), + opListPRS: listAll(func(lb *LoadBalancer) map[string]*PathRouteSet { return lb.PathRouteSets }), + opDeletePRS: deleteByName( + "DeletePathRouteSet", + "pathRouteSetName", + func(lb *LoadBalancer) map[string]*PathRouteSet { return lb.PathRouteSets }, + ), + + opCreateRP: (*Handler).createRoutingPolicy, + opUpdateRP: (*Handler).updateRoutingPolicy, + opGetRP: getByName( + "routingPolicyName", + func(lb *LoadBalancer) map[string]*RoutingPolicy { return lb.RoutingPolicies }, + ), + opListRP: listAll(func(lb *LoadBalancer) map[string]*RoutingPolicy { return lb.RoutingPolicies }), + opDeleteRP: deleteByName( + "DeleteRoutingPolicy", + "routingPolicyName", + func(lb *LoadBalancer) map[string]*RoutingPolicy { return lb.RoutingPolicies }, + ), + + opCreateRS: (*Handler).createRuleSet, + opUpdateRS: (*Handler).updateRuleSet, + opGetRS: getByName("ruleSetName", func(lb *LoadBalancer) map[string]*RuleSet { return lb.RuleSets }), + opListRS: listAll(func(lb *LoadBalancer) map[string]*RuleSet { return lb.RuleSets }), + opDeleteRS: deleteByName( + "DeleteRuleSet", + "ruleSetName", + func(lb *LoadBalancer) map[string]*RuleSet { return lb.RuleSets }, + ), + + opCreateSuite: (*Handler).createSuite, + opUpdateSuite: (*Handler).updateSuite, + opGetSuite: getByName( + "name", + func(lb *LoadBalancer) map[string]*SSLCipherSuite { return lb.SSLCipherSuites }, + ), + opListSuites: listAll(func(lb *LoadBalancer) map[string]*SSLCipherSuite { return lb.SSLCipherSuites }), + opDeleteSuite: deleteByName( + "DeleteSSLCipherSuite", + "name", + func(lb *LoadBalancer) map[string]*SSLCipherSuite { return lb.SSLCipherSuites }, + ), + + opCreateCert: (*Handler).createCertificate, + opListCerts: listAll(func(lb *LoadBalancer) map[string]*Certificate { return lb.Certificates }), + opDeleteCert: (*Handler).deleteCertificate, + } +} + +// checkListenerKey requires the listener's certificate bundle to hold a private key to terminate TLS with. +func (b *InMemoryBackend) checkListenerKey(lb *LoadBalancer, l *Listener) *APIError { + if l.SSL == nil { + return nil + } + + if _, ok := lb.Certificates[l.SSL.CertificateName]; ok && b.certKey(lb.ID, l.SSL.CertificateName) == nil { + return errParam("certificate " + l.SSL.CertificateName + " has no private key to terminate TLS with") + } + + return nil +} + +func (h *Handler) createListener(q *req) error { + return mutateBody(h, q, "CreateListener", func(b *InMemoryBackend, lb *LoadBalancer, in *listenerIn) *APIError { + if _, dup := lb.Listeners[in.Name]; dup { + return errConflict("listener " + in.Name + " already exists") + } + + l, err := buildListener(in.Name, in) + if err != nil { + return err + } + + if err = b.checkListenerKey(lb, l); err != nil { + return err + } + + lb.Listeners[l.Name] = l + + return nil + }) +} + +func (h *Handler) updateListener(q *req) error { + name := q.param("listenerName") + + return mutateBody(h, q, "UpdateListener", func(b *InMemoryBackend, lb *LoadBalancer, in *listenerIn) *APIError { + if _, err := lookup(lb.Listeners, name); err != nil { + return err + } + + l, err := buildListener(name, in) + if err != nil { + return err + } + + if err = b.checkListenerKey(lb, l); err != nil { + return err + } + + lb.Listeners[name] = l + + return nil + }) +} + +type listenerRuleSummary struct { + RuleSetName string `json:"ruleSetName"` + Rule Rule `json:"rule"` +} + +func (h *Handler) listListenerRules(q *req) error { + return readSub(h, q, func(lb *LoadBalancer) ([]listenerRuleSummary, *APIError) { + l, err := lookup(lb.Listeners, q.param("listenerName")) + if err != nil { + return nil, err + } + + out := []listenerRuleSummary{} + + for _, rsn := range l.RuleSets { + if rs := lb.RuleSets[rsn]; rs != nil { + for _, r := range rs.Items { + out = append(out, listenerRuleSummary{Rule: r, RuleSetName: rsn}) + } + } + } + + return out, nil + }) +} + +func (h *Handler) createHostname(q *req) error { + return mutateBody(h, q, "CreateHostname", func(_ *InMemoryBackend, lb *LoadBalancer, in *hostnameIn) *APIError { + if err := checkName("hostname", in.Name); err != nil { + return err + } + + if _, dup := lb.Hostnames[in.Name]; dup { + return errConflict("hostname " + in.Name + " already exists") + } + + if err := checkHostname(in.Hostname); err != nil { + return err + } + + lb.Hostnames[in.Name] = &Hostname{Name: in.Name, Hostname: in.Hostname} + + return nil + }) +} + +func (h *Handler) updateHostname(q *req) error { + name := q.param("name") + + return mutateBody(h, q, "UpdateHostname", func(_ *InMemoryBackend, lb *LoadBalancer, in *hostnameIn) *APIError { + hn, err := lookup(lb.Hostnames, name) + if err != nil { + return err + } + + if in.Hostname == "" { + return nil + } + + if err = checkHostname(in.Hostname); err != nil { + return err + } + + hn.Hostname = in.Hostname + + return nil + }) +} + +func (h *Handler) createPathRouteSet(q *req) error { + return mutateBody( + h, + q, + "CreatePathRouteSet", + func(_ *InMemoryBackend, lb *LoadBalancer, in *pathRouteSetIn) *APIError { + if _, dup := lb.PathRouteSets[in.Name]; dup { + return errConflict("path route set " + in.Name + " already exists") + } + + ps, err := buildPathRouteSet(in.Name, in) + if err != nil { + return err + } + + lb.PathRouteSets[in.Name] = ps + + return nil + }, + ) +} + +func (h *Handler) updatePathRouteSet(q *req) error { + name := q.param("pathRouteSetName") + + return mutateBody( + h, + q, + "UpdatePathRouteSet", + func(_ *InMemoryBackend, lb *LoadBalancer, in *pathRouteSetIn) *APIError { + if _, err := lookup(lb.PathRouteSets, name); err != nil { + return err + } + + ps, err := buildPathRouteSet(name, in) + if err != nil { + return err + } + + lb.PathRouteSets[name] = ps + + return nil + }, + ) +} + +func (h *Handler) createRoutingPolicy(q *req) error { + return mutateBody( + h, + q, + "CreateRoutingPolicy", + func(_ *InMemoryBackend, lb *LoadBalancer, in *routingPolicyIn) *APIError { + if _, dup := lb.RoutingPolicies[in.Name]; dup { + return errConflict("routing policy " + in.Name + " already exists") + } + + rp, err := buildRoutingPolicy(in.Name, in, "") + if err != nil { + return err + } + + lb.RoutingPolicies[in.Name] = rp + + return nil + }, + ) +} + +func (h *Handler) updateRoutingPolicy(q *req) error { + name := q.param("routingPolicyName") + + return mutateBody( + h, + q, + "UpdateRoutingPolicy", + func(_ *InMemoryBackend, lb *LoadBalancer, in *routingPolicyIn) *APIError { + cur, err := lookup(lb.RoutingPolicies, name) + if err != nil { + return err + } + + rp, err := buildRoutingPolicy(name, in, cur.ConditionLanguageVersion) + if err != nil { + return err + } + + lb.RoutingPolicies[name] = rp + + return nil + }, + ) +} + +func (h *Handler) createRuleSet(q *req) error { + return mutateBody(h, q, "CreateRuleSet", func(_ *InMemoryBackend, lb *LoadBalancer, in *ruleSetIn) *APIError { + if _, dup := lb.RuleSets[in.Name]; dup { + return errConflict("rule set " + in.Name + " already exists") + } + + rs, err := buildRuleSet(in.Name, in) + if err != nil { + return err + } + + lb.RuleSets[in.Name] = rs + + return nil + }) +} + +func (h *Handler) updateRuleSet(q *req) error { + name := q.param("ruleSetName") + + return mutateBody(h, q, "UpdateRuleSet", func(_ *InMemoryBackend, lb *LoadBalancer, in *ruleSetIn) *APIError { + if _, err := lookup(lb.RuleSets, name); err != nil { + return err + } + + rs, err := buildRuleSet(name, in) + if err != nil { + return err + } + + lb.RuleSets[name] = rs + + return nil + }) +} + +func (h *Handler) createSuite(q *req) error { + return mutateBody( + h, + q, + "CreateSSLCipherSuite", + func(_ *InMemoryBackend, lb *LoadBalancer, in *cipherSuiteIn) *APIError { + if _, dup := lb.SSLCipherSuites[in.Name]; dup { + return errConflict("SSL cipher suite " + in.Name + " already exists") + } + + cs, err := buildCipherSuite(in.Name, in) + if err != nil { + return err + } + + lb.SSLCipherSuites[in.Name] = cs + + return nil + }, + ) +} + +func (h *Handler) updateSuite(q *req) error { + name := q.param("name") + + return mutateBody( + h, + q, + "UpdateSSLCipherSuite", + func(_ *InMemoryBackend, lb *LoadBalancer, in *cipherSuiteIn) *APIError { + if _, err := lookup(lb.SSLCipherSuites, name); err != nil { + return err + } + + cs, err := buildCipherSuite(name, in) + if err != nil { + return err + } + + lb.SSLCipherSuites[name] = cs + + return nil + }, + ) +} + +func (h *Handler) createCertificate(q *req) error { + return mutateBody(h, q, "CreateCertificate", func(b *InMemoryBackend, lb *LoadBalancer, in *certIn) *APIError { + return b.createCertificate(lb, in) + }) +} + +func (h *Handler) deleteCertificate(q *req) error { + name := q.param("certificateName") + + return mutateDelete(h, q, "DeleteCertificate", func(b *InMemoryBackend, lb *LoadBalancer) *APIError { + return b.deleteCertificate(lb, name) + }) +} + +func backendSetOps() map[string]opFunc { + return map[string]opFunc{ + opCreateBS: (*Handler).createBackendSet, + opUpdateBS: (*Handler).updateBackendSet, + opGetBS: getByName( + "backendSetName", + func(lb *LoadBalancer) map[string]*BackendSet { return lb.BackendSets }, + ), + opListBS: listAll(func(lb *LoadBalancer) map[string]*BackendSet { return lb.BackendSets }), + opDeleteBS: deleteByName( + "DeleteBackendSet", + "backendSetName", + func(lb *LoadBalancer) map[string]*BackendSet { return lb.BackendSets }, + ), + opGetHC: (*Handler).getHealthChecker, + opUpdateHC: (*Handler).updateHealthChecker, + opCreateBE: (*Handler).createBackend, + opGetBE: (*Handler).getBackend, + opListBE: (*Handler).listBackends, + opUpdateBE: (*Handler).updateBackend, + opDeleteBE: (*Handler).deleteBackend, + } +} + +func (h *Handler) createBackendSet(q *req) error { + return mutateBody(h, q, "CreateBackendSet", func(_ *InMemoryBackend, lb *LoadBalancer, in *backendSetIn) *APIError { + if _, dup := lb.BackendSets[in.Name]; dup { + return errConflict("backend set " + in.Name + " already exists") + } + + bs, err := buildBackendSet(in.Name, in) + if err != nil { + return err + } + + lb.BackendSets[in.Name] = bs + + return nil + }) +} + +func (h *Handler) updateBackendSet(q *req) error { + name := q.param("backendSetName") + + return mutateBody(h, q, "UpdateBackendSet", func(_ *InMemoryBackend, lb *LoadBalancer, in *backendSetIn) *APIError { + if _, err := lookup(lb.BackendSets, name); err != nil { + return err + } + + bs, err := buildBackendSet(name, in) + if err != nil { + return err + } + + lb.BackendSets[name] = bs + + return nil + }) +} + +func (h *Handler) getHealthChecker(q *req) error { + return readSub(h, q, func(lb *LoadBalancer) (HealthChecker, *APIError) { + bs, err := lookup(lb.BackendSets, q.param("backendSetName")) + if err != nil { + return HealthChecker{}, err + } + + return bs.HealthChecker, nil + }) +} + +func (h *Handler) updateHealthChecker(q *req) error { + name := q.param("backendSetName") + + return mutateBody( + h, + q, + "UpdateHealthChecker", + func(_ *InMemoryBackend, lb *LoadBalancer, in *healthCheckerIn) *APIError { + bs, err := lookup(lb.BackendSets, name) + if err != nil { + return err + } + + hc, err := buildHealthChecker(in) + if err != nil { + return err + } + + bs.HealthChecker = hc + + return nil + }, + ) +} + +func findBackend(bs *BackendSet, name string) int { + return slices.IndexFunc(bs.Backends, func(b Backend) bool { return b.Name == name }) +} + +func (h *Handler) createBackend(q *req) error { + name := q.param("backendSetName") + + return mutateBody(h, q, "CreateBackend", func(_ *InMemoryBackend, lb *LoadBalancer, in *backendIn) *APIError { + bs, err := lookup(lb.BackendSets, name) + if err != nil { + return err + } + + be, err := buildBackend(*in) + if err != nil { + return err + } + + if findBackend(bs, be.Name) >= 0 { + return errConflict("backend " + be.Name + " already exists") + } + + bs.Backends = append(bs.Backends, be) + + return nil + }) +} + +func (h *Handler) getBackend(q *req) error { + return readSub(h, q, func(lb *LoadBalancer) (Backend, *APIError) { + bs, err := lookup(lb.BackendSets, q.param("backendSetName")) + if err != nil { + return Backend{}, err + } + + i := findBackend(bs, q.param("backendName")) + if i < 0 { + return Backend{}, errNotFound(q.param("backendName")) + } + + return bs.Backends[i], nil + }) +} + +func (h *Handler) listBackends(q *req) error { + return readSub(h, q, func(lb *LoadBalancer) ([]Backend, *APIError) { + bs, err := lookup(lb.BackendSets, q.param("backendSetName")) + if err != nil { + return nil, err + } + + return bs.Backends, nil + }) +} + +type updateBackendIn struct { + Weight *int `json:"weight"` + Backup *bool `json:"backup"` + Drain *bool `json:"drain"` + Offline *bool `json:"offline"` + MaxConnections *int `json:"maxConnections"` +} + +func (h *Handler) updateBackend(q *req) error { + set, name := q.param("backendSetName"), q.param("backendName") + + return mutateBody(h, q, "UpdateBackend", func(_ *InMemoryBackend, lb *LoadBalancer, in *updateBackendIn) *APIError { + bs, err := lookup(lb.BackendSets, set) + if err != nil { + return err + } + + i := findBackend(bs, name) + if i < 0 { + return errNotFound(name) + } + + if in.Weight == nil || in.Backup == nil || in.Drain == nil || in.Offline == nil { + return errMissing("weight, backup, drain and offline are required") + } + + if *in.Weight < 1 || (in.MaxConnections != nil && *in.MaxConnections < 1) { + return errParam("weight and maxConnections must be at least 1") + } + + bs.Backends[i].Weight, bs.Backends[i].Backup = *in.Weight, *in.Backup + bs.Backends[i].Drain, bs.Backends[i].Offline = *in.Drain, *in.Offline + bs.Backends[i].MaxConnections = in.MaxConnections + + return nil + }) +} + +func (h *Handler) deleteBackend(q *req) error { + set, name := q.param("backendSetName"), q.param("backendName") + + return mutateDelete(h, q, "DeleteBackend", func(_ *InMemoryBackend, lb *LoadBalancer) *APIError { + bs, err := lookup(lb.BackendSets, set) + if err != nil { + return err + } + + i := findBackend(bs, name) + if i < 0 { + return errNotFound(name) + } + + bs.Backends = slices.Delete(bs.Backends, i, i+1) + + return nil + }) +} diff --git a/services/ociloadbalancer/subres2_test.go b/services/ociloadbalancer/subres2_test.go new file mode 100644 index 0000000000..2c8cb1a9b8 --- /dev/null +++ b/services/ociloadbalancer/subres2_test.go @@ -0,0 +1,462 @@ +//nolint:lll // SDK request literals are naturally wide +package ociloadbalancer_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/loadbalancer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// routedLB returns an active load balancer with two backend sets. +func routedLB(t *testing.T, e *env, name string) string { + t.Helper() + + a, b := startBackend(t, "a"), startBackend(t, "b") + d := baseDetails(name) + d.BackendSets = map[string]loadbalancer.BackendSetDetails{ + "web": backendSet("ROUND_ROBIN", a), "api": backendSet("ROUND_ROBIN", b), + } + + return e.mkLB(t, d) +} + +func TestListenersAndRouting(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + id := routedLB(t, e, "routing") + ctx := t.Context() + + next := func() { e.settle() } + + { // hostnames + next() + + _, err := e.lb.CreateHostname(ctx, loadbalancer.CreateHostnameRequest{ + LoadBalancerId: &id, + Name: new("h1"), Hostname: new("app.example.com"), + }) + require.NoError(t, err) + next() + + _, err = e.lb.CreateHostname(ctx, loadbalancer.CreateHostnameRequest{ + LoadBalancerId: &id, + Name: new("h2"), Hostname: new("bad host!"), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.lb.UpdateHostname(ctx, loadbalancer.UpdateHostnameRequest{ + LoadBalancerId: &id, Name: new("h1"), Hostname: new("*.example.com"), + }) + require.NoError(t, err) + next() + + got, err := e.lb.GetHostname(ctx, loadbalancer.GetHostnameRequest{LoadBalancerId: &id, Name: new("h1")}) + require.NoError(t, err) + assert.Equal(t, "*.example.com", *got.Hostname.Hostname) + + list, err := e.lb.ListHostnames(ctx, loadbalancer.ListHostnamesRequest{LoadBalancerId: &id}) + require.NoError(t, err) + assert.Len(t, list.Items, 1) + } + + { // path route sets + next() + + _, err := e.lb.CreatePathRouteSet(ctx, loadbalancer.CreatePathRouteSetRequest{ + LoadBalancerId: &id, + Name: new("paths"), PathRoutes: []loadbalancer.PathRoute{prefix("/api", "api")}, + }) + require.NoError(t, err) + next() + + _, err = e.lb.CreatePathRouteSet(ctx, loadbalancer.CreatePathRouteSetRequest{ + LoadBalancerId: &id, + Name: new("dangling"), PathRoutes: []loadbalancer.PathRoute{prefix("/x", "missing")}, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.lb.UpdatePathRouteSet(ctx, loadbalancer.UpdatePathRouteSetRequest{ + LoadBalancerId: &id, PathRouteSetName: new("paths"), + PathRoutes: []loadbalancer.PathRoute{prefix("/api", "api"), prefix("/web", "web")}, + }) + require.NoError(t, err) + next() + + got, err := e.lb.GetPathRouteSet( + ctx, + loadbalancer.GetPathRouteSetRequest{LoadBalancerId: &id, PathRouteSetName: new("paths")}, + ) + require.NoError(t, err) + assert.Len(t, got.PathRoutes, 2) + } + + { // routing policies + next() + + rules := func(cond string) []loadbalancer.RoutingRule { + return []loadbalancer.RoutingRule{ + { + Name: new( + "r1", + ), + Condition: new(cond), + Actions: []loadbalancer.Action{loadbalancer.ForwardToBackendSet{BackendSetName: new("api")}}, + }, + } + } + + _, err := e.lb.CreateRoutingPolicy(ctx, loadbalancer.CreateRoutingPolicyRequest{ + LoadBalancerId: &id, + Name: new( + "rp", + ), + ConditionLanguageVersion: loadbalancer.CreateRoutingPolicyDetailsConditionLanguageVersionV1, + Rules: rules("all(http.request.url.path sw '/api')"), + }) + require.NoError(t, err) + next() + + _, err = e.lb.CreateRoutingPolicy(ctx, loadbalancer.CreateRoutingPolicyRequest{ + LoadBalancerId: &id, + Name: new( + "bad", + ), + ConditionLanguageVersion: loadbalancer.CreateRoutingPolicyDetailsConditionLanguageVersionV1, + Rules: rules("http.request.url.path matches '/api'"), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + got, err := e.lb.GetRoutingPolicy( + ctx, + loadbalancer.GetRoutingPolicyRequest{LoadBalancerId: &id, RoutingPolicyName: new("rp")}, + ) + require.NoError(t, err) + require.Len(t, got.Rules, 1) + assert.Equal(t, "all(http.request.url.path sw '/api')", *got.Rules[0].Condition) + require.Len(t, got.Rules[0].Actions, 1) + assert.IsType(t, loadbalancer.ForwardToBackendSet{}, got.Rules[0].Actions[0]) + } + + { // rule sets + next() + + _, err := e.lb.CreateRuleSet(ctx, loadbalancer.CreateRuleSetRequest{ + LoadBalancerId: &id, + Name: new("rs"), Items: []loadbalancer.Rule{ + loadbalancer.AddHttpRequestHeaderRule{Header: new("X-Added"), Value: new("1")}, + loadbalancer.RedirectRule{ + Conditions: []loadbalancer.RuleCondition{loadbalancer.PathMatchCondition{ + AttributeValue: new("/old"), Operator: loadbalancer.PathMatchConditionOperatorPrefixMatch, + }}, + RedirectUri: &loadbalancer.RedirectUri{Path: new("/new")}, + }, + }, + }) + require.NoError(t, err) + next() + + got, err := e.lb.GetRuleSet(ctx, loadbalancer.GetRuleSetRequest{LoadBalancerId: &id, RuleSetName: new("rs")}) + require.NoError(t, err) + require.Len(t, got.Items, 2) + assert.IsType(t, loadbalancer.AddHttpRequestHeaderRule{}, got.Items[0]) + redir, ok := got.Items[1].(loadbalancer.RedirectRule) + require.True(t, ok) + assert.Equal(t, 302, *redir.ResponseCode) + + bad := []loadbalancer.Rule{ + loadbalancer.IpBasedMaxConnectionsRule{DefaultMaxConnections: new(5)}, + loadbalancer.AddHttpRequestHeaderRule{Header: new("Host"), Value: new("x")}, + loadbalancer.ControlAccessUsingHttpMethodsRule{AllowedMethods: []string{}}, + loadbalancer.AllowRule{ + Conditions: []loadbalancer.RuleCondition{ + loadbalancer.SourceIpAddressCondition{AttributeValue: new("nope")}, + }, + }, + } + for _, r := range bad { + next() + + _, err = e.lb.CreateRuleSet(ctx, loadbalancer.CreateRuleSetRequest{ + LoadBalancerId: &id, + Name: new("badrs"), Items: []loadbalancer.Rule{r}, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "%T", r) + } + } + + { // listener + next() + + _, err := e.lb.CreateListener(ctx, loadbalancer.CreateListenerRequest{ + LoadBalancerId: &id, + Name: new("front"), DefaultBackendSetName: new("web"), Port: new(8080), Protocol: new("HTTP"), + HostnameNames: []string{"h1"}, PathRouteSetName: new("paths"), RuleSetNames: []string{"rs"}, + }) + require.NoError(t, err) + next() + + list, err := e.lb.ListListenerRules( + ctx, + loadbalancer.ListListenerRulesRequest{LoadBalancerId: &id, ListenerName: new("front")}, + ) + require.NoError(t, err) + require.Len(t, list.Items, 2) + assert.Equal(t, "rs", *list.Items[0].RuleSetName) + + cases := []struct { + name string + d loadbalancer.CreateListenerDetails + want int + }{ + {"port in use", loadbalancer.CreateListenerDetails{ + Name: new("dup"), DefaultBackendSetName: new("web"), Port: new(8080), Protocol: new("HTTP"), + }, http.StatusConflict}, + {"both path set and policy", loadbalancer.CreateListenerDetails{ + Name: new("both"), DefaultBackendSetName: new("web"), Port: new(8081), Protocol: new("HTTP"), + PathRouteSetName: new("paths"), RoutingPolicyName: new("rp"), + }, http.StatusBadRequest}, + { + "unknown rule set", + loadbalancer.CreateListenerDetails{ + Name: new( + "norules", + ), + DefaultBackendSetName: new("web"), + Port: new(8081), + Protocol: new("HTTP"), + RuleSetNames: []string{"zzz"}, + }, + http.StatusBadRequest, + }, + { + "tcp with hostnames", + loadbalancer.CreateListenerDetails{ + Name: new( + "tcph", + ), + DefaultBackendSetName: new("web"), + Port: new(8081), + Protocol: new("TCP"), + HostnameNames: []string{"h1"}, + }, + http.StatusBadRequest, + }, + {"unknown protocol", loadbalancer.CreateListenerDetails{ + Name: new("udp"), DefaultBackendSetName: new("web"), Port: new(8081), Protocol: new("UDP"), + }, http.StatusBadRequest}, + {"unknown certificate", loadbalancer.CreateListenerDetails{ + Name: new("tls"), DefaultBackendSetName: new("web"), Port: new(8443), Protocol: new("HTTP"), + SslConfiguration: &loadbalancer.SslConfigurationDetails{CertificateName: new("nocert")}, + }, http.StatusBadRequest}, + } + for _, c := range cases { + next() + + _, err = e.lb.CreateListener( + ctx, + loadbalancer.CreateListenerRequest{LoadBalancerId: &id, CreateListenerDetails: c.d}, + ) + assert.Equal(t, c.want, svcErr(t, err).GetHTTPStatusCode(), c.name) + } + } + + { // references block deletes + next() + + for _, del := range []func() error{ + func() error { + _, err := e.lb.DeleteBackendSet(ctx, loadbalancer.DeleteBackendSetRequest{LoadBalancerId: &id, BackendSetName: new("web")}) + + return err + }, + func() error { + _, err := e.lb.DeleteHostname(ctx, loadbalancer.DeleteHostnameRequest{LoadBalancerId: &id, Name: new("h1")}) + + return err + }, + func() error { + _, err := e.lb.DeleteRuleSet(ctx, loadbalancer.DeleteRuleSetRequest{LoadBalancerId: &id, RuleSetName: new("rs")}) + + return err + }, + func() error { + _, err := e.lb.DeletePathRouteSet(ctx, loadbalancer.DeletePathRouteSetRequest{LoadBalancerId: &id, PathRouteSetName: new("paths")}) + + return err + }, + } { + assert.Equal(t, http.StatusConflict, svcErr(t, del()).GetHTTPStatusCode()) + } + + _, err := e.lb.DeleteListener( + ctx, + loadbalancer.DeleteListenerRequest{LoadBalancerId: &id, ListenerName: new("front")}, + ) + require.NoError(t, err) + next() + + _, err = e.lb.DeleteRuleSet(ctx, loadbalancer.DeleteRuleSetRequest{LoadBalancerId: &id, RuleSetName: new("rs")}) + require.NoError(t, err) + } +} + +func TestCertificatesAndCipherSuites(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + id := routedLB(t, e, "certs") + ctx := t.Context() + certPEM, keyPEM := genCert(t) + otherCert, otherKey := genCert(t) + + create := func(d loadbalancer.CreateCertificateDetails) error { + e.settle() + + _, err := e.lb.CreateCertificate( + ctx, + loadbalancer.CreateCertificateRequest{LoadBalancerId: &id, CreateCertificateDetails: d}, + ) + + return err + } + + require.NoError(t, create(loadbalancer.CreateCertificateDetails{ + CertificateName: new("good"), PublicCertificate: new(string(certPEM)), PrivateKey: new(string(keyPEM)), + })) + require.NoError( + t, + create( + loadbalancer.CreateCertificateDetails{ + CertificateName: new("ca-only"), + CaCertificate: new(string(otherCert)), + }, + ), + ) + + pubOnly := loadbalancer.CreateCertificateDetails{ + CertificateName: new("good"), PublicCertificate: new(string(certPEM)), + } + errs := []struct { + d loadbalancer.CreateCertificateDetails + name string + want int + }{ + {name: "duplicate", d: pubOnly, want: 409}, + {name: "mismatched key", want: 400, d: loadbalancer.CreateCertificateDetails{ + CertificateName: new("mm"), PublicCertificate: new(string(certPEM)), PrivateKey: new(string(otherKey)), + }}, + {name: "garbage cert", want: 400, d: loadbalancer.CreateCertificateDetails{ + CertificateName: new("junk"), PublicCertificate: new("not pem"), + }}, + {name: "key without cert", want: 400, d: loadbalancer.CreateCertificateDetails{ + CertificateName: new("k"), PrivateKey: new(string(keyPEM)), + }}, + {name: "empty", want: 400, d: loadbalancer.CreateCertificateDetails{CertificateName: new("empty")}}, + } + + for _, c := range errs { + assert.Equal(t, c.want, svcErr(t, create(c.d)).GetHTTPStatusCode(), c.name) + } + + { // private key never returned + got := e.get(t, id) + require.Contains(t, got.Certificates, "good") + assert.Equal(t, string(certPEM), *got.Certificates["good"].PublicCertificate) + + status, body := rawDo(t, e.base+"/20170115/loadBalancers/"+id) + assert.Equal(t, http.StatusOK, status) + assert.NotContains(t, body, "PRIVATE KEY") + assert.NotContains(t, body, "privateKey") + + list, err := e.lb.ListCertificates(ctx, loadbalancer.ListCertificatesRequest{LoadBalancerId: &id}) + require.NoError(t, err) + assert.Len(t, list.Items, 2) + } + + { // cipher suites + e.settle() + + _, err := e.lb.CreateSSLCipherSuite(ctx, loadbalancer.CreateSSLCipherSuiteRequest{ + LoadBalancerId: &id, + Name: new("suite"), Ciphers: []string{"ECDHE-RSA-AES128-GCM-SHA256"}, + }) + require.NoError(t, err) + e.settle() + + _, err = e.lb.CreateSSLCipherSuite(ctx, loadbalancer.CreateSSLCipherSuiteRequest{ + LoadBalancerId: &id, + Name: new("weird"), Ciphers: []string{"NULL-MD5"}, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + for _, weak := range []string{"ECDHE-RSA-AES128-SHA256", "ECDHE-ECDSA-AES128-SHA256"} { + _, err = e.lb.CreateSSLCipherSuite(ctx, loadbalancer.CreateSSLCipherSuiteRequest{ + LoadBalancerId: &id, + Name: new("cbc-" + weak), Ciphers: []string{weak}, + }) + require.NoError(t, err, weak) + e.settle() + } + + _, err = e.lb.CreateSSLCipherSuite(ctx, loadbalancer.CreateSSLCipherSuiteRequest{ + LoadBalancerId: &id, + Name: new("oci-mine"), Ciphers: []string{"AES128-SHA"}, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.lb.UpdateSSLCipherSuite(ctx, loadbalancer.UpdateSSLCipherSuiteRequest{ + LoadBalancerId: &id, Name: new("suite"), + Ciphers: []string{"AES256-GCM-SHA384"}, + }) + require.NoError(t, err) + e.settle() + + got, err := e.lb.GetSSLCipherSuite( + ctx, + loadbalancer.GetSSLCipherSuiteRequest{LoadBalancerId: &id, Name: new("suite")}, + ) + require.NoError(t, err) + assert.Equal(t, []string{"AES256-GCM-SHA384"}, got.Ciphers) + + _, err = e.lb.DeleteSSLCipherSuite( + ctx, + loadbalancer.DeleteSSLCipherSuiteRequest{LoadBalancerId: &id, Name: new("suite")}, + ) + require.NoError(t, err) + } + + { // in-use certificate cannot be deleted + e.settle() + + _, err := e.lb.CreateListener(ctx, loadbalancer.CreateListenerRequest{ + LoadBalancerId: &id, + Name: new("tls"), DefaultBackendSetName: new("web"), Port: new(8443), Protocol: new("HTTP"), + SslConfiguration: &loadbalancer.SslConfigurationDetails{CertificateName: new("good")}, + }) + require.NoError(t, err) + e.settle() + + _, err = e.lb.DeleteCertificate( + ctx, + loadbalancer.DeleteCertificateRequest{LoadBalancerId: &id, CertificateName: new("good")}, + ) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.lb.CreateListener(ctx, loadbalancer.CreateListenerRequest{ + LoadBalancerId: &id, + Name: new("noKey"), DefaultBackendSetName: new("web"), Port: new(8444), Protocol: new("HTTP"), + SslConfiguration: &loadbalancer.SslConfigurationDetails{CertificateName: new("ca-only")}, + }) + require.Error(t, err) + + _, err = e.lb.DeleteCertificate( + ctx, + loadbalancer.DeleteCertificateRequest{LoadBalancerId: &id, CertificateName: new("ca-only")}, + ) + require.NoError(t, err) + } +} diff --git a/services/ociloadbalancer/subres_test.go b/services/ociloadbalancer/subres_test.go new file mode 100644 index 0000000000..026d7d6bff --- /dev/null +++ b/services/ociloadbalancer/subres_test.go @@ -0,0 +1,196 @@ +package ociloadbalancer_test + +import ( + "net/http" + "strconv" + "testing" + + "github.com/oracle/oci-go-sdk/v65/loadbalancer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prefix(path, set string) loadbalancer.PathRoute { + return loadbalancer.PathRoute{ + Path: new(path), BackendSetName: new(set), + PathMatchType: &loadbalancer.PathMatchType{MatchType: loadbalancer.PathMatchTypeMatchTypePrefixMatch}, + } +} + +func TestBackendSetsAndBackends(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + id := e.mkLB(t, baseDetails("sets")) + a, b := startBackend(t, "a"), startBackend(t, "b") + ctx := t.Context() + + step := func() { e.settle() } + + step() + + _, err := e.lb.CreateBackendSet(ctx, loadbalancer.CreateBackendSetRequest{ + LoadBalancerId: &id, + Name: new( + "web", + ), + Policy: new("LEAST_CONNECTIONS"), + HealthChecker: hc(), + Backends: []loadbalancer.BackendDetails{a.detail()}, + }) + require.NoError(t, err) + + step() + + set, err := e.lb.GetBackendSet( + ctx, + loadbalancer.GetBackendSetRequest{LoadBalancerId: &id, BackendSetName: new("web")}, + ) + require.NoError(t, err) + assert.Equal(t, "LEAST_CONNECTIONS", *set.Policy) + require.Len(t, set.Backends, 1) + assert.Equal(t, 1, *set.Backends[0].Weight) + assert.False(t, *set.Backends[0].Backup) + assert.Equal(t, 0, *set.HealthChecker.Retries) + + { // duplicate set + _, derr := e.lb.CreateBackendSet(ctx, loadbalancer.CreateBackendSetRequest{ + LoadBalancerId: &id, + Name: new("web"), Policy: new("ROUND_ROBIN"), HealthChecker: hc(), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, derr).GetHTTPStatusCode()) + } + + name := a.ip() + ":" + itoa(a.port()) + + { // backend lifecycle + step() + + _, cerr := e.lb.CreateBackend(ctx, loadbalancer.CreateBackendRequest{ + LoadBalancerId: &id, BackendSetName: new("web"), + IpAddress: new(b.ip()), Port: new(b.port()), Weight: new(3), + }) + require.NoError(t, cerr) + step() + + _, cerr = e.lb.CreateBackend(ctx, loadbalancer.CreateBackendRequest{ + LoadBalancerId: &id, BackendSetName: new("web"), + IpAddress: new(b.ip()), Port: new(b.port()), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, cerr).GetHTTPStatusCode()) + + _, cerr = e.lb.CreateBackend(ctx, loadbalancer.CreateBackendRequest{ + LoadBalancerId: &id, BackendSetName: new("web"), + IpAddress: new("not-an-ip"), Port: new(80), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, cerr).GetHTTPStatusCode()) + + list, cerr := e.lb.ListBackends( + ctx, + loadbalancer.ListBackendsRequest{LoadBalancerId: &id, BackendSetName: new("web")}, + ) + require.NoError(t, cerr) + assert.Len(t, list.Items, 2) + + bname := b.ip() + ":" + itoa(b.port()) + _, cerr = e.lb.UpdateBackend(ctx, loadbalancer.UpdateBackendRequest{ + LoadBalancerId: &id, BackendSetName: new("web"), BackendName: &bname, + Weight: new(5), Backup: new(true), Drain: new(false), Offline: new(false), + }) + require.NoError(t, cerr) + step() + + got, cerr := e.lb.GetBackend( + ctx, + loadbalancer.GetBackendRequest{LoadBalancerId: &id, BackendSetName: new("web"), BackendName: &bname}, + ) + require.NoError(t, cerr) + assert.Equal(t, 5, *got.Weight) + assert.True(t, *got.Backup) + + _, cerr = e.lb.DeleteBackend( + ctx, + loadbalancer.DeleteBackendRequest{LoadBalancerId: &id, BackendSetName: new("web"), BackendName: &bname}, + ) + require.NoError(t, cerr) + step() + + _, cerr = e.lb.GetBackend( + ctx, + loadbalancer.GetBackendRequest{LoadBalancerId: &id, BackendSetName: new("web"), BackendName: &bname}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, cerr).GetHTTPStatusCode()) + } + + { // health checker + step() + + _, herr := e.lb.UpdateHealthChecker(ctx, loadbalancer.UpdateHealthCheckerRequest{ + LoadBalancerId: &id, BackendSetName: new("web"), + Protocol: new("TCP"), Port: new(8080), ReturnCode: new(200), Retries: new(2), IntervalInMillis: new(5000), + TimeoutInMillis: new(3000), ResponseBodyRegex: new(""), + }) + require.NoError(t, herr) + step() + + got, herr := e.lb.GetHealthChecker( + ctx, + loadbalancer.GetHealthCheckerRequest{LoadBalancerId: &id, BackendSetName: new("web")}, + ) + require.NoError(t, herr) + assert.Equal(t, "TCP", *got.Protocol) + assert.Equal(t, 8080, *got.Port) + assert.Equal(t, 3000, *got.TimeoutInMillis) + + _, herr = e.lb.UpdateHealthChecker(ctx, loadbalancer.UpdateHealthCheckerRequest{ + LoadBalancerId: &id, BackendSetName: new("web"), + Protocol: new("HTTP"), Port: new(0), ReturnCode: new(200), Retries: new(1), IntervalInMillis: new(1000), + TimeoutInMillis: new(1000), ResponseBodyRegex: new("("), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, herr).GetHTTPStatusCode()) + } + + { // update and delete set + step() + + _, uerr := e.lb.UpdateBackendSet(ctx, loadbalancer.UpdateBackendSetRequest{ + LoadBalancerId: &id, + BackendSetName: new("web"), + Policy: new( + "IP_HASH", + ), + HealthChecker: hc(), + Backends: []loadbalancer.BackendDetails{a.detail(), b.detail()}, + }) + require.NoError(t, uerr) + step() + + got, uerr := e.lb.GetBackendSet( + ctx, + loadbalancer.GetBackendSetRequest{LoadBalancerId: &id, BackendSetName: new("web")}, + ) + require.NoError(t, uerr) + assert.Equal(t, "IP_HASH", *got.Policy) + assert.Len(t, got.Backends, 2) + assert.Equal(t, name, *got.Backends[0].Name) + + sets, uerr := e.lb.ListBackendSets(ctx, loadbalancer.ListBackendSetsRequest{LoadBalancerId: &id}) + require.NoError(t, uerr) + assert.Len(t, sets.Items, 1) + + _, uerr = e.lb.DeleteBackendSet( + ctx, + loadbalancer.DeleteBackendSetRequest{LoadBalancerId: &id, BackendSetName: new("web")}, + ) + require.NoError(t, uerr) + step() + + _, uerr = e.lb.GetBackendSet( + ctx, + loadbalancer.GetBackendSetRequest{LoadBalancerId: &id, BackendSetName: new("web")}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, uerr).GetHTTPStatusCode()) + } +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/services/ociloadbalancer/workreq.go b/services/ociloadbalancer/workreq.go new file mode 100644 index 0000000000..d169a8b6ed --- /dev/null +++ b/services/ociloadbalancer/workreq.go @@ -0,0 +1,91 @@ +package ociloadbalancer + +import ( + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +// Work request lifecycle states. +const ( + wrInProgress = "IN_PROGRESS" + wrSucceeded = "SUCCEEDED" +) + +// WorkRequestWire is the wire form of a work request. +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + ID string `json:"id"` + LoadBalancerID string `json:"loadBalancerId"` + CompartmentID string `json:"compartmentId,omitempty"` + Type string `json:"type"` + LifecycleState string `json:"lifecycleState"` + Message string `json:"message"` + ErrorDetails []struct{} `json:"errorDetails"` +} + +// newWR records a work request that completes at finish. Callers hold the write lock. +func (b *InMemoryBackend) newWR(lb *LoadBalancer, typ string, finish time.Time) string { + wr := &WorkRequest{ + ID: newOCID("loadbalancerworkrequest"), LoadBalancerID: lb.ID, CompartmentID: lb.CompartmentID, Type: typ, + Accepted: b.nowUTC(), FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr.ID +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + out := WorkRequestWire{ + ID: wr.ID, LoadBalancerID: wr.LoadBalancerID, CompartmentID: wr.CompartmentID, Type: wr.Type, + TimeAccepted: wr.Accepted, LifecycleState: wrInProgress, ErrorDetails: []struct{}{}, + } + + if !wr.FinishAt.After(b.nowUTC()) { + out.LifecycleState, out.TimeFinished, out.Message = wrSucceeded, &wr.FinishAt, wr.Type+" succeeded" + } + + return out +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// ListWorkRequests lists the work requests of one load balancer, newest first. +func (b *InMemoryBackend) ListWorkRequests(lbID string, o ListOpts) (page.Page[WorkRequestWire], error) { + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + if _, ok := b.lbs[lbID]; !ok { + return page.Page[WorkRequestWire]{}, errNotFound(lbID) + } + + var all []*WorkRequest + + for _, wr := range b.workReqs { + if wr.LoadBalancerID == lbID { + all = append(all, wr) + } + } + + pg := paginate(all, ListOpts{Page: o.Page, Limit: o.Limit}, func(w *WorkRequest) string { return w.ID }, + func(w *WorkRequest) (time.Time, int64) { return w.Accepted, w.Seq }) + + out := make([]WorkRequestWire, 0, len(pg.Data)) + for _, wr := range pg.Data { + out = append(out, b.wrView(wr)) + } + + return page.Page[WorkRequestWire]{Data: out, Next: pg.Next}, nil +} diff --git a/services/ocilogging/PARITY.md b/services/ocilogging/PARITY.md new file mode 100644 index 0000000000..e0ca7c027a --- /dev/null +++ b/services/ocilogging/PARITY.md @@ -0,0 +1,53 @@ +--- +service: ocilogging +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: ee4983645 +last_audit_date: 2026-10-29 +overall: B +ops: + CreateLogGroup: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with opc-work-request-id and no body. CREATING then ACTIVE after the transition window on the injectable clock. compartmentId and displayName required (255 characters), description up to 400 characters; a displayName must be unique per compartment (409 Conflict)."} + GetLogGroup: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; bare resource body with lifecycleState and timeLastModified."} + ListLogGroups: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; displayName filter; sortBy timeCreated or displayName; bare JSON array, paged with opc-next-page. isCompartmentIdInSubtree is ignored (the emulator has no compartment tree)."} + UpdateLogGroup: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request; displayName, description and tags apply at once and the group reports UPDATING for the transition window. If-Match gives 412 NoEtagMatch."} + DeleteLogGroup: {wire: ok, errors: ok, state: ok, persist: ok, note: "202; DELETING then gone. A group that still contains logs answers 409 Conflict."} + ChangeLogGroupCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "Work request MOVE_LOG_GROUP; applies at once."} + CreateLog: {wire: ok, errors: partial, state: ok, persist: ok, note: "CUSTOM and SERVICE logs. retentionDuration 30 to 180 in steps of 30 (default 30). A SERVICE log needs configuration.source (OCISERVICE) naming a service and category from the emulated catalog (objectstorage read/write, functions invoke, flowlogs all, loadbalancer access/error, apigateway access/execution, events ruleexecutionlog); the same service, resource and category cannot back two logs (409). A CUSTOM log rejects a source. isEnabled false makes the log INACTIVE."} + GetLog: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; the log must belong to the addressed log group."} + ListLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Filters logType, sourceService, sourceResource, displayName, lifecycleState; bare array."} + UpdateLog: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName, isEnabled, retentionDuration, tags, configuration.archiving and (SERVICE) source.parameters; the service, resource and category are fixed."} + DeleteLog: {wire: ok, errors: ok, state: ok, persist: ok, note: "202; DELETING then gone, ingested entries are dropped with the log."} + ChangeLogLogGroup: {wire: ok, errors: ok, state: ok, persist: ok, note: "Moves a log to another ACTIVE log group of the same compartment; work request MOVE_LOG."} + CreateUnifiedAgentConfiguration: {wire: ok, errors: partial, state: ok, persist: ok, note: "serviceConfiguration is stored as sent. configurationType LOGGING needs at least one source with name and sourceType and a destination.logObjectId that references an existing log; MONITORING is stored without further checks. isEnabled is required."} + GetUnifiedAgentConfiguration: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; configurationState is always VALID because invalid configurations are rejected at write time."} + ListUnifiedAgentConfigurations: {wire: ok, errors: ok, state: ok, persist: n/a, note: "{items:[...]} collection (the only Logging list with an envelope); filters logId, groupId, displayName, lifecycleState."} + UpdateUnifiedAgentConfiguration: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName, isEnabled and serviceConfiguration are required as in the SDK."} + DeleteUnifiedAgentConfiguration: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request."} + ChangeUnifiedAgentConfigurationCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "Work request MOVE_CONFIGURATION."} + ListServices: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Fixed emulated catalog of six services at /v2/registry/services."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS until the resource transition time, then SUCCEEDED."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; id and status filters; bare array."} + DeleteWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "204; an in-progress work request answers 409."} + ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Always empty: no emulated operation fails asynchronously."} + ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Accepted and succeeded entries."} + PutLogs: {wire: ok, errors: partial, state: ok, persist: ok, note: "POST /20200831/logs/{logId}/actions/push, 200 with no body. specversion must be 1.0; each batch needs source, type and defaultlogentrytime; each entry needs data and id; the entry time falls back to defaultlogentrytime. Data longer than 10000 characters is truncated as documented. The log must be ACTIVE (enabled): CREATING, INACTIVE and DELETING logs answer 409. The request body is limited to 1 MiB (413 PayloadTooLarge). Entries are kept per log until retentionDuration days after ingestion, with at most 50000 entries per log."} + SearchLogs: {wire: ok, errors: partial, state: ok, persist: n/a, note: "POST /20190909/search (and /actions/search). See families.query_language. timeStart and timeEnd are required (end exclusive); limit 1 to 1000 (default 1000) with opc-next-page paging; isReturnFieldInfo returns the top level field names. Results are {data:{datetime, logContent, regionId}}."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-logging-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id. InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch, IncorrectState and Conflict follow the OCI reference; the status for an ingestion into a log that is not ACTIVE (409 IncorrectState) is unverified."} + endpoints: {status: ok, note: "Management (/20200531), ingestion (/20200831) and search (/20190909) share one listener, so a single host serves LoggingManagementClient, LoggingClient and LogSearchClient."} + lifecycle: {status: ok, note: "CREATING, UPDATING and DELETING last 2 seconds on the injectable backend clock; transitions and retention are applied on every request and by a one-minute sweeper."} + query_language: {status: partial, note: "Supported: search \"[/[/]]\" with several comma separated scopes (group and log by OCID or name); | where (keyword optional) with = != <> < <= > >=, in (...), and, or, not, parentheses, string/number/true/false/null literals and * wildcards in = and != string comparisons; | sort by f [asc|desc], ...; | top N by f [asc|desc]; | limit N; | count. Fields: datetime (epoch milliseconds), logContent (the entry data as text, or a path into the CloudEvent), regionId, and CloudEvent paths data.*, source, type, subject, id, time, oracle.*. Without sort the newest entry comes first. data.* resolves when the entry data is a JSON object. Unsupported (rejected 400): select, extend, dedup, summarize, fields, head, tail, rename, timestats, join and every other operator."} + retention: {status: ok, note: "Entries older than retentionDuration days (measured from ingestion) are dropped on the next request or sweep."} + service_logs: {status: partial, note: "OCI Functions writes one entry per finished invocation into the enabled SERVICE log whose source is functions//invoke (in-process, wired in internal/app). Other services do not emit logs yet."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-logging-port / OCI_LOGGING_PORT (default 8160); never on the shared AWS router; cli.go reserves the port in the shared pool."} +items_still_open: + - "Log saved searches (CreateLogSavedSearch, GetLogSavedSearch, ListLogSavedSearches, UpdateLogSavedSearch, DeleteLogSavedSearch, ChangeLogSavedSearchCompartment) are not routed." + - "The query language lacks select, extend, dedup, summarize, timestats, joins and functions; see families.query_language." + - "The service catalog is a fixed list of six services, and only OCI Functions feeds SERVICE logs; Object Storage, Load Balancer, API Gateway, Events and flow logs never emit entries." + - "Unified agent configurations are stored and validated but no agent consumes them, so they never produce entries." + - "Archiving (Object Storage export of logs) is stored on the configuration but nothing is exported." + - "Retention, the 1 MiB request limit, the 10000 character data truncation and the log naming rules are recalled from the Oracle documentation and SDK comments, not verified against the live service; the per-compartment uniqueness of log group names is an emulator choice." + - "isCompartmentIdInSubtree is ignored, and opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified." + - "Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set." +--- diff --git a/services/ocilogging/README.md b/services/ocilogging/README.md new file mode 100644 index 0000000000..9239852fa3 --- /dev/null +++ b/services/ocilogging/README.md @@ -0,0 +1,30 @@ + +# OCI Logging + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-29 (`ee4983645`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 26 (21 ok, 5 partial) | +| Feature families | 9 (6 ok, 3 partial) | +| Known gaps | 8 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Log saved searches (CreateLogSavedSearch, GetLogSavedSearch, ListLogSavedSearches, UpdateLogSavedSearch, DeleteLogSavedSearch, ChangeLogSavedSearchCompartment) are not routed. +- The query language lacks select, extend, dedup, summarize, timestats, joins and functions; see families.query_language. +- The service catalog is a fixed list of six services, and only OCI Functions feeds SERVICE logs; Object Storage, Load Balancer, API Gateway, Events and flow logs never emit entries. +- Unified agent configurations are stored and validated but no agent consumes them, so they never produce entries. +- Archiving (Object Storage export of logs) is stored on the configuration but nothing is exported. +- Retention, the 1 MiB request limit, the 10000 character data truncation and the log naming rules are recalled from the Oracle documentation and SDK comments, not verified against the live service; the per-compartment uniqueness of log group names is an emulator choice. +- isCompartmentIdInSubtree is ignored, and opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. +- Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocilogging/agents.go b/services/ocilogging/agents.go new file mode 100644 index 0000000000..20744d8a4e --- /dev/null +++ b/services/ocilogging/agents.go @@ -0,0 +1,422 @@ +package ocilogging + +import ( + "bytes" + "encoding/json" + "net/http" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + cfgTypeLogging = "LOGGING" + cfgTypeMonitoring = "MONITORING" + cfgStateValid = "VALID" +) + +type groupAssociationWire struct { + GroupList []string `json:"groupList"` +} + +// CreateAgentConfigIn is the CreateUnifiedAgentConfiguration request body. +type CreateAgentConfigIn struct { + Tags + Enabled *bool `json:"isEnabled"` + Description *string `json:"description"` + GroupAssoc *groupAssociationWire `json:"groupAssociation"` + Name string `json:"displayName"` + Compartment string `json:"compartmentId"` + ServiceConfig json.RawMessage `json:"serviceConfiguration"` +} + +// UpdateAgentConfigIn is the UpdateUnifiedAgentConfiguration request body. +type UpdateAgentConfigIn struct { + Tags + Enabled *bool `json:"isEnabled"` + Description *string `json:"description"` + Name *string `json:"displayName"` + GroupAssoc *groupAssociationWire `json:"groupAssociation"` + ServiceConfig json.RawMessage `json:"serviceConfiguration"` +} + +// AgentConfigWire is the GetUnifiedAgentConfiguration response. +type AgentConfigWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeModified time.Time `json:"timeLastModified"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + LifecycleState string `json:"lifecycleState"` + ConfigurationState string `json:"configurationState"` + GroupAssociation groupAssociationWire `json:"groupAssociation"` + ServiceConfig json.RawMessage `json:"serviceConfiguration"` + Enabled bool `json:"isEnabled"` +} + +type agentSummaryWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeModified time.Time `json:"timeLastModified"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + LifecycleState string `json:"lifecycleState"` + ConfigurationType string `json:"configurationType"` + ConfigurationState string `json:"configurationState"` + Enabled bool `json:"isEnabled"` +} + +func agentView(a *AgentConfig) AgentConfigWire { + groups := a.Groups + if groups == nil { + groups = []string{} + } + + return AgentConfigWire{ + ID: a.ID, CompartmentID: a.CompartmentID, Name: a.Name, Description: a.Description, LifecycleState: a.State, + ConfigurationState: cfgStateValid, Enabled: a.Enabled, ServiceConfig: a.ServiceConfig, + GroupAssociation: groupAssociationWire{GroupList: groups}, TimeCreated: a.TimeCreated, + TimeModified: a.TimeModified, FreeformTags: a.Tags.FreeformTags, DefinedTags: a.Tags.DefinedTags, + } +} + +// validateServiceConfig checks the polymorphic serviceConfiguration and returns its configurationType. +func (b *InMemoryBackend) validateServiceConfig(raw json.RawMessage) (string, *APIError) { + if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return "", errMissing("serviceConfiguration is required") + } + + var c struct { + Destination *struct { + LogObjectID string `json:"logObjectId"` + } `json:"destination"` + ConfigurationType string `json:"configurationType"` + Sources []struct { + Name string `json:"name"` + SourceType string `json:"sourceType"` + } `json:"sources"` + } + + if err := json.Unmarshal(raw, &c); err != nil { + return "", errParam("serviceConfiguration is not a valid configuration object") + } + + switch { + case strs.Equal(c.ConfigurationType, cfgTypeMonitoring): + return cfgTypeMonitoring, nil + case !strs.Equal(c.ConfigurationType, cfgTypeLogging): + return "", errParam("serviceConfiguration.configurationType must be LOGGING or MONITORING") + } + + if len(c.Sources) == 0 { + return "", errParam("serviceConfiguration.sources must contain at least one source") + } + + for _, s := range c.Sources { + if s.Name == "" || s.SourceType == "" { + return "", errParam("every source needs a name and a sourceType") + } + } + + if c.Destination == nil || c.Destination.LogObjectID == "" { + return "", errMissing("serviceConfiguration.destination.logObjectId is required") + } + + if _, ok := b.logs[c.Destination.LogObjectID]; !ok { + return "", errParam("destination log " + c.Destination.LogObjectID + " does not exist") + } + + return cfgTypeLogging, nil +} + +func groupsOf(g *groupAssociationWire) []string { + if g == nil { + return nil + } + + return g.GroupList +} + +// CreateAgentConfig creates a unified agent configuration. +func (b *InMemoryBackend) CreateAgentConfig(in CreateAgentConfigIn) (string, error) { + if in.Compartment == "" { + return "", errMissing("compartmentId is required") + } + + if in.Enabled == nil { + return "", errMissing("isEnabled is required") + } + + if aerr := firstErr(validateName(in.Name), validDescription(in.Description)); aerr != nil { + return "", aerr + } + + b.mu.Lock("CreateAgentConfig") + defer b.mu.Unlock() + + b.settleAll() + + cfgType, aerr := b.validateServiceConfig(in.ServiceConfig) + if aerr != nil { + return "", aerr + } + + now := b.nowUTC() + a := &AgentConfig{ + ID: newOCID("unifiedagentconfiguration"), CompartmentID: in.Compartment, Name: in.Name, State: stateCreating, + Enabled: *in.Enabled, ServiceConfig: in.ServiceConfig, ConfigType: cfgType, Groups: groupsOf(in.GroupAssoc), + TimeCreated: now, TimeModified: now, TransitionAt: b.transitionAt(), Tags: in.Tags, Etag: newEtag(), + Seq: b.nextSeq(), + } + if in.Description != nil { + a.Description = *in.Description + } + + b.agents[a.ID] = a + + return b.newWR("CREATE_CONFIGURATION", "unifiedagentconfiguration", a.CompartmentID, a.ID, "CREATED", + *a.TransitionAt), nil +} + +func (b *InMemoryBackend) mutableAgent(id string) (*AgentConfig, *APIError) { + a, ok := b.agents[id] + if !ok { + return nil, errNotFound(id) + } + + if a.State != stateActive { + return nil, errState( + "Agent configuration " + id + " is in state " + a.State + "; the operation requires ACTIVE", + ) + } + + return a, nil +} + +// GetAgentConfig returns one agent configuration and its etag. +func (b *InMemoryBackend) GetAgentConfig(id string) (AgentConfigWire, string, error) { + b.mu.Lock("GetAgentConfig") + defer b.mu.Unlock() + + b.settleAll() + + a, ok := b.agents[id] + if !ok { + return AgentConfigWire{}, "", errNotFound(id) + } + + return agentView(a), a.Etag, nil +} + +// AgentFilter narrows ListUnifiedAgentConfigurations. +type AgentFilter struct { + Compartment, Name, State, LogID, GroupID string +} + +// ListAgentConfigs lists the agent configurations of a compartment. +func (b *InMemoryBackend) ListAgentConfigs(f AgentFilter, o ListOpts) (page.Page[agentSummaryWire], error) { + if f.Compartment == "" { + return page.Page[agentSummaryWire]{}, errMissing("compartmentId is required") + } + + b.mu.Lock("ListAgentConfigs") + defer b.mu.Unlock() + + b.settleAll() + + var all []*AgentConfig + + for _, a := range b.agents { + if a.CompartmentID != f.Compartment || (f.Name != "" && a.Name != f.Name) || + (f.State != "" && !strs.Equal(a.State, f.State)) || + (f.LogID != "" && !bytes.Contains(a.ServiceConfig, []byte(`"`+f.LogID+`"`))) || + (f.GroupID != "" && !strs.ContainsFold(a.Groups, f.GroupID)) { + continue + } + + all = append(all, a) + } + + pg := paginate(all, o, func(a *AgentConfig) string { return a.Name }, + func(a *AgentConfig) (time.Time, int64) { return a.TimeCreated, a.Seq }) + + out := make([]agentSummaryWire, 0, len(pg.Data)) + for _, a := range pg.Data { + out = append(out, agentSummaryWire{ + ID: a.ID, CompartmentID: a.CompartmentID, Name: a.Name, Description: a.Description, + LifecycleState: a.State, ConfigurationType: a.ConfigType, ConfigurationState: cfgStateValid, + Enabled: a.Enabled, TimeCreated: a.TimeCreated, TimeModified: a.TimeModified, + FreeformTags: a.Tags.FreeformTags, DefinedTags: a.Tags.DefinedTags, + }) + } + + return page.Page[agentSummaryWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateAgentConfig replaces the mutable fields of an agent configuration. +func (b *InMemoryBackend) UpdateAgentConfig(id, ifMatch string, in UpdateAgentConfigIn) (string, error) { + if in.Enabled == nil { + return "", errMissing("isEnabled is required") + } + + if in.Name == nil { + return "", errMissing("displayName is required") + } + + if aerr := firstErr(validateName(*in.Name), validDescription(in.Description)); aerr != nil { + return "", aerr + } + + b.mu.Lock("UpdateAgentConfig") + defer b.mu.Unlock() + + b.settleAll() + + a, aerr := b.mutableAgent(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(a.Etag, ifMatch); aerr != nil { + return "", aerr + } + + cfgType, aerr := b.validateServiceConfig(in.ServiceConfig) + if aerr != nil { + return "", aerr + } + + a.Name, a.Enabled, a.ServiceConfig, a.ConfigType = *in.Name, *in.Enabled, in.ServiceConfig, cfgType + if in.Description != nil { + a.Description = *in.Description + } + + if in.GroupAssoc != nil { + a.Groups = in.GroupAssoc.GroupList + } + + a.Tags.merge(in.Tags) + a.State, a.TransitionAt, a.TimeModified, a.Etag = stateUpdating, b.transitionAt(), b.nowUTC(), newEtag() + + return b.newWR("UPDATE_CONFIGURATION", "unifiedagentconfiguration", a.CompartmentID, a.ID, "UPDATED", + *a.TransitionAt), nil +} + +// DeleteAgentConfig moves an agent configuration to DELETING. +func (b *InMemoryBackend) DeleteAgentConfig(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteAgentConfig") + defer b.mu.Unlock() + + b.settleAll() + + a, aerr := b.mutableAgent(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(a.Etag, ifMatch); aerr != nil { + return "", aerr + } + + a.State, a.TransitionAt, a.TimeModified, a.Etag = stateDeleting, b.transitionAt(), b.nowUTC(), newEtag() + + return b.newWR("DELETE_CONFIGURATION", "unifiedagentconfiguration", a.CompartmentID, a.ID, "DELETED", + *a.TransitionAt), nil +} + +// ChangeAgentConfigCompartment moves an agent configuration to another compartment. +func (b *InMemoryBackend) ChangeAgentConfigCompartment(id, ifMatch, compartment string) (string, error) { + if compartment == "" { + return "", errMissing("compartmentId is required") + } + + b.mu.Lock("ChangeAgentConfigCompartment") + defer b.mu.Unlock() + + b.settleAll() + + a, aerr := b.mutableAgent(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(a.Etag, ifMatch); aerr != nil { + return "", aerr + } + + a.CompartmentID, a.Etag, a.TimeModified = compartment, newEtag(), b.nowUTC() + + return b.newWR("MOVE_CONFIGURATION", "unifiedagentconfiguration", compartment, a.ID, "UPDATED", b.nowUTC()), nil +} + +func (h *Handler) createAgentConfig(q *req) error { + var in CreateAgentConfigIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateAgentConfig(in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getAgentConfig(q *req) error { + v, etag, err := h.Backend.GetAgentConfig(q.param("unifiedAgentConfigurationId")) + + return reply(q, v, etag, err) +} + +func (h *Handler) listAgentConfigs(q *req) error { + pg, err := h.Backend.ListAgentConfigs(AgentFilter{ + Compartment: q.query("compartmentId"), Name: q.query("displayName"), State: q.query("lifecycleState"), + LogID: q.query("logId"), GroupID: q.query("groupId"), + }, q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + data := pg.Data + if data == nil { + data = []agentSummaryWire{} + } + + return q.json(http.StatusOK, map[string]any{"items": data}, "") +} + +func (h *Handler) updateAgentConfig(q *req) error { + var in UpdateAgentConfigIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateAgentConfig(q.param("unifiedAgentConfigurationId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteAgentConfig(q *req) error { + wr, err := h.Backend.DeleteAgentConfig(q.param("unifiedAgentConfigurationId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeAgentConfigCompartment(q *req) error { + var in changeCompartmentIn + if err := q.decode(&in); err != nil { + return err + } + + id := q.param("unifiedAgentConfigurationId") + wr, err := h.Backend.ChangeAgentConfigCompartment(id, q.ifMatch(), in.CompartmentID) + + return acceptedWR(q, wr, err) +} diff --git a/services/ocilogging/agents_test.go b/services/ocilogging/agents_test.go new file mode 100644 index 0000000000..07d1ac0553 --- /dev/null +++ b/services/ocilogging/agents_test.go @@ -0,0 +1,125 @@ +package ocilogging_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/logging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func loggingServiceConfig( + logID *string, + sources ...logging.UnifiedAgentLoggingSource, +) logging.UnifiedAgentLoggingConfiguration { + return logging.UnifiedAgentLoggingConfiguration{ + Sources: sources, + Destination: &logging.UnifiedAgentLoggingDestination{LogObjectId: logID}, + } +} + +func tailSource(name string) logging.UnifiedAgentTailLogSource { + return logging.UnifiedAgentTailLogSource{ + Name: new(name), Paths: []string{"/var/log/app.log"}, + Parser: logging.UnifiedAgentNoneParser{}, + } +} + +func TestUnifiedAgentConfigurationLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "g") + l := e.mkLog(t, g, "app", nil) + + create := func(mod func(*logging.CreateUnifiedAgentConfigurationDetails)) error { + d := logging.CreateUnifiedAgentConfigurationDetails{ + CompartmentId: new(testCompartment), DisplayName: new("agent"), IsEnabled: new(true), + Description: new("tail the app log"), + ServiceConfiguration: loggingServiceConfig(l.Id, tailSource("app")), + GroupAssociation: &logging.GroupAssociationDetails{GroupList: []string{"ocid1.dynamicgroup.oc1..dg"}}, + } + if mod != nil { + mod(&d) + } + + _, err := e.mgmt.CreateUnifiedAgentConfiguration(t.Context(), logging.CreateUnifiedAgentConfigurationRequest{ + CreateUnifiedAgentConfigurationDetails: d, + }) + + return err + } + + failures := []struct { + mod func(*logging.CreateUnifiedAgentConfigurationDetails) + name string + }{ + {func(d *logging.CreateUnifiedAgentConfigurationDetails) { + d.ServiceConfiguration = loggingServiceConfig(new("ocid1.log.oc1..ghost"), tailSource("a")) + }, "unknown destination log"}, + {func(d *logging.CreateUnifiedAgentConfigurationDetails) { + d.ServiceConfiguration = loggingServiceConfig(l.Id, []logging.UnifiedAgentLoggingSource{}...) + }, "no sources"}, + {func(d *logging.CreateUnifiedAgentConfigurationDetails) { d.DisplayName = new("") }, "blank name"}, + } + + for _, f := range failures { + assert.Equal(t, http.StatusBadRequest, svcErr(t, create(f.mod)).GetHTTPStatusCode(), f.name) + } + + require.NoError(t, create(nil)) + e.settle() + + list, err := e.mgmt.ListUnifiedAgentConfigurations(t.Context(), logging.ListUnifiedAgentConfigurationsRequest{ + CompartmentId: new(testCompartment), LogId: l.Id, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, logging.UnifiedAgentServiceConfigurationTypesLogging, list.Items[0].ConfigurationType) + assert.Equal(t, logging.LogLifecycleStateActive, list.Items[0].LifecycleState) + + got, err := e.mgmt.GetUnifiedAgentConfiguration(t.Context(), logging.GetUnifiedAgentConfigurationRequest{ + UnifiedAgentConfigurationId: list.Items[0].Id, + }) + require.NoError(t, err) + assert.Equal(t, []string{"ocid1.dynamicgroup.oc1..dg"}, got.GroupAssociation.GroupList) + + cfg, ok := got.ServiceConfiguration.(logging.UnifiedAgentLoggingConfiguration) + require.True(t, ok) + require.Len(t, cfg.Sources, 1) + assert.Equal(t, *l.Id, *cfg.Destination.LogObjectId) + + _, err = e.mgmt.UpdateUnifiedAgentConfiguration(t.Context(), logging.UpdateUnifiedAgentConfigurationRequest{ + UnifiedAgentConfigurationId: got.Id, + DisplayName: new("agent2"), IsEnabled: new(false), + ServiceConfiguration: loggingServiceConfig(l.Id, tailSource("a"), tailSource("b")), + }) + require.NoError(t, err) + e.settle() + + got, err = e.mgmt.GetUnifiedAgentConfiguration(t.Context(), logging.GetUnifiedAgentConfigurationRequest{ + UnifiedAgentConfigurationId: got.Id, + }) + require.NoError(t, err) + assert.Equal(t, "agent2", *got.DisplayName) + assert.False(t, *got.IsEnabled) + + _, err = e.mgmt.ChangeUnifiedAgentConfigurationCompartment(t.Context(), + logging.ChangeUnifiedAgentConfigurationCompartmentRequest{ + UnifiedAgentConfigurationId: got.Id, + CompartmentId: new("ocid1.compartment.oc1..moved"), + }) + require.NoError(t, err) + + _, err = e.mgmt.DeleteUnifiedAgentConfiguration(t.Context(), logging.DeleteUnifiedAgentConfigurationRequest{ + UnifiedAgentConfigurationId: got.Id, + }) + require.NoError(t, err) + e.settle() + + _, err = e.mgmt.GetUnifiedAgentConfiguration(t.Context(), logging.GetUnifiedAgentConfigurationRequest{ + UnifiedAgentConfigurationId: got.Id, + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} diff --git a/services/ocilogging/errors.go b/services/ocilogging/errors.go new file mode 100644 index 0000000000..413a2b669b --- /dev/null +++ b/services/ocilogging/errors.go @@ -0,0 +1,45 @@ +package ocilogging + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocilogging: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } + +func errConflict(msg string) *APIError { return newErr(http.StatusConflict, "Conflict", msg) } diff --git a/services/ocilogging/handler.go b/services/ocilogging/handler.go new file mode 100644 index 0000000000..897686c2de --- /dev/null +++ b/services/ocilogging/handler.go @@ -0,0 +1,362 @@ +package ocilogging + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Minute + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Logging: control plane, ingestion and search. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocilogging.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCILogging" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCILogging runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"logId", "logGroupId", "unifiedAgentConfigurationId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocilogging: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + if err := json.Unmarshal(q.body, v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocilogging: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCILogging requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocilogging: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCILogging", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ocilogging", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocilogging: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocilogging: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocilogging: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocilogging: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() +} diff --git a/services/ocilogging/helpers_test.go b/services/ocilogging/helpers_test.go new file mode 100644 index 0000000000..2f5adb1395 --- /dev/null +++ b/services/ocilogging/helpers_test.go @@ -0,0 +1,248 @@ +package ocilogging_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/logging" + "github.com/oracle/oci-go-sdk/v65/loggingingestion" + "github.com/oracle/oci-go-sdk/v65/loggingsearch" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + + oci "github.com/blackbirdworks/gopherstack/services/ocilogging" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *oci.InMemoryBackend + handler *oci.Handler + clock *fakeClock + prov common.ConfigurationProvider + kp keyPair + mgmt logging.LoggingManagementClient + ingest loggingingestion.LoggingClient + search loggingsearch.LogSearchClient + base string +} + +type envOpts struct { + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := oci.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + h := oci.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{ + backend: be, handler: h, clock: clock, kp: kp, + prov: common.NewRawConfigurationProvider(testTenancy, testUser, oci.Region, kp.fprint, kp.privPEM, nil), + base: fmt.Sprintf("http://localhost:%d", h.Port), + } + + var err error + + e.mgmt, err = logging.NewLoggingManagementClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.mgmt.Host = e.base + + e.ingest, err = loggingingestion.NewLoggingClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.ingest.Host = e.base + + e.search, err = loggingsearch.NewLogSearchClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.search.Host = e.base + + return e +} + +// settle advances past the CREATING/UPDATING/DELETING transition. +func (e *env) settle() { e.clock.Advance(5 * time.Second) } + +func (e *env) mkGroup(t *testing.T, name string) logging.LogGroup { + t.Helper() + + resp, err := e.mgmt.CreateLogGroup(t.Context(), logging.CreateLogGroupRequest{ + CompartmentId: new(testCompartment), DisplayName: new(name), + }) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + e.settle() + + list, err := e.mgmt.ListLogGroups(t.Context(), logging.ListLogGroupsRequest{ + CompartmentId: new(testCompartment), DisplayName: new(name), + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + got, err := e.mgmt.GetLogGroup(t.Context(), logging.GetLogGroupRequest{LogGroupId: list.Items[0].Id}) + require.NoError(t, err) + + return got.LogGroup +} + +func (e *env) mkLog(t *testing.T, g logging.LogGroup, name string, mod func(*logging.CreateLogDetails)) logging.Log { + t.Helper() + + d := logging.CreateLogDetails{DisplayName: new(name), LogType: logging.CreateLogDetailsLogTypeCustom} + if mod != nil { + mod(&d) + } + + _, err := e.mgmt.CreateLog(t.Context(), logging.CreateLogRequest{LogGroupId: g.Id, CreateLogDetails: d}) + require.NoError(t, err) + e.settle() + + list, err := e.mgmt.ListLogs(t.Context(), logging.ListLogsRequest{LogGroupId: g.Id, DisplayName: new(name)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + got, err := e.mgmt.GetLog(t.Context(), logging.GetLogRequest{LogGroupId: g.Id, LogId: list.Items[0].Id}) + require.NoError(t, err) + + return got.Log +} + +// put ingests one batch of entries with the given data strings, stamped at the fake clock. +func (e *env) put(t *testing.T, l logging.Log, offset time.Duration, data ...string) { + t.Helper() + + now := common.SDKTime{Time: e.clock.Now().Add(offset)} + entries := make([]loggingingestion.LogEntry, 0, len(data)) + + for i, d := range data { + entries = append( + entries, + loggingingestion.LogEntry{Data: new(d), Id: new(fmt.Sprintf("entry-%d-%d", offset, i))}, + ) + } + + _, err := e.ingest.PutLogs(t.Context(), loggingingestion.PutLogsRequest{ + LogId: l.Id, + Specversion: new("1.0"), + LogEntryBatches: []loggingingestion.LogEntryBatch{{ + Entries: entries, Source: new("app"), Type: new("app.requests"), Defaultlogentrytime: &now, + }}, + }) + require.NoError(t, err) +} + +func (e *env) query(t *testing.T, q string, limit *int, page *string) loggingsearch.SearchLogsResponse { + t.Helper() + + start := common.SDKTime{Time: e.clock.Now().Add(-48 * time.Hour)} + end := common.SDKTime{Time: e.clock.Now().Add(48 * time.Hour)} + + resp, err := e.search.SearchLogs(t.Context(), loggingsearch.SearchLogsRequest{ + TimeStart: &start, TimeEnd: &end, SearchQuery: new(q), IsReturnFieldInfo: new(true), + Limit: limit, Page: page, + }) + require.NoError(t, err) + + return resp +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} diff --git a/services/ocilogging/ingest.go b/services/ocilogging/ingest.go new file mode 100644 index 0000000000..d7a9b7b04a --- /dev/null +++ b/services/ocilogging/ingest.go @@ -0,0 +1,191 @@ +package ocilogging + +import ( + "encoding/json" + "net/http" + "time" + "unicode/utf8" + + "github.com/google/uuid" +) + +const ( + specVersion = "1.0" + maxEntryBytes = 1 << 20 + maxDataChars = 10000 + maxEntriesPerLog = 50000 + maxBatchesPerCall = 1000 +) + +// PutLogsIn is the PutLogs request body. +type PutLogsIn struct { + SpecVersion string `json:"specversion"` + Batches []LogBatch `json:"logEntryBatches"` +} + +// LogBatch is a group of entries sharing a source, type and subject. +type LogBatch struct { + DefaultTime *time.Time `json:"defaultlogentrytime"` + Source *string `json:"source"` + Type *string `json:"type"` + Subject string `json:"subject"` + Entries []LogEntry `json:"entries"` +} + +// LogEntry is one entry of a batch. +type LogEntry struct { + Time *time.Time `json:"time"` + Data *string `json:"data"` + ID *string `json:"id"` +} + +func validatePut(in PutLogsIn) *APIError { + if in.SpecVersion != specVersion { + return errParam("specversion must be 1.0") + } + + if len(in.Batches) == 0 || len(in.Batches) > maxBatchesPerCall { + return errParam("logEntryBatches must contain between 1 and 1000 batches") + } + + for _, bt := range in.Batches { + if bt.Source == nil || bt.Type == nil || bt.DefaultTime == nil { + return errMissing("every batch needs source, type and defaultlogentrytime") + } + + if len(bt.Entries) == 0 { + return errParam("every batch needs at least one entry") + } + + for _, e := range bt.Entries { + if e.Data == nil || e.ID == nil || *e.ID == "" { + return errMissing("every entry needs data and id") + } + + if len(*e.Data) > maxEntryBytes { + return errParam("an entry must be smaller than 1 MB") + } + } + } + + return nil +} + +func truncateChars(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + + return string([]rune(s)[:n]) +} + +// PutLogs stores the batches in an ACTIVE, enabled log. +func (b *InMemoryBackend) PutLogs(logID string, in PutLogsIn) error { + if aerr := validatePut(in); aerr != nil { + return aerr + } + + b.mu.Lock("PutLogs") + defer b.mu.Unlock() + + b.settleAll() + + l, aerr := b.liveLog("", logID) + if aerr != nil { + return aerr + } + + if l.State != stateActive { + return errState("Log " + logID + " is in state " + l.State + "; ingestion requires ACTIVE") + } + + now := b.nowUTC() + + for _, bt := range in.Batches { + for _, e := range bt.Entries { + t := bt.DefaultTime.UTC() + if e.Time != nil { + t = e.Time.UTC() + } + + b.append(l.ID, &Entry{ + ID: *e.ID, Time: t, Ingested: now, Data: truncateChars(*e.Data, maxDataChars), + Source: *bt.Source, Type: *bt.Type, Subject: bt.Subject, + }) + } + } + + return nil +} + +func (b *InMemoryBackend) append(logID string, e *Entry) { + e.Seq = b.nextSeq() + es := b.entries[logID] + es = append(es, e) + + if len(es) > maxEntriesPerLog { + es = es[len(es)-maxEntriesPerLog:] + } + + b.entries[logID] = es +} + +// ServiceEvent is a log record emitted by another emulated OCI service. +type ServiceEvent struct { + Time time.Time + Data map[string]any + Type string + Subject string +} + +// EmitServiceLog stores the event in every ACTIVE SERVICE log whose source matches; it returns how many logs took it. +func (b *InMemoryBackend) EmitServiceLog(service, resource, category string, ev ServiceEvent) int { + data, err := json.Marshal(ev.Data) + if err != nil { + return 0 + } + + b.mu.Lock("EmitServiceLog") + defer b.mu.Unlock() + + b.settleAll() + + now, n := b.nowUTC(), 0 + + for _, l := range b.logs { + s := l.Config + if l.LogType != logTypeService || l.State != stateActive || s == nil || s.Source == nil || + s.Source.Service != service || s.Source.Resource != resource || s.Source.Category != category { + continue + } + + t := ev.Time.UTC() + if ev.Time.IsZero() { + t = now + } + + b.append(l.ID, &Entry{ + ID: uuid.NewString(), Time: t, Ingested: now, Data: string(data), + Source: service + "." + resource, Type: ev.Type, Subject: ev.Subject, + }) + + n++ + } + + return n +} + +func (h *Handler) putLogs(q *req) error { + var in PutLogsIn + if err := q.decode(&in); err != nil { + return err + } + + if err := h.Backend.PutLogs(q.param("logId"), in); err != nil { + return err + } + + q.w.WriteHeader(http.StatusOK) + + return nil +} diff --git a/services/ocilogging/inproc.go b/services/ocilogging/inproc.go new file mode 100644 index 0000000000..aa2ca678dd --- /dev/null +++ b/services/ocilogging/inproc.go @@ -0,0 +1,26 @@ +package ocilogging + +import "slices" + +// Compartments lists the distinct compartments that hold log groups. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, g := range b.groups { + if g.State != stateDeleting { + seen[g.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} diff --git a/services/ocilogging/leak_main_test.go b/services/ocilogging/leak_main_test.go new file mode 100644 index 0000000000..eb023adba0 --- /dev/null +++ b/services/ocilogging/leak_main_test.go @@ -0,0 +1,18 @@ +package ocilogging_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ocilogging/loggroups.go b/services/ocilogging/loggroups.go new file mode 100644 index 0000000000..36ad5a4824 --- /dev/null +++ b/services/ocilogging/loggroups.go @@ -0,0 +1,318 @@ +package ocilogging + +import ( + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +// CreateLogGroupIn is the CreateLogGroup request body. +type CreateLogGroupIn struct { + Tags + Description *string `json:"description"` + Name string `json:"displayName"` + Compartment string `json:"compartmentId"` +} + +// UpdateLogGroupIn is the UpdateLogGroup request body. +type UpdateLogGroupIn struct { + Tags + Name *string `json:"displayName"` + Description *string `json:"description"` +} + +// LogGroupWire is the GetLogGroup response and the ListLogGroups item. +type LogGroupWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeModified time.Time `json:"timeLastModified"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + LifecycleState string `json:"lifecycleState"` +} + +func groupView(g *LogGroup) LogGroupWire { + return LogGroupWire{ + ID: g.ID, CompartmentID: g.CompartmentID, Name: g.Name, Description: g.Description, LifecycleState: g.State, + TimeCreated: g.TimeCreated, TimeModified: g.TimeModified, FreeformTags: g.Tags.FreeformTags, + DefinedTags: g.Tags.DefinedTags, + } +} + +func (b *InMemoryBackend) liveGroup(id string) (*LogGroup, *APIError) { + g, ok := b.groups[id] + if !ok { + return nil, errNotFound(id) + } + + return g, nil +} + +func (b *InMemoryBackend) mutableGroup(id string) (*LogGroup, *APIError) { + g, aerr := b.liveGroup(id) + if aerr != nil { + return nil, aerr + } + + if g.State != stateActive { + return nil, errState("Log group " + id + " is in state " + g.State + "; the operation requires ACTIVE") + } + + return g, nil +} + +// groupNameTaken reports whether another live group in the compartment already uses name. +func (b *InMemoryBackend) groupNameTaken(compartment, name, except string) bool { + for _, g := range b.groups { + if g.ID != except && g.CompartmentID == compartment && g.Name == name { + return true + } + } + + return false +} + +func validDescription(d *string) *APIError { + if d != nil && len(*d) > maxDescription { + return errParam("description must be at most 400 characters") + } + + return nil +} + +// CreateLogGroup creates a log group in CREATING state and returns its work request. +func (b *InMemoryBackend) CreateLogGroup(in CreateLogGroupIn) (string, error) { + if in.Compartment == "" { + return "", errMissing("compartmentId is required") + } + + if aerr := firstErr(validateName(in.Name), validDescription(in.Description)); aerr != nil { + return "", aerr + } + + b.mu.Lock("CreateLogGroup") + defer b.mu.Unlock() + + b.settleAll() + + if b.groupNameTaken(in.Compartment, in.Name, "") { + return "", errConflict("A log group named " + in.Name + " already exists in the compartment") + } + + now := b.nowUTC() + g := &LogGroup{ + ID: newOCID("loggroup"), CompartmentID: in.Compartment, Name: in.Name, State: stateCreating, + TimeCreated: now, TimeModified: now, TransitionAt: b.transitionAt(), Tags: in.Tags, Etag: newEtag(), + Seq: b.nextSeq(), + } + if in.Description != nil { + g.Description = *in.Description + } + + b.groups[g.ID] = g + + return b.newWR("CREATE_LOG_GROUP", "loggroup", g.CompartmentID, g.ID, "CREATED", *g.TransitionAt), nil +} + +// GetLogGroup returns one log group and its etag. +func (b *InMemoryBackend) GetLogGroup(id string) (LogGroupWire, string, error) { + b.mu.Lock("GetLogGroup") + defer b.mu.Unlock() + + b.settleAll() + + g, aerr := b.liveGroup(id) + if aerr != nil { + return LogGroupWire{}, "", aerr + } + + return groupView(g), g.Etag, nil +} + +// ListLogGroups lists the log groups of a compartment. +func (b *InMemoryBackend) ListLogGroups(compartment, name string, o ListOpts) (page.Page[LogGroupWire], error) { + if compartment == "" { + return page.Page[LogGroupWire]{}, errMissing("compartmentId is required") + } + + b.mu.Lock("ListLogGroups") + defer b.mu.Unlock() + + b.settleAll() + + var all []*LogGroup + + for _, g := range b.groups { + if g.CompartmentID == compartment && (name == "" || g.Name == name) { + all = append(all, g) + } + } + + pg := paginate(all, o, func(g *LogGroup) string { return g.Name }, + func(g *LogGroup) (time.Time, int64) { return g.TimeCreated, g.Seq }) + + out := make([]LogGroupWire, 0, len(pg.Data)) + for _, g := range pg.Data { + out = append(out, groupView(g)) + } + + return page.Page[LogGroupWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateLogGroup changes the mutable fields of a log group; it passes through UPDATING. +func (b *InMemoryBackend) UpdateLogGroup(id, ifMatch string, in UpdateLogGroupIn) (string, error) { + if in.Name != nil { + if aerr := validateName(*in.Name); aerr != nil { + return "", aerr + } + } + + if aerr := validDescription(in.Description); aerr != nil { + return "", aerr + } + + b.mu.Lock("UpdateLogGroup") + defer b.mu.Unlock() + + b.settleAll() + + g, aerr := b.mutableGroup(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(g.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if in.Name != nil { + if b.groupNameTaken(g.CompartmentID, *in.Name, g.ID) { + return "", errConflict("A log group named " + *in.Name + " already exists in the compartment") + } + + g.Name = *in.Name + } + + if in.Description != nil { + g.Description = *in.Description + } + + g.Tags.merge(in.Tags) + g.State, g.TransitionAt, g.TimeModified, g.Etag = stateUpdating, b.transitionAt(), b.nowUTC(), newEtag() + + return b.newWR("UPDATE_LOG_GROUP", "loggroup", g.CompartmentID, g.ID, "UPDATED", *g.TransitionAt), nil +} + +// DeleteLogGroup moves an empty log group to DELETING. +func (b *InMemoryBackend) DeleteLogGroup(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteLogGroup") + defer b.mu.Unlock() + + b.settleAll() + + g, aerr := b.mutableGroup(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(g.Etag, ifMatch); aerr != nil { + return "", aerr + } + + for _, l := range b.logs { + if l.GroupID == id { + return "", errConflict("Log group " + id + " still contains logs; delete them first") + } + } + + g.State, g.TransitionAt, g.TimeModified, g.Etag = stateDeleting, b.transitionAt(), b.nowUTC(), newEtag() + + return b.newWR("DELETE_LOG_GROUP", "loggroup", g.CompartmentID, g.ID, "DELETED", *g.TransitionAt), nil +} + +// ChangeLogGroupCompartment moves a log group to another compartment. +func (b *InMemoryBackend) ChangeLogGroupCompartment(id, ifMatch, compartment string) (string, error) { + if compartment == "" { + return "", errMissing("compartmentId is required") + } + + b.mu.Lock("ChangeLogGroupCompartment") + defer b.mu.Unlock() + + b.settleAll() + + g, aerr := b.mutableGroup(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(g.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if b.groupNameTaken(compartment, g.Name, g.ID) { + return "", errConflict("A log group named " + g.Name + " already exists in the target compartment") + } + + g.CompartmentID, g.Etag, g.TimeModified = compartment, newEtag(), b.nowUTC() + + return b.newWR("MOVE_LOG_GROUP", "loggroup", compartment, g.ID, "UPDATED", b.nowUTC()), nil +} + +type changeCompartmentIn struct { + CompartmentID string `json:"compartmentId"` +} + +func (h *Handler) createLogGroup(q *req) error { + var in CreateLogGroupIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateLogGroup(in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getLogGroup(q *req) error { + v, etag, err := h.Backend.GetLogGroup(q.param("logGroupId")) + + return reply(q, v, etag, err) +} + +func (h *Handler) listLogGroups(q *req) error { + pg, err := h.Backend.ListLogGroups(q.query("compartmentId"), q.query("displayName"), q.listOpts()) + + return listReply(q, pg, err) +} + +func (h *Handler) updateLogGroup(q *req) error { + var in UpdateLogGroupIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateLogGroup(q.param("logGroupId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteLogGroup(q *req) error { + wr, err := h.Backend.DeleteLogGroup(q.param("logGroupId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeLogGroupCompartment(q *req) error { + var in changeCompartmentIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.ChangeLogGroupCompartment(q.param("logGroupId"), q.ifMatch(), in.CompartmentID) + + return acceptedWR(q, wr, err) +} diff --git a/services/ocilogging/loggroups_test.go b/services/ocilogging/loggroups_test.go new file mode 100644 index 0000000000..64b5edb6ef --- /dev/null +++ b/services/ocilogging/loggroups_test.go @@ -0,0 +1,256 @@ +package ocilogging_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/logging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogGroupLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + resp, err := e.mgmt.CreateLogGroup(t.Context(), logging.CreateLogGroupRequest{ + CompartmentId: new(testCompartment), DisplayName: new("lg"), Description: new("first"), + FreeformTags: map[string]string{"k": "v"}, + }) + require.NoError(t, err) + + list, err := e.mgmt.ListLogGroups(t.Context(), logging.ListLogGroupsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, logging.LogGroupLifecycleStateCreating, list.Items[0].LifecycleState) + + wr, err := e.mgmt.GetWorkRequest(t.Context(), logging.GetWorkRequestRequest{WorkRequestId: resp.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, logging.OperationStatusInProgress, wr.Status) + assert.Equal(t, logging.OperationTypesCreateLogGroup, wr.OperationType) + + e.settle() + + got, err := e.mgmt.GetLogGroup(t.Context(), logging.GetLogGroupRequest{LogGroupId: list.Items[0].Id}) + require.NoError(t, err) + assert.Equal(t, logging.LogGroupLifecycleStateActive, got.LifecycleState) + assert.Equal(t, "first", *got.Description) + assert.Equal(t, map[string]string{"k": "v"}, got.FreeformTags) + assert.NotEmpty(t, *got.Etag) + + wr, err = e.mgmt.GetWorkRequest(t.Context(), logging.GetWorkRequestRequest{WorkRequestId: resp.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, logging.OperationStatusSucceeded, wr.Status) + + _, err = e.mgmt.UpdateLogGroup(t.Context(), logging.UpdateLogGroupRequest{ + LogGroupId: got.Id, IfMatch: new("stale"), + Description: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.mgmt.UpdateLogGroup(t.Context(), logging.UpdateLogGroupRequest{ + LogGroupId: got.Id, IfMatch: got.Etag, + DisplayName: new("renamed"), Description: new("second"), + }) + require.NoError(t, err) + e.settle() + + got, err = e.mgmt.GetLogGroup(t.Context(), logging.GetLogGroupRequest{LogGroupId: got.Id}) + require.NoError(t, err) + assert.Equal(t, "renamed", *got.DisplayName) + assert.Equal(t, "second", *got.Description) + + move, err := e.mgmt.ChangeLogGroupCompartment(t.Context(), logging.ChangeLogGroupCompartmentRequest{ + LogGroupId: got.Id, + CompartmentId: new("ocid1.compartment.oc1..other"), + }) + require.NoError(t, err) + assert.NotEmpty(t, *move.OpcWorkRequestId) + + del, err := e.mgmt.DeleteLogGroup(t.Context(), logging.DeleteLogGroupRequest{LogGroupId: got.Id}) + require.NoError(t, err) + + _, err = e.mgmt.UpdateLogGroup(t.Context(), logging.UpdateLogGroupRequest{LogGroupId: got.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + e.settle() + + _, err = e.mgmt.GetLogGroup(t.Context(), logging.GetLogGroupRequest{LogGroupId: got.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + wrs, err := e.mgmt.ListWorkRequests(t.Context(), logging.ListWorkRequestsRequest{ + CompartmentId: new("ocid1.compartment.oc1..other"), + }) + require.NoError(t, err) + require.NotEmpty(t, wrs.Items) + + _, err = e.mgmt.DeleteWorkRequest( + t.Context(), + logging.DeleteWorkRequestRequest{WorkRequestId: del.OpcWorkRequestId}, + ) + require.NoError(t, err) + + _, err = e.mgmt.GetWorkRequest(t.Context(), logging.GetWorkRequestRequest{WorkRequestId: del.OpcWorkRequestId}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestLogGroupValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(*logging.CreateLogGroupDetails) + name string + code string + status int + }{ + { + func(d *logging.CreateLogGroupDetails) { d.CompartmentId = new("") }, + "no compartment", + "MissingParameter", + 400, + }, + {func(d *logging.CreateLogGroupDetails) { d.DisplayName = new(" ") }, "blank name", "MissingParameter", 400}, + {func(d *logging.CreateLogGroupDetails) { + n := make([]byte, 300) + for i := range n { + n[i] = 'a' + } + d.DisplayName = new(string(n)) + }, "long name", "InvalidParameter", 400}, + {func(d *logging.CreateLogGroupDetails) { + n := make([]byte, 401) + for i := range n { + n[i] = 'a' + } + d.Description = new(string(n)) + }, "long description", "InvalidParameter", 400}, + {func(d *logging.CreateLogGroupDetails) { d.DisplayName = new("dup") }, "duplicate name", "Conflict", 409}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkGroup(t, "dup") + + d := logging.CreateLogGroupDetails{CompartmentId: new(testCompartment), DisplayName: new("ok")} + tt.mod(&d) + + _, err := e.mgmt.CreateLogGroup(t.Context(), logging.CreateLogGroupRequest{CreateLogGroupDetails: d}) + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestLogGroupListFilters(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + for _, n := range []string{"b", "a", "c"} { + e.mkGroup(t, n) + } + + tests := []struct { + name string + req logging.ListLogGroupsRequest + want []string + pages int + }{ + { + "by name asc", + logging.ListLogGroupsRequest{SortBy: logging.ListLogGroupsSortByDisplayname}, + []string{"a", "b", "c"}, + 1, + }, + {"by name desc", logging.ListLogGroupsRequest{ + SortBy: logging.ListLogGroupsSortByDisplayname, SortOrder: logging.ListLogGroupsSortOrderDesc, + }, []string{"c", "b", "a"}, 1}, + {"filter", logging.ListLogGroupsRequest{DisplayName: new("b")}, []string{"b"}, 1}, + {"page of two", logging.ListLogGroupsRequest{ + SortBy: logging.ListLogGroupsSortByDisplayname, Limit: new(2), + }, []string{"a", "b", "c"}, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got []string + + tt.req.CompartmentId = new(testCompartment) + + pages := 0 + + for { + resp, err := e.mgmt.ListLogGroups(t.Context(), tt.req) + require.NoError(t, err) + + pages++ + + for _, g := range resp.Items { + got = append(got, *g.DisplayName) + } + + if resp.OpcNextPage == nil { + break + } + + tt.req.Page = resp.OpcNextPage + } + + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.pages, pages) + }) + } + + status, body := rawDo(t, http.MethodGet, e.base+"/20200531/logGroups", "") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") +} + +func TestAuthEnforcement(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enforce bool + register bool + wantErr bool + }{ + {"registered key", true, true, false}, + {"unknown key rejected", true, false, true}, + {"open by default", false, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: tt.enforce, register: tt.register}) + + _, err := e.mgmt.ListLogGroups( + t.Context(), + logging.ListLogGroupsRequest{CompartmentId: new(testCompartment)}, + ) + if tt.wantErr { + assert.Equal(t, http.StatusUnauthorized, svcErr(t, err).GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + }) + } + + e := newEnv(t, envOpts{enforce: true, register: true}) + status, body := rawDo(t, http.MethodGet, e.base+"/20200531/logGroups?compartmentId=x", "") + assert.Equal(t, http.StatusUnauthorized, status) + assert.Contains(t, body, "NotAuthenticated") + + status, _ = rawDo(t, http.MethodGet, e.base+"/20200531/nope", "") + assert.Equal(t, http.StatusNotFound, status) +} diff --git a/services/ocilogging/logs.go b/services/ocilogging/logs.go new file mode 100644 index 0000000000..7a208bd5ae --- /dev/null +++ b/services/ocilogging/logs.go @@ -0,0 +1,528 @@ +package ocilogging + +import ( + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + defaultRetentionDays = 30 + sourceTypeOCIService = "OCISERVICE" +) + +type SourceWire struct { + Parameters map[string]string `json:"parameters,omitempty"` + SourceType string `json:"sourceType"` + Service string `json:"service"` + Resource string `json:"resource"` + Category string `json:"category"` +} + +type archivingWire struct { + IsEnabled *bool `json:"isEnabled,omitempty"` +} + +// ConfigWire is the log configuration as sent and returned over the wire. +type ConfigWire struct { + Source *SourceWire `json:"source,omitempty"` + Archiving *archivingWire `json:"archiving,omitempty"` + CompartmentID string `json:"compartmentId,omitempty"` +} + +// CreateLogIn is the CreateLog request body. +type CreateLogIn struct { + Tags + Enabled *bool `json:"isEnabled"` + Retention *int `json:"retentionDuration"` + Configuration *ConfigWire `json:"configuration"` + Name string `json:"displayName"` + LogType string `json:"logType"` +} + +// UpdateLogIn is the UpdateLog request body. +type UpdateLogIn struct { + Tags + Name *string `json:"displayName"` + Enabled *bool `json:"isEnabled"` + Retention *int `json:"retentionDuration"` + Configuration *ConfigWire `json:"configuration"` +} + +// ChangeLogGroupIn is the ChangeLogLogGroup request body. +type ChangeLogGroupIn struct { + TargetLogGroupID string `json:"targetLogGroupId"` +} + +// LogWire is the GetLog response and the ListLogs item. +type LogWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeModified time.Time `json:"timeLastModified"` + Configuration *ConfigWire `json:"configuration,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + LogGroupID string `json:"logGroupId"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + LogType string `json:"logType"` + LifecycleState string `json:"lifecycleState"` + Retention int `json:"retentionDuration"` + Enabled bool `json:"isEnabled"` +} + +func configView(c *LogConfig) *ConfigWire { + if c == nil { + return nil + } + + w := &ConfigWire{CompartmentID: c.CompartmentID} + if c.Archiving != nil { + w.Archiving = &archivingWire{IsEnabled: c.Archiving} + } + + if c.Source != nil { + w.Source = &SourceWire{ + SourceType: sourceTypeOCIService, Service: c.Source.Service, Resource: c.Source.Resource, + Category: c.Source.Category, Parameters: c.Source.Parameters, + } + } + + return w +} + +func (b *InMemoryBackend) logView(l *Log) LogWire { + comp := "" + if g, ok := b.groups[l.GroupID]; ok { + comp = g.CompartmentID + } + + return LogWire{ + ID: l.ID, LogGroupID: l.GroupID, CompartmentID: comp, Name: l.Name, LogType: l.LogType, + LifecycleState: l.State, Retention: l.Retention, Enabled: l.Enabled, Configuration: configView(l.Config), + TimeCreated: l.TimeCreated, TimeModified: l.TimeModified, FreeformTags: l.Tags.FreeformTags, + DefinedTags: l.Tags.DefinedTags, + } +} + +func (b *InMemoryBackend) liveLog(groupID, id string) (*Log, *APIError) { + l, ok := b.logs[id] + if !ok || (groupID != "" && l.GroupID != groupID) { + return nil, errNotFound(id) + } + + return l, nil +} + +func (b *InMemoryBackend) mutableLog(groupID, id string) (*Log, *APIError) { + l, aerr := b.liveLog(groupID, id) + if aerr != nil { + return nil, aerr + } + + if l.State == stateCreating || l.State == stateUpdating || l.State == stateDeleting { + return nil, errState("Log " + id + " is in state " + l.State + "; the operation requires ACTIVE or INACTIVE") + } + + return l, nil +} + +func (b *InMemoryBackend) logNameTaken(groupID, name, except string) bool { + for _, l := range b.logs { + if l.ID != except && l.GroupID == groupID && l.Name == name { + return true + } + } + + return false +} + +func validRetention(v *int) *APIError { + if v == nil { + return nil + } + + if *v < retentionStep || *v > maxRetentionDays || *v%retentionStep != 0 { + return errParam("retentionDuration must be 30, 60, 90, 120, 150 or 180 days") + } + + return nil +} + +// buildConfig validates a configuration for the given log type and converts it to its stored form. +func buildConfig(logType string, w *ConfigWire) (*LogConfig, *APIError) { + if w == nil { + if logType == logTypeService { + return nil, errMissing("configuration is required for a SERVICE log") + } + + return nil, nil + } + + c := &LogConfig{CompartmentID: w.CompartmentID} + if w.Archiving != nil { + c.Archiving = w.Archiving.IsEnabled + } + + if logType == logTypeCustom { + if w.Source != nil { + return nil, errParam("configuration.source is only valid for a SERVICE log") + } + + return c, nil + } + + s := w.Source + if s == nil || s.Service == "" || s.Resource == "" || s.Category == "" { + return nil, errMissing("configuration.source with service, resource and category is required for a SERVICE log") + } + + if s.SourceType != "" && !strs.Equal(s.SourceType, sourceTypeOCIService) { + return nil, errParam("configuration.source.sourceType must be OCISERVICE") + } + + if aerr := findService(s.Service, s.Category); aerr != nil { + return nil, aerr + } + + c.Source = &ServiceSource{Service: s.Service, Resource: s.Resource, Category: s.Category, Parameters: s.Parameters} + + return c, nil +} + +func (b *InMemoryBackend) serviceLogTaken(src *ServiceSource, except string) bool { + for _, l := range b.logs { + if l.ID != except && l.Config != nil && l.Config.Source != nil && sameSource(l.Config.Source, src) && + l.State != stateDeleting { + return true + } + } + + return false +} + +func normalizeType(t string) (string, *APIError) { + switch { + case t == "": + return "", errMissing("logType is required") + case strs.Equal(t, logTypeCustom): + return logTypeCustom, nil + case strs.Equal(t, logTypeService): + return logTypeService, nil + } + + return "", errParam("logType must be CUSTOM or SERVICE") +} + +// CreateLog creates a log inside a log group. +func (b *InMemoryBackend) CreateLog(groupID string, in CreateLogIn) (string, error) { + logType, aerr := normalizeType(in.LogType) + if aerr != nil { + return "", aerr + } + + if aerr = firstErr(validateName(in.Name), validRetention(in.Retention)); aerr != nil { + return "", aerr + } + + cfg, aerr := buildConfig(logType, in.Configuration) + if aerr != nil { + return "", aerr + } + + b.mu.Lock("CreateLog") + defer b.mu.Unlock() + + b.settleAll() + + g, aerr := b.mutableGroup(groupID) + if aerr != nil { + return "", aerr + } + + if b.logNameTaken(groupID, in.Name, "") { + return "", errConflict("A log named " + in.Name + " already exists in the log group") + } + + if cfg != nil && cfg.Source != nil && b.serviceLogTaken(cfg.Source, "") { + return "", errConflict("A service log for this service, resource and category already exists") + } + + now := b.nowUTC() + l := &Log{ + ID: newOCID("log"), GroupID: g.ID, Name: in.Name, LogType: logType, State: stateCreating, Config: cfg, + Enabled: in.Enabled == nil || *in.Enabled, Retention: defaultRetentionDays, TimeCreated: now, + TimeModified: now, TransitionAt: b.transitionAt(), Tags: in.Tags, Etag: newEtag(), Seq: b.nextSeq(), + } + if in.Retention != nil { + l.Retention = *in.Retention + } + + b.logs[l.ID] = l + + return b.newWR("CREATE_LOG", "log", g.CompartmentID, l.ID, "CREATED", *l.TransitionAt), nil +} + +// GetLog returns one log and its etag. +func (b *InMemoryBackend) GetLog(groupID, id string) (LogWire, string, error) { + b.mu.Lock("GetLog") + defer b.mu.Unlock() + + b.settleAll() + + l, aerr := b.liveLog(groupID, id) + if aerr != nil { + return LogWire{}, "", aerr + } + + return b.logView(l), l.Etag, nil +} + +// LogFilter narrows ListLogs. +type LogFilter struct { + LogType, Name, State, Service, Resource string +} + +func (f LogFilter) matches(l *Log) bool { + if (f.Name != "" && l.Name != f.Name) || (f.LogType != "" && !strs.Equal(l.LogType, f.LogType)) || + (f.State != "" && !strs.Equal(l.State, f.State)) { + return false + } + + if f.Service == "" && f.Resource == "" { + return true + } + + if l.Config == nil || l.Config.Source == nil { + return false + } + + src := l.Config.Source + + return (f.Service == "" || src.Service == f.Service) && (f.Resource == "" || src.Resource == f.Resource) +} + +// ListLogs lists the logs of a log group. +func (b *InMemoryBackend) ListLogs(groupID string, f LogFilter, o ListOpts) (page.Page[LogWire], error) { + b.mu.Lock("ListLogs") + defer b.mu.Unlock() + + b.settleAll() + + if _, ok := b.groups[groupID]; !ok { + return page.Page[LogWire]{}, errNotFound(groupID) + } + + var all []*Log + + for _, l := range b.logs { + if l.GroupID == groupID && f.matches(l) { + all = append(all, l) + } + } + + pg := paginate(all, o, func(l *Log) string { return l.Name }, + func(l *Log) (time.Time, int64) { return l.TimeCreated, l.Seq }) + + out := make([]LogWire, 0, len(pg.Data)) + for _, l := range pg.Data { + out = append(out, b.logView(l)) + } + + return page.Page[LogWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateLog changes the mutable fields of a log; it passes through UPDATING. +func (b *InMemoryBackend) UpdateLog(groupID, id, ifMatch string, in UpdateLogIn) (string, error) { + if in.Name != nil { + if aerr := validateName(*in.Name); aerr != nil { + return "", aerr + } + } + + if aerr := validRetention(in.Retention); aerr != nil { + return "", aerr + } + + b.mu.Lock("UpdateLog") + defer b.mu.Unlock() + + b.settleAll() + + l, aerr := b.mutableLog(groupID, id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(l.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if in.Name != nil { + if b.logNameTaken(l.GroupID, *in.Name, l.ID) { + return "", errConflict("A log named " + *in.Name + " already exists in the log group") + } + + l.Name = *in.Name + } + + if aerr = applyConfigUpdate(l, in.Configuration); aerr != nil { + return "", aerr + } + + if in.Enabled != nil { + l.Enabled = *in.Enabled + } + + if in.Retention != nil { + l.Retention = *in.Retention + } + + l.Tags.merge(in.Tags) + l.State, l.TransitionAt, l.TimeModified, l.Etag = stateUpdating, b.transitionAt(), b.nowUTC(), newEtag() + + return b.newWR("UPDATE_LOG", "log", b.groups[l.GroupID].CompartmentID, l.ID, "UPDATED", *l.TransitionAt), nil +} + +// applyConfigUpdate applies archiving and source parameters; a service log keeps its service, resource and category. +func applyConfigUpdate(l *Log, w *ConfigWire) *APIError { + if w == nil { + return nil + } + + if l.LogType == logTypeCustom && w.Source != nil { + return errParam("configuration.source is only valid for a SERVICE log") + } + + if l.Config == nil { + l.Config = &LogConfig{} + } + + if w.Archiving != nil { + l.Config.Archiving = w.Archiving.IsEnabled + } + + if w.Source != nil && l.Config.Source != nil { + l.Config.Source.Parameters = w.Source.Parameters + } + + return nil +} + +// DeleteLog moves a log to DELETING; its entries are dropped when the transition completes. +func (b *InMemoryBackend) DeleteLog(groupID, id, ifMatch string) (string, error) { + b.mu.Lock("DeleteLog") + defer b.mu.Unlock() + + b.settleAll() + + l, aerr := b.mutableLog(groupID, id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(l.Etag, ifMatch); aerr != nil { + return "", aerr + } + + l.State, l.TransitionAt, l.TimeModified, l.Etag = stateDeleting, b.transitionAt(), b.nowUTC(), newEtag() + + return b.newWR("DELETE_LOG", "log", b.groups[l.GroupID].CompartmentID, l.ID, "DELETED", *l.TransitionAt), nil +} + +// ChangeLogLogGroup moves a log to another log group of the same compartment. +func (b *InMemoryBackend) ChangeLogLogGroup(groupID, id, ifMatch, target string) (string, error) { + if target == "" { + return "", errMissing("targetLogGroupId is required") + } + + b.mu.Lock("ChangeLogLogGroup") + defer b.mu.Unlock() + + b.settleAll() + + l, aerr := b.mutableLog(groupID, id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(l.Etag, ifMatch); aerr != nil { + return "", aerr + } + + tg, aerr := b.mutableGroup(target) + if aerr != nil { + return "", aerr + } + + if tg.CompartmentID != b.groups[l.GroupID].CompartmentID { + return "", errParam("the target log group must be in the same compartment as the log") + } + + if b.logNameTaken(target, l.Name, l.ID) { + return "", errConflict("A log named " + l.Name + " already exists in the target log group") + } + + l.GroupID, l.Etag, l.TimeModified = target, newEtag(), b.nowUTC() + + return b.newWR("MOVE_LOG", "log", tg.CompartmentID, l.ID, "UPDATED", b.nowUTC()), nil +} + +func (h *Handler) createLog(q *req) error { + var in CreateLogIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateLog(q.param("logGroupId"), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getLog(q *req) error { + v, etag, err := h.Backend.GetLog(q.param("logGroupId"), q.param("logId")) + + return reply(q, v, etag, err) +} + +func (h *Handler) listLogs(q *req) error { + pg, err := h.Backend.ListLogs(q.param("logGroupId"), LogFilter{ + LogType: q.query("logType"), Name: q.query("displayName"), State: q.query("lifecycleState"), + Service: q.query("sourceService"), Resource: q.query("sourceResource"), + }, q.listOpts()) + + return listReply(q, pg, err) +} + +func (h *Handler) updateLog(q *req) error { + var in UpdateLogIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateLog(q.param("logGroupId"), q.param("logId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteLog(q *req) error { + wr, err := h.Backend.DeleteLog(q.param("logGroupId"), q.param("logId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeLogLogGroup(q *req) error { + var in ChangeLogGroupIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.ChangeLogLogGroup(q.param("logGroupId"), q.param("logId"), q.ifMatch(), in.TargetLogGroupID) + + return acceptedWR(q, wr, err) +} + +func sameSource(a, c *ServiceSource) bool { + return a.Service == c.Service && a.Resource == c.Resource && a.Category == c.Category +} diff --git a/services/ocilogging/logs_test.go b/services/ocilogging/logs_test.go new file mode 100644 index 0000000000..2dc93c8652 --- /dev/null +++ b/services/ocilogging/logs_test.go @@ -0,0 +1,240 @@ +package ocilogging_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/logging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func svcConfig(service, resource, category string) *logging.Configuration { + return &logging.Configuration{Source: logging.OciService{ + Service: new(service), Resource: new(resource), Category: new(category), + }} +} + +func TestCreateLogValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(*logging.CreateLogDetails) + name string + code string + status int + }{ + {nil, "custom ok", "", 0}, + {func(d *logging.CreateLogDetails) { d.RetentionDuration = new(90) }, "retention 90", "", 0}, + {func(d *logging.CreateLogDetails) { d.RetentionDuration = new(45) }, "retention 45", "InvalidParameter", 400}, + { + func(d *logging.CreateLogDetails) { d.RetentionDuration = new(210) }, + "retention 210", + "InvalidParameter", + 400, + }, + {func(d *logging.CreateLogDetails) { d.DisplayName = new("") }, "blank name", "MissingParameter", 400}, + {func(d *logging.CreateLogDetails) { + d.Configuration = svcConfig("objectstorage", "b", "write") + }, "custom with service source", "InvalidParameter", 400}, + {func(d *logging.CreateLogDetails) { + d.LogType = logging.CreateLogDetailsLogTypeService + d.Configuration = svcConfig("objectstorage", "bucket1", "write") + }, "service ok", "", 0}, + {func(d *logging.CreateLogDetails) { + d.LogType = logging.CreateLogDetailsLogTypeService + }, "service without configuration", "MissingParameter", 400}, + {func(d *logging.CreateLogDetails) { + d.LogType = logging.CreateLogDetailsLogTypeService + d.Configuration = svcConfig("nosuch", "r", "c") + }, "service unknown service", "InvalidParameter", 400}, + {func(d *logging.CreateLogDetails) { + d.LogType = logging.CreateLogDetailsLogTypeService + d.Configuration = svcConfig("objectstorage", "r", "nosuch") + }, "service unknown category", "InvalidParameter", 400}, + {func(d *logging.CreateLogDetails) { d.DisplayName = new("existing") }, "duplicate name", "Conflict", 409}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "g") + e.mkLog(t, g, "existing", nil) + + d := logging.CreateLogDetails{DisplayName: new("l"), LogType: logging.CreateLogDetailsLogTypeCustom} + if tt.mod != nil { + tt.mod(&d) + } + + resp, err := e.mgmt.CreateLog(t.Context(), logging.CreateLogRequest{LogGroupId: g.Id, CreateLogDetails: d}) + if tt.status == 0 { + require.NoError(t, err) + assert.NotEmpty(t, *resp.OpcWorkRequestId) + + return + } + + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestLogLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "g") + l := e.mkLog(t, g, "app", func(d *logging.CreateLogDetails) { d.RetentionDuration = new(60) }) + + assert.Equal(t, logging.LogLifecycleStateActive, l.LifecycleState) + assert.Equal(t, logging.LogLogTypeCustom, l.LogType) + assert.Equal(t, 60, *l.RetentionDuration) + assert.True(t, *l.IsEnabled) + assert.Equal(t, testCompartment, *l.CompartmentId) + + _, err := e.mgmt.UpdateLog(t.Context(), logging.UpdateLogRequest{ + LogGroupId: g.Id, LogId: l.Id, IfMatch: new("stale"), + IsEnabled: new(false), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.mgmt.UpdateLog(t.Context(), logging.UpdateLogRequest{ + LogGroupId: g.Id, LogId: l.Id, + IsEnabled: new(false), DisplayName: new("app2"), RetentionDuration: new(120), + FreeformTags: map[string]string{"a": "b"}, + }) + require.NoError(t, err) + + mid, err := e.mgmt.GetLog(t.Context(), logging.GetLogRequest{LogGroupId: g.Id, LogId: l.Id}) + require.NoError(t, err) + assert.Equal(t, logging.LogLifecycleStateUpdating, mid.LifecycleState) + + e.settle() + + got, err := e.mgmt.GetLog(t.Context(), logging.GetLogRequest{LogGroupId: g.Id, LogId: l.Id}) + require.NoError(t, err) + assert.Equal(t, logging.LogLifecycleStateInactive, got.LifecycleState) + assert.Equal(t, "app2", *got.DisplayName) + assert.Equal(t, 120, *got.RetentionDuration) + assert.False(t, *got.IsEnabled) + + _, err = e.mgmt.GetLog( + t.Context(), + logging.GetLogRequest{LogGroupId: new("ocid1.loggroup.oc1..wrong"), LogId: l.Id}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.mgmt.DeleteLogGroup(t.Context(), logging.DeleteLogGroupRequest{LogGroupId: g.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.mgmt.DeleteLog(t.Context(), logging.DeleteLogRequest{LogGroupId: g.Id, LogId: l.Id}) + require.NoError(t, err) + e.settle() + + _, err = e.mgmt.GetLog(t.Context(), logging.GetLogRequest{LogGroupId: g.Id, LogId: l.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.mgmt.DeleteLogGroup(t.Context(), logging.DeleteLogGroupRequest{LogGroupId: g.Id}) + require.NoError(t, err) +} + +func TestServiceLogConfiguration(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "g") + l := e.mkLog(t, g, "bucket-write", func(d *logging.CreateLogDetails) { + d.LogType = logging.CreateLogDetailsLogTypeService + d.Configuration = svcConfig("objectstorage", "bucket1", "write") + }) + + svc, ok := l.Configuration.Source.(logging.OciService) + require.True(t, ok) + assert.Equal(t, "objectstorage", *svc.Service) + assert.Equal(t, "bucket1", *svc.Resource) + assert.Equal(t, "write", *svc.Category) + + dup, err := e.mgmt.CreateLog(t.Context(), logging.CreateLogRequest{LogGroupId: g.Id, + DisplayName: new("other"), LogType: logging.CreateLogDetailsLogTypeService, + Configuration: svcConfig("objectstorage", "bucket1", "write")}) + _ = dup + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.mgmt.UpdateLog(t.Context(), logging.UpdateLogRequest{ + LogGroupId: g.Id, LogId: l.Id, + Configuration: &logging.UpdateConfigurationDetails{ + Source: &logging.SourceUpdateDetails{Parameters: map[string]string{"p": "1"}}, + }, + }) + require.NoError(t, err) + e.settle() + + got, err := e.mgmt.GetLog(t.Context(), logging.GetLogRequest{LogGroupId: g.Id, LogId: l.Id}) + require.NoError(t, err) + + svc, ok = got.Configuration.Source.(logging.OciService) + require.True(t, ok) + assert.Equal(t, map[string]string{"p": "1"}, svc.Parameters) + + list, err := e.mgmt.ListLogs(t.Context(), logging.ListLogsRequest{ + LogGroupId: g.Id, SourceService: new("objectstorage"), LogType: logging.ListLogsLogTypeService, + }) + require.NoError(t, err) + assert.Len(t, list.Items, 1) + + list, err = e.mgmt.ListLogs(t.Context(), logging.ListLogsRequest{LogGroupId: g.Id, SourceService: new("functions")}) + require.NoError(t, err) + assert.Empty(t, list.Items) + + svcs, err := e.mgmt.ListServices(t.Context(), logging.ListServicesRequest{}) + require.NoError(t, err) + + names := make([]string, 0, len(svcs.Items)) + for _, s := range svcs.Items { + names = append(names, *s.Name) + } + + assert.Contains(t, names, "objectstorage") + assert.Contains(t, names, "functions") +} + +func TestChangeLogLogGroup(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + src, dst := e.mkGroup(t, "src"), e.mkGroup(t, "dst") + l := e.mkLog(t, src, "app", nil) + e.mkLog(t, dst, "clash", nil) + + tests := []struct { + target *string + name string + status int + }{ + {new("ocid1.loggroup.oc1..ghost"), "unknown target", http.StatusNotFound}, + {dst.Id, "moves", 0}, + } + + for _, tt := range tests { + resp, err := e.mgmt.ChangeLogLogGroup(t.Context(), logging.ChangeLogLogGroupRequest{ + LogGroupId: src.Id, LogId: l.Id, + TargetLogGroupId: tt.target, + }) + if tt.status != 0 { + assert.Equal(t, tt.status, svcErr(t, err).GetHTTPStatusCode(), tt.name) + + continue + } + + require.NoError(t, err, tt.name) + assert.NotEmpty(t, *resp.OpcWorkRequestId) + } + + got, err := e.mgmt.GetLog(t.Context(), logging.GetLogRequest{LogGroupId: dst.Id, LogId: l.Id}) + require.NoError(t, err) + assert.Equal(t, *dst.Id, *got.LogGroupId) +} diff --git a/services/ocilogging/models.go b/services/ocilogging/models.go new file mode 100644 index 0000000000..4021b7813e --- /dev/null +++ b/services/ocilogging/models.go @@ -0,0 +1,131 @@ +package ocilogging + +import ( + "encoding/json" + "time" +) + +// Lifecycle states shared by log groups, logs and agent configurations. +const ( + stateActive = "ACTIVE" + stateCreating = "CREATING" + stateUpdating = "UPDATING" + stateDeleting = "DELETING" + stateDeleted = "DELETED" + stateInactive = "INACTIVE" +) + +// Log types. +const ( + logTypeCustom = "CUSTOM" + logTypeService = "SERVICE" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// LogGroup is a container of logs. +type LogGroup struct { + TimeCreated time.Time `json:"timeCreated"` + TimeModified time.Time `json:"timeModified"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Tags Tags `json:"tags"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + State string `json:"state"` + Etag string `json:"etag"` + Seq int64 `json:"seq"` +} + +// ServiceSource is the OCISERVICE source of a SERVICE log. +type ServiceSource struct { + Parameters map[string]string `json:"parameters,omitempty"` + Service string `json:"service"` + Resource string `json:"resource"` + Category string `json:"category"` +} + +// LogConfig is the stored log configuration. +type LogConfig struct { + Source *ServiceSource `json:"source,omitempty"` + Archiving *bool `json:"archiving,omitempty"` + CompartmentID string `json:"compartmentId,omitempty"` +} + +// Log is a custom or service log inside a log group. +type Log struct { + TimeCreated time.Time `json:"timeCreated"` + TimeModified time.Time `json:"timeModified"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Config *LogConfig `json:"configuration,omitempty"` + Tags Tags `json:"tags"` + ID string `json:"id"` + GroupID string `json:"logGroupId"` + Name string `json:"displayName"` + LogType string `json:"logType"` + State string `json:"state"` + Etag string `json:"etag"` + Retention int `json:"retentionDuration"` + Seq int64 `json:"seq"` + Enabled bool `json:"isEnabled"` +} + +// AgentConfig is a stored unified agent configuration. +type AgentConfig struct { + TimeCreated time.Time `json:"timeCreated"` + TimeModified time.Time `json:"timeModified"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + ServiceConfig json.RawMessage `json:"serviceConfiguration"` + Tags Tags `json:"tags"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + State string `json:"state"` + ConfigType string `json:"configurationType"` + Etag string `json:"etag"` + Groups []string `json:"groupList,omitempty"` + Seq int64 `json:"seq"` + Enabled bool `json:"isEnabled"` +} + +// Entry is one ingested log record. +type Entry struct { + Time time.Time `json:"time"` + Ingested time.Time `json:"ingested"` + ID string `json:"id"` + Data string `json:"data"` + Source string `json:"source"` + Type string `json:"type"` + Subject string `json:"subject,omitempty"` + Seq int64 `json:"seq"` +} + +// WorkRequest records an asynchronous admin operation. +type WorkRequest struct { + Accepted time.Time `json:"accepted"` + FinishAt time.Time `json:"finishAt"` + ID string `json:"id"` + Operation string `json:"operation"` + CompartmentID string `json:"compartmentId"` + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + EntityURI string `json:"entityUri"` + Action string `json:"action"` + Seq int64 `json:"seq"` +} diff --git a/services/ocilogging/ops.go b/services/ocilogging/ops.go new file mode 100644 index 0000000000..5f75e43c54 --- /dev/null +++ b/services/ocilogging/ops.go @@ -0,0 +1,70 @@ +package ocilogging + +import ( + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateLogGroup: (*Handler).createLogGroup, + opGetLogGroup: (*Handler).getLogGroup, + opListLogGroups: (*Handler).listLogGroups, + opUpdateLogGroup: (*Handler).updateLogGroup, + opDeleteLogGroup: (*Handler).deleteLogGroup, + opChangeGroupComp: (*Handler).changeLogGroupCompartment, + opCreateLog: (*Handler).createLog, + opGetLog: (*Handler).getLog, + opListLogs: (*Handler).listLogs, + opUpdateLog: (*Handler).updateLog, + opDeleteLog: (*Handler).deleteLog, + opChangeLogGroup: (*Handler).changeLogLogGroup, + opCreateAgentConfig: (*Handler).createAgentConfig, + opGetAgentConfig: (*Handler).getAgentConfig, + opListAgentConfigs: (*Handler).listAgentConfigs, + opUpdateAgentConfig: (*Handler).updateAgentConfig, + opDeleteAgentConfig: (*Handler).deleteAgentConfig, + opChangeAgentComp: (*Handler).changeAgentConfigCompartment, + opListServices: (*Handler).listServices, + opGetWorkRequest: (*Handler).getWorkRequest, + opListWorkRequests: (*Handler).listWorkRequests, + opDeleteWorkRequest: (*Handler).deleteWorkRequest, + opListWorkReqErrors: (*Handler).listWorkRequestErrors, + opListWorkReqLogs: (*Handler).listWorkRequestLogs, + opPutLogs: (*Handler).putLogs, + opSearchLogs: (*Handler).searchLogs, + } +} + +func reply[T any](q *req, w T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func acceptedWR(q *req, wr string, err error) error { + if err != nil { + return err + } + + return q.accepted(wr) +} + +// listReply writes a page as a bare JSON array, the shape every Logging list operation declares. +func listReply[T any](q *req, pg page.Page[T], err error) error { + if err != nil { + return err + } + + q.nextPage(pg.Next) + + data := pg.Data + if data == nil { + data = []T{} + } + + return q.json(http.StatusOK, data, "") +} diff --git a/services/ocilogging/persistence.go b/services/ocilogging/persistence.go new file mode 100644 index 0000000000..946fb8905d --- /dev/null +++ b/services/ocilogging/persistence.go @@ -0,0 +1,117 @@ +package ocilogging + +import ( + "context" + "fmt" + "slices" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Groups map[string]*LogGroup `json:"logGroups"` + Logs map[string]*Log `json:"logs"` + Agents map[string]*AgentConfig `json:"agentConfigurations"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Entries map[string][]*Entry `json:"entries"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocilogging", backendSnapshot{ + Version: snapshotVersion, Groups: b.groups, Logs: b.logs, Agents: b.agents, WorkReqs: b.workReqs, + Entries: b.entries, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocilogging", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ocilogging: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.groups, b.logs, b.agents, b.workReqs = snap.Groups, snap.Logs, snap.Agents, snap.WorkReqs + b.entries, b.seq = snap.Entries, snap.Seq + b.Auth.Load(snap.Auth) + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{ + nullCheck("log group", &snap.Groups), nullCheck("log", &snap.Logs), + nullCheck("agent configuration", &snap.Agents), nullCheck("work request", &snap.WorkReqs), + } { + if err != nil { + return err + } + } + + if snap.Entries == nil { + snap.Entries = map[string][]*Entry{} + } + + for id, es := range snap.Entries { + if slices.Contains(es, nil) { + return fmt.Errorf("%w: entry of log %q", ErrSnapshotNull, id) + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ocilogging: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ocilogging/persistence_test.go b/services/ocilogging/persistence_test.go new file mode 100644 index 0000000000..7162513235 --- /dev/null +++ b/services/ocilogging/persistence_test.go @@ -0,0 +1,233 @@ +package ocilogging_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/logging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ocilogging" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "persist") + l := e.mkLog(t, g, "app", nil) + e.put(t, l, 0, `{"msg":"kept"}`) + + snap := e.backend.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + restored := oci.NewInMemoryBackend() + restored.SetClock(e.clock.Now) + require.NoError(t, restored.Restore(t.Context(), snap)) + + got, _, err := restored.GetLog(*g.Id, *l.Id) + require.NoError(t, err) + assert.Equal(t, "app", got.Name) + + now := e.clock.Now() + start, end := now.Add(-time.Hour), now.Add(time.Hour) + + out, _, err := restored.SearchLogs(oci.SearchIn{ + TimeStart: &start, TimeEnd: &end, SearchQuery: fmt.Sprintf(`search "%s/%s/%s"`, testCompartment, *g.Id, *l.Id), + }, "", 0) + require.NoError(t, err) + assert.Equal(t, 1, out.Summary.ResultCount) + + e.backend.Reset() + + _, _, err = e.backend.GetLog(*g.Id, *l.Id) + require.Error(t, err) +} + +func TestRestoreValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + }{ + {"null group", `{"version":1,"logGroups":{"a":null}}`, true}, + {"null log", `{"version":1,"logs":{"a":null}}`, true}, + {"null agent", `{"version":1,"agentConfigurations":{"a":null}}`, true}, + {"null workreq", `{"version":1,"workRequests":{"a":null}}`, true}, + {"null entry", `{"version":1,"entries":{"a":[null]}}`, true}, + {"incompatible version resets", `{"version":99}`, false}, + {"empty ok", `{"version":1}`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := oci.NewInMemoryBackend().Restore(t.Context(), []byte(tt.data)) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestEmitServiceLog(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "grp") + svc := e.mkLog(t, g, "fn", func(d *logging.CreateLogDetails) { + d.LogType = logging.CreateLogDetailsLogTypeService + d.Configuration = svcConfig("functions", "app-1", "invoke") + }) + off := e.mkLog(t, g, "fn-off", func(d *logging.CreateLogDetails) { + d.LogType = logging.CreateLogDetailsLogTypeService + d.IsEnabled = new(false) + d.Configuration = svcConfig("functions", "app-2", "invoke") + }) + + tests := []struct { + name string + resource string + want int + }{ + {"matching log", "app-1", 1}, + {"other resource", "app-3", 0}, + {"disabled log", "app-2", 0}, + } + + for _, tt := range tests { + got := e.backend.EmitServiceLog("functions", tt.resource, "invoke", oci.ServiceEvent{ + Type: "com.oraclecloud.functions.invoke", Data: map[string]any{"msg": "ran"}, + }) + assert.Equal(t, tt.want, got, tt.name) + } + + assert.Zero(t, e.backend.EmitServiceLog("functions", "app-1", "other", oci.ServiceEvent{})) + + resp := e.query( + t, + fmt.Sprintf(`search "%s/%s/%s" | where data.msg = 'ran'`, testCompartment, *g.Id, *svc.Id), + nil, + nil, + ) + assert.Equal(t, []string{"ran"}, labels(resp)) + + resp = e.query(t, fmt.Sprintf(`search "%s/%s/%s"`, testCompartment, *g.Id, *off.Id), nil, nil) + assert.Empty(t, resp.Results) +} + +func TestErrorEnvelope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + method string + path string + body string + code string + status int + }{ + { + "missing group", + http.MethodGet, + "/20200531/logGroups/ocid1.loggroup.oc1..nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + {"bad json", http.MethodPost, "/20200531/logGroups", "{", "CannotParseRequest", 400}, + {"no body", http.MethodPost, "/20200531/logGroups", "", "MissingParameter", 400}, + {"no compartment", http.MethodPost, "/20200531/logGroups", `{"displayName":"x"}`, "MissingParameter", 400}, + {"search no body", http.MethodPost, "/20190909/search", "", "MissingParameter", 400}, + {"search alias", http.MethodPost, "/20190909/actions/search", `{"searchQuery":"x"}`, "MissingParameter", 400}, + { + "missing work request", + http.MethodGet, + "/20200531/workRequests/ocid1.loggingworkrequest.oc1..nope/errors", + "", + "NotAuthorizedOrNotFound", + 404, + }, + {"unknown path", http.MethodGet, "/20200531/zzz", "", "NotFound", 404}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.status, status) + assert.Contains(t, body, `"code":"`+tt.code+`"`) + assert.Contains(t, body, `"message"`) + }) + } +} + +func TestWorkRequestSubresources(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + resp, err := e.mgmt.CreateLogGroup(t.Context(), logging.CreateLogGroupRequest{ + CompartmentId: new(testCompartment), DisplayName: new("g"), + }) + require.NoError(t, err) + + _, err = e.mgmt.DeleteWorkRequest( + t.Context(), + logging.DeleteWorkRequestRequest{WorkRequestId: resp.OpcWorkRequestId}, + ) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "in-progress work request") + + logs, err := e.mgmt.ListWorkRequestLogs( + t.Context(), + logging.ListWorkRequestLogsRequest{WorkRequestId: resp.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Len(t, logs.Items, 1) + + e.settle() + + logs, err = e.mgmt.ListWorkRequestLogs( + t.Context(), + logging.ListWorkRequestLogsRequest{WorkRequestId: resp.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Len(t, logs.Items, 2) + + errs, err := e.mgmt.ListWorkRequestErrors( + t.Context(), + logging.ListWorkRequestErrorsRequest{WorkRequestId: resp.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Empty(t, errs.Items) + + list, err := e.mgmt.ListWorkRequests(t.Context(), logging.ListWorkRequestsRequest{ + CompartmentId: new(testCompartment), Status: logging.ListWorkRequestsStatusSucceeded, + }) + require.NoError(t, err) + assert.Len(t, list.Items, 1) +} + +func TestAdminKeyEndpoint(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + pub, err := json.Marshal(e.kp.pubPEM) + require.NoError(t, err) + + status, _ := rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", + `{"tenancy":"`+testTenancy+`","user":"`+testUser+`","publicKey":`+string(pub)+`}`) + assert.Equal(t, http.StatusCreated, status) + + status, body := rawDo(t, http.MethodGet, e.base+"/_gopherstack/oci/keys", "") + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, testTenancy) +} diff --git a/services/ocilogging/provider.go b/services/ocilogging/provider.go new file mode 100644 index 0000000000..723454d830 --- /dev/null +++ b/services/ocilogging/provider.go @@ -0,0 +1,42 @@ +package ocilogging + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocilogging: nil app context") + +// ConfigProvider extracts OCI Logging configuration from the AppContext Config. +type ConfigProvider interface { + GetOCILoggingSettings() Settings +} + +// Provider implements service.Provider for OCI Logging. It runs on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCILogging" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCILoggingSettings() + } + + handler := NewHandler(NewInMemoryBackend()) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ocilogging/query.go b/services/ocilogging/query.go new file mode 100644 index 0000000000..2f086be207 --- /dev/null +++ b/services/ocilogging/query.go @@ -0,0 +1,795 @@ +package ocilogging + +import ( + "cmp" + "encoding/json" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// Pipeline operators and special fields of the Logging query language. +const ( + kindWhere = "where" + kindSort = "sort" + kindTop = "top" + kindLimit = "limit" + kindCount = "count" + fieldTime = "datetime" + fieldRegion = "regionId" + fieldRaw = "logContent" + twoRunes = 2 +) + +// Token kinds of the Logging query language. +const ( + tkEOF = iota + tkPath + tkString + tkNumber + tkPunct +) + +type token struct { + text string + path []string + kind int + quoted bool +} + +func isWordStart(r rune) bool { return unicode.IsLetter(r) || r == '_' || r == '$' } + +func isWordPart(r rune) bool { return isWordStart(r) || unicode.IsDigit(r) } + +// lex splits a query into tokens; a path is a dotted chain of words and double-quoted segments. +func lex(src string) ([]token, *APIError) { + rs, i, out := []rune(src), 0, []token{} + + for i < len(rs) { + if unicode.IsSpace(rs[i]) { + i++ + + continue + } + + t, n, aerr := lexToken(rs, i) + if aerr != nil { + return nil, aerr + } + + out, i = append(out, t), n + } + + return append(out, token{kind: tkEOF}), nil +} + +func numberEnd(rs []rune, i int) int { + for i < len(rs) && (unicode.IsDigit(rs[i]) || rs[i] == '.') { + i++ + } + + return i +} + +// lexToken reads the token that starts at rs[i] and returns the index after it. +func lexToken(rs []rune, i int) (token, int, *APIError) { + r := rs[i] + + switch { + case r == '\'': + s, n, ok := readQuoted(rs, i, '\'') + if !ok { + return token{}, 0, errParam("unterminated string literal in search query") + } + + return token{kind: tkString, text: s}, n, nil + case r == '"' || isWordStart(r): + p, n, aerr := readPath(rs, i) + if aerr != nil { + return token{}, 0, aerr + } + + return token{ + kind: tkPath, path: p, text: strings.Join(p, "."), quoted: r == '"' && rs[n-1] == '"' && len(p) == 1, + }, n, nil + case unicode.IsDigit(r) || (r == '-' && i+1 < len(rs) && unicode.IsDigit(rs[i+1])): + n := numberEnd(rs, i+1) + + return token{kind: tkNumber, text: string(rs[i:n])}, n, nil + } + + t, n := readPunct(rs, i) + if t == "" { + return token{}, 0, errParam(fmt.Sprintf("unexpected character %q in search query", r)) + } + + return token{kind: tkPunct, text: t}, n, nil +} + +func readQuoted(rs []rune, start int, q rune) (string, int, bool) { + var sb strings.Builder + + for i := start + 1; i < len(rs); i++ { + switch { + case rs[i] == '\\' && i+1 < len(rs): + i++ + sb.WriteRune(rs[i]) + case rs[i] == q: + return sb.String(), i + 1, true + default: + sb.WriteRune(rs[i]) + } + } + + return "", 0, false +} + +func readPath(rs []rune, start int) ([]string, int, *APIError) { + var path []string + + i := start + + for { + switch { + case i < len(rs) && rs[i] == '"': + s, n, ok := readQuoted(rs, i, '"') + if !ok { + return nil, 0, errParam("unterminated quoted identifier in search query") + } + + path, i = append(path, s), n + case i < len(rs) && isWordStart(rs[i]): + j := i + for j < len(rs) && isWordPart(rs[j]) { + j++ + } + + path, i = append(path, string(rs[i:j])), j + default: + return nil, 0, errParam("malformed identifier in search query") + } + + if i < len(rs) && rs[i] == '.' { + i++ + + continue + } + + return path, i, nil + } +} + +func readPunct(rs []rune, i int) (string, int) { + two := "" + if i+1 < len(rs) { + two = string(rs[i : i+twoRunes]) + } + + switch two { + case "!=", "<=", ">=", "<>": + return two, i + twoRunes + } + + if strings.ContainsRune("=<>(),|", rs[i]) { + return string(rs[i]), i + 1 + } + + return "", i +} + +// Query is a parsed Logging search. +type Query struct { + Streams []string + ops []qop +} + +type qop struct { + where expr + kind string + keys []sortKey + n int +} + +type sortKey struct { + path []string + desc bool +} + +type parser struct { + toks []token + pos int +} + +func (p *parser) peek() token { return p.toks[p.pos] } + +func (p *parser) next() token { + t := p.toks[p.pos] + if t.kind != tkEOF { + p.pos++ + } + + return t +} + +func (p *parser) isWord(w string) bool { + t := p.peek() + + return t.kind == tkPath && len(t.path) == 1 && strs.Equal(t.path[0], w) +} + +func (p *parser) isPunct(s string) bool { + t := p.peek() + + return t.kind == tkPunct && t.text == s +} + +var unsupportedOps = []string{ //nolint:gochecknoglobals // immutable keyword list + "select", "extend", "dedup", "summarize", "fields", "head", "tail", "rename", "timestats", "join", "union", + "distinct", "stats", "sample", "highlight", "parse", +} + +// ParseQuery parses the supported subset of the Logging query language. +func ParseQuery(src string) (*Query, error) { + toks, aerr := lex(src) + if aerr != nil { + return nil, aerr + } + + p := &parser{toks: toks} + if !p.isWord("search") { + return nil, errParam(`a search query must start with search "[/[/]]"`) + } + + p.next() + + q := &Query{} + + for { + t := p.next() + if t.kind != tkString && !t.quoted { + return nil, errParam(`search needs a quoted scope such as "//"`) + } + + q.Streams = append(q.Streams, t.text) + + if !p.isPunct(",") { + break + } + + p.next() + } + + for p.isPunct("|") { + p.next() + + o, err := p.parseOp() + if err != nil { + return nil, err + } + + q.ops = append(q.ops, o) + } + + if p.peek().kind != tkEOF { + return nil, errParam("unexpected " + strconv.Quote(p.peek().text) + " in search query") + } + + return q, nil +} + +func (p *parser) parseOp() (qop, *APIError) { + t := p.peek() + if t.kind == tkPath && len(t.path) == 1 { + w := strings.ToLower(t.path[0]) + + switch { + case slices.Contains(unsupportedOps, w): + return qop{}, errParam("the " + w + " operator is not supported by this emulator") + case w == kindSort: + return p.parseSort() + case w == kindTop: + return p.parseTop() + case w == kindLimit: + p.next() + + n, err := p.parseCount() + + return qop{kind: kindLimit, n: n}, err + case w == kindCount: + p.next() + + return qop{kind: kindCount}, nil + case w == kindWhere: + p.next() + } + } + + e, err := p.parseOr() + + return qop{kind: kindWhere, where: e}, err +} + +func (p *parser) parseCount() (int, *APIError) { + t := p.next() + + n, err := strconv.Atoi(t.text) + if t.kind != tkNumber || err != nil || n < 0 { + return 0, errParam("a non-negative integer is required after limit/top") + } + + return n, nil +} + +// parseKey reads a field and an optional direction; desc applies when none is given. +func (p *parser) parseKey(desc bool) (sortKey, *APIError) { + t := p.next() + if t.kind != tkPath { + return sortKey{}, errParam("a field name is required after sort by/top ... by") + } + + k := sortKey{path: t.path, desc: desc} + + switch { + case p.isWord("desc"): + p.next() + + k.desc = true + case p.isWord("asc"): + p.next() + + k.desc = false + } + + return k, nil +} + +func (p *parser) parseSort() (qop, *APIError) { + p.next() + + if !p.isWord("by") { + return qop{}, errParam("sort must be followed by by") + } + + p.next() + + o := qop{kind: kindSort} + + for { + k, err := p.parseKey(false) + if err != nil { + return qop{}, err + } + + o.keys = append(o.keys, k) + + if !p.isPunct(",") { + return o, nil + } + + p.next() + } +} + +func (p *parser) parseTop() (qop, *APIError) { + p.next() + + n, err := p.parseCount() + if err != nil { + return qop{}, err + } + + if !p.isWord("by") { + return qop{}, errParam("top N must be followed by by ") + } + + p.next() + + k, err := p.parseKey(true) + if err != nil { + return qop{}, err + } + + return qop{kind: kindTop, n: n, keys: []sortKey{k}}, nil +} + +// Expressions. + +type expr interface{ eval(r *row) bool } + +type orExpr struct{ l, r expr } + +type andExpr struct{ l, r expr } + +type notExpr struct{ e expr } + +type cmpExpr struct { + lit any + op string + path []string + set []any +} + +func (e orExpr) eval(r *row) bool { return e.l.eval(r) || e.r.eval(r) } +func (e andExpr) eval(r *row) bool { return e.l.eval(r) && e.r.eval(r) } +func (e notExpr) eval(r *row) bool { return !e.e.eval(r) } + +func (p *parser) parseOr() (expr, *APIError) { + l, err := p.parseAnd() + if err != nil { + return nil, err + } + + for p.isWord("or") { + p.next() + + r, rerr := p.parseAnd() + if rerr != nil { + return nil, rerr + } + + l = orExpr{l, r} + } + + return l, nil +} + +func (p *parser) parseAnd() (expr, *APIError) { + l, err := p.parseNot() + if err != nil { + return nil, err + } + + for p.isWord("and") { + p.next() + + r, rerr := p.parseNot() + if rerr != nil { + return nil, rerr + } + + l = andExpr{l, r} + } + + return l, nil +} + +func (p *parser) parseNot() (expr, *APIError) { + if p.isWord("not") { + p.next() + + e, err := p.parseNot() + + return notExpr{e}, err + } + + if p.isPunct("(") { + p.next() + + e, err := p.parseOr() + if err != nil { + return nil, err + } + + if !p.isPunct(")") { + return nil, errParam("missing ) in search query") + } + + p.next() + + return e, nil + } + + return p.parseCmp() +} + +func (p *parser) parseLiteral() (any, *APIError) { + t := p.next() + + switch { + case t.kind == tkString: + return t.text, nil + case t.kind == tkNumber: + f, err := strconv.ParseFloat(t.text, 64) + if err != nil { + return nil, errParam("invalid number " + t.text) + } + + return f, nil + case t.kind == tkPath && len(t.path) == 1 && strs.Equal(t.path[0], "true"): + return true, nil + case t.kind == tkPath && len(t.path) == 1 && strs.Equal(t.path[0], "false"): + return false, nil + case t.kind == tkPath && len(t.path) == 1 && strs.Equal(t.path[0], "null"): + return nil, nil + } + + return nil, errParam("a string, number, true, false or null literal is required, got " + strconv.Quote(t.text)) +} + +func (p *parser) parseCmp() (expr, *APIError) { + t := p.next() + if t.kind != tkPath { + return nil, errParam("expected a field name in search query, got " + strconv.Quote(t.text)) + } + + c := cmpExpr{path: t.path} + nt := p.peek() + + switch { + case nt.kind == tkPunct && slices.Contains([]string{"=", "!=", "<>", "<", "<=", ">", ">="}, nt.text): + p.next() + + c.op = nt.text + if c.op == "<>" { + c.op = "!=" + } + + lit, err := p.parseLiteral() + c.lit = lit + + return c, err + case p.isWord("in"): + p.next() + + if !p.isPunct("(") { + return nil, errParam("in must be followed by a parenthesised list") + } + + p.next() + + c.op = "in" + + for { + lit, err := p.parseLiteral() + if err != nil { + return nil, err + } + + c.set = append(c.set, lit) + + if p.isPunct(",") { + p.next() + + continue + } + + break + } + + if !p.isPunct(")") { + return nil, errParam("missing ) after in list") + } + + p.next() + + return c, nil + } + + c.op = "truthy" + + return c, nil +} + +// Rows. + +type row struct { + ce map[string]any + raw string + ms float64 + seq int64 +} + +func (r *row) lookup(path []string) (any, bool) { + switch { + case path[0] == fieldTime && len(path) == 1: + return r.ms, true + case path[0] == fieldRegion && len(path) == 1: + return Region, true + case path[0] == fieldRaw && len(path) == 1: + return r.raw, true + case path[0] == fieldRaw: + path = path[1:] + } + + var cur any = r.ce + + for _, seg := range path { + m, ok := cur.(map[string]any) + if !ok { + return nil, false + } + + if cur, ok = m[seg]; !ok { + return nil, false + } + } + + return cur, true +} + +func scalarString(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + case bool: + return strconv.FormatBool(x) + case nil: + return "" + } + + b, _ := json.Marshal(v) + + return string(b) +} + +func wildcard(pattern, s string) bool { + parts := strings.Split(pattern, "*") + for i := range parts { + parts[i] = regexp.QuoteMeta(parts[i]) + } + + return regexp.MustCompile("(?s)^" + strings.Join(parts, ".*") + "$").MatchString(s) +} + +func equalVal(v, lit any) bool { + switch l := lit.(type) { + case string: + if strings.Contains(l, "*") { + return wildcard(l, scalarString(v)) + } + + return scalarString(v) == l + case float64: + f, ok := toFloat(v) + + return ok && f == l + case bool: + b, ok := v.(bool) + + return ok && b == l + } + + return v == nil +} + +func toFloat(v any) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case string: + f, err := strconv.ParseFloat(x, 64) + + return f, err == nil + } + + return 0, false +} + +func orderVal(v, lit any) (int, bool) { + if l, isNum := lit.(float64); isNum { + f, numeric := toFloat(v) + + return cmp.Compare(f, l), numeric + } + + if l, isStr := lit.(string); isStr { + return strings.Compare(scalarString(v), l), v != nil + } + + return 0, false +} + +func (c cmpExpr) eval(r *row) bool { + v, present := r.lookup(c.path) + + switch c.op { + case "truthy": + b, isBool := v.(bool) + + return present && isBool && b + case "=", "!=", "in": + return c.evalEquality(v, present) + } + + if !present { + return false + } + + d, ordered := orderVal(v, c.lit) + if !ordered { + return false + } + + switch c.op { + case "<": + return d < 0 + case "<=": + return d <= 0 + case ">": + return d > 0 + } + + return d >= 0 +} + +func (c cmpExpr) evalEquality(v any, present bool) bool { + switch c.op { + case "=": + return (present && equalVal(v, c.lit)) || (!present && c.lit == nil) + case "!=": + return (!present || !equalVal(v, c.lit)) && (present || c.lit != nil) + } + + return present && slices.ContainsFunc(c.set, func(l any) bool { return equalVal(v, l) }) +} + +func compareRows(a, c *row, keys []sortKey) int { + for _, k := range keys { + va, oka := a.lookup(k.path) + vc, okc := c.lookup(k.path) + + var d int + + switch { + case !oka || !okc: + d = cmp.Compare(b2i(oka), b2i(okc)) + default: + fa, fok := toFloat(va) + fc, cok := toFloat(vc) + + if _, isStr := va.(string); fok && cok && !isStr { + d = cmp.Compare(fa, fc) + } else { + d = strings.Compare(scalarString(va), scalarString(vc)) + } + } + + if k.desc { + d = -d + } + + if d != 0 { + return d + } + } + + return 0 +} + +func b2i(b bool) int { + if b { + return 1 + } + + return 0 +} + +// apply runs the pipeline over rows; count replaces the rows by a single count row. +func (q *Query) apply(rows []*row) ([]*row, *int) { + for _, o := range q.ops { + switch o.kind { + case kindWhere: + rows = slices.DeleteFunc(rows, func(r *row) bool { return !o.where.eval(r) }) + case kindSort: + slices.SortStableFunc(rows, func(a, c *row) int { return compareRows(a, c, o.keys) }) + case kindTop: + slices.SortStableFunc(rows, func(a, c *row) int { return compareRows(a, c, o.keys) }) + rows = rows[:min(o.n, len(rows))] + case kindLimit: + rows = rows[:min(o.n, len(rows))] + case kindCount: + n := len(rows) + + return nil, &n + } + } + + return rows, nil +} diff --git a/services/ocilogging/route.go b/services/ocilogging/route.go new file mode 100644 index 0000000000..91cd80b0b6 --- /dev/null +++ b/services/ocilogging/route.go @@ -0,0 +1,99 @@ +package ocilogging + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateLogGroup = "CreateLogGroup" + opGetLogGroup = "GetLogGroup" + opListLogGroups = "ListLogGroups" + opUpdateLogGroup = "UpdateLogGroup" + opDeleteLogGroup = "DeleteLogGroup" + opChangeGroupComp = "ChangeLogGroupCompartment" + opCreateLog = "CreateLog" + opGetLog = "GetLog" + opListLogs = "ListLogs" + opUpdateLog = "UpdateLog" + opDeleteLog = "DeleteLog" + opChangeLogGroup = "ChangeLogLogGroup" + opCreateAgentConfig = "CreateUnifiedAgentConfiguration" + opGetAgentConfig = "GetUnifiedAgentConfiguration" + opListAgentConfigs = "ListUnifiedAgentConfigurations" + opUpdateAgentConfig = "UpdateUnifiedAgentConfiguration" + opDeleteAgentConfig = "DeleteUnifiedAgentConfiguration" + opChangeAgentComp = "ChangeUnifiedAgentConfigurationCompartment" + opListServices = "ListServices" + opGetWorkRequest = "GetWorkRequest" + opListWorkRequests = "ListWorkRequests" + opDeleteWorkRequest = "DeleteWorkRequest" + opListWorkReqErrors = "ListWorkRequestErrors" + opListWorkReqLogs = "ListWorkRequestLogs" + opPutLogs = "PutLogs" + opSearchLogs = "SearchLogs" + opAdminKeys = "ManageAPIKeys" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + mgmtBase = "/20200531" + ingestBase = "/20200831" + searchBase = "/20190909" + groupPath = mgmtBase + "/logGroups/{logGroupId}" + logPath = groupPath + "/logs/{logId}" + agentPath = mgmtBase + "/unifiedAgentConfigurations/{unifiedAgentConfigurationId}" + workRequestPath = mgmtBase + "/workRequests/{workRequestId}" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: mgmtBase + "/logGroups", Op: opCreateLogGroup}, + {Method: http.MethodGet, Path: mgmtBase + "/logGroups", Op: opListLogGroups}, + {Method: http.MethodGet, Path: groupPath, Op: opGetLogGroup}, + {Method: http.MethodPut, Path: groupPath, Op: opUpdateLogGroup}, + {Method: http.MethodDelete, Path: groupPath, Op: opDeleteLogGroup}, + {Method: http.MethodPost, Path: groupPath + "/actions/changeCompartment", Op: opChangeGroupComp}, + {Method: http.MethodPost, Path: groupPath + "/logs", Op: opCreateLog}, + {Method: http.MethodGet, Path: groupPath + "/logs", Op: opListLogs}, + {Method: http.MethodGet, Path: logPath, Op: opGetLog}, + {Method: http.MethodPut, Path: logPath, Op: opUpdateLog}, + {Method: http.MethodDelete, Path: logPath, Op: opDeleteLog}, + {Method: http.MethodPost, Path: logPath + "/actions/changeLogGroup", Op: opChangeLogGroup}, + {Method: http.MethodPost, Path: mgmtBase + "/unifiedAgentConfigurations", Op: opCreateAgentConfig}, + {Method: http.MethodGet, Path: mgmtBase + "/unifiedAgentConfigurations", Op: opListAgentConfigs}, + {Method: http.MethodGet, Path: agentPath, Op: opGetAgentConfig}, + {Method: http.MethodPut, Path: agentPath, Op: opUpdateAgentConfig}, + {Method: http.MethodDelete, Path: agentPath, Op: opDeleteAgentConfig}, + {Method: http.MethodPost, Path: agentPath + "/actions/changeCompartment", Op: opChangeAgentComp}, + {Method: http.MethodGet, Path: mgmtBase + "/v2/registry/services", Op: opListServices}, + {Method: http.MethodGet, Path: mgmtBase + "/workRequests", Op: opListWorkRequests}, + {Method: http.MethodGet, Path: workRequestPath, Op: opGetWorkRequest}, + {Method: http.MethodDelete, Path: workRequestPath, Op: opDeleteWorkRequest}, + {Method: http.MethodGet, Path: workRequestPath + "/errors", Op: opListWorkReqErrors}, + {Method: http.MethodGet, Path: workRequestPath + "/logs", Op: opListWorkReqLogs}, + {Method: http.MethodPost, Path: ingestBase + "/logs/{logId}/actions/push", Op: opPutLogs}, + {Method: http.MethodPost, Path: searchBase + "/search", Op: opSearchLogs}, + {Method: http.MethodPost, Path: searchBase + "/actions/search", Op: opSearchLogs}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocilogging/search.go b/services/ocilogging/search.go new file mode 100644 index 0000000000..3c5c8e6b6a --- /dev/null +++ b/services/ocilogging/search.go @@ -0,0 +1,248 @@ +package ocilogging + +import ( + "encoding/json" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + defaultSearchLimit = 1000 + maxSearchLimit = 1000 + tenancyPlaceholder = "ocid1.tenancy.oc1..emulator" + timeMillis = "2006-01-02T15:04:05.000Z07:00" +) + +// SearchIn is the SearchLogs request body. +type SearchIn struct { + TimeStart *time.Time `json:"timeStart"` + TimeEnd *time.Time `json:"timeEnd"` + ReturnFieldInfo *bool `json:"isReturnFieldInfo"` + SearchQuery string `json:"searchQuery"` +} + +type searchResultWire struct { + Data any `json:"data"` +} + +type fieldInfoWire struct { + FieldName string `json:"fieldName"` +} + +type searchSummaryWire struct { + ResultCount int `json:"resultCount"` + FieldCount int `json:"fieldCount"` +} + +// SearchOut is the SearchLogs response body. +type SearchOut struct { + Results []searchResultWire `json:"results"` + Fields []fieldInfoWire `json:"fields,omitempty"` + Summary searchSummaryWire `json:"summary"` +} + +const maxScopeParts = 3 + +// resolveStreams maps query scopes to the ids of the logs they cover; the caller holds the lock. +func (b *InMemoryBackend) resolveStreams(streams []string) ([]string, *APIError) { + var ids []string + + for _, s := range streams { + parts := strings.Split(s, "/") + if len(parts) > maxScopeParts || parts[0] == "" { + return nil, errParam(`a scope must look like "[/[/]]"`) + } + + found, aerr := b.scopeLogs(parts) + if aerr != nil { + return nil, aerr + } + + ids = append(ids, found...) + } + + return ids, nil +} + +func logSelected(parts []string, l *Log) bool { + return len(parts) < maxScopeParts || l.ID == parts[2] || l.Name == parts[2] +} + +// scopeLogs returns the ids of the logs one scope (compartment, group, log) selects. +func (b *InMemoryBackend) scopeLogs(parts []string) ([]string, *APIError) { + var groups []string + + for _, g := range b.groups { + if g.CompartmentID == parts[0] && (len(parts) < 2 || g.ID == parts[1] || g.Name == parts[1]) { + groups = append(groups, g.ID) + } + } + + if len(parts) > 1 && len(groups) == 0 { + return nil, errParam("log group " + parts[1] + " was not found in compartment " + parts[0]) + } + + var ids []string + + for _, l := range b.logs { + if l.State != stateDeleting && slices.Contains(groups, l.GroupID) && logSelected(parts, l) { + ids = append(ids, l.ID) + } + } + + if len(parts) == maxScopeParts && len(ids) == 0 { + return nil, errParam("log " + parts[2] + " was not found in log group " + parts[1]) + } + + return ids, nil +} + +func (b *InMemoryBackend) buildRow(l *Log, e *Entry) *row { + var data any = e.Data + + var parsed any + if json.Unmarshal([]byte(e.Data), &parsed) == nil { + if _, isMap := parsed.(map[string]any); isMap { + data = parsed + } + } + + comp := "" + if g, ok := b.groups[l.GroupID]; ok { + comp = g.CompartmentID + } + + ce := map[string]any{ + "data": data, "id": e.ID, "source": e.Source, "specversion": specVersion, "type": e.Type, + "time": e.Time.UTC().Format(timeMillis), "subject": e.Subject, + "oracle": map[string]any{ + "loggroupid": l.GroupID, "logid": l.ID, "compartmentid": comp, "tenantid": tenancyPlaceholder, + "ingestedtime": e.Ingested.UTC().Format(timeMillis), + }, + } + + return &row{ce: ce, raw: e.Data, ms: float64(e.Time.UnixMilli()), seq: e.Seq} +} + +// SearchLogs runs a query over the ingested entries within the time range. +func (b *InMemoryBackend) SearchLogs(in SearchIn, token string, limit int) (SearchOut, string, error) { + if limit < 0 || limit > maxSearchLimit { + return SearchOut{}, "", errParam("limit must be between 1 and 1000") + } + + if in.TimeStart == nil || in.TimeEnd == nil { + return SearchOut{}, "", errMissing("timeStart and timeEnd are required") + } + + if !in.TimeStart.Before(*in.TimeEnd) { + return SearchOut{}, "", errParam("timeStart must be before timeEnd") + } + + q, err := ParseQuery(in.SearchQuery) + if err != nil { + return SearchOut{}, "", err + } + + b.mu.Lock("SearchLogs") + defer b.mu.Unlock() + + b.settleAll() + + ids, aerr := b.resolveStreams(q.Streams) + if aerr != nil { + return SearchOut{}, "", aerr + } + + var rows []*row + + for _, id := range slices.Compact(slices.Sorted(slices.Values(ids))) { + for _, e := range b.entries[id] { + if !e.Time.Before(*in.TimeStart) && e.Time.Before(*in.TimeEnd) { + rows = append(rows, b.buildRow(b.logs[id], e)) + } + } + } + + slices.SortStableFunc(rows, func(a, c *row) int { + return compareRows(c, a, []sortKey{{path: []string{fieldTime}}}) // newest first + }) + + rows, counted := q.apply(rows) + + return b.finishSearch(rows, counted, in, token, limit) +} + +func (b *InMemoryBackend) finishSearch( + rows []*row, counted *int, in SearchIn, token string, limit int, +) (SearchOut, string, error) { + out := SearchOut{Results: []searchResultWire{}} + + fields := []string{fieldTime, fieldRaw, fieldRegion} + + if counted != nil { + out.Results = append(out.Results, searchResultWire{Data: map[string]any{kindCount: *counted}}) + out.Summary = searchSummaryWire{ResultCount: 1, FieldCount: 1} + fields = []string{kindCount} + } else { + pg := page.New(rows, token, limit, defaultSearchLimit) + for _, r := range pg.Data { + out.Results = append(out.Results, searchResultWire{Data: map[string]any{ + fieldTime: int64(r.ms), fieldRaw: r.ce, fieldRegion: Region, + }}) + } + + out.Summary = searchSummaryWire{ResultCount: len(pg.Data), FieldCount: len(fields)} + + if in.ReturnFieldInfo != nil && *in.ReturnFieldInfo { + out.Fields = fieldList(fields) + } + + return out, pg.Next, nil + } + + if in.ReturnFieldInfo != nil && *in.ReturnFieldInfo { + out.Fields = fieldList(fields) + } + + return out, "", nil +} + +func fieldList(names []string) []fieldInfoWire { + out := make([]fieldInfoWire, 0, len(names)) + for _, n := range names { + out = append(out, fieldInfoWire{FieldName: n}) + } + + return out +} + +func (h *Handler) searchLogs(q *req) error { + var in SearchIn + if err := q.decode(&in); err != nil { + return err + } + + limit := 0 + if raw := q.query("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + return errParam("limit must be an integer") + } + + limit = n + } + + out, next, err := h.Backend.SearchLogs(in, q.query("page"), limit) + if err != nil { + return err + } + + q.nextPage(next) + + return q.json(http.StatusOK, out, "") +} diff --git a/services/ocilogging/search_test.go b/services/ocilogging/search_test.go new file mode 100644 index 0000000000..5e3383d072 --- /dev/null +++ b/services/ocilogging/search_test.go @@ -0,0 +1,428 @@ +package ocilogging_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/logging" + "github.com/oracle/oci-go-sdk/v65/loggingsearch" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type searchEnv struct { + *env + group logging.LogGroup + log logging.Log + scope string +} + +// seededSearch ingests five entries spaced ten seconds apart, oldest first. +func seededSearch(t *testing.T) *searchEnv { + t.Helper() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "grp") + l := e.mkLog(t, g, "app", nil) + + for _, s := range []struct { + data string + off time.Duration + }{ + {`{"level":"INFO","status":200,"msg":"started"}`, -50 * time.Second}, + {`{"level":"ERROR","status":500,"msg":"boom"}`, -40 * time.Second}, + {`{"level":"WARN","status":404,"msg":"missing"}`, -30 * time.Second}, + {`{"level":"ERROR","status":503,"msg":"overloaded"}`, -20 * time.Second}, + {`plain ERROR line`, -10 * time.Second}, + } { + e.put(t, l, s.off, s.data) + } + + return &searchEnv{ + env: e, + group: g, + log: l, + scope: fmt.Sprintf(`search "%s/%s/%s"`, testCompartment, *g.DisplayName, *l.DisplayName), + } +} + +func labels(resp loggingsearch.SearchLogsResponse) []string { + out := make([]string, 0, len(resp.Results)) + + for _, r := range resp.Results { + row, _ := (*r.Data).(map[string]any) + if n, ok := row["count"]; ok { + out = append(out, fmt.Sprint(n)) + + continue + } + + content, _ := row["logContent"].(map[string]any) + + switch d := content["data"].(type) { + case map[string]any: + out = append(out, fmt.Sprint(d["msg"])) + default: + out = append(out, fmt.Sprint(d)) + } + } + + return out +} + +func TestSearchLogsQueries(t *testing.T) { + t.Parallel() + + s := seededSearch(t) + + tests := []struct { + name string + tail string + limit *int + want []string + }{ + {"all newest first", "", nil, []string{"plain ERROR line", "overloaded", "missing", "boom", "started"}}, + {"equals", ` | where data.level = 'ERROR'`, nil, []string{"overloaded", "boom"}}, + {"where optional", ` | data.level = 'WARN'`, nil, []string{"missing"}}, + {"and number", ` | where data.level = 'ERROR' and data.status >= 503`, nil, []string{"overloaded"}}, + {"or", ` | where data.level = 'INFO' or data.status = 404`, nil, []string{"missing", "started"}}, + {"not group", ` | where not (data.level = 'INFO' or data.level = 'WARN')`, nil, + []string{"plain ERROR line", "overloaded", "boom"}}, + { + "not equals keeps missing", + ` | where data.level != 'ERROR'`, + nil, + []string{"plain ERROR line", "missing", "started"}, + }, + {"in list", ` | where data.level in ('WARN', 'INFO')`, nil, []string{"missing", "started"}}, + { + "wildcard logContent", + ` | where logContent = '*ERROR*'`, + nil, + []string{"plain ERROR line", "overloaded", "boom"}, + }, + {"wildcard field", ` | where data.msg = 'o*'`, nil, []string{"overloaded"}}, + { + "sort asc", + ` | sort by datetime asc`, + nil, + []string{"started", "boom", "missing", "overloaded", "plain ERROR line"}, + }, + {"sort field desc limit", ` | where data.status > 0 | sort by data.status desc | limit 2`, nil, + []string{"overloaded", "boom"}}, + { + "sort then limit one", + ` | where data.level = 'ERROR' | sort by datetime desc | limit 1`, + nil, + []string{"overloaded"}, + }, + {"top", ` | where data.status > 0 | top 2 by data.status`, nil, []string{"overloaded", "boom"}}, + {"top asc", ` | where data.status > 0 | top 1 by data.status asc`, nil, []string{"started"}}, + {"count", ` | where data.level = 'ERROR' | count`, nil, []string{"2"}}, + {"api limit", "", new(2), []string{"plain ERROR line", "overloaded"}}, + {"datetime filter", fmt.Sprintf(` | where datetime < %d`, s.clock.Now().Add(-35*time.Second).UnixMilli()), nil, + []string{"boom", "started"}}, + {"quoted path", ` | where "data"."level" = 'WARN'`, nil, []string{"missing"}}, + {"group scope", "", nil, []string{"plain ERROR line", "overloaded", "missing", "boom", "started"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + q := s.scope + tt.tail + if tt.name == "group scope" { + q = fmt.Sprintf(`search "%s/%s"`, testCompartment, *s.group.Id) + } + + resp := s.query(t, q, tt.limit, nil) + assert.Equal(t, tt.want, labels(resp)) + assert.Equal(t, len(tt.want), *resp.Summary.ResultCount) + assert.NotEmpty(t, resp.Fields) + }) + } +} + +func TestSearchResultShape(t *testing.T) { + t.Parallel() + + s := seededSearch(t) + resp := s.query(t, s.scope+` | where data.msg = 'boom'`, nil, nil) + require.Len(t, resp.Results, 1) + + row, ok := (*resp.Results[0].Data).(map[string]any) + require.True(t, ok) + assert.InDelta(t, float64(s.clock.Now().Add(-40*time.Second).UnixMilli()), row["datetime"], 1) + assert.Equal(t, "us-ashburn-1", row["regionId"]) + + ce, ok := row["logContent"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "app", ce["source"]) + assert.Equal(t, "app.requests", ce["type"]) + assert.Equal(t, "1.0", ce["specversion"]) + + oracle, ok := ce["oracle"].(map[string]any) + require.True(t, ok) + assert.Equal(t, *s.log.Id, oracle["logid"]) + assert.Equal(t, *s.group.Id, oracle["loggroupid"]) + assert.Equal(t, testCompartment, oracle["compartmentid"]) +} + +func TestSearchLogsPaging(t *testing.T) { + t.Parallel() + + s := seededSearch(t) + + var ( + got []string + page *string + ) + + pages := 0 + + for { + resp := s.query(t, s.scope, new(2), page) + got = append(got, labels(resp)...) + pages++ + + if resp.OpcNextPage == nil { + break + } + + page = resp.OpcNextPage + } + + assert.Equal(t, 3, pages) + assert.Equal(t, []string{"plain ERROR line", "overloaded", "missing", "boom", "started"}, got) +} + +func TestSearchLogsErrors(t *testing.T) { + t.Parallel() + + s := seededSearch(t) + start := common.SDKTime{Time: s.clock.Now().Add(-time.Hour)} + end := common.SDKTime{Time: s.clock.Now().Add(time.Hour)} + + tests := []struct { + name string + query string + start common.SDKTime + end common.SDKTime + limit *int + code string + }{ + {"no search keyword", `where a = 1`, start, end, nil, "InvalidParameter"}, + {"unquoted scope", `search app`, start, end, nil, "InvalidParameter"}, + {"unterminated string", s.scope + ` | where a = 'x`, start, end, nil, "InvalidParameter"}, + {"unsupported select", s.scope + ` | select data.level`, start, end, nil, "InvalidParameter"}, + {"unsupported summarize", s.scope + ` | summarize count() by level`, start, end, nil, "InvalidParameter"}, + {"bad limit operand", s.scope + ` | limit abc`, start, end, nil, "InvalidParameter"}, + {"missing paren", s.scope + ` | where (a = 1`, start, end, nil, "InvalidParameter"}, + {"missing group", fmt.Sprintf(`search "%s/nope"`, testCompartment), start, end, nil, "InvalidParameter"}, + {"missing log", fmt.Sprintf(`search "%s/grp/nope"`, testCompartment), start, end, nil, "InvalidParameter"}, + {"reversed range", s.scope, end, start, nil, "InvalidParameter"}, + {"limit too large", s.scope, start, end, new(5000), "InvalidParameter"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := s.search.SearchLogs(t.Context(), loggingsearch.SearchLogsRequest{ + TimeStart: &tt.start, TimeEnd: &tt.end, SearchQuery: new(tt.query), + Limit: tt.limit, + }) + se := svcErr(t, err) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestSearchTimeRange(t *testing.T) { + t.Parallel() + + s := seededSearch(t) + now := s.clock.Now() + + tests := []struct { + name string + start, end time.Time + want []string + }{ + {"window", now.Add(-45 * time.Second), now.Add(-25 * time.Second), []string{"missing", "boom"}}, + {"end exclusive", now.Add(-50 * time.Second), now.Add(-40 * time.Second), []string{"started"}}, + {"before everything", now.Add(-2 * time.Hour), now.Add(-time.Hour), []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + start, end := common.SDKTime{Time: tt.start}, common.SDKTime{Time: tt.end} + + resp, err := s.search.SearchLogs(t.Context(), loggingsearch.SearchLogsRequest{ + TimeStart: &start, TimeEnd: &end, SearchQuery: new(s.scope), + }) + require.NoError(t, err) + assert.Equal(t, tt.want, labels(resp)) + }) + } +} + +func TestRetentionExpiry(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "grp") + short := e.mkLog(t, g, "short", nil) + long := e.mkLog(t, g, "long", func(d *logging.CreateLogDetails) { d.RetentionDuration = new(60) }) + + e.put(t, short, 0, "short entry") + e.put(t, long, 0, "long entry") + + count := func(log logging.Log) int { + start, end := common.SDKTime{ + Time: e.clock.Now().Add(-100 * 24 * time.Hour), + }, common.SDKTime{ + Time: e.clock.Now().Add(time.Hour), + } + + resp, err := e.search.SearchLogs(t.Context(), loggingsearch.SearchLogsRequest{ + TimeStart: &start, TimeEnd: &end, + SearchQuery: new(fmt.Sprintf(`search "%s/%s/%s"`, testCompartment, *g.Id, *log.Id)), + }) + require.NoError(t, err) + + return len(resp.Results) + } + + assert.Equal(t, 1, count(short)) + assert.Equal(t, 1, count(long)) + + e.clock.Advance(29 * 24 * time.Hour) + assert.Equal(t, 1, count(short)) + + e.clock.Advance(2 * 24 * time.Hour) + assert.Equal(t, 0, count(short), "30 day retention elapsed") + assert.Equal(t, 1, count(long), "60 day retention still holds") + + e.clock.Advance(30 * 24 * time.Hour) + assert.Equal(t, 0, count(long)) +} + +func TestPutLogsValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "grp") + l := e.mkLog(t, g, "app", nil) + off := e.mkLog(t, g, "off", func(d *logging.CreateLogDetails) { d.IsEnabled = new(false) }) + + const now = "2026-10-29T10:00:00.000Z" + + entry := `{"data":"x","id":"1"}` + batch := func(entries string) string { + return `{"source":"s","type":"t","defaultlogentrytime":"` + now + `","entries":[` + entries + `]}` + } + body := func(spec, batches string) string { + return `{"specversion":"` + spec + `","logEntryBatches":[` + batches + `]}` + } + + tests := []struct { + name string + logID string + body string + code string + status int + }{ + {"ok", *l.Id, body("1.0", batch(entry)), "", 200}, + {"bad specversion", *l.Id, body("2.0", batch(entry)), "InvalidParameter", 400}, + {"no batches", *l.Id, body("1.0", ""), "InvalidParameter", 400}, + {"no entries", *l.Id, body("1.0", batch("")), "InvalidParameter", 400}, + { + "missing source", + *l.Id, + body("1.0", `{"type":"t","defaultlogentrytime":"`+now+`","entries":[`+entry+`]}`), + "MissingParameter", + 400, + }, + { + "missing default time", + *l.Id, + body("1.0", `{"source":"s","type":"t","entries":[`+entry+`]}`), + "MissingParameter", + 400, + }, + {"missing entry id", *l.Id, body("1.0", batch(`{"data":"x"}`)), "MissingParameter", 400}, + {"missing entry data", *l.Id, body("1.0", batch(`{"id":"1"}`)), "MissingParameter", 400}, + { + "entry too large", + *l.Id, + body("1.0", batch(`{"id":"1","data":"`+strings.Repeat("a", 1<<20)+`"}`)), + "PayloadTooLarge", + 413, + }, + {"not json", *l.Id, `nope`, "CannotParseRequest", 400}, + {"unknown log", "ocid1.log.oc1..ghost", body("1.0", batch(entry)), "NotAuthorizedOrNotFound", 404}, + {"disabled log", *off.Id, body("1.0", batch(entry)), "IncorrectState", 409}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, out := rawDo(t, http.MethodPost, e.base+"/20200831/logs/"+tt.logID+"/actions/push", tt.body) + assert.Equal(t, tt.status, status, out) + + if tt.code != "" { + assert.Contains(t, out, tt.code) + } + }) + } +} + +func TestPutLogsTruncatesLongData(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "grp") + l := e.mkLog(t, g, "app", nil) + + e.put(t, l, 0, strings.Repeat("z", 10001)) + + resp := e.query(t, fmt.Sprintf(`search "%s/%s/%s"`, testCompartment, *g.Id, *l.Id), nil, nil) + require.Len(t, resp.Results, 1) + assert.Len(t, labels(resp)[0], 10000) +} + +func TestLogCreatingRejectsIngestion(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + g := e.mkGroup(t, "grp") + + _, err := e.mgmt.CreateLog(t.Context(), logging.CreateLogRequest{ + LogGroupId: g.Id, + DisplayName: new("fresh"), LogType: logging.CreateLogDetailsLogTypeCustom, + }) + require.NoError(t, err) + + list, err := e.mgmt.ListLogs(t.Context(), logging.ListLogsRequest{LogGroupId: g.Id}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + status, body := rawDo( + t, + http.MethodPost, + e.base+"/20200831/logs/"+*list.Items[0].Id+"/actions/push", + `{"specversion":"1.0","logEntryBatches":[{"source":"s","type":"t",`+ + `"defaultlogentrytime":"2026-10-29T10:00:00Z","entries":[{"data":"x","id":"1"}]}]}`, + ) + assert.Equal(t, http.StatusConflict, status) + assert.Contains(t, body, "IncorrectState") +} diff --git a/services/ocilogging/services.go b/services/ocilogging/services.go new file mode 100644 index 0000000000..60a7d621c8 --- /dev/null +++ b/services/ocilogging/services.go @@ -0,0 +1,100 @@ +package ocilogging + +import "github.com/blackbirdworks/gopherstack/pkgs/page" + +type categoryWire struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` +} + +type resourceTypeWire struct { + Name string `json:"name"` + Categories []categoryWire `json:"categories"` +} + +// ServiceSummaryWire is one entry of the ListServices catalog. +type ServiceSummaryWire struct { + ID string `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + ServicePrincipalName string `json:"servicePrincipalName"` + TenantID string `json:"tenantId"` + Namespace string `json:"namespace,omitempty"` + ResourceTypes []resourceTypeWire `json:"resourceTypes"` +} + +type catalogService struct { + name string + namespace string + resource string + cats []categoryWire +} + +const ( + svcObjectStorage = "objectstorage" + svcFunctions = "functions" + svcFlowLogs = "flowlogs" + svcLoadBalancer = "loadbalancer" + svcAPIGateway = "apigateway" + svcEvents = "events" +) + +func cat(name, display string) categoryWire { return categoryWire{Name: name, DisplayName: display} } + +// catalog lists the OCI services that can emit SERVICE logs; categories follow the Logging service catalog. +func catalog() []catalogService { + rw := []categoryWire{cat("read", "Read Access Events"), cat("write", "Write Access Events")} + access := []categoryWire{cat("access", "Access Logs"), cat("error", "Error Logs")} + gateway := []categoryWire{cat("access", "Access Logs"), cat("execution", "Execution Logs")} + + return []catalogService{ + {svcObjectStorage, svcObjectStorage, "bucket", rw}, + {svcFunctions, svcFunctions, "application", []categoryWire{cat("invoke", "Function Invocation Logs")}}, + {svcFlowLogs, svcFlowLogs, "subnet", []categoryWire{cat("all", "All Records")}}, + {svcLoadBalancer, svcLoadBalancer, "loadbalancer", access}, + {svcAPIGateway, svcAPIGateway, "deployment", gateway}, + {svcEvents, svcEvents, "rule", []categoryWire{cat("ruleexecutionlog", "Rule Execution Log")}}, + } +} + +func findService(name, category string) *APIError { + for _, s := range catalog() { + if s.name != name { + continue + } + + for _, c := range s.cats { + if c.Name == category { + return nil + } + } + + return errParam("category " + category + " is not a log category of service " + name) + } + + return errParam("service " + name + " does not support service logs") +} + +// ListServices returns the service catalog. +func (b *InMemoryBackend) ListServices(o ListOpts) page.Page[ServiceSummaryWire] { + cat := catalog() + out := make([]ServiceSummaryWire, 0, len(cat)) + + for _, s := range cat { + out = append(out, ServiceSummaryWire{ + ID: "ocid1.logservice.oc1." + Region + "." + s.name, + Name: s.name, + Namespace: s.namespace, + Endpoint: "https://" + s.name + "." + Region + ".oci.oraclecloud.com", + TenantID: "ocid1.tenancy.oc1..emulator", + ServicePrincipalName: s.name + ".oci.oc1." + Region + ".oraclecloud.com", + ResourceTypes: []resourceTypeWire{{Name: s.resource, Categories: s.cats}}, + }) + } + + return page.New(out, o.Page, o.Limit, defaultListLimit) +} + +func (h *Handler) listServices(q *req) error { + return listReply(q, h.Backend.ListServices(q.listOpts()), nil) +} diff --git a/services/ocilogging/settings.go b/services/ocilogging/settings.go new file mode 100644 index 0000000000..79cdc6164b --- /dev/null +++ b/services/ocilogging/settings.go @@ -0,0 +1,13 @@ +package ocilogging + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8160 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCILogging. +type Settings struct { + Port int `json:"port" env:"OCI_LOGGING_PORT" default:"8160" name:"port" help:"Fixed TCP port for the dedicated OCI Logging (control plane, ingestion and search) listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_LOGGING_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ocilogging/store.go b/services/ocilogging/store.go new file mode 100644 index 0000000000..318f8e7fb9 --- /dev/null +++ b/services/ocilogging/store.go @@ -0,0 +1,246 @@ +package ocilogging + +import ( + "cmp" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "us-ashburn-1" + defaultListLimit = 100 + ocidTailLen = 52 + + defaultTransition = 2 * time.Second + maxName = 255 + maxDescription = 400 + retentionStep = 30 + maxRetentionDays = 180 + hoursPerDay = 24 +) + +// InMemoryBackend holds every log group, log, agent configuration, work request and ingested entry under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + groups map[string]*LogGroup + logs map[string]*Log + agents map[string]*AgentConfig + workReqs map[string]*WorkRequest + entries map[string][]*Entry + Auth *ociauth.Registry + transition time.Duration + seq int64 +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{mu: lockmetrics.New("ocilogging"), now: time.Now, transition: defaultTransition} + b.resetMaps() + b.Auth = ociauth.NewRegistry() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.groups, b.logs, b.agents = map[string]*LogGroup{}, map[string]*Log{}, map[string]*AgentConfig{} + b.workReqs, b.entries = map[string]*WorkRequest{}, map[string][]*Entry{} +} + +// SetClock injects the time source used for lifecycle, timestamps and retention. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTransition sets how long CREATING, UPDATING and DELETING states last. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) transitionAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate[T any](items []T, o ListOpts, name func(T) string, created func(T) (time.Time, int64)) page.Page[T] { + byName := strings.EqualFold(o.SortBy, "displayName") + desc := !byName + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + slices.SortStableFunc(items, func(a, c T) int { + var r int + if byName { + r = cmp.Compare(name(a), name(c)) + } else { + ta, sa := created(a) + tc, sc := created(c) + r = cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +// Sweep applies lifecycle transitions and retention against the backend clock. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + b.settleAll() +} + +func due(at *time.Time, now time.Time) bool { return at != nil && !at.After(now) } + +// settleAll completes finished transitions and drops entries past their log's retention; the caller holds the lock. +func (b *InMemoryBackend) settleAll() { + now := b.nowUTC() + + for id, g := range b.groups { + if !due(g.TransitionAt, now) { + continue + } + + g.TransitionAt = nil + + if g.State == stateDeleting { + delete(b.groups, id) + + continue + } + + g.State = stateActive + } + + for id, l := range b.logs { + if !due(l.TransitionAt, now) { + continue + } + + l.TransitionAt = nil + + switch { + case l.State == stateDeleting: + delete(b.logs, id) + delete(b.entries, id) + case l.Enabled: + l.State = stateActive + default: + l.State = stateInactive + } + } + + for id, a := range b.agents { + if !due(a.TransitionAt, now) { + continue + } + + a.TransitionAt = nil + + if a.State == stateDeleting { + delete(b.agents, id) + + continue + } + + a.State = stateActive + } + + for id, es := range b.entries { + l, ok := b.logs[id] + if !ok { + delete(b.entries, id) + + continue + } + + cutoff := now.Add(-time.Duration(l.Retention) * hoursPerDay * time.Hour) + b.entries[id] = slices.DeleteFunc(es, func(e *Entry) bool { return !e.Ingested.After(cutoff) }) + } +} + +func validateName(name string) *APIError { + if strings.TrimSpace(name) == "" { + return errMissing("displayName is required") + } + + if len(name) > maxName { + return errParam("displayName must be at most 255 characters") + } + + return nil +} + +func firstErr(errs ...*APIError) *APIError { + for _, e := range errs { + if e != nil { + return e + } + } + + return nil +} + +//go:fix inline diff --git a/services/ocilogging/workreq.go b/services/ocilogging/workreq.go new file mode 100644 index 0000000000..f0593ee05a --- /dev/null +++ b/services/ocilogging/workreq.go @@ -0,0 +1,195 @@ +package ocilogging + +import ( + "net/http" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const halfComplete = 50 + +type wrResourceWire struct { + EntityType string `json:"entityType"` + ActionType string `json:"actionType"` + Identifier string `json:"identifier"` + EntityURI string `json:"entityUri,omitempty"` +} + +// WorkRequestWire is the work request shape shared by Get and List. +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted *time.Time `json:"timeStarted,omitempty"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + OperationType string `json:"operationType"` + Status string `json:"status"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Resources []wrResourceWire `json:"resources"` + PercentComplete float32 `json:"percentComplete"` +} + +type wrLogWire struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +// newWR records a work request that completes at finish. +func (b *InMemoryBackend) newWR(op, entityType, compartment, id, action string, finish time.Time) string { + wr := &WorkRequest{ + ID: newOCID("loggingworkrequest"), Operation: op, CompartmentID: compartment, EntityType: entityType, + EntityID: id, EntityURI: "/" + entityType + "s/" + id, Action: action, Accepted: b.nowUTC(), + FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr.ID +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + out := WorkRequestWire{ + OperationType: wr.Operation, ID: wr.ID, CompartmentID: wr.CompartmentID, TimeAccepted: wr.Accepted, + Status: "IN_PROGRESS", PercentComplete: halfComplete, TimeStarted: &wr.Accepted, + Resources: []wrResourceWire{{ + EntityType: wr.EntityType, ActionType: "IN_PROGRESS", Identifier: wr.EntityID, EntityURI: wr.EntityURI, + }}, + } + + if !wr.FinishAt.After(b.nowUTC()) { + out.Status, out.PercentComplete, out.TimeFinished = "SUCCEEDED", 100, &wr.FinishAt + out.Resources[0].ActionType = wr.Action + } + + return out +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// ListWorkRequests lists the work requests of a compartment. +func (b *InMemoryBackend) ListWorkRequests( + compartment, id, status string, o ListOpts, +) (page.Page[WorkRequestWire], error) { + if compartment == "" { + return page.Page[WorkRequestWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + var all []*WorkRequest + + for _, wr := range b.workReqs { + if wr.CompartmentID != compartment || (id != "" && wr.ID != id) || + (status != "" && b.wrView(wr).Status != status) { + continue + } + + all = append(all, wr) + } + + pg := paginate(all, o, func(w *WorkRequest) string { return w.Operation }, + func(w *WorkRequest) (time.Time, int64) { return w.Accepted, w.Seq }) + + out := make([]WorkRequestWire, 0, len(pg.Data)) + for _, wr := range pg.Data { + out = append(out, b.wrView(wr)) + } + + return page.Page[WorkRequestWire]{Data: out, Next: pg.Next}, nil +} + +// DeleteWorkRequest removes a finished work request. +func (b *InMemoryBackend) DeleteWorkRequest(id string) error { + b.mu.Lock("DeleteWorkRequest") + defer b.mu.Unlock() + + wr, ok := b.workReqs[id] + if !ok { + return errNotFound(id) + } + + if wr.FinishAt.After(b.nowUTC()) { + return errState("Work request " + id + " is still in progress") + } + + delete(b.workReqs, id) + + return nil +} + +// WorkRequestLogs returns the log entries of a work request. +func (b *InMemoryBackend) WorkRequestLogs(id string, o ListOpts) (page.Page[wrLogWire], error) { + b.mu.RLock("WorkRequestLogs") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrLogWire]{}, errNotFound(id) + } + + logs := []wrLogWire{{Timestamp: wr.Accepted, Message: wr.Operation + " accepted"}} + if !wr.FinishAt.After(b.nowUTC()) { + logs = append(logs, wrLogWire{Timestamp: wr.FinishAt, Message: wr.Operation + " succeeded"}) + } + + return page.New(logs, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestExists reports whether a work request exists (its error list is always empty). +func (b *InMemoryBackend) WorkRequestExists(id string) error { + b.mu.RLock("WorkRequestExists") + defer b.mu.RUnlock() + + if _, ok := b.workReqs[id]; !ok { + return errNotFound(id) + } + + return nil +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + + return reply(q, w, "", err) +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(q.query("compartmentId"), q.query("id"), q.query("status"), q.listOpts()) + + return listReply(q, pg, err) +} + +func (h *Handler) deleteWorkRequest(q *req) error { + if err := h.Backend.DeleteWorkRequest(q.param("workRequestId")); err != nil { + return err + } + + q.w.WriteHeader(http.StatusNoContent) + + return nil +} + +func (h *Handler) listWorkRequestLogs(q *req) error { + pg, err := h.Backend.WorkRequestLogs(q.param("workRequestId"), q.listOpts()) + + return listReply(q, pg, err) +} + +func (h *Handler) listWorkRequestErrors(q *req) error { + if err := h.Backend.WorkRequestExists(q.param("workRequestId")); err != nil { + return err + } + + return q.json(http.StatusOK, []struct{}{}, "") +} diff --git a/services/ocimonitoring/PARITY.md b/services/ocimonitoring/PARITY.md new file mode 100644 index 0000000000..8491c3e3e5 --- /dev/null +++ b/services/ocimonitoring/PARITY.md @@ -0,0 +1,38 @@ +--- +service: ocimonitoring +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: ee4983645 +last_audit_date: 2026-10-29 +overall: B +ops: + PostMetricData: {wire: ok, errors: partial, state: ok, persist: ok, note: "POST /20180401/metrics (the telemetry-ingestion endpoint shares the listener). Namespace starts with a letter, letters digits and underscores, and not the reserved oci_ and oracle_ prefixes; metric name starts with a letter and allows letters, digits, dots, underscores, hyphens and dollar signs; 1 to 20 dimensions, keys printable ASCII without spaces up to 256 characters, values 1 to 512 characters; resourceGroup pattern; metadata up to 256 characters; at least one datapoint, each within two hours in the past and under ten minutes in the future, count greater than zero; at most 50 unique streams per call. NON_ATOMIC (default) stores the valid streams and reports failedMetricsCount and failedMetrics, answering 400 only when every stream fails; ATOMIC answers 400 when any fails."} + SummarizeMetricsData: {wire: ok, errors: partial, state: ok, persist: n/a, note: "See families.mql. compartmentId query parameter and namespace and query in the body are required. startTime inclusive (default now minus 3 hours), endTime exclusive (default now). resolution 1m to 60m, 1h to 24h or 1d (default 1m) must not exceed the query interval; the aggregation windows start at multiples of the resolution and span the interval. Series with no datapoints in the range are omitted. Unaggregated queries return one series per stream, groupBy(...) merges streams by the listed dimensions."} + ListMetrics: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; name and namespace accept * wildcards, resourceGroup and dimensionFilters match exactly; groupBy namespace, name or resourceGroup returns the distinct combinations; sortBy NAMESPACE, NAME or RESOURCEGROUP with sortOrder; bare array, paged with opc-next-page."} + CreateAlarm: {wire: ok, errors: partial, state: ok, persist: ok, note: "200 with the alarm and etag. Needs displayName, compartmentId, metricCompartmentId, namespace, query, severity, destinations and isEnabled. The query must parse as the MQL subset and carry a threshold or absent(). Destinations are Notifications topic OCIDs (ocid1.onstopic.) or stream OCIDs (ocid1.stream.), at most one of each; topics are checked against OCI Notifications when it runs in the same process. pendingDuration PT1M to PT1H (default PT1M), repeatNotificationDuration PT1M to P30D, resolution 1m only, messageFormat RAW, PRETTY_JSON or ONS_OPTIMIZED (the latter two only for topic destinations)."} + GetAlarm: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header."} + ListAlarms: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; displayName and lifecycleState filters; sortBy displayName or severity, sortOrder; bare array."} + ListAlarmsStatus: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Per alarm OK, FIRING or SUSPENDED (inside the suppression window) with timestampTriggered."} + UpdateAlarm: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 with the alarm and a new etag; If-Match gives 412 NoEtagMatch. Every field is optional and validated as on create."} + DeleteAlarm: {wire: ok, errors: ok, state: ok, persist: ok, note: "204; the alarm and its history are removed."} + ChangeAlarmCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 with no body."} + GetAlarmHistory: {wire: ok, errors: ok, state: ok, persist: n/a, note: "STATE_HISTORY (The alarm state is X) and STATE_TRANSITION_HISTORY (State transitioned from X to Y, with timestampTriggered); both when no type is given; RULE_HISTORY and RULE_TRANSITION_HISTORY are accepted and empty. timestampGreaterThanOrEqualTo and timestampLessThan filter; entries are oldest first, paged with opc-next-page."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-monitoring-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id. InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound and NoEtagMatch follow the OCI reference."} + mql: {status: partial, note: "Metric[interval]{dim = \"v\", dim =~ \"a*|b\", dim != \"v\", dim !=~ \"x*\"}.groupBy(d1, d2).stat() with an optional trailing threshold (> >= < <= == != in (a, b) not in (a, b)). interval 1m-60m, 1h-24h, 1d. Statistics mean, avg, sum, count, max, min, rate, percentile(p), first, last, increment and absent(). A datapoint with count n stands for n occurrences (sum and mean weight by count, percentile uses nearest rank). Unsupported (rejected 400): grouping(), joins (&& and ||), arithmetic, nested queries and other statistics."} + alarm_evaluation: {status: ok, note: "Enabled alarms are evaluated on every request and by a 15 second worker against the injectable clock, over the last query interval (both ends inclusive). Any breaching stream (after groupBy) counts. The first breaching evaluation starts pendingDuration, the alarm fires once pendingDuration minus one minute has elapsed (so PT1M fires at once) and returns to OK on the first non-breaching evaluation. Transitions are recorded in the history. An alarm with no data in the window is not breaching, except absent()."} + notifications: {status: ok, note: "OK to FIRING, FIRING to OK and (every repeatNotificationDuration while firing) REPEAT notifications are published to the topic destinations through a narrow Publisher interface wired to the OCI Notifications backend in internal/app, so subscriptions receive them with the usual retry and confirmation behaviour. RAW and PRETTY_JSON publish a JSON document (dedupeKey, title, body, type, severity, timestamp, alarmMetaData with dimensions and metricValues); ONS_OPTIMIZED publishes plain text. Suppressed alarms send nothing."} + retention: {status: ok, note: "Datapoints are dropped 90 days after their timestamp."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-monitoring-port / OCI_MONITORING_PORT (default 8161); never on the shared AWS router; cli.go reserves the port in the shared pool. The telemetry and telemetry-ingestion endpoints are the same host."} +items_still_open: + - "Alarm suppressions (CreateAlarmSuppression, GetAlarmSuppression, ListAlarmSuppressions, DeleteAlarmSuppression, RemoveAlarmSuppression, SummarizeAlarmSuppressionHistory) and RetrieveDimensionStates are not routed; only the suppression property on the alarm itself is honoured." + - "Alarm evaluation is per alarm, not per metric stream: isNotificationsPerMetricDimensionEnabled is stored but notifications are always grouped, and alarm overrides, ruleName rules and evaluationSlackDuration are stored but not evaluated." + - "Alarm notifications to Streaming destinations are accepted but not delivered; only Notifications topics receive them." + - "MQL lacks grouping(), joins, arithmetic and nested queries; see families.mql. Metrics emitted by other OCI services (oci_computeagent and so on) do not exist, only posted custom metrics." + - "compartmentIdInSubtree and metricCompartmentIdInSubtree are ignored (the emulator has no compartment tree); metrics and alarms match the exact compartment." + - "The datapoint timestamp window, the 50 stream limit, the dimension and name rules, the 90 day retention and the pendingDuration and repeat limits are recalled from the Oracle documentation and SDK comments, not verified against the live service; the notification JSON layout follows the documented alarm message format only approximately." + - "Datapoints are held in memory and in snapshots, so heavy ingestion grows the snapshot; there is no per-stream cap." + - "opc-retry-token idempotency, content-encoding gzip request bodies, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified." + - "Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set." +--- diff --git a/services/ocimonitoring/README.md b/services/ocimonitoring/README.md new file mode 100644 index 0000000000..74781e582d --- /dev/null +++ b/services/ocimonitoring/README.md @@ -0,0 +1,31 @@ + +# OCI Monitoring + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-29 (`ee4983645`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 11 (8 ok, 3 partial) | +| Feature families | 8 (6 ok, 2 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Alarm suppressions (CreateAlarmSuppression, GetAlarmSuppression, ListAlarmSuppressions, DeleteAlarmSuppression, RemoveAlarmSuppression, SummarizeAlarmSuppressionHistory) and RetrieveDimensionStates are not routed; only the suppression property on the alarm itself is honoured. +- Alarm evaluation is per alarm, not per metric stream: isNotificationsPerMetricDimensionEnabled is stored but notifications are always grouped, and alarm overrides, ruleName rules and evaluationSlackDuration are stored but not evaluated. +- Alarm notifications to Streaming destinations are accepted but not delivered; only Notifications topics receive them. +- MQL lacks grouping(), joins, arithmetic and nested queries; see families.mql. Metrics emitted by other OCI services (oci_computeagent and so on) do not exist, only posted custom metrics. +- compartmentIdInSubtree and metricCompartmentIdInSubtree are ignored (the emulator has no compartment tree); metrics and alarms match the exact compartment. +- The datapoint timestamp window, the 50 stream limit, the dimension and name rules, the 90 day retention and the pendingDuration and repeat limits are recalled from the Oracle documentation and SDK comments, not verified against the live service; the notification JSON layout follows the documented alarm message format only approximately. +- Datapoints are held in memory and in snapshots, so heavy ingestion grows the snapshot; there is no per-stream cap. +- opc-retry-token idempotency, content-encoding gzip request bodies, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. +- Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocimonitoring/alarms.go b/services/ocimonitoring/alarms.go new file mode 100644 index 0000000000..b3ba39da3a --- /dev/null +++ b/services/ocimonitoring/alarms.go @@ -0,0 +1,918 @@ +package ocimonitoring + +import ( + "encoding/json" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + destTopicPrefix = "ocid1.onstopic." + destStreamPrefix = "ocid1.stream." + minDuration = time.Minute + maxPending = time.Hour + maxRepeat = 30 * 24 * time.Hour + defaultPending = "PT1M" + onlyResolution = "1m" + formatRaw = "RAW" + formatPretty = "PRETTY_JSON" + formatONS = "ONS_OPTIMIZED" +) + +var isoDurationRe = regexp.MustCompile(`^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$`) + +// parseISODuration parses the ISO 8601 day/hour/minute/second durations alarms accept. +func parseISODuration(s string) (time.Duration, bool) { + m := isoDurationRe.FindStringSubmatch(s) + if m == nil || s == "P" || strings.HasSuffix(s, "T") { + return 0, false + } + + var d time.Duration + + for i, unit := range []time.Duration{24 * time.Hour, time.Hour, time.Minute, time.Second} { + if m[i+1] == "" { + continue + } + + n, err := strconv.Atoi(m[i+1]) + if err != nil { + return 0, false + } + + d += time.Duration(n) * unit + } + + return d, true +} + +// SuppressionWire is the alarm suppression window. +type SuppressionWire struct { + TimeSuppressFrom time.Time `json:"timeSuppressFrom"` + TimeSuppressUntil time.Time `json:"timeSuppressUntil"` + Description string `json:"description,omitempty"` +} + +// CreateAlarmIn is the CreateAlarm request body. +type CreateAlarmIn struct { + Tags + InSubtree *bool `json:"metricCompartmentIdInSubtree"` + PerDimension *bool `json:"isNotificationsPerMetricDimensionEnabled"` + Enabled *bool `json:"isEnabled"` + Suppression *SuppressionWire `json:"suppression"` + ResourceGroup *string `json:"resourceGroup"` + Resolution *string `json:"resolution"` + Pending *string `json:"pendingDuration"` + Body *string `json:"body"` + MessageFormat *string `json:"messageFormat"` + Repeat *string `json:"repeatNotificationDuration"` + RuleName *string `json:"ruleName"` + NotifVersion *string `json:"notificationVersion"` + NotifTitle *string `json:"notificationTitle"` + EvalSlack *string `json:"evaluationSlackDuration"` + AlarmSummary *string `json:"alarmSummary"` + Name string `json:"displayName"` + Compartment string `json:"compartmentId"` + MetricComp string `json:"metricCompartmentId"` + Namespace string `json:"namespace"` + Query string `json:"query"` + Severity string `json:"severity"` + Destinations []string `json:"destinations"` + Overrides []json.RawMessage `json:"overrides"` +} + +// UpdateAlarmIn is the UpdateAlarm request body. +type UpdateAlarmIn struct { + Tags + Name *string `json:"displayName"` + Compartment *string `json:"compartmentId"` + MetricComp *string `json:"metricCompartmentId"` + InSubtree *bool `json:"metricCompartmentIdInSubtree"` + Namespace *string `json:"namespace"` + ResourceGroup *string `json:"resourceGroup"` + Query *string `json:"query"` + Resolution *string `json:"resolution"` + Pending *string `json:"pendingDuration"` + Severity *string `json:"severity"` + Body *string `json:"body"` + PerDimension *bool `json:"isNotificationsPerMetricDimensionEnabled"` + MessageFormat *string `json:"messageFormat"` + Destinations []string `json:"destinations"` + Repeat *string `json:"repeatNotificationDuration"` + Suppression *SuppressionWire `json:"suppression"` + Enabled *bool `json:"isEnabled"` + RuleName *string `json:"ruleName"` + NotifVersion *string `json:"notificationVersion"` + NotifTitle *string `json:"notificationTitle"` + EvalSlack *string `json:"evaluationSlackDuration"` + AlarmSummary *string `json:"alarmSummary"` + Overrides []json.RawMessage `json:"overrides"` +} + +// AlarmWire is the Alarm response shape. +type AlarmWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + Suppression *SuppressionWire `json:"suppression,omitempty"` + InSubtree *bool `json:"metricCompartmentIdInSubtree,omitempty"` + PerDimension *bool `json:"isNotificationsPerMetricDimensionEnabled,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + RuleName *string `json:"ruleName,omitempty"` + NotifVersion *string `json:"notificationVersion,omitempty"` + NotifTitle *string `json:"notificationTitle,omitempty"` + EvalSlack *string `json:"evaluationSlackDuration,omitempty"` + AlarmSummary *string `json:"alarmSummary,omitempty"` + ID string `json:"id"` + Name string `json:"displayName"` + CompartmentID string `json:"compartmentId"` + MetricComp string `json:"metricCompartmentId"` + Namespace string `json:"namespace"` + ResourceGroup string `json:"resourceGroup,omitempty"` + Query string `json:"query"` + Resolution string `json:"resolution"` + Pending string `json:"pendingDuration"` + Severity string `json:"severity"` + Body string `json:"body,omitempty"` + MessageFormat string `json:"messageFormat"` + Repeat string `json:"repeatNotificationDuration,omitempty"` + LifecycleState string `json:"lifecycleState"` + Destinations []string `json:"destinations"` + Overrides []json.RawMessage `json:"overrides,omitempty"` + Enabled bool `json:"isEnabled"` +} + +// AlarmSummaryWire is the ListAlarms item. +type AlarmSummaryWire struct { + Suppression *SuppressionWire `json:"suppression,omitempty"` + PerDimension *bool `json:"isNotificationsPerMetricDimensionEnabled,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + Name string `json:"displayName"` + CompartmentID string `json:"compartmentId"` + MetricComp string `json:"metricCompartmentId"` + Namespace string `json:"namespace"` + ResourceGroup string `json:"resourceGroup,omitempty"` + Query string `json:"query"` + Severity string `json:"severity"` + LifecycleState string `json:"lifecycleState"` + Destinations []string `json:"destinations"` + Enabled bool `json:"isEnabled"` +} + +// AlarmStatusWire is the ListAlarmsStatus item. +type AlarmStatusWire struct { + TimestampTriggered time.Time `json:"timestampTriggered"` + Suppression *SuppressionWire `json:"suppression,omitempty"` + ID string `json:"id"` + Name string `json:"displayName"` + Severity string `json:"severity"` + Status string `json:"status"` +} + +func suppressionView(s *SuppressionSpec) *SuppressionWire { + if s == nil { + return nil + } + + return &SuppressionWire{TimeSuppressFrom: s.From, TimeSuppressUntil: s.Until, Description: s.Description} +} + +func extra(a *Alarm, k string) *string { + if v, ok := a.Extra[k]; ok { + return &v + } + + return nil +} + +func alarmView(a *Alarm) AlarmWire { + return AlarmWire{ + ID: a.ID, Name: a.Name, CompartmentID: a.CompartmentID, MetricComp: a.MetricCompartmentID, + InSubtree: a.InSubtree, Namespace: a.Namespace, ResourceGroup: a.ResourceGroup, Query: a.Query, + Resolution: a.Resolution, Pending: a.PendingDuration, Severity: a.Severity, Body: a.Body, + PerDimension: a.PerDimension, MessageFormat: a.MessageFormat, Destinations: a.Destinations, + Repeat: a.RepeatNotification, Suppression: suppressionView(a.Suppression), Enabled: a.Enabled, + LifecycleState: stateActive, FreeformTags: a.Tags.FreeformTags, DefinedTags: a.Tags.DefinedTags, + Overrides: a.Overrides, RuleName: extra(a, "ruleName"), NotifVersion: extra(a, "notificationVersion"), + NotifTitle: extra(a, "notificationTitle"), EvalSlack: extra(a, "evaluationSlackDuration"), + AlarmSummary: extra(a, "alarmSummary"), TimeCreated: a.TimeCreated, TimeUpdated: a.TimeUpdated, + } +} + +func validSeverity(s string) (string, *APIError) { + for _, v := range []string{"CRITICAL", "ERROR", "WARNING", "INFO"} { + if strs.Equal(s, v) { + return v, nil + } + } + + return "", errParam("severity must be CRITICAL, ERROR, WARNING or INFO") +} + +func validFormat(f *string, destinations []string) (string, *APIError) { + if f == nil || *f == "" { + return formatRaw, nil + } + + for _, v := range []string{formatRaw, formatPretty, formatONS} { + if strs.Equal(*f, v) { + if v != formatRaw { + for _, d := range destinations { + if !strings.HasPrefix(d, destTopicPrefix) { + return "", errParam( + "messageFormat " + v + " is only available when the destinations are Notifications topics", + ) + } + } + } + + return v, nil + } + } + + return "", errParam("messageFormat must be RAW, PRETTY_JSON or ONS_OPTIMIZED") +} + +func validDuration(name string, s *string, hi time.Duration) *APIError { + if s == nil { + return nil + } + + d, ok := parseISODuration(*s) + if !ok || d < minDuration || d > hi { + return errParam(name + " must be an ISO 8601 duration between " + minDuration.String() + " and " + hi.String()) + } + + return nil +} + +func validSuppression(s *SuppressionWire) *APIError { + if s != nil && !s.TimeSuppressFrom.Before(s.TimeSuppressUntil) { + return errParam("suppression.timeSuppressFrom must be before timeSuppressUntil") + } + + return nil +} + +// validateQuery requires an alarm query to carry a trigger rule: a threshold, or absent(). +func validateQuery(query string) *APIError { + q, err := ParseMQL(query) + if err != nil { + aerr, _ := err.(*APIError) //nolint:errorlint // ParseMQL returns only *APIError + + return aerr + } + + if q.Pred == nil && q.Stat != statAbsent { + return errParam("an alarm query needs a threshold condition such as > 80, or the absent() statistic") + } + + return nil +} + +func (b *InMemoryBackend) validateDestinations(dests []string) *APIError { + if len(dests) == 0 { + return errMissing("destinations must contain at least one destination") + } + + var topics, streams int + + for _, d := range dests { + switch { + case strings.HasPrefix(d, destTopicPrefix): + topics++ + + if b.topicChecker != nil && !b.topicChecker(d) { + return errParam("destination " + d + " does not reference an existing Notifications topic") + } + case strings.HasPrefix(d, destStreamPrefix): + streams++ + default: + return errParam("destination " + d + " must be the OCID of a Notifications topic or a Streaming stream") + } + } + + if topics > 1 || streams > 1 { + return errParam("at most one destination per destination service is allowed") + } + + return nil +} + +func strVal(p *string) string { + if p == nil { + return "" + } + + return *p +} + +func setExtra(a *Alarm, k string, v *string) { + if v == nil { + return + } + + if a.Extra == nil { + a.Extra = map[string]string{} + } + + a.Extra[k] = *v +} + +// CreateAlarm validates and stores an alarm; it starts OK. +func (b *InMemoryBackend) CreateAlarm(in CreateAlarmIn) (AlarmWire, string, error) { + if in.Compartment == "" || in.MetricComp == "" || in.Namespace == "" || in.Query == "" || in.Enabled == nil { + return AlarmWire{}, "", errMissing( + "displayName, compartmentId, metricCompartmentId, namespace, query, severity, " + + "destinations and isEnabled are required", + ) + } + + sev, aerr := validSeverity(in.Severity) + if aerr != nil { + return AlarmWire{}, "", aerr + } + + if aerr = firstErr( + validName( + in.Name, + ), + validateQuery(in.Query), + validDuration("pendingDuration", in.Pending, maxPending), + validDuration("repeatNotificationDuration", in.Repeat, maxRepeat), + validSuppression(in.Suppression), + validResolution(in.Resolution), + ); aerr != nil { + return AlarmWire{}, "", aerr + } + + format, aerr := validFormat(in.MessageFormat, in.Destinations) + if aerr != nil { + return AlarmWire{}, "", aerr + } + + b.mu.Lock("CreateAlarm") + defer b.mu.Unlock() + + if aerr = b.validateDestinations(in.Destinations); aerr != nil { + return AlarmWire{}, "", aerr + } + + now := b.nowUTC() + a := &Alarm{ + ID: newOCID("alarm"), Name: in.Name, CompartmentID: in.Compartment, MetricCompartmentID: in.MetricComp, + Namespace: in.Namespace, ResourceGroup: strVal(in.ResourceGroup), Query: in.Query, Severity: sev, + Body: strVal(in.Body), Resolution: onlyResolution, PendingDuration: orDefault(in.Pending, defaultPending), + RepeatNotification: strVal(in.Repeat), MessageFormat: format, Destinations: in.Destinations, + Enabled: *in.Enabled, InSubtree: in.InSubtree, PerDimension: in.PerDimension, Overrides: in.Overrides, + Suppression: suppressionSpec(in.Suppression), Tags: in.Tags, Status: statusOK, StatusSince: now, + TimeCreated: now, TimeUpdated: now, Etag: newEtag(), Seq: b.nextSeq(), + } + + for k, v := range map[string]*string{ + "ruleName": in.RuleName, "notificationVersion": in.NotifVersion, "notificationTitle": in.NotifTitle, + "evaluationSlackDuration": in.EvalSlack, "alarmSummary": in.AlarmSummary, + } { + setExtra(a, k, v) + } + + a.History = []HistoryEntry{ + {Timestamp: now, TimestampTriggered: now, To: statusOK, Summary: "The alarm state is OK"}, + } + b.alarms[a.ID] = a + + return alarmView(a), a.Etag, nil +} + +func validResolution(r *string) *APIError { + if r != nil && *r != onlyResolution { + return errParam("resolution must be 1m") + } + + return nil +} + +func validName(name string) *APIError { + if strings.TrimSpace(name) == "" { + return errMissing("displayName is required") + } + + if len(name) > maxName { + return errParam("displayName must be at most 255 characters") + } + + return nil +} + +func orDefault(p *string, d string) string { + if p == nil || *p == "" { + return d + } + + return *p +} + +func suppressionSpec(s *SuppressionWire) *SuppressionSpec { + if s == nil { + return nil + } + + return &SuppressionSpec{ + From: s.TimeSuppressFrom.UTC(), + Until: s.TimeSuppressUntil.UTC(), + Description: s.Description, + } +} + +func (b *InMemoryBackend) liveAlarm(id string) (*Alarm, *APIError) { + a, ok := b.alarms[id] + if !ok { + return nil, errNotFound(id) + } + + return a, nil +} + +// GetAlarm returns one alarm and its etag. +func (b *InMemoryBackend) GetAlarm(id string) (AlarmWire, string, error) { + b.mu.RLock("GetAlarm") + defer b.mu.RUnlock() + + a, aerr := b.liveAlarm(id) + if aerr != nil { + return AlarmWire{}, "", aerr + } + + return alarmView(a), a.Etag, nil +} + +// AlarmFilter narrows ListAlarms and ListAlarmsStatus. +type AlarmFilter struct { + Compartment, Name, State string +} + +func (b *InMemoryBackend) filtered(f AlarmFilter) ([]*Alarm, *APIError) { + if f.Compartment == "" { + return nil, errMissing("compartmentId is required") + } + + var out []*Alarm + + for _, a := range b.alarms { + if a.CompartmentID == f.Compartment && (f.Name == "" || a.Name == f.Name) && + (f.State == "" || strs.Equal(f.State, stateActive)) { + out = append(out, a) + } + } + + return out, nil +} + +func compareAlarms(a, c *Alarm, by string) int { + if by == "severity" { + return severityRank(a.Severity) - severityRank(c.Severity) + } + + if by == "displayName" { + return compareStr(a.Name, c.Name) + } + + return int(a.Seq - c.Seq) +} + +func severityRank(s string) int { + return slices.Index([]string{"CRITICAL", "ERROR", "WARNING", "INFO"}, s) +} + +// ListAlarms lists the alarms of a compartment. +func (b *InMemoryBackend) ListAlarms(f AlarmFilter, o ListOpts) (page.Page[AlarmSummaryWire], error) { + b.mu.RLock("ListAlarms") + defer b.mu.RUnlock() + + all, aerr := b.filtered(f) + if aerr != nil { + return page.Page[AlarmSummaryWire]{}, aerr + } + + pg := paginate(all, o, compareAlarms, "displayName") + + out := make([]AlarmSummaryWire, 0, len(pg.Data)) + for _, a := range pg.Data { + out = append(out, AlarmSummaryWire{ + ID: a.ID, Name: a.Name, CompartmentID: a.CompartmentID, MetricComp: a.MetricCompartmentID, + Namespace: a.Namespace, ResourceGroup: a.ResourceGroup, Query: a.Query, Severity: a.Severity, + LifecycleState: stateActive, Destinations: a.Destinations, Enabled: a.Enabled, + Suppression: suppressionView(a.Suppression), PerDimension: a.PerDimension, + FreeformTags: a.Tags.FreeformTags, DefinedTags: a.Tags.DefinedTags, + }) + } + + return page.Page[AlarmSummaryWire]{Data: out, Next: pg.Next}, nil +} + +// ListAlarmsStatus lists the evaluation status of the alarms of a compartment. +func (b *InMemoryBackend) ListAlarmsStatus(f AlarmFilter, o ListOpts) (page.Page[AlarmStatusWire], error) { + b.mu.RLock("ListAlarmsStatus") + defer b.mu.RUnlock() + + all, aerr := b.filtered(f) + if aerr != nil { + return page.Page[AlarmStatusWire]{}, aerr + } + + pg := paginate(all, o, compareAlarms, "displayName") + now := b.nowUTC() + + out := make([]AlarmStatusWire, 0, len(pg.Data)) + for _, a := range pg.Data { + status := a.Status + if a.suppressedAt(now) { + status = statusSuspended + } + + out = append(out, AlarmStatusWire{ + ID: a.ID, Name: a.Name, Severity: a.Severity, Status: status, TimestampTriggered: a.StatusSince, + Suppression: suppressionView(a.Suppression), + }) + } + + return page.Page[AlarmStatusWire]{Data: out, Next: pg.Next}, nil +} + +func (a *Alarm) suppressedAt(now time.Time) bool { + return a.Suppression != nil && !now.Before(a.Suppression.From) && now.Before(a.Suppression.Until) +} + +// UpdateAlarm changes the supplied fields of an alarm. +func (b *InMemoryBackend) UpdateAlarm(id, ifMatch string, in UpdateAlarmIn) (AlarmWire, string, error) { + if aerr := firstErr( + validDuration("pendingDuration", in.Pending, maxPending), + validDuration("repeatNotificationDuration", in.Repeat, maxRepeat), + validSuppression(in.Suppression), validResolution(in.Resolution), optName(in.Name), optQuery(in.Query), + ); aerr != nil { + return AlarmWire{}, "", aerr + } + + b.mu.Lock("UpdateAlarm") + defer b.mu.Unlock() + + a, aerr := b.liveAlarm(id) + if aerr != nil { + return AlarmWire{}, "", aerr + } + + if aerr = checkEtag(a.Etag, ifMatch); aerr != nil { + return AlarmWire{}, "", aerr + } + + if aerr = b.applyAlarmUpdate(a, in); aerr != nil { + return AlarmWire{}, "", aerr + } + + a.TimeUpdated, a.Etag = b.nowUTC(), newEtag() + + return alarmView(a), a.Etag, nil +} + +func optName(n *string) *APIError { + if n == nil { + return nil + } + + return validName(*n) +} + +func optQuery(q *string) *APIError { + if q == nil { + return nil + } + + return validateQuery(*q) +} + +func (b *InMemoryBackend) applyAlarmUpdate(a *Alarm, in UpdateAlarmIn) *APIError { + dests := a.Destinations + if in.Destinations != nil { + dests = in.Destinations + if aerr := b.validateDestinations(dests); aerr != nil { + return aerr + } + } + + format := a.MessageFormat + if in.MessageFormat != nil || in.Destinations != nil { + f := in.MessageFormat + if f == nil { + f = &a.MessageFormat + } + + var aerr *APIError + if format, aerr = validFormat(f, dests); aerr != nil { + return aerr + } + } + + if in.Severity != nil { + sev, aerr := validSeverity(*in.Severity) + if aerr != nil { + return aerr + } + + a.Severity = sev + } + + a.Destinations, a.MessageFormat = dests, format + applyScalars(a, in) + a.Tags.merge(in.Tags) + + return nil +} + +// applyScalars copies the optional plain fields of an update onto the alarm. +func applyScalars(a *Alarm, in UpdateAlarmIn) { + assign(&a.Name, in.Name) + assign(&a.CompartmentID, in.Compartment) + assign(&a.MetricCompartmentID, in.MetricComp) + assign(&a.Namespace, in.Namespace) + assign(&a.ResourceGroup, in.ResourceGroup) + assign(&a.Body, in.Body) + assign(&a.PendingDuration, in.Pending) + assign(&a.RepeatNotification, in.Repeat) + assign(&a.Query, in.Query) + + if in.Enabled != nil { + a.Enabled = *in.Enabled + } + + if in.InSubtree != nil { + a.InSubtree = in.InSubtree + } + + if in.PerDimension != nil { + a.PerDimension = in.PerDimension + } + + if in.Suppression != nil { + a.Suppression = suppressionSpec(in.Suppression) + } + + if in.Overrides != nil { + a.Overrides = in.Overrides + } + + for k, v := range map[string]*string{ + "ruleName": in.RuleName, "notificationVersion": in.NotifVersion, "notificationTitle": in.NotifTitle, + "evaluationSlackDuration": in.EvalSlack, "alarmSummary": in.AlarmSummary, + } { + setExtra(a, k, v) + } +} + +func assign(dst *string, src *string) { + if src != nil { + *dst = *src + } +} + +// DeleteAlarm removes an alarm. +func (b *InMemoryBackend) DeleteAlarm(id, ifMatch string) error { + b.mu.Lock("DeleteAlarm") + defer b.mu.Unlock() + + a, aerr := b.liveAlarm(id) + if aerr != nil { + return aerr + } + + if aerr = checkEtag(a.Etag, ifMatch); aerr != nil { + return aerr + } + + delete(b.alarms, id) + + return nil +} + +// ChangeAlarmCompartment moves an alarm to another compartment. +func (b *InMemoryBackend) ChangeAlarmCompartment(id, ifMatch, compartment string) error { + if compartment == "" { + return errMissing("compartmentId is required") + } + + b.mu.Lock("ChangeAlarmCompartment") + defer b.mu.Unlock() + + a, aerr := b.liveAlarm(id) + if aerr != nil { + return aerr + } + + if aerr = checkEtag(a.Etag, ifMatch); aerr != nil { + return aerr + } + + a.CompartmentID, a.Etag, a.TimeUpdated = compartment, newEtag(), b.nowUTC() + + return nil +} + +type historyEntryWire struct { + Timestamp time.Time `json:"timestamp"` + TimestampTriggered *time.Time `json:"timestampTriggered,omitempty"` + AlarmSummary string `json:"alarmSummary"` + Summary string `json:"summary"` +} + +// AlarmHistoryWire is the GetAlarmHistory response body. +type AlarmHistoryWire struct { + AlarmID string `json:"alarmId"` + Entries []historyEntryWire `json:"entries"` + IsEnabled bool `json:"isEnabled"` +} + +// HistoryFilter narrows GetAlarmHistory. +type HistoryFilter struct { + From, To *time.Time + Type string +} + +func (b *InMemoryBackend) GetAlarmHistory(id string, f HistoryFilter, o ListOpts) (AlarmHistoryWire, string, error) { + switch f.Type { + case "", "STATE_HISTORY", "STATE_TRANSITION_HISTORY", "RULE_HISTORY", "RULE_TRANSITION_HISTORY": + default: + return AlarmHistoryWire{}, "", errParam("alarmHistorytype is not a valid history type") + } + + b.mu.RLock("GetAlarmHistory") + defer b.mu.RUnlock() + + a, aerr := b.liveAlarm(id) + if aerr != nil { + return AlarmHistoryWire{}, "", aerr + } + + pg := page.New(historyEntries(a, f), o.Page, o.Limit, defaultListLimit) + if pg.Data == nil { + pg.Data = []historyEntryWire{} + } + + return AlarmHistoryWire{AlarmID: a.ID, Entries: pg.Data, IsEnabled: a.Enabled}, pg.Next, nil +} + +// historyEntries renders the recorded transitions as state history and/or state transition history entries. +func historyEntries(a *Alarm, f HistoryFilter) []historyEntryWire { + summary := a.Name + if v := extra(a, "alarmSummary"); v != nil { + summary = *v + } + + var all []historyEntryWire + + for _, h := range a.History { + if (f.From != nil && h.Timestamp.Before(*f.From)) || (f.To != nil && !h.Timestamp.Before(*f.To)) { + continue + } + + if f.Type == "" || f.Type == "STATE_HISTORY" { + all = append(all, historyEntryWire{Timestamp: h.Timestamp, AlarmSummary: summary, Summary: h.Summary}) + } + + if h.From != "" && (f.Type == "" || f.Type == "STATE_TRANSITION_HISTORY") { + trig := h.TimestampTriggered + all = append(all, historyEntryWire{ + Timestamp: h.Timestamp, TimestampTriggered: &trig, AlarmSummary: summary, + Summary: "State transitioned from " + titleCase(h.From) + " to " + titleCase(h.To), + }) + } + } + + return all +} + +func titleCase(s string) string { + if s == "" { + return s + } + + return s[:1] + strings.ToLower(s[1:]) +} + +func (h *Handler) createAlarm(q *req) error { + var in CreateAlarmIn + if err := q.decode(&in); err != nil { + return err + } + + v, etag, err := h.Backend.CreateAlarm(in) + + return reply(q, v, etag, err) +} + +func (h *Handler) getAlarm(q *req) error { + v, etag, err := h.Backend.GetAlarm(q.alarmID()) + + return reply(q, v, etag, err) +} + +func (h *Handler) alarmFilter(q *req) AlarmFilter { + return AlarmFilter{ + Compartment: q.query("compartmentId"), + Name: q.query("displayName"), + State: q.query("lifecycleState"), + } +} + +func (h *Handler) listAlarms(q *req) error { + pg, err := h.Backend.ListAlarms(h.alarmFilter(q), q.listOpts()) + + return listReply(q, pg, err) +} + +func (h *Handler) listAlarmsStatus(q *req) error { + pg, err := h.Backend.ListAlarmsStatus(h.alarmFilter(q), q.listOpts()) + + return listReply(q, pg, err) +} + +func (h *Handler) updateAlarm(q *req) error { + var in UpdateAlarmIn + if err := q.decode(&in); err != nil { + return err + } + + v, etag, err := h.Backend.UpdateAlarm(q.alarmID(), q.ifMatch(), in) + + return reply(q, v, etag, err) +} + +func (h *Handler) deleteAlarm(q *req) error { + if err := h.Backend.DeleteAlarm(q.alarmID(), q.ifMatch()); err != nil { + return err + } + + q.w.WriteHeader(http.StatusNoContent) + + return nil +} + +func (h *Handler) changeAlarmCompartment(q *req) error { + var in struct { + CompartmentID string `json:"compartmentId"` + } + + if err := q.decode(&in); err != nil { + return err + } + + if err := h.Backend.ChangeAlarmCompartment(q.alarmID(), q.ifMatch(), in.CompartmentID); err != nil { + return err + } + + q.w.WriteHeader(http.StatusOK) + + return nil +} + +func parseQueryTime(raw string) (*time.Time, *APIError) { + if raw == "" { + return nil, nil + } + + t, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return nil, errParam("timestamps must be RFC 3339") + } + + return &t, nil +} + +func (h *Handler) getAlarmHistory(q *req) error { + from, aerr := parseQueryTime(q.query("timestampGreaterThanOrEqualTo")) + if aerr != nil { + return aerr + } + + to, aerr := parseQueryTime(q.query("timestampLessThan")) + if aerr != nil { + return aerr + } + + out, next, err := h.Backend.GetAlarmHistory(q.alarmID(), + HistoryFilter{From: from, To: to, Type: q.query("alarmHistorytype")}, q.listOpts()) + if err != nil { + return err + } + + q.nextPage(next) + + return q.json(http.StatusOK, out, "") +} diff --git a/services/ocimonitoring/alarms_test.go b/services/ocimonitoring/alarms_test.go new file mode 100644 index 0000000000..0d7d64e76c --- /dev/null +++ b/services/ocimonitoring/alarms_test.go @@ -0,0 +1,536 @@ +package ocimonitoring_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/monitoring" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (e *env) alarmDetails(topic string, mod func(*monitoring.CreateAlarmDetails)) monitoring.CreateAlarmDetails { + d := monitoring.CreateAlarmDetails{ + DisplayName: new("cpu-high"), CompartmentId: new(testCompartment), MetricCompartmentId: new(testCompartment), + Namespace: new(ns), Query: new(metric + "[1m].max() > 80"), Severity: monitoring.AlarmSeverityCritical, + Destinations: []string{topic}, IsEnabled: new(true), Body: new("CPU is high"), + } + if mod != nil { + mod(&d) + } + + return d +} + +func (e *env) mkAlarm(t *testing.T, topic string, mod func(*monitoring.CreateAlarmDetails)) monitoring.Alarm { + t.Helper() + + resp, err := e.mon.CreateAlarm( + t.Context(), + monitoring.CreateAlarmRequest{CreateAlarmDetails: e.alarmDetails(topic, mod)}, + ) + require.NoError(t, err) + require.NotEmpty(t, *resp.Etag) + + return resp.Alarm +} + +func (e *env) status(t *testing.T) monitoring.AlarmStatusSummaryStatusEnum { + t.Helper() + + resp, err := e.mon.ListAlarmsStatus( + t.Context(), + monitoring.ListAlarmsStatusRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + require.Len(t, resp.Items, 1) + + return resp.Items[0].Status +} + +const topicOCID = "ocid1.onstopic.oc1..topic" + +func TestAlarmValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mod func(*monitoring.CreateAlarmDetails) + code string + }{ + {"valid", nil, ""}, + { + "absent without threshold", + func(d *monitoring.CreateAlarmDetails) { d.Query = new(metric + "[1m].absent()") }, + "", + }, + { + "query without threshold", + func(d *monitoring.CreateAlarmDetails) { d.Query = new(metric + "[1m].mean()") }, + "InvalidParameter", + }, + {"unparsable query", func(d *monitoring.CreateAlarmDetails) { d.Query = new("nonsense") }, "InvalidParameter"}, + { + "in range threshold", + func(d *monitoring.CreateAlarmDetails) { d.Query = new(metric + "[5m].mean() in (1, 5)") }, + "", + }, + {"blank name", func(d *monitoring.CreateAlarmDetails) { d.DisplayName = new("") }, "MissingParameter"}, + {"no destinations", func(d *monitoring.CreateAlarmDetails) { d.Destinations = []string{} }, "MissingParameter"}, + { + "bad destination", + func(d *monitoring.CreateAlarmDetails) { d.Destinations = []string{"ocid1.queue.oc1..q"} }, + "InvalidParameter", + }, + {"two topics", func(d *monitoring.CreateAlarmDetails) { + d.Destinations = []string{topicOCID, "ocid1.onstopic.oc1..other"} + }, "InvalidParameter"}, + {"topic and stream", func(d *monitoring.CreateAlarmDetails) { + d.Destinations = []string{topicOCID, "ocid1.stream.oc1..s"} + }, ""}, + {"pretty json with stream", func(d *monitoring.CreateAlarmDetails) { + d.Destinations = []string{"ocid1.stream.oc1..s"} + d.MessageFormat = monitoring.CreateAlarmDetailsMessageFormatPrettyJson + }, "InvalidParameter"}, + { + "pending too long", + func(d *monitoring.CreateAlarmDetails) { d.PendingDuration = new("PT2H") }, + "InvalidParameter", + }, + { + "pending not iso", + func(d *monitoring.CreateAlarmDetails) { d.PendingDuration = new("5 minutes") }, + "InvalidParameter", + }, + {"pending ok", func(d *monitoring.CreateAlarmDetails) { d.PendingDuration = new("PT10M") }, ""}, + { + "repeat too short", + func(d *monitoring.CreateAlarmDetails) { d.RepeatNotificationDuration = new("PT30S") }, + "InvalidParameter", + }, + {"repeat ok", func(d *monitoring.CreateAlarmDetails) { d.RepeatNotificationDuration = new("P2D") }, ""}, + {"resolution 5m", func(d *monitoring.CreateAlarmDetails) { d.Resolution = new("5m") }, "InvalidParameter"}, + {"suppression reversed", func(d *monitoring.CreateAlarmDetails) { + d.Suppression = &monitoring.Suppression{ + TimeSuppressFrom: &common.SDKTime{Time: epoch().Add(time.Hour)}, + TimeSuppressUntil: &common.SDKTime{Time: epoch()}, + } + }, "InvalidParameter"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + _, err := e.mon.CreateAlarm(t.Context(), monitoring.CreateAlarmRequest{ + CreateAlarmDetails: e.alarmDetails(topicOCID, tt.mod), + }) + + if tt.code == "" { + require.NoError(t, err) + + return + } + + se := svcErr(t, err) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestAlarmCRUD(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a := e.mkAlarm(t, topicOCID, func(d *monitoring.CreateAlarmDetails) { + d.FreeformTags = map[string]string{"k": "v"} + d.NotificationTitle = new("custom title") + d.RuleName = new("rule-1") + }) + + assert.Equal(t, monitoring.AlarmLifecycleStateActive, a.LifecycleState) + assert.Equal(t, "PT1M", *a.PendingDuration) + assert.Equal(t, "1m", *a.Resolution) + assert.Equal(t, monitoring.AlarmMessageFormatRaw, a.MessageFormat) + assert.Equal(t, "custom title", *a.NotificationTitle) + + got, err := e.mon.GetAlarm(t.Context(), monitoring.GetAlarmRequest{AlarmId: a.Id}) + require.NoError(t, err) + assert.Equal(t, a.Query, got.Query) + assert.Equal(t, map[string]string{"k": "v"}, got.FreeformTags) + + _, err = e.mon.UpdateAlarm(t.Context(), monitoring.UpdateAlarmRequest{ + AlarmId: a.Id, IfMatch: new("stale"), Body: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.mon.UpdateAlarm(t.Context(), monitoring.UpdateAlarmRequest{ + AlarmId: a.Id, IfMatch: got.Etag, + DisplayName: new("renamed"), Severity: monitoring.AlarmSeverityWarning, IsEnabled: new(false), + Query: new(metric + "[1m].min() < 5"), MessageFormat: monitoring.UpdateAlarmDetailsMessageFormatPrettyJson, + }) + require.NoError(t, err) + assert.Equal(t, "renamed", *upd.DisplayName) + assert.Equal(t, monitoring.AlarmSeverityWarning, upd.Severity) + assert.False(t, *upd.IsEnabled) + assert.NotEqual(t, *got.Etag, *upd.Etag) + + _, err = e.mon.UpdateAlarm(t.Context(), monitoring.UpdateAlarmRequest{ + AlarmId: a.Id, Query: new(metric + "[1m].mean()"), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + list, err := e.mon.ListAlarms(t.Context(), monitoring.ListAlarmsRequest{ + CompartmentId: new(testCompartment), DisplayName: new("renamed"), + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + _, err = e.mon.ChangeAlarmCompartment(t.Context(), monitoring.ChangeAlarmCompartmentRequest{ + AlarmId: a.Id, + CompartmentId: new("ocid1.compartment.oc1..other"), + }) + require.NoError(t, err) + + list, err = e.mon.ListAlarms(t.Context(), monitoring.ListAlarmsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, list.Items) + + _, err = e.mon.DeleteAlarm(t.Context(), monitoring.DeleteAlarmRequest{AlarmId: a.Id}) + require.NoError(t, err) + + _, err = e.mon.GetAlarm(t.Context(), monitoring.GetAlarmRequest{AlarmId: a.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.mon.DeleteAlarm(t.Context(), monitoring.DeleteAlarmRequest{AlarmId: a.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestAlarmListSorting(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + seeds := []struct { + name string + sev monitoring.AlarmSeverityEnum + }{ + {"b", monitoring.AlarmSeverityInfo}, + {"a", monitoring.AlarmSeverityCritical}, + {"c", monitoring.AlarmSeverityWarning}, + } + + for _, s := range seeds { + e.mkAlarm(t, topicOCID, func(d *monitoring.CreateAlarmDetails) { + d.DisplayName, d.Severity = new(s.name), s.sev + }) + } + + tests := []struct { + name string + req monitoring.ListAlarmsRequest + want []string + }{ + { + "by name", + monitoring.ListAlarmsRequest{SortBy: monitoring.ListAlarmsSortByDisplayname}, + []string{"a", "b", "c"}, + }, + {"by name desc", monitoring.ListAlarmsRequest{ + SortBy: monitoring.ListAlarmsSortByDisplayname, SortOrder: monitoring.ListAlarmsSortOrderDesc, + }, []string{"c", "b", "a"}}, + { + "by severity", + monitoring.ListAlarmsRequest{SortBy: monitoring.ListAlarmsSortBySeverity}, + []string{"a", "c", "b"}, + }, + } + + for _, tt := range tests { + tt.req.CompartmentId = new(testCompartment) + + resp, err := e.mon.ListAlarms(t.Context(), tt.req) + require.NoError(t, err) + + var got []string + for _, a := range resp.Items { + got = append(got, *a.DisplayName) + } + + assert.Equal(t, tt.want, got, tt.name) + } +} + +func TestAlarmFiringDeliversToNotifications(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + o := newONS(t, e) + a := e.mkAlarm(t, o.topicID, nil) + dims := map[string]string{"host": "a"} + + assert.Equal(t, monitoring.AlarmStatusSummaryStatusOk, e.status(t)) + assert.True(t, o.sink.none(t), "an OK alarm sends nothing") + + e.post(t, ns, metric, dims, pt{0, 95, 0}) + assert.Equal( + t, + monitoring.AlarmStatusSummaryStatusFiring, + e.status(t), + "PT1M fires on the first breaching evaluation", + ) + + msg := o.sink.next(t) + assert.Equal(t, "alarms", msg.header.Get("X-Oci-Ns-Topicname")) + assert.Equal(t, o.topicID, msg.header.Get("X-Oci-Ns-Topicocid")) + assert.Contains(t, msg.header.Get("Content-Type"), "application/json") + + var n struct { + Type string `json:"type"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + DedupeKey string `json:"dedupeKey"` + AlarmMetaData []struct { + ID string `json:"id"` + Status string `json:"status"` + Query string `json:"query"` + Dimensions []map[string]string `json:"dimensions"` + MetricValues []map[string]float64 `json:"metricValues"` + TotalMetricsFiring int `json:"totalMetricsFiring"` + } `json:"alarmMetaData"` + } + + require.NoError(t, json.Unmarshal([]byte(msg.body), &n)) + assert.Equal(t, "OK_TO_FIRING", n.Type) + assert.Equal(t, "CRITICAL", n.Severity) + assert.Equal(t, "cpu-high", n.Title) + assert.Equal(t, "CPU is high", n.Body) + assert.Equal(t, *a.Id, n.DedupeKey) + require.Len(t, n.AlarmMetaData, 1) + assert.Equal(t, "FIRING", n.AlarmMetaData[0].Status) + assert.Equal(t, 1, n.AlarmMetaData[0].TotalMetricsFiring) + assert.Equal(t, []map[string]string{dims}, n.AlarmMetaData[0].Dimensions) + assert.InDelta(t, 95, n.AlarmMetaData[0].MetricValues[0][metric], 1e-9) + + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t)) + assert.True(t, o.sink.none(t), "no repeat without repeatNotificationDuration") + + e.clock.Advance(5 * time.Minute) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusOk, e.status(t), "no data in the window clears the alarm") + + var ok struct { + Type string `json:"type"` + } + + require.NoError(t, json.Unmarshal([]byte(o.sink.next(t).body), &ok)) + assert.Equal(t, "FIRING_TO_OK", ok.Type) + + hist, err := e.mon.GetAlarmHistory(t.Context(), monitoring.GetAlarmHistoryRequest{AlarmId: a.Id}) + require.NoError(t, err) + assert.True(t, *hist.IsEnabled) + + summaries := make([]string, 0, len(hist.Entries)) + for _, h := range hist.Entries { + summaries = append(summaries, *h.Summary) + } + + assert.Equal(t, []string{ + "The alarm state is OK", + "The alarm state is FIRING", "State transitioned from Ok to Firing", + "The alarm state is OK", "State transitioned from Firing to Ok", + }, summaries) + + trans, err := e.mon.GetAlarmHistory(t.Context(), monitoring.GetAlarmHistoryRequest{ + AlarmId: a.Id, AlarmHistorytype: monitoring.GetAlarmHistoryAlarmHistorytypeStateTransitionHistory, + }) + require.NoError(t, err) + require.Len(t, trans.Entries, 2) + assert.NotNil(t, trans.Entries[0].TimestampTriggered) + + state, err := e.mon.GetAlarmHistory(t.Context(), monitoring.GetAlarmHistoryRequest{ + AlarmId: a.Id, AlarmHistorytype: monitoring.GetAlarmHistoryAlarmHistorytypeStateHistory, + }) + require.NoError(t, err) + assert.Len(t, state.Entries, 3) +} + +func TestAlarmPendingDuration(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + o := newONS(t, e) + e.mkAlarm(t, o.topicID, func(d *monitoring.CreateAlarmDetails) { d.PendingDuration = new("PT3M") }) + + dims := map[string]string{"host": "a"} + + steps := []struct { + want monitoring.AlarmStatusSummaryStatusEnum + advance time.Duration + }{ + {monitoring.AlarmStatusSummaryStatusOk, 0}, + {monitoring.AlarmStatusSummaryStatusOk, time.Minute}, + {monitoring.AlarmStatusSummaryStatusFiring, time.Minute}, + } + + for i, s := range steps { + e.clock.Advance(s.advance) + e.post(t, ns, metric, dims, pt{0, 90, 0}) + assert.Equal(t, s.want, e.status(t), "step %d", i) + } + + msg := o.sink.next(t) + assert.Contains(t, msg.body, "OK_TO_FIRING") + assert.True(t, o.sink.none(t)) +} + +func TestAlarmPendingResetsWhenConditionClears(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + o := newONS(t, e) + e.mkAlarm(t, o.topicID, func(d *monitoring.CreateAlarmDetails) { d.PendingDuration = new("PT3M") }) + + dims := map[string]string{"host": "a"} + + e.post(t, ns, metric, dims, pt{0, 90, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusOk, e.status(t)) + + e.clock.Advance(90 * time.Second) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusOk, e.status(t), "the window is empty, pending resets") + + e.clock.Advance(30 * time.Second) + e.post(t, ns, metric, dims, pt{0, 90, 0}) + e.clock.Advance(time.Minute) + e.post(t, ns, metric, dims, pt{0, 90, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusOk, e.status(t), "only one minute of breach since the reset") + assert.True(t, o.sink.none(t)) +} + +func TestAlarmRepeatNotification(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + o := newONS(t, e) + e.mkAlarm(t, o.topicID, func(d *monitoring.CreateAlarmDetails) { d.RepeatNotificationDuration = new("PT2M") }) + + dims := map[string]string{"host": "a"} + e.post(t, ns, metric, dims, pt{0, 90, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t)) + assert.Contains(t, o.sink.next(t).body, "OK_TO_FIRING") + + e.clock.Advance(time.Minute) + e.post(t, ns, metric, dims, pt{0, 90, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t)) + assert.True(t, o.sink.none(t), "repeat interval not reached") + + e.clock.Advance(time.Minute) + e.post(t, ns, metric, dims, pt{0, 90, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t)) + assert.Contains(t, o.sink.next(t).body, "REPEAT") +} + +func TestAlarmSuppressionAndDisable(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + o := newONS(t, e) + a := e.mkAlarm(t, o.topicID, func(d *monitoring.CreateAlarmDetails) { + d.Suppression = &monitoring.Suppression{ + TimeSuppressFrom: &common.SDKTime{Time: epoch().Add(-time.Hour)}, + TimeSuppressUntil: &common.SDKTime{Time: epoch().Add(time.Hour)}, + Description: new("maintenance"), + } + }) + + e.post(t, ns, metric, map[string]string{"host": "a"}, pt{0, 90, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusSuspended, e.status(t)) + assert.True(t, o.sink.none(t), "suppressed alarms send nothing") + + got, err := e.mon.GetAlarm(t.Context(), monitoring.GetAlarmRequest{AlarmId: a.Id}) + require.NoError(t, err) + assert.Equal(t, "maintenance", *got.Suppression.Description) + + _, err = e.mon.UpdateAlarm(t.Context(), monitoring.UpdateAlarmRequest{ + AlarmId: a.Id, IsEnabled: new(false), + }) + require.NoError(t, err) + + e.clock.Advance(3 * time.Hour) + e.post(t, ns, metric, map[string]string{"host": "a"}, pt{0, 95, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t), "state is unchanged while disabled") + assert.True(t, o.sink.none(t)) +} + +func TestAlarmAbsentAndGrouping(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + o := newONS(t, e) + e.mkAlarm(t, o.topicID, func(d *monitoring.CreateAlarmDetails) { + d.Query = new(metric + "[1m].absent()") + d.MessageFormat = monitoring.CreateAlarmDetailsMessageFormatOnsOptimized + }) + + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t), "no data at all fires absent()") + + msg := o.sink.next(t) + assert.Contains(t, msg.header.Get("Content-Type"), "text/plain") + assert.Contains(t, msg.body, "Alarm: cpu-high") + assert.Contains(t, msg.body, "Status: FIRING") + + e.post(t, ns, metric, map[string]string{"host": "a"}, pt{0, 1, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusOk, e.status(t)) + assert.Contains(t, o.sink.next(t).body, "Status: OK") +} + +func TestAlarmGroupedThreshold(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + o := newONS(t, e) + e.mkAlarm(t, o.topicID, func(d *monitoring.CreateAlarmDetails) { + d.Query = new(metric + "[1m]{host =~ \"web*\"}.groupBy(host).sum() >= 100") + }) + + e.post(t, ns, metric, map[string]string{"host": "web1"}, pt{0, 60, 0}) + e.post(t, ns, metric, map[string]string{"host": "web2"}, pt{0, 120, 0}) + e.post(t, ns, metric, map[string]string{"host": "db1"}, pt{0, 500, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t)) + + var n struct { + AlarmMetaData []struct { + Dimensions []map[string]string `json:"dimensions"` + TotalMetricsFiring int `json:"totalMetricsFiring"` + } `json:"alarmMetaData"` + } + + require.NoError(t, json.Unmarshal([]byte(o.sink.next(t).body), &n)) + assert.Equal(t, 1, n.AlarmMetaData[0].TotalMetricsFiring, "only web2 breaches; db1 is filtered out") + assert.Equal(t, []map[string]string{{"host": "web2"}}, n.AlarmMetaData[0].Dimensions) +} + +func TestAlarmDestinationChecked(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + newONS(t, e) + + _, err := e.mon.CreateAlarm(t.Context(), monitoring.CreateAlarmRequest{ + CreateAlarmDetails: e.alarmDetails("ocid1.onstopic.oc1..ghost", nil), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestAlarmWithoutPublisherStillTransitions(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkAlarm(t, topicOCID, nil) + e.post(t, ns, metric, map[string]string{"host": "a"}, pt{0, 95, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t)) +} diff --git a/services/ocimonitoring/errors.go b/services/ocimonitoring/errors.go new file mode 100644 index 0000000000..6ecc229806 --- /dev/null +++ b/services/ocimonitoring/errors.go @@ -0,0 +1,41 @@ +package ocimonitoring + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocimonitoring: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } diff --git a/services/ocimonitoring/eval.go b/services/ocimonitoring/eval.go new file mode 100644 index 0000000000..dc2b5915c1 --- /dev/null +++ b/services/ocimonitoring/eval.go @@ -0,0 +1,258 @@ +package ocimonitoring + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" +) + +const ( + noticeTypeFiring = "OK_TO_FIRING" + noticeTypeOK = "FIRING_TO_OK" + noticeTypeRepeat = "REPEAT" + noticeVersion = 1.3 +) + +type notice struct { + topic, title, body string + asJSON bool +} + +type firing struct { + dims map[string]string + value float64 +} + +// evaluateAll steps every enabled alarm and returns the notifications they raised; the caller holds the lock. +func (b *InMemoryBackend) evaluateAll(now time.Time) []notice { + var out []notice + + ids := make([]string, 0, len(b.alarms)) + for id := range b.alarms { + ids = append(ids, id) + } + + slices.Sort(ids) + + for _, id := range ids { + if a := b.alarms[id]; a.Enabled { + out = append(out, b.evaluate(a, now)...) + } + } + + return out +} + +// breaching returns the metric streams whose aggregate over the last interval satisfies the alarm condition. +func (b *InMemoryBackend) breaching(a *Alarm, q *MQL, now time.Time) []firing { + groups := b.collectGroups(q, a.Namespace, a.MetricCompartmentID, a.ResourceGroup) + if q.Stat == statAbsent && len(groups) == 0 { + return []firing{{dims: map[string]string{}, value: 1}} + } + + var out []firing + + for _, g := range groups { + pts := pointsIn(g.points, now.Add(-q.Interval), now.Add(time.Nanosecond)) + + v, ok := windowStat(q, pts) + if !ok { + continue + } + + if (q.Pred != nil && q.Pred.holds(v)) || (q.Pred == nil && q.Stat == statAbsent && v == 1) { + out = append(out, firing{dims: g.dims, value: v}) + } + } + + return out +} + +// pendingNeeded is how long the condition must already have held before the alarm fires: the first +// breaching evaluation counts toward pendingDuration, so PT1M fires at once. +func pendingNeeded(a *Alarm) time.Duration { + d, _ := parseISODuration(a.PendingDuration) + + return max(d-minDuration, 0) +} + +func (b *InMemoryBackend) evaluate(a *Alarm, now time.Time) []notice { + q, err := ParseMQL(a.Query) + if err != nil { + return nil + } + + hits := b.breaching(a, q, now) + + switch { + case a.Status == statusOK && len(hits) > 0: + if a.PendingSince == nil { + a.PendingSince = &now + } + + if now.Sub(*a.PendingSince) < pendingNeeded(a) { + return nil + } + + return b.transition(a, statusFiring, noticeTypeFiring, now, hits) + case a.Status == statusOK: + a.PendingSince = nil + + return nil + case len(hits) == 0: + a.PendingSince = nil + + return b.transition(a, statusOK, noticeTypeOK, now, nil) + } + + if repeat, ok := parseISODuration( + a.RepeatNotification, + ); ok && a.LastNotified != nil && + now.Sub(*a.LastNotified) >= repeat { + return b.notices(a, noticeTypeRepeat, now, hits) + } + + return nil +} + +func (b *InMemoryBackend) transition(a *Alarm, to, kind string, now time.Time, hits []firing) []notice { + triggered := now + if to == statusFiring && a.PendingSince != nil { + triggered = *a.PendingSince + } + + a.History = append(a.History, HistoryEntry{ + Timestamp: now, TimestampTriggered: triggered, From: a.Status, To: to, Summary: "The alarm state is " + to, + }) + + if len(a.History) > maxHistory { + a.History = a.History[len(a.History)-maxHistory:] + } + + a.Status, a.StatusSince, a.PendingSince = to, now, nil + + return b.notices(a, kind, now, hits) +} + +func (b *InMemoryBackend) notices(a *Alarm, kind string, now time.Time, hits []firing) []notice { + if a.suppressedAt(now) { + return nil + } + + a.LastNotified = &now + + title, body, asJSON := renderNotice(a, kind, now, hits) + + var out []notice + + for _, d := range a.Destinations { + if strings.HasPrefix(d, destTopicPrefix) { + out = append(out, notice{topic: d, title: title, body: body, asJSON: asJSON}) + } + } + + return out +} + +type metaWire struct { + ID string `json:"id"` + Status string `json:"status"` + Severity string `json:"severity"` + Namespace string `json:"namespace"` + Query string `json:"query"` + AlarmSummary string `json:"alarmSummary"` + PendingDuration string `json:"pendingDuration"` + Dimensions []map[string]string `json:"dimensions"` + MetricValues []map[string]float64 `json:"metricValues"` + TotalMetricsFiring int `json:"totalMetricsFiring"` +} + +type noticeWire struct { + DedupeKey string `json:"dedupeKey"` + Title string `json:"title"` + Body string `json:"body"` + Type string `json:"type"` + Severity string `json:"severity"` + Timestamp string `json:"timestamp"` + NotificationType string `json:"notificationType"` + AlarmMetaData []metaWire `json:"alarmMetaData"` + TimestampEpochMillis int64 `json:"timestampEpochMillis"` + Version float64 `json:"version"` +} + +func renderNotice(a *Alarm, kind string, now time.Time, hits []firing) (string, string, bool) { + title := a.Name + if v := extra(a, "notificationTitle"); v != nil { + title = *v + } + + summary := a.Name + if v := extra(a, "alarmSummary"); v != nil { + summary = *v + } + + status := statusFiring + if kind == noticeTypeOK { + status = statusOK + } + + meta := metaWire{ + ID: a.ID, Status: status, Severity: a.Severity, Namespace: a.Namespace, Query: a.Query, + TotalMetricsFiring: len(hits), Dimensions: []map[string]string{}, MetricValues: []map[string]float64{}, + AlarmSummary: summary, PendingDuration: a.PendingDuration, + } + + if q, err := ParseMQL(a.Query); err == nil { + for _, h := range hits { + meta.Dimensions = append(meta.Dimensions, h.dims) + meta.MetricValues = append(meta.MetricValues, map[string]float64{q.Metric: h.value}) + } + } + + if a.MessageFormat == formatONS { + return title, fmt.Sprintf("Alarm: %s\nStatus: %s\nSeverity: %s\nNamespace: %s\nQuery: %s\nSummary: %s\n%s", + a.Name, status, a.Severity, a.Namespace, a.Query, summary, a.Body), false + } + + w := noticeWire{ + DedupeKey: a.ID, Title: title, Body: a.Body, Type: kind, Severity: a.Severity, + Timestamp: now.Format(time.RFC3339Nano), TimestampEpochMillis: now.UnixMilli(), + NotificationType: "Grouped metrics", AlarmMetaData: []metaWire{meta}, Version: noticeVersion, + } + + var ( + out []byte + err error + ) + + if a.MessageFormat == formatPretty { + out, err = json.MarshalIndent(w, "", " ") + } else { + out, err = json.Marshal(w) + } + + if err != nil { + return title, "", false + } + + return title, string(out), true +} + +// deliver publishes the notices outside the backend lock. +func deliver(ctx context.Context, pub Publisher, ns []notice) { + if pub == nil { + return + } + + for _, n := range ns { + if err := pub(ctx, n.topic, n.title, n.body, n.asJSON); err != nil { + logger.Load(ctx).WarnContext(ctx, "ocimonitoring: alarm notification not delivered", + "topic", n.topic, "error", err) + } + } +} diff --git a/services/ocimonitoring/handler.go b/services/ocimonitoring/handler.go new file mode 100644 index 0000000000..0ee1af6e2f --- /dev/null +++ b/services/ocimonitoring/handler.go @@ -0,0 +1,355 @@ +package ocimonitoring + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = 15 * time.Second + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Monitoring: alarms, metric ingestion and metric queries. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocimonitoring.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIMonitoring" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIMonitoring runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"alarmId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocimonitoring: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) alarmID() string { return q.rt.params["alarmId"] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + if err := json.Unmarshal(q.body, v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocimonitoring: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIMonitoring requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep(context.WithoutCancel(q.r.Context())) + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocimonitoring: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIMonitoring", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ocimonitoring", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocimonitoring: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocimonitoring: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep(ctx) + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocimonitoring: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocimonitoring: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() +} diff --git a/services/ocimonitoring/helpers_test.go b/services/ocimonitoring/helpers_test.go new file mode 100644 index 0000000000..dc8fd628c4 --- /dev/null +++ b/services/ocimonitoring/helpers_test.go @@ -0,0 +1,192 @@ +package ocimonitoring_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/monitoring" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + + oci "github.com/blackbirdworks/gopherstack/services/ocimonitoring" +) + +// epoch is the fixed test start, aligned to five minutes. +func epoch() time.Time { return time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) } + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(epoch().UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *oci.InMemoryBackend + handler *oci.Handler + clock *fakeClock + prov common.ConfigurationProvider + kp keyPair + mon monitoring.MonitoringClient + base string +} + +type envOpts struct { + enforce bool + register bool + // realClock starts the fake clock at the wall time, which signature verification needs. + realClock bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := oci.NewInMemoryBackend() + clock := newFakeClock() + if opts.realClock { + clock.ns.Store(time.Now().UnixNano()) + } + + be.SetClock(clock.Now) + + h := oci.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{ + backend: be, handler: h, clock: clock, kp: kp, + prov: common.NewRawConfigurationProvider(testTenancy, testUser, oci.Region, kp.fprint, kp.privPEM, nil), + base: fmt.Sprintf("http://localhost:%d", h.Port), + } + + var err error + + e.mon, err = monitoring.NewMonitoringClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.mon.Host = e.base + + return e +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +// post sends one metric stream with the given (offset from now, value) points. +func (e *env) post(t *testing.T, ns, name string, dims map[string]string, pts ...pt) monitoring.PostMetricDataResponse { + t.Helper() + + resp, err := e.mon.PostMetricData(t.Context(), monitoring.PostMetricDataRequest{ + MetricData: []monitoring.MetricDataDetails{e.metric(ns, name, dims, pts...)}, + }) + require.NoError(t, err) + + return resp +} + +type pt struct { + off time.Duration + value float64 + count int +} + +func (e *env) metric(ns, name string, dims map[string]string, pts ...pt) monitoring.MetricDataDetails { + dps := make([]monitoring.Datapoint, 0, len(pts)) + + for _, p := range pts { + dp := monitoring.Datapoint{Timestamp: &common.SDKTime{Time: e.clock.Now().Add(p.off)}, Value: new(p.value)} + if p.count > 0 { + dp.Count = new(p.count) + } + + dps = append(dps, dp) + } + + return monitoring.MetricDataDetails{ + Namespace: new(ns), CompartmentId: new(testCompartment), Name: new(name), Dimensions: dims, Datapoints: dps, + } +} diff --git a/services/ocimonitoring/inproc.go b/services/ocimonitoring/inproc.go new file mode 100644 index 0000000000..afda151bd9 --- /dev/null +++ b/services/ocimonitoring/inproc.go @@ -0,0 +1,29 @@ +package ocimonitoring + +import "slices" + +// Compartments lists the distinct compartments that hold alarms or metric streams. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, a := range b.alarms { + seen[a.CompartmentID] = struct{}{} + seen[a.MetricCompartmentID] = struct{}{} + } + + for _, s := range b.streams { + seen[s.CompartmentID] = struct{}{} + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} diff --git a/services/ocimonitoring/leak_main_test.go b/services/ocimonitoring/leak_main_test.go new file mode 100644 index 0000000000..e38be1a806 --- /dev/null +++ b/services/ocimonitoring/leak_main_test.go @@ -0,0 +1,18 @@ +package ocimonitoring_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ocimonitoring/metrics.go b/services/ocimonitoring/metrics.go new file mode 100644 index 0000000000..8a20310636 --- /dev/null +++ b/services/ocimonitoring/metrics.go @@ -0,0 +1,759 @@ +package ocimonitoring + +import ( + "fmt" + "math" + "net/http" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + maxStreamsPerPost = 50 + maxDimensions = 20 + maxDimKey = 256 + maxDimValue = 512 + maxMetadata = 256 + maxPastAge = 2 * time.Hour + maxFutureSkew = 10 * time.Minute + defaultRange = 3 * time.Hour + defaultResolution = time.Minute + maxReturnedPoints = 100000 + atomicBatch = "ATOMIC" + nonAtomicBatch = "NON_ATOMIC" + groupByNamespace = "namespace" + groupByName = "name" + groupByResourceGrp = "resourceGroup" +) + +var ( + namespaceRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + metricNameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._$-]*$`) + resourceGroupRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._$-]*$`) +) + +// DatapointIn is one posted datapoint. +type DatapointIn struct { + Timestamp *time.Time `json:"timestamp"` + Value *float64 `json:"value"` + Count *int `json:"count,omitempty"` +} + +// MetricDataIn is one posted metric stream. +type MetricDataIn struct { + Dimensions map[string]string `json:"dimensions"` + Metadata map[string]string `json:"metadata,omitempty"` + ResourceGroup *string `json:"resourceGroup,omitempty"` + Namespace string `json:"namespace"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + Datapoints []DatapointIn `json:"datapoints"` +} + +// PostMetricDataIn is the PostMetricData request body. +type PostMetricDataIn struct { + BatchAtomicity string `json:"batchAtomicity"` + MetricData []MetricDataIn `json:"metricData"` +} + +// FailedMetric describes a rejected metric stream. +type FailedMetric struct { + Message string `json:"message"` + MetricData MetricDataIn `json:"metricData"` +} + +// PostMetricDataOut is the PostMetricData response body. +type PostMetricDataOut struct { + FailedMetrics []FailedMetric `json:"failedMetrics,omitempty"` + FailedMetricsCount int `json:"failedMetricsCount"` +} + +func printableASCII(s string) bool { + for _, r := range s { + if r <= ' ' || r > '~' { + return false + } + } + + return s != "" +} + +// validateMetric checks one posted stream and returns the reason it is rejected, or "". +func validateMetric(m MetricDataIn, now time.Time) string { + if msg := validateIdentity(m); msg != "" { + return msg + } + + if msg := validateDimensions(m); msg != "" { + return msg + } + + for _, d := range m.Datapoints { + if msg := validateDatapoint(d, now); msg != "" { + return msg + } + } + + return "" +} + +func validateIdentity(m MetricDataIn) string { + switch { + case !namespaceRe.MatchString(m.Namespace): + return "namespace must start with a letter and contain only letters, digits and underscores" + case strings.HasPrefix(strs.Fold(m.Namespace), "oci_") || strings.HasPrefix(strs.Fold(m.Namespace), "oracle_"): + return "namespaces starting with oci_ or oracle_ are reserved" + case m.CompartmentID == "": + return "compartmentId is required" + case !metricNameRe.MatchString(m.Name): + return "name must start with a letter and contain only letters, digits, dots, underscores, hyphens " + + "and dollar signs" + case len(m.Datapoints) == 0: + return "at least one datapoint is required" + case m.ResourceGroup != nil && !resourceGroupRe.MatchString(*m.ResourceGroup): + return "resourceGroup must start with a letter and contain only letters, digits, periods, underscores, " + + "hyphens and dollar signs" + } + + return "" +} + +func validateDimensions(m MetricDataIn) string { + if len(m.Dimensions) < 1 || len(m.Dimensions) > maxDimensions { + return "a metric needs between 1 and 20 dimensions" + } + + for k, v := range m.Dimensions { + if !printableASCII(k) || len(k) > maxDimKey { + return "dimension keys must be printable ASCII without spaces, up to 256 characters" + } + + if v == "" || len(v) > maxDimValue { + return "dimension values must be 1 to 512 characters" + } + } + + for k, v := range m.Metadata { + if k == "" || len(k) > maxMetadata || len(v) > maxMetadata { + return "metadata keys and values are limited to 256 characters" + } + } + + return "" +} + +func validateDatapoint(d DatapointIn, now time.Time) string { + switch { + case d.Timestamp == nil || d.Value == nil: + return "every datapoint needs a timestamp and a value" + case d.Timestamp.Before(now.Add(-maxPastAge)) || !d.Timestamp.Before(now.Add(maxFutureSkew)): + return "datapoint timestamps must be less than two hours in the past and less than ten minutes in the future" + case d.Count != nil && *d.Count <= 0: + return "datapoint count must be greater than zero" + case math.IsNaN(*d.Value) || math.IsInf(*d.Value, 0): + return "datapoint values must be finite numbers" + } + + return "" +} + +func streamKey(namespace, compartment, name, rg string, dims map[string]string) string { + keys := make([]string, 0, len(dims)) + for k := range dims { + keys = append(keys, k) + } + + slices.Sort(keys) + + var sb strings.Builder + + sb.WriteString(namespace + "\x00" + compartment + "\x00" + name + "\x00" + rg) + + for _, k := range keys { + sb.WriteString("\x00" + k + "=" + dims[k]) + } + + return sb.String() +} + +// PostMetricData stores the datapoints of every valid stream. +func (b *InMemoryBackend) PostMetricData(in PostMetricDataIn) (PostMetricDataOut, error) { + atomicity := strings.ToUpper(in.BatchAtomicity) + + switch atomicity { + case "": + atomicity = nonAtomicBatch + case atomicBatch, nonAtomicBatch: + default: + return PostMetricDataOut{}, errParam("batchAtomicity must be ATOMIC or NON_ATOMIC") + } + + if len(in.MetricData) == 0 { + return PostMetricDataOut{}, errMissing("metricData is required") + } + + b.mu.Lock("PostMetricData") + defer b.mu.Unlock() + + now := b.nowUTC() + unique := map[string]struct{}{} + valid := make([]MetricDataIn, 0, len(in.MetricData)) + + var out PostMetricDataOut + + for _, m := range in.MetricData { + rg := "" + if m.ResourceGroup != nil { + rg = *m.ResourceGroup + } + + unique[streamKey(m.Namespace, m.CompartmentID, m.Name, rg, m.Dimensions)] = struct{}{} + + if msg := validateMetric(m, now); msg != "" { + out.FailedMetrics = append(out.FailedMetrics, FailedMetric{MetricData: m, Message: msg}) + + continue + } + + valid = append(valid, m) + } + + if len(unique) > maxStreamsPerPost { + return PostMetricDataOut{}, errParam("a request can carry at most 50 unique metric streams") + } + + out.FailedMetricsCount = len(out.FailedMetrics) + + if out.FailedMetricsCount > 0 && (atomicity == atomicBatch || len(valid) == 0) { + return PostMetricDataOut{}, errParam(out.FailedMetrics[0].Message) + } + + for _, m := range valid { + b.store(m) + } + + return out, nil +} + +func (b *InMemoryBackend) store(m MetricDataIn) { + rg := "" + if m.ResourceGroup != nil { + rg = *m.ResourceGroup + } + + key := streamKey(m.Namespace, m.CompartmentID, m.Name, rg, m.Dimensions) + + s, ok := b.streams[key] + if !ok { + s = &Stream{ + Namespace: m.Namespace, CompartmentID: m.CompartmentID, Name: m.Name, ResourceGroup: rg, + Dimensions: m.Dimensions, + } + b.streams[key] = s + } + + if m.Metadata != nil { + s.Metadata = m.Metadata + } + + for _, d := range m.Datapoints { + n := 1 + if d.Count != nil { + n = *d.Count + } + + s.Points = append(s.Points, Point{T: d.Timestamp.UTC(), V: *d.Value, N: n}) + } + + sort.SliceStable(s.Points, func(i, j int) bool { return s.Points[i].T.Before(s.Points[j].T) }) +} + +// group is a set of streams aggregated together. +type group struct { + dims map[string]string + metadata map[string]string + points []Point +} + +// collectGroups selects the streams a query covers and merges them per group; the caller holds the lock. +func (b *InMemoryBackend) collectGroups(q *MQL, namespace, compartment, rg string) []*group { + byKey := map[string]*group{} + + for _, s := range b.streams { + if s.Namespace != namespace || s.CompartmentID != compartment || s.Name != q.Metric || + s.ResourceGroup != rg || !q.matches(s.Dimensions) { + continue + } + + dims := s.Dimensions + + if q.Grouped { + dims = map[string]string{} + + for _, d := range q.GroupBy { + if v, ok := s.Dimensions[d]; ok { + dims[d] = v + } + } + } + + key := streamKey("", "", "", "", dims) + + g, ok := byKey[key] + if !ok { + g = &group{dims: dims} + if !q.Grouped { + g.metadata = s.Metadata + } + + byKey[key] = g + } + + g.points = append(g.points, s.Points...) + } + + out := make([]*group, 0, len(byKey)) + + for _, g := range byKey { + sort.SliceStable(g.points, func(i, j int) bool { return g.points[i].T.Before(g.points[j].T) }) + out = append(out, g) + } + + slices.SortFunc(out, func(a, c *group) int { + return compareStr(streamKey("", "", "", "", a.dims), streamKey("", "", "", "", c.dims)) + }) + + return out +} + +// windowStat applies a statistic to the (time ordered) points of one window. +func windowStat(q *MQL, pts []Point) (float64, bool) { + if q.Stat == statAbsent { + return b2f(len(pts) == 0), true + } + + if len(pts) == 0 { + return 0, false + } + + var ( + sum, cnt float64 + lo, hi = pts[0].V, pts[0].V + ) + + for _, p := range pts { + sum += p.V * float64(p.N) + cnt += float64(p.N) + lo, hi = math.Min(lo, p.V), math.Max(hi, p.V) + } + + first, last := pts[0], pts[len(pts)-1] + + switch q.Stat { + case statSum: + return sum, true + case statCount: + return cnt, true + case statMax: + return hi, true + case statMin: + return lo, true + case statFirst: + return first.V, true + case statLast: + return last.V, true + case statIncrement: + return last.V - first.V, true + case statRate: + secs := last.T.Sub(first.T).Seconds() + if secs <= 0 { + return 0, false + } + + return (last.V - first.V) / secs, true + case statPercent: + return percentile(pts, q.Quantile), true + } + + return sum / cnt, true +} + +func b2f(b bool) float64 { + if b { + return 1 + } + + return 0 +} + +// percentile uses the nearest-rank method over the counted values. +func percentile(pts []Point, p float64) float64 { + sorted := slices.Clone(pts) + slices.SortFunc(sorted, func(a, c Point) int { + switch { + case a.V < c.V: + return -1 + case a.V > c.V: + return 1 + } + + return 0 + }) + + total := 0 + for _, pt := range sorted { + total += pt.N + } + + rank := int(math.Ceil(p * float64(total))) + seen := 0 + + for _, pt := range sorted { + seen += pt.N + if seen >= rank { + return pt.V + } + } + + return sorted[len(sorted)-1].V +} + +// pointsIn returns the points with from <= T < to. +func pointsIn(pts []Point, from, to time.Time) []Point { + lo := sort.Search(len(pts), func(i int) bool { return !pts[i].T.Before(from) }) + hi := sort.Search(len(pts), func(i int) bool { return !pts[i].T.Before(to) }) + + return pts[lo:hi] +} + +// SummarizeIn is the SummarizeMetricsData request body. +type SummarizeIn struct { + StartTime *time.Time `json:"startTime"` + EndTime *time.Time `json:"endTime"` + ResourceGroup *string `json:"resourceGroup"` + Namespace string `json:"namespace"` + Query string `json:"query"` + Resolution string `json:"resolution"` +} + +type aggregatedWire struct { + Timestamp time.Time `json:"timestamp"` + Value float64 `json:"value"` +} + +// MetricDataWire is one aggregated series of a SummarizeMetricsData response. +type MetricDataWire struct { + Dimensions map[string]string `json:"dimensions"` + Metadata map[string]string `json:"metadata,omitempty"` + Namespace string `json:"namespace"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + ResourceGroup string `json:"resourceGroup,omitempty"` + Resolution string `json:"resolution"` + AggregatedDatapoints []aggregatedWire `json:"aggregatedDatapoints"` +} + +// SummarizeMetricsData aggregates stored datapoints with the MQL statistic of the query. +func (b *InMemoryBackend) SummarizeMetricsData(compartment string, in SummarizeIn) ([]MetricDataWire, error) { + if compartment == "" { + return nil, errMissing("compartmentId is required") + } + + if in.Namespace == "" || in.Query == "" { + return nil, errMissing("namespace and query are required") + } + + q, err := ParseMQL(in.Query) + if err != nil { + return nil, err + } + + if q.Pred != nil { + return nil, errParam("a threshold condition is only valid in an alarm query") + } + + resolution := defaultResolution + + if in.Resolution != "" { + var aerr *APIError + if resolution, aerr = parseInterval(in.Resolution); aerr != nil { + return nil, aerr + } + } + + if resolution > q.Interval { + return nil, errParam("resolution must be equal to or less than the query interval") + } + + b.mu.RLock("SummarizeMetricsData") + defer b.mu.RUnlock() + + start, end, aerr := summaryRange(in, b.nowUTC()) + if aerr != nil { + return nil, aerr + } + + groups := b.collectGroups(q, in.Namespace, compartment, rgOf(in.ResourceGroup)) + if int(end.Sub(start)/resolution)*len(groups) > maxReturnedPoints { + return nil, errParam("the query would return more than 100000 data points; narrow the time range") + } + + out := make([]MetricDataWire, 0, len(groups)) + + for _, g := range groups { + w := MetricDataWire{ + Dimensions: g.dims, Metadata: g.metadata, Namespace: in.Namespace, CompartmentID: compartment, + Name: q.Metric, ResourceGroup: rgOf(in.ResourceGroup), Resolution: resolutionString(in.Resolution), + AggregatedDatapoints: aggregate(q, g.points, start, end, resolution), + } + + if len(w.AggregatedDatapoints) > 0 { + out = append(out, w) + } + } + + return out, nil +} + +// summaryRange resolves the requested time range against the defaults. +func summaryRange(in SummarizeIn, now time.Time) (time.Time, time.Time, *APIError) { + start, end := now.Add(-defaultRange), now + + if in.StartTime != nil { + start = in.StartTime.UTC() + } + + if in.EndTime != nil { + end = in.EndTime.UTC() + } + + if !start.Before(end) { + return start, end, errParam("startTime must be before endTime") + } + + return start, end, nil +} + +// aggregate evaluates the statistic over the windows that start at every resolution step of [start, end). +func aggregate(q *MQL, points []Point, start, end time.Time, resolution time.Duration) []aggregatedWire { + out := []aggregatedWire{} + clipped := pointsIn(points, start, end) + first := start.Truncate(resolution) + + if first.Before(start) { + first = first.Add(resolution) + } + + for ts := first; ts.Before(end); ts = ts.Add(resolution) { + if v, ok := windowStat(q, pointsIn(clipped, ts, ts.Add(q.Interval))); ok { + out = append(out, aggregatedWire{Timestamp: ts, Value: v}) + } + } + + return out +} + +func resolutionString(r string) string { + if r == "" { + return "1m" + } + + return r +} + +func rgOf(p *string) string { + if p == nil { + return "" + } + + return *p +} + +// ListMetricsIn is the ListMetrics request body. +type ListMetricsIn struct { + DimensionFilters map[string]string `json:"dimensionFilters"` + Name *string `json:"name"` + Namespace *string `json:"namespace"` + ResourceGroup *string `json:"resourceGroup"` + SortBy string `json:"sortBy"` + SortOrder string `json:"sortOrder"` + GroupBy []string `json:"groupBy"` +} + +// MetricWire is one ListMetrics item. +type MetricWire struct { + Dimensions map[string]string `json:"dimensions,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + ResourceGroup string `json:"resourceGroup,omitempty"` + CompartmentID string `json:"compartmentId"` +} + +func wildcardMatch(pattern *string, v string) bool { + if pattern == nil { + return true + } + + parts := strings.Split(*pattern, "*") + for i := range parts { + parts[i] = regexp.QuoteMeta(parts[i]) + } + + return regexp.MustCompile("^" + strings.Join(parts, ".*") + "$").MatchString(v) +} + +// ListMetrics lists the metric definitions of a compartment. +func (b *InMemoryBackend) ListMetrics( + compartment string, in ListMetricsIn, pageToken string, limit int, +) (page.Page[MetricWire], error) { + if compartment == "" { + return page.Page[MetricWire]{}, errMissing("compartmentId is required") + } + + for _, g := range in.GroupBy { + if g != groupByNamespace && g != groupByName && g != groupByResourceGrp { + return page.Page[MetricWire]{}, errParam("groupBy supports namespace, name and resourceGroup") + } + } + + b.mu.RLock("ListMetrics") + defer b.mu.RUnlock() + + seen := map[string]MetricWire{} + + for _, s := range b.streams { + if s.CompartmentID != compartment || !in.selects(s) { + continue + } + + w := metricDefinition(s, in.GroupBy) + seen[fmt.Sprintf("%s|%s|%s|%s", w.Namespace, w.Name, w.ResourceGroup, streamKey("", "", "", "", w.Dimensions))] = w + } + + all := make([]MetricWire, 0, len(seen)) + for _, w := range seen { + all = append(all, w) + } + + return paginate( + all, + ListOpts{SortBy: strings.ToUpper(in.SortBy), Order: in.SortOrder, Page: pageToken, Limit: limit}, + compareMetrics, + "NAMESPACE", + ), nil +} + +// selects reports whether a stream passes the name, namespace, resource group and dimension filters. +func (in ListMetricsIn) selects(s *Stream) bool { + if !wildcardMatch(in.Name, s.Name) || !wildcardMatch(in.Namespace, s.Namespace) || + (in.ResourceGroup != nil && *in.ResourceGroup != s.ResourceGroup) { + return false + } + + return len(in.GroupBy) > 0 || hasDims(s.Dimensions, in.DimensionFilters) +} + +// metricDefinition is the stream's definition, reduced to the grouped fields when groupBy is used. +func metricDefinition(s *Stream, groupBy []string) MetricWire { + if len(groupBy) == 0 { + return MetricWire{ + CompartmentID: s.CompartmentID, Name: s.Name, Namespace: s.Namespace, ResourceGroup: s.ResourceGroup, + Dimensions: s.Dimensions, + } + } + + w := MetricWire{CompartmentID: s.CompartmentID} + + for _, g := range groupBy { + switch g { + case groupByNamespace: + w.Namespace = s.Namespace + case groupByName: + w.Name = s.Name + default: + w.ResourceGroup = s.ResourceGroup + } + } + + return w +} + +func compareMetrics(a, c MetricWire, by string) int { + switch by { + case "NAME": + return compareStr(a.Name, c.Name) + case "RESOURCEGROUP": + return compareStr(a.ResourceGroup, c.ResourceGroup) + } + + return compareStr(a.Namespace+"\x00"+a.Name+"\x00"+streamKey("", "", "", "", a.Dimensions), + c.Namespace+"\x00"+c.Name+"\x00"+streamKey("", "", "", "", c.Dimensions)) +} + +func hasDims(have, want map[string]string) bool { + for k, v := range want { + if have[k] != v { + return false + } + } + + return true +} + +func (h *Handler) postMetricData(q *req) error { + var in PostMetricDataIn + if err := q.decode(&in); err != nil { + return err + } + + out, err := h.Backend.PostMetricData(in) + + return reply(q, out, "", err) +} + +func (h *Handler) summarizeMetricsData(q *req) error { + var in SummarizeIn + if err := q.decode(&in); err != nil { + return err + } + + out, err := h.Backend.SummarizeMetricsData(q.query("compartmentId"), in) + if err != nil { + return err + } + + if out == nil { + out = []MetricDataWire{} + } + + return q.json(http.StatusOK, out, "") +} + +func (h *Handler) listMetrics(q *req) error { + var in ListMetricsIn + if err := q.decode(&in); err != nil { + return err + } + + limit := 0 + + if raw := q.query("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + return errParam("limit must be an integer") + } + + limit = n + } + + pg, err := h.Backend.ListMetrics(q.query("compartmentId"), in, q.query("page"), limit) + + return listReply(q, pg, err) +} diff --git a/services/ocimonitoring/metrics_test.go b/services/ocimonitoring/metrics_test.go new file mode 100644 index 0000000000..f9cadb10af --- /dev/null +++ b/services/ocimonitoring/metrics_test.go @@ -0,0 +1,508 @@ +package ocimonitoring_test + +import ( + "net/http" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/monitoring" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + ns = "custom_ns" + metric = "CpuUtilization" +) + +func min1(n int) time.Duration { return time.Duration(n) * time.Minute } + +// seeded posts host a: 10@-5m 20@-4m 30(x2)@-3m 40@-2m and host b: 100@-4m. +func seeded(t *testing.T) *env { + t.Helper() + + e := newEnv(t, envOpts{}) + e.post(t, ns, metric, map[string]string{"host": "a", "az": "1"}, + pt{-min1(5), 10, 0}, pt{-min1(4), 20, 0}, pt{-min1(3), 30, 2}, pt{-min1(2), 40, 0}) + e.post(t, ns, metric, map[string]string{"host": "b", "az": "1"}, pt{-min1(4), 100, 0}) + + return e +} + +func (e *env) summarize(t *testing.T, query, resolution string) ([]monitoring.MetricData, error) { + t.Helper() + + d := monitoring.SummarizeMetricsDataDetails{ + Namespace: new(ns), Query: new(query), + StartTime: &common.SDKTime{Time: e.clock.Now().Add(-min1(5))}, EndTime: &common.SDKTime{Time: e.clock.Now()}, + } + if resolution != "" { + d.Resolution = new(resolution) + } + + resp, err := e.mon.SummarizeMetricsData(t.Context(), monitoring.SummarizeMetricsDataRequest{ + CompartmentId: new(testCompartment), SummarizeMetricsDataDetails: d, + }) + + return resp.Items, err +} + +func values(m monitoring.MetricData) []float64 { + out := make([]float64, 0, len(m.AggregatedDatapoints)) + for _, d := range m.AggregatedDatapoints { + out = append(out, *d.Value) + } + + return out +} + +func TestSummarizeStatistics(t *testing.T) { + t.Parallel() + + e := seeded(t) + + tests := []struct { + name string + query string + resolution string + want []float64 + }{ + {"mean per minute", metric + `[1m]{host = "a"}.mean()`, "", []float64{10, 20, 30, 40}}, + {"avg alias", metric + `[1m]{host = "a"}.avg()`, "", []float64{10, 20, 30, 40}}, + {"sum counts weights", metric + `[1m]{host = "a"}.sum()`, "", []float64{10, 20, 60, 40}}, + {"count", metric + `[1m]{host = "a"}.count()`, "", []float64{1, 1, 2, 1}}, + {"max", metric + `[1m]{host = "a"}.max()`, "", []float64{10, 20, 30, 40}}, + {"min whole range", metric + `[5m]{host = "a"}.min()`, "5m", []float64{10}}, + {"max whole range", metric + `[5m]{host = "a"}.max()`, "5m", []float64{40}}, + {"sum whole range", metric + `[5m]{host = "a"}.sum()`, "5m", []float64{130}}, + {"mean whole range", metric + `[5m]{host = "a"}.mean()`, "5m", []float64{26}}, + {"count whole range", metric + `[5m]{host = "a"}.count()`, "5m", []float64{5}}, + {"first", metric + `[5m]{host = "a"}.first()`, "5m", []float64{10}}, + {"last", metric + `[5m]{host = "a"}.last()`, "5m", []float64{40}}, + {"increment", metric + `[5m]{host = "a"}.increment()`, "5m", []float64{30}}, + {"rate per second", metric + `[5m]{host = "a"}.rate()`, "5m", []float64{30.0 / 180}}, + {"percentile 50", metric + `[5m]{host = "a"}.percentile(0.5)`, "5m", []float64{30}}, + {"percentile 90", metric + `[5m]{host = "a"}.percentile(0.9)`, "5m", []float64{40}}, + {"sliding five minute window", metric + `[2m]{host = "a"}.sum()`, "1m", + []float64{30, 80, 100, 40}}, + {"absent over data", metric + `[1m]{host = "a"}.absent()`, "", []float64{0, 0, 0, 0, 1}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + items, err := e.summarize(t, tt.query, tt.resolution) + require.NoError(t, err) + require.Len(t, items, 1) + assert.InDeltaSlice(t, tt.want, values(items[0]), 1e-9) + assert.Equal(t, ns, *items[0].Namespace) + assert.Equal(t, metric, *items[0].Name) + assert.Equal(t, map[string]string{"host": "a", "az": "1"}, items[0].Dimensions) + }) + } +} + +func TestSummarizeGroupingAndFilters(t *testing.T) { + t.Parallel() + + e := seeded(t) + + tests := []struct { + name string + query string + dims []map[string]string + first []float64 + }{ + {"per stream", metric + `[5m].max()`, []map[string]string{{"host": "a", "az": "1"}, {"host": "b", "az": "1"}}, + []float64{40}}, + { + "group by host", + metric + `[5m].groupBy(host).max()`, + []map[string]string{{"host": "a"}, {"host": "b"}}, + []float64{40}, + }, + {"group by az", metric + `[5m].groupBy(az).max()`, []map[string]string{{"az": "1"}}, []float64{100}}, + {"group by nothing", metric + `[5m].groupBy().sum()`, []map[string]string{{}}, []float64{230}}, + { + "not equal", + metric + `[5m]{host != "a"}.max()`, + []map[string]string{{"host": "b", "az": "1"}}, + []float64{100}, + }, + { + "fuzzy or", + metric + `[5m]{host =~ "a|b"}.groupBy(host).max()`, + []map[string]string{{"host": "a"}, {"host": "b"}}, + []float64{40}, + }, + { + "fuzzy wildcard", + metric + `[5m]{host =~ "b*"}.max()`, + []map[string]string{{"host": "b", "az": "1"}}, + []float64{100}, + }, + { + "not fuzzy", + metric + `[5m]{host !=~ "a*"}.max()`, + []map[string]string{{"host": "b", "az": "1"}}, + []float64{100}, + }, + { + "two filters", + metric + `[5m]{host = "a", az = "1"}.max()`, + []map[string]string{{"host": "a", "az": "1"}}, + []float64{40}, + }, + {"no match", metric + `[5m]{host =~ "z*"}.max()`, nil, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + items, err := e.summarize(t, tt.query, "5m") + require.NoError(t, err) + require.Len(t, items, len(tt.dims)) + + for i, want := range tt.dims { + assert.Equal(t, want, items[i].Dimensions) + } + + if len(items) > 0 { + assert.Equal(t, tt.first, values(items[0])) + } + }) + } +} + +func TestSummarizeErrors(t *testing.T) { + t.Parallel() + + e := seeded(t) + + tests := []struct { + name string + query string + resolution string + }{ + {"no interval", metric + `.mean()`, ""}, + {"bad interval", metric + `[7x].mean()`, ""}, + {"interval 61m", metric + `[61m].mean()`, ""}, + {"interval 2d", metric + `[2d].mean()`, ""}, + {"no statistic", metric + `[1m]`, ""}, + {"unknown statistic", metric + `[1m].median()`, ""}, + {"two statistics", metric + `[1m].mean().sum()`, ""}, + {"bad percentile", metric + `[1m].percentile(1.5)`, ""}, + {"threshold in summarize", metric + `[1m].mean() > 5`, ""}, + {"join unsupported", metric + `[1m].mean() > 5 && ` + metric + `[1m].max() > 1`, ""}, + {"grouping unsupported", metric + `[1m].grouping().mean()`, ""}, + {"unquoted value", metric + `[1m]{host = a}.mean()`, ""}, + {"resolution above interval", metric + `[1m].mean()`, "5m"}, + {"bad resolution", metric + `[5m].mean()`, "zz"}, + {"missing metric", `[1m].mean()`, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.summarize(t, tt.query, tt.resolution) + se := svcErr(t, err) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + assert.Equal(t, "InvalidParameter", se.GetCode()) + }) + } + + status, body := rawDo(t, http.MethodPost, e.base+"/20180401/metrics/actions/summarizeMetricsData", + `{"namespace":"x","query":"m[1m].mean()"}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") + + d := monitoring.SummarizeMetricsDataDetails{ + Namespace: new(ns), Query: new(metric + "[1m].mean()"), + StartTime: &common.SDKTime{Time: e.clock.Now()}, EndTime: &common.SDKTime{Time: e.clock.Now().Add(-time.Hour)}, + } + _, err := e.mon.SummarizeMetricsData(t.Context(), monitoring.SummarizeMetricsDataRequest{ + CompartmentId: new(testCompartment), SummarizeMetricsDataDetails: d, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestSummarizeDefaultRange(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.post(t, ns, metric, map[string]string{"h": "1"}, pt{-2 * time.Hour, 5, 0}, pt{-min1(1), 7, 0}) + + resp, err := e.mon.SummarizeMetricsData(t.Context(), monitoring.SummarizeMetricsDataRequest{ + CompartmentId: new(testCompartment), + Namespace: new(ns), Query: new(metric + "[1h].sum()"), Resolution: new("1h"), + }) + require.NoError(t, err) + require.Len(t, resp.Items, 1) + assert.InDeltaSlice(t, []float64{5, 7}, values(resp.Items[0]), 1e-9, "3 hour default range covers both") + assert.Equal(t, "1h", *resp.Items[0].Resolution) +} + +func TestPostMetricDataValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(*monitoring.MetricDataDetails) + name string + ok bool + }{ + {nil, "valid", true}, + {func(m *monitoring.MetricDataDetails) { m.Namespace = new("oci_custom") }, "reserved oci_ prefix", false}, + {func(m *monitoring.MetricDataDetails) { m.Namespace = new("Oracle_x") }, "reserved oracle_ prefix", false}, + {func(m *monitoring.MetricDataDetails) { m.Namespace = new("my-ns") }, "namespace with dash", false}, + {func(m *monitoring.MetricDataDetails) { m.Namespace = new("1ns") }, "namespace digit first", false}, + {func(m *monitoring.MetricDataDetails) { m.Name = new("bad name") }, "name with space", false}, + { + func(m *monitoring.MetricDataDetails) { m.Name = new("my_app.success-rate$1") }, + "name with dots and dollar", + true, + }, + {func(m *monitoring.MetricDataDetails) { m.Dimensions = map[string]string{} }, "no dimensions", false}, + {func(m *monitoring.MetricDataDetails) { + m.Dimensions = map[string]string{} + for i := range 21 { + m.Dimensions["k"+string(rune('a'+i))] = "v" + } + }, "21 dimensions", false}, + { + func(m *monitoring.MetricDataDetails) { m.Dimensions = map[string]string{"k": ""} }, + "empty dimension value", + false, + }, + { + func(m *monitoring.MetricDataDetails) { m.Dimensions = map[string]string{"a b": "v"} }, + "dimension key with space", + false, + }, + {func(m *monitoring.MetricDataDetails) { + m.Dimensions = map[string]string{"k": string(make([]byte, 513))} + }, "dimension value too long", false}, + {func(m *monitoring.MetricDataDetails) { m.Datapoints = []monitoring.Datapoint{} }, "no datapoints", false}, + {func(m *monitoring.MetricDataDetails) { + m.Datapoints[0].Timestamp = &common.SDKTime{Time: epoch().Add(-2*time.Hour - time.Second)} + }, "too old", false}, + {func(m *monitoring.MetricDataDetails) { + m.Datapoints[0].Timestamp = &common.SDKTime{Time: epoch().Add(-2*time.Hour + time.Second)} + }, "just inside past limit", true}, + {func(m *monitoring.MetricDataDetails) { + m.Datapoints[0].Timestamp = &common.SDKTime{Time: epoch().Add(10 * time.Minute)} + }, "too far in future", false}, + {func(m *monitoring.MetricDataDetails) { + m.Datapoints[0].Timestamp = &common.SDKTime{Time: epoch().Add(9 * time.Minute)} + }, "just inside future limit", true}, + {func(m *monitoring.MetricDataDetails) { m.Datapoints[0].Count = new(0) }, "zero count", false}, + {func(m *monitoring.MetricDataDetails) { m.ResourceGroup = new("1bad") }, "bad resource group", false}, + { + func(m *monitoring.MetricDataDetails) { m.ResourceGroup = new("frontend-fleet") }, + "good resource group", + true, + }, + {func(m *monitoring.MetricDataDetails) { + m.Metadata = map[string]string{"unit": string(make([]byte, 257))} + }, "long metadata", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + m := e.metric(ns, metric, map[string]string{"host": "a"}, pt{0, 1, 0}) + + if tt.mod != nil { + tt.mod(&m) + } + + resp, err := e.mon.PostMetricData(t.Context(), monitoring.PostMetricDataRequest{ + MetricData: []monitoring.MetricDataDetails{m}, + }) + if tt.ok { + require.NoError(t, err) + assert.Zero(t, *resp.FailedMetricsCount) + + return + } + + se := svcErr(t, err) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + assert.Equal(t, "InvalidParameter", se.GetCode()) + }) + } +} + +func TestPostMetricDataBatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + atomicity monitoring.PostMetricDataDetailsBatchAtomicityEnum + wantFailed int + wantErr bool + wantStored int + }{ + {"non atomic partial", monitoring.PostMetricDataDetailsBatchAtomicityNonAtomic, 1, false, 1}, + {"default is non atomic", "", 1, false, 1}, + {"atomic rejects all", monitoring.PostMetricDataDetailsBatchAtomicityAtomic, 0, true, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + good := e.metric(ns, "good", map[string]string{"h": "1"}, pt{0, 1, 0}) + bad := e.metric("oci_reserved", "bad", map[string]string{"h": "1"}, pt{0, 1, 0}) + + resp, err := e.mon.PostMetricData(t.Context(), monitoring.PostMetricDataRequest{ + MetricData: []monitoring.MetricDataDetails{good, bad}, BatchAtomicity: tt.atomicity, + }) + if tt.wantErr { + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantFailed, *resp.FailedMetricsCount) + require.Len(t, resp.FailedMetrics, 1) + assert.Equal(t, "oci_reserved", *resp.FailedMetrics[0].MetricData.Namespace) + assert.NotEmpty(t, *resp.FailedMetrics[0].Message) + } + + list, lerr := e.mon.ListMetrics(t.Context(), monitoring.ListMetricsRequest{ + CompartmentId: new(testCompartment), ListMetricsDetails: monitoring.ListMetricsDetails{}, + }) + require.NoError(t, lerr) + assert.Len(t, list.Items, tt.wantStored) + }) + } + + e := newEnv(t, envOpts{}) + many := make([]monitoring.MetricDataDetails, 0, 51) + + for i := range 51 { + many = append(many, e.metric(ns, "m", map[string]string{"i": string(rune('A' + i))}, pt{0, 1, 0})) + } + + _, err := e.mon.PostMetricData(t.Context(), monitoring.PostMetricDataRequest{ + MetricData: many, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "more than 50 streams") + + status, body := rawDo(t, http.MethodPost, e.base+"/20180401/metrics", `{}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") + + status, body = rawDo(t, http.MethodPost, e.base+"/20180401/metrics", `{"batchAtomicity":"MAYBE","metricData":[{}]}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "InvalidParameter") +} + +func TestListMetrics(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.post(t, ns, "cpu.user", map[string]string{"host": "a"}, pt{0, 1, 0}) + e.post(t, ns, "cpu.system", map[string]string{"host": "a"}, pt{0, 1, 0}) + e.post(t, ns, "disk", map[string]string{"host": "b"}, pt{0, 1, 0}) + e.post(t, "other_ns", "cpu.user", map[string]string{"host": "c"}, pt{0, 1, 0}) + + tests := []struct { + name string + d monitoring.ListMetricsDetails + want []string + }{ + {"all", monitoring.ListMetricsDetails{}, []string{"cpu.system", "cpu.user", "disk", "cpu.user"}}, + { + "name wildcard", + monitoring.ListMetricsDetails{Name: new("cpu*")}, + []string{"cpu.system", "cpu.user", "cpu.user"}, + }, + {"namespace", monitoring.ListMetricsDetails{Namespace: new("other_ns")}, []string{"cpu.user"}}, + { + "dimension filter", + monitoring.ListMetricsDetails{DimensionFilters: map[string]string{"host": "b"}}, + []string{"disk"}, + }, + {"sort by name desc", monitoring.ListMetricsDetails{ + SortBy: monitoring.ListMetricsDetailsSortByName, SortOrder: monitoring.ListMetricsDetailsSortOrderDesc, + }, []string{"disk", "cpu.user", "cpu.user", "cpu.system"}}, + { + "group by name", + monitoring.ListMetricsDetails{GroupBy: []string{"name"}}, + []string{"cpu.system", "cpu.user", "disk"}, + }, + {"group by namespace", monitoring.ListMetricsDetails{GroupBy: []string{"namespace"}}, []string{"", ""}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.mon.ListMetrics(t.Context(), monitoring.ListMetricsRequest{ + CompartmentId: new(testCompartment), ListMetricsDetails: tt.d, + }) + require.NoError(t, err) + + got := make([]string, 0, len(resp.Items)) + for _, m := range resp.Items { + got = append(got, deref(m.Name)) + } + + assert.Equal(t, tt.want, got) + }) + } + + _, err := e.mon.ListMetrics(t.Context(), monitoring.ListMetricsRequest{ + CompartmentId: new(testCompartment), GroupBy: []string{"bogus"}, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + first, err := e.mon.ListMetrics(t.Context(), monitoring.ListMetricsRequest{ + CompartmentId: new(testCompartment), Limit: new(3), + }) + require.NoError(t, err) + require.NotNil(t, first.OpcNextPage) + + rest, err := e.mon.ListMetrics(t.Context(), monitoring.ListMetricsRequest{ + CompartmentId: new(testCompartment), Limit: new(3), Page: first.OpcNextPage, + }) + require.NoError(t, err) + assert.Len(t, first.Items, 3) + assert.Len(t, rest.Items, 1) +} + +func deref(s *string) string { + if s == nil { + return "" + } + + return *s +} + +func TestMetricRetention(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.post(t, ns, metric, map[string]string{"h": "1"}, pt{0, 1, 0}) + + list := func() int { + resp, err := e.mon.ListMetrics(t.Context(), monitoring.ListMetricsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + return len(resp.Items) + } + + assert.Equal(t, 1, list()) + + e.clock.Advance(89 * 24 * time.Hour) + assert.Equal(t, 1, list()) + + e.clock.Advance(2 * 24 * time.Hour) + assert.Equal(t, 0, list(), "90 day metric retention elapsed") +} diff --git a/services/ocimonitoring/models.go b/services/ocimonitoring/models.go new file mode 100644 index 0000000000..fed338bd5f --- /dev/null +++ b/services/ocimonitoring/models.go @@ -0,0 +1,98 @@ +package ocimonitoring + +import ( + "encoding/json" + "time" +) + +// Alarm statuses. +const ( + statusOK = "OK" + statusFiring = "FIRING" + statusSuspended = "SUSPENDED" + stateActive = "ACTIVE" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// Point is one stored datapoint. +type Point struct { + T time.Time `json:"t"` + V float64 `json:"v"` + N int `json:"n"` +} + +// Stream is a unique metric stream: namespace, compartment, name, resource group and dimensions. +type Stream struct { + Dimensions map[string]string `json:"dimensions"` + Metadata map[string]string `json:"metadata,omitempty"` + Namespace string `json:"namespace"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + ResourceGroup string `json:"resourceGroup,omitempty"` + Points []Point `json:"points"` +} + +// SuppressionSpec is the alarm suppression window. +type SuppressionSpec struct { + From time.Time `json:"timeSuppressFrom"` + Until time.Time `json:"timeSuppressUntil"` + Description string `json:"description,omitempty"` +} + +// HistoryEntry records one alarm state change. +type HistoryEntry struct { + Timestamp time.Time `json:"timestamp"` + TimestampTriggered time.Time `json:"timestampTriggered"` + From string `json:"from,omitempty"` + To string `json:"to"` + Summary string `json:"summary"` +} + +// Alarm is a stored alarm and its evaluation state. +type Alarm struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + StatusSince time.Time `json:"statusSince"` + PendingSince *time.Time `json:"pendingSince,omitempty"` + LastNotified *time.Time `json:"lastNotified,omitempty"` + Suppression *SuppressionSpec `json:"suppression,omitempty"` + InSubtree *bool `json:"metricCompartmentIdInSubtree,omitempty"` + PerDimension *bool `json:"isNotificationsPerMetricDimensionEnabled,omitempty"` + Tags Tags `json:"tags"` + Extra map[string]string `json:"extra,omitempty"` + Overrides []json.RawMessage `json:"overrides,omitempty"` + ID string `json:"id"` + Name string `json:"displayName"` + CompartmentID string `json:"compartmentId"` + MetricCompartmentID string `json:"metricCompartmentId"` + Namespace string `json:"namespace"` + ResourceGroup string `json:"resourceGroup,omitempty"` + Query string `json:"query"` + Severity string `json:"severity"` + Body string `json:"body,omitempty"` + Resolution string `json:"resolution"` + PendingDuration string `json:"pendingDuration"` + RepeatNotification string `json:"repeatNotificationDuration,omitempty"` + MessageFormat string `json:"messageFormat"` + Status string `json:"status"` + Etag string `json:"etag"` + Destinations []string `json:"destinations"` + History []HistoryEntry `json:"history,omitempty"` + Seq int64 `json:"seq"` + Enabled bool `json:"isEnabled"` +} diff --git a/services/ocimonitoring/mql.go b/services/ocimonitoring/mql.go new file mode 100644 index 0000000000..060721a821 --- /dev/null +++ b/services/ocimonitoring/mql.go @@ -0,0 +1,454 @@ +package ocimonitoring + +import ( + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// Statistics supported by the MQL subset. +const ( + statMean = "mean" + statSum = "sum" + statCount = "count" + statMax = "max" + statMin = "min" + statRate = "rate" + statPercent = "percentile" + statFirst = "first" + statLast = "last" + statIncrement = "increment" + statAbsent = "absent" + + hoursPerDay = 24 +) + +// Dimension filter operators. +const ( + opEq = "=" + opNotEq = "!=" + opFuzzy = "=~" + opNotFuzz = "!=~" +) + +type dimFilter struct { + key, op, value string +} + +type predicate struct { + op string + values []float64 +} + +// MQL is a parsed Monitoring Query Language expression. +type MQL struct { + Pred *predicate + Metric string + Stat string + Filters []dimFilter + GroupBy []string + Interval time.Duration + Quantile float64 + Grouped bool +} + +var intervalRe = regexp.MustCompile(`^(\d+)([mhd])$`) + +// parseInterval parses 1m-60m, 1h-24h or 1d. +func parseInterval(s string) (time.Duration, *APIError) { + m := intervalRe.FindStringSubmatch(s) + if m == nil { + return 0, errParam("interval " + strconv.Quote(s) + " must be 1m-60m, 1h-24h or 1d") + } + + n, _ := strconv.Atoi(m[1]) + + switch { + case m[2] == "m" && n >= 1 && n <= 60: + return time.Duration(n) * time.Minute, nil + case m[2] == "h" && n >= 1 && n <= 24: + return time.Duration(n) * time.Hour, nil + case m[2] == "d" && n == 1: + return hoursPerDay * time.Hour, nil + } + + return 0, errParam("interval " + strconv.Quote(s) + " must be 1m-60m, 1h-24h or 1d") +} + +type mqlScanner struct { + src []rune + pos int +} + +func (s *mqlScanner) skip() { + for s.pos < len(s.src) && unicode.IsSpace(s.src[s.pos]) { + s.pos++ + } +} + +func (s *mqlScanner) peek() rune { + s.skip() + + if s.pos >= len(s.src) { + return 0 + } + + return s.src[s.pos] +} + +func (s *mqlScanner) accept(lit string) bool { + s.skip() + + if strings.HasPrefix(string(s.src[s.pos:]), lit) { + s.pos += len([]rune(lit)) + + return true + } + + return false +} + +func (s *mqlScanner) word() string { + s.skip() + + start := s.pos + for s.pos < len(s.src) && (unicode.IsLetter(s.src[s.pos]) || unicode.IsDigit(s.src[s.pos]) || s.src[s.pos] == '_') { + s.pos++ + } + + return string(s.src[start:s.pos]) +} + +func (s *mqlScanner) quoted() (string, bool) { + s.skip() + + if s.pos >= len(s.src) || s.src[s.pos] != '"' { + return "", false + } + + var sb strings.Builder + + for s.pos++; s.pos < len(s.src); s.pos++ { + switch c := s.src[s.pos]; { + case c == '\\' && s.pos+1 < len(s.src): + s.pos++ + sb.WriteRune(s.src[s.pos]) + case c == '"': + s.pos++ + + return sb.String(), true + default: + sb.WriteRune(c) + } + } + + return "", false +} + +func (s *mqlScanner) number() (float64, bool) { + s.skip() + + start := s.pos + if s.pos < len(s.src) && s.src[s.pos] == '-' { + s.pos++ + } + + for s.pos < len(s.src) && (unicode.IsDigit(s.src[s.pos]) || s.src[s.pos] == '.') { + s.pos++ + } + + f, err := strconv.ParseFloat(string(s.src[start:s.pos]), 64) + if err != nil { + s.pos = start + + return 0, false + } + + return f, true +} + +// ParseMQL parses `Metric[interval]{filters}.groupBy(dims).stat() [predicate]`. +func ParseMQL(src string) (*MQL, error) { + s := &mqlScanner{src: []rune(src)} + q := &MQL{} + + open := strings.IndexRune(src, '[') + if open <= 0 { + return nil, errParam("a query needs a metric name followed by an interval in square brackets") + } + + q.Metric = strings.TrimSpace(src[:open]) + if q.Metric == "" { + return nil, errParam("a query needs a metric name") + } + + s.pos = len([]rune(src[:open])) + 1 + + closeIdx := strings.IndexRune(string(s.src[s.pos:]), ']') + if closeIdx < 0 { + return nil, errParam("the interval is missing its closing bracket") + } + + iv, aerr := parseInterval(strings.TrimSpace(string(s.src[s.pos : s.pos+closeIdx]))) + if aerr != nil { + return nil, aerr + } + + q.Interval, s.pos = iv, s.pos+closeIdx+1 + + if aerr = q.parseFilters(s); aerr != nil { + return nil, aerr + } + + if aerr = q.parseChain(s); aerr != nil { + return nil, aerr + } + + if aerr = q.parsePredicate(s); aerr != nil { + return nil, aerr + } + + if s.peek() != 0 { + return nil, errParam("unexpected " + strconv.Quote(string(s.src[s.pos:])) + + " in query; joins (&&, ||) and arithmetic are not supported by this emulator") + } + + return q, nil +} + +func (q *MQL) parseFilters(s *mqlScanner) *APIError { + if s.peek() != '{' { + return nil + } + + s.pos++ + + for { + key := s.word() + if key == "" { + return errParam("a dimension filter needs a dimension name") + } + + var op string + + for _, cand := range []string{opNotFuzz, opFuzzy, opNotEq, opEq} { + if s.accept(cand) { + op = cand + + break + } + } + + if op == "" { + return errParam("dimension filter " + key + " needs one of =, !=, =~ or !=~") + } + + val, ok := s.quoted() + if !ok { + return errParam("dimension filter values must be double-quoted") + } + + q.Filters = append(q.Filters, dimFilter{key: key, op: op, value: val}) + + switch { + case s.accept(","): + case s.accept("}"): + return nil + default: + return errParam("a dimension filter set must end with }") + } + } +} + +func (q *MQL) parseChain(s *mqlScanner) *APIError { + for s.accept(".") { + fn := s.word() + if fn == "" || !s.accept("(") { + return errParam("expected a function call such as .mean() after the metric") + } + + var aerr *APIError + + switch { + case strs.Equal(fn, "groupBy"): + aerr = q.parseGroupBy(s) + case strs.Equal(fn, "grouping"): + aerr = errParam("grouping() is not supported by this emulator; use groupBy()") + default: + aerr = q.setStat(strings.ToLower(fn), s) + } + + if aerr != nil { + return aerr + } + + if !s.accept(")") { + return errParam("missing ) after " + fn) + } + } + + if q.Stat == "" { + return errParam("a query needs a statistic such as .mean()") + } + + return nil +} + +func (q *MQL) parseGroupBy(s *mqlScanner) *APIError { + q.Grouped = true + + for s.peek() != ')' { + d := s.word() + if d == "" { + return errParam("groupBy takes dimension names") + } + + q.GroupBy = append(q.GroupBy, d) + + if !s.accept(",") { + break + } + } + + return nil +} + +func (q *MQL) setStat(fn string, s *mqlScanner) *APIError { + if q.Stat != "" { + return errParam("a query can have only one statistic") + } + + switch fn { + case "avg": + fn = statMean + case statMean, statSum, statCount, statMax, statMin, statRate, statFirst, statLast, statIncrement: + case statAbsent: + s.number() + case statPercent: + p, ok := s.number() + if !ok || p <= 0 || p >= 1 { + return errParam("percentile(p) needs 0 < p < 1") + } + + q.Quantile = p + default: + return errParam("statistic " + fn + "() is not supported by this emulator") + } + + q.Stat = fn + + return nil +} + +func (q *MQL) parsePredicate(s *mqlScanner) *APIError { + for _, op := range []string{">=", "<=", "==", "!=", ">", "<"} { + if s.accept(op) { + v, ok := s.number() + if !ok { + return errParam("a threshold number is required after " + op) + } + + q.Pred = &predicate{op: op, values: []float64{v}} + + return nil + } + } + + negate := false + + save := s.pos + if strs.Equal(s.word(), "not") { + negate = true + } else { + s.pos = save + } + + if strs.Equal(s.word(), "in") && s.accept("(") { + lo, ok1 := s.number() + _ = s.accept(",") + hi, ok2 := s.number() + + if !ok1 || !ok2 || !s.accept(")") { + return errParam("in (low, high) needs two numbers") + } + + op := "in" + if negate { + op = "not in" + } + + q.Pred = &predicate{op: op, values: []float64{lo, hi}} + + return nil + } + + s.pos = save + + return nil +} + +func (p *predicate) holds(v float64) bool { + switch p.op { + case ">": + return v > p.values[0] + case ">=": + return v >= p.values[0] + case "<": + return v < p.values[0] + case "<=": + return v <= p.values[0] + case "==": + return v == p.values[0] + case "!=": + return v != p.values[0] + case "in": + return v >= p.values[0] && v <= p.values[1] + } + + return v < p.values[0] || v > p.values[1] +} + +func fuzzyMatch(pattern, v string) bool { + for alt := range strings.SplitSeq(pattern, "|") { + parts := strings.Split(alt, "*") + for i := range parts { + parts[i] = regexp.QuoteMeta(parts[i]) + } + + if regexp.MustCompile("^" + strings.Join(parts, ".*") + "$").MatchString(v) { + return true + } + } + + return false +} + +// matches reports whether a dimension set satisfies every filter. +func (q *MQL) matches(dims map[string]string) bool { + for _, f := range q.Filters { + v, present := dims[f.key] + + var ok bool + + switch f.op { + case opEq: + ok = present && v == f.value + case opNotEq: + ok = !present || v != f.value + case opFuzzy: + ok = present && fuzzyMatch(f.value, v) + default: + ok = !present || !fuzzyMatch(f.value, v) + } + + if !ok { + return false + } + } + + return true +} diff --git a/services/ocimonitoring/ons_test.go b/services/ocimonitoring/ons_test.go new file mode 100644 index 0000000000..56a4a18a1a --- /dev/null +++ b/services/ocimonitoring/ons_test.go @@ -0,0 +1,118 @@ +package ocimonitoring_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + ons "github.com/blackbirdworks/gopherstack/services/ocinotifications" +) + +type received struct { + header http.Header + body string +} + +// sink is an HTTP endpoint that records every request it receives. +type sink struct { + requests chan received + srv *httptest.Server +} + +func newSink(t *testing.T) *sink { + t.Helper() + + s := &sink{requests: make(chan received, 64)} + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + s.requests <- received{header: r.Header.Clone(), body: string(b)} + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(s.srv.Close) + + return s +} + +func (s *sink) next(t *testing.T) received { + t.Helper() + + select { + case r := <-s.requests: + return r + case <-t.Context().Done(): + t.Fatal("no request received") + + return received{} + } +} + +func (s *sink) none(t *testing.T) bool { + t.Helper() + + select { + case <-s.requests: + return false + default: + return true + } +} + +// onsEnv is a Notifications backend with one confirmed HTTPS subscription feeding a sink. +type onsEnv struct { + backend *ons.InMemoryBackend + sink *sink + topicID string +} + +// newONS starts Notifications, subscribes a sink to a fresh topic and wires the monitoring backend to it. +func newONS(t *testing.T, e *env) *onsEnv { + t.Helper() + + nb := ons.NewInMemoryBackend() + nb.SetTransition(0) + + h := ons.NewHandler(nb) + h.Port = 0 + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + topic, err := nb.CreateTopic(ons.CreateTopicIn{Name: "alarms", Compartment: testCompartment}, "http://x") + require.NoError(t, err) + + sk := newSink(t) + sub, err := nb.CreateSubscription(ons.CreateSubscriptionIn{ + TopicID: topic.TopicID, Compartment: testCompartment, Protocol: "HTTPS", Endpoint: sk.srv.URL + "/hook", + }, "http://x") + require.NoError(t, err) + nb.Kick() + + conf := sk.next(t) + u, err := url.Parse(conf.header.Get("X-Oci-Ns-Confirmationurl")) + require.NoError(t, err) + + _, err = nb.ConfirmSubscription(sub.ID, u.Query().Get("token"), "HTTPS") + require.NoError(t, err) + + e.backend.SetPublisher(func(_ context.Context, topicID, title, body string, asJSON bool) error { + msgType := "" + if asJSON { + msgType = "JSON" + } + + _, perr := nb.PublishMessage(topicID, msgType, ons.PublishIn{Title: &title, Body: &body}) + + return perr + }) + e.backend.SetTopicChecker(func(id string) bool { + _, gerr := nb.GetTopic(id, "http://x") + + return gerr == nil + }) + + return &onsEnv{backend: nb, sink: sk, topicID: topic.TopicID} +} diff --git a/services/ocimonitoring/ops.go b/services/ocimonitoring/ops.go new file mode 100644 index 0000000000..bf6c702a29 --- /dev/null +++ b/services/ocimonitoring/ops.go @@ -0,0 +1,41 @@ +package ocimonitoring + +import ( + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateAlarm: (*Handler).createAlarm, opGetAlarm: (*Handler).getAlarm, opListAlarms: (*Handler).listAlarms, + opListAlarmsStatus: (*Handler).listAlarmsStatus, opUpdateAlarm: (*Handler).updateAlarm, + opDeleteAlarm: (*Handler).deleteAlarm, opChangeAlarmComp: (*Handler).changeAlarmCompartment, + opGetAlarmHistory: (*Handler).getAlarmHistory, opPostMetricData: (*Handler).postMetricData, + opListMetrics: (*Handler).listMetrics, opSummarizeMetrics: (*Handler).summarizeMetricsData, + } +} + +func reply[T any](q *req, w T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +// listReply writes a page as a bare JSON array, the shape the Monitoring list operations declare. +func listReply[T any](q *req, pg page.Page[T], err error) error { + if err != nil { + return err + } + + q.nextPage(pg.Next) + + data := pg.Data + if data == nil { + data = []T{} + } + + return q.json(http.StatusOK, data, "") +} diff --git a/services/ocimonitoring/persistence.go b/services/ocimonitoring/persistence.go new file mode 100644 index 0000000000..d82f001110 --- /dev/null +++ b/services/ocimonitoring/persistence.go @@ -0,0 +1,98 @@ +package ocimonitoring + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Streams map[string]*Stream `json:"streams"` + Alarms map[string]*Alarm `json:"alarms"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocimonitoring", backendSnapshot{ + Version: snapshotVersion, Streams: b.streams, Alarms: b.alarms, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocimonitoring", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ocimonitoring: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.streams, b.alarms, b.seq = snap.Streams, snap.Alarms, snap.Seq + b.Auth.Load(snap.Auth) + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{nullCheck("stream", &snap.Streams), nullCheck("alarm", &snap.Alarms)} { + if err != nil { + return err + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ocimonitoring: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ocimonitoring/persistence_test.go b/services/ocimonitoring/persistence_test.go new file mode 100644 index 0000000000..264b0d9624 --- /dev/null +++ b/services/ocimonitoring/persistence_test.go @@ -0,0 +1,163 @@ +package ocimonitoring_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/monitoring" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ocimonitoring" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + a := e.mkAlarm(t, topicOCID, nil) + e.post(t, ns, metric, map[string]string{"host": "a"}, pt{0, 95, 0}) + assert.Equal(t, monitoring.AlarmStatusSummaryStatusFiring, e.status(t)) + + snap := e.backend.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + restored := oci.NewInMemoryBackend() + restored.SetClock(e.clock.Now) + require.NoError(t, restored.Restore(t.Context(), snap)) + + got, _, err := restored.GetAlarm(*a.Id) + require.NoError(t, err) + assert.Equal(t, "cpu-high", got.Name) + + st, err := restored.ListAlarmsStatus(oci.AlarmFilter{Compartment: testCompartment}, oci.ListOpts{}) + require.NoError(t, err) + require.Len(t, st.Data, 1) + assert.Equal(t, "FIRING", st.Data[0].Status, "evaluation state survives") + + metrics, err := restored.ListMetrics(testCompartment, oci.ListMetricsIn{}, "", 0) + require.NoError(t, err) + assert.Len(t, metrics.Data, 1) + + e.backend.Reset() + + _, _, err = e.backend.GetAlarm(*a.Id) + require.Error(t, err) +} + +func TestRestoreValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + }{ + {"null stream", `{"version":1,"streams":{"a":null}}`, true}, + {"null alarm", `{"version":1,"alarms":{"a":null}}`, true}, + {"incompatible version resets", `{"version":99}`, false}, + {"empty ok", `{"version":1}`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := oci.NewInMemoryBackend().Restore(t.Context(), []byte(tt.data)) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestAuthAndErrorEnvelope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true, realClock: true}) + + _, err := e.mon.ListAlarms(t.Context(), monitoring.ListAlarmsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + open := newEnv(t, envOpts{enforce: true, realClock: true}) + _, err = open.mon.ListAlarms(t.Context(), monitoring.ListAlarmsRequest{CompartmentId: new(testCompartment)}) + assert.Equal(t, http.StatusUnauthorized, svcErr(t, err).GetHTTPStatusCode()) + + tests := []struct { + name string + method string + path string + body string + code string + status int + }{ + {"unsigned rejected", http.MethodGet, "/20180401/alarms?compartmentId=x", "", "NotAuthenticated", 401}, + {"unknown path", http.MethodGet, "/20180401/zzz", "", "NotFound", 404}, + } + + for _, tt := range tests { + status, body := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.status, status, tt.name) + assert.Contains(t, body, `"code":"`+tt.code+`"`, tt.name) + } + + plain := newEnv(t, envOpts{}) + plainTests := []struct { + name string + method string + path string + body string + code string + status int + }{ + {"missing alarm", http.MethodGet, "/20180401/alarms/ocid1.alarm.oc1..nope", "", "NotAuthorizedOrNotFound", 404}, + { + "missing alarm history", + http.MethodGet, + "/20180401/alarms/ocid1.alarm.oc1..nope/history", + "", + "NotAuthorizedOrNotFound", + 404, + }, + {"bad json", http.MethodPost, "/20180401/alarms", "{", "CannotParseRequest", 400}, + {"no body", http.MethodPost, "/20180401/alarms", "", "MissingParameter", 400}, + {"no compartment on list", http.MethodGet, "/20180401/alarms", "", "MissingParameter", 400}, + { + "list metrics no compartment", + http.MethodPost, + "/20180401/metrics/actions/listMetrics", + `{}`, + "MissingParameter", + 400, + }, + } + + for _, tt := range plainTests { + status, body := rawDo(t, tt.method, plain.base+tt.path, tt.body) + assert.Equal(t, tt.status, status, tt.name) + assert.Contains(t, body, `"code":"`+tt.code+`"`, tt.name) + } + + a := plain.mkAlarm(t, topicOCID, nil) + status, body := rawDo(t, http.MethodGet, plain.base+"/20180401/alarms/"+*a.Id+"/history?alarmHistorytype=BOGUS", "") + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "InvalidParameter") + + status, _ = rawDo(t, http.MethodGet, plain.base+"/20180401/alarms/"+*a.Id+"/history?timestampLessThan=notatime", "") + assert.Equal(t, http.StatusBadRequest, status) +} + +func TestAdminKeyEndpoint(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + pub, err := json.Marshal(e.kp.pubPEM) + require.NoError(t, err) + + status, _ := rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", + `{"tenancy":"`+testTenancy+`","user":"`+testUser+`","publicKey":`+string(pub)+`}`) + assert.Equal(t, http.StatusCreated, status) + + status, body := rawDo(t, http.MethodGet, e.base+"/_gopherstack/oci/keys", "") + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, testTenancy) +} diff --git a/services/ocimonitoring/provider.go b/services/ocimonitoring/provider.go new file mode 100644 index 0000000000..af0c7330d4 --- /dev/null +++ b/services/ocimonitoring/provider.go @@ -0,0 +1,42 @@ +package ocimonitoring + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocimonitoring: nil app context") + +// ConfigProvider extracts OCI Monitoring configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIMonitoringSettings() Settings +} + +// Provider implements service.Provider for OCI Monitoring. It runs on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIMonitoring" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIMonitoringSettings() + } + + handler := NewHandler(NewInMemoryBackend()) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ocimonitoring/route.go b/services/ocimonitoring/route.go new file mode 100644 index 0000000000..426b34eb2c --- /dev/null +++ b/services/ocimonitoring/route.go @@ -0,0 +1,63 @@ +package ocimonitoring + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateAlarm = "CreateAlarm" + opGetAlarm = "GetAlarm" + opListAlarms = "ListAlarms" + opListAlarmsStatus = "ListAlarmsStatus" + opUpdateAlarm = "UpdateAlarm" + opDeleteAlarm = "DeleteAlarm" + opChangeAlarmComp = "ChangeAlarmCompartment" + opGetAlarmHistory = "GetAlarmHistory" + opPostMetricData = "PostMetricData" + opListMetrics = "ListMetrics" + opSummarizeMetrics = "SummarizeMetricsData" + opAdminKeys = "ManageAPIKeys" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + base = "/20180401" + alarmPath = base + "/alarms/{alarmId}" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/alarms", Op: opCreateAlarm}, + {Method: http.MethodGet, Path: base + "/alarms", Op: opListAlarms}, + {Method: http.MethodGet, Path: base + "/alarms/status", Op: opListAlarmsStatus}, + {Method: http.MethodGet, Path: alarmPath, Op: opGetAlarm}, + {Method: http.MethodPut, Path: alarmPath, Op: opUpdateAlarm}, + {Method: http.MethodDelete, Path: alarmPath, Op: opDeleteAlarm}, + {Method: http.MethodPost, Path: alarmPath + "/actions/changeCompartment", Op: opChangeAlarmComp}, + {Method: http.MethodGet, Path: alarmPath + "/history", Op: opGetAlarmHistory}, + {Method: http.MethodPost, Path: base + "/metrics", Op: opPostMetricData}, + {Method: http.MethodPost, Path: base + "/metrics/actions/listMetrics", Op: opListMetrics}, + {Method: http.MethodPost, Path: base + "/metrics/actions/summarizeMetricsData", Op: opSummarizeMetrics}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocimonitoring/settings.go b/services/ocimonitoring/settings.go new file mode 100644 index 0000000000..ba8a6cb502 --- /dev/null +++ b/services/ocimonitoring/settings.go @@ -0,0 +1,13 @@ +package ocimonitoring + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8161 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIMonitoring. +type Settings struct { + Port int `json:"port" env:"OCI_MONITORING_PORT" default:"8161" name:"port" help:"Fixed TCP port for the dedicated OCI Monitoring (alarms, metric ingestion and metric queries) listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_MONITORING_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ocimonitoring/store.go b/services/ocimonitoring/store.go new file mode 100644 index 0000000000..fa439205ce --- /dev/null +++ b/services/ocimonitoring/store.go @@ -0,0 +1,171 @@ +package ocimonitoring + +import ( + "cmp" + "context" + "slices" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "us-ashburn-1" + defaultListLimit = 100 + ocidTailLen = 52 + maxName = 255 + metricRetention = 90 * 24 * time.Hour + maxHistory = 1000 +) + +// Publisher delivers an alarm notification to a Notifications topic. +type Publisher func(ctx context.Context, topicID, title, body string, asJSON bool) error + +// TopicChecker reports whether a Notifications topic exists. +type TopicChecker func(topicID string) bool + +// InMemoryBackend holds every metric stream and alarm under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + streams map[string]*Stream + alarms map[string]*Alarm + Auth *ociauth.Registry + publisher Publisher + topicChecker TopicChecker + seq int64 +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{mu: lockmetrics.New("ocimonitoring"), now: time.Now} + b.resetMaps() + b.Auth = ociauth.NewRegistry() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.streams, b.alarms = map[string]*Stream{}, map[string]*Alarm{} +} + +// SetClock injects the time source used for datapoint validation, evaluation and retention. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetPublisher wires alarm notification delivery to a Notifications backend. +func (b *InMemoryBackend) SetPublisher(p Publisher) { + b.mu.Lock("SetPublisher") + b.publisher = p + b.mu.Unlock() +} + +// SetTopicChecker wires validation of alarm destinations against a Notifications backend. +func (b *InMemoryBackend) SetTopicChecker(c TopicChecker) { + b.mu.Lock("SetTopicChecker") + b.topicChecker = c + b.mu.Unlock() +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate[T any](items []T, o ListOpts, less func(a, c T, by string) int, defaultBy string) page.Page[T] { + by := o.SortBy + if by == "" { + by = defaultBy + } + + desc := strs.Equal(o.Order, "DESC") + + slices.SortStableFunc(items, func(a, c T) int { + r := less(a, c, by) + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +func firstErr(errs ...*APIError) *APIError { + for _, e := range errs { + if e != nil { + return e + } + } + + return nil +} + +func compareStr(a, c string) int { return cmp.Compare(a, c) } + +// Sweep drops datapoints past retention, then evaluates every alarm and delivers the notifications it raised. +func (b *InMemoryBackend) Sweep(ctx context.Context) { + b.mu.Lock("Sweep") + now := b.nowUTC() + b.prune(now) + out := b.evaluateAll(now) + pub := b.publisher + b.mu.Unlock() + + deliver(ctx, pub, out) +} + +func (b *InMemoryBackend) prune(now time.Time) { + cutoff := now.Add(-metricRetention) + + for k, s := range b.streams { + s.Points = slices.DeleteFunc(s.Points, func(p Point) bool { return p.T.Before(cutoff) }) + if len(s.Points) == 0 { + delete(b.streams, k) + } + } +} diff --git a/services/ocimysql/PARITY.md b/services/ocimysql/PARITY.md new file mode 100644 index 0000000000..73e902cf33 --- /dev/null +++ b/services/ocimysql/PARITY.md @@ -0,0 +1,58 @@ +--- +service: ocimysql +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: 0ac1daf25 +last_audit_date: 2026-10-29 +overall: B +ops: + CreateDbSystem: {wire: ok, errors: partial, state: ok, persist: ok, note: "200 with the DbSystem body (CREATING), etag and opc-work-request-id. shapeName must be a MySQL DB system shape (ListShapes), subnetId must exist in OCI Core when it runs in the same process, mysqlVersion one of ListVersions (default newest 8.4). source NONE needs adminUsername (1 to 32 characters, not a reserved name) and adminPassword (8 to 32 characters with a digit, lower, upper and special character); source BACKUP restores an ACTIVE backup and inherits its administrator unless credentials are given. dataStorageSizeInGBs 50 to 131072 (default 50), port and portX default 3306 and 33060, backupPolicy window and 1 to 35 day retention, deletionPolicy, hostnameLabel (RFC 1123, unique per subnet), ipAddress or the first free host from offset 5 of the subnet CIDR, HA not on MySQL.Free. configurationId must match the shape; the default is the shape's Standalone or HA configuration. rest, databaseConsole, secureConnections, encryptData, customerContacts, readEndpoint, telemetryConfiguration, nsgIds, securityAttributes are stored and echoed only. adminPassword is never returned. OCI_MYSQL_ENGINE=docker: ACTIVE when a real mysql container accepts the admin login."} + GetDbSystem: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; endpoints, dataStorage, channels, currentPlacement, pointInTimeRecoveryDetails when the PITR policy is on; DELETED systems stay readable."} + ListDbSystems: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; dbSystemId, displayName, lifecycleState, configurationId, isHeatWaveClusterAttached filters; sortBy displayName or timeCreated (default newest first); bare JSON array with opc-next-page. DELETED systems are listed only when lifecycleState asks for them."} + UpdateDbSystem: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with UPDATE_DBSYSTEM, no body. Needs ACTIVE or INACTIVE (409 IncorrectState otherwise); If-Match gives 412. Name, description, tags, policies, ports and mode changes apply at once; shape, mysqlVersion (upgrade only), configurationId, storage (grow only), isHighlyAvailable, administrator password or name, subnet, hostname and address put the system in UPDATING for the transition window. Docker engine: an administrator change runs ALTER USER on the live server and a version change relaunches the container on the same data volume with the new image."} + DeleteDbSystem: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with DELETE_DBSYSTEM; DELETING then DELETED. isDeleteProtected gives 409 Conflict. finalBackup REQUIRE_FINAL_BACKUP stores a backup first (with a dump under the docker engine); automaticBackupRetention DELETE removes the system's automatic backups. Docker engine: container and data volume are removed."} + StartDbSystem: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with START_DBSYSTEM; INACTIVE to UPDATING to ACTIVE, 409 from any other state. Docker engine: StartContainer on the stopped container, or a new container on the existing volume when the server is gone (after a restore)."} + StopDbSystem: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with STOP_DBSYSTEM; body shutdownType IMMEDIATE, FAST or SLOW is required; ACTIVE to UPDATING to INACTIVE. Docker engine: StopContainer, data kept. The shutdown type is validated but not applied (Docker stop semantics)."} + RestartDbSystem: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with RESTART_DBSYSTEM; shutdownType required; ACTIVE only."} + CreateBackup: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 with the Backup body (CREATING), etag, opc-work-request-id. DB system must be ACTIVE (409). backupType FULL (default) or INCREMENTAL (recorded; every backup is a full dump), retentionInDays 1 to 365 (default from the policy), softDelete recorded. dbSystemSnapshot captures the system's configuration. Docker engine: mysqldump of every user schema is held in the backup and persisted in snapshots."} + GetBackup: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; DELETED backups stay readable."} + ListBackups: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; backupId, dbSystemId, displayName, lifecycleState, creationType filters; sortBy displayName, timeCreated, timeUpdated; bare array."} + UpdateBackup: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 with the Backup body; displayName, description, softDelete, retentionInDays, tags; ACTIVE only; If-Match gives 412."} + DeleteBackup: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with DELETE_BACKUP; DELETING then DELETED; ACTIVE or FAILED only."} + ChangeBackupCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "200, no body."} + CreateChannel: {wire: ok, errors: partial, state: partial, persist: ok, note: "200 with the Channel body (CREATING, then ACTIVE or INACTIVE by isEnabled), CREATE_CHANNEL work request. source MYSQL needs hostname, username, password, sslMode; the password is validated and discarded. target DBSYSTEM must reference an ACTIVE DB system. State only: nothing is replicated and the source is never contacted."} + GetChannel: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; DB system bodies list their channels."} + ListChannels: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; dbSystemId, channelId, displayName, lifecycleState, isEnabled filters."} + UpdateChannel: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with UPDATE_CHANNEL; isEnabled switches ACTIVE and INACTIVE."} + DeleteChannel: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with DELETE_CHANNEL."} + ResumeChannel: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with RESUME_CHANNEL; INACTIVE or NEEDS_ATTENTION only."} + ResetChannel: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with RESET_CHANNEL; INACTIVE or NEEDS_ATTENTION only."} + CreateConfiguration: {wire: ok, errors: ok, state: ok, persist: ok, note: "200 with the CUSTOM Configuration; shapeName must be a DB system shape, variables are checked against the SDK ConfigurationVariables model (unknown names and wrong value types are 400) and merged over the parent (default: the shape's Standalone default), initVariables.lowerCaseTableNames."} + GetConfiguration: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Default and custom configurations; etag header."} + ListConfigurations: {wire: ok, errors: ok, state: ok, persist: n/a, note: "The shape defaults (Standalone and HA per DB system shape, HA not for MySQL.Free) plus the compartment's custom ones; configurationId, lifecycleState, type, displayName, shapeName filters; sortBy displayName, shapeName, timeCreated, timeUpdated."} + UpdateConfiguration: {wire: ok, errors: ok, state: ok, persist: ok, note: "displayName, description and tags only (the SDK model has no variables); default configurations are 400."} + DeleteConfiguration: {wire: ok, errors: ok, state: ok, persist: ok, note: "204; 409 while a DB system uses it; default configurations are 400."} + ListShapes: {wire: ok, errors: ok, state: ok, persist: n/a, note: "MySQL.Free, MySQL.2 to MySQL.256, the MySQL.VM.Standard.E3.* family and the HeatWave cluster shapes with cpuCoreCount, memorySizeInGBs and isSupportedFor; isSupportedFor and name filters; bare array."} + ListVersions: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Families 8.0, 8.4 and 9.x with a representative patch list."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS until the resource transition time, then SUCCEEDED (or FAILED when the docker engine could not finish)."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; sortBy ID, OPERATION_TYPE, STATUS, TIME_*; bare array."} + ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Empty except for a failed engine operation."} + ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Accepted and succeeded or failed entries."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-mysql-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id. InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch, IncorrectState and Conflict (delete protection) follow the OCI reference; the 400 used for references to unknown subnets and backups is unverified (the live service may answer 404)."} + lifecycle: {status: ok, note: "CREATING, UPDATING and DELETING last 2 seconds on the injectable backend clock and are applied on every request and by a one-minute sweeper; the same sweep takes automatic backups once the policy window opens and deletes backups past their retention."} + oci_core_integration: {status: partial, note: "internal/app wires a narrow Network interface onto OCI Core subnets (existence, availability domain, CIDR) and compartment checks onto OCI Identity. The endpoint address is allocated by this service from the subnet CIDR (first free host from offset 5, unique among DB systems); OCI Core has no allocator, so no PrivateIp or VNIC resource is created there. Standalone, subnets are only shape-checked."} + docker_engine: {status: ok, note: "OCI_MYSQL_ENGINE=docker (--oci-mysql-engine) runs a mysql container per DB system through pkgs/mysqlengine: named data volume, port published on 127.0.0.1 (endpoints show 127.0.0.1 and the published port), administrator created with the requested password, StopContainer/StartContainer for stop/start, container and volume removed on delete, relaunch on the same volume after a restore. OCI_MYSQL_IMAGE overrides the image (default mysql:8.0, mysql:8.4 or mysql:9 by version family)."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-mysql-port / OCI_MYSQL_PORT (default 8159); never on the shared AWS router; cli.go reserves the port in the shared pool."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} +items_still_open: + - "HeatWave cluster operations (AddHeatWaveCluster, GetHeatWaveCluster, UpdateHeatWaveCluster, DeleteHeatWaveCluster, Start/Stop/RestartHeatWaveCluster, the memory estimate operations) are not routed; isHeatWaveClusterAttached is always false." + - "Read replicas (CreateReplica and friends), blue/green deployments, ControlledUpdateDbSystem, GenerateDbSystemStatus/GetDbSystemStatus, ListMaintenanceEvents, CopyBackup, ExportBackup, ValidateBackup, CancelBackupDeletion, GenerateChannelStatus/GetChannelStatus and CancelWorkRequest are not routed." + - "Source types PITR and IMPORTURL for CreateDbSystem are rejected 400; the PITR policy flag is stored and pointInTimeRecoveryDetails reports creation time to now, but no point-in-time restore exists." + - "Channels are state only: no replication is configured on the target server, the source is never contacted and statuses are not generated." + - "Configuration variables are validated and stored but not applied to the MySQL server, even under the docker engine; shutdownType, crashRecovery, databaseMode, accessMode, isHighlyAvailable (a single container), secureConnections, rest, databaseConsole, readEndpoint and maintenance schedules are stored and echoed only." + - "Backups are logical dumps (mysqldump of user schemas, no accounts) held in the service state and in persistence snapshots, not in Object Storage; INCREMENTAL backups are full dumps; softDelete/DELETE_SCHEDULED is not modelled. A backup taken before the docker engine was enabled restores to an empty server." + - "Under the docker engine the endpoint is 127.0.0.1 with a random published port; portX (X protocol) is not published and the configured port/portX only apply to the state-only endpoint." + - "Shape and version catalogues are representative (documented shape names; 8.0.39-8.0.42, 8.4.3-8.4.6, 9.1.0-9.4.0), the free-shape HA rule, the 1 to 35 day automatic and 1 to 365 day manual retention ranges, the 50 to 131072 GB storage range and the reserved administrator names come from the Oracle documentation and SDK comments on 2026-10-29; default ordering, DELETED visibility in lists and the exact 400 versus 404 for dangling references are unverified." + - "opc-retry-token idempotency, service limits, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified." +--- diff --git a/services/ocimysql/README.md b/services/ocimysql/README.md new file mode 100644 index 0000000000..846e88f6f7 --- /dev/null +++ b/services/ocimysql/README.md @@ -0,0 +1,31 @@ + +# OCI MySQL HeatWave Database Service + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-29 (`0ac1daf25`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 32 (30 ok, 2 partial) | +| Feature families | 7 (5 ok, 2 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- HeatWave cluster operations (AddHeatWaveCluster, GetHeatWaveCluster, UpdateHeatWaveCluster, DeleteHeatWaveCluster, Start/Stop/RestartHeatWaveCluster, the memory estimate operations) are not routed; isHeatWaveClusterAttached is always false. +- Read replicas (CreateReplica and friends), blue/green deployments, ControlledUpdateDbSystem, GenerateDbSystemStatus/GetDbSystemStatus, ListMaintenanceEvents, CopyBackup, ExportBackup, ValidateBackup, CancelBackupDeletion, GenerateChannelStatus/GetChannelStatus and CancelWorkRequest are not routed. +- Source types PITR and IMPORTURL for CreateDbSystem are rejected 400; the PITR policy flag is stored and pointInTimeRecoveryDetails reports creation time to now, but no point-in-time restore exists. +- Channels are state only: no replication is configured on the target server, the source is never contacted and statuses are not generated. +- Configuration variables are validated and stored but not applied to the MySQL server, even under the docker engine; shutdownType, crashRecovery, databaseMode, accessMode, isHighlyAvailable (a single container), secureConnections, rest, databaseConsole, readEndpoint and maintenance schedules are stored and echoed only. +- Backups are logical dumps (mysqldump of user schemas, no accounts) held in the service state and in persistence snapshots, not in Object Storage; INCREMENTAL backups are full dumps; softDelete/DELETE_SCHEDULED is not modelled. A backup taken before the docker engine was enabled restores to an empty server. +- Under the docker engine the endpoint is 127.0.0.1 with a random published port; portX (X protocol) is not published and the configured port/portX only apply to the state-only endpoint. +- Shape and version catalogues are representative (documented shape names; 8.0.39-8.0.42, 8.4.3-8.4.6, 9.1.0-9.4.0), the free-shape HA rule, the 1 to 35 day automatic and 1 to 365 day manual retention ranges, the 50 to 131072 GB storage range and the reserved administrator names come from the Oracle documentation and SDK comments on 2026-10-29; default ordering, DELETED visibility in lists and the exact 400 versus 404 for dangling references are unverified. +- opc-retry-token idempotency, service limits, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocimysql/backup_test.go b/services/ocimysql/backup_test.go new file mode 100644 index 0000000000..da0a583c9f --- /dev/null +++ b/services/ocimysql/backup_test.go @@ -0,0 +1,334 @@ +package ocimysql_test + +import ( + "net/http" + "testing" + "time" + + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (e *env) mkBackup(t *testing.T, dbID *string, name string) my.Backup { + t.Helper() + + resp, err := e.bk.CreateBackup(t.Context(), my.CreateBackupRequest{ + DbSystemId: dbID, DisplayName: new(name), + }) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + assert.Equal(t, my.BackupLifecycleStateCreating, resp.LifecycleState) + e.settle() + + got, err := e.bk.GetBackup(t.Context(), my.GetBackupRequest{BackupId: resp.Id}) + require.NoError(t, err) + + return got.Backup +} + +func TestBackupLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + db := e.mkDB(t, "src", nil) + + created, err := e.bk.CreateBackup(ctx, my.CreateBackupRequest{ + DbSystemId: db.Id, DisplayName: new("nightly"), RetentionInDays: new(10), + FreeformTags: map[string]string{"k": "v"}, + }) + require.NoError(t, err) + + wr, err := e.wr.GetWorkRequest(ctx, my.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, my.WorkRequestOperationTypeCreateBackup, wr.OperationType) + + e.settle() + + got, err := e.bk.GetBackup(ctx, my.GetBackupRequest{BackupId: created.Id}) + require.NoError(t, err) + bk := got.Backup + assert.Equal(t, my.BackupLifecycleStateActive, bk.LifecycleState) + assert.Equal(t, my.BackupCreationTypeManual, bk.CreationType) + assert.Equal(t, my.BackupBackupTypeFull, bk.BackupType) + assert.Equal(t, db.Id, bk.DbSystemId) + assert.Equal(t, 10, *bk.RetentionInDays) + assert.Equal(t, shapeSmall, *bk.ShapeName) + assert.Equal(t, "8.4.6", *bk.MysqlVersion) + assert.Equal(t, adminUser, *bk.DbSystemSnapshot.AdminUsername) + assert.Equal(t, "v", bk.FreeformTags["k"]) + require.NotNil(t, got.Etag) + + _, err = e.bk.UpdateBackup(ctx, my.UpdateBackupRequest{ + BackupId: bk.Id, IfMatch: new("stale"), DisplayName: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.bk.UpdateBackup(ctx, my.UpdateBackupRequest{ + BackupId: bk.Id, IfMatch: got.Etag, + DisplayName: new("renamed"), Description: new("desc"), RetentionInDays: new(20), + }) + require.NoError(t, err) + assert.Equal(t, "renamed", *upd.DisplayName) + assert.Equal(t, 20, *upd.RetentionInDays) + + lst, err := e.bk.ListBackups(ctx, my.ListBackupsRequest{CompartmentId: new(testCompartment), DbSystemId: db.Id}) + require.NoError(t, err) + require.Len(t, lst.Items, 1) + assert.Equal(t, "desc", *lst.Items[0].Description) + + del, err := e.bk.DeleteBackup(ctx, my.DeleteBackupRequest{BackupId: bk.Id}) + require.NoError(t, err) + require.NotNil(t, del.OpcWorkRequestId) + + mid, err := e.bk.GetBackup(ctx, my.GetBackupRequest{BackupId: bk.Id}) + require.NoError(t, err) + assert.Equal(t, my.BackupLifecycleStateDeleting, mid.LifecycleState) + + _, err = e.bk.DeleteBackup(ctx, my.DeleteBackupRequest{BackupId: bk.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + e.settle() + + gone, err := e.bk.GetBackup(ctx, my.GetBackupRequest{BackupId: bk.Id}) + require.NoError(t, err) + assert.Equal(t, my.BackupLifecycleStateDeleted, gone.LifecycleState) + + lst, err = e.bk.ListBackups(ctx, my.ListBackupsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, lst.Items) + + _, err = e.bk.UpdateBackup(ctx, my.UpdateBackupRequest{ + BackupId: bk.Id, DisplayName: new("late"), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestCreateBackupValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + det func(db my.DbSystem) my.CreateBackupDetails + code string + status int + }{ + { + "no db system", + func(my.DbSystem) my.CreateBackupDetails { return my.CreateBackupDetails{} }, + "MissingParameter", + 400, + }, + {"unknown db system", func(my.DbSystem) my.CreateBackupDetails { + return my.CreateBackupDetails{DbSystemId: new("ocid1.mysqldbsystem.oc1.iad.nope")} + }, "NotAuthorizedOrNotFound", 404}, + {"bad retention", func(db my.DbSystem) my.CreateBackupDetails { + return my.CreateBackupDetails{DbSystemId: db.Id, RetentionInDays: new(0)} + }, "InvalidParameter", 400}, + {"long name", func(db my.DbSystem) my.CreateBackupDetails { + return my.CreateBackupDetails{DbSystemId: db.Id, DisplayName: new(string(make([]byte, 300)))} + }, "InvalidParameter", 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + db := e.mkDB(t, "src", nil) + + _, err := e.bk.CreateBackup(t.Context(), my.CreateBackupRequest{CreateBackupDetails: tt.det(db)}) + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } + + e := newEnv(t, envOpts{}) + db := e.mkDB(t, "stopped", nil) + + _, err := e.db.StopDbSystem(t.Context(), my.StopDbSystemRequest{ + DbSystemId: db.Id, ShutdownType: my.InnoDbShutdownModeFast, + }) + require.NoError(t, err) + + _, err = e.bk.CreateBackup( + t.Context(), + my.CreateBackupRequest{DbSystemId: db.Id}, + ) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "backups need an ACTIVE DB system") +} + +func TestRestoreFromBackup(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + src := e.mkDB(t, "src", func(d *my.CreateDbSystemDetails) { + d.DataStorageSizeInGBs = new(120) + d.ShapeName = new(shapeBig) + }) + bk := e.mkBackup(t, src.Id, "snap") + + restored := e.mkDB(t, "copy", func(d *my.CreateDbSystemDetails) { + d.AdminUsername, d.AdminPassword = nil, nil + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: bk.Id} + d.ShapeName = new(shapeBig) + }) + + require.IsType(t, my.DbSystemSourceFromBackup{}, restored.Source) + assert.Equal(t, *bk.Id, *restored.Source.(my.DbSystemSourceFromBackup).BackupId) + assert.Equal(t, 120, *restored.DataStorageSizeInGBs, "size follows the backup") + assert.NotEqual(t, *src.IpAddress, *restored.IpAddress) + + override := e.mkDB(t, "copy2", func(d *my.CreateDbSystemDetails) { + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: bk.Id} + d.AdminPassword = new("Other#Passw0rd") + }) + assert.Equal(t, my.DbSystemLifecycleStateActive, override.LifecycleState) + + _, err := e.db.CreateDbSystem(ctx, my.CreateDbSystemRequest{CreateDbSystemDetails: func() my.CreateDbSystemDetails { + d := dbDetails("weak") + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: bk.Id} + d.AdminPassword = new("weak") + + return d + }()}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.bk.DeleteBackup(ctx, my.DeleteBackupRequest{BackupId: bk.Id}) + require.NoError(t, err) + + _, err = e.db.CreateDbSystem(ctx, my.CreateDbSystemRequest{CreateDbSystemDetails: func() my.CreateDbSystemDetails { + d := dbDetails("late") + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: bk.Id} + + return d + }()}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "a DELETING backup is not restorable") +} + +type autoBackupStart struct { + start time.Time + name string +} + +func autoBackupStarts() []autoBackupStart { + return []autoBackupStart{ + {name: "0010", start: time.Date(2026, 3, 10, 0, 10, 0, 0, time.UTC)}, + {name: "1200", start: time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC)}, + {name: "2242", start: time.Date(2026, 3, 10, 22, 42, 0, 0, time.UTC)}, + {name: "2359", start: time.Date(2026, 3, 10, 23, 59, 0, 0, time.UTC)}, + } +} + +func TestAutomaticBackupsAndRetention(t *testing.T) { + t.Parallel() + + for _, tt := range autoBackupStarts() { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{start: tt.start}) + ctx := t.Context() + + window := e.clock.Now().Add(time.Hour).Format("15:04") + db := e.mkDB(t, "auto", func(d *my.CreateDbSystemDetails) { + d.BackupPolicy = &my.CreateBackupPolicyDetails{WindowStartTime: &window, RetentionInDays: new(2)} + }) + + e.backend.Sweep() + + lst, err := e.bk.ListBackups(ctx, my.ListBackupsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, lst.Items, "the window has not opened yet") + + e.clock.Advance(2 * time.Hour) + e.backend.Sweep() + e.backend.Sweep() + e.settle() + + lst, err = e.bk.ListBackups(ctx, my.ListBackupsRequest{ + CompartmentId: new(testCompartment), CreationType: my.BackupCreationTypeAutomatic, + }) + require.NoError(t, err) + require.Len(t, lst.Items, 1, "one automatic backup per window") + assert.Equal(t, db.Id, lst.Items[0].DbSystemId) + assert.Equal(t, 2, *lst.Items[0].RetentionInDays) + + e.clock.Advance(3 * 24 * time.Hour) + e.backend.Sweep() + + lst, err = e.bk.ListBackups(ctx, my.ListBackupsRequest{ + CompartmentId: new(testCompartment), LifecycleState: my.BackupLifecycleStateDeleted, + }) + require.NoError(t, err) + assert.NotEmpty(t, lst.Items, "backups past their retention are deleted") + }) + } +} + +func TestDeletionPolicyBackupRetention(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + retain my.CreateDeletionPolicyDetailsAutomaticBackupRetentionEnum + wantLive int + }{ + {"delete automatic backups", my.CreateDeletionPolicyDetailsAutomaticBackupRetentionDelete, 1}, + {"retain automatic backups", my.CreateDeletionPolicyDetailsAutomaticBackupRetentionRetain, 2}, + } + + for _, tt := range tests { + for _, st := range autoBackupStarts() { + t.Run(tt.name+" "+st.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{start: st.start}) + window := e.clock.Now().Add(time.Hour).Format("15:04") + db := e.mkDB(t, "pol", func(d *my.CreateDbSystemDetails) { + d.BackupPolicy = &my.CreateBackupPolicyDetails{WindowStartTime: &window} + d.DeletionPolicy = &my.CreateDeletionPolicyDetails{AutomaticBackupRetention: tt.retain} + }) + e.mkBackup(t, db.Id, "manual") + + e.clock.Advance(2 * time.Hour) + e.backend.Sweep() + e.settle() + + _, err := e.db.DeleteDbSystem(t.Context(), my.DeleteDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err) + + lst, err := e.bk.ListBackups(t.Context(), my.ListBackupsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Len(t, lst.Items, tt.wantLive) + }) + } + } +} + +func TestChangeBackupCompartment(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + db := e.mkDB(t, "src", nil) + bk := e.mkBackup(t, db.Id, "mv") + + _, err := e.bk.ChangeBackupCompartment(t.Context(), my.ChangeBackupCompartmentRequest{ + BackupId: bk.Id, + CompartmentId: new(otherCompart), + }) + require.NoError(t, err) + + moved, err := e.bk.GetBackup(t.Context(), my.GetBackupRequest{BackupId: bk.Id}) + require.NoError(t, err) + assert.Equal(t, otherCompart, *moved.CompartmentId) + + _, err = e.bk.ChangeBackupCompartment(t.Context(), my.ChangeBackupCompartmentRequest{ + BackupId: new("ocid1.mysqlbackup.oc1.iad.nope"), + CompartmentId: new(otherCompart), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} diff --git a/services/ocimysql/backups.go b/services/ocimysql/backups.go new file mode 100644 index 0000000000..ddbc5ac2dd --- /dev/null +++ b/services/ocimysql/backups.go @@ -0,0 +1,523 @@ +package ocimysql + +import ( + "maps" + "net/http" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + creationManual = "MANUAL" + creationAuto = "AUTOMATIC" + backupFull = "FULL" + backupIncr = "INCREMENTAL" + maxBackupRetain = 365 + bytesPerGB = 1 << 30 + backupMinSizeGBs = 1 +) + +func cloneSystem(s *DBSystem) *DBSystem { + c := *s + c.Maintenance, c.Extra = maps.Clone(s.Maintenance), maps.Clone(s.Extra) + c.TransitionAt = nil + + return &c +} + +func sizeGBs(n int) int { return max(backupMinSizeGBs, (n+bytesPerGB-1)/bytesPerGB) } + +// newBackupRecord stores a backup of s. Without a dump (state-only mode, or a dump still being taken) it settles on +// the clock; with one it is ACTIVE at once. +func (b *InMemoryBackend) newBackupRecord(s *DBSystem, creation, name string, retention int, dump []byte) *Backup { + now := b.nowUTC() + bk := &Backup{ + ID: newOCID("mysqlbackup"), + CompartmentID: s.CompartmentID, + DBSystemID: s.ID, + Name: name, + State: stateCreating, + BackupType: backupFull, + CreationType: creation, + SoftDelete: disabled, + Retention: retention, + SizeGB: backupMinSizeGBs, + Etag: newEtag(), + TimeCreated: now, + TimeUpdated: now, + Snapshot: cloneSystem(s), + Seq: b.nextSeq(), + } + b.backups[bk.ID] = bk + + switch { + case dump != nil: + bk.State, bk.Dump, bk.SizeGB = stateActive, dump, sizeGBs(len(dump)) + case b.eng == nil: + bk.TransitionAt = b.transitionAt() + } + + return bk +} + +func (b *InMemoryBackend) dropAutomaticBackups(sysID string) { + for _, bk := range b.backups { + if bk.DBSystemID == sysID && bk.CreationType == creationAuto && bk.State != stateDeleted { + bk.State, bk.Dump, bk.TransitionAt = stateDeleted, nil, nil + } + } +} + +// startBackup records a backup of s and, with the engine, dumps the server in the background. +func (b *InMemoryBackend) startBackup( + s *DBSystem, + creation, name string, + retention int, + op string, +) (*Backup, string, *APIError) { + if b.eng != nil && b.eng.live[s.ID] == nil { + return nil, "", errState("DB system " + s.ID + " has no running server to back up") + } + + bk := b.newBackupRecord(s, creation, name, retention, nil) + + var wr string + if op != "" { + wr = b.newWR(op, bk.CompartmentID, entityBackup, bk.ID, actionCreated, bk.TransitionAt) + } + + if b.eng != nil { + b.spawnBackup(bk.ID, wr, s.ID) + } + + return bk, wr, nil +} + +func (b *InMemoryBackend) spawnBackup(bkID, wrID, sysID string) { + ctx := b.runCtx + + b.wg.Go(func() { + var ( + dump []byte + err error + ) + + if inst := b.instance(sysID); inst == nil { + err = errNoServer + } else { + dump, err = inst.Dump(ctx) + } + + b.mu.Lock("spawnBackup") + defer b.mu.Unlock() + + bk, ok := b.backups[bkID] + if !ok || ctx.Err() != nil { + return + } + + bk.TimeUpdated, bk.Etag = b.nowUTC(), newEtag() + + if err != nil { + bk.State, bk.Details = stateFailed, "Backup failed: "+err.Error() + b.finishWR(wrID, bk.Details) + + return + } + + if dump == nil { + dump = []byte{} + } + + bk.State, bk.Dump, bk.SizeGB = stateActive, dump, sizeGBs(len(dump)) + b.finishWR(wrID, "") + }) +} + +// autoBackups takes the daily backup of every ACTIVE DB system once its backup window has opened. +func (b *InMemoryBackend) autoBackups() { + now := b.nowUTC() + + for _, s := range b.systems { + if s.State != stateActive || !s.BackupPolicy.IsEnabled { + continue + } + + t, err := time.Parse("15:04", s.BackupPolicy.WindowStartTime) + if err != nil { + continue + } + + window := time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, time.UTC) + if window.After(now) { + window = window.AddDate(0, 0, -1) + } + + if !s.LastAutoBackup.Before(window) { + continue + } + + s.LastAutoBackup = now + _, _, _ = b.startBackup( + s, + creationAuto, + "Automatic backup "+now.Format("2006-01-02"), + s.BackupPolicy.RetentionInDays, + "", + ) + } +} + +// expireBackups deletes backups older than their retention. +func (b *InMemoryBackend) expireBackups() { + now := b.nowUTC() + + for _, bk := range b.backups { + if bk.State != stateActive { + continue + } + + if bk.TimeCreated.Add(time.Duration(bk.Retention) * hoursPerDay * time.Hour).Before(now) { + bk.State, bk.Dump = stateDeleted, nil + } + } +} + +// CreateBackupIn is the CreateBackup body. +type CreateBackupIn struct { + Tags + Retention *int `json:"retentionInDays"` + DBSystemID string `json:"dbSystemId"` + Name string `json:"displayName"` + Description string `json:"description"` + SoftDelete string `json:"softDelete"` + BackupType string `json:"backupType"` +} + +// CreateBackup starts a manual backup of an ACTIVE DB system. +func (b *InMemoryBackend) CreateBackup(in CreateBackupIn) (Doc, string, string, error) { + b.mu.Lock("CreateBackup") + defer b.mu.Unlock() + + if in.DBSystemID == "" { + return nil, "", "", errMissing("dbSystemId is required") + } + + if aerr := firstErr( + validEnum("backupType", in.BackupType, backupFull, backupIncr), + validEnum("softDelete", in.SoftDelete, enabled, disabled), + validRetention(in.Retention), + ); aerr != nil { + return nil, "", "", aerr + } + + b.settleID(in.DBSystemID) + + s, ok := b.systems[in.DBSystemID] + if !ok || s.State == stateDeleted { + return nil, "", "", errNotFound(in.DBSystemID) + } + + if s.State != stateActive { + return nil, "", "", errState("DB system " + s.ID + " is in state " + s.State + "; backups require ACTIVE") + } + + name := orStr(in.Name, "MySQLBackup"+b.nowUTC().Format("20060102150405")) + if aerr := validateName(name); aerr != nil { + return nil, "", "", aerr + } + + retention := s.BackupPolicy.RetentionInDays + if in.Retention != nil { + retention = *in.Retention + } + + bk, wr, aerr := b.startBackup(s, creationManual, name, retention, wrCreateBackup) + if aerr != nil { + return nil, "", "", aerr + } + + bk.Description, bk.Tags = in.Description, in.Tags + setNonEmpty(&bk.BackupType, strings.ToUpper(in.BackupType)) + setNonEmpty(&bk.SoftDelete, strings.ToUpper(in.SoftDelete)) + + return b.backupDoc(bk, true), bk.Etag, wr, nil +} + +func validRetention(r *int) *APIError { + if r != nil && (*r < minRetentionDays || *r > maxBackupRetain) { + return errParam("retentionInDays must be between 1 and 365") + } + + return nil +} + +func (b *InMemoryBackend) backupDoc(bk *Backup, full bool) Doc { + d := Doc{ + "id": bk.ID, keyCompartment: bk.CompartmentID, keyTimeCreated: bk.TimeCreated, keyLifecycle: bk.State, + "backupType": bk.BackupType, "creationType": bk.CreationType, paramDBSystem: bk.DBSystemID, + keyDisplayName: bk.Name, "softDelete": bk.SoftDelete, "backupSizeInGBs": bk.SizeGB, + "retentionInDays": bk.Retention, "lifecycleDetails": bk.Details, + } + putTags(d, bk.Tags) + + if bk.Description != "" { + d["description"] = bk.Description + } + + if sn := bk.Snapshot; sn != nil { + d["dataStorageSizeInGBs"], d["mysqlVersion"], d["shapeName"] = sn.DataGB, sn.Version, sn.Shape + } + + if full { + d[keyTimeUpdated] = bk.TimeUpdated + + if sn := bk.Snapshot; sn != nil { + snap := b.systemDoc(sn) + snap["adminUsername"], snap["region"] = sn.AdminUser, Region + delete(snap, keyLifecycle) + d["dbSystemSnapshot"] = snap + } + } + + return d +} + +// GetBackup returns one backup. +func (b *InMemoryBackend) GetBackup(id string) (Doc, string, error) { + b.mu.Lock("GetBackup") + defer b.mu.Unlock() + + b.settleID(id) + + bk, ok := b.backups[id] + if !ok { + return nil, "", errNotFound(id) + } + + return b.backupDoc(bk, true), bk.Etag, nil +} + +// BackupListIn are the ListBackups filters. +type BackupListIn struct { + Compartment string + ID string + DBSystemID string + Name string + State string + CreationType string + ListOpts +} + +func (in BackupListIn) matches(bk *Backup) bool { + if bk.CompartmentID != in.Compartment || (in.ID != "" && bk.ID != in.ID) || + (in.DBSystemID != "" && bk.DBSystemID != in.DBSystemID) || (in.Name != "" && bk.Name != in.Name) || + (in.CreationType != "" && !strings.EqualFold(bk.CreationType, in.CreationType)) { + return false + } + + return stateMatches(bk.State, in.State) +} + +// ListBackups lists the backups of a compartment. +func (b *InMemoryBackend) ListBackups(in BackupListIn) (page.Page[Doc], error) { + if aerr := firstErr( + b.checkCompartmentLocked(in.Compartment), + validListOpts(in.ListOpts, keyDisplayName, keyTimeCreated, keyTimeUpdated), + validEnum("creationType", in.CreationType, creationManual, creationAuto, "OPERATOR"), + ); aerr != nil { + return page.Page[Doc]{}, aerr + } + + b.mu.Lock("ListBackups") + defer b.mu.Unlock() + + b.settleAll() + + var all []*Backup + + for _, bk := range b.backups { + if in.matches(bk) { + all = append(all, bk) + } + } + + pg := paginate(all, in.ListOpts, func(bk *Backup) sortKeys { + return sortKeys{created: bk.TimeCreated, updated: bk.TimeUpdated, name: bk.Name, seq: bk.Seq} + }) + + out := make([]Doc, 0, len(pg.Data)) + for _, bk := range pg.Data { + out = append(out, b.backupDoc(bk, false)) + } + + return page.Page[Doc]{Data: out, Next: pg.Next}, nil +} + +// UpdateBackupIn is the UpdateBackup body. +type UpdateBackupIn struct { + Tags + Name *string `json:"displayName"` + Description *string `json:"description"` + Retention *int `json:"retentionInDays"` + SoftDelete string `json:"softDelete"` +} + +// UpdateBackup updates a backup in place. +func (b *InMemoryBackend) UpdateBackup(id, ifMatch string, in UpdateBackupIn) (Doc, string, error) { + b.mu.Lock("UpdateBackup") + defer b.mu.Unlock() + + b.settleID(id) + + bk, ok := b.backups[id] + if !ok || bk.State == stateDeleted { + return nil, "", errNotFound(id) + } + + if aerr := firstErr(checkEtag(bk.Etag, ifMatch), validEnum("softDelete", in.SoftDelete, enabled, disabled), + validRetention(in.Retention)); aerr != nil { + return nil, "", aerr + } + + if bk.State != stateActive { + return nil, "", errState("backup " + id + " is in state " + bk.State + "; updates require ACTIVE") + } + + if in.Name != nil { + if aerr := validateName(*in.Name); aerr != nil { + return nil, "", aerr + } + + bk.Name = *in.Name + } + + if in.Description != nil { + bk.Description = *in.Description + } + + if in.Retention != nil { + bk.Retention = *in.Retention + } + + setNonEmpty(&bk.SoftDelete, strings.ToUpper(in.SoftDelete)) + bk.Tags.merge(in.Tags) + bk.TimeUpdated, bk.Etag = b.nowUTC(), newEtag() + + return b.backupDoc(bk, true), bk.Etag, nil +} + +// DeleteBackup deletes a backup and returns its work request. +func (b *InMemoryBackend) DeleteBackup(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteBackup") + defer b.mu.Unlock() + + b.settleID(id) + + bk, ok := b.backups[id] + if !ok || bk.State == stateDeleted { + return "", errNotFound(id) + } + + if aerr := checkEtag(bk.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if bk.State != stateActive && bk.State != stateFailed { + return "", errState("backup " + id + " is in state " + bk.State + "; deletion requires ACTIVE or FAILED") + } + + bk.State, bk.TimeUpdated, bk.Etag, bk.TransitionAt = stateDeleting, b.nowUTC(), newEtag(), b.transitionAt() + + return b.newWR(wrDeleteBackup, bk.CompartmentID, entityBackup, id, actionDeleted, bk.TransitionAt), nil +} + +// ChangeBackupCompartment moves a backup to another compartment. +func (b *InMemoryBackend) ChangeBackupCompartment(id, ifMatch, compartment string) error { + b.mu.Lock("ChangeBackupCompartment") + defer b.mu.Unlock() + + b.settleID(id) + + bk, ok := b.backups[id] + if !ok || bk.State == stateDeleted { + return errNotFound(id) + } + + if aerr := firstErr(checkEtag(bk.Etag, ifMatch), b.checkCompartment(compartment)); aerr != nil { + return aerr + } + + bk.CompartmentID, bk.TimeUpdated, bk.Etag = compartment, b.nowUTC(), newEtag() + + return nil +} + +func (h *Handler) createBackup(q *req) error { + var in CreateBackupIn + if err := q.decode(&in); err != nil { + return err + } + + d, etag, wr, err := h.Backend.CreateBackup(in) + if err != nil { + return err + } + + q.header("opc-work-request-id", wr) + + return q.json(http.StatusOK, d, etag) +} + +func (h *Handler) getBackup(q *req) error { + d, etag, err := h.Backend.GetBackup(q.param("backupId")) + + return reply(q, d, etag, err) +} + +func (h *Handler) listBackups(q *req) error { + pg, err := h.Backend.ListBackups(BackupListIn{ + Compartment: q.query(keyCompartment), ID: q.query("backupId"), DBSystemID: q.query(paramDBSystem), + Name: q.query(keyDisplayName), State: q.query(keyLifecycle), CreationType: q.query("creationType"), + ListOpts: q.listOpts(), + }) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateBackup(q *req) error { + var in UpdateBackupIn + if err := q.decode(&in); err != nil { + return err + } + + d, etag, err := h.Backend.UpdateBackup(q.param("backupId"), q.ifMatch(), in) + + return reply(q, d, etag, err) +} + +func (h *Handler) deleteBackup(q *req) error { + wr, err := h.Backend.DeleteBackup(q.param("backupId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeBackupCompartment(q *req) error { + var in struct { + CompartmentID string `json:"compartmentId"` + } + + if err := q.decode(&in); err != nil { + return err + } + + if err := h.Backend.ChangeBackupCompartment(q.param("backupId"), q.ifMatch(), in.CompartmentID); err != nil { + return err + } + + q.w.WriteHeader(http.StatusOK) + + return nil +} diff --git a/services/ocimysql/catalog.go b/services/ocimysql/catalog.go new file mode 100644 index 0000000000..567cd511fa --- /dev/null +++ b/services/ocimysql/catalog.go @@ -0,0 +1,164 @@ +package ocimysql + +import ( + "crypto/sha256" + "encoding/hex" + "slices" + "strconv" + "strings" +) + +const ( + supportDBSystem = "DBSYSTEM" + supportHeatWave = "HEATWAVECLUSTER" + shapeFree = "MySQL.Free" + gib = int64(1) << 30 + tenancyOwner = "ocid1.tenancy.oc1..mysqldefaults" + + minConnections = 100 + connectionsPerGB = 16 + bufferPoolShare = 2 + defaultBinlogExpiry = 3600 +) + +type shapeDef struct { + name string + support string + cpus int + memGB int +} + +//nolint:gochecknoglobals // immutable catalogue of documented MySQL shapes +var shapeDefs = []shapeDef{ + {shapeFree, supportDBSystem, 2, 8}, + {"MySQL.2", supportDBSystem, 2, 16}, + {"MySQL.4", supportDBSystem, 4, 32}, + {"MySQL.8", supportDBSystem, 8, 64}, + {"MySQL.16", supportDBSystem, 16, 128}, + {"MySQL.32", supportDBSystem, 32, 256}, + {"MySQL.48", supportDBSystem, 48, 384}, + {"MySQL.64", supportDBSystem, 64, 512}, + {"MySQL.256", supportDBSystem, 256, 2048}, + {"MySQL.VM.Standard.E3.1.8GB", supportDBSystem, 1, 8}, + {"MySQL.VM.Standard.E3.1.16GB", supportDBSystem, 1, 16}, + {"MySQL.VM.Standard.E3.2.32GB", supportDBSystem, 2, 32}, + {"MySQL.VM.Standard.E3.4.64GB", supportDBSystem, 4, 64}, + {"MySQL.VM.Standard.E3.8.128GB", supportDBSystem, 8, 128}, + {"MySQL.VM.Standard.E3.16.256GB", supportDBSystem, 16, 256}, + {"MySQL.VM.Standard.E3.24.384GB", supportDBSystem, 24, 384}, + {"MySQL.VM.Standard.E3.32.512GB", supportDBSystem, 32, 512}, + {"MySQL.VM.Standard.E3.48.768GB", supportDBSystem, 48, 768}, + {"MySQL.VM.Standard.E3.64.1024GB", supportDBSystem, 64, 1024}, + {"MySQL.HeatWave.VM.Standard", supportHeatWave, 32, 512}, + {"MySQL.HeatWave.VM.Standard.E3", supportHeatWave, 32, 512}, + {"MySQL.HeatWave.BM.Standard", supportHeatWave, 128, 1024}, + {"MySQL.HeatWave.BM.Standard.E3", supportHeatWave, 128, 2048}, +} + +func shapeByName(name string) (shapeDef, bool) { + for _, s := range shapeDefs { + if s.name == name { + return s, true + } + } + + return shapeDef{}, false +} + +type versionFamily struct { + family string + image string + versions []string +} + +//nolint:gochecknoglobals // immutable catalogue of representative MySQL releases +var versionFamilies = []versionFamily{ + {"8.0", "mysql:8.0", []string{"8.0.39", "8.0.40", "8.0.41", "8.0.42"}}, + {"8.4", "mysql:8.4", []string{"8.4.3", "8.4.4", "8.4.5", "8.4.6"}}, + {"9.x", "mysql:9", []string{"9.1.0", "9.2.0", "9.3.0", "9.4.0"}}, +} + +const defaultVersion = "8.4.6" + +func familyOf(version string) (versionFamily, bool) { + for _, f := range versionFamilies { + if slices.Contains(f.versions, version) { + return f, true + } + } + + return versionFamily{}, false +} + +// versionTuple parses "major.minor.patch" for ordering; ok is false for anything else. +func versionTuple(v string) [3]int { + var out [3]int + + for i, p := range strings.SplitN(v, ".", len(out)) { + out[i], _ = strconv.Atoi(p) + } + + return out +} + +func versionBefore(a, b string) bool { + ta, tb := versionTuple(a), versionTuple(b) + + return slices.Compare(ta[:], tb[:]) < 0 +} + +func defaultConfigID(shape string, ha bool) string { + sum := sha256.Sum256([]byte(shape + strconv.FormatBool(ha))) + + return "ocid1.mysqlconfiguration.oc1..default" + hex.EncodeToString(sum[:])[:24] +} + +// defaultConfigs builds the DEFAULT configurations: one standalone and, except for the free shape, one HA per +// DB system shape. +func defaultConfigs() []*Configuration { + var out []*Configuration + + for _, s := range shapeDefs { + if s.support != supportDBSystem { + continue + } + + for _, ha := range []bool{false, true} { + if ha && s.name == shapeFree { + continue + } + + kind := "Standalone" + if ha { + kind = "HA" + } + + out = append(out, &Configuration{ + ID: defaultConfigID(s.name, ha), + CompartmentID: tenancyOwner, + Shape: s.name, + Type: configDefault, + State: stateActive, + Name: s.name + "." + kind, + Description: "Default " + kind + " configuration for " + s.name, + Variables: defaultVariables(s), + Etag: "default", + }) + } + } + + return out +} + +func defaultVariables(s shapeDef) map[string]any { + return map[string]any{ + "maxConnections": max(minConnections, s.memGB*connectionsPerGB), + "innodbBufferPoolSize": int64(s.memGB) * gib / bufferPoolShare, + "autocommit": true, + "binlogExpireLogsSeconds": defaultBinlogExpiry, + "sqlMode": "ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE," + + "ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "characterSetServer": "UTF8MB4", + "transactionIsolation": "REPEATABLE-READ", + } +} diff --git a/services/ocimysql/channel_test.go b/services/ocimysql/channel_test.go new file mode 100644 index 0000000000..f82b6944b7 --- /dev/null +++ b/services/ocimysql/channel_test.go @@ -0,0 +1,180 @@ +package ocimysql_test + +import ( + "net/http" + "testing" + + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func channelDetails(dbID *string) my.CreateChannelDetails { + return my.CreateChannelDetails{ + DisplayName: new("repl"), + Source: my.CreateChannelSourceFromMysqlDetails{ + Hostname: new("source.example.com"), Username: new("repl"), Password: new("S0urce#pass"), + SslMode: my.ChannelSourceMysqlSslModeRequired, + }, + Target: my.CreateChannelTargetFromDbSystemDetails{DbSystemId: dbID, ChannelName: new("ch1")}, + } +} + +func TestChannelLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + db := e.mkDB(t, "target", nil) + + created, err := e.ch.CreateChannel(ctx, my.CreateChannelRequest{CreateChannelDetails: channelDetails(db.Id)}) + require.NoError(t, err) + assert.Equal(t, my.ChannelLifecycleStateCreating, created.LifecycleState) + assert.True(t, *created.IsEnabled) + assert.Equal(t, testCompartment, *created.CompartmentId, "defaults to the DB system's compartment") + + src, ok := created.Source.(my.ChannelSourceMysql) + require.True(t, ok) + assert.Equal(t, 3306, *src.Port) + assert.Equal(t, "source.example.com", *src.Hostname) + + tgt, ok := created.Target.(my.ChannelTargetDbSystem) + require.True(t, ok) + assert.Equal(t, 0, *tgt.DelayInSeconds) + assert.Equal( + t, + my.ChannelTargetDbSystemTablesWithoutPrimaryKeyHandlingRaiseError, + tgt.TablesWithoutPrimaryKeyHandling, + ) + + e.settle() + + got, err := e.ch.GetChannel(ctx, my.GetChannelRequest{ChannelId: created.Id}) + require.NoError(t, err) + assert.Equal(t, my.ChannelLifecycleStateActive, got.LifecycleState) + + withCh := e.getDB(t, db.Id) + require.Len(t, withCh.Channels, 1) + assert.Equal(t, *created.Id, *withCh.Channels[0].Id) + + _, err = e.ch.ResumeChannel(ctx, my.ResumeChannelRequest{ChannelId: created.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "an ACTIVE channel cannot be resumed") + + upd, err := e.ch.UpdateChannel(ctx, my.UpdateChannelRequest{ + ChannelId: created.Id, IfMatch: got.Etag, + IsEnabled: new(false), DisplayName: new("paused"), + }) + require.NoError(t, err) + require.NotNil(t, upd.OpcWorkRequestId) + + e.settle() + + paused, err := e.ch.GetChannel(ctx, my.GetChannelRequest{ChannelId: created.Id}) + require.NoError(t, err) + assert.Equal(t, my.ChannelLifecycleStateInactive, paused.LifecycleState) + assert.Equal(t, "paused", *paused.DisplayName) + assert.False(t, *paused.IsEnabled) + + _, err = e.ch.ResumeChannel(ctx, my.ResumeChannelRequest{ChannelId: created.Id}) + require.NoError(t, err) + e.settle() + + resumed, err := e.ch.GetChannel(ctx, my.GetChannelRequest{ChannelId: created.Id}) + require.NoError(t, err) + assert.Equal(t, my.ChannelLifecycleStateActive, resumed.LifecycleState) + assert.True(t, *resumed.IsEnabled) + + lst, err := e.ch.ListChannels(ctx, my.ListChannelsRequest{CompartmentId: new(testCompartment), DbSystemId: db.Id}) + require.NoError(t, err) + require.Len(t, lst.Items, 1) + + del, err := e.ch.DeleteChannel(ctx, my.DeleteChannelRequest{ChannelId: created.Id}) + require.NoError(t, err) + require.NotNil(t, del.OpcWorkRequestId) + e.settle() + + lst, err = e.ch.ListChannels(ctx, my.ListChannelsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, lst.Items) + assert.Empty(t, e.getDB(t, db.Id).Channels) +} + +func TestChannelValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(d *my.CreateChannelDetails) + name string + want int + }{ + {func(d *my.CreateChannelDetails) { + d.Source = my.CreateChannelSourceFromMysqlDetails{ + Username: new("u"), + Password: new("p"), + SslMode: my.ChannelSourceMysqlSslModeRequired, + } + }, "no hostname", 400}, + {func(d *my.CreateChannelDetails) { + d.Source = my.CreateChannelSourceFromMysqlDetails{ + Hostname: new("h"), + Username: new("u"), + SslMode: my.ChannelSourceMysqlSslModeRequired, + } + }, "no password", 400}, + {func(d *my.CreateChannelDetails) { + d.Source = my.CreateChannelSourceFromMysqlDetails{ + Hostname: new("h"), + Username: new("u"), + Password: new("p"), + } + }, "no ssl mode", 400}, + {func(d *my.CreateChannelDetails) { + d.Source = my.CreateChannelSourceFromMysqlDetails{ + Hostname: new( + "h", + ), + Username: new("u"), + Password: new("p"), + Port: new(70000), + SslMode: my.ChannelSourceMysqlSslModeDisabled, + } + }, "bad port", 400}, + {func(d *my.CreateChannelDetails) { + d.Target = my.CreateChannelTargetFromDbSystemDetails{DbSystemId: new("ocid1.mysqldbsystem.oc1.iad.nope")} + }, "unknown target", 400}, + {func(d *my.CreateChannelDetails) { + d.Target = my.CreateChannelTargetFromDbSystemDetails{} + }, "no target", 400}, + {func(d *my.CreateChannelDetails) { + d.Target = my.CreateChannelTargetFromDbSystemDetails{DelayInSeconds: new(-1)} + }, "negative delay", 400}, + {func(*my.CreateChannelDetails) {}, "valid", 200}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + db := e.mkDB(t, "target", nil) + + d := channelDetails(db.Id) + tt.mod(&d) + + if tgt, ok := d.Target.(my.CreateChannelTargetFromDbSystemDetails); ok && tgt.DbSystemId == nil && + tt.name == "negative delay" { + tgt.DbSystemId = db.Id + d.Target = tgt + } + + _, err := e.ch.CreateChannel(t.Context(), my.CreateChannelRequest{CreateChannelDetails: d}) + if tt.want == 200 { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.want, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} diff --git a/services/ocimysql/channels.go b/services/ocimysql/channels.go new file mode 100644 index 0000000000..5652f02f85 --- /dev/null +++ b/services/ocimysql/channels.go @@ -0,0 +1,517 @@ +package ocimysql + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + stateNeedsAttention = "NEEDS_ATTENTION" + targetDBSystem = "DBSYSTEM" + sourceMysql = "MYSQL" + defaultChannelPort = 3306 + channelRaise = "RAISE_ERROR" +) + +// ChannelSourceIn is the channel source details; the password is validated and discarded. +type ChannelSourceIn struct { + Port *int `json:"port"` + SourceType string `json:"sourceType"` + Hostname string `json:"hostname"` + Username string `json:"username"` + Password string `json:"password"` + SslMode string `json:"sslMode"` +} + +// ChannelTargetIn is the channel target details. +type ChannelTargetIn struct { + DelayInSeconds *int `json:"delayInSeconds"` + TargetType string `json:"targetType"` + DBSystemID string `json:"dbSystemId"` + ChannelName string `json:"channelName"` + ApplierUsername string `json:"applierUsername"` + TablesWithoutPrimaryKeyHandling string `json:"tablesWithoutPrimaryKeyHandling"` + Filters json.RawMessage `json:"filters"` +} + +// CreateChannelIn is the CreateChannel body. +type CreateChannelIn struct { + Tags + IsEnabled *bool `json:"isEnabled"` + Source ChannelSourceIn `json:"source"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Target ChannelTargetIn `json:"target"` +} + +//nolint:gochecknoglobals // immutable enum value lists +var ( + sslModes = []string{"VERIFY_IDENTITY", "VERIFY_CA", "REQUIRED", "DISABLED"} + tableHandling = []string{channelRaise, "ALLOW", "GENERATE_IMPLICIT_PRIMARY_KEY"} +) + +func (b *InMemoryBackend) channelSource(in ChannelSourceIn, cur *ChannelSource) (ChannelSource, *APIError) { + var out ChannelSource + if cur != nil { + out = *cur + } + + if in.SourceType != "" && !strings.EqualFold(in.SourceType, sourceMysql) { + return out, errParam("source.sourceType must be " + sourceMysql) + } + + out.SourceType = sourceMysql + setNonEmpty(&out.Hostname, in.Hostname) + setNonEmpty(&out.Username, in.Username) + setNonEmpty(&out.SslMode, strings.ToUpper(in.SslMode)) + + if in.Password != "" { + out.HasPassword = true + } + + if in.Port != nil { + out.Port = *in.Port + } + + if cur == nil && out.Port == 0 { + out.Port = defaultChannelPort + } + + switch { + case out.Hostname == "": + return out, errMissing("source.hostname is required") + case out.Username == "": + return out, errMissing("source.username is required") + case !out.HasPassword: + return out, errMissing("source.password is required") + case out.SslMode == "": + return out, errMissing("source.sslMode is required") + case !containsFold(sslModes, out.SslMode): + return out, errParam("source.sslMode must be one of " + strings.Join(sslModes, ", ")) + case out.Port < 1 || out.Port > 65535: + return out, errParam("source.port must be between 1 and 65535") + } + + return out, nil +} + +func (b *InMemoryBackend) channelTarget(in ChannelTargetIn, cur *ChannelTarget) (ChannelTarget, *APIError) { + var out ChannelTarget + if cur != nil { + out = *cur + } + + if in.TargetType != "" && !strings.EqualFold(in.TargetType, targetDBSystem) { + return out, errParam("target.targetType must be " + targetDBSystem) + } + + out.TargetType = targetDBSystem + + if cur == nil { + out.TablesWithoutPrimaryKeyHandling = channelRaise + out.ApplierUsername = "applier" + + if in.DBSystemID == "" { + return out, errMissing("target.dbSystemId is required") + } + + out.DBSystemID = in.DBSystemID + } + + setNonEmpty(&out.ChannelName, in.ChannelName) + setNonEmpty(&out.ApplierUsername, in.ApplierUsername) + setNonEmpty(&out.TablesWithoutPrimaryKeyHandling, strings.ToUpper(in.TablesWithoutPrimaryKeyHandling)) + + if in.DelayInSeconds != nil { + if *in.DelayInSeconds < 0 { + return out, errParam("target.delayInSeconds must not be negative") + } + + out.DelayInSeconds = *in.DelayInSeconds + } + + if in.Filters != nil { + out.Filters = in.Filters + } + + if !containsFold(tableHandling, out.TablesWithoutPrimaryKeyHandling) { + return out, errParam( + "target.tablesWithoutPrimaryKeyHandling must be one of " + strings.Join(tableHandling, ", "), + ) + } + + return out, nil +} + +func (b *InMemoryBackend) channelDocs(c *Channel) (Doc, Doc) { + src := Doc{ + keySourceType: c.Source.SourceType, "hostname": c.Source.Hostname, "port": c.Source.Port, + "username": c.Source.Username, "sslMode": c.Source.SslMode, + } + tgt := Doc{ + "targetType": c.Target.TargetType, paramDBSystem: c.Target.DBSystemID, "channelName": c.Target.ChannelName, + "applierUsername": c.Target.ApplierUsername, "delayInSeconds": c.Target.DelayInSeconds, + "tablesWithoutPrimaryKeyHandling": c.Target.TablesWithoutPrimaryKeyHandling, + } + + if c.Target.Filters != nil { + tgt["filters"] = c.Target.Filters + } + + return src, tgt +} + +func (b *InMemoryBackend) channelSummary(c *Channel) Doc { + src, tgt := b.channelDocs(c) + d := Doc{ + "id": c.ID, keyCompartment: c.CompartmentID, keyIsEnabled: c.Enabled, "source": src, "target": tgt, + keyLifecycle: c.State, keyDisplayName: c.Name, keyTimeCreated: c.TimeCreated, keyTimeUpdated: c.TimeUpdated, + } + if c.Details != "" { + d["lifecycleDetails"] = c.Details + } + + putTags(d, c.Tags) + + return d +} + +func (b *InMemoryBackend) channelDoc(c *Channel) Doc { + d := b.channelSummary(c) + if c.Description != "" { + d["description"] = c.Description + } + + return d +} + +// CreateChannel creates a replication channel record. +func (b *InMemoryBackend) CreateChannel(in CreateChannelIn) (Doc, string, string, error) { + b.mu.Lock("CreateChannel") + defer b.mu.Unlock() + + src, aerr := b.channelSource(in.Source, nil) + tgt, aerr2 := b.channelTarget(in.Target, nil) + + if aerr = firstErr(aerr, aerr2); aerr != nil { + return nil, "", "", aerr + } + + b.settleID(tgt.DBSystemID) + + s, ok := b.systems[tgt.DBSystemID] + if !ok || s.State == stateDeleted { + return nil, "", "", errParam( + "target.dbSystemId " + tgt.DBSystemID + " does not reference an existing DB system", + ) + } + + if s.State != stateActive { + return nil, "", "", errState("DB system " + s.ID + " is in state " + s.State + "; channels require ACTIVE") + } + + compartment := orStr(in.CompartmentID, s.CompartmentID) + if aerr = b.checkCompartment(compartment); aerr != nil { + return nil, "", "", aerr + } + + name := orStr(in.DisplayName, "MySQLChannel"+b.nowUTC().Format("20060102150405")) + if aerr = validateName(name); aerr != nil { + return nil, "", "", aerr + } + + if tgt.ChannelName == "" { + tgt.ChannelName = "channel" + b.nowUTC().Format("150405") + } + + now := b.nowUTC() + enabledNow := in.IsEnabled == nil || *in.IsEnabled + c := &Channel{ + ID: newOCID("mysqlchannel"), CompartmentID: compartment, Name: name, Description: in.Description, + Enabled: enabledNow, State: stateCreating, After: stateInactive, Source: src, Target: tgt, Tags: in.Tags, + Etag: newEtag(), TimeCreated: now, TimeUpdated: now, TransitionAt: b.transitionAt(), Seq: b.nextSeq(), + } + if enabledNow { + c.After = stateActive + } + + b.channels[c.ID] = c + wr := b.newWR(wrCreateChannel, compartment, entityChannel, c.ID, actionCreated, c.TransitionAt) + + return b.channelDoc(c), c.Etag, wr, nil +} + +func (b *InMemoryBackend) channelFor(id, ifMatch string) (*Channel, *APIError) { + b.settleID(id) + + c, ok := b.channels[id] + if !ok || c.State == stateDeleted { + return nil, errNotFound(id) + } + + return c, checkEtag(c.Etag, ifMatch) +} + +// GetChannel returns one channel. +func (b *InMemoryBackend) GetChannel(id string) (Doc, string, error) { + b.mu.Lock("GetChannel") + defer b.mu.Unlock() + + c, aerr := b.channelFor(id, "") + if aerr != nil { + return nil, "", aerr + } + + return b.channelDoc(c), c.Etag, nil +} + +// ChannelListIn are the ListChannels filters. +type ChannelListIn struct { + Compartment string + DBSystemID string + ID string + Name string + State string + IsEnabled *bool + ListOpts +} + +func (in ChannelListIn) matches(c *Channel) bool { + if c.CompartmentID != in.Compartment || (in.DBSystemID != "" && c.Target.DBSystemID != in.DBSystemID) || + (in.ID != "" && c.ID != in.ID) || (in.Name != "" && c.Name != in.Name) || + (in.IsEnabled != nil && c.Enabled != *in.IsEnabled) { + return false + } + + return stateMatches(c.State, in.State) +} + +// ListChannels lists the channels of a compartment. +func (b *InMemoryBackend) ListChannels(in ChannelListIn) (page.Page[Doc], error) { + if aerr := firstErr(b.checkCompartmentLocked(in.Compartment), + validListOpts(in.ListOpts, keyDisplayName, keyTimeCreated)); aerr != nil { + return page.Page[Doc]{}, aerr + } + + b.mu.Lock("ListChannels") + defer b.mu.Unlock() + + b.settleAll() + + var all []*Channel + + for _, c := range b.channels { + if in.matches(c) { + all = append(all, c) + } + } + + pg := paginate(all, in.ListOpts, func(c *Channel) sortKeys { + return sortKeys{created: c.TimeCreated, updated: c.TimeUpdated, name: c.Name, seq: c.Seq} + }) + + out := make([]Doc, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, b.channelSummary(c)) + } + + return page.Page[Doc]{Data: out, Next: pg.Next}, nil +} + +func (b *InMemoryBackend) channelTransition(c *Channel, op, after string) string { + c.State, c.After, c.TimeUpdated, c.Etag = stateUpdating, after, b.nowUTC(), newEtag() + c.TransitionAt = b.transitionAt() + + return b.newWR(op, c.CompartmentID, entityChannel, c.ID, actionUpdated, c.TransitionAt) +} + +// UpdateChannelIn is the UpdateChannel body. +type UpdateChannelIn struct { + Tags + IsEnabled *bool `json:"isEnabled"` + DisplayName *string `json:"displayName"` + Description *string `json:"description"` + Source ChannelSourceIn `json:"source"` + Target ChannelTargetIn `json:"target"` +} + +// UpdateChannel updates a channel and returns its work request. +func (b *InMemoryBackend) UpdateChannel(id, ifMatch string, in UpdateChannelIn) (string, error) { + b.mu.Lock("UpdateChannel") + defer b.mu.Unlock() + + c, aerr := b.channelFor(id, ifMatch) + if aerr != nil { + return "", aerr + } + + if c.State != stateActive && c.State != stateInactive && c.State != stateNeedsAttention { + return "", errState("channel " + id + " is in state " + c.State) + } + + src, aerr := b.channelSource(in.Source, &c.Source) + tgt, aerr2 := b.channelTarget(in.Target, &c.Target) + + if aerr = firstErr(aerr, aerr2); aerr != nil { + return "", aerr + } + + if in.DisplayName != nil { + if aerr = validateName(*in.DisplayName); aerr != nil { + return "", aerr + } + + c.Name = *in.DisplayName + } + + if in.Description != nil { + c.Description = *in.Description + } + + if in.IsEnabled != nil { + c.Enabled = *in.IsEnabled + } + + c.Source, c.Target = src, tgt + c.Tags.merge(in.Tags) + + return b.channelTransition(c, wrUpdateChannel, enabledState(c.Enabled)), nil +} + +func enabledState(on bool) string { + if on { + return stateActive + } + + return stateInactive +} + +// ResumeChannel enables a channel. +func (b *InMemoryBackend) ResumeChannel(id, ifMatch string) (string, error) { + b.mu.Lock("ResumeChannel") + defer b.mu.Unlock() + + c, aerr := b.channelFor(id, ifMatch) + if aerr != nil { + return "", aerr + } + + if c.State != stateInactive && c.State != stateNeedsAttention { + return "", errState( + "channel " + id + " is in state " + c.State + "; resume requires INACTIVE or NEEDS_ATTENTION", + ) + } + + c.Enabled = true + + return b.channelTransition(c, wrResumeChannel, stateActive), nil +} + +// ResetChannel resets a channel's replication state. +func (b *InMemoryBackend) ResetChannel(id, ifMatch string) (string, error) { + b.mu.Lock("ResetChannel") + defer b.mu.Unlock() + + c, aerr := b.channelFor(id, ifMatch) + if aerr != nil { + return "", aerr + } + + if c.State != stateInactive && c.State != stateNeedsAttention { + return "", errState( + "channel " + id + " is in state " + c.State + "; reset requires INACTIVE or NEEDS_ATTENTION", + ) + } + + return b.channelTransition(c, wrResetChannel, enabledState(c.Enabled)), nil +} + +// DeleteChannel deletes a channel. +func (b *InMemoryBackend) DeleteChannel(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteChannel") + defer b.mu.Unlock() + + c, aerr := b.channelFor(id, ifMatch) + if aerr != nil { + return "", aerr + } + + if c.State == stateCreating || c.State == stateUpdating || c.State == stateDeleting { + return "", errState("channel " + id + " is in state " + c.State) + } + + c.State, c.After, c.TimeUpdated, c.Etag = stateDeleting, "", b.nowUTC(), newEtag() + c.TransitionAt = b.transitionAt() + + return b.newWR(wrDeleteChannel, c.CompartmentID, entityChannel, id, actionDeleted, c.TransitionAt), nil +} + +func (h *Handler) createChannel(q *req) error { + var in CreateChannelIn + if err := q.decode(&in); err != nil { + return err + } + + d, etag, wr, err := h.Backend.CreateChannel(in) + if err != nil { + return err + } + + q.header("opc-work-request-id", wr) + + return q.json(http.StatusOK, d, etag) +} + +func (h *Handler) getChannel(q *req) error { + d, etag, err := h.Backend.GetChannel(q.param("channelId")) + + return reply(q, d, etag, err) +} + +func (h *Handler) listChannels(q *req) error { + in := ChannelListIn{ + Compartment: q.query(keyCompartment), DBSystemID: q.query(paramDBSystem), ID: q.query("channelId"), + Name: q.query(keyDisplayName), State: q.query(keyLifecycle), ListOpts: q.listOpts(), + } + if v := q.query(keyIsEnabled); v != "" { + t := strings.EqualFold(v, "true") + in.IsEnabled = &t + } + + pg, err := h.Backend.ListChannels(in) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateChannel(q *req) error { + var in UpdateChannelIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateChannel(q.param("channelId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) resumeChannel(q *req) error { + wr, err := h.Backend.ResumeChannel(q.param("channelId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) resetChannel(q *req) error { + wr, err := h.Backend.ResetChannel(q.param("channelId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteChannel(q *req) error { + wr, err := h.Backend.DeleteChannel(q.param("channelId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} diff --git a/services/ocimysql/config_test.go b/services/ocimysql/config_test.go new file mode 100644 index 0000000000..fd53324f3c --- /dev/null +++ b/services/ocimysql/config_test.go @@ -0,0 +1,211 @@ +package ocimysql_test + +import ( + "net/http" + "testing" + + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultConfigurations(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + lst, err := e.aas.ListConfigurations(ctx, my.ListConfigurationsRequest{ + CompartmentId: new(testCompartment), ShapeName: new(shapeSmall), + SortBy: my.ListConfigurationsSortByDisplayname, SortOrder: my.ListConfigurationsSortOrderAsc, + }) + require.NoError(t, err) + require.Len(t, lst.Items, 2) + assert.Equal(t, "MySQL.2.HA", *lst.Items[0].DisplayName) + assert.Equal(t, "MySQL.2.Standalone", *lst.Items[1].DisplayName) + + for _, c := range lst.Items { + assert.Equal(t, my.ConfigurationTypeDefault, c.Type) + assert.Equal(t, my.ConfigurationLifecycleStateActive, c.LifecycleState) + } + + free, err := e.aas.ListConfigurations(ctx, my.ListConfigurationsRequest{ + CompartmentId: new(testCompartment), ShapeName: new("MySQL.Free"), + }) + require.NoError(t, err) + assert.Len(t, free.Items, 1, "the free shape has no HA configuration") + + got, err := e.aas.GetConfiguration(ctx, my.GetConfigurationRequest{ConfigurationId: lst.Items[1].Id}) + require.NoError(t, err) + assert.Equal(t, 16*16, *got.Variables.MaxConnections) + assert.True(t, *got.Variables.Autocommit) + assert.NotNil(t, got.Options) + + _, err = e.aas.UpdateConfiguration(ctx, my.UpdateConfigurationRequest{ + ConfigurationId: got.Id, DisplayName: new("x"), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "defaults are read-only") + + _, err = e.aas.DeleteConfiguration(ctx, my.DeleteConfigurationRequest{ConfigurationId: got.Id}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + db := e.mkDB(t, "cfg", nil) + assert.Equal(t, *lst.Items[1].Id, *db.ConfigurationId, "standalone default for the shape") + + ha := e.mkDB(t, "cfgha", func(d *my.CreateDbSystemDetails) { d.IsHighlyAvailable = new(true) }) + assert.Equal(t, *lst.Items[0].Id, *ha.ConfigurationId, "HA default for HA systems") +} + +func TestCustomConfigurationLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + created, err := e.aas.CreateConfiguration(ctx, my.CreateConfigurationRequest{ + CompartmentId: new(testCompartment), + ShapeName: new(shapeSmall), + DisplayName: new("tuned"), + Variables: &my.ConfigurationVariables{ + MaxConnections: new(777), Autocommit: new(false), SqlMode: new("STRICT_ALL_TABLES"), + BinlogExpireLogsSeconds: new(86400), + }, + InitVariables: &my.InitializationVariables{ + LowerCaseTableNames: my.InitializationVariablesLowerCaseTableNamesInsensitiveLowercase, + }, + }) + require.NoError(t, err) + + c := created.Configuration + assert.Equal(t, my.ConfigurationTypeCustom, c.Type) + assert.Equal(t, 777, *c.Variables.MaxConnections) + assert.False(t, *c.Variables.Autocommit) + assert.NotNil(t, c.Variables.InnodbBufferPoolSize, "unset variables inherit from the shape default") + assert.NotEmpty(t, *c.ParentConfigurationId) + assert.Equal( + t, + my.InitializationVariablesLowerCaseTableNamesInsensitiveLowercase, + c.InitVariables.LowerCaseTableNames, + ) + + upd, err := e.aas.UpdateConfiguration(ctx, my.UpdateConfigurationRequest{ + ConfigurationId: c.Id, IfMatch: created.Etag, + DisplayName: new("renamed"), Description: new("d"), + }) + require.NoError(t, err) + assert.Equal(t, "d", *upd.Description) + assert.Equal(t, 777, *upd.Variables.MaxConnections, "variables are immutable after create") + + _, err = e.aas.UpdateConfiguration(ctx, my.UpdateConfigurationRequest{ + ConfigurationId: c.Id, IfMatch: new("stale"), + DisplayName: new("n"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + lst, err := e.aas.ListConfigurations(ctx, my.ListConfigurationsRequest{ + CompartmentId: new(testCompartment), Type: []my.ListConfigurationsTypeEnum{my.ListConfigurationsTypeCustom}, + }) + require.NoError(t, err) + require.Len(t, lst.Items, 1) + assert.Equal(t, "renamed", *lst.Items[0].DisplayName) + + other, err := e.aas.ListConfigurations(ctx, my.ListConfigurationsRequest{ + CompartmentId: new(otherCompart), Type: []my.ListConfigurationsTypeEnum{my.ListConfigurationsTypeCustom}, + }) + require.NoError(t, err) + assert.Empty(t, other.Items, "custom configurations are per compartment") + + db := e.mkDB(t, "uses", func(d *my.CreateDbSystemDetails) { d.ConfigurationId = c.Id }) + assert.Equal(t, *c.Id, *db.ConfigurationId) + + _, err = e.aas.DeleteConfiguration(ctx, my.DeleteConfigurationRequest{ConfigurationId: c.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "in use") + + _, err = e.db.DeleteDbSystem(ctx, my.DeleteDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err) + e.settle() + + _, err = e.aas.DeleteConfiguration(ctx, my.DeleteConfigurationRequest{ConfigurationId: c.Id}) + require.NoError(t, err) + + _, err = e.aas.GetConfiguration(ctx, my.GetConfigurationRequest{ConfigurationId: c.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestConfigurationValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + }{ + { + "unknown variable", + `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.2","variables":{"notAVariable":1}}`, + }, + { + "wrong type int", + `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.2","variables":{"maxConnections":"many"}}`, + }, + { + "fractional int", + `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.2","variables":{"maxConnections":1.5}}`, + }, + { + "wrong type bool", + `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.2","variables":{"autocommit":"yes"}}`, + }, + { + "wrong type string", + `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.2","variables":{"sqlMode":5}}`, + }, + {"bad shape", `{"compartmentId":"` + testCompartment + `","shapeName":"nope"}`}, + {"heatwave shape", `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.HeatWave.VM.Standard"}`}, + {"no shape", `{"compartmentId":"` + testCompartment + `"}`}, + {"no compartment", `{"shapeName":"MySQL.2"}`}, + { + "unknown parent", + `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.2",` + + `"parentConfigurationId":"ocid1.mysqlconfiguration.oc1..nope"}`, + }, + { + "bad lower case", + `{"compartmentId":"` + testCompartment + `","shapeName":"MySQL.2","initVariables":{"lowerCaseTableNames":"MAYBE"}}`, + }, + } + + e := newEnv(t, envOpts{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, http.MethodPost, e.base+"/20190415/configurations", tt.body) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, `"code":"`) + }) + } + + e2 := newEnv(t, envOpts{}) + custom, err := e2.aas.CreateConfiguration(t.Context(), my.CreateConfigurationRequest{ + CompartmentId: new(testCompartment), + ShapeName: new(shapeBig), + }) + require.NoError(t, err) + + _, err = e2.db.CreateDbSystem( + t.Context(), + my.CreateDbSystemRequest{CreateDbSystemDetails: func() my.CreateDbSystemDetails { + d := dbDetails("mismatch") + d.ConfigurationId = custom.Id + + return d + }()}, + ) + assert.Equal( + t, + http.StatusBadRequest, + svcErr(t, err).GetHTTPStatusCode(), + "configuration shape must match the DB system shape", + ) +} diff --git a/services/ocimysql/configurations.go b/services/ocimysql/configurations.go new file mode 100644 index 0000000000..5cd6b8d8b7 --- /dev/null +++ b/services/ocimysql/configurations.go @@ -0,0 +1,441 @@ +package ocimysql + +import ( + "maps" + "math" + "net/http" + "slices" + "sort" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + caseSensitive = "CASE_SENSITIVE" + caseInsensitive = "CASE_INSENSITIVE_LOWERCASE" +) + +// CreateConfigurationIn is the CreateConfiguration body. +type CreateConfigurationIn struct { + Tags + Variables map[string]any `json:"variables"` + InitVariables *struct { + LowerCaseTableNames string `json:"lowerCaseTableNames"` + } `json:"initVariables"` + CompartmentID string `json:"compartmentId"` + ShapeName string `json:"shapeName"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + ParentID string `json:"parentConfigurationId"` +} + +// validateVariables checks names and value kinds against the SDK ConfigurationVariables model. +func validateVariables(vars map[string]any) (map[string]any, *APIError) { + out := make(map[string]any, len(vars)) + + for k, v := range vars { + kind, ok := configVariables[k] + if !ok { + return nil, errParam("variables." + k + " is not a MySQL configuration variable") + } + + if v == nil { + continue + } + + nv, ok := coerceVariable(kind, v) + if !ok { + return nil, errParam("variables." + k + " must be of type " + kind) + } + + out[k] = nv + } + + return out, nil +} + +func coerceVariable(kind string, v any) (any, bool) { + switch kind { + case varInt: + f, ok := v.(float64) + if ok && f == math.Trunc(f) { + return int64(f), true + } + + i, isInt := v.(int64) + + return i, isInt + case varBool: + b, ok := v.(bool) + + return b, ok + default: + s, ok := v.(string) + + return s, ok + } +} + +func (b *InMemoryBackend) configDoc(c *Configuration, full bool) Doc { + d := Doc{ + "id": c.ID, keyCompartment: c.CompartmentID, "shapeName": c.Shape, "type": c.Type, + keyLifecycle: c.State, keyDisplayName: c.Name, keyTimeCreated: c.TimeCreated, keyTimeUpdated: c.TimeUpdated, + } + if c.Description != "" { + d["description"] = c.Description + } + + putTags(d, c.Tags) + + if !full { + return d + } + + d["options"], d["variables"] = []Doc{}, c.Variables + if c.ParentID != "" { + d["parentConfigurationId"] = c.ParentID + } + + if c.LowerCase != "" { + d["initVariables"] = Doc{"lowerCaseTableNames": c.LowerCase} + } + + return d +} + +// CreateConfiguration creates a CUSTOM configuration. +func (b *InMemoryBackend) CreateConfiguration(in CreateConfigurationIn) (Doc, string, error) { + b.mu.Lock("CreateConfiguration") + defer b.mu.Unlock() + + if aerr := firstErr(b.checkCompartment(in.CompartmentID)); aerr != nil { + return nil, "", aerr + } + + parent, shape, aerr := b.configParent(in) + if aerr != nil { + return nil, "", aerr + } + + vars, aerr := validateVariables(in.Variables) + if aerr != nil { + return nil, "", aerr + } + + merged := maps.Clone(parent.Variables) + if merged == nil { + merged = map[string]any{} + } + + maps.Copy(merged, vars) + + name := orStr(in.DisplayName, "MySQLConfiguration"+b.nowUTC().Format("20060102150405")) + if aerr = validateName(name); aerr != nil { + return nil, "", aerr + } + + lower := "" + if in.InitVariables != nil { + lower = in.InitVariables.LowerCaseTableNames + if aerr = validEnum("initVariables.lowerCaseTableNames", lower, caseSensitive, caseInsensitive); aerr != nil { + return nil, "", aerr + } + } + + now := b.nowUTC() + c := &Configuration{ + ID: newOCID("mysqlconfiguration"), + CompartmentID: in.CompartmentID, + Shape: shape, + Type: configCustom, + State: stateActive, + Name: name, + Description: in.Description, + ParentID: parent.ID, + LowerCase: strings.ToUpper(lower), + Variables: merged, + Tags: in.Tags, + Etag: newEtag(), + TimeCreated: now, + TimeUpdated: now, + Seq: b.nextSeq(), + } + b.configs[c.ID] = c + + return b.configDoc(c, true), c.Etag, nil +} + +func (b *InMemoryBackend) configParent(in CreateConfigurationIn) (*Configuration, string, *APIError) { + if in.ShapeName == "" { + return nil, "", errMissing("shapeName is required") + } + + sh, ok := shapeByName(in.ShapeName) + if !ok || sh.support != supportDBSystem { + return nil, "", errParam("shapeName " + in.ShapeName + " is not a MySQL DB system shape; see ListShapes") + } + + pid := orStr(in.ParentID, defaultConfigID(in.ShapeName, false)) + + parent, ok := b.lookupConfig(pid) + if !ok { + return nil, "", errParam("parentConfigurationId " + pid + " does not reference an existing configuration") + } + + if parent.Shape != in.ShapeName { + return nil, "", errParam("parentConfigurationId is for shape " + parent.Shape + ", not " + in.ShapeName) + } + + return parent, in.ShapeName, nil +} + +// GetConfiguration returns a default or custom configuration. +func (b *InMemoryBackend) GetConfiguration(id string) (Doc, string, error) { + b.mu.RLock("GetConfiguration") + defer b.mu.RUnlock() + + c, ok := b.lookupConfig(id) + if !ok { + return nil, "", errNotFound(id) + } + + return b.configDoc(c, true), c.Etag, nil +} + +// ConfigListIn are the ListConfigurations filters. +type ConfigListIn struct { + Compartment string + ID string + State string + Name string + Shape string + Types []string + ListOpts +} + +func (b *InMemoryBackend) validConfigList(in ConfigListIn) *APIError { + aerr := firstErr(b.checkCompartmentLocked(in.Compartment), + validListOpts(in.ListOpts, keyDisplayName, "shapeName", keyTimeCreated, keyTimeUpdated), + validEnum(keyLifecycle, in.State, stateActive, stateDeleted)) + + for _, t := range in.Types { + aerr = firstErr(aerr, validEnum("type", t, configDefault, configCustom)) + } + + return aerr +} + +func (in ConfigListIn) matches(c *Configuration) bool { + return (in.ID == "" || c.ID == in.ID) && (in.Name == "" || c.Name == in.Name) && + (in.Shape == "" || c.Shape == in.Shape) && (in.State == "" || strings.EqualFold(c.State, in.State)) && + (len(in.Types) == 0 || containsFold(in.Types, c.Type)) +} + +// ListConfigurations lists the default configurations and the custom ones of a compartment. +func (b *InMemoryBackend) ListConfigurations(in ConfigListIn) (page.Page[Doc], error) { + if aerr := b.validConfigList(in); aerr != nil { + return page.Page[Doc]{}, aerr + } + + b.mu.RLock("ListConfigurations") + defer b.mu.RUnlock() + + all := slices.Clone(b.defaultList) + + for _, c := range b.configs { + if c.CompartmentID == in.Compartment && c.State == stateActive { + all = append(all, c) + } + } + + all = slices.DeleteFunc(all, func(c *Configuration) bool { return !in.matches(c) }) + + pg := paginate(all, in.ListOpts, func(c *Configuration) sortKeys { + return sortKeys{created: c.TimeCreated, updated: c.TimeUpdated, name: c.Name, shape: c.Shape, seq: c.Seq} + }) + + out := make([]Doc, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, b.configDoc(c, false)) + } + + return page.Page[Doc]{Data: out, Next: pg.Next}, nil +} + +// UpdateConfigurationIn is the UpdateConfiguration body. +type UpdateConfigurationIn struct { + DisplayName *string `json:"displayName"` + Description *string `json:"description"` + Tags +} + +func (b *InMemoryBackend) customConfig(id, ifMatch string) (*Configuration, *APIError) { + if _, isDefault := b.defaults[id]; isDefault { + return nil, errParam("default configurations cannot be modified or deleted") + } + + c, ok := b.configs[id] + if !ok || c.State != stateActive { + return nil, errNotFound(id) + } + + return c, checkEtag(c.Etag, ifMatch) +} + +// UpdateConfiguration updates a custom configuration. +func (b *InMemoryBackend) UpdateConfiguration(id, ifMatch string, in UpdateConfigurationIn) (Doc, string, error) { + b.mu.Lock("UpdateConfiguration") + defer b.mu.Unlock() + + c, aerr := b.customConfig(id, ifMatch) + if aerr != nil { + return nil, "", aerr + } + + if in.DisplayName != nil { + if aerr = validateName(*in.DisplayName); aerr != nil { + return nil, "", aerr + } + + c.Name = *in.DisplayName + } + + if in.Description != nil { + c.Description = *in.Description + } + + c.Tags.merge(in.Tags) + c.TimeUpdated, c.Etag = b.nowUTC(), newEtag() + + return b.configDoc(c, true), c.Etag, nil +} + +// DeleteConfiguration deletes a custom configuration that no DB system uses. +func (b *InMemoryBackend) DeleteConfiguration(id, ifMatch string) error { + b.mu.Lock("DeleteConfiguration") + defer b.mu.Unlock() + + if _, aerr := b.customConfig(id, ifMatch); aerr != nil { + return aerr + } + + for _, s := range b.systems { + if s.ConfigID == id && s.State != stateDeleted { + return errState("configuration " + id + " is used by DB system " + s.ID) + } + } + + delete(b.configs, id) + + return nil +} + +// ListShapes lists the documented MySQL shapes. +func (b *InMemoryBackend) ListShapes(compartment string, supportedFor []string, name string) ([]Doc, error) { + if aerr := b.checkCompartmentLocked(compartment); aerr != nil { + return nil, aerr + } + + for _, v := range supportedFor { + if aerr := validEnum("isSupportedFor", v, supportDBSystem, supportHeatWave); aerr != nil { + return nil, aerr + } + } + + out := []Doc{} + + for _, s := range shapeDefs { + if (name != "" && s.name != name) || (len(supportedFor) > 0 && !containsFold(supportedFor, s.support)) { + continue + } + + out = append(out, Doc{ + "name": s.name, "cpuCoreCount": s.cpus, "memorySizeInGBs": s.memGB, "isSupportedFor": []string{s.support}, + }) + } + + return out, nil +} + +// ListVersions lists the available MySQL versions by family. +func (b *InMemoryBackend) ListVersions(compartment string) ([]Doc, error) { + if aerr := b.checkCompartmentLocked(compartment); aerr != nil { + return nil, aerr + } + + fams := append([]versionFamily(nil), versionFamilies...) + sort.SliceStable(fams, func(i, j int) bool { return fams[i].family < fams[j].family }) + + out := make([]Doc, 0, len(fams)) + + for _, f := range fams { + vs := make([]Doc, 0, len(f.versions)) + for _, v := range f.versions { + vs = append(vs, Doc{"version": v, "description": "MySQL " + v}) + } + + out = append(out, Doc{"versionFamily": f.family, "versions": vs}) + } + + return out, nil +} + +func (h *Handler) createConfiguration(q *req) error { + var in CreateConfigurationIn + if err := q.decode(&in); err != nil { + return err + } + + d, etag, err := h.Backend.CreateConfiguration(in) + + return reply(q, d, etag, err) +} + +func (h *Handler) getConfiguration(q *req) error { + d, etag, err := h.Backend.GetConfiguration(q.param("configurationId")) + + return reply(q, d, etag, err) +} + +func (h *Handler) listConfigurations(q *req) error { + pg, err := h.Backend.ListConfigurations(ConfigListIn{ + Compartment: q.query(keyCompartment), ID: q.query("configurationId"), State: q.query(keyLifecycle), + Name: q.query(keyDisplayName), Shape: q.query("shapeName"), Types: q.queryAll("type"), ListOpts: q.listOpts(), + }) + + return collectionReply(q, pg, err) +} + +func (h *Handler) updateConfiguration(q *req) error { + var in UpdateConfigurationIn + if err := q.decode(&in); err != nil { + return err + } + + d, etag, err := h.Backend.UpdateConfiguration(q.param("configurationId"), q.ifMatch(), in) + + return reply(q, d, etag, err) +} + +func (h *Handler) deleteConfiguration(q *req) error { + if err := h.Backend.DeleteConfiguration(q.param("configurationId"), q.ifMatch()); err != nil { + return err + } + + q.w.WriteHeader(http.StatusNoContent) + + return nil +} + +func (h *Handler) listShapes(q *req) error { + out, err := h.Backend.ListShapes(q.query(keyCompartment), q.queryAll("isSupportedFor"), q.query("name")) + + return reply(q, out, "", err) +} + +func (h *Handler) listVersions(q *req) error { + out, err := h.Backend.ListVersions(q.query(keyCompartment)) + + return reply(q, out, "", err) +} diff --git a/services/ocimysql/configvars.go b/services/ocimysql/configvars.go new file mode 100644 index 0000000000..87f5fa7a28 --- /dev/null +++ b/services/ocimysql/configvars.go @@ -0,0 +1,130 @@ +package ocimysql + +// Variable value kinds of MySQL configuration variables (SDK mysql.ConfigurationVariables). +const ( + varInt = "int" + varBool = "bool" + varString = "string" +) + +//nolint:gochecknoglobals // immutable lookup of the SDK ConfigurationVariables fields +var configVariables = map[string]string{ + "autocommit": varBool, + "autoIncrementIncrement": varInt, + "autoIncrementOffset": varInt, + "bigTables": varBool, + "binlogExpireLogsSeconds": varInt, + "binlogGroupCommitSyncDelay": varInt, + "binlogGroupCommitSyncNoDelayCount": varInt, + "binlogRowMetadata": varString, + "binlogRowValueOptions": varString, + "binlogTransactionCompression": varBool, + "blockEncryptionMode": varString, + "characterSetServer": varString, + "collationServer": varString, + "completionType": varString, + "connectionMemoryChunkSize": varInt, + "connectionMemoryLimit": varInt, + "connectTimeout": varInt, + "cteMaxRecursionDepth": varInt, + "defaultAuthenticationPlugin": varString, + "explainFormat": varString, + "explicitDefaultsForTimestamp": varBool, + "foreignKeyChecks": varBool, + "generatedRandomPasswordLength": varInt, + "globalConnectionMemoryLimit": varInt, + "globalConnectionMemoryTracking": varBool, + "groupConcatMaxLen": varInt, + "groupReplicationConsistency": varString, + "informationSchemaStatsExpiry": varInt, + "innodbAdaptiveHashIndex": varBool, + "innodbAutoincLockMode": varInt, + "innodbBufferPoolDumpPct": varInt, + "innodbBufferPoolInstances": varInt, + "innodbBufferPoolSize": varInt, + "innodbChangeBuffering": varString, + "innodbDdlBufferSize": varInt, + "innodbDdlThreads": varInt, + "innodbFtEnableStopword": varBool, + "innodbFtMaxTokenSize": varInt, + "innodbFtMinTokenSize": varInt, + "innodbFtNumWordOptimize": varInt, + "innodbFtResultCacheLimit": varInt, + "innodbFtServerStopwordTable": varString, + "innodbLockWaitTimeout": varInt, + "innodbLogWriterThreads": varBool, + "innodbMaxPurgeLagDelay": varInt, + "innodbMaxPurgeLag": varInt, + "innodbNumaInterleave": varBool, + "innodbOnlineAlterLogMaxSize": varInt, + "innodbRedoLogCapacity": varInt, + "innodbRollbackOnTimeout": varBool, + "innodbSortBufferSize": varInt, + "innodbStatsPersistentSamplePages": varInt, + "innodbStatsTransientSamplePages": varInt, + "innodbStrictMode": varBool, + "innodbUndoLogTruncate": varBool, + "interactiveTimeout": varInt, + "joinBufferSize": varInt, + "localInfile": varBool, + "longQueryTime": varInt, + "mandatoryRoles": varString, + "maxAllowedPacket": varInt, + "maxBinlogCacheSize": varInt, + "maxConnectErrors": varInt, + "maxConnections": varInt, + "maxExecutionTime": varInt, + "maxHeapTableSize": varInt, + "maxPreparedStmtCount": varInt, + "maxSeeksForKey": varInt, + "maxUserConnections": varInt, + "mysqlFirewallMode": varBool, + "mysqlxConnectTimeout": varInt, + "mysqlxDeflateDefaultCompressionLevel": varInt, + "mysqlxDeflateMaxClientCompressionLevel": varInt, + "mysqlxDocumentIdUniquePrefix": varInt, + "mysqlxEnableHelloNotice": varBool, + "mysqlxIdleWorkerThreadTimeout": varInt, + "mysqlxInteractiveTimeout": varInt, + "mysqlxLz4DefaultCompressionLevel": varInt, + "mysqlxLz4MaxClientCompressionLevel": varInt, + "mysqlxMaxAllowedPacket": varInt, + "mysqlxMinWorkerThreads": varInt, + "mysqlxReadTimeout": varInt, + "mysqlxWaitTimeout": varInt, + "mysqlxWriteTimeout": varInt, + "mysqlxZstdDefaultCompressionLevel": varInt, + "mysqlxZstdMaxClientCompressionLevel": varInt, + "mysqlZstdDefaultCompressionLevel": varInt, + "netReadTimeout": varInt, + "netWriteTimeout": varInt, + "optimizerSwitch": varString, + "parserMaxMemSize": varInt, + "queryAllocBlockSize": varInt, + "queryPreallocSize": varInt, + "rangeOptimizerMaxMemSize": varInt, + "regexpTimeLimit": varInt, + "relayLogSpaceLimit": varInt, + "replicaNetTimeout": varInt, + "replicaParallelWorkers": varInt, + "replicaTypeConversions": varString, + "requireSecureTransport": varBool, + "skipNameResolve": varBool, + "sortBufferSize": varInt, + "sqlGenerateInvisiblePrimaryKey": varBool, + "sqlMode": varString, + "sqlRequirePrimaryKey": varBool, + "sqlWarnings": varBool, + "tableDefinitionCache": varInt, + "tableOpenCache": varInt, + "temptableMaxRam": varInt, + "threadPoolDedicatedListeners": varBool, + "threadPoolMaxTransactionsLimit": varInt, + "threadPoolQueryThreadsPerGroup": varInt, + "threadPoolSize": varInt, + "threadPoolTransactionDelay": varInt, + "timeZone": varString, + "tmpTableSize": varInt, + "transactionIsolation": varString, + "waitTimeout": varInt, +} diff --git a/services/ocimysql/dbsystem_test.go b/services/ocimysql/dbsystem_test.go new file mode 100644 index 0000000000..e3cbbbb837 --- /dev/null +++ b/services/ocimysql/dbsystem_test.go @@ -0,0 +1,878 @@ +package ocimysql_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/common" + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" +) + +func TestDbSystemLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + ctx := t.Context() + + d := dbDetails("life") + d.FreeformTags = map[string]string{"env": "test"} + d.HostnameLabel = new("lifedb") + created := e.createDB(t, d) + assert.Equal(t, my.DbSystemLifecycleStateCreating, created.LifecycleState) + assert.NotEmpty(t, created.Etag) + + wr, err := e.wr.GetWorkRequest(ctx, my.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, my.WorkRequestOperationStatusInProgress, wr.Status) + assert.Equal(t, my.WorkRequestOperationTypeCreateDbsystem, wr.OperationType) + + e.settle() + + got := e.getDB(t, created.Id) + assert.Equal(t, my.DbSystemLifecycleStateActive, got.LifecycleState) + assert.Equal(t, "test", got.FreeformTags["env"]) + assert.Equal(t, "8.4.6", *got.MysqlVersion) + assert.Equal(t, shapeSmall, *got.ShapeName) + assert.Equal(t, 50, *got.DataStorageSizeInGBs) + assert.Equal(t, "Uocm:US-ASHBURN-AD-2", *got.AvailabilityDomain) + assert.Equal(t, "10.0.1.5", *got.IpAddress) + assert.Equal(t, 3306, *got.Port) + assert.Equal(t, 33060, *got.PortX) + assert.Equal(t, my.DbSystemDatabaseModeWrite, got.DatabaseMode) + assert.Equal(t, my.CrashRecoveryStatusEnabled, got.CrashRecovery) + assert.True(t, *got.BackupPolicy.IsEnabled) + assert.Equal(t, 7, *got.BackupPolicy.RetentionInDays) + assert.False(t, *got.DeletionPolicy.IsDeleteProtected) + assert.Equal(t, "lifedb", *got.HostnameLabel) + assert.IsType(t, my.DbSystemSourceFromNone{}, got.Source) + require.Len(t, got.Endpoints, 1) + assert.Equal(t, "10.0.1.5", *got.Endpoints[0].IpAddress) + assert.Equal(t, my.DbSystemEndpointStatusActive, got.Endpoints[0].Status) + assert.NotEmpty(t, *got.ConfigurationId) + + status, body := rawDo(t, http.MethodGet, e.base+"/20190415/dbSystems/"+*got.Id, "") + assert.Equal(t, http.StatusUnauthorized, status, "unsigned request is rejected under enforce") + assert.NotContains(t, body, adminPass) + + wr, err = e.wr.GetWorkRequest(ctx, my.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, my.WorkRequestOperationStatusSucceeded, wr.Status) + assert.Equal(t, *got.Id, *wr.Resources[0].Identifier) + + lst, err := e.db.ListDbSystems(ctx, my.ListDbSystemsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, lst.Items, 1) + assert.Equal(t, "life", *lst.Items[0].DisplayName) +} + +func TestDbSystemStateMachine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + act func(e *env, id *string) (*string, error) + prep func(t *testing.T, e *env, id *string) + mid my.DbSystemLifecycleStateEnum + end my.DbSystemLifecycleStateEnum + wantOp my.WorkRequestOperationTypeEnum + wantErr int + }{ + { + name: "stop", mid: my.DbSystemLifecycleStateUpdating, end: my.DbSystemLifecycleStateInactive, + wantOp: my.WorkRequestOperationTypeStopDbsystem, + act: func(e *env, id *string) (*string, error) { + r, err := e.db.StopDbSystem(e.ctx(), my.StopDbSystemRequest{ + DbSystemId: id, + ShutdownType: my.InnoDbShutdownModeFast, + }) + + return r.OpcWorkRequestId, err + }, + }, + { + name: "restart", mid: my.DbSystemLifecycleStateUpdating, end: my.DbSystemLifecycleStateActive, + wantOp: my.WorkRequestOperationTypeRestartDbsystem, + act: func(e *env, id *string) (*string, error) { + r, err := e.db.RestartDbSystem(e.ctx(), my.RestartDbSystemRequest{ + DbSystemId: id, + ShutdownType: my.InnoDbShutdownModeImmediate, + }) + + return r.OpcWorkRequestId, err + }, + }, + { + name: "start from stopped", mid: my.DbSystemLifecycleStateUpdating, end: my.DbSystemLifecycleStateActive, + wantOp: my.WorkRequestOperationTypeStartDbsystem, + prep: func(t *testing.T, e *env, id *string) { + t.Helper() + + _, err := e.db.StopDbSystem(t.Context(), my.StopDbSystemRequest{ + DbSystemId: id, + ShutdownType: my.InnoDbShutdownModeSlow, + }) + require.NoError(t, err) + e.settle() + }, + act: func(e *env, id *string) (*string, error) { + r, err := e.db.StartDbSystem(e.ctx(), my.StartDbSystemRequest{DbSystemId: id}) + + return r.OpcWorkRequestId, err + }, + }, + { + name: "start while active", wantErr: http.StatusConflict, + act: func(e *env, id *string) (*string, error) { + r, err := e.db.StartDbSystem(e.ctx(), my.StartDbSystemRequest{DbSystemId: id}) + + return r.OpcWorkRequestId, err + }, + }, + { + name: "delete", mid: my.DbSystemLifecycleStateDeleting, end: my.DbSystemLifecycleStateDeleted, + wantOp: my.WorkRequestOperationTypeDeleteDbsystem, + act: func(e *env, id *string) (*string, error) { + r, err := e.db.DeleteDbSystem(e.ctx(), my.DeleteDbSystemRequest{DbSystemId: id}) + + return r.OpcWorkRequestId, err + }, + }, + { + name: "restart while stopped", wantErr: http.StatusConflict, + prep: func(t *testing.T, e *env, id *string) { + t.Helper() + + _, err := e.db.StopDbSystem(t.Context(), my.StopDbSystemRequest{ + DbSystemId: id, + ShutdownType: my.InnoDbShutdownModeFast, + }) + require.NoError(t, err) + e.settle() + }, + act: func(e *env, id *string) (*string, error) { + r, err := e.db.RestartDbSystem(e.ctx(), my.RestartDbSystemRequest{ + DbSystemId: id, + ShutdownType: my.InnoDbShutdownModeFast, + }) + + return r.OpcWorkRequestId, err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + db := e.mkDB(t, "sm", nil) + + if tt.prep != nil { + tt.prep(t, e, db.Id) + } + + wrID, err := tt.act(e, db.Id) + if tt.wantErr != 0 { + assert.Equal(t, tt.wantErr, svcErr(t, err).GetHTTPStatusCode()) + assert.Equal(t, "IncorrectState", svcErr(t, err).GetCode()) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.mid, e.getDB(t, db.Id).LifecycleState) + + wr, err := e.wr.GetWorkRequest(t.Context(), my.GetWorkRequestRequest{WorkRequestId: wrID}) + require.NoError(t, err) + assert.Equal(t, tt.wantOp, wr.OperationType) + assert.Equal(t, my.WorkRequestOperationStatusInProgress, wr.Status) + + e.settle() + + assert.Equal(t, tt.end, e.getDB(t, db.Id).LifecycleState) + + wr, err = e.wr.GetWorkRequest(t.Context(), my.GetWorkRequestRequest{WorkRequestId: wrID}) + require.NoError(t, err) + assert.Equal(t, my.WorkRequestOperationStatusSucceeded, wr.Status) + }) + } +} + +func TestCreateDbSystemValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(*my.CreateDbSystemDetails) + name string + code string + status int + }{ + { + func(d *my.CreateDbSystemDetails) { d.ShapeName = new("VM.Standard.E4.Flex") }, + "bad shape", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.ShapeName = new("MySQL.HeatWave.VM.Standard") }, + "heatwave shape", + "InvalidParameter", + 400, + }, + {func(d *my.CreateDbSystemDetails) { d.ShapeName = nil }, "no shape", "MissingParameter", 400}, + {func(d *my.CreateDbSystemDetails) { d.MysqlVersion = new("5.7.44") }, "bad version", "InvalidParameter", 400}, + {func(d *my.CreateDbSystemDetails) { d.AdminPassword = new("short1!A") }, "minimum length password", "", 0}, + { + func(d *my.CreateDbSystemDetails) { d.AdminPassword = new("Sh0rt!") }, + "short password", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.AdminPassword = new("alllowercase1!") }, + "no upper", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.AdminPassword = new("ALLUPPERCASE1!") }, + "no lower", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.AdminPassword = new("NoDigitsHere!") }, + "no digit", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.AdminPassword = new("NoSpecial1234") }, + "no special", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.AdminPassword = new(strings.Repeat("aA1!", 9)) }, + "long password", + "InvalidParameter", + 400, + }, + {func(d *my.CreateDbSystemDetails) { d.AdminPassword = nil }, "no password", "MissingParameter", 400}, + {func(d *my.CreateDbSystemDetails) { d.AdminUsername = nil }, "no username", "MissingParameter", 400}, + { + func(d *my.CreateDbSystemDetails) { d.AdminUsername = new("ociadmin") }, + "reserved user", + "InvalidParameter", + 400, + }, + {func(d *my.CreateDbSystemDetails) { d.AdminUsername = new("ROOT") }, "root user", "InvalidParameter", 400}, + { + func(d *my.CreateDbSystemDetails) { d.AdminUsername = new(strings.Repeat("a", 33)) }, + "long username", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.SubnetId = new("ocid1.subnet.oc1.iad.nope") }, + "unknown subnet", + "InvalidParameter", + 400, + }, + {func(d *my.CreateDbSystemDetails) { d.SubnetId = new("not-an-ocid") }, "bad subnet", "InvalidParameter", 400}, + {func(d *my.CreateDbSystemDetails) { d.SubnetId = nil }, "no subnet", "MissingParameter", 400}, + { + func(d *my.CreateDbSystemDetails) { d.DataStorageSizeInGBs = new(10) }, + "small storage", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.DataStorageSizeInGBs = new(200000) }, + "huge storage", + "InvalidParameter", + 400, + }, + {func(d *my.CreateDbSystemDetails) { d.DataStorageSizeInGBs = new(100) }, "ok storage", "", 0}, + { + func(d *my.CreateDbSystemDetails) { d.Port = new(3306); d.PortX = new(3306) }, + "same ports", + "InvalidParameter", + 400, + }, + {func(d *my.CreateDbSystemDetails) { d.HostnameLabel = new("-bad-") }, "bad hostname", "InvalidParameter", 400}, + { + func(d *my.CreateDbSystemDetails) { d.IpAddress = new("192.168.0.9") }, + "ip outside subnet", + "InvalidParameter", + 400, + }, + { + func(d *my.CreateDbSystemDetails) { d.ConfigurationId = new("ocid1.mysqlconfiguration.oc1..nope") }, + "bad config", + "InvalidParameter", + 400, + }, + {func(d *my.CreateDbSystemDetails) { + d.ShapeName = new("MySQL.Free") + d.IsHighlyAvailable = new(true) + }, "free ha", "InvalidParameter", 400}, + {func(d *my.CreateDbSystemDetails) { + d.BackupPolicy = &my.CreateBackupPolicyDetails{RetentionInDays: new(99)} + }, "retention", "InvalidParameter", 400}, + {func(d *my.CreateDbSystemDetails) { + d.BackupPolicy = &my.CreateBackupPolicyDetails{WindowStartTime: new("nine o'clock")} + }, "window", "InvalidParameter", 400}, + {func(d *my.CreateDbSystemDetails) { + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: new("ocid1.mysqlbackup.oc1.iad.nope")} + }, "unknown backup", "InvalidParameter", 400}, + {func(d *my.CreateDbSystemDetails) { d.CompartmentId = nil }, "no compartment", "MissingParameter", 400}, + { + func(d *my.CreateDbSystemDetails) { d.DisplayName = new(strings.Repeat("n", 256)) }, + "long name", + "InvalidParameter", + 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + d := dbDetails("v") + tt.mod(&d) + + _, err := e.db.CreateDbSystem(t.Context(), my.CreateDbSystemRequest{CreateDbSystemDetails: d}) + if tt.status == 0 { + require.NoError(t, err) + + return + } + + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestEndpointAddressing(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + a := e.mkDB(t, "a", nil) + b := e.mkDB(t, "b", nil) + assert.Equal(t, "10.0.1.5", *a.IpAddress) + assert.Equal(t, "10.0.1.6", *b.IpAddress) + + explicit := e.mkDB(t, "c", func(d *my.CreateDbSystemDetails) { d.IpAddress = new("10.0.1.50") }) + assert.Equal(t, "10.0.1.50", *explicit.IpAddress) + + _, err := e.db.CreateDbSystem(t.Context(), my.CreateDbSystemRequest{ + CreateDbSystemDetails: func() my.CreateDbSystemDetails { + d := dbDetails("dup") + d.IpAddress = new("10.0.1.50") + + return d + }(), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "address already taken") + + e.mkDB(t, "l1", func(d *my.CreateDbSystemDetails) { d.HostnameLabel = new("same") }) + + _, err = e.db.CreateDbSystem(t.Context(), my.CreateDbSystemRequest{ + CreateDbSystemDetails: func() my.CreateDbSystemDetails { + d := dbDetails("l2") + d.HostnameLabel = new("SAME") + + return d + }(), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "hostname label unique per subnet") + + for range 3 { + e.mkDB(t, "tiny", func(d *my.CreateDbSystemDetails) { d.SubnetId = new(subB) }) + } + + _, err = e.db.CreateDbSystem(t.Context(), my.CreateDbSystemRequest{ + CreateDbSystemDetails: func() my.CreateDbSystemDetails { + d := dbDetails("full") + d.SubnetId = new(subB) + + return d + }(), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "the /29 subnet runs out of addresses") + + standalone := newEnv(t, envOpts{noNet: true}) + free := standalone.mkDB(t, "free", nil) + assert.Regexp(t, `^\d+\.\d+\.\d+\.\d+$`, *free.IpAddress, "no OCI Core wired: documentation range address") +} + +func TestUpdateDbSystem(t *testing.T) { + t.Parallel() + + tests := []struct { + upd my.UpdateDbSystemDetails + check func(t *testing.T, got my.DbSystem) + name string + wantStatus int + transition bool + }{ + { + name: "rename is immediate", + upd: my.UpdateDbSystemDetails{DisplayName: new("renamed"), Description: new("d")}, + check: func(t *testing.T, got my.DbSystem) { + t.Helper() + assert.Equal(t, "renamed", *got.DisplayName) + assert.Equal(t, "d", *got.Description) + }, + }, + { + name: "shape change", transition: true, upd: my.UpdateDbSystemDetails{ShapeName: new(shapeBig)}, + check: func(t *testing.T, got my.DbSystem) { + t.Helper() + assert.Equal(t, shapeBig, *got.ShapeName) + }, + }, + { + name: "version upgrade", transition: true, upd: my.UpdateDbSystemDetails{MysqlVersion: new("9.4.0")}, + check: func(t *testing.T, got my.DbSystem) { + t.Helper() + assert.Equal(t, "9.4.0", *got.MysqlVersion) + }, + }, + {name: "version downgrade", upd: my.UpdateDbSystemDetails{MysqlVersion: new("8.0.39")}, wantStatus: 400}, + { + name: "grow storage", transition: true, upd: my.UpdateDbSystemDetails{DataStorageSizeInGBs: new(80)}, + check: func(t *testing.T, got my.DbSystem) { + t.Helper() + assert.Equal(t, 80, *got.DataStorageSizeInGBs) + }, + }, + {name: "shrink storage", upd: my.UpdateDbSystemDetails{DataStorageSizeInGBs: new(40)}, wantStatus: 400}, + {name: "bad shape", upd: my.UpdateDbSystemDetails{ShapeName: new("nope")}, wantStatus: 400}, + {name: "weak admin password", upd: my.UpdateDbSystemDetails{AdminPassword: new("weak")}, wantStatus: 400}, + { + name: "admin password reset", + transition: true, + upd: my.UpdateDbSystemDetails{AdminPassword: new("N3wPassw0rd#")}, + }, + { + name: "policies", upd: my.UpdateDbSystemDetails{ + BackupPolicy: &my.UpdateBackupPolicyDetails{RetentionInDays: new(14), WindowStartTime: new("03:30")}, + DeletionPolicy: &my.UpdateDeletionPolicyDetails{IsDeleteProtected: new(true)}, + }, + check: func(t *testing.T, got my.DbSystem) { + t.Helper() + assert.Equal(t, 14, *got.BackupPolicy.RetentionInDays) + assert.Equal(t, "03:30", *got.BackupPolicy.WindowStartTime) + assert.True(t, *got.DeletionPolicy.IsDeleteProtected) + }, + }, + { + name: "ports", upd: my.UpdateDbSystemDetails{Port: new(3307), PortX: new(33061)}, + check: func(t *testing.T, got my.DbSystem) { + t.Helper() + assert.Equal(t, 3307, *got.Port) + }, + }, + {name: "same ports", upd: my.UpdateDbSystemDetails{Port: new(33060)}, wantStatus: 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + db := e.mkDB(t, "upd", nil) + + resp, err := e.db.UpdateDbSystem(t.Context(), my.UpdateDbSystemRequest{ + DbSystemId: db.Id, UpdateDbSystemDetails: tt.upd, + }) + if tt.wantStatus != 0 { + assert.Equal(t, tt.wantStatus, svcErr(t, err).GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + + mid := e.getDB(t, db.Id) + if tt.transition { + assert.Equal(t, my.DbSystemLifecycleStateUpdating, mid.LifecycleState) + e.settle() + } else { + assert.Equal(t, my.DbSystemLifecycleStateActive, mid.LifecycleState) + } + + got := e.getDB(t, db.Id) + assert.Equal(t, my.DbSystemLifecycleStateActive, got.LifecycleState) + + if tt.check != nil { + tt.check(t, got) + } + }) + } +} + +func TestUpdateRespectsEtagAndState(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + db := e.mkDB(t, "etag", nil) + + _, err := e.db.UpdateDbSystem(ctx, my.UpdateDbSystemRequest{ + DbSystemId: db.Id, + IfMatch: new("stale"), + DisplayName: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + got, err := e.db.GetDbSystem(ctx, my.GetDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err) + + _, err = e.db.UpdateDbSystem(ctx, my.UpdateDbSystemRequest{ + DbSystemId: db.Id, IfMatch: got.Etag, ShapeName: new(shapeBig), + }) + require.NoError(t, err) + + _, err = e.db.UpdateDbSystem(ctx, my.UpdateDbSystemRequest{ + DbSystemId: db.Id, DisplayName: new("busy"), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "UPDATING DB systems are not updatable") + + _, err = e.db.DeleteDbSystem(ctx, my.DeleteDbSystemRequest{DbSystemId: db.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestDeleteProtectionAndFinalBackup(t *testing.T) { + t.Parallel() + + tests := []struct { + policy *my.CreateDeletionPolicyDetails + name string + wantBackup bool + protected bool + }{ + {name: "protected", policy: &my.CreateDeletionPolicyDetails{IsDeleteProtected: new(true)}, protected: true}, + {name: "final backup", policy: &my.CreateDeletionPolicyDetails{ + FinalBackup: my.CreateDeletionPolicyDetailsFinalBackupRequireFinalBackup, + }, wantBackup: true}, + {name: "default skips final backup", policy: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + db := e.mkDB(t, "del", func(d *my.CreateDbSystemDetails) { d.DeletionPolicy = tt.policy }) + + _, err := e.db.DeleteDbSystem(t.Context(), my.DeleteDbSystemRequest{DbSystemId: db.Id}) + if tt.protected { + se := svcErr(t, err) + assert.Equal(t, http.StatusConflict, se.GetHTTPStatusCode()) + assert.Equal(t, my.DbSystemLifecycleStateActive, e.getDB(t, db.Id).LifecycleState) + + _, err = e.db.UpdateDbSystem(t.Context(), my.UpdateDbSystemRequest{ + DbSystemId: db.Id, + DeletionPolicy: &my.UpdateDeletionPolicyDetails{IsDeleteProtected: new(false)}, + }) + require.NoError(t, err) + + _, err = e.db.DeleteDbSystem(t.Context(), my.DeleteDbSystemRequest{DbSystemId: db.Id}) + } + + require.NoError(t, err) + e.settle() + assert.Equal(t, my.DbSystemLifecycleStateDeleted, e.getDB(t, db.Id).LifecycleState) + + bks, err := e.bk.ListBackups(t.Context(), my.ListBackupsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + if !tt.wantBackup { + assert.Empty(t, bks.Items) + + return + } + + require.Len(t, bks.Items, 1) + assert.Equal(t, db.Id, bks.Items[0].DbSystemId) + }) + } +} + +func TestListDbSystemsFilters(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + names := []string{"charlie", "alpha", "bravo"} + ids := map[string]*string{} + + for _, n := range names { + ids[n] = e.mkDB(t, n, nil).Id + } + + e.mkDB(t, "elsewhere", func(d *my.CreateDbSystemDetails) { d.CompartmentId = new(otherCompart) }) + + del := e.mkDB(t, "gone", nil) + _, err := e.db.DeleteDbSystem(ctx, my.DeleteDbSystemRequest{DbSystemId: del.Id}) + require.NoError(t, err) + e.settle() + + tests := []struct { + name string + req my.ListDbSystemsRequest + want []string + }{ + { + "by name asc", + my.ListDbSystemsRequest{SortBy: my.ListDbSystemsSortByDisplayname, SortOrder: my.ListDbSystemsSortOrderAsc}, + []string{"alpha", "bravo", "charlie"}, + }, + { + "by name desc", + my.ListDbSystemsRequest{ + SortBy: my.ListDbSystemsSortByDisplayname, + SortOrder: my.ListDbSystemsSortOrderDesc, + }, + []string{"charlie", "bravo", "alpha"}, + }, + { + "by created asc", + my.ListDbSystemsRequest{SortBy: my.ListDbSystemsSortByTimecreated, SortOrder: my.ListDbSystemsSortOrderAsc}, + []string{"charlie", "alpha", "bravo"}, + }, + {"display name filter", my.ListDbSystemsRequest{DisplayName: new("bravo")}, []string{"bravo"}}, + {"deleted state", my.ListDbSystemsRequest{LifecycleState: my.DbSystemLifecycleStateDeleted}, []string{"gone"}}, + {"id filter", my.ListDbSystemsRequest{DbSystemId: ids["alpha"]}, []string{"alpha"}}, + {"heatwave attached", my.ListDbSystemsRequest{IsHeatWaveClusterAttached: new(true)}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.req.CompartmentId = new(testCompartment) + + got, lerr := e.db.ListDbSystems(t.Context(), tt.req) + require.NoError(t, lerr) + + var have []string + for _, it := range got.Items { + have = append(have, *it.DisplayName) + } + + assert.Equal(t, tt.want, have) + }) + } + + page1, err := e.db.ListDbSystems(ctx, my.ListDbSystemsRequest{ + CompartmentId: new(testCompartment), Limit: new(2), + SortBy: my.ListDbSystemsSortByDisplayname, SortOrder: my.ListDbSystemsSortOrderAsc, + }) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.NotNil(t, page1.OpcNextPage) + + page2, err := e.db.ListDbSystems(ctx, my.ListDbSystemsRequest{ + CompartmentId: new(testCompartment), Limit: new(2), Page: page1.OpcNextPage, + SortBy: my.ListDbSystemsSortByDisplayname, SortOrder: my.ListDbSystemsSortOrderAsc, + }) + require.NoError(t, err) + require.Len(t, page2.Items, 1) + assert.Equal(t, "charlie", *page2.Items[0].DisplayName) + assert.Nil(t, page2.OpcNextPage) + + status, _ := rawDo(t, http.MethodGet, e.base+"/20190415/dbSystems", "") + assert.Equal(t, http.StatusBadRequest, status, "compartmentId is required") + + status, _ = rawDo( + t, + http.MethodGet, + e.base+"/20190415/dbSystems?compartmentId="+testCompartment+"&sortBy=bogus", + "", + ) + assert.Equal(t, http.StatusBadRequest, status) +} + +func TestShapesAndVersions(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + all, err := e.aas.ListShapes(ctx, my.ListShapesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + byName := map[string]my.ShapeSummary{} + for _, s := range all.Items { + byName[*s.Name] = s + } + + assert.Equal(t, 16, *byName["MySQL.2"].MemorySizeInGBs) + assert.Equal(t, 2, *byName["MySQL.2"].CpuCoreCount) + assert.Contains(t, byName, "MySQL.Free") + assert.Equal(t, []my.ShapeSummaryIsSupportedForEnum{my.ShapeSummaryIsSupportedForHeatwavecluster}, + byName["MySQL.HeatWave.VM.Standard"].IsSupportedFor) + + dbOnly, err := e.aas.ListShapes(ctx, my.ListShapesRequest{ + CompartmentId: new( + testCompartment, + ), + IsSupportedFor: []my.ListShapesIsSupportedForEnum{my.ListShapesIsSupportedForDbsystem}, + }) + require.NoError(t, err) + + for _, s := range dbOnly.Items { + assert.NotContains(t, *s.Name, "HeatWave") + } + + one, err := e.aas.ListShapes(ctx, my.ListShapesRequest{CompartmentId: new(testCompartment), Name: new("MySQL.8")}) + require.NoError(t, err) + require.Len(t, one.Items, 1) + + vers, err := e.aas.ListVersions(ctx, my.ListVersionsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, vers.Items, 3) + + fams := map[string][]my.Version{} + for _, f := range vers.Items { + fams[*f.VersionFamily] = f.Versions + } + + assert.Contains(t, fams, "8.0") + assert.Contains(t, fams, "8.4") + assert.Contains(t, fams, "9.x") + assert.Equal(t, "8.4.3", *fams["8.4"][0].Version) + + status, _ := rawDo(t, http.MethodGet, e.base+"/20190415/shapes", "") + assert.Equal(t, http.StatusBadRequest, status, "compartmentId is required") +} + +func TestErrorEnvelope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + method string + path string + body string + code string + status int + }{ + { + "missing db system", + http.MethodGet, + "/20190415/dbSystems/ocid1.mysqldbsystem.oc1.iad.nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + { + "missing backup", + http.MethodGet, + "/20190415/backups/ocid1.mysqlbackup.oc1.iad.nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + { + "missing channel", + http.MethodGet, + "/20190415/channels/ocid1.mysqlchannel.oc1.iad.nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + { + "missing configuration", + http.MethodGet, + "/20190415/configurations/ocid1.mysqlconfiguration.oc1.iad.nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + { + "missing work request", + http.MethodGet, + "/20190415/workRequests/ocid1.mysqlworkrequest.oc1.iad.nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + {"bad json", http.MethodPost, "/20190415/dbSystems", "{", "CannotParseRequest", 400}, + {"no body", http.MethodPost, "/20190415/dbSystems", "", "MissingParameter", 400}, + {"unknown route", http.MethodGet, "/20190415/nothing", "", "NotFound", 404}, + {"wrong method", http.MethodPatch, "/20190415/dbSystems", "", "NotFound", 404}, + {"stop without type", http.MethodPost, "/20190415/dbSystems/x/actions/stop", `{}`, "MissingParameter", 400}, + { + "stop bad type", + http.MethodPost, + "/20190415/dbSystems/x/actions/stop", + `{"shutdownType":"NOW"}`, + "InvalidParameter", + 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.status, status) + assert.Contains(t, body, `"code":"`+tt.code+`"`) + assert.Contains(t, body, `"message"`) + }) + } +} + +func TestRequestIDsAndAuth(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + + resp, err := testclient.New(t).Get(e.base + "/20190415/shapes?compartmentId=" + testCompartment) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Opc-Request-Id")) + + signed, err := e.aas.ListVersions(t.Context(), my.ListVersionsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.NotEmpty(t, signed.OpcRequestId) + + other := newKeyPair(t) + stranger := common.NewRawConfigurationProvider( + testTenancy, + testUser, + "us-ashburn-1", + other.fprint, + other.privPEM, + nil, + ) + cl, err := my.NewMysqlaasClientWithConfigurationProvider(stranger) + require.NoError(t, err) + + cl.Host = e.base + + _, err = cl.ListVersions(t.Context(), my.ListVersionsRequest{CompartmentId: new(testCompartment)}) + assert.Equal(t, http.StatusUnauthorized, svcErr(t, err).GetHTTPStatusCode(), "unregistered key is rejected") +} diff --git a/services/ocimysql/dbsystems.go b/services/ocimysql/dbsystems.go new file mode 100644 index 0000000000..2ff6784d18 --- /dev/null +++ b/services/ocimysql/dbsystems.go @@ -0,0 +1,945 @@ +package ocimysql + +import ( + "encoding/json" + "maps" + "net/http" + "regexp" + "strings" + "time" + "unicode" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + minStorageGB = 50 + maxStorageGB = 131072 + minPasswordLen = 8 + maxPasswordLen = 32 + maxUsernameLen = 32 + defaultMySQLPort = 3306 + defaultMySQLXPort = 33060 + minRetentionDays = 1 + maxRetentionDays = 35 + defaultRetention = 7 + defaultWindow = "00:00" + sourceNone = "NONE" + sourceBackup = "BACKUP" + enabled = "ENABLED" + disabled = "DISABLED" + modeReadWrite = "READ_WRITE" + modeReadOnly = "READ_ONLY" + accessUnrestr = "UNRESTRICTED" + accessRestricted = "RESTRICTED" + skipFinalBackup = "SKIP_FINAL_BACKUP" + requireFinal = "REQUIRE_FINAL_BACKUP" + retainBackups = "RETAIN" + deleteBackups = "DELETE" + hostLabelMax = 63 +) + +//nolint:gochecknoglobals // immutable reserved-name list and label pattern +var ( + reservedUsers = []string{ + "administrator", "ociadmin", "ocidbm", "ocirest", "ocirpl", "oracle-cloud-agent", "mysql.sys", + "mysql.session", "mysql.infoschema", "mysql_option_tracker_persister", "mysql_rest_service_admin", + "mysql_rest_service_data_provider", "mysql_rest_service_dev", "mysql_rest_service_meta_provider", + "mysql_rest_service_schema_admin", "mysql_rest_service_user", "mysql_task_admin", "mysql_task_user", + "rrhhuser", "mysqlexport", "root", + } + hostLabelRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`) + extraKeys = []string{ + "rest", "databaseConsole", "secureConnections", "encryptData", "customerContacts", "readEndpoint", + "telemetryConfiguration", "securityAttributes", "nsgIds", "ipv6AddressIpv6SubnetCidrPairDetails", + "isIpv6Enabled", + } +) + +// BackupPolicyIn is the create/update backup policy. +type BackupPolicyIn struct { + Tags + IsEnabled *bool `json:"isEnabled"` + PitrPolicy *struct{ IsEnabled *bool } `json:"pitrPolicy"` + WindowStartTime *string `json:"windowStartTime"` + RetentionInDays *int `json:"retentionInDays"` + SoftDelete string `json:"softDelete"` + CopyPolicies json.RawMessage `json:"copyPolicies"` +} + +// DeletionPolicyIn is the create/update deletion policy. +type DeletionPolicyIn struct { + IsDeleteProtected *bool `json:"isDeleteProtected"` + AutomaticBackupRetention string `json:"automaticBackupRetention"` + FinalBackup string `json:"finalBackup"` +} + +// DataStorageIn is dataStorage. +type DataStorageIn struct { + IsAutoExpandStorageEnabled *bool `json:"isAutoExpandStorageEnabled"` + MaxStorageSizeInGBs *int `json:"maxStorageSizeInGBs"` +} + +// SourceIn is the create source details. +type SourceIn struct { + SourceType string `json:"sourceType"` + BackupID string `json:"backupId"` +} + +// CreateDBSystemIn is the CreateDBSystem body. +type CreateDBSystemIn struct { + Tags + Extra map[string]json.RawMessage `json:"-"` + IsHighlyAvailable *bool `json:"isHighlyAvailable"` + DataStorageGBs *int `json:"dataStorageSizeInGBs"` + Port *int `json:"port"` + PortX *int `json:"portX"` + DataStorage *DataStorageIn `json:"dataStorage"` + BackupPolicy *BackupPolicyIn `json:"backupPolicy"` + Source *SourceIn `json:"source"` + DeletionPolicy *DeletionPolicyIn `json:"deletionPolicy"` + Maintenance map[string]any `json:"maintenance"` + Description string `json:"description"` + AdminPassword string `json:"adminPassword"` + DisplayName string `json:"displayName"` + ShapeName string `json:"shapeName"` + AvailabilityDom string `json:"availabilityDomain"` + FaultDomain string `json:"faultDomain"` + ConfigurationID string `json:"configurationId"` + MysqlVersion string `json:"mysqlVersion"` + AdminUsername string `json:"adminUsername"` + SubnetID string `json:"subnetId"` + HostnameLabel string `json:"hostnameLabel"` + IPAddress string `json:"ipAddress"` + CrashRecovery string `json:"crashRecovery"` + DBManagement string `json:"databaseManagement"` + DatabaseMode string `json:"databaseMode"` + AccessMode string `json:"accessMode"` + CompartmentID string `json:"compartmentId"` +} + +func validPassword(p string) bool { + if len(p) < minPasswordLen || len(p) > maxPasswordLen { + return false + } + + var digit, lower, upper, special bool + + for _, r := range p { + switch { + case unicode.IsDigit(r): + digit = true + case unicode.IsLower(r): + lower = true + case unicode.IsUpper(r): + upper = true + default: + special = true + } + } + + return digit && lower && upper && special +} + +func validAdmin(user, pass string) *APIError { + switch { + case user == "": + return errMissing("adminUsername is required") + case len(user) > maxUsernameLen: + return errParam("adminUsername must be at most 32 characters") + case containsFold(reservedUsers, user): + return errParam("adminUsername " + user + " is reserved and cannot be used") + case strings.ContainsAny(user, "'\"\\`@% \t\r\n"): + return errParam("adminUsername must not contain quotes, backslashes, @, %, or whitespace") + case pass == "": + return errMissing("adminPassword is required") + case !validPassword(pass): + return errParam("adminPassword must be 8 to 32 characters long and contain at least 1 numeric, 1 lowercase, " + + "1 uppercase, and 1 special (nonalphanumeric) character") + } + + return nil +} + +func validStorage(gbs int) *APIError { + if gbs < minStorageGB || gbs > maxStorageGB { + return errParam("dataStorageSizeInGBs must be between 50 and 131072") + } + + return nil +} + +func validEnum(field, v string, allowed ...string) *APIError { + if v != "" && !containsFold(allowed, v) { + return errParam(field + " must be one of " + strings.Join(allowed, ", ")) + } + + return nil +} + +func validWindow(w string) (string, *APIError) { + for _, layout := range []string{"15:04", "15:04:05", "15:04:05Z07:00", "15:04Z07:00", "15:04:05Z"} { + if t, err := time.Parse(layout, w); err == nil { + return t.Format("15:04"), nil + } + } + + return "", errParam("backupPolicy.windowStartTime must be the time portion of an RFC3339 timestamp, e.g. 03:00") +} + +func newBackupPolicy() BackupPolicy { + return BackupPolicy{ + IsEnabled: true, + WindowStartTime: defaultWindow, + RetentionInDays: defaultRetention, + SoftDelete: disabled, + } +} + +func mergeBackupPolicy(cur BackupPolicy, in *BackupPolicyIn) (BackupPolicy, *APIError) { + if in == nil { + return cur, nil + } + + if aerr := validEnum("backupPolicy.softDelete", in.SoftDelete, enabled, disabled); aerr != nil { + return cur, aerr + } + + if in.IsEnabled != nil { + cur.IsEnabled = *in.IsEnabled + } + + if in.WindowStartTime != nil { + w, aerr := validWindow(*in.WindowStartTime) + if aerr != nil { + return cur, aerr + } + + cur.WindowStartTime = w + } + + if in.RetentionInDays != nil { + if *in.RetentionInDays < minRetentionDays || *in.RetentionInDays > maxRetentionDays { + return cur, errParam("backupPolicy.retentionInDays must be between 1 and 35") + } + + cur.RetentionInDays = *in.RetentionInDays + } + + if in.SoftDelete != "" { + cur.SoftDelete = strings.ToUpper(in.SoftDelete) + } + + if in.PitrPolicy != nil && in.PitrPolicy.IsEnabled != nil { + cur.PitrEnabled = *in.PitrPolicy.IsEnabled + } + + if in.CopyPolicies != nil { + cur.CopyPolicies = in.CopyPolicies + } + + cur.Tags.merge(in.Tags) + + return cur, nil +} + +func mergeDeletion(cur DeletionPolicy, in *DeletionPolicyIn) (DeletionPolicy, *APIError) { + if in == nil { + return cur, nil + } + + if aerr := firstErr( + validEnum("deletionPolicy.automaticBackupRetention", in.AutomaticBackupRetention, deleteBackups, retainBackups), + validEnum("deletionPolicy.finalBackup", in.FinalBackup, skipFinalBackup, requireFinal), + ); aerr != nil { + return cur, aerr + } + + if in.AutomaticBackupRetention != "" { + cur.AutomaticBackupRetention = strings.ToUpper(in.AutomaticBackupRetention) + } + + if in.FinalBackup != "" { + cur.FinalBackup = strings.ToUpper(in.FinalBackup) + } + + if in.IsDeleteProtected != nil { + cur.IsDeleteProtected = *in.IsDeleteProtected + } + + return cur, nil +} + +func validPorts(port, portX int) *APIError { + switch { + case port < 1 || port > 65535 || portX < 1 || portX > 65535: + return errParam("port and portX must be between 1 and 65535") + case port == portX: + return errParam("port and portX must differ") + } + + return nil +} + +func validHostLabel(l string) *APIError { + if l != "" && (len(l) > hostLabelMax || !hostLabelRe.MatchString(l)) { + return errParam("hostnameLabel must be an RFC 1123 label of at most 63 characters") + } + + return nil +} + +// checkSubnet resolves the subnet and returns its CIDR and availability domain. +func (b *InMemoryBackend) checkSubnet(id string) (SubnetInfo, *APIError) { + if id == "" { + return SubnetInfo{}, errMissing("subnetId is required") + } + + if !strings.HasPrefix(id, "ocid1.subnet.") { + return SubnetInfo{}, errParam("subnetId must be the OCID of a subnet") + } + + if b.net == nil { + return SubnetInfo{ID: id}, nil + } + + sub, ok := b.net.Subnet(id) + if !ok { + return SubnetInfo{}, errParam("subnetId " + id + " does not reference an existing subnet") + } + + return sub, nil +} + +// allocIP picks the DB system's address in its subnet: want when given and free, else the first free host. +func (b *InMemoryBackend) allocIP(sub SubnetInfo, want, exceptID string) (string, *APIError) { + taken := map[string]bool{} + + for _, s := range b.systems { + if s.SubnetID == sub.ID && s.State != stateDeleted && s.ID != exceptID { + taken[s.IP] = true + } + } + + if want != "" { + switch { + case taken[want]: + return "", errParam("ipAddress " + want + " is already in use in the subnet") + case sub.CIDR != "" && !inCIDR(sub.CIDR, want): + return "", errParam("ipAddress " + want + " is not within the subnet CIDR block " + sub.CIDR) + } + + return want, nil + } + + if sub.CIDR == "" { + return documentationAddr(b.nextSeq()), nil + } + + for off := firstHostOffset; ; off++ { + ip := hostIn(sub.CIDR, off) + if ip == "" { + return "", errParam("the subnet has no free addresses") + } + + if !taken[ip] { + return ip, nil + } + } +} + +func (b *InMemoryBackend) hostnameTaken(subnet, label, exceptID string) bool { + for _, s := range b.systems { + if s.SubnetID == subnet && s.State != stateDeleted && s.ID != exceptID && strings.EqualFold(s.Hostname, label) { + return true + } + } + + return false +} + +func (b *InMemoryBackend) lookupConfig(id string) (*Configuration, bool) { + if c, ok := b.defaults[id]; ok { + return c, true + } + + c, ok := b.configs[id] + + return c, ok && c.State == stateActive +} + +func (b *InMemoryBackend) resolveConfig(id, shape string, ha bool) (string, *APIError) { + if id == "" { + return defaultConfigID(shape, ha && shape != shapeFree), nil + } + + c, ok := b.lookupConfig(id) + + switch { + case !ok: + return "", errParam("configurationId " + id + " does not reference an existing configuration") + case c.Shape != shape: + return "", errParam("configuration " + id + " is for shape " + c.Shape + ", not " + shape) + } + + return id, nil +} + +type createPlan struct { + sys *DBSystem + backup *Backup + dump []byte +} + +func (b *InMemoryBackend) checkSource(in *SourceIn) (*Source, *Backup, *APIError) { + if in == nil || in.SourceType == "" || strings.EqualFold(in.SourceType, sourceNone) { + return &Source{SourceType: sourceNone}, nil, nil + } + + if !strings.EqualFold(in.SourceType, sourceBackup) { + return nil, nil, errParam("source.sourceType " + in.SourceType + " is not supported; use NONE or BACKUP") + } + + if in.BackupID == "" { + return nil, nil, errMissing("source.backupId is required for sourceType BACKUP") + } + + bk, ok := b.backups[in.BackupID] + if !ok || bk.State == stateDeleted { + return nil, nil, errParam("source.backupId " + in.BackupID + " does not reference an existing backup") + } + + if bk.State != stateActive { + return nil, nil, errState("backup " + in.BackupID + " is in state " + bk.State + "; it must be ACTIVE") + } + + return &Source{SourceType: sourceBackup, BackupID: bk.ID}, bk, nil +} + +func (b *InMemoryBackend) buildSystem(in CreateDBSystemIn) (*createPlan, *APIError) { + if aerr := firstErr(b.checkCompartment(in.CompartmentID), b.validateCreateFields(in)); aerr != nil { + return nil, aerr + } + + src, bk, aerr := b.checkSource(in.Source) + if aerr != nil { + return nil, aerr + } + + user, pass := in.AdminUsername, in.AdminPassword + if bk == nil || user != "" || pass != "" { + if aerr = validAdmin(user, pass); aerr != nil { + return nil, aerr + } + } else if bk.Snapshot != nil { + user, pass = bk.Snapshot.AdminUser, bk.Snapshot.AdminPass + } + + return b.assemble(in, src, bk, user, pass) +} + +func (b *InMemoryBackend) validateCreateFields(in CreateDBSystemIn) *APIError { + sh, ok := shapeByName(in.ShapeName) + + switch { + case in.ShapeName == "": + return errMissing("shapeName is required") + case !ok || sh.support != supportDBSystem: + return errParam("shapeName " + in.ShapeName + " is not a MySQL DB system shape; see ListShapes") + case in.IsHighlyAvailable != nil && *in.IsHighlyAvailable && in.ShapeName == shapeFree: + return errParam("shape " + shapeFree + " does not support high availability") + case in.MysqlVersion != "" && !isKnownVersion(in.MysqlVersion): + return errParam("mysqlVersion " + in.MysqlVersion + " is not supported; see ListVersions") + } + + if in.DisplayName != "" { + if aerr := validateName(in.DisplayName); aerr != nil { + return aerr + } + } + + return firstErr( + validHostLabel(in.HostnameLabel), + validEnum("crashRecovery", in.CrashRecovery, enabled, disabled), + validEnum("databaseManagement", in.DBManagement, enabled, disabled), + validEnum("databaseMode", in.DatabaseMode, modeReadWrite, modeReadOnly), + validEnum("accessMode", in.AccessMode, accessUnrestr, accessRestricted), + ) +} + +func isKnownVersion(v string) bool { + _, ok := familyOf(v) + + return ok +} + +func (b *InMemoryBackend) assemble( + in CreateDBSystemIn, src *Source, bk *Backup, user, pass string, +) (*createPlan, *APIError) { + sub, aerr := b.checkSubnet(in.SubnetID) + if aerr != nil { + return nil, aerr + } + + gbs, aerr := storageSize(in, bk) + if aerr != nil { + return nil, aerr + } + + port, portX := defaultMySQLPort, defaultMySQLXPort + if in.Port != nil { + port = *in.Port + } + + if in.PortX != nil { + portX = *in.PortX + } + + policy, aerr2 := mergeBackupPolicy(newBackupPolicy(), in.BackupPolicy) + del, aerr3 := mergeDeletion(DeletionPolicy{ + AutomaticBackupRetention: deleteBackups, FinalBackup: skipFinalBackup, + }, in.DeletionPolicy) + + if aerr = firstErr(validPorts(port, portX), aerr2, aerr3); aerr != nil { + return nil, aerr + } + + ha := in.IsHighlyAvailable != nil && *in.IsHighlyAvailable + + cfg, aerr := b.resolveConfig(in.ConfigurationID, in.ShapeName, ha) + if aerr != nil { + return nil, aerr + } + + if in.HostnameLabel != "" && b.hostnameTaken(sub.ID, in.HostnameLabel, "") { + return nil, errParam("hostnameLabel " + in.HostnameLabel + " is already used in the subnet") + } + + ip, aerr := b.allocIP(sub, in.IPAddress, "") + if aerr != nil { + return nil, aerr + } + + return b.finishSystem(in, systemParts{ + src: src, bk: bk, user: user, pass: pass, sub: sub, gbs: gbs, port: port, portX: portX, policy: policy, + del: del, cfg: cfg, ip: ip, ha: ha, + }), nil +} + +type systemParts struct { + bk *Backup + src *Source + sub SubnetInfo + cfg string + ip string + user string + pass string + del DeletionPolicy + policy BackupPolicy + gbs int + port int + portX int + ha bool +} + +func storageSize(in CreateDBSystemIn, bk *Backup) (int, *APIError) { + gbs := minStorageGB + if in.DataStorageGBs != nil { + gbs = *in.DataStorageGBs + } else if bk != nil && bk.Snapshot != nil { + gbs = bk.Snapshot.DataGB + } + + if aerr := validStorage(gbs); aerr != nil { + return 0, aerr + } + + if ds := in.DataStorage; ds != nil && ds.MaxStorageSizeInGBs != nil { + if *ds.MaxStorageSizeInGBs <= gbs || *ds.MaxStorageSizeInGBs > maxStorageGB { + return 0, errParam( + "dataStorage.maxStorageSizeInGBs must be greater than the initial size and at most 131072", + ) + } + } + + return gbs, nil +} + +func (b *InMemoryBackend) finishSystem(in CreateDBSystemIn, p systemParts) *createPlan { + now := b.nowUTC() + version := in.MysqlVersion + + if version == "" { + version = defaultVersion + } + + name := in.DisplayName + if name == "" { + name = "MySQLDBSystem" + now.Format("20060102150405") + } + + ad := orStr(in.AvailabilityDom, p.sub.AvailabilityDomain, defaultAD) + + s := &DBSystem{ + ID: newOCID("mysqldbsystem"), CompartmentID: in.CompartmentID, Name: name, Description: in.Description, + SubnetID: p.sub.ID, Shape: in.ShapeName, Version: version, ConfigID: p.cfg, Hostname: in.HostnameLabel, + AD: ad, FD: orStr(in.FaultDomain, "", defaultFD), State: stateCreating, Etag: newEtag(), + AdminUser: p.user, AdminPass: p.pass, RootSecret: newSecret(), + DatabaseMode: orStr(strings.ToUpper(in.DatabaseMode), "", modeReadWrite), + AccessMode: orStr(strings.ToUpper(in.AccessMode), "", accessUnrestr), + CrashRecovery: orStr(strings.ToUpper(in.CrashRecovery), "", enabled), + DBManagement: orStr(strings.ToUpper(in.DBManagement), "", disabled), + IP: p.ip, DataGB: p.gbs, Port: p.port, PortX: p.portX, HA: p.ha, AutoExpand: p.ha, + BackupPolicy: p.policy, Deletion: p.del, Source: p.src, Tags: in.Tags, + TimeCreated: now, TimeUpdated: now, LastAutoBackup: now, Seq: b.nextSeq(), + Maintenance: mergeMaintenance(in.Maintenance), Extra: in.Extra, + } + + if ds := in.DataStorage; ds != nil { + if ds.IsAutoExpandStorageEnabled != nil { + s.AutoExpand = *ds.IsAutoExpandStorageEnabled + } + + if ds.MaxStorageSizeInGBs != nil { + s.MaxGB = *ds.MaxStorageSizeInGBs + } + } + + plan := &createPlan{sys: s, backup: p.bk} + if p.bk != nil { + plan.dump = p.bk.Dump + } + + return plan +} + +func mergeMaintenance(in map[string]any) map[string]any { + out := map[string]any{ + "windowStartTime": "SUNDAY 03:00", "versionPreference": "OLDEST", + "versionTrackPreference": "LONG_TERM_SUPPORT", "maintenanceScheduleType": "REGULAR", + } + maps.Copy(out, in) + + return out +} + +func orStr(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + + return "" +} + +// CreateDBSystem creates a DB system in CREATING state and returns its body, etag and work request. +func (b *InMemoryBackend) CreateDBSystem(in CreateDBSystemIn) (Doc, string, string, error) { + b.mu.Lock("CreateDbSystem") + defer b.mu.Unlock() + + plan, aerr := b.buildSystem(in) + if aerr != nil { + return nil, "", "", aerr + } + + s := plan.sys + s.TransitionAt = b.transitionAt() + + if b.eng != nil { + s.TransitionAt = nil + } + + wr := b.newWR(wrCreateDBSystem, s.CompartmentID, entityDBSystem, s.ID, actionCreated, s.TransitionAt) + b.systems[s.ID] = s + + if b.eng != nil { + b.spawn(s.ID, wr, b.launchWork(s.ID, launchIn{ + admin: s.AdminUser, password: s.AdminPass, dump: plan.dump, fresh: true, + }), func(s *DBSystem) { s.State = stateActive }) + } + + return b.systemDoc(s), s.Etag, wr, nil +} + +// Doc is a JSON object response body. +type Doc = map[string]any + +func newSecret() string { return newEtag() + newEtag() } + +func (b *InMemoryBackend) endpointFor(s *DBSystem) (string, int) { + if s.EngineHost != "" && s.EnginePort != 0 { + return s.EngineHost, s.EnginePort + } + + return s.IP, s.Port +} + +func (b *InMemoryBackend) endpointDoc(s *DBSystem) Doc { + ip, port := b.endpointFor(s) + status := "ACTIVE" + + switch s.State { + case stateInactive, stateFailed, stateDeleted: + status = "INACTIVE" + case stateUpdating, stateCreating: + status = "UPDATING" + } + + d := Doc{ + "ipAddress": ip, "port": port, "portX": s.PortX, "ipAddressVersion": "IPV4", + "modes": []string{"READ", "WRITE"}, "status": status, "resourceType": "DBSYSTEM", "resourceId": s.ID, + } + if s.Hostname != "" { + d["hostname"] = s.Hostname + } + + return d +} + +func (b *InMemoryBackend) policyDoc(p BackupPolicy) Doc { + d := Doc{ + keyIsEnabled: p.IsEnabled, "windowStartTime": p.WindowStartTime, "retentionInDays": p.RetentionInDays, + "softDelete": p.SoftDelete, "pitrPolicy": Doc{keyIsEnabled: p.PitrEnabled}, + } + putTags(d, p.Tags) + + if p.CopyPolicies != nil { + d["copyPolicies"] = p.CopyPolicies + } + + return d +} + +func putTags(d Doc, t Tags) { + if t.FreeformTags != nil { + d["freeformTags"] = t.FreeformTags + } + + if t.DefinedTags != nil { + d["definedTags"] = t.DefinedTags + } +} + +func (b *InMemoryBackend) deletionDoc(p DeletionPolicy) Doc { + return Doc{ + "automaticBackupRetention": p.AutomaticBackupRetention, "finalBackup": p.FinalBackup, + "isDeleteProtected": p.IsDeleteProtected, + } +} + +func (b *InMemoryBackend) sourceDoc(s *Source) Doc { + if s == nil || s.SourceType == sourceNone { + return Doc{keySourceType: sourceNone} + } + + return Doc{keySourceType: s.SourceType, "backupId": s.BackupID} +} + +func (b *InMemoryBackend) channelsOf(id string) []Doc { + out := []Doc{} + + for _, c := range b.channels { + if c.Target.DBSystemID == id && c.State != stateDeleted { + out = append(out, b.channelSummary(c)) + } + } + + return out +} + +func (b *InMemoryBackend) systemBase(s *DBSystem) Doc { + d := Doc{ + "id": s.ID, keyDisplayName: s.Name, keyCompartment: s.CompartmentID, keyLifecycle: s.State, + "mysqlVersion": s.Version, keyTimeCreated: s.TimeCreated, keyTimeUpdated: s.TimeUpdated, + "databaseMode": s.DatabaseMode, "accessMode": s.AccessMode, "isHighlyAvailable": s.HA, + "isHeatWaveClusterAttached": false, "availabilityDomain": s.AD, "faultDomain": s.FD, "shapeName": s.Shape, + "currentPlacement": Doc{"availabilityDomain": s.AD, "faultDomain": s.FD}, + "endpoints": []Doc{b.endpointDoc(s)}, "deletionPolicy": b.deletionDoc(s.Deletion), + "backupPolicy": b.policyDoc(s.BackupPolicy), "crashRecovery": s.CrashRecovery, + "databaseManagement": s.DBManagement, + } + putTags(d, s.Tags) + + if s.Description != "" { + d["description"] = s.Description + } + + if s.Details != "" { + d["lifecycleDetails"] = s.Details + } + + return d +} + +func (b *InMemoryBackend) systemDoc(s *DBSystem) Doc { + d := b.systemBase(s) + ip, port := b.endpointFor(s) + maxGB := max(s.MaxGB, s.DataGB) + + d["subnetId"], d["dataStorageSizeInGBs"] = s.SubnetID, s.DataGB + d["dataStorage"] = Doc{ + "isAutoExpandStorageEnabled": s.AutoExpand, "maxStorageSizeInGBs": maxGB, "allocatedStorageSizeInGBs": s.DataGB, + "dataStorageSizeInGBs": s.DataGB, "dataStorageSizeLimitInGBs": max(maxGB, maxStorageGB), + } + d["maintenance"], d["source"] = s.Maintenance, b.sourceDoc(s.Source) + d["configurationId"], d["ipAddress"], d["port"], d["portX"] = s.ConfigID, ip, port, s.PortX + d["channels"] = b.channelsOf(s.ID) + + if s.Hostname != "" { + d["hostnameLabel"] = s.Hostname + } + + if s.BackupPolicy.PitrEnabled { + d["pointInTimeRecoveryDetails"] = Doc{ + "timeEarliestRecoveryPoint": s.TimeCreated, "timeLatestRecoveryPoint": b.nowUTC(), + } + } + + for k, v := range s.Extra { + d[k] = v + } + + return d +} + +func (b *InMemoryBackend) systemSummary(s *DBSystem) Doc { + return b.systemBase(s) +} + +// GetDBSystem returns one DB system. +func (b *InMemoryBackend) GetDBSystem(id string) (Doc, string, error) { + b.mu.Lock("GetDbSystem") + defer b.mu.Unlock() + + b.settleID(id) + + s, ok := b.systems[id] + if !ok { + return nil, "", errNotFound(id) + } + + return b.systemDoc(s), s.Etag, nil +} + +// DBSystemListIn are the ListDBSystems filters. +type DBSystemListIn struct { + Compartment string + ID string + Name string + State string + ConfigurationID string + HeatWave *bool + ListOpts +} + +func (in DBSystemListIn) matches(s *DBSystem) bool { + if s.CompartmentID != in.Compartment || (in.ID != "" && s.ID != in.ID) || + (in.Name != "" && s.Name != in.Name) || (in.ConfigurationID != "" && s.ConfigID != in.ConfigurationID) || + (in.HeatWave != nil && *in.HeatWave) { + return false + } + + return stateMatches(s.State, in.State) +} + +// stateMatches filters by lifecycleState; without one, DELETED resources are hidden. +func stateMatches(state, want string) bool { + if want == "" { + return state != stateDeleted + } + + return strings.EqualFold(state, want) +} + +// ListDBSystems lists the DB systems of a compartment. +func (b *InMemoryBackend) ListDBSystems(in DBSystemListIn) (page.Page[Doc], error) { + if aerr := firstErr( + b.checkCompartmentLocked(in.Compartment), + validListOpts(in.ListOpts, keyDisplayName, keyTimeCreated), + validEnum(keyLifecycle, in.State, stateCreating, stateActive, stateInactive, stateUpdating, + stateDeleting, stateDeleted, stateFailed), + ); aerr != nil { + return page.Page[Doc]{}, aerr + } + + b.mu.Lock("ListDbSystems") + defer b.mu.Unlock() + + b.settleAll() + + var all []*DBSystem + + for _, s := range b.systems { + if in.matches(s) { + all = append(all, s) + } + } + + pg := paginate(all, in.ListOpts, func(s *DBSystem) sortKeys { + return sortKeys{created: s.TimeCreated, updated: s.TimeUpdated, name: s.Name, seq: s.Seq} + }) + + out := make([]Doc, 0, len(pg.Data)) + for _, s := range pg.Data { + out = append(out, b.systemSummary(s)) + } + + return page.Page[Doc]{Data: out, Next: pg.Next}, nil +} + +func (h *Handler) createDBSystem(q *req) error { + var in CreateDBSystemIn + if err := q.decode(&in); err != nil { + return err + } + + in.Extra = extras(q.body) + + d, etag, wr, err := h.Backend.CreateDBSystem(in) + if err != nil { + return err + } + + q.header("opc-work-request-id", wr) + + return q.json(http.StatusOK, d, etag) +} + +func extras(body []byte) map[string]json.RawMessage { + var all map[string]json.RawMessage + if json.Unmarshal(body, &all) != nil { + return nil + } + + out := map[string]json.RawMessage{} + + for _, k := range extraKeys { + if v, ok := all[k]; ok && string(v) != "null" { + out[k] = v + } + } + + return out +} + +func (h *Handler) getDBSystem(q *req) error { + d, etag, err := h.Backend.GetDBSystem(q.param(paramDBSystem)) + + return reply(q, d, etag, err) +} + +func (h *Handler) listDBSystems(q *req) error { + in := DBSystemListIn{ + Compartment: q.query(keyCompartment), ID: q.query(paramDBSystem), Name: q.query(keyDisplayName), + State: q.query(keyLifecycle), ConfigurationID: q.query("configurationId"), ListOpts: q.listOpts(), + } + if v := q.query("isHeatWaveClusterAttached"); v != "" { + t := strings.EqualFold(v, "true") + in.HeatWave = &t + } + + pg, err := h.Backend.ListDBSystems(in) + + return collectionReply(q, pg, err) +} diff --git a/services/ocimysql/docker_test.go b/services/ocimysql/docker_test.go new file mode 100644 index 0000000000..7c2be4a5b5 --- /dev/null +++ b/services/ocimysql/docker_test.go @@ -0,0 +1,191 @@ +package ocimysql_test + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os/exec" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + + ocimysql "github.com/blackbirdworks/gopherstack/services/ocimysql" +) + +const dockerWait = 8 * time.Minute + +func dockerEnv(t *testing.T) *env { + t.Helper() + + if testing.Short() { + t.Skip("docker engine test skipped in short mode") + } + + rt, err := container.NewRuntime(container.Config{Logger: slog.Default()}) + if err != nil { + t.Skipf("container runtime unavailable: %v", err) + } + + pctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + if err = rt.Ping(pctx); err != nil { + _ = rt.Close() + t.Skipf("container runtime unreachable: %v", err) + } + + vols, _ := rt.(container.VolumeRuntime) + + e := newEnv(t, envOpts{}) + require.NoError(t, e.backend.EnableDockerEngine(ocimysql.DockerConfig{ + Runtime: rt, Volumes: vols, Logger: slog.Default(), StartTimeout: 5 * time.Minute, + })) + t.Cleanup(func() { + e.backend.Reset() + e.backend.Close() + _ = rt.Close() + }) + + e.wait = dockerWait + + return e +} + +func openAdmin(t *testing.T, host string, port int, user, pass string) *sql.DB { + t.Helper() + + cfg := mysql.NewConfig() + cfg.User, cfg.Passwd, cfg.Net, cfg.Addr = user, pass, "tcp", fmt.Sprintf("%s:%d", host, port) + cfg.Timeout = 5 * time.Second + cfg.InterpolateParams = true + + c, err := mysql.NewConnector(cfg) + require.NoError(t, err) + + db := sql.OpenDB(c) + t.Cleanup(func() { _ = db.Close() }) + + return db +} + +func (e *env) connect(t *testing.T, db my.DbSystem, pass string) *sql.DB { + t.Helper() + + return openAdmin(t, *db.IpAddress, *db.Port, adminUser, pass) +} + +func rowCount(t *testing.T, c *sql.DB) int { + t.Helper() + + var n int + + require.NoError(t, c.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM shop.orders").Scan(&n)) + + return n +} + +func dockerList(t *testing.T, kind, filter string) string { + t.Helper() + + args := []string{kind, "ls", "-q", "--filter", "name=" + filter} + if kind == "container" { + args = []string{"ps", "-aq", "--filter", "name=" + filter} + } + + out, err := exec.CommandContext(t.Context(), "docker", args...).Output() + require.NoError(t, err) + + return strings.TrimSpace(string(out)) +} + +func TestDockerEngineEndToEnd(t *testing.T) { + t.Parallel() + + e := dockerEnv(t) + ctx := t.Context() + + created := e.createDB(t, dbDetails("real")) + db := e.awaitState(t, created.Id, my.DbSystemLifecycleStateActive) + assert.Equal(t, "127.0.0.1", *db.IpAddress) + + c := e.connect(t, db, adminPass) + require.NoError(t, c.PingContext(ctx), "the admin user created with CreateDbSystem can connect") + + _, err := c.ExecContext(ctx, "CREATE DATABASE shop") + require.NoError(t, err) + _, err = c.ExecContext(ctx, "CREATE TABLE shop.orders (id INT PRIMARY KEY, item VARCHAR(40))") + require.NoError(t, err) + _, err = c.ExecContext(ctx, "INSERT INTO shop.orders VALUES (1, 'widget')") + require.NoError(t, err) + + bad := e.connect(t, db, "Wr0ng#Password") + require.Error(t, bad.PingContext(ctx), "wrong password is rejected") + + _, err = e.db.StopDbSystem(ctx, my.StopDbSystemRequest{ + DbSystemId: db.Id, ShutdownType: my.InnoDbShutdownModeSlow, + }) + require.NoError(t, err) + stopped := e.awaitState(t, db.Id, my.DbSystemLifecycleStateInactive) + + down := e.connect(t, stopped, adminPass) + require.Error(t, down.PingContext(ctx), "a stopped DB system refuses connections") + + _, err = e.db.StartDbSystem(ctx, my.StartDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err) + + restarted := e.awaitState(t, db.Id, my.DbSystemLifecycleStateActive) + c2 := e.connect(t, restarted, adminPass) + assert.Equal(t, 1, rowCount(t, c2), "data survives stop and start") + + bkResp, err := e.bk.CreateBackup(ctx, my.CreateBackupRequest{ + DbSystemId: db.Id, DisplayName: new("pre"), + }) + require.NoError(t, err) + + bk := e.awaitBackup(t, bkResp.Id, my.BackupLifecycleStateActive) + + _, err = c2.ExecContext(ctx, "INSERT INTO shop.orders VALUES (2, 'gadget')") + require.NoError(t, err) + + clone := e.createDB(t, func() my.CreateDbSystemDetails { + d := dbDetails("clone") + d.AdminUsername, d.AdminPassword = nil, nil + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: bk.Id} + + return d + }()) + cloned := e.awaitState(t, clone.Id, my.DbSystemLifecycleStateActive) + + c3 := e.connect(t, cloned, adminPass) + assert.Equal(t, 1, rowCount(t, c3), "the restored DB system has the backed-up table and not later writes") + assert.NotEqual(t, *db.Port, *cloned.Port) + + _, err = e.db.UpdateDbSystem(ctx, my.UpdateDbSystemRequest{ + DbSystemId: clone.Id, AdminPassword: new("N3wPassw0rd#"), + }) + require.NoError(t, err) + e.awaitState(t, clone.Id, my.DbSystemLifecycleStateActive) + + require.NoError(t, e.connect(t, cloned, "N3wPassw0rd#").PingContext(ctx)) + require.Error(t, e.connect(t, cloned, adminPass).PingContext(ctx), "the old password stops working") + + for _, id := range []*string{db.Id, clone.Id} { + _, err = e.db.DeleteDbSystem(ctx, my.DeleteDbSystemRequest{DbSystemId: id}) + require.NoError(t, err) + e.awaitState(t, id, my.DbSystemLifecycleStateDeleted) + } + + for _, id := range []*string{db.Id, clone.Id} { + tail := strings.ToLower((*id)[len(*id)-12:]) + assert.Empty(t, dockerList(t, "container", tail), "deleting a DB system removes its container") + assert.Empty(t, dockerList(t, "volume", tail), "deleting a DB system removes its volume") + } +} diff --git a/services/ocimysql/engine.go b/services/ocimysql/engine.go new file mode 100644 index 0000000000..b04ad5fede --- /dev/null +++ b/services/ocimysql/engine.go @@ -0,0 +1,252 @@ +package ocimysql + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/mysqlengine" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" +) + +const enginePrefix = "gopherstack-ocimysql" + +var errNoServer = errors.New("ocimysql: the DB system has no running server") + +// Instance is one running MySQL server of a DB system. +type Instance interface { + Endpoint() (string, int) + EnsureAdmin(ctx context.Context, user, password string) error + DropUser(ctx context.Context, user string) error + Stop(ctx context.Context) error + Start(ctx context.Context) error + Dump(ctx context.Context) ([]byte, error) + Restore(ctx context.Context, data []byte) error + // Close removes the server; purge also deletes its data volume. + Close(ctx context.Context, purge bool) +} + +// LaunchSpec describes the server of one DB system. Fresh discards any earlier data volume. +type LaunchSpec struct { + ID string + Image string + RootSecret string + Fresh bool +} + +// Launcher starts servers. +type Launcher interface { + Launch(ctx context.Context, spec LaunchSpec) (Instance, error) + RemoveVolume(ctx context.Context, id string) +} + +// DockerConfig configures the docker engine: each DB system runs a mysql container with a named data volume. +type DockerConfig struct { + Runtime mysqlengine.Runtime + Volumes container.VolumeRuntime + Ports *portalloc.Allocator + Logger *slog.Logger + Image string + StartTimeout time.Duration +} + +type dbEngine struct { + launcher Launcher + live map[string]Instance + image string +} + +type dockerLauncher struct{ e *mysqlengine.Engine } + +func (l dockerLauncher) Launch(ctx context.Context, spec LaunchSpec) (Instance, error) { + return l.e.Launch(ctx, mysqlengine.Spec{ + ID: spec.ID, Image: spec.Image, RootPassword: spec.RootSecret, Persist: true, Fresh: spec.Fresh, + }) +} + +func (l dockerLauncher) RemoveVolume(ctx context.Context, id string) { l.e.RemoveVolume(ctx, id) } + +// EnableDockerEngine switches the backend to docker mode. Call it before serving. +func (b *InMemoryBackend) EnableDockerEngine(cfg DockerConfig) error { + e, err := mysqlengine.New(mysqlengine.Config{ + Runtime: cfg.Runtime, Volumes: cfg.Volumes, Ports: cfg.Ports, Logger: cfg.Logger, Prefix: enginePrefix, + StartTimeout: cfg.StartTimeout, + }) + if err != nil { + return err //nolint:wrapcheck // sentinel from mysqlengine + } + + b.SetLauncher(dockerLauncher{e: e}, cfg.Image) + + return nil +} + +// SetLauncher switches the backend to engine mode with a custom Launcher; image overrides the per-version image. +func (b *InMemoryBackend) SetLauncher(l Launcher, image string) { + b.mu.Lock("SetLauncher") + b.eng = &dbEngine{launcher: l, live: map[string]Instance{}, image: image} + b.mu.Unlock() +} + +func (e *dbEngine) imageFor(version string) string { + if e.image != "" { + return e.image + } + + f, _ := familyOf(version) + + return f.image +} + +// detachAllLocked removes every live instance from the map and returns them with their ids. +func (b *InMemoryBackend) detachAllLocked() map[string]Instance { + if b.eng == nil { + return nil + } + + out := b.eng.live + b.eng.live = map[string]Instance{} + + return out +} + +func (e *dbEngine) closeAll(lives map[string]Instance, purge bool) { + if e == nil { + return + } + + for _, inst := range lives { + inst.Close(context.Background(), purge) + } +} + +func (b *InMemoryBackend) instance(id string) Instance { + b.mu.RLock("instance") + defer b.mu.RUnlock() + + if b.eng == nil { + return nil + } + + return b.eng.live[id] +} + +type launchIn struct { + admin, password string + dump []byte + fresh bool +} + +// launchWork starts (or restarts, on an existing volume) the server of a DB system and records its endpoint. +func (b *InMemoryBackend) launchWork(id string, in launchIn) func(ctx context.Context) error { + return func(ctx context.Context) error { + b.mu.RLock("launchWork") + s, ok := b.systems[id] + + if !ok { + b.mu.RUnlock() + + return nil + } + + spec := LaunchSpec{ID: id, Image: b.eng.imageFor(s.Version), RootSecret: s.RootSecret, Fresh: in.fresh} + b.mu.RUnlock() + + inst, err := b.eng.launcher.Launch(ctx, spec) + if err != nil { + return err //nolint:wrapcheck // launcher errors carry the cause + } + + if err = prepareInstance(ctx, inst, in); err != nil { + inst.Close(ctx, in.fresh) + + return err + } + + host, port := inst.Endpoint() + + b.mu.Lock("launchWork") + defer b.mu.Unlock() + + cur, found := b.systems[id] + if !found || ctx.Err() != nil { + go inst.Close(context.WithoutCancel(ctx), in.fresh) + + return nil + } + + cur.EngineHost, cur.EnginePort = host, port + b.eng.live[id] = inst + + return nil + } +} + +func prepareInstance(ctx context.Context, inst Instance, in launchIn) error { + if len(in.dump) > 0 { + if err := inst.Restore(ctx, in.dump); err != nil { + return err //nolint:wrapcheck // engine errors carry the cause + } + } + + if in.admin == "" { + return nil + } + + return inst.EnsureAdmin(ctx, in.admin, in.password) //nolint:wrapcheck // engine errors carry the cause +} + +// spawn runs work off the lock and then settles the DB system: done on success, FAILED otherwise. +func (b *InMemoryBackend) spawn(id, wrID string, work func(ctx context.Context) error, done func(s *DBSystem)) { + ctx := b.runCtx + + b.wg.Go(func() { + err := work(ctx) + + b.mu.Lock("spawn") + defer b.mu.Unlock() + + s, ok := b.systems[id] + if !ok || ctx.Err() != nil { + return + } + + s.TimeUpdated, s.Etag, s.TransitionAt = b.nowUTC(), newEtag(), nil + + if err != nil { + s.State, s.Details, s.After = stateFailed, "Operation failed: "+err.Error(), "" + b.finishWR(wrID, s.Details) + + return + } + + s.Details = "" + done(s) + b.finishWR(wrID, "") + }) +} + +// relaunchLocked restarts the server of every restored DB system that was running. +func (b *InMemoryBackend) relaunchLocked() { + if b.eng == nil { + return + } + + for _, bk := range b.backups { + if bk.State == stateCreating && bk.TransitionAt == nil { + bk.State, bk.Details = stateFailed, "Backup interrupted by an emulator restart" + } + } + + for _, s := range b.systems { + if s.State != stateActive && s.State != stateUpdating && s.State != stateCreating { + continue + } + + s.State, s.TransitionAt = stateUpdating, nil + b.spawn(s.ID, "", b.launchWork(s.ID, launchIn{admin: s.AdminUser, password: s.AdminPass}), + func(s *DBSystem) { s.State = stateActive }) + } +} diff --git a/services/ocimysql/engine_test.go b/services/ocimysql/engine_test.go new file mode 100644 index 0000000000..ae0a9ed59a --- /dev/null +++ b/services/ocimysql/engine_test.go @@ -0,0 +1,325 @@ +package ocimysql_test + +import ( + "errors" + "testing" + + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errDockerExploded = errors.New("docker exploded") + +func newEngineEnv(t *testing.T) (*env, *fakeLauncher) { + t.Helper() + + l := newFakeLauncher() + + return newEnv(t, envOpts{launcher: l}), l +} + +func (e *env) mkEngineDB(t *testing.T, name string, mod func(*my.CreateDbSystemDetails)) my.DbSystem { + t.Helper() + + d := dbDetails(name) + if mod != nil { + mod(&d) + } + + resp := e.createDB(t, d) + + return e.awaitState(t, resp.Id, my.DbSystemLifecycleStateActive) +} + +func TestEngineLifecycle(t *testing.T) { + t.Parallel() + + e, l := newEngineEnv(t) + ctx := t.Context() + + created := e.createDB(t, dbDetails("eng")) + assert.Equal(t, my.DbSystemLifecycleStateCreating, created.LifecycleState) + + db := e.awaitState(t, created.Id, my.DbSystemLifecycleStateActive) + assert.Equal(t, "127.0.0.1", *db.IpAddress, "the engine publishes on loopback") + assert.Greater(t, *db.Port, 40000) + assert.Equal(t, *db.IpAddress, *db.Endpoints[0].IpAddress) + assert.Equal(t, *db.Port, *db.Endpoints[0].Port) + + wr, err := e.wr.GetWorkRequest(ctx, my.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, my.WorkRequestOperationStatusSucceeded, wr.Status) + + inst := l.inst(*db.Id) + require.NotNil(t, inst) + assert.Equal(t, adminPass, inst.admins[adminUser]) + assert.True(t, l.specs[0].Fresh) + assert.Equal(t, "mysql:8.4", l.specs[0].Image) + assert.NotEmpty(t, l.specs[0].RootSecret) + + _, err = e.db.StopDbSystem(ctx, my.StopDbSystemRequest{ + DbSystemId: db.Id, ShutdownType: my.InnoDbShutdownModeFast, + }) + require.NoError(t, err) + + stopped := e.awaitState(t, db.Id, my.DbSystemLifecycleStateInactive) + assert.Equal(t, my.DbSystemEndpointStatusInactive, stopped.Endpoints[0].Status) + + l.mu.Lock() + assert.True(t, inst.stopped) + l.mu.Unlock() + + _, err = e.db.StartDbSystem(ctx, my.StartDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err) + e.awaitState(t, db.Id, my.DbSystemLifecycleStateActive) + + l.mu.Lock() + assert.False(t, inst.stopped) + assert.Equal(t, 1, inst.starts) + assert.Len(t, l.specs, 1, "stop and start reuse the container") + l.mu.Unlock() + + _, err = e.db.RestartDbSystem(ctx, my.RestartDbSystemRequest{ + DbSystemId: db.Id, + ShutdownType: my.InnoDbShutdownModeImmediate, + }) + require.NoError(t, err) + e.awaitState(t, db.Id, my.DbSystemLifecycleStateActive) + + _, err = e.db.DeleteDbSystem(ctx, my.DeleteDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err) + e.awaitState(t, db.Id, my.DbSystemLifecycleStateDeleted) + + l.mu.Lock() + assert.True(t, inst.closed) + assert.True(t, inst.purged, "delete removes the data volume") + l.mu.Unlock() +} + +func TestEngineLaunchFailure(t *testing.T) { + t.Parallel() + + e, l := newEngineEnv(t) + l.failLaunch = errDockerExploded + + created := e.createDB(t, dbDetails("bad")) + db := e.awaitState(t, created.Id, my.DbSystemLifecycleStateFailed) + assert.Contains(t, *db.LifecycleDetails, "docker exploded") + + wr, err := e.wr.GetWorkRequest(t.Context(), my.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, my.WorkRequestOperationStatusFailed, wr.Status) + + errs, err := e.wr.ListWorkRequestErrors( + t.Context(), + my.ListWorkRequestErrorsRequest{WorkRequestId: created.OpcWorkRequestId}, + ) + require.NoError(t, err) + require.Len(t, errs.Items, 1) + assert.Contains(t, *errs.Items[0].Message, "docker exploded") + + logs, err := e.wr.ListWorkRequestLogs( + t.Context(), + my.ListWorkRequestLogsRequest{WorkRequestId: created.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Len(t, logs.Items, 2) + + _, err = e.db.DeleteDbSystem(t.Context(), my.DeleteDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err, "FAILED DB systems can be deleted") + e.awaitState(t, db.Id, my.DbSystemLifecycleStateDeleted) + + l.mu.Lock() + assert.Contains(t, l.removed, *db.Id, "a never-launched DB system still has its volume removed") + l.mu.Unlock() +} + +func TestEngineBackupAndRestore(t *testing.T) { + t.Parallel() + + e, l := newEngineEnv(t) + ctx := t.Context() + + src := e.mkEngineDB(t, "src", nil) + l.addTable(*src.Id, testTable) + l.addTable(*src.Id, "customers") + + resp, err := e.bk.CreateBackup(ctx, my.CreateBackupRequest{ + DbSystemId: src.Id, DisplayName: new("b1"), + }) + require.NoError(t, err) + assert.Equal(t, my.BackupLifecycleStateCreating, resp.LifecycleState) + + bk := e.awaitBackup(t, resp.Id, my.BackupLifecycleStateActive) + assert.GreaterOrEqual(t, *bk.BackupSizeInGBs, 1) + + l.addTable(*src.Id, "after_backup") + + dst := e.mkEngineDB(t, "dst", func(d *my.CreateDbSystemDetails) { + d.AdminUsername, d.AdminPassword = nil, nil + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: bk.Id} + }) + + inst := l.inst(*dst.Id) + require.NotNil(t, inst) + + l.mu.Lock() + assert.Equal( + t, + []string{testTable, "customers"}, + inst.tables, + "the restore carries the data of the backup, not later writes", + ) + assert.Equal(t, adminPass, inst.admins[adminUser], "admin credentials follow the backup") + l.mu.Unlock() + + t.Run("backup of a stopped system is refused", func(t *testing.T) { + t.Parallel() + + _, serr := e.db.StopDbSystem(ctx, my.StopDbSystemRequest{ + DbSystemId: dst.Id, ShutdownType: my.InnoDbShutdownModeFast, + }) + require.NoError(t, serr) + e.awaitState(t, dst.Id, my.DbSystemLifecycleStateInactive) + + _, serr = e.bk.CreateBackup( + ctx, + my.CreateBackupRequest{DbSystemId: dst.Id}, + ) + assert.Equal(t, 409, svcErr(t, serr).GetHTTPStatusCode()) + }) +} + +func TestEngineFinalBackupOnDelete(t *testing.T) { + t.Parallel() + + e, l := newEngineEnv(t) + ctx := t.Context() + + db := e.mkEngineDB(t, "fin", func(d *my.CreateDbSystemDetails) { + d.DeletionPolicy = &my.CreateDeletionPolicyDetails{ + FinalBackup: my.CreateDeletionPolicyDetailsFinalBackupRequireFinalBackup, + } + }) + l.addTable(*db.Id, testTable) + + _, err := e.db.StopDbSystem(ctx, my.StopDbSystemRequest{ + DbSystemId: db.Id, ShutdownType: my.InnoDbShutdownModeFast, + }) + require.NoError(t, err) + e.awaitState(t, db.Id, my.DbSystemLifecycleStateInactive) + + _, err = e.db.DeleteDbSystem(ctx, my.DeleteDbSystemRequest{DbSystemId: db.Id}) + require.NoError(t, err) + e.awaitState(t, db.Id, my.DbSystemLifecycleStateDeleted) + + lst, err := e.bk.ListBackups(ctx, my.ListBackupsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, lst.Items, 1) + assert.Equal(t, my.BackupLifecycleStateActive, lst.Items[0].LifecycleState) + + restored := e.mkEngineDB(t, "back", func(d *my.CreateDbSystemDetails) { + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: lst.Items[0].Id} + }) + + l.mu.Lock() + assert.Equal( + t, + []string{testTable}, + l.insts[*restored.Id].tables, + "the final backup is restorable after the system is gone", + ) + l.mu.Unlock() +} + +func TestEngineUpdate(t *testing.T) { + t.Parallel() + + e, l := newEngineEnv(t) + ctx := t.Context() + db := e.mkEngineDB(t, "upd", nil) + l.addTable(*db.Id, testTable) + + _, err := e.db.UpdateDbSystem(ctx, my.UpdateDbSystemRequest{ + DbSystemId: db.Id, AdminPassword: new("N3wPassw0rd#"), + }) + require.NoError(t, err) + e.awaitState(t, db.Id, my.DbSystemLifecycleStateActive) + + l.mu.Lock() + assert.Equal(t, "N3wPassw0rd#", l.insts[*db.Id].admins[adminUser]) + l.mu.Unlock() + + _, err = e.db.UpdateDbSystem(ctx, my.UpdateDbSystemRequest{ + DbSystemId: db.Id, MysqlVersion: new("9.4.0"), + }) + require.NoError(t, err) + + up := e.awaitState(t, db.Id, my.DbSystemLifecycleStateActive) + assert.Equal(t, "9.4.0", *up.MysqlVersion) + + l.mu.Lock() + defer l.mu.Unlock() + + require.Len(t, l.specs, 2) + assert.False(t, l.specs[1].Fresh, "an in-place upgrade keeps the data volume") + assert.Equal(t, "mysql:9", l.specs[1].Image) + assert.Equal(t, l.specs[0].RootSecret, l.specs[1].RootSecret) +} + +func TestEngineSnapshotRestoreRelaunches(t *testing.T) { + t.Parallel() + + e, _ := newEngineEnv(t) + db := e.mkEngineDB(t, "persist", nil) + + stopped := e.mkEngineDB(t, "parked", nil) + _, err := e.db.StopDbSystem(t.Context(), my.StopDbSystemRequest{ + DbSystemId: stopped.Id, ShutdownType: my.InnoDbShutdownModeFast, + }) + require.NoError(t, err) + e.awaitState(t, stopped.Id, my.DbSystemLifecycleStateInactive) + + snap := e.backend.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + l2 := newFakeLauncher() + r := newEnv(t, envOpts{launcher: l2}) + require.NoError(t, r.backend.Restore(t.Context(), snap)) + + got := r.awaitState(t, db.Id, my.DbSystemLifecycleStateActive) + assert.Equal(t, "127.0.0.1", *got.IpAddress) + + l2.mu.Lock() + require.Len(t, l2.specs, 1, "only the running system is relaunched") + assert.False(t, l2.specs[0].Fresh, "restore reuses the data volume") + assert.Equal(t, *db.Id, l2.specs[0].ID) + l2.mu.Unlock() + + assert.Equal(t, my.DbSystemLifecycleStateInactive, r.getDB(t, stopped.Id).LifecycleState) + + _, err = r.db.StartDbSystem(t.Context(), my.StartDbSystemRequest{DbSystemId: stopped.Id}) + require.NoError(t, err) + r.awaitState(t, stopped.Id, my.DbSystemLifecycleStateActive) + + l2.mu.Lock() + assert.Len(t, l2.specs, 2, "starting a system with no server launches one on its volume") + l2.mu.Unlock() +} + +func TestResetClosesEngineServers(t *testing.T) { + t.Parallel() + + e, l := newEngineEnv(t) + db := e.mkEngineDB(t, "reset", nil) + inst := l.inst(*db.Id) + + e.backend.Reset() + + l.mu.Lock() + defer l.mu.Unlock() + + assert.True(t, inst.closed) + assert.True(t, inst.purged) +} diff --git a/services/ocimysql/errors.go b/services/ocimysql/errors.go new file mode 100644 index 0000000000..b2196bc191 --- /dev/null +++ b/services/ocimysql/errors.go @@ -0,0 +1,43 @@ +package ocimysql + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocimysql: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } diff --git a/services/ocimysql/handler.go b/services/ocimysql/handler.go new file mode 100644 index 0000000000..f81ca587b9 --- /dev/null +++ b/services/ocimysql/handler.go @@ -0,0 +1,367 @@ +package ocimysql + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Minute + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI MySQL HeatWave Database Service. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocimysql.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIMySQL" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIMySQL runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{paramDBSystem, "backupId", "channelId", "configurationId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocimysql: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) queryAll(k string) []string { return q.r.URL.Query()[k] } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + if err := json.Unmarshal(q.body, v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocimysql: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIMySQL requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocimysql: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIMySQL", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ocimysql", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocimysql: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocimysql: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + h.Backend.Close() + + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocimysql: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocimysql: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() + h.Backend.Close() +} diff --git a/services/ocimysql/helpers_test.go b/services/ocimysql/helpers_test.go new file mode 100644 index 0000000000..05391ebf39 --- /dev/null +++ b/services/ocimysql/helpers_test.go @@ -0,0 +1,427 @@ +package ocimysql_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + ocimysql "github.com/blackbirdworks/gopherstack/services/ocimysql" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + otherCompart = "ocid1.compartment.oc1..other" + subA = "ocid1.subnet.oc1.iad.suba" + subB = "ocid1.subnet.oc1.iad.subb" + shapeSmall = "MySQL.2" + shapeBig = "MySQL.4" + adminUser = "dbadmin" + adminPass = "Passw0rd!x" + testTable = "orders" +) + +type fakeNet struct{} + +func (fakeNet) Subnet(id string) (ocimysql.SubnetInfo, bool) { + switch id { + case subA: + return ocimysql.SubnetInfo{ + ID: subA, + VcnID: "vcn", + AvailabilityDomain: "Uocm:US-ASHBURN-AD-2", + CIDR: "10.0.1.0/24", + }, true + case subB: + return ocimysql.SubnetInfo{ID: subB, VcnID: "vcn", CIDR: "10.0.2.0/29"}, true + } + + return ocimysql.SubnetInfo{}, false +} + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock(start time.Time) *fakeClock { + c := &fakeClock{} + if start.IsZero() { + start = time.Now() + } + + c.ns.Store(start.UTC().Truncate(time.Second).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *ocimysql.InMemoryBackend + handler *ocimysql.Handler + clock *fakeClock + prov common.ConfigurationProvider + kp keyPair + db my.DbSystemClient + bk my.DbBackupsClient + ch my.ChannelsClient + aas my.MysqlaasClient + wr my.WorkRequestsClient + base string + port int + wait time.Duration +} + +type envOpts struct { + start time.Time + launcher *fakeLauncher + enforce bool + register bool + noNet bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := ocimysql.NewInMemoryBackend() + clock := newFakeClock(opts.start) + be.SetClock(clock.Now) + + if !opts.noNet { + be.SetNetwork(fakeNet{}) + } + + if opts.launcher != nil { + be.SetLauncher(opts.launcher, "") + } + + h := ocimysql.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{ + backend: be, handler: h, clock: clock, kp: kp, port: h.Port, + prov: common.NewRawConfigurationProvider(testTenancy, testUser, ocimysql.Region, kp.fprint, kp.privPEM, nil), + base: fmt.Sprintf("http://localhost:%d", h.Port), + } + + var err error + + e.db, err = my.NewDbSystemClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.bk, err = my.NewDbBackupsClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.ch, err = my.NewChannelsClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.aas, err = my.NewMysqlaasClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.wr, err = my.NewWorkRequestsClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.db.Host, e.bk.Host, e.ch.Host, e.aas.Host, e.wr.Host = e.base, e.base, e.base, e.base, e.base + + return e +} + +// settle advances past the CREATING/UPDATING/DELETING transition. +func (e *env) settle() { e.clock.Advance(5 * time.Second) } + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +func dbDetails(name string) my.CreateDbSystemDetails { + return my.CreateDbSystemDetails{ + CompartmentId: new(testCompartment), ShapeName: new(shapeSmall), SubnetId: new(subA), + DisplayName: new(name), AdminUsername: new(adminUser), AdminPassword: new(adminPass), + } +} + +func (e *env) createDB(t *testing.T, d my.CreateDbSystemDetails) my.CreateDbSystemResponse { + t.Helper() + + resp, err := e.db.CreateDbSystem(t.Context(), my.CreateDbSystemRequest{CreateDbSystemDetails: d}) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + + return resp +} + +// mkDB creates a DB system and settles it to ACTIVE on the clock. +func (e *env) mkDB(t *testing.T, name string, mod func(*my.CreateDbSystemDetails)) my.DbSystem { + t.Helper() + + d := dbDetails(name) + if mod != nil { + mod(&d) + } + + resp := e.createDB(t, d) + e.settle() + + return e.getDB(t, resp.Id) +} + +func (e *env) getDB(t *testing.T, id *string) my.DbSystem { + t.Helper() + + got, err := e.db.GetDbSystem(t.Context(), my.GetDbSystemRequest{DbSystemId: id}) + require.NoError(t, err) + + return got.DbSystem +} + +// awaitState waits for the background engine job to move the DB system into state. +func (e *env) awaitState(t *testing.T, id *string, state my.DbSystemLifecycleStateEnum) my.DbSystem { + t.Helper() + + var got my.DbSystem + + require.Eventually(t, func() bool { + r, err := e.db.GetDbSystem(t.Context(), my.GetDbSystemRequest{DbSystemId: id}) + if err != nil { + return false + } + + got = r.DbSystem + + return got.LifecycleState == state + }, e.waitFor(), 20*time.Millisecond) + + return got +} + +func (e *env) awaitBackup(t *testing.T, id *string, state my.BackupLifecycleStateEnum) my.Backup { + t.Helper() + + var got my.Backup + + require.Eventually(t, func() bool { + r, err := e.bk.GetBackup(t.Context(), my.GetBackupRequest{BackupId: id}) + if err != nil { + return false + } + + got = r.Backup + + return got.LifecycleState == state + }, e.waitFor(), 20*time.Millisecond) + + return got +} + +// fakeLauncher stands in for the docker engine: a server keeps a set of table names that Dump and Restore carry. +var errEmptyDump = errors.New("empty dump") + +type fakeLauncher struct { + failLaunch error + insts map[string]*fakeInst + specs []ocimysql.LaunchSpec + removed []string + mu sync.Mutex +} + +type fakeInst struct { + admins map[string]string + tables []string + port int + stopped bool + closed bool + purged bool + starts int +} + +func newFakeLauncher() *fakeLauncher { return &fakeLauncher{insts: map[string]*fakeInst{}} } + +func (l *fakeLauncher) Launch(_ context.Context, spec ocimysql.LaunchSpec) (ocimysql.Instance, error) { + l.mu.Lock() + defer l.mu.Unlock() + + l.specs = append(l.specs, spec) + + if l.failLaunch != nil { + return nil, l.failLaunch + } + + in := &fakeInst{admins: map[string]string{}, port: 40000 + len(l.specs)} + l.insts[spec.ID] = in + + return &fakeHandle{l: l, in: in}, nil +} + +func (l *fakeLauncher) RemoveVolume(_ context.Context, id string) { + l.mu.Lock() + l.removed = append(l.removed, id) + l.mu.Unlock() +} + +func (l *fakeLauncher) inst(id string) *fakeInst { + l.mu.Lock() + defer l.mu.Unlock() + + return l.insts[id] +} + +type fakeHandle struct { + l *fakeLauncher + in *fakeInst +} + +func (h *fakeHandle) Endpoint() (string, int) { return "127.0.0.1", h.in.port } + +func (h *fakeHandle) EnsureAdmin(_ context.Context, user, pass string) error { + h.l.mu.Lock() + h.in.admins[user] = pass + h.l.mu.Unlock() + + return nil +} + +func (h *fakeHandle) DropUser(_ context.Context, user string) error { + h.l.mu.Lock() + delete(h.in.admins, user) + h.l.mu.Unlock() + + return nil +} + +func (h *fakeHandle) Stop(context.Context) error { + h.l.mu.Lock() + h.in.stopped = true + h.l.mu.Unlock() + + return nil +} + +func (h *fakeHandle) Start(context.Context) error { + h.l.mu.Lock() + h.in.stopped = false + h.in.starts++ + h.l.mu.Unlock() + + return nil +} + +func (h *fakeHandle) Dump(context.Context) ([]byte, error) { + h.l.mu.Lock() + defer h.l.mu.Unlock() + + return []byte(strings.Join(h.in.tables, ",")), nil +} + +func (h *fakeHandle) Restore(_ context.Context, data []byte) error { + h.l.mu.Lock() + defer h.l.mu.Unlock() + + if len(data) == 0 { + return errEmptyDump + } + + h.in.tables = strings.Split(string(data), ",") + + return nil +} + +func (h *fakeHandle) Close(_ context.Context, purge bool) { + h.l.mu.Lock() + h.in.closed, h.in.purged = true, purge + h.l.mu.Unlock() +} + +func (l *fakeLauncher) addTable(id, name string) { + l.mu.Lock() + defer l.mu.Unlock() + + l.insts[id].tables = append(l.insts[id].tables, name) +} + +func (e *env) ctx() context.Context { return context.Background() } + +func (e *env) waitFor() time.Duration { + if e.wait > 0 { + return e.wait + } + + return 30 * time.Second +} diff --git a/services/ocimysql/inproc.go b/services/ocimysql/inproc.go new file mode 100644 index 0000000000..3d2cccb2cb --- /dev/null +++ b/services/ocimysql/inproc.go @@ -0,0 +1,26 @@ +package ocimysql + +import "slices" + +// Compartments lists the distinct compartments that hold DB systems. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, s := range b.systems { + if s.State != stateDeleted { + seen[s.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} diff --git a/services/ocimysql/leak_main_test.go b/services/ocimysql/leak_main_test.go new file mode 100644 index 0000000000..42499355ac --- /dev/null +++ b/services/ocimysql/leak_main_test.go @@ -0,0 +1,18 @@ +package ocimysql_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ocimysql/lifecycle.go b/services/ocimysql/lifecycle.go new file mode 100644 index 0000000000..b851c375d2 --- /dev/null +++ b/services/ocimysql/lifecycle.go @@ -0,0 +1,752 @@ +package ocimysql + +import ( + "context" + "encoding/json" + "net/http" + "net/netip" + "slices" + "strings" + "time" +) + +const ( + hoursPerDay = 24 + shutdownImmediate = "IMMEDIATE" +) + +func inCIDR(cidr, ip string) bool { + p, err := netip.ParsePrefix(cidr) + if err != nil { + return false + } + + a, err := netip.ParseAddr(ip) + + return err == nil && p.Contains(a) +} + +func due(t *time.Time, now time.Time) bool { return t != nil && !t.After(now) } + +// Sweep applies due lifecycle transitions, automatic backups and backup retention against the backend clock. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + b.settleAll() + b.autoBackups() + b.expireBackups() +} + +func (b *InMemoryBackend) settleAll() { + now := b.nowUTC() + + for _, s := range b.systems { + settleSystem(s, now) + } + + for _, bk := range b.backups { + settleBackup(bk, now) + } + + for _, c := range b.channels { + settleChannel(c, now) + } +} + +func (b *InMemoryBackend) settleID(id string) { + now := b.nowUTC() + + if s, ok := b.systems[id]; ok { + settleSystem(s, now) + } + + if bk, ok := b.backups[id]; ok { + settleBackup(bk, now) + } + + if c, ok := b.channels[id]; ok { + settleChannel(c, now) + } +} + +func settleSystem(s *DBSystem, now time.Time) { + if !due(s.TransitionAt, now) { + return + } + + s.TransitionAt = nil + + switch { + case s.State == stateDeleting: + s.State = stateDeleted + case s.After != "": + s.State = s.After + default: + s.State = stateActive + } + + s.After = "" +} + +func settleBackup(bk *Backup, now time.Time) { + if !due(bk.TransitionAt, now) { + return + } + + bk.TransitionAt = nil + + if bk.State == stateDeleting { + bk.State, bk.Dump = stateDeleted, nil + + return + } + + bk.State = stateActive +} + +func settleChannel(c *Channel, now time.Time) { + if !due(c.TransitionAt, now) { + return + } + + c.TransitionAt = nil + + switch { + case c.State == stateDeleting: + c.State = stateDeleted + case c.After != "": + c.State = c.After + default: + c.State = stateActive + } + + c.After = "" +} + +// begin moves a DB system into a transitional state; the clock settles it unless the engine does. +func (b *InMemoryBackend) begin(s *DBSystem, op, after string, byEngine bool) string { + s.State, s.After, s.TimeUpdated, s.Etag = stateUpdating, after, b.nowUTC(), newEtag() + s.TransitionAt = nil + + if !byEngine { + s.TransitionAt = b.transitionAt() + } + + return b.newWR(op, s.CompartmentID, entityDBSystem, s.ID, actionUpdated, s.TransitionAt) +} + +func (b *InMemoryBackend) mutable(id, ifMatch string, states ...string) (*DBSystem, *APIError) { + b.settleID(id) + + s, ok := b.systems[id] + if !ok || s.State == stateDeleted { + return nil, errNotFound(id) + } + + if aerr := checkEtag(s.Etag, ifMatch); aerr != nil { + return nil, aerr + } + + if slices.Contains(states, s.State) { + return s, nil + } + + return nil, errState("DB system " + id + " is in state " + s.State + "; operation requires " + + strings.Join(states, " or ")) +} + +// UpdateDBSystemIn is the UpdateDBSystem body. +type UpdateDBSystemIn struct { + Tags + DataStorageGBs *int `json:"dataStorageSizeInGBs"` + HostnameLabel *string `json:"hostnameLabel"` + SubnetID *string `json:"subnetId"` + ShapeName *string `json:"shapeName"` + MysqlVersion *string `json:"mysqlVersion"` + ConfigID *string `json:"configurationId"` + AdminUsername *string `json:"adminUsername"` + Port *int `json:"port"` + Extra map[string]json.RawMessage `json:"-"` + IPAddress *string `json:"ipAddress"` + Description *string `json:"description"` + HA *bool `json:"isHighlyAvailable"` + AdminPassword *string `json:"adminPassword"` + PortX *int `json:"portX"` + DataStorage *DataStorageIn `json:"dataStorage"` + BackupPolicy *BackupPolicyIn `json:"backupPolicy"` + Deletion *DeletionPolicyIn `json:"deletionPolicy"` + Maintenance map[string]any `json:"maintenance"` + DisplayName *string `json:"displayName"` + DBManagement string `json:"databaseManagement"` + DatabaseMode string `json:"databaseMode"` + AccessMode string `json:"accessMode"` + CrashRecovery string `json:"crashRecovery"` +} + +type updateEffect struct { + structural bool + admin bool + version bool +} + +func (b *InMemoryBackend) applyUpdate(n *DBSystem, in UpdateDBSystemIn) (updateEffect, *APIError) { + var fx updateEffect + + if aerr := firstErr( + validEnum("crashRecovery", in.CrashRecovery, enabled, disabled), + validEnum("databaseManagement", in.DBManagement, enabled, disabled), + validEnum("databaseMode", in.DatabaseMode, modeReadWrite, modeReadOnly), + validEnum("accessMode", in.AccessMode, accessUnrestr, accessRestricted), + b.applyIdentity(n, in), + ); aerr != nil { + return fx, aerr + } + + if aerr := firstErr(b.applyShapeAndVersion(n, in, &fx), applyStorage(n, in, &fx), b.applyAdmin(n, in, &fx), + b.applyNetwork(n, in), applyPorts(n, in)); aerr != nil { + return fx, aerr + } + + pol, aerr := mergeBackupPolicy(n.BackupPolicy, in.BackupPolicy) + del, aerr2 := mergeDeletion(n.Deletion, in.Deletion) + + if aerr = firstErr(aerr, aerr2); aerr != nil { + return fx, aerr + } + + n.BackupPolicy, n.Deletion = pol, del + n.Tags.merge(in.Tags) + + for k, v := range in.Maintenance { + if n.Maintenance == nil { + n.Maintenance = map[string]any{} + } + + n.Maintenance[k] = v + } + + for k, v := range in.Extra { + if n.Extra == nil { + n.Extra = map[string]json.RawMessage{} + } + + n.Extra[k] = v + } + + setNonEmpty(&n.CrashRecovery, strings.ToUpper(in.CrashRecovery)) + setNonEmpty(&n.DBManagement, strings.ToUpper(in.DBManagement)) + setNonEmpty(&n.DatabaseMode, strings.ToUpper(in.DatabaseMode)) + setNonEmpty(&n.AccessMode, strings.ToUpper(in.AccessMode)) + + return fx, nil +} + +func setNonEmpty(dst *string, v string) { + if v != "" { + *dst = v + } +} + +func (b *InMemoryBackend) applyIdentity(n *DBSystem, in UpdateDBSystemIn) *APIError { + if in.DisplayName != nil { + if aerr := validateName(*in.DisplayName); aerr != nil { + return aerr + } + + n.Name = *in.DisplayName + } + + if in.Description != nil { + n.Description = *in.Description + } + + return nil +} + +func (b *InMemoryBackend) applyShapeAndVersion(n *DBSystem, in UpdateDBSystemIn, fx *updateEffect) *APIError { + oldShape, oldHA := n.Shape, n.HA + + if in.ShapeName != nil && *in.ShapeName != n.Shape { + sh, ok := shapeByName(*in.ShapeName) + if !ok || sh.support != supportDBSystem { + return errParam("shapeName " + *in.ShapeName + " is not a MySQL DB system shape; see ListShapes") + } + + n.Shape, fx.structural = *in.ShapeName, true + } + + if in.HA != nil && *in.HA != n.HA { + n.HA, fx.structural = *in.HA, true + } + + if n.HA && n.Shape == shapeFree { + return errParam("shape " + shapeFree + " does not support high availability") + } + + if in.MysqlVersion != nil && *in.MysqlVersion != n.Version { + switch { + case !isKnownVersion(*in.MysqlVersion): + return errParam("mysqlVersion " + *in.MysqlVersion + " is not supported; see ListVersions") + case versionBefore(*in.MysqlVersion, n.Version): + return errParam("mysqlVersion cannot be downgraded from " + n.Version) + } + + n.Version, fx.structural, fx.version = *in.MysqlVersion, true, true + } + + return b.applyConfig(n, in, oldShape, oldHA, fx) +} + +func (b *InMemoryBackend) applyConfig( + n *DBSystem, + in UpdateDBSystemIn, + oldShape string, + oldHA bool, + fx *updateEffect, +) *APIError { + cur, curKnown := b.lookupConfig(n.ConfigID) + curDefault := curKnown && cur.Type == configDefault + + switch { + case in.ConfigID != nil && *in.ConfigID != n.ConfigID: + id, aerr := b.resolveConfig(*in.ConfigID, n.Shape, n.HA) + if aerr != nil { + return aerr + } + + n.ConfigID, fx.structural = id, true + case n.Shape != oldShape && curDefault: + n.ConfigID = defaultConfigID(n.Shape, n.HA) + case n.Shape != oldShape: + return errParam( + "configuration " + n.ConfigID + " is for shape " + oldShape + "; set configurationId for " + n.Shape, + ) + case n.HA != oldHA && curDefault: + n.ConfigID = defaultConfigID(n.Shape, n.HA) + } + + return nil +} + +func applyStorage(n *DBSystem, in UpdateDBSystemIn, fx *updateEffect) *APIError { + if in.DataStorageGBs != nil { + if aerr := validStorage(*in.DataStorageGBs); aerr != nil { + return aerr + } + + if *in.DataStorageGBs < n.DataGB { + return errParam("dataStorageSizeInGBs cannot be decreased") + } + } + + if ds := in.DataStorage; ds != nil { + if ds.IsAutoExpandStorageEnabled != nil { + n.AutoExpand = *ds.IsAutoExpandStorageEnabled + } + + if ds.MaxStorageSizeInGBs != nil { + if *ds.MaxStorageSizeInGBs <= n.DataGB || *ds.MaxStorageSizeInGBs > maxStorageGB { + return errParam( + "dataStorage.maxStorageSizeInGBs must be greater than the current size and at most 131072", + ) + } + + n.MaxGB = *ds.MaxStorageSizeInGBs + } + } + + if in.DataStorageGBs != nil && *in.DataStorageGBs != n.DataGB { + n.DataGB, fx.structural = *in.DataStorageGBs, true + } + + return nil +} + +func (b *InMemoryBackend) applyAdmin(n *DBSystem, in UpdateDBSystemIn, fx *updateEffect) *APIError { + if in.AdminUsername == nil && in.AdminPassword == nil { + return nil + } + + if in.AdminPassword == nil { + return errMissing("adminPassword is required to change the administrator") + } + + user, pass := n.AdminUser, *in.AdminPassword + if in.AdminUsername != nil { + user = *in.AdminUsername + } + + if aerr := validAdmin(user, pass); aerr != nil { + return aerr + } + + n.AdminUser, n.AdminPass, fx.admin, fx.structural = user, pass, true, true + + return nil +} + +func (b *InMemoryBackend) applyNetwork(n *DBSystem, in UpdateDBSystemIn) *APIError { + subnetChanged := in.SubnetID != nil && *in.SubnetID != n.SubnetID + if !subnetChanged && in.IPAddress == nil && in.HostnameLabel == nil { + return nil + } + + sub, aerr := b.checkSubnet(orStr(deref(in.SubnetID), n.SubnetID)) + if aerr != nil { + return aerr + } + + label := n.Hostname + if in.HostnameLabel != nil { + label = *in.HostnameLabel + } + + if aerr = validHostLabel(label); aerr != nil { + return aerr + } + + if label != "" && b.hostnameTaken(sub.ID, label, n.ID) { + return errParam("hostnameLabel " + label + " is already used in the subnet") + } + + want := deref(in.IPAddress) + if want == "" && !subnetChanged { + want = n.IP + } + + ip, aerr := b.allocIP(sub, want, n.ID) + if aerr != nil { + return aerr + } + + n.SubnetID, n.Hostname, n.IP = sub.ID, label, ip + + return nil +} + +func deref(s *string) string { + if s == nil { + return "" + } + + return *s +} + +func applyPorts(n *DBSystem, in UpdateDBSystemIn) *APIError { + port, portX := n.Port, n.PortX + if in.Port != nil { + port = *in.Port + } + + if in.PortX != nil { + portX = *in.PortX + } + + if aerr := validPorts(port, portX); aerr != nil { + return aerr + } + + n.Port, n.PortX = port, portX + + return nil +} + +// UpdateDBSystem updates a DB system and returns its work request. +func (b *InMemoryBackend) UpdateDBSystem(id, ifMatch string, in UpdateDBSystemIn) (string, error) { + b.mu.Lock("UpdateDbSystem") + defer b.mu.Unlock() + + s, aerr := b.mutable(id, ifMatch, stateActive, stateInactive) + if aerr != nil { + return "", aerr + } + + n := *s + + fx, aerr := b.applyUpdate(&n, in) + if aerr != nil { + return "", aerr + } + + if (fx.admin || fx.version) && b.eng != nil && s.State != stateActive { + return "", errState("DB system " + id + " must be ACTIVE to change the administrator or MySQL version") + } + + prev := s.State + *s = n + + if !fx.structural { + s.TimeUpdated, s.Etag = b.nowUTC(), newEtag() + wr := b.newWR(wrUpdateDBSystem, s.CompartmentID, entityDBSystem, s.ID, actionUpdated, nil) + b.finishWR(wr, "") + + return wr, nil + } + + engineJob := b.eng != nil && (fx.admin || fx.version) + wr := b.begin(s, wrUpdateDBSystem, prev, engineJob) + + if engineJob { + b.spawn(s.ID, wr, b.updateWork(s, fx), func(s *DBSystem) { s.State = prev }) + } + + return wr, nil +} + +func (b *InMemoryBackend) updateWork(s *DBSystem, fx updateEffect) func(ctx context.Context) error { + id, user, pass := s.ID, s.AdminUser, s.AdminPass + oldInst := b.eng.live[id] + + return func(ctx context.Context) error { + if fx.version { + if oldInst != nil { + oldInst.Close(ctx, false) + } + + b.mu.Lock("updateWork") + delete(b.eng.live, id) + b.mu.Unlock() + + return b.launchWork(id, launchIn{admin: user, password: pass})(ctx) + } + + if oldInst == nil { + return errNoServer + } + + return oldInst.EnsureAdmin(ctx, user, pass) //nolint:wrapcheck // engine errors carry the cause + } +} + +func parseShutdown(q *req) error { + var in struct { + ShutdownType string `json:"shutdownType"` + } + + if err := q.decode(&in); err != nil { + return err + } + + if in.ShutdownType == "" { + return errMissing("shutdownType is required") + } + + if aerr := validEnum("shutdownType", in.ShutdownType, shutdownImmediate, "FAST", "SLOW"); aerr != nil { + return aerr + } + + return nil +} + +// StopDBSystem stops an ACTIVE DB system; data is kept. +func (b *InMemoryBackend) StopDBSystem(id, ifMatch string) (string, error) { + b.mu.Lock("StopDbSystem") + defer b.mu.Unlock() + + s, aerr := b.mutable(id, ifMatch, stateActive) + if aerr != nil { + return "", aerr + } + + wr := b.begin(s, wrStopDBSystem, stateInactive, b.eng != nil) + + if b.eng != nil { + b.spawn(id, wr, func(ctx context.Context) error { + if inst := b.instance(id); inst != nil { + return inst.Stop(ctx) //nolint:wrapcheck // engine errors carry the cause + } + + return nil + }, func(s *DBSystem) { s.State = stateInactive }) + } + + return wr, nil +} + +// StartDBSystem starts an INACTIVE DB system. +func (b *InMemoryBackend) StartDBSystem(id, ifMatch string) (string, error) { + b.mu.Lock("StartDbSystem") + defer b.mu.Unlock() + + s, aerr := b.mutable(id, ifMatch, stateInactive) + if aerr != nil { + return "", aerr + } + + wr := b.begin(s, wrStartDBSystem, stateActive, b.eng != nil) + + if b.eng != nil { + b.spawn(id, wr, b.startWork(s), func(s *DBSystem) { s.State = stateActive }) + } + + return wr, nil +} + +// startWork starts the stopped server or, when it is gone, launches a new one on the same data volume. +func (b *InMemoryBackend) startWork(s *DBSystem) func(ctx context.Context) error { + id, user, pass := s.ID, s.AdminUser, s.AdminPass + + return func(ctx context.Context) error { + if inst := b.instance(id); inst != nil { + return inst.Start(ctx) //nolint:wrapcheck // engine errors carry the cause + } + + return b.launchWork(id, launchIn{admin: user, password: pass})(ctx) + } +} + +// RestartDBSystem restarts an ACTIVE DB system. +func (b *InMemoryBackend) RestartDBSystem(id, ifMatch string) (string, error) { + b.mu.Lock("RestartDbSystem") + defer b.mu.Unlock() + + s, aerr := b.mutable(id, ifMatch, stateActive) + if aerr != nil { + return "", aerr + } + + wr := b.begin(s, wrRestartDBSystem, stateActive, b.eng != nil) + + if b.eng != nil { + start := b.startWork(s) + + b.spawn(id, wr, func(ctx context.Context) error { + if inst := b.instance(id); inst != nil { + if err := inst.Stop(ctx); err != nil { + return err //nolint:wrapcheck // engine errors carry the cause + } + } + + return start(ctx) + }, func(s *DBSystem) { s.State = stateActive }) + } + + return wr, nil +} + +// DeleteDBSystem deletes a DB system, honouring its deletion policy. +func (b *InMemoryBackend) DeleteDBSystem(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteDbSystem") + defer b.mu.Unlock() + + s, aerr := b.mutable(id, ifMatch, stateActive, stateInactive, stateFailed) + if aerr != nil { + return "", aerr + } + + if s.Deletion.IsDeleteProtected { + return "", newErr(http.StatusConflict, "Conflict", + "DB system "+id+" is delete protected; set deletionPolicy.isDeleteProtected to false first") + } + + wasStopped := s.State == stateInactive + s.State, s.After, s.TimeUpdated, s.Etag = stateDeleting, "", b.nowUTC(), newEtag() + s.TransitionAt = nil + + if s.Deletion.AutomaticBackupRetention == deleteBackups { + b.dropAutomaticBackups(id) + } + + final := s.Deletion.FinalBackup == requireFinal + + if b.eng == nil { + s.TransitionAt = b.transitionAt() + wr := b.newWR(wrDeleteDBSystem, s.CompartmentID, entityDBSystem, id, actionDeleted, s.TransitionAt) + + if final { + b.newBackupRecord(s, creationManual, "Final backup of "+s.Name, s.BackupPolicy.RetentionInDays, nil) + } + + return wr, nil + } + + inst := b.eng.live[id] + delete(b.eng.live, id) + + wr := b.newWR(wrDeleteDBSystem, s.CompartmentID, entityDBSystem, id, actionDeleted, nil) + snap := *s + b.spawn(id, wr, func(ctx context.Context) error { + return b.teardown(ctx, &snap, inst, final, wasStopped) + }, func(s *DBSystem) { s.State = stateDeleted }) + + return wr, nil +} + +// teardown takes the final backup, if required, and removes the server and its data volume. +func (b *InMemoryBackend) teardown(ctx context.Context, s *DBSystem, inst Instance, final, wasStopped bool) error { + var err error + + if inst == nil { + b.eng.launcher.RemoveVolume(ctx, s.ID) + + return nil + } + + if final { + if wasStopped { + err = inst.Start(ctx) + } + + var dump []byte + + if err == nil { + dump, err = inst.Dump(ctx) + } + + if err == nil { + b.mu.Lock("teardown") + b.newBackupRecord(s, creationManual, "Final backup of "+s.Name, s.BackupPolicy.RetentionInDays, dump) + b.mu.Unlock() + } + } + + inst.Close(ctx, true) + + return err //nolint:wrapcheck // engine errors carry the cause +} + +func (h *Handler) updateDBSystem(q *req) error { + var in UpdateDBSystemIn + if err := q.decode(&in); err != nil { + return err + } + + in.Extra = extras(q.body) + + wr, err := h.Backend.UpdateDBSystem(q.param(paramDBSystem), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteDBSystem(q *req) error { + wr, err := h.Backend.DeleteDBSystem(q.param(paramDBSystem), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) startDBSystem(q *req) error { + wr, err := h.Backend.StartDBSystem(q.param(paramDBSystem), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) stopDBSystem(q *req) error { + if err := parseShutdown(q); err != nil { + return err + } + + wr, err := h.Backend.StopDBSystem(q.param(paramDBSystem), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) restartDBSystem(q *req) error { + if err := parseShutdown(q); err != nil { + return err + } + + wr, err := h.Backend.RestartDBSystem(q.param(paramDBSystem), q.ifMatch()) + + return acceptedWR(q, wr, err) +} diff --git a/services/ocimysql/models.go b/services/ocimysql/models.go new file mode 100644 index 0000000000..10049fd971 --- /dev/null +++ b/services/ocimysql/models.go @@ -0,0 +1,209 @@ +package ocimysql + +import ( + "encoding/json" + "time" +) + +// Lifecycle states. +const ( + stateCreating = "CREATING" + stateActive = "ACTIVE" + stateInactive = "INACTIVE" + stateUpdating = "UPDATING" + stateDeleting = "DELETING" + stateDeleted = "DELETED" + stateFailed = "FAILED" +) + +// Configuration types. +const ( + configDefault = "DEFAULT" + configCustom = "CUSTOM" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// BackupPolicy is the automatic backup policy of a DB system. +type BackupPolicy struct { + Tags Tags `json:"tags"` + WindowStartTime string `json:"windowStartTime"` + SoftDelete string `json:"softDelete"` + CopyPolicies json.RawMessage `json:"copyPolicies,omitempty"` + RetentionInDays int `json:"retentionInDays"` + IsEnabled bool `json:"isEnabled"` + PitrEnabled bool `json:"pitrEnabled"` +} + +// DeletionPolicy controls what DeleteDBSystem does. +type DeletionPolicy struct { + AutomaticBackupRetention string `json:"automaticBackupRetention"` + FinalBackup string `json:"finalBackup"` + IsDeleteProtected bool `json:"isDeleteProtected"` +} + +// Source says where a DB system's data came from. +type Source struct { + SourceType string `json:"sourceType"` + BackupID string `json:"backupId,omitempty"` +} + +// DBSystem is a MySQL DB system. AdminPassword and RootSecret are never returned by the API. +type DBSystem struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + LastAutoBackup time.Time `json:"lastAutoBackup"` + Tags Tags `json:"tags"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Source *Source `json:"source,omitempty"` + Extra map[string]json.RawMessage `json:"extra,omitempty"` + Maintenance map[string]any `json:"maintenance,omitempty"` + FD string `json:"faultDomain"` + AdminUser string `json:"adminUsername"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + SubnetID string `json:"subnetId"` + Shape string `json:"shapeName"` + Version string `json:"mysqlVersion"` + ConfigID string `json:"configurationId"` + Hostname string `json:"hostnameLabel,omitempty"` + AD string `json:"availabilityDomain"` + EngineHost string `json:"engineHost,omitempty"` + State string `json:"state"` + After string `json:"after,omitempty"` + Details string `json:"details,omitempty"` + Etag string `json:"etag"` + IP string `json:"ipAddress"` + AdminPass string `json:"adminPassword,omitempty"` + RootSecret string `json:"rootSecret,omitempty"` + DatabaseMode string `json:"databaseMode"` + AccessMode string `json:"accessMode"` + CrashRecovery string `json:"crashRecovery"` + DBManagement string `json:"databaseManagement"` + Deletion DeletionPolicy `json:"deletionPolicy"` + BackupPolicy BackupPolicy `json:"backupPolicy"` + DataGB int `json:"dataStorageSizeInGBs"` + MaxGB int `json:"maxStorageSizeInGBs,omitempty"` + Port int `json:"port"` + PortX int `json:"portX"` + EnginePort int `json:"enginePort,omitempty"` + Seq int64 `json:"seq"` + AutoExpand bool `json:"isAutoExpandStorageEnabled"` + HA bool `json:"isHighlyAvailable"` +} + +// Backup is a DB system backup. Dump holds the logical dump when the docker engine took it. +type Backup struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Snapshot *DBSystem `json:"snapshot,omitempty"` + Tags Tags `json:"tags"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + DBSystemID string `json:"dbSystemId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + State string `json:"state"` + Details string `json:"details,omitempty"` + BackupType string `json:"backupType"` + CreationType string `json:"creationType"` + SoftDelete string `json:"softDelete"` + Etag string `json:"etag"` + Dump []byte `json:"dump,omitempty"` + Retention int `json:"retentionInDays"` + SizeGB int `json:"backupSizeInGBs"` + Seq int64 `json:"seq"` +} + +// ChannelSource is the replication source of a channel; the password is never stored. +type ChannelSource struct { + Extra map[string]json.RawMessage `json:"extra,omitempty"` + SourceType string `json:"sourceType"` + Hostname string `json:"hostname"` + Username string `json:"username"` + SslMode string `json:"sslMode"` + Port int `json:"port"` + HasPassword bool `json:"hasPassword"` +} + +// ChannelTarget is the DB system a channel replicates into. +type ChannelTarget struct { + TargetType string `json:"targetType"` + DBSystemID string `json:"dbSystemId"` + ChannelName string `json:"channelName"` + ApplierUsername string `json:"applierUsername"` + TablesWithoutPrimaryKeyHandling string `json:"tablesWithoutPrimaryKeyHandling"` + Filters json.RawMessage `json:"filters,omitempty"` + DelayInSeconds int `json:"delayInSeconds"` +} + +// Channel is a replication channel. It records configuration and lifecycle only; no data is replicated. +type Channel struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + Tags Tags `json:"tags"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Details string `json:"details,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + State string `json:"state"` + After string `json:"after,omitempty"` + Etag string `json:"etag"` + Target ChannelTarget `json:"target"` + Source ChannelSource `json:"source"` + Seq int64 `json:"seq"` + Enabled bool `json:"isEnabled"` +} + +// Configuration is a DEFAULT or CUSTOM set of MySQL variables for a shape. +type Configuration struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + Variables map[string]any `json:"variables"` + Tags Tags `json:"tags"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Shape string `json:"shapeName"` + Type string `json:"type"` + State string `json:"state"` + Name string `json:"displayName"` + Description string `json:"description,omitempty"` + ParentID string `json:"parentConfigurationId,omitempty"` + LowerCase string `json:"lowerCaseTableNames,omitempty"` + Etag string `json:"etag"` + Seq int64 `json:"seq"` +} + +// WorkRequest records an asynchronous operation. +type WorkRequest struct { + Accepted time.Time `json:"accepted"` + FinishAt *time.Time `json:"finishAt,omitempty"` + ID string `json:"id"` + Operation string `json:"operation"` + CompartmentID string `json:"compartmentId"` + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + EntityURI string `json:"entityUri"` + Action string `json:"action"` + Failure string `json:"failure,omitempty"` + Seq int64 `json:"seq"` +} diff --git a/services/ocimysql/ops.go b/services/ocimysql/ops.go new file mode 100644 index 0000000000..98ba24aec1 --- /dev/null +++ b/services/ocimysql/ops.go @@ -0,0 +1,55 @@ +package ocimysql + +import ( + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateDBSystem: (*Handler).createDBSystem, opGetDBSystem: (*Handler).getDBSystem, + opListDBSystems: (*Handler).listDBSystems, opUpdateDBSystem: (*Handler).updateDBSystem, + opDeleteDBSystem: (*Handler).deleteDBSystem, opStartDBSystem: (*Handler).startDBSystem, + opStopDBSystem: (*Handler).stopDBSystem, opRestartDBSystem: (*Handler).restartDBSystem, + opCreateBackup: (*Handler).createBackup, opGetBackup: (*Handler).getBackup, + opListBackups: (*Handler).listBackups, opUpdateBackup: (*Handler).updateBackup, + opDeleteBackup: (*Handler).deleteBackup, opChangeBackupComp: (*Handler).changeBackupCompartment, + opCreateChannel: (*Handler).createChannel, opGetChannel: (*Handler).getChannel, + opListChannels: (*Handler).listChannels, opUpdateChannel: (*Handler).updateChannel, + opDeleteChannel: (*Handler).deleteChannel, opResumeChannel: (*Handler).resumeChannel, + opResetChannel: (*Handler).resetChannel, + opCreateConfig: (*Handler).createConfiguration, opGetConfig: (*Handler).getConfiguration, + opListConfigs: (*Handler).listConfigurations, opUpdateConfig: (*Handler).updateConfiguration, + opDeleteConfig: (*Handler).deleteConfiguration, + opListShapes: (*Handler).listShapes, opListVersions: (*Handler).listVersions, + opGetWorkRequest: (*Handler).getWorkRequest, opListWorkRequests: (*Handler).listWorkRequests, + opListWorkReqErrors: (*Handler).listWorkRequestErrors, opListWorkReqLogs: (*Handler).listWorkRequestLogs, + } +} + +func reply[T any](q *req, w T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func acceptedWR(q *req, wr string, err error) error { + if err != nil { + return err + } + + return q.accepted(wr) +} + +func collectionReply[T any](q *req, pg page.Page[T], err error) error { + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, pg.Data, "") +} diff --git a/services/ocimysql/persistence.go b/services/ocimysql/persistence.go new file mode 100644 index 0000000000..a24da59493 --- /dev/null +++ b/services/ocimysql/persistence.go @@ -0,0 +1,120 @@ +package ocimysql + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Systems map[string]*DBSystem `json:"dbSystems"` + Backups map[string]*Backup `json:"backups"` + Channels map[string]*Channel `json:"channels"` + Configs map[string]*Configuration `json:"configurations"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocimysql", backendSnapshot{ + Version: snapshotVersion, Systems: b.systems, Backups: b.backups, Channels: b.channels, Configs: b.configs, + WorkReqs: b.workReqs, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot and relaunches engine servers. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocimysql", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ocimysql: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.stop() + b.wg.Wait() + + b.mu.Lock("Restore") + lives := b.detachAllLocked() + b.systems, b.backups, b.channels, b.configs = snap.Systems, snap.Backups, snap.Channels, snap.Configs + b.workReqs, b.seq = snap.WorkReqs, snap.Seq + + for _, s := range b.systems { + s.EngineHost, s.EnginePort = "", 0 + } + + b.runCtx, b.stop = contextWithCancel() + b.relaunchLocked() + e := b.eng + b.mu.Unlock() + + e.closeAll(lives, false) + b.Auth.Load(snap.Auth) + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{ + nullCheck("DB system", &snap.Systems), nullCheck("backup", &snap.Backups), + nullCheck("channel", &snap.Channels), nullCheck("configuration", &snap.Configs), + nullCheck("work request", &snap.WorkReqs), + } { + if err != nil { + return err + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ocimysql: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ocimysql/persistence_test.go b/services/ocimysql/persistence_test.go new file mode 100644 index 0000000000..0d7f948c45 --- /dev/null +++ b/services/ocimysql/persistence_test.go @@ -0,0 +1,154 @@ +package ocimysql_test + +import ( + "net/http" + "testing" + + my "github.com/oracle/oci-go-sdk/v65/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ocimysql "github.com/blackbirdworks/gopherstack/services/ocimysql" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + db := e.mkDB(t, "persist", func(d *my.CreateDbSystemDetails) { + d.FreeformTags = map[string]string{"k": "v"} + d.DeletionPolicy = &my.CreateDeletionPolicyDetails{IsDeleteProtected: new(true)} + }) + bk := e.mkBackup(t, db.Id, "snap") + + cfg, err := e.aas.CreateConfiguration(ctx, my.CreateConfigurationRequest{ + CompartmentId: new( + testCompartment, + ), + ShapeName: new(shapeSmall), + Variables: &my.ConfigurationVariables{MaxConnections: new(321)}, + }) + require.NoError(t, err) + + ch, err := e.ch.CreateChannel(ctx, my.CreateChannelRequest{CreateChannelDetails: channelDetails(db.Id)}) + require.NoError(t, err) + e.settle() + + snap := e.backend.Snapshot(ctx) + require.NotEmpty(t, snap) + assert.NotContains(t, string(snap), "S0urce#pass", "channel passwords are not stored") + + restored := newEnv(t, envOpts{}) + require.NoError(t, restored.backend.Restore(ctx, snap)) + restored.clock.ns.Store(e.clock.ns.Load()) + + got := restored.getDB(t, db.Id) + assert.Equal(t, "persist", *got.DisplayName) + assert.Equal(t, my.DbSystemLifecycleStateActive, got.LifecycleState) + assert.Equal(t, *db.IpAddress, *got.IpAddress) + assert.Equal(t, "v", got.FreeformTags["k"]) + assert.True(t, *got.DeletionPolicy.IsDeleteProtected) + assert.Len(t, got.Channels, 1) + + gb, err := restored.bk.GetBackup(ctx, my.GetBackupRequest{BackupId: bk.Id}) + require.NoError(t, err) + assert.Equal(t, adminUser, *gb.DbSystemSnapshot.AdminUsername) + + gc, err := restored.aas.GetConfiguration(ctx, my.GetConfigurationRequest{ConfigurationId: cfg.Id}) + require.NoError(t, err) + assert.Equal(t, 321, *gc.Variables.MaxConnections) + + gch, err := restored.ch.GetChannel(ctx, my.GetChannelRequest{ChannelId: ch.Id}) + require.NoError(t, err) + assert.Equal(t, my.ChannelLifecycleStateActive, gch.LifecycleState) + + wr, err := restored.wr.GetWorkRequest( + ctx, + my.GetWorkRequestRequest{WorkRequestId: new(workRequestOf(t, restored, db.Id))}, + ) + require.NoError(t, err) + assert.Equal(t, my.WorkRequestOperationStatusSucceeded, wr.Status) + + fromBackup := restored.mkDB(t, "again", func(d *my.CreateDbSystemDetails) { + d.AdminUsername, d.AdminPassword = nil, nil + d.Source = my.CreateDbSystemSourceFromBackupDetails{BackupId: bk.Id} + }) + assert.Equal( + t, + my.DbSystemLifecycleStateActive, + fromBackup.LifecycleState, + "admin credentials survive the round trip", + ) + + restored.backend.Reset() + + _, err = restored.db.GetDbSystem(ctx, my.GetDbSystemRequest{DbSystemId: db.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func workRequestOf(t *testing.T, e *env, dbID *string) string { + t.Helper() + + lst, err := e.wr.ListWorkRequests(t.Context(), my.ListWorkRequestsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + for _, w := range lst.Items { + if w.OperationType == my.WorkRequestOperationTypeCreateDbsystem { + return *w.Id + } + } + + require.Fail(t, "no CREATE_DBSYSTEM work request for "+*dbID) + + return "" +} + +func TestRestoreRejectsBadSnapshots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + }{ + {"null db system", `{"version":1,"dbSystems":{"a":null}}`, true}, + {"null backup", `{"version":1,"backups":{"a":null}}`, true}, + {"null channel", `{"version":1,"channels":{"a":null}}`, true}, + {"null configuration", `{"version":1,"configurations":{"a":null}}`, true}, + {"null work request", `{"version":1,"workRequests":{"a":null}}`, true}, + {"old version starts empty", `{"version":99}`, false}, + {"empty maps initialised", `{"version":1}`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := ocimysql.NewInMemoryBackend() + err := b.Restore(t.Context(), []byte(tt.data)) + + if tt.wantErr { + require.ErrorIs(t, err, ocimysql.ErrSnapshotNull) + + return + } + + require.NoError(t, err) + + _, _, err = b.GetDBSystem("ocid1.mysqldbsystem.oc1.iad.x") + require.Error(t, err) + }) + } +} + +func TestProviderInit(t *testing.T) { + t.Parallel() + + _, err := (&ocimysql.Provider{}).Init(nil) + require.ErrorIs(t, err, ocimysql.ErrNilAppContext) + + assert.Equal(t, "OCIMySQL", (&ocimysql.Provider{}).Name()) + assert.Equal(t, ocimysql.DefaultPort, ocimysql.DefaultSettings().Port) +} diff --git a/services/ocimysql/provider.go b/services/ocimysql/provider.go new file mode 100644 index 0000000000..d86faf0a8c --- /dev/null +++ b/services/ocimysql/provider.go @@ -0,0 +1,72 @@ +package ocimysql + +import ( + "errors" + "os" + + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocimysql: nil app context") + +// ConfigProvider extracts OCI MySQL configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIMySQLSettings() Settings +} + +// EngineConfigProvider exposes the effective engine mode (stub or docker). +type EngineConfigProvider interface { + GetOCIMySQLEngine() string +} + +// Provider implements service.Provider for OCI MySQL. It runs on its own listener (see Handler.StartWorker) +// and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIMySQL" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIMySQLSettings() + } + + backend := NewInMemoryBackend() + if ec, ok := ctx.Config.(EngineConfigProvider); ok && ec.GetOCIMySQLEngine() == EngineDocker { + enableDockerEngine(ctx, backend) + } + + handler := NewHandler(backend) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} + +func enableDockerEngine(ctx *service.AppContext, backend *InMemoryBackend) { + rt, err := container.NewRuntime(container.Config{Logger: ctx.Logger}) + if err != nil { + ctx.Logger.Warn("OCIMySQL: container runtime unavailable; DB systems stay metadata-only", "error", err) + + return + } + + vols, _ := rt.(container.VolumeRuntime) + + if err = backend.EnableDockerEngine(DockerConfig{ + Runtime: rt, Volumes: vols, Ports: ctx.PortAlloc, Logger: ctx.Logger, Image: os.Getenv("OCI_MYSQL_IMAGE"), + }); err != nil { + ctx.Logger.Warn("OCIMySQL: invalid engine configuration; DB systems stay metadata-only", "error", err) + _ = rt.Close() + } +} diff --git a/services/ocimysql/route.go b/services/ocimysql/route.go new file mode 100644 index 0000000000..98e517a21d --- /dev/null +++ b/services/ocimysql/route.go @@ -0,0 +1,120 @@ +package ocimysql + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateDBSystem = "CreateDbSystem" + opGetDBSystem = "GetDbSystem" + opListDBSystems = "ListDbSystems" + opUpdateDBSystem = "UpdateDbSystem" + opDeleteDBSystem = "DeleteDbSystem" + opStartDBSystem = "StartDbSystem" + opStopDBSystem = "StopDbSystem" + opRestartDBSystem = "RestartDbSystem" + opCreateBackup = "CreateBackup" + opGetBackup = "GetBackup" + opListBackups = "ListBackups" + opUpdateBackup = "UpdateBackup" + opDeleteBackup = "DeleteBackup" + opChangeBackupComp = "ChangeBackupCompartment" + opCreateChannel = "CreateChannel" + opGetChannel = "GetChannel" + opListChannels = "ListChannels" + opUpdateChannel = "UpdateChannel" + opDeleteChannel = "DeleteChannel" + opResumeChannel = "ResumeChannel" + opResetChannel = "ResetChannel" + opCreateConfig = "CreateConfiguration" + opGetConfig = "GetConfiguration" + opListConfigs = "ListConfigurations" + opUpdateConfig = "UpdateConfiguration" + opDeleteConfig = "DeleteConfiguration" + opListShapes = "ListShapes" + opListVersions = "ListVersions" + opGetWorkRequest = "GetWorkRequest" + opListWorkRequests = "ListWorkRequests" + opListWorkReqErrors = "ListWorkRequestErrors" + opListWorkReqLogs = "ListWorkRequestLogs" + opAdminKeys = "ManageAPIKeys" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + base = "/20190415" + dbSystemPath = base + "/dbSystems/{dbSystemId}" + backupPath = base + "/backups/{backupId}" + channelPath = base + "/channels/{channelId}" + configPath = base + "/configurations/{configurationId}" + workRequestPath = base + "/workRequests/{workRequestId}" +) + +const ( + keyCompartment = "compartmentId" + keyDisplayName = "displayName" + keyTimeCreated = "timeCreated" + keyTimeUpdated = "timeUpdated" + keyLifecycle = "lifecycleState" + keySourceType = "sourceType" + keyIsEnabled = "isEnabled" + paramDBSystem = "dbSystemId" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/dbSystems", Op: opCreateDBSystem}, + {Method: http.MethodGet, Path: base + "/dbSystems", Op: opListDBSystems}, + {Method: http.MethodGet, Path: dbSystemPath, Op: opGetDBSystem}, + {Method: http.MethodPut, Path: dbSystemPath, Op: opUpdateDBSystem}, + {Method: http.MethodDelete, Path: dbSystemPath, Op: opDeleteDBSystem}, + {Method: http.MethodPost, Path: dbSystemPath + "/actions/start", Op: opStartDBSystem}, + {Method: http.MethodPost, Path: dbSystemPath + "/actions/stop", Op: opStopDBSystem}, + {Method: http.MethodPost, Path: dbSystemPath + "/actions/restart", Op: opRestartDBSystem}, + {Method: http.MethodPost, Path: base + "/backups", Op: opCreateBackup}, + {Method: http.MethodGet, Path: base + "/backups", Op: opListBackups}, + {Method: http.MethodGet, Path: backupPath, Op: opGetBackup}, + {Method: http.MethodPut, Path: backupPath, Op: opUpdateBackup}, + {Method: http.MethodDelete, Path: backupPath, Op: opDeleteBackup}, + {Method: http.MethodPost, Path: backupPath + "/actions/changeCompartment", Op: opChangeBackupComp}, + {Method: http.MethodPost, Path: base + "/channels", Op: opCreateChannel}, + {Method: http.MethodGet, Path: base + "/channels", Op: opListChannels}, + {Method: http.MethodGet, Path: channelPath, Op: opGetChannel}, + {Method: http.MethodPut, Path: channelPath, Op: opUpdateChannel}, + {Method: http.MethodDelete, Path: channelPath, Op: opDeleteChannel}, + {Method: http.MethodPost, Path: channelPath + "/actions/resume", Op: opResumeChannel}, + {Method: http.MethodPost, Path: channelPath + "/actions/reset", Op: opResetChannel}, + {Method: http.MethodPost, Path: base + "/configurations", Op: opCreateConfig}, + {Method: http.MethodGet, Path: base + "/configurations", Op: opListConfigs}, + {Method: http.MethodGet, Path: configPath, Op: opGetConfig}, + {Method: http.MethodPut, Path: configPath, Op: opUpdateConfig}, + {Method: http.MethodDelete, Path: configPath, Op: opDeleteConfig}, + {Method: http.MethodGet, Path: base + "/shapes", Op: opListShapes}, + {Method: http.MethodGet, Path: base + "/versions", Op: opListVersions}, + {Method: http.MethodGet, Path: base + "/workRequests", Op: opListWorkRequests}, + {Method: http.MethodGet, Path: workRequestPath, Op: opGetWorkRequest}, + {Method: http.MethodGet, Path: workRequestPath + "/errors", Op: opListWorkReqErrors}, + {Method: http.MethodGet, Path: workRequestPath + "/logs", Op: opListWorkReqLogs}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocimysql/settings.go b/services/ocimysql/settings.go new file mode 100644 index 0000000000..07c8178123 --- /dev/null +++ b/services/ocimysql/settings.go @@ -0,0 +1,20 @@ +package ocimysql + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8159 + +// Engine modes (OCI_MYSQL_ENGINE). +const ( + EngineStub = "stub" + EngineDocker = "docker" +) + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIMySQL. +type Settings struct { + Engine string `json:"engine" env:"OCI_MYSQL_ENGINE" default:"stub" name:"engine" help:"OCI MySQL engine mode: stub (control plane only) or docker (each ACTIVE DB system runs a real MySQL container)."` //nolint:lll // config struct tags are intentionally verbose + Port int `json:"port" env:"OCI_MYSQL_PORT" default:"8159" name:"port" help:"Fixed TCP port for the dedicated OCI MySQL HeatWave Database Service listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_MYSQL_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ocimysql/store.go b/services/ocimysql/store.go new file mode 100644 index 0000000000..b6f189e5c1 --- /dev/null +++ b/services/ocimysql/store.go @@ -0,0 +1,323 @@ +package ocimysql + +import ( + "cmp" + "context" + "net/netip" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + // Region is the emulated region name. + Region = "us-ashburn-1" + regionKey = "iad" + defaultListLimit = 100 + ocidTailLen = 52 + + defaultTransition = 2 * time.Second + maxName = 255 + defaultAD = "Uocm:US-ASHBURN-AD-1" + defaultFD = "FAULT-DOMAIN-1" + firstHostOffset = 5 +) + +// SubnetInfo is the slice of an OCI Core subnet the service validates references against. +type SubnetInfo struct { + ID string + VcnID string + AvailabilityDomain string + CIDR string +} + +// Network is the narrow view of OCI Core the service needs. +type Network interface { + Subnet(id string) (SubnetInfo, bool) +} + +// InMemoryBackend holds every DB system, backup, channel, configuration and work request under one lock. +type InMemoryBackend struct { + net Network + runCtx context.Context + eng *dbEngine + Auth *ociauth.Registry + channels map[string]*Channel + configs map[string]*Configuration + workReqs map[string]*WorkRequest + defaults map[string]*Configuration + stop context.CancelFunc + backups map[string]*Backup + systems map[string]*DBSystem + compartment func(string) bool + mu *lockmetrics.RWMutex + wg *sync.WaitGroup + now func() time.Time + defaultList []*Configuration + transition time.Duration + seq int64 +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + ctx, stop := context.WithCancel(context.Background()) + b := &InMemoryBackend{ + mu: lockmetrics.New("ocimysql"), now: time.Now, transition: defaultTransition, + wg: &sync.WaitGroup{}, runCtx: ctx, stop: stop, defaults: map[string]*Configuration{}, + } + b.resetMaps() + b.Auth = ociauth.NewRegistry() + + b.defaultList = defaultConfigs() + for _, c := range b.defaultList { + c.TimeCreated = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + c.TimeUpdated = c.TimeCreated + b.defaults[c.ID] = c + } + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.systems, b.backups, b.channels = map[string]*DBSystem{}, map[string]*Backup{}, map[string]*Channel{} + b.configs, b.workReqs = map[string]*Configuration{}, map[string]*WorkRequest{} +} + +// SetClock injects the time source used for lifecycle transitions, backup windows and retention. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTransition sets how long CREATING, UPDATING and DELETING states last without the docker engine. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetNetwork wires subnet validation and endpoint address allocation against OCI Core; nil disconnects. +func (b *InMemoryBackend) SetNetwork(n Network) { + b.mu.Lock("SetNetwork") + b.net = n + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// Reset clears all state and tears down engine servers. +func (b *InMemoryBackend) Reset() { + b.stop() + b.wg.Wait() + + b.mu.Lock("Reset") + lives := b.detachAllLocked() + b.resetMaps() + b.runCtx, b.stop = context.WithCancel(context.Background()) + e := b.eng + b.mu.Unlock() + + e.closeAll(lives, true) + b.Auth.Reset() +} + +// Close stops engine goroutines and removes the servers; data volumes are kept. +func (b *InMemoryBackend) Close() { + b.stop() + b.wg.Wait() + + b.mu.Lock("Close") + lives := b.detachAllLocked() + e := b.eng + b.mu.Unlock() + + e.closeAll(lives, false) +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + regionKey + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) transitionAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +func (b *InMemoryBackend) checkCompartmentLocked(id string) *APIError { + b.mu.RLock("checkCompartment") + defer b.mu.RUnlock() + + return b.checkCompartment(id) +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func validListOpts(o ListOpts, sorts ...string) *APIError { + if o.SortBy != "" && !containsFold(sorts, o.SortBy) { + return errParam("sortBy must be one of " + strings.Join(sorts, ", ")) + } + + if o.Order != "" && !strings.EqualFold(o.Order, "ASC") && !strings.EqualFold(o.Order, "DESC") { + return errParam("sortOrder must be ASC or DESC") + } + + return nil +} + +// sortKeys are the comparable fields of a listed resource. +type sortKeys struct { + created time.Time + updated time.Time + name string + shape string + seq int64 +} + +func paginate[T any](items []T, o ListOpts, keys func(T) sortKeys) page.Page[T] { + desc := false + + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + default: + desc = !strings.EqualFold(o.SortBy, keyDisplayName) && !strings.EqualFold(o.SortBy, "shapeName") + } + + slices.SortStableFunc(items, func(a, c T) int { + ka, kc := keys(a), keys(c) + + var r int + + switch { + case strings.EqualFold(o.SortBy, keyDisplayName): + r = cmp.Compare(ka.name, kc.name) + case strings.EqualFold(o.SortBy, "shapeName"): + r = cmp.Compare(ka.shape, kc.shape) + case strings.EqualFold(o.SortBy, keyTimeUpdated): + r = ka.updated.Compare(kc.updated) + default: + r = ka.created.Compare(kc.created) + } + + r = cmp.Or(r, cmp.Compare(ka.seq, kc.seq)) + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +func firstErr(errs ...*APIError) *APIError { + for _, e := range errs { + if e != nil { + return e + } + } + + return nil +} + +func validateName(name string) *APIError { + if strings.TrimSpace(name) == "" { + return errMissing("displayName must not be empty") + } + + if len(name) > maxName { + return errParam("displayName must be at most 255 characters") + } + + return nil +} + +func containsFold(list []string, v string) bool { + return slices.ContainsFunc(list, func(s string) bool { return strings.EqualFold(s, v) }) +} + +func hostIn(cidr string, offset int) string { + p, err := netip.ParsePrefix(cidr) + if err != nil { + return "" + } + + a := p.Masked().Addr() + for range offset { + a = a.Next() + } + + if !p.Contains(a) { + return "" + } + + return a.String() +} + +// documentationAddr hands out addresses from the RFC 5737 documentation ranges when no subnet is known. +func documentationAddr(n int64) string { + bases := []string{"203.0.113.", "198.51.100.", "192.0.2."} + const hosts = 253 + + i := (n - 1) % (hosts * int64(len(bases))) + + return bases[i/hosts] + strconv.FormatInt(i%hosts+1, 10) +} + +func contextWithCancel() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) +} diff --git a/services/ocimysql/workreq.go b/services/ocimysql/workreq.go new file mode 100644 index 0000000000..7f9c25c39d --- /dev/null +++ b/services/ocimysql/workreq.go @@ -0,0 +1,262 @@ +package ocimysql + +import ( + "cmp" + "slices" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + entityDBSystem = "dbsystem" + entityBackup = "backup" + entityChannel = "channel" + wrStatusPending = "IN_PROGRESS" + actionCreated = "CREATED" + actionUpdated = "UPDATED" + actionDeleted = "DELETED" + wrHalfway = 50 +) + +// Work request operation types (SDK WorkRequestOperationTypeEnum). +const ( + wrCreateDBSystem = "CREATE_DBSYSTEM" + wrUpdateDBSystem = "UPDATE_DBSYSTEM" + wrDeleteDBSystem = "DELETE_DBSYSTEM" + wrStartDBSystem = "START_DBSYSTEM" + wrStopDBSystem = "STOP_DBSYSTEM" + wrRestartDBSystem = "RESTART_DBSYSTEM" + wrCreateBackup = "CREATE_BACKUP" + wrDeleteBackup = "DELETE_BACKUP" + wrUpdateBackup = "UPDATE_BACKUP" + wrCreateChannel = "CREATE_CHANNEL" + wrUpdateChannel = "UPDATE_CHANNEL" + wrResumeChannel = "RESUME_CHANNEL" + wrResetChannel = "RESET_CHANNEL" + wrDeleteChannel = "DELETE_CHANNEL" +) + +type wrResourceWire struct { + EntityType string `json:"entityType"` + ActionType string `json:"actionType"` + Identifier string `json:"identifier"` + EntityURI string `json:"entityUri,omitempty"` +} + +// WorkRequestWire is the WorkRequest and WorkRequestSummary body. +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted *time.Time `json:"timeStarted,omitempty"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + OperationType string `json:"operationType"` + Status string `json:"status"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Resources []wrResourceWire `json:"resources"` + PercentComplete float32 `json:"percentComplete"` +} + +type wrLogWire struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +type wrErrorWire struct { + Timestamp time.Time `json:"timestamp"` + Code string `json:"code"` + Message string `json:"message"` +} + +// newWR records a work request; finish is nil while completion waits on the engine. +func (b *InMemoryBackend) newWR(op, compartment, entityType, id, action string, finish *time.Time) string { + wr := &WorkRequest{ + ID: newOCID("mysqlworkrequest"), Operation: op, CompartmentID: compartment, EntityType: entityType, + EntityID: id, EntityURI: "/" + entityPath(entityType) + "/" + id, Action: action, Accepted: b.nowUTC(), + FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr.ID +} + +func entityPath(entityType string) string { + switch entityType { + case entityBackup: + return "backups" + case entityChannel: + return "channels" + default: + return "dbSystems" + } +} + +func (wr *WorkRequest) finished(now time.Time) bool { + return wr.Failure != "" || (wr.FinishAt != nil && !wr.FinishAt.After(now)) +} + +// finishWR completes a work request at now; an empty failure means success. +func (b *InMemoryBackend) finishWR(id, failure string) { + wr, ok := b.workReqs[id] + if !ok { + return + } + + now := b.nowUTC() + wr.FinishAt, wr.Failure = &now, failure +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + started := wr.Accepted + out := WorkRequestWire{ + OperationType: wr.Operation, ID: wr.ID, CompartmentID: wr.CompartmentID, TimeAccepted: wr.Accepted, + Status: wrStatusPending, TimeStarted: &started, PercentComplete: wrHalfway, + Resources: []wrResourceWire{{ + EntityType: wr.EntityType, ActionType: wrStatusPending, Identifier: wr.EntityID, EntityURI: wr.EntityURI, + }}, + } + + if !wr.finished(b.nowUTC()) { + return out + } + + out.TimeFinished, out.PercentComplete = wr.FinishAt, 100 + + if wr.Failure != "" { + out.Status, out.Resources[0].ActionType = "FAILED", "FAILED" + + return out + } + + out.Status, out.Resources[0].ActionType = "SUCCEEDED", wr.Action + + return out +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// ListWorkRequests lists the work requests of a compartment. +func (b *InMemoryBackend) ListWorkRequests(compartment string, o ListOpts) (page.Page[WorkRequestWire], error) { + if aerr := firstErr( + b.checkCompartmentLocked(compartment), + validListOpts(o, "ID", "OPERATION_TYPE", "STATUS", "TIME_ACCEPTED", "TIME_STARTED", "TIME_FINISHED"), + ); aerr != nil { + return page.Page[WorkRequestWire]{}, aerr + } + + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + all := make([]WorkRequestWire, 0, len(b.workReqs)) + seqs := map[string]int64{} + + for _, wr := range b.workReqs { + if wr.CompartmentID == compartment { + all = append(all, b.wrView(wr)) + seqs[wr.ID] = wr.Seq + } + } + + desc := !strings.EqualFold(o.Order, "ASC") + by := strings.ToUpper(o.SortBy) + + slices.SortStableFunc(all, func(a, c WorkRequestWire) int { + var r int + + switch by { + case "ID": + r = cmp.Compare(a.ID, c.ID) + case "OPERATION_TYPE": + r = cmp.Compare(a.OperationType, c.OperationType) + case "STATUS": + r = cmp.Compare(a.Status, c.Status) + default: + r = a.TimeAccepted.Compare(c.TimeAccepted) + } + + r = cmp.Or(r, cmp.Compare(seqs[a.ID], seqs[c.ID])) + if desc { + return -r + } + + return r + }) + + return page.New(all, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestLogs returns the log entries of a work request. +func (b *InMemoryBackend) WorkRequestLogs(id string, o ListOpts) (page.Page[wrLogWire], error) { + b.mu.RLock("WorkRequestLogs") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrLogWire]{}, errNotFound(id) + } + + logs := []wrLogWire{{Timestamp: wr.Accepted, Message: wr.Operation + " accepted"}} + + switch { + case wr.Failure != "": + logs = append(logs, wrLogWire{Timestamp: *wr.FinishAt, Message: wr.Operation + " failed: " + wr.Failure}) + case wr.finished(b.nowUTC()): + logs = append(logs, wrLogWire{Timestamp: *wr.FinishAt, Message: wr.Operation + " succeeded"}) + } + + return page.New(logs, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestErrors returns the errors of a work request. +func (b *InMemoryBackend) WorkRequestErrors(id string, o ListOpts) (page.Page[wrErrorWire], error) { + b.mu.RLock("WorkRequestErrors") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrErrorWire]{}, errNotFound(id) + } + + out := []wrErrorWire{} + if wr.Failure != "" { + out = append(out, wrErrorWire{Timestamp: *wr.FinishAt, Code: "InternalError", Message: wr.Failure}) + } + + return page.New(out, o.Page, o.Limit, defaultListLimit), nil +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + + return reply(q, w, "", err) +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(q.query(keyCompartment), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestLogs(q *req) error { + pg, err := h.Backend.WorkRequestLogs(q.param("workRequestId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestErrors(q *req) error { + pg, err := h.Backend.WorkRequestErrors(q.param("workRequestId"), q.listOpts()) + + return collectionReply(q, pg, err) +} diff --git a/services/ocinosql/PARITY.md b/services/ocinosql/PARITY.md new file mode 100644 index 0000000000..ae54263d3c --- /dev/null +++ b/services/ocinosql/PARITY.md @@ -0,0 +1,49 @@ +--- +service: ocinosql +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: 57ea757db +last_audit_date: 2026-10-29 +overall: B +ops: + CreateTable: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with opc-work-request-id and no body. ddlStatement must be CREATE TABLE [IF NOT EXISTS] matching name; tableLimits required unless isAutoReclaimable (then 50/50/25). CREATING then ACTIVE after the transition window on the injectable clock. Duplicate name (case-insensitive, per compartment) is 409 Conflict."} + GetTable: {wire: ok, errors: ok, state: ok, persist: n/a, note: "By OCID or by name plus compartmentId; etag header; schema (columns, primaryKey, shardKey, ttl in days, identity), canonical ddlStatement, tableLimits, schemaState MUTABLE. DELETED tables are not found."} + ListTables: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; name, lifecycleState (ALL includes DELETED) filters; sortBy name or timeCreated; opc-next-page; autoReclaimableTables and onDemandCapacityTables counts."} + UpdateTable: {wire: ok, errors: partial, state: ok, persist: ok, note: "ALTER TABLE (ADD column, DROP column, USING TTL) or tableLimits (not both), plus tags; table reports UPDATING for the transition window. Dropped column values are removed from rows, added columns with DEFAULT are backfilled. Columns used by an index or key cannot be dropped. If-Match gives 412 NoEtagMatch."} + DeleteTable: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request; DELETING then DELETED, rows and indexes are dropped. isIfExists on a missing table answers 204 without a work request."} + ChangeTableCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "Work request UPDATE_TABLE; moves immediately; 409 when the name is taken in the target compartment."} + ListTableUsage: {wire: ok, errors: ok, state: ok, persist: ok, note: "One item per 60 s period with recorded activity: readUnits and writeUnits consumed by GetRow, UpdateRow, DeleteRow and Query (1 unit per KB, minimum 1; ABSOLUTE reads cost double), storageInGBs rounded up from stored row bytes. Throttle counts are 0 because no throttling is applied. timeStart defaults to one hour ago."} + CreateIndex: {wire: ok, errors: ok, state: ok, persist: ok, note: "Keys on scalar columns, or on JSON columns with jsonPath and jsonFieldType (INTEGER, LONG, DOUBLE, NUMBER, STRING, BOOLEAN, TIMESTAMP). ARRAY, MAP, RECORD and BINARY columns are rejected. CREATING then ACTIVE; work request UPDATE_TABLE. Duplicate name is 409 unless isIfNotExists."} + GetIndex: {wire: ok, errors: ok, state: ok, persist: n/a, note: "No etag header is returned."} + ListIndexes: {wire: ok, errors: ok, state: ok, persist: n/a, note: "name, lifecycleState filters; sortBy name or timeCreated; paged."} + DeleteIndex: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request; DELETING then removed. isIfExists on a missing index answers 204. If-Match is not evaluated (indexes have no etag)."} + UpdateRow: {wire: ok, errors: ok, state: ok, persist: ok, note: "Put with option IF_ABSENT/IF_PRESENT and If-Match (row version); a refused put returns a null version and, with isGetReturnRow, existingVersion and existingValue. ttl in days (0 never expires, absent or isTtlUseTableDefault uses the table default), isExactMatch, identity (ALWAYS / BY DEFAULT [ON NULL], START WITH, INCREMENT BY) with generatedValue, UUID columns (AS UUID GENERATED BY DEFAULT). Values are type-checked per column; long values keep full precision."} + GetRow: {wire: ok, errors: ok, state: ok, persist: n/a, note: "key=column:value for every primary key column; missing row answers 200 with a null value; etag is the row version; timeOfExpiration for rows with a TTL; expired rows are invisible."} + DeleteRow: {wire: ok, errors: ok, state: ok, persist: ok, note: "isSuccess false for a missing row or a non-matching If-Match version; isGetReturnRow returns existingVersion and existingValue."} + Query: {wire: ok, errors: partial, state: ok, persist: n/a, note: "SELECT subset, see families.sql_subset. limit and opc-next-page paging; consistency; variables with DECLARE; isPrepared with a PrepareStatement result. Non-SELECT statements are 400 with a pointer to the right API."} + PrepareStatement: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Validates the SELECT against its table and returns an opaque statement; isGetQueryPlan returns a small emulator-specific plan object (the real plan format is not reproduced)."} + SummarizeStatement: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Classifies SELECT, INSERT, UPDATE, DELETE, CREATE/ALTER/DROP TABLE and CREATE/DROP INDEX with table and index names and the if-exists flags; a syntax problem is reported in syntaxError with HTTP 200."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS until the resource transition time, then SUCCEEDED; resources carry the table and action."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; items envelope, paged with opc-next-page."} + ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Always empty: no emulated operation fails asynchronously."} + ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Accepted and succeeded entries."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-nosql-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id. InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, Conflict, NoEtagMatch and IncorrectState follow the OCI conventions; a missing table or index is NotAuthorizedOrNotFound (as OCI answers) rather than a NoSQL-specific code, and duplicate table or index is 409 Conflict and limit violations are 400 LimitExceeded (OCI API error reference, docs.oracle.com/en-us/iaas/Content/API/References/apierrors.htm). The native-driver codes TableExists/IndexExists/TableNotFound (nosql-go-sdk nosqlerr/errors.go) belong to the data-plane protocol, not this REST API."} + service_limits: {status: ok, note: "Verified against Oracle NoSQL Cloud Service Limits (docs.oracle.com/en/cloud/paas/nosql-cloud/fkdyw/): provisioned 40000 read and 20000 write units per table, 5000 GB storage, 50 columns, 5 indexes, table name 256 characters, column and index names 64 characters (letter first, then letters, digits, underscore), primary key 64 bytes, row 512 KB, query string 10 KB. Native-driver tests (nosql-go-sdk nosqldb/data_ops_test.go TestDataSizeLimit) confirm the 64 byte and 512 KB boundaries."} + ddl_subset: {status: partial, note: "CREATE TABLE: columns INTEGER, LONG, FLOAT, DOUBLE, NUMBER, STRING, BOOLEAN, BINARY, TIMESTAMP(p), JSON, ENUM, ARRAY, MAP, RECORD; NOT NULL, DEFAULT literal, GENERATED ALWAYS / BY DEFAULT [ON NULL] AS IDENTITY, AS UUID; PRIMARY KEY(SHARD(..), ..); USING TTL n DAYS|HOURS. ALTER TABLE (ADD, DROP) and USING TTL. Unsupported: child tables, FIXED_BINARY, inline PRIMARY KEY, MODIFY column, COMMENT, identity options other than START WITH and INCREMENT BY."} + sql_subset: {status: partial, note: "SELECT * | paths [AS alias] | COUNT(*) FROM table [alias] [WHERE ...] [ORDER BY ...] [LIMIT n] [OFFSET n]; conditions AND, OR, NOT, parentheses, =, !=, <>, <, <=, >, >=, IN, NOT IN, BETWEEN, IS [NOT] NULL over columns, JSON paths (a.b, a[0]), literals and declared variables. ORDER BY must name a leading prefix of the primary key or of an ACTIVE index, all in one direction. Unsupported with 400: DML, DDL, joins, GROUP BY, HAVING, DISTINCT, LIKE, functions, aggregates other than COUNT(*), subqueries, array filter steps."} + ttl: {status: ok, note: "Row expiry is evaluated against the injectable clock on every read and query, and expired rows are removed by a one-minute sweeper."} + lifecycle: {status: ok, note: "CREATING, UPDATING and DELETING last 2 seconds on the injectable backend clock; transitions are applied on every request and by a one-minute sweeper."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-nosql-port / OCI_NOSQL_PORT (default 8162); never on the shared AWS router; cli.go reserves the port in the shared pool."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} +items_still_open: + - "CreateReplica, DeleteReplica, GetConfiguration, UpdateConfiguration, UnassignKmsKey and DeleteWorkRequest are not routed (multi-region tables, tenancy KMS configuration and work request deletion)." + - "Throughput and storage limits are validated but not enforced: no read or write throttling, no storage quota, so the throttle counts of ListTableUsage stay 0." + - "The 50/50/25 auto-reclaimable (Always Free) defaults and the minimum of 1 unit are not found in any primary source reached; the documented region-level limits (30 tables, 3 on-demand tables, 4 DDL operations per minute, 100 schema updates per table, 4 limit decreases per day, once-a-day capacity mode change, 10000/5000 on-demand ceiling) are not enforced." + - "The REST error code for oversize keys, rows and query strings is LimitExceeded (400) by inference from the OCI error reference; the NoSQL REST reference page itself could not be fetched. Key and row size are approximated from string and number bytes, not the server's serialized form." + - "Queries scan all rows of a table; indexes only validate and order ORDER BY clauses, there is no index selection, and maxReadInKBs is accepted but not used to cut a batch." + - "Timestamps in WHERE comparisons are compared as normalized strings, so a literal must use the column's ISO 8601 form." + - "opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified." + - "The dashboard has no OCI NoSQL page." + - "Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set." +--- diff --git a/services/ocinosql/README.md b/services/ocinosql/README.md new file mode 100644 index 0000000000..bdea64faa3 --- /dev/null +++ b/services/ocinosql/README.md @@ -0,0 +1,31 @@ + +# OCI NoSQL Database Cloud Service + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-29 (`57ea757db`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 21 (18 ok, 3 partial) | +| Feature families | 9 (6 ok, 3 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- CreateReplica, DeleteReplica, GetConfiguration, UpdateConfiguration, UnassignKmsKey and DeleteWorkRequest are not routed (multi-region tables, tenancy KMS configuration and work request deletion). +- Throughput and storage limits are validated but not enforced: no read or write throttling, no storage quota, so the throttle counts of ListTableUsage stay 0. +- The 50/50/25 auto-reclaimable (Always Free) defaults and the minimum of 1 unit are not found in any primary source reached; the documented region-level limits (30 tables, 3 on-demand tables, 4 DDL operations per minute, 100 schema updates per table, 4 limit decreases per day, once-a-day capacity mode change, 10000/5000 on-demand ceiling) are not enforced. +- The REST error code for oversize keys, rows and query strings is LimitExceeded (400) by inference from the OCI error reference; the NoSQL REST reference page itself could not be fetched. Key and row size are approximated from string and number bytes, not the server's serialized form. +- Queries scan all rows of a table; indexes only validate and order ORDER BY clauses, there is no index selection, and maxReadInKBs is accepted but not used to cut a batch. +- Timestamps in WHERE comparisons are compared as normalized strings, so a literal must use the column's ISO 8601 form. +- opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. +- The dashboard has no OCI NoSQL page. +- Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocinosql/alter.go b/services/ocinosql/alter.go new file mode 100644 index 0000000000..f1be37a408 --- /dev/null +++ b/services/ocinosql/alter.go @@ -0,0 +1,226 @@ +package ocinosql + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// applyAlter applies an ALTER TABLE statement to t; t is untouched when it returns an error. +func (b *InMemoryBackend) applyAlter(t *Table, ddl string) *APIError { + spec, aerr := parseAlter(ddl) + if aerr != nil { + return aerr + } + + if !strs.Equal(spec.name, t.Name) { + return errParam("ALTER TABLE names table '" + spec.name + "' but the request addresses '" + t.Name + "'") + } + + cols := append([]Column{}, t.Columns...) + + var removed []string + + for _, d := range spec.drop { + i := indexOfColumn(cols, d) + if i < 0 { + return errParam("cannot drop column '" + d + "': no such column") + } + + if err := b.canDrop(t, cols[i].Name); err != nil { + return err + } + + removed = append(removed, cols[i].Name) + cols = append(cols[:i], cols[i+1:]...) + } + + var added []Column + + for _, c := range spec.add { + if err := checkAddColumn(cols, c); err != nil { + return err + } + + cols, added = append(cols, c), append(added, c) + } + + if spec.identity != nil && t.Identity != nil { + return errParam("the table already has an identity column") + } + + t.Columns = cols + if spec.identity != nil { + t.Identity = spec.identity + } + + if spec.ttlHours != nil { + t.TTLHours = *spec.ttlHours + } + + for _, c := range removed { + if t.Identity != nil && strs.Equal(t.Identity.Column, c) { + t.Identity = nil + } + } + + reshapeRows(t, removed, added) + t.DDL = renderDDL(t) + + return nil +} + +func indexOfColumn(cols []Column, name string) int { + for i, c := range cols { + if strs.Equal(c.Name, name) { + return i + } + } + + return -1 +} + +func (b *InMemoryBackend) canDrop(t *Table, col string) *APIError { + if strs.ContainsFold(t.PrimaryKey, col) { + return errParam("cannot drop primary key column '" + col + "'") + } + + for _, ix := range t.Indexes { + for _, k := range ix.Keys { + if strs.Equal(k.Column, col) { + return errParam("cannot drop column '" + col + "': it is used by index " + ix.Name) + } + } + } + + return nil +} + +func checkAddColumn(cols []Column, c Column) *APIError { + if len(cols) >= maxColumns { + return errLimit("a table can have at most " + strconv.Itoa(maxColumns) + " columns") + } + + if indexOfColumn(cols, c.Name) >= 0 { + return errParam("cannot add column '" + c.Name + "': it already exists") + } + + ty, _ := parseTypeString(c.Type) + if len(c.Default) > 0 { + if _, msg := coerceDefault(ty, c.Default); msg != "" { + return errParam("default of column '" + c.Name + "' " + msg) + } + } + + if c.NotNull && len(c.Default) == 0 && !c.Gen { + return errParam("cannot add NOT NULL column '" + c.Name + "' without a DEFAULT") + } + + return nil +} + +func coerceDefault(ty *dtype, raw json.RawMessage) (any, string) { + v, err := decodeJSON(raw) + if err != nil { + return nil, "is not a valid literal" + } + + return coerce(ty, v) +} + +// reshapeRows drops removed columns from stored rows and backfills defaults of added ones. +func reshapeRows(t *Table, removed []string, added []Column) { + if len(removed) == 0 && len(added) == 0 { + return + } + + for _, r := range t.Rows { + m := decodeRow(r.Value) + + for _, c := range removed { + delete(m, c) + } + + for _, c := range added { + if len(c.Default) == 0 { + continue + } + + ty, _ := parseTypeString(c.Type) + if v, msg := coerceDefault(ty, c.Default); msg == "" { + m[c.Name] = v + } + } + + r.Value, _ = json.Marshal(m) + } +} + +func renderDDL(t *Table) string { + parts := make([]string, 0, len(t.Columns)+1) + + for _, c := range t.Columns { + s := c.Name + " " + c.Type + + switch { + case t.Identity != nil && strs.Equal(t.Identity.Column, c.Name): + s += renderIdentity(t.Identity) + case c.AsUUID && c.Gen: + s += " AS UUID GENERATED BY DEFAULT" + case c.AsUUID: + s += " AS UUID" + } + + if c.NotNull && !strs.ContainsFold(t.PrimaryKey, c.Name) { + s += " NOT NULL" + } + + if len(c.Default) > 0 { + s += " DEFAULT " + renderDefault(c.Default) + } + + parts = append(parts, s) + } + + pk := strings.Join(t.PrimaryKey[len(t.ShardKey):], ", ") + if len(t.PrimaryKey) == len(t.ShardKey) { + pk = "" + } + + key := "SHARD(" + strings.Join(t.ShardKey, ", ") + ")" + if pk != "" { + key += ", " + pk + } + + parts = append(parts, "PRIMARY KEY("+key+")") + out := "CREATE TABLE " + t.Name + "(" + strings.Join(parts, ", ") + ")" + + if t.TTLHours > 0 { + out += " USING TTL " + strconv.Itoa(t.TTLHours) + " HOURS" + } + + return out +} + +func renderIdentity(id *Identity) string { + if id.Always { + return " GENERATED ALWAYS AS IDENTITY" + } + + if id.OnNull { + return " GENERATED BY DEFAULT ON NULL AS IDENTITY" + } + + return " GENERATED BY DEFAULT AS IDENTITY" +} + +func renderDefault(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" + } + + return string(raw) +} diff --git a/services/ocinosql/consts.go b/services/ocinosql/consts.go new file mode 100644 index 0000000000..0d56b3cfe0 --- /dev/null +++ b/services/ocinosql/consts.go @@ -0,0 +1,16 @@ +package ocinosql + +const ( + kwNull = "NULL" + kwTrue = "TRUE" + kwFalse = "FALSE" + kwAnd = "AND" + sqlSelect = "SELECT" + sqlInsert = "INSERT" + sqlUpdate = "UPDATE" + sqlDelete = "DELETE" + + filterAll = "ALL" + consAbsolute = "ABSOLUTE" + consEventual = "EVENTUAL" +) diff --git a/services/ocinosql/ddl.go b/services/ocinosql/ddl.go new file mode 100644 index 0000000000..b48777d1f8 --- /dev/null +++ b/services/ocinosql/ddl.go @@ -0,0 +1,607 @@ +package ocinosql + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + hoursPerDay = 24 + maxNameLen = 256 + maxFieldLen = 64 + maxColumns = 50 +) + +var identRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + +// Column is one table column. +type Column struct { + Name string `json:"name"` + Type string `json:"type"` + Default json.RawMessage `json:"default,omitempty"` + NotNull bool `json:"notNull,omitempty"` + AsUUID bool `json:"asUuid,omitempty"` + Gen bool `json:"generated,omitempty"` +} + +// Identity is the table's identity column spec and its counter. +type Identity struct { + Column string `json:"column"` + Always bool `json:"always"` + OnNull bool `json:"onNull,omitempty"` + Next int64 `json:"next"` + Increment int64 `json:"increment"` +} + +type createSpec struct { + ttlHours *int + identity *Identity + name string + cols []Column + pk []string + shard []string + ifNotExists bool +} + +type alterSpec struct { + ttlHours *int + identity *Identity + name string + add []Column + drop []string +} + +func validIdent(kind, name string) *APIError { + limit := maxFieldLen + if kind == "table" { + limit = maxNameLen + } + + if len(name) > limit || !identRe.MatchString(name) { + return errParam(kind + " name '" + name + "' is invalid: it must start with a letter and contain only " + + "letters, digits and underscores, at most " + strconv.Itoa(limit) + " characters") + } + + return nil +} + +func parseCreate(sql string) (*createSpec, *APIError) { + p, err := newParser(sql) + if err != nil { + return nil, err + } + + if !p.acceptKw("CREATE") || !p.acceptKw("TABLE") { + return nil, errParam("ddlStatement must be a CREATE TABLE statement") + } + + spec := &createSpec{} + + if p.acceptKw("IF") { + if e := firstErr(p.expectKw("NOT"), p.expectKw("EXISTS")); e != nil { + return nil, e + } + + spec.ifNotExists = true + } + + name, e := p.ident("a table name") + if e != nil { + return nil, e + } + + if p.isPunct(".") { + return nil, errParam("child tables (parent.child names) are not supported") + } + + spec.name = name + + if e = p.parseCreateBody(spec); e != nil { + return nil, e + } + + if spec.ttlHours, e = p.parseTTL(); e != nil { + return nil, e + } + + if !p.atEOF() { + return nil, p.fail("end of statement") + } + + return spec, validateSchema(spec) +} + +func firstErr(errs ...*APIError) *APIError { + for _, e := range errs { + if e != nil { + return e + } + } + + return nil +} + +func (p *parser) parseCreateBody(spec *createSpec) *APIError { + if e := p.expectPunct("("); e != nil { + return e + } + + for { + var e *APIError + + if p.isKw("PRIMARY") { + e = p.parsePrimaryKey(spec) + } else { + e = p.parseColumnInto(spec) + } + + if e != nil { + return e + } + + if !p.acceptPunct(",") { + break + } + } + + return p.expectPunct(")") +} + +func (p *parser) parseColumnInto(spec *createSpec) *APIError { + col, id, e := p.parseColumn() + if e != nil { + return e + } + + if id != nil { + if spec.identity != nil { + return errParam("a table can have only one identity column") + } + + spec.identity = id + } + + spec.cols = append(spec.cols, col) + + return nil +} + +func (p *parser) parsePrimaryKey(spec *createSpec) *APIError { + p.pos++ + + if e := firstErr(p.expectKw("KEY"), p.expectPunct("(")); e != nil { + return e + } + + if len(spec.pk) > 0 { + return errParam("PRIMARY KEY specified more than once") + } + + if p.acceptKw("SHARD") { + if e := p.expectPunct("("); e != nil { + return e + } + + names, e := p.identList() + if e != nil { + return e + } + + spec.shard = names + + if e = p.expectPunct(")"); e != nil { + return e + } + + p.acceptPunct(",") + } + + names, e := p.identList() + if e != nil { + return e + } + + spec.pk = append(append([]string{}, spec.shard...), names...) + + return p.expectPunct(")") +} + +func (p *parser) identList() ([]string, *APIError) { + var out []string + + for { + n, e := p.ident("a column name") + if e != nil { + return nil, e + } + + out = append(out, n) + + if !p.isPunct(",") || p.toks[p.pos+1].kind != tkIdent || p.toks[p.pos+1].up == "SHARD" { + return out, nil + } + + p.pos++ + } +} + +func (p *parser) parseTTL() (*int, *APIError) { + if !p.acceptKw("USING") { + return nil, nil + } + + if e := p.expectKw("TTL"); e != nil { + return nil, e + } + + n, err := strconv.Atoi(p.next().text) + if err != nil || n < 0 { + return nil, errParam("TTL must be a non-negative integer") + } + + hours := n + + switch { + case p.acceptKw("DAYS"), p.acceptKw("DAY"): + hours = n * hoursPerDay + case p.acceptKw("HOURS"), p.acceptKw("HOUR"): + default: + return nil, p.fail("DAYS or HOURS") + } + + return &hours, nil +} + +func (p *parser) parseColumn() (Column, *Identity, *APIError) { + name, e := p.ident("a column name") + if e != nil { + return Column{}, nil, e + } + + if e = validIdent("column", name); e != nil { + return Column{}, nil, e + } + + t, e := p.parseType() + if e != nil { + return Column{}, nil, e + } + + col := Column{Name: name, Type: t.String()} + + var id *Identity + + for { + var done bool + + done, id, e = p.parseColumnOption(&col, t, id) + if e != nil { + return Column{}, nil, e + } + + if done { + break + } + } + + if id != nil { + id.Column = name + } + + return col, id, nil +} + +func (p *parser) parseColumnOption(col *Column, t *dtype, id *Identity) (bool, *Identity, *APIError) { + switch { + case p.acceptKw("NOT"): + col.NotNull = true + + return false, id, p.expectKw(kwNull) + case p.acceptKw("DEFAULT"): + lit, e := p.defaultLiteral() + col.Default = lit + + return false, id, e + case p.isKw("AS") && p.toks[p.pos+1].up == "UUID": + p.pos += 2 + col.AsUUID = true + + return false, id, nil + case p.acceptKw("GENERATED"): + return p.parseGenerated(col, t, id) + } + + return true, id, nil +} + +func (p *parser) defaultLiteral() (json.RawMessage, *APIError) { + t := p.next() + switch { + case t.kind == tkNumber: + return json.RawMessage(t.text), nil + case t.kind == tkString: + b, _ := json.Marshal(t.text) + + return b, nil + case t.up == kwTrue || t.up == kwFalse: + return json.RawMessage(strings.ToLower(t.text)), nil + case t.up == kwNull: + return json.RawMessage("null"), nil + case t.text == "-" && p.peek().kind == tkNumber: + return json.RawMessage("-" + p.next().text), nil + } + + return nil, errParam("syntax error: DEFAULT must be a number, string, boolean or NULL literal") +} + +func (p *parser) parseGenerated(col *Column, t *dtype, id *Identity) (bool, *Identity, *APIError) { + always := p.acceptKw("ALWAYS") + if !always { + if e := firstErr(p.expectKw("BY"), p.expectKw("DEFAULT")); e != nil { + return false, id, e + } + } + + if col.AsUUID && !p.isKw("AS") { + col.Gen = true + + return false, id, nil + } + + onNull := false + if !always && p.acceptKw("ON") { + if e := p.expectKw(kwNull); e != nil { + return false, id, e + } + + onNull = true + } + + if e := p.expectKw("AS"); e != nil { + return false, id, e + } + + if p.acceptKw("UUID") { + col.AsUUID, col.Gen = true, true + + return false, id, nil + } + + if e := p.expectKw("IDENTITY"); e != nil { + return false, id, e + } + + if t.kind != kInteger && t.kind != kLong { + return false, id, errParam("identity columns must be INTEGER or LONG") + } + + if id != nil { + return false, id, errParam("a column can have only one identity specification") + } + + col.Gen = true + + ident := &Identity{Always: always, OnNull: onNull, Next: 1, Increment: 1} + + return false, ident, p.parseIdentityOptions(ident) +} + +func (p *parser) parseIdentityOptions(id *Identity) *APIError { + if !p.acceptPunct("(") { + return nil + } + + for !p.acceptPunct(")") { + switch { + case p.acceptKw("START"): + v, e := p.optInt("WITH") + if e != nil { + return e + } + + id.Next = v + case p.acceptKw("INCREMENT"): + v, e := p.optInt("BY") + if e != nil { + return e + } + + if v == 0 { + return errParam("identity INCREMENT BY must not be zero") + } + + id.Increment = v + default: + return errParam("syntax error: unsupported identity option '" + p.peek().text + "'") + } + } + + return nil +} + +func (p *parser) optInt(kw string) (int64, *APIError) { + if e := p.expectKw(kw); e != nil { + return 0, e + } + + neg := p.acceptPunct("-") + + v, err := strconv.ParseInt(p.next().text, 10, 64) + if err != nil { + return 0, errParam("syntax error: expected an integer") + } + + if neg { + v = -v + } + + return v, nil +} + +func parseAlter(sql string) (*alterSpec, *APIError) { + p, err := newParser(sql) + if err != nil { + return nil, err + } + + if !p.acceptKw("ALTER") || !p.acceptKw("TABLE") { + return nil, errParam("ddlStatement must be an ALTER TABLE statement") + } + + name, e := p.ident("a table name") + if e != nil { + return nil, e + } + + spec := &alterSpec{name: name} + + if p.isPunct("(") { + if e = p.parseAlterActions(spec); e != nil { + return nil, e + } + } + + if spec.ttlHours, e = p.parseTTL(); e != nil { + return nil, e + } + + if !p.atEOF() { + return nil, p.fail("end of statement") + } + + if len(spec.add) == 0 && len(spec.drop) == 0 && spec.ttlHours == nil { + return nil, errParam("ALTER TABLE must add a column, drop a column or set USING TTL") + } + + return spec, nil +} + +func (p *parser) parseAlterActions(spec *alterSpec) *APIError { + p.pos++ + + for { + switch { + case p.acceptKw("ADD"): + col, id, e := p.parseColumn() + if e != nil { + return e + } + + if id != nil { + if spec.identity != nil { + return errParam("a table can have only one identity column") + } + + spec.identity = id + } + + spec.add = append(spec.add, col) + case p.acceptKw("DROP"): + n, e := p.ident("a column name") + if e != nil { + return e + } + + spec.drop = append(spec.drop, n) + default: + return p.fail("ADD or DROP") + } + + if !p.acceptPunct(",") { + break + } + } + + return p.expectPunct(")") +} + +func validateSchema(s *createSpec) *APIError { + if e := validIdent("table", s.name); e != nil { + return e + } + + if len(s.cols) == 0 { + return errParam("a table needs at least one column") + } + + if len(s.cols) > maxColumns { + return errLimit("a table can have at most " + strconv.Itoa(maxColumns) + " columns") + } + + byName := map[string]*dtype{} + + for i := range s.cols { + c := &s.cols[i] + + t, e := parseTypeString(c.Type) + if e != nil { + return e + } + + if len(c.Default) > 0 { + if _, msg := coerceDefault(t, c.Default); msg != "" { + return errParam("default of column '" + c.Name + "' " + msg) + } + } + + if _, dup := byName[strs.Fold(c.Name)]; dup { + return errParam("duplicate column '" + c.Name + "'") + } + + byName[strs.Fold(c.Name)] = t + } + + if len(s.pk) == 0 { + return errParam("a primary key is required: add PRIMARY KEY(...)") + } + + return validateKey(s, byName) +} + +func validateKey(s *createSpec, byName map[string]*dtype) *APIError { + seen := map[string]bool{} + + for i, n := range s.pk { + t, ok := byName[strs.Fold(n)] + if !ok { + return errParam("primary key column '" + n + "' is not a column of the table") + } + + if !t.keyable() { + return errParam("primary key column '" + n + "' has type " + t.kind + " which cannot be part of a key") + } + + if seen[strs.Fold(n)] { + return errParam("duplicate primary key column '" + n + "'") + } + + seen[strs.Fold(n)] = true + s.pk[i] = canonicalName(s.cols, n) + + for j := range s.cols { + if strs.Equal(s.cols[j].Name, n) { + s.cols[j].NotNull = true + } + } + } + + if len(s.shard) > 0 && !strs.Equal(strings.Join(s.shard, ","), strings.Join(s.pk[:len(s.shard)], ",")) { + return errParam("SHARD columns must be the leading columns of the primary key") + } + + return nil +} + +func canonicalName(cols []Column, n string) string { + for _, c := range cols { + if strs.Equal(c.Name, n) { + return c.Name + } + } + + return n +} diff --git a/services/ocinosql/errors.go b/services/ocinosql/errors.go new file mode 100644 index 0000000000..aa6129d994 --- /dev/null +++ b/services/ocinosql/errors.go @@ -0,0 +1,47 @@ +package ocinosql + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocinosql: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errLimit(msg string) *APIError { return newErr(http.StatusBadRequest, "LimitExceeded", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } + +func errConflict(msg string) *APIError { return newErr(http.StatusConflict, "Conflict", msg) } diff --git a/services/ocinosql/handler.go b/services/ocinosql/handler.go new file mode 100644 index 0000000000..4f9a1016e1 --- /dev/null +++ b/services/ocinosql/handler.go @@ -0,0 +1,368 @@ +package ocinosql + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Minute + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI NoSQL Database: tables, indexes, rows, queries and work requests. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocinosql.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCINoSQL" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCINoSQL runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"tableNameOrId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocinosql: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) flag(k string) bool { return q.query(k) == "true" } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + dec := json.NewDecoder(bytes.NewReader(q.body)) + dec.UseNumber() + + if err := dec.Decode(v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocinosql: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCINoSQL requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocinosql: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCINoSQL", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ocinosql", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocinosql: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocinosql: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocinosql: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocinosql: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() +} diff --git a/services/ocinosql/helpers_test.go b/services/ocinosql/helpers_test.go new file mode 100644 index 0000000000..f089c6753a --- /dev/null +++ b/services/ocinosql/helpers_test.go @@ -0,0 +1,242 @@ +package ocinosql_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/nosql" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + ocinosql "github.com/blackbirdworks/gopherstack/services/ocinosql" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" + otherCompart = "ocid1.compartment.oc1..other" +) + +func usersDDL(name string) string { + return "CREATE TABLE " + name + " (id INTEGER, name STRING, age INTEGER, active BOOLEAN, info JSON, " + + "PRIMARY KEY(id))" +} + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UTC().Truncate(time.Second).UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *ocinosql.InMemoryBackend + handler *ocinosql.Handler + clock *fakeClock + prov common.ConfigurationProvider + kp keyPair + client nosql.NosqlClient + base string + port int +} + +type envOpts struct { + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := ocinosql.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + h := ocinosql.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{ + backend: be, handler: h, clock: clock, kp: kp, port: h.Port, + prov: common.NewRawConfigurationProvider(testTenancy, testUser, ocinosql.Region, kp.fprint, kp.privPEM, nil), + base: fmt.Sprintf("http://localhost:%d", h.Port), + } + + var err error + + e.client, err = nosql.NewNosqlClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.client.Host = e.base + + return e +} + +// settle advances past the CREATING/UPDATING/DELETING transition. +func (e *env) settle() { e.clock.Advance(5 * time.Second) } + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string, http.Header) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out), resp.Header +} + +func provisioned(r, w, s int) *nosql.TableLimits { + return &nosql.TableLimits{ + MaxReadUnits: &r, MaxWriteUnits: &w, MaxStorageInGBs: &s, + CapacityMode: nosql.TableLimitsCapacityModeProvisioned, + } +} + +func (e *env) createTable(t *testing.T, name, ddl string, limits *nosql.TableLimits) nosql.CreateTableResponse { + t.Helper() + + if limits == nil { + limits = provisioned(50, 50, 10) + } + + resp, err := e.client.CreateTable(t.Context(), nosql.CreateTableRequest{ + Name: new(name), CompartmentId: new(testCompartment), DdlStatement: new(ddl), TableLimits: limits, + }) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + + return resp +} + +// mkTable creates a table and settles it to ACTIVE. +func (e *env) mkTable(t *testing.T, name, ddl string) string { + t.Helper() + + e.createTable(t, name, ddl, nil) + e.settle() + + got := e.getTable(t, name) + require.Equal(t, nosql.TableLifecycleStateActive, got.LifecycleState) + + return *got.Id +} + +func (e *env) getTable(t *testing.T, ref string) nosql.Table { + t.Helper() + + resp, err := e.client.GetTable(t.Context(), nosql.GetTableRequest{ + TableNameOrId: new(ref), CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + + return resp.Table +} + +func (e *env) put(t *testing.T, table string, value map[string]any) nosql.UpdateRowResult { + t.Helper() + + resp, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new(table), + Value: value, CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + + return resp.UpdateRowResult +} + +func (e *env) query(t *testing.T, stmt string, limit int, page *string) nosql.QueryResponse { + t.Helper() + + req := nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(stmt), + Page: page, + } + if limit > 0 { + req.Limit = new(limit) + } + + resp, err := e.client.Query(t.Context(), req) + require.NoError(t, err) + + return resp +} + +func (e *env) workRequest(t *testing.T, id *string) nosql.WorkRequest { + t.Helper() + + resp, err := e.client.GetWorkRequest(t.Context(), nosql.GetWorkRequestRequest{WorkRequestId: id}) + require.NoError(t, err) + + return resp.WorkRequest +} diff --git a/services/ocinosql/index_test.go b/services/ocinosql/index_test.go new file mode 100644 index 0000000000..fc86222eab --- /dev/null +++ b/services/ocinosql/index_test.go @@ -0,0 +1,191 @@ +package ocinosql_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/nosql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (e *env) createIndex(t *testing.T, table, name string, keys ...nosql.IndexKey) nosql.CreateIndexResponse { + t.Helper() + + resp, err := e.client.CreateIndex(t.Context(), nosql.CreateIndexRequest{ + TableNameOrId: new(table), + Name: new(name), Keys: keys, CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + + return resp +} + +func TestIndexLifecycleAndOrdering(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + seedUsers(t, e) + + orderQ := "SELECT id FROM users ORDER BY age" + + _, err := e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(orderQ)}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "no index yet") + + created := e.createIndex(t, "users", "idx_age", nosql.IndexKey{ColumnName: new("age")}) + e.createIndex(t, "users", "idx_score", nosql.IndexKey{ + ColumnName: new("info"), JsonPath: new("score"), JsonFieldType: new("integer"), + }) + + got, err := e.client.GetIndex(t.Context(), nosql.GetIndexRequest{ + TableNameOrId: new("users"), IndexName: new("idx_age"), CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + assert.Equal(t, nosql.IndexLifecycleStateCreating, got.LifecycleState) + assert.Equal(t, "users", *got.TableName) + + _, err = e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(orderQ)}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "index still CREATING") + + e.settle() + + assert.Equal(t, nosql.WorkRequestStatusSucceeded, e.workRequest(t, created.OpcWorkRequestId).Status) + + asc := e.query(t, orderQ, 0, nil) + assert.Equal(t, []int{4, 5, 2, 1, 6, 3}, ids(asc.Items), "missing values sort first, ties break on primary key") + + desc := e.query(t, "SELECT id FROM users ORDER BY age DESC", 0, nil) + assert.Equal(t, []int{3, 6, 1, 2, 5, 4}, ids(desc.Items)) + + byScore := e.query(t, "SELECT id FROM users ORDER BY info.score DESC LIMIT 3", 0, nil) + assert.Equal(t, []int{2, 5, 1}, ids(byScore.Items)) + + _, err = e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new("SELECT id FROM users ORDER BY age ASC, id DESC")}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "mixed directions") + + list, err := e.client.ListIndexes(t.Context(), nosql.ListIndexesRequest{ + TableNameOrId: new("users"), CompartmentId: new(testCompartment), + SortBy: nosql.ListIndexesSortByName, SortOrder: nosql.ListIndexesSortOrderAsc, + }) + require.NoError(t, err) + require.Len(t, list.Items, 2) + assert.Equal(t, "idx_age", *list.Items[0].Name) + assert.Equal(t, nosql.IndexLifecycleStateActive, list.Items[0].LifecycleState) + assert.Equal(t, "score", *list.Items[1].Keys[0].JsonPath) + assert.Equal(t, "INTEGER", *list.Items[1].Keys[0].JsonFieldType) + + del, err := e.client.DeleteIndex(t.Context(), nosql.DeleteIndexRequest{ + TableNameOrId: new("users"), IndexName: new("idx_age"), CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + + g2, err := e.client.GetIndex(t.Context(), nosql.GetIndexRequest{ + TableNameOrId: new("users"), IndexName: new("idx_age"), CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + assert.Equal(t, nosql.IndexLifecycleStateDeleting, g2.LifecycleState) + + e.settle() + assert.Equal(t, nosql.WorkRequestStatusSucceeded, e.workRequest(t, del.OpcWorkRequestId).Status) + + _, err = e.client.GetIndex(t.Context(), nosql.GetIndexRequest{ + TableNameOrId: new("users"), IndexName: new("idx_age"), CompartmentId: new(testCompartment), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(orderQ)}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "index dropped") +} + +func TestIndexErrors(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable( + t, + "t", + "CREATE TABLE t (id INTEGER, name STRING, j JSON, arr ARRAY(STRING), bin BINARY, PRIMARY KEY(id))", + ) + e.createIndex(t, "t", "idx_name", nosql.IndexKey{ColumnName: new("name")}) + e.settle() + + tests := []struct { + name string + idx string + keys []nosql.IndexKey + status int + }{ + {name: "duplicate", idx: "IDX_NAME", keys: []nosql.IndexKey{{ColumnName: new("name")}}, status: 409}, + {name: "no keys", idx: "i1", keys: []nosql.IndexKey{}, status: 400}, + {name: "unknown column", idx: "i2", keys: []nosql.IndexKey{{ColumnName: new("zzz")}}, status: 400}, + {name: "json without path", idx: "i3", keys: []nosql.IndexKey{{ColumnName: new("j")}}, status: 400}, + { + name: "json bad type", + idx: "i4", + keys: []nosql.IndexKey{{ColumnName: new("j"), JsonPath: new("a"), JsonFieldType: new("blob")}}, + status: 400, + }, + { + name: "path on plain column", + idx: "i5", + keys: []nosql.IndexKey{{ColumnName: new("name"), JsonPath: new("a"), JsonFieldType: new("string")}}, + status: 400, + }, + {name: "array column", idx: "i6", keys: []nosql.IndexKey{{ColumnName: new("arr")}}, status: 400}, + {name: "binary column", idx: "i7", keys: []nosql.IndexKey{{ColumnName: new("bin")}}, status: 400}, + { + name: "repeated key", + idx: "i8", + keys: []nosql.IndexKey{{ColumnName: new("name")}, {ColumnName: new("NAME")}}, + status: 400, + }, + {name: "bad index name", idx: "9x", keys: []nosql.IndexKey{{ColumnName: new("name")}}, status: 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.client.CreateIndex(t.Context(), nosql.CreateIndexRequest{ + TableNameOrId: new("t"), + Name: new(tt.idx), Keys: tt.keys, CompartmentId: new(testCompartment), + }) + assert.Equal(t, tt.status, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + resp, err := e.client.CreateIndex(t.Context(), nosql.CreateIndexRequest{ + TableNameOrId: new("t"), + Name: new("idx_name"), Keys: []nosql.IndexKey{{ColumnName: new("name")}}, + CompartmentId: new(testCompartment), IsIfNotExists: new(true), + }) + require.NoError(t, err) + assert.NotNil(t, resp.OpcWorkRequestId) + + _, err = e.client.UpdateTable(t.Context(), nosql.UpdateTableRequest{ + TableNameOrId: new("t"), + CompartmentId: new(testCompartment), DdlStatement: new("ALTER TABLE t (DROP name)"), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "indexed column cannot be dropped") + + _, err = e.client.DeleteIndex(t.Context(), nosql.DeleteIndexRequest{ + TableNameOrId: new("t"), IndexName: new("ghost"), CompartmentId: new(testCompartment), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + noop, err := e.client.DeleteIndex(t.Context(), nosql.DeleteIndexRequest{ + TableNameOrId: new("t"), IndexName: new("ghost"), CompartmentId: new(testCompartment), IsIfExists: new(true), + }) + require.NoError(t, err) + assert.Nil(t, noop.OpcWorkRequestId) + + _, err = e.client.ListIndexes( + t.Context(), + nosql.ListIndexesRequest{TableNameOrId: new("nope"), CompartmentId: new(testCompartment)}, + ) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} diff --git a/services/ocinosql/indexes.go b/services/ocinosql/indexes.go new file mode 100644 index 0000000000..1a64c335bf --- /dev/null +++ b/services/ocinosql/indexes.go @@ -0,0 +1,268 @@ +package ocinosql + +import ( + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +type indexKeyIn struct { + ColumnName string `json:"columnName"` + JSONPath string `json:"jsonPath"` + JSONFieldType string `json:"jsonFieldType"` +} + +type createIndexIn struct { + IfNotExists *bool `json:"isIfNotExists"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + Keys []indexKeyIn `json:"keys"` +} + +//nolint:gochecknoglobals // immutable lookup +var indexJSONTypes = []string{kInteger, kLong, kDouble, kNumber, kString, kBoolean, kTimestamp} + +func buildIndexKeys(t *Table, in []indexKeyIn) ([]IndexKey, *APIError) { + if len(in) == 0 { + return nil, errMissing("keys must contain at least one index key") + } + + sv := newSchemaView(t.Columns) + keys := make([]IndexKey, 0, len(in)) + + for _, k := range in { + i, ok := sv.lookup(k.ColumnName) + if !ok { + return nil, errParam("index key column '" + k.ColumnName + "' is not a column of table " + t.Name) + } + + key := IndexKey{Column: sv.cols[i].Name, JSONPath: k.JSONPath, JSONType: strings.ToUpper(k.JSONFieldType)} + if aerr := checkIndexKey(sv.types[i], key); aerr != nil { + return nil, aerr + } + + if slices.ContainsFunc(keys, func(o IndexKey) bool { + return o.Column == key.Column && o.JSONPath == key.JSONPath + }) { + return nil, errParam("duplicate index key '" + k.ColumnName + "'") + } + + keys = append(keys, key) + } + + return keys, nil +} + +func checkIndexKey(ty *dtype, k IndexKey) *APIError { + switch ty.kind { + case kJSON: + if k.JSONPath == "" || k.JSONType == "" { + return errParam("jsonPath and jsonFieldType are required for JSON column '" + k.Column + "'") + } + + if !slices.Contains(indexJSONTypes, k.JSONType) { + return errParam("jsonFieldType '" + k.JSONType + "' is not a supported index type") + } + case kArray, kMap, kRecord, kBinary: + return errParam("column '" + k.Column + "' of type " + ty.kind + " cannot be indexed") + default: + if k.JSONPath != "" || k.JSONType != "" { + return errParam("jsonPath and jsonFieldType are only valid for JSON columns") + } + } + + return nil +} + +// CreateIndex adds a secondary index to a table and returns its work request id. +func (b *InMemoryBackend) CreateIndex(ref string, in createIndexIn) (string, error) { + b.mu.Lock("CreateIndex") + defer b.mu.Unlock() + + t, aerr := b.resolve(ref, in.CompartmentID) + if aerr != nil { + return "", aerr + } + + if aerr = b.modifiable(t); aerr != nil { + return "", aerr + } + + if aerr = validIdent("index", in.Name); aerr != nil { + return "", aerr + } + + if len(t.Indexes) >= maxIndexes { + return "", errLimit("a table can have at most " + strconv.Itoa(maxIndexes) + " indexes") + } + + keys, aerr := buildIndexKeys(t, in.Keys) + if aerr != nil { + return "", aerr + } + + if _, exists := t.Indexes[strs.Fold(in.Name)]; exists { + if in.IfNotExists != nil && *in.IfNotExists { + return b.newWR(wrUpdate, t.CompartmentID, t.ID, "UPDATED", b.nowUTC()), nil + } + + return "", errConflict("Index " + in.Name + " already exists on table " + t.Name) + } + + at := b.transitionAt() + t.Indexes[strs.Fold(in.Name)] = &Index{ + Name: in.Name, State: stateCreating, Keys: keys, TransitionAt: at, Created: b.nowUTC(), + } + t.Etag, t.TimeUpdated = newEtag(), b.nowUTC() + + return b.newWR(wrUpdate, t.CompartmentID, t.ID, "UPDATED", *at), nil +} + +// DeleteIndex removes an index; ifExists turns a missing index into a no-op (empty work request id). +func (b *InMemoryBackend) DeleteIndex(ref, name, compartment string, ifExists bool) (string, error) { + b.mu.Lock("DeleteIndex") + defer b.mu.Unlock() + + t, aerr := b.resolve(ref, compartment) + if aerr != nil { + return "", aerr + } + + ix, ok := t.Indexes[strs.Fold(name)] + if !ok { + if ifExists { + return "", nil + } + + return "", errNotFound("index " + name + " of table " + t.Name) + } + + if ix.State != stateActive { + return "", errState("Index " + ix.Name + " is in state " + ix.State + "; it must be ACTIVE") + } + + ix.State, ix.TransitionAt = stateDeleting, b.transitionAt() + t.Etag, t.TimeUpdated = newEtag(), b.nowUTC() + + return b.newWR(wrUpdate, t.CompartmentID, t.ID, "UPDATED", *ix.TransitionAt), nil +} + +type indexKeyWire struct { + ColumnName string `json:"columnName"` + JSONPath string `json:"jsonPath,omitempty"` + JSONFieldType string `json:"jsonFieldType,omitempty"` +} + +// IndexWire is the full index representation. +type IndexWire struct { + Name string `json:"name"` + CompartmentID string `json:"compartmentId,omitempty"` + TableName string `json:"tableName,omitempty"` + TableID string `json:"tableId,omitempty"` + LifecycleState string `json:"lifecycleState"` + Keys []indexKeyWire `json:"keys"` +} + +func indexView(t *Table, ix *Index, full bool) IndexWire { + keys := make([]indexKeyWire, len(ix.Keys)) + for i, k := range ix.Keys { + keys[i] = indexKeyWire{ColumnName: k.Column, JSONPath: k.JSONPath, JSONFieldType: k.JSONType} + } + + w := IndexWire{Name: ix.Name, LifecycleState: ix.State, Keys: keys} + if full { + w.CompartmentID, w.TableName, w.TableID = t.CompartmentID, t.Name, t.ID + } + + return w +} + +// GetIndex returns one index. +func (b *InMemoryBackend) GetIndex(ref, name, compartment string) (IndexWire, error) { + b.mu.RLock("GetIndex") + defer b.mu.RUnlock() + + t, aerr := b.resolve(ref, compartment) + if aerr != nil { + return IndexWire{}, aerr + } + + ix, ok := t.Indexes[strs.Fold(name)] + if !ok { + return IndexWire{}, errNotFound("index " + name + " of table " + t.Name) + } + + return indexView(t, ix, true), nil +} + +// ListIndexes lists the indexes of a table. +func (b *InMemoryBackend) ListIndexes(ref, compartment, name, state string, o ListOpts) (page.Page[IndexWire], error) { + b.mu.RLock("ListIndexes") + defer b.mu.RUnlock() + + t, aerr := b.resolve(ref, compartment) + if aerr != nil { + return page.Page[IndexWire]{}, aerr + } + + var all []*Index + + for _, ix := range t.Indexes { + if (name != "" && !strs.Equal(ix.Name, name)) || (state != "" && state != filterAll && ix.State != state) { + continue + } + + all = append(all, ix) + } + + pg := paginate(all, o, func(i *Index) string { return i.Name }, + func(i *Index) (time.Time, int64) { return i.Created, 0 }) + + out := make([]IndexWire, 0, len(pg.Data)) + for _, ix := range pg.Data { + out = append(out, indexView(t, ix, false)) + } + + return page.Page[IndexWire]{Data: out, Next: pg.Next}, nil +} + +func (h *Handler) createIndex(q *req) error { + var in createIndexIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateIndex(q.param("tableNameOrId"), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteIndex(q *req) error { + wr, err := h.Backend.DeleteIndex(q.param("tableNameOrId"), q.param("indexName"), q.query("compartmentId"), + q.flag("isIfExists")) + if err == nil && wr == "" { + q.w.WriteHeader(http.StatusNoContent) + + return nil + } + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getIndex(q *req) error { + w, err := h.Backend.GetIndex(q.param("tableNameOrId"), q.param("indexName"), q.query("compartmentId")) + + return reply(q, w, "", err) +} + +func (h *Handler) listIndexes(q *req) error { + pg, err := h.Backend.ListIndexes(q.param("tableNameOrId"), q.query("compartmentId"), q.query("name"), + q.query("lifecycleState"), q.listOpts()) + + return collectionReply(q, pg, err) +} diff --git a/services/ocinosql/inproc.go b/services/ocinosql/inproc.go new file mode 100644 index 0000000000..c6940fa237 --- /dev/null +++ b/services/ocinosql/inproc.go @@ -0,0 +1,26 @@ +package ocinosql + +import "slices" + +// Compartments lists the distinct compartments that hold tables. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, t := range b.tables { + if t.State != stateDeleted { + seen[t.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} diff --git a/services/ocinosql/leak_main_test.go b/services/ocinosql/leak_main_test.go new file mode 100644 index 0000000000..87f975a8db --- /dev/null +++ b/services/ocinosql/leak_main_test.go @@ -0,0 +1,18 @@ +package ocinosql_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ocinosql/lexer.go b/services/ocinosql/lexer.go new file mode 100644 index 0000000000..b389bcba36 --- /dev/null +++ b/services/ocinosql/lexer.go @@ -0,0 +1,270 @@ +package ocinosql + +import ( + "strings" + "unicode" +) + +const twoRunes = 2 + +type tokKind int + +const ( + tkEOF tokKind = iota + tkIdent + tkNumber + tkString + tkVar + tkPunct +) + +type tok struct { + text string + up string + kind tokKind +} + +// lex splits a NoSQL SQL or DDL statement into tokens. +func lex(s string) ([]tok, *APIError) { + var out []tok + + rs := []rune(s) + + for i := 0; i < len(rs); { + t, n, err := lexOne(rs, i) + if err != nil { + return nil, err + } + + if t != nil { + out = append(out, *t) + } + + i = n + } + + return append(out, tok{kind: tkEOF}), nil +} + +func lexOne(rs []rune, i int) (*tok, int, *APIError) { + c := rs[i] + + switch { + case unicode.IsSpace(c): + return nil, i + 1, nil + case c == '\'': + text, n, ok := lexString(rs, i) + if !ok { + return nil, 0, errParam("syntax error: unterminated string literal") + } + + return &tok{kind: tkString, text: text}, n, nil + case c == '"': + end := indexRune(rs, i+1, '"') + if end < 0 { + return nil, 0, errParam("syntax error: unterminated quoted identifier") + } + + t := string(rs[i+1 : end]) + + return &tok{kind: tkIdent, text: t, up: strings.ToUpper(t)}, end + 1, nil + case unicode.IsDigit(c): + j := lexNumberEnd(rs, i) + + return &tok{kind: tkNumber, text: string(rs[i:j])}, j, nil + case c == '$' || c == '_' || unicode.IsLetter(c): + return lexWord(rs, i) + } + + t, n, ok := lexPunct(rs, i) + if !ok { + return nil, 0, errParam("syntax error: unexpected character '" + string(c) + "'") + } + + return &tok{kind: tkPunct, text: t, up: t}, n, nil +} + +func lexWord(rs []rune, i int) (*tok, int, *APIError) { + j := i + 1 + for j < len(rs) && (unicode.IsLetter(rs[j]) || unicode.IsDigit(rs[j]) || rs[j] == '_') { + j++ + } + + t := string(rs[i:j]) + + kind := tkIdent + if rs[i] == '$' { + kind = tkVar + } + + return &tok{kind: kind, text: t, up: strings.ToUpper(t)}, j, nil +} + +func lexNumberEnd(rs []rune, i int) int { + j := i + for j < len(rs) { + c := rs[j] + signAfterExp := (c == '-' || c == '+') && (rs[j-1] == 'e' || rs[j-1] == 'E') + + if !unicode.IsDigit(c) && c != '.' && c != 'e' && c != 'E' && !signAfterExp { + break + } + + j++ + } + + return j +} + +func indexRune(rs []rune, from int, r rune) int { + for i := from; i < len(rs); i++ { + if rs[i] == r { + return i + } + } + + return -1 +} + +func lexString(rs []rune, start int) (string, int, bool) { + var sb strings.Builder + + for i := start + 1; i < len(rs); i++ { + if rs[i] != '\'' { + sb.WriteRune(rs[i]) + + continue + } + + if i+1 < len(rs) && rs[i+1] == '\'' { + sb.WriteRune('\'') + + i++ + + continue + } + + return sb.String(), i + 1, true + } + + return "", 0, false +} + +func lexPunct(rs []rune, i int) (string, int, bool) { + if i+1 < len(rs) { + two := string(rs[i : i+twoRunes]) + switch two { + case "<=", ">=", "<>", "!=": + return two, i + twoRunes, true + } + } + + if strings.ContainsRune("(),.;=<>*[]:+-", rs[i]) { + return string(rs[i]), i + 1, true + } + + return "", 0, false +} + +type parser struct { + toks []tok + pos int +} + +func newParser(sql string) (*parser, *APIError) { + toks, err := lex(sql) + if err != nil { + return nil, err + } + + return &parser{toks: toks}, nil +} + +func (p *parser) peek() tok { return p.toks[p.pos] } + +func (p *parser) next() tok { + t := p.toks[p.pos] + if t.kind != tkEOF { + p.pos++ + } + + return t +} + +func (p *parser) atEOF() bool { + for p.peek().up == ";" { + p.pos++ + } + + return p.peek().kind == tkEOF +} + +func (p *parser) isKw(kw string) bool { + t := p.peek() + + return t.kind == tkIdent && t.up == kw +} + +func (p *parser) isPunct(s string) bool { + t := p.peek() + + return t.kind == tkPunct && t.text == s +} + +func (p *parser) acceptKw(kw string) bool { + if p.isKw(kw) { + p.pos++ + + return true + } + + return false +} + +func (p *parser) acceptPunct(s string) bool { + if p.isPunct(s) { + p.pos++ + + return true + } + + return false +} + +func (p *parser) fail(want string) *APIError { + t := p.peek() + got := "end of statement" + + if t.kind != tkEOF { + got = "'" + t.text + "'" + } + + return errParam("syntax error: expected " + want + " but found " + got) +} + +func (p *parser) expectKw(kw string) *APIError { + if !p.acceptKw(kw) { + return p.fail(kw) + } + + return nil +} + +func (p *parser) expectPunct(s string) *APIError { + if !p.acceptPunct(s) { + return p.fail("'" + s + "'") + } + + return nil +} + +func (p *parser) ident(what string) (string, *APIError) { + t := p.peek() + if t.kind != tkIdent { + return "", p.fail(what) + } + + p.pos++ + + return t.text, nil +} diff --git a/services/ocinosql/limits.go b/services/ocinosql/limits.go new file mode 100644 index 0000000000..5ba7ab51f7 --- /dev/null +++ b/services/ocinosql/limits.go @@ -0,0 +1,57 @@ +package ocinosql + +import ( + "encoding/json" + "strconv" +) + +// Service limits from Oracle's "Oracle NoSQL Database Cloud Service Limits" +// (docs.oracle.com/en/cloud/paas/nosql-cloud/fkdyw/). +const ( + maxIndexes = 5 + maxKeyBytes = 64 + maxRowBytes = 512 * 1024 + maxQueryLen = 10 * 1024 +) + +func valueBytes(v any) int { + switch x := v.(type) { + case string: + return len(x) + case json.Number: + return len(x.String()) + case map[string]any: + n := 0 + for _, e := range x { + n += valueBytes(e) + } + + return n + case []any: + n := 0 + for _, e := range x { + n += valueBytes(e) + } + + return n + } + + return 0 +} + +func checkRowSize(pk []string, row map[string]any) *APIError { + keyBytes := 0 + for _, c := range pk { + keyBytes += len(keyPart(row[c])) + } + + if keyBytes > maxKeyBytes { + return errLimit("the primary key exceeds " + strconv.Itoa(maxKeyBytes) + " bytes") + } + + if valueBytes(row) > maxRowBytes { + return errLimit("the row exceeds " + strconv.Itoa(maxRowBytes) + " bytes") + } + + return nil +} diff --git a/services/ocinosql/limits_test.go b/services/ocinosql/limits_test.go new file mode 100644 index 0000000000..def0450651 --- /dev/null +++ b/services/ocinosql/limits_test.go @@ -0,0 +1,166 @@ +package ocinosql_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/nosql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func wideDDL(table string, cols int) string { + parts := []string{"id INTEGER"} + for i := 1; i < cols; i++ { + parts = append(parts, fmt.Sprintf("c%d STRING", i)) + } + + return "CREATE TABLE " + table + " (" + strings.Join(parts, ", ") + ", PRIMARY KEY(id))" +} + +func TestServiceLimitsOnCreateTable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ddl string + limits *nosql.TableLimits + wantCode string + wantOK bool + }{ + {name: "write 20000", ddl: usersDDL("t1"), limits: provisioned(40000, 20000, 10), wantOK: true}, + {name: "write 20001", ddl: usersDDL("t2"), limits: provisioned(100, 20001, 10), wantCode: "InvalidParameter"}, + {name: "read 40001", ddl: usersDDL("t3"), limits: provisioned(40001, 100, 10), wantCode: "InvalidParameter"}, + {name: "50 columns", ddl: wideDDL("t4", 50), limits: provisioned(10, 10, 1), wantOK: true}, + {name: "51 columns", ddl: wideDDL("t5", 51), limits: provisioned(10, 10, 1), wantCode: "LimitExceeded"}, + { + name: "column name 64", + ddl: "CREATE TABLE t6 (id INTEGER, " + strings.Repeat("a", 64) + " STRING, PRIMARY KEY(id))", + limits: provisioned(10, 10, 1), wantOK: true, + }, + { + name: "column name 65", + ddl: "CREATE TABLE t7 (id INTEGER, " + strings.Repeat("a", 65) + " STRING, PRIMARY KEY(id))", + limits: provisioned(10, 10, 1), + wantCode: "InvalidParameter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + name := strings.Fields(tt.ddl)[2] + + _, err := e.client.CreateTable(t.Context(), nosql.CreateTableRequest{ + Name: &name, CompartmentId: new(testCompartment), DdlStatement: new(tt.ddl), TableLimits: tt.limits, + }) + if tt.wantOK { + require.NoError(t, err) + + return + } + + se := svcErr(t, err) + assert.Equal(t, tt.wantCode, se.GetCode()) + assert.NotEqual(t, http.StatusOK, se.GetHTTPStatusCode()) + }) + } +} + +func TestIndexLimits(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "idxlim", wideDDL("idxlim", 8)) + + _, err := e.client.CreateIndex(t.Context(), nosql.CreateIndexRequest{ + TableNameOrId: new("idxlim"), CompartmentId: new(testCompartment), + Name: new(strings.Repeat("i", 65)), Keys: []nosql.IndexKey{{ColumnName: new("c1")}}, + }) + assert.Equal(t, "InvalidParameter", svcErr(t, err).GetCode(), "index name over 64 characters") + + for i := 1; i <= 5; i++ { + _, err = e.client.CreateIndex(t.Context(), nosql.CreateIndexRequest{ + TableNameOrId: new("idxlim"), CompartmentId: new(testCompartment), + Name: new(fmt.Sprintf("ix%d", i)), Keys: []nosql.IndexKey{{ColumnName: new(fmt.Sprintf("c%d", i))}}, + }) + require.NoError(t, err) + } + + _, err = e.client.CreateIndex(t.Context(), nosql.CreateIndexRequest{ + TableNameOrId: new("idxlim"), CompartmentId: new(testCompartment), + Name: new("ix6"), Keys: []nosql.IndexKey{{ColumnName: new("c6")}}, + }) + assert.Equal(t, "LimitExceeded", svcErr(t, err).GetCode(), "sixth index") +} + +func TestRowQueryAndKeySizeLimits(t *testing.T) { + t.Parallel() + + const rowLimit = 512 * 1024 + + tests := []struct { + name string + sk int + pk int + data int + wantOK bool + }{ + {name: "key 64", sk: 64, pk: 0, data: 100, wantOK: true}, + {name: "key 62 plus 2", sk: 62, pk: 2, data: 100, wantOK: true}, + {name: "key 66", sk: 64, pk: 2, data: 0}, + {name: "row at limit", sk: 1, pk: 63, data: rowLimit - 64, wantOK: true}, + {name: "row over limit", sk: 2, pk: 2, data: rowLimit}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "sized", "CREATE TABLE sized (sk STRING, pk STRING, data STRING, PRIMARY KEY(SHARD(sk), pk))") + + _, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("sized"), CompartmentId: new(testCompartment), + Value: map[string]any{ + "sk": strings.Repeat("s", tt.sk), "pk": strings.Repeat("p", tt.pk), + "data": strings.Repeat("d", tt.data), + }, + }) + if tt.wantOK { + require.NoError(t, err) + + return + } + + se := svcErr(t, err) + assert.Equal(t, "LimitExceeded", se.GetCode()) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + }) + } +} + +func TestQueryStringLimit(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "qlim", "CREATE TABLE qlim (id INTEGER, PRIMARY KEY(id))") + + long := "SELECT id FROM qlim WHERE id IN (" + strings.Repeat("1, ", 4000) + "1)" + require.Greater(t, len(long), 10*1024) + + _, err := e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(long), + }) + assert.Equal(t, "LimitExceeded", svcErr(t, err).GetCode()) + + short := "SELECT id FROM qlim WHERE id IN (" + strings.Repeat("1, ", 100) + "1)" + _, err = e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(short), + }) + require.NoError(t, err) +} diff --git a/services/ocinosql/misc_test.go b/services/ocinosql/misc_test.go new file mode 100644 index 0000000000..922aba490f --- /dev/null +++ b/services/ocinosql/misc_test.go @@ -0,0 +1,310 @@ +package ocinosql_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/nosql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/persistence" + ocinosql "github.com/blackbirdworks/gopherstack/services/ocinosql" +) + +func TestWorkRequests(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + created := e.createTable(t, "t", usersDDL("t"), nil) + + wr := e.workRequest(t, created.OpcWorkRequestId) + require.Len(t, wr.Resources, 1) + assert.Equal(t, "table", *wr.Resources[0].EntityType) + assert.Equal(t, nosql.WorkRequestResourceActionTypeInProgress, wr.Resources[0].ActionType) + assert.InDelta(t, 50, *wr.PercentComplete, 0) + + e.settle() + + wr = e.workRequest(t, created.OpcWorkRequestId) + assert.Equal(t, nosql.WorkRequestStatusSucceeded, wr.Status) + assert.Equal(t, nosql.WorkRequestResourceActionTypeCreated, wr.Resources[0].ActionType) + assert.InDelta(t, 100, *wr.PercentComplete, 0) + assert.NotNil(t, wr.TimeFinished) + + logs, err := e.client.ListWorkRequestLogs( + t.Context(), + nosql.ListWorkRequestLogsRequest{WorkRequestId: created.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Len(t, logs.Items, 2) + + errs, err := e.client.ListWorkRequestErrors( + t.Context(), + nosql.ListWorkRequestErrorsRequest{WorkRequestId: created.OpcWorkRequestId}, + ) + require.NoError(t, err) + assert.Empty(t, errs.Items) + + list, err := e.client.ListWorkRequests( + t.Context(), + nosql.ListWorkRequestsRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Len(t, list.Items, 1) + + other, err := e.client.ListWorkRequests( + t.Context(), + nosql.ListWorkRequestsRequest{CompartmentId: new(otherCompart)}, + ) + require.NoError(t, err) + assert.Empty(t, other.Items) + + for _, fn := range []func() error{ + func() error { + _, gerr := e.client.GetWorkRequest(t.Context(), + nosql.GetWorkRequestRequest{WorkRequestId: new("ocid1.nosqlworkrequest.oc1..x")}) + + return gerr + }, + func() error { + _, gerr := e.client.ListWorkRequestLogs(t.Context(), nosql.ListWorkRequestLogsRequest{WorkRequestId: new("nope")}) + + return gerr + }, + func() error { + _, gerr := e.client.ListWorkRequestErrors(t.Context(), + nosql.ListWorkRequestErrorsRequest{WorkRequestId: new("nope")}) + + return gerr + }, + } { + assert.Equal(t, http.StatusNotFound, svcErr(t, fn()).GetHTTPStatusCode()) + } +} + +func TestErrorShapes(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + method string + path string + body string + code string + status int + }{ + {name: "no route", method: http.MethodGet, path: "/20190828/nothing", status: 404, code: "NotFound"}, + {name: "wrong method", method: http.MethodPatch, path: "/20190828/tables", status: 404, code: "NotFound"}, + { + name: "bad json", + method: http.MethodPost, + path: "/20190828/tables", + body: "{not json", + status: 400, + code: "CannotParseRequest", + }, + {name: "empty body", method: http.MethodPost, path: "/20190828/tables", status: 400, code: "MissingParameter"}, + { + name: "list needs compartment", + method: http.MethodGet, + path: "/20190828/tables", + status: 400, + code: "MissingParameter", + }, + { + name: "missing table", + method: http.MethodGet, + path: "/20190828/tables/ocid1.nosqltable.oc1..nope", + status: 404, + code: "NotAuthorizedOrNotFound", + }, + { + name: "row table missing", + method: http.MethodPut, + path: "/20190828/tables/x/rows", + body: `{"compartmentId":"c"}`, + status: 404, + code: "NotAuthorizedOrNotFound", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body, hdr := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.status, status) + assert.Contains(t, body, `"code":"`+tt.code+`"`) + assert.NotEmpty(t, hdr.Get("Opc-Request-Id")) + }) + } + + e.mkTable(t, "t", usersDDL("t")) + + status, body, _ := rawDo( + t, + http.MethodPut, + e.base+"/20190828/tables/t/rows", + `{"compartmentId":"`+testCompartment+`"}`, + ) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "MissingParameter") +} + +func TestAuthEnforced(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + + status, body, _ := rawDo(t, http.MethodGet, e.base+"/20190828/tables?compartmentId="+testCompartment, "") + assert.Equal(t, http.StatusUnauthorized, status, "unsigned request rejected") + assert.Contains(t, body, "NotAuthenticated") + + _, err := e.client.ListTables(t.Context(), nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err, "registered key is accepted") + + stranger := newKeyPair(t) + prov := common.NewRawConfigurationProvider( + testTenancy, + testUser, + ocinosql.Region, + stranger.fprint, + stranger.privPEM, + nil, + ) + c, err := nosql.NewNosqlClientWithConfigurationProvider(prov) + require.NoError(t, err) + + c.Host = e.base + + _, err = c.ListTables(t.Context(), nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + assert.Equal(t, http.StatusUnauthorized, svcErr(t, err).GetHTTPStatusCode(), "unregistered key rejected") +} + +func TestAdminKeys(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true}) + + status, _, _ := rawDo(t, http.MethodGet, e.base+"/20190828/tables?compartmentId=c", "") + assert.Equal(t, http.StatusUnauthorized, status) + + body := `{"tenancy":"` + testTenancy + `","user":"` + testUser + `","publicKey":` + quote(e.kp.pubPEM) + `}` + + status, out, _ := rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", body) + require.Equal(t, http.StatusCreated, status, out) + + _, err := e.client.ListTables(t.Context(), nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + + status, _, _ = rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", `{"tenancy":"x"}`) + assert.Equal(t, http.StatusBadRequest, status) +} + +func quote(s string) string { + out := []byte{'"'} + + for _, r := range s { + if r == '\n' { + out = append(out, '\\', 'n') + + continue + } + + out = append(out, string(r)...) + } + + return string(append(out, '"')) +} + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true}) + ctx := t.Context() + + seedUsers(t, e) + e.createIndex(t, "users", "idx_age", nosql.IndexKey{ColumnName: new("age")}) + e.mkTable( + t, + "ids", + "CREATE TABLE ids (id INTEGER GENERATED ALWAYS AS IDENTITY, v STRING, PRIMARY KEY(id)) USING TTL 2 DAYS", + ) + e.put(t, "ids", map[string]any{"v": "a"}) + e.settle() + + snap := e.backend.Snapshot(ctx) + require.NotEmpty(t, snap) + + restored := newEnv(t, envOpts{}) + require.NoError(t, restored.backend.Restore(ctx, snap)) + restored.clock.ns.Store(e.clock.ns.Load()) + + tbl := restored.getTable(t, "users") + assert.Equal(t, nosql.TableLifecycleStateActive, tbl.LifecycleState) + assert.Len(t, tbl.Schema.Columns, 5) + + got := restored.query(t, "SELECT id FROM users ORDER BY age DESC LIMIT 2", 0, nil) + assert.Equal(t, []int{3, 6}, ids(got.Items), "rows and the ACTIVE index survive") + + next := restored.put(t, "ids", map[string]any{"v": "b"}) + assert.Equal(t, "2", *next.GeneratedValue, "identity counter survives") + assert.Equal(t, 2, *restored.getTable(t, "ids").Schema.Ttl) + + list, err := restored.client.ListWorkRequests( + ctx, + nosql.ListWorkRequestsRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.NotEmpty(t, list.Items) + + require.Error(t, restored.backend.Restore(ctx, []byte("{"))) + require.Error(t, restored.backend.Restore(ctx, persistence.MarshalSnapshot(ctx, "ocinosql", + map[string]any{"version": 1, "tables": map[string]any{"x": nil}}))) + + restored.backend.Reset() + + empty, err := restored.client.ListTables(ctx, nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, empty.Items) +} + +func TestIdentityKeyLinking(t *testing.T) { + t.Parallel() + + a := newEnv(t, envOpts{enforce: true}) + b := newEnv(t, envOpts{enforce: true, register: true}) + + a.backend.Auth.Link(b.backend.Auth) + + c, err := nosql.NewNosqlClientWithConfigurationProvider(b.prov) + require.NoError(t, err) + + c.Host = a.base + + _, err = c.ListTables(t.Context(), nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err, "keys registered with the linked registry authenticate here") +} + +func TestCompartmentChecker(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.backend.SetCompartmentChecker(func(id string) bool { return id == testCompartment }) + + _, err := e.client.CreateTable(t.Context(), nosql.CreateTableRequest{ + Name: new( + "t", + ), + CompartmentId: new(otherCompart), + DdlStatement: new(usersDDL("t")), + TableLimits: provisioned(5, 5, 5), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + e.createTable(t, "t", usersDDL("t"), nil) +} diff --git a/services/ocinosql/models.go b/services/ocinosql/models.go new file mode 100644 index 0000000000..0c45a77e8d --- /dev/null +++ b/services/ocinosql/models.go @@ -0,0 +1,111 @@ +package ocinosql + +import ( + "encoding/json" + "time" +) + +// Lifecycle states. +const ( + stateActive = "ACTIVE" + stateCreating = "CREATING" + stateUpdating = "UPDATING" + stateDeleting = "DELETING" + stateDeleted = "DELETED" +) + +// Capacity modes. +const ( + modeProvisioned = "PROVISIONED" + modeOnDemand = "ON_DEMAND" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// Limits are a table's throughput and storage limits. +type Limits struct { + Mode string `json:"mode"` + ReadUnits int `json:"readUnits"` + WriteUnits int `json:"writeUnits"` + StorageGB int `json:"storageGb"` +} + +// Row is one stored table row. +type Row struct { + Expire *time.Time `json:"expire,omitempty"` + Version string `json:"version"` + Value json.RawMessage `json:"value"` +} + +// IndexKey is one key of a secondary index. +type IndexKey struct { + Column string `json:"column"` + JSONPath string `json:"jsonPath,omitempty"` + JSONType string `json:"jsonType,omitempty"` +} + +// Index is a secondary index on a table. +type Index struct { + Created time.Time `json:"created"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Name string `json:"name"` + State string `json:"state"` + Keys []IndexKey `json:"keys"` +} + +// Usage is the consumption recorded for one minute. +type Usage struct { + Reads int `json:"reads"` + Writes int `json:"writes"` +} + +// Table is a NoSQL table with its rows and indexes. +type Table struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + Tags Tags `json:"tags"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Rows map[string]*Row `json:"rows,omitempty"` + Indexes map[string]*Index `json:"indexes,omitempty"` + Usage map[int64]*Usage `json:"usage,omitempty"` + Identity *Identity `json:"identity,omitempty"` + CompartmentID string `json:"compartmentId"` + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + DDL string `json:"ddl"` + Etag string `json:"etag"` + Columns []Column `json:"columns"` + PrimaryKey []string `json:"primaryKey"` + ShardKey []string `json:"shardKey"` + Limits Limits `json:"limits"` + TTLHours int `json:"ttlHours"` + RowSeq int64 `json:"rowSeq"` + AutoReclaim bool `json:"autoReclaimable,omitempty"` +} + +// WorkRequest records an asynchronous admin operation. +type WorkRequest struct { + Accepted time.Time `json:"accepted"` + FinishAt time.Time `json:"finishAt"` + ID string `json:"id"` + Operation string `json:"operation"` + CompartmentID string `json:"compartmentId"` + EntityID string `json:"entityId"` + Action string `json:"action"` + Seq int64 `json:"seq"` +} diff --git a/services/ocinosql/ops.go b/services/ocinosql/ops.go new file mode 100644 index 0000000000..570a851740 --- /dev/null +++ b/services/ocinosql/ops.go @@ -0,0 +1,51 @@ +package ocinosql + +import ( + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const itemsKey = "items" + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateTable: (*Handler).createTable, opGetTable: (*Handler).getTable, + opListTables: (*Handler).listTables, opUpdateTable: (*Handler).updateTable, + opDeleteTable: (*Handler).deleteTable, opChangeTableComp: (*Handler).changeTableCompartment, + opListTableUsage: (*Handler).listTableUsage, + opCreateIndex: (*Handler).createIndex, opGetIndex: (*Handler).getIndex, + opListIndexes: (*Handler).listIndexes, opDeleteIndex: (*Handler).deleteIndex, + opUpdateRow: (*Handler).updateRow, opGetRow: (*Handler).getRow, opDeleteRow: (*Handler).deleteRow, + opQuery: (*Handler).query, opPrepareStatement: (*Handler).prepareStatement, + opSummarizeStmt: (*Handler).summarizeStatement, + opGetWorkRequest: (*Handler).getWorkRequest, opListWorkRequests: (*Handler).listWorkRequests, + opListWorkReqErrors: (*Handler).listWorkRequestErrors, opListWorkReqLogs: (*Handler).listWorkRequestLogs, + } +} + +func reply[T any](q *req, w T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func acceptedWR(q *req, wr string, err error) error { + if err != nil { + return err + } + + return q.accepted(wr) +} + +func collectionReply[T any](q *req, pg page.Page[T], err error) error { + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, map[string]any{itemsKey: pg.Data}, "") +} diff --git a/services/ocinosql/persistence.go b/services/ocinosql/persistence.go new file mode 100644 index 0000000000..df48c9b591 --- /dev/null +++ b/services/ocinosql/persistence.go @@ -0,0 +1,98 @@ +package ocinosql + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Tables map[string]*Table `json:"tables"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocinosql", backendSnapshot{ + Version: snapshotVersion, Tables: b.tables, WorkReqs: b.workReqs, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocinosql", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ocinosql: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.tables, b.workReqs, b.seq = snap.Tables, snap.WorkReqs, snap.Seq + b.Auth.Load(snap.Auth) + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{nullCheck("table", &snap.Tables), nullCheck("work request", &snap.WorkReqs)} { + if err != nil { + return err + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ocinosql: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ocinosql/provider.go b/services/ocinosql/provider.go new file mode 100644 index 0000000000..2edf83d68c --- /dev/null +++ b/services/ocinosql/provider.go @@ -0,0 +1,42 @@ +package ocinosql + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocinosql: nil app context") + +// ConfigProvider extracts OCI NoSQL configuration from the AppContext Config. +type ConfigProvider interface { + GetOCINoSQLSettings() Settings +} + +// Provider implements service.Provider for OCI NoSQL. It runs on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCINoSQL" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCINoSQLSettings() + } + + handler := NewHandler(NewInMemoryBackend()) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ocinosql/query.go b/services/ocinosql/query.go new file mode 100644 index 0000000000..37bdd5700a --- /dev/null +++ b/services/ocinosql/query.go @@ -0,0 +1,279 @@ +package ocinosql + +import ( + "encoding/base64" + "net/http" + "strconv" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + preparedPrefix = "gsp1:" + defaultQueryLimit = 1000 +) + +type queryIn struct { + IsPrepared *bool `json:"isPrepared"` + Variables map[string]any `json:"variables"` + CompartmentID string `json:"compartmentId"` + Statement string `json:"statement"` + Consistency string `json:"consistency"` +} + +// QueryResult is the Query response body. +type QueryResult struct { + Items []map[string]any `json:"items"` + Usage usageWire `json:"usage"` +} + +// PreparedStatement is the PrepareStatement response body. +type PreparedStatement struct { + QueryPlan any `json:"queryPlan,omitempty"` + Statement string `json:"statement"` + Usage usageWire `json:"usage"` +} + +// StatementSummary is the SummarizeStatement response body. +type StatementSummary struct { + SyntaxError *string `json:"syntaxError,omitempty"` + Operation string `json:"operation,omitempty"` + TableName string `json:"tableName,omitempty"` + IndexName string `json:"indexName,omitempty"` + IsIfExists bool `json:"isIfExists"` + IsIfNotExists bool `json:"isIfNotExists"` +} + +func decodeStatement(stmt string, prepared bool) (string, *APIError) { + if !prepared { + return stmt, nil + } + + raw, ok := strings.CutPrefix(stmt, preparedPrefix) + if !ok { + return "", errParam("statement is not a prepared statement returned by PrepareStatement") + } + + b, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return "", errParam("prepared statement is corrupt") + } + + return string(b), nil +} + +// selectOrReject parses a SELECT and explains why other statement kinds are refused. +func selectOrReject(sql string) (*selectStmt, *APIError) { + st, aerr := parseSelect(sql) + if aerr == nil { + return st, nil + } + + switch summarize(sql).operation { + case sqlInsert, sqlUpdate, sqlDelete: + return nil, errParam("DML statements are not supported by Query; use UpdateRow and DeleteRow") + case "CREATE_TABLE", "ALTER_TABLE", "DROP_TABLE": + return nil, errParam("table DDL is not accepted by Query; use CreateTable, UpdateTable and DeleteTable") + case "CREATE_INDEX", "DROP_INDEX": + return nil, errParam("index DDL is not accepted by Query; use CreateIndex and DeleteIndex") + } + + return nil, aerr +} + +func (b *InMemoryBackend) planFor(in queryIn, prepared bool) (*plan, *APIError) { + if aerr := b.checkCompartment(in.CompartmentID); aerr != nil { + return nil, aerr + } + + sql, aerr := decodeStatement(in.Statement, prepared) + if aerr != nil { + return nil, aerr + } + + if len(sql) > maxQueryLen { + return nil, errLimit("the query string exceeds " + strconv.Itoa(maxQueryLen) + " bytes") + } + + st, aerr := selectOrReject(sql) + if aerr != nil { + return nil, aerr + } + + t, aerr := b.rowTable(st.table, in.CompartmentID) + if aerr != nil { + return nil, aerr + } + + return newPlan(t, st, in.Variables) +} + +// Query runs a SELECT statement and returns one page of results. +func (b *InMemoryBackend) Query(in queryIn, limit int, token string) (page.Page[map[string]any], usageWire, error) { + b.mu.Lock("Query") + defer b.mu.Unlock() + + if err := page.ValidateToken(token); err != nil { + return page.Page[map[string]any]{}, usageWire{}, errParam("page token is invalid") + } + + if in.Consistency != "" && in.Consistency != consEventual && in.Consistency != consAbsolute { + return page.Page[map[string]any]{}, usageWire{}, errParam("consistency must be EVENTUAL or ABSOLUTE") + } + + pl, aerr := b.planFor(in, in.IsPrepared != nil && *in.IsPrepared) + if aerr != nil { + return page.Page[map[string]any]{}, usageWire{}, aerr + } + + rows, scanned := pl.run(b.nowUTC) + units := unitsFor(scanned) + + if in.Consistency == consAbsolute { + units *= 2 + } + + pl.table.record(b.nowUTC(), units, 0) + + return page.New(rows, token, limit, defaultQueryLimit), usageWire{ReadUnitsConsumed: units}, nil +} + +// PrepareStatement validates a SELECT against its table and returns an opaque prepared form. +func (b *InMemoryBackend) PrepareStatement(compartment, statement string, withPlan bool) (PreparedStatement, error) { + b.mu.RLock("PrepareStatement") + defer b.mu.RUnlock() + + if aerr := b.checkCompartment(compartment); aerr != nil { + return PreparedStatement{}, aerr + } + + st, aerr := selectOrReject(statement) + if aerr != nil { + return PreparedStatement{}, aerr + } + + t, aerr := b.rowTable(st.table, compartment) + if aerr != nil { + return PreparedStatement{}, aerr + } + + vars := map[string]any{} + for name := range st.vars { + vars[name] = nil + } + + pl := &plan{table: t, sv: newSchemaView(t.Columns), stmt: st, vars: vars} + if aerr = pl.validateOnly(); aerr != nil { + return PreparedStatement{}, aerr + } + + out := PreparedStatement{Statement: preparedPrefix + base64.StdEncoding.EncodeToString([]byte(statement))} + if withPlan { + out.QueryPlan = pl.describe() + } + + return out, nil +} + +func (pl *plan) validateOnly() *APIError { + st := pl.stmt + + if st.where != nil { + if aerr := pl.bind(st.where); aerr != nil { + return aerr + } + } + + for i := range st.items { + var aerr *APIError + if st.items[i].path, aerr = pl.normPath(st.items[i].path); aerr != nil { + return aerr + } + } + + for i := range st.order { + var aerr *APIError + if st.order[i].path, aerr = pl.normPath(st.order[i].path); aerr != nil { + return aerr + } + } + + return pl.checkOrder() +} + +func (pl *plan) describe() map[string]any { + access := "PRIMARY_KEY_SCAN" + if len(pl.stmt.order) > 0 && !matchesKey(pl.stmt.order, pl.table.PrimaryKey) { + access = "INDEX_SCAN" + } + + d := map[string]any{ + "table": pl.table.Name, + "access": access, + "filtered": pl.stmt.where != nil, + "count": pl.stmt.count, + } + if pl.stmt.limit != nil { + d["limit"] = *pl.stmt.limit + } + + if pl.stmt.offset != nil { + d["offset"] = *pl.stmt.offset + } + + return d +} + +// SummarizeStatement classifies a statement and reports syntax errors without executing anything. +func (b *InMemoryBackend) SummarizeStatement(compartment, statement string) (StatementSummary, error) { + b.mu.RLock("SummarizeStatement") + defer b.mu.RUnlock() + + if aerr := b.checkCompartment(compartment); aerr != nil { + return StatementSummary{}, aerr + } + + s := summarize(statement) + out := StatementSummary{ + Operation: s.operation, TableName: s.table, IndexName: s.index, IsIfExists: s.ifExists, + IsIfNotExists: s.ifNotExists, + } + + if s.syntaxError != "" { + out.SyntaxError = &s.syntaxError + } + + return out, nil +} + +func (h *Handler) query(q *req) error { + var in queryIn + if err := q.decode(&in); err != nil { + return err + } + + limit, _ := strconv.Atoi(q.query("limit")) + + pg, usage, err := h.Backend.Query(in, limit, q.query("page")) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, QueryResult{Items: pg.Data, Usage: usage}, "") +} + +func (h *Handler) prepareStatement(q *req) error { + res, err := h.Backend.PrepareStatement(q.query("compartmentId"), q.query("statement"), + q.flag("isGetQueryPlan")) + + return reply(q, res, "", err) +} + +func (h *Handler) summarizeStatement(q *req) error { + res, err := h.Backend.SummarizeStatement(q.query("compartmentId"), q.query("statement")) + + return reply(q, res, "", err) +} diff --git a/services/ocinosql/query_test.go b/services/ocinosql/query_test.go new file mode 100644 index 0000000000..d2922d2871 --- /dev/null +++ b/services/ocinosql/query_test.go @@ -0,0 +1,337 @@ +package ocinosql_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/nosql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func seedUsers(t *testing.T, e *env) { + t.Helper() + + e.mkTable(t, "users", usersDDL("users")) + + rows := []map[string]any{ + {"id": 1, "name": "ann", "age": 30, "active": true, "info": map[string]any{"city": "Paris", "score": 5}}, + {"id": 2, "name": "bob", "age": 25, "active": false, "info": map[string]any{"city": "Rome", "score": 9}}, + {"id": 3, "name": "cy", "age": 41, "active": true, "info": map[string]any{"city": "Paris", "score": 1}}, + {"id": 4, "name": "dee", "active": true}, + {"id": 5, "name": "eve", "age": 19, "active": false, "info": map[string]any{"score": 7}}, + {"id": 6, "name": "fay", "age": 30, "active": true, "info": map[string]any{"city": "Oslo", "score": 3}}, + } + for _, r := range rows { + e.put(t, "users", r) + } +} + +func ids(items []map[string]any) []int { + out := make([]int, 0, len(items)) + + for _, it := range items { + if v, ok := it["id"].(float64); ok { + out = append(out, int(v)) + } + } + + return out +} + +func TestQuerySelect(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + seedUsers(t, e) + + tests := []struct { + name string + sql string + want []int + }{ + {name: "all", sql: "SELECT * FROM users", want: []int{1, 2, 3, 4, 5, 6}}, + {name: "gt", sql: "SELECT id FROM users WHERE age > 29", want: []int{1, 3, 6}}, + {name: "and", sql: "SELECT id FROM users WHERE age >= 30 AND active = true", want: []int{1, 3, 6}}, + {name: "or", sql: "SELECT id FROM users WHERE name = 'ann' OR name = 'bob'", want: []int{1, 2}}, + {name: "in", sql: "SELECT id FROM users WHERE id IN (1, 3, 5)", want: []int{1, 3, 5}}, + {name: "not in", sql: "SELECT id FROM users WHERE id NOT IN (1, 2)", want: []int{3, 4, 5, 6}}, + {name: "is null", sql: "SELECT id FROM users WHERE age IS NULL", want: []int{4}}, + {name: "is not null", sql: "SELECT id FROM users WHERE info IS NOT NULL", want: []int{1, 2, 3, 5, 6}}, + {name: "json path", sql: "SELECT id FROM users WHERE info.city = 'Paris'", want: []int{1, 3}}, + {name: "alias path", sql: "SELECT u.id FROM users u WHERE u.info.score >= 5", want: []int{1, 2, 5}}, + {name: "table qualified", sql: "SELECT users.id FROM users WHERE users.age = 25", want: []int{2}}, + {name: "not paren", sql: "SELECT id FROM users WHERE NOT (age < 30 OR age IS NULL)", want: []int{1, 3, 6}}, + {name: "between", sql: "SELECT id FROM users WHERE age BETWEEN 19 AND 25", want: []int{2, 5}}, + {name: "neq", sql: "SELECT id FROM users WHERE age <> 30", want: []int{2, 3, 5}}, + {name: "string cmp", sql: "SELECT id FROM users WHERE name >= 'dee'", want: []int{4, 5, 6}}, + {name: "order desc limit", sql: "SELECT id FROM users ORDER BY id DESC LIMIT 2", want: []int{6, 5}}, + {name: "limit offset", sql: "SELECT id FROM users LIMIT 2 OFFSET 2", want: []int{3, 4}}, + {name: "offset limit", sql: "SELECT id FROM users OFFSET 4 LIMIT 5", want: []int{5, 6}}, + {name: "offset past end", sql: "SELECT id FROM users OFFSET 50", want: []int{}}, + {name: "case insensitive", sql: "select ID from USERS where AGE = 41", want: []int{3}}, + {name: "no match", sql: "SELECT id FROM users WHERE name = 'zed'", want: []int{}}, + {name: "trailing semicolon", sql: "SELECT id FROM users WHERE info.score = 9;", want: []int{2}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp := e.query(t, tt.sql, 0, nil) + assert.Equal(t, tt.want, ids(resp.Items)) + assert.Positive(t, *resp.Usage.ReadUnitsConsumed) + }) + } +} + +func TestQueryProjection(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + seedUsers(t, e) + + resp := e.query(t, "SELECT name, age AS years, info.city FROM users WHERE id = 1", 0, nil) + require.Len(t, resp.Items, 1) + assert.Equal(t, map[string]any{"name": "ann", "years": float64(30), "city": "Paris"}, resp.Items[0]) + + resp = e.query(t, "SELECT * FROM users WHERE id = 4", 0, nil) + require.Len(t, resp.Items, 1) + assert.Equal(t, "dee", resp.Items[0]["name"]) + assert.Nil(t, resp.Items[0]["age"]) + + cnt := e.query(t, "SELECT COUNT(*) FROM users WHERE active = true", 0, nil) + require.Len(t, cnt.Items, 1) + assert.InDelta(t, 4, cnt.Items[0]["Column_1"], 0) + + cnt = e.query(t, "SELECT COUNT(*) FROM users", 0, nil) + assert.InDelta(t, 6, cnt.Items[0]["Column_1"], 0) +} + +func TestQueryPaging(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + seedUsers(t, e) + + var ( + got []int + page *string + pages int + ) + + for { + resp := e.query(t, "SELECT id FROM users", 4, page) + got = append(got, ids(resp.Items)...) + pages++ + + if resp.OpcNextPage == nil { + break + } + + page = resp.OpcNextPage + } + + assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, got) + assert.Equal(t, 2, pages) + + resp := e.query(t, "SELECT id FROM users LIMIT 5", 3, nil) + assert.Equal(t, []int{1, 2, 3}, ids(resp.Items), "page size and statement LIMIT combine") + require.NotNil(t, resp.OpcNextPage) + + resp = e.query(t, "SELECT id FROM users LIMIT 5", 3, resp.OpcNextPage) + assert.Equal(t, []int{4, 5}, ids(resp.Items)) + assert.Nil(t, resp.OpcNextPage) +} + +func TestQueryVariablesAndPrepared(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + seedUsers(t, e) + + stmt := "DECLARE $min INTEGER; $city STRING; SELECT id FROM users WHERE age > $min AND info.city = $city" + + prep, err := e.client.PrepareStatement(t.Context(), nosql.PrepareStatementRequest{ + CompartmentId: new(testCompartment), Statement: new(stmt), IsGetQueryPlan: new(true), + }) + require.NoError(t, err) + require.NotNil(t, prep.Statement) + assert.NotNil(t, prep.QueryPlan) + + tests := []struct { + name string + vars map[string]any + want []int + fail bool + }{ + {name: "bound", vars: map[string]any{"$min": 20, "$city": "Paris"}, want: []int{1, 3}}, + {name: "without dollar", vars: map[string]any{"min": 35, "city": "Paris"}, want: []int{3}}, + {name: "missing", vars: map[string]any{"$min": 20}, fail: true}, + {name: "wrong type", vars: map[string]any{"$min": "x", "$city": "Paris"}, fail: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + for _, prepared := range []bool{false, true} { + statement := stmt + if prepared { + statement = *prep.Statement + } + + resp, qerr := e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(statement), IsPrepared: new(prepared), + Variables: tt.vars}) + if tt.fail { + assert.Equal(t, http.StatusBadRequest, svcErr(t, qerr).GetHTTPStatusCode()) + + continue + } + + require.NoError(t, qerr) + assert.Equal(t, tt.want, ids(resp.Items)) + } + }) + } + + _, err = e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new("SELECT id FROM users"), IsPrepared: new(true)}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "raw SQL is not a prepared statement") +} + +func TestQueryUnsupported(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + seedUsers(t, e) + + tests := []struct { + name string + sql string + status int + }{ + {name: "insert", sql: "INSERT INTO users VALUES (9, 'x')", status: 400}, + {name: "update", sql: "UPDATE users SET age = 1 WHERE id = 1", status: 400}, + {name: "delete", sql: "DELETE FROM users WHERE id = 1", status: 400}, + {name: "create table", sql: "CREATE TABLE z (a INTEGER, PRIMARY KEY(a))", status: 400}, + {name: "alter table", sql: "ALTER TABLE users (ADD z INTEGER)", status: 400}, + {name: "drop table", sql: "DROP TABLE users", status: 400}, + {name: "create index", sql: "CREATE INDEX i ON users(age)", status: 400}, + {name: "join", sql: "SELECT * FROM users a JOIN users b ON a.id = b.id", status: 400}, + {name: "group by", sql: "SELECT age FROM users GROUP BY age", status: 400}, + {name: "function", sql: "SELECT lower(name) FROM users", status: 400}, + {name: "like", sql: "SELECT id FROM users WHERE name LIKE 'a%'", status: 400}, + {name: "distinct", sql: "SELECT DISTINCT age FROM users", status: 400}, + {name: "sum", sql: "SELECT SUM(age) FROM users", status: 400}, + {name: "garbage", sql: "BANANA", status: 400}, + {name: "missing from", sql: "SELECT id", status: 400}, + {name: "unterminated string", sql: "SELECT id FROM users WHERE name = 'x", status: 400}, + {name: "unknown column", sql: "SELECT nope FROM users", status: 400}, + {name: "unknown where column", sql: "SELECT id FROM users WHERE nope = 1", status: 400}, + {name: "unknown table", sql: "SELECT * FROM ghosts", status: 404}, + {name: "order by non key", sql: "SELECT id FROM users ORDER BY age", status: 400}, + {name: "undeclared variable", sql: "SELECT id FROM users WHERE id = $x", status: 400}, + {name: "negative limit", sql: "SELECT id FROM users LIMIT -1", status: 400}, + {name: "huge limit", sql: "SELECT id FROM users LIMIT 9223372036854775807", status: 400}, + {name: "huge offset", sql: "SELECT id FROM users OFFSET 4294967296", status: 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.client.Query(t.Context(), nosql.QueryRequest{ + CompartmentId: new(testCompartment), Statement: new(tt.sql)}) + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode(), se.GetMessage()) + + if tt.status == http.StatusBadRequest { + assert.Equal(t, "InvalidParameter", se.GetCode()) + } + }) + } +} + +func TestSummarizeStatement(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + sql string + op nosql.StatementSummaryOperationEnum + table string + index string + ifExists bool + ifNotExist bool + syntaxErr bool + }{ + { + name: "select", + sql: "SELECT * FROM users WHERE id = 1", + op: nosql.StatementSummaryOperationSelect, + table: "users", + }, + {name: "bad select", sql: "SELECT FROM", op: nosql.StatementSummaryOperationSelect, syntaxErr: true}, + {name: "create table", sql: "CREATE TABLE IF NOT EXISTS t (a INTEGER, PRIMARY KEY(a))", + op: nosql.StatementSummaryOperationCreateTable, table: "t", ifNotExist: true}, + { + name: "bad create", + sql: "CREATE TABLE t (a INTEGER)", + op: nosql.StatementSummaryOperationCreateTable, + table: "t", + syntaxErr: true, + }, + {name: "alter", sql: "ALTER TABLE t (ADD b STRING)", op: nosql.StatementSummaryOperationAlterTable, table: "t"}, + { + name: "drop table", + sql: "DROP TABLE IF EXISTS t", + op: nosql.StatementSummaryOperationDropTable, + table: "t", + ifExists: true, + }, + {name: "create index", sql: "CREATE INDEX IF NOT EXISTS i1 ON t(a)", + op: nosql.StatementSummaryOperationCreateIndex, table: "t", index: "i1", ifNotExist: true}, + { + name: "drop index", + sql: "DROP INDEX i1 ON t", + op: nosql.StatementSummaryOperationDropIndex, + table: "t", + index: "i1", + }, + {name: "insert", sql: "INSERT INTO t VALUES (1)", op: nosql.StatementSummaryOperationInsert, table: "t"}, + {name: "update", sql: "UPDATE t SET a = 1", op: nosql.StatementSummaryOperationUpdate, table: "t"}, + {name: "delete", sql: "DELETE FROM t", op: nosql.StatementSummaryOperationDelete, table: "t"}, + {name: "garbage", sql: "BANANA PIE", syntaxErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.client.SummarizeStatement(t.Context(), nosql.SummarizeStatementRequest{ + CompartmentId: new(testCompartment), Statement: new(tt.sql), + }) + require.NoError(t, err) + + assert.Equal(t, tt.op, resp.Operation) + assert.Equal(t, tt.syntaxErr, resp.SyntaxError != nil) + + if tt.table != "" { + require.NotNil(t, resp.TableName) + assert.Equal(t, tt.table, *resp.TableName) + } + + if tt.index != "" { + require.NotNil(t, resp.IndexName) + assert.Equal(t, tt.index, *resp.IndexName) + } + + assert.Equal(t, tt.ifExists, resp.IsIfExists != nil && *resp.IsIfExists) + assert.Equal(t, tt.ifNotExist, resp.IsIfNotExists != nil && *resp.IsIfNotExists) + }) + } +} diff --git a/services/ocinosql/route.go b/services/ocinosql/route.go new file mode 100644 index 0000000000..6c087977a8 --- /dev/null +++ b/services/ocinosql/route.go @@ -0,0 +1,85 @@ +package ocinosql + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateTable = "CreateTable" + opGetTable = "GetTable" + opListTables = "ListTables" + opUpdateTable = "UpdateTable" + opDeleteTable = "DeleteTable" + opChangeTableComp = "ChangeTableCompartment" + opListTableUsage = "ListTableUsage" + opCreateIndex = "CreateIndex" + opGetIndex = "GetIndex" + opListIndexes = "ListIndexes" + opDeleteIndex = "DeleteIndex" + opUpdateRow = "UpdateRow" + opGetRow = "GetRow" + opDeleteRow = "DeleteRow" + opQuery = "Query" + opPrepareStatement = "PrepareStatement" + opSummarizeStmt = "SummarizeStatement" + opGetWorkRequest = "GetWorkRequest" + opListWorkRequests = "ListWorkRequests" + opListWorkReqErrors = "ListWorkRequestErrors" + opListWorkReqLogs = "ListWorkRequestLogs" + opAdminKeys = "ManageAPIKeys" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + base = "/20190828" + tablePath = base + "/tables/{tableNameOrId}" + indexPath = tablePath + "/indexes/{indexName}" + workRequestPath = base + "/workRequests/{workRequestId}" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/tables", Op: opCreateTable}, + {Method: http.MethodGet, Path: base + "/tables", Op: opListTables}, + {Method: http.MethodGet, Path: tablePath, Op: opGetTable}, + {Method: http.MethodPut, Path: tablePath, Op: opUpdateTable}, + {Method: http.MethodDelete, Path: tablePath, Op: opDeleteTable}, + {Method: http.MethodPost, Path: tablePath + "/actions/changeCompartment", Op: opChangeTableComp}, + {Method: http.MethodGet, Path: tablePath + "/usage", Op: opListTableUsage}, + {Method: http.MethodPost, Path: tablePath + "/indexes", Op: opCreateIndex}, + {Method: http.MethodGet, Path: tablePath + "/indexes", Op: opListIndexes}, + {Method: http.MethodGet, Path: indexPath, Op: opGetIndex}, + {Method: http.MethodDelete, Path: indexPath, Op: opDeleteIndex}, + {Method: http.MethodPut, Path: tablePath + "/rows", Op: opUpdateRow}, + {Method: http.MethodGet, Path: tablePath + "/rows", Op: opGetRow}, + {Method: http.MethodDelete, Path: tablePath + "/rows", Op: opDeleteRow}, + {Method: http.MethodPost, Path: base + "/query", Op: opQuery}, + {Method: http.MethodGet, Path: base + "/query/prepare", Op: opPrepareStatement}, + {Method: http.MethodGet, Path: base + "/query/summarize", Op: opSummarizeStmt}, + {Method: http.MethodGet, Path: base + "/workRequests", Op: opListWorkRequests}, + {Method: http.MethodGet, Path: workRequestPath, Op: opGetWorkRequest}, + {Method: http.MethodGet, Path: workRequestPath + "/errors", Op: opListWorkReqErrors}, + {Method: http.MethodGet, Path: workRequestPath + "/logs", Op: opListWorkReqLogs}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocinosql/row_test.go b/services/ocinosql/row_test.go new file mode 100644 index 0000000000..9921aa184f --- /dev/null +++ b/services/ocinosql/row_test.go @@ -0,0 +1,441 @@ +package ocinosql_test + +import ( + "net/http" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/nosql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (e *env) getRow(t *testing.T, table string, key ...string) nosql.GetRowResponse { + t.Helper() + + resp, err := e.client.GetRow(t.Context(), nosql.GetRowRequest{ + TableNameOrId: new(table), CompartmentId: new(testCompartment), Key: key, + }) + require.NoError(t, err) + + return resp +} + +func TestRowPutGetDelete(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ddl := "CREATE TABLE rich (id LONG, big LONG, n NUMBER, d DOUBLE, ok BOOLEAN, at TIMESTAMP(3), " + + "bin BINARY, tags ARRAY(STRING), m MAP(INTEGER), r RECORD(a STRING, b INTEGER), j JSON, " + + "color ENUM(red, green), PRIMARY KEY(id))" + e.mkTable(t, "rich", ddl) + + put := e.put(t, "rich", map[string]any{ + "id": 7, "big": int64(9007199254740993), "n": 12.5, "d": 1.5, "ok": true, "at": "2024-03-04T05:06:07.123456Z", + "bin": "AQID", "tags": []string{"x", "y"}, "m": map[string]int{"k": 1}, + "r": map[string]any{"a": "s", "b": 2}, "j": map[string]any{"deep": []int{1, 2}}, "color": "red", + }) + require.NotNil(t, put.Version) + assert.Positive(t, *put.Usage.WriteUnitsConsumed) + + got := e.getRow(t, "rich", "id:7") + assert.Equal(t, *put.Version, *got.Etag) + assert.Equal(t, "2024-03-04T05:06:07.123Z", got.Value["at"], "timestamp truncated to column precision") + assert.Equal(t, true, got.Value["ok"]) + assert.Equal(t, []any{"x", "y"}, got.Value["tags"]) + assert.Equal(t, "red", got.Value["color"]) + assert.Equal(t, map[string]any{"a": "s", "b": float64(2)}, got.Value["r"]) + assert.Positive(t, *got.Usage.ReadUnitsConsumed) + + raw, body, _ := rawDo( + t, + http.MethodGet, + e.base+"/20190828/tables/rich/rows?compartmentId="+testCompartment+"&key=id:7", + "", + ) + require.Equal(t, http.StatusOK, raw) + assert.Contains(t, body, `"big":9007199254740993`, "long values keep full precision") + + absent := e.getRow(t, "rich", "id:8") + assert.Nil(t, absent.Value) + + del, err := e.client.DeleteRow(t.Context(), nosql.DeleteRowRequest{ + TableNameOrId: new( + "rich", + ), + CompartmentId: new(testCompartment), + Key: []string{"id:7"}, + IsGetReturnRow: new(true), + }) + require.NoError(t, err) + assert.True(t, *del.IsSuccess) + assert.Equal(t, *put.Version, *del.ExistingVersion) + assert.Equal(t, "red", del.ExistingValue["color"]) + assert.Nil(t, e.getRow(t, "rich", "id:7").Value) + + del, err = e.client.DeleteRow(t.Context(), nosql.DeleteRowRequest{ + TableNameOrId: new("rich"), CompartmentId: new(testCompartment), Key: []string{"id:7"}, + }) + require.NoError(t, err) + assert.False(t, *del.IsSuccess) +} + +func TestRowPutOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + option nosql.UpdateRowDetailsOptionEnum + existing bool + returnRow bool + wantApplied bool + wantExisting bool + }{ + {name: "absent on missing", option: nosql.UpdateRowDetailsOptionAbsent, wantApplied: true}, + {name: "absent on existing", option: nosql.UpdateRowDetailsOptionAbsent, existing: true}, + { + name: "absent on existing returns row", + option: nosql.UpdateRowDetailsOptionAbsent, + existing: true, + returnRow: true, + wantExisting: true, + }, + {name: "present on missing", option: nosql.UpdateRowDetailsOptionPresent}, + {name: "present on existing", option: nosql.UpdateRowDetailsOptionPresent, existing: true, wantApplied: true}, + {name: "no option overwrites", existing: true, wantApplied: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + + if tt.existing { + e.put(t, "t", map[string]any{"id": 1, "name": "old"}) + } + + resp, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("t"), + Value: map[string]any{"id": 1, "name": "new"}, CompartmentId: new(testCompartment), + Option: tt.option, IsGetReturnRow: new(tt.returnRow), + }) + require.NoError(t, err) + + assert.Equal(t, tt.wantApplied, resp.Version != nil) + assert.Equal(t, tt.wantExisting, resp.ExistingValue != nil) + + want := "old" + if tt.wantApplied { + want = "new" + } + + if tt.existing || tt.wantApplied { + assert.Equal(t, want, e.getRow(t, "t", "id:1").Value["name"]) + } + + if tt.wantExisting { + assert.Equal(t, "old", resp.ExistingValue["name"]) + assert.NotNil(t, resp.ExistingVersion) + } + }) + } +} + +func TestRowVersionMatch(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + + v1 := e.put(t, "t", map[string]any{"id": 1, "name": "a"}) + + stale, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("t"), IfMatch: new("bogus"), + Value: map[string]any{"id": 1, "name": "b"}, CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + assert.Nil(t, stale.Version) + + ok, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("t"), IfMatch: v1.Version, + Value: map[string]any{"id": 1, "name": "b"}, CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + require.NotNil(t, ok.Version) + assert.NotEqual(t, *v1.Version, *ok.Version) + + del, err := e.client.DeleteRow(t.Context(), nosql.DeleteRowRequest{ + TableNameOrId: new("t"), CompartmentId: new(testCompartment), Key: []string{"id:1"}, IfMatch: v1.Version, + }) + require.NoError(t, err) + assert.False(t, *del.IsSuccess, "old version no longer matches") + assert.Equal(t, "b", e.getRow(t, "t", "id:1").Value["name"]) +} + +func TestRowValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", "CREATE TABLE t (id INTEGER, s STRING, i INTEGER, b BOOLEAN, req STRING NOT NULL DEFAULT 'x', "+ + "arr ARRAY(INTEGER), e ENUM(a, b), bin BINARY, PRIMARY KEY(id))") + + tests := []struct { + value map[string]any + name string + exact bool + ok bool + }{ + {name: "valid", value: map[string]any{"id": 1, "s": "x"}, ok: true}, + {name: "missing key", value: map[string]any{"s": "x"}}, + {name: "null key", value: map[string]any{"id": nil}}, + {name: "string for int", value: map[string]any{"id": 1, "i": "5"}}, + {name: "fraction for int", value: map[string]any{"id": 1, "i": 1.5}}, + {name: "int overflow", value: map[string]any{"id": 1, "i": int64(1) << 40}}, + {name: "int for string", value: map[string]any{"id": 1, "s": 5}}, + {name: "string for bool", value: map[string]any{"id": 1, "b": "true"}}, + {name: "bad array element", value: map[string]any{"id": 1, "arr": []any{1, "z"}}}, + {name: "bad enum", value: map[string]any{"id": 1, "e": "c"}}, + {name: "bad binary", value: map[string]any{"id": 1, "bin": "not base64!"}}, + {name: "null for not null", value: map[string]any{"id": 1, "req": nil}}, + {name: "extra ignored", value: map[string]any{"id": 1, "zzz": 1}, ok: true}, + {name: "extra exact", value: map[string]any{"id": 1, "zzz": 1}, exact: true}, + {name: "missing exact", value: map[string]any{"id": 1}, exact: true}, + {name: "case insensitive column", value: map[string]any{"ID": 2, "S": "y"}, ok: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("t"), + Value: tt.value, CompartmentId: new(testCompartment), IsExactMatch: new(tt.exact), + }) + if tt.ok { + require.NoError(t, err) + + return + } + + se := svcErr(t, err) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + assert.Equal(t, "InvalidParameter", se.GetCode()) + }) + } +} + +func TestRowColumnDefault(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable( + t, + "t", + "CREATE TABLE t (id INTEGER, req STRING NOT NULL DEFAULT 'x', n INTEGER DEFAULT 4, PRIMARY KEY(id))", + ) + e.put(t, "t", map[string]any{"id": 1}) + + got := e.getRow(t, "t", "id:1") + assert.Equal(t, "x", got.Value["req"]) + assert.InDelta(t, 4, got.Value["n"], 0) +} + +func TestRowKeyErrors(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", "CREATE TABLE t (a INTEGER, b STRING, c STRING, PRIMARY KEY(SHARD(a), b))") + + tests := []struct { + name string + key []string + }{ + {name: "no key", key: nil}, + {name: "partial key", key: []string{"a:1"}}, + {name: "no colon", key: []string{"a=1", "b:x"}}, + {name: "non key column", key: []string{"a:1", "c:x"}}, + {name: "bad int", key: []string{"a:z", "b:x"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.client.GetRow(t.Context(), nosql.GetRowRequest{ + TableNameOrId: new("t"), CompartmentId: new(testCompartment), Key: tt.key, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteRow(t.Context(), nosql.DeleteRowRequest{ + TableNameOrId: new("t"), CompartmentId: new(testCompartment), Key: tt.key, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + _, err := e.client.GetRow(t.Context(), nosql.GetRowRequest{ + TableNameOrId: new("nope"), CompartmentId: new(testCompartment), Key: []string{"a:1"}, + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestRowTTL(t *testing.T) { + t.Parallel() + + tests := []struct { + ttl *int + useTable *bool + name string + want time.Duration + }{ + {name: "table default", want: 48 * time.Hour}, + {name: "explicit days", ttl: new(5), want: 5 * 24 * time.Hour}, + {name: "explicit zero never expires", ttl: new(0)}, + {name: "use table default flag", ttl: new(9), useTable: new(true), want: 48 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")+" USING TTL 48 HOURS") + + start := e.clock.Now() + + _, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("t"), + Value: map[string]any{"id": 1}, CompartmentId: new(testCompartment), + Ttl: tt.ttl, IsTtlUseTableDefault: tt.useTable, + }) + require.NoError(t, err) + + got := e.getRow(t, "t", "id:1") + if tt.want == 0 { + assert.Nil(t, got.TimeOfExpiration) + e.clock.Advance(1000 * 24 * time.Hour) + assert.NotNil(t, e.getRow(t, "t", "id:1").Value) + + return + } + + require.NotNil(t, got.TimeOfExpiration) + assert.WithinDuration(t, start.Add(tt.want), got.TimeOfExpiration.Time, time.Second) + + e.clock.Advance(tt.want - time.Minute) + assert.NotNil(t, e.getRow(t, "t", "id:1").Value, "still alive just before expiry") + + e.clock.Advance(2 * time.Minute) + assert.Nil(t, e.getRow(t, "t", "id:1").Value, "expired rows are invisible") + assert.Empty(t, e.query(t, "SELECT * FROM t", 0, nil).Items) + }) + } + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + + _, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("t"), + Value: map[string]any{"id": 1}, CompartmentId: new(testCompartment), Ttl: new(-1), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestRowSweepRemovesExpired(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")+" USING TTL 1 HOURS") + e.put(t, "t", map[string]any{"id": 1}) + + e.clock.Advance(2 * time.Hour) + e.backend.SweepRows() + + snap := string(e.backend.Snapshot(t.Context())) + assert.NotContains(t, snap, `"rows"`, "expired row no longer stored") +} + +func TestIdentityAndUUID(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "ids", "CREATE TABLE ids (id INTEGER GENERATED ALWAYS AS IDENTITY (START WITH 10 INCREMENT BY 5), "+ + "u STRING AS UUID GENERATED BY DEFAULT, v STRING, PRIMARY KEY(id))") + + r1 := e.put(t, "ids", map[string]any{"v": "a"}) + r2 := e.put(t, "ids", map[string]any{"v": "b"}) + assert.Equal(t, "10", *r1.GeneratedValue) + assert.Equal(t, "15", *r2.GeneratedValue) + + got := e.getRow(t, "ids", "id:15") + assert.Equal(t, "b", got.Value["v"]) + assert.Len(t, got.Value["u"], 36) + + _, err := e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("ids"), + Value: map[string]any{"id": 1, "v": "c"}, CompartmentId: new(testCompartment), + }) + assert.Equal( + t, + http.StatusBadRequest, + svcErr(t, err).GetHTTPStatusCode(), + "ALWAYS identity rejects explicit values", + ) + + tbl := e.getTable(t, "ids") + assert.True(t, *tbl.Schema.Identity.IsAlways) + assert.Equal(t, "id", *tbl.Schema.Identity.ColumnName) + + e.mkTable(t, "byd", "CREATE TABLE byd (id LONG GENERATED BY DEFAULT AS IDENTITY, PRIMARY KEY(id))") + assert.Equal(t, "1", *e.put(t, "byd", map[string]any{}).GeneratedValue) + assert.Nil(t, e.put(t, "byd", map[string]any{"id": 50}).GeneratedValue) + assert.Equal(t, "2", *e.put(t, "byd", map[string]any{}).GeneratedValue) + + _, err = e.client.UpdateRow(t.Context(), nosql.UpdateRowRequest{ + TableNameOrId: new("ids"), + Value: map[string]any{"u": "not-a-uuid", "v": "d"}, CompartmentId: new(testCompartment), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "invalid UUID is refused") +} + +func TestListTableUsage(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + + empty, err := e.client.ListTableUsage(t.Context(), nosql.ListTableUsageRequest{ + TableNameOrId: new("t"), CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + assert.Empty(t, empty.Items) + + e.put(t, "t", map[string]any{"id": 1, "name": "a"}) + e.put(t, "t", map[string]any{"id": 2, "name": "b"}) + e.getRow(t, "t", "id:1") + e.clock.Advance(2 * time.Minute) + e.getRow(t, "t", "id:2") + + resp, err := e.client.ListTableUsage(t.Context(), nosql.ListTableUsageRequest{ + TableNameOrId: new("t"), CompartmentId: new(testCompartment), + }) + require.NoError(t, err) + require.Len(t, resp.Items, 2) + assert.Equal(t, 60, *resp.Items[0].SecondsInPeriod) + assert.Equal(t, 2, *resp.Items[0].WriteUnits) + assert.Equal(t, 1, *resp.Items[0].ReadUnits) + assert.Equal(t, 0, *resp.Items[1].WriteUnits) + assert.Equal(t, 1, *resp.Items[1].ReadUnits) + assert.True(t, resp.Items[1].TimeStarted.After(resp.Items[0].TimeStarted.Time)) + + start := e.clock.Now().Add(time.Minute) + late, err := e.client.ListTableUsage(t.Context(), nosql.ListTableUsageRequest{ + TableNameOrId: new("t"), CompartmentId: new(testCompartment), + TimeStart: &common.SDKTime{Time: start}, TimeEnd: &common.SDKTime{Time: start.Add(time.Hour)}, + }) + require.NoError(t, err) + assert.Empty(t, late.Items) +} diff --git a/services/ocinosql/rows.go b/services/ocinosql/rows.go new file mode 100644 index 0000000000..ef5c06e37f --- /dev/null +++ b/services/ocinosql/rows.go @@ -0,0 +1,479 @@ +package ocinosql + +import ( + "encoding/base64" + "encoding/binary" + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + bytesPerUnit = 1024 + secondsPerMin = 60 + optIfAbsent = "IF_ABSENT" + optIfPresent = "IF_PRESENT" +) + +type updateRowIn struct { + Value map[string]any `json:"value"` + IsGetReturnRow *bool `json:"isGetReturnRow"` + TTL *int `json:"ttl"` + IsTTLUseDefault *bool `json:"isTtlUseTableDefault"` + IsExactMatch *bool `json:"isExactMatch"` + CompartmentID string `json:"compartmentId"` + Option string `json:"option"` +} + +type usageWire struct { + ReadUnitsConsumed int `json:"readUnitsConsumed"` + WriteUnitsConsumed int `json:"writeUnitsConsumed"` +} + +// UpdateRowResult is the UpdateRow response body. +type UpdateRowResult struct { + Version *string `json:"version"` + ExistingVersion *string `json:"existingVersion,omitempty"` + GeneratedValue *string `json:"generatedValue,omitempty"` + ExistingValue json.RawMessage `json:"existingValue,omitempty"` + Usage usageWire `json:"usage"` +} + +// RowResult is the GetRow response body. +type RowResult struct { + TimeOfExpiration *time.Time `json:"timeOfExpiration,omitempty"` + Value json.RawMessage `json:"value"` + Usage usageWire `json:"usage"` +} + +// DeleteRowResult is the DeleteRow response body. +type DeleteRowResult struct { + ExistingVersion *string `json:"existingVersion,omitempty"` + ExistingValue json.RawMessage `json:"existingValue,omitempty"` + IsSuccess bool `json:"isSuccess"` + Usage usageWire `json:"usage"` +} + +func unitsFor(size int) int { return max(1, (size+bytesPerUnit-1)/bytesPerUnit) } + +func (t *Table) record(now time.Time, reads, writes int) { + if t.Usage == nil { + t.Usage = map[int64]*Usage{} + } + + minute := now.Unix() / secondsPerMin * secondsPerMin + + u := t.Usage[minute] + if u == nil { + u = &Usage{} + t.Usage[minute] = u + } + + u.Reads += reads + u.Writes += writes +} + +func (t *Table) nextVersion() string { + t.RowSeq++ + + var buf [8]byte + + binary.BigEndian.PutUint64(buf[:], uint64(t.RowSeq)) //nolint:gosec // counter is positive + + return base64.StdEncoding.EncodeToString(buf[:]) +} + +// rowTable resolves a table whose data plane is available. +func (b *InMemoryBackend) rowTable(ref, compartment string) (*Table, *APIError) { + t, aerr := b.resolve(ref, compartment) + if aerr != nil { + return nil, aerr + } + + if t.State != stateActive && t.State != stateUpdating { + return nil, errState( + "Table " + t.Name + " is in state " + t.State + "; rows are only available in ACTIVE or UPDATING", + ) + } + + return t, nil +} + +func (t *Table) live(key string, now time.Time) *Row { + r := t.Rows[key] + if r == nil { + return nil + } + + if r.Expire != nil && !r.Expire.After(now) { + delete(t.Rows, key) + + return nil + } + + return r +} + +// prepareRow validates and completes a row value, generating identity and UUID columns. +func (t *Table) prepareRow(in map[string]any, exact bool) (map[string]any, *string, *APIError) { + sv := newSchemaView(t.Columns) + out := make(map[string]any, len(sv.cols)) + given := map[string]bool{} + + for k, v := range in { + i, ok := sv.lookup(k) + if !ok { + if exact { + return nil, nil, errParam("column '" + k + "' is not part of table " + t.Name) + } + + continue + } + + n, msg := coerce(sv.types[i], v) + if msg != "" { + return nil, nil, errParam("value of column '" + sv.cols[i].Name + "' " + msg) + } + + out[sv.cols[i].Name], given[sv.cols[i].Name] = n, true + } + + gen, aerr := t.generate(sv, out, given) + if aerr != nil { + return nil, nil, aerr + } + + return out, gen, t.fillDefaults(sv, out, given, exact) +} + +func (t *Table) generate(sv *schemaView, out map[string]any, given map[string]bool) (*string, *APIError) { + var generated *string + + for _, c := range sv.cols { + switch { + case t.Identity != nil && strs.Equal(t.Identity.Column, c.Name): + g, aerr := t.generateIdentity(c.Name, out, given) + if aerr != nil { + return nil, aerr + } + + generated = g + case c.AsUUID && c.Gen && (!given[c.Name] || out[c.Name] == nil): + out[c.Name], given[c.Name] = newUUID(), true + case c.AsUUID && out[c.Name] != nil: + if s, _ := out[c.Name].(string); !validUUID(s) { + return nil, errParam("value of column '" + c.Name + "' is not a valid UUID") + } + } + } + + return generated, nil +} + +func (t *Table) generateIdentity(col string, out map[string]any, given map[string]bool) (*string, *APIError) { + id := t.Identity + if id.Always && given[col] && out[col] != nil { + return nil, errParam("column '" + col + "' is GENERATED ALWAYS AS IDENTITY and cannot be assigned a value") + } + + if given[col] && out[col] != nil { + return nil, nil + } + + if given[col] && !id.OnNull && !id.Always { + return nil, errParam("column '" + col + "' cannot be null") + } + + v := strconv.FormatInt(id.Next, 10) + id.Next += id.Increment + out[col], given[col] = json.Number(v), true + + return &v, nil +} + +func (t *Table) fillDefaults(sv *schemaView, out map[string]any, given map[string]bool, exact bool) *APIError { + for i, c := range sv.cols { + if given[c.Name] { + if out[c.Name] == nil && c.NotNull { + return errParam("column '" + c.Name + "' cannot be null") + } + + continue + } + + if len(c.Default) > 0 { + v, _ := coerceDefault(sv.types[i], c.Default) + out[c.Name] = v + + continue + } + + if c.NotNull || (exact && !c.Gen) { + return errParam("column '" + c.Name + "' requires a value") + } + + out[c.Name] = nil + } + + return nil +} + +func (t *Table) rowExpiry(in updateRowIn, now time.Time) (*time.Time, *APIError) { + hours := t.TTLHours + + if in.TTL != nil && (in.IsTTLUseDefault == nil || !*in.IsTTLUseDefault) { + if *in.TTL < 0 { + return nil, errParam("ttl must not be negative") + } + + hours = *in.TTL * hoursPerDay + } + + if hours == 0 { + return nil, nil + } + + at := now.Add(time.Duration(hours) * time.Hour) + + return &at, nil +} + +func ptrIf(s string) *string { + if s == "" { + return nil + } + + return &s +} + +// UpdateRow puts a row into a table. +func (b *InMemoryBackend) UpdateRow(ref, ifMatch string, in updateRowIn) (UpdateRowResult, error) { + b.mu.Lock("UpdateRow") + defer b.mu.Unlock() + + t, aerr := b.rowTable(ref, in.CompartmentID) + if aerr != nil { + return UpdateRowResult{}, aerr + } + + if in.Value == nil { + return UpdateRowResult{}, errMissing("value is required") + } + + if in.Option != "" && in.Option != optIfAbsent && in.Option != optIfPresent { + return UpdateRowResult{}, errParam("option must be IF_ABSENT or IF_PRESENT") + } + + now := b.nowUTC() + + expire, aerr := t.rowExpiry(in, now) + if aerr != nil { + return UpdateRowResult{}, aerr + } + + row, gen, aerr := t.prepareRow(in.Value, in.IsExactMatch != nil && *in.IsExactMatch) + if aerr != nil { + return UpdateRowResult{}, aerr + } + + key, aerr := rowKey(t.PrimaryKey, row) + if aerr != nil { + return UpdateRowResult{}, aerr + } + + if aerr = checkRowSize(t.PrimaryKey, row); aerr != nil { + return UpdateRowResult{}, aerr + } + + existing := t.live(key, now) + if refused(in.Option, ifMatch, existing) { + return b.refusedPut(t, existing, in, now), nil + } + + raw, _ := json.Marshal(row) + t.Rows[key] = &Row{Value: raw, Version: t.nextVersion(), Expire: expire} + w := unitsFor(len(raw)) + t.record(now, 0, w) + + return UpdateRowResult{ + Version: ptrIf(t.Rows[key].Version), + GeneratedValue: gen, + Usage: usageWire{WriteUnitsConsumed: w}, + }, nil +} + +func refused(option, ifMatch string, existing *Row) bool { + switch { + case option == optIfAbsent && existing != nil, option == optIfPresent && existing == nil: + return true + case ifMatch != "" && (existing == nil || existing.Version != ifMatch): + return true + } + + return false +} + +func (b *InMemoryBackend) refusedPut(t *Table, existing *Row, in updateRowIn, now time.Time) UpdateRowResult { + res := UpdateRowResult{} + + if existing != nil && in.IsGetReturnRow != nil && *in.IsGetReturnRow { + res.ExistingVersion, res.ExistingValue = ptrIf(existing.Version), existing.Value + res.Usage.ReadUnitsConsumed = unitsFor(len(existing.Value)) + } + + t.record(now, res.Usage.ReadUnitsConsumed, 0) + + return res +} + +func parseKey(t *Table, parts []string) (map[string]any, *APIError) { + sv := newSchemaView(t.Columns) + row := map[string]any{} + + for _, p := range parts { + name, val, ok := strings.Cut(p, ":") + if !ok { + return nil, errParam("key '" + p + "' must have the form column-name:value") + } + + i, found := sv.lookup(name) + if !found || !strs.ContainsFold(t.PrimaryKey, name) { + return nil, errParam("'" + name + "' is not a primary key column of table " + t.Name) + } + + var raw any = val + if sv.types[i].isNumeric() { + raw = json.Number(val) + } + + n, msg := coerce(sv.types[i], raw) + if msg != "" { + return nil, errParam("key value of column '" + sv.cols[i].Name + "' " + msg) + } + + row[sv.cols[i].Name] = n + } + + if len(row) != len(t.PrimaryKey) { + return nil, errParam("key must contain a value for every primary key column") + } + + return row, nil +} + +// GetRow reads one row by primary key. +func (b *InMemoryBackend) GetRow(ref, compartment string, key []string, consistency string) (RowResult, string, error) { + b.mu.Lock("GetRow") + defer b.mu.Unlock() + + t, aerr := b.rowTable(ref, compartment) + if aerr != nil { + return RowResult{}, "", aerr + } + + if consistency != "" && consistency != consEventual && consistency != consAbsolute { + return RowResult{}, "", errParam("consistency must be EVENTUAL or ABSOLUTE") + } + + kv, aerr := parseKey(t, key) + if aerr != nil { + return RowResult{}, "", aerr + } + + ks, _ := rowKey(t.PrimaryKey, kv) + now := b.nowUTC() + r := t.live(ks, now) + + size := 0 + if r != nil { + size = len(r.Value) + } + + units := unitsFor(size) + if consistency == consAbsolute { + units *= 2 + } + + t.record(now, units, 0) + + if r == nil { + return RowResult{Usage: usageWire{ReadUnitsConsumed: units}}, "", nil + } + + return RowResult{ + Value: r.Value, + TimeOfExpiration: r.Expire, + Usage: usageWire{ReadUnitsConsumed: units}, + }, r.Version, nil +} + +// DeleteRow removes one row by primary key. +func (b *InMemoryBackend) DeleteRow( + ref, compartment string, + key []string, + ifMatch string, + returnRow bool, +) (DeleteRowResult, error) { + b.mu.Lock("DeleteRow") + defer b.mu.Unlock() + + t, aerr := b.rowTable(ref, compartment) + if aerr != nil { + return DeleteRowResult{}, aerr + } + + kv, aerr := parseKey(t, key) + if aerr != nil { + return DeleteRowResult{}, aerr + } + + ks, _ := rowKey(t.PrimaryKey, kv) + now := b.nowUTC() + r := t.live(ks, now) + res := DeleteRowResult{} + + if r != nil && returnRow { + res.ExistingVersion, res.ExistingValue = ptrIf(r.Version), r.Value + res.Usage.ReadUnitsConsumed = unitsFor(len(r.Value)) + } + + if r != nil && (ifMatch == "" || ifMatch == r.Version) { + delete(t.Rows, ks) + res.IsSuccess = true + res.Usage.WriteUnitsConsumed = unitsFor(len(r.Value)) + } else if r == nil { + res.Usage.WriteUnitsConsumed = 1 + } + + t.record(now, res.Usage.ReadUnitsConsumed, res.Usage.WriteUnitsConsumed) + + return res, nil +} + +func (h *Handler) updateRow(q *req) error { + var in updateRowIn + if err := q.decode(&in); err != nil { + return err + } + + res, err := h.Backend.UpdateRow(q.param("tableNameOrId"), q.ifMatch(), in) + + return reply(q, res, "", err) +} + +func (h *Handler) getRow(q *req) error { + res, etag, err := h.Backend.GetRow(q.param("tableNameOrId"), q.query("compartmentId"), q.r.URL.Query()["key"], + q.query("consistency")) + + return reply(q, res, etag, err) +} + +func (h *Handler) deleteRow(q *req) error { + res, err := h.Backend.DeleteRow(q.param("tableNameOrId"), q.query("compartmentId"), q.r.URL.Query()["key"], + q.ifMatch(), q.flag("isGetReturnRow")) + + return reply(q, res, "", err) +} diff --git a/services/ocinosql/settings.go b/services/ocinosql/settings.go new file mode 100644 index 0000000000..ea01255cb7 --- /dev/null +++ b/services/ocinosql/settings.go @@ -0,0 +1,13 @@ +package ocinosql + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8162 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCINoSQL. +type Settings struct { + Port int `json:"port" env:"OCI_NOSQL_PORT" default:"8162" name:"port" help:"Fixed TCP port for the dedicated OCI NoSQL Database listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_NOSQL_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ocinosql/sqlexec.go b/services/ocinosql/sqlexec.go new file mode 100644 index 0000000000..a2479f3f5f --- /dev/null +++ b/services/ocinosql/sqlexec.go @@ -0,0 +1,442 @@ +package ocinosql + +import ( + "encoding/json" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +type entry struct { + row map[string]any + size int +} + +type plan struct { + table *Table + sv *schemaView + stmt *selectStmt + vars map[string]any + desc bool +} + +func (pl *plan) normPath(steps []step) ([]step, *APIError) { + if len(steps) > 1 && !steps[0].isIdx { + _, isCol := pl.sv.lookup(steps[0].key) + if !isCol && (strs.Equal(steps[0].key, pl.stmt.alias) || strs.Equal(steps[0].key, pl.stmt.table)) { + steps = steps[1:] + } + } + + if steps[0].isIdx { + return nil, errParam("syntax error: a path must start with a column name") + } + + i, ok := pl.sv.lookup(steps[0].key) + if !ok { + return nil, errParam("column '" + steps[0].key + "' does not exist in table " + pl.table.Name) + } + + out := append([]step{}, steps...) + out[0].key = pl.sv.cols[i].Name + + return out, nil +} + +// bind resolves paths in an expression tree and checks variable references. +func (pl *plan) bind(e expr) *APIError { + switch x := e.(type) { + case *binExpr: + return firstErr(pl.bind(x.l), pl.bind(x.r)) + case *notExpr: + return pl.bind(x.e) + case *nullExpr: + return pl.bind(x.e) + case *inExpr: + if aerr := pl.bind(x.e); aerr != nil { + return aerr + } + + for _, v := range x.list { + if aerr := pl.bind(v); aerr != nil { + return aerr + } + } + case *pathExpr: + steps, aerr := pl.normPath(x.steps) + if aerr != nil { + return aerr + } + + x.steps = steps + case *varExpr: + if _, ok := pl.stmt.vars[x.name]; !ok { + return errParam("variable " + x.name + " is used but not declared") + } + } + + return nil +} + +func newPlan(t *Table, st *selectStmt, vars map[string]any) (*plan, *APIError) { + pl := &plan{table: t, sv: newSchemaView(t.Columns), stmt: st} + + bound, aerr := bindVars(st, vars) + if aerr != nil { + return nil, aerr + } + + pl.vars = bound + + if st.where != nil { + if aerr = pl.bind(st.where); aerr != nil { + return nil, aerr + } + } + + for i := range st.items { + if st.items[i].path, aerr = pl.normPath(st.items[i].path); aerr != nil { + return nil, aerr + } + } + + for i := range st.order { + if st.order[i].path, aerr = pl.normPath(st.order[i].path); aerr != nil { + return nil, aerr + } + } + + return pl, pl.checkOrder() +} + +func bindVars(st *selectStmt, vars map[string]any) (map[string]any, *APIError) { + out := map[string]any{} + + for name, ty := range st.vars { + v, ok := vars[name] + if !ok { + v, ok = vars[strings.TrimPrefix(name, "$")] + } + + if !ok { + return nil, errParam("variable " + name + " is declared but not bound") + } + + n, msg := coerce(ty, v) + if msg != "" { + return nil, errParam("variable " + name + " " + msg) + } + + out[name] = n + } + + return out, nil +} + +func pathKey(steps []step) (string, bool) { + var sb strings.Builder + + for i, s := range steps[1:] { + if s.isIdx { + return "", false + } + + if i > 0 { + sb.WriteByte('.') + } + + sb.WriteString(s.key) + } + + return sb.String(), true +} + +func (pl *plan) checkOrder() *APIError { + ord := pl.stmt.order + if len(ord) == 0 { + return nil + } + + for _, o := range ord[1:] { + if o.desc != ord[0].desc { + return errParam("ORDER BY with mixed ASC and DESC is not supported") + } + } + + pl.desc = ord[0].desc + + if matchesKey(ord, pl.table.PrimaryKey) { + return nil + } + + for _, ix := range pl.table.Indexes { + if ix.State != stateActive || len(ord) > len(ix.Keys) { + continue + } + + ok := true + + for i, o := range ord { + jp, plain := pathKey(o.path) + if !plain || !strs.Equal(o.path[0].key, ix.Keys[i].Column) || jp != ix.Keys[i].JSONPath { + ok = false + + break + } + } + + if ok { + return nil + } + } + + return errParam("ORDER BY must name a leading prefix of the primary key or of an ACTIVE index") +} + +func matchesKey(ord []orderItem, pk []string) bool { + if len(ord) > len(pk) { + return false + } + + for i, o := range ord { + if len(o.path) != 1 || !strs.Equal(o.path[0].key, pk[i]) { + return false + } + } + + return true +} + +func lookupStep(v any, s step) (any, bool) { + if s.isIdx { + arr, ok := v.([]any) + if !ok || s.idx >= len(arr) { + return nil, false + } + + return arr[s.idx], true + } + + m, ok := v.(map[string]any) + if !ok { + return nil, false + } + + if x, found := m[s.key]; found { + return x, true + } + + for k, x := range m { + if strs.Equal(k, s.key) { + return x, true + } + } + + return nil, false +} + +func evalPath(row map[string]any, steps []step) any { + var cur any = row + + for _, s := range steps { + next, ok := lookupStep(cur, s) + if !ok { + return nil + } + + cur = next + } + + return cur +} + +func (pl *plan) value(e expr, row map[string]any) any { + switch x := e.(type) { + case *litExpr: + return x.v + case *varExpr: + return pl.vars[x.name] + case *pathExpr: + return evalPath(row, x.steps) + default: + return pl.truth(e, row) + } +} + +func (pl *plan) truth(e expr, row map[string]any) bool { + switch x := e.(type) { + case *binExpr: + return pl.evalBin(x, row) + case *notExpr: + return !pl.truth(x.e, row) + case *nullExpr: + return (pl.value(x.e, row) == nil) != x.not + case *inExpr: + v := pl.value(x.e, row) + found := false + + for _, c := range x.list { + if r, ok := compareVals(v, pl.value(c, row)); ok && r == 0 { + found = true + } + } + + return found != x.not && v != nil + } + + b, _ := pl.value(e, row).(bool) + + return b +} + +func (pl *plan) evalBin(x *binExpr, row map[string]any) bool { + switch x.op { + case kwAnd: + return pl.truth(x.l, row) && pl.truth(x.r, row) + case "OR": + return pl.truth(x.l, row) || pl.truth(x.r, row) + } + + l, r := pl.value(x.l, row), pl.value(x.r, row) + if l == nil || r == nil { + return false + } + + c, ok := compareVals(l, r) + if !ok { + return x.op == "!=" + } + + switch x.op { + case "=": + return c == 0 + case "!=": + return c != 0 + case "<": + return c < 0 + case "<=": + return c <= 0 + case ">": + return c > 0 + } + + return c >= 0 +} + +func cmpNilFirst(a, b any) int { + switch { + case a == nil && b == nil: + return 0 + case a == nil: + return -1 + case b == nil: + return 1 + } + + c, _ := compareVals(a, b) + + return c +} + +func (pl *plan) sortEntries(es []entry) { + order := pl.stmt.order + pk := pl.table.PrimaryKey + + slices.SortStableFunc(es, func(a, b entry) int { + for _, o := range order { + if c := cmpNilFirst(evalPath(a.row, o.path), evalPath(b.row, o.path)); c != 0 { + if pl.desc { + return -c + } + + return c + } + } + + for _, k := range pk { + if c := cmpNilFirst(a.row[k], b.row[k]); c != 0 { + if pl.desc { + return -c + } + + return c + } + } + + return 0 + }) +} + +func (pl *plan) project(es []entry) []map[string]any { + st := pl.stmt + if st.count { + return []map[string]any{{"Column_1": json.Number(strconv.Itoa(len(es)))}} + } + + out := make([]map[string]any, 0, len(es)) + + for _, e := range es { + if st.star { + out = append(out, e.row) + + continue + } + + m := make(map[string]any, len(st.items)) + + for i, it := range st.items { + name := it.alias + if name == "" { + last := it.path[len(it.path)-1] + name = last.key + + if last.isIdx || m[name] != nil { + name = "Column_" + strconv.Itoa(i+1) + } + } + + m[name] = evalPath(e.row, it.path) + } + + out = append(out, m) + } + + return out +} + +// run scans the table, filters, orders and slices the rows; scanned is the number of bytes read. +func (pl *plan) run(now func() time.Time) ([]map[string]any, int) { + scanned := 0 + + var es []entry + + for k, r := range pl.table.Rows { + if r.Expire != nil && !r.Expire.After(now()) { + delete(pl.table.Rows, k) + + continue + } + + scanned += len(r.Value) + row := decodeRow(r.Value) + + if pl.stmt.where == nil || pl.truth(pl.stmt.where, row) { + es = append(es, entry{row: row, size: len(r.Value)}) + } + } + + pl.sortEntries(es) + + if o := pl.stmt.offset; o != nil { + es = es[min(int(*o), len(es)):] + } + + if l := pl.stmt.limit; l != nil && int(*l) < len(es) { + es = es[:*l] + } + + return pl.project(es), scanned +} diff --git a/services/ocinosql/sqlparse.go b/services/ocinosql/sqlparse.go new file mode 100644 index 0000000000..0ce8cc9d24 --- /dev/null +++ b/services/ocinosql/sqlparse.go @@ -0,0 +1,686 @@ +package ocinosql + +import ( + "encoding/json" + "strconv" + "strings" +) + +type step struct { + key string + idx int + isIdx bool +} + +type expr any + +type ( + binExpr struct { + l, r expr + op string + } + notExpr struct{ e expr } + inExpr struct { + e expr + list []expr + not bool + } + nullExpr struct { + e expr + not bool + } + pathExpr struct{ steps []step } + litExpr struct{ v any } + varExpr struct{ name string } +) + +type selItem struct { + alias string + path []step +} + +type orderItem struct { + path []step + desc bool +} + +type selectStmt struct { + where expr + vars map[string]*dtype + limit *int64 + offset *int64 + table string + alias string + items []selItem + order []orderItem + star bool + count bool +} + +var unsupportedKw = map[string]bool{ //nolint:gochecknoglobals // immutable lookup + "GROUP": true, "JOIN": true, "HAVING": true, "UNION": true, "LEFT": true, "INNER": true, "DISTINCT": true, + "LIKE": true, "EXISTS": true, sqlSelect: true, +} + +func (p *parser) unsupported(what string) *APIError { + return errParam("syntax error: " + what + " is not supported by this SQL subset") +} + +func parseSelect(sql string) (*selectStmt, *APIError) { + p, err := newParser(sql) + if err != nil { + return nil, err + } + + st := &selectStmt{vars: map[string]*dtype{}} + + if p.acceptKw("DECLARE") { + if e := p.parseDeclare(st); e != nil { + return nil, e + } + } + + if e := p.expectKw(sqlSelect); e != nil { + return nil, e + } + + if e := p.parseSelectList(st); e != nil { + return nil, e + } + + if e := p.parseFromTail(st); e != nil { + return nil, e + } + + if !p.atEOF() { + if t := p.peek(); unsupportedKw[t.up] { + return nil, p.unsupported(t.up) + } + + return nil, p.fail("end of statement") + } + + return st, nil +} + +func (p *parser) parseDeclare(st *selectStmt) *APIError { + for p.peek().kind == tkVar { + name := p.next().text + + t, e := p.parseType() + if e != nil { + return e + } + + st.vars[name] = t + + if e = p.expectPunct(";"); e != nil { + return e + } + } + + if len(st.vars) == 0 { + return p.fail("a variable declaration") + } + + return nil +} + +func (p *parser) parseSelectList(st *selectStmt) *APIError { + if e := p.unsupportedHere(); e != nil { + return e + } + + if p.acceptPunct("*") { + st.star = true + + return nil + } + + for { + if e := p.parseSelectItem(st); e != nil { + return e + } + + if !p.acceptPunct(",") { + break + } + } + + if st.count && len(st.items) > 0 { + return p.unsupported("mixing COUNT(*) with other select items") + } + + return nil +} + +func (p *parser) parseSelectItem(st *selectStmt) *APIError { + if p.isKw("COUNT") && p.toks[p.pos+1].text == "(" { + p.pos++ + + st.count = true + + return firstErr(p.expectPunct("("), p.expectPunct("*"), p.expectPunct(")")) + } + + steps, e := p.parsePath() + if e != nil { + return e + } + + it := selItem{path: steps} + if p.acceptKw("AS") { + if it.alias, e = p.ident("an alias"); e != nil { + return e + } + } + + st.items = append(st.items, it) + + return nil +} + +func (p *parser) unsupportedHere() *APIError { + if t := p.peek(); t.kind == tkIdent && (t.up == "DISTINCT" || t.up == sqlSelect) { + return p.unsupported(t.up) + } + + return nil +} + +func (p *parser) parseFromTail(st *selectStmt) *APIError { + if e := p.expectKw("FROM"); e != nil { + return e + } + + name, e := p.ident("a table name") + if e != nil { + return e + } + + if p.isPunct(".") { + return p.unsupported("child tables") + } + + st.table, st.alias = name, name + + if p.acceptKw("AS") { + if st.alias, e = p.ident("an alias"); e != nil { + return e + } + } else if t := p.peek(); t.kind == tkIdent && !isClauseKw(t.up) && !unsupportedKw[t.up] { + st.alias = p.next().text + } + + if p.acceptKw("WHERE") { + if st.where, e = p.parseOr(); e != nil { + return e + } + } + + return p.parseOrderLimit(st) +} + +func isClauseKw(up string) bool { + switch up { + case "WHERE", "ORDER", "LIMIT", "OFFSET": + return true + } + + return false +} + +func (p *parser) parseOrderLimit(st *selectStmt) *APIError { + if p.acceptKw("ORDER") { + if e := p.parseOrderBy(st); e != nil { + return e + } + } + + for { + switch { + case p.acceptKw("LIMIT"): + n, e := p.nonNegInt("LIMIT") + if e != nil { + return e + } + + st.limit = &n + case p.acceptKw("OFFSET"): + n, e := p.nonNegInt("OFFSET") + if e != nil { + return e + } + + st.offset = &n + default: + return nil + } + } +} + +func (p *parser) parseOrderBy(st *selectStmt) *APIError { + if e := p.expectKw("BY"); e != nil { + return e + } + + for { + steps, e := p.parsePath() + if e != nil { + return e + } + + it := orderItem{path: steps} + if p.acceptKw("DESC") { + it.desc = true + } else { + p.acceptKw("ASC") + } + + st.order = append(st.order, it) + + if !p.acceptPunct(",") { + return nil + } + } +} + +func (p *parser) nonNegInt(what string) (int64, *APIError) { + n, err := strconv.ParseInt(p.next().text, 10, 32) + if err != nil || n < 0 { + return 0, errParam("syntax error: " + what + " must be a non-negative integer") + } + + return n, nil +} + +func (p *parser) parsePath() ([]step, *APIError) { + key, e := p.ident("a column name") + if e != nil { + return nil, e + } + + if p.isPunct("(") { + return nil, p.unsupported("function " + key + "()") + } + + steps := []step{{key: key}} + + for { + switch { + case p.acceptPunct("."): + k, e2 := p.ident("a field name") + if e2 != nil { + return nil, e2 + } + + steps = append(steps, step{key: k}) + case p.acceptPunct("["): + n, err := strconv.Atoi(p.next().text) + if err != nil || n < 0 { + return nil, p.unsupported("array slice or filter steps") + } + + if e2 := p.expectPunct("]"); e2 != nil { + return nil, e2 + } + + steps = append(steps, step{idx: n, isIdx: true}) + default: + return steps, nil + } + } +} + +func (p *parser) parseOr() (expr, *APIError) { + l, e := p.parseAnd() + if e != nil { + return nil, e + } + + for p.acceptKw("OR") { + r, e2 := p.parseAnd() + if e2 != nil { + return nil, e2 + } + + l = &binExpr{op: "OR", l: l, r: r} + } + + return l, nil +} + +func (p *parser) parseAnd() (expr, *APIError) { + l, e := p.parseNot() + if e != nil { + return nil, e + } + + for p.acceptKw(kwAnd) { + r, e2 := p.parseNot() + if e2 != nil { + return nil, e2 + } + + l = &binExpr{op: kwAnd, l: l, r: r} + } + + return l, nil +} + +func (p *parser) parseNot() (expr, *APIError) { + if p.acceptKw("NOT") { + e, err := p.parseNot() + if err != nil { + return nil, err + } + + return ¬Expr{e: e}, nil + } + + return p.parseCompare() +} + +//nolint:gochecknoglobals // immutable lookup +var cmpOps = map[string]string{"=": "=", "<": "<", "<=": "<=", ">": ">", ">=": ">=", "<>": "!=", "!=": "!="} + +func (p *parser) parseCompare() (expr, *APIError) { + l, e := p.parseOperand() + if e != nil { + return nil, e + } + + t := p.peek() + + if op, ok := cmpOps[t.text]; ok && t.kind == tkPunct { + p.pos++ + + r, e2 := p.parseOperand() + if e2 != nil { + return nil, e2 + } + + return &binExpr{op: op, l: l, r: r}, nil + } + + if t.kind != tkIdent { + return l, nil + } + + switch t.up { + case "IS": + p.pos++ + not := p.acceptKw("NOT") + + return &nullExpr{e: l, not: not}, p.expectKw(kwNull) + case "IN", "NOT", "BETWEEN": + return p.parseInBetween(l) + } + + return l, nil +} + +func (p *parser) parseInBetween(l expr) (expr, *APIError) { + not := p.acceptKw("NOT") + + if p.acceptKw("BETWEEN") { + lo, e := p.parseOperand() + if e != nil { + return nil, e + } + + if e = p.expectKw(kwAnd); e != nil { + return nil, e + } + + hi, e := p.parseOperand() + if e != nil { + return nil, e + } + + var out expr = &binExpr{op: kwAnd, l: &binExpr{op: ">=", l: l, r: lo}, r: &binExpr{op: "<=", l: l, r: hi}} + if not { + out = ¬Expr{e: out} + } + + return out, nil + } + + if e := p.expectKw("IN"); e != nil { + return nil, e + } + + if e := p.expectPunct("("); e != nil { + return nil, e + } + + in := &inExpr{e: l, not: not} + + for { + v, e := p.parseOperand() + if e != nil { + return nil, e + } + + in.list = append(in.list, v) + + if !p.acceptPunct(",") { + break + } + } + + return in, p.expectPunct(")") +} + +func (p *parser) parseOperand() (expr, *APIError) { + t := p.peek() + + switch { + case t.kind == tkPunct && t.text == "(": + p.pos++ + + e, err := p.parseOr() + if err != nil { + return nil, err + } + + return e, p.expectPunct(")") + case t.kind == tkPunct && t.text == "-": + p.pos++ + + n := p.next() + if n.kind != tkNumber { + return nil, p.fail("a number") + } + + return &litExpr{v: json.Number("-" + n.text)}, nil + case t.kind == tkIdent && !isLiteralKw(t.up): + steps, err := p.parsePath() + if err != nil { + return nil, err + } + + return &pathExpr{steps: steps}, nil + } + + lit, ok := literalOf(t) + if !ok { + return nil, p.fail("an expression") + } + + p.pos++ + + return lit, nil +} + +func isLiteralKw(up string) bool { return up == kwTrue || up == kwFalse || up == kwNull } + +func literalOf(t tok) (expr, bool) { + switch { + case t.kind == tkNumber: + return &litExpr{v: json.Number(t.text)}, true + case t.kind == tkString: + return &litExpr{v: t.text}, true + case t.kind == tkVar: + return &varExpr{name: t.text}, true + case t.kind == tkIdent && (t.up == kwTrue || t.up == kwFalse): + return &litExpr{v: t.up == kwTrue}, true + case t.kind == tkIdent && t.up == kwNull: + return &litExpr{v: nil}, true + } + + return nil, false +} + +// stmtKind classifies the leading keywords of a statement for SummarizeStatement. +type stmtSummary struct { + syntaxError string + operation string + table string + index string + ifExists bool + ifNotExists bool +} + +func summarize(sql string) stmtSummary { + p, err := newParser(sql) + if err != nil { + return stmtSummary{syntaxError: err.Message} + } + + if p.isKw("DECLARE") { + return summarizeSelect(sql) + } + + first := p.next().up + + switch first { + case sqlSelect: + return summarizeSelect(sql) + case "CREATE": + return summarizeCreate(p, sql) + case "ALTER": + s := stmtSummary{operation: "ALTER_TABLE"} + if _, e := parseAlter(sql); e != nil { + s.syntaxError = e.Message + } + + p.acceptKw("TABLE") + s.table = p.next().text + + return s + case "DROP": + return summarizeDrop(p) + case sqlInsert: + p.acceptKw("INTO") + + return stmtSummary{operation: sqlInsert, table: p.next().text} + case "UPSERT": + return stmtSummary{operation: sqlInsert, table: p.next().text} + case sqlUpdate: + return stmtSummary{operation: sqlUpdate, table: p.next().text} + case sqlDelete: + p.acceptKw("FROM") + + return stmtSummary{operation: sqlDelete, table: p.next().text} + } + + return stmtSummary{syntaxError: "syntax error: unrecognized statement '" + strings.TrimSpace(first) + "'"} +} + +func summarizeSelect(sql string) stmtSummary { + s := stmtSummary{operation: sqlSelect} + + st, e := parseSelect(sql) + if e != nil { + s.syntaxError = e.Message + + return s + } + + s.table = st.table + + return s +} + +func summarizeCreate(p *parser, sql string) stmtSummary { + if p.acceptKw("TABLE") { + s := stmtSummary{operation: "CREATE_TABLE"} + + spec, e := parseCreate(sql) + if e != nil { + s.syntaxError = e.Message + s.ifNotExists = p.isKw("IF") + s.table = p.next().text + + return s + } + + s.table, s.ifNotExists = spec.name, spec.ifNotExists + + return s + } + + s := stmtSummary{operation: "CREATE_INDEX"} + + if !p.acceptKw("INDEX") { + return stmtSummary{syntaxError: "syntax error: expected TABLE or INDEX after CREATE"} + } + + if p.acceptKw("IF") { + s.ifNotExists = p.acceptKw("NOT") && p.acceptKw("EXISTS") + } + + s.index = p.next().text + + if e := p.expectKw("ON"); e != nil { + s.syntaxError = e.Message + + return s + } + + s.table = p.next().text + + if e := p.expectPunct("("); e != nil { + s.syntaxError = e.Message + } + + return s +} + +func summarizeDrop(p *parser) stmtSummary { + kind := p.next().up + s := stmtSummary{} + + switch kind { + case "TABLE": + s.operation = "DROP_TABLE" + case "INDEX": + s.operation = "DROP_INDEX" + default: + return stmtSummary{syntaxError: "syntax error: expected TABLE or INDEX after DROP"} + } + + if p.acceptKw("IF") { + s.ifExists = p.acceptKw("EXISTS") + } + + name := p.next().text + if kind == "TABLE" { + s.table = name + + return s + } + + s.index = name + + if e := p.expectKw("ON"); e != nil { + s.syntaxError = e.Message + + return s + } + + s.table = p.next().text + + return s +} diff --git a/services/ocinosql/store.go b/services/ocinosql/store.go new file mode 100644 index 0000000000..7668243bca --- /dev/null +++ b/services/ocinosql/store.go @@ -0,0 +1,220 @@ +package ocinosql + +import ( + "cmp" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "us-ashburn-1" + defaultListLimit = 100 + ocidTailLen = 52 + + defaultTransition = 2 * time.Second +) + +// InMemoryBackend holds every table and work request under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + tables map[string]*Table + workReqs map[string]*WorkRequest + Auth *ociauth.Registry + compartment func(string) bool + transition time.Duration + seq int64 +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{mu: lockmetrics.New("ocinosql"), now: time.Now, transition: defaultTransition} + b.resetMaps() + b.Auth = ociauth.NewRegistry() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.tables, b.workReqs = map[string]*Table{}, map[string]*WorkRequest{} +} + +// SetClock injects the time source used for lifecycle transitions and TTL expiry. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTransition sets how long CREATING, UPDATING and DELETING states last. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetCompartmentChecker validates compartmentId arguments against another service (OCI Identity). +func (b *InMemoryBackend) SetCompartmentChecker(fn func(string) bool) { + b.mu.Lock("SetCompartmentChecker") + b.compartment = fn + b.mu.Unlock() +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) transitionAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} + +func (b *InMemoryBackend) checkCompartment(id string) *APIError { + if id == "" { + return errMissing("compartmentId is required") + } + + if b.compartment != nil && !b.compartment(id) { + return errNotFound(id) + } + + return nil +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate[T any](items []T, o ListOpts, name func(T) string, created func(T) (time.Time, int64)) page.Page[T] { + byName := strings.EqualFold(o.SortBy, "name") + desc := !byName + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + slices.SortStableFunc(items, func(a, c T) int { + var r int + if byName { + r = cmp.Compare(strs.Fold(name(a)), strs.Fold(name(c))) + } else { + ta, sa := created(a) + tc, sc := created(c) + r = cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +// Sweep applies lifecycle transitions against the backend clock. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + now := b.nowUTC() + for _, t := range b.tables { + settle(t, now) + } +} + +// SweepRows removes expired rows from every table. +func (b *InMemoryBackend) SweepRows() { + b.mu.Lock("SweepRows") + defer b.mu.Unlock() + + now := b.nowUTC() + for _, t := range b.tables { + purgeExpired(t, now) + } +} + +func due(at *time.Time, now time.Time) bool { return at != nil && !at.After(now) } + +// settle moves a table and its indexes through their pending transitions. +func settle(t *Table, now time.Time) { + if due(t.TransitionAt, now) { + t.TransitionAt = nil + + switch t.State { + case stateCreating, stateUpdating: + t.State = stateActive + case stateDeleting: + t.State = stateDeleted + t.Rows, t.Indexes, t.Usage = nil, nil, nil + } + } + + for name, ix := range t.Indexes { + if !due(ix.TransitionAt, now) { + continue + } + + ix.TransitionAt = nil + + if ix.State == stateDeleting { + delete(t.Indexes, name) + + continue + } + + ix.State = stateActive + } +} + +func purgeExpired(t *Table, now time.Time) { + for k, r := range t.Rows { + if r.Expire != nil && !r.Expire.After(now) { + delete(t.Rows, k) + } + } +} diff --git a/services/ocinosql/table_test.go b/services/ocinosql/table_test.go new file mode 100644 index 0000000000..7fcaffdc85 --- /dev/null +++ b/services/ocinosql/table_test.go @@ -0,0 +1,570 @@ +package ocinosql_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/nosql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTableLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + ctx := t.Context() + + ddl := "CREATE TABLE orders (id LONG, region STRING, total NUMBER NOT NULL DEFAULT 0, tags ARRAY(STRING), " + + "attrs MAP(INTEGER), addr RECORD(city STRING, zip INTEGER), at TIMESTAMP(3), blob BINARY, " + + "PRIMARY KEY(SHARD(region), id)) USING TTL 5 DAYS" + + created := e.createTable(t, "orders", ddl, provisioned(100, 200, 25)) + + wr := e.workRequest(t, created.OpcWorkRequestId) + assert.Equal(t, nosql.WorkRequestStatusInProgress, wr.Status) + assert.Equal(t, nosql.WorkRequestOperationTypeCreateTable, wr.OperationType) + assert.Equal(t, testCompartment, *wr.CompartmentId) + + tbl := e.getTable(t, "ORDERS") + assert.Equal(t, nosql.TableLifecycleStateCreating, tbl.LifecycleState) + + _, err := e.client.GetRow(ctx, nosql.GetRowRequest{ + TableNameOrId: new("orders"), CompartmentId: new(testCompartment), Key: []string{"region:a", "id:1"}, + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "rows unavailable while CREATING") + + e.settle() + + tbl = e.getTable(t, "orders") + assert.Equal(t, nosql.TableLifecycleStateActive, tbl.LifecycleState) + assert.Equal(t, nosql.WorkRequestStatusSucceeded, e.workRequest(t, created.OpcWorkRequestId).Status) + assert.Equal(t, 100, *tbl.TableLimits.MaxReadUnits) + assert.Equal(t, 200, *tbl.TableLimits.MaxWriteUnits) + assert.Equal(t, 25, *tbl.TableLimits.MaxStorageInGBs) + assert.Equal(t, nosql.TableLimitsCapacityModeProvisioned, tbl.TableLimits.CapacityMode) + assert.Equal(t, 5, *tbl.Schema.Ttl) + assert.Equal(t, []string{"region", "id"}, tbl.Schema.PrimaryKey) + assert.Equal(t, []string{"region"}, tbl.Schema.ShardKey) + + types := map[string]string{} + nullable := map[string]bool{} + + for _, c := range tbl.Schema.Columns { + types[*c.Name], nullable[*c.Name] = *c.Type, *c.IsNullable + } + + assert.Equal(t, map[string]string{ + "id": "LONG", "region": "STRING", "total": "NUMBER", "tags": "ARRAY(STRING)", "attrs": "MAP(INTEGER)", + "addr": "RECORD(city STRING, zip INTEGER)", "at": "TIMESTAMP(3)", "blob": "BINARY", + }, types) + assert.False(t, nullable["id"], "key columns are NOT NULL") + assert.False(t, nullable["total"]) + assert.True(t, nullable["tags"]) + + byID, err := e.client.GetTable(ctx, nosql.GetTableRequest{TableNameOrId: tbl.Id}) + require.NoError(t, err) + assert.Equal(t, "orders", *byID.Name) + assert.NotEmpty(t, *byID.Etag) + + list, err := e.client.ListTables(ctx, nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, "orders", *list.Items[0].Name) + + del, err := e.client.DeleteTable(ctx, nosql.DeleteTableRequest{ + TableNameOrId: tbl.Id, IfMatch: byID.Etag, + }) + require.NoError(t, err) + assert.Equal(t, nosql.TableLifecycleStateDeleting, e.getTable(t, "orders").LifecycleState) + e.settle() + assert.Equal(t, nosql.WorkRequestStatusSucceeded, e.workRequest(t, del.OpcWorkRequestId).Status) + + list, err = e.client.ListTables(ctx, nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, list.Items) + + all, err := e.client.ListTables(ctx, nosql.ListTablesRequest{ + CompartmentId: new(testCompartment), LifecycleState: nosql.ListTablesLifecycleStateAll, + }) + require.NoError(t, err) + require.Len(t, all.Items, 1) + assert.Equal(t, nosql.TableLifecycleStateDeleted, all.Items[0].LifecycleState) + + e.createTable(t, "orders", ddl, provisioned(10, 10, 1)) +} + +func TestCreateTableErrors(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "taken", usersDDL("taken")) + + tests := []struct { + limits *nosql.TableLimits + name string + tbl string + ddl string + code string + status int + }{ + {name: "duplicate", tbl: "taken", ddl: usersDDL("taken"), status: 409, code: "Conflict"}, + {name: "name mismatch", tbl: "other", ddl: usersDDL("x0"), status: 400, code: "InvalidParameter"}, + {name: "not create", tbl: "x1", ddl: "SELECT * FROM x1", status: 400, code: "InvalidParameter"}, + {name: "no primary key", tbl: "x2", ddl: "CREATE TABLE x2 (a INTEGER)", status: 400, code: "InvalidParameter"}, + { + name: "pk column missing", + tbl: "x3", + ddl: "CREATE TABLE x3 (a INTEGER, PRIMARY KEY(b))", + status: 400, + code: "InvalidParameter", + }, + { + name: "pk bad type", + tbl: "x4", + ddl: "CREATE TABLE x4 (a JSON, PRIMARY KEY(a))", + status: 400, + code: "InvalidParameter", + }, + { + name: "duplicate column", + tbl: "x5", + ddl: "CREATE TABLE x5 (a INTEGER, a STRING, PRIMARY KEY(a))", + status: 400, + code: "InvalidParameter", + }, + { + name: "unknown type", + tbl: "x6", + ddl: "CREATE TABLE x6 (a INTEGER, b BLOB, PRIMARY KEY(a))", + status: 400, + code: "InvalidParameter", + }, + { + name: "shard not prefix", + tbl: "x7", + ddl: "CREATE TABLE x7 (a INTEGER, b INTEGER, PRIMARY KEY(a, SHARD(b)))", + status: 400, + code: "InvalidParameter", + }, + { + name: "child table", + tbl: "x8", + ddl: "CREATE TABLE x8.child (a INTEGER, PRIMARY KEY(a))", + status: 400, + code: "InvalidParameter", + }, + { + name: "bad default", + tbl: "x9", + ddl: "CREATE TABLE x9 (a INTEGER, b INTEGER DEFAULT 'z', PRIMARY KEY(a))", + status: 400, + code: "InvalidParameter", + }, + { + name: "trailing junk", + tbl: "y1", + ddl: "CREATE TABLE y1 (a INTEGER, PRIMARY KEY(a)) FOO", + status: 400, + code: "InvalidParameter", + }, + { + name: "bad ttl unit", + tbl: "y2", + ddl: "CREATE TABLE y2 (a INTEGER, PRIMARY KEY(a)) USING TTL 1 WEEKS", + status: 400, + code: "InvalidParameter", + }, + { + name: "read units zero", + tbl: "y3", + ddl: "CREATE TABLE y3 (a INTEGER, PRIMARY KEY(a))", + limits: provisioned(0, 10, 1), + status: 400, + code: "InvalidParameter", + }, + { + name: "write units too big", + tbl: "y4", + ddl: "CREATE TABLE y4 (a INTEGER, PRIMARY KEY(a))", + limits: provisioned(10, 40001, 1), + status: 400, + code: "InvalidParameter", + }, + { + name: "storage too big", + tbl: "y5", + ddl: "CREATE TABLE y5 (a INTEGER, PRIMARY KEY(a))", + limits: provisioned(10, 10, 5001), + status: 400, + code: "InvalidParameter", + }, + { + name: "storage zero", + tbl: "y6", + ddl: "CREATE TABLE y6 (a INTEGER, PRIMARY KEY(a))", + limits: provisioned(10, 10, 0), + status: 400, + code: "InvalidParameter", + }, + { + name: "bad capacity mode", + tbl: "y7", + ddl: "CREATE TABLE y7 (a INTEGER, PRIMARY KEY(a))", + status: 400, + code: "InvalidParameter", + limits: &nosql.TableLimits{MaxStorageInGBs: new(1), CapacityMode: "BURST"}, + }, + { + name: "bad table name", + tbl: "1bad", + ddl: "CREATE TABLE 1bad (a INTEGER, PRIMARY KEY(a))", + status: 400, + code: "InvalidParameter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + limits := tt.limits + if limits == nil { + limits = provisioned(10, 10, 1) + } + + _, err := e.client.CreateTable(t.Context(), nosql.CreateTableRequest{ + Name: new(tt.tbl), CompartmentId: new(testCompartment), DdlStatement: new(tt.ddl), TableLimits: limits, + }) + se := svcErr(t, err) + assert.Equal(t, tt.status, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestTableLimits(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + ctx := t.Context() + + _, err := e.client.CreateTable(ctx, nosql.CreateTableRequest{ + Name: new("nolimits"), CompartmentId: new(testCompartment), + DdlStatement: new("CREATE TABLE nolimits (a INTEGER, PRIMARY KEY(a))")}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "limits required") + + _, err = e.client.CreateTable(ctx, nosql.CreateTableRequest{ + Name: new("reclaim"), CompartmentId: new(testCompartment), IsAutoReclaimable: new(true), + DdlStatement: new("CREATE TABLE reclaim (a INTEGER, PRIMARY KEY(a))")}) + require.NoError(t, err) + + e.createTable(t, "ondemand", "CREATE TABLE ondemand (a INTEGER, PRIMARY KEY(a))", + &nosql.TableLimits{MaxStorageInGBs: new(40), CapacityMode: nosql.TableLimitsCapacityModeOnDemand}) + e.settle() + + od := e.getTable(t, "ondemand") + assert.Equal(t, nosql.TableLimitsCapacityModeOnDemand, od.TableLimits.CapacityMode) + assert.Equal(t, 0, *od.TableLimits.MaxReadUnits) + assert.Equal(t, 0, *od.TableLimits.MaxWriteUnits) + assert.Equal(t, 40, *od.TableLimits.MaxStorageInGBs) + + rc := e.getTable(t, "reclaim") + assert.True(t, *rc.IsAutoReclaimable) + assert.Equal(t, 25, *rc.TableLimits.MaxStorageInGBs) + + tests := []struct { + limits *nosql.TableLimits + name string + status int + }{ + {name: "to provisioned", limits: provisioned(300, 400, 80), status: 202}, + {name: "invalid", limits: provisioned(0, 400, 80), status: 400}, + } + + for _, tt := range tests { + resp, uerr := e.client.UpdateTable(ctx, nosql.UpdateTableRequest{ + TableNameOrId: new("ondemand"), + CompartmentId: new(testCompartment), TableLimits: tt.limits, + }) + if tt.status == http.StatusAccepted { + require.NoError(t, uerr, tt.name) + assert.Equal(t, nosql.TableLifecycleStateUpdating, e.getTable(t, "ondemand").LifecycleState) + + _, err = e.client.UpdateTable(ctx, nosql.UpdateTableRequest{ + TableNameOrId: new("ondemand"), + CompartmentId: new(testCompartment), TableLimits: tt.limits, + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "UPDATING blocks further updates") + + e.settle() + assert.Equal(t, nosql.WorkRequestStatusSucceeded, e.workRequest(t, resp.OpcWorkRequestId).Status) + + got := e.getTable(t, "ondemand") + assert.Equal(t, nosql.TableLimitsCapacityModeProvisioned, got.TableLimits.CapacityMode) + assert.Equal(t, 300, *got.TableLimits.MaxReadUnits) + + continue + } + + assert.Equal(t, tt.status, svcErr(t, uerr).GetHTTPStatusCode(), tt.name) + } +} + +func TestAlterTable(t *testing.T) { + t.Parallel() + + tests := []struct { + check func(t *testing.T, tbl nosql.Table) + name string + ddl string + status int + }{ + { + name: "add column", + ddl: "ALTER TABLE t (ADD nick STRING)", + status: 202, + check: func(t *testing.T, tbl nosql.Table) { + t.Helper() + assert.Len(t, tbl.Schema.Columns, 6) + }, + }, + {name: "add with default", ddl: "ALTER TABLE t (ADD score INTEGER NOT NULL DEFAULT 7)", status: 202, + check: func(t *testing.T, tbl nosql.Table) { + t.Helper() + assert.Len(t, tbl.Schema.Columns, 6) + }}, + {name: "drop column", ddl: "ALTER TABLE t (DROP age)", status: 202, check: func(t *testing.T, tbl nosql.Table) { + t.Helper() + assert.Len(t, tbl.Schema.Columns, 4) + }}, + {name: "ttl", ddl: "ALTER TABLE t USING TTL 3 DAYS", status: 202, check: func(t *testing.T, tbl nosql.Table) { + t.Helper() + assert.Equal(t, 3, *tbl.Schema.Ttl) + }}, + { + name: "ttl hours", + ddl: "ALTER TABLE t USING TTL 48 HOURS", + status: 202, + check: func(t *testing.T, tbl nosql.Table) { + t.Helper() + assert.Equal(t, 2, *tbl.Schema.Ttl) + }, + }, + {name: "add existing", ddl: "ALTER TABLE t (ADD age INTEGER)", status: 400}, + {name: "drop missing", ddl: "ALTER TABLE t (DROP nothing)", status: 400}, + {name: "drop key", ddl: "ALTER TABLE t (DROP id)", status: 400}, + {name: "not null no default", ddl: "ALTER TABLE t (ADD n INTEGER NOT NULL)", status: 400}, + {name: "wrong table", ddl: "ALTER TABLE other (ADD n INTEGER)", status: 400}, + {name: "not alter", ddl: "DROP TABLE t", status: 400}, + {name: "empty alter", ddl: "ALTER TABLE t", status: 400}, + {name: "syntax", ddl: "ALTER TABLE t (ADD)", status: 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + e.put(t, "t", map[string]any{"id": 1, "name": "ann", "age": 30}) + + resp, err := e.client.UpdateTable(t.Context(), nosql.UpdateTableRequest{ + TableNameOrId: new("t"), + CompartmentId: new(testCompartment), DdlStatement: new(tt.ddl), + }) + if tt.status != http.StatusAccepted { + assert.Equal(t, tt.status, svcErr(t, err).GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + e.settle() + assert.Equal( + t, + nosql.WorkRequestOperationTypeUpdateTable, + e.workRequest(t, resp.OpcWorkRequestId).OperationType, + ) + tt.check(t, e.getTable(t, "t")) + }) + } +} + +func TestAlterBackfillAndDrop(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + e.put(t, "t", map[string]any{"id": 1, "name": "ann", "age": 30}) + + alter := func(ddl string) { + _, err := e.client.UpdateTable(t.Context(), nosql.UpdateTableRequest{ + TableNameOrId: new("t"), + CompartmentId: new(testCompartment), DdlStatement: new(ddl), + }) + require.NoError(t, err) + e.settle() + } + + alter("ALTER TABLE t (ADD score INTEGER NOT NULL DEFAULT 7)") + + rows := e.query(t, "SELECT id, score FROM t", 0, nil) + require.Len(t, rows.Items, 1) + assert.InDelta(t, 7, rows.Items[0]["score"], 0) + + alter("ALTER TABLE t (DROP score)") + alter("ALTER TABLE t (ADD score STRING)") + + rows = e.query(t, "SELECT id, score FROM t", 0, nil) + require.Len(t, rows.Items, 1) + assert.Nil(t, rows.Items[0]["score"], "a dropped column's values do not resurface") +} + +func TestIfMatchAndIfExists(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + + _, err := e.client.UpdateTable(t.Context(), nosql.UpdateTableRequest{ + TableNameOrId: new("t"), IfMatch: new("stale"), + CompartmentId: new(testCompartment), FreeformTags: map[string]string{"a": "b"}, + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteTable(t.Context(), nosql.DeleteTableRequest{ + TableNameOrId: new("t"), CompartmentId: new(testCompartment), IfMatch: new("stale"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.client.DeleteTable(t.Context(), nosql.DeleteTableRequest{ + TableNameOrId: new("missing"), CompartmentId: new(testCompartment), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + resp, err := e.client.DeleteTable(t.Context(), nosql.DeleteTableRequest{ + TableNameOrId: new("missing"), CompartmentId: new(testCompartment), IsIfExists: new(true), + }) + require.NoError(t, err) + assert.Nil(t, resp.OpcWorkRequestId) + + _, err = e.client.GetTable(t.Context(), nosql.GetTableRequest{TableNameOrId: new("t")}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "a table name needs a compartment") + + tbl := e.getTable(t, "t") + _, err = e.client.GetTable( + t.Context(), + nosql.GetTableRequest{TableNameOrId: tbl.Id, CompartmentId: new(otherCompart)}, + ) + assert.Equal( + t, + http.StatusBadRequest, + svcErr(t, err).GetHTTPStatusCode(), + "compartment must match the OCID's table", + ) + + _, err = e.client.UpdateTable(t.Context(), nosql.UpdateTableRequest{ + TableNameOrId: new("t"), + CompartmentId: new(testCompartment), DdlStatement: new("ALTER TABLE t USING TTL 1 DAYS"), + TableLimits: provisioned(5, 5, 5), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode(), "ddl and limits are exclusive") +} + +func TestChangeTableCompartment(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + id := e.mkTable(t, "t", usersDDL("t")) + + tests := []struct { + name string + to string + status int + }{ + {name: "moves", to: otherCompart, status: 202}, + } + + for _, tt := range tests { + resp, err := e.client.ChangeTableCompartment(t.Context(), nosql.ChangeTableCompartmentRequest{ + TableNameOrId: new(id), + ToCompartmentId: new(tt.to), FromCompartmentId: new(testCompartment), + }) + require.NoError(t, err, tt.name) + assert.NotNil(t, resp.OpcWorkRequestId) + } + + got, err := e.client.GetTable(t.Context(), nosql.GetTableRequest{TableNameOrId: new(id)}) + require.NoError(t, err) + assert.Equal(t, otherCompart, *got.CompartmentId) + + list, err := e.client.ListTables(t.Context(), nosql.ListTablesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, list.Items) + + e.mkTable(t, "t", usersDDL("t")) + + _, err = e.client.ChangeTableCompartment(t.Context(), nosql.ChangeTableCompartmentRequest{ + TableNameOrId: new(id), + ToCompartmentId: new(testCompartment), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode(), "name already used there") +} + +func TestListTablesPaging(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + for _, n := range []string{"c_t", "a_t", "b_t"} { + e.createTable(t, n, "CREATE TABLE "+n+" (a INTEGER, PRIMARY KEY(a))", nil) + } + + e.settle() + + var names []string + + var page *string + + for { + resp, err := e.client.ListTables(t.Context(), nosql.ListTablesRequest{ + CompartmentId: new(testCompartment), Limit: new(2), Page: page, + SortBy: nosql.ListTablesSortByName, SortOrder: nosql.ListTablesSortOrderAsc, + }) + require.NoError(t, err) + + for _, it := range resp.Items { + names = append(names, *it.Name) + } + + if resp.OpcNextPage == nil { + break + } + + page = resp.OpcNextPage + } + + assert.Equal(t, []string{"a_t", "b_t", "c_t"}, names) + + one, err := e.client.ListTables( + t.Context(), + nosql.ListTablesRequest{CompartmentId: new(testCompartment), Name: new("B_T")}, + ) + require.NoError(t, err) + assert.Len(t, one.Items, 1) +} + +func TestIfNotExistsDDL(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + e.mkTable(t, "t", usersDDL("t")) + + resp, err := e.client.CreateTable(t.Context(), nosql.CreateTableRequest{ + Name: new("t"), CompartmentId: new(testCompartment), TableLimits: provisioned(5, 5, 5), + DdlStatement: new("CREATE TABLE IF NOT EXISTS t (id INTEGER, PRIMARY KEY(id))")}) + require.NoError(t, err) + assert.Equal(t, nosql.WorkRequestStatusSucceeded, e.workRequest(t, resp.OpcWorkRequestId).Status) + assert.Len(t, e.getTable(t, "t").Schema.Columns, 5, "existing table is untouched") +} diff --git a/services/ocinosql/tables.go b/services/ocinosql/tables.go new file mode 100644 index 0000000000..35b6d094a0 --- /dev/null +++ b/services/ocinosql/tables.go @@ -0,0 +1,550 @@ +package ocinosql + +import ( + "encoding/json" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// Emulator limit constants; Oracle documents the per-table maximums as service limits. +const ( + maxReadUnits = 40000 + maxWriteUnits = 20000 + maxStorageGB = 5000 + reclaimReadWrit = 50 + reclaimStorage = 25 + ocidPrefix = "ocid1." +) + +type limitsIn struct { + MaxReadUnits *int `json:"maxReadUnits"` + MaxWriteUnits *int `json:"maxWriteUnits"` + MaxStorageInGBs *int `json:"maxStorageInGBs"` + CapacityMode string `json:"capacityMode"` +} + +type createTableIn struct { + Tags + TableLimits *limitsIn `json:"tableLimits"` + AutoReclaim *bool `json:"isAutoReclaimable"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + DDL string `json:"ddlStatement"` +} + +type updateTableIn struct { + Tags + TableLimits *limitsIn `json:"tableLimits"` + CompartmentID string `json:"compartmentId"` + DDL string `json:"ddlStatement"` +} + +type changeCompartmentIn struct { + ToCompartmentID string `json:"toCompartmentId"` + FromCompartmentID string `json:"fromCompartmentId"` +} + +func buildLimits(in *limitsIn, autoReclaim bool) (Limits, *APIError) { + if in == nil { + if autoReclaim { + return Limits{Mode: modeProvisioned, ReadUnits: reclaimReadWrit, WriteUnits: reclaimReadWrit, + StorageGB: reclaimStorage}, nil + } + + return Limits{}, errMissing("tableLimits is required unless isAutoReclaimable is true") + } + + mode := in.CapacityMode + if mode == "" { + mode = modeProvisioned + } + + if mode != modeProvisioned && mode != modeOnDemand { + return Limits{}, errParam("capacityMode must be PROVISIONED or ON_DEMAND") + } + + if in.MaxStorageInGBs == nil || *in.MaxStorageInGBs < 1 || *in.MaxStorageInGBs > maxStorageGB { + return Limits{}, errParam("maxStorageInGBs must be between 1 and 5000") + } + + lim := Limits{Mode: mode, StorageGB: *in.MaxStorageInGBs} + if mode == modeOnDemand { + return lim, nil + } + + for _, u := range []struct { + v *int + name string + max int + }{{in.MaxReadUnits, "maxReadUnits", maxReadUnits}, {in.MaxWriteUnits, "maxWriteUnits", maxWriteUnits}} { + if u.v == nil || *u.v < 1 || *u.v > u.max { + return Limits{}, errParam(u.name + " must be between 1 and " + strconv.Itoa(u.max) + + " for PROVISIONED capacity") + } + } + + lim.ReadUnits, lim.WriteUnits = *in.MaxReadUnits, *in.MaxWriteUnits + + return lim, nil +} + +// resolve finds a live table by OCID or by name within a compartment. +func (b *InMemoryBackend) resolve(ref, compartment string) (*Table, *APIError) { + if strings.HasPrefix(ref, ocidPrefix) { + t, ok := b.tables[ref] + if !ok || t.State == stateDeleted { + return nil, errNotFound(ref) + } + + if compartment != "" && compartment != t.CompartmentID { + return nil, errParam("compartmentId does not match the compartment of table " + ref) + } + + return t, nil + } + + if compartment == "" { + return nil, errMissing("compartmentId is required when tableNameOrId is a table name") + } + + for _, t := range b.tables { + if t.CompartmentID == compartment && t.State != stateDeleted && strs.Equal(t.Name, ref) { + return t, nil + } + } + + return nil, errNotFound(ref) +} + +func (b *InMemoryBackend) nameTaken(compartment, name, except string) bool { + for _, t := range b.tables { + if t.ID != except && t.CompartmentID == compartment && t.State != stateDeleted && strs.Equal(t.Name, name) { + return true + } + } + + return false +} + +func (b *InMemoryBackend) finishAt(t *Table) time.Time { return *t.TransitionAt } + +// CreateTable creates a table from a CREATE TABLE statement and returns its work request id. +func (b *InMemoryBackend) CreateTable(in createTableIn) (string, error) { + b.mu.Lock("CreateTable") + defer b.mu.Unlock() + + if aerr := b.checkCompartment(in.CompartmentID); aerr != nil { + return "", aerr + } + + spec, aerr := parseCreate(in.DDL) + if aerr != nil { + return "", aerr + } + + if in.Name == "" || !strs.Equal(in.Name, spec.name) { + return "", errParam("name must match the table name in ddlStatement") + } + + if aerr = validIdent("table", in.Name); aerr != nil { + return "", aerr + } + + if b.nameTaken(in.CompartmentID, in.Name, "") { + return b.createExisting(in, spec) + } + + reclaim := in.AutoReclaim != nil && *in.AutoReclaim + + lim, aerr := buildLimits(in.TableLimits, reclaim) + if aerr != nil { + return "", aerr + } + + now := b.nowUTC() + t := &Table{ + ID: newOCID("nosqltable"), CompartmentID: in.CompartmentID, Name: in.Name, State: stateCreating, + Etag: newEtag(), Limits: lim, Columns: spec.cols, PrimaryKey: spec.pk, + ShardKey: spec.shard, Identity: spec.identity, AutoReclaim: reclaim, TimeCreated: now, TimeUpdated: now, + Rows: map[string]*Row{}, Indexes: map[string]*Index{}, Usage: map[int64]*Usage{}, Tags: in.Tags, + TransitionAt: b.transitionAt(), + } + + if len(t.ShardKey) == 0 { + t.ShardKey = slices.Clone(spec.pk) + } + + if spec.ttlHours != nil { + t.TTLHours = *spec.ttlHours + } + + t.DDL = renderDDL(t) + b.tables[t.ID] = t + + return b.newWR(wrCreate, t.CompartmentID, t.ID, "CREATED", b.finishAt(t)), nil +} + +func (b *InMemoryBackend) createExisting(in createTableIn, spec *createSpec) (string, error) { + if !spec.ifNotExists { + return "", errConflict("Table " + in.Name + " already exists in compartment " + in.CompartmentID) + } + + t, aerr := b.resolve(in.Name, in.CompartmentID) + if aerr != nil { + return "", aerr + } + + return b.newWR(wrCreate, t.CompartmentID, t.ID, "CREATED", b.nowUTC()), nil +} + +func (b *InMemoryBackend) modifiable(t *Table) *APIError { + if t.State != stateActive { + return errState("Table " + t.Name + " is in state " + t.State + "; it must be ACTIVE for this operation") + } + + return nil +} + +// UpdateTable alters a table's schema, limits or tags. +func (b *InMemoryBackend) UpdateTable(ref, ifMatch string, in updateTableIn) (string, error) { + b.mu.Lock("UpdateTable") + defer b.mu.Unlock() + + t, aerr := b.resolve(ref, in.CompartmentID) + if aerr != nil { + return "", aerr + } + + if aerr = firstErr(b.modifiable(t), checkEtag(t.Etag, ifMatch)); aerr != nil { + return "", aerr + } + + if in.DDL != "" && in.TableLimits != nil { + return "", errParam("ddlStatement and tableLimits cannot both be specified in one update") + } + + changed := false + + switch { + case in.DDL != "": + if aerr = b.applyAlter(t, in.DDL); aerr != nil { + return "", aerr + } + + changed = true + case in.TableLimits != nil: + lim, e := buildLimits(in.TableLimits, false) + if e != nil { + return "", e + } + + t.Limits, changed = lim, true + } + + t.Tags.merge(in.Tags) + t.Etag, t.TimeUpdated = newEtag(), b.nowUTC() + + if !changed { + return b.newWR(wrUpdate, t.CompartmentID, t.ID, "UPDATED", b.nowUTC()), nil + } + + t.State, t.TransitionAt = stateUpdating, b.transitionAt() + + return b.newWR(wrUpdate, t.CompartmentID, t.ID, "UPDATED", b.finishAt(t)), nil +} + +// DeleteTable drops a table; ifExists turns a missing table into a no-op (empty work request id). +func (b *InMemoryBackend) DeleteTable(ref, compartment, ifMatch string, ifExists bool) (string, error) { + b.mu.Lock("DeleteTable") + defer b.mu.Unlock() + + t, aerr := b.resolve(ref, compartment) + if aerr != nil { + if ifExists && aerr.Status == http.StatusNotFound { + return "", nil + } + + return "", aerr + } + + if aerr = firstErr(b.modifiable(t), checkEtag(t.Etag, ifMatch)); aerr != nil { + return "", aerr + } + + t.State, t.TransitionAt, t.TimeUpdated = stateDeleting, b.transitionAt(), b.nowUTC() + + return b.newWR(wrDelete, t.CompartmentID, t.ID, "DELETED", b.finishAt(t)), nil +} + +// ChangeTableCompartment moves a table to another compartment. +func (b *InMemoryBackend) ChangeTableCompartment(ref, ifMatch string, in changeCompartmentIn) (string, error) { + b.mu.Lock("ChangeTableCompartment") + defer b.mu.Unlock() + + t, aerr := b.resolve(ref, in.FromCompartmentID) + if aerr != nil { + return "", aerr + } + + if aerr = firstErr(b.checkCompartment(in.ToCompartmentID), b.modifiable(t), + checkEtag(t.Etag, ifMatch)); aerr != nil { + return "", aerr + } + + if b.nameTaken(in.ToCompartmentID, t.Name, t.ID) { + return "", errConflict("Table " + t.Name + " already exists in compartment " + in.ToCompartmentID) + } + + t.CompartmentID, t.Etag, t.TimeUpdated = in.ToCompartmentID, newEtag(), b.nowUTC() + + return b.newWR(wrUpdate, t.CompartmentID, t.ID, "UPDATED", b.nowUTC()), nil +} + +// Table wire types. + +type limitsWire struct { + CapacityMode string `json:"capacityMode"` + MaxReadUnits int `json:"maxReadUnits"` + MaxWriteUnits int `json:"maxWriteUnits"` + MaxStorageInGBs int `json:"maxStorageInGBs"` +} + +type columnWire struct { + DefaultValue *string `json:"defaultValue,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + IsNullable bool `json:"isNullable"` + IsAsUUID bool `json:"isAsUuid"` + IsGenerated bool `json:"isGenerated"` +} + +type identityWire struct { + ColumnName string `json:"columnName"` + IsAlways bool `json:"isAlways"` + IsNull bool `json:"isNull"` +} + +type schemaWire struct { + Identity *identityWire `json:"identity,omitempty"` + Columns []columnWire `json:"columns"` + PrimaryKey []string `json:"primaryKey"` + ShardKey []string `json:"shardKey"` + TTL int `json:"ttl"` +} + +// TableWire is the full table representation. +type TableWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + Schema *schemaWire `json:"schema,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + DDLStatement string `json:"ddlStatement,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + LifecycleState string `json:"lifecycleState"` + SchemaState string `json:"schemaState"` + TableLimits limitsWire `json:"tableLimits"` + IsAutoReclaimable bool `json:"isAutoReclaimable"` + IsMultiRegion bool `json:"isMultiRegion"` +} + +func limitsView(l Limits) limitsWire { + return limitsWire{MaxReadUnits: l.ReadUnits, MaxWriteUnits: l.WriteUnits, MaxStorageInGBs: l.StorageGB, + CapacityMode: l.Mode} +} + +func schemaView2(t *Table) *schemaWire { + cols := make([]columnWire, len(t.Columns)) + + for i, c := range t.Columns { + cols[i] = columnWire{Name: c.Name, Type: c.Type, IsNullable: !c.NotNull, IsAsUUID: c.AsUUID, IsGenerated: c.Gen} + + if len(c.Default) > 0 { + var s string + if json.Unmarshal(c.Default, &s) != nil { + s = string(c.Default) + } + + cols[i].DefaultValue = &s + } + } + + out := &schemaWire{Columns: cols, PrimaryKey: t.PrimaryKey, ShardKey: t.ShardKey, TTL: t.TTLHours / hoursPerDay} + if t.Identity != nil { + out.Identity = &identityWire{ + ColumnName: t.Identity.Column, + IsAlways: t.Identity.Always, + IsNull: t.Identity.OnNull, + } + } + + return out +} + +func tableView(t *Table, full bool) TableWire { + w := TableWire{ + ID: t.ID, CompartmentID: t.CompartmentID, Name: t.Name, LifecycleState: t.State, SchemaState: "MUTABLE", + TimeCreated: t.TimeCreated, TimeUpdated: t.TimeUpdated, TableLimits: limitsView(t.Limits), + IsAutoReclaimable: t.AutoReclaim, FreeformTags: t.Tags.FreeformTags, DefinedTags: t.Tags.DefinedTags, + } + + if full { + w.Schema, w.DDLStatement = schemaView2(t), t.DDL + } + + return w +} + +// GetTable returns one table and its etag. +func (b *InMemoryBackend) GetTable(ref, compartment string) (TableWire, string, error) { + b.mu.RLock("GetTable") + defer b.mu.RUnlock() + + t, aerr := b.resolve(ref, compartment) + if aerr != nil { + return TableWire{}, "", aerr + } + + return tableView(t, true), t.Etag, nil +} + +// TableList is the ListTables result body. +type TableList struct { + Items []TableWire `json:"items"` + AutoReclaimableTables int `json:"autoReclaimableTables"` + OnDemandCapacityTables int `json:"onDemandCapacityTables"` +} + +func (l *TableList) count(t *Table) { + if t.State == stateDeleted { + return + } + + if t.AutoReclaim { + l.AutoReclaimableTables++ + } + + if t.Limits.Mode == modeOnDemand { + l.OnDemandCapacityTables++ + } +} + +func tableMatches(t *Table, name, state string) bool { + if name != "" && !strs.Equal(t.Name, name) { + return false + } + + return state == "" || state == filterAll || t.State == state +} + +// ListTables lists tables of a compartment. +func (b *InMemoryBackend) ListTables( + compartment, name, state string, + o ListOpts, +) (page.Page[TableWire], TableList, error) { + if compartment == "" { + return page.Page[TableWire]{}, TableList{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListTables") + defer b.mu.RUnlock() + + var ( + all []*Table + stats TableList + ) + + for _, t := range b.tables { + if t.CompartmentID != compartment || (t.State == stateDeleted && state != filterAll && state != stateDeleted) { + continue + } + + stats.count(t) + + if tableMatches(t, name, state) { + all = append(all, t) + } + } + + pg := paginate(all, o, func(t *Table) string { return t.Name }, + func(t *Table) (time.Time, int64) { return t.TimeCreated, 0 }) + + out := make([]TableWire, 0, len(pg.Data)) + for _, t := range pg.Data { + out = append(out, tableView(t, false)) + } + + return page.Page[TableWire]{Data: out, Next: pg.Next}, stats, nil +} + +func (h *Handler) createTable(q *req) error { + var in createTableIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateTable(in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getTable(q *req) error { + w, etag, err := h.Backend.GetTable(q.param("tableNameOrId"), q.query("compartmentId")) + + return reply(q, w, etag, err) +} + +func (h *Handler) listTables(q *req) error { + pg, stats, err := h.Backend.ListTables(q.query("compartmentId"), q.query("name"), q.query("lifecycleState"), + q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + stats.Items = pg.Data + + return q.json(http.StatusOK, stats, "") +} + +func (h *Handler) updateTable(q *req) error { + var in updateTableIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateTable(q.param("tableNameOrId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteTable(q *req) error { + wr, err := h.Backend.DeleteTable(q.param("tableNameOrId"), q.query("compartmentId"), q.ifMatch(), + q.flag("isIfExists")) + if err == nil && wr == "" { + q.w.WriteHeader(http.StatusNoContent) + + return nil + } + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeTableCompartment(q *req) error { + var in changeCompartmentIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.ChangeTableCompartment(q.param("tableNameOrId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} diff --git a/services/ocinosql/types.go b/services/ocinosql/types.go new file mode 100644 index 0000000000..4db9e41bf9 --- /dev/null +++ b/services/ocinosql/types.go @@ -0,0 +1,204 @@ +package ocinosql + +import ( + "strconv" + "strings" +) + +// Column type kinds. +const ( + kInteger = "INTEGER" + kLong = "LONG" + kFloat = "FLOAT" + kDouble = "DOUBLE" + kNumber = "NUMBER" + kString = "STRING" + kBoolean = "BOOLEAN" + kBinary = "BINARY" + kTimestamp = "TIMESTAMP" + kJSON = "JSON" + kEnum = "ENUM" + kArray = "ARRAY" + kMap = "MAP" + kRecord = "RECORD" + + defaultTimestampPrecision = 9 + maxTimestampPrecision = 9 +) + +type dtype struct { + elem *dtype + kind string + fields []dfield + enum []string + prec int +} + +type dfield struct { + typ *dtype + name string +} + +func (t *dtype) String() string { + switch t.kind { + case kTimestamp: + return kTimestamp + "(" + strconv.Itoa(t.prec) + ")" + case kEnum: + return kEnum + "(" + strings.Join(t.enum, ",") + ")" + case kArray, kMap: + return t.kind + "(" + t.elem.String() + ")" + case kRecord: + parts := make([]string, len(t.fields)) + for i, f := range t.fields { + parts[i] = f.name + " " + f.typ.String() + } + + return kRecord + "(" + strings.Join(parts, ", ") + ")" + } + + return t.kind +} + +func (t *dtype) isNumeric() bool { + switch t.kind { + case kInteger, kLong, kFloat, kDouble, kNumber: + return true + } + + return false +} + +// keyable reports whether a column of this type may be part of a primary key. +func (t *dtype) keyable() bool { + switch t.kind { + case kInteger, kLong, kNumber, kString, kTimestamp, kEnum: + return true + } + + return false +} + +var simpleKinds = map[string]string{ //nolint:gochecknoglobals // immutable lookup + "INTEGER": kInteger, "INT": kInteger, "LONG": kLong, "FLOAT": kFloat, "DOUBLE": kDouble, "NUMBER": kNumber, + "STRING": kString, "BOOLEAN": kBoolean, "BINARY": kBinary, "JSON": kJSON, +} + +func (p *parser) parseType() (*dtype, *APIError) { + t := p.peek() + if t.kind != tkIdent { + return nil, p.fail("a data type") + } + + p.pos++ + + if k, ok := simpleKinds[t.up]; ok { + return &dtype{kind: k}, nil + } + + switch t.up { + case kTimestamp: + return p.parseTimestampType() + case kEnum: + return p.parseEnumType() + case kArray, kMap: + return p.parseContainerType(t.up) + case kRecord: + return p.parseRecordType() + } + + return nil, errParam("syntax error: unsupported data type '" + t.text + "'") +} + +func (p *parser) parseTimestampType() (*dtype, *APIError) { + prec := defaultTimestampPrecision + + if p.acceptPunct("(") { + n := p.next() + + v, err := strconv.Atoi(n.text) + if err != nil || v < 0 || v > maxTimestampPrecision { + return nil, errParam("TIMESTAMP precision must be an integer between 0 and 9") + } + + prec = v + + if e := p.expectPunct(")"); e != nil { + return nil, e + } + } + + return &dtype{kind: kTimestamp, prec: prec}, nil +} + +func (p *parser) parseEnumType() (*dtype, *APIError) { + if e := p.expectPunct("("); e != nil { + return nil, e + } + + t := &dtype{kind: kEnum} + + for { + name, e := p.ident("an enum value") + if e != nil { + return nil, e + } + + t.enum = append(t.enum, name) + + if !p.acceptPunct(",") { + break + } + } + + return t, p.expectPunct(")") +} + +func (p *parser) parseContainerType(kind string) (*dtype, *APIError) { + if e := p.expectPunct("("); e != nil { + return nil, e + } + + elem, e := p.parseType() + if e != nil { + return nil, e + } + + return &dtype{kind: kind, elem: elem}, p.expectPunct(")") +} + +func (p *parser) parseRecordType() (*dtype, *APIError) { + if e := p.expectPunct("("); e != nil { + return nil, e + } + + t := &dtype{kind: kRecord} + + for { + name, e := p.ident("a field name") + if e != nil { + return nil, e + } + + ft, e := p.parseType() + if e != nil { + return nil, e + } + + t.fields = append(t.fields, dfield{name: name, typ: ft}) + + if !p.acceptPunct(",") { + break + } + } + + return t, p.expectPunct(")") +} + +func parseTypeString(s string) (*dtype, *APIError) { + p, err := newParser(s) + if err != nil { + return nil, err + } + + return p.parseType() +} diff --git a/services/ocinosql/usage.go b/services/ocinosql/usage.go new file mode 100644 index 0000000000..0a1ee4b74d --- /dev/null +++ b/services/ocinosql/usage.go @@ -0,0 +1,101 @@ +package ocinosql + +import ( + "slices" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + gigabyte = 1 << 30 + defaultSpan = time.Hour +) + +// UsageWire is one ListTableUsage item. +type UsageWire struct { + TimeStarted time.Time `json:"timeStarted"` + SecondsInPeriod int `json:"secondsInPeriod"` + ReadUnits int `json:"readUnits"` + WriteUnits int `json:"writeUnits"` + StorageInGBs int `json:"storageInGBs"` + ReadThrottleCount int `json:"readThrottleCount"` + WriteThrottleCount int `json:"writeThrottleCount"` + StorageThrottleCount int `json:"storageThrottleCount"` +} + +func parseTime(s, what string) (time.Time, *APIError) { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{}, errParam(what + " must be an RFC 3339 timestamp") + } + + return t.UTC(), nil +} + +// ListTableUsage reports per-minute consumption recorded for a table. +func (b *InMemoryBackend) ListTableUsage( + ref, compartment, start, end string, + o ListOpts, +) (page.Page[UsageWire], error) { + b.mu.RLock("ListTableUsage") + defer b.mu.RUnlock() + + t, aerr := b.resolve(ref, compartment) + if aerr != nil { + return page.Page[UsageWire]{}, aerr + } + + to := b.nowUTC() + from := to.Add(-defaultSpan) + + if start != "" { + if from, aerr = parseTime(start, "timeStart"); aerr != nil { + return page.Page[UsageWire]{}, aerr + } + } + + if end != "" { + if to, aerr = parseTime(end, "timeEnd"); aerr != nil { + return page.Page[UsageWire]{}, aerr + } + } + + if to.Before(from) { + return page.Page[UsageWire]{}, errParam("timeEnd must not be before timeStart") + } + + size := 0 + for _, r := range t.Rows { + size += len(r.Value) + } + + storage := (size + gigabyte - 1) / gigabyte + + minutes := make([]int64, 0, len(t.Usage)) + for m := range t.Usage { + if ts := time.Unix(m, 0); !ts.Before(from.Truncate(time.Minute)) && !ts.After(to) { + minutes = append(minutes, m) + } + } + + slices.Sort(minutes) + + items := make([]UsageWire, 0, len(minutes)) + for _, m := range minutes { + u := t.Usage[m] + items = append(items, UsageWire{ + TimeStarted: time.Unix(m, 0).UTC(), SecondsInPeriod: secondsPerMin, ReadUnits: u.Reads, + WriteUnits: u.Writes, StorageInGBs: storage, + }) + } + + return page.New(items, o.Page, o.Limit, defaultListLimit), nil +} + +func (h *Handler) listTableUsage(q *req) error { + pg, err := h.Backend.ListTableUsage(q.param("tableNameOrId"), q.query("compartmentId"), q.query("timeStart"), + q.query("timeEnd"), q.listOpts()) + + return collectionReply(q, pg, err) +} diff --git a/services/ocinosql/values.go b/services/ocinosql/values.go new file mode 100644 index 0000000000..646010f0f0 --- /dev/null +++ b/services/ocinosql/values.go @@ -0,0 +1,354 @@ +package ocinosql + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "math" + "math/big" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/strs" +) + +// schemaView is the parsed form of a table's columns. +type schemaView struct { + byName map[string]int + cols []Column + types []*dtype +} + +func newSchemaView(cols []Column) *schemaView { + sv := &schemaView{cols: cols, types: make([]*dtype, len(cols)), byName: make(map[string]int, len(cols))} + + for i, c := range cols { + sv.types[i], _ = parseTypeString(c.Type) + sv.byName[strs.Fold(c.Name)] = i + } + + return sv +} + +func (sv *schemaView) lookup(name string) (int, bool) { + i, ok := sv.byName[strs.Fold(name)] + + return i, ok +} + +const ( + decimalBase = 10 + bigFloatPrec = 128 +) + +func decodeJSON(raw []byte) (any, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + + var v any + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("decode json: %w", err) + } + + return v, nil +} + +func decodeRow(raw json.RawMessage) map[string]any { + v, err := decodeJSON(raw) + if err != nil { + return map[string]any{} + } + + m, _ := v.(map[string]any) + + return m +} + +func coerceInt(v any, lo, hi int64, kind string) (any, string) { + n, ok := v.(json.Number) + if !ok { + return nil, "is not a valid " + kind + } + + i, err := strconv.ParseInt(n.String(), 10, 64) + if err != nil { + f, _, ferr := big.ParseFloat(n.String(), decimalBase, bigFloatPrec, big.ToNearestEven) + if ferr != nil || !f.IsInt() { + return nil, "is not a valid " + kind + } + + bi, _ := f.Int(nil) + if !bi.IsInt64() { + return nil, "is out of range for " + kind + } + + i = bi.Int64() + } + + if i < lo || i > hi { + return nil, "is out of range for " + kind + } + + return json.Number(strconv.FormatInt(i, 10)), "" +} + +func parseTimestamp(v any) (time.Time, bool) { + switch x := v.(type) { + case string: + for _, layout := range []string{ + time.RFC3339Nano, "2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05", "2006-01-02", + } { + if t, err := time.Parse(layout, x); err == nil { + return t.UTC(), true + } + } + case json.Number: + if ms, err := strconv.ParseInt(x.String(), 10, 64); err == nil { + return time.UnixMilli(ms).UTC(), true + } + } + + return time.Time{}, false +} + +func formatTimestamp(t time.Time, prec int) string { + layout := "2006-01-02T15:04:05" + if prec > 0 { + layout += "." + strings.Repeat("0", prec) + } + + step := time.Duration(math.Pow10(maxTimestampPrecision - prec)) + + return t.UTC().Truncate(step).Format(layout) + "Z" +} + +// coerce validates v against t and returns its normalized form; the second result is a problem description. +func coerce(t *dtype, v any) (any, string) { + if v == nil { + return nil, "" + } + + switch t.kind { + case kInteger: + return coerceInt(v, math.MinInt32, math.MaxInt32, kInteger) + case kLong: + return coerceInt(v, math.MinInt64, math.MaxInt64, kLong) + case kFloat, kDouble: + return coerceFloat(t.kind, v) + case kString, kBoolean, kNumber, kBinary, kTimestamp, kEnum: + return coerceScalar(t, v) + } + + return coerceComposite(t, v) +} + +func coerceScalar(t *dtype, v any) (any, string) { + switch t.kind { + case kNumber: + n, ok := v.(json.Number) + if _, rok := new(big.Rat).SetString(n.String()); !ok || !rok { + return nil, "is not a valid NUMBER" + } + + return n, "" + case kString: + if _, ok := v.(string); !ok { + return nil, "is not a valid STRING" + } + case kBoolean: + if _, ok := v.(bool); !ok { + return nil, "is not a valid BOOLEAN" + } + case kBinary: + s, ok := v.(string) + if _, err := base64.StdEncoding.DecodeString(s); !ok || err != nil { + return nil, "is not a valid base64-encoded BINARY" + } + case kTimestamp: + ts, ok := parseTimestamp(v) + if !ok { + return nil, "is not a valid TIMESTAMP" + } + + return formatTimestamp(ts, t.prec), "" + case kEnum: + s, _ := v.(string) + if !strs.ContainsFold(t.enum, s) { + return nil, "is not one of the ENUM values" + } + } + + return v, "" +} + +func coerceComposite(t *dtype, v any) (any, string) { + switch t.kind { + case kArray: + arr, ok := v.([]any) + if !ok { + return nil, "is not a valid ARRAY" + } + + out := make([]any, len(arr)) + + for i, e := range arr { + n, msg := coerce(t.elem, e) + if msg != "" { + return nil, "element " + strconv.Itoa(i) + " " + msg + } + + out[i] = n + } + + return out, "" + case kMap: + m, ok := v.(map[string]any) + if !ok { + return nil, "is not a valid MAP" + } + + out := make(map[string]any, len(m)) + + for k, e := range m { + n, msg := coerce(t.elem, e) + if msg != "" { + return nil, "entry '" + k + "' " + msg + } + + out[k] = n + } + + return out, "" + case kRecord: + return coerceRecord(t, v) + } + + return v, "" +} + +func coerceRecord(t *dtype, v any) (any, string) { + m, ok := v.(map[string]any) + if !ok { + return nil, "is not a valid RECORD" + } + + out := make(map[string]any, len(t.fields)) + used := 0 + + for _, f := range t.fields { + var raw any + + for k, e := range m { + if strs.Equal(k, f.name) { + raw, used = e, used+1 + } + } + + n, msg := coerce(f.typ, raw) + if msg != "" { + return nil, "field '" + f.name + "' " + msg + } + + out[f.name] = n + } + + if used != len(m) { + return nil, "has fields that are not part of the RECORD" + } + + return out, "" +} + +func cmpNum(a, b json.Number) (int, bool) { + ra, ok1 := new(big.Rat).SetString(a.String()) + rb, ok2 := new(big.Rat).SetString(b.String()) + + if !ok1 || !ok2 { + return 0, false + } + + return ra.Cmp(rb), true +} + +// compareVals orders two scalar values of the same family; ok is false when they are not comparable. +func compareVals(a, b any) (int, bool) { + switch x := a.(type) { + case json.Number: + if y, ok := b.(json.Number); ok { + return cmpNum(x, y) + } + case string: + if y, ok := b.(string); ok { + return strings.Compare(x, y), true + } + case bool: + if y, ok := b.(bool); ok { + switch { + case x == y: + return 0, true + case !x: + return -1, true + } + + return 1, true + } + } + + return 0, false +} + +func keyPart(v any) string { + if n, ok := v.(json.Number); ok { + if r, rok := new(big.Rat).SetString(n.String()); rok { + return r.RatString() + } + } + + s, _ := v.(string) + + return s +} + +// rowKey builds the canonical primary-key string from a normalized row. +func rowKey(pk []string, row map[string]any) (string, *APIError) { + parts := make([]string, len(pk)) + + for i, c := range pk { + v, ok := row[c] + if !ok || v == nil { + return "", errParam("primary key column '" + c + "' is missing a value") + } + + parts[i] = keyPart(v) + } + + b, _ := json.Marshal(parts) //nolint:errchkjson // []string cannot fail + + return string(b), nil +} + +func newUUID() string { return uuid.NewString() } + +func validUUID(s string) bool { + _, err := uuid.Parse(s) + + return err == nil +} + +func coerceFloat(kind string, v any) (any, string) { + n, ok := v.(json.Number) + if !ok { + return nil, "is not a valid " + kind + } + + f, err := strconv.ParseFloat(n.String(), 64) + if err != nil { + return nil, "is not a valid " + kind + } + + return json.Number(strconv.FormatFloat(f, 'g', -1, 64)), "" +} diff --git a/services/ocinosql/workreq.go b/services/ocinosql/workreq.go new file mode 100644 index 0000000000..3414ed1da1 --- /dev/null +++ b/services/ocinosql/workreq.go @@ -0,0 +1,170 @@ +package ocinosql + +import ( + "net/http" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + halfComplete = 50 + entityTable = "table" + wrCreate = "CREATE_TABLE" + wrUpdate = "UPDATE_TABLE" + wrDelete = "DELETE_TABLE" +) + +type wrResourceWire struct { + EntityType string `json:"entityType"` + ActionType string `json:"actionType"` + Identifier string `json:"identifier"` + EntityURI string `json:"entityUri,omitempty"` +} + +// WorkRequestWire is the wire form of a work request. +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted *time.Time `json:"timeStarted,omitempty"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + OperationType string `json:"operationType"` + Status string `json:"status"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Resources []wrResourceWire `json:"resources"` + PercentComplete float32 `json:"percentComplete"` +} + +type wrLogWire struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +// newWR records a work request that completes with the resource transition. +func (b *InMemoryBackend) newWR(op, compartment, id, action string, finish time.Time) string { + wr := &WorkRequest{ + ID: newOCID("nosqlworkrequest"), Operation: op, CompartmentID: compartment, + EntityID: id, Action: action, Accepted: b.nowUTC(), FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr.ID +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + now := b.nowUTC() + out := WorkRequestWire{ + OperationType: wr.Operation, ID: wr.ID, CompartmentID: wr.CompartmentID, TimeAccepted: wr.Accepted, + Status: "IN_PROGRESS", PercentComplete: halfComplete, TimeStarted: &wr.Accepted, + Resources: []wrResourceWire{{ + EntityType: entityTable, ActionType: "IN_PROGRESS", Identifier: wr.EntityID, + EntityURI: base + "/tables/" + wr.EntityID, + }}, + } + + if !wr.FinishAt.After(now) { + out.Status, out.PercentComplete, out.TimeFinished = "SUCCEEDED", 100, &wr.FinishAt + out.Resources[0].ActionType = wr.Action + } + + return out +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// ListWorkRequests lists the work requests of a compartment, optionally for one resource. +func (b *InMemoryBackend) ListWorkRequests(compartment string, o ListOpts) (page.Page[WorkRequestWire], error) { + if compartment == "" { + return page.Page[WorkRequestWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + var all []*WorkRequest + + for _, wr := range b.workReqs { + if wr.CompartmentID != compartment { + continue + } + + all = append(all, wr) + } + + pg := paginate(all, o, func(w *WorkRequest) string { return w.ID }, + func(w *WorkRequest) (time.Time, int64) { return w.Accepted, w.Seq }) + + out := make([]WorkRequestWire, 0, len(pg.Data)) + for _, wr := range pg.Data { + out = append(out, b.wrView(wr)) + } + + return page.Page[WorkRequestWire]{Data: out, Next: pg.Next}, nil +} + +// WorkRequestLogs returns the log entries of a work request. +func (b *InMemoryBackend) WorkRequestLogs(id string, o ListOpts) (page.Page[wrLogWire], error) { + b.mu.RLock("WorkRequestLogs") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrLogWire]{}, errNotFound(id) + } + + logs := []wrLogWire{{Timestamp: wr.Accepted, Message: wr.Operation + " accepted"}} + if !wr.FinishAt.After(b.nowUTC()) { + logs = append(logs, wrLogWire{Timestamp: wr.FinishAt, Message: wr.Operation + " succeeded"}) + } + + return page.New(logs, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestExists reports whether a work request exists (its error list is always empty). +func (b *InMemoryBackend) WorkRequestExists(id string) error { + b.mu.RLock("WorkRequestExists") + defer b.mu.RUnlock() + + if _, ok := b.workReqs[id]; !ok { + return errNotFound(id) + } + + return nil +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + + return reply(q, w, "", err) +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(q.query("compartmentId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestLogs(q *req) error { + pg, err := h.Backend.WorkRequestLogs(q.param("workRequestId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestErrors(q *req) error { + if err := h.Backend.WorkRequestExists(q.param("workRequestId")); err != nil { + return err + } + + return q.json(http.StatusOK, map[string]any{itemsKey: []struct{}{}}, "") +} diff --git a/services/ocinotifications/PARITY.md b/services/ocinotifications/PARITY.md new file mode 100644 index 0000000000..2718713682 --- /dev/null +++ b/services/ocinotifications/PARITY.md @@ -0,0 +1,43 @@ +--- +service: ocinotifications +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: 3a4fe760a +last_audit_date: 2026-10-27 +overall: B +ops: + CreateTopic: {wire: ok, errors: partial, state: ok, persist: ok, note: "200 with the topic and etag; CREATING then ACTIVE after the transition window on the injectable clock. Names are unique across live topics (409 Conflict), up to 256 characters. apiEndpoint is the base URL the client addressed, shortTopicId is generated."} + GetTopic: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; DELETING topics stay readable until the transition completes, then 404."} + ListTopics: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required; id, name, lifecycleState filters; sortBy TIMECREATED or LIFECYCLESTATE; bare array body, paged with opc-next-page."} + UpdateTopic: {wire: ok, errors: ok, state: ok, persist: ok, note: "Description and tags; If-Match gives 412 NoEtagMatch; returns the topic with a new etag."} + DeleteTopic: {wire: ok, errors: ok, state: ok, persist: ok, note: "202; DELETING then removed together with its subscriptions and deliveries."} + ChangeTopicCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "202; moves the topic record only, its subscriptions keep their compartment."} + CreateSubscription: {wire: ok, errors: partial, state: ok, persist: ok, note: "Protocols CUSTOM_HTTPS, EMAIL, HTTPS, ORACLE_FUNCTIONS, PAGERDUTY, SLACK, SMS with per-protocol endpoint checks (http: or https: URL, email address, E.164 number, function OCID, any PagerDuty key). EMAIL, HTTPS, CUSTOM_HTTPS, SLACK and SMS start PENDING and get a confirmation delivery; ORACLE_FUNCTIONS and PAGERDUTY start ACTIVE. Duplicate topic/protocol/endpoint is 409. The topic must be ACTIVE. Returns deliverPolicy as a JSON string, createdTime in epoch milliseconds and an etag."} + GetSubscription: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header."} + ListSubscriptions: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId required, topicId filter; summaries carry deliveryPolicy as an object; bare array body, paged."} + UpdateSubscription: {wire: ok, errors: ok, state: ok, persist: ok, note: "deliveryPolicy.backoffRetryPolicy (EXPONENTIAL, maxRetryDuration 10000 to 86400000 ms) and tags; the response body is the update details shape the SDK declares, with the new etag header."} + DeleteSubscription: {wire: ok, errors: ok, state: ok, persist: ok, note: "204; drops the subscription and its deliveries. If-Match gives 412."} + ChangeSubscriptionCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "202."} + GetConfirmSubscription: {wire: ok, errors: partial, state: ok, persist: ok, note: "Unsigned link (token and protocol query parameters, no signature needed even with enforcement on) that moves PENDING to ACTIVE; idempotent for an ACTIVE subscription. A wrong token or protocol is 400 InvalidParameter (code unverified). Returns the confirmation result with an unsubscribe URL."} + GetUnsubscription: {wire: ok, errors: partial, state: ok, persist: ok, note: "Unsigned link that removes the subscription; the body is a JSON string with a confirmation sentence."} + ResendSubscriptionConfirmation: {wire: ok, errors: ok, state: ok, persist: ok, note: "Only PENDING subscriptions (409 IncorrectState otherwise); supersedes earlier unsent confirmations and queues a new one with the same token."} + PublishMessage: {wire: ok, errors: partial, state: ok, persist: ok, note: "body required, title optional, 64 KB limit; messageType header JSON (body must be valid JSON) or RAW_TEXT (default). Returns messageId and timeStamp and fans out to every ACTIVE subscription; PENDING ones receive nothing. The topic must be ACTIVE."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-notifications-enforce-auth rejects unsigned and unknown keys. The confirmation and unsubscription links carry tokens instead."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id; InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch and IncorrectState follow the OCI reference; Conflict for duplicate topics and subscriptions is unverified."} + one_listener: {status: ok, note: "The control plane (topics) and data plane (subscriptions, publish) share one listener and the /20181201 base path, so one host serves both SDK clients."} + https_delivery: {status: ok, note: "HTTPS and CUSTOM_HTTPS deliveries are POSTed through pkgs/httpclient with Content-Type, X-OCI-NS-MessageId, X-OCI-NS-TopicOcid, X-OCI-NS-TopicName, X-OCI-NS-MessageType and X-OCI-NS-UnsubscribeURL; confirmations add X-OCI-NS-ConfirmationURL and X-OCI-NS-SubscriptionId. Any 2xx acknowledges."} + retry_policy: {status: ok, note: "Per the Oracle docs: an immediate retry, then exponential backoff of 1, 2, 4, 8, 16 and 32 minutes (32 repeats) until the subscription's maxRetryDuration (default 7200000 ms, 2 hours) from publication, then the message is DISCARDED. The dispatcher is event driven: it sleeps on a kick channel and a timer for the next due retry, both on the injectable clock and timer."} + recorded_protocols: {status: ok, note: "EMAIL, SMS, SLACK and PAGERDUTY deliveries are recorded, not sent, with status RECORDED. GET /_gopherstack/oci/deliveries (optional topicId, subscriptionId) lists every delivery with its confirmationUrl, so a confirmation sent to an email or phone can be completed."} + functions: {status: ok, note: "ORACLE_FUNCTIONS deliveries invoke OCI Functions in the same process (message as the payload) with the same retry policy; without the Functions backend they are RECORDED."} + lifecycle: {status: ok, note: "Topic CREATING and DELETING last 2 seconds on the injectable backend clock; transitions are applied on every request and by a one-minute sweeper."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-notifications-port / OCI_NOTIFICATIONS_PORT (default 8157); never on the shared AWS router; cli.go reserves the port in the shared pool."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state; the delivery log keeps the newest 2000 finished entries."} +items_still_open: + - "EMAIL, SMS, SLACK and PAGERDUTY messages are recorded but never sent; Slack formatting, PagerDuty incident creation and friendly email formatting are not modelled." + - "Whether CUSTOM_HTTPS, SLACK, SMS and PAGERDUTY subscriptions start PENDING or ACTIVE is recalled from the documentation, not verified; the confirmation body and the X-OCI-NS-MessageType values are unverified." + - "Signed deliveries (X-OCI-NS-Signature, X-OCI-NS-Timestamp, X-OCI-NS-SignatureVersion, X-OCI-NS-SigningCertURL) and the friendly-formatting setting are not implemented." + - "The publish rate (60 messages per minute per topic) and per-subscription delivery rates are not enforced; SMS is not restricted to Announcements, Monitoring and Connector Hub messages." + - "The maxRetryDuration range (10 s to 24 h), topic name rules, the status codes for DeleteSubscription (204) and DeleteTopic (202), and the error codes for bad confirmation tokens are unverified." + - "Deleted topics and unsubscribed subscriptions disappear instead of passing through a DELETED state." + - "Topic and subscription tagging is stored verbatim; opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified." +--- diff --git a/services/ocinotifications/README.md b/services/ocinotifications/README.md new file mode 100644 index 0000000000..cce8635da8 --- /dev/null +++ b/services/ocinotifications/README.md @@ -0,0 +1,29 @@ + +# OCI Notifications + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-27 (`3a4fe760a`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 16 (11 ok, 5 partial) | +| Feature families | 10 (9 ok, 1 partial) | +| Known gaps | 7 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- EMAIL, SMS, SLACK and PAGERDUTY messages are recorded but never sent; Slack formatting, PagerDuty incident creation and friendly email formatting are not modelled. +- Whether CUSTOM_HTTPS, SLACK, SMS and PAGERDUTY subscriptions start PENDING or ACTIVE is recalled from the documentation, not verified; the confirmation body and the X-OCI-NS-MessageType values are unverified. +- Signed deliveries (X-OCI-NS-Signature, X-OCI-NS-Timestamp, X-OCI-NS-SignatureVersion, X-OCI-NS-SigningCertURL) and the friendly-formatting setting are not implemented. +- The publish rate (60 messages per minute per topic) and per-subscription delivery rates are not enforced; SMS is not restricted to Announcements, Monitoring and Connector Hub messages. +- The maxRetryDuration range (10 s to 24 h), topic name rules, the status codes for DeleteSubscription (204) and DeleteTopic (202), and the error codes for bad confirmation tokens are unverified. +- Deleted topics and unsubscribed subscriptions disappear instead of passing through a DELETED state. +- Topic and subscription tagging is stored verbatim; opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ocinotifications/errors.go b/services/ocinotifications/errors.go new file mode 100644 index 0000000000..099985a613 --- /dev/null +++ b/services/ocinotifications/errors.go @@ -0,0 +1,45 @@ +package ocinotifications + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ocinotifications: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errExists(msg string) *APIError { return newErr(http.StatusConflict, "Conflict", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } diff --git a/services/ocinotifications/handler.go b/services/ocinotifications/handler.go new file mode 100644 index 0000000000..9e9d2dc97c --- /dev/null +++ b/services/ocinotifications/handler.go @@ -0,0 +1,393 @@ +package ocinotifications + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Minute + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Notifications: topics, subscriptions and message publishing. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + cancel context.CancelFunc + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ocinotifications.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCINotifications" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCINotifications runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"topicId", "subscriptionId", "id"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ocinotifications: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +// endpoint returns the base URL the client addressed, so returned messages endpoints stay reachable. +func (q *req) endpoint() string { + host := q.r.Host + if h, port, err := net.SplitHostPort(host); err == nil { + if ip := net.ParseIP(h); ip == nil && h != "" { + host = h + ":" + port + } + } + + return "http://" + host +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + if err := json.Unmarshal(q.body, v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ocinotifications: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCINotifications requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + switch q.rt.op { + case opAdminKeys: + h.adminKeys(q) + + return nil + case opAdminDeliveries: + h.adminDeliveries(q) + + return nil + } + + h.Backend.Sweep() + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify && !tokenAuthenticated(q.rt.op) { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +// tokenAuthenticated reports operations reached from links in messages, which carry a token instead of a signature. +func tokenAuthenticated(op string) bool { return op == opGetConfirmSub || op == opGetUnsubscription } + +// adminDeliveries lists delivery records so tests and users can read recorded email, SMS and similar messages. +func (h *Handler) adminDeliveries(q *req) { + if q.r.Method != http.MethodGet { + h.fail(q, errNoRoute()) + + return + } + + _ = q.json(http.StatusOK, h.Backend.Deliveries(q.query("topicId"), q.query("subscriptionId")), "") +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ocinotifications: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCINotifications", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + workerCtx, cancel := context.WithCancel(ctx) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop, h.cancel = srv, stop, cancel + h.srvMu.Unlock() + + workerCtx = logger.WithWorker(workerCtx, "ocinotifications", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ocinotifications: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ocinotifications: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { h.Backend.workerLoop(workerCtx, stop, &h.wg) }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop, cancel := h.srv, h.stop, h.cancel + h.srv, h.stop, h.cancel = nil, nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + cancel() + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ocinotifications: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ocinotifications: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() + h.Backend.client.CloseIdleConnections() +} diff --git a/services/ocinotifications/helpers_test.go b/services/ocinotifications/helpers_test.go new file mode 100644 index 0000000000..2d4b6930cb --- /dev/null +++ b/services/ocinotifications/helpers_test.go @@ -0,0 +1,272 @@ +package ocinotifications_test + +import ( + "net/http/httptest" + "net/url" + + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/ons" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + oci "github.com/blackbirdworks/gopherstack/services/ocinotifications" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *oci.InMemoryBackend + handler *oci.Handler + clock *fakeClock + prov common.ConfigurationProvider + kp keyPair + ctl ons.NotificationControlPlaneClient + dp ons.NotificationDataPlaneClient + base string +} + +type envOpts struct { + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := oci.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + h := oci.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{ + backend: be, handler: h, clock: clock, kp: kp, + prov: common.NewRawConfigurationProvider(testTenancy, testUser, oci.Region, kp.fprint, kp.privPEM, nil), + base: fmt.Sprintf("http://localhost:%d", h.Port), + } + + var err error + + e.ctl, err = ons.NewNotificationControlPlaneClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.ctl.Host = e.base + + e.dp, err = ons.NewNotificationDataPlaneClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.dp.Host = e.base + + return e +} + +// settle advances past the CREATING/DELETING transition. +func (e *env) settle() { e.clock.Advance(5 * time.Second) } + +func (e *env) mkTopic(t *testing.T, name string) ons.NotificationTopic { + t.Helper() + + resp, err := e.ctl.CreateTopic(t.Context(), ons.CreateTopicRequest{ + Name: new(name), CompartmentId: new(testCompartment)}) + require.NoError(t, err) + e.settle() + + got, err := e.ctl.GetTopic(t.Context(), ons.GetTopicRequest{TopicId: resp.TopicId}) + require.NoError(t, err) + require.Equal(t, ons.NotificationTopicLifecycleStateActive, got.LifecycleState) + + return got.NotificationTopic +} + +func (e *env) subscribe(t *testing.T, topic ons.NotificationTopic, protocol, endpoint string) ons.Subscription { + t.Helper() + + resp, err := e.dp.CreateSubscription(t.Context(), ons.CreateSubscriptionRequest{ + TopicId: topic.TopicId, + CompartmentId: new(testCompartment), + Protocol: new(protocol), + Endpoint: new(endpoint), + }) + require.NoError(t, err) + + return resp.Subscription +} + +func (e *env) publish(t *testing.T, topic ons.NotificationTopic, body string) ons.PublishResult { + t.Helper() + + resp, err := e.dp.PublishMessage(t.Context(), ons.PublishMessageRequest{ + TopicId: topic.TopicId, Body: new(body), + }) + require.NoError(t, err) + + return resp.PublishResult +} + +func (e *env) deliveries(t *testing.T, subID string) []oci.DeliveryView { + t.Helper() + + return e.backend.Deliveries("", subID) +} + +func (e *env) confirm(t *testing.T, sub ons.Subscription, confirmationURL string) ons.ConfirmationResult { + t.Helper() + + u, err := url.Parse(confirmationURL) + require.NoError(t, err) + + resp, err := e.dp.GetConfirmSubscription(t.Context(), ons.GetConfirmSubscriptionRequest{ + Id: sub.Id, Token: new(u.Query().Get("token")), Protocol: sub.Protocol, + }) + require.NoError(t, err) + + return resp.ConfirmationResult +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, rawURL, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, rawURL, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +type received struct { + header http.Header + body string +} + +// sink is an HTTP endpoint that records every request and answers with the next scripted status. +type sink struct { + srv *httptest.Server + requests chan received + statuses chan int +} + +func newSink(t *testing.T, statuses ...int) *sink { + t.Helper() + + s := &sink{requests: make(chan received, 64), statuses: make(chan int, len(statuses)+1)} + for _, st := range statuses { + s.statuses <- st + } + + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + s.requests <- received{header: r.Header.Clone(), body: string(b)} + + status := http.StatusOK + + select { + case st := <-s.statuses: + status = st + default: + } + + w.WriteHeader(status) + })) + t.Cleanup(s.srv.Close) + + return s +} + +func (s *sink) next(t *testing.T) received { + t.Helper() + + select { + case r := <-s.requests: + return r + case <-t.Context().Done(): + t.Fatal("no request received") + + return received{} + } +} diff --git a/services/ocinotifications/inproc.go b/services/ocinotifications/inproc.go new file mode 100644 index 0000000000..0505509992 --- /dev/null +++ b/services/ocinotifications/inproc.go @@ -0,0 +1,38 @@ +package ocinotifications + +import "slices" + +// Compartments lists the distinct compartments that hold live topics or subscriptions. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, t := range b.topics { + seen[t.CompartmentID] = struct{}{} + } + + for _, s := range b.subs { + seen[s.CompartmentID] = struct{}{} + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} + +// TopicActive reports whether id is an ACTIVE topic. +func (b *InMemoryBackend) TopicActive(id string) bool { + b.mu.Lock("TopicActive") + defer b.mu.Unlock() + + _, aerr := b.activeTopic(id) + + return aerr == nil +} diff --git a/services/ocinotifications/leak_main_test.go b/services/ocinotifications/leak_main_test.go new file mode 100644 index 0000000000..3208d57bb1 --- /dev/null +++ b/services/ocinotifications/leak_main_test.go @@ -0,0 +1,18 @@ +package ocinotifications_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ocinotifications/models.go b/services/ocinotifications/models.go new file mode 100644 index 0000000000..f276dd0567 --- /dev/null +++ b/services/ocinotifications/models.go @@ -0,0 +1,107 @@ +package ocinotifications + +import "time" + +// Topic lifecycle states. +const ( + stateActive = "ACTIVE" + stateCreating = "CREATING" + stateDeleting = "DELETING" +) + +// Subscription lifecycle states. +const ( + subPending = "PENDING" + subActive = "ACTIVE" +) + +// Delivery states. +const ( + deliveryPending = "PENDING" + deliveryDelivered = "DELIVERED" + deliveryDiscarded = "DISCARDED" + deliveryRecorded = "RECORDED" +) + +// Delivery kinds. +const ( + kindMessage = "MESSAGE" + kindConfirmation = "CONFIRMATION" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// Topic is a notification topic. +type Topic struct { + TimeCreated time.Time `json:"timeCreated"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Tags Tags `json:"tags"` + ID string `json:"id"` + ShortID string `json:"shortId"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + Description string `json:"description"` + State string `json:"state"` + Etag string `json:"etag"` + Seq int64 `json:"seq"` +} + +// Subscription is one endpoint subscribed to a topic. +type Subscription struct { + TimeCreated time.Time `json:"timeCreated"` + Tags Tags `json:"tags"` + ID string `json:"id"` + TopicID string `json:"topicId"` + CompartmentID string `json:"compartmentId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + State string `json:"state"` + Metadata string `json:"metadata,omitempty"` + BaseURL string `json:"baseUrl"` + ConfirmToken string `json:"confirmToken"` + UnsubToken string `json:"unsubToken"` + Etag string `json:"etag"` + MaxRetryMs int `json:"maxRetryMs"` + Seq int64 `json:"seq"` +} + +// Delivery records one message or confirmation handed to a subscription endpoint. +type Delivery struct { + Published time.Time `json:"published"` + NextAttempt time.Time `json:"nextAttempt"` + Deadline time.Time `json:"deadline"` + Completed time.Time `json:"completed"` + SubscriptionID string `json:"subscriptionId"` + Title string `json:"title,omitempty"` + Kind string `json:"kind"` + MessageID string `json:"messageId"` + TopicID string `json:"topicId"` + TopicName string `json:"topicName"` + LastError string `json:"lastError,omitempty"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + ID string `json:"id"` + Body string `json:"body"` + MessageType string `json:"messageType"` + ConfirmationURL string `json:"confirmationUrl,omitempty"` + UnsubscribeURL string `json:"unsubscribeUrl,omitempty"` + Status string `json:"status"` + Attempts int `json:"attempts"` + LastStatus int `json:"lastStatus,omitempty"` + Seq int64 `json:"seq"` + inflight bool +} diff --git a/services/ocinotifications/ops.go b/services/ocinotifications/ops.go new file mode 100644 index 0000000000..943a8a15e8 --- /dev/null +++ b/services/ocinotifications/ops.go @@ -0,0 +1,15 @@ +package ocinotifications + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateTopic: (*Handler).createTopic, opGetTopic: (*Handler).getTopic, + opListTopics: (*Handler).listTopics, opUpdateTopic: (*Handler).updateTopic, + opDeleteTopic: (*Handler).deleteTopic, opChangeTopicComp: (*Handler).changeTopicCompartment, + opPublishMessage: (*Handler).publishMessage, + opCreateSub: (*Handler).createSubscription, opGetSub: (*Handler).getSubscription, + opListSubs: (*Handler).listSubscriptions, opUpdateSub: (*Handler).updateSubscription, + opDeleteSub: (*Handler).deleteSubscription, opChangeSubComp: (*Handler).changeSubscriptionCompartment, + opGetConfirmSub: (*Handler).confirmSubscription, opGetUnsubscription: (*Handler).unsubscribe, + opResendConfirm: (*Handler).resendConfirmation, + } +} diff --git a/services/ocinotifications/persistence.go b/services/ocinotifications/persistence.go new file mode 100644 index 0000000000..7194075124 --- /dev/null +++ b/services/ocinotifications/persistence.go @@ -0,0 +1,103 @@ +package ocinotifications + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Topics map[string]*Topic `json:"topics"` + Subs map[string]*Subscription `json:"subscriptions"` + Deliveries map[string]*Delivery `json:"deliveries"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ocinotifications", backendSnapshot{ + Version: snapshotVersion, Topics: b.topics, Subs: b.subs, Deliveries: b.deliveries, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ocinotifications", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ocinotifications: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.topics, b.subs, b.deliveries, b.seq = snap.Topics, snap.Subs, snap.Deliveries, snap.Seq + b.Auth.Load(snap.Auth) + b.Kick() + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{ + nullCheck("topic", &snap.Topics), nullCheck("subscription", &snap.Subs), + nullCheck("delivery", &snap.Deliveries), + } { + if err != nil { + return err + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ocinotifications: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ocinotifications/persistence_test.go b/services/ocinotifications/persistence_test.go new file mode 100644 index 0000000000..15265a8b94 --- /dev/null +++ b/services/ocinotifications/persistence_test.go @@ -0,0 +1,106 @@ +package ocinotifications_test + +import ( + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/ons" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSnapshotRestoreResumesDeliveries(t *testing.T) { + t.Parallel() + + src := newEnv(t, envOpts{}) + topic := src.mkTopic(t, "persist") + sink := newSink(t, 200, 500, 500) + sub := src.subscribe(t, topic, "HTTPS", sink.srv.URL) + src.confirm(t, sub, sink.next(t).header.Get("X-Oci-Ns-Confirmationurl")) + email := src.subscribe(t, topic, "EMAIL", "a@example.com") + + res := src.publish(t, topic, "survives") + sink.next(t) + sink.next(t) + + require.Eventually(t, func() bool { + for _, d := range src.deliveries(t, *sub.Id) { + if d.MessageID == *res.MessageId { + return d.Attempts == 2 && d.Status == "PENDING" + } + } + + return false + }, 5*time.Second, 5*time.Millisecond) + + snap := src.backend.Snapshot(t.Context()) + + dst := newEnv(t, envOpts{}) + dst.clock.ns.Store(src.clock.ns.Load()) + require.NoError(t, dst.backend.Restore(t.Context(), snap)) + + got, err := dst.ctl.GetTopic(t.Context(), ons.GetTopicRequest{TopicId: topic.TopicId}) + require.NoError(t, err) + assert.Equal(t, "persist", *got.Name) + + list, err := dst.dp.ListSubscriptions( + t.Context(), + ons.ListSubscriptionsRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + assert.Len(t, list.Items, 2) + + pending, err := dst.dp.GetSubscription(t.Context(), ons.GetSubscriptionRequest{SubscriptionId: email.Id}) + require.NoError(t, err) + assert.Equal(t, ons.SubscriptionLifecycleStatePending, pending.LifecycleState) + + dst.clock.Advance(61 * time.Second) + dst.backend.Kick() + + assert.Equal(t, "survives", sink.next(t).body) + + require.Eventually(t, func() bool { + for _, d := range dst.deliveries(t, *sub.Id) { + if d.MessageID == *res.MessageId { + return d.Status == "DELIVERED" + } + } + + return false + }, 5*time.Second, 5*time.Millisecond) +} + +func TestRestoreGuards(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload string + wantErr bool + }{ + {"null entry", `{"version":1,"topics":{"a":null}}`, true}, + {"old version", `{"version":99}`, false}, + {"empty", `{"version":1}`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + err := e.backend.Restore(t.Context(), []byte(tt.payload)) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestReset(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "reset") + e.handler.Reset() + + _, err := e.ctl.GetTopic(t.Context(), ons.GetTopicRequest{TopicId: topic.TopicId}) + require.Error(t, err) +} diff --git a/services/ocinotifications/provider.go b/services/ocinotifications/provider.go new file mode 100644 index 0000000000..52bae5e14d --- /dev/null +++ b/services/ocinotifications/provider.go @@ -0,0 +1,42 @@ +package ocinotifications + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ocinotifications: nil app context") + +// ConfigProvider extracts OCI Notifications configuration from the AppContext Config. +type ConfigProvider interface { + GetOCINotificationsSettings() Settings +} + +// Provider implements service.Provider for OCI Notifications. It runs on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCINotifications" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCINotificationsSettings() + } + + handler := NewHandler(NewInMemoryBackend()) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ocinotifications/publish.go b/services/ocinotifications/publish.go new file mode 100644 index 0000000000..9418c1535e --- /dev/null +++ b/services/ocinotifications/publish.go @@ -0,0 +1,414 @@ +package ocinotifications + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + maxMessageBytes = 64 * 1024 + msgJSON = "JSON" + msgRawText = "RAW_TEXT" + maxBackoff = 32 * time.Minute + immediateTries = 2 + maxErrBody = 256 + maxBackoffShift = 5 +) + +// PublishIn is the PublishMessage request body. +type PublishIn struct { + Title *string `json:"title"` + Body *string `json:"body"` +} + +type PublishWire struct { + TimeStamp time.Time `json:"timeStamp"` + MessageID string `json:"messageId"` +} + +// ErrEndpointStatus marks a delivery attempt that the endpoint answered with a non-2xx status. +var ErrEndpointStatus = errors.New("endpoint answered") + +// DeliveryView is a delivery record as reported by the admin endpoint. +type DeliveryView = Delivery + +func isHTTPProtocol(p string) bool { return p == protoHTTPS || p == protoCustom } + +// recordsOnly reports protocols whose delivery the emulator records without sending anything. +func recordsOnly(p string) bool { + return p == protoEmail || p == protoSMS || p == protoSlack || p == protoPagerDuty +} + +func (b *InMemoryBackend) newDelivery(s *Subscription, t *Topic, kind string) *Delivery { + now := b.nowUTC() + d := &Delivery{ + ID: uuid.NewString(), Kind: kind, TopicID: t.ID, TopicName: t.Name, SubscriptionID: s.ID, Protocol: s.Protocol, + Endpoint: s.Endpoint, Published: now, NextAttempt: now, Status: deliveryPending, + Deadline: now.Add(time.Duration(s.MaxRetryMs) * time.Millisecond), UnsubscribeURL: unsubscribeURL(s), + Seq: b.nextSeq(), + } + b.deliveries[d.ID] = d + + return d +} + +// finalize marks a delivery as complete without an attempt, for protocols that are only recorded. +func (b *InMemoryBackend) finalize(d *Delivery, status, reason string) { + d.Status, d.LastError, d.Completed = status, reason, b.nowUTC() +} + +func (b *InMemoryBackend) route(d *Delivery) { + switch { + case isHTTPProtocol(d.Protocol): + case d.Protocol == protoFunctions && b.invoker != nil && d.Kind == kindMessage: + case d.Protocol == protoFunctions && d.Kind == kindConfirmation: + b.finalize(d, deliveryRecorded, "") + case d.Protocol == protoFunctions: + b.finalize(d, deliveryRecorded, "no OCI Functions backend is wired; delivery recorded only") + case recordsOnly(d.Protocol): + b.finalize(d, deliveryRecorded, "") + } + + b.trim() +} + +func (b *InMemoryBackend) trim() { + if len(b.deliveries) <= maxKeptDeliveries { + return + } + + done := make([]*Delivery, 0, len(b.deliveries)) + + for _, d := range b.deliveries { + if d.Status != deliveryPending { + done = append(done, d) + } + } + + slices.SortFunc(done, func(a, c *Delivery) int { return int(a.Seq - c.Seq) }) + + for _, d := range done { + if len(b.deliveries) <= maxKeptDeliveries { + return + } + + delete(b.deliveries, d.ID) + } +} + +// queueConfirmation creates the confirmation delivery for a PENDING subscription. +func (b *InMemoryBackend) queueConfirmation(s *Subscription, t *Topic) { + d := b.newDelivery(s, t, kindConfirmation) + d.ConfirmationURL = b.confirmationURL(s) + d.MessageType = msgJSON + body, _ := json.Marshal(map[string]string{ //nolint:errchkjson // static shape + "SubscriptionId": s.ID, "ConfirmationURL": d.ConfirmationURL, + "Message": "You have chosen to subscribe to the topic " + t.Name + + ". To confirm the subscription, visit the ConfirmationURL included in this message.", + }) + d.Body = string(body) + d.Title = "Confirm your subscription to " + t.Name + b.route(d) +} + +// cancelConfirmations discards confirmation deliveries still waiting to be sent. +func (b *InMemoryBackend) cancelConfirmations(subID string) { + for _, d := range b.deliveries { + if d.SubscriptionID == subID && d.Kind == kindConfirmation && d.Status == deliveryPending { + b.finalize(d, deliveryDiscarded, "superseded") + } + } +} + +// PublishMessage fans a message out to every ACTIVE subscription of a topic. +func (b *InMemoryBackend) PublishMessage(topicID, messageType string, in PublishIn) (PublishWire, error) { + if in.Body == nil { + return PublishWire{}, errMissing("body is required") + } + + if messageType == "" { + messageType = msgRawText + } + + if messageType != msgJSON && messageType != msgRawText { + return PublishWire{}, errParam("messageType must be JSON or RAW_TEXT") + } + + title := "" + if in.Title != nil { + title = *in.Title + } + + if len(*in.Body)+len(title) > maxMessageBytes { + return PublishWire{}, errParam("a message is limited to 64 KB") + } + + if messageType == msgJSON && !json.Valid([]byte(*in.Body)) { + return PublishWire{}, errParam("body must be valid JSON when messageType is JSON") + } + + b.mu.Lock("PublishMessage") + defer b.mu.Unlock() + + t, aerr := b.activeTopic(topicID) + if aerr != nil { + return PublishWire{}, aerr + } + + res := PublishWire{MessageID: uuid.NewString(), TimeStamp: b.nowUTC()} + + for _, s := range b.topicSubs(t.ID) { + if s.State != subActive { + continue + } + + d := b.newDelivery(s, t, kindMessage) + d.MessageID, d.Title, d.Body, d.MessageType = res.MessageID, title, *in.Body, messageType + b.route(d) + } + + b.Kick() + + return res, nil +} + +// Deliveries lists delivery records, optionally filtered by topic and subscription, oldest first. +func (b *InMemoryBackend) Deliveries(topicID, subscriptionID string) []DeliveryView { + b.mu.RLock("Deliveries") + defer b.mu.RUnlock() + + var out []DeliveryView + + for _, d := range b.deliveries { + if (topicID == "" || d.TopicID == topicID) && (subscriptionID == "" || d.SubscriptionID == subscriptionID) { + out = append(out, *d) + } + } + + slices.SortFunc(out, func(a, c DeliveryView) int { return int(a.Seq - c.Seq) }) + + return out +} + +// NextDue reports how long until the earliest pending delivery is due; negative when none is waiting. +func (b *InMemoryBackend) NextDue() (time.Duration, func(time.Duration) <-chan time.Time) { + b.mu.RLock("NextDue") + defer b.mu.RUnlock() + + now, wait := b.nowUTC(), time.Duration(-1) + + for _, d := range b.deliveries { + if d.Status != deliveryPending || d.inflight || !b.deliverable(d) { + continue + } + + if w := max(d.NextAttempt.Sub(now), 0); wait < 0 || w < wait { + wait = w + } + } + + return wait, b.after +} + +// deliverable reports whether a pending delivery has a live route; the caller holds the lock. +func (b *InMemoryBackend) deliverable(d *Delivery) bool { + return isHTTPProtocol(d.Protocol) || (d.Protocol == protoFunctions && b.invoker != nil) +} + +// DeliverDue starts an attempt for every pending delivery whose time has come, using spawn to run each one. +func (b *InMemoryBackend) DeliverDue(ctx context.Context, spawn func(func())) { + b.mu.Lock("DeliverDue") + + now := b.nowUTC() + + var due []*Delivery + + for _, d := range b.deliveries { + if d.Status == deliveryPending && !d.inflight && b.deliverable(d) && !d.NextAttempt.After(now) { + d.inflight = true + due = append(due, d) + } + } + + slices.SortFunc(due, func(a, c *Delivery) int { return int(a.Seq - c.Seq) }) + + invoker, client := b.invoker, b.client + b.mu.Unlock() + + for _, d := range due { + snap := *d + + spawn(func() { b.attempt(ctx, client, invoker, d.ID, snap) }) + } +} + +// Header names as Oracle documents them; set directly on the map so the exact casing reaches the wire. +const ( + hdrMessageID = "X-OCI-NS-MessageId" + hdrTopicOcid = "X-OCI-NS-TopicOcid" + hdrTopicName = "X-OCI-NS-TopicName" + hdrMessageType = "X-OCI-NS-MessageType" + hdrUnsubscribeURL = "X-OCI-NS-UnsubscribeURL" + hdrConfirmURL = "X-OCI-NS-ConfirmationURL" + hdrSubscriptionID = "X-OCI-NS-SubscriptionId" +) + +func (snap Delivery) headers() http.Header { + ct := "text/plain; charset=UTF-8" + if snap.MessageType == msgJSON { + ct = "application/json" + } + + h := http.Header{ + "Content-Type": {ct}, + hdrTopicOcid: {snap.TopicID}, + hdrTopicName: {snap.TopicName}, + hdrUnsubscribeURL: {snap.UnsubscribeURL}, + } + + if snap.Kind == kindConfirmation { + h[hdrMessageType] = []string{"SubscriptionConfirmation"} + h[hdrConfirmURL] = []string{snap.ConfirmationURL} + h[hdrSubscriptionID] = []string{snap.SubscriptionID} + + return h + } + + h[hdrMessageType] = []string{"Notification"} + h[hdrMessageID] = []string{snap.MessageID} + + return h +} + +func send(ctx context.Context, client *http.Client, snap Delivery) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, snap.Endpoint, bytes.NewReader([]byte(snap.Body))) + if err != nil { + return 0, fmt.Errorf("build request: %w", err) + } + + req.Header = snap.headers() + + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("post: %w", err) + } + + defer resp.Body.Close() + + msg, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBody)) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return resp.StatusCode, fmt.Errorf( + "%w %d: %s", + ErrEndpointStatus, + resp.StatusCode, + strings.TrimSpace(string(msg)), + ) + } + + return resp.StatusCode, nil +} + +func (b *InMemoryBackend) attempt( + ctx context.Context, client *http.Client, invoker FunctionInvoker, id string, snap Delivery, +) { + var ( + status int + err error + ) + + if snap.Protocol == protoFunctions { + err = invoker(ctx, snap.Endpoint, snap.headers(), []byte(snap.Body)) + } else { + status, err = send(ctx, client, snap) + } + + b.mu.Lock("DeliveryResult") + + d, ok := b.deliveries[id] + if !ok { + b.mu.Unlock() + + return + } + + d.inflight = false + + if ctx.Err() != nil { + b.mu.Unlock() + + return + } + + d.Attempts++ + d.LastStatus = status + + now := b.nowUTC() + + switch { + case err == nil: + d.Status, d.Completed, d.LastError = deliveryDelivered, now, "" + case !now.Before(d.Deadline): + d.Status, d.Completed, d.LastError = deliveryDiscarded, now, err.Error() + default: + d.LastError, d.NextAttempt = err.Error(), now.Add(backoff(d.Attempts)) + } + + b.mu.Unlock() + b.Kick() +} + +// backoff is the wait after a failed attempt: two immediate tries, then 1, 2, 4, 8, 16 and 32 minutes (capped). +func backoff(attempts int) time.Duration { + if attempts < immediateTries { + return 0 + } + + return min(time.Minute<= 0 { + timer = after(wait) + } + + select { + case <-b.kick: + case <-timer: + case <-stop: + return + case <-ctx.Done(): + return + } + + b.DeliverDue(ctx, func(f func()) { wg.Go(f) }) + } +} + +func (h *Handler) publishMessage(q *req) error { + var in PublishIn + if err := q.decode(&in); err != nil { + return err + } + + out, err := h.Backend.PublishMessage(q.param("topicId"), strings.ToUpper(q.r.Header.Get("Messagetype")), in) + if err != nil { + return err + } + + return q.json(http.StatusOK, out, "") +} diff --git a/services/ocinotifications/route.go b/services/ocinotifications/route.go new file mode 100644 index 0000000000..dc2b3de7f5 --- /dev/null +++ b/services/ocinotifications/route.go @@ -0,0 +1,80 @@ +package ocinotifications + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateTopic = "CreateTopic" + opGetTopic = "GetTopic" + opListTopics = "ListTopics" + opUpdateTopic = "UpdateTopic" + opDeleteTopic = "DeleteTopic" + opChangeTopicComp = "ChangeTopicCompartment" + opPublishMessage = "PublishMessage" + opCreateSub = "CreateSubscription" + opGetSub = "GetSubscription" + opListSubs = "ListSubscriptions" + opUpdateSub = "UpdateSubscription" + opDeleteSub = "DeleteSubscription" + opChangeSubComp = "ChangeSubscriptionCompartment" + opGetConfirmSub = "GetConfirmSubscription" + opGetUnsubscription = "GetUnsubscription" + opResendConfirm = "ResendSubscriptionConfirmation" + opAdminKeys = "ManageAPIKeys" + opAdminDeliveries = "ListDeliveries" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + adminDeliveriesPath = "/_gopherstack/oci/deliveries" + topicPath = apiBase + "/topics/{topicId}" + subPath = apiBase + "/subscriptions/{subscriptionId}" + changeCompartment = "/actions/changeCompartment" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: apiBase + "/topics", Op: opCreateTopic}, + {Method: http.MethodGet, Path: apiBase + "/topics", Op: opListTopics}, + {Method: http.MethodGet, Path: topicPath, Op: opGetTopic}, + {Method: http.MethodPut, Path: topicPath, Op: opUpdateTopic}, + {Method: http.MethodDelete, Path: topicPath, Op: opDeleteTopic}, + {Method: http.MethodPost, Path: topicPath + changeCompartment, Op: opChangeTopicComp}, + {Method: http.MethodPost, Path: topicPath + "/messages", Op: opPublishMessage}, + {Method: http.MethodPost, Path: apiBase + "/subscriptions", Op: opCreateSub}, + {Method: http.MethodGet, Path: apiBase + "/subscriptions", Op: opListSubs}, + {Method: http.MethodGet, Path: subPath, Op: opGetSub}, + {Method: http.MethodPut, Path: subPath, Op: opUpdateSub}, + {Method: http.MethodDelete, Path: subPath, Op: opDeleteSub}, + {Method: http.MethodPost, Path: subPath + changeCompartment, Op: opChangeSubComp}, + {Method: http.MethodGet, Path: subPath + "/confirmation", Op: opGetConfirmSub}, + {Method: http.MethodGet, Path: subPath + "/unsubscription", Op: opGetUnsubscription}, + {Method: http.MethodPost, Path: subPath + "/resendConfirmation", Op: opResendConfirm}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if path == adminDeliveriesPath { + return route{op: opAdminDeliveries} + } + + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ocinotifications/settings.go b/services/ocinotifications/settings.go new file mode 100644 index 0000000000..11ae3a30c5 --- /dev/null +++ b/services/ocinotifications/settings.go @@ -0,0 +1,13 @@ +package ocinotifications + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8157 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCINotifications. +type Settings struct { + Port int `json:"port" env:"OCI_NOTIFICATIONS_PORT" default:"8157" name:"port" help:"Fixed TCP port for the dedicated OCI Notifications (topics, subscriptions and publish) listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_NOTIFICATIONS_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ocinotifications/store.go b/services/ocinotifications/store.go new file mode 100644 index 0000000000..7651d4a400 --- /dev/null +++ b/services/ocinotifications/store.go @@ -0,0 +1,211 @@ +package ocinotifications + +import ( + "cmp" + "context" + "net/http" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "us-ashburn-1" + defaultListLimit = 100 + ocidTailLen = 52 + shortIDLen = 12 + + defaultTransition = 2 * time.Second + maxName = 256 + attemptTimeout = 15 * time.Second + maxKeptDeliveries = 2000 +) + +// FunctionInvoker invokes an OCI Function by OCID with the message as payload. +type FunctionInvoker func(ctx context.Context, functionID string, header http.Header, body []byte) error + +// InMemoryBackend holds every topic, subscription and delivery under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + after func(time.Duration) <-chan time.Time + kick chan struct{} + client *http.Client + invoker FunctionInvoker + topics map[string]*Topic + subs map[string]*Subscription + deliveries map[string]*Delivery + Auth *ociauth.Registry + transition time.Duration + seq int64 +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{ + mu: lockmetrics.New("ocinotifications"), now: time.Now, after: time.After, transition: defaultTransition, + kick: make(chan struct{}, 1), client: httpclient.New(attemptTimeout), + } + b.resetMaps() + b.Auth = ociauth.NewRegistry() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.topics, b.subs, b.deliveries = map[string]*Topic{}, map[string]*Subscription{}, map[string]*Delivery{} +} + +// SetClock injects the time source used for lifecycle transitions and delivery retries. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTimer injects the wait the dispatcher uses until the next retry; it must return a channel firing after d. +func (b *InMemoryBackend) SetTimer(after func(time.Duration) <-chan time.Time) { + b.mu.Lock("SetTimer") + b.after = after + b.mu.Unlock() +} + +// SetTransition sets how long CREATING and DELETING topic states last. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetFunctionInvoker wires ORACLE_FUNCTIONS subscriptions to the OCI Functions backend. +func (b *InMemoryBackend) SetFunctionInvoker(fn FunctionInvoker) { + b.mu.Lock("SetFunctionInvoker") + b.invoker = fn + b.mu.Unlock() +} + +// Kick wakes the dispatcher so it re-evaluates which deliveries are due. +func (b *InMemoryBackend) Kick() { + select { + case b.kick <- struct{}{}: + default: + } +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate[T any](items []T, o ListOpts, state func(T) string, created func(T) (time.Time, int64)) page.Page[T] { + byState := strings.EqualFold(o.SortBy, "LIFECYCLESTATE") + desc := !byState + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + slices.SortStableFunc(items, func(a, c T) int { + ta, sa := created(a) + tc, sc := created(c) + r := cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + + if byState { + r = cmp.Or(cmp.Compare(state(a), state(c)), r) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +// Sweep applies topic lifecycle transitions against the backend clock; deleted topics take their subscriptions along. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + b.sweepLocked() +} + +func (b *InMemoryBackend) sweepLocked() { + now := b.nowUTC() + + for id, t := range b.topics { + if t.TransitionAt == nil || t.TransitionAt.After(now) { + continue + } + + t.TransitionAt = nil + + switch t.State { + case stateCreating: + t.State = stateActive + case stateDeleting: + delete(b.topics, id) + b.dropTopicChildren(id) + } + } +} + +func (b *InMemoryBackend) dropTopicChildren(topicID string) { + for id, s := range b.subs { + if s.TopicID == topicID { + delete(b.subs, id) + } + } + + for id, d := range b.deliveries { + if d.TopicID == topicID { + delete(b.deliveries, id) + } + } +} diff --git a/services/ocinotifications/subs.go b/services/ocinotifications/subs.go new file mode 100644 index 0000000000..de6576d716 --- /dev/null +++ b/services/ocinotifications/subs.go @@ -0,0 +1,612 @@ +package ocinotifications + +import ( + "encoding/json" + "net/http" + "net/mail" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + maxEndpoint = 512 + defaultRetryMs = 7200000 + minRetryMs = 10000 + maxRetryMs = 86400000 + policyExponential = "EXPONENTIAL" + apiBase = "/20181201" + + protoEmail = "EMAIL" + protoHTTPS = "HTTPS" + protoCustom = "CUSTOM_HTTPS" + protoFunctions = "ORACLE_FUNCTIONS" + protoPagerDuty = "PAGERDUTY" + protoSlack = "SLACK" + protoSMS = "SMS" +) + +var e164 = regexp.MustCompile(`^\+[1-9][0-9]{6,14}$`) + +// CreateSubscriptionIn is the CreateSubscription request body. +type CreateSubscriptionIn struct { + Tags + Metadata *string `json:"metadata"` + TopicID string `json:"topicId"` + Compartment string `json:"compartmentId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` +} + +type retryPolicyWire struct { + PolicyType string `json:"policyType"` + MaxRetryDuration int `json:"maxRetryDuration"` +} + +type deliveryPolicyWire struct { + BackoffRetryPolicy *retryPolicyWire `json:"backoffRetryPolicy,omitempty"` +} + +// UpdateSubscriptionIn is the UpdateSubscription request body. +type UpdateSubscriptionIn struct { + Tags + DeliveryPolicy *deliveryPolicyWire `json:"deliveryPolicy"` +} + +type SubWire struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + TopicID string `json:"topicId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + LifecycleState string `json:"lifecycleState"` + CompartmentID string `json:"compartmentId"` + DeliverPolicy string `json:"deliverPolicy"` + Etag string `json:"etag"` + CreatedTime int64 `json:"createdTime"` +} + +type subSummaryWire struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + DeliveryPolicy deliveryPolicyWire `json:"deliveryPolicy"` + ID string `json:"id"` + TopicID string `json:"topicId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + LifecycleState string `json:"lifecycleState"` + CompartmentID string `json:"compartmentId"` + Etag string `json:"etag"` + CreatedTime int64 `json:"createdTime"` +} + +type ConfirmationWire struct { + TopicName string `json:"topicName"` + TopicID string `json:"topicId"` + Endpoint string `json:"endpoint"` + UnsubscribeURL string `json:"unsubscribeUrl"` + Message string `json:"message"` + SubscriptionID string `json:"subscriptionId"` +} + +func policyOf(s *Subscription) deliveryPolicyWire { + return deliveryPolicyWire{ + BackoffRetryPolicy: &retryPolicyWire{PolicyType: policyExponential, MaxRetryDuration: s.MaxRetryMs}, + } +} + +func subView(s *Subscription) SubWire { + pol, _ := json.Marshal(policyOf(s)) //nolint:errchkjson // static shape + + return SubWire{ + ID: s.ID, + TopicID: s.TopicID, + Protocol: s.Protocol, + Endpoint: s.Endpoint, + LifecycleState: s.State, + CompartmentID: s.CompartmentID, + DeliverPolicy: string(pol), + Etag: s.Etag, + CreatedTime: s.TimeCreated.UnixMilli(), + FreeformTags: s.Tags.FreeformTags, + DefinedTags: s.Tags.DefinedTags, + } +} + +func subSummary(s *Subscription) subSummaryWire { + return subSummaryWire{ + ID: s.ID, + TopicID: s.TopicID, + Protocol: s.Protocol, + Endpoint: s.Endpoint, + LifecycleState: s.State, + CompartmentID: s.CompartmentID, + DeliveryPolicy: policyOf(s), + Etag: s.Etag, + CreatedTime: s.TimeCreated.UnixMilli(), + FreeformTags: s.Tags.FreeformTags, + DefinedTags: s.Tags.DefinedTags, + } +} + +func validateHTTPURL(endpoint string) *APIError { + u, err := url.Parse(endpoint) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return errParam("endpoint must be a URL beginning with http: or https:") + } + + return nil +} + +// validateEndpoint checks the endpoint format each protocol expects. +func validateEndpoint(protocol, endpoint string) *APIError { + if endpoint == "" { + return errMissing("endpoint is required") + } + + if len(endpoint) > maxEndpoint { + return errParam("endpoint must be at most 512 characters") + } + + switch protocol { + case protoEmail: + if a, err := mail.ParseAddress(endpoint); err != nil || a.Address != endpoint { + return errParam("endpoint must be an email address") + } + case protoHTTPS, protoCustom, protoSlack: + return validateHTTPURL(endpoint) + case protoSMS: + if !e164.MatchString(endpoint) { + return errParam("endpoint must be a phone number in E.164 format") + } + case protoFunctions: + if !strings.HasPrefix(endpoint, "ocid1.fnfunc.") { + return errParam("endpoint must be the OCID of a function") + } + case protoPagerDuty: + default: + return errParam("protocol must be one of CUSTOM_HTTPS, EMAIL, HTTPS, ORACLE_FUNCTIONS, PAGERDUTY, SLACK, SMS") + } + + return nil +} + +func needsConfirmation(protocol string) bool { + return protocol != protoFunctions && protocol != protoPagerDuty +} + +func (b *InMemoryBackend) sub(id string) (*Subscription, *APIError) { + b.sweepLocked() + + s, ok := b.subs[id] + if !ok { + return nil, errNotFound(id) + } + + return s, nil +} + +func (b *InMemoryBackend) confirmationURL(s *Subscription) string { + return s.BaseURL + apiBase + "/subscriptions/" + s.ID + "/confirmation?token=" + url.QueryEscape(s.ConfirmToken) + + "&protocol=" + s.Protocol +} + +func unsubscribeURL(s *Subscription) string { + return s.BaseURL + apiBase + "/subscriptions/" + s.ID + "/unsubscription?token=" + url.QueryEscape(s.UnsubToken) + + "&protocol=" + s.Protocol +} + +// CreateSubscription subscribes an endpoint to a topic; protocols that need it start PENDING and are sent a +// confirmation. +func (b *InMemoryBackend) CreateSubscription(in CreateSubscriptionIn, baseURL string) (SubWire, error) { + if in.TopicID == "" || in.Compartment == "" { + return SubWire{}, errMissing("topicId and compartmentId are required") + } + + if in.Protocol == "" { + return SubWire{}, errMissing("protocol is required") + } + + if aerr := validateEndpoint(in.Protocol, in.Endpoint); aerr != nil { + return SubWire{}, aerr + } + + b.mu.Lock("CreateSubscription") + defer b.mu.Unlock() + + topic, aerr := b.activeTopic(in.TopicID) + if aerr != nil { + return SubWire{}, aerr + } + + for _, s := range b.subs { + if s.TopicID == in.TopicID && s.Protocol == in.Protocol && s.Endpoint == in.Endpoint { + return SubWire{}, errExists("The endpoint is already subscribed to this topic") + } + } + + s := &Subscription{ + ID: newOCID("onssubscription"), TopicID: topic.ID, CompartmentID: in.Compartment, Protocol: in.Protocol, + Endpoint: in.Endpoint, State: subActive, BaseURL: baseURL, TimeCreated: b.nowUTC(), Tags: in.Tags, + ConfirmToken: uuid.NewString(), UnsubToken: uuid.NewString(), Etag: newEtag(), MaxRetryMs: defaultRetryMs, + Seq: b.nextSeq(), + } + if in.Metadata != nil { + s.Metadata = *in.Metadata + } + + if needsConfirmation(s.Protocol) { + s.State = subPending + b.queueConfirmation(s, topic) + } + + b.subs[s.ID] = s + + return subView(s), nil +} + +// GetSubscription returns one subscription. +func (b *InMemoryBackend) GetSubscription(id string) (SubWire, error) { + b.mu.Lock("GetSubscription") + defer b.mu.Unlock() + + s, aerr := b.sub(id) + if aerr != nil { + return SubWire{}, aerr + } + + return subView(s), nil +} + +// ListSubscriptions lists the subscriptions of a compartment, optionally of one topic. +func (b *InMemoryBackend) ListSubscriptions( + compartment, topicID string, + o ListOpts, +) (page.Page[subSummaryWire], error) { + if compartment == "" { + return page.Page[subSummaryWire]{}, errMissing("compartmentId is required") + } + + b.mu.Lock("ListSubscriptions") + defer b.mu.Unlock() + + b.sweepLocked() + + var all []*Subscription + + for _, s := range b.subs { + if s.CompartmentID == compartment && (topicID == "" || s.TopicID == topicID) { + all = append(all, s) + } + } + + o.SortBy = "" + pg := paginate(all, o, func(s *Subscription) string { return s.State }, + func(s *Subscription) (time.Time, int64) { return s.TimeCreated, s.Seq }) + + out := make([]subSummaryWire, 0, len(pg.Data)) + for _, s := range pg.Data { + out = append(out, subSummary(s)) + } + + return page.Page[subSummaryWire]{Data: out, Next: pg.Next}, nil +} + +func validatePolicy(p *deliveryPolicyWire) *APIError { + if p == nil || p.BackoffRetryPolicy == nil { + return nil + } + + r := p.BackoffRetryPolicy + if r.PolicyType != policyExponential { + return errParam("backoffRetryPolicy.policyType must be EXPONENTIAL") + } + + if r.MaxRetryDuration < minRetryMs || r.MaxRetryDuration > maxRetryMs { + return errParam("backoffRetryPolicy.maxRetryDuration must be between " + strconv.Itoa(minRetryMs) + + " and " + strconv.Itoa(maxRetryMs) + " milliseconds") + } + + return nil +} + +// UpdateSubscription changes a subscription's delivery policy and tags. +func (b *InMemoryBackend) UpdateSubscription( + id, ifMatch string, + in UpdateSubscriptionIn, +) (UpdateSubscriptionIn, string, error) { + if aerr := validatePolicy(in.DeliveryPolicy); aerr != nil { + return UpdateSubscriptionIn{}, "", aerr + } + + b.mu.Lock("UpdateSubscription") + defer b.mu.Unlock() + + s, aerr := b.sub(id) + if aerr != nil { + return UpdateSubscriptionIn{}, "", aerr + } + + if aerr = checkEtag(s.Etag, ifMatch); aerr != nil { + return UpdateSubscriptionIn{}, "", aerr + } + + if in.DeliveryPolicy != nil && in.DeliveryPolicy.BackoffRetryPolicy != nil { + s.MaxRetryMs = in.DeliveryPolicy.BackoffRetryPolicy.MaxRetryDuration + } + + s.Tags.merge(in.Tags) + s.Etag = newEtag() + pol := policyOf(s) + + return UpdateSubscriptionIn{DeliveryPolicy: &pol, Tags: s.Tags}, s.Etag, nil +} + +func (b *InMemoryBackend) dropSubscription(id string) { + delete(b.subs, id) + + for did, d := range b.deliveries { + if d.SubscriptionID == id { + delete(b.deliveries, did) + } + } +} + +// DeleteSubscription removes a subscription and its deliveries. +func (b *InMemoryBackend) DeleteSubscription(id, ifMatch string) error { + b.mu.Lock("DeleteSubscription") + defer b.mu.Unlock() + + s, aerr := b.sub(id) + if aerr != nil { + return aerr + } + + if aerr = checkEtag(s.Etag, ifMatch); aerr != nil { + return aerr + } + + b.dropSubscription(id) + + return nil +} + +// ChangeSubscriptionCompartment moves a subscription to another compartment. +func (b *InMemoryBackend) ChangeSubscriptionCompartment(id, ifMatch, compartment string) error { + if compartment == "" { + return errMissing("compartmentId is required") + } + + b.mu.Lock("ChangeSubscriptionCompartment") + defer b.mu.Unlock() + + s, aerr := b.sub(id) + if aerr != nil { + return aerr + } + + if aerr = checkEtag(s.Etag, ifMatch); aerr != nil { + return aerr + } + + s.CompartmentID, s.Etag = compartment, newEtag() + + return nil +} + +func (b *InMemoryBackend) tokenSub(id, token, protocol string, unsub bool) (*Subscription, *Topic, *APIError) { + s, aerr := b.sub(id) + if aerr != nil { + return nil, nil, aerr + } + + want := s.ConfirmToken + if unsub { + want = s.UnsubToken + } + + if token == "" || token != want || !strings.EqualFold(protocol, s.Protocol) { + return nil, nil, errParam("The token or protocol does not match the subscription") + } + + return s, b.topics[s.TopicID], nil +} + +// ConfirmSubscription activates a PENDING subscription from the token sent to its endpoint. +func (b *InMemoryBackend) ConfirmSubscription(id, token, protocol string) (ConfirmationWire, error) { + b.mu.Lock("ConfirmSubscription") + defer b.mu.Unlock() + + s, topic, aerr := b.tokenSub(id, token, protocol, false) + if aerr != nil { + return ConfirmationWire{}, aerr + } + + if s.State == subPending { + s.State, s.Etag = subActive, newEtag() + } + + b.cancelConfirmations(s.ID) + + name := "" + if topic != nil { + name = topic.Name + } + + return ConfirmationWire{ + TopicName: name, TopicID: s.TopicID, Endpoint: s.Endpoint, UnsubscribeURL: unsubscribeURL(s), + Message: "Subscription confirmed", SubscriptionID: s.ID, + }, nil +} + +// Unsubscribe removes a subscription from the token embedded in its unsubscribe link. +func (b *InMemoryBackend) Unsubscribe(id, token, protocol string) (string, error) { + b.mu.Lock("Unsubscribe") + defer b.mu.Unlock() + + s, _, aerr := b.tokenSub(id, token, protocol, true) + if aerr != nil { + return "", aerr + } + + b.dropSubscription(s.ID) + + return "Subscription " + s.ID + " was unsubscribed", nil +} + +// ResendConfirmation sends a PENDING subscription's confirmation again. +func (b *InMemoryBackend) ResendConfirmation(id string) (SubWire, string, error) { + b.mu.Lock("ResendConfirmation") + defer b.mu.Unlock() + + s, aerr := b.sub(id) + if aerr != nil { + return SubWire{}, "", aerr + } + + if s.State != subPending { + return SubWire{}, "", errState( + "Subscription " + id + " is " + s.State + "; only PENDING subscriptions can be resent", + ) + } + + topic, aerr := b.activeTopic(s.TopicID) + if aerr != nil { + return SubWire{}, "", aerr + } + + b.cancelConfirmations(s.ID) + b.queueConfirmation(s, topic) + + return subView(s), s.Etag, nil +} + +func (b *InMemoryBackend) topicSubs(topicID string) []*Subscription { + var out []*Subscription + + for _, s := range b.subs { + if s.TopicID == topicID { + out = append(out, s) + } + } + + slices.SortFunc(out, func(a, c *Subscription) int { return int(a.Seq - c.Seq) }) + + return out +} + +func (h *Handler) createSubscription(q *req) error { + var in CreateSubscriptionIn + if err := q.decode(&in); err != nil { + return err + } + + v, err := h.Backend.CreateSubscription(in, q.endpoint()) + if err != nil { + return err + } + + h.Backend.Kick() + + return q.json(http.StatusOK, v, v.Etag) +} + +func (h *Handler) getSubscription(q *req) error { + v, err := h.Backend.GetSubscription(q.param("subscriptionId")) + if err != nil { + return err + } + + return q.json(http.StatusOK, v, v.Etag) +} + +func (h *Handler) listSubscriptions(q *req) error { + pg, err := h.Backend.ListSubscriptions(q.query("compartmentId"), q.query("topicId"), q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, pg.Data, "") +} + +func (h *Handler) updateSubscription(q *req) error { + var in UpdateSubscriptionIn + if err := q.decode(&in); err != nil { + return err + } + + out, etag, err := h.Backend.UpdateSubscription(q.param("subscriptionId"), q.ifMatch(), in) + if err != nil { + return err + } + + return q.json(http.StatusOK, out, etag) +} + +func (h *Handler) deleteSubscription(q *req) error { + if err := h.Backend.DeleteSubscription(q.param("subscriptionId"), q.ifMatch()); err != nil { + return err + } + + q.w.WriteHeader(http.StatusNoContent) + + return nil +} + +func (h *Handler) changeSubscriptionCompartment(q *req) error { + var in changeCompartmentIn + if err := q.decode(&in); err != nil { + return err + } + + if err := h.Backend.ChangeSubscriptionCompartment( + q.param("subscriptionId"), + q.ifMatch(), + in.CompartmentID, + ); err != nil { + return err + } + + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (h *Handler) confirmSubscription(q *req) error { + out, err := h.Backend.ConfirmSubscription(q.param("subscriptionId"), q.query("token"), q.query("protocol")) + if err != nil { + return err + } + + return q.json(http.StatusOK, out, "") +} + +func (h *Handler) unsubscribe(q *req) error { + msg, err := h.Backend.Unsubscribe(q.param("subscriptionId"), q.query("token"), q.query("protocol")) + if err != nil { + return err + } + + return q.json(http.StatusOK, msg, "") +} + +func (h *Handler) resendConfirmation(q *req) error { + v, etag, err := h.Backend.ResendConfirmation(q.param("subscriptionId")) + if err != nil { + return err + } + + h.Backend.Kick() + + return q.json(http.StatusOK, v, etag) +} diff --git a/services/ocinotifications/subscriptions_test.go b/services/ocinotifications/subscriptions_test.go new file mode 100644 index 0000000000..876e657828 --- /dev/null +++ b/services/ocinotifications/subscriptions_test.go @@ -0,0 +1,632 @@ +package ocinotifications_test + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/ons" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateSubscriptionValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "val") + + tests := []struct { + name string + protocol string + endpoint string + code int + }{ + {"email ok", "EMAIL", "a@example.com", 0}, + {"email bad", "EMAIL", "not-an-email", http.StatusBadRequest}, + {"https ok", "HTTPS", "https://example.com/hook", 0}, + {"https bad scheme", "HTTPS", "ftp://example.com", http.StatusBadRequest}, + {"custom https ok", "CUSTOM_HTTPS", "http://example.com/hook", 0}, + {"sms ok", "SMS", "+14155550100", 0}, + {"sms bad", "SMS", "4155550100", http.StatusBadRequest}, + {"function ok", "ORACLE_FUNCTIONS", "ocid1.fnfunc.oc1..abc", 0}, + {"function bad", "ORACLE_FUNCTIONS", "fn", http.StatusBadRequest}, + {"slack bad", "SLACK", "hook", http.StatusBadRequest}, + {"pagerduty ok", "PAGERDUTY", "key", 0}, + {"unknown protocol", "CARRIER_PIGEON", "x", http.StatusBadRequest}, + {"long endpoint", "HTTPS", "https://example.com/" + strings.Repeat("a", 512), http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.dp.CreateSubscription(t.Context(), ons.CreateSubscriptionRequest{ + TopicId: topic.TopicId, CompartmentId: new(testCompartment), + Protocol: new(tt.protocol), Endpoint: new(tt.endpoint), + }) + if tt.code == 0 { + require.NoError(t, err) + + return + } + + assert.Equal(t, tt.code, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} + +func TestSubscriptionInitialState(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "states") + + tests := []struct { + protocol string + endpoint string + want ons.SubscriptionLifecycleStateEnum + }{ + {"EMAIL", "a@example.com", ons.SubscriptionLifecycleStatePending}, + {"HTTPS", "https://example.com/a", ons.SubscriptionLifecycleStatePending}, + {"CUSTOM_HTTPS", "https://example.com/b", ons.SubscriptionLifecycleStatePending}, + {"SMS", "+14155550100", ons.SubscriptionLifecycleStatePending}, + {"SLACK", "https://hooks.example.com/x", ons.SubscriptionLifecycleStatePending}, + {"ORACLE_FUNCTIONS", "ocid1.fnfunc.oc1..abc", ons.SubscriptionLifecycleStateActive}, + {"PAGERDUTY", "key", ons.SubscriptionLifecycleStateActive}, + } + + for _, tt := range tests { + t.Run(tt.protocol, func(t *testing.T) { + t.Parallel() + + sub := e.subscribe(t, topic, tt.protocol, tt.endpoint) + assert.Equal(t, tt.want, sub.LifecycleState) + assert.Contains(t, *sub.DeliverPolicy, "7200000") + assert.NotNil(t, sub.CreatedTime) + require.NotNil(t, sub.Etag) + }) + } +} + +func TestDuplicateSubscription(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "dup") + e.subscribe(t, topic, "EMAIL", "a@example.com") + + _, err := e.dp.CreateSubscription(t.Context(), ons.CreateSubscriptionRequest{ + TopicId: topic.TopicId, CompartmentId: new(testCompartment), + Protocol: new("EMAIL"), Endpoint: new("a@example.com"), + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestHTTPSConfirmationAndDelivery(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + topic := e.mkTopic(t, "https") + sink := newSink(t) + sub := e.subscribe(t, topic, "HTTPS", sink.srv.URL+"/hook") + + conf := sink.next(t) + assert.Equal(t, *sub.Id, conf.header.Get("X-Oci-Ns-Subscriptionid")) + assert.Equal(t, "SubscriptionConfirmation", conf.header.Get("X-Oci-Ns-Messagetype")) + assert.Contains(t, conf.body, "ConfirmationURL") + + skipped := e.publish(t, topic, "before confirmation") + assert.NotEmpty(t, *skipped.MessageId) + assert.Empty(t, sink.requests, "a PENDING subscription receives nothing") + + // The confirmation link is unsigned: it must work against an enforcing listener. + u, err := url.Parse(conf.header.Get("X-Oci-Ns-Confirmationurl")) + require.NoError(t, err) + + status, body := rawDo(t, http.MethodGet, conf.header.Get("X-Oci-Ns-Confirmationurl"), "") + require.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, *sub.Id) + assert.NotEmpty(t, u.Query().Get("token")) + + got, err := e.dp.GetSubscription(t.Context(), ons.GetSubscriptionRequest{SubscriptionId: sub.Id}) + require.NoError(t, err) + assert.Equal(t, ons.SubscriptionLifecycleStateActive, got.LifecycleState) + + res := e.publish(t, topic, `{"hello":"world"}`) + + msg := sink.next(t) + assert.JSONEq(t, `{"hello":"world"}`, msg.body) + assert.Equal(t, *res.MessageId, msg.header.Get("X-Oci-Ns-Messageid")) + assert.Equal(t, *topic.TopicId, msg.header.Get("X-Oci-Ns-Topicocid")) + assert.Equal(t, "https", msg.header.Get("X-Oci-Ns-Topicname")) + assert.Equal(t, "Notification", msg.header.Get("X-Oci-Ns-Messagetype")) + assert.Contains(t, msg.header.Get("X-Oci-Ns-Unsubscribeurl"), "/unsubscription?token=") + assert.Contains(t, msg.header.Get("Content-Type"), "text/plain") + + require.Eventually(t, func() bool { + for _, d := range e.deliveries(t, *sub.Id) { + if d.Kind == "MESSAGE" && d.MessageID == *res.MessageId { + return d.Status == "DELIVERED" && d.Attempts == 1 + } + } + + return false + }, 5*time.Second, 5*time.Millisecond) +} + +func TestJSONMessageTypeAndTitle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "json") + sink := newSink(t) + sub := e.subscribe(t, topic, "CUSTOM_HTTPS", sink.srv.URL) + e.confirm(t, sub, sink.next(t).header.Get("X-Oci-Ns-Confirmationurl")) + + _, err := e.dp.PublishMessage(t.Context(), ons.PublishMessageRequest{ + TopicId: topic.TopicId, MessageType: ons.PublishMessageMessageTypeJson, + Body: new(`{"a":1}`), Title: new("t"), + }) + require.NoError(t, err) + assert.Equal(t, "application/json", sink.next(t).header.Get("Content-Type")) + + _, err = e.dp.PublishMessage(t.Context(), ons.PublishMessageRequest{ + TopicId: topic.TopicId, MessageType: ons.PublishMessageMessageTypeJson, + Body: new("not json"), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestPublishValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "pub") + + tests := []struct { + name string + body string + ok bool + }{ + {"limit", strings.Repeat("x", 64*1024), true}, + {"over limit", strings.Repeat("x", 64*1024+1), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.dp.PublishMessage(t.Context(), ons.PublishMessageRequest{ + TopicId: topic.TopicId, Body: new(tt.body), + }) + if tt.ok { + require.NoError(t, err) + + return + } + + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + _, err := e.dp.PublishMessage(t.Context(), ons.PublishMessageRequest{ + TopicId: new("ocid1.onstopic.oc1..nope"), Body: new("x"), + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestDeliveryRetriesWithBackoff(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "retry") + sink := newSink(t, 200, 500, 503) + sub := e.subscribe(t, topic, "HTTPS", sink.srv.URL) + e.confirm(t, sub, sink.next(t).header.Get("X-Oci-Ns-Confirmationurl")) + + res := e.publish(t, topic, "retry me") + + attempts := func() (int, string) { + for _, d := range e.deliveries(t, *sub.Id) { + if d.MessageID == *res.MessageId { + return d.Attempts, d.Status + } + } + + return 0, "" + } + + sink.next(t) // 500 + sink.next(t) // 503: the immediate retry + + require.Eventually(t, func() bool { + n, _ := attempts() + + return n == 2 + }, 5*time.Second, 5*time.Millisecond) + assert.Empty(t, sink.requests, "no further attempt before the first backoff elapses") + + e.clock.Advance(30 * time.Second) + e.backend.Kick() + e.clock.Advance(31 * time.Second) + e.backend.Kick() + + assert.Equal(t, "retry me", sink.next(t).body) + + require.Eventually(t, func() bool { + n, st := attempts() + + return n == 3 && st == "DELIVERED" + }, 5*time.Second, 5*time.Millisecond) +} + +func TestDeliveryDiscardedAfterRetryDuration(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "discard") + sink := newSink(t, 200, 500, 500, 500) + sub := e.subscribe(t, topic, "HTTPS", sink.srv.URL) + e.confirm(t, sub, sink.next(t).header.Get("X-Oci-Ns-Confirmationurl")) + + _, err := e.dp.UpdateSubscription(t.Context(), ons.UpdateSubscriptionRequest{ + SubscriptionId: sub.Id, + DeliveryPolicy: &ons.DeliveryPolicy{ + BackoffRetryPolicy: &ons.BackoffRetryPolicy{ + MaxRetryDuration: new(10000), PolicyType: ons.BackoffRetryPolicyPolicyTypeExponential, + }, + }, + }) + require.NoError(t, err) + + res := e.publish(t, topic, "doomed") + sink.next(t) + sink.next(t) + + status := func() (int, string) { + for _, d := range e.deliveries(t, *sub.Id) { + if d.MessageID == *res.MessageId { + return d.Attempts, d.Status + } + } + + return 0, "" + } + + require.Eventually(t, func() bool { + n, _ := status() + + return n == 2 + }, 5*time.Second, 5*time.Millisecond) + + e.clock.Advance(61 * time.Second) + e.backend.Kick() + sink.next(t) + + require.Eventually(t, func() bool { + _, st := status() + + return st == "DISCARDED" + }, 5*time.Second, 5*time.Millisecond) +} + +func TestUpdateSubscriptionPolicyValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "policy") + sub := e.subscribe(t, topic, "PAGERDUTY", "key") + + tests := []struct { + name string + ms int + ok bool + }{ + {"too short", 9999, false}, + {"too long", 86400001, false}, + {"valid", 60000, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := e.dp.UpdateSubscription(t.Context(), ons.UpdateSubscriptionRequest{ + SubscriptionId: sub.Id, + FreeformTags: map[string]string{"a": "b"}, + DeliveryPolicy: &ons.DeliveryPolicy{BackoffRetryPolicy: &ons.BackoffRetryPolicy{ + MaxRetryDuration: new(tt.ms), PolicyType: ons.BackoffRetryPolicyPolicyTypeExponential, + }}, + }) + if !tt.ok { + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.ms, *resp.DeliveryPolicy.BackoffRetryPolicy.MaxRetryDuration) + require.NotNil(t, resp.Etag) + + list, lerr := e.dp.ListSubscriptions(t.Context(), ons.ListSubscriptionsRequest{ + CompartmentId: new(testCompartment), TopicId: topic.TopicId, + }) + require.NoError(t, lerr) + require.Len(t, list.Items, 1) + assert.Equal(t, tt.ms, *list.Items[0].DeliveryPolicy.BackoffRetryPolicy.MaxRetryDuration) + }) + } +} + +func TestRecordedProtocols(t *testing.T) { + t.Parallel() + + tests := []struct { + protocol string + endpoint string + }{ + {"EMAIL", "ops@example.com"}, + {"SMS", "+14155550100"}, + {"SLACK", "https://hooks.example.com/services/x"}, + } + + for _, tt := range tests { + t.Run(tt.protocol, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "rec") + sub := e.subscribe(t, topic, tt.protocol, tt.endpoint) + + ds := e.deliveries(t, *sub.Id) + require.Len(t, ds, 1) + assert.Equal(t, "CONFIRMATION", ds[0].Kind) + assert.Equal(t, "RECORDED", ds[0].Status) + assert.Equal(t, tt.endpoint, ds[0].Endpoint) + + e.publish(t, topic, "before") + assert.Len(t, e.deliveries(t, *sub.Id), 1, "PENDING subscriptions receive nothing") + + res := e.confirm(t, sub, ds[0].ConfirmationURL) + assert.Equal(t, tt.endpoint, *res.Endpoint) + assert.Equal(t, *topic.TopicId, *res.TopicId) + assert.Equal(t, "rec", *res.TopicName) + + published := e.publish(t, topic, "after") + + ds = e.deliveries(t, *sub.Id) + require.Len(t, ds, 2) + assert.Equal(t, "MESSAGE", ds[1].Kind) + assert.Equal(t, "RECORDED", ds[1].Status) + assert.Equal(t, "after", ds[1].Body) + assert.Equal(t, *published.MessageId, ds[1].MessageID) + }) + } +} + +func TestConfirmationTokenChecks(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "tok") + sub := e.subscribe(t, topic, "EMAIL", "a@example.com") + + tests := []struct { + id *string + name string + token string + protocol string + code int + }{ + {sub.Id, "wrong token", "nope", "EMAIL", http.StatusBadRequest}, + {sub.Id, "wrong protocol", "x", "SMS", http.StatusBadRequest}, + {new("missing"), "unknown subscription", "x", "EMAIL", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := e.dp.GetConfirmSubscription(t.Context(), ons.GetConfirmSubscriptionRequest{ + Id: tt.id, Token: new(tt.token), Protocol: new(tt.protocol), + }) + assert.Equal(t, tt.code, svcErr(t, err).GetHTTPStatusCode()) + }) + } +} + +func TestResendConfirmation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "resend") + sub := e.subscribe(t, topic, "EMAIL", "a@example.com") + + _, err := e.dp.ResendSubscriptionConfirmation(t.Context(), ons.ResendSubscriptionConfirmationRequest{Id: sub.Id}) + require.NoError(t, err) + + ds := e.deliveries(t, *sub.Id) + require.Len(t, ds, 2) + assert.Equal(t, "RECORDED", ds[1].Status) + + e.confirm(t, sub, ds[1].ConfirmationURL) + + _, err = e.dp.ResendSubscriptionConfirmation(t.Context(), ons.ResendSubscriptionConfirmationRequest{Id: sub.Id}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestUnsubscribeLink(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + topic := e.mkTopic(t, "unsub") + sink := newSink(t) + sub := e.subscribe(t, topic, "HTTPS", sink.srv.URL) + e.confirm(t, sub, sink.next(t).header.Get("X-Oci-Ns-Confirmationurl")) + + e.publish(t, topic, "hello") + link := sink.next(t).header.Get("X-Oci-Ns-Unsubscribeurl") + + status, _ := rawDo(t, http.MethodGet, link, "") + require.Equal(t, http.StatusOK, status) + + _, err := e.dp.GetSubscription(t.Context(), ons.GetSubscriptionRequest{SubscriptionId: sub.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + status, _ = rawDo(t, http.MethodGet, link, "") + assert.Equal(t, http.StatusNotFound, status) +} + +func TestUnsubscriptionThroughSDK(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "sdkunsub") + sub := e.subscribe(t, topic, "EMAIL", "a@example.com") + + link := e.deliveries(t, *sub.Id)[0].UnsubscribeURL + u, err := url.Parse(link) + require.NoError(t, err) + + resp, err := e.dp.GetUnsubscription(t.Context(), ons.GetUnsubscriptionRequest{ + Id: sub.Id, Token: new(u.Query().Get("token")), Protocol: new("EMAIL"), + }) + require.NoError(t, err) + require.NotNil(t, resp.Value) + assert.Contains(t, *resp.Value, *sub.Id) +} + +func TestSubscriptionManagement(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + topic := e.mkTopic(t, "mgmt") + sub := e.subscribe(t, topic, "PAGERDUTY", "key") + + _, err := e.dp.ChangeSubscriptionCompartment(t.Context(), ons.ChangeSubscriptionCompartmentRequest{ + SubscriptionId: sub.Id, + ChangeSubscriptionCompartmentDetails: ons.ChangeCompartmentDetails{ + CompartmentId: new("ocid1.compartment.oc1..other"), + }, + }) + require.NoError(t, err) + + got, err := e.dp.GetSubscription(t.Context(), ons.GetSubscriptionRequest{SubscriptionId: sub.Id}) + require.NoError(t, err) + assert.Equal(t, "ocid1.compartment.oc1..other", *got.CompartmentId) + + _, err = e.dp.DeleteSubscription( + t.Context(), + ons.DeleteSubscriptionRequest{SubscriptionId: sub.Id, IfMatch: new("stale")}, + ) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.dp.DeleteSubscription( + t.Context(), + ons.DeleteSubscriptionRequest{SubscriptionId: sub.Id, IfMatch: got.Etag}, + ) + require.NoError(t, err) + + _, err = e.dp.DeleteSubscription(t.Context(), ons.DeleteSubscriptionRequest{SubscriptionId: sub.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestFunctionsDelivery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + invoker func(context.Context, string, http.Header, []byte) error + want string + }{ + {"invoked", func(context.Context, string, http.Header, []byte) error { return nil }, "DELIVERED"}, + {"no backend", nil, "RECORDED"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + var ( + mu sync.Mutex + seen []string + ) + + if tt.invoker != nil { + e.backend.SetFunctionInvoker(func(ctx context.Context, fn string, h http.Header, body []byte) error { + mu.Lock() + seen = append(seen, fn+"|"+string(body)+"|"+headerValue(h, "x-oci-ns-topicname")) + mu.Unlock() + + return tt.invoker(ctx, fn, h, body) + }) + } + + topic := e.mkTopic(t, "fn") + sub := e.subscribe(t, topic, "ORACLE_FUNCTIONS", "ocid1.fnfunc.oc1..abc") + require.Equal(t, ons.SubscriptionLifecycleStateActive, sub.LifecycleState) + + e.publish(t, topic, "payload") + + require.Eventually(t, func() bool { + ds := e.deliveries(t, *sub.Id) + + return len(ds) == 1 && ds[0].Status == tt.want + }, 5*time.Second, 5*time.Millisecond) + + mu.Lock() + defer mu.Unlock() + + if tt.invoker != nil { + assert.Equal(t, []string{"ocid1.fnfunc.oc1..abc|payload|fn"}, seen) + } + }) + } +} + +var errFunctionFailed = errors.New("function failed") + +func TestFunctionsRetryOnError(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + calls := make(chan struct{}, 8) + e.backend.SetFunctionInvoker(func(context.Context, string, http.Header, []byte) error { + calls <- struct{}{} + + return errFunctionFailed + }) + + topic := e.mkTopic(t, "fnretry") + sub := e.subscribe(t, topic, "ORACLE_FUNCTIONS", "ocid1.fnfunc.oc1..abc") + e.publish(t, topic, "payload") + + <-calls + <-calls + + require.Eventually(t, func() bool { + ds := e.deliveries(t, *sub.Id) + + return len(ds) == 1 && ds[0].Attempts == 2 && ds[0].LastError == "function failed" + }, 5*time.Second, 5*time.Millisecond) +} + +// headerValue reads a header whose key keeps Oracle's documented casing, so Header.Get cannot find it. +func headerValue(h http.Header, name string) string { + for k, v := range h { + if strings.EqualFold(k, name) && len(v) > 0 { + return v[0] + } + } + + return "" +} diff --git a/services/ocinotifications/topics.go b/services/ocinotifications/topics.go new file mode 100644 index 0000000000..e658f84792 --- /dev/null +++ b/services/ocinotifications/topics.go @@ -0,0 +1,316 @@ +package ocinotifications + +import ( + "net/http" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +// CreateTopicIn is the CreateTopic request body. +type CreateTopicIn struct { + Tags + Description *string `json:"description"` + Name string `json:"name"` + Compartment string `json:"compartmentId"` +} + +// UpdateTopicIn is the UpdateTopic request body. +type UpdateTopicIn struct { + Tags + Description *string `json:"description"` +} + +type TopicWire struct { + TimeCreated time.Time `json:"timeCreated"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + Name string `json:"name"` + TopicID string `json:"topicId"` + CompartmentID string `json:"compartmentId"` + LifecycleState string `json:"lifecycleState"` + APIEndpoint string `json:"apiEndpoint"` + ShortTopicID string `json:"shortTopicId"` + Description string `json:"description,omitempty"` + Etag string `json:"etag"` +} + +func topicView(t *Topic, endpoint string) TopicWire { + return TopicWire{ + Name: t.Name, + TopicID: t.ID, + CompartmentID: t.CompartmentID, + LifecycleState: t.State, + TimeCreated: t.TimeCreated, + APIEndpoint: endpoint, + ShortTopicID: t.ShortID, + Description: t.Description, + Etag: t.Etag, + FreeformTags: t.Tags.FreeformTags, + DefinedTags: t.Tags.DefinedTags, + } +} + +func errTopicState(t *Topic) *APIError { + return errState("Topic " + t.ID + " is in state " + t.State + "; the operation requires ACTIVE") +} + +// topic returns a topic that has not started deleting away; the caller holds the lock. +func (b *InMemoryBackend) topic(id string) (*Topic, *APIError) { + b.sweepLocked() + + t, ok := b.topics[id] + if !ok { + return nil, errNotFound(id) + } + + return t, nil +} + +func (b *InMemoryBackend) activeTopic(id string) (*Topic, *APIError) { + t, aerr := b.topic(id) + if aerr != nil { + return nil, aerr + } + + if t.State != stateActive { + return nil, errTopicState(t) + } + + return t, nil +} + +// CreateTopic creates a topic in CREATING state. +func (b *InMemoryBackend) CreateTopic(in CreateTopicIn, endpoint string) (TopicWire, error) { + if in.Compartment == "" { + return TopicWire{}, errMissing("compartmentId is required") + } + + if strings.TrimSpace(in.Name) == "" { + return TopicWire{}, errMissing("name is required") + } + + if len(in.Name) > maxName { + return TopicWire{}, errParam("name must be at most 256 characters") + } + + b.mu.Lock("CreateTopic") + defer b.mu.Unlock() + + b.sweepLocked() + + for _, t := range b.topics { + if t.Name == in.Name { + return TopicWire{}, errExists("A topic named " + in.Name + " already exists") + } + } + + t := &Topic{ + ID: newOCID("onstopic"), ShortID: strings.ToUpper(awsid.NewN("", shortIDLen)), CompartmentID: in.Compartment, + Name: in.Name, State: stateCreating, TimeCreated: b.nowUTC(), Tags: in.Tags, Etag: newEtag(), + Seq: b.nextSeq(), + } + if in.Description != nil { + t.Description = *in.Description + } + + at := b.nowUTC().Add(b.transition) + t.TransitionAt = &at + b.topics[t.ID] = t + + return topicView(t, endpoint), nil +} + +// GetTopic returns one topic. +func (b *InMemoryBackend) GetTopic(id, endpoint string) (TopicWire, error) { + b.mu.Lock("GetTopic") + defer b.mu.Unlock() + + t, aerr := b.topic(id) + if aerr != nil { + return TopicWire{}, aerr + } + + return topicView(t, endpoint), nil +} + +// ListTopics lists the topics of a compartment. +func (b *InMemoryBackend) ListTopics( + compartment, id, name, state, endpoint string, + o ListOpts, +) (page.Page[TopicWire], error) { + if compartment == "" { + return page.Page[TopicWire]{}, errMissing("compartmentId is required") + } + + b.mu.Lock("ListTopics") + defer b.mu.Unlock() + + b.sweepLocked() + + var all []*Topic + + for _, t := range b.topics { + if t.CompartmentID == compartment && (id == "" || t.ID == id) && (name == "" || t.Name == name) && + (state == "" || strings.EqualFold(t.State, state)) { + all = append(all, t) + } + } + + pg := paginate(all, o, func(t *Topic) string { return t.State }, + func(t *Topic) (time.Time, int64) { return t.TimeCreated, t.Seq }) + + out := make([]TopicWire, 0, len(pg.Data)) + for _, t := range pg.Data { + out = append(out, topicView(t, endpoint)) + } + + return page.Page[TopicWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateTopic changes a topic's description and tags. +func (b *InMemoryBackend) UpdateTopic(id, ifMatch, endpoint string, in UpdateTopicIn) (TopicWire, error) { + b.mu.Lock("UpdateTopic") + defer b.mu.Unlock() + + t, aerr := b.activeTopic(id) + if aerr != nil { + return TopicWire{}, aerr + } + + if aerr = checkEtag(t.Etag, ifMatch); aerr != nil { + return TopicWire{}, aerr + } + + if in.Description != nil { + t.Description = *in.Description + } + + t.Tags.merge(in.Tags) + t.Etag = newEtag() + + return topicView(t, endpoint), nil +} + +// DeleteTopic moves a topic to DELETING; its subscriptions go with it once the transition completes. +func (b *InMemoryBackend) DeleteTopic(id, ifMatch string) error { + b.mu.Lock("DeleteTopic") + defer b.mu.Unlock() + + t, aerr := b.activeTopic(id) + if aerr != nil { + return aerr + } + + if aerr = checkEtag(t.Etag, ifMatch); aerr != nil { + return aerr + } + + at := b.nowUTC().Add(b.transition) + t.State, t.TransitionAt, t.Etag = stateDeleting, &at, newEtag() + + return nil +} + +// ChangeTopicCompartment moves a topic to another compartment. +func (b *InMemoryBackend) ChangeTopicCompartment(id, ifMatch, compartment string) error { + if compartment == "" { + return errMissing("compartmentId is required") + } + + b.mu.Lock("ChangeTopicCompartment") + defer b.mu.Unlock() + + t, aerr := b.activeTopic(id) + if aerr != nil { + return aerr + } + + if aerr = checkEtag(t.Etag, ifMatch); aerr != nil { + return aerr + } + + t.CompartmentID, t.Etag = compartment, newEtag() + + return nil +} + +type changeCompartmentIn struct { + CompartmentID string `json:"compartmentId"` +} + +func (h *Handler) createTopic(q *req) error { + var in CreateTopicIn + if err := q.decode(&in); err != nil { + return err + } + + v, err := h.Backend.CreateTopic(in, q.endpoint()) + if err != nil { + return err + } + + return q.json(http.StatusOK, v, v.Etag) +} + +func (h *Handler) getTopic(q *req) error { + v, err := h.Backend.GetTopic(q.param("topicId"), q.endpoint()) + if err != nil { + return err + } + + return q.json(http.StatusOK, v, v.Etag) +} + +func (h *Handler) listTopics(q *req) error { + pg, err := h.Backend.ListTopics(q.query("compartmentId"), q.query("id"), q.query("name"), + q.query("lifecycleState"), q.endpoint(), q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, pg.Data, "") +} + +func (h *Handler) updateTopic(q *req) error { + var in UpdateTopicIn + if err := q.decode(&in); err != nil { + return err + } + + v, err := h.Backend.UpdateTopic(q.param("topicId"), q.ifMatch(), q.endpoint(), in) + if err != nil { + return err + } + + return q.json(http.StatusOK, v, v.Etag) +} + +func (h *Handler) deleteTopic(q *req) error { + if err := h.Backend.DeleteTopic(q.param("topicId"), q.ifMatch()); err != nil { + return err + } + + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (h *Handler) changeTopicCompartment(q *req) error { + var in changeCompartmentIn + if err := q.decode(&in); err != nil { + return err + } + + if err := h.Backend.ChangeTopicCompartment(q.param("topicId"), q.ifMatch(), in.CompartmentID); err != nil { + return err + } + + q.w.WriteHeader(http.StatusAccepted) + + return nil +} diff --git a/services/ocinotifications/topics_test.go b/services/ocinotifications/topics_test.go new file mode 100644 index 0000000000..fddd390a9a --- /dev/null +++ b/services/ocinotifications/topics_test.go @@ -0,0 +1,210 @@ +package ocinotifications_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/ons" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTopicLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + ctx := t.Context() + + created, err := e.ctl.CreateTopic(ctx, ons.CreateTopicRequest{ + Name: new("orders"), CompartmentId: new(testCompartment), Description: new("order events"), + FreeformTags: map[string]string{"k": "v"}}) + require.NoError(t, err) + require.NotNil(t, created.Etag) + assert.Equal(t, ons.NotificationTopicLifecycleStateCreating, created.LifecycleState) + assert.Equal(t, e.base, *created.ApiEndpoint) + assert.Contains(t, *created.TopicId, "ocid1.onstopic.") + assert.NotEmpty(t, *created.ShortTopicId) + + _, err = e.ctl.CreateTopic(ctx, ons.CreateTopicRequest{ + Name: new("orders"), CompartmentId: new(testCompartment)}) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.dp.PublishMessage(ctx, ons.PublishMessageRequest{ + TopicId: created.TopicId, Body: new("x"), + }) + assert.Equal(t, "IncorrectState", svcErr(t, err).GetCode()) + + e.settle() + + got, err := e.ctl.GetTopic(ctx, ons.GetTopicRequest{TopicId: created.TopicId}) + require.NoError(t, err) + assert.Equal(t, ons.NotificationTopicLifecycleStateActive, got.LifecycleState) + assert.Equal(t, "order events", *got.Description) + assert.Equal(t, "v", got.FreeformTags["k"]) + + _, err = e.ctl.UpdateTopic(ctx, ons.UpdateTopicRequest{ + TopicId: created.TopicId, IfMatch: new("stale"), + Description: new("x"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + upd, err := e.ctl.UpdateTopic(ctx, ons.UpdateTopicRequest{ + TopicId: created.TopicId, IfMatch: got.Etag, + Description: new("updated"), + }) + require.NoError(t, err) + assert.Equal(t, "updated", *upd.Description) + assert.NotEqual(t, *got.Etag, *upd.Etag) + + list, err := e.ctl.ListTopics(ctx, ons.ListTopicsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, "orders", *list.Items[0].Name) + + sub := e.subscribe(t, got.NotificationTopic, "PAGERDUTY", "integration-key") + + _, err = e.ctl.ChangeTopicCompartment(ctx, ons.ChangeTopicCompartmentRequest{ + TopicId: created.TopicId, + ChangeTopicCompartmentDetails: ons.ChangeCompartmentDetails{CompartmentId: new("ocid1.compartment.oc1..other")}, + }) + require.NoError(t, err) + + moved, err := e.ctl.ListTopics(ctx, ons.ListTopicsRequest{CompartmentId: new("ocid1.compartment.oc1..other")}) + require.NoError(t, err) + assert.Len(t, moved.Items, 1) + + _, err = e.ctl.DeleteTopic(ctx, ons.DeleteTopicRequest{TopicId: created.TopicId}) + require.NoError(t, err) + + del, err := e.ctl.GetTopic(ctx, ons.GetTopicRequest{TopicId: created.TopicId}) + require.NoError(t, err) + assert.Equal(t, ons.NotificationTopicLifecycleStateDeleting, del.LifecycleState) + + e.settle() + + _, err = e.ctl.GetTopic(ctx, ons.GetTopicRequest{TopicId: created.TopicId}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.dp.GetSubscription(ctx, ons.GetSubscriptionRequest{SubscriptionId: sub.Id}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode(), "subscriptions go with the topic") +} + +func TestCreateTopicValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + det ons.CreateTopicDetails + code string + }{ + {"blank name", ons.CreateTopicDetails{Name: new(" "), CompartmentId: new(testCompartment)}, "MissingParameter"}, + { + "long name", + ons.CreateTopicDetails{Name: new(strings.Repeat("n", 257)), CompartmentId: new(testCompartment)}, + "InvalidParameter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + _, err := e.ctl.CreateTopic(t.Context(), ons.CreateTopicRequest{CreateTopicDetails: tt.det}) + se := svcErr(t, err) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestListTopicsFilters(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + for _, n := range []string{"t1", "t2", "t3"} { + e.mkTopic(t, n) + } + + tests := []struct { + req ons.ListTopicsRequest + name string + want int + }{ + {ons.ListTopicsRequest{}, "all", 3}, + {ons.ListTopicsRequest{Name: new("t2")}, "by name", 1}, + {ons.ListTopicsRequest{LifecycleState: ons.NotificationTopicSummaryLifecycleStateCreating}, "by state", 0}, + {ons.ListTopicsRequest{Limit: new(2)}, "limit", 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := tt.req + r.CompartmentId = new(testCompartment) + + resp, err := e.ctl.ListTopics(t.Context(), r) + require.NoError(t, err) + assert.Len(t, resp.Items, tt.want) + + if tt.name == "limit" { + require.NotNil(t, resp.OpcNextPage) + } + }) + } + + _, err := e.ctl.ListTopics(t.Context(), ons.ListTopicsRequest{CompartmentId: new("")}) + require.Error(t, err) +} + +func TestAuthEnforced(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + + status, body := rawDo(t, http.MethodGet, e.base+"/20181201/topics?compartmentId="+testCompartment, "") + assert.Equal(t, http.StatusUnauthorized, status) + assert.Contains(t, body, "NotAuthenticated") + + _, err := e.ctl.ListTopics(t.Context(), ons.ListTopicsRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) +} + +func TestErrorEnvelope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + method string + path string + body string + code string + status int + }{ + { + "missing topic", + http.MethodGet, + "/20181201/topics/ocid1.onstopic.oc1..nope", + "", + "NotAuthorizedOrNotFound", + 404, + }, + {"bad json", http.MethodPost, "/20181201/topics", "{", "CannotParseRequest", 400}, + {"unknown route", http.MethodGet, "/20181201/nothing", "", "NotFound", 404}, + {"missing subscription", http.MethodGet, "/20181201/subscriptions/nope", "", "NotAuthorizedOrNotFound", 404}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.status, status) + assert.Contains(t, body, `"code":"`+tt.code+`"`) + }) + } +} diff --git a/services/ociobjectstorage/PARITY.md b/services/ociobjectstorage/PARITY.md index 1113c84fa8..b88b5b2b05 100644 --- a/services/ociobjectstorage/PARITY.md +++ b/services/ociobjectstorage/PARITY.md @@ -53,6 +53,7 @@ ops: ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Always empty."} ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "One completion entry."} families: + object_events: {status: partial, note: "Buckets with objectEventsEnabled (CreateBucket, UpdateBucket) report createobject, updateobject and deleteobject to the sink set with SetObjectEventSink (PutObject, CommitUploadedParts, CopyObject destination, DeleteObject, BatchDeleteObjects); internal/app wires it to OCI Events (docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm). RenameObject and bucket events are not emitted."} auth: {status: ok, note: "pkgs/ociauth parses the draft-cavage Authorization header (keyId tenancy/user/fingerprint). Accept-any by default. A request signed with a key registered through POST /_gopherstack/oci/keys {tenancy,user,publicKey} is verified for real: (request-target), host and date must be signed, date within 5 minutes, a signed x-content-sha256 must match the body, RSA PKCS1v15 or PSS SHA-256. --oci-objectstorage-enforce-auth additionally rejects unsigned requests and unknown keys, except anonymous reads of ObjectRead / ObjectReadWithoutList buckets. Registered keys persist."} error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id. Documented codes (InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NamespaceNotFound, NoEtagMatch) follow the OCI error reference; BucketNotFound, ObjectNotFound, BucketAlreadyExists, BucketNotEmpty, NoSuchUpload, InvalidUploadPart, InvalidDigest, InvalidRange, IfNoneMatchFailed, NotRestored, ObjectUnderRetention and LifecyclePolicyNotFound are best-effort and not verified against the live service."} versioning: {status: ok, note: "Enabled, Suspended (version id null) and Disabled; delete markers; listing with ListObjectVersions."} diff --git a/services/ociobjectstorage/README.md b/services/ociobjectstorage/README.md index 0c53318541..5f09da9790 100644 --- a/services/ociobjectstorage/README.md +++ b/services/ociobjectstorage/README.md @@ -8,7 +8,7 @@ | Metric | Value | | --- | --- | | PARITY entries audited | 45 (25 ok, 20 partial) | -| Feature families | 7 (6 ok, 1 partial) | +| Feature families | 8 (6 ok, 2 partial) | | Known gaps | 11 | | Deferred items | 0 | | Resource leaks | unknown | diff --git a/services/ociobjectstorage/auth_test.go b/services/ociobjectstorage/auth_test.go index cf8f6a7658..94450b9ab6 100644 --- a/services/ociobjectstorage/auth_test.go +++ b/services/ociobjectstorage/auth_test.go @@ -10,6 +10,8 @@ import ( "github.com/oracle/oci-go-sdk/v65/objectstorage" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestSignatureVerification(t *testing.T) { @@ -96,7 +98,7 @@ func TestBodyHashIsVerified(t *testing.T) { testTenancy+"/"+testUser+"/"+e.keys.fprint+`",algorithm="rsa-sha256",signature="AAAA"`, ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ociobjectstorage/events.go b/services/ociobjectstorage/events.go new file mode 100644 index 0000000000..21b5ecd568 --- /dev/null +++ b/services/ociobjectstorage/events.go @@ -0,0 +1,64 @@ +package ociobjectstorage + +import "time" + +// Object event types (https://docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm). +const ( + EventCreateObject = "com.oraclecloud.objectstorage.createobject" + EventUpdateObject = "com.oraclecloud.objectstorage.updateobject" + EventDeleteObject = "com.oraclecloud.objectstorage.deleteobject" +) + +// ObjectEvent describes an object state change in a bucket with objectEventsEnabled. +type ObjectEvent struct { + Time time.Time + EventType string + CompartmentID string + Namespace string + BucketName string + BucketID string + ObjectName string + ETag string +} + +// SetObjectEventSink installs the receiver of object events; it is called outside the backend lock. +func (b *InMemoryBackend) SetObjectEventSink(fn func(ObjectEvent)) { + b.mu.Lock("SetObjectEventSink") + b.sink = fn + b.mu.Unlock() +} + +// recordEvent queues an event while the lock is held; flushEvents delivers it after unlock. +func (b *InMemoryBackend) recordEvent(bk *Bucket, eventType, object, etag string) { + if b.sink == nil || !bk.ObjectEventsEnabled { + return + } + + b.pending = append(b.pending, ObjectEvent{ + Time: b.nowUTC(), EventType: eventType, CompartmentID: bk.CompartmentID, Namespace: b.namespace, + BucketName: bk.Name, BucketID: bk.ID, ObjectName: object, ETag: etag, + }) +} + +func (b *InMemoryBackend) flushEvents() { + b.mu.Lock("flushEvents") + evs, sink := b.pending, b.sink + b.pending = nil + b.mu.Unlock() + + if sink == nil { + return + } + + for _, e := range evs { + sink(e) + } +} + +func writeEventType(replaced bool) string { + if replaced { + return EventUpdateObject + } + + return EventCreateObject +} diff --git a/services/ociobjectstorage/events_test.go b/services/ociobjectstorage/events_test.go new file mode 100644 index 0000000000..96e6915bc8 --- /dev/null +++ b/services/ociobjectstorage/events_test.go @@ -0,0 +1,134 @@ +package ociobjectstorage_test + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" +) + +func TestObjectEvents(t *testing.T) { + t.Parallel() + + ns := oci.DefaultNamespace + meta, pre := oci.ObjectMeta{}, oci.Preconds{} + + tests := []struct { + name string + enabled *bool + run func(t *testing.T, b *oci.InMemoryBackend) + want []string + }{ + {"put create then update", new(true), func(t *testing.T, b *oci.InMemoryBackend) { + t.Helper() + + _, err := b.PutObject(ns, "bk", "o", []byte("1"), meta, pre, "") + require.NoError(t, err) + _, err = b.PutObject(ns, "bk", "o", []byte("2"), meta, pre, "") + require.NoError(t, err) + }, []string{oci.EventCreateObject, oci.EventUpdateObject}}, + {"delete", new(true), func(t *testing.T, b *oci.InMemoryBackend) { + t.Helper() + + _, err := b.PutObject(ns, "bk", "o", []byte("1"), meta, pre, "") + require.NoError(t, err) + _, err = b.DeleteObject(ns, "bk", "o", "", "") + require.NoError(t, err) + }, []string{oci.EventCreateObject, oci.EventDeleteObject}}, + {"failed delete emits nothing", new(true), func(t *testing.T, b *oci.InMemoryBackend) { + t.Helper() + + _, err := b.DeleteObject(ns, "bk", "missing", "", "") + require.Error(t, err) + }, nil}, + {"batch delete", new(true), func(t *testing.T, b *oci.InMemoryBackend) { + t.Helper() + + _, err := b.PutObject(ns, "bk", "o", []byte("1"), meta, pre, "") + require.NoError(t, err) + _, _, err = b.BatchDelete(ns, "bk", []oci.BatchItem{{ObjectName: "o"}, {ObjectName: "nope"}}) + require.NoError(t, err) + }, []string{oci.EventCreateObject, oci.EventDeleteObject}}, + {"copy", new(true), func(t *testing.T, b *oci.InMemoryBackend) { + t.Helper() + + _, err := b.PutObject(ns, "bk", "o", []byte("1"), meta, pre, "") + require.NoError(t, err) + _, err = b.CopyObject(ns, "bk", oci.CopyInput{ + SourceObjectName: "o", + DestinationRegion: oci.Region, + DestinationNS: ns, + DestinationBucket: "bk", + DestinationObject: "c", + }) + require.NoError(t, err) + }, []string{oci.EventCreateObject, oci.EventCreateObject}}, + {"multipart commit", new(true), func(t *testing.T, b *oci.InMemoryBackend) { + t.Helper() + + u, err := b.CreateUpload(ns, "bk", oci.CreateUploadInput{Object: "big"}, pre) + require.NoError(t, err) + etag, _, err := b.UploadPart(ns, "bk", "big", u.ID, 1, []byte("x"), "") + require.NoError(t, err) + _, err = b.CommitUpload(ns, "bk", "big", u.ID, oci.CommitInput{ + PartsToCommit: []oci.CommitPart{{Etag: etag, PartNum: 1}}, + }, pre) + require.NoError(t, err) + }, []string{oci.EventCreateObject}}, + {"flag off", nil, func(t *testing.T, b *oci.InMemoryBackend) { + t.Helper() + + _, err := b.PutObject(ns, "bk", "o", []byte("1"), meta, pre, "") + require.NoError(t, err) + }, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := oci.NewInMemoryBackend() + + var ( + mu sync.Mutex + got []oci.ObjectEvent + ) + + b.SetObjectEventSink(func(e oci.ObjectEvent) { + mu.Lock() + got = append(got, e) + mu.Unlock() + }) + + _, err := b.CreateBucket(ns, oci.CreateBucketInput{ + Name: "bk", CompartmentID: "ocid1.compartment.oc1..c", ObjectEventsEnabled: tt.enabled, + }, "") + require.NoError(t, err) + + tt.run(t, b) + + mu.Lock() + defer mu.Unlock() + + types := make([]string, 0, len(got)) + for _, e := range got { + types = append(types, e.EventType) + assert.Equal(t, "bk", e.BucketName) + assert.Equal(t, "ocid1.compartment.oc1..c", e.CompartmentID) + assert.NotEmpty(t, e.BucketID) + assert.Equal(t, ns, e.Namespace) + } + + if tt.want == nil { + assert.Empty(t, types) + + return + } + + assert.Equal(t, tt.want, types) + }) + } +} diff --git a/services/ociobjectstorage/features_test.go b/services/ociobjectstorage/features_test.go index ed72bb467a..056c53f3ad 100644 --- a/services/ociobjectstorage/features_test.go +++ b/services/ociobjectstorage/features_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oci "github.com/blackbirdworks/gopherstack/services/ociobjectstorage" ) @@ -21,7 +22,7 @@ func rawDo(t *testing.T, method, url, body string) (int, string) { req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ociobjectstorage/handler.go b/services/ociobjectstorage/handler.go index 10366f6a80..a7a60b6e8f 100644 --- a/services/ociobjectstorage/handler.go +++ b/services/ociobjectstorage/handler.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "sync" "time" @@ -17,6 +16,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -302,13 +302,13 @@ func (h *Handler) adminKeys(q *req) { // StartWorker binds the dedicated listener synchronously so a port conflict is returned to the // caller, then starts the lifecycle sweeper. func (h *Handler) StartWorker(ctx context.Context) error { - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { return fmt.Errorf("ociobjectstorage: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) e.Any("/*", telemetry.WrapEchoHandler("OCIObjectStorage", h.Handler(), h)) diff --git a/services/ociobjectstorage/helpers_test.go b/services/ociobjectstorage/helpers_test.go index 8e8810741e..01601bd8bb 100644 --- a/services/ociobjectstorage/helpers_test.go +++ b/services/ociobjectstorage/helpers_test.go @@ -9,7 +9,6 @@ import ( "encoding/pem" "fmt" "io" - "net" "strings" "sync/atomic" "testing" @@ -83,20 +82,6 @@ type envOpts struct { register bool } -func freePort(t *testing.T) int { - t.Helper() - - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - defer l.Close() - - tcp, ok := l.Addr().(*net.TCPAddr) - require.True(t, ok) - - return tcp.Port -} - func newEnv(t *testing.T, opts envOpts) *env { t.Helper() @@ -105,7 +90,7 @@ func newEnv(t *testing.T, opts envOpts) *env { be.SetClock(clock.Now) h := oci.NewHandler(be) - h.Port = freePort(t) + h.Port = 0 h.EnforceAuth = opts.enforce require.NoError(t, h.StartWorker(t.Context())) diff --git a/services/ociobjectstorage/multipart.go b/services/ociobjectstorage/multipart.go index d68ef15795..78a8b96780 100644 --- a/services/ociobjectstorage/multipart.go +++ b/services/ociobjectstorage/multipart.go @@ -130,6 +130,8 @@ func (b *InMemoryBackend) CommitUpload( return ObjectInfo{}, errMissing("partsToCommit must list at least one part") } + defer b.flushEvents() + b.mu.Lock("CommitUpload") defer b.mu.Unlock() @@ -193,6 +195,7 @@ func (b *InMemoryBackend) CommitUpload( } delete(bk.Uploads, id) + b.recordEvent(bk, writeEventType(cur != nil), object, o.Etag) return b.info(o), nil } diff --git a/services/ociobjectstorage/objects.go b/services/ociobjectstorage/objects.go index a34813e2ac..917452a2b4 100644 --- a/services/ociobjectstorage/objects.go +++ b/services/ociobjectstorage/objects.go @@ -111,6 +111,8 @@ func (b *InMemoryBackend) PutObject( "The Content-MD5 you specified did not match what we received") } + defer b.flushEvents() + b.mu.Lock("PutObject") defer b.mu.Unlock() @@ -135,6 +137,8 @@ func (b *InMemoryBackend) PutObject( return ObjectInfo{}, err } + b.recordEvent(bk, writeEventType(cur != nil), name, o.Etag) + return b.info(o), nil } @@ -246,6 +250,8 @@ type DeleteResult struct { // DeleteObject deletes an object or one version of it. func (b *InMemoryBackend) DeleteObject(ns, bucket, name, versionID, ifMatch string) (DeleteResult, error) { + defer b.flushEvents() + b.mu.Lock("DeleteObject") defer b.mu.Unlock() @@ -254,7 +260,12 @@ func (b *InMemoryBackend) DeleteObject(ns, bucket, name, versionID, ifMatch stri return DeleteResult{}, err } - return b.deleteLocked(bk, name, versionID, ifMatch) + res, err := b.deleteLocked(bk, name, versionID, ifMatch) + if err == nil { + b.recordEvent(bk, EventDeleteObject, name, "") + } + + return res, err } func (b *InMemoryBackend) deleteLocked(bk *Bucket, name, versionID, ifMatch string) (DeleteResult, error) { @@ -596,6 +607,8 @@ func (b *InMemoryBackend) CopyObject(ns, bucket string, in CopyInput) (string, e return "", err } + defer b.flushEvents() + b.mu.Lock("CopyObject") defer b.mu.Unlock() @@ -640,17 +653,20 @@ func (b *InMemoryBackend) CopyObject(ns, bucket string, in CopyInput) (string, e return "", err } - if _, err = b.storeLocked( + stored, err := b.storeLocked( dst, in.DestinationObject, append([]byte(nil), o.Data...), copyMeta(o, dst, in), o.MD5, o.MultipartMD5, - ); err != nil { + ) + if err != nil { return "", err } + b.recordEvent(dst, writeEventType(cur != nil), in.DestinationObject, stored.Etag) + now := b.nowUTC() wr := &WorkRequest{ ID: "ocid1.coreservicesworkrequest.oc1." + Region + "." + newVersionID(), OperationType: "COPY_OBJECT", @@ -764,6 +780,8 @@ type BatchDeleted struct { // BatchDelete deletes several objects, reporting per-object outcomes. func (b *InMemoryBackend) BatchDelete(ns, bucket string, items []BatchItem) ([]BatchDeleted, []BatchFailure, error) { + defer b.flushEvents() + b.mu.Lock("BatchDelete") defer b.mu.Unlock() @@ -787,6 +805,8 @@ func (b *InMemoryBackend) BatchDelete(ns, bucket string, items []BatchItem) ([]B continue } + b.recordEvent(bk, EventDeleteObject, it.ObjectName, "") + deleted = append(deleted, BatchDeleted{ObjectName: it.ObjectName, TimeLastModified: res.LastModified}) } diff --git a/services/ociobjectstorage/store.go b/services/ociobjectstorage/store.go index 5763c2f05d..ce12bf53f9 100644 --- a/services/ociobjectstorage/store.go +++ b/services/ociobjectstorage/store.go @@ -38,6 +38,8 @@ type InMemoryBackend struct { Auth *ociauth.Registry namespace string nsMeta nsMetaWire + sink func(ObjectEvent) + pending []ObjectEvent } // NewInMemoryBackend creates an empty backend serving the default namespace. diff --git a/services/ociqueue/PARITY.md b/services/ociqueue/PARITY.md new file mode 100644 index 0000000000..d10be5650b --- /dev/null +++ b/services/ociqueue/PARITY.md @@ -0,0 +1,49 @@ +--- +service: ociqueue +sdk_module: github.com/oracle/oci-go-sdk/v65@v65.126.2 # go.mod dependency (test-only): drives every operation in the service tests +last_audit_commit: 3a4fe760a +last_audit_date: 2026-10-27 +overall: B +ops: + CreateQueue: {wire: ok, errors: partial, state: ok, persist: ok, note: "202 with opc-work-request-id and no body, as the SDK response declares. CREATING then ACTIVE after the transition window on the injectable clock. retentionInSeconds 10 to 604800 (default 86400), visibilityInSeconds 0 to 43200 (default 30), timeoutInSeconds 0 to 30 (default 30), deadLetterQueueDeliveryCount 0 to 20 (default 5, 0 disables the DLQ), channelConsumptionLimit 1 to 100 (default 100). customEncryptionKeyId must be an OCID of an ENABLED OCI Vault key when the Vault backend is wired. A non-empty capabilities list is rejected 400 (see items_still_open)."} + GetQueue: {wire: ok, errors: ok, state: ok, persist: n/a, note: "etag header; messagesEndpoint is the base URL the client addressed, so it works as the QueueClient host; DELETED queues stay readable."} + ListQueues: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId or id required; displayName, lifecycleState filters; sortBy timeCreated or displayName; items envelope, paged with opc-next-page."} + UpdateQueue: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request; displayName, visibility, timeout, DLQ count, channel limit, key and tags apply immediately and the queue reports UPDATING for the transition window while data plane calls keep working. If-Match gives 412 NoEtagMatch."} + DeleteQueue: {wire: ok, errors: ok, state: ok, persist: ok, note: "202 with work request; DELETING then DELETED, at which point all messages are dropped. Parked long polls are released and answer 409 IncorrectState."} + ChangeQueueCompartment: {wire: ok, errors: ok, state: ok, persist: ok, note: "Work request MOVE_QUEUE; sets the compartment immediately."} + PurgeQueue: {wire: ok, errors: ok, state: ok, persist: ok, note: "purgeType NORMAL, DLQ or BOTH, optionally limited to channelIds; applied immediately, the PURGE_QUEUE work request completes after the transition window. consumerGroupId is rejected 400."} + GetWorkRequest: {wire: ok, errors: ok, state: ok, persist: ok, note: "IN_PROGRESS until the resource transition time, then SUCCEEDED; resources carry the entity and action."} + ListWorkRequests: {wire: ok, errors: ok, state: ok, persist: n/a, note: "compartmentId or workRequestId, resourceId filter; items envelope."} + ListWorkRequestErrors: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Always empty: no emulated operation fails asynchronously."} + ListWorkRequestLogs: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Accepted and succeeded entries."} + PutMessages: {wire: ok, errors: partial, state: ok, persist: ok, note: "Up to 20 messages and 512 KiB per request, 128 KiB per message, channelId up to 64 characters (metadata requires channelId). Returns per message id and expireAfter (now + retention). Wakes parked long polls."} + GetMessages: {wire: ok, errors: partial, state: ok, persist: ok, note: "limit 1 to 20 (default 1), visibilityInSeconds 0 to 43200 (default queue value; 0 peeks but still counts a delivery), timeoutInSeconds 0 to 30 (default queue value; 0 is a short poll). Long polling parks on a per-queue wake channel plus timers for the poll deadline and the next message becoming visible, so it never spins; it ends on a put, a visibility change, the deadline, client disconnect, queue deletion or listener shutdown. channelFilter matches a channel id exactly."} + UpdateMessage: {wire: ok, errors: partial, state: ok, persist: ok, note: "Sets the visibility of the message that holds the receipt (0 to 43200 seconds from now); returns id and visibleAfter. Unknown or superseded receipt is 404."} + UpdateMessages: {wire: ok, errors: partial, state: ok, persist: ok, note: "Up to 20 entries; each entry reports id and visibleAfter or errorCode 404 with errorMessage, with clientFailures counted."} + DeleteMessage: {wire: ok, errors: partial, state: ok, persist: ok, note: "204; unknown receipt is 404."} + DeleteMessages: {wire: ok, errors: partial, state: ok, persist: ok, note: "Up to 20 entries; failed entries carry errorCode 404 and errorMessage, successful ones are empty objects."} + GetStats: {wire: ok, errors: ok, state: ok, persist: n/a, note: "visibleMessages, inFlightMessages and sizeInBytes (content bytes) for the queue and for the DLQ, optionally for one channelId."} + ListChannels: {wire: ok, errors: ok, state: ok, persist: n/a, note: "Channels that currently hold messages; channelFilter is a substring match; sorted and paged with opc-next-page."} +families: + auth: {status: ok, note: "pkgs/ociauth verifies draft-cavage signatures for API keys registered through POST /_gopherstack/oci/keys or OCI Identity; accept-any otherwise, --oci-queue-enforce-auth rejects unsigned and unknown keys."} + error_envelope: {status: partial, note: "Errors are {code,message} with opc-request-id. InvalidParameter, MissingParameter, NotAuthenticated, NotAuthorizedOrNotFound, NoEtagMatch and IncorrectState follow the OCI reference; the status used for oversize messages (400 InvalidParameter) and for a stale receipt (404) is unverified."} + messages_endpoint: {status: ok, note: "Admin and messages APIs share one listener and the /20210201 base path, so a queue's messagesEndpoint is the URL the client addressed."} + lifecycle: {status: ok, note: "CREATING, UPDATING and DELETING last 2 seconds on the injectable backend clock; transitions, retention and dead lettering are applied on every request and by a one-minute sweeper."} + visibility_and_receipts: {status: ok, note: "A delivery hides the message until now + visibility and issues a fresh receipt; the previous receipt stops working. Receipts stay valid after the visibility window lapses until the message is delivered again."} + dead_letter: {status: ok, note: "A message that has been delivered deadLetterQueueDeliveryCount times and becomes visible again moves to the DLQ instead of being delivered once more; DLQ messages follow retention, show in GetStats and are cleared by PurgeQueue DLQ or BOTH."} + long_polling: {status: ok, note: "Event driven; the poll timer is injectable (SetTimer) so tests fire it without sleeping."} + retention: {status: ok, note: "Messages and DLQ entries past retentionInSeconds are dropped on the next request or sweep."} + routing_isolation: {status: ok, note: "Own http.Server bound in StartWorker to --oci-queue-port / OCI_QUEUE_PORT (default 8156); never on the shared AWS router; cli.go reserves the port in the shared pool."} + vault_integration: {status: ok, note: "When OCI Vault runs in the same process, customEncryptionKeyId is checked against its keys (must exist and be ENABLED); standalone any ocid1.key. OCID is accepted."} + observability: {status: ok, note: "telemetry.WrapEchoHandler feeds ExtractOperation/ExtractResource; one lockmetrics.RWMutex guards all state."} +items_still_open: + - "Consumer groups (CreateConsumerGroup, GetConsumerGroup, ListConsumerGroups, UpdateConsumerGroup, DeleteConsumerGroup) are not routed, the consumerGroupId query parameter is ignored by the data plane and the CONSUMER_GROUPS capability is rejected." + - "The LARGE_MESSAGES capability is rejected; only the standard 128 KiB message limit exists." + - "The DLQ cannot be consumed: the SDK has no API to read dead lettered messages, so they are only counted by GetStats and cleared by PurgeQueue." + - "Limits (retention 10 to 604800 s, visibility 0 to 43200 s, poll timeout 0 to 30 s, DLQ count 0 to 20, batch of 20 messages, 128 KiB per message, 512 KiB per request, 64 character channel ids) and the channelConsumptionLimit semantics are recalled from the Oracle documentation and SDK comments, not verified against the live service." + - "channelFilter is an exact match on GetMessages and a substring match on ListChannels; the live matching rules are unverified." + - "Throughput limits and per-queue storage quotas (and therefore channelConsumptionLimit enforcement) are not applied." + - "Messages are held in memory and in snapshots, so large backlogs grow the snapshot." + - "opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified." + - "Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set." +--- diff --git a/services/ociqueue/README.md b/services/ociqueue/README.md new file mode 100644 index 0000000000..5502836d0f --- /dev/null +++ b/services/ociqueue/README.md @@ -0,0 +1,31 @@ + +# OCI Queue + +**Parity grade: B** · SDK `github.com/oracle/oci-go-sdk/v65@v65.126.2` · last audited 2026-10-27 (`3a4fe760a`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 19 (12 ok, 7 partial) | +| Feature families | 11 (10 ok, 1 partial) | +| Known gaps | 9 | +| Deferred items | 0 | +| Resource leaks | unknown | + +### Known gaps + +- Consumer groups (CreateConsumerGroup, GetConsumerGroup, ListConsumerGroups, UpdateConsumerGroup, DeleteConsumerGroup) are not routed, the consumerGroupId query parameter is ignored by the data plane and the CONSUMER_GROUPS capability is rejected. +- The LARGE_MESSAGES capability is rejected; only the standard 128 KiB message limit exists. +- The DLQ cannot be consumed: the SDK has no API to read dead lettered messages, so they are only counted by GetStats and cleared by PurgeQueue. +- Limits (retention 10 to 604800 s, visibility 0 to 43200 s, poll timeout 0 to 30 s, DLQ count 0 to 20, batch of 20 messages, 128 KiB per message, 512 KiB per request, 64 character channel ids) and the channelConsumptionLimit semantics are recalled from the Oracle documentation and SDK comments, not verified against the live service. +- channelFilter is an exact match on GetMessages and a substring match on ListChannels; the live matching rules are unverified. +- Throughput limits and per-queue storage quotas (and therefore channelConsumptionLimit enforcement) are not applied. +- Messages are held in memory and in snapshots, so large backlogs grow the snapshot. +- opc-retry-token idempotency, IAM policy checks, resource principals and instance principals are not applied; only API-key signatures are verified. +- Tag values and defined-tag namespaces are stored verbatim, not validated against a tag namespace; systemTags are never set. + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/ociqueue/errors.go b/services/ociqueue/errors.go new file mode 100644 index 0000000000..aa4f028cbd --- /dev/null +++ b/services/ociqueue/errors.go @@ -0,0 +1,43 @@ +package ociqueue + +import ( + "errors" + "net/http" +) + +// ErrSnapshotNull is returned when a snapshot contains a null entry. +var ErrSnapshotNull = errors.New("ociqueue: null entry in snapshot") + +// APIError is an OCI error response: HTTP status plus the `code` and `message` body fields. +type APIError struct { + Code string + Message string + Status int +} + +func (e *APIError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, msg string) *APIError { + return &APIError{Status: status, Code: code, Message: msg} +} + +func errNotFound(what string) *APIError { + return newErr(http.StatusNotFound, "NotAuthorizedOrNotFound", + "Authorization failed or requested resource ("+what+") not found.") +} + +func errNoRoute() *APIError { + return newErr(http.StatusNotFound, "NotFound", "No operation exists at the specified path and method") +} + +func errParam(msg string) *APIError { return newErr(http.StatusBadRequest, "InvalidParameter", msg) } + +func errMissing(msg string) *APIError { return newErr(http.StatusBadRequest, "MissingParameter", msg) } + +func errState(msg string) *APIError { return newErr(http.StatusConflict, "IncorrectState", msg) } + +func errEtag() *APIError { + return newErr(http.StatusPreconditionFailed, "NoEtagMatch", "The If-Match header does not match the current ETag") +} + +func errAuth(msg string) *APIError { return newErr(http.StatusUnauthorized, "NotAuthenticated", msg) } diff --git a/services/ociqueue/handler.go b/services/ociqueue/handler.go new file mode 100644 index 0000000000..62a3e9dcfc --- /dev/null +++ b/services/ociqueue/handler.go @@ -0,0 +1,383 @@ +package ociqueue + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/telemetry" +) + +const ( + maxBodyBytes = 1 << 20 + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + idleTimeout = 120 * time.Second + sweepInterval = time.Minute + adminBodyLimit = 1 << 20 +) + +// req is the per-request state handed to every operation. +type req struct { + w http.ResponseWriter + r *http.Request + rt route + body []byte +} + +type opFunc func(h *Handler, q *req) error + +// Handler serves OCI Queue: queue administration and the messages API. +type Handler struct { + Backend *InMemoryBackend + srvMu *lockmetrics.RWMutex + srv *http.Server + stop chan struct{} + ops map[string]opFunc + wg sync.WaitGroup + Port int + EnforceAuth bool +} + +var ( + _ service.BackgroundWorker = (*Handler)(nil) + _ service.Shutdowner = (*Handler)(nil) + _ service.Resettable = (*Handler)(nil) +) + +// NewHandler creates a Handler; callers override Port and EnforceAuth from Settings. +func NewHandler(backend *InMemoryBackend) *Handler { + return &Handler{ + Backend: backend, Port: DefaultPort, ops: buildOps(), + srvMu: lockmetrics.New("ociqueue.server"), + } +} + +// Name returns the service name. +func (h *Handler) Name() string { return "OCIQueue" } + +// GetSupportedOperations lists the supported operations. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.ops)) + for k := range h.ops { + ops = append(ops, k) + } + + return ops +} + +// RouteMatcher never matches: OCIQueue runs on its own listener. +func (h *Handler) RouteMatcher() service.Matcher { + return func(*echo.Context) bool { return false } +} + +// MatchPriority is irrelevant because RouteMatcher never matches. +func (h *Handler) MatchPriority() int { return 0 } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { return parseRoute(c.Request()).op } + +// ExtractResource returns the addressed resource id for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + p := parseRoute(c.Request()).params + for _, k := range []string{"queueId", "workRequestId"} { + if v := p[k]; v != "" { + return v + } + } + + return "" +} + +// Reset clears all in-memory state. +func (h *Handler) Reset() { h.Backend.Reset() } + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (q *req) header(k, v string) { q.w.Header().Set(k, v) } + +func (q *req) json(status int, v any, etag string) error { + out, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ociqueue: encode response: %w", err) + } + + if etag != "" { + q.header("etag", etag) + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(status) + _, _ = q.w.Write(out) + + return nil +} + +func (q *req) query(k string) string { return q.r.URL.Query().Get(k) } + +func (q *req) param(k string) string { return q.rt.params[k] } + +func (q *req) ifMatch() string { return q.r.Header.Get("If-Match") } + +func (q *req) listOpts() ListOpts { + n, _ := strconv.Atoi(q.query("limit")) + + return ListOpts{SortBy: q.query("sortBy"), Order: q.query("sortOrder"), Page: q.query("page"), Limit: n} +} + +func (q *req) accepted(workRequestID string) error { + q.header("opc-work-request-id", workRequestID) + q.w.WriteHeader(http.StatusAccepted) + + return nil +} + +func (q *req) nextPage(next string) { + if next != "" { + q.header("opc-next-page", next) + } +} + +// endpoint returns the base URL the client addressed, so returned messages endpoints stay reachable. +func (q *req) endpoint() string { + host := q.r.Host + if h, port, err := net.SplitHostPort(host); err == nil { + if ip := net.ParseIP(h); ip == nil && h != "" { + host = h + ":" + port + } + } + + return "http://" + host +} + +func (q *req) decode(v any) error { + if len(q.body) == 0 { + return errMissing("A request body is required") + } + + if err := json.Unmarshal(q.body, v); err != nil { + return newErr( + http.StatusBadRequest, + "CannotParseRequest", + "The request body is not valid JSON for this operation", + ) + } + + return nil +} + +func (h *Handler) fail(q *req, err error) { + var ae *APIError + if !errors.As(err, &ae) { + logger.Load(q.r.Context()).ErrorContext(q.r.Context(), "ociqueue: internal error", "error", err) + + ae = newErr(http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") + } + + q.header("Content-Type", "application/json") + q.w.WriteHeader(ae.Status) + + out, _ := json.Marshal(errorBody{Code: ae.Code, Message: ae.Message}) //nolint:errchkjson // static shape + _, _ = q.w.Write(out) +} + +// Handler returns the Echo handler for all OCIQueue requests. +func (h *Handler) Handler() echo.HandlerFunc { return h.handle(true) } + +// Trusted returns a handler that skips request-signature verification, for the in-process dashboard proxy. +func (h *Handler) Trusted() echo.HandlerFunc { return h.handle(false) } + +func (h *Handler) handle(verify bool) echo.HandlerFunc { + return func(c *echo.Context) error { + q := &req{w: c.Response(), r: c.Request(), rt: parseRoute(c.Request())} + q.header("opc-request-id", strings.ToUpper(uuid.NewString())) + + if q.rt.op == opAdminKeys { + h.adminKeys(q) + + return nil + } + + h.Backend.Sweep() + + if err := h.serve(q, verify); err != nil { + h.fail(q, err) + } + + return nil + } +} + +func (h *Handler) serve(q *req, verify bool) error { + fn, ok := h.ops[q.rt.op] + if !ok { + return errNoRoute() + } + + body, err := io.ReadAll(http.MaxBytesReader(q.w, q.r.Body, maxBodyBytes)) + if err != nil { + return newErr(http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request body is too large or unreadable") + } + + q.body = body + + if verify { + if _, err = ociauth.Verify(q.r, q.body, ociauth.Options{ + Registry: h.Backend.Auth, Now: h.Backend.nowUTC, Enforce: h.EnforceAuth, + }); err != nil { + return errAuth("The required information to complete authentication was not provided or was incorrect.") + } + } + + return fn(h, q) +} + +type adminKeyReq struct { + Tenancy string `json:"tenancy"` + User string `json:"user"` + PublicKey string `json:"publicKey"` +} + +// adminKeys is the emulator-only control endpoint (/_gopherstack/oci/keys) for registering API +// public keys; once registered, requests signed with that keyId are cryptographically verified. +func (h *Handler) adminKeys(q *req) { + switch q.r.Method { + case http.MethodPost: + var in adminKeyReq + + body, _ := io.ReadAll(io.LimitReader(q.r.Body, adminBodyLimit)) + if err := json.Unmarshal(body, &in); err != nil || in.Tenancy == "" || in.User == "" { + h.fail(q, errParam("tenancy, user and publicKey are required")) + + return + } + + fp, err := h.Backend.Auth.Register(in.Tenancy, in.User, in.PublicKey) + if err != nil { + h.fail(q, errParam(err.Error())) + + return + } + + _ = q.json(http.StatusCreated, map[string]string{ + "keyId": ociauth.KeyID{Tenancy: in.Tenancy, User: in.User, Fingerprint: fp}.String(), "fingerprint": fp, + }, "") + case http.MethodGet: + _ = q.json(http.StatusOK, h.Backend.Auth.Records(), "") + case http.MethodDelete: + if !h.Backend.Auth.Remove(q.r.URL.Query().Get("keyId")) { + h.fail(q, newErr(http.StatusNotFound, "NotFound", "No such API key")) + + return + } + + q.w.WriteHeader(http.StatusNoContent) + default: + h.fail(q, errNoRoute()) + } +} + +// StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. +func (h *Handler) StartWorker(ctx context.Context) error { + listener, port, err := portalloc.ListenTCP(ctx, h.Port) + if err != nil { + return fmt.Errorf("ociqueue: bind port %d: %w", h.Port, err) + } + + h.Port = port + + e := echo.New() + e.Use(logger.EchoMiddleware(logger.Load(ctx))) + e.Any("/*", telemetry.WrapEchoHandler("OCIQueue", h.Handler(), h)) + + srv := &http.Server{ + Handler: e, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, IdleTimeout: idleTimeout, + } + stop := make(chan struct{}) + + h.srvMu.Lock("StartWorker") + h.srv, h.stop = srv, stop + h.srvMu.Unlock() + + workerCtx := logger.WithWorker(ctx, "ociqueue", "listener") + log := logger.Load(workerCtx) + + log.InfoContext(workerCtx, "ociqueue: starting dedicated listener", "port", h.Port) + + h.wg.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + log.ErrorContext(workerCtx, "ociqueue: listener stopped", "error", serveErr) + } + }) + + h.wg.Go(func() { + t := time.NewTicker(sweepInterval) + defer t.Stop() + + for { + select { + case <-t.C: + h.Backend.Sweep() + case <-stop: + return + case <-ctx.Done(): + return + } + } + }) + + return nil +} + +// Shutdown stops the listener and the sweeper. +func (h *Handler) Shutdown(ctx context.Context) { + h.srvMu.Lock("Shutdown") + srv, stop := h.srv, h.stop + h.srv, h.stop = nil, nil + h.srvMu.Unlock() + + if srv == nil { + return + } + + close(stop) + + log := logger.Load(ctx) + + if err := srv.Shutdown(ctx); err != nil { + log.ErrorContext(ctx, "ociqueue: graceful shutdown failed, forcing close", "error", err) + + if closeErr := srv.Close(); closeErr != nil { + log.ErrorContext(ctx, "ociqueue: forced close also failed", "error", closeErr) + } + } + + h.wg.Wait() +} + +// stopCh is closed when the listener shuts down, releasing parked long polls. +func (h *Handler) stopCh() <-chan struct{} { + h.srvMu.RLock("stopCh") + defer h.srvMu.RUnlock() + + return h.stop +} diff --git a/services/ociqueue/helpers_test.go b/services/ociqueue/helpers_test.go new file mode 100644 index 0000000000..bde7f5c137 --- /dev/null +++ b/services/ociqueue/helpers_test.go @@ -0,0 +1,229 @@ +package ociqueue_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/queue" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" + oci "github.com/blackbirdworks/gopherstack/services/ociqueue" +) + +const ( + testTenancy = "ocid1.tenancy.oc1..tenancy" + testUser = "ocid1.user.oc1..user" + testCompartment = "ocid1.compartment.oc1..comp" +) + +type keyPair struct { + privPEM string + pubPEM string + fprint string +} + +func newKeyPair(t *testing.T) keyPair { + t.Helper() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + require.NoError(t, err) + + fp, err := ociauth.Fingerprint(&k.PublicKey) + require.NoError(t, err) + + return keyPair{ + privPEM: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})), + pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub})), + fprint: fp, + } +} + +type fakeClock struct{ ns atomic.Int64 } + +func newFakeClock() *fakeClock { + c := &fakeClock{} + c.ns.Store(time.Now().UnixNano()) + + return c +} + +func (c *fakeClock) Now() time.Time { return time.Unix(0, c.ns.Load()).UTC() } +func (c *fakeClock) Advance(d time.Duration) { c.ns.Add(int64(d)) } + +type env struct { + backend *oci.InMemoryBackend + handler *oci.Handler + clock *fakeClock + prov common.ConfigurationProvider + kp keyPair + admin queue.QueueAdminClient + base string +} + +type envOpts struct { + enforce bool + register bool +} + +func newEnv(t *testing.T, opts envOpts) *env { + t.Helper() + + be := oci.NewInMemoryBackend() + clock := newFakeClock() + be.SetClock(clock.Now) + + h := oci.NewHandler(be) + h.Port = 0 + h.EnforceAuth = opts.enforce + + require.NoError(t, h.StartWorker(t.Context())) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + + kp := newKeyPair(t) + if opts.register { + _, err := be.Auth.Register(testTenancy, testUser, kp.pubPEM) + require.NoError(t, err) + } + + e := &env{ + backend: be, handler: h, clock: clock, kp: kp, + prov: common.NewRawConfigurationProvider(testTenancy, testUser, oci.Region, kp.fprint, kp.privPEM, nil), + base: fmt.Sprintf("http://localhost:%d", h.Port), + } + + var err error + + e.admin, err = queue.NewQueueAdminClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + e.admin.Host = e.base + + return e +} + +func (e *env) data(t *testing.T, q queue.Queue) queue.QueueClient { + t.Helper() + + c, err := queue.NewQueueClientWithConfigurationProvider(e.prov) + require.NoError(t, err) + + c.Host = *q.MessagesEndpoint + + return c +} + +// settle advances past the CREATING/UPDATING/DELETING transition. +func (e *env) settle() { e.clock.Advance(5 * time.Second) } + +func (e *env) mkQueue(t *testing.T, name string, mod func(*queue.CreateQueueDetails)) queue.Queue { + t.Helper() + + d := queue.CreateQueueDetails{CompartmentId: new(testCompartment), DisplayName: new(name)} + if mod != nil { + mod(&d) + } + + resp, err := e.admin.CreateQueue(t.Context(), queue.CreateQueueRequest{CreateQueueDetails: d}) + require.NoError(t, err) + require.NotNil(t, resp.OpcWorkRequestId) + e.settle() + + list, err := e.admin.ListQueues(t.Context(), queue.ListQueuesRequest{ + CompartmentId: new(testCompartment), DisplayName: new(name), + }) + require.NoError(t, err) + require.NotEmpty(t, list.Items) + + got, err := e.admin.GetQueue(t.Context(), queue.GetQueueRequest{QueueId: list.Items[len(list.Items)-1].Id}) + require.NoError(t, err) + + return got.Queue +} + +func svcErr(t *testing.T, err error) common.ServiceError { + t.Helper() + require.Error(t, err) + + se, ok := common.IsServiceError(err) + require.True(t, ok, "not a service error: %v", err) + + return se +} + +func rawDo(t *testing.T, method, url, body string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := testclient.New(t).Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(out) +} + +func put(t *testing.T, c queue.QueueClient, q queue.Queue, channel string, contents ...string) { + t.Helper() + + entries := make([]queue.PutMessagesDetailsEntry, 0, len(contents)) + + for _, content := range contents { + e := queue.PutMessagesDetailsEntry{Content: new(content)} + if channel != "" { + e.Metadata = &queue.MessageMetadata{ChannelId: new(channel)} + } + + entries = append(entries, e) + } + + resp, err := c.PutMessages(t.Context(), queue.PutMessagesRequest{ + QueueId: q.Id, Messages: entries, + }) + require.NoError(t, err) + require.Len(t, resp.Messages, len(contents)) +} + +func get(t *testing.T, c queue.QueueClient, q queue.Queue, mod func(*queue.GetMessagesRequest)) []queue.GetMessage { + t.Helper() + + r := queue.GetMessagesRequest{QueueId: q.Id, TimeoutInSeconds: new(0)} + if mod != nil { + mod(&r) + } + + resp, err := c.GetMessages(t.Context(), r) + require.NoError(t, err) + + return resp.Messages +} + +func quote(s string) string { + b, _ := json.Marshal(s) + + return string(b) +} diff --git a/services/ociqueue/inproc.go b/services/ociqueue/inproc.go new file mode 100644 index 0000000000..a3671d6cfb --- /dev/null +++ b/services/ociqueue/inproc.go @@ -0,0 +1,80 @@ +package ociqueue + +import ( + "slices" + "time" +) + +// PeekedMessage is a read-only view of one stored message; it carries no receipt. +type PeekedMessage struct { + CreatedAt time.Time `json:"createdAt"` + VisibleAfter time.Time `json:"visibleAfter"` + ExpireAfter time.Time `json:"expireAfter"` + Properties map[string]string `json:"customProperties,omitempty"` + Content string `json:"content"` + Channel string `json:"channelId,omitempty"` + ID int64 `json:"id"` + DeliveryCount int `json:"deliveryCount"` + InFlight bool `json:"inFlight"` +} + +// Compartments lists the distinct compartments that hold live queues. +func (b *InMemoryBackend) Compartments() []string { + b.mu.RLock("Compartments") + defer b.mu.RUnlock() + + seen := map[string]struct{}{} + + for _, q := range b.queues { + if q.State != stateDeleted { + seen[q.CompartmentID] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + + slices.Sort(out) + + return out +} + +// Peek returns up to limit messages of a queue or its dead letter queue without delivering them: no receipt is +// issued, delivery counts and visibility stay untouched. +func (b *InMemoryBackend) Peek(queueID string, dlq bool, limit int) ([]PeekedMessage, error) { + if aerr := inRange("limit", &limit, 1, maxGetLimit); aerr != nil { + return nil, aerr + } + + b.mu.Lock("Peek") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return nil, aerr + } + + src := q.Messages + if dlq { + src = q.DLQ + } + + now := b.nowUTC() + out := make([]PeekedMessage, 0, maxGetLimit) + + for _, m := range src { + if len(out) >= limit { + break + } + + out = append(out, PeekedMessage{ + ID: m.ID, Content: m.Content, Channel: m.Channel, Properties: m.Props, DeliveryCount: m.Deliveries, + CreatedAt: m.Created, VisibleAfter: m.VisibleAfter, ExpireAfter: m.Expire, + InFlight: m.VisibleAfter.After(now), + }) + } + + return out, nil +} diff --git a/services/ociqueue/leak_main_test.go b/services/ociqueue/leak_main_test.go new file mode 100644 index 0000000000..66e835e2b3 --- /dev/null +++ b/services/ociqueue/leak_main_test.go @@ -0,0 +1,18 @@ +package ociqueue_test + +import ( + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { + // The SDK's default policy retries 409 IncorrectState for minutes, which tests assert on directly. + _ = os.Setenv("OCI_SDK_DEFAULT_RETRY_ENABLED", "false") + + // The OCI SDK client arms a per-request timeout timer that outlives unread response bodies. + testleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.setRequestCancel.func4")) +} diff --git a/services/ociqueue/messages.go b/services/ociqueue/messages.go new file mode 100644 index 0000000000..9b2b1754f3 --- /dev/null +++ b/services/ociqueue/messages.go @@ -0,0 +1,645 @@ +package ociqueue + +import ( + "context" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + maxBatch = 20 + maxContentBytes = 128 * 1024 + maxBatchBytes = 512 * 1024 + maxChannelID = 64 + defaultGetLimit = 1 + maxGetLimit = 20 +) + +type metaIn struct { + ChannelID *string `json:"channelId"` + CustomProperties map[string]string `json:"customProperties"` +} + +type putEntryIn struct { + Content *string `json:"content"` + Metadata *metaIn `json:"metadata"` +} + +type putMessagesIn struct { + Messages []putEntryIn `json:"messages"` +} + +type PutMessageWire struct { + ExpireAfter time.Time `json:"expireAfter"` + ID int64 `json:"id"` +} + +type metaWire struct { + CustomProperties map[string]string `json:"customProperties,omitempty"` + ChannelID string `json:"channelId"` +} + +type GetMessageWire struct { + VisibleAfter time.Time `json:"visibleAfter"` + ExpireAfter time.Time `json:"expireAfter"` + CreatedAt time.Time `json:"createdAt"` + Metadata *metaWire `json:"metadata,omitempty"` + Content string `json:"content"` + Receipt string `json:"receipt"` + ID int64 `json:"id"` + DeliveryCount int `json:"deliveryCount"` +} + +// GetParams carries the GetMessages query parameters; nil fields fall back to the queue defaults. +type GetParams struct { + Visibility *int + Timeout *int + Limit *int + ChannelFilter string +} + +type UpdatedWire struct { + VisibleAfter time.Time `json:"visibleAfter"` + ID int64 `json:"id"` +} + +type resultEntryWire struct { + ID *int64 `json:"id,omitempty"` + VisibleAfter *time.Time `json:"visibleAfter,omitempty"` + ErrorCode *int `json:"errorCode,omitempty"` + ErrorMessage *string `json:"errorMessage,omitempty"` +} + +type BatchResultWire struct { + Entries []resultEntryWire `json:"entries"` + ServerFailures int `json:"serverFailures"` + ClientFailures int `json:"clientFailures"` +} + +type receiptEntryIn struct { + Visibility *int `json:"visibilityInSeconds"` + Receipt string `json:"receipt"` +} + +type receiptsIn struct { + Entries []receiptEntryIn `json:"entries"` +} + +type statsWire struct { + VisibleMessages int64 `json:"visibleMessages"` + InFlightMessages int64 `json:"inFlightMessages"` + SizeInBytes int64 `json:"sizeInBytes"` +} + +type QueueStatsWire struct { + Queue statsWire `json:"queue"` + DLQ statsWire `json:"dlq"` +} + +// dataQueue returns a queue that accepts data plane calls; the caller holds the write lock. +func (b *InMemoryBackend) dataQueue(id string) (*Queue, *APIError) { + b.settleID(id) + + q, aerr := b.liveQueue(id) + if aerr != nil { + return nil, aerr + } + + if q.State == stateCreating || q.State == stateDeleting { + return nil, errState("Queue " + id + " is in state " + q.State + "; the operation requires ACTIVE") + } + + return q, nil +} + +func validatePut(in putMessagesIn) *APIError { + if len(in.Messages) == 0 { + return errMissing("messages is required") + } + + if len(in.Messages) > maxBatch { + return errParam("a request carries at most 20 messages") + } + + total := 0 + + for _, m := range in.Messages { + if m.Content == nil { + return errMissing("every message requires content") + } + + if len(*m.Content) > maxContentBytes { + return errParam("a message content is limited to 128 KiB") + } + + total += len(*m.Content) + + if m.Metadata != nil { + if m.Metadata.ChannelID == nil || *m.Metadata.ChannelID == "" { + return errMissing("metadata.channelId is required when metadata is set") + } + + if len(*m.Metadata.ChannelID) > maxChannelID { + return errParam("channelId must be at most 64 characters") + } + } + } + + if total > maxBatchBytes { + return errParam("a request carries at most 512 KiB of message content") + } + + return nil +} + +// PutMessages appends messages to a queue. +func (b *InMemoryBackend) PutMessages(queueID string, in putMessagesIn) ([]PutMessageWire, error) { + if aerr := validatePut(in); aerr != nil { + return nil, aerr + } + + b.mu.Lock("PutMessages") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return nil, aerr + } + + now := b.nowUTC() + out := make([]PutMessageWire, 0, len(in.Messages)) + + for _, e := range in.Messages { + m := &Message{ + ID: q.NextMsgID, Content: *e.Content, Created: now, VisibleAfter: now, + Expire: now.Add(time.Duration(q.Retention) * time.Second), + } + q.NextMsgID++ + + if e.Metadata != nil { + m.Channel, m.Props = *e.Metadata.ChannelID, e.Metadata.CustomProperties + } + + q.Messages = append(q.Messages, m) + out = append(out, PutMessageWire{ID: m.ID, ExpireAfter: m.Expire}) + } + + q.notify() + + return out, nil +} + +func matchChannel(m *Message, filter string) bool { return filter == "" || m.Channel == filter } + +func (m *Message) view() GetMessageWire { + w := GetMessageWire{ + ID: m.ID, Content: m.Content, Receipt: m.Receipt, DeliveryCount: m.Deliveries, + VisibleAfter: m.VisibleAfter, ExpireAfter: m.Expire, CreatedAt: m.Created, + } + if m.Channel != "" { + w.Metadata = &metaWire{ChannelID: m.Channel, CustomProperties: m.Props} + } + + return w +} + +type pollResult struct { + wake <-chan struct{} + after func(time.Duration) <-chan time.Time + msgs []GetMessageWire + until time.Duration + timeout int +} + +// pollOnce delivers up to limit visible messages, or reports how long until the next one becomes visible. +func (b *InMemoryBackend) pollOnce(queueID string, p GetParams) (pollResult, error) { + b.mu.Lock("GetMessages") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return pollResult{}, aerr + } + + vis, limit := def(p.Visibility, q.Visibility), def(p.Limit, defaultGetLimit) + res := pollResult{timeout: def(p.Timeout, q.Timeout), after: b.after, wake: q.waiter(), until: -1} + now := b.nowUTC() + res.msgs = []GetMessageWire{} + + for _, m := range q.Messages { + if !matchChannel(m, p.ChannelFilter) { + continue + } + + if m.VisibleAfter.After(now) { + if d := m.VisibleAfter.Sub(now); res.until < 0 || d < res.until { + res.until = d + } + + continue + } + + if len(res.msgs) >= limit { + continue + } + + m.Deliveries++ + m.Receipt = uuid.NewString() + m.VisibleAfter = now.Add(time.Duration(vis) * time.Second) + res.msgs = append(res.msgs, m.view()) + } + + if len(res.msgs) > 0 { + q.notify() + } + + return res, nil +} + +// GetMessages returns visible messages, long polling until one arrives, timeout elapses, ctx or stop ends. +func (b *InMemoryBackend) GetMessages( + ctx context.Context, stop <-chan struct{}, queueID string, p GetParams, +) ([]GetMessageWire, error) { + if aerr := firstErr( + inRange("visibilityInSeconds", p.Visibility, 0, maxVisibility), + inRange("timeoutInSeconds", p.Timeout, 0, maxPollTimeout), + inRange("limit", p.Limit, 1, maxGetLimit), + ); aerr != nil { + return nil, aerr + } + + var deadline <-chan time.Time + + for { + res, err := b.pollOnce(queueID, p) + if err != nil { + return nil, err + } + + if len(res.msgs) > 0 || res.timeout == 0 { + return res.msgs, nil + } + + if deadline == nil { + deadline = res.after(time.Duration(res.timeout) * time.Second) + } + + var visible <-chan time.Time + if res.until >= 0 { + visible = res.after(res.until) + } + + select { + case <-res.wake: + case <-visible: + case <-deadline: + return res.msgs, nil + case <-ctx.Done(): + return res.msgs, nil + case <-stop: + return res.msgs, nil + } + } +} + +func findReceipt(q *Queue, receipt string) *Message { + if receipt == "" { + return nil + } + + for _, m := range q.Messages { + if m.Receipt == receipt { + return m + } + } + + return nil +} + +func errReceipt() *APIError { + return errNotFound("message receipt") +} + +func (b *InMemoryBackend) setVisibility(q *Queue, receipt string, vis int) (*Message, *APIError) { + m := findReceipt(q, receipt) + if m == nil { + return nil, errReceipt() + } + + m.VisibleAfter = b.nowUTC().Add(time.Duration(vis) * time.Second) + q.notify() + + return m, nil +} + +// UpdateMessage changes the visibility of one in-flight message. +func (b *InMemoryBackend) UpdateMessage(queueID, receipt string, vis *int) (UpdatedWire, error) { + if vis == nil { + return UpdatedWire{}, errMissing("visibilityInSeconds is required") + } + + if aerr := inRange("visibilityInSeconds", vis, 0, maxVisibility); aerr != nil { + return UpdatedWire{}, aerr + } + + b.mu.Lock("UpdateMessage") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return UpdatedWire{}, aerr + } + + m, aerr := b.setVisibility(q, receipt, *vis) + if aerr != nil { + return UpdatedWire{}, aerr + } + + return UpdatedWire{ID: m.ID, VisibleAfter: m.VisibleAfter}, nil +} + +func checkEntries(n int) *APIError { + if n == 0 { + return errMissing("entries is required") + } + + if n > maxBatch { + return errParam("a request carries at most 20 entries") + } + + return nil +} + +func failEntry(res *BatchResultWire, msg string) { + code := http.StatusNotFound + res.ClientFailures++ + res.Entries = append(res.Entries, resultEntryWire{ErrorCode: &code, ErrorMessage: &msg}) +} + +// UpdateMessages changes the visibility of several in-flight messages; each entry reports its own result. +func (b *InMemoryBackend) UpdateMessages(queueID string, in receiptsIn) (BatchResultWire, error) { + if aerr := checkEntries(len(in.Entries)); aerr != nil { + return BatchResultWire{}, aerr + } + + for _, e := range in.Entries { + if e.Visibility == nil { + return BatchResultWire{}, errMissing("every entry requires visibilityInSeconds") + } + + if aerr := inRange("visibilityInSeconds", e.Visibility, 0, maxVisibility); aerr != nil { + return BatchResultWire{}, aerr + } + } + + b.mu.Lock("UpdateMessages") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return BatchResultWire{}, aerr + } + + res := BatchResultWire{Entries: make([]resultEntryWire, 0, len(in.Entries))} + + for _, e := range in.Entries { + m, ae := b.setVisibility(q, e.Receipt, *e.Visibility) + if ae != nil { + failEntry(&res, "The message receipt is invalid or the message no longer exists") + + continue + } + + res.Entries = append(res.Entries, resultEntryWire{ID: &m.ID, VisibleAfter: &m.VisibleAfter}) + } + + return res, nil +} + +func (q *Queue) remove(receipt string) bool { + i := slices.IndexFunc(q.Messages, func(m *Message) bool { return receipt != "" && m.Receipt == receipt }) + if i < 0 { + return false + } + + q.Messages = slices.Delete(q.Messages, i, i+1) + + return true +} + +// DeleteMessage deletes one message by receipt. +func (b *InMemoryBackend) DeleteMessage(queueID, receipt string) error { + b.mu.Lock("DeleteMessage") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return aerr + } + + if !q.remove(receipt) { + return errReceipt() + } + + q.notify() + + return nil +} + +// DeleteMessages deletes several messages by receipt; each entry reports its own result. +func (b *InMemoryBackend) DeleteMessages(queueID string, in receiptsIn) (BatchResultWire, error) { + if aerr := checkEntries(len(in.Entries)); aerr != nil { + return BatchResultWire{}, aerr + } + + b.mu.Lock("DeleteMessages") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return BatchResultWire{}, aerr + } + + res := BatchResultWire{Entries: make([]resultEntryWire, 0, len(in.Entries))} + + for _, e := range in.Entries { + if !q.remove(e.Receipt) { + failEntry(&res, "The message receipt is invalid or the message no longer exists") + + continue + } + + res.Entries = append(res.Entries, resultEntryWire{}) + } + + q.notify() + + return res, nil +} + +func tally(msgs []*Message, now time.Time, channel string) statsWire { + var s statsWire + + for _, m := range msgs { + if !matchChannel(m, channel) { + continue + } + + if m.VisibleAfter.After(now) { + s.InFlightMessages++ + } else { + s.VisibleMessages++ + } + + s.SizeInBytes += int64(len(m.Content)) + } + + return s +} + +// GetStats counts visible and in-flight messages and their size, for the queue and its dead letter queue. +func (b *InMemoryBackend) GetStats(queueID, channel string) (QueueStatsWire, error) { + b.mu.Lock("GetStats") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return QueueStatsWire{}, aerr + } + + now := b.nowUTC() + + return QueueStatsWire{Queue: tally(q.Messages, now, channel), DLQ: tally(q.DLQ, now, channel)}, nil +} + +// ListChannels lists the channels that currently hold messages. +func (b *InMemoryBackend) ListChannels(queueID, filter string, o ListOpts) (page.Page[string], error) { + b.mu.Lock("ListChannels") + defer b.mu.Unlock() + + q, aerr := b.dataQueue(queueID) + if aerr != nil { + return page.Page[string]{}, aerr + } + + seen := map[string]bool{} + + for _, m := range append(slices.Clone(q.Messages), q.DLQ...) { + if m.Channel != "" && (filter == "" || strings.Contains(m.Channel, filter)) { + seen[m.Channel] = true + } + } + + all := make([]string, 0, len(seen)) + for c := range seen { + all = append(all, c) + } + + slices.Sort(all) + + return page.New(all, o.Page, o.Limit, defaultListLimit), nil +} + +func queryInt(q *req, key string) *int { + v := q.query(key) + if v == "" { + return nil + } + + n, err := strconv.Atoi(v) + if err != nil { + n = -1 + } + + return &n +} + +func (h *Handler) putMessages(q *req) error { + var in putMessagesIn + if err := q.decode(&in); err != nil { + return err + } + + out, err := h.Backend.PutMessages(q.param("queueId"), in) + + return reply(q, map[string]any{"messages": out}, "", err) +} + +func (h *Handler) getMessages(q *req) error { + out, err := h.Backend.GetMessages(q.r.Context(), h.stopCh(), q.param("queueId"), GetParams{ + Visibility: queryInt(q, "visibilityInSeconds"), Timeout: queryInt(q, "timeoutInSeconds"), + Limit: queryInt(q, "limit"), ChannelFilter: q.query("channelFilter"), + }) + + return reply(q, map[string]any{"messages": out}, "", err) +} + +func (h *Handler) updateMessage(q *req) error { + var in struct { + Visibility *int `json:"visibilityInSeconds"` + } + + if err := q.decode(&in); err != nil { + return err + } + + out, err := h.Backend.UpdateMessage(q.param("queueId"), q.param("messageReceipt"), in.Visibility) + + return reply(q, out, "", err) +} + +func (h *Handler) updateMessages(q *req) error { + var in receiptsIn + if err := q.decode(&in); err != nil { + return err + } + + out, err := h.Backend.UpdateMessages(q.param("queueId"), in) + + return reply(q, out, "", err) +} + +func (h *Handler) deleteMessage(q *req) error { + if err := h.Backend.DeleteMessage(q.param("queueId"), q.param("messageReceipt")); err != nil { + return err + } + + q.w.WriteHeader(http.StatusNoContent) + + return nil +} + +func (h *Handler) deleteMessages(q *req) error { + var in receiptsIn + if err := q.decode(&in); err != nil { + return err + } + + out, err := h.Backend.DeleteMessages(q.param("queueId"), in) + + return reply(q, out, "", err) +} + +func (h *Handler) getStats(q *req) error { + out, err := h.Backend.GetStats(q.param("queueId"), q.query("channelId")) + + return reply(q, out, "", err) +} + +func (h *Handler) listChannels(q *req) error { + pg, err := h.Backend.ListChannels(q.param("queueId"), q.query("channelFilter"), q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, map[string]any{itemsKey: pg.Data}, "") +} diff --git a/services/ociqueue/messages_test.go b/services/ociqueue/messages_test.go new file mode 100644 index 0000000000..6f279a5b66 --- /dev/null +++ b/services/ociqueue/messages_test.go @@ -0,0 +1,537 @@ +package ociqueue_test + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/queue" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPutGetDeleteFlow(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + q := e.mkQueue(t, "flow", nil) + c := e.data(t, q) + + put(t, c, q, "", "one", "two", "three") + + got := get(t, c, q, func(r *queue.GetMessagesRequest) { r.Limit = new(2) }) + require.Len(t, got, 2) + assert.Equal(t, "one", *got[0].Content) + assert.Equal(t, "two", *got[1].Content) + assert.Equal(t, 1, *got[0].DeliveryCount) + assert.NotEmpty(t, *got[0].Receipt) + + again := get(t, c, q, func(r *queue.GetMessagesRequest) { r.Limit = new(20) }) + require.Len(t, again, 1, "in-flight messages are hidden") + assert.Equal(t, "three", *again[0].Content) + + stats, err := c.GetStats(t.Context(), queue.GetStatsRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.EqualValues(t, 0, *stats.Queue.VisibleMessages) + assert.EqualValues(t, 3, *stats.Queue.InFlightMessages) + assert.EqualValues(t, 11, *stats.Queue.SizeInBytes) + + _, err = c.DeleteMessage(t.Context(), queue.DeleteMessageRequest{QueueId: q.Id, MessageReceipt: got[0].Receipt}) + require.NoError(t, err) + + _, err = c.DeleteMessage(t.Context(), queue.DeleteMessageRequest{QueueId: q.Id, MessageReceipt: got[0].Receipt}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) + + res, err := c.DeleteMessages(t.Context(), queue.DeleteMessagesRequest{ + QueueId: q.Id, + Entries: []queue.DeleteMessagesDetailsEntry{ + {Receipt: got[1].Receipt}, {Receipt: new("bogus")}, + }, + }) + require.NoError(t, err) + assert.Equal(t, 1, *res.ClientFailures) + assert.Equal(t, 0, *res.ServerFailures) + require.Len(t, res.Entries, 2) + assert.Nil(t, res.Entries[0].ErrorCode) + assert.Equal(t, http.StatusNotFound, *res.Entries[1].ErrorCode) + + e.clock.Advance(31 * time.Second) + + left := get(t, c, q, nil) + require.Len(t, left, 0+1) + assert.Equal(t, "three", *left[0].Content) + assert.Equal(t, 2, *left[0].DeliveryCount, "redelivery after the visibility timeout") +} + +func TestVisibilityUpdates(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "vis", nil) + c := e.data(t, q) + + put(t, c, q, "", "a", "b") + got := get(t, c, q, func(r *queue.GetMessagesRequest) { r.Limit = new(2); r.VisibilityInSeconds = new(100) }) + require.Len(t, got, 2) + + upd, err := c.UpdateMessage(t.Context(), queue.UpdateMessageRequest{ + QueueId: q.Id, MessageReceipt: got[0].Receipt, + VisibilityInSeconds: new(0), + }) + require.NoError(t, err) + assert.Equal(t, *got[0].Id, *upd.Id) + + again := get(t, c, q, nil) + require.Len(t, again, 1) + assert.Equal(t, "a", *again[0].Content) + + batch, err := c.UpdateMessages(t.Context(), queue.UpdateMessagesRequest{ + QueueId: q.Id, + Entries: []queue.UpdateMessagesDetailsEntry{ + {Receipt: got[1].Receipt, VisibilityInSeconds: new(0)}, + {Receipt: new("bogus"), VisibilityInSeconds: new(10)}, + }, + }) + require.NoError(t, err) + assert.Equal(t, 1, *batch.ClientFailures) + require.NotNil(t, batch.Entries[0].VisibleAfter) + assert.Equal(t, http.StatusNotFound, *batch.Entries[1].ErrorCode) + + b := get(t, c, q, nil) + require.Len(t, b, 1) + assert.Equal(t, "b", *b[0].Content) + + _, err = c.UpdateMessage(t.Context(), queue.UpdateMessageRequest{ + QueueId: q.Id, MessageReceipt: got[1].Receipt, + VisibilityInSeconds: new(50000), + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestPeekIncrementsDeliveryCount(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "peek", nil) + c := e.data(t, q) + + put(t, c, q, "", "a") + + for want := 1; want <= 3; want++ { + got := get(t, c, q, func(r *queue.GetMessagesRequest) { r.VisibilityInSeconds = new(0) }) + require.Len(t, got, 1) + assert.Equal(t, want, *got[0].DeliveryCount) + } +} + +func TestPutValidation(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "putval", nil) + c := e.data(t, q) + + many := make([]queue.PutMessagesDetailsEntry, 21) + for i := range many { + many[i] = queue.PutMessagesDetailsEntry{Content: new("x")} + } + + tests := []struct { + name string + entries []queue.PutMessagesDetailsEntry + }{ + {"empty", []queue.PutMessagesDetailsEntry{}}, + {"too many", many}, + {"too large", []queue.PutMessagesDetailsEntry{{Content: new(strings.Repeat("x", 128*1024+1))}}}, + {"long channel", []queue.PutMessagesDetailsEntry{{ + Content: new("x"), Metadata: &queue.MessageMetadata{ChannelId: new(strings.Repeat("c", 65))}, + }}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := c.PutMessages(t.Context(), queue.PutMessagesRequest{ + QueueId: q.Id, Messages: tt.entries, + }) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + }) + } + + _, err := c.GetMessages(t.Context(), queue.GetMessagesRequest{QueueId: q.Id, Limit: new(21)}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) + + put(t, c, q, "", strings.Repeat("x", 128*1024)) +} + +func TestChannels(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "chan", nil) + c := e.data(t, q) + + put(t, c, q, "alpha", "a1", "a2") + put(t, c, q, "beta", "b1") + put(t, c, q, "", "plain") + + ch, err := c.ListChannels(t.Context(), queue.ListChannelsRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "beta"}, ch.Items) + + one, err := c.ListChannels(t.Context(), queue.ListChannelsRequest{QueueId: q.Id, Limit: new(1)}) + require.NoError(t, err) + assert.Equal(t, []string{"alpha"}, one.Items) + require.NotNil(t, one.OpcNextPage) + + filtered, err := c.ListChannels(t.Context(), queue.ListChannelsRequest{QueueId: q.Id, ChannelFilter: new("bet")}) + require.NoError(t, err) + assert.Equal(t, []string{"beta"}, filtered.Items) + + st, err := c.GetStats(t.Context(), queue.GetStatsRequest{QueueId: q.Id, ChannelId: new("alpha")}) + require.NoError(t, err) + assert.EqualValues(t, 2, *st.Queue.VisibleMessages) + + got := get(t, c, q, func(r *queue.GetMessagesRequest) { r.ChannelFilter = new("beta"); r.Limit = new(5) }) + require.Len(t, got, 1) + assert.Equal(t, "b1", *got[0].Content) + assert.Equal(t, "beta", *got[0].Metadata.ChannelId) +} + +func TestRetentionExpiresMessages(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "ret", func(d *queue.CreateQueueDetails) { d.RetentionInSeconds = new(60) }) + c := e.data(t, q) + + put(t, c, q, "", "old") + e.clock.Advance(61 * time.Second) + assert.Empty(t, get(t, c, q, nil)) + + st, err := c.GetStats(t.Context(), queue.GetStatsRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.EqualValues(t, 0, *st.Queue.SizeInBytes) +} + +func TestDeadLetterQueue(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "dlq", func(d *queue.CreateQueueDetails) { d.DeadLetterQueueDeliveryCount = new(2) }) + c := e.data(t, q) + + put(t, c, q, "", "poison", "fine") + + peek := func(r *queue.GetMessagesRequest) { r.VisibilityInSeconds = new(0); r.Limit = new(1) } + for range 2 { + got := get(t, c, q, peek) + require.Len(t, got, 1) + assert.Equal(t, "poison", *got[0].Content) + } + + got := get(t, c, q, peek) + require.Len(t, got, 1) + assert.Equal(t, "fine", *got[0].Content, "the poison message went to the dead letter queue") + + st, err := c.GetStats(t.Context(), queue.GetStatsRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.EqualValues(t, 1, *st.Dlq.VisibleMessages) + assert.EqualValues(t, len("poison"), *st.Dlq.SizeInBytes) + + _, err = e.admin.PurgeQueue(t.Context(), queue.PurgeQueueRequest{ + QueueId: q.Id, + PurgeType: queue.PurgeQueueDetailsPurgeTypeDlq, + }) + require.NoError(t, err) + + st, err = c.GetStats(t.Context(), queue.GetStatsRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.EqualValues(t, 0, *st.Dlq.VisibleMessages) + assert.EqualValues(t, 1, *st.Queue.VisibleMessages) +} + +func TestDLQDisabled(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "nodlq", func(d *queue.CreateQueueDetails) { d.DeadLetterQueueDeliveryCount = new(0) }) + c := e.data(t, q) + + put(t, c, q, "", "m") + + for want := 1; want <= 8; want++ { + got := get(t, c, q, func(r *queue.GetMessagesRequest) { r.VisibilityInSeconds = new(0) }) + require.Len(t, got, 1) + assert.Equal(t, want, *got[0].DeliveryCount) + } +} + +func TestPurge(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + det queue.PurgeQueueDetails + name string + want int64 + code int + }{ + { + queue.PurgeQueueDetails{PurgeType: queue.PurgeQueueDetailsPurgeTypeNormal, ConsumerGroupId: new("g")}, + "group", 0, http.StatusBadRequest, + }, + { + queue.PurgeQueueDetails{PurgeType: queue.PurgeQueueDetailsPurgeTypeNormal, ChannelIds: []string{"x"}}, + "channel", 1, 0, + }, + {queue.PurgeQueueDetails{PurgeType: queue.PurgeQueueDetailsPurgeTypeBoth}, "all", 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + q := e.mkQueue(t, "purge-"+tt.name, nil) + c := e.data(t, q) + + put(t, c, q, "x", "a") + put(t, c, q, "y", "b") + + _, err := e.admin.PurgeQueue(t.Context(), queue.PurgeQueueRequest{QueueId: q.Id, PurgeQueueDetails: tt.det}) + if tt.code != 0 { + assert.Equal(t, tt.code, svcErr(t, err).GetHTTPStatusCode()) + + return + } + + require.NoError(t, err) + + st, serr := c.GetStats(t.Context(), queue.GetStatsRequest{QueueId: q.Id}) + require.NoError(t, serr) + assert.Equal(t, tt.want, *st.Queue.VisibleMessages) + }) + } +} + +func TestLongPollWakesOnPut(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "poll", nil) + c := e.data(t, q) + + parked := make(chan struct{}, 4) + e.backend.SetTimer(func(time.Duration) <-chan time.Time { + parked <- struct{}{} + + return nil + }) + + done := make(chan []queue.GetMessage, 1) + + go func() { + resp, err := c.GetMessages( + context.Background(), + queue.GetMessagesRequest{QueueId: q.Id, TimeoutInSeconds: new(30)}, + ) + if err == nil { + done <- resp.Messages + } + + close(done) + }() + + <-parked + put(t, c, q, "", "wake up") + + got := <-done + require.Len(t, got, 1) + assert.Equal(t, "wake up", *got[0].Content) +} + +func TestLongPollTimeoutReturnsEmpty(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "polltimeout", nil) + c := e.data(t, q) + + fire := make(chan time.Time, 1) + parked := make(chan time.Duration, 4) + e.backend.SetTimer(func(d time.Duration) <-chan time.Time { + parked <- d + + return fire + }) + + done := make(chan []queue.GetMessage, 1) + + go func() { + resp, err := c.GetMessages( + context.Background(), + queue.GetMessagesRequest{QueueId: q.Id, TimeoutInSeconds: new(7)}, + ) + if err == nil { + done <- resp.Messages + } + + close(done) + }() + + assert.Equal(t, 7*time.Second, <-parked) + fire <- time.Time{} + + got, ok := <-done + require.True(t, ok) + assert.Empty(t, got) +} + +func TestLongPollWakesWhenVisibilityExpires(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "pollvis", nil) + c := e.data(t, q) + + put(t, c, q, "", "hidden") + require.Len(t, get(t, c, q, func(r *queue.GetMessagesRequest) { r.VisibilityInSeconds = new(10) }), 1) + + durations := make(chan time.Duration, 8) + fires := make(chan chan time.Time, 8) + e.backend.SetTimer(func(d time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + durations <- d + fires <- ch + + return ch + }) + + done := make(chan []queue.GetMessage, 1) + + go func() { + resp, err := c.GetMessages( + context.Background(), + queue.GetMessagesRequest{QueueId: q.Id, TimeoutInSeconds: new(30)}, + ) + if err == nil { + done <- resp.Messages + } + + close(done) + }() + + assert.Equal(t, 30*time.Second, <-durations) + <-fires + assert.Equal(t, 10*time.Second, <-durations) + + e.clock.Advance(11 * time.Second) + (<-fires) <- time.Time{} + + got := <-done + require.Len(t, got, 1) + assert.Equal(t, 2, *got[0].DeliveryCount) +} + +func TestLongPollEndsOnShutdown(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "pollstop", nil) + c := e.data(t, q) + + parked := make(chan struct{}, 4) + e.backend.SetTimer(func(time.Duration) <-chan time.Time { + parked <- struct{}{} + + return nil + }) + + done := make(chan struct{}) + + go func() { + defer close(done) + + _, _ = c.GetMessages(context.Background(), queue.GetMessagesRequest{QueueId: q.Id, TimeoutInSeconds: new(30)}) + }() + + <-parked + e.handler.Shutdown(context.Background()) + <-done +} + +func TestDeleteQueueReleasesPollers(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "polldel", nil) + c := e.data(t, q) + + parked := make(chan struct{}, 4) + e.backend.SetTimer(func(time.Duration) <-chan time.Time { + parked <- struct{}{} + + return nil + }) + + errc := make(chan error, 1) + + go func() { + _, err := c.GetMessages( + context.Background(), + queue.GetMessagesRequest{QueueId: q.Id, TimeoutInSeconds: new(30)}, + ) + errc <- err + }() + + <-parked + + _, err := e.admin.DeleteQueue(t.Context(), queue.DeleteQueueRequest{QueueId: q.Id}) + require.NoError(t, err) + + assert.Equal(t, http.StatusConflict, svcErr(t, <-errc).GetHTTPStatusCode()) +} + +func TestBackendPeekLimitBounds(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "peeklimit", nil) + c := e.data(t, q) + + for range 3 { + put(t, c, q, "", "m") + } + + tests := []struct { + name string + limit int + want int + wantErr bool + }{ + {name: "within", limit: 2, want: 2}, + {name: "max", limit: 20, want: 3}, + {name: "zero", limit: 0, wantErr: true}, + {name: "over", limit: 1 << 30, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := e.backend.Peek(*q.Id, false, tt.limit) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.Len(t, got, tt.want) + }) + } +} diff --git a/services/ociqueue/models.go b/services/ociqueue/models.go new file mode 100644 index 0000000000..69eb605399 --- /dev/null +++ b/services/ociqueue/models.go @@ -0,0 +1,79 @@ +package ociqueue + +import "time" + +// Lifecycle states. +const ( + stateActive = "ACTIVE" + stateCreating = "CREATING" + stateUpdating = "UPDATING" + stateDeleting = "DELETING" + stateDeleted = "DELETED" +) + +// Tags are the shared freeform and defined tag sets of a resource. +type Tags struct { + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` +} + +func (t *Tags) merge(in Tags) { + if in.FreeformTags != nil { + t.FreeformTags = in.FreeformTags + } + + if in.DefinedTags != nil { + t.DefinedTags = in.DefinedTags + } +} + +// Message is one stored queue message. +type Message struct { + Created time.Time `json:"created"` + Expire time.Time `json:"expire"` + VisibleAfter time.Time `json:"visibleAfter"` + Props map[string]string `json:"props,omitempty"` + Content string `json:"content"` + Channel string `json:"channel,omitempty"` + Receipt string `json:"receipt,omitempty"` + ID int64 `json:"id"` + Deliveries int `json:"deliveries"` +} + +// Queue is a message queue with its dead letter queue. +type Queue struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + TransitionAt *time.Time `json:"transitionAt,omitempty"` + Tags Tags `json:"tags"` + wake chan struct{} // closed and replaced whenever a poller should re-check + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"name"` + State string `json:"state"` + KMSKeyID string `json:"kmsKeyId,omitempty"` + Etag string `json:"etag"` + Messages []*Message `json:"messages,omitempty"` + DLQ []*Message `json:"dlq,omitempty"` + Retention int `json:"retentionInSeconds"` + Visibility int `json:"visibilityInSeconds"` + Timeout int `json:"timeoutInSeconds"` + DLQCount int `json:"deadLetterQueueDeliveryCount"` + ChannelLimit int `json:"channelConsumptionLimit"` + NextMsgID int64 `json:"nextMsgId"` + Seq int64 `json:"seq"` +} + +// WorkRequest records an asynchronous admin operation. +type WorkRequest struct { + Accepted time.Time `json:"accepted"` + FinishAt time.Time `json:"finishAt"` + ID string `json:"id"` + Operation string `json:"operation"` + CompartmentID string `json:"compartmentId"` + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + EntityURI string `json:"entityUri"` + Action string `json:"action"` + Seq int64 `json:"seq"` +} diff --git a/services/ociqueue/ops.go b/services/ociqueue/ops.go new file mode 100644 index 0000000000..716dcb1097 --- /dev/null +++ b/services/ociqueue/ops.go @@ -0,0 +1,50 @@ +package ociqueue + +import ( + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const itemsKey = "items" + +func buildOps() map[string]opFunc { + return map[string]opFunc{ + opCreateQueue: (*Handler).createQueue, opGetQueue: (*Handler).getQueue, + opListQueues: (*Handler).listQueues, opUpdateQueue: (*Handler).updateQueue, + opDeleteQueue: (*Handler).deleteQueue, opChangeQueueComp: (*Handler).changeQueueCompartment, + opPurgeQueue: (*Handler).purgeQueue, + opGetWorkRequest: (*Handler).getWorkRequest, opListWorkRequests: (*Handler).listWorkRequests, + opListWorkReqErrors: (*Handler).listWorkRequestErrors, opListWorkReqLogs: (*Handler).listWorkRequestLogs, + opPutMessages: (*Handler).putMessages, opGetMessages: (*Handler).getMessages, + opUpdateMessage: (*Handler).updateMessage, opUpdateMessages: (*Handler).updateMessages, + opDeleteMessage: (*Handler).deleteMessage, opDeleteMessages: (*Handler).deleteMessages, + opGetStats: (*Handler).getStats, opListChannels: (*Handler).listChannels, + } +} + +func reply[T any](q *req, w T, etag string, err error) error { + if err != nil { + return err + } + + return q.json(http.StatusOK, w, etag) +} + +func acceptedWR(q *req, wr string, err error) error { + if err != nil { + return err + } + + return q.accepted(wr) +} + +func collectionReply[T any](q *req, pg page.Page[T], err error) error { + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, map[string]any{itemsKey: pg.Data}, "") +} diff --git a/services/ociqueue/persistence.go b/services/ociqueue/persistence.go new file mode 100644 index 0000000000..9e39010ea4 --- /dev/null +++ b/services/ociqueue/persistence.go @@ -0,0 +1,102 @@ +package ociqueue + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +// snapshotVersion must be bumped when a change to the stored types would make an older snapshot unsafe to decode. +const snapshotVersion = 1 + +type backendSnapshot struct { + Queues map[string]*Queue `json:"queues"` + WorkReqs map[string]*WorkRequest `json:"workRequests"` + Auth []ociauth.KeyRecord `json:"authKeys"` + Version int `json:"version"` + Seq int64 `json:"seq"` +} + +// Snapshot serialises the backend state, including registered API keys. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + keys := b.Auth.Records() + + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + return persistence.MarshalSnapshot(ctx, "ociqueue", backendSnapshot{ + Version: snapshotVersion, Queues: b.queues, WorkReqs: b.workReqs, Auth: keys, Seq: b.seq, + }) +} + +// Restore loads backend state from a snapshot. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "ociqueue", data, &snap); err != nil { + return err + } + + if snap.Version != snapshotVersion { + logger.Load(ctx).WarnContext(ctx, "ociqueue: discarding incompatible snapshot version, starting empty", + "gotVersion", snap.Version, "wantVersion", snapshotVersion) + b.Reset() + + return nil + } + + if err := checkSnapshot(&snap); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + for _, q := range b.queues { + q.notify() + } + + b.queues, b.workReqs, b.seq = snap.Queues, snap.WorkReqs, snap.Seq + b.Auth.Load(snap.Auth) + + return nil +} + +func nullCheck[T any](kind string, m *map[string]*T) error { + if *m == nil { + *m = map[string]*T{} + } + + for id, v := range *m { + if v == nil { + return fmt.Errorf("%w: %s %q", ErrSnapshotNull, kind, id) + } + } + + return nil +} + +func checkSnapshot(snap *backendSnapshot) error { + for _, err := range []error{nullCheck("queue", &snap.Queues), nullCheck("work request", &snap.WorkReqs)} { + if err != nil { + return err + } + } + + return nil +} + +// Snapshot implements persistence.Persistable by delegating to the backend. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable by delegating to the backend. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + if err := h.Backend.Restore(ctx, data); err != nil { + return fmt.Errorf("ociqueue: restore snapshot: %w", err) + } + + return nil +} diff --git a/services/ociqueue/persistence_test.go b/services/ociqueue/persistence_test.go new file mode 100644 index 0000000000..600ffec489 --- /dev/null +++ b/services/ociqueue/persistence_test.go @@ -0,0 +1,117 @@ +package ociqueue_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/queue" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + oci "github.com/blackbirdworks/gopherstack/services/ociqueue" +) + +func TestSnapshotRestore(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "persist", func(d *queue.CreateQueueDetails) { d.DeadLetterQueueDeliveryCount = new(1) }) + c := e.data(t, q) + + put(t, c, q, "ch", "keep", "poison") + in := get(t, c, q, func(r *queue.GetMessagesRequest) { r.Limit = new(1); r.VisibilityInSeconds = new(0) }) + require.Len(t, in, 1) + + snap := e.backend.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + restored := oci.NewInMemoryBackend() + restored.SetClock(e.clock.Now) + require.NoError(t, restored.Restore(t.Context(), snap)) + + st, err := restored.GetStats(*q.Id, "") + require.NoError(t, err) + assert.EqualValues(t, 1, st.DLQ.VisibleMessages, "the dead lettered message survives") + assert.EqualValues(t, 1, st.Queue.VisibleMessages) + + got, _, err := restored.GetQueue(*q.Id, "http://x") + require.NoError(t, err) + assert.Equal(t, "persist", got.Name) + + e.backend.Reset() + + _, _, err = e.backend.GetQueue(*q.Id, "http://x") + require.Error(t, err) +} + +func TestRestoreRejectsNullEntries(t *testing.T) { + t.Parallel() + + b := oci.NewInMemoryBackend() + err := b.Restore(t.Context(), []byte(`{"version":1,"queues":{"a":null}}`)) + require.Error(t, err) +} + +func TestRestoreDiscardsOldVersion(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "stale", nil) + + require.NoError(t, e.backend.Restore(t.Context(), []byte(`{"version":99}`))) + + _, _, err := e.backend.GetQueue(*q.Id, "http://x") + require.Error(t, err) +} + +func TestErrorEnvelope(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + + tests := []struct { + name string + method string + path string + body string + code string + status int + }{ + {"missing queue", http.MethodGet, "/20210201/queues/ocid1.queue.oc1..nope", "", "NotAuthorizedOrNotFound", 404}, + {"bad json", http.MethodPost, "/20210201/queues", "{", "CannotParseRequest", 400}, + {"no body", http.MethodPost, "/20210201/queues", "", "MissingParameter", 400}, + {"no compartment", http.MethodPost, "/20210201/queues", `{"displayName":"x"}`, "MissingParameter", 400}, + { + "bad purge", + http.MethodPost, + "/20210201/queues/ocid1.queue.oc1..nope/actions/purge", + `{"purgeType":"WHAT"}`, + "InvalidParameter", + 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body := rawDo(t, tt.method, e.base+tt.path, tt.body) + assert.Equal(t, tt.status, status) + assert.Contains(t, body, `"code":"`+tt.code+`"`) + assert.Contains(t, body, `"message"`) + }) + } +} + +func TestAdminKeyEndpoint(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + status, _ := rawDo(t, http.MethodPost, e.base+"/_gopherstack/oci/keys", + `{"tenancy":"`+testTenancy+`","user":"`+testUser+`","publicKey":`+quote(e.kp.pubPEM)+`}`) + assert.Equal(t, http.StatusCreated, status) + + status, body := rawDo(t, http.MethodGet, e.base+"/_gopherstack/oci/keys", "") + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, testTenancy) +} diff --git a/services/ociqueue/provider.go b/services/ociqueue/provider.go new file mode 100644 index 0000000000..4a8082d6f6 --- /dev/null +++ b/services/ociqueue/provider.go @@ -0,0 +1,42 @@ +package ociqueue + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("ociqueue: nil app context") + +// ConfigProvider extracts OCI Queue configuration from the AppContext Config. +type ConfigProvider interface { + GetOCIQueueSettings() Settings +} + +// Provider implements service.Provider for OCI Queue. It runs on its own +// listener (see Handler.StartWorker) and never matches on the shared AWS router. +type Provider struct{} + +// Name returns the service provider name. +func (p *Provider) Name() string { return "OCIQueue" } + +// Init creates the backend and handler; the port is bound in StartWorker. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + settings := DefaultSettings() + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetOCIQueueSettings() + } + + handler := NewHandler(NewInMemoryBackend()) + handler.Port = settings.Port + handler.EnforceAuth = settings.EnforceAuth + + return handler, nil +} diff --git a/services/ociqueue/queue_test.go b/services/ociqueue/queue_test.go new file mode 100644 index 0000000000..a4facbac87 --- /dev/null +++ b/services/ociqueue/queue_test.go @@ -0,0 +1,287 @@ +package ociqueue_test + +import ( + "net/http" + "testing" + + "github.com/oracle/oci-go-sdk/v65/queue" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQueueLifecycle(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{register: true, enforce: true}) + ctx := t.Context() + + created, err := e.admin.CreateQueue(ctx, queue.CreateQueueRequest{ + CompartmentId: new(testCompartment), DisplayName: new("lifecycle"), + FreeformTags: map[string]string{"env": "test"}}) + require.NoError(t, err) + + wr, err := e.admin.GetWorkRequest(ctx, queue.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, queue.OperationStatusInProgress, wr.Status) + assert.Equal(t, queue.OperationTypeCreateQueue, wr.OperationType) + + list, err := e.admin.ListQueues(ctx, queue.ListQueuesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, queue.QueueLifecycleStateCreating, list.Items[0].LifecycleState) + + _, err = e.data(t, queue.Queue{MessagesEndpoint: &e.base}).PutMessages(ctx, queue.PutMessagesRequest{ + QueueId: list.Items[0].Id, + Messages: []queue.PutMessagesDetailsEntry{{Content: new("x")}}, + }) + assert.Equal(t, http.StatusConflict, svcErr(t, err).GetHTTPStatusCode()) + + e.settle() + + got, err := e.admin.GetQueue(ctx, queue.GetQueueRequest{QueueId: list.Items[0].Id}) + require.NoError(t, err) + q := got.Queue + assert.Equal(t, queue.QueueLifecycleStateActive, q.LifecycleState) + assert.Equal(t, 86400, *q.RetentionInSeconds) + assert.Equal(t, 30, *q.VisibilityInSeconds) + assert.Equal(t, 30, *q.TimeoutInSeconds) + assert.Equal(t, 5, *q.DeadLetterQueueDeliveryCount) + assert.Equal(t, e.base, *q.MessagesEndpoint) + assert.Equal(t, "test", q.FreeformTags["env"]) + require.NotNil(t, got.Etag) + + wr, err = e.admin.GetWorkRequest(ctx, queue.GetWorkRequestRequest{WorkRequestId: created.OpcWorkRequestId}) + require.NoError(t, err) + assert.Equal(t, queue.OperationStatusSucceeded, wr.Status) + assert.Equal(t, queue.ActionTypeCreated, wr.Resources[0].ActionType) + + _, err = e.admin.UpdateQueue(ctx, queue.UpdateQueueRequest{ + QueueId: q.Id, IfMatch: new("stale"), + DisplayName: new("renamed"), + }) + assert.Equal(t, http.StatusPreconditionFailed, svcErr(t, err).GetHTTPStatusCode()) + + _, err = e.admin.UpdateQueue(ctx, queue.UpdateQueueRequest{ + QueueId: q.Id, IfMatch: got.Etag, + DisplayName: new("renamed"), VisibilityInSeconds: new(60), + }) + require.NoError(t, err) + + mid, err := e.admin.GetQueue(ctx, queue.GetQueueRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.Equal(t, queue.QueueLifecycleStateUpdating, mid.LifecycleState) + assert.Equal(t, "renamed", *mid.DisplayName) + assert.Equal(t, 60, *mid.VisibilityInSeconds) + e.settle() + + _, err = e.admin.ChangeQueueCompartment(ctx, queue.ChangeQueueCompartmentRequest{ + QueueId: q.Id, + CompartmentId: new("ocid1.compartment.oc1..other"), + }) + require.NoError(t, err) + + none, err := e.admin.ListQueues(ctx, queue.ListQueuesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) + assert.Empty(t, none.Items) + + _, err = e.admin.DeleteQueue(ctx, queue.DeleteQueueRequest{QueueId: q.Id}) + require.NoError(t, err) + + del, err := e.admin.GetQueue(ctx, queue.GetQueueRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.Equal(t, queue.QueueLifecycleStateDeleting, del.LifecycleState) + e.settle() + + del, err = e.admin.GetQueue(ctx, queue.GetQueueRequest{QueueId: q.Id}) + require.NoError(t, err) + assert.Equal(t, queue.QueueLifecycleStateDeleted, del.LifecycleState) + + _, err = e.data(t, q).PutMessages(ctx, queue.PutMessagesRequest{ + QueueId: q.Id, + Messages: []queue.PutMessagesDetailsEntry{{Content: new("x")}}, + }) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestCreateQueueValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mod func(*queue.CreateQueueDetails) + name string + code string + }{ + {func(d *queue.CreateQueueDetails) { d.RetentionInSeconds = new(5) }, "retention low", "InvalidParameter"}, + { + func(d *queue.CreateQueueDetails) { d.RetentionInSeconds = new(604801) }, + "retention high", + "InvalidParameter", + }, + { + func(d *queue.CreateQueueDetails) { d.VisibilityInSeconds = new(43201) }, + "visibility high", + "InvalidParameter", + }, + {func(d *queue.CreateQueueDetails) { d.TimeoutInSeconds = new(31) }, "timeout high", "InvalidParameter"}, + { + func(d *queue.CreateQueueDetails) { d.DeadLetterQueueDeliveryCount = new(21) }, + "dlq high", + "InvalidParameter", + }, + {func(d *queue.CreateQueueDetails) { d.DisplayName = new(" ") }, "blank name", "MissingParameter"}, + {func(d *queue.CreateQueueDetails) { d.CustomEncryptionKeyId = new("nope") }, "bad key", "InvalidParameter"}, + { + func(d *queue.CreateQueueDetails) { + d.Capabilities = []queue.CapabilityDetails{queue.LargeMessagesCapabilityDetails{}} + }, + "capabilities", "InvalidParameter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + d := queue.CreateQueueDetails{CompartmentId: new(testCompartment), DisplayName: new("v")} + tt.mod(&d) + + _, err := e.admin.CreateQueue(t.Context(), queue.CreateQueueRequest{CreateQueueDetails: d}) + se := svcErr(t, err) + assert.Equal(t, http.StatusBadRequest, se.GetHTTPStatusCode()) + assert.Equal(t, tt.code, se.GetCode()) + }) + } +} + +func TestListQueuesFilters(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + for _, n := range []string{"b", "a", "c"} { + e.mkQueue(t, n, nil) + } + + tests := []struct { + req queue.ListQueuesRequest + name string + want []string + }{ + { + queue.ListQueuesRequest{SortBy: queue.ListQueuesSortByDisplayname, SortOrder: queue.ListQueuesSortOrderAsc}, + "by name asc", + []string{"a", "b", "c"}, + }, + { + queue.ListQueuesRequest{ + SortBy: queue.ListQueuesSortByDisplayname, + SortOrder: queue.ListQueuesSortOrderDesc, + }, + "by name desc", + []string{"c", "b", "a"}, + }, + {queue.ListQueuesRequest{DisplayName: new("b")}, "name filter", []string{"b"}}, + {queue.ListQueuesRequest{LifecycleState: queue.QueueLifecycleStateDeleted}, "state filter", nil}, + { + queue.ListQueuesRequest{ + Limit: new(2), + SortBy: queue.ListQueuesSortByDisplayname, + SortOrder: queue.ListQueuesSortOrderAsc, + }, + "limit", + []string{"a", "b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := tt.req + r.CompartmentId = new(testCompartment) + + resp, err := e.admin.ListQueues(t.Context(), r) + require.NoError(t, err) + + var got []string + for _, q := range resp.Items { + got = append(got, *q.DisplayName) + } + + assert.Equal(t, tt.want, got) + + if tt.name == "limit" { + require.NotNil(t, resp.OpcNextPage) + + r.Page = resp.OpcNextPage + next, nerr := e.admin.ListQueues(t.Context(), r) + require.NoError(t, nerr) + require.Len(t, next.Items, 1) + assert.Equal(t, "c", *next.Items[0].DisplayName) + } + }) + } +} + +func TestListQueuesRequiresCompartment(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + _, err := e.admin.ListQueues(t.Context(), queue.ListQueuesRequest{}) + assert.Equal(t, http.StatusBadRequest, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestWorkRequests(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + q := e.mkQueue(t, "wr", nil) + + list, err := e.admin.ListWorkRequests( + t.Context(), + queue.ListWorkRequestsRequest{CompartmentId: new(testCompartment)}, + ) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, queue.OperationStatusSucceeded, list.Items[0].Status) + assert.Equal(t, *q.Id, *list.Items[0].Resources[0].Identifier) + + logs, err := e.admin.ListWorkRequestLogs( + t.Context(), + queue.ListWorkRequestLogsRequest{WorkRequestId: list.Items[0].Id}, + ) + require.NoError(t, err) + assert.Len(t, logs.Items, 2) + + errs, err := e.admin.ListWorkRequestErrors( + t.Context(), + queue.ListWorkRequestErrorsRequest{WorkRequestId: list.Items[0].Id}, + ) + require.NoError(t, err) + assert.Empty(t, errs.Items) + + _, err = e.admin.GetWorkRequest(t.Context(), queue.GetWorkRequestRequest{WorkRequestId: new("missing")}) + assert.Equal(t, http.StatusNotFound, svcErr(t, err).GetHTTPStatusCode()) +} + +func TestAuthEnforced(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{enforce: true, register: true}) + + status, body := rawDo(t, http.MethodGet, e.base+"/20210201/queues?compartmentId="+testCompartment, "") + assert.Equal(t, http.StatusUnauthorized, status) + assert.Contains(t, body, "NotAuthenticated") + + _, err := e.admin.ListQueues(t.Context(), queue.ListQueuesRequest{CompartmentId: new(testCompartment)}) + require.NoError(t, err) +} + +func TestUnknownRoute(t *testing.T) { + t.Parallel() + + e := newEnv(t, envOpts{}) + status, body := rawDo(t, http.MethodGet, e.base+"/20210201/nothing", "") + assert.Equal(t, http.StatusNotFound, status) + assert.Contains(t, body, "NotFound") +} diff --git a/services/ociqueue/queues.go b/services/ociqueue/queues.go new file mode 100644 index 0000000000..99019f4c11 --- /dev/null +++ b/services/ociqueue/queues.go @@ -0,0 +1,493 @@ +package ociqueue + +import ( + "encoding/json" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + minRetention = 10 + maxRetention = 604800 + defaultRetention = 86400 + maxVisibility = 43200 + defaultVisible = 30 + maxPollTimeout = 30 + defaultTimeout = 30 + maxDLQCount = 20 + defaultDLQCount = 5 + maxChannelLimit = 100 +) + +// CreateQueueIn is the CreateQueue request body. +type CreateQueueIn struct { + Tags + Retention *int `json:"retentionInSeconds"` + Visibility *int `json:"visibilityInSeconds"` + Timeout *int `json:"timeoutInSeconds"` + ChannelLimit *int `json:"channelConsumptionLimit"` + DLQCount *int `json:"deadLetterQueueDeliveryCount"` + KMSKeyID *string `json:"customEncryptionKeyId"` + Name string `json:"displayName"` + Compartment string `json:"compartmentId"` + Capabilities []json.RawMessage `json:"capabilities"` +} + +// UpdateQueueIn is the UpdateQueue request body. +type UpdateQueueIn struct { + Tags + Name *string `json:"displayName"` + Visibility *int `json:"visibilityInSeconds"` + Timeout *int `json:"timeoutInSeconds"` + ChannelLimit *int `json:"channelConsumptionLimit"` + DLQCount *int `json:"deadLetterQueueDeliveryCount"` + KMSKeyID *string `json:"customEncryptionKeyId"` + Capabilities []json.RawMessage `json:"capabilities"` +} + +// PurgeIn is the PurgeQueue request body. +type PurgeIn struct { + ConsumerGroupID *string `json:"consumerGroupId"` + PurgeType string `json:"purgeType"` + ChannelIDs []string `json:"channelIds"` +} + +type QueueWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + KMSKeyID *string `json:"customEncryptionKeyId,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + MessagesEndpoint string `json:"messagesEndpoint"` + Retention int `json:"retentionInSeconds"` + Visibility int `json:"visibilityInSeconds"` + Timeout int `json:"timeoutInSeconds"` + DLQCount int `json:"deadLetterQueueDeliveryCount"` + ChannelLimit int `json:"channelConsumptionLimit"` +} + +type queueSummaryWire struct { + TimeCreated time.Time `json:"timeCreated"` + TimeUpdated time.Time `json:"timeUpdated"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Name string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + MessagesEndpoint string `json:"messagesEndpoint"` +} + +func queueView(q *Queue, endpoint string) QueueWire { + w := QueueWire{ + ID: q.ID, CompartmentID: q.CompartmentID, Name: q.Name, LifecycleState: q.State, MessagesEndpoint: endpoint, + Retention: q.Retention, Visibility: q.Visibility, Timeout: q.Timeout, DLQCount: q.DLQCount, + ChannelLimit: q.ChannelLimit, TimeCreated: q.TimeCreated, TimeUpdated: q.TimeUpdated, + FreeformTags: q.Tags.FreeformTags, DefinedTags: q.Tags.DefinedTags, + } + if q.KMSKeyID != "" { + w.KMSKeyID = &q.KMSKeyID + } + + return w +} + +func inRange(name string, v *int, lo, hi int) *APIError { + if v != nil && (*v < lo || *v > hi) { + return errParam(name + " must be between " + strconv.Itoa(lo) + " and " + strconv.Itoa(hi)) + } + + return nil +} + +func validateName(name string) *APIError { + if strings.TrimSpace(name) == "" { + return errMissing("displayName is required") + } + + if len(name) > maxName { + return errParam("displayName must be at most 255 characters") + } + + return nil +} + +func firstErr(errs ...*APIError) *APIError { + for _, e := range errs { + if e != nil { + return e + } + } + + return nil +} + +func checkCapabilities(c []json.RawMessage) *APIError { + if len(c) > 0 { + return errParam("capabilities are not supported by this emulator") + } + + return nil +} + +func def(v *int, d int) int { + if v == nil { + return d + } + + return *v +} + +func (b *InMemoryBackend) liveQueue(id string) (*Queue, *APIError) { + q, ok := b.queues[id] + if !ok || q.State == stateDeleted { + return nil, errNotFound(id) + } + + return q, nil +} + +func (b *InMemoryBackend) mutableQueue(id string) (*Queue, *APIError) { + q, aerr := b.liveQueue(id) + if aerr != nil { + return nil, aerr + } + + if q.State == stateCreating || q.State == stateDeleting { + return nil, errState("Queue " + id + " is in state " + q.State + "; the operation requires ACTIVE") + } + + return q, nil +} + +// CreateQueue creates a queue in CREATING state and returns its work request. +func (b *InMemoryBackend) CreateQueue(in CreateQueueIn) (string, error) { + if in.Compartment == "" { + return "", errMissing("compartmentId is required") + } + + if aerr := firstErr( + validateName(in.Name), + inRange("retentionInSeconds", in.Retention, minRetention, maxRetention), + inRange("visibilityInSeconds", in.Visibility, 0, maxVisibility), + inRange("timeoutInSeconds", in.Timeout, 0, maxPollTimeout), + inRange("deadLetterQueueDeliveryCount", in.DLQCount, 0, maxDLQCount), + inRange("channelConsumptionLimit", in.ChannelLimit, 1, maxChannelLimit), + checkCapabilities(in.Capabilities), + ); aerr != nil { + return "", aerr + } + + b.mu.Lock("CreateQueue") + defer b.mu.Unlock() + + now := b.nowUTC() + q := &Queue{ + ID: newOCID("queue"), CompartmentID: in.Compartment, Name: in.Name, State: stateCreating, + TimeCreated: now, TimeUpdated: now, TransitionAt: b.transitionAt(), Tags: in.Tags, Etag: newEtag(), + Retention: def(in.Retention, defaultRetention), Visibility: def(in.Visibility, defaultVisible), + Timeout: def(in.Timeout, defaultTimeout), DLQCount: def(in.DLQCount, defaultDLQCount), + ChannelLimit: def(in.ChannelLimit, maxChannelLimit), NextMsgID: 1, Seq: b.nextSeq(), + } + + if in.KMSKeyID != nil && *in.KMSKeyID != "" { + if aerr := b.checkKey(*in.KMSKeyID); aerr != nil { + return "", aerr + } + + q.KMSKeyID = *in.KMSKeyID + } + + b.queues[q.ID] = q + + return b.newWR("CREATE_QUEUE", q.CompartmentID, q.ID, "CREATED", "/queues/"+q.ID, *q.TransitionAt), nil +} + +// GetQueue returns one queue and its etag. +func (b *InMemoryBackend) GetQueue(id, endpoint string) (QueueWire, string, error) { + b.mu.Lock("GetQueue") + defer b.mu.Unlock() + + q, ok := b.queues[id] + if !ok { + return QueueWire{}, "", errNotFound(id) + } + + b.settle(q, b.nowUTC()) + + return queueView(q, endpoint), q.Etag, nil +} + +// ListQueues lists the queues of a compartment. +func (b *InMemoryBackend) ListQueues( + compartment, id, name, state, endpoint string, + o ListOpts, +) (page.Page[queueSummaryWire], error) { + if compartment == "" && id == "" { + return page.Page[queueSummaryWire]{}, errMissing("compartmentId is required") + } + + b.mu.Lock("ListQueues") + defer b.mu.Unlock() + + now := b.nowUTC() + + var all []*Queue + + for _, q := range b.queues { + b.settle(q, now) + + if (compartment == "" || q.CompartmentID == compartment) && (id == "" || q.ID == id) && + (name == "" || q.Name == name) && (state == "" || strings.EqualFold(q.State, state)) { + all = append(all, q) + } + } + + pg := paginate(all, o, func(q *Queue) string { return q.Name }, + func(q *Queue) (time.Time, int64) { return q.TimeCreated, q.Seq }) + + out := make([]queueSummaryWire, 0, len(pg.Data)) + for _, q := range pg.Data { + out = append(out, queueSummaryWire{ + ID: q.ID, CompartmentID: q.CompartmentID, Name: q.Name, LifecycleState: q.State, + MessagesEndpoint: endpoint, TimeCreated: q.TimeCreated, TimeUpdated: q.TimeUpdated, + FreeformTags: q.Tags.FreeformTags, DefinedTags: q.Tags.DefinedTags, + }) + } + + return page.Page[queueSummaryWire]{Data: out, Next: pg.Next}, nil +} + +// UpdateQueue changes the mutable settings of a queue; it passes through UPDATING. +func (b *InMemoryBackend) UpdateQueue(id, ifMatch string, in UpdateQueueIn) (string, error) { + if aerr := firstErr( + inRange("visibilityInSeconds", in.Visibility, 0, maxVisibility), + inRange("timeoutInSeconds", in.Timeout, 0, maxPollTimeout), + inRange("deadLetterQueueDeliveryCount", in.DLQCount, 0, maxDLQCount), + inRange("channelConsumptionLimit", in.ChannelLimit, 1, maxChannelLimit), + checkCapabilities(in.Capabilities), + ); aerr != nil { + return "", aerr + } + + if in.Name != nil { + if aerr := validateName(*in.Name); aerr != nil { + return "", aerr + } + } + + b.mu.Lock("UpdateQueue") + defer b.mu.Unlock() + + b.settleID(id) + + q, aerr := b.mutableQueue(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(q.Etag, ifMatch); aerr != nil { + return "", aerr + } + + if in.KMSKeyID != nil && *in.KMSKeyID != "" { + if aerr = b.checkKey(*in.KMSKeyID); aerr != nil { + return "", aerr + } + + q.KMSKeyID = *in.KMSKeyID + } + + applyUpdate(q, in) + q.State, q.TransitionAt, q.TimeUpdated, q.Etag = stateUpdating, b.transitionAt(), b.nowUTC(), newEtag() + q.notify() + + return b.newWR("UPDATE_QUEUE", q.CompartmentID, q.ID, "UPDATED", "/queues/"+q.ID, *q.TransitionAt), nil +} + +func applyUpdate(q *Queue, in UpdateQueueIn) { + if in.Name != nil { + q.Name = *in.Name + } + + q.Visibility = def(in.Visibility, q.Visibility) + q.Timeout = def(in.Timeout, q.Timeout) + q.DLQCount = def(in.DLQCount, q.DLQCount) + q.ChannelLimit = def(in.ChannelLimit, q.ChannelLimit) + q.Tags.merge(in.Tags) +} + +// DeleteQueue moves a queue to DELETING. +func (b *InMemoryBackend) DeleteQueue(id, ifMatch string) (string, error) { + b.mu.Lock("DeleteQueue") + defer b.mu.Unlock() + + b.settleID(id) + + q, aerr := b.mutableQueue(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(q.Etag, ifMatch); aerr != nil { + return "", aerr + } + + q.State, q.TransitionAt, q.TimeUpdated, q.Etag = stateDeleting, b.transitionAt(), b.nowUTC(), newEtag() + q.notify() + + return b.newWR("DELETE_QUEUE", q.CompartmentID, q.ID, "DELETED", "/queues/"+q.ID, *q.TransitionAt), nil +} + +// ChangeQueueCompartment moves a queue to another compartment. +func (b *InMemoryBackend) ChangeQueueCompartment(id, ifMatch, compartment string) (string, error) { + if compartment == "" { + return "", errMissing("compartmentId is required") + } + + b.mu.Lock("ChangeQueueCompartment") + defer b.mu.Unlock() + + b.settleID(id) + + q, aerr := b.mutableQueue(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(q.Etag, ifMatch); aerr != nil { + return "", aerr + } + + q.CompartmentID, q.Etag, q.TimeUpdated = compartment, newEtag(), b.nowUTC() + + return b.newWR("MOVE_QUEUE", compartment, q.ID, "UPDATED", "/queues/"+q.ID, b.nowUTC()), nil +} + +// PurgeQueue deletes the messages of a queue, its dead letter queue, or both, optionally per channel. +func (b *InMemoryBackend) PurgeQueue(id, ifMatch string, in PurgeIn) (string, error) { + switch in.PurgeType { + case "NORMAL", "DLQ", "BOTH": + case "": + return "", errMissing("purgeType is required") + default: + return "", errParam("purgeType must be NORMAL, DLQ or BOTH") + } + + if in.ConsumerGroupID != nil && *in.ConsumerGroupID != "" { + return "", errParam("consumer groups are not supported by this emulator") + } + + b.mu.Lock("PurgeQueue") + defer b.mu.Unlock() + + b.settleID(id) + + q, aerr := b.mutableQueue(id) + if aerr != nil { + return "", aerr + } + + if aerr = checkEtag(q.Etag, ifMatch); aerr != nil { + return "", aerr + } + + drop := func(m *Message) bool { + return len(in.ChannelIDs) == 0 || slices.Contains(in.ChannelIDs, m.Channel) + } + + if in.PurgeType != "DLQ" { + q.Messages = slices.DeleteFunc(q.Messages, drop) + } + + if in.PurgeType != "NORMAL" { + q.DLQ = slices.DeleteFunc(q.DLQ, drop) + } + + q.TimeUpdated = b.nowUTC() + q.notify() + + return b.newWR("PURGE_QUEUE", q.CompartmentID, q.ID, "UPDATED", "/queues/"+q.ID, + b.nowUTC().Add(b.transition)), nil +} + +type changeCompartmentIn struct { + CompartmentID string `json:"compartmentId"` +} + +func (h *Handler) createQueue(q *req) error { + var in CreateQueueIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.CreateQueue(in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) getQueue(q *req) error { + v, etag, err := h.Backend.GetQueue(q.param("queueId"), q.endpoint()) + + return reply(q, v, etag, err) +} + +func (h *Handler) listQueues(q *req) error { + pg, err := h.Backend.ListQueues(q.query("compartmentId"), q.query("id"), q.query("displayName"), + q.query("lifecycleState"), q.endpoint(), q.listOpts()) + if err != nil { + return err + } + + q.nextPage(pg.Next) + + return q.json(http.StatusOK, map[string]any{itemsKey: pg.Data}, "") +} + +func (h *Handler) updateQueue(q *req) error { + var in UpdateQueueIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.UpdateQueue(q.param("queueId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) deleteQueue(q *req) error { + wr, err := h.Backend.DeleteQueue(q.param("queueId"), q.ifMatch()) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) changeQueueCompartment(q *req) error { + var in changeCompartmentIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.ChangeQueueCompartment(q.param("queueId"), q.ifMatch(), in.CompartmentID) + + return acceptedWR(q, wr, err) +} + +func (h *Handler) purgeQueue(q *req) error { + var in PurgeIn + if err := q.decode(&in); err != nil { + return err + } + + wr, err := h.Backend.PurgeQueue(q.param("queueId"), q.ifMatch(), in) + + return acceptedWR(q, wr, err) +} diff --git a/services/ociqueue/route.go b/services/ociqueue/route.go new file mode 100644 index 0000000000..51f5fb1d94 --- /dev/null +++ b/services/ociqueue/route.go @@ -0,0 +1,81 @@ +package ociqueue + +import ( + "net/http" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Operation names, matching the OCI SDK operation names. +const ( + opCreateQueue = "CreateQueue" + opGetQueue = "GetQueue" + opListQueues = "ListQueues" + opUpdateQueue = "UpdateQueue" + opDeleteQueue = "DeleteQueue" + opChangeQueueComp = "ChangeQueueCompartment" + opPurgeQueue = "PurgeQueue" + opGetWorkRequest = "GetWorkRequest" + opListWorkRequests = "ListWorkRequests" + opListWorkReqErrors = "ListWorkRequestErrors" + opListWorkReqLogs = "ListWorkRequestLogs" + opPutMessages = "PutMessages" + opGetMessages = "GetMessages" + opUpdateMessage = "UpdateMessage" + opUpdateMessages = "UpdateMessages" + opDeleteMessage = "DeleteMessage" + opDeleteMessages = "DeleteMessages" + opGetStats = "GetStats" + opListChannels = "ListChannels" + opAdminKeys = "ManageAPIKeys" + opUnknown = "Unknown" + adminPrefix = "_gopherstack" + base = "/20210201" + queuePath = base + "/queues/{queueId}" + workRequestPath = base + "/workRequests/{workRequestId}" + messagesPath = queuePath + "/messages" +) + +//nolint:gochecknoglobals // immutable route table +var router = restroute.New([]restroute.Route{ + {Method: http.MethodPost, Path: base + "/queues", Op: opCreateQueue}, + {Method: http.MethodGet, Path: base + "/queues", Op: opListQueues}, + {Method: http.MethodGet, Path: queuePath, Op: opGetQueue}, + {Method: http.MethodPut, Path: queuePath, Op: opUpdateQueue}, + {Method: http.MethodDelete, Path: queuePath, Op: opDeleteQueue}, + {Method: http.MethodPost, Path: queuePath + "/actions/changeCompartment", Op: opChangeQueueComp}, + {Method: http.MethodPost, Path: queuePath + "/actions/purge", Op: opPurgeQueue}, + {Method: http.MethodGet, Path: base + "/workRequests", Op: opListWorkRequests}, + {Method: http.MethodGet, Path: workRequestPath, Op: opGetWorkRequest}, + {Method: http.MethodGet, Path: workRequestPath + "/errors", Op: opListWorkReqErrors}, + {Method: http.MethodGet, Path: workRequestPath + "/logs", Op: opListWorkReqLogs}, + {Method: http.MethodPost, Path: messagesPath, Op: opPutMessages}, + {Method: http.MethodGet, Path: messagesPath, Op: opGetMessages}, + {Method: http.MethodPut, Path: messagesPath + "/{messageReceipt}", Op: opUpdateMessage}, + {Method: http.MethodDelete, Path: messagesPath + "/{messageReceipt}", Op: opDeleteMessage}, + {Method: http.MethodPost, Path: messagesPath + "/actions/updateMessages", Op: opUpdateMessages}, + {Method: http.MethodPost, Path: messagesPath + "/actions/deleteMessages", Op: opDeleteMessages}, + {Method: http.MethodGet, Path: queuePath + "/stats", Op: opGetStats}, + {Method: http.MethodGet, Path: queuePath + "/channels", Op: opListChannels}, +}) + +type route struct { + params map[string]string + op string +} + +// parseRoute maps method and path to an operation. +func parseRoute(r *http.Request) route { + path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/"+adminPrefix+"/") { + return route{op: opAdminKeys} + } + + op, params := router.Match(r.Method, path) + if op == "" { + op = opUnknown + } + + return route{op: op, params: params} +} diff --git a/services/ociqueue/settings.go b/services/ociqueue/settings.go new file mode 100644 index 0000000000..66772bb903 --- /dev/null +++ b/services/ociqueue/settings.go @@ -0,0 +1,13 @@ +package ociqueue + +// DefaultPort is the dedicated listener's default TCP port. +const DefaultPort = 8156 + +// Settings holds service-level configuration, embedded in the root CLI as CLI.OCIQueue. +type Settings struct { + Port int `json:"port" env:"OCI_QUEUE_PORT" default:"8156" name:"port" help:"Fixed TCP port for the dedicated OCI Queue (queue admin and messages) listener; startup fails if it is unavailable."` //nolint:lll // config struct tags are intentionally verbose + EnforceAuth bool `json:"enforceAuth" env:"OCI_QUEUE_ENFORCE_AUTH" name:"enforce-auth" help:"Reject unsigned requests and signatures from API keys not registered with the emulator; off by default so any signing config works."` //nolint:lll // config struct tags are intentionally verbose +} + +// DefaultSettings returns the settings used when no ConfigProvider is available. +func DefaultSettings() Settings { return Settings{Port: DefaultPort} } diff --git a/services/ociqueue/store.go b/services/ociqueue/store.go new file mode 100644 index 0000000000..e74fbb8cbe --- /dev/null +++ b/services/ociqueue/store.go @@ -0,0 +1,256 @@ +package ociqueue + +import ( + "cmp" + "slices" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awsid" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + // Region is the fixed region baked into emulated OCIDs. + Region = "us-ashburn-1" + defaultListLimit = 100 + ocidTailLen = 52 + + defaultTransition = 2 * time.Second + maxName = 255 +) + +// KeyChecker reports the lifecycle state of a KMS key OCID and whether it exists. +type KeyChecker func(keyID string) (state string, found bool) + +// InMemoryBackend holds every queue and work request under one lock. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + now func() time.Time + after func(time.Duration) <-chan time.Time + queues map[string]*Queue + workReqs map[string]*WorkRequest + Auth *ociauth.Registry + keyChecker KeyChecker + transition time.Duration + seq int64 +} + +// NewInMemoryBackend creates an empty backend. +func NewInMemoryBackend() *InMemoryBackend { + b := &InMemoryBackend{ + mu: lockmetrics.New("ociqueue"), now: time.Now, after: time.After, transition: defaultTransition, + } + b.resetMaps() + b.Auth = ociauth.NewRegistry() + + return b +} + +func (b *InMemoryBackend) resetMaps() { + b.queues, b.workReqs = map[string]*Queue{}, map[string]*WorkRequest{} +} + +// SetClock injects the time source used for lifecycle, visibility and retention. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + b.now = now + b.mu.Unlock() +} + +// SetTimer injects the wait used by long polls; it must return a channel that fires after d. +func (b *InMemoryBackend) SetTimer(after func(time.Duration) <-chan time.Time) { + b.mu.Lock("SetTimer") + b.after = after + b.mu.Unlock() +} + +// SetTransition sets how long CREATING, UPDATING and DELETING states last. +func (b *InMemoryBackend) SetTransition(d time.Duration) { + b.mu.Lock("SetTransition") + b.transition = d + b.mu.Unlock() +} + +// SetKeyChecker wires validation of customEncryptionKeyId references against a Vault backend. +func (b *InMemoryBackend) SetKeyChecker(fn KeyChecker) { + b.mu.Lock("SetKeyChecker") + b.keyChecker = fn + b.mu.Unlock() +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + + for _, q := range b.queues { + q.notify() + } + + b.resetMaps() + b.mu.Unlock() + b.Auth.Reset() +} + +func (b *InMemoryBackend) nowUTC() time.Time { return b.now().UTC() } + +func (b *InMemoryBackend) nextSeq() int64 { + b.seq++ + + return b.seq +} + +func newEtag() string { return uuid.NewString() } + +func newOCID(kind string) string { + return "ocid1." + kind + ".oc1." + Region + "." + awsid.NewN("", ocidTailLen) +} + +func checkEtag(current, ifMatch string) *APIError { + if ifMatch != "" && ifMatch != current { + return errEtag() + } + + return nil +} + +func (b *InMemoryBackend) transitionAt() *time.Time { + t := b.nowUTC().Add(b.transition) + + return &t +} + +// ListOpts carries the shared list query parameters. +type ListOpts struct { + SortBy string + Order string + Page string + Limit int +} + +func paginate[T any](items []T, o ListOpts, name func(T) string, created func(T) (time.Time, int64)) page.Page[T] { + byName := strings.EqualFold(o.SortBy, "displayName") + desc := !byName + switch strings.ToUpper(o.Order) { + case "ASC": + desc = false + case "DESC": + desc = true + } + + slices.SortStableFunc(items, func(a, c T) int { + var r int + if byName { + r = cmp.Compare(name(a), name(c)) + } else { + ta, sa := created(a) + tc, sc := created(c) + r = cmp.Or(ta.Compare(tc), cmp.Compare(sa, sc)) + } + + if desc { + return -r + } + + return r + }) + + return page.New(items, o.Page, o.Limit, defaultListLimit) +} + +func (b *InMemoryBackend) checkKey(keyID string) *APIError { + if !strings.HasPrefix(keyID, "ocid1.key.") { + return errParam("customEncryptionKeyId must be the OCID of a Vault key") + } + + if b.keyChecker == nil { + return nil + } + + state, found := b.keyChecker(keyID) + switch { + case !found: + return errParam("customEncryptionKeyId " + keyID + " does not reference an existing key") + case state != "ENABLED": + return errParam("customEncryptionKeyId " + keyID + " is in state " + state + "; the key must be ENABLED") + } + + return nil +} + +// Sweep applies lifecycle transitions, retention and dead lettering against the backend clock. +func (b *InMemoryBackend) Sweep() { + b.mu.Lock("Sweep") + defer b.mu.Unlock() + + now := b.nowUTC() + for _, q := range b.queues { + b.settle(q, now) + } +} + +// settle moves one queue through its transition and applies retention and the dead letter threshold. +func (b *InMemoryBackend) settle(q *Queue, now time.Time) { + if q.TransitionAt != nil && !q.TransitionAt.After(now) { + q.TransitionAt = nil + + switch q.State { + case stateCreating, stateUpdating: + q.State = stateActive + case stateDeleting: + q.State = stateDeleted + q.Messages, q.DLQ = nil, nil + q.notify() + + return + } + } + + q.Messages = slices.DeleteFunc(q.Messages, func(m *Message) bool { return !m.Expire.After(now) }) + q.DLQ = slices.DeleteFunc(q.DLQ, func(m *Message) bool { return !m.Expire.After(now) }) + + if q.DLQCount <= 0 { + return + } + + kept := q.Messages[:0] + + for _, m := range q.Messages { + if !m.VisibleAfter.After(now) && m.Deliveries >= q.DLQCount { + q.DLQ = append(q.DLQ, m) + + continue + } + + kept = append(kept, m) + } + + clear(q.Messages[len(kept):]) + q.Messages = kept +} + +func (q *Queue) notify() { + if q.wake != nil { + close(q.wake) + } + + q.wake = make(chan struct{}) +} + +func (q *Queue) waiter() <-chan struct{} { + if q.wake == nil { + q.wake = make(chan struct{}) + } + + return q.wake +} + +func (b *InMemoryBackend) settleID(id string) { + if q, ok := b.queues[id]; ok { + b.settle(q, b.nowUTC()) + } +} diff --git a/services/ociqueue/workreq.go b/services/ociqueue/workreq.go new file mode 100644 index 0000000000..2f50a57328 --- /dev/null +++ b/services/ociqueue/workreq.go @@ -0,0 +1,166 @@ +package ociqueue + +import ( + "net/http" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const halfComplete = 50 + +type wrResourceWire struct { + EntityType string `json:"entityType"` + ActionType string `json:"actionType"` + Identifier string `json:"identifier"` + EntityURI string `json:"entityUri,omitempty"` +} + +type WorkRequestWire struct { + TimeAccepted time.Time `json:"timeAccepted"` + TimeStarted *time.Time `json:"timeStarted,omitempty"` + TimeFinished *time.Time `json:"timeFinished,omitempty"` + OperationType string `json:"operationType"` + Status string `json:"status"` + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Resources []wrResourceWire `json:"resources"` + PercentComplete float32 `json:"percentComplete"` +} + +type wrLogWire struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +// newWR records a work request that completes with the resource transition. +func (b *InMemoryBackend) newWR(op, compartment, id, action, uri string, finish time.Time) string { + wr := &WorkRequest{ + ID: newOCID("queueworkrequest"), Operation: op, CompartmentID: compartment, EntityType: "queue", + EntityID: id, EntityURI: uri, Action: action, Accepted: b.nowUTC(), FinishAt: finish, Seq: b.nextSeq(), + } + b.workReqs[wr.ID] = wr + + return wr.ID +} + +func (b *InMemoryBackend) wrView(wr *WorkRequest) WorkRequestWire { + now := b.nowUTC() + out := WorkRequestWire{ + OperationType: wr.Operation, ID: wr.ID, CompartmentID: wr.CompartmentID, TimeAccepted: wr.Accepted, + Status: "IN_PROGRESS", PercentComplete: halfComplete, TimeStarted: &wr.Accepted, + Resources: []wrResourceWire{{ + EntityType: wr.EntityType, ActionType: "IN_PROGRESS", Identifier: wr.EntityID, EntityURI: wr.EntityURI, + }}, + } + + if !wr.FinishAt.After(now) { + out.Status, out.PercentComplete, out.TimeFinished = "SUCCEEDED", 100, &wr.FinishAt + out.Resources[0].ActionType = wr.Action + } + + return out +} + +// GetWorkRequest returns one work request. +func (b *InMemoryBackend) GetWorkRequest(id string) (WorkRequestWire, error) { + b.mu.RLock("GetWorkRequest") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return WorkRequestWire{}, errNotFound(id) + } + + return b.wrView(wr), nil +} + +// ListWorkRequests lists the work requests of a compartment, optionally for one resource. +func (b *InMemoryBackend) ListWorkRequests( + compartment, resourceID, id string, o ListOpts, +) (page.Page[WorkRequestWire], error) { + if compartment == "" && id == "" { + return page.Page[WorkRequestWire]{}, errMissing("compartmentId is required") + } + + b.mu.RLock("ListWorkRequests") + defer b.mu.RUnlock() + + var all []*WorkRequest + + for _, wr := range b.workReqs { + if (id != "" && wr.ID != id) || (compartment != "" && wr.CompartmentID != compartment) || + (resourceID != "" && wr.EntityID != resourceID) { + continue + } + + all = append(all, wr) + } + + pg := paginate(all, o, func(w *WorkRequest) string { return w.ID }, + func(w *WorkRequest) (time.Time, int64) { return w.Accepted, w.Seq }) + + out := make([]WorkRequestWire, 0, len(pg.Data)) + for _, wr := range pg.Data { + out = append(out, b.wrView(wr)) + } + + return page.Page[WorkRequestWire]{Data: out, Next: pg.Next}, nil +} + +// WorkRequestLogs returns the log entries of a work request. +func (b *InMemoryBackend) WorkRequestLogs(id string, o ListOpts) (page.Page[wrLogWire], error) { + b.mu.RLock("WorkRequestLogs") + defer b.mu.RUnlock() + + wr, ok := b.workReqs[id] + if !ok { + return page.Page[wrLogWire]{}, errNotFound(id) + } + + logs := []wrLogWire{{Timestamp: wr.Accepted, Message: wr.Operation + " accepted"}} + if !wr.FinishAt.After(b.nowUTC()) { + logs = append(logs, wrLogWire{Timestamp: wr.FinishAt, Message: wr.Operation + " succeeded"}) + } + + return page.New(logs, o.Page, o.Limit, defaultListLimit), nil +} + +// WorkRequestExists reports whether a work request exists (its error list is always empty). +func (b *InMemoryBackend) WorkRequestExists(id string) error { + b.mu.RLock("WorkRequestExists") + defer b.mu.RUnlock() + + if _, ok := b.workReqs[id]; !ok { + return errNotFound(id) + } + + return nil +} + +func (h *Handler) getWorkRequest(q *req) error { + w, err := h.Backend.GetWorkRequest(q.param("workRequestId")) + + return reply(q, w, "", err) +} + +func (h *Handler) listWorkRequests(q *req) error { + pg, err := h.Backend.ListWorkRequests(q.query("compartmentId"), q.query("resourceId"), + q.query("workRequestId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestLogs(q *req) error { + pg, err := h.Backend.WorkRequestLogs(q.param("workRequestId"), q.listOpts()) + + return collectionReply(q, pg, err) +} + +func (h *Handler) listWorkRequestErrors(q *req) error { + if err := h.Backend.WorkRequestExists(q.param("workRequestId")); err != nil { + return err + } + + return q.json(http.StatusOK, map[string]any{itemsKey: []struct{}{}}, "") +} diff --git a/services/ocistreaming/handler.go b/services/ocistreaming/handler.go index cc4fabaa74..ad3f717cba 100644 --- a/services/ocistreaming/handler.go +++ b/services/ocistreaming/handler.go @@ -19,6 +19,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -290,13 +291,13 @@ func (h *Handler) adminKeys(q *req) { // StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. func (h *Handler) StartWorker(ctx context.Context) error { - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { return fmt.Errorf("ocistreaming: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) e.Any("/*", telemetry.WrapEchoHandler("OCIStreaming", h.Handler(), h)) diff --git a/services/ocistreaming/helpers_test.go b/services/ocistreaming/helpers_test.go index ac4064d576..9aaeaf7d7b 100644 --- a/services/ocistreaming/helpers_test.go +++ b/services/ocistreaming/helpers_test.go @@ -8,7 +8,6 @@ import ( "encoding/pem" "fmt" "io" - "net" "net/http" "strings" "sync/atomic" @@ -21,6 +20,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oci "github.com/blackbirdworks/gopherstack/services/ocistreaming" ) @@ -82,20 +82,6 @@ type envOpts struct { register bool } -func freePort(t *testing.T) int { - t.Helper() - - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - defer l.Close() - - tcp, ok := l.Addr().(*net.TCPAddr) - require.True(t, ok) - - return tcp.Port -} - func newEnv(t *testing.T, opts envOpts) *env { t.Helper() @@ -104,7 +90,7 @@ func newEnv(t *testing.T, opts envOpts) *env { be.SetClock(clock.Now) h := oci.NewHandler(be) - h.Port = freePort(t) + h.Port = 0 h.EnforceAuth = opts.enforce require.NoError(t, h.StartWorker(t.Context())) @@ -192,7 +178,7 @@ func rawDo(t *testing.T, method, url, body string) (int, string) { req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ocistreaming/inproc.go b/services/ocistreaming/inproc.go index 653f7dc3a0..1e1630c9ea 100644 --- a/services/ocistreaming/inproc.go +++ b/services/ocistreaming/inproc.go @@ -1,6 +1,7 @@ package ocistreaming import ( + "errors" "sort" "strconv" ) @@ -56,3 +57,30 @@ func (b *InMemoryBackend) PartitionRanges(streamID string) ([]PartitionRange, er return out, nil } + +// ErrAppendFailed is returned when AppendMessage's single message is rejected by the partition. +var ErrAppendFailed = errors.New("ocistreaming: message rejected") + +// AppendMessage puts one message on a live stream, as PutMessages would; key may be empty. +func (b *InMemoryBackend) AppendMessage(streamID string, key, value []byte) error { + res, err := b.PutMessages(streamID, putMessagesIn{Messages: []putEntryIn{{Key: key, Value: value}}}) + if err != nil { + return err + } + + if res.Failures > 0 { + return ErrAppendFailed + } + + return nil +} + +// StreamActive reports whether id is an ACTIVE stream. +func (b *InMemoryBackend) StreamActive(id string) bool { + b.mu.RLock("StreamActive") + defer b.mu.RUnlock() + + _, aerr := b.activeStream(id) + + return aerr == nil +} diff --git a/services/ocivault/bundles.go b/services/ocivault/bundles.go index 3b10195617..00258327c0 100644 --- a/services/ocivault/bundles.go +++ b/services/ocivault/bundles.go @@ -173,3 +173,26 @@ func (b *InMemoryBackend) ListSecretBundleVersions(id string, o ListOpts) (page. return page.Page[bundleVersionSummaryWire]{Data: out, Next: pg.Next}, nil } + +// SecretContent returns the decoded content of the current version of a secret. +func (b *InMemoryBackend) SecretContent(id string) (string, error) { + b.mu.RLock("SecretContent") + defer b.mu.RUnlock() + + s, aerr := b.secretByID(id) + if aerr != nil { + return "", aerr + } + + w, err := b.bundleOf(s, BundleSelector{Stage: "CURRENT"}) + if err != nil { + return "", err + } + + raw, derr := base64.StdEncoding.DecodeString(w.Content.Content) + if derr != nil { + return "", derr + } + + return string(raw), nil +} diff --git a/services/ocivault/handler.go b/services/ocivault/handler.go index 80e3b949e1..8523e4ebdd 100644 --- a/services/ocivault/handler.go +++ b/services/ocivault/handler.go @@ -19,6 +19,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/ociauth" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/pkgs/telemetry" ) @@ -289,13 +290,13 @@ func (h *Handler) adminKeys(q *req) { // StartWorker binds the dedicated listener synchronously so a port conflict is returned to the caller. func (h *Handler) StartWorker(ctx context.Context) error { - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", h.Port)) + listener, port, err := portalloc.ListenTCP(ctx, h.Port) if err != nil { return fmt.Errorf("ocivault: bind port %d: %w", h.Port, err) } + h.Port = port + e := echo.New() e.Use(logger.EchoMiddleware(logger.Load(ctx))) e.Any("/*", telemetry.WrapEchoHandler("OCIVault", h.Handler(), h)) diff --git a/services/ocivault/handler_test.go b/services/ocivault/handler_test.go index ed166817e0..4e5db40ae3 100644 --- a/services/ocivault/handler_test.go +++ b/services/ocivault/handler_test.go @@ -18,6 +18,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/persistence" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oci "github.com/blackbirdworks/gopherstack/services/ocivault" ) @@ -29,7 +30,7 @@ func rawDo(t *testing.T, method, url, body string) (int, string) { req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ocivault/helpers_test.go b/services/ocivault/helpers_test.go index b99cce3e65..d03fb64949 100644 --- a/services/ocivault/helpers_test.go +++ b/services/ocivault/helpers_test.go @@ -7,7 +7,6 @@ import ( "crypto/x509" "encoding/pem" "fmt" - "net" "sync/atomic" "testing" "time" @@ -85,20 +84,6 @@ type envOpts struct { register bool } -func freePort(t *testing.T) int { - t.Helper() - - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - defer l.Close() - - tcp, ok := l.Addr().(*net.TCPAddr) - require.True(t, ok) - - return tcp.Port -} - func newEnv(t *testing.T, opts envOpts) *env { t.Helper() @@ -107,7 +92,7 @@ func newEnv(t *testing.T, opts envOpts) *env { be.SetClock(clock.Now) h := oci.NewHandler(be) - h.Port = freePort(t) + h.Port = 0 h.EnforceAuth = opts.enforce require.NoError(t, h.StartWorker(t.Context())) diff --git a/services/omics/handler.go b/services/omics/handler.go index 6f461c58ac..cb633586ce 100644 --- a/services/omics/handler.go +++ b/services/omics/handler.go @@ -197,6 +197,19 @@ type Handler struct { idem *idempotency.Memo s3 S3ObjectReader Backend StorageBackend + + maxPartBytes int64 +} + +// maxReadSetPartBytes is the documented 100 MiB ceiling of one multipart read-set part. +const maxReadSetPartBytes int64 = 100 << 20 + +func (h *Handler) partLimit() int64 { + if h.maxPartBytes > 0 { + return h.maxPartBytes + } + + return maxReadSetPartBytes } // NewHandler constructs a new Handler. diff --git a/services/omics/handler_read_sets.go b/services/omics/handler_read_sets.go index 3f0e64fecf..0fd9239fab 100644 --- a/services/omics/handler_read_sets.go +++ b/services/omics/handler_read_sets.go @@ -1,11 +1,13 @@ package omics import ( - "io" + "errors" "net/http" "strconv" "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" ) func (h *Handler) handleBatchDeleteReadSet(c *echo.Context, storeID string) error { @@ -392,8 +394,15 @@ func (h *Handler) handleUploadReadSetPart(c *echo.Context, storeID, uploadID str partSource = "SOURCE1" } - data, err := io.ReadAll(c.Request().Body) + data, err := httputils.ReadBodyLimit(c.Request(), h.partLimit()) if err != nil { + if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge { + return c.JSON( + http.StatusBadRequest, + errResp("ValidationException", "part payload exceeds the maximum part size of 100 MiB"), + ) + } + return c.JSON( http.StatusInternalServerError, errResp("InternalServerException", "failed to read request body"), diff --git a/services/omics/iam_enforcement_test.go b/services/omics/iam_enforcement_test.go index 7ad90804b6..4bc940d94a 100644 --- a/services/omics/iam_enforcement_test.go +++ b/services/omics/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/omics" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/omics/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/omics/read_set_part_limit_test.go b/services/omics/read_set_part_limit_test.go new file mode 100644 index 0000000000..951c5f4c80 --- /dev/null +++ b/services/omics/read_set_part_limit_test.go @@ -0,0 +1,49 @@ +package omics //nolint:testpackage // sets the unexported per-part limit. + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUploadReadSetPart_SizeLimit(t *testing.T) { + t.Parallel() + + const limit = 64 + + tests := []struct { + name string + wantMsg string + size int + wantStatus int + }{ + {name: "at limit", size: limit, wantStatus: http.StatusNotFound}, + {name: "over limit", size: limit + 1, wantStatus: http.StatusBadRequest, wantMsg: "maximum part size"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + h := NewHandler(NewInMemoryBackend("000000000000", "us-east-1")) + h.maxPartBytes = limit + + req := httptest.NewRequest( + http.MethodPut, + "/sequencestore/1234567890/upload/missing/part?partNumber=1&partSource=SOURCE1", + bytes.NewReader([]byte(strings.Repeat("a", tc.size))), + ) + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.New().NewContext(req, rec))) + + assert.Equal(t, tc.wantStatus, rec.Code) + assert.Contains(t, rec.Body.String(), tc.wantMsg) + }) + } +} diff --git a/services/opensearch/data_plane_test.go b/services/opensearch/data_plane_test.go index c6de30cfbf..673bafb75e 100644 --- a/services/opensearch/data_plane_test.go +++ b/services/opensearch/data_plane_test.go @@ -19,6 +19,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/endpointstrategy" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/opensearch" ) @@ -70,7 +71,7 @@ func (e *dataPlaneEnv) do(t *testing.T, method, path, body, user, pass string) ( req.SetBasicAuth(user, pass) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/opensearch/iam_enforcement_test.go b/services/opensearch/iam_enforcement_test.go index 1f6ba2510c..e9ca8e7b15 100644 --- a/services/opensearch/iam_enforcement_test.go +++ b/services/opensearch/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/opensearch" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/es/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/opensearch/wire_serverless_tags_leak_test.go b/services/opensearch/wire_serverless_tags_leak_test.go index eebed8d117..406cd8ccf9 100644 --- a/services/opensearch/wire_serverless_tags_leak_test.go +++ b/services/opensearch/wire_serverless_tags_leak_test.go @@ -19,17 +19,19 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/config" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/opensearch" ) // captureTransport records the raw bytes of the last HTTP response, then // replays them so the real SDK deserializer still sees the full body. type captureTransport struct { + rt http.RoundTripper body []byte } func (c *captureTransport) Do(req *http.Request) (*http.Response, error) { - resp, err := http.DefaultTransport.RoundTrip(req) + resp, err := c.rt.RoundTrip(req) if err != nil { return nil, err } @@ -73,7 +75,7 @@ func newCapturingTestServerlessClient( ) require.NoError(t, err) - capture := &captureTransport{} + capture := &captureTransport{rt: testclient.New(t).Transport} client := opensearchserverless.NewFromConfig(cfg, func(o *opensearchserverless.Options) { o.BaseEndpoint = aws.String(srv.URL) diff --git a/services/opsworks/iam_enforcement_test.go b/services/opsworks/iam_enforcement_test.go index 48602ce5ec..d7e3be884c 100644 --- a/services/opsworks/iam_enforcement_test.go +++ b/services/opsworks/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/opsworks" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/opsworks/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/organizations/PARITY.md b/services/organizations/PARITY.md index 415b9ae34b..3d0a083122 100644 --- a/services/organizations/PARITY.md +++ b/services/organizations/PARITY.md @@ -120,9 +120,9 @@ ops: DeregisterDelegatedAdministrator: {wire: ok, errors: ok, state: ok, persist: ok} ListDelegatedAdministrators: {wire: fixed, errors: ok, state: ok, persist: ok, note: "MaxResults field missing from request DTO, results never truncated; added field + wired page.New. 2026-08-30 (ordering pass): the unfiltered branch (ServicePrincipal==\"\") sources from b.delegatedAdmins.All() (store.Table map walk), keyed by ServicePrincipal+AccountID (delegatedAdminKeyFn), NOT by AccountID alone -- so a single account registered as delegated admin for multiple different service principals (RegisterDelegatedAdministrator only rejects a duplicate servicePrincipal+accountID pair, never a repeat AccountID across services) produces multiple DelegatedAdmin rows tied on AccountID under a sort keyed on AccountID alone. Real types.DelegatedAdministrator (organizations@v1.53.5 types/types.go:192) has no ServicePrincipal member, so this can't be proven through the wire response the way ListPolicies' sibling bug can (every row for one account is AccountID-indistinguishable, and the table's total entry count doesn't change with reordering, so a wire-level drop/duplicate count is unaffected either way) -- proven instead via TestListDelegatedAdministratorsOrderIsStableAcrossCalls asserting InMemoryBackend.ListDelegatedAdministrators(\"\")'s own return order (via the exported, wire-excluded DelegatedAdmin.ServicePrincipal field) is identical across repeated calls with nothing changed in between, which page.New's index-based cursor requires and the map-walk source doesn't provide unaided. Fixed by adding ServicePrincipal as a secondary sort key. Hand-reverted and confirmed to fail against unfixed code."} ListDelegatedServicesForAccount: {wire: fixed, errors: ok, state: ok, persist: ok, note: "same gap, fixed the same way"} - AcceptHandshake: {wire: ok, errors: ok, state: ok, persist: ok} - CancelHandshake: {wire: ok, errors: ok, state: ok, persist: ok} - DeclineHandshake: {wire: ok, errors: ok, state: ok, persist: ok} + AcceptHandshake: {wire: ok, errors: fixed, state: ok, persist: ok, note: "2026-10-30 (gopherstack-tnqy.6): only a recipient may accept: the invited account for INVITE, a non-management member account for ENABLE_ALL_FEATURES (api_op_AcceptHandshake.go); others get AccessDeniedException. A handshake no longer OPEN returns HandshakeAlreadyInStateException when already in the requested state, else InvalidHandshakeTransitionException (types/errors.go; was HandshakeConstraintViolationException). Callers with no explicit identity (anonymous default account) stay permitted."} + CancelHandshake: {wire: ok, errors: fixed, state: ok, persist: ok, note: "2026-10-30 (gopherstack-tnqy.6): only the originating (management) account may cancel (api_op_CancelHandshake.go); recipients get AccessDeniedException. A handshake no longer OPEN returns HandshakeAlreadyInStateException when already in the requested state, else InvalidHandshakeTransitionException (types/errors.go; was HandshakeConstraintViolationException). Callers with no explicit identity (anonymous default account) stay permitted."} + DeclineHandshake: {wire: ok, errors: fixed, state: ok, persist: ok, note: "2026-10-30 (gopherstack-tnqy.6): only a recipient may decline (api_op_DeclineHandshake.go); the originator gets AccessDeniedException. A handshake no longer OPEN returns HandshakeAlreadyInStateException when already in the requested state, else InvalidHandshakeTransitionException (types/errors.go; was HandshakeConstraintViolationException). Callers with no explicit identity (anonymous default account) stay permitted."} DescribeHandshake: {wire: ok, errors: ok, state: ok, persist: ok} InviteAccountToOrganization: {wire: ok, errors: ok, state: ok, persist: ok, note: "gopherstack-i8lo (2026-08-22): Target.Type (HandshakeParty, organizations@v1.53.5 types/types.go:420, required alongside Id:415) was decoded but never validated -- only Target.Id was checked. Now rejects a missing Target.Type with InvalidInputException."} LeaveOrganization: {wire: ok, errors: ok, state: ok, persist: ok} @@ -652,3 +652,11 @@ FIXED: UpdatePolicy Description is a pointer end to end. "If provided, the new d ## 2026-10-05 (undeclared response members) Account drops RoleName/IamUserAccessToBilling and EffectivePolicy drops PolicyId; all three are request-only or absent from the pinned SDK output types. State is kept in the backend. + +## 2026-10-31 SCP enforcement support + +- FullAWSAccess is now attached to every OU and account created while SCPs are enabled, and to the root, every OU and every account when the SCP type is (re-)enabled; the User Guide "Service control policies (SCPs)" / "SCP evaluation" pages state it is attached to every root, OU and account. +- DisablePolicyType(SERVICE_CONTROL_POLICY) detaches all SCPs instead of failing with PolicyTypeAttached ("SCP effects on permissions": disabling detaches all SCPs automatically). Other policy types keep the attached check (unverified for them). +- `SCPsForAccount` (scp.go) feeds services/iam enforcement; tests `scp_test.go`. +- DetachPolicy refuses to remove the last SCP from a root/OU/account while SCPs are enabled: ConstraintViolationException, reason MIN_POLICY_TYPE_ATTACHMENT_LIMIT_EXCEEDED (DetachPolicy API reference: "Every root, OU, and account must have at least one SCP attached"; the reason code appears in its ConstraintViolationException list). +- Restore attaches FullAWSAccess to any root/OU/account with no SCP when SCPs are enabled, repairing snapshots from before default attachment (`TestRestoreMigratesMissingDefaultSCPs`). diff --git a/services/organizations/accounts_test.go b/services/organizations/accounts_test.go index ca4b31e091..b963451bb4 100644 --- a/services/organizations/accounts_test.go +++ b/services/organizations/accounts_test.go @@ -846,7 +846,7 @@ func TestRemoveAccount_InvitedCreatesLeaveHandshake(t *testing.T) { }, "", nil) require.NoError(t, err) - _, err = b.AcceptHandshake(h.ID) + _, err = b.AcceptHandshake(t.Context(), h.ID) require.NoError(t, err) // Verify the account is now in the org. diff --git a/services/organizations/create_account_lifecycle.go b/services/organizations/create_account_lifecycle.go index 2916794f99..2a4e5bd6d1 100644 --- a/services/organizations/create_account_lifecycle.go +++ b/services/organizations/create_account_lifecycle.go @@ -81,6 +81,7 @@ func (b *InMemoryBackend) completeCreateLocked(status *CreateAccountStatus, p cr }) b.accountParent[acctID] = b.root.ID b.addAccountChild(b.root.ID, acctID) + b.attachDefaultSCPLocked(acctID) b.setTagsLocked(acctID, p.tags) if b.emailToAccountID == nil { diff --git a/services/organizations/error_messages.go b/services/organizations/error_messages.go index 129eacc8bc..186048ff2a 100644 --- a/services/organizations/error_messages.go +++ b/services/organizations/error_messages.go @@ -64,9 +64,22 @@ func lookupErrorDetails() map[error]errorDetail { message: "We can't find a handshake with the HandshakeId that you specified.", }, ErrHandshakeConstraintViolation: { + message: "The requested operation would violate the constraint identified in the reason.", + }, + ErrHandshakeAlreadyInState: { message: "The specified handshake is already in the requested state. " + "For example, you can't accept a handshake that was already accepted.", }, + ErrInvalidHandshakeTransition: { + message: "You can't perform the operation on the handshake in its current state. " + + "For example, you can't cancel a handshake that was already accepted or accept a " + + "handshake that was already declined.", + }, + ErrAccessDeniedHandshake: { + message: "You don't have permissions to perform the requested operation. " + + "The user or role that is making the request must have at least one IAM permissions policy " + + "attached that grants the required permissions.", + }, } } @@ -102,6 +115,10 @@ func constraintErrorDetails() map[error]errorDetail { ErrDuplicateHandshake: { message: "A handshake for the specified account is already in progress.", }, + ErrMinPolicyAttachment: { + message: "You can't detach the last service control policy from a root, OU, or account.", + reason: "MIN_POLICY_TYPE_ATTACHMENT_LIMIT_EXCEEDED", + }, ErrPolicyTypeAttached: { message: "You can't disable a policy type while policies of that type are attached to targets.", reason: "MIN_POLICY_TYPE_ATTACHMENT_LIMIT_EXCEEDED", diff --git a/services/organizations/errors.go b/services/organizations/errors.go index 41be817c8d..ff6d16d6ae 100644 --- a/services/organizations/errors.go +++ b/services/organizations/errors.go @@ -84,6 +84,11 @@ var ( "ConstraintViolationException: maximum policies per target exceeded", awserr.ErrConflict, ) + // ErrMinPolicyAttachment is returned when detaching would leave a target without any SCP. + ErrMinPolicyAttachment = awserr.New( + "ConstraintViolationException: minimum policies per target", + awserr.ErrConflict, + ) // ErrHandshakeNotFound is returned when a handshake does not exist. ErrHandshakeNotFound = awserr.New( "HandshakeNotFoundException: handshake not found", @@ -94,6 +99,21 @@ var ( "HandshakeConstraintViolationException: handshake is not in a valid state for this transition", awserr.ErrConflict, ) + // ErrHandshakeAlreadyInState is returned when the handshake is already in the requested state. + ErrHandshakeAlreadyInState = awserr.New( + "HandshakeAlreadyInStateException: handshake is already in the requested state", + awserr.ErrConflict, + ) + // ErrInvalidHandshakeTransition is returned when the handshake's current state forbids the operation. + ErrInvalidHandshakeTransition = awserr.New( + "InvalidHandshakeTransitionException: handshake cannot make this transition from its current state", + awserr.ErrConflict, + ) + // ErrAccessDeniedHandshake is returned when the caller is not the party allowed to respond to a handshake. + ErrAccessDeniedHandshake = awserr.New( + "AccessDeniedException: caller is not permitted to act on this handshake", + awserr.ErrConflict, + ) // ErrResourcePolicyNotFound is returned when no resource policy exists. ErrResourcePolicyNotFound = awserr.New( "ResourcePolicyNotFoundException: resource policy not found", diff --git a/services/organizations/handler.go b/services/organizations/handler.go index 87ae4130a1..b765d40a32 100644 --- a/services/organizations/handler.go +++ b/services/organizations/handler.go @@ -2,6 +2,7 @@ package organizations import ( "encoding/json" + "maps" "net/http" "strings" @@ -267,6 +268,24 @@ const errConstraintViolation = "ConstraintViolationException" const errInvalidInput = "InvalidInputException" func getErrorTable() map[error]awserr.APIError { + table := baseErrorTable() + maps.Copy(table, handshakeErrorTable()) + + return table +} + +func handshakeErrorTable() map[error]awserr.APIError { + return map[error]awserr.APIError{ + ErrHandshakeAlreadyInState: {Code: "HandshakeAlreadyInStateException", HTTPStatus: http.StatusBadRequest}, + ErrInvalidHandshakeTransition: { + Code: "InvalidHandshakeTransitionException", + HTTPStatus: http.StatusBadRequest, + }, + ErrAccessDeniedHandshake: {Code: "AccessDeniedException", HTTPStatus: http.StatusBadRequest}, + } +} + +func baseErrorTable() map[error]awserr.APIError { return map[error]awserr.APIError{ ErrOrgNotFound: {Code: "AWSOrganizationsNotInUseException", HTTPStatus: http.StatusBadRequest}, ErrOrgAlreadyExists: {Code: "AlreadyInOrganizationException", HTTPStatus: http.StatusBadRequest}, @@ -322,6 +341,7 @@ func getErrorTable() map[error]awserr.APIError { ErrOrganizationNotEmpty: {Code: "OrganizationNotEmptyException", HTTPStatus: http.StatusBadRequest}, ErrDuplicateHandshake: {Code: "DuplicateHandshakeException", HTTPStatus: http.StatusBadRequest}, ErrPolicyTypeAttached: {Code: errConstraintViolation, HTTPStatus: http.StatusBadRequest}, + ErrMinPolicyAttachment: {Code: errConstraintViolation, HTTPStatus: http.StatusBadRequest}, ErrMalformedPolicyDocument: { Code: "MalformedPolicyDocumentException", HTTPStatus: http.StatusBadRequest, diff --git a/services/organizations/handler_handshakes.go b/services/organizations/handler_handshakes.go index 90d7670d5d..16e5f71102 100644 --- a/services/organizations/handler_handshakes.go +++ b/services/organizations/handler_handshakes.go @@ -282,7 +282,7 @@ func (h *Handler) handleAcceptHandshake(c *echo.Context, body []byte) error { return h.writeError(c, http.StatusBadRequest, "InvalidInputException", "HandshakeId is required") } - hs, err := h.Backend.AcceptHandshake(req.HandshakeID) + hs, err := h.Backend.AcceptHandshake(c.Request().Context(), req.HandshakeID) if err != nil { return h.handleBackendError(c, err) } @@ -300,7 +300,7 @@ func (h *Handler) handleCancelHandshake(c *echo.Context, body []byte) error { return h.writeError(c, http.StatusBadRequest, "InvalidInputException", "HandshakeId is required") } - hs, err := h.Backend.CancelHandshake(req.HandshakeID) + hs, err := h.Backend.CancelHandshake(c.Request().Context(), req.HandshakeID) if err != nil { return h.handleBackendError(c, err) } @@ -318,7 +318,7 @@ func (h *Handler) handleDeclineHandshake(c *echo.Context, body []byte) error { return h.writeError(c, http.StatusBadRequest, "InvalidInputException", "HandshakeId is required") } - hs, err := h.Backend.DeclineHandshake(req.HandshakeID) + hs, err := h.Backend.DeclineHandshake(c.Request().Context(), req.HandshakeID) if err != nil { return h.handleBackendError(c, err) } diff --git a/services/organizations/handshake_authz.go b/services/organizations/handshake_authz.go new file mode 100644 index 0000000000..d0713eb54d --- /dev/null +++ b/services/organizations/handshake_authz.go @@ -0,0 +1,76 @@ +package organizations + +import ( + "context" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" +) + +const handshakeTargetEmail = "EMAIL" + +// handshakeStateError classifies a response to a handshake no longer OPEN: +// already in the requested state vs an invalid transition (types/errors.go). +func handshakeStateError(current, requested string) error { + if current == requested { + return ErrHandshakeAlreadyInState + } + + return ErrInvalidHandshakeTransition +} + +// authorizeHandshakeCaller enforces who may act on h: only the originator +// cancels; only a recipient accepts or declines (api_op_CancelHandshake.go, +// api_op_DeclineHandshake.go, api_op_AcceptHandshake.go). A request with no +// explicit caller identity is permitted. Must be called with b.mu held. +func (b *InMemoryBackend) authorizeHandshakeCaller(ctx context.Context, h *Handshake, asOriginator bool) error { + caller, ok := awsmeta.CallerAccount(ctx) + if !ok || len(h.Parties) == 0 { + return nil + } + + originator := h.Parties[0].ID + allowed := caller == originator + + if !asOriginator { + allowed = caller != originator && b.isHandshakeRecipient(h, caller) + + if h.Action == handshakeActionEnableFeatures { + allowed = caller != originator && b.accounts.Has(caller) + } + } + + if allowed { + return nil + } + + return fmt.Errorf("%w: account %s", ErrAccessDeniedHandshake, caller) +} + +// isHandshakeRecipient matches caller against the non-originator parties; an +// EMAIL or ORGANIZATION party the backend cannot map to an account admits any +// account that is not a known member. +func (b *InMemoryBackend) isHandshakeRecipient(h *Handshake, caller string) bool { + for _, p := range h.Parties[1:] { + switch p.Type { + case targetTypeAccount: + if p.ID == caller { + return true + } + case handshakeTargetEmail: + if acct, ok := b.accounts.Get(caller); ok { + if acct.Email == p.ID { + return true + } + + continue + } + + return true + default: + return !b.accounts.Has(caller) + } + } + + return false +} diff --git a/services/organizations/handshake_caller_identity_test.go b/services/organizations/handshake_caller_identity_test.go new file mode 100644 index 0000000000..f1021d29ae --- /dev/null +++ b/services/organizations/handshake_caller_identity_test.go @@ -0,0 +1,288 @@ +package organizations_test + +import ( + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + organizationssdk "github.com/aws/aws-sdk-go-v2/service/organizations" + organizationstypes "github.com/aws/aws-sdk-go-v2/service/organizations/types" + smithy "github.com/aws/smithy-go" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/organizations" +) + +const ( + hsMgmtAcct = "000000000000" + hsInviteeAcct = "111111111111" + hsStrangerAcct = "333333333333" +) + +type hsStack struct { + clients map[string]*organizationssdk.Client +} + +func newHandshakeStack(t *testing.T) *hsStack { + t.Helper() + + principals := map[string]*awsmeta.Principal{ + "mgmt": {Kind: awsmeta.PrincipalKindUser, AccountID: hsMgmtAcct, Arn: "arn:aws:iam::000000000000:user/m"}, + "invitee": { + Kind: awsmeta.PrincipalKindUser, + AccountID: hsInviteeAcct, + Arn: "arn:aws:iam::111111111111:user/i", + }, + "stranger": { + Kind: awsmeta.PrincipalKindUser, + AccountID: hsStrangerAcct, + Arn: "arn:aws:iam::333333333333:user/s", + }, + "anon": nil, + } + + e := echo.New() + e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + meta := awsmeta.FromRequest(req, tagsRTRegion) + if p := principals[meta.AccessKeyID]; p != nil { + meta.Principal = p + meta.Account = p.AccountID + } + + c.SetRequest(req.WithContext(awsmeta.Set(req.Context(), meta))) + + return next(c) + } + }) + + registry := service.NewRegistry() + require.NoError( + t, + registry.Register(organizations.NewHandler(organizations.NewInMemoryBackend(hsMgmtAcct, tagsRTRegion))), + ) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + s := &hsStack{clients: map[string]*organizationssdk.Client{}} + + for name := range principals { + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion(tagsRTRegion), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(name, "x", "")), + ) + require.NoError(t, err) + + s.clients[name] = organizationssdk.NewFromConfig(cfg, func(o *organizationssdk.Options) { + o.BaseEndpoint = aws.String(srv.URL) + }) + } + + _, err := s.clients["mgmt"].CreateOrganization(t.Context(), &organizationssdk.CreateOrganizationInput{}) + require.NoError(t, err) + + return s +} + +func (s *hsStack) invite(t *testing.T) string { + t.Helper() + + out, err := s.clients["mgmt"].InviteAccountToOrganization( + t.Context(), + &organizationssdk.InviteAccountToOrganizationInput{ + Target: &organizationstypes.HandshakeParty{ + Id: aws.String(hsInviteeAcct), + Type: organizationstypes.HandshakePartyTypeAccount, + }, + }, + ) + require.NoError(t, err) + + return aws.ToString(out.Handshake.Id) +} + +func requireAPIErrorCode(t *testing.T, err error, code string) { + t.Helper() + require.Error(t, err) + + var apiErr smithy.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, code, apiErr.ErrorCode()) +} + +func (s *hsStack) respond(t *testing.T, op, caller, id string) error { + t.Helper() + + var err error + + switch op { + case "accept": + _, err = s.clients[caller].AcceptHandshake( + t.Context(), + &organizationssdk.AcceptHandshakeInput{HandshakeId: aws.String(id)}, + ) + case "decline": + _, err = s.clients[caller].DeclineHandshake( + t.Context(), + &organizationssdk.DeclineHandshakeInput{HandshakeId: aws.String(id)}, + ) + default: + _, err = s.clients[caller].CancelHandshake( + t.Context(), + &organizationssdk.CancelHandshakeInput{HandshakeId: aws.String(id)}, + ) + } + + return err +} + +func TestHandshakeCallerIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + op string + caller string + wantCode string + }{ + {name: "accept by target", op: "accept", caller: "invitee"}, + {name: "accept by originator", op: "accept", caller: "mgmt", wantCode: "AccessDeniedException"}, + {name: "accept by stranger", op: "accept", caller: "stranger", wantCode: "AccessDeniedException"}, + {name: "accept anonymous", op: "accept", caller: "anon"}, + {name: "decline by target", op: "decline", caller: "invitee"}, + {name: "decline by originator", op: "decline", caller: "mgmt", wantCode: "AccessDeniedException"}, + {name: "decline by stranger", op: "decline", caller: "stranger", wantCode: "AccessDeniedException"}, + {name: "cancel by originator", op: "cancel", caller: "mgmt"}, + {name: "cancel by target", op: "cancel", caller: "invitee", wantCode: "AccessDeniedException"}, + {name: "cancel by stranger", op: "cancel", caller: "stranger", wantCode: "AccessDeniedException"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newHandshakeStack(t) + id := s.invite(t) + + err := s.respond(t, tt.op, tt.caller, id) + if tt.wantCode != "" { + requireAPIErrorCode(t, err, tt.wantCode) + + got, derr := s.clients["mgmt"].DescribeHandshake( + t.Context(), + &organizationssdk.DescribeHandshakeInput{HandshakeId: aws.String(id)}, + ) + require.NoError(t, derr) + assert.Equal(t, organizationstypes.HandshakeStateOpen, got.Handshake.State) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestHandshakeTransitionErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + firstOp string + firstBy string + secondOp string + secondBy string + wantCode string + unknownID bool + }{ + { + name: "accept twice", firstOp: "accept", firstBy: "invitee", secondOp: "accept", secondBy: "invitee", + wantCode: "HandshakeAlreadyInStateException", + }, + { + name: "decline after accept", firstOp: "accept", firstBy: "invitee", + secondOp: "decline", secondBy: "invitee", + wantCode: "InvalidHandshakeTransitionException", + }, + { + name: "cancel after accept", firstOp: "accept", firstBy: "invitee", secondOp: "cancel", secondBy: "mgmt", + wantCode: "InvalidHandshakeTransitionException", + }, + { + name: "accept after cancel", firstOp: "cancel", firstBy: "mgmt", secondOp: "accept", secondBy: "invitee", + wantCode: "InvalidHandshakeTransitionException", + }, + { + name: "cancel twice", firstOp: "cancel", firstBy: "mgmt", secondOp: "cancel", secondBy: "mgmt", + wantCode: "HandshakeAlreadyInStateException", + }, + { + name: "unknown handshake", + secondOp: "accept", + secondBy: "invitee", + wantCode: "HandshakeNotFoundException", + unknownID: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newHandshakeStack(t) + id := s.invite(t) + + if tt.unknownID { + id = "h-doesnotexist" + } else { + require.NoError(t, s.respond(t, tt.firstOp, tt.firstBy, id)) + } + + requireAPIErrorCode(t, s.respond(t, tt.secondOp, tt.secondBy, id), tt.wantCode) + }) + } +} + +func TestEnableAllFeaturesHandshakeCaller(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caller string + wantCode string + }{ + {name: "member accepts", caller: "invitee"}, + {name: "management cannot accept", caller: "mgmt", wantCode: "AccessDeniedException"}, + {name: "non member cannot accept", caller: "stranger", wantCode: "AccessDeniedException"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newHandshakeStack(t) + require.NoError(t, s.respond(t, "accept", "invitee", s.invite(t))) + + out, err := s.clients["mgmt"].EnableAllFeatures(t.Context(), &organizationssdk.EnableAllFeaturesInput{}) + require.NoError(t, err) + + err = s.respond(t, "accept", tt.caller, aws.ToString(out.Handshake.Id)) + if tt.wantCode != "" { + requireAPIErrorCode(t, err, tt.wantCode) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/services/organizations/handshake_expiry_test.go b/services/organizations/handshake_expiry_test.go index 7c5b02f8a7..bc6fd2baa3 100644 --- a/services/organizations/handshake_expiry_test.go +++ b/services/organizations/handshake_expiry_test.go @@ -35,7 +35,7 @@ func TestCancelHandshake_EvictedAfterRetention(t *testing.T) { ) require.NoError(t, err) - _, err = b.CancelHandshake(h.ID) + _, err = b.CancelHandshake(t.Context(), h.ID) require.NoError(t, err) require.Equal(t, 1, organizations.HandshakeCount(b), @@ -70,7 +70,7 @@ func TestCancelHandshake_KeptWithinRetention(t *testing.T) { ) require.NoError(t, err) - _, err = b.CancelHandshake(h.ID) + _, err = b.CancelHandshake(t.Context(), h.ID) require.NoError(t, err) time.Sleep(30*24*time.Hour - time.Second) diff --git a/services/organizations/handshakes.go b/services/organizations/handshakes.go index d66061bfd9..e28f726bb8 100644 --- a/services/organizations/handshakes.go +++ b/services/organizations/handshakes.go @@ -2,6 +2,7 @@ package organizations import ( "cmp" + "context" "slices" "strconv" "strings" @@ -68,7 +69,7 @@ const ( // AcceptHandshake accepts an OPEN handshake. // For INVITE handshakes, the invited account is added to the organization. -func (b *InMemoryBackend) AcceptHandshake(handshakeID string) (*Handshake, error) { +func (b *InMemoryBackend) AcceptHandshake(ctx context.Context, handshakeID string) (*Handshake, error) { b.mu.Lock("AcceptHandshake") defer b.mu.Unlock() @@ -79,8 +80,12 @@ func (b *InMemoryBackend) AcceptHandshake(handshakeID string) (*Handshake, error return nil, ErrHandshakeNotFound } + if err := b.authorizeHandshakeCaller(ctx, h, false); err != nil { + return nil, err + } + if h.State != handshakeStateOpen { - return nil, ErrHandshakeConstraintViolation + return nil, handshakeStateError(h.State, handshakeStateAccepted) } h.State = handshakeStateAccepted @@ -109,6 +114,7 @@ func (b *InMemoryBackend) AcceptHandshake(handshakeID string) (*Handshake, error b.accounts.Put(acct) b.accountParent[acctID] = b.root.ID b.addAccountChild(b.root.ID, acctID) + b.attachDefaultSCPLocked(acctID) b.setTagsLocked(acctID, h.PendingTags) } @@ -121,7 +127,7 @@ func (b *InMemoryBackend) AcceptHandshake(handshakeID string) (*Handshake, error } // CancelHandshake cancels an OPEN handshake. -func (b *InMemoryBackend) CancelHandshake(handshakeID string) (*Handshake, error) { +func (b *InMemoryBackend) CancelHandshake(ctx context.Context, handshakeID string) (*Handshake, error) { b.mu.Lock("CancelHandshake") defer b.mu.Unlock() @@ -132,8 +138,12 @@ func (b *InMemoryBackend) CancelHandshake(handshakeID string) (*Handshake, error return nil, ErrHandshakeNotFound } + if err := b.authorizeHandshakeCaller(ctx, h, true); err != nil { + return nil, err + } + if h.State != handshakeStateOpen { - return nil, ErrHandshakeConstraintViolation + return nil, handshakeStateError(h.State, handshakeStateCanceled) } h.State = handshakeStateCanceled @@ -144,7 +154,7 @@ func (b *InMemoryBackend) CancelHandshake(handshakeID string) (*Handshake, error } // DeclineHandshake declines an OPEN handshake. -func (b *InMemoryBackend) DeclineHandshake(handshakeID string) (*Handshake, error) { +func (b *InMemoryBackend) DeclineHandshake(ctx context.Context, handshakeID string) (*Handshake, error) { b.mu.Lock("DeclineHandshake") defer b.mu.Unlock() @@ -155,8 +165,12 @@ func (b *InMemoryBackend) DeclineHandshake(handshakeID string) (*Handshake, erro return nil, ErrHandshakeNotFound } + if err := b.authorizeHandshakeCaller(ctx, h, false); err != nil { + return nil, err + } + if h.State != handshakeStateOpen { - return nil, ErrHandshakeConstraintViolation + return nil, handshakeStateError(h.State, handshakeStateDeclined) } h.State = handshakeStateDeclined diff --git a/services/organizations/handshakes_test.go b/services/organizations/handshakes_test.go index 4f2f4a9b52..e71fc0aebc 100644 --- a/services/organizations/handshakes_test.go +++ b/services/organizations/handshakes_test.go @@ -104,7 +104,7 @@ func TestAcceptHandshake_InviteAddsAccount(t *testing.T) { require.NoError(t, err) assert.Equal(t, "OPEN", hs.State) - accepted, err := b.AcceptHandshake(hs.ID) + accepted, err := b.AcceptHandshake(t.Context(), hs.ID) require.NoError(t, err) assert.Equal(t, "ACCEPTED", accepted.State) @@ -180,11 +180,11 @@ func TestAcceptHandshake_StateTransitions(t *testing.T) { switch tt.transition { case "accept": - result, transErr = b.AcceptHandshake(hsID) + result, transErr = b.AcceptHandshake(t.Context(), hsID) case "cancel": - result, transErr = b.CancelHandshake(hsID) + result, transErr = b.CancelHandshake(t.Context(), hsID) case "decline": - result, transErr = b.DeclineHandshake(hsID) + result, transErr = b.DeclineHandshake(t.Context(), hsID) } if tt.wantErr { @@ -344,11 +344,11 @@ func TestInviteHandshake_StateMachine(t *testing.T) { switch tt.action { case "accept": - result, transErr = b.AcceptHandshake(hs.ID) + result, transErr = b.AcceptHandshake(t.Context(), hs.ID) case "decline": - result, transErr = b.DeclineHandshake(hs.ID) + result, transErr = b.DeclineHandshake(t.Context(), hs.ID) case "cancel": - result, transErr = b.CancelHandshake(hs.ID) + result, transErr = b.CancelHandshake(t.Context(), hs.ID) } require.NoError(t, transErr) @@ -463,15 +463,15 @@ func TestInviteHandshake_DuplicateAcceptFails(t *testing.T) { applyOp := func(op, hsID string) error { switch op { case "accept": - _, e := b.AcceptHandshake(hsID) + _, e := b.AcceptHandshake(t.Context(), hsID) return e case "decline": - _, e := b.DeclineHandshake(hsID) + _, e := b.DeclineHandshake(t.Context(), hsID) return e case "cancel": - _, e := b.CancelHandshake(hsID) + _, e := b.CancelHandshake(t.Context(), hsID) return e } @@ -722,7 +722,7 @@ func TestInviteAccount_AfterCancel_OK(t *testing.T) { h1, err := b.InviteAccountToOrganization(target, "", nil) require.NoError(t, err) - _, err = b.CancelHandshake(h1.ID) + _, err = b.CancelHandshake(t.Context(), h1.ID) require.NoError(t, err) _, err = b.InviteAccountToOrganization(target, "re-invite after cancel", nil) @@ -845,11 +845,11 @@ func TestBackend_HandshakeOperations(t *testing.T) { case "DescribeHandshake": result, err = b.DescribeHandshake(tt.id) case "AcceptHandshake": - result, err = b.AcceptHandshake(tt.id) + result, err = b.AcceptHandshake(t.Context(), tt.id) case "CancelHandshake": - result, err = b.CancelHandshake(tt.id) + result, err = b.CancelHandshake(t.Context(), tt.id) case "DeclineHandshake": - result, err = b.DeclineHandshake(tt.id) + result, err = b.DeclineHandshake(t.Context(), tt.id) } if tt.wantErr { @@ -1009,7 +1009,7 @@ func TestBackend_ResponsibilityTransfer_Lifecycle(t *testing.T) { require.Len(t, outbound, 1) assert.Equal(t, "REQUESTED", outbound[0].Status) - _, err = b.AcceptHandshake(hs.ID) + _, err = b.AcceptHandshake(t.Context(), hs.ID) require.NoError(t, err) result, err := b.DescribeResponsibilityTransfer(outbound[0].ID) diff --git a/services/organizations/iam_enforcement_test.go b/services/organizations/iam_enforcement_test.go index 727f297dc9..40ccdb1840 100644 --- a/services/organizations/iam_enforcement_test.go +++ b/services/organizations/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/organizations" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/organizations/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/organizations/interfaces.go b/services/organizations/interfaces.go index ecf6dd7d07..1e01560c20 100644 --- a/services/organizations/interfaces.go +++ b/services/organizations/interfaces.go @@ -69,9 +69,9 @@ type StorageBackend interface { ListDelegatedAdministrators(servicePrincipal string) ([]*DelegatedAdmin, error) // Handshake operations - AcceptHandshake(handshakeID string) (*Handshake, error) - CancelHandshake(handshakeID string) (*Handshake, error) - DeclineHandshake(handshakeID string) (*Handshake, error) + AcceptHandshake(ctx context.Context, handshakeID string) (*Handshake, error) + CancelHandshake(ctx context.Context, handshakeID string) (*Handshake, error) + DeclineHandshake(ctx context.Context, handshakeID string) (*Handshake, error) DescribeHandshake(handshakeID string) (*Handshake, error) DescribeResponsibilityTransfer(transferID string) (*ResponsibilityTransfer, error) EnableAllFeatures() (*Handshake, error) diff --git a/services/organizations/organization_test.go b/services/organizations/organization_test.go index acc7826ef5..e87134f1ae 100644 --- a/services/organizations/organization_test.go +++ b/services/organizations/organization_test.go @@ -453,10 +453,6 @@ func TestDescribeOrganization_AllTypesAfterEnableDisable(t *testing.T) { _, err := b.EnablePolicyType(rootID, "TAG_POLICY") require.NoError(t, err) - // The default FullAWSAccess SCP is attached to root; detach it so - // disabling SCP isn't rejected by the still-attached guard. - require.NoError(t, b.DetachPolicy("p-FullAWSAccess", rootID)) - // Disable one. _, err = b.DisablePolicyType(rootID, "SERVICE_CONTROL_POLICY") require.NoError(t, err) diff --git a/services/organizations/organizational_units.go b/services/organizations/organizational_units.go index e78cf0a092..c93e1e763b 100644 --- a/services/organizations/organizational_units.go +++ b/services/organizations/organizational_units.go @@ -81,6 +81,7 @@ func (b *InMemoryBackend) CreateOrganizationalUnit( } b.ousByParent[parentID][name] = ouID b.setTagsLocked(ouID, tags) + b.attachDefaultSCPLocked(ouID) ou.Path = b.ouPathLocked(ou) return ou, nil diff --git a/services/organizations/persistence.go b/services/organizations/persistence.go index 84e76a4e1b..f55a4e9068 100644 --- a/services/organizations/persistence.go +++ b/services/organizations/persistence.go @@ -221,6 +221,7 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { b.accountCounter = snap.AccountCounter b.statusCounter = snap.StatusCounter b.failInterruptedCreatesLocked(time.Now()) + b.attachMissingDefaultSCPsLocked() return nil } diff --git a/services/organizations/policies.go b/services/organizations/policies.go index 4aa6b4cf5f..eb307ce6f2 100644 --- a/services/organizations/policies.go +++ b/services/organizations/policies.go @@ -332,6 +332,10 @@ func (b *InMemoryBackend) EnablePolicyType(rootID, policyType string) (*Root, er Status: policyStatusEnabled, }) + if policyType == policyTypeSCP { + b.attachDefaultSCPEverywhereLocked() + } + return copyRoot(b.root), nil } @@ -370,6 +374,10 @@ func (b *InMemoryBackend) DisablePolicyType(rootID, policyType string) (*Root, e return nil, ErrPolicyTypeNotEnabled } + if policyType == policyTypeSCP { + b.detachAllSCPsLocked() + } + // AWS rejects disabling a policy type when policies of that type are still attached to any target. for policyID, targets := range b.policyTargets { if len(targets) > 0 { diff --git a/services/organizations/policies_test.go b/services/organizations/policies_test.go index 679d1f6531..4d6b301258 100644 --- a/services/organizations/policies_test.go +++ b/services/organizations/policies_test.go @@ -609,20 +609,50 @@ func TestDeletePolicy_Succeeds(t *testing.T) { // DeleteOrganization: OrganizationNotEmptyException when member accounts exist // --------------------------------------------------------------------------- -// TestDisablePolicyType_WithAttached_Rejected verifies that disabling -// a policy type while policies of that type are attached returns ConstraintViolationException. -func TestDisablePolicyType_WithAttached_Rejected(t *testing.T) { +// TestDisablePolicyType_WithAttached verifies that disabling the SCP type detaches +// every SCP (Organizations User Guide, "SCP effects on permissions"), while other +// types are still rejected while attached. +func TestDisablePolicyType_WithAttached(t *testing.T) { t.Parallel() - b, rootID := newOrgBackend(t) + tests := []struct { + name string + policyType string + wantErr bool + }{ + {name: "scp_auto_detached", policyType: "SERVICE_CONTROL_POLICY"}, + {name: "tag_policy_rejected", policyType: "TAG_POLICY", wantErr: true}, + } - p, err := b.CreatePolicy("scp", "", `{"Version":"2012-10-17"}`, "SERVICE_CONTROL_POLICY", nil) - require.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - require.NoError(t, b.AttachPolicy(p.PolicySummary.ID, rootID)) + b, rootID := newOrgBackend(t) - _, err = b.DisablePolicyType(rootID, "SERVICE_CONTROL_POLICY") - require.Error(t, err, "DisablePolicyType must fail while policies of that type are attached") + if tt.policyType != "SERVICE_CONTROL_POLICY" { + _, err := b.EnablePolicyType(rootID, tt.policyType) + require.NoError(t, err) + } + + p, err := b.CreatePolicy("p", "", `{"Version":"2012-10-17"}`, tt.policyType, nil) + require.NoError(t, err) + require.NoError(t, b.AttachPolicy(p.PolicySummary.ID, rootID)) + + _, err = b.DisablePolicyType(rootID, tt.policyType) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + + attached, err := b.ListPoliciesForTarget(rootID, tt.policyType) + require.NoError(t, err) + assert.Empty(t, attached) + }) + } } // TestEnablePolicyType_UnknownType_Rejected verifies EnablePolicyType rejects @@ -699,10 +729,6 @@ func TestDisablePolicyType_AfterDetach_OK(t *testing.T) { require.NoError(t, b.AttachPolicy(p.PolicySummary.ID, rootID)) require.NoError(t, b.DetachPolicy(p.PolicySummary.ID, rootID)) - // The default FullAWSAccess SCP is also attached to root; detach it too - // so disabling isn't rejected by the still-attached guard. - require.NoError(t, b.DetachPolicy("p-FullAWSAccess", rootID)) - _, err = b.DisablePolicyType(rootID, "SERVICE_CONTROL_POLICY") require.NoError(t, err) } @@ -807,14 +833,6 @@ func TestHandler_DisablePolicyType(t *testing.T) { "PolicyType": "SERVICE_CONTROL_POLICY", }) - // The default FullAWSAccess SCP is attached to root; detach it so - // disabling isn't rejected by the still-attached guard. - detachRec := doRequest(t, h, "DetachPolicy", map[string]any{ - "PolicyId": "p-FullAWSAccess", - "TargetId": rootID, - }) - require.Equal(t, http.StatusOK, detachRec.Code) - rec := doRequest(t, h, "DisablePolicyType", map[string]any{ "RootId": rootID, "PolicyType": "SERVICE_CONTROL_POLICY", diff --git a/services/organizations/policy_attachments.go b/services/organizations/policy_attachments.go index 6eead7a7f0..d950da7b58 100644 --- a/services/organizations/policy_attachments.go +++ b/services/organizations/policy_attachments.go @@ -63,6 +63,10 @@ func (b *InMemoryBackend) DetachPolicy(policyID, targetID string) error { return ErrPolicyNotAttached } + if b.lastSCPLocked(policyID, targetID) { + return ErrMinPolicyAttachment + } + b.policyTargets[policyID] = removeString(targets, targetID) b.targetPolicies[targetID] = removeString(b.targetPolicies[targetID], policyID) diff --git a/services/organizations/scp.go b/services/organizations/scp.go new file mode 100644 index 0000000000..ed0b96d01e --- /dev/null +++ b/services/organizations/scp.go @@ -0,0 +1,149 @@ +package organizations + +import ( + "slices" + + "github.com/blackbirdworks/gopherstack/services/iam" +) + +// SCPsForAccount implements iam.SCPProvider. Levels are root-first, then each +// OU on the path, then the account itself. +func (b *InMemoryBackend) SCPsForAccount(accountID string) ([]iam.SCPLevel, bool) { + b.mu.RLock("SCPsForAccount") + defer b.mu.RUnlock() + + if b.org == nil || b.root == nil || accountID == b.org.MasterAccountID || !b.scpEnabledLocked() { + return nil, false + } + + if !b.accounts.Has(accountID) { + return nil, false + } + + parent, ok := b.accountParent[accountID] + if !ok { + return nil, false + } + + ous, ok := b.ancestorOUChainLocked(parent) + if !ok { + return nil, false + } + + path := append([]string{b.root.ID}, ous...) + path = append(path, accountID) + + levels := make([]iam.SCPLevel, 0, len(path)) + + for _, target := range path { + lvl := iam.SCPLevel{TargetID: target} + + for _, pid := range b.targetPolicies[target] { + if p, found := b.policies.Get(pid); found && p.PolicySummary.Type == policyTypeSCP { + lvl.Policies = append(lvl.Policies, iam.SCPPolicy{ARN: p.PolicySummary.ARN, Document: p.Content}) + } + } + + levels = append(levels, lvl) + } + + return levels, true +} + +func (b *InMemoryBackend) scpEnabledLocked() bool { + return slices.ContainsFunc(b.root.PolicyTypes, func(pt PolicyTypeSummary) bool { + return pt.Type == policyTypeSCP && pt.Status == policyStatusEnabled + }) +} + +// attachDefaultSCPLocked attaches FullAWSAccess to a new OU or account, as +// Organizations does for every entity created while SCPs are enabled. +func (b *InMemoryBackend) attachDefaultSCPLocked(targetID string) { + if b.root == nil || !b.scpEnabledLocked() || !b.policies.Has(fullAWSAccessPolicyID) { + return + } + + if slices.Contains(b.targetPolicies[targetID], fullAWSAccessPolicyID) { + return + } + + b.targetPolicies[targetID] = append(b.targetPolicies[targetID], fullAWSAccessPolicyID) + b.policyTargets[fullAWSAccessPolicyID] = append(b.policyTargets[fullAWSAccessPolicyID], targetID) +} + +// attachDefaultSCPEverywhereLocked attaches FullAWSAccess to the root, every OU and every account. +func (b *InMemoryBackend) attachDefaultSCPEverywhereLocked() { + b.attachDefaultSCPLocked(b.root.ID) + + for _, ou := range b.ous.All() { + b.attachDefaultSCPLocked(ou.ID) + } + + for _, a := range b.accounts.All() { + b.attachDefaultSCPLocked(a.ID) + } +} + +// detachAllSCPsLocked removes every SCP attachment: disabling the SCP policy +// type detaches all SCPs (Organizations User Guide, "SCP effects on permissions"). +func (b *InMemoryBackend) detachAllSCPsLocked() { + for _, p := range b.policies.All() { + if p.PolicySummary.Type != policyTypeSCP { + continue + } + + for _, target := range b.policyTargets[p.PolicySummary.ID] { + b.targetPolicies[target] = removeString(b.targetPolicies[target], p.PolicySummary.ID) + } + + b.policyTargets[p.PolicySummary.ID] = []string{} + } +} + +// scpCountLocked counts the SCPs attached to targetID. +func (b *InMemoryBackend) scpCountLocked(targetID string) int { + n := 0 + + for _, pid := range b.targetPolicies[targetID] { + if p, ok := b.policies.Get(pid); ok && p.PolicySummary.Type == policyTypeSCP { + n++ + } + } + + return n +} + +// lastSCPLocked reports whether detaching policyID from targetID would leave an +// enabled-SCP target with none (DetachPolicy API reference: every root, OU and +// account must have at least one SCP; reason MIN_POLICY_TYPE_ATTACHMENT_LIMIT_EXCEEDED). +func (b *InMemoryBackend) lastSCPLocked(policyID, targetID string) bool { + p, ok := b.policies.Get(policyID) + if !ok || p.PolicySummary.Type != policyTypeSCP || b.root == nil || !b.scpEnabledLocked() { + return false + } + + return b.scpCountLocked(targetID) <= 1 +} + +// attachMissingDefaultSCPsLocked repairs restored state in which an entity has +// no SCP while the SCP type is enabled, which would deny everything under enforcement. +func (b *InMemoryBackend) attachMissingDefaultSCPsLocked() { + if b.root == nil || !b.scpEnabledLocked() { + return + } + + targets := []string{b.root.ID} + for _, ou := range b.ous.All() { + targets = append(targets, ou.ID) + } + + for _, a := range b.accounts.All() { + targets = append(targets, a.ID) + } + + for _, t := range targets { + if b.scpCountLocked(t) == 0 { + b.attachDefaultSCPLocked(t) + } + } +} diff --git a/services/organizations/scp_test.go b/services/organizations/scp_test.go new file mode 100644 index 0000000000..21c0fc92c8 --- /dev/null +++ b/services/organizations/scp_test.go @@ -0,0 +1,192 @@ +package organizations_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/iam" + "github.com/blackbirdworks/gopherstack/services/organizations" +) + +type scpOrg struct { + b *organizations.InMemoryBackend + rootID string + ouID string + childID string + acctID string +} + +func newSCPOrg(t *testing.T) scpOrg { + t.Helper() + + b := organizations.NewInMemoryBackend("000000000000", "us-east-1") + _, root, err := b.CreateOrganization("ALL") + require.NoError(t, err) + + ou, err := b.CreateOrganizationalUnit(root.ID, "prod", nil) + require.NoError(t, err) + + child, err := b.CreateOrganizationalUnit(ou.ID, "web", nil) + require.NoError(t, err) + + st, err := b.CreateAccount("member", "m@example.com", "", "", nil) + require.NoError(t, err) + require.NoError(t, b.MoveAccount(st.AccountID, root.ID, child.ID)) + + return scpOrg{b: b, rootID: root.ID, ouID: ou.ID, childID: child.ID, acctID: st.AccountID} +} + +func TestSCPsForAccount(t *testing.T) { + t.Parallel() + + tests := []struct { + mutate func(t *testing.T, o scpOrg) string + name string + wantTarget []string + wantOK bool + }{ + { + name: "member_path_root_first", + mutate: func(_ *testing.T, o scpOrg) string { return o.acctID }, + wantOK: true, + }, + { + name: "management_account_exempt", + mutate: func(_ *testing.T, _ scpOrg) string { return "000000000000" }, + }, + { + name: "unknown_account", + mutate: func(_ *testing.T, _ scpOrg) string { return "999999999999" }, + }, + { + name: "type_disabled", + mutate: func(t *testing.T, o scpOrg) string { + t.Helper() + + _, err := o.b.DisablePolicyType(o.rootID, "SERVICE_CONTROL_POLICY") + require.NoError(t, err) + + return o.acctID + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + o := newSCPOrg(t) + acct := tt.mutate(t, o) + + levels, ok := o.b.SCPsForAccount(acct) + require.Equal(t, tt.wantOK, ok) + + if !tt.wantOK { + return + } + + require.Len(t, levels, 4) + assert.Equal(t, []string{o.rootID, o.ouID, o.childID, o.acctID}, + []string{levels[0].TargetID, levels[1].TargetID, levels[2].TargetID, levels[3].TargetID}) + + for _, lvl := range levels { + require.Len(t, lvl.Policies, 1, lvl.TargetID) + assert.Contains(t, lvl.Policies[0].ARN, "p-FullAWSAccess") + } + }) + } +} + +func TestSCPsForAccountReenableAttachesDefault(t *testing.T) { + t.Parallel() + + o := newSCPOrg(t) + + _, err := o.b.DisablePolicyType(o.rootID, "SERVICE_CONTROL_POLICY") + require.NoError(t, err) + _, err = o.b.EnablePolicyType(o.rootID, "SERVICE_CONTROL_POLICY") + require.NoError(t, err) + + levels, ok := o.b.SCPsForAccount(o.acctID) + require.True(t, ok) + + for _, lvl := range levels { + assert.Len(t, lvl.Policies, 1, lvl.TargetID) + } +} + +func TestRestoreMigratesMissingDefaultSCPs(t *testing.T) { + t.Parallel() + + o := newSCPOrg(t) + + var snap map[string]json.RawMessage + require.NoError(t, json.Unmarshal(o.b.Snapshot(context.Background()), &snap)) + + oldShape := func(key string, v any) { + raw, err := json.Marshal(v) + require.NoError(t, err) + + snap[key] = raw + } + + oldShape("targetPolicies", map[string][]string{o.rootID: {"p-FullAWSAccess"}}) + oldShape("policyTargets", map[string][]string{"p-FullAWSAccess": {o.rootID}}) + + data, err := json.Marshal(snap) + require.NoError(t, err) + + restored := organizations.NewInMemoryBackend("000000000000", "us-east-1") + require.NoError(t, restored.Restore(context.Background(), data)) + + levels, ok := restored.SCPsForAccount(o.acctID) + require.True(t, ok) + require.Len(t, levels, 4) + + for _, lvl := range levels { + require.Len(t, lvl.Policies, 1, lvl.TargetID) + assert.Equal(t, iam.EvalAllow, + iam.EvaluatePolicies([]string{lvl.Policies[0].Document}, "s3:DeleteBucket", "*", iam.ConditionContext{})) + } +} + +func TestDetachLastSCPRejected(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + replace bool + wantErr bool + }{ + {name: "last_scp_rejected", wantErr: true}, + {name: "replaced_first_allowed", replace: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + o := newSCPOrg(t) + + if tt.replace { + p, err := o.b.CreatePolicy("allow-s3", "", `{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`, "SERVICE_CONTROL_POLICY", nil) + require.NoError(t, err) + require.NoError(t, o.b.AttachPolicy(p.PolicySummary.ID, o.ouID)) + } + + err := o.b.DetachPolicy("p-FullAWSAccess", o.ouID) + if tt.wantErr { + require.ErrorIs(t, err, organizations.ErrMinPolicyAttachment) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/services/outposts/iam_enforcement_test.go b/services/outposts/iam_enforcement_test.go index a4e0da0147..a1b3104037 100644 --- a/services/outposts/iam_enforcement_test.go +++ b/services/outposts/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/outposts" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/outposts/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/personalize/iam_enforcement_test.go b/services/personalize/iam_enforcement_test.go index 6566ba89f4..eaea493911 100644 --- a/services/personalize/iam_enforcement_test.go +++ b/services/personalize/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/personalize" ) @@ -167,7 +168,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/personalize/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/pinpoint/error_envelope_test.go b/services/pinpoint/error_envelope_test.go index a71bcec607..6d933eab06 100644 --- a/services/pinpoint/error_envelope_test.go +++ b/services/pinpoint/error_envelope_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/pinpoint" ) @@ -79,7 +80,7 @@ func TestErrorEnvelope_GetAppNotFoundDecodesToTypedError(t *testing.T) { "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/mobiletargeting/aws4_request, "+ "SignedHeaders=host, Signature=deadbeef") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/pinpoint/iam_enforcement_test.go b/services/pinpoint/iam_enforcement_test.go index f3638e3140..4eafbc668a 100644 --- a/services/pinpoint/iam_enforcement_test.go +++ b/services/pinpoint/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/pinpoint" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/mobiletargeting/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/pipes/errsweep_wire_shape_test.go b/services/pipes/errsweep_wire_shape_test.go index e5c7bd1dda..9617e60448 100644 --- a/services/pipes/errsweep_wire_shape_test.go +++ b/services/pipes/errsweep_wire_shape_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/pipes" ) @@ -47,7 +48,7 @@ func TestHandler_MalformedBodyCarriesTypedErrorCode(t *testing.T) { "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/pipes/aws4_request, "+ "SignedHeaders=content-type;host, Signature=deadbeef") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/pipes/iam_enforcement_test.go b/services/pipes/iam_enforcement_test.go index c35f7410c3..c6aeaabf16 100644 --- a/services/pipes/iam_enforcement_test.go +++ b/services/pipes/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/pipes" ) @@ -170,7 +171,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/pipes/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/polly/iam_enforcement_test.go b/services/polly/iam_enforcement_test.go index 1762222dc5..6f4c5ac88b 100644 --- a/services/polly/iam_enforcement_test.go +++ b/services/polly/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/polly" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/polly/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/quicksight/error_envelope_test.go b/services/quicksight/error_envelope_test.go index aab67a63a0..ba7d52131a 100644 --- a/services/quicksight/error_envelope_test.go +++ b/services/quicksight/error_envelope_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/quicksight" ) @@ -81,7 +82,7 @@ func TestErrorEnvelope_DescribeDataSetNotFoundDecodesToTypedError(t *testing.T) "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/quicksight/aws4_request, "+ "SignedHeaders=host, Signature=deadbeef") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/quicksight/iam_enforcement_test.go b/services/quicksight/iam_enforcement_test.go index 9dcba292b6..326e5cb111 100644 --- a/services/quicksight/iam_enforcement_test.go +++ b/services/quicksight/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/quicksight" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/quicksight/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ram/errsweep_wire_shape_test.go b/services/ram/errsweep_wire_shape_test.go index f66c1b0310..6a929860e5 100644 --- a/services/ram/errsweep_wire_shape_test.go +++ b/services/ram/errsweep_wire_shape_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/ram" ) @@ -50,7 +51,7 @@ func TestHandler_MalformedBodyCarriesTypedErrorCode(t *testing.T) { "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/ram/aws4_request, "+ "SignedHeaders=content-type;host, Signature=deadbeef") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ram/iam_enforcement_test.go b/services/ram/iam_enforcement_test.go index 2c1c6d00d3..3e96490e06 100644 --- a/services/ram/iam_enforcement_test.go +++ b/services/ram/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/ram" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ram/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/rds/PARITY.md b/services/rds/PARITY.md index a66f9b5773..ddfefa8f9a 100644 --- a/services/rds/PARITY.md +++ b/services/rds/PARITY.md @@ -130,8 +130,8 @@ ops: ModifyDBCluster: {wire: ok, errors: ok, state: ok, persist: ok} DescribeDBClusters: {wire: ok, errors: ok, state: ok, persist: ok, note: "Filters support added this pass — see families/describe_filters"} CreateDBSnapshot: {wire: ok, errors: ok, state: ok, persist: ok} - CopyDBSnapshot: {wire: ok, errors: ok, state: ok, persist: ok} - RestoreDBInstanceFromDBSnapshot: {wire: ok, errors: ok, state: ok, persist: ok} + CopyDBSnapshot: {wire: ok, errors: ok, state: fixed, persist: ok, note: "2026-10-30 (gopherstack-tnqy.6): the source may be an ARN; another account's snapshot copies only when shared with the caller (or \"all\"), else DBSnapshotNotFound; the copy is owned by the caller."} + RestoreDBInstanceFromDBSnapshot: {wire: ok, errors: ok, state: fixed, persist: ok, note: "2026-10-30 (gopherstack-tnqy.6): DBSnapshotIdentifier accepts an ARN; a snapshot owned by another account restores only when shared with the caller (restore attribute, or \"all\"), else DBSnapshotNotFound. Same for RestoreDBClusterFromSnapshot / cluster snapshots."} RestoreDBInstanceToPointInTime: {wire: ok, errors: ok, state: ok, persist: ok} RestoreDBInstanceFromS3: {wire: fixed, errors: ok, state: fixed, persist: n/a, note: "gopherstack-afi1: 3 of 7 required members -- S3IngestionRoleArn, SourceEngine, SourceEngineVersion (rds@v1.124.1 api_op_RestoreDBInstanceFromS3.go:84,91,100) -- were dropped by the handler (vals.Get only read DBInstanceIdentifier/Engine/DBInstanceClass/S3BucketName); Engine and DBInstanceClass were also unvalidated as required despite being so on the wire. All 7 now validated present (InvalidParameterValue -- this op's own deserializeOpError switch has no validation-style exception, same convention already used for the pre-existing DBInstanceIdentifier/S3BucketName checks). The 3 new fields describe the S3 ingestion source only; DBInstance's real response shape (types/types.go) has no members for them, so they're validated but not persisted -- nothing to echo them into."} CreateDBClusterSnapshot: {wire: ok, errors: ok, state: ok, persist: ok} @@ -248,6 +248,7 @@ gaps: [] # - [FIXED, prior pass] CreateDBShardGroup/DeleteDBShardGroup/ModifyDBShardGroup/RebootDBShardGroup and CreateIntegration/DeleteIntegration/ModifyIntegration and CreateCustomDBEngineVersion/DeleteCustomDBEngineVersion/ModifyCustomDBEngineVersion (10 ops total) previously wrapped their response fields one XML level too deep (e.g. `...`) when the real aws-sdk-go-v2 output for all 10 is a FLAT shape with no such wrapper (`...`) — see Notes. A real aws-sdk-go-v2 client's query-XML deserializer only looks for named fields as direct children of the `` element, so every field on these 10 ops (including the identifier needed to address the resource in a follow-up call) previously came back empty/zero to a real SDK client, even though the emulator's backend state was correct. # - [FIXED, prior pass] CreateCustomDBEngineVersion/ModifyCustomDBEngineVersion additionally serialized the description field under the wrong element name (`DatabaseInstallationFilesS3BucketName` instead of `DBEngineVersionDescription`) — see Notes. items_still_open: + - "Snapshot sharing is caller-aware only for snapshots created or copied by an explicit-account caller (DBSnapshot.OwnerAccountID, unset = visible to all as before); instances, clusters and other resources are not account-scoped, and snapshot identifiers stay global across accounts (gopherstack-tnqy.6)." - "UNVERIFIABLE: CreateDBInstance without MasterUserPassword (and without ManageMasterUserPassword) is accepted and EngineVersion values outside the small built-in catalog are accepted: the pinned SDK does not mark the password required and ships no engine-version catalog to check against." - "UNVERIFIABLE (gopherstack-1jkv): two different roles added to a cluster with FeatureName omitted on both coexist; the pinned SDK documents no collision rule." - "UNVERIFIABLE: MasterUserSecret.KmsKeyId stays empty when MasterUserSecretKmsKeyId is unset; the pinned SDK does not state the default key's shape. Changing the key on a managed secret does not re-encrypt the Secrets Manager secret." @@ -1908,3 +1909,7 @@ OptimizedWritesEnabled (DBInstance, DBCluster), StorageOptimized and GlobalClust ## 2026-10-11 reserved ID-pinning tag (gopherstack-cefu7.23) `CreateDBInstance` honours the emulator-only `gopherstack:id` tag to pin `DbiResourceId` (`db-` plus 26 uppercase alphanumerics; unpinned instances keep using the identifier). The tag is dropped from every Create* op, so it is never stored or listed; a malformed ID returns `InvalidParameterValue`, a reused ID `DBInstanceAlreadyExists`, and `AddTagsToResource` rejects the key. See docs/pinned-ids.md. + +## 2026-10-30 shared snapshot visibility (gopherstack-tnqy.6) + +DescribeDBSnapshots/DescribeDBClusterSnapshots honour SnapshotType (manual, automated, shared, public), IncludeShared and IncludePublic (api_op_DescribeDBSnapshots.go) for an explicit caller account; a foreign snapshot is addressable only by ARN and only when shared. ModifyDBSnapshotAttribute, DescribeDBSnapshotAttributes and DeleteDBSnapshot (and cluster variants) reach only the owner's snapshots (DBSnapshotNotFound otherwise). Anonymous default-account requests see everything. diff --git a/services/rds/README.md b/services/rds/README.md index 81d445aef3..f521c8ec0f 100644 --- a/services/rds/README.md +++ b/services/rds/README.md @@ -9,13 +9,14 @@ | --- | --- | | PARITY entries audited | 52 (51 ok, 1 partial) | | Feature families | 29 (28 ok, 1 partial) | -| Known gaps | 6 | +| Known gaps | 7 | | Structural gaps (can't be emulated) | 7 | | Deferred items | 0 | | Resource leaks | fixed | ### Known gaps +- Snapshot sharing is caller-aware only for snapshots created or copied by an explicit-account caller (DBSnapshot.OwnerAccountID, unset = visible to all as before); instances, clusters and other resources are not account-scoped, and snapshot identifiers stay global across accounts (gopherstack-tnqy.6). - UNVERIFIABLE: CreateDBInstance without MasterUserPassword (and without ManageMasterUserPassword) is accepted and EngineVersion values outside the small built-in catalog are accepted: the pinned SDK does not mark the password required and ships no engine-version catalog to check against. - UNVERIFIABLE (gopherstack-1jkv): two different roles added to a cluster with FeatureName omitted on both coexist; the pinned SDK documents no collision rule. - UNVERIFIABLE: MasterUserSecret.KmsKeyId stays empty when MasterUserSecretKmsKeyId is unset; the pinned SDK does not state the default key's shape. Changing the key on a managed secret does not re-encrypt the Secrets Manager secret. diff --git a/services/rds/cluster_snapshots.go b/services/rds/cluster_snapshots.go index c4c728f0fc..1ad262dc1d 100644 --- a/services/rds/cluster_snapshots.go +++ b/services/rds/cluster_snapshots.go @@ -10,6 +10,11 @@ import ( // CreateDBClusterSnapshot creates a snapshot of the given cluster. func (b *InMemoryBackend) CreateDBClusterSnapshot(snapshotID, clusterID string) (*DBClusterSnapshot, error) { + return b.CreateDBClusterSnapshotAs("", snapshotID, clusterID) +} + +// CreateDBClusterSnapshotAs is CreateDBClusterSnapshot owned by caller's account. +func (b *InMemoryBackend) CreateDBClusterSnapshotAs(caller, snapshotID, clusterID string) (*DBClusterSnapshot, error) { if snapshotID == "" { return nil, fmt.Errorf("%w: DBClusterSnapshotIdentifier must not be empty", ErrInvalidParameter) } @@ -31,6 +36,8 @@ func (b *InMemoryBackend) CreateDBClusterSnapshot(snapshotID, clusterID string) return nil, fmt.Errorf("%w: cluster %s not found", ErrClusterNotFound, clusterID) } snap := b.newManualClusterSnapshotLocked(snapshotID, cluster) + snap.OwnerAccountID = caller + snap.DBClusterSnapshotArn = b.ownedSnapshotARN(caller, "cluster-snapshot", snapshotID) b.clusterSnapshots.Put(snap) b.storeSnapDataLocked(snapKindCluster, snapshotID, data) cp := *snap @@ -69,12 +76,20 @@ func (b *InMemoryBackend) newManualClusterSnapshotLocked(snapshotID string, clus // DescribeDBClusterSnapshots returns cluster snapshots. // If clusterID is non-empty, only snapshots whose DBClusterIdentifier matches are returned. func (b *InMemoryBackend) DescribeDBClusterSnapshots(snapshotID, clusterID string) ([]DBClusterSnapshot, error) { + return b.DescribeDBClusterSnapshotsAs(SnapshotQuery{SnapshotID: snapshotID, ParentID: clusterID}) +} + +// DescribeDBClusterSnapshotsAs is DescribeDBClusterSnapshots from the caller's point of view. +func (b *InMemoryBackend) DescribeDBClusterSnapshotsAs(q SnapshotQuery) ([]DBClusterSnapshot, error) { b.mu.RLock("DescribeDBClusterSnapshots") defer b.mu.RUnlock() - if snapshotID != "" { - snap, exists := b.clusterSnapshots.Get(normalizeID(snapshotID)) - if !exists { - return nil, fmt.Errorf("%w: cluster snapshot %s not found", ErrClusterSnapshotNotFound, snapshotID) + if q.SnapshotID != "" { + id, byARN := snapshotRef(q.SnapshotID) + snap, exists := b.clusterSnapshots.Get(normalizeID(id)) + if !exists || !b.snapshotReachableLocked( + q.CallerAccount, snap.OwnerAccountID, snapKindCluster, snap.DBClusterSnapshotIdentifier, byARN, + ) { + return nil, fmt.Errorf("%w: cluster snapshot %s not found", ErrClusterSnapshotNotFound, q.SnapshotID) } cp := *snap @@ -82,7 +97,12 @@ func (b *InMemoryBackend) DescribeDBClusterSnapshots(snapshotID, clusterID strin } result := make([]DBClusterSnapshot, 0, b.clusterSnapshots.Len()) for _, snap := range b.clusterSnapshots.All() { - if clusterID != "" && !idEqual(snap.DBClusterIdentifier, clusterID) { + if q.ParentID != "" && !idEqual(snap.DBClusterIdentifier, q.ParentID) { + continue + } + if !b.snapshotListedLocked( + q, snapKindCluster, snap.DBClusterSnapshotIdentifier, snap.OwnerAccountID, snap.SnapshotType, + ) { continue } result = append(result, *snap) @@ -166,13 +186,18 @@ func matchesAllDBClusterSnapshotFilters(s DBClusterSnapshot, filters map[string] // DeleteDBClusterSnapshot removes the given cluster snapshot. func (b *InMemoryBackend) DeleteDBClusterSnapshot(snapshotID string) (*DBClusterSnapshot, error) { + return b.DeleteDBClusterSnapshotAs("", snapshotID) +} + +// DeleteDBClusterSnapshotAs deletes a cluster snapshot the caller owns. +func (b *InMemoryBackend) DeleteDBClusterSnapshotAs(caller, snapshotID string) (*DBClusterSnapshot, error) { if snapshotID == "" { return nil, fmt.Errorf("%w: DBClusterSnapshotIdentifier must not be empty", ErrInvalidParameter) } b.mu.Lock("DeleteDBClusterSnapshot") defer b.mu.Unlock() snap, exists := b.clusterSnapshots.Get(normalizeID(snapshotID)) - if !exists { + if !exists || !ownsSnapshot(caller, snap.OwnerAccountID) { return nil, fmt.Errorf("%w: cluster snapshot %s not found", ErrClusterSnapshotNotFound, snapshotID) } cp := *snap @@ -180,7 +205,7 @@ func (b *InMemoryBackend) DeleteDBClusterSnapshot(snapshotID string) (*DBCluster b.dropSnapDataLocked(snapKindCluster, snapshotID) // Use snap.DBClusterSnapshotIdentifier (the stored, creation-time casing) // rather than the raw snapshotID argument -- see normalizeID. - delete(b.tags, b.rdsARN("cluster-snapshot", snap.DBClusterSnapshotIdentifier)) + delete(b.tags, cmp.Or(snap.DBClusterSnapshotArn, b.rdsARN("cluster-snapshot", snap.DBClusterSnapshotIdentifier))) return &cp, nil } @@ -193,12 +218,19 @@ func (b *InMemoryBackend) DeleteDBClusterSnapshot(snapshotID string) (*DBCluster func (b *InMemoryBackend) CopyDBClusterSnapshot( sourceSnapshotID, targetSnapshotID string, copyTags bool, ) (*DBClusterSnapshot, error) { - return b.CopyDBClusterSnapshotWithKMS(sourceSnapshotID, targetSnapshotID, copyTags, "") + return b.CopyDBClusterSnapshotAs("", sourceSnapshotID, targetSnapshotID, copyTags, "") } // CopyDBClusterSnapshotWithKMS is CopyDBClusterSnapshot plus KmsKeyId, which re-encrypts the copy. func (b *InMemoryBackend) CopyDBClusterSnapshotWithKMS( sourceSnapshotID, targetSnapshotID string, copyTags bool, kmsKeyID string, +) (*DBClusterSnapshot, error) { + return b.CopyDBClusterSnapshotAs("", sourceSnapshotID, targetSnapshotID, copyTags, kmsKeyID) +} + +// CopyDBClusterSnapshotAs copies a snapshot reachable by caller into caller's account. +func (b *InMemoryBackend) CopyDBClusterSnapshotAs( + caller, sourceSnapshotID, targetSnapshotID string, copyTags bool, kmsKeyID string, ) (*DBClusterSnapshot, error) { if sourceSnapshotID == "" { return nil, fmt.Errorf("%w: SourceDBClusterSnapshotIdentifier must not be empty", ErrInvalidParameter) @@ -211,8 +243,11 @@ func (b *InMemoryBackend) CopyDBClusterSnapshotWithKMS( // SourceDBClusterSnapshotIdentifier accepts either a bare identifier or a // full ARN (real AWS docs: required when copying an encrypted snapshot); // previously only a bare identifier resolved, so the ARN form always 404ed. - source, srcExists := b.clusterSnapshots.Get(normalizeID(rdsIDFromARN(sourceSnapshotID))) - if !srcExists { + srcID, byARN := snapshotRef(sourceSnapshotID) + source, srcExists := b.clusterSnapshots.Get(normalizeID(srcID)) + if !srcExists || !b.snapshotReachableLocked( + caller, source.OwnerAccountID, snapKindCluster, source.DBClusterSnapshotIdentifier, byARN, + ) { return nil, fmt.Errorf("%w: cluster snapshot %s not found", ErrClusterSnapshotNotFound, sourceSnapshotID) } if _, dstExists := b.clusterSnapshots.Get(normalizeID(targetSnapshotID)); dstExists { @@ -222,11 +257,12 @@ func (b *InMemoryBackend) CopyDBClusterSnapshotWithKMS( targetSnapshotID, ) } - targetArn := b.rdsARN("cluster-snapshot", targetSnapshotID) + targetArn := b.ownedSnapshotARN(caller, "cluster-snapshot", targetSnapshotID) snap := &DBClusterSnapshot{ SnapshotCreateTime: time.Now().UTC(), DBClusterSnapshotIdentifier: targetSnapshotID, DBClusterSnapshotArn: targetArn, + OwnerAccountID: caller, DBClusterIdentifier: source.DBClusterIdentifier, DBClusterResourceID: source.DBClusterResourceID, Engine: source.Engine, @@ -264,11 +300,18 @@ func (b *InMemoryBackend) CopyDBClusterSnapshotWithKMS( // DescribeDBClusterSnapshotAttributes returns attributes for a DB cluster snapshot. func (b *InMemoryBackend) DescribeDBClusterSnapshotAttributes( snapshotID string, +) (*DBClusterSnapshotAttributesResult, error) { + return b.DescribeDBClusterSnapshotAttributesAs("", snapshotID) +} + +// DescribeDBClusterSnapshotAttributesAs describes the attributes of a cluster snapshot the caller owns. +func (b *InMemoryBackend) DescribeDBClusterSnapshotAttributesAs( + caller, snapshotID string, ) (*DBClusterSnapshotAttributesResult, error) { b.mu.RLock("DescribeDBClusterSnapshotAttributes") defer b.mu.RUnlock() snap, ok := b.clusterSnapshots.Get(normalizeID(snapshotID)) - if !ok { + if !ok || !ownsSnapshot(caller, snap.OwnerAccountID) { return nil, fmt.Errorf("%w: %s", ErrClusterSnapshotNotFound, snapshotID) } if stored, storedOK := b.clusterSnapshotAttributes.Get(normalizeID(snapshotID)); storedOK { @@ -299,10 +342,18 @@ func (b *InMemoryBackend) DescribeDBClusterSnapshotAttributes( func (b *InMemoryBackend) ModifyDBClusterSnapshotAttribute( snapshotID, attributeName string, valuesToAdd, valuesToRemove []string, +) (*DBClusterSnapshotAttributesResult, error) { + return b.ModifyDBClusterSnapshotAttributeAs("", snapshotID, attributeName, valuesToAdd, valuesToRemove) +} + +// ModifyDBClusterSnapshotAttributeAs modifies the attributes of a cluster snapshot the caller owns. +func (b *InMemoryBackend) ModifyDBClusterSnapshotAttributeAs( + caller, snapshotID, attributeName string, + valuesToAdd, valuesToRemove []string, ) (*DBClusterSnapshotAttributesResult, error) { b.mu.Lock("ModifyDBClusterSnapshotAttribute") defer b.mu.Unlock() - if _, ok := b.clusterSnapshots.Get(normalizeID(snapshotID)); !ok { + if snap, ok := b.clusterSnapshots.Get(normalizeID(snapshotID)); !ok || !ownsSnapshot(caller, snap.OwnerAccountID) { return nil, fmt.Errorf("%w: %s", ErrClusterSnapshotNotFound, snapshotID) } result, ok := b.clusterSnapshotAttributes.Get(normalizeID(snapshotID)) diff --git a/services/rds/db_clusters.go b/services/rds/db_clusters.go index 2bf34746a1..35bf384572 100644 --- a/services/rds/db_clusters.go +++ b/services/rds/db_clusters.go @@ -686,8 +686,11 @@ func (b *InMemoryBackend) RestoreDBClusterFromSnapshot( if _, exists := b.clusters.Get(normalizeID(clusterID)); exists { return nil, fmt.Errorf("%w: cluster %s already exists", ErrClusterAlreadyExists, clusterID) } - snap, exists := b.clusterSnapshots.Get(normalizeID(snapshotID)) - if !exists { + snapID, byARN := snapshotRef(snapshotID) + snap, exists := b.clusterSnapshots.Get(normalizeID(snapID)) + if !exists || !b.snapshotReachableLocked( + opts.CallerAccount, snap.OwnerAccountID, snapKindCluster, snap.DBClusterSnapshotIdentifier, byARN, + ) { return nil, fmt.Errorf( "%w: cluster snapshot %s not found", ErrClusterSnapshotNotFound, diff --git a/services/rds/db_snapshots.go b/services/rds/db_snapshots.go index c35e9bbdf8..1e3b2b49be 100644 --- a/services/rds/db_snapshots.go +++ b/services/rds/db_snapshots.go @@ -10,6 +10,11 @@ import ( // CreateDBSnapshot creates a snapshot of the given DB instance. func (b *InMemoryBackend) CreateDBSnapshot(snapshotID, instanceID string) (*DBSnapshot, error) { + return b.CreateDBSnapshotAs("", snapshotID, instanceID) +} + +// CreateDBSnapshotAs is CreateDBSnapshot owned by caller's account. +func (b *InMemoryBackend) CreateDBSnapshotAs(caller, snapshotID, instanceID string) (*DBSnapshot, error) { if snapshotID == "" { return nil, fmt.Errorf("%w: DBSnapshotIdentifier is required", ErrInvalidParameter) } @@ -36,6 +41,8 @@ func (b *InMemoryBackend) CreateDBSnapshot(snapshotID, instanceID string) (*DBSn } snap := b.newManualSnapshotLocked(snapshotID, inst) + snap.OwnerAccountID = caller + snap.DBSnapshotArn = b.ownedSnapshotARN(caller, "snapshot", snapshotID) b.snapshots.Put(snap) b.storeSnapDataLocked(snapKindInstance, snapshotID, data) b.copyTenantDatabasesToSnapshotLocked(snap.DBSnapshotIdentifier, inst) @@ -91,13 +98,21 @@ func (b *InMemoryBackend) newManualSnapshotLocked(snapshotID string, inst *DBIns // DescribeDBSnapshots returns snapshots. If snapshotID is non-empty, returns only that snapshot. // If instanceID is non-empty, returns all snapshots for that instance. func (b *InMemoryBackend) DescribeDBSnapshots(snapshotID, instanceID string) ([]DBSnapshot, error) { + return b.DescribeDBSnapshotsAs(SnapshotQuery{SnapshotID: snapshotID, ParentID: instanceID}) +} + +// DescribeDBSnapshotsAs is DescribeDBSnapshots from the caller's point of view. +func (b *InMemoryBackend) DescribeDBSnapshotsAs(q SnapshotQuery) ([]DBSnapshot, error) { b.mu.RLock("DescribeDBSnapshots") defer b.mu.RUnlock() - if snapshotID != "" { - snap, exists := b.snapshots.Get(normalizeID(snapshotID)) - if !exists { - return nil, fmt.Errorf("%w: snapshot %s not found", ErrSnapshotNotFound, snapshotID) + if q.SnapshotID != "" { + id, byARN := snapshotRef(q.SnapshotID) + snap, exists := b.snapshots.Get(normalizeID(id)) + if !exists || !b.snapshotReachableLocked( + q.CallerAccount, snap.OwnerAccountID, snapKindInstance, snap.DBSnapshotIdentifier, byARN, + ) { + return nil, fmt.Errorf("%w: snapshot %s not found", ErrSnapshotNotFound, q.SnapshotID) } return []DBSnapshot{*snap}, nil @@ -105,7 +120,16 @@ func (b *InMemoryBackend) DescribeDBSnapshots(snapshotID, instanceID string) ([] snaps := make([]DBSnapshot, 0, b.snapshots.Len()) for _, snap := range b.snapshots.All() { - if instanceID != "" && !idEqual(snap.DBInstanceIdentifier, instanceID) { + if q.ParentID != "" && !idEqual(snap.DBInstanceIdentifier, q.ParentID) { + continue + } + if !b.snapshotListedLocked( + q, + snapKindInstance, + snap.DBSnapshotIdentifier, + snap.OwnerAccountID, + snap.SnapshotType, + ) { continue } snaps = append(snaps, *snap) @@ -195,11 +219,16 @@ func matchesAllDBSnapshotFilters(s DBSnapshot, filters map[string][]string) bool // DeleteDBSnapshot removes the given snapshot. func (b *InMemoryBackend) DeleteDBSnapshot(snapshotID string) (*DBSnapshot, error) { + return b.DeleteDBSnapshotAs("", snapshotID) +} + +// DeleteDBSnapshotAs deletes a snapshot the caller owns. +func (b *InMemoryBackend) DeleteDBSnapshotAs(caller, snapshotID string) (*DBSnapshot, error) { b.mu.Lock("DeleteDBSnapshot") defer b.mu.Unlock() snap, exists := b.snapshots.Get(normalizeID(snapshotID)) - if !exists { + if !exists || !ownsSnapshot(caller, snap.OwnerAccountID) { return nil, fmt.Errorf("%w: snapshot %s not found", ErrSnapshotNotFound, snapshotID) } @@ -210,7 +239,7 @@ func (b *InMemoryBackend) DeleteDBSnapshot(snapshotID string) (*DBSnapshot, erro // than the raw snapshotID argument: they can differ purely in case (see // normalizeID), and the tags map is keyed by ARN built from whichever // casing was live when tags were added. - delete(b.tags, b.rdsARN("snapshot", snap.DBSnapshotIdentifier)) + delete(b.tags, cmp.Or(snap.DBSnapshotArn, b.rdsARN("snapshot", snap.DBSnapshotIdentifier))) return &cp, nil } @@ -230,8 +259,11 @@ func (b *InMemoryBackend) CopyDBSnapshot( b.mu.Lock("CopyDBSnapshot") defer b.mu.Unlock() - src, exists := b.snapshots.Get(normalizeID(sourceSnapshotID)) - if !exists { + srcID, byARN := snapshotRef(sourceSnapshotID) + src, exists := b.snapshots.Get(normalizeID(srcID)) + if !exists || !b.snapshotReachableLocked( + opts.CallerAccount, src.OwnerAccountID, snapKindInstance, src.DBSnapshotIdentifier, byARN, + ) { return nil, fmt.Errorf("%w: snapshot %s not found", ErrSnapshotNotFound, sourceSnapshotID) } if _, alreadyExists := b.snapshots.Get(normalizeID(targetSnapshotID)); alreadyExists { @@ -246,7 +278,8 @@ func (b *InMemoryBackend) CopyDBSnapshot( snap := &DBSnapshot{ SnapshotCreateTime: time.Now().UTC(), DBSnapshotIdentifier: targetSnapshotID, - DBSnapshotArn: b.rdsARN("snapshot", targetSnapshotID), + DBSnapshotArn: b.ownedSnapshotARN(opts.CallerAccount, "snapshot", targetSnapshotID), + OwnerAccountID: opts.CallerAccount, DBInstanceIdentifier: src.DBInstanceIdentifier, DbiResourceID: src.DbiResourceID, Engine: src.Engine, @@ -307,8 +340,11 @@ func (b *InMemoryBackend) RestoreDBInstanceFromDBSnapshot( return } - snap, exists := b.snapshots.Get(normalizeID(snapshotID)) - if !exists { + snapID, byARN := snapshotRef(snapshotID) + snap, exists := b.snapshots.Get(normalizeID(snapID)) + if !exists || !b.snapshotReachableLocked( + opts.CallerAccount, snap.OwnerAccountID, snapKindInstance, snap.DBSnapshotIdentifier, byARN, + ) { err = fmt.Errorf("%w: snapshot %s not found", ErrSnapshotNotFound, snapshotID) return @@ -399,9 +435,16 @@ func restoredInstanceFromSnapshot( // DescribeDBSnapshotAttributes returns attributes for a DB snapshot. func (b *InMemoryBackend) DescribeDBSnapshotAttributes(snapshotID string) (*DBSnapshotAttributesResult, error) { + return b.DescribeDBSnapshotAttributesAs("", snapshotID) +} + +// DescribeDBSnapshotAttributesAs describes the attributes of a snapshot the caller owns. +func (b *InMemoryBackend) DescribeDBSnapshotAttributesAs( + caller, snapshotID string, +) (*DBSnapshotAttributesResult, error) { b.mu.RLock("DescribeDBSnapshotAttributes") defer b.mu.RUnlock() - if _, ok := b.snapshots.Get(normalizeID(snapshotID)); !ok { + if snap, ok := b.snapshots.Get(normalizeID(snapshotID)); !ok || !ownsSnapshot(caller, snap.OwnerAccountID) { return nil, fmt.Errorf("%w: %s", ErrSnapshotNotFound, snapshotID) } if attrs, ok := b.snapshotAttributes.Get(normalizeID(snapshotID)); ok { @@ -441,10 +484,18 @@ func (b *InMemoryBackend) ModifyDBSnapshot(snapshotID, optionGroupName, engineVe func (b *InMemoryBackend) ModifyDBSnapshotAttribute( snapshotID, attributeName string, valuesToAdd, valuesToRemove []string, +) (*DBSnapshotAttributesResult, error) { + return b.ModifyDBSnapshotAttributeAs("", snapshotID, attributeName, valuesToAdd, valuesToRemove) +} + +// ModifyDBSnapshotAttributeAs modifies the attributes of a snapshot the caller owns. +func (b *InMemoryBackend) ModifyDBSnapshotAttributeAs( + caller, snapshotID, attributeName string, + valuesToAdd, valuesToRemove []string, ) (*DBSnapshotAttributesResult, error) { b.mu.Lock("ModifyDBSnapshotAttribute") defer b.mu.Unlock() - if _, ok := b.snapshots.Get(normalizeID(snapshotID)); !ok { + if snap, ok := b.snapshots.Get(normalizeID(snapshotID)); !ok || !ownsSnapshot(caller, snap.OwnerAccountID) { return nil, fmt.Errorf("%w: %s", ErrSnapshotNotFound, snapshotID) } result, ok := b.snapshotAttributes.Get(normalizeID(snapshotID)) diff --git a/services/rds/engine.go b/services/rds/engine.go index f587051db3..04ed6e0118 100644 --- a/services/rds/engine.go +++ b/services/rds/engine.go @@ -138,6 +138,7 @@ type liveDB struct { dbPort atomic.Int32 ready bool paused bool + logical bool } func (lb *liveDB) release() { @@ -356,9 +357,10 @@ func engineEnv(lb *liveDB) []string { func (e *dbEngine) spec(lb *liveDB, plan replPlan) container.Spec { s := container.Spec{ - Name: lb.ctrName, - Image: engineImage(lb.kind, lb.version), - Env: engineEnv(lb), + Name: lb.ctrName, + Labels: container.ServiceLabels("rds"), + Image: engineImage(lb.kind, lb.version), + Env: engineEnv(lb), Ports: []string{container.PortSpec( container.BindHostFor(e.cfg.Host), strconv.Itoa(lb.port), strconv.Itoa(engineContainerPort(lb.kind)))}, Network: plan.network, @@ -532,7 +534,8 @@ func (b *InMemoryBackend) launchSeededUnitLocked( } lb := &liveDB{ - gone: make(chan struct{}), capture: make(chan struct{}, 1), key: key, resourceID: resourceID, + logical: seed.logical, + gone: make(chan struct{}), capture: make(chan struct{}, 1), key: key, resourceID: resourceID, kind: e.kindOf(engine), engine: engine, version: version, user: user, password: password, dbName: dbName, seed: seed, ctrName: "gopherstack-rds-" + randomPassword(), serverID: randomServerID(), @@ -575,6 +578,7 @@ func (b *InMemoryBackend) provisionSeededInstanceLocked(inst *DBInstance, passwo return true } + seed.logical = b.logicalReplicationLocked(inst.DBParameterGroupName, b.parameterGroups) b.launchSeededUnitLocked(unitKeyForInstance(inst.DBInstanceIdentifier), inst.DBInstanceIdentifier, inst.Engine, inst.EngineVersion, inst.MasterUsername, password, inst.DBName, seed) @@ -593,6 +597,7 @@ func (b *InMemoryBackend) provisionSeededClusterLocked(c *DBCluster, password st } c.Status = instanceStatusCreating + seed.logical = b.logicalReplicationLocked(c.DBClusterParameterGroupName, b.clusterParameterGroups) b.launchSeededUnitLocked(unitKeyForCluster(c.DBClusterIdentifier), c.DBClusterIdentifier, c.Engine, c.EngineVersion, c.MasterUsername, password, c.DatabaseName, seed) diff --git a/services/rds/engine_data.go b/services/rds/engine_data.go index f38dbac950..48dc0da166 100644 --- a/services/rds/engine_data.go +++ b/services/rds/engine_data.go @@ -16,7 +16,6 @@ import ( const ( dumpChunkChars = 60000 - cmdEnv = "env" snapKindInstance = "i:" snapKindCluster = "c:" ) @@ -43,6 +42,8 @@ type engineSeed struct { dbName string // replicate makes the unit a streaming replica of fromKey instead of a restored copy. replicate bool + // logical starts PostgreSQL with wal_level=logical (rds.logical_replication=1 in the parameter group). + logical bool } func (s engineSeed) empty() bool { return s.dump == "" && s.fromKey == "" } @@ -69,7 +70,18 @@ func (e *dbEngine) execer() (EngineExecRuntime, bool) { } func (e *dbEngine) run(ctx context.Context, x EngineExecRuntime, id string, cmd []string) (string, error) { - res, err := x.Exec(ctx, id, cmd) + return e.runEnv(ctx, x, id, cmd, nil) +} + +func mysqlPwdEnv(pw string) []string { return []string{"MYSQL_PWD=" + pw} } + +func pgPwdEnv(pw string) []string { return []string{"PGPASSWORD=" + pw} } + +// runEnv runs cmd with env on the exec process so passwords stay out of argv. +func (e *dbEngine) runEnv( + ctx context.Context, x EngineExecRuntime, id string, cmd, env []string, +) (string, error) { + res, err := container.ExecWithEnv(ctx, x, id, cmd, env) if err != nil { return "", fmt.Errorf("exec: %w", err) } @@ -122,9 +134,9 @@ func (e *dbEngine) dumpUnitFor(ctx context.Context, in unitInfo, forReplica bool return "", err } - return e.run(ctx, x, in.containerID, []string{ - cmdEnv, "PGPASSWORD=" + l.Password, "pg_dump", "-U", l.User, "-d", db, "--no-owner", "--no-privileges", - }) + return e.runEnv(ctx, x, in.containerID, []string{ + "pg_dump", "-U", l.User, "-d", db, "--no-owner", "--no-privileges", + }, pgPwdEnv(l.Password)) } return e.dumpMySQL(ctx, x, in, forReplica) @@ -133,11 +145,11 @@ func (e *dbEngine) dumpUnitFor(ctx context.Context, in unitInfo, forReplica bool func (e *dbEngine) dumpMySQL(ctx context.Context, x EngineExecRuntime, in unitInfo, forReplica bool) (string, error) { l := in.login client, dumper := mysqlClients(l.Kind) - env := "MYSQL_PWD=" + l.Password + env := mysqlPwdEnv(l.Password) - out, err := e.run(ctx, x, in.containerID, []string{ - cmdEnv, env, client, "-u" + mysqlRootUser, "-N", "-B", "-e", "SHOW DATABASES", - }) + out, err := e.runEnv(ctx, x, in.containerID, []string{ + client, "-u" + mysqlRootUser, "-N", "-B", "-e", "SHOW DATABASES", + }, env) if err != nil { return "", err } @@ -159,13 +171,13 @@ func (e *dbEngine) dumpMySQL(ctx context.Context, x EngineExecRuntime, in unitIn } cmd := append([]string{ - cmdEnv, env, dumper, "-u" + mysqlRootUser, "--single-transaction", "--skip-dump-date", + dumper, "-u" + mysqlRootUser, "--single-transaction", "--skip-dump-date", "--routines", "--events", "--triggers", }, mysqlDumpPositionFlags(l.Kind, forReplica)...) cmd = append(cmd, "--databases") - return e.run(ctx, x, in.containerID, append(cmd, names...)) + return e.runEnv(ctx, x, in.containerID, append(cmd, names...), env) } // mysqlPositionScript returns the statement that starts a replica of a source holding no user databases at @@ -179,9 +191,9 @@ func (e *dbEngine) mysqlPositionScript(ctx context.Context, x EngineExecRuntime, expr, format = "@@GLOBAL.gtid_binlog_pos", "SET GLOBAL gtid_slave_pos='%s';\n" } - out, err := e.run(ctx, x, in.containerID, []string{ - cmdEnv, "MYSQL_PWD=" + l.Password, client, "-u" + mysqlRootUser, "-N", "-B", "-e", "SELECT " + expr, - }) + out, err := e.runEnv(ctx, x, in.containerID, []string{ + client, "-u" + mysqlRootUser, "-N", "-B", "-e", "SELECT " + expr, + }, mysqlPwdEnv(l.Password)) if err != nil { return "", err } @@ -260,17 +272,17 @@ func (e *dbEngine) loadDump(ctx context.Context, in unitInfo, dump string) error if l.Kind == enginePostgres { db, _ := pgDatabase(l) - _, err := e.run(ctx, x, in.containerID, []string{ - cmdEnv, "PGPASSWORD=" + l.Password, "sh", "-c", pgLoadScript, path, l.User, db, - }) + _, err := e.runEnv(ctx, x, in.containerID, []string{ + "sh", "-c", pgLoadScript, path, l.User, db, + }, pgPwdEnv(l.Password)) return err } client, _ := mysqlClients(l.Kind) - _, err := e.run(ctx, x, in.containerID, []string{ - cmdEnv, "MYSQL_PWD=" + l.Password, "sh", "-c", mysqlLoadScript, path, client, mysqlRootUser, - }) + _, err := e.runEnv(ctx, x, in.containerID, []string{ + "sh", "-c", mysqlLoadScript, path, client, mysqlRootUser, + }, mysqlPwdEnv(l.Password)) return err } diff --git a/services/rds/engine_data_docker_test.go b/services/rds/engine_data_docker_test.go index 9914492c5f..399a3330f5 100644 --- a/services/rds/engine_data_docker_test.go +++ b/services/rds/engine_data_docker_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/services/rds" ) @@ -76,7 +77,10 @@ func TestEngineDockerSnapshotReplicaAndPortChange(t *testing.T) { assert.Equal(t, "alpha", name, id) } - newPort := freePort(t) + newPort, err := portalloc.AcquireEphemeral() + require.NoError(t, err) + t.Cleanup(func() { portalloc.ReleaseEphemeral(newPort) }) + _, err = b.ModifyDBInstance("src", "", 0, rds.DBInstanceOptions{DBPortNumber: newPort}) require.NoError(t, err) diff --git a/services/rds/engine_data_test.go b/services/rds/engine_data_test.go index 2a7f88e21b..2eac7e3483 100644 --- a/services/rds/engine_data_test.go +++ b/services/rds/engine_data_test.go @@ -13,9 +13,20 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" "github.com/blackbirdworks/gopherstack/services/rds" ) +func (f *fakeEngineRuntime) ExecEnv( + ctx context.Context, id string, cmd, env []string, +) (container.ExecResult, error) { + f.mu.Lock() + f.envs = append(f.envs, slices.Clone(env)) + f.mu.Unlock() + + return f.Exec(ctx, id, cmd) +} + func (f *fakeEngineRuntime) Exec(_ context.Context, id string, cmd []string) (container.ExecResult, error) { f.mu.Lock() defer f.mu.Unlock() @@ -147,6 +158,39 @@ func TestEngineSnapshotRestoreCarriesData(t *testing.T) { } } +func TestEngineExecPasswordInEnvNotArgv(t *testing.T) { + t.Parallel() + + for _, tt := range dataCases() { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newEngineFixture(t, engineWait) + f.readySource(t, tt.engine) + + _, err := f.b.CreateDBSnapshot("snap1", "src") + require.NoError(t, err) + + wantEnv := "MYSQL_PWD=pw" + if tt.engine == "postgres" { + wantEnv = "PGPASSWORD=pw" + } + + f.rt.mu.Lock() + defer f.rt.mu.Unlock() + + assert.Contains(t, f.rt.envs, []string{wantEnv}) + + for _, cmd := range f.rt.execs { + for _, a := range cmd { + assert.NotContains(t, a, "MYSQL_PWD") + assert.NotContains(t, a, "PGPASSWORD") + } + } + }) + } +} + func TestEngineReadReplicaGetsOwnEngineWithData(t *testing.T) { t.Parallel() @@ -348,18 +392,6 @@ func TestEngineClusterSnapshotAndPointInTimeRestore(t *testing.T) { } } -func freePort(t *testing.T) int { - t.Helper() - - l, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") - require.NoError(t, err) - - port := l.Addr().(*net.TCPAddr).Port - require.NoError(t, l.Close()) - - return port -} - func dialable(t *testing.T, port int) bool { t.Helper() @@ -385,9 +417,11 @@ func TestEngineModifyDBPortNumberRebindsEndpoint(t *testing.T) { f.readySource(t, tt.engine) oldPort := f.instance(t, "src").Port - newPort := freePort(t) + newPort, err := portalloc.AcquireEphemeral() + require.NoError(t, err) + t.Cleanup(func() { portalloc.ReleaseEphemeral(newPort) }) - _, err := f.b.ModifyDBInstance("src", "", 0, rds.DBInstanceOptions{DBPortNumber: newPort}) + _, err = f.b.ModifyDBInstance("src", "", 0, rds.DBInstanceOptions{DBPortNumber: newPort}) require.NoError(t, err) assert.Equal(t, newPort, f.instance(t, "src").Port) assert.True(t, dialable(t, newPort)) diff --git a/services/rds/engine_docker_test.go b/services/rds/engine_docker_test.go index d2c69407c7..dd988b9aa8 100644 --- a/services/rds/engine_docker_test.go +++ b/services/rds/engine_docker_test.go @@ -103,6 +103,17 @@ func (r *recordingEngineRuntime) Exec( return x.Exec(ctx, id, cmd) } +func (r *recordingEngineRuntime) ExecEnv( + ctx context.Context, id string, cmd, env []string, +) (container.ExecResult, error) { + x, ok := r.EngineRuntime.(rds.EngineExecRuntime) + if !ok { + return container.ExecResult{}, container.ErrExecUnsupported + } + + return container.ExecWithEnv(ctx, x, id, cmd, env) +} + func (r *recordingEngineRuntime) Close() error { if c, ok := r.EngineRuntime.(io.Closer); ok { return c.Close() diff --git a/services/rds/engine_pgext_docker_test.go b/services/rds/engine_pgext_docker_test.go index 326dce3115..bcba13989a 100644 --- a/services/rds/engine_pgext_docker_test.go +++ b/services/rds/engine_pgext_docker_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/rds" "github.com/blackbirdworks/gopherstack/services/s3" ) @@ -130,7 +131,7 @@ func (e *pgExtEnv) s3Do(t *testing.T, method, path, body string) (int, string) { req, err := http.NewRequestWithContext(t.Context(), method, e.s3.URL+path, strings.NewReader(body)) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) out, _ := io.ReadAll(resp.Body) diff --git a/services/rds/engine_pgext_internal_test.go b/services/rds/engine_pgext_internal_test.go index a81ffa43fa..f7a7bb6c4b 100644 --- a/services/rds/engine_pgext_internal_test.go +++ b/services/rds/engine_pgext_internal_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestPgExtImages(t *testing.T) { @@ -158,7 +159,7 @@ func TestCalloutServer(t *testing.T) { req.Header.Set(calloutAuthHeader, tt.token) req.Header.Set("X-Amz-Invocation-Type", "Event") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) body, _ := io.ReadAll(resp.Body) diff --git a/services/rds/engine_replica.go b/services/rds/engine_replica.go index 0928a87a6d..aaeeec162f 100644 --- a/services/rds/engine_replica.go +++ b/services/rds/engine_replica.go @@ -9,26 +9,17 @@ import ( "strconv" "sync/atomic" + "github.com/blackbirdworks/gopherstack/pkgs/store" + "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" "github.com/blackbirdworks/gopherstack/pkgs/logger" ) const ( replUser = "rdsrepl" - replKeyEnv = "RDS_REPL_KEY" serverIDMin = 2 serverIDSpan = 1<<31 - serverIDMin - - pgReplicaScript = `set -e -host="$1"; port="$2"; shift 2 -if [ ! -s "$PGDATA/PG_VERSION" ]; then - gosu postgres pg_basebackup -d "host=$host port=$port user=` + replUser + ` password=$` + replKeyEnv + `" ` + - `-D "$PGDATA" -R -X stream -c fast -fi -exec docker-entrypoint.sh "$@"` - - pgHBAScript = `f="$PGDATA/pg_hba.conf"; grep -q "^host replication ` + replUser + ` " "$f" || ` + - `echo "host replication ` + replUser + ` samenet scram-sha-256" >> "$f"` ) var errNoNetworkRuntime = errors.New("container runtime cannot create networks") @@ -231,8 +222,28 @@ func (b *InMemoryBackend) replicaPlan(ctx context.Context, lb *liveDB) (replPlan return plan, nil } -func pgServerFlags() []string { - return []string{"-c", "wal_level=replica", "-c", "max_wal_senders=10", "-c", "max_replication_slots=10"} +const paramLogicalReplication = "rds.logical_replication" + +// pgServerFlags starts PostgreSQL like RDS: wal_level=replica unless the parameter group sets +// rds.logical_replication=1. +func pgServerFlags(logical bool) []string { + level := "wal_level=replica" + if logical { + level = "wal_level=logical" + } + + return []string{"-c", level, "-c", "max_wal_senders=10", "-c", "max_replication_slots=10"} +} + +// logicalReplicationLocked reports whether the named parameter group sets rds.logical_replication to 1. +func (b *InMemoryBackend) logicalReplicationLocked(group string, groups *store.Table[DBParameterGroup]) bool { + if group == "" { + return false + } + + pg, ok := groups.Get(normalizeID(group)) + + return ok && pg.Parameters[paramLogicalReplication].ParameterValue == "1" } func mysqlServerFlags(lb *liveDB) []string { @@ -252,7 +263,7 @@ func applyReplicationFlags(s *container.Spec, lb *liveDB, plan replPlan) { return } - args := append([]string{enginePostgres}, pgServerFlags()...) + args := append([]string{enginePostgres}, pgServerFlags(lb.logical)...) if plan.network == "" { s.Cmd = args @@ -260,9 +271,9 @@ func applyReplicationFlags(s *container.Spec, lb *liveDB, plan replPlan) { } s.Entrypoint = append([]string{ - "sh", "-c", pgReplicaScript, "pg-replica", plan.srcHost, strconv.Itoa(pgContainerPort), + "sh", "-c", dbrepl.PGReplicaScript(replUser), "pg-replica", plan.srcHost, strconv.Itoa(pgContainerPort), }, args...) - s.Env = append(s.Env, replKeyEnv+"="+plan.srcPass) + s.Env = append(s.Env, dbrepl.PasswordEnv+"="+plan.srcPass) } // prepareSource creates the replication login on the source and lets it connect; safe to repeat. @@ -275,13 +286,11 @@ func (e *dbEngine) prepareSource(ctx context.Context, in unitInfo, pass string) l := in.login if l.Kind != enginePostgres { client, _ := mysqlClients(l.Kind) - stmt := fmt.Sprintf("CREATE USER IF NOT EXISTS '%[1]s'@'%%' IDENTIFIED BY '%[2]s'; "+ - "ALTER USER '%[1]s'@'%%' IDENTIFIED BY '%[2]s'; GRANT REPLICATION SLAVE ON *.* TO '%[1]s'@'%%'", - replUser, pass) + stmt := dbrepl.MySQLReplicaUserSQL(replUser, pass) - _, err := e.run(ctx, x, in.containerID, []string{ - cmdEnv, "MYSQL_PWD=" + l.Password, client, "-u" + mysqlRootUser, "-e", stmt, - }) + _, err := e.runEnv(ctx, x, in.containerID, []string{ + client, "-u" + mysqlRootUser, "-e", stmt, + }, mysqlPwdEnv(l.Password)) return err } @@ -292,19 +301,15 @@ func (e *dbEngine) prepareSource(ctx context.Context, in unitInfo, pass string) } psql := func(sqlText string) []string { - return []string{cmdEnv, "PGPASSWORD=" + l.Password, "psql", "-q", "-v", "ON_ERROR_STOP=1", - "-U", l.User, "-d", db, "-c", sqlText} + return []string{"psql", "-q", "-v", "ON_ERROR_STOP=1", "-U", l.User, "-d", db, "-c", sqlText} } - role := fmt.Sprintf("SET password_encryption='scram-sha-256'; DO $$ BEGIN "+ - "CREATE ROLE %[1]s WITH LOGIN REPLICATION PASSWORD '%[2]s'; "+ - "EXCEPTION WHEN duplicate_object THEN ALTER ROLE %[1]s WITH LOGIN REPLICATION PASSWORD '%[2]s'; END $$", - replUser, pass) + role := dbrepl.PGReplicaRoleSQL(replUser, pass) for _, cmd := range [][]string{ - psql(role), {"sh", "-c", pgHBAScript, "pg-hba"}, psql("SELECT pg_reload_conf()"), + psql(role), {"sh", "-c", dbrepl.PGHBAScript(replUser), "pg-hba"}, psql("SELECT pg_reload_conf()"), } { - if _, err = e.run(ctx, x, in.containerID, cmd); err != nil { + if _, err = e.runEnv(ctx, x, in.containerID, cmd, pgPwdEnv(l.Password)); err != nil { return err } } diff --git a/services/rds/engine_replica_sql.go b/services/rds/engine_replica_sql.go index 84202a76ed..b7ad073cba 100644 --- a/services/rds/engine_replica_sql.go +++ b/services/rds/engine_replica_sql.go @@ -1,26 +1,18 @@ package rds import ( - "cmp" "context" - "database/sql" "errors" "fmt" "regexp" - "strconv" "strings" "github.com/blackbirdworks/gopherstack/pkgs/container" + "github.com/blackbirdworks/gopherstack/pkgs/dbrepl" ) -var errReplicaNotStreaming = errors.New("replication is not running") - // ReplicaStatus is a read replica's replication health as reported by its engine. -type ReplicaStatus struct { - Message string - LagSeconds float64 - Replicating bool -} +type ReplicaStatus = dbrepl.Status func (e *dbEngine) mysqlExec(ctx context.Context, in unitInfo, stmt string) error { x, ok := e.execer() @@ -29,9 +21,9 @@ func (e *dbEngine) mysqlExec(ctx context.Context, in unitInfo, stmt string) erro } client, _ := mysqlClients(in.login.Kind) - _, err := e.run(ctx, x, in.containerID, []string{ - cmdEnv, "MYSQL_PWD=" + in.login.Password, client, "-u" + mysqlRootUser, "-e", stmt, - }) + _, err := e.runEnv(ctx, x, in.containerID, []string{ + client, "-u" + mysqlRootUser, "-e", stmt, + }, mysqlPwdEnv(in.login.Password)) return err } @@ -51,9 +43,7 @@ func mysqlChangeSourceStmt(kind string, plan replPlan) string { plan.srcHost, mysqlContainerPort, replUser, plan.srcPass) } - return fmt.Sprintf("CHANGE REPLICATION SOURCE TO SOURCE_HOST='%s', SOURCE_PORT=%d, SOURCE_USER='%s', "+ - "SOURCE_PASSWORD='%s', SOURCE_AUTO_POSITION=1, GET_SOURCE_PUBLIC_KEY=1; START REPLICA", - plan.srcHost, mysqlContainerPort, replUser, plan.srcPass) + return dbrepl.MySQLChangeSourceSQL(plan.srcHost, mysqlContainerPort, replUser, plan.srcPass) } // seedReplica finishes a MySQL/MariaDB replica: it loads a consistent dump of the source and starts replication @@ -167,10 +157,10 @@ func (e *dbEngine) promoteEngine(ctx context.Context, in unitInfo) error { return err } - out, err := e.run(ctx, x, in.containerID, []string{ - cmdEnv, "PGPASSWORD=" + l.Password, "psql", "-tA", "-U", l.User, "-d", db, + out, err := e.runEnv(ctx, x, in.containerID, []string{ + "psql", "-tA", "-U", l.User, "-d", db, "-c", "SELECT pg_promote(true, 60)", - }) + }, pgPwdEnv(l.Password)) if err != nil { return err } @@ -205,94 +195,5 @@ func queryReplicaStatus(ctx context.Context, l EngineLogin) (ReplicaStatus, erro defer func() { _ = db.Close() }() - if l.Kind == enginePostgres { - return pgReplicaStatus(ctx, db) - } - - return mysqlReplicaStatus(ctx, db) -} - -func pgReplicaStatus(ctx context.Context, db *sql.DB) (ReplicaStatus, error) { - var ( - status string - lag float64 - ) - - err := db.QueryRowContext(ctx, `SELECT COALESCE((SELECT status FROM pg_stat_wal_receiver LIMIT 1), ''), `+ - `CASE WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0 `+ - `ELSE COALESCE(EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp()), 0) END::float8`).Scan(&status, &lag) - if err != nil { - return ReplicaStatus{}, fmt.Errorf("replica status: %w", err) - } - - if status != "streaming" { - return ReplicaStatus{Message: "WAL receiver status: " + cmp.Or(status, "not running")}, nil - } - - return ReplicaStatus{Replicating: true, LagSeconds: lag}, nil -} - -func mysqlReplicaStatus(ctx context.Context, db *sql.DB) (ReplicaStatus, error) { - rows, err := db.QueryContext(ctx, "SHOW REPLICA STATUS") - if err != nil { - return ReplicaStatus{}, fmt.Errorf("replica status: %w", err) - } - - defer func() { _ = rows.Close() }() - - cols, err := rows.Columns() - if err != nil { - return ReplicaStatus{}, fmt.Errorf("replica status columns: %w", err) - } - - if !rows.Next() { - if err = rows.Err(); err != nil { - return ReplicaStatus{}, fmt.Errorf("replica status rows: %w", err) - } - - return ReplicaStatus{}, errReplicaNotStreaming - } - - vals := make([]sql.NullString, len(cols)) - ptrs := make([]any, len(cols)) - - for i := range vals { - ptrs[i] = &vals[i] - } - - if err = rows.Scan(ptrs...); err != nil { - return ReplicaStatus{}, fmt.Errorf("replica status scan: %w", err) - } - - row := make(map[string]string, len(cols)) - for i, c := range cols { - row[strings.ToLower(c)] = vals[i].String - } - - return interpretMySQLReplica(row), nil -} - -func firstOf(row map[string]string, keys ...string) string { - for _, k := range keys { - if v := row[k]; v != "" { - return v - } - } - - return "" -} - -func interpretMySQLReplica(row map[string]string) ReplicaStatus { - ioOn := firstOf(row, "replica_io_running", "slave_io_running") == "Yes" - sqlOn := firstOf(row, "replica_sql_running", "slave_sql_running") == "Yes" - - if !ioOn || !sqlOn { - msg := firstOf(row, "last_io_error", "last_sql_error") - - return ReplicaStatus{Message: cmp.Or(msg, "replication threads are not running")} - } - - lag, _ := strconv.ParseFloat(firstOf(row, "seconds_behind_source", "seconds_behind_master"), 64) - - return ReplicaStatus{Replicating: true, LagSeconds: lag} + return dbrepl.QueryStatus(ctx, db, l.Kind == enginePostgres) } diff --git a/services/rds/engine_replica_test.go b/services/rds/engine_replica_test.go index 1f4d021022..150c6ddcc0 100644 --- a/services/rds/engine_replica_test.go +++ b/services/rds/engine_replica_test.go @@ -71,6 +71,16 @@ func (s *streamFakeRuntime) Exec(ctx context.Context, id string, cmd []string) ( return s.fakeEngineRuntime.Exec(ctx, id, cmd) } +func (s *streamFakeRuntime) ExecEnv( + ctx context.Context, id string, cmd, env []string, +) (container.ExecResult, error) { + s.mu.Lock() + s.envs = append(s.envs, slices.Clone(env)) + s.mu.Unlock() + + return s.Exec(ctx, id, cmd) +} + func (s *streamFakeRuntime) netState() ([]string, []string, []string) { s.netMu.Lock() defer s.netMu.Unlock() @@ -383,3 +393,53 @@ func TestEngineReplicaFallsBackToCopyWithoutNetworks(t *testing.T) { assert.Empty(t, f.rt.spec(1).Entrypoint) assert.Empty(t, f.instance(t, "rep").StatusInfos) } + +func TestEnginePostgresWalLevelFollowsParameterGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + param string + want string + group bool + }{ + {name: "no group is replica", want: "wal_level=replica"}, + {name: "group without the parameter is replica", group: true, want: "wal_level=replica"}, + {name: "logical replication off", group: true, param: "0", want: "wal_level=replica"}, + {name: "logical replication on", group: true, param: "1", want: "wal_level=logical"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newStreamFixture(t) + opts := rds.DBInstanceOptions{MasterUserPassword: "pw"} + group := "" + + if tt.group { + _, err := f.b.CreateDBParameterGroup("pg-logical", "postgres16", "d") + require.NoError(t, err) + + if tt.param != "" { + _, err = f.b.ModifyDBParameterGroup("pg-logical", []rds.DBParameter{ + { + ParameterName: "rds.logical_replication", + ParameterValue: tt.param, + ApplyMethod: "pending-reboot", + }, + }) + require.NoError(t, err) + } + + group = "pg-logical" + } + + _, err := f.b.CreateDBInstance("src", "postgres", "db.t3.micro", "appdb", "master", group, 20, opts) + require.NoError(t, err) + f.waitAvailable(t, "src") + + assert.Contains(t, f.rt.spec(0).Cmd, tt.want) + }) + } +} diff --git a/services/rds/engine_sql.go b/services/rds/engine_sql.go index 993c8d8c9e..cc9a55fe31 100644 --- a/services/rds/engine_sql.go +++ b/services/rds/engine_sql.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "net/url" + "sync" "github.com/go-sql-driver/mysql" _ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" database/sql driver @@ -134,9 +135,11 @@ func setEnginePassword(ctx context.Context, l EngineLogin, newPassword string) e // silenceDriverLogs stops the MySQL driver printing every refused probe while the container boots. func silenceDriverLogs() { - _ = mysql.SetLogger(discardLogger{}) + silenceOnce.Do(func() { _ = mysql.SetLogger(discardLogger{}) }) } +var silenceOnce sync.Once //nolint:gochecknoglobals // guards the driver's process-wide logger + type discardLogger struct{} func (discardLogger) Print(...any) {} diff --git a/services/rds/engine_test.go b/services/rds/engine_test.go index 02e26f7cf8..c9747525ba 100644 --- a/services/rds/engine_test.go +++ b/services/rds/engine_test.go @@ -26,6 +26,7 @@ type fakeEngineRuntime struct { loaded map[string]string specs []container.Spec execs [][]string + envs [][]string mu sync.Mutex stops int starts int diff --git a/services/rds/handler_cluster_snapshots.go b/services/rds/handler_cluster_snapshots.go index 808695e29b..4380a8a612 100644 --- a/services/rds/handler_cluster_snapshots.go +++ b/services/rds/handler_cluster_snapshots.go @@ -9,7 +9,7 @@ import ( func (h *Handler) handleCreateDBClusterSnapshot(vals url.Values) (any, error) { snapshotID := vals.Get("DBClusterSnapshotIdentifier") clusterID := vals.Get("DBClusterIdentifier") - snap, err := h.Backend.CreateDBClusterSnapshot(snapshotID, clusterID) + snap, err := h.Backend.CreateDBClusterSnapshotAs(vals.Get(callerAccountParam), snapshotID, clusterID) if err != nil { return nil, err } @@ -25,7 +25,7 @@ func (h *Handler) handleCreateDBClusterSnapshot(vals url.Values) (any, error) { func (h *Handler) handleDescribeDBClusterSnapshots(vals url.Values) (any, error) { snapshotID := vals.Get("DBClusterSnapshotIdentifier") clusterID := vals.Get("DBClusterIdentifier") - snaps, err := h.Backend.DescribeDBClusterSnapshots(snapshotID, clusterID) + snaps, err := h.Backend.DescribeDBClusterSnapshotsAs(parseSnapshotQuery(vals, snapshotID, clusterID)) if err != nil { return nil, err } @@ -53,7 +53,7 @@ func (h *Handler) handleDescribeDBClusterSnapshots(vals url.Values) (any, error) func (h *Handler) handleDeleteDBClusterSnapshot(vals url.Values) (any, error) { snapshotID := vals.Get("DBClusterSnapshotIdentifier") - snap, err := h.Backend.DeleteDBClusterSnapshot(snapshotID) + snap, err := h.Backend.DeleteDBClusterSnapshotAs(vals.Get(callerAccountParam), snapshotID) if err != nil { return nil, err } @@ -68,8 +68,8 @@ func (h *Handler) handleCopyDBClusterSnapshot(vals url.Values) (any, error) { sourceSnapshotID := vals.Get("SourceDBClusterSnapshotIdentifier") targetSnapshotID := vals.Get("TargetDBClusterSnapshotIdentifier") copyTags := vals.Get("CopyTags") == formTrue - snap, err := h.Backend.CopyDBClusterSnapshotWithKMS( - sourceSnapshotID, targetSnapshotID, copyTags, vals.Get("KmsKeyId"), + snap, err := h.Backend.CopyDBClusterSnapshotAs( + vals.Get(callerAccountParam), sourceSnapshotID, targetSnapshotID, copyTags, vals.Get("KmsKeyId"), ) if err != nil { return nil, err @@ -200,7 +200,7 @@ type modifyDBClusterSnapshotAttributeResponse struct { func (h *Handler) handleDescribeDBClusterSnapshotAttributes(vals url.Values) (any, error) { snapshotID := vals.Get("DBClusterSnapshotIdentifier") - result, err := h.Backend.DescribeDBClusterSnapshotAttributes(snapshotID) + result, err := h.Backend.DescribeDBClusterSnapshotAttributesAs(vals.Get(callerAccountParam), snapshotID) if err != nil { return nil, err } @@ -219,7 +219,9 @@ func (h *Handler) handleModifyDBClusterSnapshotAttribute(vals url.Values) (any, // locationName is "AttributeValue", not the query-protocol default "member". valuesToAdd := extractMemberList(vals, "ValuesToAdd.AttributeValue.") valuesToRemove := extractMemberList(vals, "ValuesToRemove.AttributeValue.") - result, err := h.Backend.ModifyDBClusterSnapshotAttribute(snapshotID, attributeName, valuesToAdd, valuesToRemove) + result, err := h.Backend.ModifyDBClusterSnapshotAttributeAs( + vals.Get(callerAccountParam), snapshotID, attributeName, valuesToAdd, valuesToRemove, + ) if err != nil { return nil, err } diff --git a/services/rds/handler_db_clusters.go b/services/rds/handler_db_clusters.go index df9b9c8921..81a907d0f1 100644 --- a/services/rds/handler_db_clusters.go +++ b/services/rds/handler_db_clusters.go @@ -386,6 +386,7 @@ func (h *Handler) handleRestoreDBClusterFromSnapshot(vals url.Values) (any, erro return nil, err } + restoreOpts.CallerAccount = vals.Get(callerAccountParam) cluster, err := h.Backend.RestoreDBClusterFromSnapshot(clusterID, snapshotID, engine, restoreOpts) if err != nil { return nil, err diff --git a/services/rds/handler_db_snapshots.go b/services/rds/handler_db_snapshots.go index 764e9928a9..880c60b173 100644 --- a/services/rds/handler_db_snapshots.go +++ b/services/rds/handler_db_snapshots.go @@ -10,7 +10,7 @@ func (h *Handler) handleCreateDBSnapshot(vals url.Values) (any, error) { snapshotID := vals.Get("DBSnapshotIdentifier") instanceID := vals.Get("DBInstanceIdentifier") - snap, err := h.Backend.CreateDBSnapshot(snapshotID, instanceID) + snap, err := h.Backend.CreateDBSnapshotAs(vals.Get(callerAccountParam), snapshotID, instanceID) if err != nil { return nil, err } @@ -27,7 +27,7 @@ func (h *Handler) handleDescribeDBSnapshots(vals url.Values) (any, error) { snapshotID := vals.Get("DBSnapshotIdentifier") instanceID := vals.Get("DBInstanceIdentifier") - snaps, err := h.Backend.DescribeDBSnapshots(snapshotID, instanceID) + snaps, err := h.Backend.DescribeDBSnapshotsAs(parseSnapshotQuery(vals, snapshotID, instanceID)) if err != nil { return nil, err } @@ -56,7 +56,7 @@ func (h *Handler) handleDescribeDBSnapshots(vals url.Values) (any, error) { func (h *Handler) handleDeleteDBSnapshot(vals url.Values) (any, error) { snapshotID := vals.Get("DBSnapshotIdentifier") - snap, err := h.Backend.DeleteDBSnapshot(snapshotID) + snap, err := h.Backend.DeleteDBSnapshotAs(vals.Get(callerAccountParam), snapshotID) if err != nil { return nil, err } @@ -157,6 +157,7 @@ func (h *Handler) handleRestoreDBInstanceFromDBSnapshot(vals url.Values) (any, e id := vals.Get("DBInstanceIdentifier") snapshotID := vals.Get("DBSnapshotIdentifier") opts := parseRestoreDBInstanceOptions(vals) + opts.CallerAccount = vals.Get(callerAccountParam) inst, err := h.Backend.RestoreDBInstanceFromDBSnapshot(id, snapshotID, opts) if err != nil { @@ -176,9 +177,10 @@ func (h *Handler) handleCopyDBSnapshot(vals url.Values) (any, error) { targetSnapshotID := vals.Get("TargetDBSnapshotIdentifier") opts := CopyDBSnapshotOptions{ - KmsKeyID: vals.Get("KmsKeyId"), - SourceRegion: vals.Get("SourceRegion"), - CopyTags: vals.Get("CopyTags") == formTrue, + KmsKeyID: vals.Get("KmsKeyId"), + SourceRegion: vals.Get("SourceRegion"), + CopyTags: vals.Get("CopyTags") == formTrue, + CallerAccount: vals.Get(callerAccountParam), } snap, err := h.Backend.CopyDBSnapshot(sourceSnapshotID, targetSnapshotID, opts) @@ -232,7 +234,7 @@ type modifyDBSnapshotAttributeResponse struct { func (h *Handler) handleDescribeDBSnapshotAttributes(vals url.Values) (any, error) { snapshotID := vals.Get("DBSnapshotIdentifier") - result, err := h.Backend.DescribeDBSnapshotAttributes(snapshotID) + result, err := h.Backend.DescribeDBSnapshotAttributesAs(vals.Get(callerAccountParam), snapshotID) if err != nil { return nil, err } @@ -266,7 +268,9 @@ func (h *Handler) handleModifyDBSnapshotAttribute(vals url.Values) (any, error) // locationName is "AttributeValue", not the query-protocol default "member". valuesToAdd := extractMemberList(vals, "ValuesToAdd.AttributeValue.") valuesToRemove := extractMemberList(vals, "ValuesToRemove.AttributeValue.") - result, err := h.Backend.ModifyDBSnapshotAttribute(snapshotID, attributeName, valuesToAdd, valuesToRemove) + result, err := h.Backend.ModifyDBSnapshotAttributeAs( + vals.Get(callerAccountParam), snapshotID, attributeName, valuesToAdd, valuesToRemove, + ) if err != nil { return nil, err } diff --git a/services/rds/handler_dispatch.go b/services/rds/handler_dispatch.go index 8d1f5657c4..a4760f4b47 100644 --- a/services/rds/handler_dispatch.go +++ b/services/rds/handler_dispatch.go @@ -11,6 +11,7 @@ import ( "github.com/labstack/echo/v5" + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/service" @@ -199,6 +200,11 @@ func (h *Handler) Handler() echo.HandlerFunc { region := httputils.ExtractRegionFromRequest(r, h.defaultRegion) handler := h.getHandlerForRegion(region) + vals.Del(callerAccountParam) + if account, ok := awsmeta.CallerAccount(r.Context()); ok { + vals.Set(callerAccountParam, account) + } + resp, opErr := handler.dispatch(action, vals) if opErr != nil { return handler.handleOpError(c, action, opErr) diff --git a/services/rds/iam_enforcement_test.go b/services/rds/iam_enforcement_test.go index 568cb99e02..b84d571875 100644 --- a/services/rds/iam_enforcement_test.go +++ b/services/rds/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/rds" ) @@ -158,7 +159,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/rds/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/rds/models.go b/services/rds/models.go index 1b7b2f8d4c..e6bf513e8a 100644 --- a/services/rds/models.go +++ b/services/rds/models.go @@ -202,7 +202,7 @@ type PendingModifiedValues struct { // DBSnapshot represents an RDS database snapshot. type DBSnapshot struct { SnapshotCreateTime time.Time `json:"snapshotCreateTime"` - DBSnapshotIdentifier string `json:"dbSnapshotIdentifier"` + KmsKeyID string `json:"kmsKeyID,omitempty"` DBSnapshotArn string `json:"dbSnapshotArn,omitempty"` DBInstanceIdentifier string `json:"dbInstanceIdentifier"` DbiResourceID string `json:"dbiResourceId,omitempty"` @@ -210,15 +210,16 @@ type DBSnapshot struct { EngineVersion string `json:"engineVersion"` Status string `json:"status"` StorageType string `json:"storageType"` + DBSnapshotIdentifier string `json:"dbSnapshotIdentifier"` OptionGroupName string `json:"optionGroupName"` - KmsKeyID string `json:"kmsKeyID,omitempty"` - SourceRegion string `json:"sourceRegion,omitempty"` - SnapshotType string `json:"snapshotType,omitempty"` MasterUsername string `json:"masterUsername,omitempty"` + SnapshotType string `json:"snapshotType,omitempty"` + SourceRegion string `json:"sourceRegion,omitempty"` DBInstanceClass string `json:"dbInstanceClass,omitempty"` - AllocatedStorage int `json:"allocatedStorage"` + OwnerAccountID string `json:"ownerAccountID,omitempty"` Port int `json:"port"` PercentProgress int `json:"percentProgress"` + AllocatedStorage int `json:"allocatedStorage"` StorageEncrypted bool `json:"storageEncrypted"` CopyTagsToSnapshot bool `json:"copyTagsToSnapshot,omitempty"` } @@ -366,23 +367,24 @@ type DBCluster struct { type DBClusterSnapshot struct { SnapshotCreateTime time.Time `json:"snapshotCreateTime"` ClusterCreateTime time.Time `json:"clusterCreateTime,omitzero"` - Status string `json:"status"` - MasterUsername string `json:"masterUsername,omitempty"` + DBClusterSnapshotArn string `json:"dbClusterSnapshotArn,omitempty"` + Engine string `json:"engine"` SourceDBClusterSnapshotArn string `json:"sourceDBClusterSnapshotArn,omitempty"` KmsKeyID string `json:"kmsKeyId,omitempty"` SnapshotType string `json:"snapshotType,omitempty"` StorageType string `json:"storageType,omitempty"` DBClusterSnapshotIdentifier string `json:"dbClusterSnapshotIdentifier"` EngineVersion string `json:"engineVersion,omitempty"` - DBClusterSnapshotArn string `json:"dbClusterSnapshotArn,omitempty"` + Status string `json:"status"` DBClusterIdentifier string `json:"dbClusterIdentifier"` DBClusterResourceID string `json:"dbClusterResourceId,omitempty"` - Engine string `json:"engine"` - BackupRetentionPeriod int `json:"backupRetentionPeriod,omitempty"` + MasterUsername string `json:"masterUsername,omitempty"` + OwnerAccountID string `json:"ownerAccountID,omitempty"` Iops int `json:"iops,omitempty"` AllocatedStorage int `json:"allocatedStorage,omitempty"` Port int `json:"port,omitempty"` PercentProgress int `json:"percentProgress"` + BackupRetentionPeriod int `json:"backupRetentionPeriod,omitempty"` IAMDatabaseAuthEnabled bool `json:"iamDatabaseAuthenticationEnabled,omitempty"` StorageEncrypted bool `json:"storageEncrypted,omitempty"` CopyTagsToSnapshot bool `json:"copyTagsToSnapshot,omitempty"` @@ -743,6 +745,7 @@ type DBInstanceAutomatedBackup struct { // DBInstanceOptions holds optional fields for CreateDBInstance and ModifyDBInstance. type DBInstanceOptions struct { + CallerAccount string RestoreTime time.Time PinnedDbiResourceID string SourceDBClusterIdentifier string @@ -803,13 +806,15 @@ type DBInstanceOptions struct { // CopyDBSnapshotOptions holds optional fields for CopyDBSnapshot. type CopyDBSnapshotOptions struct { - KmsKeyID string - SourceRegion string - CopyTags bool + CallerAccount string + KmsKeyID string + SourceRegion string + CopyTags bool } // DBClusterOptions holds optional fields for CreateDBCluster and ModifyDBCluster. type DBClusterOptions struct { + CallerAccount string RestoreTime time.Time ServerlessV2Scaling *ServerlessV2ScalingConfiguration NetworkType string diff --git a/services/rds/snapshot_access.go b/services/rds/snapshot_access.go new file mode 100644 index 0000000000..fe70756e28 --- /dev/null +++ b/services/rds/snapshot_access.go @@ -0,0 +1,128 @@ +package rds + +import ( + "net/url" + "slices" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" +) + +const ( + callerAccountParam = "X-Gopherstack-Caller-Account" + restoreAttribute = "restore" + shareWithAll = "all" + snapshotTypeShared = "shared" + snapshotTypePublic = "public" +) + +// SnapshotQuery selects snapshots for DescribeDBSnapshots and +// DescribeDBClusterSnapshots on behalf of CallerAccount ("" = no identity, +// everything visible). +type SnapshotQuery struct { + CallerAccount string + SnapshotID string + ParentID string + SnapshotType string + IncludeShared bool + IncludePublic bool +} + +// snapshotRef splits a snapshot reference into its identifier and whether it +// was given as an ARN (the only way to address another account's snapshot). +func snapshotRef(ref string) (string, bool) { + id := rdsIDFromARN(ref) + + return id, id != ref +} + +func ownsSnapshot(caller, owner string) bool { + return caller == "" || owner == "" || owner == caller +} + +func sharedWithAccount(restore []string, account string) bool { + return account != "" && slices.Contains(restore, account) +} + +func (b *InMemoryBackend) restoreValuesLocked(kind string, id string) []string { + var attrs []DBSnapshotAttribute + + if kind == snapKindCluster { + if r, ok := b.clusterSnapshotAttributes.Get(normalizeID(id)); ok { + attrs = r.DBClusterSnapshotAttributes + } + } else if r, ok := b.snapshotAttributes.Get(normalizeID(id)); ok { + attrs = r.DBSnapshotAttributes + } + + for _, a := range attrs { + if a.AttributeName == restoreAttribute { + return a.AttributeValues + } + } + + return nil +} + +// snapshotReachableLocked reports whether caller may address the snapshot: +// its owner always, anyone else only by ARN and only when the owner shared it +// with the caller's account or with "all". +func (b *InMemoryBackend) snapshotReachableLocked(caller, owner string, kind string, id string, byARN bool) bool { + if ownsSnapshot(caller, owner) { + return true + } + + if !byARN { + return false + } + + restore := b.restoreValuesLocked(kind, id) + + return sharedWithAccount(restore, caller) || slices.Contains(restore, shareWithAll) +} + +// snapshotListedLocked applies DescribeDB(Cluster)Snapshots' SnapshotType, +// IncludeShared and IncludePublic semantics (api_op_DescribeDBSnapshots.go). +func (b *InMemoryBackend) snapshotListedLocked( + q SnapshotQuery, kind string, id, owner, storedType string, +) bool { + own := ownsSnapshot(q.CallerAccount, owner) + restore := b.restoreValuesLocked(kind, id) + public := slices.Contains(restore, shareWithAll) + + switch q.SnapshotType { + case snapshotTypeShared: + return !own && sharedWithAccount(restore, q.CallerAccount) + case snapshotTypePublic: + return public + case "": + switch { + case own: + return true + case q.IncludeShared && sharedWithAccount(restore, q.CallerAccount): + return true + default: + return q.IncludePublic && public + } + default: + return own && storedType == q.SnapshotType + } +} + +func (b *InMemoryBackend) ownedSnapshotARN(owner, resource, id string) string { + if owner == "" { + return b.rdsARN(resource, id) + } + + return arn.Build("rds", b.region, owner, resource+":"+id) +} + +func parseSnapshotQuery(vals url.Values, snapshotID, parentID string) SnapshotQuery { + return SnapshotQuery{ + CallerAccount: vals.Get(callerAccountParam), + SnapshotID: snapshotID, + ParentID: parentID, + SnapshotType: vals.Get("SnapshotType"), + IncludeShared: vals.Get("IncludeShared") == formTrue, + IncludePublic: vals.Get("IncludePublic") == formTrue, + } +} diff --git a/services/rds/snapshot_sharing_test.go b/services/rds/snapshot_sharing_test.go new file mode 100644 index 0000000000..f40f39ae17 --- /dev/null +++ b/services/rds/snapshot_sharing_test.go @@ -0,0 +1,424 @@ +package rds_test + +import ( + "net/http/httptest" + "slices" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + rdssdk "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/rds" +) + +const ( + shareOwner = "111111111111" + shareGuest = "222222222222" + shareOther = "333333333333" +) + +type shareStack struct { + clients map[string]*rdssdk.Client +} + +func newShareStack(t *testing.T) *shareStack { + t.Helper() + + b := rds.NewInMemoryBackend("000000000000", config.DefaultRegion) + t.Cleanup(b.Close) + + e := echo.New() + e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + c.SetRequest(req.WithContext(awsmeta.Set(req.Context(), awsmeta.FromRequest(req, config.DefaultRegion)))) + + return next(c) + } + }) + registry := service.NewRegistry() + require.NoError(t, registry.Register(rds.NewHandler(b))) + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + s := &shareStack{clients: map[string]*rdssdk.Client{}} + + for _, acct := range []string{shareOwner, shareGuest, shareOther} { + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion(config.DefaultRegion), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(acct, "x", "")), + ) + require.NoError(t, err) + + s.clients[acct] = rdssdk.NewFromConfig(cfg, func(o *rdssdk.Options) { o.BaseEndpoint = aws.String(srv.URL) }) + } + + return s +} + +func snapshotARN(acct, id string) string { + return "arn:aws:rds:" + config.DefaultRegion + ":" + acct + ":snapshot:" + id +} + +func clusterSnapshotARN(acct, id string) string { + return "arn:aws:rds:" + config.DefaultRegion + ":" + acct + ":cluster-snapshot:" + id +} + +// seedSnapshots has the owner create shared-with-guest, public and private snapshots. +func (s *shareStack) seedSnapshots(t *testing.T) { + t.Helper() + + owner := s.clients[shareOwner] + _, err := owner.CreateDBInstance(t.Context(), &rdssdk.CreateDBInstanceInput{ + DBInstanceIdentifier: aws.String("src"), Engine: aws.String("postgres"), + DBInstanceClass: aws.String("db.t3.micro"), MasterUsername: aws.String("admin"), + MasterUserPassword: aws.String("Passw0rd!x"), AllocatedStorage: aws.Int32(20), + }) + require.NoError(t, err) + + for _, id := range []string{"snap-shared", "snap-public", "snap-private"} { + out, cerr := owner.CreateDBSnapshot(t.Context(), &rdssdk.CreateDBSnapshotInput{ + DBInstanceIdentifier: aws.String("src"), DBSnapshotIdentifier: aws.String(id), + }) + require.NoError(t, cerr) + assert.Equal(t, snapshotARN(shareOwner, id), aws.ToString(out.DBSnapshot.DBSnapshotArn)) + } + + for id, value := range map[string]string{"snap-shared": shareGuest, "snap-public": "all"} { + _, merr := owner.ModifyDBSnapshotAttribute(t.Context(), &rdssdk.ModifyDBSnapshotAttributeInput{ + DBSnapshotIdentifier: aws.String(id), AttributeName: aws.String("restore"), + ValuesToAdd: []string{value}, + }) + require.NoError(t, merr) + } +} + +func snapshotIDs(out *rdssdk.DescribeDBSnapshotsOutput) []string { + ids := make([]string, 0, len(out.DBSnapshots)) + for _, s := range out.DBSnapshots { + ids = append(ids, aws.ToString(s.DBSnapshotIdentifier)) + } + + slices.Sort(ids) + + return ids +} + +func TestDescribeDBSnapshotsSharing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caller string + snapshotType string + want []string + includeShared bool + includePublic bool + }{ + {name: "owner default", caller: shareOwner, want: []string{"snap-private", "snap-public", "snap-shared"}}, + { + name: "owner manual", caller: shareOwner, snapshotType: "manual", + want: []string{"snap-private", "snap-public", "snap-shared"}, + }, + {name: "guest default sees nothing", caller: shareGuest, want: []string{}}, + {name: "guest include shared", caller: shareGuest, includeShared: true, want: []string{"snap-shared"}}, + {name: "guest include public", caller: shareGuest, includePublic: true, want: []string{"snap-public"}}, + { + name: "guest include both", caller: shareGuest, includeShared: true, includePublic: true, + want: []string{"snap-public", "snap-shared"}, + }, + {name: "guest shared type", caller: shareGuest, snapshotType: "shared", want: []string{"snap-shared"}}, + {name: "guest public type", caller: shareGuest, snapshotType: "public", want: []string{"snap-public"}}, + {name: "guest manual type", caller: shareGuest, snapshotType: "manual", want: []string{}}, + {name: "other include shared", caller: shareOther, includeShared: true, want: []string{}}, + {name: "other shared type", caller: shareOther, snapshotType: "shared", want: []string{}}, + {name: "other public type", caller: shareOther, snapshotType: "public", want: []string{"snap-public"}}, + {name: "owner shared type", caller: shareOwner, snapshotType: "shared", want: []string{}}, + {name: "owner public type", caller: shareOwner, snapshotType: "public", want: []string{"snap-public"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newShareStack(t) + s.seedSnapshots(t) + + in := &rdssdk.DescribeDBSnapshotsInput{ + IncludeShared: aws.Bool(tt.includeShared), IncludePublic: aws.Bool(tt.includePublic), + } + if tt.snapshotType != "" { + in.SnapshotType = aws.String(tt.snapshotType) + } + + out, err := s.clients[tt.caller].DescribeDBSnapshots(t.Context(), in) + require.NoError(t, err) + assert.Equal(t, tt.want, snapshotIDs(out)) + }) + } +} + +func TestSharedDBSnapshotAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + op string + caller string + snapshot string + wantCode string + }{ + { + name: "describe shared by arn", + op: "describe", + caller: shareGuest, + snapshot: snapshotARN(shareOwner, "snap-shared"), + }, + { + name: "describe shared by name", op: "describe", caller: shareGuest, snapshot: "snap-shared", + wantCode: "DBSnapshotNotFound", + }, + { + name: "describe private by arn", op: "describe", caller: shareGuest, + snapshot: snapshotARN(shareOwner, "snap-private"), wantCode: "DBSnapshotNotFound", + }, + { + name: "describe public by arn", + op: "describe", + caller: shareOther, + snapshot: snapshotARN(shareOwner, "snap-public"), + }, + { + name: "describe shared by third party", op: "describe", caller: shareOther, + snapshot: snapshotARN(shareOwner, "snap-shared"), wantCode: "DBSnapshotNotFound", + }, + {name: "restore shared", op: "restore", caller: shareGuest, snapshot: snapshotARN(shareOwner, "snap-shared")}, + {name: "restore public", op: "restore", caller: shareOther, snapshot: snapshotARN(shareOwner, "snap-public")}, + { + name: "restore private", op: "restore", caller: shareGuest, + snapshot: snapshotARN(shareOwner, "snap-private"), + wantCode: "DBSnapshotNotFound", + }, + { + name: "restore shared by third party", op: "restore", caller: shareOther, + snapshot: snapshotARN(shareOwner, "snap-shared"), wantCode: "DBSnapshotNotFound", + }, + { + name: "restore shared by name", op: "restore", caller: shareGuest, snapshot: "snap-shared", + wantCode: "DBSnapshotNotFound", + }, + {name: "restore own", op: "restore", caller: shareOwner, snapshot: "snap-private"}, + {name: "copy shared", op: "copy", caller: shareGuest, snapshot: snapshotARN(shareOwner, "snap-shared")}, + { + name: "copy private", op: "copy", caller: shareGuest, snapshot: snapshotARN(shareOwner, "snap-private"), + wantCode: "DBSnapshotNotFound", + }, + { + name: "modify attribute as guest", op: "modify", caller: shareGuest, snapshot: "snap-shared", + wantCode: "DBSnapshotNotFound", + }, + { + name: "describe attributes as guest", op: "attrs", caller: shareGuest, snapshot: "snap-shared", + wantCode: "DBSnapshotNotFound", + }, + { + name: "delete as guest", + op: "delete", + caller: shareGuest, + snapshot: "snap-shared", + wantCode: "DBSnapshotNotFound", + }, + {name: "delete as owner", op: "delete", caller: shareOwner, snapshot: "snap-shared"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newShareStack(t) + s.seedSnapshots(t) + c := s.clients[tt.caller] + + var err error + + switch tt.op { + case "describe": + _, err = c.DescribeDBSnapshots(t.Context(), &rdssdk.DescribeDBSnapshotsInput{ + DBSnapshotIdentifier: aws.String(tt.snapshot), + }) + case "restore": + _, err = c.RestoreDBInstanceFromDBSnapshot(t.Context(), &rdssdk.RestoreDBInstanceFromDBSnapshotInput{ + DBInstanceIdentifier: aws.String("restored"), DBSnapshotIdentifier: aws.String(tt.snapshot), + }) + case "copy": + _, err = c.CopyDBSnapshot(t.Context(), &rdssdk.CopyDBSnapshotInput{ + SourceDBSnapshotIdentifier: aws.String(tt.snapshot), TargetDBSnapshotIdentifier: aws.String("copy"), + }) + case "modify": + _, err = c.ModifyDBSnapshotAttribute(t.Context(), &rdssdk.ModifyDBSnapshotAttributeInput{ + DBSnapshotIdentifier: aws.String(tt.snapshot), AttributeName: aws.String("restore"), + ValuesToAdd: []string{shareOther}, + }) + case "attrs": + _, err = c.DescribeDBSnapshotAttributes(t.Context(), &rdssdk.DescribeDBSnapshotAttributesInput{ + DBSnapshotIdentifier: aws.String(tt.snapshot), + }) + default: + _, err = c.DeleteDBSnapshot( + t.Context(), + &rdssdk.DeleteDBSnapshotInput{DBSnapshotIdentifier: aws.String(tt.snapshot)}, + ) + } + + if tt.wantCode != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantCode) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestDBSnapshotCopyOwnedByCaller(t *testing.T) { + t.Parallel() + + s := newShareStack(t) + s.seedSnapshots(t) + + out, err := s.clients[shareGuest].CopyDBSnapshot(t.Context(), &rdssdk.CopyDBSnapshotInput{ + SourceDBSnapshotIdentifier: aws.String(snapshotARN(shareOwner, "snap-shared")), + TargetDBSnapshotIdentifier: aws.String("guest-copy"), + }) + require.NoError(t, err) + assert.Equal(t, snapshotARN(shareGuest, "guest-copy"), aws.ToString(out.DBSnapshot.DBSnapshotArn)) + + guest, err := s.clients[shareGuest].DescribeDBSnapshots(t.Context(), &rdssdk.DescribeDBSnapshotsInput{}) + require.NoError(t, err) + assert.Equal(t, []string{"guest-copy"}, snapshotIDs(guest)) + + owner, err := s.clients[shareOwner].DescribeDBSnapshots(t.Context(), &rdssdk.DescribeDBSnapshotsInput{}) + require.NoError(t, err) + assert.NotContains(t, snapshotIDs(owner), "guest-copy") +} + +func TestUnshareRevokesAccess(t *testing.T) { + t.Parallel() + + s := newShareStack(t) + s.seedSnapshots(t) + + _, err := s.clients[shareOwner].ModifyDBSnapshotAttribute(t.Context(), &rdssdk.ModifyDBSnapshotAttributeInput{ + DBSnapshotIdentifier: aws.String("snap-shared"), AttributeName: aws.String("restore"), + ValuesToRemove: []string{shareGuest}, + }) + require.NoError(t, err) + + out, err := s.clients[shareGuest].DescribeDBSnapshots(t.Context(), &rdssdk.DescribeDBSnapshotsInput{ + IncludeShared: aws.Bool(true), + }) + require.NoError(t, err) + assert.Empty(t, out.DBSnapshots) +} + +func TestSharedDBClusterSnapshots(t *testing.T) { + t.Parallel() + + s := newShareStack(t) + owner := s.clients[shareOwner] + ctx := t.Context() + + _, err := owner.CreateDBCluster(ctx, &rdssdk.CreateDBClusterInput{ + DBClusterIdentifier: aws.String( + "csrc", + ), Engine: aws.String("aurora-mysql"), MasterUsername: aws.String("admin"), + MasterUserPassword: aws.String("Passw0rd!x"), + }) + require.NoError(t, err) + + for _, id := range []string{"csnap-shared", "csnap-private"} { + _, err = owner.CreateDBClusterSnapshot(ctx, &rdssdk.CreateDBClusterSnapshotInput{ + DBClusterIdentifier: aws.String("csrc"), DBClusterSnapshotIdentifier: aws.String(id), + }) + require.NoError(t, err) + } + + _, err = owner.ModifyDBClusterSnapshotAttribute(ctx, &rdssdk.ModifyDBClusterSnapshotAttributeInput{ + DBClusterSnapshotIdentifier: aws.String("csnap-shared"), AttributeName: aws.String("restore"), + ValuesToAdd: []string{shareGuest}, + }) + require.NoError(t, err) + + guest := s.clients[shareGuest] + + def, err := guest.DescribeDBClusterSnapshots(ctx, &rdssdk.DescribeDBClusterSnapshotsInput{}) + require.NoError(t, err) + assert.Empty(t, def.DBClusterSnapshots) + + shared, err := guest.DescribeDBClusterSnapshots( + ctx, + &rdssdk.DescribeDBClusterSnapshotsInput{IncludeShared: aws.Bool(true)}, + ) + require.NoError(t, err) + require.Len(t, shared.DBClusterSnapshots, 1) + assert.Equal(t, "csnap-shared", aws.ToString(shared.DBClusterSnapshots[0].DBClusterSnapshotIdentifier)) + + byType, err := s.clients[shareOther].DescribeDBClusterSnapshots(ctx, &rdssdk.DescribeDBClusterSnapshotsInput{ + SnapshotType: aws.String("shared"), + }) + require.NoError(t, err) + assert.Empty(t, byType.DBClusterSnapshots) + + tests := []struct { + name string + caller string + snapshot string + wantCode string + }{ + {name: "restore shared", caller: shareGuest, snapshot: clusterSnapshotARN(shareOwner, "csnap-shared")}, + { + name: "restore private", caller: shareGuest, snapshot: clusterSnapshotARN(shareOwner, "csnap-private"), + wantCode: "DBClusterSnapshotNotFound", + }, + { + name: "restore by name", caller: shareGuest, snapshot: "csnap-shared", + wantCode: "DBClusterSnapshotNotFound", + }, + { + name: "restore third party", caller: shareOther, snapshot: clusterSnapshotARN(shareOwner, "csnap-shared"), + wantCode: "DBClusterSnapshotNotFound", + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, rerr := s.clients[tt.caller].RestoreDBClusterFromSnapshot(ctx, &rdssdk.RestoreDBClusterFromSnapshotInput{ + DBClusterIdentifier: aws.String("restored-" + strconv.Itoa(i)), + SnapshotIdentifier: aws.String(tt.snapshot), Engine: aws.String("aurora-mysql"), + }) + if tt.wantCode != "" { + require.Error(t, rerr) + assert.Contains(t, rerr.Error(), tt.wantCode) + + return + } + + require.NoError(t, rerr) + }) + } +} diff --git a/services/rdsdata/errsweep_wire_shape_test.go b/services/rdsdata/errsweep_wire_shape_test.go index f33a16b5c2..892396fd70 100644 --- a/services/rdsdata/errsweep_wire_shape_test.go +++ b/services/rdsdata/errsweep_wire_shape_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/rdsdata" ) @@ -50,7 +51,7 @@ func TestHandler_MalformedBodyCarriesTypedErrorCode(t *testing.T) { "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/rds-data/aws4_request, "+ "SignedHeaders=content-type;host, Signature=deadbeef") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/rdsdata/iam_enforcement_test.go b/services/rdsdata/iam_enforcement_test.go index 4bb26bcc17..bf6eea3e21 100644 --- a/services/rdsdata/iam_enforcement_test.go +++ b/services/rdsdata/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/rdsdata" ) @@ -176,7 +177,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/rds-data/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/redshift/handler_snapshots_test.go b/services/redshift/handler_snapshots_test.go index 442209bf94..b4cac69e38 100644 --- a/services/redshift/handler_snapshots_test.go +++ b/services/redshift/handler_snapshots_test.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" @@ -818,28 +819,30 @@ func TestRestoreFromClusterSnapshot_Lifecycle(t *testing.T) { t.Run("activation_delay_transitions_restoring_to_available", func(t *testing.T) { t.Parallel() - b := newRedshiftBackend() - redshift.SetClusterActivationDelay(b, 20*time.Millisecond) + synctest.Test(t, func(t *testing.T) { + b := newRedshiftBackend() + redshift.SetClusterActivationDelay(b, 20*time.Millisecond) - _, err := b.CreateCluster("src-cluster", "", "", "", nil, "", redshift.CreateClusterOptions{}) - require.NoError(t, err) - _, err = b.CreateClusterSnapshot("src-snap", "src-cluster") - require.NoError(t, err) + _, err := b.CreateCluster("src-cluster", "", "", "", nil, "", redshift.CreateClusterOptions{}) + require.NoError(t, err) + _, err = b.CreateClusterSnapshot("src-snap", "src-cluster") + require.NoError(t, err) - restored, err := b.RestoreFromClusterSnapshot( - "restored-cluster", - "src-snap", - redshift.RestoreFromClusterSnapshotOptions{}, - ) - require.NoError(t, err) - assert.Equal(t, "restoring", restored.Status, - "restored cluster should start in restoring state when an activation delay is configured") + restored, err := b.RestoreFromClusterSnapshot( + "restored-cluster", + "src-snap", + redshift.RestoreFromClusterSnapshotOptions{}, + ) + require.NoError(t, err) + assert.Equal(t, "restoring", restored.Status, + "restored cluster should start in restoring state when an activation delay is configured") - require.Eventually(t, func() bool { - clusters, _, descErr := b.DescribeClusters("restored-cluster", "", 0, nil, nil) + require.Eventually(t, func() bool { + clusters, _, descErr := b.DescribeClusters("restored-cluster", "", 0, nil, nil) - return descErr == nil && len(clusters) == 1 && clusters[0].Status == "available" - }, time.Second, 5*time.Millisecond, - "restored cluster must transition out of restoring, previously it never did") + return descErr == nil && len(clusters) == 1 && clusters[0].Status == "available" + }, time.Second, 5*time.Millisecond, + "restored cluster must transition out of restoring, previously it never did") + }) }) } diff --git a/services/redshift/iam_enforcement_test.go b/services/redshift/iam_enforcement_test.go index 98233d1594..e187302573 100644 --- a/services/redshift/iam_enforcement_test.go +++ b/services/redshift/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/redshift" ) @@ -158,7 +159,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/redshift/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/redshiftdata/iam_enforcement_test.go b/services/redshiftdata/iam_enforcement_test.go index 7277670cab..c4869c2dc6 100644 --- a/services/redshiftdata/iam_enforcement_test.go +++ b/services/redshiftdata/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/redshiftdata" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/redshift-data/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/rekognition/iam_enforcement_test.go b/services/rekognition/iam_enforcement_test.go index ae0aae33b0..f3eeed4cae 100644 --- a/services/rekognition/iam_enforcement_test.go +++ b/services/rekognition/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/rekognition" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/rekognition/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/resiliencehub/iam_enforcement_test.go b/services/resiliencehub/iam_enforcement_test.go index 80b70f58bd..d051b42826 100644 --- a/services/resiliencehub/iam_enforcement_test.go +++ b/services/resiliencehub/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/resiliencehub" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/resiliencehub/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/resiliencehub/realism_test.go b/services/resiliencehub/realism_test.go index dc07999a35..79dbe3d480 100644 --- a/services/resiliencehub/realism_test.go +++ b/services/resiliencehub/realism_test.go @@ -2,6 +2,7 @@ package resiliencehub_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -28,35 +29,39 @@ func TestTransitionDelay_Assessment(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - h, client := newTestHandlerAndClient(t) - h.Backend.SetTransitionDelay(tt.delay) - ctx := t.Context() + synctest.Test(t, func(t *testing.T) { + h, client := newInMemoryHandlerAndClient(t) + h.Backend.SetTransitionDelay(tt.delay) + ctx := t.Context() - app, err := client.CreateApp(ctx, &resiliencehubsdk.CreateAppInput{Name: aws.String("app")}) - require.NoError(t, err) + app, err := client.CreateApp(ctx, &resiliencehubsdk.CreateAppInput{Name: aws.String("app")}) + require.NoError(t, err) - started, err := client.StartAppAssessment(ctx, &resiliencehubsdk.StartAppAssessmentInput{ - AppArn: app.App.AppArn, AppVersion: aws.String("draft"), AssessmentName: aws.String("a"), - }) - require.NoError(t, err) - - status := func() types.AssessmentStatus { - out, dErr := client.DescribeAppAssessment(ctx, &resiliencehubsdk.DescribeAppAssessmentInput{ - AssessmentArn: started.Assessment.AssessmentArn, + started, err := client.StartAppAssessment(ctx, &resiliencehubsdk.StartAppAssessmentInput{ + AppArn: app.App.AppArn, AppVersion: aws.String("draft"), AssessmentName: aws.String("a"), }) - require.NoError(t, dErr) + require.NoError(t, err) + + status := func() types.AssessmentStatus { + out, dErr := client.DescribeAppAssessment(ctx, &resiliencehubsdk.DescribeAppAssessmentInput{ + AssessmentArn: started.Assessment.AssessmentArn, + }) + require.NoError(t, dErr) - return out.Assessment.AssessmentStatus - } + return out.Assessment.AssessmentStatus + } - if tt.wantStays { - assert.Equal(t, types.AssessmentStatusPending, status()) + if tt.wantStays { + assert.Equal(t, types.AssessmentStatusPending, status()) - return - } + return + } - require.Eventually(t, func() bool { return status() == types.AssessmentStatusSuccess }, - defaultAsyncWait, defaultAsyncPoll) + time.Sleep(10 * tt.delay) + synctest.Wait() + + assert.Equal(t, types.AssessmentStatusSuccess, status()) + }) }) } } diff --git a/services/resiliencehub/sdk_roundtrip_helper_test.go b/services/resiliencehub/sdk_roundtrip_helper_test.go index d6f9c526e5..880178cada 100644 --- a/services/resiliencehub/sdk_roundtrip_helper_test.go +++ b/services/resiliencehub/sdk_roundtrip_helper_test.go @@ -12,6 +12,7 @@ import ( "github.com/labstack/echo/v5" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/resiliencehub" ) @@ -79,3 +80,22 @@ func newTestHandlerAndClient(t *testing.T) (*resiliencehub.Handler, *resilienceh return h, client } + +// newInMemoryHandlerAndClient is newTestHandlerAndClient over an in-process transport so it can +// run inside a testing/synctest bubble. +func newInMemoryHandlerAndClient(t *testing.T) (*resiliencehub.Handler, *resiliencehubsdk.Client) { + t.Helper() + + backend := resiliencehub.NewInMemoryBackend(t.Context(), rtTestAccountID, rtTestRegion) + t.Cleanup(backend.Close) + + h := resiliencehub.NewHandler(backend) + client := resiliencehubsdk.NewFromConfig(aws.Config{ + Region: rtTestRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.ServeInMemory(t, h), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), + }) + + return h, client +} diff --git a/services/resourcegroups/handler_create_tags_test.go b/services/resourcegroups/handler_create_tags_test.go index cfcfd7196c..baa12c3847 100644 --- a/services/resourcegroups/handler_create_tags_test.go +++ b/services/resourcegroups/handler_create_tags_test.go @@ -1,17 +1,16 @@ package resourcegroups_test import ( - "net/http/httptest" "testing" "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" resourcegroupssdk "github.com/aws/aws-sdk-go-v2/service/resourcegroups" "github.com/labstack/echo/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/resourcegroups" ) @@ -19,7 +18,7 @@ import ( const tagsRTRegion = "us-east-1" // newTestResourceGroupsClient stands up the real aws-sdk-go-v2 -// resourcegroups client against an httptest server running this package's +// resourcegroups client over an in-process transport (synctest-safe) running this package's // Handler, wired through the same pkgs/service registry/router used in // production. func newTestResourceGroupsClient(t *testing.T, h *resourcegroups.Handler) *resourcegroupssdk.Client { @@ -30,20 +29,11 @@ func newTestResourceGroupsClient(t *testing.T, h *resourcegroups.Handler) *resou require.NoError(t, registry.Register(h)) e.Use(service.NewServiceRouter(registry).RouteHandler()) - srv := httptest.NewServer(e) - t.Cleanup(srv.Close) - - cfg, err := awscfg.LoadDefaultConfig( - t.Context(), - awscfg.WithRegion(tagsRTRegion), - awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider("test", "test", ""), - ), - ) - require.NoError(t, err) - - return resourcegroupssdk.NewFromConfig(cfg, func(o *resourcegroupssdk.Options) { - o.BaseEndpoint = aws.String(srv.URL) + return resourcegroupssdk.NewFromConfig(aws.Config{ + Region: tagsRTRegion, + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.HandlerClient(e), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), }) } diff --git a/services/resourcegroups/iam_enforcement_test.go b/services/resourcegroups/iam_enforcement_test.go index 9e0d4d60f9..0928bcb02f 100644 --- a/services/resourcegroups/iam_enforcement_test.go +++ b/services/resourcegroups/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/resourcegroups" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/resource-groups/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/resourcegroups/wire_errors_pagination_test.go b/services/resourcegroups/wire_errors_pagination_test.go index dcf9dc7ecb..014ed52833 100644 --- a/services/resourcegroups/wire_errors_pagination_test.go +++ b/services/resourcegroups/wire_errors_pagination_test.go @@ -3,6 +3,7 @@ package resourcegroups_test import ( "fmt" "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -277,25 +278,27 @@ func TestSDK_ConfigurationStatusLifecycle(t *testing.T) { func TestSDK_ConfigurationStatusCompletes(t *testing.T) { t.Parallel() - b := resourcegroups.NewInMemoryBackend("000000000000", "us-east-1") - b.SetLifecycleDelay(20 * time.Millisecond) - c := newTestResourceGroupsClient(t, resourcegroups.NewHandler(b)) - - _, err := c.CreateGroup(t.Context(), &resourcegroupssdk.CreateGroupInput{ - Name: aws.String("cfg"), - Configuration: []types.GroupConfigurationItem{{ - Type: aws.String("AWS::ResourceGroups::Generic"), - }}, - }) - require.NoError(t, err) + synctest.Test(t, func(t *testing.T) { + b := resourcegroups.NewInMemoryBackend("000000000000", "us-east-1") + b.SetLifecycleDelay(20 * time.Millisecond) + c := newTestResourceGroupsClient(t, resourcegroups.NewHandler(b)) - require.Eventually(t, func() bool { - got, getErr := c.GetGroupConfiguration(t.Context(), &resourcegroupssdk.GetGroupConfigurationInput{ - Group: aws.String("cfg"), + _, err := c.CreateGroup(t.Context(), &resourcegroupssdk.CreateGroupInput{ + Name: aws.String("cfg"), + Configuration: []types.GroupConfigurationItem{{ + Type: aws.String("AWS::ResourceGroups::Generic"), + }}, }) + require.NoError(t, err) - return getErr == nil && got.GroupConfiguration.Status == types.GroupConfigurationStatusUpdateComplete - }, 5*time.Second, 10*time.Millisecond) + require.Eventually(t, func() bool { + got, getErr := c.GetGroupConfiguration(t.Context(), &resourcegroupssdk.GetGroupConfigurationInput{ + Group: aws.String("cfg"), + }) + + return getErr == nil && got.GroupConfiguration.Status == types.GroupConfigurationStatusUpdateComplete + }, 5*time.Second, 10*time.Millisecond) + }) } func TestSDK_AccountSettingsInProgress(t *testing.T) { diff --git a/services/resourcegroupstaggingapi/cross_service.go b/services/resourcegroupstaggingapi/cross_service.go index cc1222ab44..598f82c65b 100644 --- a/services/resourcegroupstaggingapi/cross_service.go +++ b/services/resourcegroupstaggingapi/cross_service.go @@ -4,6 +4,8 @@ import ( "context" "slices" "time" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" ) // TaggedResource represents a resource with its ARN, type, and tag set. @@ -125,11 +127,11 @@ func (b *InMemoryBackend) getResources( tagFilters []TagFilter, typeFilters []string, ) []TaggedResource { - region := getRegion(ctx, b.defaultRegion) + cacheKey := awsmeta.Account(ctx) + "/" + getRegion(ctx, b.defaultRegion) useCache := len(tagFilters) == 0 && len(typeFilters) == 0 if useCache { - if c := b.caches[region]; c != nil && time.Now().Before(c.expiresAt) { + if c := b.caches[cacheKey]; c != nil && time.Now().Before(c.expiresAt) { return c.resources } } @@ -146,7 +148,7 @@ func (b *InMemoryBackend) getResources( all := deduplicateResources(slices.Concat(perProvider...)) if useCache { - b.caches[region] = &resourceCache{ + b.caches[cacheKey] = &resourceCache{ resources: all, expiresAt: time.Now().Add(resourceCacheTTL), } diff --git a/services/resourcegroupstaggingapi/iam_enforcement_test.go b/services/resourcegroupstaggingapi/iam_enforcement_test.go index 6252ef589b..ef5ddc94bd 100644 --- a/services/resourcegroupstaggingapi/iam_enforcement_test.go +++ b/services/resourcegroupstaggingapi/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/resourcegroupstaggingapi" ) @@ -163,7 +164,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/tagging/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/resourcegroupstaggingapi/resource_cache_account_test.go b/services/resourcegroupstaggingapi/resource_cache_account_test.go new file mode 100644 index 0000000000..ad4ba940c2 --- /dev/null +++ b/services/resourcegroupstaggingapi/resource_cache_account_test.go @@ -0,0 +1,31 @@ +package resourcegroupstaggingapi_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + rgt "github.com/blackbirdworks/gopherstack/services/resourcegroupstaggingapi" +) + +func TestGetResources_CacheIsPerAccount(t *testing.T) { + t.Parallel() + + b := rgt.NewInMemoryBackend("000000000000", "us-east-1") + b.RegisterProvider(func(ctx context.Context) []rgt.TaggedResource { + return []rgt.TaggedResource{{ + ResourceARN: "arn:aws:sqs:us-east-1:" + awsmeta.Account(ctx) + ":q", Tags: map[string]string{"k": "v"}, + }} + }) + + for _, acct := range []string{"000000000000", "111111111111", "000000000000"} { + ctx := awsmeta.Set(t.Context(), &awsmeta.Metadata{Region: "us-east-1", Account: acct}) + out, err := b.GetResources(ctx, &rgt.GetResourcesInput{}) + require.NoError(t, err) + require.Len(t, out.ResourceTagMappingList, 1) + assert.Contains(t, out.ResourceTagMappingList[0].ResourceARN, ":"+acct+":") + } +} diff --git a/services/rolesanywhere/iam_enforcement_test.go b/services/rolesanywhere/iam_enforcement_test.go index 4783192f11..60e1c06469 100644 --- a/services/rolesanywhere/iam_enforcement_test.go +++ b/services/rolesanywhere/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/rolesanywhere" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/rolesanywhere/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/route53/iam_enforcement_test.go b/services/route53/iam_enforcement_test.go index f373ec8b47..fa25fc2ff5 100644 --- a/services/route53/iam_enforcement_test.go +++ b/services/route53/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/route53" ) @@ -169,7 +170,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/route53/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/route53recoverycontrolconfig/endpoints_test.go b/services/route53recoverycontrolconfig/endpoints_test.go index 5e7d130190..17dc354833 100644 --- a/services/route53recoverycontrolconfig/endpoints_test.go +++ b/services/route53recoverycontrolconfig/endpoints_test.go @@ -12,15 +12,20 @@ import ( "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) -type hostRoundTripper struct{ host string } +type hostRoundTripper struct { + rt http.RoundTripper + host string +} func (h hostRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { r = r.Clone(r.Context()) r.Host = h.host - return http.DefaultTransport.RoundTrip(r) + return h.rt.RoundTrip(r) } func (f *fixture) clientVia(t *testing.T, host string) *sdk.Client { @@ -34,7 +39,7 @@ func (f *fixture) clientVia(t *testing.T, host string) *sdk.Client { return sdk.NewFromConfig(cfg, func(o *sdk.Options) { o.BaseEndpoint = aws.String(f.url) - o.HTTPClient = &http.Client{Transport: hostRoundTripper{host: host}} + o.HTTPClient = &http.Client{Transport: hostRoundTripper{host: host, rt: testclient.New(t).Transport}} }) } diff --git a/services/route53resolver/iam_enforcement_test.go b/services/route53resolver/iam_enforcement_test.go index 7f0c8a8cbd..a9472b2ff3 100644 --- a/services/route53resolver/iam_enforcement_test.go +++ b/services/route53resolver/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/route53resolver" ) @@ -172,7 +173,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/route53resolver/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/s3/iam_enforcement_test.go b/services/s3/iam_enforcement_test.go index a2423fdce7..8d46c49c87 100644 --- a/services/s3/iam_enforcement_test.go +++ b/services/s3/iam_enforcement_test.go @@ -16,6 +16,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/s3" ) @@ -178,7 +179,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/s3/multipart.go b/services/s3/multipart.go index 1228b41a20..5709ca437d 100644 --- a/services/s3/multipart.go +++ b/services/s3/multipart.go @@ -180,7 +180,7 @@ func (b *InMemoryBackend) UploadPart( } tr := io.TeeReader(input.Body, io.MultiWriter(writers...)) - originalSize, err := io.Copy(io.Discard, tr) + originalSize, err := copyPooled(io.Discard, tr) if err != nil { return nil, err } diff --git a/services/s3/object_lambda_test.go b/services/s3/object_lambda_test.go index c2c5760be7..a65e2c99d7 100644 --- a/services/s3/object_lambda_test.go +++ b/services/s3/object_lambda_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" "github.com/blackbirdworks/gopherstack/services/s3" ) @@ -56,7 +57,7 @@ func (l *staticObjectLambda) InvokeFunction( wgorReq.Header.Set("X-Amz-Fwd-Status", l.fwdStatus) } - wgorResp, err := http.DefaultClient.Do(wgorReq) + wgorResp, err := noKeepAliveClient().Do(wgorReq) if err != nil { return nil, 0, err } @@ -455,3 +456,10 @@ func TestS3ObjectLambda_AliasCannotCreateBucket(t *testing.T) { serveS3Handler(handler, rec, httptest.NewRequest(http.MethodPut, "/myolap-abcd1234--ol-s3", nil)) assert.Equal(t, http.StatusBadRequest, rec.Code) } + +func noKeepAliveClient() *http.Client { + tr := httpclient.NewTransport() + tr.DisableKeepAlives = true + + return &http.Client{Transport: tr} +} diff --git a/services/s3/object_ops_get.go b/services/s3/object_ops_get.go index 1414d75f66..42a0ffb116 100644 --- a/services/s3/object_ops_get.go +++ b/services/s3/object_ops_get.go @@ -102,7 +102,7 @@ func (h *S3Handler) getObject( w.WriteHeader(http.StatusOK) - written, copyErr := io.Copy(w, ver.Body) + written, copyErr := copyPooled(w, ver.Body) if copyErr != nil { logger.Load(ctx).ErrorContext(ctx, "failed to write object data", "error", copyErr) } diff --git a/services/s3/objects.go b/services/s3/objects.go index 6e5158cd31..e691b87d71 100644 --- a/services/s3/objects.go +++ b/services/s3/objects.go @@ -1283,7 +1283,7 @@ func (b *InMemoryBackend) computeObjectHashes( } } - n, err := io.Copy(io.MultiWriter(writers...), body) + n, err := copyPooled(io.MultiWriter(writers...), body) if err != nil { return 0, nil, "", nil, err } diff --git a/services/s3/utils.go b/services/s3/utils.go index ce6d7b485c..ca2c6b0e2f 100644 --- a/services/s3/utils.go +++ b/services/s3/utils.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "strings" + "sync" "github.com/aws/aws-sdk-go-v2/aws" @@ -197,3 +198,22 @@ func verifyBodyContentMD5(r *http.Request) error { return nil } + +const copyBufSize = 32 * 1024 + +var copyBufPool = sync.Pool{ //nolint:gochecknoglobals // sync.Pool requires package-level allocation + New: func() any { + b := make([]byte, copyBufSize) + + return &b + }, +} + +// copyPooled is io.Copy with a pooled scratch buffer, avoiding the 32KiB +// allocation io.Copy makes when neither side implements WriterTo/ReaderFrom. +func copyPooled(dst io.Writer, src io.Reader) (int64, error) { + bp, _ := copyBufPool.Get().(*[]byte) + defer copyBufPool.Put(bp) + + return io.CopyBuffer(dst, src, *bp) +} diff --git a/services/s3control/iam_enforcement_test.go b/services/s3control/iam_enforcement_test.go index 1f24d059c7..e64887776f 100644 --- a/services/s3control/iam_enforcement_test.go +++ b/services/s3control/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/s3control" ) @@ -167,7 +168,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/s3tables/iam_enforcement_test.go b/services/s3tables/iam_enforcement_test.go index a312fb5d22..9cfd696e6a 100644 --- a/services/s3tables/iam_enforcement_test.go +++ b/services/s3tables/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/s3tables" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/s3tables/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/s3tables/metadata_writer.go b/services/s3tables/metadata_writer.go index 6767084aa5..4e4ab31f52 100644 --- a/services/s3tables/metadata_writer.go +++ b/services/s3tables/metadata_writer.go @@ -4,11 +4,12 @@ import ( "context" "encoding/json" "fmt" - "maps" "strings" "time" "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" ) const ( @@ -52,7 +53,12 @@ func (b *InMemoryBackend) materializeMetadata(t *Table, bucketID string) (string return "", nil } - body, err := json.Marshal(buildIcebergTableMetadata(t, time.Now())) + doc, err := buildIcebergTableMetadata(t, time.Now()) + if err != nil { + return "", fmt.Errorf("build iceberg metadata: %w", err) + } + + body, err := iceberg.EncodeMetadata(doc) if err != nil { return "", fmt.Errorf("encode iceberg metadata: %w", err) } @@ -157,14 +163,32 @@ func icebergSortOrder(iceberg map[string]any) map[string]any { } // buildIcebergTableMetadata renders the Iceberg v2 table metadata document for a freshly created table. -func buildIcebergTableMetadata(t *Table, now time.Time) map[string]any { - iceberg, _ := t.Metadata[metaKeyIceberg].(map[string]any) - schema, lastColumnID := icebergSchema(iceberg) - spec, lastPartitionID := icebergPartitionSpec(iceberg) - order := icebergSortOrder(iceberg) +func buildIcebergTableMetadata(t *Table, now time.Time) (*iceberg.TableMetadata, error) { + src, _ := t.Metadata[metaKeyIceberg].(map[string]any) + schemaMap, _ := icebergSchema(src) + specMap, _ := icebergPartitionSpec(src) + order := icebergSortOrder(src) + + var schema iceberg.Schema + + var spec iceberg.PartitionSpec + + if err := roundTrip(schemaMap, &schema); err != nil { + return nil, err + } + + if err := roundTrip(specMap, &spec); err != nil { + return nil, err + } + + orderJSON, err := json.Marshal(order) + if err != nil { + return nil, err + } props := map[string]string{} - if p, ok := iceberg["properties"].(map[string]any); ok { + + if p, ok := src["properties"].(map[string]any); ok { for k, v := range p { if s, isStr := v.(string); isStr { props[k] = s @@ -172,24 +196,17 @@ func buildIcebergTableMetadata(t *Table, now time.Time) map[string]any { } } - return map[string]any{ - "format-version": icebergFormatVersion, - "table-uuid": uuid.NewString(), - "location": t.WarehouseLocation, - "last-sequence-number": 0, - "last-updated-ms": now.UnixMilli(), - "last-column-id": lastColumnID, - "current-schema-id": 0, - "schemas": []map[string]any{schema}, - "default-spec-id": spec["spec-id"], - "partition-specs": []map[string]any{spec}, - "last-partition-id": lastPartitionID, - "default-sort-order-id": order["order-id"], - "sort-orders": []map[string]any{order}, - "properties": maps.Clone(props), - "current-snapshot-id": -1, - "snapshots": []any{}, - "snapshot-log": []any{}, - "metadata-log": []any{}, + return iceberg.NewTableMetadata(iceberg.NewParams{ + Location: t.WarehouseLocation, Schema: schema, Spec: spec, SortOrder: orderJSON, + Properties: props, Now: now, + }), nil +} + +func roundTrip(in map[string]any, out any) error { + b, err := json.Marshal(in) + if err != nil { + return err } + + return json.Unmarshal(b, out) } diff --git a/services/s3vectors/malformed_test.go b/services/s3vectors/malformed_test.go index 8c3c29f6aa..89218cfcff 100644 --- a/services/s3vectors/malformed_test.go +++ b/services/s3vectors/malformed_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestMalformedBodiesAreClientErrors(t *testing.T) { @@ -55,7 +57,7 @@ func TestMalformedBodiesAreClientErrors(t *testing.T) { req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "aws-sdk-go-v2/1.41.0 api/s3vectors#1.0.0") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sagemaker/iam_enforcement_test.go b/services/sagemaker/iam_enforcement_test.go index 66d60e2808..440b0a0c3f 100644 --- a/services/sagemaker/iam_enforcement_test.go +++ b/services/sagemaker/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/sagemaker" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/sagemaker/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sagemaker/request_realism_test.go b/services/sagemaker/request_realism_test.go index 1bfc295a96..ac36d79e39 100644 --- a/services/sagemaker/request_realism_test.go +++ b/services/sagemaker/request_realism_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" @@ -167,95 +168,101 @@ func TestRequestRealism_ListPaging(t *testing.T) { func TestRequestRealism_TrainingSecondaryStatusProgression(t *testing.T) { t.Parallel() - h := newTestHandler(t) - h.Backend.SetLifecycleDelay(20 * time.Millisecond) + synctest.Test(t, func(t *testing.T) { + h := newTestHandler(t) + h.Backend.SetLifecycleDelay(20 * time.Millisecond) - rec := doSageMakerRequest(t, h, "CreateTrainingJob", trainingBody("progress", nil)) - require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + rec := doSageMakerRequest(t, h, "CreateTrainingJob", trainingBody("progress", nil)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) - var got struct { - TrainingJobStatus string `json:"TrainingJobStatus"` - SecondaryStatus string `json:"SecondaryStatus"` - SecondaryStatusTransitions []struct { - EndTime *float64 `json:"EndTime"` - Status string `json:"Status"` - } `json:"SecondaryStatusTransitions"` - } + var got struct { + TrainingJobStatus string `json:"TrainingJobStatus"` + SecondaryStatus string `json:"SecondaryStatus"` + SecondaryStatusTransitions []struct { + EndTime *float64 `json:"EndTime"` + Status string `json:"Status"` + } `json:"SecondaryStatusTransitions"` + } - require.Eventually(t, func() bool { - r := doSageMakerRequest(t, h, "DescribeTrainingJob", map[string]any{"TrainingJobName": "progress"}) - got.SecondaryStatusTransitions = nil + require.Eventually(t, func() bool { + r := doSageMakerRequest(t, h, "DescribeTrainingJob", map[string]any{"TrainingJobName": "progress"}) + got.SecondaryStatusTransitions = nil - return json.Unmarshal(r.Body.Bytes(), &got) == nil && got.TrainingJobStatus == "Completed" - }, 5*time.Second, 5*time.Millisecond) + return json.Unmarshal(r.Body.Bytes(), &got) == nil && got.TrainingJobStatus == "Completed" + }, 5*time.Second, 5*time.Millisecond) - statuses := make([]string, 0, len(got.SecondaryStatusTransitions)) - for _, tr := range got.SecondaryStatusTransitions { - statuses = append(statuses, tr.Status) - assert.NotNil(t, tr.EndTime, tr.Status) - } + statuses := make([]string, 0, len(got.SecondaryStatusTransitions)) + for _, tr := range got.SecondaryStatusTransitions { + statuses = append(statuses, tr.Status) + assert.NotNil(t, tr.EndTime, tr.Status) + } - assert.Equal(t, []string{"Starting", "Downloading", "Training", "Uploading", "Completed"}, statuses) - assert.Equal(t, "Completed", got.SecondaryStatus) + assert.Equal(t, []string{"Starting", "Downloading", "Training", "Uploading", "Completed"}, statuses) + assert.Equal(t, "Completed", got.SecondaryStatus) + }) } func TestRequestRealism_StopCompletedTrainingJobIsNoop(t *testing.T) { t.Parallel() - h := newTestHandler(t) - h.Backend.SetLifecycleDelay(time.Millisecond) + synctest.Test(t, func(t *testing.T) { + h := newTestHandler(t) + h.Backend.SetLifecycleDelay(time.Millisecond) + + require.Equal(t, http.StatusOK, doSageMakerRequest(t, h, "CreateTrainingJob", trainingBody("done", nil)).Code) - require.Equal(t, http.StatusOK, doSageMakerRequest(t, h, "CreateTrainingJob", trainingBody("done", nil)).Code) + require.Eventually(t, func() bool { + var out struct { + TrainingJobStatus string `json:"TrainingJobStatus"` + } + r := doSageMakerRequest(t, h, "DescribeTrainingJob", map[string]any{"TrainingJobName": "done"}) + + return json.Unmarshal(r.Body.Bytes(), &out) == nil && out.TrainingJobStatus == "Completed" + }, 5*time.Second, 5*time.Millisecond) + + require.Equal(t, http.StatusOK, + doSageMakerRequest(t, h, "StopTrainingJob", map[string]any{"TrainingJobName": "done"}).Code) - require.Eventually(t, func() bool { var out struct { TrainingJobStatus string `json:"TrainingJobStatus"` } r := doSageMakerRequest(t, h, "DescribeTrainingJob", map[string]any{"TrainingJobName": "done"}) - - return json.Unmarshal(r.Body.Bytes(), &out) == nil && out.TrainingJobStatus == "Completed" - }, 5*time.Second, 5*time.Millisecond) - - require.Equal(t, http.StatusOK, - doSageMakerRequest(t, h, "StopTrainingJob", map[string]any{"TrainingJobName": "done"}).Code) - - var out struct { - TrainingJobStatus string `json:"TrainingJobStatus"` - } - r := doSageMakerRequest(t, h, "DescribeTrainingJob", map[string]any{"TrainingJobName": "done"}) - require.NoError(t, json.Unmarshal(r.Body.Bytes(), &out)) - assert.Equal(t, "Completed", out.TrainingJobStatus) + require.NoError(t, json.Unmarshal(r.Body.Bytes(), &out)) + assert.Equal(t, "Completed", out.TrainingJobStatus) + }) } func TestRequestRealism_StopFinishedProcessingJobKeepsStatus(t *testing.T) { t.Parallel() - h := newTestHandler(t) - h.Backend.SetLifecycleDelay(time.Millisecond) + synctest.Test(t, func(t *testing.T) { + h := newTestHandler(t) + h.Backend.SetLifecycleDelay(time.Millisecond) - create := map[string]any{ - "ProcessingJobName": "pj", - "RoleArn": realismRole, - "AppSpecification": map[string]any{"ImageUri": "img"}, - "ProcessingResources": map[string]any{"ClusterConfig": map[string]any{ - "InstanceCount": 1, "InstanceType": "ml.m5.large", "VolumeSizeInGB": 1, - }}, - } - require.Equal(t, http.StatusOK, doSageMakerRequest(t, h, "CreateProcessingJob", create).Code) - - status := func() string { - var out struct { - ProcessingJobStatus string `json:"ProcessingJobStatus"` + create := map[string]any{ + "ProcessingJobName": "pj", + "RoleArn": realismRole, + "AppSpecification": map[string]any{"ImageUri": "img"}, + "ProcessingResources": map[string]any{"ClusterConfig": map[string]any{ + "InstanceCount": 1, "InstanceType": "ml.m5.large", "VolumeSizeInGB": 1, + }}, } - r := doSageMakerRequest(t, h, "DescribeProcessingJob", map[string]any{"ProcessingJobName": "pj"}) - _ = json.Unmarshal(r.Body.Bytes(), &out) + require.Equal(t, http.StatusOK, doSageMakerRequest(t, h, "CreateProcessingJob", create).Code) - return out.ProcessingJobStatus - } + status := func() string { + var out struct { + ProcessingJobStatus string `json:"ProcessingJobStatus"` + } + r := doSageMakerRequest(t, h, "DescribeProcessingJob", map[string]any{"ProcessingJobName": "pj"}) + _ = json.Unmarshal(r.Body.Bytes(), &out) + + return out.ProcessingJobStatus + } - require.Eventually(t, func() bool { return status() == "Completed" }, 5*time.Second, 5*time.Millisecond) - require.Equal(t, http.StatusOK, - doSageMakerRequest(t, h, "StopProcessingJob", map[string]any{"ProcessingJobName": "pj"}).Code) + require.Eventually(t, func() bool { return status() == "Completed" }, 5*time.Second, 5*time.Millisecond) + require.Equal(t, http.StatusOK, + doSageMakerRequest(t, h, "StopProcessingJob", map[string]any{"ProcessingJobName": "pj"}).Code) - assert.Equal(t, "Completed", status()) + assert.Equal(t, "Completed", status()) + }) } diff --git a/services/sagemakerruntime/handler_test.go b/services/sagemakerruntime/handler_test.go index 620f3d7a99..e160516882 100644 --- a/services/sagemakerruntime/handler_test.go +++ b/services/sagemakerruntime/handler_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/sagemakerruntime" ) @@ -87,6 +88,7 @@ func newTestSDKClient(t *testing.T, h *sagemakerruntime.Handler) *sagemakerrunti cfg, err := awscfg.LoadDefaultConfig( t.Context(), awscfg.WithRegion("us-east-1"), + awscfg.WithHTTPClient(sdktest.PlainBodyHTTPClient()), awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) require.NoError(t, err) diff --git a/services/sagemakerruntime/iam_enforcement_test.go b/services/sagemakerruntime/iam_enforcement_test.go index 5861da56cc..2082d53a37 100644 --- a/services/sagemakerruntime/iam_enforcement_test.go +++ b/services/sagemakerruntime/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/sagemakerruntime" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/sagemaker/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/scheduler/iam_enforcement_test.go b/services/scheduler/iam_enforcement_test.go index af94f57fce..c52a5153fc 100644 --- a/services/scheduler/iam_enforcement_test.go +++ b/services/scheduler/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/scheduler" ) @@ -171,7 +172,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/scheduler/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/secretsmanager/iam_enforcement_test.go b/services/secretsmanager/iam_enforcement_test.go index f951148177..6d46875ff7 100644 --- a/services/secretsmanager/iam_enforcement_test.go +++ b/services/secretsmanager/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/secretsmanager" ) @@ -161,7 +162,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/secretsmanager/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/securityhub/iam_enforcement_test.go b/services/securityhub/iam_enforcement_test.go index 8864e4c2e9..32b8428a94 100644 --- a/services/securityhub/iam_enforcement_test.go +++ b/services/securityhub/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/securityhub" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/securityhub/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/serverlessrepo/iam_enforcement_test.go b/services/serverlessrepo/iam_enforcement_test.go index 993cafcce4..516b87874e 100644 --- a/services/serverlessrepo/iam_enforcement_test.go +++ b/services/serverlessrepo/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/serverlessrepo" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/serverlessrepo/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/serverlessrepo/wire_errors_lifecycle_test.go b/services/serverlessrepo/wire_errors_lifecycle_test.go index c0f8fec20b..8cbc7af9bf 100644 --- a/services/serverlessrepo/wire_errors_lifecycle_test.go +++ b/services/serverlessrepo/wire_errors_lifecycle_test.go @@ -2,21 +2,35 @@ package serverlessrepo_test import ( "testing" + "testing/synctest" "time" "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" sarsdk "github.com/aws/aws-sdk-go-v2/service/serverlessapplicationrepository" "github.com/aws/aws-sdk-go-v2/service/serverlessapplicationrepository/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/services/serverlessrepo" ) +func newInMemorySARClient(t *testing.T, h *serverlessrepo.Handler) *sarsdk.Client { + t.Helper() + + return sarsdk.NewFromConfig(aws.Config{ + Region: "us-east-1", + BaseEndpoint: aws.String(sdktest.InMemoryEndpoint), + HTTPClient: sdktest.ServeInMemory(t, h), + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), + }) +} + func newSARFixture(t *testing.T, b *serverlessrepo.InMemoryBackend) *sarsdk.Client { t.Helper() - client, _ := newTestSARSDKClient(t, serverlessrepo.NewHandler(b)) + client := newInMemorySARClient(t, serverlessrepo.NewHandler(b)) _, err := client.CreateApplication(t.Context(), &sarsdk.CreateApplicationInput{ Name: aws.String("app"), Description: aws.String("d"), @@ -284,21 +298,23 @@ func TestSDK_TemplateStatusLifecycle(t *testing.T) { func TestSDK_TemplateStatusCompletes(t *testing.T) { t.Parallel() - b := serverlessrepo.NewInMemoryBackend("000000000000", "us-east-1") - b.SetTemplatePrepareDelay(20 * time.Millisecond) - client := newSARFixture(t, b) - - created, err := client.CreateCloudFormationTemplate(t.Context(), &sarsdk.CreateCloudFormationTemplateInput{ - ApplicationId: aws.String("app"), - }) - require.NoError(t, err) - require.Equal(t, types.StatusPreparing, created.Status) + synctest.Test(t, func(t *testing.T) { + b := serverlessrepo.NewInMemoryBackend("000000000000", "us-east-1") + b.SetTemplatePrepareDelay(20 * time.Millisecond) + client := newSARFixture(t, b) - require.Eventually(t, func() bool { - got, getErr := client.GetCloudFormationTemplate(t.Context(), &sarsdk.GetCloudFormationTemplateInput{ - ApplicationId: aws.String("app"), TemplateId: created.TemplateId, + created, err := client.CreateCloudFormationTemplate(t.Context(), &sarsdk.CreateCloudFormationTemplateInput{ + ApplicationId: aws.String("app"), }) + require.NoError(t, err) + require.Equal(t, types.StatusPreparing, created.Status) - return getErr == nil && got.Status == types.StatusActive - }, 5*time.Second, 10*time.Millisecond) + require.Eventually(t, func() bool { + got, getErr := client.GetCloudFormationTemplate(t.Context(), &sarsdk.GetCloudFormationTemplateInput{ + ApplicationId: aws.String("app"), TemplateId: created.TemplateId, + }) + + return getErr == nil && got.Status == types.StatusActive + }, 5*time.Second, 10*time.Millisecond) + }) } diff --git a/services/serverlessrepo/wire_sdk_roundtrip_test.go b/services/serverlessrepo/wire_sdk_roundtrip_test.go index c9292597ac..fc2ac4f7af 100644 --- a/services/serverlessrepo/wire_sdk_roundtrip_test.go +++ b/services/serverlessrepo/wire_sdk_roundtrip_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/serverlessrepo" ) @@ -86,7 +87,7 @@ func TestCreateApplication_NoTopLevelSourceCodeURL_SDKRoundTrip(t *testing.T) { "AWS4-HMAC-SHA256 Credential=test/20230101/us-east-1/serverlessrepo/aws4_request", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/servicediscovery/iam_enforcement_test.go b/services/servicediscovery/iam_enforcement_test.go index 5311a7e341..22161f0ed9 100644 --- a/services/servicediscovery/iam_enforcement_test.go +++ b/services/servicediscovery/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/servicediscovery" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/servicediscovery/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ses/iam_enforcement_test.go b/services/ses/iam_enforcement_test.go index 4d9f814275..8ac432edcf 100644 --- a/services/ses/iam_enforcement_test.go +++ b/services/ses/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/ses" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ses/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sesv2/iam_enforcement_test.go b/services/sesv2/iam_enforcement_test.go index dc32583342..68a48cc335 100644 --- a/services/sesv2/iam_enforcement_test.go +++ b/services/sesv2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/sesv2" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ses/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/shield/iam_enforcement_test.go b/services/shield/iam_enforcement_test.go index a65c423708..a7c6d3edf1 100644 --- a/services/shield/iam_enforcement_test.go +++ b/services/shield/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/shield" ) @@ -164,7 +165,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/shield/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sns/errsweep_wire_shape_test.go b/services/sns/errsweep_wire_shape_test.go index 827bf6b07c..dd41eb6a66 100644 --- a/services/sns/errsweep_wire_shape_test.go +++ b/services/sns/errsweep_wire_shape_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/sns" ) @@ -91,7 +92,7 @@ func rawSNSErrorBody(t *testing.T, url, action, formBody string) []byte { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "aws-sdk-go2/1.30.0 api/sns#1.42.4") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sns/iam_enforcement_test.go b/services/sns/iam_enforcement_test.go index ac69c4426d..5f2c0c0244 100644 --- a/services/sns/iam_enforcement_test.go +++ b/services/sns/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/sns" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/sns/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sns/realism_sdk_test.go b/services/sns/realism_sdk_test.go index 2f2dfa77fd..d5fdd1b650 100644 --- a/services/sns/realism_sdk_test.go +++ b/services/sns/realism_sdk_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/sns" ) @@ -233,7 +234,7 @@ func TestSubscribe_HTTPConfirmationFlow_SDK(t *testing.T) { req, reqErr := http.NewRequestWithContext(t.Context(), http.MethodGet, msg["SubscribeURL"], nil) require.NoError(t, reqErr) - resp, getErr := http.DefaultClient.Do(req) + resp, getErr := testclient.New(t).Do(req) require.NoError(t, getErr) body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() diff --git a/services/sqs/errsweep_wire_shape_test.go b/services/sqs/errsweep_wire_shape_test.go index f4764df485..07d8b0515f 100644 --- a/services/sqs/errsweep_wire_shape_test.go +++ b/services/sqs/errsweep_wire_shape_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/sqs" ) @@ -105,7 +106,7 @@ func rawSQSErrorBody(t *testing.T, url, action, jsonBody string) []byte { req.Header.Set("Content-Type", "application/x-amz-json-1.0") req.Header.Set("X-Amz-Target", "AmazonSQS."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sqs/handler_oversized_body_test.go b/services/sqs/handler_oversized_body_test.go index 8452ba6528..fa557af6d9 100644 --- a/services/sqs/handler_oversized_body_test.go +++ b/services/sqs/handler_oversized_body_test.go @@ -18,6 +18,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/sqs" ) @@ -86,7 +87,7 @@ func TestRouteMatcher_OversizedQueryProtocolBodyRoutesInsteadOf404(t *testing.T) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "aws-sdk-go2/1.30.0 api/sqs#1.46.4") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/sqs/iam_enforcement_test.go b/services/sqs/iam_enforcement_test.go index 1b77f79524..aafaee02cd 100644 --- a/services/sqs/iam_enforcement_test.go +++ b/services/sqs/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/sqs" ) @@ -157,7 +158,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/sqs/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ssm/iam_enforcement_test.go b/services/ssm/iam_enforcement_test.go index d5156b7c30..a1162adc53 100644 --- a/services/ssm/iam_enforcement_test.go +++ b/services/ssm/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/ssm" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/ssm/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ssm/models_sessions.go b/services/ssm/models_sessions.go index e5f512f045..b747ffc2e7 100644 --- a/services/ssm/models_sessions.go +++ b/services/ssm/models_sessions.go @@ -108,6 +108,8 @@ type Session struct { DocumentName string `json:"DocumentName,omitempty"` AccessType string `json:"AccessType,omitempty"` MaxSessionDuration string `json:"MaxSessionDuration,omitempty"` + ContainerID string `json:"ContainerId,omitempty"` + ExecCommand string `json:"ExecCommand,omitempty"` StartDate float64 `json:"StartDate"` EndDate float64 `json:"EndDate,omitempty"` } diff --git a/services/ssm/sessions.go b/services/ssm/sessions.go index 0447037fbe..1c7eeb324b 100644 --- a/services/ssm/sessions.go +++ b/services/ssm/sessions.go @@ -324,6 +324,50 @@ func (b *InMemoryBackend) StartSession( }, nil } +// ContainerSessionInput describes a Session Manager session that runs Command in a docker container. +type ContainerSessionInput struct { + Target string + ContainerID string + Command string + StreamBase string +} + +// StartContainerSession creates a session whose data channel runs Command in a container (ECS Exec). +func (b *InMemoryBackend) StartContainerSession( + ctx context.Context, + in ContainerSessionInput, +) (*StartSessionOutput, error) { + opener := b.shellOpener() + if _, ok := opener.(CommandShellOpener); !ok { + return nil, errNoShellBackend + } + + if in.StreamBase == "" { + return nil, errNoStreamBase + } + + region := getRegion(ctx) + b.mu.Lock("StartContainerSession") + defer b.mu.Unlock() + + sessionID := sessionIDPrefix + uuid.NewString() + sess := Session{ + SessionID: sessionID, + Target: in.Target, + Status: sessionStatusConnected, + StartDate: UnixTimeFloat(timeNow()), + StreamURL: in.StreamBase + dataChannelPath + sessionID + "?role=publish_subscribe", + TokenValue: uuid.NewString(), + AccessType: accessTypeStandard, + ContainerID: in.ContainerID, + ExecCommand: in.Command, + } + + b.sessionsStore(region).Put(&sess) + + return &StartSessionOutput{SessionID: sessionID, StreamURL: sess.StreamURL, TokenValue: sess.TokenValue}, nil +} + // TerminateSession terminates an active SSM session. func (b *InMemoryBackend) TerminateSession( ctx context.Context, diff --git a/services/ssm/ssmmessages.go b/services/ssm/ssmmessages.go index 1c5ce5aa1a..cac8a6d813 100644 --- a/services/ssm/ssmmessages.go +++ b/services/ssm/ssmmessages.go @@ -21,6 +21,11 @@ type ShellOpener interface { OpenShell(ctx context.Context, containerID string, cols, rows uint) (Shell, error) } +// CommandShellOpener runs an arbitrary command in a container with a TTY. +type CommandShellOpener interface { + OpenCommand(ctx context.Context, containerID string, cmd []string, cols, rows uint) (Shell, error) +} + type streamBaseKey struct{} const schemeHTTPS = "https" @@ -33,6 +38,13 @@ func (b *InMemoryBackend) SetShellOpener(o ShellOpener) { b.shells = o } +// ShellsEnabled reports whether command sessions in containers can be opened. +func (b *InMemoryBackend) ShellsEnabled() bool { + _, ok := b.shellOpener().(CommandShellOpener) + + return ok +} + func (b *InMemoryBackend) shellOpener() ShellOpener { b.mu.RLock("shellOpener") defer b.mu.RUnlock() diff --git a/services/ssm/ssmmessages_container_test.go b/services/ssm/ssmmessages_container_test.go new file mode 100644 index 0000000000..59955db537 --- /dev/null +++ b/services/ssm/ssmmessages_container_test.go @@ -0,0 +1,100 @@ +package ssm_test + +import ( + "context" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/ssm" +) + +type commandOpener struct { + fakeOpener + + container string + cmd []string + mu sync.Mutex +} + +func (o *commandOpener) OpenCommand(_ context.Context, id string, cmd []string, _, _ uint) (ssm.Shell, error) { + o.mu.Lock() + o.container, o.cmd = id, cmd + o.mu.Unlock() + + return &fakeShell{out: make(chan []byte, 8)}, nil +} + +func TestContainerSession(t *testing.T) { + t.Parallel() + + tests := []struct { + opener ssm.ShellOpener + wantErr string + name string + base string + }{ + {name: "streams_command", opener: &commandOpener{}, base: "ws://"}, + {name: "no_opener", wantErr: "no shell backend"}, + {name: "opener_without_commands", opener: fakeOpener{}, wantErr: "no shell backend"}, + {name: "no_base", opener: &commandOpener{}, base: "-", wantErr: "no host"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, bk := newTestHandler(t) + e := echo.New() + e.Any("/*", h.Handler()) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + if tt.opener != nil { + bk.SetShellOpener(tt.opener) + } + + base := "" + if tt.base == "ws://" { + base = "ws://" + strings.TrimPrefix(srv.URL, "http://") + } + + out, err := bk.StartContainerSession(t.Context(), ssm.ContainerSessionInput{ + Target: "ecs:c_t_r", ContainerID: "cid-1", Command: "echo hi", StreamBase: base, + }) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + + ws, _, err := websocket.DefaultDialer.DialContext(t.Context(), out.StreamURL, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ws.Close() }) + + c := &dcClient{t: t, ws: ws} + c.open(out.TokenValue) + c.handshake(map[int64]bool{}) + c.input(ssm.PayloadOutput, []byte("echo ok\n")) + + got := c.nextNonAck(map[int64]bool{}) + assert.Equal(t, "ok\n", string(got.Payload)) + + op, isOpener := tt.opener.(*commandOpener) + require.True(t, isOpener) + op.mu.Lock() + defer op.mu.Unlock() + assert.Equal(t, "cid-1", op.container) + assert.Equal(t, []string{"sh", "-c", "echo hi"}, op.cmd) + }) + } +} diff --git a/services/ssm/ssmmessages_docker.go b/services/ssm/ssmmessages_docker.go index ef90ab5860..4c47d40319 100644 --- a/services/ssm/ssmmessages_docker.go +++ b/services/ssm/ssmmessages_docker.go @@ -39,5 +39,12 @@ func (d *dockerShellOpener) OpenShell(ctx context.Context, containerID string, c ) } +// OpenCommand runs command through sh in the container with a TTY. +func (d *dockerShellOpener) OpenCommand( + ctx context.Context, containerID string, cmd []string, cols, rows uint, +) (Shell, error) { + return d.api.ContainerExecInteractive(context.WithoutCancel(ctx), containerID, cmd, cols, rows) +} + // Close releases the Docker client. func (d *dockerShellOpener) Close() error { return d.api.Close() } diff --git a/services/ssm/ssmmessages_server.go b/services/ssm/ssmmessages_server.go index 6be037d017..2a376d9b47 100644 --- a/services/ssm/ssmmessages_server.go +++ b/services/ssm/ssmmessages_server.go @@ -34,6 +34,7 @@ const ( var ( errNoShellBackend = errors.New("no shell backend is configured (EC2 compute provider is not docker)") + errNoStreamBase = errors.New("the request carries no host to build a data channel URL from") errTargetNotContainer = errors.New("target is not a running docker-backed managed instance") ) @@ -71,6 +72,8 @@ type dataChannel struct { sessionID string region string target string + container string + command string document string wg sync.WaitGroup wmu sync.Mutex @@ -118,6 +121,7 @@ func (d *dataChannel) serve(parent context.Context) { } d.region, d.target, d.document, d.params = region, sess.Target, sess.DocumentName, sess.Parameters + d.container, d.command = sess.ContainerID, sess.ExecCommand d.started = time.Now() d.b.trackChannel(d.sessionID, cancel) @@ -311,6 +315,10 @@ func (d *dataChannel) startShell(ctx context.Context) error { return errNoShellBackend } + if d.container != "" { + return d.startContainerCommand(ctx, opener) + } + containerID, ok := d.b.shellContainer(d.region, d.target) if !ok { return fmt.Errorf("%w: %s", errTargetNotContainer, d.target) @@ -327,6 +335,23 @@ func (d *dataChannel) startShell(ctx context.Context) error { return nil } +func (d *dataChannel) startContainerCommand(ctx context.Context, opener ShellOpener) error { + cmdOpener, ok := opener.(CommandShellOpener) + if !ok { + return errNoShellBackend + } + + sh, err := cmdOpener.OpenCommand(ctx, d.container, []string{"sh", "-c", d.command}, d.cols, d.rows) + if err != nil { + return err + } + + d.shell = sh + d.wg.Go(func() { d.pumpShell(sh) }) + + return nil +} + func (d *dataChannel) pumpShell(sh Shell) { defer close(d.exited) diff --git a/services/ssm/ssmmessages_test.go b/services/ssm/ssmmessages_test.go index 19c0d4eb4f..7cb7bec9d3 100644 --- a/services/ssm/ssmmessages_test.go +++ b/services/ssm/ssmmessages_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/ssm" ) @@ -195,7 +196,7 @@ func ssmCall(t *testing.T, srv *httptest.Server, op string, in any) map[string]a req.Header.Set("X-Amz-Target", "AmazonSSM."+op) req.Header.Set("Content-Type", "application/x-amz-json-1.1") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ssoadmin/iam_enforcement_test.go b/services/ssoadmin/iam_enforcement_test.go index 159fc01299..48cc5a4e4d 100644 --- a/services/ssoadmin/iam_enforcement_test.go +++ b/services/ssoadmin/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/ssoadmin" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/sso/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ssooidc/routing_test.go b/services/ssooidc/routing_test.go index cb3fae5f57..7b2a300594 100644 --- a/services/ssooidc/routing_test.go +++ b/services/ssooidc/routing_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oidc "github.com/blackbirdworks/gopherstack/services/ssooidc" ) @@ -101,7 +102,7 @@ func TestErrorBodyShape(t *testing.T) { req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, f.srv.URL+"/token", http.NoBody) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/ssooidc/verification_test.go b/services/ssooidc/verification_test.go index 81cd0c492d..eef22b4594 100644 --- a/services/ssooidc/verification_test.go +++ b/services/ssooidc/verification_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" oidc "github.com/blackbirdworks/gopherstack/services/ssooidc" ) @@ -31,7 +32,7 @@ func (f fixture) page(t *testing.T, method, rawURL string, form url.Values) (int req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/stepfunctions/PARITY.md b/services/stepfunctions/PARITY.md index b230e185cc..db91c73c96 100644 --- a/services/stepfunctions/PARITY.md +++ b/services/stepfunctions/PARITY.md @@ -516,11 +516,10 @@ filter_semantics: {status: ok, note: "gopherstack-uox6 (value-semantics sweep, 2 gaps: [] structural_gaps: - "ExecutionThrottled is never emitted: the emulator applies no account-level StartExecution throttling." - - "ItemReader InputType=PARQUET (asl.ErrParquetUnsupported) fails with a sentinel error: no pure-Go Parquet reader is in go.mod." - - "TestState RevealSecrets has no effect and TRACE adds no HTTP request/response: the executor runs without SDK integrations, so HTTP Tasks and EventBridge connection secrets are unavailable." items_still_open: + - "ItemReader InputType PARQUET and TestState HTTP/TRACE are implemented from the Step Functions developer guide (input-output-itemreader, test-state-isolation) but several details are undocumented and unverified: the JSON form of Parquet timestamp/date/decimal values (rendered as Athena prints them, e.g. '2024-03-05 10:20:30.000'), the string encoding of InspectionData request/response headers (a JSON object of name to joined value here), the exact redaction form when revealSecrets is false (auth headers dropped, other secret values replaced with ***), the VersionId rejection for PARQUET (not enforced), and the 100,000,000 MaxItems cap (not enforced). states:RevealSecrets is not authorized separately under --enforce-iam: the enforcement middleware evaluates one action per request (states:TestState)." - "TestState StateConfiguration.ErrorCausedByState/MapIterationFailureCount/MapItemReaderData are accepted but unused (the pinned SDK documents the fields but not their observable effect), and MockInput.fieldValidationMode is enum-checked but mocks are not validated against service API models (no per-service response schemas). The pinned SDK models no StateConfiguration.Variables." - - "JSONata (gopherstack-iisrz) gaps (ToleratedFailureCount/Percentage expressions are supported: TestJSONata_ToleratedFailureExpressions): Items given as a JSON object (AWS accepts array or object but the docs checked, state-map-distributed and input-output-itemspath, do not give the per-pair item shape; objects are rejected with States.QueryEvaluationError); ItemReader/ItemBatcher/ResultWriter expressions; Retry Output/Assign; the Expression-evaluation memory limit is not enforced; the AWS wording of JSONPath-field-in-JSONata validation errors is undocumented, so a plain InvalidDefinition message is used; omitted Task Arguments passes the state input (unverified against AWS)." + - "JSONata (gopherstack-iisrz) still open: (1) the per-pair iteration input for an object-valued Map Items is not specified in the docs checked (input-output-itemspath says only 'passing the pair to the iteration'; the context-object page documents $states.context.Map.Item.Key/Value, 'Key only valid for JSON objects'), so the default input is {\"Key\",\"Value\"} and pairs iterate in sorted-key order (JSON object order is not kept); an ItemReader JSON file holding an object (docs: 'must contain an array or JSON object') is still rejected. (2) The Expression evaluation memory limit has no documented numeric threshold: gnata's sequence-size caps (D2014/D2015) stand in for it. (3) The AWS wording of JSONPath-field-in-JSONata validation errors is undocumented (ValidateStateMachineDefinitionDiagnostic documents codes, SCHEMA_VALIDATION_FAILED among them, and states that wording must not be relied on), so a plain InvalidDefinition message is used. (4) What a JSONata Task with omitted Arguments sends is undocumented (state-task: Arguments is 'Optional'); the state input is passed, unverified. (5) ReaderConfig.MaxItems as a JSONata expression is undocumented (docs only offer MaxItemsPath, JSONPath only) and is not accepted." - "Service integrations not implemented (bd gopherstack-wdw): bedrock optimized integrations other than invokeModel (createModelCustomizationJob.sync etc.; only InvokeModel is served by bedrockruntime), eks:call against a cluster without the Docker EKS engine (fails the task: the synthetic endpoint is not reachable), and aws-sdk integrations for services outside the 55-service table in sdk_services.go. Not documented by AWS and unverified: States.Http.StatusCode. Cause format, SQS/SNS/StepFunctions/Bedrock optimized error prefixes, ECS/Glue/EKS .sync output (Glue keeps GetJobRun {JobRun}; EKS returns DescribeCluster/DescribeNodegroup, deletes return the delete response)." - "Service-initiated calls not yet authorized against a customer role under --enforce-iam (2026-10-03): Firehose delivery role (S3/Redshift/HTTP destinations), CloudWatch Logs subscription-filter RoleArn (Kinesis/Firehose destinations), IoT rule action roleArn, SNS SubscriptionRoleArn Firehose subscriptions, Step Functions activity paths, Scheduler DLQ sqs:SendMessage under the schedule role, and EventBridge Lambda/SQS/SNS targets (AWS authorizes them by the target's resource policy, which gopherstack does not evaluate for these in-process deliveries)." deferred: [] @@ -1573,3 +1572,32 @@ History: ExecutionStarted/Succeeded/Failed/Aborted/TimedOut now carry their *Eve ## 2026-10-22 EKS and Bedrock integrations, S3 role authorization `eks:createCluster/deleteCluster/createNodegroup/deleteNodegroup` (+ `.sync`, polling DescribeCluster/DescribeNodegroup; delete polls until ResourceNotFoundException) and `eks:call` (Kubernetes request to the cluster endpoint with an `aws eks get-token` style bearer and the supplied CA) run through the generic SDK integration; `bedrock:invokeModel` encodes `Body` and returns it parsed. S3 ItemReader/ResultWriter calls are authorized as the execution role (s3:GetObject/ListBucket/PutObject). The IAM enforcement middleware now answers REST-JSON services (eks, bedrock, lambda, apigateway) with `X-Amzn-Errortype: AccessDeniedException` JSON instead of query XML. Proof: internal/app/sfn_eks_bedrock_test.go, eks_call_test.go, s3_role_authz_test.go. + +## 2026-10-30 (JSONata Distributed Map, gopherstack-iisrz) + +Sources (docs.aws.amazon.com/step-functions/latest/dg): transforming-data, state-map-distributed, state-map-inline, input-output-itemreader, input-output-itembatcher, input-output-resultwriter, input-output-itemspath, input-output-contextobject, concepts-error-handling, state-task. + +- Map `Items` accepts an array, an object or an expression evaluating to either (transforming-data, state-map-distributed). state-map-inline says "array" only; the newer, wider rule is the default and `SFN_JSONATA_MAP_OBJECT_ITEMS=false` (Settings.JSONataMapObjectItems) restores array-only. An object yields one `{"Key","Value"}` iteration per pair; ItemSelector sees `$states.context.Map.Item.Key/Value/Index`. +- ItemReader/ResultWriter `Arguments` (replacing `Parameters`) and ItemBatcher `MaxItemsPerBatch`/`MaxInputBytesPerBatch`/`BatchInput` are evaluated as JSONata at Map entry; batch limits must be positive integers (`States.QueryEvaluationError` otherwise). JSONPath-only fields (`Parameters`, `*Path`) are rejected in JSONata states and `Arguments`/expressions in JSONPath states. +- Retry has no Output/Assign in the docs (retrier fields: ErrorEquals, IntervalSeconds, MaxAttempts, BackoffRate, MaxDelaySeconds, JitterStrategy); Catch Output/Assign were already implemented. Nothing to add. +- Documented evaluation errors map to `States.QueryEvaluationError` with Cause `Expression evaluation timeout` (1s), `Stack overflow error` and `Expression evaluation memory limit exceeded`. + +## 2026-10-31 -- Parquet ItemReader and TestState HTTP TRACE (gopherstack-49qct.84) + +Sources: developer guide "ItemReader (Map)" (InputType PARQUET: 256MB row +groups, 5MB footers, GZIP/ZSTD/Snappy internal compression, VersionId +unsupported; ATHENA_DATA with CSV/JSONL/PARQUET; LOAD_AND_FLATTEN) and +"TestState API" (TRACE only for an HTTP Task, otherwise an error; revealSecrets +needs states:RevealSecrets and cannot combine with a mock); SDK +types.InspectionDataRequest/Response (protocol, method, url, headers, body / +protocol, statusCode, statusMessage, headers, body). + +- `asl.decodeParquetItems` reads Parquet via `pkgs/parquetio`, one JSON object + per row; the size limits and unreadable files fail as `States.ItemReaderFailed` + (`TestItemReader_Parquet`, `TestItemReader_ParquetFlattenAndAthena`, + `TestDecodeReaderItems_ParquetLimits`). `ErrParquetUnsupported` is gone. +- TestState runs an `http:invoke` Task against the real connection (backend SDK + integration) when no mock is given; TRACE returns inspectionData.request and + .response; secrets are redacted unless revealSecrets + (`TestStepFunctionsTestStateHTTPTrace`, internal/app). TRACE on a non-HTTP + state and revealSecrets with a mock are ValidationException. diff --git a/services/stepfunctions/README.md b/services/stepfunctions/README.md index 3f5767afc6..2fe7bdb966 100644 --- a/services/stepfunctions/README.md +++ b/services/stepfunctions/README.md @@ -9,15 +9,16 @@ | --- | --- | | PARITY entries audited | 37 (37 ok) | | Feature families | 10 (10 ok) | -| Known gaps | 4 | -| Structural gaps (can't be emulated) | 3 | +| Known gaps | 5 | +| Structural gaps (can't be emulated) | 1 | | Deferred items | 0 | | Resource leaks | clean | ### Known gaps +- ItemReader InputType PARQUET and TestState HTTP/TRACE are implemented from the Step Functions developer guide (input-output-itemreader, test-state-isolation) but several details are undocumented and unverified: the JSON form of Parquet timestamp/date/decimal values (rendered as Athena prints them, e.g. '2024-03-05 10:20:30.000'), the string encoding of InspectionData request/response headers (a JSON object of name to joined value here), the exact redaction form when revealSecrets is false (auth headers dropped, other secret values replaced with ***), the VersionId rejection for PARQUET (not enforced), and the 100,000,000 MaxItems cap (not enforced). states:RevealSecrets is not authorized separately under --enforce-iam: the enforcement middleware evaluates one action per request (states:TestState). - TestState StateConfiguration.ErrorCausedByState/MapIterationFailureCount/MapItemReaderData are accepted but unused (the pinned SDK documents the fields but not their observable effect), and MockInput.fieldValidationMode is enum-checked but mocks are not validated against service API models (no per-service response schemas). The pinned SDK models no StateConfiguration.Variables. -- JSONata (gopherstack-iisrz) gaps (ToleratedFailureCount/Percentage expressions are supported: TestJSONata_ToleratedFailureExpressions): Items given as a JSON object (AWS accepts array or object but the docs checked, state-map-distributed and input-output-itemspath, do not give the per-pair item shape; objects are rejected with States.QueryEvaluationError); ItemReader/ItemBatcher/ResultWriter expressions; Retry Output/Assign; the Expression-evaluation memory limit is not enforced; the AWS wording of JSONPath-field-in-JSONata validation errors is undocumented, so a plain InvalidDefinition message is used; omitted Task Arguments passes the state input (unverified against AWS). +- JSONata (gopherstack-iisrz) still open: (1) the per-pair iteration input for an object-valued Map Items is not specified in the docs checked (input-output-itemspath says only 'passing the pair to the iteration'; the context-object page documents $states.context.Map.Item.Key/Value, 'Key only valid for JSON objects'), so the default input is {"Key","Value"} and pairs iterate in sorted-key order (JSON object order is not kept); an ItemReader JSON file holding an object (docs: 'must contain an array or JSON object') is still rejected. (2) The Expression evaluation memory limit has no documented numeric threshold: gnata's sequence-size caps (D2014/D2015) stand in for it. (3) The AWS wording of JSONPath-field-in-JSONata validation errors is undocumented (ValidateStateMachineDefinitionDiagnostic documents codes, SCHEMA_VALIDATION_FAILED among them, and states that wording must not be relied on), so a plain InvalidDefinition message is used. (4) What a JSONata Task with omitted Arguments sends is undocumented (state-task: Arguments is 'Optional'); the state input is passed, unverified. (5) ReaderConfig.MaxItems as a JSONata expression is undocumented (docs only offer MaxItemsPath, JSONPath only) and is not accepted. - Service integrations not implemented (bd gopherstack-wdw): bedrock optimized integrations other than invokeModel (createModelCustomizationJob.sync etc.; only InvokeModel is served by bedrockruntime), eks:call against a cluster without the Docker EKS engine (fails the task: the synthetic endpoint is not reachable), and aws-sdk integrations for services outside the 55-service table in sdk_services.go. Not documented by AWS and unverified: States.Http.StatusCode. Cause format, SQS/SNS/StepFunctions/Bedrock optimized error prefixes, ECS/Glue/EKS .sync output (Glue keeps GetJobRun {JobRun}; EKS returns DescribeCluster/DescribeNodegroup, deletes return the delete response). - Service-initiated calls not yet authorized against a customer role under --enforce-iam (2026-10-03): Firehose delivery role (S3/Redshift/HTTP destinations), CloudWatch Logs subscription-filter RoleArn (Kinesis/Firehose destinations), IoT rule action roleArn, SNS SubscriptionRoleArn Firehose subscriptions, Step Functions activity paths, Scheduler DLQ sqs:SendMessage under the schedule role, and EventBridge Lambda/SQS/SNS targets (AWS authorizes them by the target's resource policy, which gopherstack does not evaluate for these in-process deliveries). @@ -26,8 +27,6 @@ These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. - ExecutionThrottled is never emitted: the emulator applies no account-level StartExecution throttling. -- ItemReader InputType=PARQUET (asl.ErrParquetUnsupported) fails with a sentinel error: no pure-Go Parquet reader is in go.mod. -- TestState RevealSecrets has no effect and TRACE adds no HTTP request/response: the executor runs without SDK integrations, so HTTP Tasks and EventBridge connection secrets are unavailable. ## More diff --git a/services/stepfunctions/asl/executor.go b/services/stepfunctions/asl/executor.go index 221c279f1e..c1071f55c4 100644 --- a/services/stepfunctions/asl/executor.go +++ b/services/stepfunctions/asl/executor.go @@ -445,7 +445,7 @@ func (c *jsonPathCache) store(path string, parts []string) { // Executor runs an ASL state machine. type Executor struct { - inspection map[string]any + activity ActivityInvoker lambda LambdaInvoker mapItemValue any callback TaskTokenCallbackInvoker @@ -464,28 +464,30 @@ type Executor struct { distributedMapRunner DistributedMapRunner s3w S3Writer s3 S3Reader - activity ActivityInvoker - matchedRule *ChoiceRule - jsonPathCache *jsonPathCache sm *StateMachine + contextOverride map[string]any + jsonPathCache *jsonPathCache + inspection map[string]any jx *jxScope + jxMapCfg *jxMapConfig + matchedRule *ChoiceRule mock *MockRun outerVars map[string]any execSem *semaphore.Weighted jxNums map[string]int vars map[string]any varSizes map[string]int - contextOverride map[string]any execMeta executionMeta + caughtCause string branchName string currentMapState string lastNext string caughtErr string - caughtCause string - jxPct float64 mapItemIdx int + jxPct float64 retrierRetryCount int outerVarBytes int + rejectObjectItems bool jxPctSet bool inMapItem bool caught bool @@ -699,6 +701,11 @@ func (e *Executor) SetSNSIntegration(sns SNSIntegration) { e.sns = sns } // SetDynamoDBIntegration configures the DynamoDB integration for Task states. func (e *Executor) SetDynamoDBIntegration(ddb DynamoDBIntegration) { e.dynamodb = ddb } +// SetRejectMapObjectItems makes a JSONata Map reject an object-valued Items. +// Default accepts it: the Distributed Map and JSONata pages document array or +// object, while the Inline Map page documents arrays only. +func (e *Executor) SetRejectMapObjectItems(reject bool) { e.rejectObjectItems = reject } + // SetS3Reader configures the S3 reader for Map state ItemReader. func (e *Executor) SetS3Reader(s3 S3Reader) { e.s3 = s3 } @@ -2329,7 +2336,7 @@ func (e *Executor) executeMap( rawBatches := batchItems(items, maxItemsPerBatch, maxInputBytesPerBatch) - batched, wrapErr := wrapItemBatcherBatches(rawBatches, state.ItemBatcher.BatchInput) + batched, wrapErr := wrapItemBatcherBatches(rawBatches, e.batchInput(state.ItemBatcher)) if wrapErr != nil { return nil, wrapErr } @@ -2350,7 +2357,7 @@ func (e *Executor) executeMap( func (e *Executor) selectMapItems(state *State, items []any) ([]any, error) { switch { case len(state.ItemSelector) == 0: - return items, nil + return unwrapMapPairs(items), nil case state.jx != nil: return e.jxItemSelector(state, items) default: @@ -2440,6 +2447,10 @@ var ErrMaxInputBytesPerBatchPathNotNumber = errors.New( // MaxInputBytesPerBatch(Path) against the Map state's pre-Parameters input, // the same way resolveToleratedFailureCount resolves ToleratedFailureCountPath. func (e *Executor) resolveItemBatcherLimits(batcher *ItemBatcher, mapInput any) (int, int, error) { + if e.jxMapCfg != nil { + return e.jxMapCfg.maxItems, e.jxMapCfg.maxBytes, nil + } + maxItems := batcher.MaxItemsPerBatch if batcher.MaxItemsPerBatchPath != "" { @@ -2527,6 +2538,14 @@ func batchItems(items []any, maxItemsPerBatch, maxInputBytesPerBatch int) []any return batches } +func (e *Executor) batchInput(b *ItemBatcher) json.RawMessage { + if e.jxMapCfg != nil { + return e.jxMapCfg.batchInput + } + + return b.BatchInput +} + // wrapItemBatcherBatches converts raw item batches into the AWS Map // iteration-input shape for ItemBatcher: {"Items": [...]}, plus a // "BatchInput" field merged in when ItemBatcher.BatchInput is set (AWS docs: @@ -2661,12 +2680,6 @@ var ErrS3ListReaderNotConfigured = errors.New("S3 list reader not configured for // this emulator doesn't recognize. var ErrItemReaderUnsupportedResource = errors.New("ItemReader: unsupported Resource") -// ErrParquetUnsupported is returned for InputType PARQUET, which this -// emulator doesn't decode (no pure-Go Parquet reader dependency) -- see PARITY.md. -var ErrParquetUnsupported = errors.New( - "ItemReader: InputType PARQUET is not supported by this emulator", -) - // ErrItemReaderInvalidData is returned when ItemReader S3 object cannot be parsed as items. var ErrItemReaderInvalidData = errors.New( "ItemReader: unable to parse S3 object as JSON array or JSON lines", @@ -2674,7 +2687,14 @@ var ErrItemReaderInvalidData = errors.New( func (e *Executor) resolveMapItems(ctx context.Context, state *State, input any) ([]any, error) { if state.ItemReader != nil { - items, err := e.resolveItemsFromReader(ctx, state.ItemReader) + reader := state.ItemReader + if e.jxMapCfg != nil && e.jxMapCfg.readerParams != nil { + rd := *reader + rd.Parameters = e.jxMapCfg.readerParams + reader = &rd + } + + items, err := e.resolveItemsFromReader(ctx, reader) if err != nil { return nil, err } @@ -2762,7 +2782,7 @@ func (e *Executor) resolveItemsFromS3GetObject(ctx context.Context, reader *Item } bucket, _ := reader.Parameters["Bucket"].(string) - key, _ := reader.Parameters["Key"].(string) + key, _ := reader.Parameters[fieldKey].(string) data, err := e.getS3Object(ctx, bucket, key) if err != nil { @@ -2812,7 +2832,7 @@ func (e *Executor) resolveItemsFromS3List(ctx context.Context, reader *ItemReade for i, o := range objs { items[i] = map[string]any{ "Etag": o.ETag, - "Key": o.Key, + fieldKey: o.Key, "LastModified": awstime.Epoch(o.LastModified), "Size": o.Size, "StorageClass": o.StorageClass, @@ -3042,7 +3062,7 @@ func decodeReaderItems(data []byte, cfg *ReaderConfig) ([]any, error) { case "", "JSON": return decodeJSONItems(data, cfg) case "PARQUET": - return nil, ErrParquetUnsupported + return decodeParquetItems(data) default: return nil, fmt.Errorf("%w: unsupported InputType %q", ErrItemReaderInvalidData, inputType) } diff --git a/services/stepfunctions/asl/intrinsics_extras_test.go b/services/stepfunctions/asl/intrinsics_extras_test.go index 09caca2bce..2b17d07f2c 100644 --- a/services/stepfunctions/asl/intrinsics_extras_test.go +++ b/services/stepfunctions/asl/intrinsics_extras_test.go @@ -339,3 +339,27 @@ func TestIntrinsics_UndocumentedFunctionsRejected(t *testing.T) { }) } } + +func TestDecodeReaderItems_ParquetLimits(t *testing.T) { + t.Parallel() + + oversizedFooter := append(make([]byte, 16), 0, 0, 0x60, 0, 'P', 'A', 'R', '1') + + tests := []struct { + wantErr error + name string + data []byte + }{ + {name: "footer_over_5mb", data: oversizedFooter, wantErr: asl.ErrParquetFooterTooLarge}, + {name: "not_parquet", data: []byte("plain text, no magic"), wantErr: asl.ErrParquetInvalid}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := asl.DecodeReaderItems(tt.data, &asl.ReaderConfig{InputType: "PARQUET"}) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/services/stepfunctions/asl/item_reader_parquet.go b/services/stepfunctions/asl/item_reader_parquet.go new file mode 100644 index 0000000000..f98cec716a --- /dev/null +++ b/services/stepfunctions/asl/item_reader_parquet.go @@ -0,0 +1,80 @@ +package asl + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +const ( + parquetMaxRowGroupBytes = 256 << 20 + parquetMaxFooterBytes = 5 << 20 + parquetTrailerBytes = 8 +) + +var ( + // ErrParquetInvalid is returned for bytes that do not decode as a Parquet file. + ErrParquetInvalid = errors.New("ItemReader: unable to read Parquet file") + // ErrParquetRowGroupTooLarge is returned for a row group over the documented 256MB limit. + ErrParquetRowGroupTooLarge = errors.New("ItemReader: Parquet row group exceeds the 256MB limit") + // ErrParquetFooterTooLarge is returned for a footer over the documented 5MB limit. + ErrParquetFooterTooLarge = errors.New("ItemReader: Parquet footer exceeds the 5MB limit") +) + +// decodeParquetItems turns every row of a Parquet object into a JSON object +// item (AWS docs input-output-itemreader.html, "Parquet file in an Amazon S3 +// bucket": 256MB row groups, 5MB footers, internal GZIP/ZSTD/Snappy). +func decodeParquetItems(data []byte) ([]any, error) { + if n := len(data); n >= parquetTrailerBytes && string(data[n-4:]) == "PAR1" { + if footer := binary.LittleEndian.Uint32(data[n-parquetTrailerBytes:]); footer > parquetMaxFooterBytes { + return nil, ErrParquetFooterTooLarge + } + } + + pf, err := parquetio.Open(data) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrParquetInvalid, err) + } + + if pf.MaxRowGroupBytes() > parquetMaxRowGroupBytes { + return nil, ErrParquetRowGroupTooLarge + } + + cols := pf.Columns() + items := make([]any, 0, pf.NumRows()) + + err = pf.ForEach(cols, func(vals []any) error { + row := make(map[string]any, len(cols)) + for i, c := range cols { + row[c] = vals[i] + } + + items = append(items, row) + + return nil + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrParquetInvalid, err) + } + + return normalizeJSONNumbers(items) +} + +// normalizeJSONNumbers round-trips items so numbers carry the float64 type +// every other ItemReader decoder yields. +func normalizeJSONNumbers(items []any) ([]any, error) { + raw, err := json.Marshal(items) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrItemReaderInvalidData, err) + } + + var out []any + if err = json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("%w: %w", ErrItemReaderInvalidData, err) + } + + return out, nil +} diff --git a/services/stepfunctions/asl/jsonata.go b/services/stepfunctions/asl/jsonata.go index 38ffa50bc3..978adea651 100644 --- a/services/stepfunctions/asl/jsonata.go +++ b/services/stepfunctions/asl/jsonata.go @@ -72,7 +72,25 @@ func malformedJSONata(s string) bool { } func queryEvalError(err error) error { - return &FailError{ErrCode: errCodeStatesQueryEvaluation, Cause: err.Error()} + return &FailError{ErrCode: errCodeStatesQueryEvaluation, Cause: evalErrorCause(err)} +} + +// evalErrorCause maps gnata's guardrail codes to the messages AWS documents +// under "Handling expression errors"; the memory limit is approximated by +// gnata's sequence-size caps. +func evalErrorCause(err error) string { + msg := err.Error() + + switch { + case strings.Contains(msg, "D1012"): + return "Expression evaluation timeout" + case strings.Contains(msg, "D1011"), strings.Contains(msg, "U1001"): + return "Stack overflow error" + case strings.Contains(msg, "D2014"), strings.Contains(msg, "D2015"): + return "Expression evaluation memory limit exceeded" + } + + return msg } // eval runs the expression with the given variable bindings ($states and @@ -231,7 +249,11 @@ func (s *jxScope) withMapItem(idx int, item any) *jxScope { maps.Copy(ctx, cur) } - ctx["Map"] = mapItemContext(idx, item) + if p, ok := item.(mapPair); ok { + ctx["Map"] = mapPairContext(idx, p) + } else { + ctx["Map"] = mapItemContext(idx, item) + } states := maps.Clone(s.states) states["context"] = ctx diff --git a/services/stepfunctions/asl/jsonata_exec.go b/services/stepfunctions/asl/jsonata_exec.go index bc856a616a..4aa9ad90a8 100644 --- a/services/stepfunctions/asl/jsonata_exec.go +++ b/services/stepfunctions/asl/jsonata_exec.go @@ -2,9 +2,12 @@ package asl import ( "context" + "encoding/json" "errors" "fmt" "maps" + "math" + "slices" "time" ) @@ -23,6 +26,7 @@ func (e *Executor) runJSONataState( defer func() { e.jx = prev e.jxNums = nil + e.jxMapCfg = nil e.jxPctSet = false }() @@ -232,6 +236,12 @@ func (e *Executor) jxCompoundBody( return "", nil, err } + if state.Type == StateTypeMap { + if err := e.jxResolveMapConfig(state, scope); err != nil { + return "", nil, err + } + } + var ( next string result any @@ -310,7 +320,14 @@ func (e *Executor) jxCatchOutput(catcher *Catcher, errorResult map[string]any) ( return out, nil } -// jxMapItems resolves a Map's Items field (array literal or expression). +// mapPair is one key/value pair of an object-valued Items field. +type mapPair struct { + Value any + Key string +} + +// jxMapItems resolves a Map's Items field (array or object literal, or an +// expression evaluating to either). An object yields one mapPair per key. func (e *Executor) jxMapItems(state *State) ([]any, error) { if !state.jx.hasItems { return nil, queryEvalError(fmt.Errorf("%w: Map state requires Items", errJSONataEval)) @@ -321,12 +338,39 @@ func (e *Executor) jxMapItems(state *State) ([]any, error) { return nil, err } - items, ok := v.([]any) - if !ok { - return nil, queryEvalError(fmt.Errorf("%w: Items must evaluate to an array, got %T", errJSONataEval, v)) + switch t := v.(type) { + case []any: + return t, nil + case map[string]any: + if !e.rejectObjectItems { + return objectPairs(t), nil + } + + return nil, queryEvalError(fmt.Errorf("%w: Items must evaluate to an array, got an object", errJSONataEval)) } - return items, nil + return nil, queryEvalError(fmt.Errorf("%w: Items must evaluate to an array or object, got %T", errJSONataEval, v)) +} + +func objectPairs(m map[string]any) []any { + out := make([]any, 0, len(m)) + + for _, k := range slices.Sorted(maps.Keys(m)) { + out = append(out, mapPair{Key: k, Value: m[k]}) + } + + return out +} + +// unwrapMapPairs turns mapPair items into the {"Key","Value"} iteration input. +func unwrapMapPairs(items []any) []any { + for i, it := range items { + if p, ok := it.(mapPair); ok { + items[i] = map[string]any{fieldKey: p.Key, fieldValue: p.Value} + } + } + + return items } // jxItemSelector evaluates ItemSelector per item with $states.context.Map.Item set. @@ -346,3 +390,100 @@ func (e *Executor) jxItemSelector(state *State, items []any) ([]any, error) { return out, nil } + +// jxMapConfig holds the per-entry evaluation of a JSONata Distributed Map's +// ItemReader/ResultWriter Arguments and ItemBatcher limits and BatchInput. +type jxMapConfig struct { + readerParams map[string]any + writerParams map[string]any + batchInput json.RawMessage + maxItems int + maxBytes int +} + +func (e *Executor) jxResolveMapConfig(state *State, scope *jxScope) error { + cfg := &jxMapConfig{} + st := scope.st + + var err error + if cfg.readerParams, err = scope.evalObject(st.hasReader, st.readerArgs, "ItemReader Arguments"); err != nil { + return err + } + + if cfg.writerParams, err = scope.evalObject(st.hasWriter, st.writerArgs, "ResultWriter Arguments"); err != nil { + return err + } + + if state.ItemBatcher != nil { + if err = scope.resolveBatcher(state.ItemBatcher, cfg); err != nil { + return err + } + } + + e.jxMapCfg = cfg + + return nil +} + +func (s *jxScope) resolveBatcher(b *ItemBatcher, cfg *jxMapConfig) error { + var err error + if cfg.maxItems, err = s.evalBatchLimit(b.maxItemsExpr, b.MaxItemsPerBatch, "MaxItemsPerBatch"); err != nil { + return err + } + + limit := s.evalBatchLimit + if cfg.maxBytes, err = limit(b.maxBytesExpr, b.MaxInputBytesPerBatch, "MaxInputBytesPerBatch"); err != nil { + return err + } + + if !s.st.hasBatchIn { + return nil + } + + v, err := s.evalValue(s.st.batchInput) + if err != nil { + return err + } + + if cfg.batchInput, err = json.Marshal(v); err != nil { + return queryEvalError(err) + } + + return nil +} + +func (s *jxScope) evalObject(present bool, raw any, field string) (map[string]any, error) { + if !present { + return nil, nil //nolint:nilnil // field absent + } + + v, err := s.evalValue(raw) + if err != nil { + return nil, err + } + + m, ok := v.(map[string]any) + if !ok { + return nil, queryEvalError(fmt.Errorf("%w: %s must evaluate to an object, got %T", errJSONataEval, field, v)) + } + + return m, nil +} + +// evalBatchLimit evaluates a "{% %}" limit; it must be a positive integer. +func (s *jxScope) evalBatchLimit(expr string, literal int, field string) (int, error) { + if expr == "" { + return literal, nil + } + + f, err := s.evalNumber(expr) + if err != nil { + return 0, err + } + + if f < 1 || f != math.Trunc(f) || f > math.MaxInt32 { + return 0, queryEvalError(fmt.Errorf("%w: %s must be a positive integer, got %v", errJSONataEval, field, f)) + } + + return int(f), nil +} diff --git a/services/stepfunctions/asl/jsonata_test.go b/services/stepfunctions/asl/jsonata_test.go index b0c51960f5..942f0258e6 100644 --- a/services/stepfunctions/asl/jsonata_test.go +++ b/services/stepfunctions/asl/jsonata_test.go @@ -132,3 +132,45 @@ func TestJSONata_ChoiceRuleAssignMergesWithStateAssign(t *testing.T) { require.False(t, res.Failed) assert.Equal(t, map[string]any{"a": float64(1), "b": float64(2)}, res.Output) } + +func TestJSONata_DocumentedEvaluationErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + wantCause string + }{ + { + name: "stack_overflow", + output: `($f := function($n){1 + $f($n+1)}; $f(0))`, + wantCause: "Stack overflow error", + }, + { + name: "memory_limit", + output: `$count([1..20000000])`, + wantCause: "Expression evaluation memory limit exceeded", + }, + { + name: "timeout", + output: `$count([1..9000000].($ * 2).($ + 1).($ - 1).($ * 3))`, + wantCause: "Expression evaluation timeout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + sm, err := asl.Parse(`{"QueryLanguage":"JSONata","StartAt":"P","States":{ + "P":{"Type":"Pass","End":true,"Output":"{% ` + tt.output + ` %}"}}}`) + require.NoError(t, err) + + res, err := asl.NewExecutor(sm, nil, nil).Execute(t.Context(), "arn", `{}`) + require.NoError(t, err) + require.True(t, res.Failed) + assert.Equal(t, "States.QueryEvaluationError", res.Error) + assert.Equal(t, tt.wantCause, res.Cause) + }) + } +} diff --git a/services/stepfunctions/asl/jsonata_validate.go b/services/stepfunctions/asl/jsonata_validate.go index 6938f0b428..c0517b57c5 100644 --- a/services/stepfunctions/asl/jsonata_validate.go +++ b/services/stepfunctions/asl/jsonata_validate.go @@ -17,14 +17,21 @@ var ( // jxState holds a JSONata state's decoded fields and compiled expressions, // built once at Parse time and read-only afterwards. type jxState struct { - exprs map[string]*jsonataExpr - args any - output any - items any - itemSel any - hasArgs bool - hasOut bool - hasItems bool + exprs map[string]*jsonataExpr + args any + output any + items any + itemSel any + + readerArgs any + writerArgs any + batchInput any + hasArgs bool + hasOut bool + hasItems bool + hasReader bool + hasWriter bool + hasBatchIn bool } // exprCtx says which $states members an expression may read. @@ -267,6 +274,10 @@ func rejectJSONataFields(name string, st *State) error { } } + if field := jsonataDistributedField(st); field != "" { + return jsonataOnlyErr(name, field) + } + for _, r := range st.Choices { if r.Condition != "" { return jsonataOnlyErr(name, "Condition") @@ -282,6 +293,40 @@ func rejectJSONataFields(name string, st *State) error { return nil } +// jsonataDistributedField names a JSONata-only Distributed Map field set on st. +func jsonataDistributedField(st *State) string { + switch { + case st.ItemReader != nil && len(st.ItemReader.Arguments) > 0: + return "ItemReader.Arguments" + case st.ResultWriter != nil && len(st.ResultWriter.Arguments) > 0: + return "ResultWriter.Arguments" + case st.ItemBatcher != nil && st.ItemBatcher.maxItemsExpr != "": + return "ItemBatcher.MaxItemsPerBatch expression" + case st.ItemBatcher != nil && st.ItemBatcher.maxBytesExpr != "": + return "ItemBatcher.MaxInputBytesPerBatch expression" + } + + return "" +} + +// jsonPathDistributedField names a JSONPath-only Distributed Map field set on st. +func jsonPathDistributedField(st *State) string { + switch { + case st.ItemReader != nil && len(st.ItemReader.Parameters) > 0: + return "ItemReader.Parameters" + case st.ItemReader != nil && st.ItemReader.ReaderConfig != nil && st.ItemReader.ReaderConfig.MaxItemsPath != "": + return "ItemReader.ReaderConfig.MaxItemsPath" + case st.ResultWriter != nil && len(st.ResultWriter.Parameters) > 0: + return "ResultWriter.Parameters" + case st.ItemBatcher != nil && st.ItemBatcher.MaxItemsPerBatchPath != "": + return "ItemBatcher.MaxItemsPerBatchPath" + case st.ItemBatcher != nil && st.ItemBatcher.MaxInputBytesPerBatchPath != "": + return "ItemBatcher.MaxInputBytesPerBatchPath" + } + + return "" +} + func rejectJSONPathFields(name string, st *State) error { checks := []struct { field string @@ -309,6 +354,10 @@ func rejectJSONPathFields(name string, st *State) error { } } + if field := jsonPathDistributedField(st); field != "" { + return jsonPathOnlyErr(name, field) + } + for _, r := range st.Choices { if r.Variable != "" || len(r.And) > 0 || len(r.Or) > 0 || r.Not != nil { return jsonPathOnlyErr(name, "Variable/And/Or/Not") @@ -445,6 +494,9 @@ func (j *jxState) prepareFields(name string, st *State) error { {&j.output, &j.hasOut, "Output", st.Output, exprCtx{result: hasResult}}, {&j.items, &j.hasItems, fieldItems, st.Items, plain}, {&j.itemSel, new(bool), "ItemSelector", st.ItemSelector, plain}, + {&j.readerArgs, &j.hasReader, "ItemReader Arguments", readerArguments(st), plain}, + {&j.writerArgs, &j.hasWriter, "ResultWriter Arguments", writerArguments(st), plain}, + {&j.batchInput, &j.hasBatchIn, "ItemBatcher BatchInput", batchInputRaw(st), plain}, } for _, f := range fields { @@ -467,6 +519,30 @@ func (j *jxState) prepareFields(name string, st *State) error { return j.prepareRest(name, st, exprCtx{result: hasResult}) } +func readerArguments(st *State) json.RawMessage { + if st.ItemReader == nil { + return nil + } + + return st.ItemReader.Arguments +} + +func writerArguments(st *State) json.RawMessage { + if st.ResultWriter == nil { + return nil + } + + return st.ResultWriter.Arguments +} + +func batchInputRaw(st *State) json.RawMessage { + if st.ItemBatcher == nil { + return nil + } + + return st.ItemBatcher.BatchInput +} + func (j *jxState) prepareRest(name string, st *State, assignCtx exprCtx) error { for _, v := range st.assignVals { if err := j.scan(name, "Assign", v, assignCtx); err != nil { @@ -480,6 +556,19 @@ func (j *jxState) prepareRest(name string, st *State, assignCtx exprCtx) error { } } + if b := st.ItemBatcher; b != nil { + for _, src := range []string{b.maxItemsExpr, b.maxBytesExpr} { + if _, ok := wrappedJSONata(src); src != "" && !ok { + return fmt.Errorf("%w: state %q: ItemBatcher limit %q must be an integer or a \"{%% %%}\" expression", + ErrParseError, name, src) + } + + if err := j.scan(name, "ItemBatcher", src, exprCtx{}); err != nil { + return err + } + } + } + for _, s := range []string{st.Timestamp, st.Error, st.Cause} { if err := j.scan(name, "Timestamp/Error/Cause", s, exprCtx{}); err != nil { return err diff --git a/services/stepfunctions/asl/parser.go b/services/stepfunctions/asl/parser.go index 22abb3b2b4..864f7de619 100644 --- a/services/stepfunctions/asl/parser.go +++ b/services/stepfunctions/asl/parser.go @@ -27,11 +27,18 @@ const ( fieldSeconds = "Seconds" fieldItems = "Items" + fieldKey = "Key" + fieldValue = "Value" ) // mapItemContext is the `$$.Map` / `$states.context.Map` object for one item. func mapItemContext(idx int, value any) map[string]any { - return map[string]any{"Item": map[string]any{"Index": float64(idx), "Value": value}} + return map[string]any{"Item": map[string]any{"Index": float64(idx), fieldValue: value}} +} + +// mapPairContext is the `$states.context.Map` object for an object-Items pair. +func mapPairContext(idx int, p mapPair) map[string]any { + return map[string]any{"Item": map[string]any{"Index": float64(idx), fieldKey: p.Key, fieldValue: p.Value}} } // ProcessorConfig specifies configuration for an ItemProcessor. @@ -59,20 +66,94 @@ type StateMachine struct { type ItemBatcher struct { MaxItemsPerBatchPath string `json:"MaxItemsPerBatchPath,omitempty"` MaxInputBytesPerBatchPath string `json:"MaxInputBytesPerBatchPath,omitempty"` - // BatchInput is fixed JSON merged into every batch's iteration input - // alongside Items (AWS docs: input-output-itembatcher.html, "Batch input"). + // maxItemsExpr/maxBytesExpr hold "{% %}" limits (JSONata states only). + maxItemsExpr string + maxBytesExpr string + // BatchInput is fixed JSON merged into every batch (input-output-itembatcher.html). BatchInput json.RawMessage `json:"BatchInput,omitempty"` - // MaxItemsPerBatch is the maximum number of items per batch. Zero means no limit. - MaxItemsPerBatch int `json:"MaxItemsPerBatch,omitempty"` - // MaxInputBytesPerBatch is the max total JSON bytes per batch. Zero means no limit. + // MaxItemsPerBatch and MaxInputBytesPerBatch: zero means no limit. + MaxItemsPerBatch int `json:"MaxItemsPerBatch,omitempty"` MaxInputBytesPerBatch int `json:"MaxInputBytesPerBatch,omitempty"` } +type itemBatcherWire struct { + MaxItemsPerBatchPath string `json:"MaxItemsPerBatchPath,omitempty"` + MaxInputBytesPerBatchPath string `json:"MaxInputBytesPerBatchPath,omitempty"` + MaxItemsPerBatch json.RawMessage `json:"MaxItemsPerBatch,omitempty"` + MaxInputBytesPerBatch json.RawMessage `json:"MaxInputBytesPerBatch,omitempty"` + BatchInput json.RawMessage `json:"BatchInput,omitempty"` +} + +// decodeBatchLimit reads an integer limit, or a JSONata string into expr. +func decodeBatchLimit(raw json.RawMessage, n *int, expr *string) error { + if len(raw) == 0 { + return nil + } + + if raw[0] == '"' { + return json.Unmarshal(raw, expr) + } + + return json.Unmarshal(raw, n) +} + +// UnmarshalJSON accepts a JSONata string for either batch limit. +func (b *ItemBatcher) UnmarshalJSON(data []byte) error { + var w itemBatcherWire + if err := json.Unmarshal(data, &w); err != nil { + return err + } + + *b = ItemBatcher{ + MaxItemsPerBatchPath: w.MaxItemsPerBatchPath, + MaxInputBytesPerBatchPath: w.MaxInputBytesPerBatchPath, + BatchInput: w.BatchInput, + } + + if err := decodeBatchLimit(w.MaxItemsPerBatch, &b.MaxItemsPerBatch, &b.maxItemsExpr); err != nil { + return err + } + + return decodeBatchLimit(w.MaxInputBytesPerBatch, &b.MaxInputBytesPerBatch, &b.maxBytesExpr) +} + +// MarshalJSON mirrors UnmarshalJSON so a JSONata limit survives a round trip. +func (b ItemBatcher) MarshalJSON() ([]byte, error) { + w := itemBatcherWire{ + MaxItemsPerBatchPath: b.MaxItemsPerBatchPath, + MaxInputBytesPerBatchPath: b.MaxInputBytesPerBatchPath, + BatchInput: b.BatchInput, + } + + var err error + if w.MaxItemsPerBatch, err = encodeBatchLimit(b.MaxItemsPerBatch, b.maxItemsExpr); err != nil { + return nil, err + } + + if w.MaxInputBytesPerBatch, err = encodeBatchLimit(b.MaxInputBytesPerBatch, b.maxBytesExpr); err != nil { + return nil, err + } + + return json.Marshal(w) +} + +func encodeBatchLimit(n int, expr string) (json.RawMessage, error) { + switch { + case expr != "": + return json.Marshal(expr) + case n != 0: + return json.Marshal(n) + } + + return nil, nil +} + // ItemReader configures reading items from S3 for a Map state's Distributed Map. type ItemReader struct { - Parameters map[string]any `json:"Parameters,omitempty"` - ReaderConfig *ReaderConfig `json:"ReaderConfig,omitempty"` - Resource string `json:"Resource,omitempty"` + Parameters map[string]any `json:"Parameters,omitempty"` + ReaderConfig *ReaderConfig `json:"ReaderConfig,omitempty"` + Resource string `json:"Resource,omitempty"` + Arguments json.RawMessage `json:"Arguments,omitempty"` } // ReaderConfig describes how the ItemReader should interpret S3 object data. @@ -115,6 +196,7 @@ type ResultWriter struct { Parameters map[string]any `json:"Parameters,omitempty"` WriterConfig *ResultWriterConfig `json:"WriterConfig,omitempty"` Resource string `json:"Resource,omitempty"` + Arguments json.RawMessage `json:"Arguments,omitempty"` } // ResultWriterConfig is ResultWriter.WriterConfig: Transformation diff --git a/services/stepfunctions/asl/result_writer.go b/services/stepfunctions/asl/result_writer.go index a60a93ac52..f2f101f8d6 100644 --- a/services/stepfunctions/asl/result_writer.go +++ b/services/stepfunctions/asl/result_writer.go @@ -120,6 +120,12 @@ func (e *Executor) exportMapResults( items, results []any, errs []error, meta []DistributedMapItemResult, stateMachineArn string, ) (any, int, error) { rw := state.ResultWriter + if e.jxMapCfg != nil && e.jxMapCfg.writerParams != nil { + eff := *rw + eff.Parameters = e.jxMapCfg.writerParams + rw = &eff + } + bucket, _ := rw.Parameters["Bucket"].(string) exporting := rw.Resource != "" || bucket != "" diff --git a/services/stepfunctions/handler_util.go b/services/stepfunctions/handler_util.go index f06074c384..54705da4f1 100644 --- a/services/stepfunctions/handler_util.go +++ b/services/stepfunctions/handler_util.go @@ -3,6 +3,7 @@ package stepfunctions import ( "encoding/json" "fmt" + "strings" "github.com/blackbirdworks/gopherstack/pkgs/ptrconv" "github.com/blackbirdworks/gopherstack/services/stepfunctions/asl" @@ -87,6 +88,8 @@ func (h *Handler) utilActions() map[string]actionFn { } } +const inspectionTrace = "TRACE" + // bareStateName names the state when TestState is given a bare state definition. const bareStateName = "TestStateName" @@ -98,6 +101,7 @@ type testStateInput struct { Input string `json:"input"` RoleArn string `json:"roleArn,omitempty"` InspectionLevel string `json:"inspectionLevel,omitempty"` + RevealSecrets bool `json:"revealSecrets,omitempty"` } type testStateMock struct { @@ -162,22 +166,32 @@ func testStateMockRun(m *testStateMock, stateName string) (*asl.MockRun, error) } type testStateOutput struct { - InspectionData map[string]string `json:"inspectionData,omitempty"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` - Cause string `json:"cause,omitempty"` - Status string `json:"status"` - NextState string `json:"nextState,omitempty"` + InspectionData map[string]any `json:"inspectionData,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Cause string `json:"cause,omitempty"` + Status string `json:"status"` + NextState string `json:"nextState,omitempty"` } // inspectionDataFor renders the executor's recorded stage data as the // JSON-string members of types.InspectionData. INFO (the default) returns none. -func inspectionDataFor(level string, executor *asl.Executor) map[string]string { +func inspectionDataFor(level string, executor *asl.Executor, trace *httpTrace) map[string]any { if level == "" || level == "INFO" { return nil } - out := map[string]string{} + out := map[string]any{} + + if trace != nil && level == inspectionTrace { + if trace.request != nil { + out["request"] = trace.request + } + + if trace.response != nil { + out["response"] = trace.response + } + } for key, value := range executor.Inspection() { if raw, err := json.Marshal(value); err == nil { @@ -189,7 +203,7 @@ func inspectionDataFor(level string, executor *asl.Executor) map[string]string { } func validInspectionLevel(level string) bool { - return level == "" || level == "INFO" || level == "DEBUG" || level == "TRACE" + return level == "" || level == "INFO" || level == "DEBUG" || level == inspectionTrace } // detachNext swaps Next for End:true so TestState can run a non-terminal @@ -263,15 +277,14 @@ func (h *Handler) handleTestState(body []byte) (any, error) { return nil, fmt.Errorf("%w: %w", ErrInvalidDefinition, err) } - var lambdaInvoker asl.LambdaInvoker - - if bk, ok := h.Backend.(*InMemoryBackend); ok { - bk.mu.RLock("TestState") - lambdaInvoker = bk.lambdaInvoker - bk.mu.RUnlock() + if vErr := validateTestStateTrace(&input, states[stateName]); vErr != nil { + return nil, vErr } + lambdaInvoker, rejectObjectItems := h.testStateBackendConfig() + executor := asl.NewExecutor(sm, lambdaInvoker, nil) + executor.SetRejectMapObjectItems(rejectObjectItems) executor.EnableInspection() executor.EnableSingleState() @@ -284,15 +297,22 @@ func (h *Handler) handleTestState(body []byte) (any, error) { stateInput = "{}" } - result, execErr := executor.Execute(h.svcCtx, "test-state", stateInput) - if execErr != nil { - out := &testStateOutput{ - Status: "FAILED", - Error: execErr.Error(), - InspectionData: inspectionDataFor(input.InspectionLevel, executor), - } + execCtx, trace := withHTTPTrace(h.svcCtx, input.RevealSecrets) + if input.Mock == nil && isHTTPTaskState(states[stateName]) { + executor.SetSDKIntegration(h.testStateSDKIntegration()) + } + + result, execErr := executor.Execute(execCtx, "test-state", stateInput) + inspection := inspectionDataFor(input.InspectionLevel, executor, trace) - return out, nil //nolint:nilerr // execution errors are encoded in the response body + return testStateResult(result, execErr, nextStateName, inspection), nil +} + +func testStateResult( + result *asl.ExecutionResult, execErr error, nextState string, inspection map[string]any, +) *testStateOutput { + if execErr != nil { + return &testStateOutput{Status: "FAILED", Error: execErr.Error(), InspectionData: inspection} } if result.Failed { @@ -302,25 +322,21 @@ func (h *Handler) handleTestState(body []byte) (any, error) { } return &testStateOutput{ - Status: status, Error: result.Error, Cause: result.Cause, - InspectionData: inspectionDataFor(input.InspectionLevel, executor), - }, nil + Status: status, Error: result.Error, Cause: result.Cause, InspectionData: inspection, + } } outputBytes, _ := json.Marshal(result.Output) out := &testStateOutput{ - Status: "SUCCEEDED", - Output: string(outputBytes), - NextState: nextStateName, - InspectionData: inspectionDataFor(input.InspectionLevel, executor), + Status: "SUCCEEDED", Output: string(outputBytes), NextState: nextState, InspectionData: inspection, } if result.Caught { out.Status, out.Error, out.Cause, out.NextState = "CAUGHT_ERROR", result.Error, result.Cause, result.NextState } - return out, nil + return out } // configureTestStateExecutor applies the request's mock, context and @@ -368,3 +384,53 @@ func configureTestStateExecutor( return nil } + +// isHTTPTaskState reports whether raw is a Task state calling http:invoke. +func isHTTPTaskState(raw json.RawMessage) bool { + var st struct { + Type string `json:"Type"` + Resource string `json:"Resource"` + } + + _ = json.Unmarshal(raw, &st) + + return st.Type == stateTypeTask && strings.HasSuffix(st.Resource, ":::http:invoke") +} + +// validateTestStateTrace rejects the TRACE and revealSecrets combinations AWS +// rejects (test-state-isolation.html, "TRACE inspectionLevel"). +func validateTestStateTrace(input *testStateInput, raw json.RawMessage) error { + if input.InspectionLevel == inspectionTrace && !isHTTPTaskState(raw) { + return fmt.Errorf("%w: inspectionLevel TRACE is only applicable to an HTTP Task state", ErrValidation) + } + + if input.RevealSecrets && input.Mock != nil { + return fmt.Errorf("%w: revealSecrets cannot be used together with a mock", ErrValidation) + } + + return nil +} + +func (h *Handler) testStateSDKIntegration() asl.SDKIntegration { + bk, ok := h.Backend.(*InMemoryBackend) + if !ok { + return nil + } + + bk.mu.RLock("TestStateSDK") + defer bk.mu.RUnlock() + + return bk.sdkIntegration +} + +func (h *Handler) testStateBackendConfig() (asl.LambdaInvoker, bool) { + bk, ok := h.Backend.(*InMemoryBackend) + if !ok { + return nil, false + } + + bk.mu.RLock("TestState") + defer bk.mu.RUnlock() + + return bk.lambdaInvoker, !bk.settings.JSONataMapObjectItems +} diff --git a/services/stepfunctions/http_task.go b/services/stepfunctions/http_task.go index 01f120a8c5..f0c6f6e920 100644 --- a/services/stepfunctions/http_task.go +++ b/services/stepfunctions/http_task.go @@ -133,7 +133,12 @@ func (a *sdkAdapter) invokeHTTP(ctx context.Context, call asl.SDKCall) (any, err return nil, err } - return doHTTP(client, req) + trace := httpTraceFrom(ctx) + if trace != nil { + trace.recordRequest(req, creds) + } + + return doHTTP(client, req, trace) } func (a *sdkAdapter) connectionCredentials(ctx context.Context, arn string) (ConnectionCredentials, error) { @@ -472,7 +477,7 @@ func redirectPolicy(secretHeader string) func(*http.Request, []*http.Request) er } } -func doHTTP(client *http.Client, req *http.Request) (any, error) { +func doHTTP(client *http.Client, req *http.Request, trace *httpTrace) (any, error) { resp, err := client.Do(req) if err != nil { return nil, transportFailure(req.Context(), err) @@ -488,6 +493,10 @@ func doHTTP(client *http.Client, req *http.Request) (any, error) { return nil, transportFailure(req.Context(), err) } + if trace != nil { + trace.recordResponse(resp, data) + } + if len(data) > httpMaxResponseBytes { return nil, &asl.FailError{ErrCode: errCodeDataLimit, Cause: "the HTTP response exceeds the payload size quota"} } diff --git a/services/stepfunctions/http_trace.go b/services/stepfunctions/http_trace.go new file mode 100644 index 0000000000..93c05ee1de --- /dev/null +++ b/services/stepfunctions/http_trace.go @@ -0,0 +1,126 @@ +package stepfunctions + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" +) + +const traceMask = "***" + +type httpTraceKey struct{} + +// httpTrace collects the HTTP Task request and response for a TestState call +// at inspection level TRACE (types.InspectionDataRequest/Response). +type httpTrace struct { + request, response map[string]string + secrets []string + reveal bool +} + +func withHTTPTrace(ctx context.Context, reveal bool) (context.Context, *httpTrace) { + tr := &httpTrace{reveal: reveal} + + return context.WithValue(ctx, httpTraceKey{}, tr), tr +} + +func httpTraceFrom(ctx context.Context) *httpTrace { + tr, _ := ctx.Value(httpTraceKey{}).(*httpTrace) + + return tr +} + +func connectionSecrets(c ConnectionCredentials) []string { + secrets := []string{c.APIKeyValue, c.Password} + + if c.AuthType == authBasic { + secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte(c.Username+":"+c.Password))) + } + + if c.OAuth != nil { + secrets = append(secrets, c.OAuth.ClientSecret) + } + + for _, group := range [][]NameValue{c.Headers, c.Query, c.Body} { + for _, nv := range group { + secrets = append(secrets, nv.Value) + } + } + + return secrets +} + +func (t *httpTrace) scrub(s string) string { + if t.reveal { + return s + } + + for _, secret := range t.secrets { + if secret != "" { + s = strings.ReplaceAll(s, secret, traceMask) + } + } + + return s +} + +func (t *httpTrace) headers(h http.Header, dropAuth string) string { + flat := make(map[string]string, len(h)) + + for k, vs := range h { + if !t.reveal && (strings.EqualFold(k, "Authorization") || strings.EqualFold(k, dropAuth)) { + continue + } + + flat[k] = t.scrub(strings.Join(vs, ",")) + } + + raw, _ := json.Marshal(flat) + + return string(raw) +} + +func (t *httpTrace) recordRequest(req *http.Request, creds ConnectionCredentials) { + t.secrets = connectionSecrets(creds) + if creds.AuthType == authOAuth || creds.AuthType == authBasic { + t.secrets = append(t.secrets, strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")) + } + + var body string + + if req.GetBody != nil { + if rc, err := req.GetBody(); err == nil { + raw, _ := io.ReadAll(rc) + body = string(raw) + } + } + + dropName := "" + if creds.AuthType == authAPIKey { + dropName = creds.APIKeyName + } + + t.request = map[string]string{ + "protocol": req.Proto, + "method": req.Method, + "url": t.scrub(req.URL.String()), + "headers": t.headers(req.Header, dropName), + "body": t.scrub(body), + } +} + +func (t *httpTrace) recordResponse(resp *http.Response, data []byte) { + status := strings.TrimSpace(strings.TrimPrefix(resp.Status, strconv.Itoa(resp.StatusCode))) + + t.response = map[string]string{ + "protocol": resp.Proto, + "statusCode": strconv.Itoa(resp.StatusCode), + "statusMessage": status, + "headers": t.headers(resp.Header, ""), + "body": t.scrub(string(data)), + } +} diff --git a/services/stepfunctions/iam_enforcement_test.go b/services/stepfunctions/iam_enforcement_test.go index 0d037a116d..e96d50c091 100644 --- a/services/stepfunctions/iam_enforcement_test.go +++ b/services/stepfunctions/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/stepfunctions" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/states/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/stepfunctions/item_reader_parquet_test.go b/services/stepfunctions/item_reader_parquet_test.go new file mode 100644 index 0000000000..5ff112abad --- /dev/null +++ b/services/stepfunctions/item_reader_parquet_test.go @@ -0,0 +1,166 @@ +package stepfunctions_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + sfntypes "github.com/aws/aws-sdk-go-v2/service/sfn/types" + + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +func parquetFixture(t *testing.T, compression string) []byte { + t.Helper() + + cols := []parquetio.Column{ + {Name: "id", Type: "bigint"}, + {Name: "score", Type: "double"}, + {Name: "name", Type: "string"}, + {Name: "active", Type: "boolean"}, + {Name: "seen", Type: "timestamp"}, + {Name: "note", Type: "string"}, + } + rows := [][]any{ + {int64(1), 1.5, "alpha", true, "2024-03-05 10:20:30", nil}, + {int64(2), 2.25, "beta", false, "2024-03-06 00:00:00", "x"}, + {int64(3), 0.0, "gamma", true, "2024-03-07 12:00:00", nil}, + } + + var buf bytes.Buffer + require.NoError(t, parquetio.Write(&buf, cols, rows, compression)) + + return buf.Bytes() +} + +func TestItemReader_Parquet(t *testing.T) { + t.Parallel() + + const wantAll = `[ + {"id":1,"score":1.5,"name":"alpha","active":true,"seen":"2024-03-05 10:20:30.000","note":null}, + {"id":2,"score":2.25,"name":"beta","active":false,"seen":"2024-03-06 00:00:00.000","note":"x"}, + {"id":3,"score":0,"name":"gamma","active":true,"seen":"2024-03-07 12:00:00.000","note":null} + ]` + + tests := []struct { + name string + config string + compression string + want string + wantErrCode string + wantCause string + }{ + { + name: "snappy_rows_become_objects", + config: `{"InputType":"PARQUET"}`, + compression: "SNAPPY", + want: wantAll, + }, + { + name: "zstd", + config: `{"InputType":"PARQUET"}`, + compression: "ZSTD", + want: wantAll, + }, + { + name: "gzip", + config: `{"InputType":"parquet"}`, + compression: "GZIP", + want: wantAll, + }, + { + name: "max_items", + config: `{"InputType":"PARQUET","MaxItems":2}`, + compression: "SNAPPY", + want: `[ + {"id":1,"score":1.5,"name":"alpha","active":true,"seen":"2024-03-05 10:20:30.000","note":null}, + {"id":2,"score":2.25,"name":"beta","active":false,"seen":"2024-03-06 00:00:00.000","note":"x"} + ]`, + }, + { + name: "corrupt_file", + config: `{"InputType":"PARQUET"}`, + compression: "CORRUPT", + wantErrCode: "States.ItemReaderFailed", + wantCause: "unable to read Parquet file", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + const bucket = "parquet-bucket" + + s3Bk := newBucketBackedS3(t, bucket) + data := []byte("this is not parquet") + if tc.compression != "CORRUPT" { + data = parquetFixture(t, tc.compression) + } + + putS3Object(t, s3Bk, bucket, "data/rows.parquet", data) + + itemReader := `{ + "Resource": "arn:aws:states:::s3:getObject", + "ReaderConfig": ` + tc.config + `, + "Parameters": {"Bucket": "` + bucket + `", "Key": "data/rows.parquet"} + }` + + status, output, errCode, cause := runItemReaderExecution(t, s3Bk, itemReader) + + if tc.wantErrCode != "" { + assert.Equal(t, sfntypes.ExecutionStatusFailed, status) + assert.Equal(t, tc.wantErrCode, errCode) + assert.Contains(t, cause, tc.wantCause) + + return + } + + require.Equal(t, sfntypes.ExecutionStatusSucceeded, status, "error=%s cause=%s", errCode, cause) + assert.JSONEq(t, tc.want, output) + }) + } +} + +func TestItemReader_ParquetFlattenAndAthena(t *testing.T) { + t.Parallel() + + const bucket = "parquet-multi-bucket" + + s3Bk := newBucketBackedS3(t, bucket) + putS3Object(t, s3Bk, bucket, "p/a.parquet", parquetFixture(t, "SNAPPY")) + putS3Object(t, s3Bk, bucket, "p/b.parquet", parquetFixture(t, "SNAPPY")) + putS3Object(t, s3Bk, bucket, "m/manifest.csv", []byte("s3://"+bucket+"/p/a.parquet\ns3://"+bucket+"/p/b.parquet\n")) + + tests := []struct { + name string + itemReader string + }{ + {"load_and_flatten", `{ + "Resource": "arn:aws:states:::s3:listObjectsV2", + "ReaderConfig": {"InputType": "PARQUET", "Transformation": "LOAD_AND_FLATTEN"}, + "Parameters": {"Bucket": "` + bucket + `", "Prefix": "p/"} + }`}, + {"athena_manifest", `{ + "Resource": "arn:aws:states:::s3:getObject", + "ReaderConfig": {"ManifestType": "ATHENA_DATA", "InputType": "PARQUET"}, + "Parameters": {"Bucket": "` + bucket + `", "Key": "m/manifest.csv"} + }`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + status, output, errCode, cause := runItemReaderExecution(t, s3Bk, tc.itemReader) + require.Equal(t, sfntypes.ExecutionStatusSucceeded, status, "error=%s cause=%s", errCode, cause) + + var items []map[string]any + require.NoError(t, json.Unmarshal([]byte(output), &items)) + assert.Len(t, items, 6) + }) + } +} diff --git a/services/stepfunctions/item_reader_s3_resource_test.go b/services/stepfunctions/item_reader_s3_resource_test.go index 12ce153a6f..4051b5a7ce 100644 --- a/services/stepfunctions/item_reader_s3_resource_test.go +++ b/services/stepfunctions/item_reader_s3_resource_test.go @@ -385,25 +385,6 @@ func TestItemReader_S3GetObject_Errors(t *testing.T) { assert.Equal(t, "States.ItemReaderFailed", errCode) assert.Contains(t, cause, "NoSuchKey") }) - - t.Run("PARQUET InputType is a recorded gap", func(t *testing.T) { - t.Parallel() - - const bucket = "parquet-bucket" - s3Bk := newBucketBackedS3(t, bucket) - putS3Object(t, s3Bk, bucket, "data.parquet", []byte("not really parquet")) - - itemReader := `{ - "Resource": "arn:aws:states:::s3:getObject", - "ReaderConfig": {"InputType": "PARQUET"}, - "Parameters": {"Bucket": "` + bucket + `", "Key": "data.parquet"} - }` - - status, _, errCode, cause := runItemReaderExecution(t, s3Bk, itemReader) - assert.Equal(t, sfntypes.ExecutionStatusFailed, status) - assert.Equal(t, "States.ItemReaderFailed", errCode) - assert.Contains(t, cause, "PARQUET") - }) } // TestItemReader_ItemsPointer covers ReaderConfig.ItemsPointer, the RFC 6901 diff --git a/services/stepfunctions/jsonata_distributed_map_test.go b/services/stepfunctions/jsonata_distributed_map_test.go new file mode 100644 index 0000000000..d403b25215 --- /dev/null +++ b/services/stepfunctions/jsonata_distributed_map_test.go @@ -0,0 +1,266 @@ +package stepfunctions_test + +import ( + "context" + "encoding/json" + "testing" + "testing/synctest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/stepfunctions" +) + +const jsonataPassProcessor = `"ItemProcessor":{"StartAt":"P","States":{"P":{"Type":"Pass","End":true}}}` + +func TestJSONata_SDK_MapObjectItems(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state string + input string + wantOutput string + wantStatus string + wantError string + }{ + { + name: "object_literal_pairs", + state: `"Items":{"b":2,"a":1},`, + input: `{}`, + wantOutput: `[{"Key":"a","Value":1},{"Key":"b","Value":2}]`, + wantStatus: "SUCCEEDED", + }, + { + name: "object_expression", + state: `"Items":"{% $states.input.servers %}",`, + input: `{"servers":{"web":{"port":80}}}`, + wantOutput: `[{"Key":"web","Value":{"port":80}}]`, + wantStatus: "SUCCEEDED", + }, + { + name: "item_selector_context_key_value", + state: `"Items":"{% $states.input %}","ItemSelector":{"k":"{% $states.context.Map.Item.Key %}",` + + `"v":"{% $states.context.Map.Item.Value %}","i":"{% $states.context.Map.Item.Index %}"},`, + input: `{"x":10,"y":20}`, + wantOutput: `[{"i":0,"k":"x","v":10},{"i":1,"k":"y","v":20}]`, + wantStatus: "SUCCEEDED", + }, + { + name: "array_unchanged", + state: `"Items":"{% $states.input %}",`, + input: `[1,2]`, + wantOutput: `[1,2]`, + wantStatus: "SUCCEEDED", + }, + { + name: "scalar_rejected", + state: `"Items":"{% 5 %}",`, + input: `{}`, + wantStatus: "FAILED", + wantError: "States.QueryEvaluationError", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + def := `{"QueryLanguage":"JSONata","StartAt":"M","States":{"M":{"Type":"Map",` + + tt.state + jsonataPassProcessor + `,"End":true}}}` + got := runJSONataExpress(t, def, tt.input) + + assert.Equal(t, tt.wantStatus, got.status, "cause=%s", got.cause) + assert.Equal(t, tt.wantError, got.errMsg) + + if tt.wantOutput != "" { + assert.JSONEq(t, tt.wantOutput, got.output) + } + }) + } +} + +func TestJSONata_MapObjectItemsRejectSetting(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + t.Helper() + testRejectObjectItems(t) + }) +} + +func testRejectObjectItems(t *testing.T) { + t.Helper() + + b := stepfunctions.NewInMemoryBackend() + b.SetSettings(stepfunctions.Settings{JSONataMapObjectItems: false}) + + def := `{"QueryLanguage":"JSONata","StartAt":"M","States":{"M":{"Type":"Map","Items":{"a":1},` + + jsonataPassProcessor + `,"End":true}}}` + + sm, err := b.CreateStateMachine(context.Background(), "reject-obj", def, validRoleARN, "STANDARD") + require.NoError(t, err) + + exec, err := b.StartExecution(sm.StateMachineArn, "e", `{}`) + require.NoError(t, err) + + d := waitForTerminalExecution(t, b, exec.ExecutionArn) + assert.Equal(t, "FAILED", d.Status) + assert.Equal(t, "States.QueryEvaluationError", d.Error) +} + +func TestJSONata_SDK_ItemBatcherExpressions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + batcher string + wantOutput string + wantStatus string + wantError string + }{ + { + name: "max_items_expression", + batcher: `{"MaxItemsPerBatch":"{% $states.input.n %}"}`, + wantOutput: `[{"Items":[1,2]},{"Items":[3]}]`, + wantStatus: "SUCCEEDED", + }, + { + name: "batch_input_expression", + batcher: `{"MaxItemsPerBatch":"{% 3 %}","BatchInput":{"count":"{% $count($states.input.items) %}",` + + `"fixed":"x"}}`, + wantOutput: `[{"BatchInput":{"count":3,"fixed":"x"},"Items":[1,2,3]}]`, + wantStatus: "SUCCEEDED", + }, + { + name: "batch_input_direct_expression", + batcher: `{"MaxItemsPerBatch":3,"BatchInput":"{% $states.input.items[0] %}"}`, + wantOutput: `[{"BatchInput":1,"Items":[1,2,3]}]`, + wantStatus: "SUCCEEDED", + }, + { + name: "max_bytes_expression", + batcher: `{"MaxInputBytesPerBatch":"{% 2 %}"}`, + wantOutput: `[{"Items":[1,2]},{"Items":[3]}]`, + wantStatus: "SUCCEEDED", + }, + { + name: "non_positive_limit_fails", + batcher: `{"MaxItemsPerBatch":"{% 0 %}"}`, + wantStatus: "FAILED", + wantError: "States.QueryEvaluationError", + }, + { + name: "non_integer_limit_fails", + batcher: `{"MaxItemsPerBatch":"{% 1.5 %}"}`, + wantStatus: "FAILED", + wantError: "States.QueryEvaluationError", + }, + { + name: "string_limit_fails", + batcher: `{"MaxItemsPerBatch":"{% 'x' %}"}`, + wantStatus: "FAILED", + wantError: "States.QueryEvaluationError", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + def := `{"QueryLanguage":"JSONata","StartAt":"M","States":{"M":{"Type":"Map",` + + `"Items":"{% $states.input.items %}","ItemBatcher":` + tt.batcher + `,` + + jsonataPassProcessor + `,"End":true}}}` + got := runJSONataExpress(t, def, `{"items":[1,2,3],"n":2}`) + + assert.Equal(t, tt.wantStatus, got.status, "cause=%s", got.cause) + assert.Equal(t, tt.wantError, got.errMsg) + + if tt.wantOutput != "" { + assert.JSONEq(t, tt.wantOutput, got.output) + } + }) + } +} + +func TestJSONata_DistributedMapS3Arguments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reader string + writer string + input string + wantOutput string + wantExport bool + }{ + { + name: "reader_arguments_expression", + reader: `"ItemReader":{"Resource":"arn:aws:states:::s3:getObject","ReaderConfig":{"InputType":"JSON"},` + + `"Arguments":{"Bucket":"data-bucket","Key":"{% $states.input.key %}"}},`, + input: `{"key":"items.json"}`, + wantOutput: `[{"id":1},{"id":2}]`, + }, + { + name: "reader_arguments_whole_expression", + reader: `"ItemReader":{"Resource":"arn:aws:states:::s3:getObject","ReaderConfig":{"InputType":"JSON"},` + + `"Arguments":"{% {'Bucket':'data-bucket','Key':'items.json'} %}"},`, + input: `{}`, + wantOutput: `[{"id":1},{"id":2}]`, + }, + { + name: "writer_arguments_expression", + reader: `"ItemReader":{"Resource":"arn:aws:states:::s3:getObject","ReaderConfig":{"InputType":"JSON"},` + + `"Arguments":{"Bucket":"data-bucket","Key":"items.json"}},`, + writer: `"ResultWriter":{"Resource":"arn:aws:states:::s3:putObject",` + + `"Arguments":{"Bucket":"{% $states.input.out %}","Prefix":"jobs"}},`, + input: `{"out":"data-bucket"}`, + wantExport: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + runS3ArgumentsCase(t, tt.reader, tt.writer, tt.input, tt.wantOutput, tt.wantExport) + }) + }) + } +} + +func runS3ArgumentsCase(t *testing.T, reader, writer, input, wantOutput string, wantExport bool) { + t.Helper() + + s3Bk := newBucketBackedS3(t, "data-bucket") + putS3Object(t, s3Bk, "data-bucket", "items.json", []byte(`[{"id":1},{"id":2}]`)) + + b := stepfunctions.NewInMemoryBackend() + b.SetS3Reader(stepfunctions.NewS3Integration(s3Bk)) + b.SetS3ResultWriter(stepfunctions.NewS3ResultWriterIntegration(s3Bk)) + + def := `{"QueryLanguage":"JSONata","StartAt":"M","States":{"M":{"Type":"Map",` + + reader + writer + jsonataPassProcessor + `,"End":true}}}` + + sm, err := b.CreateStateMachine(context.Background(), "s3-args", def, validRoleARN, "STANDARD") + require.NoError(t, err) + + exec, err := b.StartExecution(sm.StateMachineArn, "e", input) + require.NoError(t, err) + + d := waitForTerminalExecution(t, b, exec.ExecutionArn) + require.Equal(t, "SUCCEEDED", d.Status, "error=%s cause=%s", d.Error, d.Cause) + + if !wantExport { + assert.JSONEq(t, wantOutput, d.Output) + + return + } + + var out mapExportOutput + require.NoError(t, json.Unmarshal([]byte(d.Output), &out)) + assert.Equal(t, "data-bucket", out.ResultWriterDetails.Bucket) + assert.NotEmpty(t, getS3ObjectBytes(t, s3Bk, "data-bucket", out.ResultWriterDetails.Key)) +} diff --git a/services/stepfunctions/jsonata_sdk_test.go b/services/stepfunctions/jsonata_sdk_test.go index 110e2bb336..cfa5393bab 100644 --- a/services/stepfunctions/jsonata_sdk_test.go +++ b/services/stepfunctions/jsonata_sdk_test.go @@ -438,6 +438,8 @@ func TestJSONata_SDK_DefinitionValidation(t *testing.T) { t.Parallel() task := `"Type":"Task","Resource":"` + jsonataLambdaRes + `"` + const mapProcessor = `"ItemProcessor":{"StartAt":"X","States":{"X":{"Type":"Succeed"}}}` + tests := []struct { name string definition string @@ -453,6 +455,27 @@ func TestJSONata_SDK_DefinitionValidation(t *testing.T) { {"items_path_in_jsonata", jsonataStateDef( `{"Type":"Map","ItemsPath":"$.x","End":true,` + `"ItemProcessor":{"StartAt":"X","States":{"X":{"Type":"Succeed"}}}}`)}, + {"item_reader_parameters_in_jsonata", jsonataStateDef( + `{"Type":"Map","Items":[1],"End":true,"ItemReader":{"Resource":"arn:aws:states:::s3:getObject",` + + `"Parameters":{"Bucket":"b","Key":"k"}},` + mapProcessor + `}`)}, + {"result_writer_parameters_in_jsonata", jsonataStateDef( + `{"Type":"Map","Items":[1],"End":true,"ResultWriter":{"Resource":"arn:aws:states:::s3:putObject",` + + `"Parameters":{"Bucket":"b"}},` + mapProcessor + `}`)}, + {"batcher_path_in_jsonata", jsonataStateDef( + `{"Type":"Map","Items":[1],"End":true,"ItemBatcher":{"MaxItemsPerBatchPath":"$.n"},` + mapProcessor + `}`)}, + {"reader_max_items_path_in_jsonata", jsonataStateDef( + `{"Type":"Map","Items":[1],"End":true,"ItemReader":{"ReaderConfig":{"MaxItemsPath":"$.n"},` + + `"Resource":"arn:aws:states:::s3:getObject","Arguments":{"Bucket":"b","Key":"k"}},` + mapProcessor + `}`)}, + {"item_reader_arguments_in_jsonpath", `{"StartAt":"S","States":{"S":{"Type":"Map","End":true,` + + `"ItemReader":{"Resource":"arn:aws:states:::s3:getObject","Arguments":{"Bucket":"b","Key":"k"}},` + + mapProcessor + `}}}`}, + {"result_writer_arguments_in_jsonpath", `{"StartAt":"S","States":{"S":{"Type":"Map","End":true,` + + `"ResultWriter":{"Resource":"arn:aws:states:::s3:putObject","Arguments":{"Bucket":"b"}},` + + mapProcessor + `}}}`}, + {"batcher_expression_in_jsonpath", `{"StartAt":"S","States":{"S":{"Type":"Map","End":true,` + + `"ItemBatcher":{"MaxItemsPerBatch":"{% 2 %}"},` + mapProcessor + `}}}`}, + {"batcher_limit_not_expression", jsonataStateDef( + `{"Type":"Map","Items":[1],"End":true,"ItemBatcher":{"MaxItemsPerBatch":"two"},` + mapProcessor + `}`)}, {"choice_variable_in_jsonata", `{"QueryLanguage":"JSONata","StartAt":"S","States":{ "S":{"Type":"Choice","Choices":[{"Variable":"$.a","NumericEquals":1,"Next":"E"}]}, "E":{"Type":"Succeed"}}}`}, @@ -559,6 +582,21 @@ func TestJSONata_SDK_TestState(t *testing.T) { input: `{"n":2}`, wantOutput: `6`, }, + { + name: "map_object_items_pairs", + definition: `{"QueryLanguage":"JSONata","Type":"Map","Items":{"b":2,"a":1},"End":true,` + + `"ItemProcessor":{"StartAt":"P","States":{"P":{"Type":"Pass","End":true}}}}`, + input: `{}`, + wantOutput: `[{"Key":"a","Value":1},{"Key":"b","Value":2}]`, + }, + { + name: "map_batcher_expression", + definition: `{"QueryLanguage":"JSONata","Type":"Map","Items":"{% $states.input %}","End":true,` + + `"ItemBatcher":{"MaxItemsPerBatch":"{% 2 %}"},` + + `"ItemProcessor":{"StartAt":"P","States":{"P":{"Type":"Pass","End":true}}}}`, + input: `[1,2,3]`, + wantOutput: `[{"Items":[1,2]},{"Items":[3]}]`, + }, } for _, tt := range tests { diff --git a/services/stepfunctions/settings.go b/services/stepfunctions/settings.go index 3811c9db0b..4de28ee33c 100644 --- a/services/stepfunctions/settings.go +++ b/services/stepfunctions/settings.go @@ -12,6 +12,8 @@ type Settings struct { // TaskTokenTTL is the maximum time a task token may remain in tasksByToken without // a worker response before the janitor evicts it. Defaults to 1 hour. TaskTokenTTL time.Duration `json:"task_token_ttl" name:"task-token-ttl" env:"SFN_TASK_TOKEN_TTL" default:"1h" help:"Max lifetime of an unreceived task token before eviction."` //nolint:lll,golines // Kong struct tag makes this line long + // JSONataMapObjectItems lets a JSONata Map's Items evaluate to an object (one iteration per key/value pair). + JSONataMapObjectItems bool `json:"jsonata_map_object_items" name:"jsonata-map-object-items" env:"SFN_JSONATA_MAP_OBJECT_ITEMS" default:"true" help:"Accept an object as a JSONata Map Items value (the Inline Map docs say array only)."` //nolint:lll // Kong struct tag makes this line long } const ( @@ -28,5 +30,7 @@ func DefaultSettings() Settings { ExecutionRetention: defaultExecutionRetention, JanitorInterval: defaultJanitorInterval, TaskTokenTTL: defaultTaskTokenTTL, + + JSONataMapObjectItems: true, } } diff --git a/services/stepfunctions/store.go b/services/stepfunctions/store.go index 1c287ba969..edf8b52438 100644 --- a/services/stepfunctions/store.go +++ b/services/stepfunctions/store.go @@ -407,20 +407,21 @@ func (b *InMemoryBackend) SetS3ResultWriter(w asl.S3Writer) { // on the backend, copied out under lock so a goroutine building an // asl.Executor doesn't need to hold b.mu for the executor's lifetime. type integrationsSnapshot struct { - lambdaInvoker asl.LambdaInvoker - sqsIntegration asl.SQSIntegration - snsIntegration asl.SNSIntegration - ddbIntegration asl.DynamoDBIntegration - ecsIntegration asl.ECSIntegration - ecsSyncWaiter asl.ECSSyncWaiter - glueIntegration asl.GlueIntegration - glueSyncWaiter asl.GlueSyncWaiter - ebIntegration asl.EventBridgeIntegration - sdkIntegration asl.SDKIntegration - roleAuth roleauth.Authorizer - s3Reader asl.S3Reader - s3ResultWriter asl.S3Writer - mockRun *asl.MockRun + lambdaInvoker asl.LambdaInvoker + sqsIntegration asl.SQSIntegration + snsIntegration asl.SNSIntegration + ddbIntegration asl.DynamoDBIntegration + ecsIntegration asl.ECSIntegration + ecsSyncWaiter asl.ECSSyncWaiter + glueIntegration asl.GlueIntegration + glueSyncWaiter asl.GlueSyncWaiter + ebIntegration asl.EventBridgeIntegration + sdkIntegration asl.SDKIntegration + roleAuth roleauth.Authorizer + s3Reader asl.S3Reader + s3ResultWriter asl.S3Writer + mockRun *asl.MockRun + rejectObjectItems bool } // snapshotIntegrationsLocked copies the configured integrations. Must be @@ -440,6 +441,8 @@ func (b *InMemoryBackend) snapshotIntegrationsLocked() integrationsSnapshot { roleAuth: b.roleAuth, s3Reader: b.s3Reader, s3ResultWriter: b.s3ResultWriter, + + rejectObjectItems: !b.settings.JSONataMapObjectItems, } } @@ -460,6 +463,7 @@ func applyIntegrations(executor *asl.Executor, s integrationsSnapshot) { executor.SetS3Reader(s.s3Reader) executor.SetS3ResultWriter(s.s3ResultWriter) executor.SetMockRun(s.mockRun) + executor.SetRejectMapObjectItems(s.rejectObjectItems) } func (b *InMemoryBackend) smARN(region, name string) string { diff --git a/services/stepfunctions/test_state_inspection_test.go b/services/stepfunctions/test_state_inspection_test.go index f6c87ec268..91fa6bec0c 100644 --- a/services/stepfunctions/test_state_inspection_test.go +++ b/services/stepfunctions/test_state_inspection_test.go @@ -39,15 +39,7 @@ func TestTestState_InspectionLevel_RealClient(t *testing.T) { "afterResultPath": `{"data":{"n":7},"out":{"picked":1}}`, }, }, - { - name: "trace_without_http_task", - level: sfntypes.InspectionLevelTrace, - wantFields: map[string]string{ - "input": `{"data":{"n":7}}`, - "afterInputPath": `{"n":7}`, - "afterParameters": `{"v":7}`, - }, - }, + {name: "trace_without_http_task", level: sfntypes.InspectionLevelTrace, wantErr: true}, {name: "unknown_level", level: sfntypes.InspectionLevel("VERBOSE"), wantErr: true}, } diff --git a/services/sts/assume_role_test.go b/services/sts/assume_role_test.go index 763a12a819..be1ec95a53 100644 --- a/services/sts/assume_role_test.go +++ b/services/sts/assume_role_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/sts" ) @@ -84,7 +85,7 @@ func postSTS(t *testing.T, serverURL string, form map[string]string, authHeader, req.Header.Set("X-Amz-Security-Token", secToken) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) diff --git a/services/sts/iam_enforcement_test.go b/services/sts/iam_enforcement_test.go index cd7639164d..f8e0405b8f 100644 --- a/services/sts/iam_enforcement_test.go +++ b/services/sts/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/sts" ) @@ -156,7 +157,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/sts/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/support/attachment_upload_test.go b/services/support/attachment_upload_test.go index 86294c766b..e1d015a86f 100644 --- a/services/support/attachment_upload_test.go +++ b/services/support/attachment_upload_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/require" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/support" ) @@ -86,7 +87,7 @@ func doRaw(t *testing.T, method, url string, body []byte) (*http.Response, []byt req, err := http.NewRequestWithContext(t.Context(), method, url, bytes.NewReader(body)) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/support/iam_enforcement_test.go b/services/support/iam_enforcement_test.go index c629760a76..733dad1222 100644 --- a/services/support/iam_enforcement_test.go +++ b/services/support/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/support" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/support/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/swf/iam_enforcement_test.go b/services/swf/iam_enforcement_test.go index 60eb5d90bd..9f619a6e66 100644 --- a/services/swf/iam_enforcement_test.go +++ b/services/swf/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/swf" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/swf/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/textract/iam_enforcement_test.go b/services/textract/iam_enforcement_test.go index c5a5636ace..71c2650ba5 100644 --- a/services/textract/iam_enforcement_test.go +++ b/services/textract/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/textract" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/textract/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/timestreamquery/PARITY.md b/services/timestreamquery/PARITY.md index aa9f09ac88..c232730bbd 100644 --- a/services/timestreamquery/PARITY.md +++ b/services/timestreamquery/PARITY.md @@ -2,7 +2,7 @@ service: timestreamquery sdk_module: aws-sdk-go-v2/service/timestreamquery@v1.44.3 last_audit_commit: a98a164d # NOT updated this pass -- git commands are off-limits (gopherstack-r80d batch 25) -last_audit_date: 2026-10-07 +last_audit_date: 2026-10-31 overall: A # this pass (gopherstack-r80d batch 25): DescribeScheduledQuery's # ScheduledQuery.TargetConfiguration.TimestreamConfiguration was missing 2 of # its 4 required members (TimeColumn/DimensionMappings) -- CreateScheduledQuery's @@ -22,14 +22,14 @@ overall: A # this pass (gopherstack-r80d batch 25): DescribeScheduled # explicit (manual) ExecuteScheduledQuery call, so the claim contradicted the # only mechanism that ever populates it; now MANUAL_TRIGGER_SUCCESS. ops: - Query: {wire: fixed, errors: ok, state: ok, persist: ok, note: "deterministic mock rows/columns inferred from SQL projection (documented; no real Timestream Write data source exists to query against). ClientToken TTL fixed 8h->4h to match documented window. marshalColumnInfos (shared with PrepareQuery) previously hand-picked only Type.ScalarType out of ColumnInfo, silently dropping ArrayColumnInfo/RowColumnInfo/TimeSeriesMeasureValueColumnInfo whenever set; now passes the full ColumnType struct through so the nested union marshals correctly (types.Type)."} + Query: {wire: fixed, errors: ok, state: fixed, persist: ok, note: "executes real SQL over records written via TimestreamWrite (2026-10-31, gopherstack-49qct.77; see the \"Query execution\" section). Rows/columns are computed, not inferred. ClientToken TTL fixed 8h->4h to match documented window. marshalColumnInfos (shared with PrepareQuery) previously hand-picked only Type.ScalarType out of ColumnInfo, silently dropping ArrayColumnInfo/RowColumnInfo/TimeSeriesMeasureValueColumnInfo whenever set; now passes the full ColumnType struct through so the nested union marshals correctly (types.Type)."} CancelQuery: {wire: ok, errors: ok, state: fixed, persist: ok, note: "was non-idempotent: 2nd CancelQuery on the same QueryId 404'd (ValidationException) instead of succeeding per documented idempotent-cancel contract. Now marks Cancelled in place instead of deleting."} PrepareQuery: {wire: ok, errors: ok, state: fixed, persist: n/a, note: "disguised no-op: ValidateOnly=true (the ONLY mode real Timestream documents as supported) returned an EMPTY Columns/Parameters list, discarding the inferred result for the one mode real clients use. Now returns the same inferred Columns/Parameters regardless of ValidateOnly. Shares the marshalColumnInfos nested-union fix noted under Query. 2026-08-20: verified PrepareQueryOutput.Columns is actually types.SelectColumn (api_op_PrepareQuery.go), a WIDER sibling of types.ColumnInfo carrying three extra optional fields (Aliased/DatabaseName/TableName) this package's marshalColumnInfos reuse omits -- see gaps (Parameters is types.ParameterMapping, wire-identical {Name,Type} to ColumnInfo, so no gap there)."} DescribeEndpoints: {wire: ok, errors: ok, state: ok, persist: n/a} CreateScheduledQuery: {wire: fixed, errors: ok, state: fixed, persist: ok, note: "ClientToken was parsed by the SDK request but never read by the handler/backend, so an SDK-auto-retried create (aws-sdk-go-v2 auto-generates ClientToken via idempotency-token-autofill middleware) hit ConflictException instead of replaying the original success. Now caches ClientToken->Arn for 8h and replays. Also: KmsKeyId (types.go:659) was accepted nowhere on the request and returned nowhere on ScheduledQueryDescription (types.go:659, same field name) -- a silent-drop, not just missing encryption. Now parsed, stored, and echoed back on DescribeScheduledQuery (still no actual at-rest encryption -- see gaps). 2026-08-20: TargetConfiguration.TimestreamConfiguration.DimensionMappings/TimeColumn (both required subfields of types.TimestreamConfiguration whenever TargetConfiguration is set, api_op_CreateScheduledQuery.go) plus MeasureNameColumn/MixedMeasureMappings/MultiMeasureMappings (optional) were accepted nowhere on create and echoed nowhere on describe -- confirmed with a failing real-SDK round-trip test before the fix. Now modeled end-to-end via ScheduledQueryTargetDetail, parsed in parseTargetConfiguration, and echoed in targetConfigurationView. 2026-08-23: closed the previously-disclosed ErrorReportConfiguration.S3Configuration.EncryptionOption/ObjectKeyPrefix gap (both real, optional members of types.S3Configuration) -- only BucketName was parsed/stored/echoed; now both are threaded through the same accepted-and-echoed pattern as KmsKeyId (still no actual encryption applied, honestly scoped, see gaps)."} DeleteScheduledQuery: {wire: ok, errors: ok, state: ok, persist: ok} DescribeScheduledQuery: {wire: fixed, errors: ok, state: ok, persist: ok, note: "LastRunSummary.ExecutionStats used the misspelled wire field ExecutionTimeInMillisecs; real field is ExecutionTimeInMillis (no trailing ecs) per awsAwsjson10_deserializeDocumentExecutionStats. gopherstack-r80d batch 25: ScheduledQueryDescription.TargetConfiguration.TimestreamConfiguration (types/types.go, TimestreamConfiguration struct) requires DatabaseName/DimensionMappings/TableName/TimeColumn once TargetConfiguration itself is present (validators.go:651-666, validateTimestreamConfiguration) -- CreateScheduledQuery's request parsing only ever read DatabaseName/TableName from the request body, silently dropping TimeColumn/DimensionMappings (no backing struct field at all). A real client's CreateScheduledQuery request setting a full, client-side-valid TargetConfiguration (the SDK's own validator requires all 4 once TargetConfiguration is set at all) got back a DescribeScheduledQuery response missing 2 required members. Fixed: added TargetTimeColumn/TargetDimensionMappings to the ScheduledQuery domain model, threaded through CreateScheduledQuery's request parsing and the StorageBackend interface/InMemoryBackend signature, and echoed on DescribeScheduledQuery's TargetConfiguration.TimestreamConfiguration view."} - ExecuteScheduledQuery: {wire: ok, errors: ok, state: fixed, persist: ok, note: "documented as running a scheduled query manually (api_op_ExecuteScheduledQuery.go: 'You can use this API to run a scheduled query manually'). This emulator has no scheduler goroutine, so ExecuteScheduledQuery is the ONLY code path that ever populates a run -- yet LastRunSummary.RunStatus and ScheduledQueryListEntry.LastRunStatus always claimed AUTO_TRIGGER_SUCCESS, asserting an automatic trigger that never happened. Fixed to MANUAL_TRIGGER_SUCCESS."} + ExecuteScheduledQuery: {wire: ok, errors: ok, state: fixed, persist: ok, note: "2026-10-31: now runs the query (with @scheduled_runtime = InvocationTime) and ingests the result rows into the target table per TargetConfiguration (TimeColumn, DimensionMappings, MeasureNameColumn, MixedMeasureMappings, MultiMeasureMappings); LastRunSummary carries the real RunStatus (MANUAL_TRIGGER_SUCCESS or MANUAL_TRIGGER_FAILURE + FailureReason) and ExecutionStats (QueryResultRows, RecordsIngested, DataWrites, CumulativeBytesScanned, BytesMetered, ExecutionTimeInMillis). Earlier: documented as running a scheduled query manually (api_op_ExecuteScheduledQuery.go: 'You can use this API to run a scheduled query manually'). This emulator has no scheduler goroutine, so ExecuteScheduledQuery is the ONLY code path that ever populates a run -- yet LastRunSummary.RunStatus and ScheduledQueryListEntry.LastRunStatus always claimed AUTO_TRIGGER_SUCCESS, asserting an automatic trigger that never happened. Fixed to MANUAL_TRIGGER_SUCCESS."} ListScheduledQueries: {wire: ok, errors: ok, state: ok, persist: ok} UpdateScheduledQuery: {wire: ok, errors: ok, state: ok, persist: ok} DescribeAccountSettings: {wire: fixed, errors: ok, state: ok, persist: ok, note: "DELETED an invented LastUpdatedTime field: neither DescribeAccountSettingsOutput nor UpdateAccountSettingsOutput (real SDK) defines one. Now also surfaces QueryCompute.ProvisionedCapacity.NotificationConfiguration when set (see UpdateAccountSettings)."} @@ -38,15 +38,48 @@ families: tags: {status: deferred, note: "TagResource/UntagResource/ListTagsForResource are in GetSupportedOperations() and have working handlers/backend methods (own ARN-keyed tag map), but RouteMatcher intentionally excludes them (writeServiceTagOps) so production traffic is routed to the TimestreamWrite handler's unified cross-resource tag store instead. Verified TimestreamWrite's TagResource treats ResourceARN as an opaque key (no resource-type-specific lookup), so scheduled-query ARNs tag correctly there. This package's own tag handlers are dead code in production, reachable only via direct unit tests / Handler() bypassing RouteMatcher -- confirmed intentional, not a routing bug."} route_matching: {status: ok, note: "X-Amz-Target prefix Timestream_20181101., Content-Type application/x-amz-json-1.0 (awsjson1.0) verified against serializers.go (awsAwsjson10_*). DescribeEndpoints wired for SDK endpoint-discovery (fetchOpQueryDiscoverEndpoint calls DescribeEndpoints first)."} gaps: [] -items_still_open: [] +items_still_open: + - "Query engine: functions outside the Trino/Athena SQLite function set (time-series: create_time_series, interpolate_*, derivative_*, integral_*, ...; geospatial; parse_duration, typeof, current_timezone beyond a literal) return ValidationException 'Function ... not registered'. The exact real message text is unverified." + - "Query engine: result ColumnInfo types for expressions are inferred from values (BIGINT/DOUBLE/VARCHAR/TIMESTAMP); boolean-valued expressions (comparisons) report BIGINT, and an empty result reports VARCHAR for computed columns. Plain column references keep their declared type." + - "Query engine: table schema is derived from the records still retained, so a table whose records have all aged out (or were never written) loses its dimension/measure columns until new data arrives; real Timestream keeps the schema. SELECT * column order (dimensions in first-seen order, measure_name, time, measure_value::T sorted by type, multi attributes in first-seen order) is unverified." + - "Query paging: a NextToken is valid for 5 uses or 1 hour (Query API doc), but issuing a newer token does not invalidate older ones, and the 1 MB page-size cap is not applied (MaxRows only; default 1000)." + - "Query: the 60 s timeout, QueryExecutionException, EXPLAIN, UNLOAD and parameterised (@name) queries other than @scheduled_runtime are not implemented." + - "ScheduledQuery: no scheduler (every run is a manual ExecuteScheduledQuery), no S3 error report on failed rows, RecentlyFailedRuns and run QueryInsightsResponse are omitted." structural_gaps: - "CreateScheduledQuery KmsKeyId is stored and echoed, but there is no at-rest encryption layer to apply it to." - - "ScheduledQueryDescription.RecentlyFailedRuns and ScheduledQueryRunSummary.QueryInsightsResponse: ExecuteScheduledQuery runs no real query engine, so no run can fail or produce insights; both fields are optional and omitted." -deferred: - - "Query/CancelQuery against genuinely long-running or multi-page real query execution semantics -- this emulator's Query is synchronous and instantaneous (matches the mock-data-source design already documented in QueryWithOptions), so QueryExecutionException (a real error type for query engine failures) is never returned. Acceptable per the documented deterministic-mock design; revisit only if a real backing data source is added." leaks: {status: clean, note: "clientTokens/scheduledQueryTokens/pageStore are self-contained caches with their own mutex, reset on Reset()/version-mismatch Restore(); queries table is bounded by maxRetainedQueries (10000) with arbitrary eviction, cancelled or not. No goroutines/tickers in this package -- nothing to ctx-parent or drain on Shutdown."} --- +## Query execution (2026-10-31, gopherstack-49qct.77) + +Sources: Timestream Developer Guide "Date / time functions" (ago, bin, now, interval literals, ns timestamps), +"Query" API reference (MaxRows 1-1000, ClientToken 4 h window, NextToken valid for 5 uses or 1 hour, ConflictException +for polling a cancelled query), DESCRIBE/SHOW statements, ScheduledQuery TimestreamConfiguration shapes in +aws-sdk-go-v2 timestreamquery. + +**Engine.** `Query` loads each referenced `"db"."table"` from TimestreamWrite (via `RecordSource`, wired in +internal/app) into a private in-memory SQLite database and runs the statement after translating Trino SQL with +athena's `TranslateTrino` (joins, CTEs, window functions, GROUP BY/HAVING, UNION, `INTERVAL` arithmetic, the Trino +string/date/JSON/array function set and aggregates such as approx_percentile, max_by, count_if). Timestream-only +syntax is rewritten first (`ts_rewrite.go`): `measure_value::double|bigint|varchar|boolean|timestamp` columns, +interval literals (`15m`, `1d`, `10day`), `ago()`, `bin()`, `now()`/`current_timestamp`/`current_date`, +`@scheduled_runtime`. Extra functions: from/to_milliseconds, from/to_nanoseconds. DESCRIBE, SHOW DATABASES|TABLES| +MEASURES (with LIKE) are answered from the derived schema. + +**Table model.** Dimensions (varchar), `measure_name`, `time` (ns timestamp, rendered `2026-10-31 12:00:00.000000000`), +`measure_value::` for single-measure records and one column per MULTI attribute; absent values are NULL. +Records older than the memory window but inside `MagneticStoreRetentionPeriodInDays` stay queryable; older ones are +excluded at query time and removed by the retention sweep (which now uses the magnetic window, not the memory window). + +**Result model.** ColumnInfo scalar types, Datum ScalarValue/NullValue, doubles rendered Java-style (`60.25`, `100.0`, +`1.0E7`), unaliased expression columns named `_col`. Pagination materialises the result; tokens are `queryId:offset`. + +**Status.** CumulativeBytesScanned = stored size of every record of every referenced table (time 8 B + measure name + +values + dimension names/values); CumulativeBytesMetered = max(scanned, 10 MB) whenever a table is read, else 0. + +Tests: internal/app/timestream_query_sdk_test.go (SDK write + query, full app), test/integration/timestream_query_parity_test.go, +services/timestreamquery/ts_rewrite_test.go, services/timestreamwrite/records_test.go (TestTableData_RetentionAndRestore). + ## Notes **2026-10-10 (realism pass):** Query and PrepareQuery reject statements that cannot parse (unknown leading keyword, unbalanced parentheses, unterminated literal) with ValidationException; this is a shallow check, not a SQL parser. CreateScheduledQuery validates Name (1-64 of letters, digits, `_`, `.`, `-`) and the query text; ListScheduledQueries tokens are opaque and malformed ones, or MaxResults over 1000, return ValidationException. Tests: TestQuerySyntaxValidation, TestScheduledQueryPagingAndNames. diff --git a/services/timestreamquery/README.md b/services/timestreamquery/README.md index e9253b6f71..19fe45ceb4 100644 --- a/services/timestreamquery/README.md +++ b/services/timestreamquery/README.md @@ -1,7 +1,7 @@ # Timestream Query -**Parity grade: A** · SDK `aws-sdk-go-v2/service/timestreamquery@v1.44.3` · last audited 2026-10-07 (`a98a164d`) +**Parity grade: A** · SDK `aws-sdk-go-v2/service/timestreamquery@v1.44.3` · last audited 2026-10-31 (`a98a164d`) ## Coverage @@ -9,21 +9,25 @@ | --- | --- | | PARITY entries audited | 12 (12 ok) | | Feature families | 2 (1 ok, 1 deferred) | -| Known gaps | none | -| Structural gaps (can't be emulated) | 2 | -| Deferred items | 1 | +| Known gaps | 6 | +| Structural gaps (can't be emulated) | 1 | +| Deferred items | 0 | | Resource leaks | clean | +### Known gaps + +- Query engine: functions outside the Trino/Athena SQLite function set (time-series: create_time_series, interpolate_*, derivative_*, integral_*, ...; geospatial; parse_duration, typeof, current_timezone beyond a literal) return ValidationException 'Function ... not registered'. The exact real message text is unverified. +- Query engine: result ColumnInfo types for expressions are inferred from values (BIGINT/DOUBLE/VARCHAR/TIMESTAMP); boolean-valued expressions (comparisons) report BIGINT, and an empty result reports VARCHAR for computed columns. Plain column references keep their declared type. +- Query engine: table schema is derived from the records still retained, so a table whose records have all aged out (or were never written) loses its dimension/measure columns until new data arrives; real Timestream keeps the schema. SELECT * column order (dimensions in first-seen order, measure_name, time, measure_value::T sorted by type, multi attributes in first-seen order) is unverified. +- Query paging: a NextToken is valid for 5 uses or 1 hour (Query API doc), but issuing a newer token does not invalidate older ones, and the 1 MB page-size cap is not applied (MaxRows only; default 1000). +- Query: the 60 s timeout, QueryExecutionException, EXPLAIN, UNLOAD and parameterised (@name) queries other than @scheduled_runtime are not implemented. +- ScheduledQuery: no scheduler (every run is a manual ExecuteScheduledQuery), no S3 error report on failed rows, RecentlyFailedRuns and run QueryInsightsResponse are omitted. + ### Structural gaps These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. - CreateScheduledQuery KmsKeyId is stored and echoed, but there is no at-rest encryption layer to apply it to. -- ScheduledQueryDescription.RecentlyFailedRuns and ScheduledQueryRunSummary.QueryInsightsResponse: ExecuteScheduledQuery runs no real query engine, so no run can fail or produce insights; both fields are optional and omitted. - -### Deferred - -- Query/CancelQuery against genuinely long-running or multi-page real query execution semantics -- this emulator's Query is synchronous and instantaneous (matches the mock-data-source design already documented in QueryWithOptions), so QueryExecutionException (a real error type for query engine failures) is never returned. Acceptable per the documented deterministic-mock design; revisit only if a real backing data source is added. ## More diff --git a/services/timestreamquery/catalog_test.go b/services/timestreamquery/catalog_test.go index 340bacf574..346d3370ff 100644 --- a/services/timestreamquery/catalog_test.go +++ b/services/timestreamquery/catalog_test.go @@ -34,7 +34,7 @@ func TestCatalogTableReferences(t *testing.T) { wired bool }{ {name: "existing", query: "SELECT * FROM db.tbl", wired: true}, - {name: "quoted existing", query: `SELECT * FROM "db"."tbl" WHERE x = 1`, wired: true}, + {name: "quoted existing", query: `SELECT * FROM "db"."tbl" WHERE measure_name = 'x'`, wired: true}, {name: "missing table", query: "SELECT * FROM db.nope", wantErr: "Table 'db.nope' does not exist", wired: true}, { name: "missing database", diff --git a/services/timestreamquery/iam_enforcement_test.go b/services/timestreamquery/iam_enforcement_test.go index ce1aa99580..834d17341b 100644 --- a/services/timestreamquery/iam_enforcement_test.go +++ b/services/timestreamquery/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/timestreamquery" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/timestream/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/timestreamquery/models.go b/services/timestreamquery/models.go index 346c56be95..dcaefc9677 100644 --- a/services/timestreamquery/models.go +++ b/services/timestreamquery/models.go @@ -19,6 +19,9 @@ type ScheduledQuery struct { Name string `json:"name"` Arn string `json:"arn"` KmsKeyID string `json:"kms_key_id,omitempty"` + LastRunStats *ExecutionStats `json:"last_run_stats,omitempty"` + LastRunStatus string `json:"last_run_status,omitempty"` + LastRunFailure string `json:"last_run_failure,omitempty"` // ErrorReportEncryptionOption/ErrorReportObjectKeyPrefix mirror // types.S3Configuration.EncryptionOption/ObjectKeyPrefix (both optional, // api_op_CreateScheduledQuery.go) -- accepted and echoed back, but this diff --git a/services/timestreamquery/query_execution.go b/services/timestreamquery/query_execution.go index 594223d5d0..6626c9cd06 100644 --- a/services/timestreamquery/query_execution.go +++ b/services/timestreamquery/query_execution.go @@ -12,13 +12,7 @@ import ( "github.com/google/uuid" ) -// Numeric constants for estimation heuristics. -const ( - bytesPerCell = 32 // avg bytes per scalar cell for scan estimate - minScanBytes = 10 * 1024 * 1024 // 10 MB minimum Timestream billing unit - tokenParts = 2 // NextToken format: "queryID:offset" - queryProgressComplete = 100.0 -) +const queryProgressComplete = 100.0 // maxRetainedQueries bounds the queries map so a long-running instance cannot // leak memory through repeated Query calls: once at the cap, an arbitrarily @@ -37,24 +31,18 @@ func (b *InMemoryBackend) QueryWithOptions(ctx context.Context, opts QueryOption return nil, err } - // Continuation page from NextToken. if opts.NextToken != "" { - queryID, rows, cols, nextTok, resolveErr := b.pageStore.resolve(opts.NextToken, maxRows) + pg, resolveErr := b.pageStore.resolve(opts.NextToken, maxRows, b.clock()) if resolveErr != nil { return nil, resolveErr } - scanned := estimateBytesScanned(len(rows), len(cols)) return &QueryPage{ - QueryID: queryID, - Rows: rows, - Columns: cols, - QueryStatus: QueryStatusDetail{ - ProgressPercentage: queryProgressComplete, - CumulativeBytesScanned: scanned, - CumulativeBytesMetered: estimateBytesMetered(scanned), - }, - NextToken: nextTok, + QueryID: pg.queryID, + Rows: pg.rows, + Columns: pg.cols, + QueryStatus: pg.status, + NextToken: pg.next, }, nil } @@ -66,36 +54,32 @@ func (b *InMemoryBackend) QueryWithOptions(ctx context.Context, opts QueryOption return nil, err } - // ClientToken idempotency check. if opts.ClientToken != "" { b.clientTokens.sweep() if cachedID := b.clientTokens.get(opts.ClientToken); cachedID != "" { - // Return the first page of the cached result. - page, nextTok, resumeErr := b.resumeFirstPage(cachedID, maxRows) + pg, resumeErr := b.pageStore.restart(cachedID, maxRows, b.clock()) if resumeErr == nil { - return &QueryPage{QueryID: cachedID, Rows: page, NextToken: nextTok}, nil + return &QueryPage{ + QueryID: cachedID, Rows: pg.rows, Columns: pg.cols, QueryStatus: pg.status, NextToken: pg.next, + }, nil } - // If cached result is gone, fall through and re-execute. } } - // New query execution — infer schema from SQL. - cols, _ := inferColumnsFromSQL(opts.QueryString) - rows := []Row{} // Simulator: always empty (no live Timestream Write data available). + rs, err := b.execute(ctx, opts.QueryString, nil) + if err != nil { + return nil, err + } queryID := newQueryID() - scanned := estimateBytesScanned(len(rows), len(cols)) + status := statusFor(rs) result := &QueryResult{ - QueryID: queryID, - QueryStatus: QueryStatusDetail{ - ProgressPercentage: queryProgressComplete, - CumulativeBytesScanned: scanned, - CumulativeBytesMetered: estimateBytesMetered(scanned), - }, - Rows: rows, - Columns: cols, - Insights: buildQueryInsightsResponse(rows, cols), + QueryID: queryID, + QueryStatus: status, + Rows: rs.rows, + Columns: rs.cols, + Insights: buildQueryInsightsResponse(rs), } b.mu.Lock("QueryWithOptions") @@ -107,7 +91,7 @@ func (b *InMemoryBackend) QueryWithOptions(ctx context.Context, opts QueryOption b.clientTokens.set(opts.ClientToken, queryID) } - page, nextTok := b.pageStore.store(queryID, rows, cols, maxRows) + pg := b.pageStore.store(queryID, rs.rows, rs.cols, status, maxRows, b.clock()) var insights *QueryInsightsResponse if opts.InsightsMode != "DISABLED" { @@ -117,47 +101,31 @@ func (b *InMemoryBackend) QueryWithOptions(ctx context.Context, opts QueryOption return &QueryPage{ QueryID: queryID, - Rows: page, - Columns: cols, - QueryStatus: result.QueryStatus, + Rows: pg.rows, + Columns: rs.cols, + QueryStatus: status, Insights: insights, - NextToken: nextTok, + NextToken: pg.next, }, nil } -// resumeFirstPage retrieves the first page of a previously-stored query result. -func (b *InMemoryBackend) resumeFirstPage(queryID string, maxRows int) ([]Row, string, error) { - b.pageStore.mu.Lock() - defer b.pageStore.mu.Unlock() - r, ok := b.pageStore.results[queryID] - if !ok { - return nil, "", fmt.Errorf("%w: query %q not found in page store", ErrNotFound, queryID) +// Query runs a query and returns its full result (legacy path); a failing query yields an empty result. +func (b *InMemoryBackend) Query(ctx context.Context, queryString string) *QueryResult { + rs, err := b.execute(ctx, queryString, nil) + if err != nil { + rs = &resultSet{rows: []Row{}} } - r.offset = 0 - page, next := r.nextPage(maxRows) - return page, next, nil -} + result := &QueryResult{ + QueryID: newQueryID(), + QueryStatus: statusFor(rs), + Rows: rs.rows, + Columns: rs.cols, + } -// Query runs a query and returns a result (legacy path, calls QueryWithOptions). -// ctx is accepted for interface consistency; query results are not region-isolated. -func (b *InMemoryBackend) Query(_ context.Context, queryString string) *QueryResult { b.mu.Lock("Query") defer b.mu.Unlock() - queryID := newQueryID() - cols, _ := inferColumnsFromSQL(queryString) - result := &QueryResult{ - QueryID: queryID, - QueryStatus: QueryStatusDetail{ - ProgressPercentage: queryProgressComplete, - CumulativeBytesScanned: 0, - CumulativeBytesMetered: 0, - }, - Rows: []Row{}, - Columns: cols, - } - evictOldestQueryLocked(b) b.queries.Put(result) @@ -181,6 +149,7 @@ func (b *InMemoryBackend) CancelQuery(_ context.Context, queryID string) error { } qr.Cancelled = true + b.pageStore.cancel(queryID) return nil } @@ -274,35 +243,27 @@ func NullDatum() Datum { return Datum{NullValue: &t} } -// estimateBytesScanned returns a rough byte estimate for n rows with c columns. -// Assumes ~32 bytes per cell (average scalar value) as a sim heuristic. -func estimateBytesScanned(rows int, cols int) int64 { - if rows == 0 || cols == 0 { - return 0 - } - estimated := int64(rows) * int64(cols) * bytesPerCell - // Timestream bills a 10 MB minimum per query scan. - // minScanBytes used from package-level constant - if estimated < minScanBytes { - return minScanBytes +// statusFor reports the completed-query status: bytes scanned is the stored size of every +// record in the referenced tables; metered applies Timestream's 10 MB per-query minimum +// whenever a table was read. +func statusFor(rs *resultSet) QueryStatusDetail { + metered := rs.scanned + if rs.tables > 0 && metered < minMeteredBytes { + metered = minMeteredBytes } - return estimated -} - -// estimateBytesMetered returns the billing unit. For BYTES_SCANNED model, -// it mirrors bytes scanned; for COMPUTE_UNITS, it is a fixed minimum. -func estimateBytesMetered(scanned int64) int64 { - return scanned + return QueryStatusDetail{ + ProgressPercentage: queryProgressComplete, + CumulativeBytesScanned: rs.scanned, + CumulativeBytesMetered: metered, + } } -// buildQueryInsightsResponse builds a QueryInsightsResponse for a completed query. -func buildQueryInsightsResponse(rows []Row, cols []ColumnInfo) QueryInsightsResponse { - bytesOut := estimateBytesScanned(len(rows), len(cols)) - +// buildQueryInsightsResponse builds the insights payload for a completed query. +func buildQueryInsightsResponse(rs *resultSet) QueryInsightsResponse { return QueryInsightsResponse{ - OutputRows: int64(len(rows)), - OutputBytes: bytesOut, + OutputRows: int64(len(rs.rows)), + OutputBytes: rs.outBytes(), } } @@ -371,29 +332,45 @@ func (c *clientTokenCache) sweep() { } // --------------------------------------------------------------------------- -// Paginated query result store (gap #7) +// Paginated query result store // --------------------------------------------------------------------------- +const ( + // pageTokenTTL and pageTokenMaxUses follow the Query API doc: "A pagination token can be + // used for up to five Query invocations, OR for a duration of up to 1 hour". + pageTokenTTL = time.Hour + pageTokenMaxUses = 5 +) + // pagedQueryResult stores the full result set for a query awaiting paging. type pagedQueryResult struct { createdAt time.Time + uses map[int]int queryID string rows []Row columns []ColumnInfo - offset int + status QueryStatusDetail + cancelled bool +} + +// pageOut is one served page. +type pageOut struct { + queryID string + next string + rows []Row + cols []ColumnInfo + status QueryStatusDetail } -// nextPage slices up to maxRows rows starting at result.offset and advances the cursor. -// It returns the page slice and a NextToken if more rows remain; empty string means last page. -func (r *pagedQueryResult) nextPage(maxRows int) ([]Row, string) { - end := min(r.offset+maxRows, len(r.rows)) - page := r.rows[r.offset:end] - r.offset = end - if r.offset < len(r.rows) { - return page, r.queryID + ":" + strconv.Itoa(r.offset) +func (r *pagedQueryResult) slice(offset, maxRows int) pageOut { + end := min(offset+maxRows, len(r.rows)) + out := pageOut{queryID: r.queryID, rows: r.rows[offset:end], cols: r.columns, status: r.status} + + if end < len(r.rows) { + out.next = r.queryID + ":" + strconv.Itoa(end) } - return page, "" + return out } // nextTokenStore manages paged query results. @@ -406,48 +383,84 @@ func newNextTokenStore() *nextTokenStore { return &nextTokenStore{results: make(map[string]*pagedQueryResult)} } -// store saves a paged result and returns the initial NextToken if rows > maxRows. -func (s *nextTokenStore) store(queryID string, rows []Row, cols []ColumnInfo, maxRows int) ([]Row, string) { +func (s *nextTokenStore) sweepLocked(now time.Time) { + for id, r := range s.results { + if now.Sub(r.createdAt) > pageTokenTTL { + delete(s.results, id) + } + } +} + +// store saves a materialised result and returns its first page. +func (s *nextTokenStore) store( + queryID string, rows []Row, cols []ColumnInfo, status QueryStatusDetail, maxRows int, now time.Time, +) pageOut { s.mu.Lock() defer s.mu.Unlock() + s.sweepLocked(now) + r := &pagedQueryResult{ - queryID: queryID, - rows: rows, - columns: cols, - offset: 0, - createdAt: time.Now(), + queryID: queryID, rows: rows, columns: cols, status: status, createdAt: now, uses: map[int]int{}, } s.results[queryID] = r - return r.nextPage(maxRows) + return r.slice(0, maxRows) } -// resolve retrieves the next page for a continuation token. -// Token format: "queryID:offset". -func (s *nextTokenStore) resolve(token string, maxRows int) (string, []Row, []ColumnInfo, string, error) { +// restart returns the first page of a stored result (ClientToken replay). +func (s *nextTokenStore) restart(queryID string, maxRows int, now time.Time) (pageOut, error) { s.mu.Lock() defer s.mu.Unlock() - parts := strings.SplitN(token, ":", tokenParts) - if len(parts) != tokenParts { - return "", nil, nil, "", fmt.Errorf("%w: invalid NextToken", ErrValidation) + r, ok := s.results[queryID] + if !ok || now.Sub(r.createdAt) > pageTokenTTL { + return pageOut{}, fmt.Errorf("%w: query %q not found in page store", ErrNotFound, queryID) } - queryID := parts[0] - offset, err := strconv.Atoi(parts[1]) - if err != nil { - return "", nil, nil, "", fmt.Errorf("%w: invalid NextToken offset", ErrValidation) + + return r.slice(0, maxRows), nil +} + +// cancel invalidates further paging of queryID. +func (s *nextTokenStore) cancel(queryID string) { + s.mu.Lock() + defer s.mu.Unlock() + + if r, ok := s.results[queryID]; ok { + r.cancelled = true } +} - r, ok := s.results[queryID] +// resolve serves the page named by a continuation token ("queryID:offset"). +func (s *nextTokenStore) resolve(token string, maxRows int, now time.Time) (pageOut, error) { + s.mu.Lock() + defer s.mu.Unlock() + + queryID, offStr, ok := strings.Cut(token, ":") if !ok { - return "", nil, nil, "", fmt.Errorf("%w: NextToken refers to expired or unknown query", ErrValidation) + return pageOut{}, fmt.Errorf("%w: Invalid pagination token", ErrValidation) + } + + offset, err := strconv.Atoi(offStr) + if err != nil || offset < 0 { + return pageOut{}, fmt.Errorf("%w: Invalid pagination token", ErrValidation) } - r.offset = offset - page, nextToken := r.nextPage(maxRows) + r, ok := s.results[queryID] + if !ok || now.Sub(r.createdAt) > pageTokenTTL || offset > len(r.rows) { + return pageOut{}, fmt.Errorf("%w: Invalid pagination token", ErrValidation) + } + + if r.cancelled { + return pageOut{}, fmt.Errorf("%w: Unable to poll results for a cancelled query", ErrAlreadyExists) + } + + r.uses[offset]++ + if r.uses[offset] > pageTokenMaxUses { + return pageOut{}, fmt.Errorf("%w: Invalid pagination token", ErrValidation) + } - return queryID, page, r.columns, nextToken, nil + return r.slice(offset, maxRows), nil } // newQueryID returns a new unique query ID. diff --git a/services/timestreamquery/query_execution_test.go b/services/timestreamquery/query_execution_test.go index 5619a1cdbd..4f4756eab0 100644 --- a/services/timestreamquery/query_execution_test.go +++ b/services/timestreamquery/query_execution_test.go @@ -22,7 +22,7 @@ func TestTimestreamQueryHandler_Query(t *testing.T) { }{ { name: "success", - body: map[string]any{"QueryString": "SELECT * FROM my_table"}, + body: map[string]any{"QueryString": "SELECT * FROM mydb.my_table"}, wantCode: http.StatusOK, wantQueryID: true, }, diff --git a/services/timestreamquery/query_validation_test.go b/services/timestreamquery/query_validation_test.go index 3489077de8..cc4c2dfb58 100644 --- a/services/timestreamquery/query_validation_test.go +++ b/services/timestreamquery/query_validation_test.go @@ -18,7 +18,7 @@ func TestQuerySyntaxValidation(t *testing.T) { wantErr bool }{ {"SELECT 1", false}, - {" select * from db.t where x in (1, 2)", false}, + {" select * from db.t where measure_name in ('a', 'b')", false}, {"WITH a AS (SELECT 1) SELECT * FROM a", false}, {"SELEC 1", true}, {"DROP TABLE x", true}, diff --git a/services/timestreamquery/scheduled_queries.go b/services/timestreamquery/scheduled_queries.go index fec5de811a..e07e122737 100644 --- a/services/timestreamquery/scheduled_queries.go +++ b/services/timestreamquery/scheduled_queries.go @@ -192,22 +192,6 @@ func (b *InMemoryBackend) UpdateScheduledQuery(_ context.Context, arnStr, state return nil } -// ExecuteScheduledQuery marks a scheduled query as executed at the given invocation time. -// The ARN self-encodes the region, so no separate region resolution is needed. -func (b *InMemoryBackend) ExecuteScheduledQuery(_ context.Context, arnStr string, invocationTime time.Time) error { - b.mu.Lock("ExecuteScheduledQuery") - defer b.mu.Unlock() - - sq, err := b.lookupByARN(arnStr) - if err != nil { - return err - } - - sq.LastRunTime = invocationTime - - return nil -} - // lookupByARN finds a scheduled query by its (globally unique) ARN. // Must be called with the lock held. func (b *InMemoryBackend) lookupByARN(arnStr string) (*ScheduledQuery, error) { @@ -401,12 +385,6 @@ const ( runStatusManualTriggerSuccess = "MANUAL_TRIGGER_SUCCESS" ) -// Numeric constants used to simulate a scheduled query execution. -const ( - simExecTimeMs = 500 // simulated execution time in milliseconds - simRecordsIngested = 10 // simulated records ingested per execution -) - // buildScheduledQueryListEntry converts a ScheduledQuery to an enriched list entry. func buildScheduledQueryListEntry(sq *ScheduledQuery) ScheduledQueryListEntry { entry := ScheduledQueryListEntry{ @@ -425,7 +403,10 @@ func buildScheduledQueryListEntry(sq *ScheduledQuery) ScheduledQueryListEntry { } now := time.Now() if !sq.LastRunTime.IsZero() { - entry.LastRunStatus = runStatusManualTriggerSuccess + entry.LastRunStatus = sq.LastRunStatus + if entry.LastRunStatus == "" { + entry.LastRunStatus = runStatusManualTriggerSuccess + } entry.PreviousInvocationTime = epochSeconds(sq.LastRunTime) } else if sq.ScheduleExpression != "" { entry.PreviousInvocationTime = epochSeconds(previousInvocationTime(sq.ScheduleExpression, now)) @@ -435,21 +416,28 @@ func buildScheduledQueryListEntry(sq *ScheduledQuery) ScheduledQueryListEntry { return entry } -// buildLastRunSummary constructs a full LastRunSummary for a ScheduledQuery. +// buildLastRunSummary constructs the LastRunSummary from the recorded outcome of the latest run. func buildLastRunSummary(sq *ScheduledQuery) *LastRunSummary { if sq.LastRunTime.IsZero() { return nil } - triggerTime := sq.LastRunTime.Add(-time.Second) + + status := sq.LastRunStatus + if status == "" { + status = runStatusManualTriggerSuccess + } + + stats := ExecutionStats{} + if sq.LastRunStats != nil { + stats = *sq.LastRunStats + } return &LastRunSummary{ - RunStatus: runStatusManualTriggerSuccess, + RunStatus: status, + FailureReason: sq.LastRunFailure, InvocationTime: epochSeconds(sq.LastRunTime), - TriggerTime: epochSeconds(triggerTime), - ExecutionStats: &ExecutionStats{ - ExecutionTimeInMillis: simExecTimeMs, - RecordsIngested: simRecordsIngested, - }, + TriggerTime: epochSeconds(sq.LastRunTime.Add(-time.Second)), + ExecutionStats: &stats, } } diff --git a/services/timestreamquery/scheduled_write.go b/services/timestreamquery/scheduled_write.go new file mode 100644 index 0000000000..c6413e22fc --- /dev/null +++ b/services/timestreamquery/scheduled_write.go @@ -0,0 +1,239 @@ +package timestreamquery + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +const runStatusManualTriggerFailure = "MANUAL_TRIGGER_FAILURE" + +var ( + errTargetColumn = errors.New("column named in the target configuration is not in the query result") + errTimeColumn = errors.New("time column value is not a timestamp") +) + +// ExecuteScheduledQuery runs the scheduled query once for invocationTime ("@scheduled_runtime") +// and ingests its result rows into the target table, recording the run outcome. +func (b *InMemoryBackend) ExecuteScheduledQuery(ctx context.Context, arnStr string, invocationTime time.Time) error { + b.mu.RLock("ExecuteScheduledQuery") + found, err := b.lookupByARN(arnStr) + if err != nil { + b.mu.RUnlock() + + return err + } + + sq := cloneScheduledQuery(found) + b.mu.RUnlock() + + ctx = context.WithValue(ctx, regionContextKey{}, regionFromARN(arnStr, b.defaultRegion)) + started := time.Now() + stats, runErr := b.runScheduledQuery(ctx, sq, invocationTime) + stats.ExecutionTimeInMillis = time.Since(started).Milliseconds() + + b.mu.Lock("ExecuteScheduledQuery") + defer b.mu.Unlock() + + cur, lookupErr := b.lookupByARN(arnStr) + if lookupErr != nil { + return nil + } + + cur.LastRunTime = invocationTime + cur.LastRunStats = &stats + cur.LastRunStatus = runStatusManualTriggerSuccess + cur.LastRunFailure = "" + + if runErr != nil { + cur.LastRunStatus = runStatusManualTriggerFailure + cur.LastRunFailure = runErr.Error() + } + + return nil +} + +func (b *InMemoryBackend) runScheduledQuery( + ctx context.Context, sq *ScheduledQuery, invocationTime time.Time, +) (ExecutionStats, error) { + var stats ExecutionStats + + rs, err := b.execute(ctx, sq.QueryString, map[string]string{"@scheduled_runtime": formatTS(invocationTime.UTC())}) + if err != nil { + return stats, err + } + + stats.QueryResultRows = int64(len(rs.rows)) + stats.CumulativeBytesScanned = rs.scanned + stats.BytesMetered = statusFor(rs).CumulativeBytesMetered + + src := b.recordSource() + if sq.TargetDetail == nil || src == nil { + return stats, nil + } + + recs, err := scheduledRecords(rs, sq.TargetDetail, invocationTime) + if err != nil { + return stats, err + } + + if len(recs) == 0 { + return stats, nil + } + + region := getRegion(ctx, b.defaultRegion) + if err = src.AppendRecords(region, sq.TargetDatabase, sq.TargetTable, recs); err != nil { + return stats, err + } + + stats.RecordsIngested = int64(len(recs)) + + for _, r := range recs { + stats.DataWrites += recordBytes(r) + } + + return stats, nil +} + +type rowView struct { + idx map[string]int + data []Datum +} + +func (v rowView) get(col string) (string, bool) { + i, ok := v.idx[col] + if !ok || i >= len(v.data) || v.data[i].ScalarValue == nil { + return "", false + } + + return *v.data[i].ScalarValue, true +} + +// scheduledRecords maps result rows onto records per TargetConfiguration.TimestreamConfiguration. +func scheduledRecords(rs *resultSet, td *ScheduledQueryTargetDetail, version time.Time) ([]SourceRecord, error) { + idx := make(map[string]int, len(rs.cols)) + for i, c := range rs.cols { + idx[c.Name] = i + } + + for _, need := range append([]string{td.TimeColumn, td.MeasureNameColumn}, dimensionColumns(td)...) { + if _, ok := idx[need]; need != "" && !ok { + return nil, fmt.Errorf("%w: '%s'", errTargetColumn, need) + } + } + + var out []SourceRecord + + for _, row := range rs.rows { + v := rowView{idx: idx, data: row.Data} + + raw, ok := v.get(td.TimeColumn) + if !ok { + continue + } + + t, ok := parseTSValue(raw) + if !ok { + return nil, fmt.Errorf("%w: '%s' = '%s'", errTimeColumn, td.TimeColumn, raw) + } + + base := SourceRecord{Time: t, Version: version.UnixMilli(), Dimensions: dimensionsOf(v, td)} + out = append(out, recordsForRow(v, td, base)...) + } + + return out, nil +} + +func dimensionColumns(td *ScheduledQueryTargetDetail) []string { + cols := make([]string, 0, len(td.DimensionMappings)) + for _, d := range td.DimensionMappings { + cols = append(cols, d.Name) + } + + return cols +} + +func dimensionsOf(v rowView, td *ScheduledQueryTargetDetail) []SourceDimension { + var dims []SourceDimension + + for _, d := range td.DimensionMappings { + if val, ok := v.get(d.Name); ok { + dims = append(dims, SourceDimension{Name: d.Name, Value: val}) + } + } + + return dims +} + +func recordsForRow(v rowView, td *ScheduledQueryTargetDetail, base SourceRecord) []SourceRecord { + nameVal, _ := v.get(td.MeasureNameColumn) + + var out []SourceRecord + + if mm := td.MultiMeasureMappings; mm != nil { + name := mm.TargetMultiMeasureName + if td.MeasureNameColumn != "" { + name = nameVal + } + + if r, ok := multiRecord(v, base, name, mm.MultiMeasureAttributeMappings); ok { + out = append(out, r) + } + } + + for _, m := range td.MixedMeasureMappings { + if td.MeasureNameColumn != "" && m.MeasureName != nameVal { + continue + } + + target := firstNonEmpty(m.TargetMeasureName, m.MeasureName, m.SourceColumn) + + if strings.EqualFold(m.MeasureValueType, measureTypeMulti) { + if r, ok := multiRecord(v, base, target, m.MultiMeasureAttributeMappings); ok { + out = append(out, r) + } + + continue + } + + if val, ok := v.get(m.SourceColumn); ok { + r := base + r.MeasureName = target + r.MeasureValue = val + r.MeasureValueType = strings.ToUpper(m.MeasureValueType) + out = append(out, r) + } + } + + return out +} + +func multiRecord(v rowView, base SourceRecord, name string, attrs []MultiMeasureAttributeMapping) (SourceRecord, bool) { + r := base + r.MeasureName = name + r.MeasureValueType = measureTypeMulti + + for _, a := range attrs { + if val, ok := v.get(a.SourceColumn); ok { + r.Measures = append(r.Measures, SourceMeasure{ + Name: firstNonEmpty(a.TargetMultiMeasureAttributeName, a.SourceColumn), + Value: val, + Type: strings.ToUpper(a.MeasureValueType), + }) + } + } + + return r, len(r.Measures) > 0 +} + +func firstNonEmpty(xs ...string) string { + for _, x := range xs { + if x != "" { + return x + } + } + + return "" +} diff --git a/services/timestreamquery/source.go b/services/timestreamquery/source.go new file mode 100644 index 0000000000..1f14855b81 --- /dev/null +++ b/services/timestreamquery/source.go @@ -0,0 +1,84 @@ +package timestreamquery + +import "time" + +// Measure value types as written by TimestreamWrite. +const ( + measureTypeDouble = "DOUBLE" + measureTypeBigint = "BIGINT" + measureTypeVarchar = "VARCHAR" + measureTypeBoolean = "BOOLEAN" + measureTypeTimestamp = "TIMESTAMP" + measureTypeMulti = "MULTI" +) + +// SourceDimension is one dimension of a stored record. +type SourceDimension struct { + Name string + Value string +} + +// SourceMeasure is one named measure of a MULTI record. TIMESTAMP values arrive +// formatted as "2006-01-02 15:04:05.000000000". +type SourceMeasure struct { + Name string + Value string + Type string +} + +// SourceRecord is a record read from, or written to, a Timestream Write table. +// A record is single-measure when MeasureValueType is not MULTI, else Measures holds its values. +type SourceRecord struct { + Time time.Time + MeasureName string + MeasureValue string + MeasureValueType string + Dimensions []SourceDimension + Measures []SourceMeasure + Version int64 +} + +// RecordSource is the seam onto Timestream Write's tables. +type RecordSource interface { + Catalog + // TableRecords returns the records still queryable at now. + TableRecords(region, database, table string, now time.Time) ([]SourceRecord, error) + ListDatabases(region string) []string + ListTables(region, database string) []string + AppendRecords(region, database, table string, records []SourceRecord) error +} + +// SetRecordSource wires the record source used to execute queries and scheduled-query writes. +// It also serves as the table catalog. +func (b *InMemoryBackend) SetRecordSource(s RecordSource) { + b.mu.Lock("SetRecordSource") + defer b.mu.Unlock() + b.source = s + b.catalog = s +} + +func (b *InMemoryBackend) recordSource() RecordSource { + b.mu.RLock("recordSource") + defer b.mu.RUnlock() + + return b.source +} + +// SetClock replaces the wall clock used for now(), ago() and page-token expiry. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + b.nowFn = now +} + +func (b *InMemoryBackend) clock() time.Time { + b.mu.RLock("clock") + fn := b.nowFn + b.mu.RUnlock() + + if fn == nil { + return time.Now().UTC() + } + + return fn().UTC() +} diff --git a/services/timestreamquery/store.go b/services/timestreamquery/store.go index e6e299df79..02d0ff94a5 100644 --- a/services/timestreamquery/store.go +++ b/services/timestreamquery/store.go @@ -3,6 +3,7 @@ package timestreamquery import ( "context" "strings" + "time" "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" "github.com/blackbirdworks/gopherstack/pkgs/store" @@ -47,6 +48,8 @@ type InMemoryBackend struct { pageStore *nextTokenStore tagWriteBackend TagWriteBackend catalog Catalog + source RecordSource + nowFn func() time.Time accountID string defaultRegion string } diff --git a/services/timestreamquery/ts_engine.go b/services/timestreamquery/ts_engine.go new file mode 100644 index 0000000000..346155d5b8 --- /dev/null +++ b/services/timestreamquery/ts_engine.go @@ -0,0 +1,262 @@ +package timestreamquery + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/services/athena" +) + +const ( + engineTablePrefix = "ts_" + // minMeteredBytes is the 10 MB minimum Timestream bills for any query that scans data. + minMeteredBytes = 10 * 1024 * 1024 +) + +var ( + reNoColumn = regexp.MustCompile(`no such column: (\S+)`) + reNoFunction = regexp.MustCompile(`no such function: (\S+)`) + reSQLiteTag = regexp.MustCompile(`^SQL logic error: |\s\(\d+\)$`) +) + +func validationf(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrValidation, fmt.Sprintf(format, args...)) +} + +func tableKey(db, table string) string { return db + "\x00" + table } + +func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } + +// execute runs a SELECT, DESCRIBE or SHOW statement against the written records. +func (b *InMemoryBackend) execute( + ctx context.Context, query string, vars map[string]string, +) (*resultSet, error) { + query = strings.TrimSpace(strings.TrimRight(strings.TrimSpace(query), ";")) + region := getRegion(ctx, b.defaultRegion) + + switch firstWord(query) { + case "DESCRIBE": + return b.describe(region, query) + case "SHOW": + return b.show(region, query) + case "EXPLAIN": + return nil, validationf("line 1:1: EXPLAIN is not supported") + } + + now := b.clock() + src := b.recordSource() + + tables, scanned, err := loadSourceTables(region, src, query, now) + if err != nil { + return nil, err + } + + translated, err := translate(rewriteTimestream(query, now, vars), tables) + if err != nil { + return nil, err + } + + rs, err := runTranslated(ctx, translated, tables) + if err != nil { + return nil, err + } + + rs.scanned = scanned + rs.tables = len(tables) + + return rs, nil +} + +func firstWord(q string) string { + end := strings.IndexFunc(q, func(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }) + if end < 0 { + end = len(q) + } + + return strings.ToUpper(q[:end]) +} + +type tableData struct { + sqlName string + schema tableSchema + records []SourceRecord +} + +type loadedSet map[string]*tableData + +func loadSourceTables( + region string, src RecordSource, query string, now time.Time, +) (loadedSet, int64, error) { + set := loadedSet{} + + var scanned int64 + + for _, ref := range tableRefs(query) { + key := tableKey(ref.database, ref.table) + if _, dup := set[key]; dup { + continue + } + + var recs []SourceRecord + + if src != nil { + var err error + + recs, err = src.TableRecords(region, ref.database, ref.table, now) + if err != nil { + return nil, 0, validationf("line 1:1: Table '%s.%s' does not exist", ref.database, ref.table) + } + } + + sort.SliceStable(recs, func(i, j int) bool { return recs[i].Time.Before(recs[j].Time) }) + + for _, r := range recs { + scanned += recordBytes(r) + } + + set[key] = &tableData{ + sqlName: engineTablePrefix + strconv.Itoa(len(set)), schema: deriveSchema(recs), records: recs, + } + } + + return set, scanned, nil +} + +func translate(q string, set loadedSet) (string, error) { + resolve := func(parts []string) (string, string, bool) { + if len(parts) != qualifiedParts { + return "", "", false + } + + t, ok := set[tableKey(parts[0], parts[1])] + if !ok { + return "", "", false + } + + return "main", t.sqlName, true + } + + out, unresolved, err := athena.TranslateTrino(q, resolve) + if err != nil { + return "", validationf("%s", err.Error()) + } + + if len(unresolved) > 0 { + if !strings.Contains(unresolved[0], ".") { + return "", validationf("line 1:15: Schema must be specified when session schema is not set") + } + + return "", validationf("line 1:15: Table '%s' does not exist", unresolved[0]) + } + + return out, nil +} + +func runTranslated(ctx context.Context, q string, set loadedSet) (*resultSet, error) { + registerTimestreamFuncs() + + db, err := athena.OpenEngineDB() + if err != nil { + return nil, fmt.Errorf("timestreamquery: open engine: %w", err) + } + defer func() { _ = db.Close() }() + + conn, err := db.Conn(ctx) + if err != nil { + return nil, fmt.Errorf("timestreamquery: engine connection: %w", err) + } + defer func() { _ = conn.Close() }() + + for _, t := range set { + if err = loadTable(ctx, conn, t); err != nil { + return nil, fmt.Errorf("timestreamquery: load table: %w", err) + } + } + + for _, p := range []string{"PRAGMA case_sensitive_like=ON", "PRAGMA query_only=ON"} { + if _, err = conn.ExecContext(ctx, p); err != nil { + return nil, fmt.Errorf("timestreamquery: engine pragma: %w", err) + } + } + + rows, err := conn.QueryContext(ctx, q) + if err != nil { + return nil, mapEngineError(err) + } + defer func() { _ = rows.Close() }() + + rs, err := readRows(rows) + if err != nil { + return nil, mapEngineError(err) + } + + return rs, nil +} + +func loadTable(ctx context.Context, conn *sql.Conn, t *tableData) error { + decls := make([]string, len(t.schema.cols)) + marks := make([]string, len(t.schema.cols)) + + for i, c := range t.schema.cols { + decls[i] = quoteIdent(c.name) + " " + declOf(c.typ) + marks[i] = "?" + } + + target := quoteIdent("main") + "." + quoteIdent(t.sqlName) + ddl := "CREATE TABLE " + target + " (" + strings.Join(decls, ", ") + ")" //nolint:gosec // quoted identifiers + if _, err := conn.ExecContext(ctx, ddl); err != nil { + return err + } + + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return err + } + + if err = insertRecords(ctx, tx, target, strings.Join(marks, ","), t); err != nil { + _ = tx.Rollback() + + return err + } + + return tx.Commit() +} + +func insertRecords(ctx context.Context, tx *sql.Tx, target, marks string, t *tableData) error { + insert := "INSERT INTO " + target + " VALUES (" + marks + ")" //nolint:gosec // quoted identifiers + + stmt, err := tx.PrepareContext(ctx, insert) + if err != nil { + return err + } + defer func() { _ = stmt.Close() }() + + for _, r := range t.records { + if _, err = stmt.ExecContext(ctx, t.schema.rowValues(r)...); err != nil { + return err + } + } + + return nil +} + +// mapEngineError turns SQLite failures into Timestream-style ValidationExceptions. +func mapEngineError(err error) error { + msg := err.Error() + + if m := reNoColumn.FindStringSubmatch(msg); m != nil { + return validationf("line 1:8: Column '%s' does not exist", strings.Trim(m[1], `"`)) + } + + if m := reNoFunction.FindStringSubmatch(msg); m != nil { + return validationf("line 1:8: Function '%s' not registered", m[1]) + } + + return validationf("line 1:1: %s", reSQLiteTag.ReplaceAllString(msg, "")) +} diff --git a/services/timestreamquery/ts_funcs.go b/services/timestreamquery/ts_funcs.go new file mode 100644 index 0000000000..1f7e7858b2 --- /dev/null +++ b/services/timestreamquery/ts_funcs.go @@ -0,0 +1,148 @@ +package timestreamquery + +import ( + "database/sql/driver" + "errors" + "sync" + "time" + + sqlitedriver "modernc.org/sqlite" +) + +//nolint:gochecknoglobals // the sqlite driver's function registry is process-wide +var registerFuncsOnce sync.Once + +var errBadArg = errors.New("invalid argument") + +const argsTwo = 2 + +func tsParseLayouts() []string { + return []string{ + "2006-01-02 15:04:05.999999999", "2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05Z07:00", "2006-01-02", + } +} + +func parseTSValue(v driver.Value) (time.Time, bool) { + s, ok := v.(string) + if !ok { + if b, isBytes := v.([]byte); isBytes { + s, ok = string(b), true + } + } + + if !ok { + return time.Time{}, false + } + + for _, l := range tsParseLayouts() { + if t, err := time.Parse(l, s); err == nil { + return t.UTC(), true + } + } + + return time.Time{}, false +} + +func intArg(v driver.Value) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + return int64(n), true + default: + return 0, false + } +} + +// udfBin implements bin(x, interval): x rounded down to a multiple of the interval (nanoseconds for timestamps). +func udfBin(_ *sqlitedriver.FunctionContext, args []driver.Value) (driver.Value, error) { + if args[0] == nil || args[1] == nil { + return nil, nil //nolint:nilnil // SQL NULL + } + + step, ok := intArg(args[1]) + if !ok || step <= 0 { + return nil, errBadArg + } + + if t, isTS := parseTSValue(args[0]); isTS { + ns := t.UnixNano() + + return formatTS(time.Unix(0, ns-floorMod(ns, step))), nil + } + + x, isNum := args[0].(int64) + if !isNum { + f, isFloat := args[0].(float64) + if !isFloat { + return nil, errBadArg + } + + return float64(int64(f) - floorMod(int64(f), step)), nil + } + + return x - floorMod(x, step), nil +} + +func floorMod(a, m int64) int64 { + r := a % m + if r < 0 { + r += m + } + + return r +} + +// udfAgo implements ago(interval) against the query's fixed now (first argument). +func udfAgo(_ *sqlitedriver.FunctionContext, args []driver.Value) (driver.Value, error) { + now, ok := parseTSValue(args[0]) + d, isInt := intArg(args[1]) + + if !ok || !isInt { + return nil, errBadArg + } + + return formatTS(now.Add(-time.Duration(d))), nil +} + +func unitFunc(toTime func(int64) time.Time) func(*sqlitedriver.FunctionContext, []driver.Value) (driver.Value, error) { + return func(_ *sqlitedriver.FunctionContext, args []driver.Value) (driver.Value, error) { + if args[0] == nil { + return nil, nil + } + + n, ok := intArg(args[0]) + if !ok { + return nil, errBadArg + } + + return formatTS(toTime(n)), nil + } +} + +func toUnit(per func(time.Time) int64) func(*sqlitedriver.FunctionContext, []driver.Value) (driver.Value, error) { + return func(_ *sqlitedriver.FunctionContext, args []driver.Value) (driver.Value, error) { + if args[0] == nil { + return nil, nil + } + + t, ok := parseTSValue(args[0]) + if !ok { + return nil, errBadArg + } + + return per(t), nil + } +} + +func registerTimestreamFuncs() { + registerFuncsOnce.Do(func() { + reg := sqlitedriver.MustRegisterDeterministicScalarFunction + reg("ts_bin", argsTwo, udfBin) + reg("ts_ago", argsTwo, udfAgo) + reg("from_milliseconds", 1, unitFunc(time.UnixMilli)) + reg("from_nanoseconds", 1, unitFunc(func(n int64) time.Time { return time.Unix(0, n) })) + reg("to_milliseconds", 1, toUnit(func(t time.Time) int64 { return t.UnixMilli() })) + reg("to_nanoseconds", 1, toUnit(func(t time.Time) int64 { return t.UnixNano() })) + }) +} diff --git a/services/timestreamquery/ts_meta.go b/services/timestreamquery/ts_meta.go new file mode 100644 index 0000000000..132699a2e4 --- /dev/null +++ b/services/timestreamquery/ts_meta.go @@ -0,0 +1,196 @@ +package timestreamquery + +import ( + "encoding/json" + "regexp" + "sort" + "strings" +) + +func varcharResult(names []string, rows [][]string) *resultSet { + rs := &resultSet{} + for _, n := range names { + rs.cols = append(rs.cols, scalarColumnInfo(n, ScalarTypeVarchar)) + } + + for _, r := range rows { + data := make([]Datum, len(r)) + for i, v := range r { + data[i] = ScalarDatum(v) + } + + rs.rows = append(rs.rows, Row{Data: data}) + } + + return rs +} + +// afterKeyword returns the text following the first whole-word kw in q. +func afterKeyword(q, kw string) (string, bool) { + i := indexKeyword(strings.ToUpper(q), kw) + if i < 0 { + return "", false + } + + return q[i+len(kw):], true +} + +func (b *InMemoryBackend) tableSchemaFor(region, ref string, src RecordSource) (tableSchema, error) { + r, ok := parseRefAfter(ref, 0) + if !ok { + return tableSchema{}, validationf("line 1:1: expected database.table") + } + + if src == nil { + return deriveSchema(nil), nil + } + + if !src.DatabaseExists(region, r.database) { + return tableSchema{}, validationf("line 1:1: Database '%s' does not exist", r.database) + } + + recs, err := src.TableRecords(region, r.database, r.table, b.clock()) + if err != nil { + return tableSchema{}, validationf("line 1:1: Table '%s.%s' does not exist", r.database, r.table) + } + + return deriveSchema(recs), nil +} + +func (b *InMemoryBackend) describe(region, q string) (*resultSet, error) { + schema, err := b.tableSchemaFor(region, strings.TrimSpace(q[len("DESCRIBE"):]), b.recordSource()) + if err != nil { + return nil, err + } + + rows := make([][]string, 0, len(schema.cols)) + for _, c := range schema.cols { + rows = append(rows, []string{c.name, c.typ, c.attr}) + } + + return varcharResult([]string{"Column", "Type", "Timestream attribute type"}, rows), nil +} + +func likeMatcher(q string) func(string) bool { + rest, ok := afterKeyword(q, "LIKE") + if !ok { + return func(string) bool { return true } + } + + rest = strings.TrimSpace(rest) + if len(rest) < 2 || rest[0] != '\'' { + return func(string) bool { return true } + } + + pat := rest[1:] + if end := strings.IndexByte(pat, '\''); end >= 0 { + pat = pat[:end] + } + + re := regexp.MustCompile("^" + strings.NewReplacer("%", ".*", "_", ".").Replace(regexp.QuoteMeta(pat)) + "$") + + return re.MatchString +} + +func (b *InMemoryBackend) show(region, q string) (*resultSet, error) { + src := b.recordSource() + words := strings.Fields(strings.ToUpper(q)) + + if len(words) < argsTwo { + return nil, validationf("line 1:5: mismatched input ''. Expecting: 'DATABASES', 'MEASURES', 'TABLES'") + } + + switch words[1] { + case "DATABASES": + return b.showDatabases(region, q, src), nil + case "TABLES": + return b.showTables(region, q, src) + case "MEASURES": + return b.showMeasures(region, q, src) + default: + return nil, validationf( + "line 1:6: mismatched input '%s'. Expecting: 'DATABASES', 'MEASURES', 'TABLES'", strings.ToLower(words[1])) + } +} + +func (b *InMemoryBackend) showDatabases(region, q string, src RecordSource) *resultSet { + var rows [][]string + + if src != nil { + match := likeMatcher(q) + + for _, d := range src.ListDatabases(region) { + if match(d) { + rows = append(rows, []string{d}) + } + } + } + + return varcharResult([]string{"DatabaseName"}, rows) +} + +func (b *InMemoryBackend) showTables(region, q string, src RecordSource) (*resultSet, error) { + var rows [][]string + + rest, hasFrom := afterKeyword(q, "FROM") + if src != nil && hasFrom { + db, _, ok := parseIdent(strings.TrimSpace(rest), 0) + if !ok { + return nil, validationf("line 1:1: expected database name") + } + + if !src.DatabaseExists(region, db) { + return nil, validationf("line 1:1: Database '%s' does not exist", db) + } + + match := likeMatcher(q) + + for _, t := range src.ListTables(region, db) { + if match(t) { + rows = append(rows, []string{t}) + } + } + } + + return varcharResult([]string{"TableName"}, rows), nil +} + +func (b *InMemoryBackend) showMeasures(region, q string, src RecordSource) (*resultSet, error) { + rest, ok := afterKeyword(q, "FROM") + if !ok { + return nil, validationf("line 1:14: mismatched input ''. Expecting: 'FROM'") + } + + schema, err := b.tableSchemaFor(region, strings.TrimSpace(rest), src) + if err != nil { + return nil, err + } + + types := map[string]string{} + for _, c := range schema.cols { + types[c.name] = c.typ + } + + match := likeMatcher(q) + + var rows [][]string + + for _, m := range schema.measures { + if !match(m.name) { + continue + } + + dims := make([]map[string]string, 0, len(m.dims)) + sorted := append([]string(nil), m.dims...) + sort.Strings(sorted) + + for _, d := range sorted { + dims = append(dims, map[string]string{"dimension_name": d, "data_type": types[d]}) + } + + enc, _ := json.Marshal(dims) + rows = append(rows, []string{m.name, m.typ, string(enc)}) + } + + return varcharResult([]string{"measure_name", "data_type", "Dimensions"}, rows), nil +} diff --git a/services/timestreamquery/ts_query_test.go b/services/timestreamquery/ts_query_test.go new file mode 100644 index 0000000000..7115bab430 --- /dev/null +++ b/services/timestreamquery/ts_query_test.go @@ -0,0 +1,179 @@ +package timestreamquery_test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/timestreamquery" +) + +var errNoTable = errors.New("no such table") + +type memSource struct { + tables map[string][]timestreamquery.SourceRecord +} + +func (m *memSource) DatabaseExists(_, db string) bool { return db == "db" } + +func (m *memSource) TableExists(_, db, table string) bool { + _, ok := m.tables[db+"."+table] + + return ok +} + +func (m *memSource) TableRecords(_, db, table string, _ time.Time) ([]timestreamquery.SourceRecord, error) { + recs, ok := m.tables[db+"."+table] + if !ok { + return nil, errNoTable + } + + return recs, nil +} + +func (m *memSource) ListDatabases(string) []string { return []string{"db"} } + +func (m *memSource) ListTables(string, string) []string { return []string{"t"} } + +func (m *memSource) AppendRecords(_, db, table string, recs []timestreamquery.SourceRecord) error { + m.tables[db+"."+table] = append(m.tables[db+"."+table], recs...) + + return nil +} + +func queryRows(t *testing.T, q string) ([][]string, []string) { + t.Helper() + + now := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + at := func(d time.Duration) time.Time { return now.Add(-d) } + dims := []timestreamquery.SourceDimension{{Name: "host", Value: "a"}} + + src := &memSource{tables: map[string][]timestreamquery.SourceRecord{"db.t": { + { + Time: at(20 * time.Minute), + MeasureName: "cpu", + MeasureValueType: "DOUBLE", + MeasureValue: "10", + Dimensions: dims, + }, + { + Time: at(5 * time.Minute), + MeasureName: "cpu", + MeasureValueType: "DOUBLE", + MeasureValue: "30", + Dimensions: dims, + }, + {Time: at(3 * time.Hour), MeasureName: "cpu", MeasureValueType: "DOUBLE", MeasureValue: "1", Dimensions: dims}, + {Time: at(time.Minute), MeasureName: "n", MeasureValueType: "BIGINT", MeasureValue: "7"}, + }}} + + b := timestreamquery.NewInMemoryBackend("000000000000", "us-east-1") + b.SetClock(func() time.Time { return now }) + b.SetRecordSource(src) + + page, err := b.QueryWithOptions(t.Context(), timestreamquery.QueryOptions{QueryString: q}) + require.NoError(t, err) + + rows := make([][]string, 0, len(page.Rows)) + for _, r := range page.Rows { + row := make([]string, 0, len(r.Data)) + + for _, d := range r.Data { + if d.NullValue != nil { + row = append(row, "") + + continue + } + + row = append(row, *d.ScalarValue) + } + + rows = append(rows, row) + } + + types := make([]string, 0, len(page.Columns)) + for _, c := range page.Columns { + types = append(types, c.Name+":"+c.Type.ScalarType) + } + + return rows, types +} + +func TestQuery_TimestreamSyntax(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + wantRows [][]string + wantTypes []string + }{ + { + name: "ago literal and double cast", + query: `SELECT measure_value::double FROM db.t WHERE measure_name = 'cpu' AND time > ago(1h) ORDER BY time`, + wantRows: [][]string{{"10.0"}, {"30.0"}}, + wantTypes: []string{"measure_value::double:DOUBLE"}, + }, + { + name: "ago interval form", + query: `SELECT count(*) FROM db.t WHERE time > ago(INTERVAL '10' MINUTE)`, + wantRows: [][]string{{"2"}}, + wantTypes: []string{"_col0:BIGINT"}, + }, + { + name: "bin five minutes", + query: `SELECT bin(time, 5m) AS b FROM db.t WHERE measure_name = 'cpu' AND time > ago(1h) ORDER BY b`, + wantRows: [][]string{{"2026-10-31 11:40:00.000000000"}, {"2026-10-31 11:55:00.000000000"}}, + wantTypes: []string{"b:TIMESTAMP"}, + }, + { + name: "bin ten days", + query: `SELECT bin(TIMESTAMP '2022-06-17 10:15:20', 30day)`, + wantRows: [][]string{{"2022-05-28 00:00:00.000000000"}}, + wantTypes: []string{"_col0:TIMESTAMP"}, + }, + { + name: "now arithmetic", + query: `SELECT now() - 2h`, + wantRows: [][]string{{"2026-10-31 10:00:00.000000000"}}, + wantTypes: []string{"_col0:TIMESTAMP"}, + }, + { + name: "from_milliseconds", + query: `SELECT from_milliseconds(1), to_milliseconds(from_milliseconds(1500))`, + wantRows: [][]string{{"1970-01-01 00:00:00.001000000", "1500"}}, + wantTypes: []string{"_col0:TIMESTAMP", "_col1:BIGINT"}, + }, + { + name: "doubles render java style", + query: `SELECT avg(measure_value::double) AS a FROM db.t WHERE measure_name = 'cpu' AND time > ago(1h)`, + wantRows: [][]string{{"20.0"}}, + wantTypes: []string{"a:DOUBLE"}, + }, + { + name: "null dimension", + query: `SELECT host FROM db.t WHERE measure_name = 'n'`, + wantRows: [][]string{{""}}, + wantTypes: []string{"host:VARCHAR"}, + }, + { + name: "string literals untouched", + query: `SELECT 'ago(1h) now()'`, + wantRows: [][]string{{"ago(1h) now()"}}, + wantTypes: []string{"_col0:VARCHAR"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rows, types := queryRows(t, tt.query) + assert.Equal(t, tt.wantRows, rows) + assert.Equal(t, tt.wantTypes, types) + }) + } +} diff --git a/services/timestreamquery/ts_result.go b/services/timestreamquery/ts_result.go new file mode 100644 index 0000000000..53d1239826 --- /dev/null +++ b/services/timestreamquery/ts_result.go @@ -0,0 +1,233 @@ +package timestreamquery + +import ( + "database/sql" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + reIdent = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_:]*$`) + reTSText = regexp.MustCompile(`^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.(\d{3}|\d{9})$`) +) + +// resultSet is a fully materialised query result. +type resultSet struct { + cols []ColumnInfo + rows []Row + scanned int64 + tables int +} + +// outBytes is the size of the rendered scalar values, reported as insights OutputBytes. +func (r *resultSet) outBytes() int64 { + var n int64 + + for _, row := range r.rows { + for _, d := range row.Data { + if d.ScalarValue != nil { + n += int64(len(*d.ScalarValue)) + } + } + } + + return n +} + +func columnName(name string, i int) string { + if reIdent.MatchString(name) && !strings.EqualFold(name, "null") { + return name + } + + return "_col" + strconv.Itoa(i) +} + +func declType(decl string) string { + switch strings.ToUpper(decl) { + case declTimestamp: + return ScalarTypeTimestamp + case declBigint: + return ScalarTypeBigint + case declDouble: + return ScalarTypeDouble + case declBoolean: + return ScalarTypeBoolean + case declVarchar: + return ScalarTypeVarchar + default: + return "" + } +} + +func inferType(col []any) string { + for _, v := range col { + switch x := v.(type) { + case nil: + continue + case int64: + return ScalarTypeBigint + case float64: + return ScalarTypeDouble + case bool: + return ScalarTypeBoolean + case string: + if reTSText.MatchString(x) { + return ScalarTypeTimestamp + } + + return ScalarTypeVarchar + default: + return ScalarTypeVarchar + } + } + + return ScalarTypeVarchar +} + +func readRows(rows *sql.Rows) (*resultSet, error) { + names, err := rows.Columns() + if err != nil { + return nil, err + } + + ctypes, err := rows.ColumnTypes() + if err != nil { + return nil, err + } + + var raw [][]any + + for rows.Next() { + dest := make([]any, len(names)) + ptrs := make([]any, len(names)) + + for i := range dest { + ptrs[i] = &dest[i] + } + + if err = rows.Scan(ptrs...); err != nil { + return nil, err + } + + raw = append(raw, dest) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + rs := &resultSet{rows: make([]Row, 0, len(raw))} + types := make([]string, len(names)) + + for i, n := range names { + col := make([]any, len(raw)) + for r := range raw { + col[r] = raw[r][i] + } + + t := declType(ctypes[i].DatabaseTypeName()) + if t == "" { + t = inferType(col) + } + + types[i] = t + rs.cols = append(rs.cols, scalarColumnInfo(columnName(n, i), t)) + } + + for _, r := range raw { + data := make([]Datum, len(r)) + for i, v := range r { + data[i] = datumOf(v, types[i]) + } + + rs.rows = append(rs.rows, Row{Data: data}) + } + + return rs, nil +} + +func datumOf(v any, typ string) Datum { + if v == nil { + return NullDatum() + } + + switch x := v.(type) { + case []byte: + return ScalarDatum(string(x)) + case int64: + if typ == ScalarTypeBoolean { + return ScalarDatum(strconv.FormatBool(x != 0)) + } + + if typ == ScalarTypeDouble { + return ScalarDatum(javaDouble(float64(x))) + } + + return ScalarDatum(strconv.FormatInt(x, 10)) + case float64: + if typ == ScalarTypeBigint { + return ScalarDatum(strconv.FormatInt(int64(x), 10)) + } + + return ScalarDatum(javaDouble(x)) + case bool: + return ScalarDatum(strconv.FormatBool(x)) + case time.Time: + return ScalarDatum(formatTS(x)) + case string: + if typ == ScalarTypeTimestamp { + if t, ok := parseTSValue(x); ok { + return ScalarDatum(formatTS(t)) + } + } + + return ScalarDatum(x) + default: + return ScalarDatum("") + } +} + +// javaDouble renders a double the way Timestream (Java Double.toString) does: +// plain decimal in [1e-3, 1e7) with at least one fractional digit, else scientific. +func javaDouble(f float64) string { + switch { + case math.IsNaN(f): + return "NaN" + case math.IsInf(f, 1): + return "Infinity" + case math.IsInf(f, -1): + return "-Infinity" + case f == 0: + return "0.0" + } + + abs := math.Abs(f) + if abs >= 1e-3 && abs < 1e7 { + s := strconv.FormatFloat(f, 'f', -1, 64) + if !strings.Contains(s, ".") { + s += ".0" + } + + return s + } + + s := strconv.FormatFloat(f, 'E', -1, 64) + mant, exp, _ := strings.Cut(s, "E") + + if !strings.Contains(mant, ".") { + mant += ".0" + } + + exp = strings.TrimPrefix(exp, "+") + neg := strings.HasPrefix(exp, "-") + exp = strings.TrimLeft(strings.TrimPrefix(exp, "-"), "0") + + if neg { + exp = "-" + exp + } + + return mant + "E" + exp +} diff --git a/services/timestreamquery/ts_rewrite.go b/services/timestreamquery/ts_rewrite.go new file mode 100644 index 0000000000..79080c0565 --- /dev/null +++ b/services/timestreamquery/ts_rewrite.go @@ -0,0 +1,142 @@ +package timestreamquery + +import ( + "regexp" + "strconv" + "strings" + "time" +) + +var ( + reAgoInterval = regexp.MustCompile(`(?i)\bago\(\s*interval\s+'(\d+)'\s+([a-z]+)\s*\)`) + reBinInterval = regexp.MustCompile(`(?is)\bbin\((.+?),\s*interval\s+'(\d+)'\s+([a-z]+)\s*\)`) + reMeasureCol = regexp.MustCompile(`(?i)\bmeasure_value::(double|bigint|varchar|boolean|timestamp)\b`) + reDuration = regexp.MustCompile( + `(?i)\b(\d+)(nanoseconds?|ns|microseconds?|us|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d)\b`) + reSignedDur = regexp.MustCompile( + `(?i)([+\-])\s*(\d+)(nanoseconds?|ns|microseconds?|us|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d)\b`, + ) + reAgo = regexp.MustCompile(`(?i)\bago\(`) + reBin = regexp.MustCompile(`(?i)\bbin\(`) + reNow = regexp.MustCompile(`(?i)\bnow\(\s*\)|\bcurrent_timestamp\b(\s*\(\s*\))?|\blocaltimestamp\b`) + reDate = regexp.MustCompile(`(?i)\bcurrent_date\b`) + reTimezone = regexp.MustCompile(`(?i)\bcurrent_timezone\(\s*\)`) +) + +const hoursPerDayDur = 24 + +func unitNanos(unit string) int64 { + u := strings.ToLower(unit) + + switch { + case u == "ns" || strings.HasPrefix(u, "nano"): + return 1 + case u == "us" || strings.HasPrefix(u, "micro"): + return int64(time.Microsecond) + case u == "ms" || strings.HasPrefix(u, "milli"): + return int64(time.Millisecond) + case u == "s" || strings.HasPrefix(u, "sec"): + return int64(time.Second) + case u == "m" || strings.HasPrefix(u, "min"): + return int64(time.Minute) + case u == "h" || strings.HasPrefix(u, "hour"): + return int64(time.Hour) + default: + return int64(hoursPerDayDur * time.Hour) + } +} + +func durationNanos(n, unit string) string { + v, _ := strconv.ParseInt(n, 10, 64) + + return strconv.FormatInt(v*unitNanos(unit), 10) +} + +// rewriteTimestream rewrites Timestream-only syntax (interval literals, ago/bin/now, +// measure_value::T columns, @scheduled_runtime) into the Trino subset the SQLite +// engine translates. String literals and quoted identifiers are left untouched. +func rewriteTimestream(q string, now time.Time, vars map[string]string) string { + q = reAgoInterval.ReplaceAllStringFunc(q, func(m string) string { + g := reAgoInterval.FindStringSubmatch(m) + + return "ago(" + durationNanos(g[1], g[2]) + "ns)" + }) + q = reBinInterval.ReplaceAllStringFunc(q, func(m string) string { + g := reBinInterval.FindStringSubmatch(m) + + return "bin(" + g[1] + ", " + durationNanos(g[2], g[3]) + "ns)" + }) + + nowLit := "'" + formatTS(now) + "'" + dateLit := "'" + now.UTC().Format("2006-01-02") + "'" + + var b strings.Builder + + for len(q) > 0 { + i := strings.IndexAny(q, `'"`) + if i < 0 { + b.WriteString(rewriteCode(q, nowLit, dateLit, vars)) + + break + } + + b.WriteString(rewriteCode(q[:i], nowLit, dateLit, vars)) + + end := closingQuote(q, i) + b.WriteString(q[i:end]) + q = q[end:] + } + + return b.String() +} + +// closingQuote returns the index just past the quoted run starting at q[i] (” and "" escape). +func closingQuote(q string, i int) int { + quote := q[i] + + for j := i + 1; j < len(q); j++ { + if q[j] != quote { + continue + } + + if j+1 < len(q) && q[j+1] == quote { + j++ + + continue + } + + return j + 1 + } + + return len(q) +} + +func rewriteCode(seg, nowLit, dateLit string, vars map[string]string) string { + for name, v := range vars { + seg = strings.ReplaceAll(seg, name, "'"+v+"'") + } + + seg = reMeasureCol.ReplaceAllString(seg, `"measure_value::$1"`) + seg = reSignedDur.ReplaceAllStringFunc(seg, func(m string) string { + g := reSignedDur.FindStringSubmatch(m) + + ns, _ := strconv.ParseInt(durationNanos(g[2], g[3]), 10, 64) + if ns%int64(time.Second) != 0 { + return m + } + + return g[1] + " INTERVAL '" + strconv.FormatInt(ns/int64(time.Second), 10) + "' second" + }) + seg = reDuration.ReplaceAllStringFunc(seg, func(m string) string { + g := reDuration.FindStringSubmatch(m) + + return durationNanos(g[1], g[2]) + }) + seg = reAgo.ReplaceAllString(seg, "ts_ago("+nowLit+", ") + seg = reBin.ReplaceAllString(seg, "ts_bin(") + seg = reNow.ReplaceAllString(seg, nowLit) + seg = reDate.ReplaceAllString(seg, dateLit) + seg = reTimezone.ReplaceAllString(seg, "'UTC'") + + return seg +} diff --git a/services/timestreamquery/ts_schema.go b/services/timestreamquery/ts_schema.go new file mode 100644 index 0000000000..dfe38d4550 --- /dev/null +++ b/services/timestreamquery/ts_schema.go @@ -0,0 +1,269 @@ +package timestreamquery + +import ( + "slices" + "sort" + "strconv" + "strings" + "time" +) + +const ( + tsLayout = "2006-01-02 15:04:05.000000000" + colMeasureName = "measure_name" + colTime = "time" + colMeasurePfx = "measure_value::" + declTimestamp = "TIMESTAMP_TEXT" + declVarchar = "VARCHAR" + declBigint = "BIGINT" + declDouble = "DOUBLE" + declBoolean = "BOOLEAN" + typBigint = "bigint" + typBoolean = "boolean" + typDouble = "double" + typTimestamp = "timestamp" + typVarchar = "varchar" + typMulti = "multi" + attrDimension = "DIMENSION" + attrMeasureName = "MEASURE_NAME" + attrTimestamp = "TIMESTAMP" + attrMeasureVal = "MEASURE_VALUE" + attrMulti = "MULTI" +) + +// tsColumn is one column of a table's derived schema; typ is a lower-case Timestream type. +type tsColumn struct { + name string + typ string + attr string +} + +// measureInfo describes one measure_name for SHOW MEASURES. +type measureInfo struct { + name string + typ string + dims []string +} + +type tableSchema struct { + cols []tsColumn + measures []measureInfo +} + +func scalarTypeOf(measureType string) string { + switch strings.ToUpper(measureType) { + case measureTypeBigint: + return typBigint + case measureTypeBoolean: + return typBoolean + case measureTypeTimestamp: + return typTimestamp + case measureTypeVarchar: + return typVarchar + default: + return typDouble + } +} + +func isMulti(r SourceRecord) bool { return strings.EqualFold(r.MeasureValueType, measureTypeMulti) } + +func singleColumnOrder() []string { + return []string{typBigint, typBoolean, typDouble, typTimestamp, typVarchar} +} + +type schemaBuilder struct { + seenDim map[string]bool + multiType map[string]string + single map[string]bool + minfo map[string]*measureInfo + dims []string + multiName []string + order []string +} + +func (sb *schemaBuilder) measure(name string) *measureInfo { + mi := sb.minfo[name] + if mi == nil { + mi = &measureInfo{name: name} + sb.minfo[name] = mi + sb.order = append(sb.order, name) + } + + return mi +} + +func (sb *schemaBuilder) add(r SourceRecord) { + mi := sb.measure(r.MeasureName) + + for _, d := range r.Dimensions { + if !sb.seenDim[d.Name] { + sb.seenDim[d.Name] = true + sb.dims = append(sb.dims, d.Name) + } + + if !slices.Contains(mi.dims, d.Name) { + mi.dims = append(mi.dims, d.Name) + } + } + + if !isMulti(r) { + t := scalarTypeOf(r.MeasureValueType) + sb.single[t] = true + mi.typ = t + + return + } + + mi.typ = typMulti + + for _, m := range r.Measures { + if _, ok := sb.multiType[m.Name]; !ok { + sb.multiType[m.Name] = scalarTypeOf(m.Type) + sb.multiName = append(sb.multiName, m.Name) + } + } +} + +// deriveSchema builds the table schema from its records: dimensions in order of +// first appearance, then measure_name, time, then one measure_value::T column per +// single-measure type seen and one column per MULTI attribute. +func deriveSchema(records []SourceRecord) tableSchema { + sb := &schemaBuilder{ + seenDim: map[string]bool{}, multiType: map[string]string{}, + single: map[string]bool{}, minfo: map[string]*measureInfo{}, + } + for _, r := range records { + sb.add(r) + } + + s := tableSchema{} + for _, d := range sb.dims { + s.cols = append(s.cols, tsColumn{name: d, typ: typVarchar, attr: attrDimension}) + } + + s.cols = append(s.cols, + tsColumn{name: colMeasureName, typ: typVarchar, attr: attrMeasureName}, + tsColumn{name: colTime, typ: typTimestamp, attr: attrTimestamp}, + ) + + for _, t := range singleColumnOrder() { + if sb.single[t] { + s.cols = append(s.cols, tsColumn{name: colMeasurePfx + t, typ: t, attr: attrMeasureVal}) + } + } + + for _, n := range sb.multiName { + if sb.seenDim[n] || n == colTime || n == colMeasureName { + continue + } + + s.cols = append(s.cols, tsColumn{name: n, typ: sb.multiType[n], attr: attrMulti}) + } + + for _, n := range sb.order { + s.measures = append(s.measures, *sb.minfo[n]) + } + + sort.Slice(s.measures, func(i, j int) bool { return s.measures[i].name < s.measures[j].name }) + + return s +} + +func declOf(typ string) string { + switch typ { + case typBigint: + return declBigint + case typBoolean: + return declBoolean + case typTimestamp: + return declTimestamp + case typVarchar: + return declVarchar + default: + return declDouble + } +} + +func formatTS(t time.Time) string { return t.UTC().Format(tsLayout) } + +// sqlValue converts a stored measure string into the value bound to its SQLite column. +func sqlValue(typ, v string) any { + switch typ { + case typBigint: + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil + } + + return n + case typBoolean: + if strings.EqualFold(v, "true") { + return int64(1) + } + + return int64(0) + case typDouble: + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return nil + } + + return f + default: + return v + } +} + +// recordBytes approximates the stored size of a record, used for bytes-scanned accounting. +func recordBytes(r SourceRecord) int64 { + const timeBytes = 8 + + n := int64(timeBytes + len(r.MeasureName) + len(r.MeasureValue)) + for _, d := range r.Dimensions { + n += int64(len(d.Name) + len(d.Value)) + } + + for _, m := range r.Measures { + n += int64(len(m.Name) + len(m.Value)) + } + + return n +} + +// rowValues returns the record's values aligned with the schema columns. +func (s tableSchema) rowValues(r SourceRecord) []any { + dim := make(map[string]string, len(r.Dimensions)) + for _, d := range r.Dimensions { + dim[d.Name] = d.Value + } + + multi := make(map[string]SourceMeasure, len(r.Measures)) + for _, m := range r.Measures { + multi[m.Name] = m + } + + out := make([]any, len(s.cols)) + + for i, c := range s.cols { + switch c.attr { + case attrDimension: + if v, ok := dim[c.name]; ok { + out[i] = v + } + case attrMeasureName: + out[i] = r.MeasureName + case attrTimestamp: + out[i] = formatTS(r.Time) + case attrMeasureVal: + if !isMulti(r) && scalarTypeOf(r.MeasureValueType) == c.typ { + out[i] = sqlValue(c.typ, r.MeasureValue) + } + case attrMulti: + if m, ok := multi[c.name]; ok && isMulti(r) { + out[i] = sqlValue(c.typ, m.Value) + } + } + } + + return out +} diff --git a/services/timestreamwrite/iam_enforcement_test.go b/services/timestreamwrite/iam_enforcement_test.go index a24827c723..e02080f939 100644 --- a/services/timestreamwrite/iam_enforcement_test.go +++ b/services/timestreamwrite/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/timestreamwrite" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/timestream/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/timestreamwrite/models.go b/services/timestreamwrite/models.go index b923ddc10b..b2dbea2202 100644 --- a/services/timestreamwrite/models.go +++ b/services/timestreamwrite/models.go @@ -243,6 +243,7 @@ type InMemoryBackend struct { batchLoadTasks *store.Table[BatchLoadTask] registry *store.Registry mu *lockmetrics.RWMutex + now func() time.Time region string nextTaskID int } diff --git a/services/timestreamwrite/persistence.go b/services/timestreamwrite/persistence.go index 0920729ef8..50cedf4c66 100644 --- a/services/timestreamwrite/persistence.go +++ b/services/timestreamwrite/persistence.go @@ -168,6 +168,7 @@ func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { for tblName, recs := range tblRecs { idx := make(map[string]int, len(recs)) for i, r := range recs { + recs[i].InternalTimestamp = parseTimestreamTime(r.Time, r.TimeUnit) idx[recordKey(r)] = i } diff --git a/services/timestreamwrite/records.go b/services/timestreamwrite/records.go index 75eff8cb35..884f1b93bc 100644 --- a/services/timestreamwrite/records.go +++ b/services/timestreamwrite/records.go @@ -111,7 +111,7 @@ func (b *InMemoryBackend) WriteRecords(dbName, tblName string, records []Record) slot.recordIndex = make(map[string]int) } - rejected, memoryInserted, magneticInserted := writeRecordsIntoSlot(slot, records, tbl, time.Now().UTC()) + rejected, memoryInserted, magneticInserted := writeRecordsIntoSlot(slot, records, tbl, b.now().UTC()) if len(rejected) > 0 { return nil, &RejectedRecordsError{RejectedRecords: rejected} @@ -265,7 +265,7 @@ func (b *InMemoryBackend) SweepRetention(ctx context.Context) { b.mu.Lock("SweepRetention") defer b.mu.Unlock() - now := time.Now().UTC() + now := b.now().UTC() totalPruned := 0 for _, tbl := range b.tables.All() { @@ -280,11 +280,26 @@ func (b *InMemoryBackend) SweepRetention(ctx context.Context) { telemetry.RecordWorkerTask("timestreamwrite", "RetentionSweeper", "success") } -// pruneTableRecords drops records older than the table's memory-store retention -// window. Returns the number of records removed. Caller must hold b.mu in write -// mode. Returns 0 (no-op) when the table has no retention configured or no slot. +// magneticCutoff returns the instant before which records have aged out of the +// magnetic store, or false when the table keeps records indefinitely. +func magneticCutoff(tbl *Table, now time.Time) (time.Time, bool) { + if tbl == nil || tbl.RetentionProperties == nil || tbl.RetentionProperties.MagneticStoreRetentionPeriodInDays == 0 { + return time.Time{}, false + } + + hours := tbl.RetentionProperties.MagneticStoreRetentionPeriodInDays * hoursPerDay + + return now.Add(-time.Duration(hours) * time.Hour), true +} + +const hoursPerDay = 24 + +// pruneTableRecords drops records older than the table's magnetic-store +// retention (records past the memory window move to the magnetic store and are +// only deleted once that ages out). Caller must hold b.mu in write mode. func (b *InMemoryBackend) pruneTableRecords(dbName, tblName string, tbl *Table, now time.Time) int { - if tbl.RetentionProperties == nil || tbl.RetentionProperties.MemoryStoreRetentionPeriodInHours == 0 { + cutoff, ok := magneticCutoff(tbl, now) + if !ok { return 0 } @@ -293,9 +308,6 @@ func (b *InMemoryBackend) pruneTableRecords(dbName, tblName string, tbl *Table, return 0 } - retention := time.Duration(tbl.RetentionProperties.MemoryStoreRetentionPeriodInHours) * time.Hour - cutoff := now.Add(-retention) - newRecords := make([]Record, 0, len(slot.records)) for _, r := range slot.records { if r.InternalTimestamp.After(cutoff) { @@ -306,7 +318,6 @@ func (b *InMemoryBackend) pruneTableRecords(dbName, tblName string, tbl *Table, pruned := len(slot.records) - len(newRecords) if pruned > 0 { slot.records = newRecords - // Rebuild the dedup index after pruning to keep offsets consistent. slot.recordIndex = make(map[string]int, len(newRecords)) for i, r := range slot.records { slot.recordIndex[recordKey(r)] = i @@ -316,6 +327,51 @@ func (b *InMemoryBackend) pruneTableRecords(dbName, tblName string, tbl *Table, return pruned } +// TableData is a point-in-time copy of a table's queryable records. +type TableData struct { + Table Table + Records []Record +} + +// TableData returns the records of a table that have not aged out of the magnetic +// store at now; memory-store and magnetic-store records are both queryable. +func (b *InMemoryBackend) TableData(dbName, tblName string, now time.Time) (*TableData, error) { + b.mu.RLock("TableData") + defer b.mu.RUnlock() + + if !b.databases.Has(dbName) { + return nil, fmt.Errorf("%w: database %s not found", ErrDatabaseNotFound, dbName) + } + + tbl, ok := b.tables.Get(tableKey(dbName, tblName)) + slot := b.records[dbName][tblName] + + if !ok || slot == nil { + return nil, fmt.Errorf("%w: table %s not found", ErrTableNotFound, tblName) + } + + cutoff, bounded := magneticCutoff(tbl, now) + + slot.mu.RLock("TableData") + defer slot.mu.RUnlock() + + out := &TableData{Table: *tbl, Records: make([]Record, 0, len(slot.records))} + + for _, r := range slot.records { + if bounded && !r.InternalTimestamp.After(cutoff) { + continue + } + + if r.InternalTimestamp.IsZero() { + r.InternalTimestamp = parseTimestreamTime(r.Time, r.TimeUnit) + } + + out.Records = append(out.Records, r) + } + + return out, nil +} + // missingRequiredPartitionKey returns the name of the first REQUIRED dimension partition key absent from r. func missingRequiredPartitionKey(tbl *Table, r Record) string { if tbl == nil || tbl.Schema == nil { diff --git a/services/timestreamwrite/records_test.go b/services/timestreamwrite/records_test.go index 35d76b0d87..52ba8ff723 100644 --- a/services/timestreamwrite/records_test.go +++ b/services/timestreamwrite/records_test.go @@ -904,3 +904,130 @@ func TestInMemoryBackend_RecordCountNonexistentTable(t *testing.T) { count := timestreamwrite.RecordCount(b, "ghost-db", "ghost-tbl") assert.Equal(t, 0, count, "non-existent table should return zero records") } + +func TestTableData_RetentionAndRestore(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + ms := func(d time.Duration) string { return strconv.FormatInt(t0.Add(-d).UnixMilli(), 10) } + + tests := []struct { + name string + want []string + advance time.Duration + }{ + {"memory aged record still queryable", []string{"recent", "memoryaged"}, 0}, + {"magnetic expiry drops older", []string{"recent"}, 10 * time.Hour}, + {"recent ages into magnetic", []string{"recent"}, 40 * time.Hour}, + {"everything expired", nil, 50 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + b := timestreamwrite.NewInMemoryBackend() + b.SetClock(func() time.Time { return t0 }) + + _, err := b.CreateDatabase("db", "", nil) + require.NoError(t, err) + + _, err = b.CreateTable("db", "tbl", nil, ×treamwrite.CreateTableInput{ + RetentionProperties: ×treamwrite.RetentionProperties{ + MemoryStoreRetentionPeriodInHours: 24, MagneticStoreRetentionPeriodInDays: 2, + }, + MagneticStoreWriteProperties: ×treamwrite.MagneticStoreWriteProperties{ + EnableMagneticStoreWrites: true, + }, + }) + require.NoError(t, err) + + dim := func(v string) []timestreamwrite.Dimension { return []timestreamwrite.Dimension{{Name: "d", Value: v}} } + _, err = b.WriteRecords("db", "tbl", []timestreamwrite.Record{ + { + MeasureName: "m", + MeasureValue: "1", + MeasureValueType: "DOUBLE", + Time: ms(time.Hour), + TimeUnit: "MILLISECONDS", + Dimensions: dim("recent"), + }, + { + MeasureName: "m", + MeasureValue: "1", + MeasureValueType: "DOUBLE", + Time: ms(40 * time.Hour), + TimeUnit: "MILLISECONDS", + Dimensions: dim("memoryaged"), + }, + { + MeasureName: "m", + MeasureValue: "1", + MeasureValueType: "DOUBLE", + Time: ms(60 * time.Hour), + TimeUnit: "MILLISECONDS", + Dimensions: dim("old"), + }, + }) + require.NoError(t, err) + + snap, err := b.Snapshot() + require.NoError(t, err) + + restored := timestreamwrite.NewInMemoryBackend() + require.NoError(t, restored.Restore(t.Context(), snap)) + + for _, backend := range []*timestreamwrite.InMemoryBackend{b, restored} { + data, dErr := backend.TableData("db", "tbl", t0.Add(tt.advance)) + require.NoError(t, dErr) + + var got []string + for _, r := range data.Records { + got = append(got, r.Dimensions[0].Value) + } + + assert.ElementsMatch(t, tt.want, got) + } + }) + } +} + +func TestSweepRetention_UsesMagneticWindow(t *testing.T) { + t.Parallel() + + t0 := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + now := t0 + + b := timestreamwrite.NewInMemoryBackend() + b.SetClock(func() time.Time { return now }) + + _, err := b.CreateDatabase("db", "", nil) + require.NoError(t, err) + + _, err = b.CreateTable("db", "tbl", nil, ×treamwrite.CreateTableInput{ + RetentionProperties: ×treamwrite.RetentionProperties{ + MemoryStoreRetentionPeriodInHours: 1, MagneticStoreRetentionPeriodInDays: 1, + }, + }) + require.NoError(t, err) + + _, err = b.WriteRecords("db", "tbl", []timestreamwrite.Record{{ + MeasureName: "m", MeasureValue: "1", MeasureValueType: "DOUBLE", + Time: strconv.FormatInt(t0.UnixMilli(), 10), TimeUnit: "MILLISECONDS", + }}) + require.NoError(t, err) + + now = t0.Add(5 * time.Hour) + b.SweepRetention(t.Context()) + + data, err := b.TableData("db", "tbl", now) + require.NoError(t, err) + assert.Len(t, data.Records, 1, "past memory retention but within magnetic: kept") + + now = t0.Add(26 * time.Hour) + b.SweepRetention(t.Context()) + + data, err = b.TableData("db", "tbl", now) + require.NoError(t, err) + assert.Empty(t, data.Records) +} diff --git a/services/timestreamwrite/store.go b/services/timestreamwrite/store.go index 9f96e8723d..763b3f36d4 100644 --- a/services/timestreamwrite/store.go +++ b/services/timestreamwrite/store.go @@ -3,6 +3,7 @@ package timestreamwrite import ( "fmt" "strings" + "time" "github.com/blackbirdworks/gopherstack/pkgs/arn" "github.com/blackbirdworks/gopherstack/pkgs/config" @@ -26,6 +27,7 @@ func NewInMemoryBackendForRegion(region string) *InMemoryBackend { mu: lockmetrics.New("timestreamwrite"), registry: store.NewRegistry(), region: region, + now: time.Now, } registerAllTables(b) b.ensureNonNilMaps() @@ -119,3 +121,10 @@ func (b *InMemoryBackend) isKnownARNLocked(arn string) bool { return false } + +// SetClock replaces the wall clock used for retention decisions. +func (b *InMemoryBackend) SetClock(now func() time.Time) { + b.mu.Lock("SetClock") + defer b.mu.Unlock() + b.now = now +} diff --git a/services/transcribe/iam_enforcement_test.go b/services/transcribe/iam_enforcement_test.go index c6f15a94cc..ac22413590 100644 --- a/services/transcribe/iam_enforcement_test.go +++ b/services/transcribe/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/transcribe" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/transcribe/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/transfer/iam_enforcement_test.go b/services/transfer/iam_enforcement_test.go index c060b6bd18..f980fe7b80 100644 --- a/services/transfer/iam_enforcement_test.go +++ b/services/transfer/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/transfer" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/transfer/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/translate/iam_enforcement_test.go b/services/translate/iam_enforcement_test.go index 07fa9d84e4..38a8eceeea 100644 --- a/services/translate/iam_enforcement_test.go +++ b/services/translate/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/translate" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/translate/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/translate/validation.go b/services/translate/validation.go index 0b51ece497..1e7636928a 100644 --- a/services/translate/validation.go +++ b/services/translate/validation.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "slices" + "sync" ) const ( @@ -14,7 +15,9 @@ const ( var ( resourceNameRe = regexp.MustCompile(`^([A-Za-z0-9-]_?){1,256}$`) // Brackets are admitted so the "[fail]" job-name failure marker stays usable. - jobNameRe = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-%@\[\]]{1,256}$`) + jobNameRe = sync.OnceValue(func() *regexp.Regexp { //nolint:gochecknoglobals // lazy: eager compile costs MBs + return regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-%@\[\]]{1,256}$`) + }) s3URIRe = regexp.MustCompile(`^s3://[a-z0-9][.\-a-z0-9]{1,61}[a-z0-9](/.*)?$`) codePrefixRe = regexp.MustCompile(`^[A-Za-z]+Exception: `) ) @@ -43,7 +46,7 @@ func validateResourceName(sentinel error, field, name string) error { } func validateJobName(name string) error { - if name != "" && !jobNameRe.MatchString(name) { + if name != "" && !jobNameRe().MatchString(name) { return fmt.Errorf("%w: JobName contains invalid characters or exceeds 256 characters", ErrValidation) } diff --git a/services/verifiedpermissions/PARITY.md b/services/verifiedpermissions/PARITY.md index 3f32dbea7e..0df6faad3c 100644 --- a/services/verifiedpermissions/PARITY.md +++ b/services/verifiedpermissions/PARITY.md @@ -50,13 +50,34 @@ gaps: [] items_still_open: - "DeletePolicyStoreAlias never returns InvalidStateException: the SDK lists it for the op but the only documented trigger (InvalidStateException type doc) is DeletePolicyStore's deletion protection." - "CreatePolicyStoreAlias never returns ServiceQuotaExceededException: AWS documents no numeric alias quota." -structural_gaps: - - "IsAuthorizedWithToken/BatchIsAuthorizedWithToken do not verify JWT signatures or expiry: that needs the issuer's real signing keys; aud/client_id matching is implemented." +structural_gaps: [] deferred: [] # the one item deferred last pass (CreatePolicyStore ClientToken) is now implemented; see ops notes leaks: {status: clean, note: "no goroutines/janitors in this service; InMemoryBackend uses a single lockmetrics.RWMutex. Prior pass fixed real ghost-row leaks: DeletePolicy/DeleteIdentitySource/DeletePolicyStore's cascade/DeletePolicyTemplate's cascade all clear resourceTags (previously only arnIndex was cleaned, so a tagged-then-deleted resource left its tag map entry behind forever); DeletePolicyStore also clears policySetCache/policySetDirty for the deleted store. This pass adds policyStoreAliases (a new store.Table registered on b.registry, keyed by AliasName) to that same cascade: DeletePolicyStore now also deletes every alias pointing at the store being deleted (see policy_stores.go's DeletePolicyStore -- the real API's docs are silent on this since DeletePolicyStore predates aliases entirely, so gopherstack picked cascade-delete per this campaign's documented-choice convention, proven by TestVPHandler_DeletePolicyStore_CascadesAliases/TestBackend_DeletePolicyStore_CascadesAliases). Aliases carry no arnIndex/resourceTags entries at all (not a taggable resource type in the real API -- TagResource's own doc says only policy stores can be tagged), so no ARN/tag cleanup was needed for them. clientTokens (ClientToken idempotency state) remains an ephemeral, never-persisted map; entries age out via the 8h idempotencyWindow check at lookup time (no janitor goroutine). Snapshot/Restore of the new policyStoreAliases table fully exercised by persistence_test.go's TestInMemoryBackend_SnapshotRestore_FullState (extended this pass) plus store_test.go's new alias tests."} ## Notes +### 2026-10-31 JWT verification for IsAuthorizedWithToken / BatchIsAuthorizedWithToken (gopherstack-49qct.80) + +Tokens are now verified before a principal is derived (`token_verify.go`, `oidc_jwks.go`): +RS256 signature via kid lookup, `exp` required, `nbf`, issuer, audience/`client_id`, and for Cognito +`token_use` (`id` for identityToken, `access` for accessToken). Clock is injectable (`TokenVerifier.SetClock`). + +- Cognito: signing key resolved in-process from cognitoidp (`Handler.UserPoolSigningKey`, wired in + `internal/app/verifiedpermissions_wiring.go`); accepted issuers are the emulator's pool issuer and the + ARN-derived `https://cognito-idp..amazonaws.com/`. +- OIDC: `/.well-known/openid-configuration` -> `jwks_uri` via `pkgs/httpclient`, cached 1h + (kid miss refetches at most every 30s). https only (http for loopback). Self-signed issuers: + `--verifiedpermissions-oidc-ca-file`. tokenSelection type must match the submitted token param. +- Groups: Cognito `cognito:groups` / OIDC `groupClaim` become parents of the principal entity, typed by + `GroupEntityType`. Principal/group IDs are `|` (Cognito) and `|` + (OIDC), per the SDK CreateIdentitySource and `EntityIdPrefix` docs. +- `--verifiedpermissions-disable-jwt-verification` / `VERIFIEDPERMISSIONS_DISABLE_JWT_VERIFICATION=1` + skips signature/expiry/issuer/audience checks (claims still parsed). +- Failure shape: `ValidationException` (HTTP 400). The SDK declares only AccessDenied/InternalServer/ + ResourceNotFound/Throttling/Validation for this op; the exact code real AWS returns for a bad token is + not documented, ValidationException is the closest declared fit. +- Without a verifier installed (bare `NewHandler`, unit tests) the legacy claims-only path is kept. + ### 2026-09-19 (later same day): required-output-member re-verification, no new findings Independent re-read of the census (`cmd/requiredoutputfields`) against HEAD diff --git a/services/verifiedpermissions/README.md b/services/verifiedpermissions/README.md index c88d0cdbdb..cccd68da4c 100644 --- a/services/verifiedpermissions/README.md +++ b/services/verifiedpermissions/README.md @@ -9,7 +9,6 @@ | --- | --- | | PARITY entries audited | 34 (33 ok, 1 partial) | | Known gaps | 2 | -| Structural gaps (can't be emulated) | 1 | | Deferred items | 0 | | Resource leaks | clean | @@ -18,12 +17,6 @@ - DeletePolicyStoreAlias never returns InvalidStateException: the SDK lists it for the op but the only documented trigger (InvalidStateException type doc) is DeletePolicyStore's deletion protection. - CreatePolicyStoreAlias never returns ServiceQuotaExceededException: AWS documents no numeric alias quota. -### Structural gaps - -These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. - -- IsAuthorizedWithToken/BatchIsAuthorizedWithToken do not verify JWT signatures or expiry: that needs the issuer's real signing keys; aud/client_id matching is implemented. - ## More - [Full parity audit](PARITY.md) diff --git a/services/verifiedpermissions/handler.go b/services/verifiedpermissions/handler.go index 9b6b3f00e1..9644569b76 100644 --- a/services/verifiedpermissions/handler.go +++ b/services/verifiedpermissions/handler.go @@ -38,8 +38,16 @@ type Handler struct { peers *regionpeers.Set[Handler] Backend StorageBackend ops map[string]service.JSONOpFunc + // verifier validates JWTs; nil keeps the legacy claims-only behaviour. + verifier *TokenVerifier } +// WithTokenVerifier installs the JWT verifier shared by this handler and its regional peers. +func (h *Handler) WithTokenVerifier(v *TokenVerifier) { h.verifier = v } + +// TokenVerifier returns the installed verifier (nil when none). +func (h *Handler) TokenVerifier() *TokenVerifier { return h.verifier } + // NewHandler creates a new Verified Permissions handler. func NewHandler(backend StorageBackend) *Handler { h := &Handler{Backend: backend} diff --git a/services/verifiedpermissions/handler_authorization.go b/services/verifiedpermissions/handler_authorization.go index 50e7a03fdb..55f6ab88d4 100644 --- a/services/verifiedpermissions/handler_authorization.go +++ b/services/verifiedpermissions/handler_authorization.go @@ -6,8 +6,11 @@ import ( "encoding/json" "errors" "fmt" + "maps" "slices" "strings" + + cedar "github.com/cedar-policy/cedar-go" ) type entityIdentifierJSON struct { @@ -277,13 +280,20 @@ func (h *Handler) handleBatchIsAuthorizedWithToken( token = in.IdentityToken } - principalType, principalID := h.principalFromToken(resolvedID, isAccessToken, token) + principal, err := h.resolveTokenPrincipal(resolvedID, isAccessToken, token) + if err != nil { + return nil, err + } + + principalType, principalID := principal.Type, principal.ID entities, err := entitiesToCedar(in.Entities) if err != nil { return nil, err } + entities = withPrincipalGroups(entities, principal) + requests := make([]AuthorizationRequest, 0, len(in.Requests)) for _, r := range in.Requests { @@ -411,35 +421,53 @@ func parseJWTClaims(token string) (map[string]any, error) { return claims, nil } -// principalFromToken resolves PrincipalEntityType and PrincipalEntityID from -// a JWT token, using the identity source in the policy store whose issuer -// matches the token's "iss" claim (falling back to the first identity source -// when no "iss" claim is present or none match, since a policy store -// typically has exactly one identity source anyway), then validating the -// token's aud/client_id claim against that source's configured client -// IDs/audiences (see matchesConfiguredAudience). isAccessToken selects which -// claim real AWS checks: "client_id"/"aud" for access tokens vs "aud" for ID -// tokens. Returns ("", "") when no principal can be resolved -- the caller's -// response omits the (optional, per the real SDK) principal field and the -// request evaluates with no principal, i.e. fails closed to DENY. -func (h *Handler) principalFromToken(policyStoreID string, isAccessToken bool, token string) (string, string) { - sources, _, err := h.Backend.ListIdentitySources(policyStoreID, "", 0, nil) - if err != nil || len(sources) == 0 { - return "", "" +// tokenPrincipal is the principal (and group parents) a token resolves to. +type tokenPrincipal struct { + Type string + ID string + Groups []cedar.EntityUID +} + +// resolveTokenPrincipal picks the identity source matching the token's issuer, +// verifies the token (signature, expiry, issuer, token_use, audience) when a +// TokenVerifier is installed, and derives the principal and group parents. +// Without a verifier only the legacy claim checks run and failures fail closed +// to an empty principal. +func (h *Handler) resolveTokenPrincipal( + policyStoreID string, + isAccessToken bool, + token string, +) (tokenPrincipal, error) { + sources, _, listErr := h.Backend.ListIdentitySources(policyStoreID, "", 0, nil) + if listErr != nil || len(sources) == 0 { + return tokenPrincipal{}, nil //nolint:nilerr // no identity source: fail closed to an empty principal } claims, err := parseJWTClaims(token) if err != nil { - return "", "" + if h.verifier != nil { + return tokenPrincipal{}, fmt.Errorf("%w: %w", ErrTokenInvalid, err) + } + + return tokenPrincipal{}, nil } - iss, _ := claims["iss"].(string) + iss, _ := claims[claimIss].(string) is, ok := matchIdentitySource(sources, iss, claims, isAccessToken) - if !ok { - return "", "" + + if h.verifier != nil { + if claims, err = h.verifier.Verify(is, token, isAccessToken); err != nil { + return tokenPrincipal{}, err + } + } else if !ok { + return tokenPrincipal{}, nil } + return principalFromClaims(is, claims), nil +} + +func principalFromClaims(is IdentitySource, claims map[string]any) tokenPrincipal { claimName := "sub" if is.OIDCTokenSelection != nil && is.OIDCTokenSelection.PrincipalIDClaim != "" { @@ -448,7 +476,83 @@ func (h *Handler) principalFromToken(policyStoreID string, isAccessToken bool, t claimVal, _ := claims[claimName].(string) - return is.PrincipalEntityType, claimVal + prefix := is.EntityIDPrefix + if is.UserPoolArn != "" { + prefix = cognitoPoolID(is.UserPoolArn) + } + + idOf := func(v string) string { + if prefix == "" { + return v + } + + return prefix + "|" + v + } + + out := tokenPrincipal{Type: is.PrincipalEntityType, ID: idOf(claimVal)} + + var groupType, groupClaim string + + switch { + case is.UserPoolArn != "" && is.CognitoGroupConfig != nil: + groupType, groupClaim = is.CognitoGroupConfig.GroupEntityType, "cognito:groups" + case is.OIDCGroupConfig != nil: + groupType, groupClaim = is.OIDCGroupConfig.GroupEntityType, is.OIDCGroupConfig.GroupClaim + } + + if groupType == "" || groupClaim == "" { + return out + } + + for _, g := range claimStrings(claims[groupClaim]) { + out.Groups = append(out.Groups, cedar.NewEntityUID(cedar.EntityType(groupType), cedar.String(idOf(g)))) + } + + return out +} + +// claimStrings reads a claim holding a string or an array of strings. +func claimStrings(v any) []string { + switch vv := v.(type) { + case string: + if vv == "" { + return nil + } + + return []string{vv} + case []any: + out := make([]string, 0, len(vv)) + + for _, item := range vv { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + + return out + } + + return nil +} + +// withPrincipalGroups returns entities with the principal made a member of the token's groups. +func withPrincipalGroups(entities cedar.EntityMap, p tokenPrincipal) cedar.EntityMap { + if p.Type == "" || len(p.Groups) == 0 { + return entities + } + + uid := cedar.NewEntityUID(cedar.EntityType(p.Type), cedar.String(p.ID)) + out := make(cedar.EntityMap, len(entities)+1) + maps.Copy(out, entities) + + ent := out[uid] + ent.UID = uid + + parents := slices.Collect(ent.Parents.All()) + ent.Parents = cedar.NewEntityUIDSet(append(parents, p.Groups...)...) + out[uid] = ent + + return out } // matchIdentitySource returns the identity source in sources whose OIDC @@ -467,7 +571,8 @@ func matchIdentitySource( if issuer != "" { for _, is := range sources { switch { - case is.UserPoolArn != "" && cognitoIssuerFromUserPoolArn(is.UserPoolArn) == issuer: + case is.UserPoolArn != "" && (cognitoIssuerFromUserPoolArn(is.UserPoolArn) == issuer || + strings.HasSuffix(issuer, "/"+cognitoPoolID(is.UserPoolArn))): return is, matchesConfiguredAudience(is, claims, isAccessToken) case is.OpenIDIssuer != "" && is.OpenIDIssuer == issuer: return is, matchesConfiguredAudience(is, claims, isAccessToken) @@ -616,7 +721,12 @@ func (h *Handler) handleIsAuthorizedWithToken( req.ResourceEntityID = in.Resource.EntityID } - req.PrincipalEntityType, req.PrincipalEntityID = h.principalFromToken(resolvedID, isAccessToken, token) + principal, err := h.resolveTokenPrincipal(resolvedID, isAccessToken, token) + if err != nil { + return nil, err + } + + req.PrincipalEntityType, req.PrincipalEntityID = principal.Type, principal.ID req.Context, err = contextToCedar(in.Context) if err != nil { @@ -628,6 +738,8 @@ func (h *Handler) handleIsAuthorizedWithToken( return nil, err } + entities = withPrincipalGroups(entities, principal) + decision, err := h.Backend.IsAuthorizedWithToken(resolvedID, req, entities) if err != nil { return nil, err diff --git a/services/verifiedpermissions/iam_enforcement_test.go b/services/verifiedpermissions/iam_enforcement_test.go index c8c9fb221d..7d9841985d 100644 --- a/services/verifiedpermissions/iam_enforcement_test.go +++ b/services/verifiedpermissions/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/verifiedpermissions" ) @@ -163,7 +164,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/verifiedpermissions/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/verifiedpermissions/oidc_jwks.go b/services/verifiedpermissions/oidc_jwks.go new file mode 100644 index 0000000000..e09957cda8 --- /dev/null +++ b/services/verifiedpermissions/oidc_jwks.go @@ -0,0 +1,194 @@ +package verifiedpermissions + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "time" + + awsarn "github.com/aws/aws-sdk-go-v2/aws/arn" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" +) + +var ( + errKeyNotFound = errors.New("signing key not found in JWKS") + errBadIssuerURL = errors.New("issuer must be an https URL") + errBadDiscoveryDoc = errors.New("bad discovery document") +) + +type jwksEntry struct { + fetched time.Time + keys map[string]*rsa.PublicKey +} + +// cognitoPoolID extracts the user pool ID from a user pool ARN. +func cognitoPoolID(userPoolArn string) string { + parsed, err := awsarn.Parse(userPoolArn) + if err != nil { + return "" + } + + return strings.TrimPrefix(parsed.Resource, "userpool/") +} + +// oidcKey returns the issuer's signing key for kid, fetching discovery + JWKS +// when the cache is cold, older than jwksCacheTTL, or lacks kid (rate limited). +func (v *TokenVerifier) oidcKey(issuer, kid string) (*rsa.PublicKey, error) { + now := v.now() + + if e, ok := v.cache.Get(issuer); ok { + age := now.Sub(e.fetched) + if key, hit := e.keys[kid]; hit && age < jwksCacheTTL { + return key, nil + } + + if age < jwksMinRefresh { + return nil, fmt.Errorf("%w: %q", errKeyNotFound, kid) + } + } + + keys, err := v.fetchJWKS(issuer) + if err != nil { + return nil, err + } + + v.cache.Set(issuer, jwksEntry{fetched: now, keys: keys}) + + key, ok := keys[kid] + if !ok { + return nil, fmt.Errorf("%w: %q", errKeyNotFound, kid) + } + + return key, nil +} + +func (v *TokenVerifier) getJSON(rawURL string, out any) error { + u, err := url.Parse(rawURL) + if err != nil || !allowedIssuerURL(u) { + return fmt.Errorf("%w: %q", errBadIssuerURL, rawURL) + } + + ctx, cancel := context.WithTimeout(context.Background(), oidcHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + resp, err := v.client.Load().Do(req) + if err != nil { + return fmt.Errorf("fetch %s: %w", rawURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: fetch %s: status %d", errBadDiscoveryDoc, rawURL, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxOIDCBody)) + if err != nil { + return fmt.Errorf("read %s: %w", rawURL, err) + } + + if err = json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decode %s: %w", rawURL, err) + } + + return nil +} + +func isLoopbackHost(h string) bool { + return h == "localhost" || h == "127.0.0.1" || h == "::1" +} + +func (v *TokenVerifier) fetchJWKS(issuer string) (map[string]*rsa.PublicKey, error) { + var disc struct { + JWKSURI string `json:"jwks_uri"` + } + + if err := v.getJSON(strings.TrimSuffix(issuer, "/")+oidcDiscoveryTL, &disc); err != nil { + return nil, err + } + + if disc.JWKSURI == "" { + return nil, fmt.Errorf("%w: missing jwks_uri", errBadDiscoveryDoc) + } + + var set struct { + Keys []struct { + Kty string `json:"kty"` + Use string `json:"use"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + + if err := v.getJSON(disc.JWKSURI, &set); err != nil { + return nil, err + } + + keys := make(map[string]*rsa.PublicKey, len(set.Keys)) + + for _, k := range set.Keys { + if k.Kty != "RSA" || (k.Use != "" && k.Use != "sig") { + continue + } + + nb, nerr := base64.RawURLEncoding.DecodeString(k.N) + eb, eerr := base64.RawURLEncoding.DecodeString(k.E) + + if nerr != nil || eerr != nil { + continue + } + + keys[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: int(new(big.Int).SetBytes(eb).Int64())} + } + + return keys, nil +} + +// oidcHTTPClient builds the OIDC fetch client, trusting caFile's PEM CAs in addition to the system roots. +func oidcHTTPClient(caFile string) (*http.Client, error) { + c := httpclient.New(oidcHTTPTimeout) + if caFile == "" { + return c, nil + } + + pem, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read OIDC CA file: %w", err) + } + + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("%w: no certificates in %s", errBadDiscoveryDoc, caFile) + } + + tr := httpclient.NewTransport() + tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + c.Transport = tr + + return c, nil +} + +func allowedIssuerURL(u *url.URL) bool { + return u.Scheme == "https" || (u.Scheme == "http" && isLoopbackHost(u.Hostname())) +} diff --git a/services/verifiedpermissions/provider.go b/services/verifiedpermissions/provider.go index 7301f3a61d..badbe10506 100644 --- a/services/verifiedpermissions/provider.go +++ b/services/verifiedpermissions/provider.go @@ -28,6 +28,18 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { backend := NewInMemoryBackend(accountID, region) handler := NewHandler(backend) + var settings Settings + if cp, ok := ctx.Config.(ConfigProvider); ok { + settings = cp.GetVerifiedPermissionsSettings() + } + + client, err := oidcHTTPClient(settings.OIDCCAFile) + if err != nil { + return nil, err + } + + handler.WithTokenVerifier(NewTokenVerifier(settings.DisableJWTVerification, client)) + handler.EnableRegions() return handler, nil diff --git a/services/verifiedpermissions/regions.go b/services/verifiedpermissions/regions.go index 33754c5906..499a1f5158 100644 --- a/services/verifiedpermissions/regions.go +++ b/services/verifiedpermissions/regions.go @@ -17,7 +17,10 @@ func (h *Handler) EnableRegions() { } h.peers = regionpeers.New(home.region, func(region string) *Handler { - return NewHandler(NewInMemoryBackend(home.accountID, region)) + peer := NewHandler(NewInMemoryBackend(home.accountID, region)) + peer.verifier = h.verifier + + return peer }) } diff --git a/services/verifiedpermissions/token_verify.go b/services/verifiedpermissions/token_verify.go new file mode 100644 index 0000000000..1ddb7937ff --- /dev/null +++ b/services/verifiedpermissions/token_verify.go @@ -0,0 +1,184 @@ +package verifiedpermissions + +import ( + "crypto/rsa" + "errors" + "fmt" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" + "github.com/blackbirdworks/gopherstack/pkgs/safemap" +) + +// Settings configures JWT verification for IsAuthorizedWithToken. +type Settings struct { + //nolint:lll // Kong struct tag + OIDCCAFile string `json:"oidc_ca_file" env:"GOPHERSTACK_VERIFIEDPERMISSIONS_OIDC_CA_FILE" help:"PEM bundle of extra CAs trusted for OIDC discovery/JWKS (self-signed issuers)."` + //nolint:lll // Kong struct tag + DisableJWTVerification bool `json:"disable_jwt_verification" env:"VERIFIEDPERMISSIONS_DISABLE_JWT_VERIFICATION" help:"Skip JWT signature/expiry/issuer checks in IsAuthorizedWithToken (claims still parsed)."` +} + +// ConfigProvider extracts Settings from the abstract AppContext config. +type ConfigProvider interface { + GetVerifiedPermissionsSettings() Settings +} + +// CognitoKeyResolver resolves a user pool's signing key and token issuer in-process. +type CognitoKeyResolver interface { + UserPoolSigningKey(userPoolID, kid string) (*rsa.PublicKey, string, error) +} + +var ( + // ErrTokenInvalid is returned when a submitted JWT fails validation. + ErrTokenInvalid = ErrValidation + errNoKeyResolver = errors.New("no key source available") +) + +const ( + claimIss = "iss" + claimTokenUse = "token_use" + tokenUseID = "id" + tokenUseAccess = "access" + oidcDiscoveryTL = "/.well-known/openid-configuration" + jwksCacheTTL = time.Hour + jwksMinRefresh = 30 * time.Second + oidcHTTPTimeout = 10 * time.Second + maxOIDCBody = 1 << 20 +) + +type cognitoHolder struct{ r CognitoKeyResolver } + +type clockHolder struct{ fn func() time.Time } + +// TokenVerifier validates identity-source JWTs. One instance is shared by every region. +type TokenVerifier struct { + cognito atomic.Pointer[cognitoHolder] + clock atomic.Pointer[clockHolder] + client atomic.Pointer[http.Client] + cache *safemap.Map[string, jwksEntry] + disabled bool +} + +// NewTokenVerifier builds a verifier. client may be nil for a default httpclient. +func NewTokenVerifier(disabled bool, client *http.Client) *TokenVerifier { + v := &TokenVerifier{disabled: disabled, cache: safemap.New[string, jwksEntry]("verifiedpermissions.jwks")} + if client == nil { + client = httpclient.New(oidcHTTPTimeout) + } + + v.client.Store(client) + + return v +} + +// SetCognitoKeys wires the in-process Cognito user pool key source. +func (v *TokenVerifier) SetCognitoKeys(r CognitoKeyResolver) { v.cognito.Store(&cognitoHolder{r: r}) } + +// SetClock overrides the clock used for exp/nbf checks and JWKS cache aging. +func (v *TokenVerifier) SetClock(fn func() time.Time) { v.clock.Store(&clockHolder{fn: fn}) } + +// SetHTTPClient overrides the client used for OIDC discovery and JWKS fetches. +func (v *TokenVerifier) SetHTTPClient(c *http.Client) { v.client.Store(c) } + +func (v *TokenVerifier) now() time.Time { + if c := v.clock.Load(); c != nil && c.fn != nil { + return c.fn() + } + + return time.Now() +} + +func tokenErr(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrTokenInvalid, fmt.Sprintf(format, args...)) +} + +// Verify validates token against identity source is and returns its claims. +// access selects the token flavour submitted (accessToken vs identityToken). +func (v *TokenVerifier) Verify(is IdentitySource, token string, access bool) (map[string]any, error) { + if v.disabled { + return parseJWTClaims(token) + } + + if is.UserPoolArn == "" && is.OIDCTokenSelection != nil { + if want := is.OIDCTokenSelection.TokenType; (want == tokenTypeAccess) != access && want != "" { + return nil, tokenErr("identity source accepts %s tokens only", strings.ToLower(want)) + } + } + + var issuer string + + parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + + key, iss, kerr := v.signingKey(is, t, kid) + issuer = iss + + return key, kerr + }, + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + jwt.WithTimeFunc(v.now), + jwt.WithExpirationRequired(), + ) + if err != nil { + return nil, tokenErr("token validation failed: %v", err) + } + + claims, _ := parsed.Claims.(jwt.MapClaims) + if got, _ := claims[claimIss].(string); !issuerAccepted(is, issuer, got) { + return nil, tokenErr("token issuer %q does not match the identity source", got) + } + + if is.UserPoolArn != "" { + want := tokenUseID + if access { + want = tokenUseAccess + } + + if tu, _ := claims[claimTokenUse].(string); tu != want { + return nil, tokenErr("token_use must be %q", want) + } + } + + if !matchesConfiguredAudience(is, claims, access) { + return nil, tokenErr("token audience/client does not match the identity source") + } + + return map[string]any(claims), nil +} + +func issuerAccepted(is IdentitySource, poolIssuer, got string) bool { + if got == "" { + return false + } + + if is.UserPoolArn != "" { + return got == poolIssuer || got == cognitoIssuerFromUserPoolArn(is.UserPoolArn) + } + + return got == is.OpenIDIssuer +} + +func (v *TokenVerifier) signingKey(is IdentitySource, _ *jwt.Token, kid string) (*rsa.PublicKey, string, error) { + if is.UserPoolArn != "" { + h := v.cognito.Load() + if h == nil || h.r == nil { + return nil, "", fmt.Errorf("cognito: %w", errNoKeyResolver) + } + + key, iss, err := h.r.UserPoolSigningKey(cognitoPoolID(is.UserPoolArn), kid) + if err != nil { + return nil, "", fmt.Errorf("cognito key lookup: %w", err) + } + + return key, iss, nil + } + + key, err := v.oidcKey(is.OpenIDIssuer, kid) + + return key, is.OpenIDIssuer, err +} diff --git a/services/vpclattice/iam_enforcement_test.go b/services/vpclattice/iam_enforcement_test.go index f45d2369d5..2ddf8e5352 100644 --- a/services/vpclattice/iam_enforcement_test.go +++ b/services/vpclattice/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/vpclattice" ) @@ -165,7 +166,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/vpc-lattice/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/waf/iam_enforcement_test.go b/services/waf/iam_enforcement_test.go index d95f1ad081..090f8a15a1 100644 --- a/services/waf/iam_enforcement_test.go +++ b/services/waf/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/waf" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/waf/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/wafv2/iam_enforcement_test.go b/services/wafv2/iam_enforcement_test.go index d8ad1156da..1925070c78 100644 --- a/services/wafv2/iam_enforcement_test.go +++ b/services/wafv2/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/wafv2" ) @@ -172,7 +173,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/wafv2/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/workmail/iam_enforcement_test.go b/services/workmail/iam_enforcement_test.go index 31db003ba1..65a67e8386 100644 --- a/services/workmail/iam_enforcement_test.go +++ b/services/workmail/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/workmail" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/workmail/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/workspaces/iam_enforcement_test.go b/services/workspaces/iam_enforcement_test.go index 977de92a09..6b902e10d2 100644 --- a/services/workspaces/iam_enforcement_test.go +++ b/services/workspaces/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/workspaces" ) @@ -160,7 +161,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/workspaces/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/services/xray/iam_enforcement_test.go b/services/xray/iam_enforcement_test.go index 4dd9dbccbd..c2b7c3f2d0 100644 --- a/services/xray/iam_enforcement_test.go +++ b/services/xray/iam_enforcement_test.go @@ -13,6 +13,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" "github.com/blackbirdworks/gopherstack/services/iam" "github.com/blackbirdworks/gopherstack/services/xray" ) @@ -179,7 +180,7 @@ func TestIAMEnforcement(t *testing.T) { "/20260826/us-east-1/xray/aws4_request, SignedHeaders=host, Signature=mock", ) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/test/e2e/eventbridge_test.go b/test/e2e/eventbridge_test.go index 5bf4b7b028..090c1f4c92 100644 --- a/test/e2e/eventbridge_test.go +++ b/test/e2e/eventbridge_test.go @@ -64,10 +64,10 @@ func TestEventBridgeDashboard_Rules(t *testing.T) { require.NoError(t, err) _, err = stack.EventBridgeHandler.Backend.PutRule(t.Context(), ebbackend.PutRuleInput{ - Name: "test-rule", - EventBusName: "rules-bus", - State: "ENABLED", - ScheduleExpression: "rate(5 minutes)", + Name: "test-rule", + EventBusName: "rules-bus", + State: "ENABLED", + EventPattern: `{"source":["test"]}`, }) require.NoError(t, err) diff --git a/test/guards/eager_regexp_guard_test.go b/test/guards/eager_regexp_guard_test.go new file mode 100644 index 0000000000..45fcc73e60 --- /dev/null +++ b/test/guards/eager_regexp_guard_test.go @@ -0,0 +1,133 @@ +package guards_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "regexp/syntax" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// maxEagerRepeatCost bounds ranges-in-class x repeat-count for a package-level +// regexp.MustCompile. Unicode classes (\p{L}) inside {1,256} make regexp's +// one-pass compiler allocate megabytes at process start; such patterns belong +// behind sync.OnceValue. +const maxEagerRepeatCost = 20000 + +func repeatCost(re *syntax.Regexp) int { + worst := 0 + + if re.Op == syntax.OpRepeat && re.Max > 1 { + worst = classRanges(re) * re.Max + } + + for _, sub := range re.Sub { + worst = max(worst, repeatCost(sub)) + } + + return worst +} + +func classRanges(re *syntax.Regexp) int { + n := 0 + if re.Op == syntax.OpCharClass { + n = len(re.Rune) / 2 + } + + for _, sub := range re.Sub { + n = max(n, classRanges(sub)) + } + + return n +} + +func regexpProgramSize(t *testing.T, pattern string) int { + t.Helper() + + re, err := syntax.Parse(pattern, syntax.Perl) + if err != nil { + return 0 + } + + return repeatCost(re) +} + +func eagerRegexpOffenders(t *testing.T, file string) []string { + t.Helper() + + f, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) + require.NoError(t, err) + + var offenders []string + + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.VAR { + continue + } + + for _, spec := range gen.Specs { + for _, val := range spec.(*ast.ValueSpec).Values { + call, isCall := val.(*ast.CallExpr) + if !isCall || len(call.Args) != 1 { + continue + } + + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel || sel.Sel.Name != "MustCompile" { + continue + } + + lit, isLit := call.Args[0].(*ast.BasicLit) + if !isLit || lit.Kind != token.STRING { + continue + } + + pattern, uerr := strconv.Unquote(lit.Value) + if uerr != nil { + continue + } + + if n := regexpProgramSize(t, pattern); n > maxEagerRepeatCost { + offenders = append(offenders, file+": "+strconv.Itoa(n)+" repeat cost: "+pattern) + } + } + } + } + + return offenders +} + +func TestNoHeavyPackageLevelRegexp(t *testing.T) { + t.Parallel() + + var offenders []string + + for _, root := range []string{"services", "pkgs", "internal", "dashboard"} { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() && (d.Name() == "node_modules" || d.Name() == "testdata") { + return filepath.SkipDir + } + + if !d.IsDir() && strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { + offenders = append(offenders, eagerRegexpOffenders(t, path)...) + } + + return nil + }) + require.NoError(t, err) + } + + assert.Empty(t, offenders, "wrap in sync.OnceValue: package-level regexps this large allocate MBs at startup") +} diff --git a/test/guards/embed_tracked_guard_test.go b/test/guards/embed_tracked_guard_test.go new file mode 100644 index 0000000000..da025fd852 --- /dev/null +++ b/test/guards/embed_tracked_guard_test.go @@ -0,0 +1,344 @@ +package guards_test + +import ( + "bufio" + "bytes" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// embedUntrackedAllowlist lists generated embed targets that are intentionally untracked. +// dashboard/static/spa/* is built at build time and gitignored (only .keep is tracked). +var embedUntrackedAllowlist = []string{"dashboard/static/spa/"} //nolint:gochecknoglobals // immutable test table + +func gitTrackedSet(t *testing.T, root string) map[string]bool { + t.Helper() + + cmd := exec.CommandContext(t.Context(), "git", "ls-files", "-z") + cmd.Dir = root + + out, err := cmd.Output() + if err != nil { + return nil + } + + set := map[string]bool{} + for f := range bytes.SplitSeq(out, []byte{0}) { + if len(f) > 0 { + set[string(f)] = true + } + } + + return set +} + +// splitEmbedPatterns splits a //go:embed argument list, honouring "..." and `...` quoting. +func splitEmbedPatterns(s string) []string { + var ( + pats []string + cur strings.Builder + quote rune + has bool + ) + + flush := func() { + if has { + pats = append(pats, cur.String()) + } + + cur.Reset() + + has = false + } + + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + case r == '"' || r == '`': + quote, has = r, true + case r == ' ' || r == '\t': + flush() + default: + cur.WriteRune(r) + + has = true + } + } + + flush() + + return pats +} + +type embedDirective struct { + file string + patterns []string +} + +func findEmbedDirectives(t *testing.T, root string) []embedDirective { + t.Helper() + + var found []embedDirective + + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + switch d.Name() { + case ".git", "vendor", "node_modules", "testdata", ".dolt": + return filepath.SkipDir + } + + return nil + } + + if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + + f, openErr := os.Open(p) + if openErr != nil { + return openErr + } + defer f.Close() + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + + for sc.Scan() { + if rest, ok := strings.CutPrefix(sc.Text(), "//go:embed "); ok { + found = append(found, embedDirective{file: p, patterns: splitEmbedPatterns(rest)}) + } + } + + return sc.Err() + }) + require.NoError(t, err) + + return found +} + +// expandEmbed returns slash-relative (to root) files matched by one pattern, or nil if none. +func expandEmbed(t *testing.T, root, pkgDir, pattern string) []string { + t.Helper() + + all := false + if rest, ok := strings.CutPrefix(pattern, "all:"); ok { + pattern, all = rest, true + } + + matches, err := filepath.Glob(filepath.Join(pkgDir, filepath.FromSlash(pattern))) + require.NoError(t, err) + + var files []string + + for _, m := range matches { + err = filepath.WalkDir(m, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if p != m && !all && (strings.HasPrefix(d.Name(), ".") || strings.HasPrefix(d.Name(), "_")) { + if d.IsDir() { + return filepath.SkipDir + } + + return nil + } + + if !d.IsDir() { + rel, relErr := filepath.Rel(root, p) + if relErr != nil { + return relErr + } + + files = append(files, filepath.ToSlash(rel)) + } + + return nil + }) + require.NoError(t, err) + } + + return files +} + +func embedViolations(t *testing.T, root string, tracked map[string]bool, allow []string) []string { + t.Helper() + + var violations []string + + for _, dir := range findEmbedDirectives(t, root) { + pkgDir := filepath.Dir(dir.file) + + for _, pat := range dir.patterns { + files := expandEmbed(t, root, pkgDir, pat) + if len(files) == 0 { + violations = append(violations, dir.file+": pattern "+pat+" matches no files") + + continue + } + + for _, f := range files { + if tracked[f] || hasAnyPrefix(f, allow) { + continue + } + + violations = append(violations, dir.file+": embedded file "+f+" is not tracked by git (gitignored?)") + } + } + } + + sort.Strings(violations) + + return violations +} + +func hasAnyPrefix(s string, prefixes []string) bool { + for _, p := range prefixes { + if strings.HasPrefix(s, p) { + return true + } + } + + return false +} + +func TestEmbedTargetsTrackedByGit(t *testing.T) { + t.Parallel() + + tracked := gitTrackedSet(t, ".") + if tracked == nil { + t.Skip("not a git checkout") + } + + violations := embedViolations(t, ".", tracked, embedUntrackedAllowlist) + assert.Empty(t, violations, "go:embed targets must be committed; check .gitignore") +} + +func TestEmbedGuardDetectsViolations(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + tests := []struct { + name string + src string + files map[string]string + track []string + allow []string + want []string + }{ + { + name: "tracked file ok", + src: "//go:embed a.txt\n", + files: map[string]string{"pkg/a.txt": "x"}, + track: []string{"pkg/a.txt"}, + }, + { + name: "ignored file flagged", + src: "//go:embed a.txt b.txt\n", + files: map[string]string{"pkg/a.txt": "x", "pkg/b.txt": "y", ".gitignore": "*.txt\n!a.txt\n"}, + track: []string{"pkg/a.txt"}, + want: []string{"pkg/b.txt"}, + }, + { + name: "quoted pattern", + src: "//go:embed \"a b.txt\"\n", + files: map[string]string{"pkg/a b.txt": "x"}, + want: []string{"pkg/a b.txt"}, + }, + { + name: "missing match flagged", + src: "//go:embed nope.json\n", + files: map[string]string{"pkg/other": "x"}, + track: []string{"pkg/other"}, + want: []string{"matches no files"}, + }, + { + name: "dir skips dot and underscore files", + src: "//go:embed dir\n", + files: map[string]string{"pkg/dir/a": "x", "pkg/dir/.h": "x", "pkg/dir/_u": "x"}, + track: []string{"pkg/dir/a"}, + }, + { + name: "all prefix includes dot files", + src: "//go:embed all:dir\n", + files: map[string]string{"pkg/dir/a": "x", "pkg/dir/.h": "x"}, + track: []string{"pkg/dir/a"}, + want: []string{"pkg/dir/.h"}, + }, + { + name: "glob pattern", + src: "//go:embed m/*.gz\n", + files: map[string]string{"pkg/m/a.gz": "x", "pkg/m/b.gz": "x"}, + track: []string{"pkg/m/a.gz"}, + want: []string{"pkg/m/b.gz"}, + }, + { + name: "allowlist exempts", + src: "//go:embed all:spa\n", + files: map[string]string{"pkg/spa/app.js": "x"}, + allow: []string{"pkg/spa/"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + for rel, content := range tt.files { + full := filepath.Join(root, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(path.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) + } + + require.NoError(t, os.MkdirAll(filepath.Join(root, "pkg"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pkg", "x.go"), + []byte("package pkg\n\nimport _ \"embed\"\n\n"+tt.src+"var v string\n"), 0o644)) + + run := func(args ...string) { + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + } + + run("init", "-q") + run("add", "-f", "--intent-to-add", "--", "pkg/x.go") + + if len(tt.track) > 0 { + run(append([]string{"add", "-f", "--"}, tt.track...)...) + } + + tracked := gitTrackedSet(t, root) + require.NotNil(t, tracked) + + got := embedViolations(t, root, tracked, tt.allow) + require.Len(t, got, len(tt.want), "%v", got) + + for i, w := range tt.want { + assert.Contains(t, got[i], w) + } + }) + } +} diff --git a/test/guards/free_port_helper_guard_test.go b/test/guards/free_port_helper_guard_test.go new file mode 100644 index 0000000000..f6d6b5ddfb --- /dev/null +++ b/test/guards/free_port_helper_guard_test.go @@ -0,0 +1,167 @@ +package guards_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pickedPortHelpers returns the names of funcs in src that return an int port read from a ":0" +// listener whose Close() runs before the caller binds it (a TOCTOU under parallel tests). +func pickedPortHelpers(t *testing.T, src string) []string { + t.Helper() + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "x_test.go", src, 0) + require.NoError(t, err) + + var names []string + + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Body == nil || fn.Type.Results == nil || len(fn.Type.Results.List) != 1 { + continue + } + + if id, isIdent := fn.Type.Results.List[0].Type.(*ast.Ident); !isIdent || id.Name != "int" { + continue + } + + body := src[fset.Position(fn.Body.Pos()).Offset:fset.Position(fn.Body.End()).Offset] + if strings.Contains(body, `:0"`) && strings.Contains(body, ".Port") && strings.Contains(body, "Close()") { + names = append(names, fn.Name.Name) + } + } + + return names +} + +func TestPickedPortHelpers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want int + }{ + { + name: "close_and_return", + src: `package x +func freePort(t *testing.T) int { + ln, _ := lc.Listen(ctx, "tcp", "127.0.0.1:0") + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port +}`, + want: 1, + }, + { + name: "renamed", + src: `package x +func pickPort() int { + ln, _ := net.Listen("tcp", ":0") + ln.Close() + return ln.Addr().(*net.TCPAddr).Port +}`, + want: 1, + }, + { + name: "no_return_int", + src: `package x +func serve(t *testing.T) { + ln, _ := lc.Listen(ctx, "tcp", "127.0.0.1:0") + defer ln.Close() + _ = ln.Addr().(*net.TCPAddr).Port +}`, + want: 0, + }, + {name: "unrelated", src: "package x\nfunc n() int { return 1 }", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Len(t, pickedPortHelpers(t, tt.src), tt.want) + }) + } +} + +// pickedPortAllowlist holds helpers that predate the guard in services not yet converted; each entry must +// still exist, so converting a service forces its removal here. +func pickedPortAllowlist() map[string]string { + const debt = "not yet converted to Port 0 / portalloc.AcquireEphemeral" + + return map[string]string{ + "services/azurearm/handler_test.go: freeEphemeralPort": debt, + "services/azurearm/handler_test.go: reserveEphemeralPort": debt, + "services/azureblob/coverage_test.go: freeEphemeralPort": debt, + "services/azureblob/coverage_test.go: reserveEphemeralPort": debt, + "services/azurequeue/coverage_test.go: freeEphemeralPort": debt, + "services/azurequeue/coverage_test.go: reserveEphemeralPort": debt, + "services/azuretable/coverage_test.go: freeEphemeralPort": debt, + "services/azuretable/coverage_test.go: reserveEphemeralPort": debt, + "services/cosmosdb/coverage_test.go: reserveEphemeralPort": debt, + "services/ec2/docker_front_internal_test.go: echoServer": "keeps its listener open", + "services/ec2/docker_front_internal_test.go: freeTCPPort": debt, + "services/iot/broker_test.go: freeTCPPort": debt, + "services/lambda/function_url_host_test.go: freeLoopbackPort": debt, + "services/lambda/kafka_integration_internal_test.go: freeTCPPort": debt, + "services/neptune/gremlin_ws_proxy_test.go: wsFreePort": debt, + } +} + +// TestNoPickedPortHelpersInServiceTests forbids helpers that probe a free port, close the listener +// and hand the number back: another parallel listener can take it before the service binds it. +// Start the service on Port 0 and read the bound port back, or use portalloc.AcquireEphemeral. +func TestNoPickedPortHelpersInServiceTests(t *testing.T) { + t.Parallel() + + allow := pickedPortAllowlist() + seen := map[string]bool{} + + var offenders []string + + err := filepath.WalkDir("services", func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() || !strings.HasSuffix(path, "_test.go") { + return nil + } + + src, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + + for _, name := range pickedPortHelpers(t, string(src)) { + key := filepath.ToSlash(path) + ": " + name + if _, ok := allow[key]; ok { + seen[key] = true + + continue + } + + offenders = append(offenders, key) + } + + return nil + }) + require.NoError(t, err) + + for key := range allow { + if !seen[key] { + offenders = append(offenders, key+": stale allowlist entry") + } + } + + assert.Empty(t, offenders, "start on Port 0 and read the bound port, or use portalloc.AcquireEphemeral") +} diff --git a/test/guards/no_default_client_in_tests_guard_test.go b/test/guards/no_default_client_in_tests_guard_test.go new file mode 100644 index 0000000000..66080c760d --- /dev/null +++ b/test/guards/no_default_client_in_tests_guard_test.go @@ -0,0 +1,125 @@ +package guards_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var defaultTransportTestUse = regexp.MustCompile( + `\bhttp\.(?:DefaultClient|DefaultTransport|Get\(|Post\(|Head\(|PostForm\()`) + +func defaultClientTestAllowlist() map[string]string { + return map[string]string{ + "pkgs/httpclient/httpclient_test.go": "proves clients are isolated from http.DefaultTransport", + "services/sns/leak_main_test.go": "TestMain restricts dialing; httpclient clones inherit it", + "test/guards/http_transport_guard_test.go": "fixture strings", + "test/guards/no_default_client_in_tests_guard_test.go": "fixture strings", + } +} + +func stripLineComments(src string) string { + lines := strings.Split(src, "\n") + for i, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "//") { + lines[i] = "" + } + } + + return strings.Join(lines, "\n") +} + +func defaultClientTestUses(src string) []string { + return defaultTransportTestUse.FindAllString(stripLineComments(src), -1) +} + +func TestDefaultClientTestUses(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want int + }{ + {name: "default_client", src: "resp, err := http.DefaultClient.Do(req)", want: 1}, + {name: "default_transport", src: "tr := http.DefaultTransport", want: 1}, + {name: "get", src: "resp, err := http.Get(u)", want: 1}, + {name: "post_form", src: "http.PostForm(u, v)", want: 1}, + {name: "comment_only", src: "// http.Get(u) is banned", want: 0}, + {name: "own_client", src: "resp, err := testclient.New(t).Get(u)", want: 0}, + {name: "status_const", src: "http.StatusOK, http.MethodGet", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Len(t, defaultClientTestUses(tt.src), tt.want) + }) + } +} + +// TestNoDefaultClientInTests keeps tests off http.DefaultTransport: httptest.Server.Close +// closes its idle connections and breaks in-flight requests of parallel sibling tests. +// Use srv.Client(), pkgs/testclient or pkgs/httpclient. +func TestNoDefaultClientInTests(t *testing.T) { + t.Parallel() + + allow := defaultClientTestAllowlist() + used := map[string]bool{} + + var offenders []string + + err := filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + if n := d.Name(); n == "node_modules" || (n != "." && strings.HasPrefix(n, ".")) { + return filepath.SkipDir + } + + return nil + } + + if !strings.HasSuffix(path, "_test.go") { + return nil + } + + src, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + + uses := defaultClientTestUses(string(src)) + if len(uses) == 0 { + return nil + } + + slashed := filepath.ToSlash(path) + if _, ok := allow[slashed]; ok { + used[slashed] = true + + return nil + } + + offenders = append(offenders, slashed+": "+strings.Join(uses, ", ")) + + return nil + }) + require.NoError(t, err) + + for path := range allow { + if !used[path] { + offenders = append(offenders, path+": stale allowlist entry") + } + } + + assert.Empty(t, offenders, "use srv.Client(), testclient.New(t) or httpclient.New") +} diff --git a/test/guards/server_readiness_wait_guard_test.go b/test/guards/server_readiness_wait_guard_test.go new file mode 100644 index 0000000000..a7447fa47f --- /dev/null +++ b/test/guards/server_readiness_wait_guard_test.go @@ -0,0 +1,272 @@ +package guards_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const readinessHelper = "waitForServerReady" + +func readinessPaths() []string { + return []string{"/_gopherstack/health", "/_localstack/health", "/_localstack/init/ready"} +} + +func mentionsReadinessPath(n ast.Node) bool { + found := false + + ast.Inspect(n, func(x ast.Node) bool { + if lit, ok := x.(*ast.BasicLit); ok && lit.Kind == token.STRING { + for _, p := range readinessPaths() { + if strings.Contains(lit.Value, p) { + found = true + } + } + } + + return !found + }) + + return found +} + +func isFixedDeadlineExpr(x ast.Node) bool { + sel, ok := x.(*ast.SelectorExpr) + if !ok { + return false + } + + pkg, isIdent := sel.X.(*ast.Ident) + if !isIdent { + return false + } + + switch pkg.Name + "." + sel.Sel.Name { + case "time.After", "time.Since", "time.Until", "context.WithTimeout": + return true + } + + return false +} + +func isNowAdd(x ast.Node) bool { + call, ok := x.(*ast.CallExpr) + if !ok { + return false + } + + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Add" { + return false + } + + inner, ok := sel.X.(*ast.CallExpr) + if !ok { + return false + } + + s2, ok := inner.Fun.(*ast.SelectorExpr) + + return ok && s2.Sel.Name == "Now" +} + +func hasFixedDeadline(fn ast.Node) bool { + found := false + + ast.Inspect(fn, func(x ast.Node) bool { + if isFixedDeadlineExpr(x) || isNowAdd(x) { + found = true + } + + return !found + }) + + return found +} + +// fixedBudgetReadinessWaits returns "func: reason" for readiness polls bounded by a fixed wall-clock +// budget: an Eventually* whose condition requests a health/ready path, or a hand-rolled loop polling +// one under time.After/Now().Add/WithTimeout. Conditions that hide the request in a helper are not seen. +func fixedBudgetReadinessWaits(t *testing.T, src string) []string { + t.Helper() + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "x_test.go", src, 0) + require.NoError(t, err) + + var out []string + + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Body == nil || fn.Name.Name == readinessHelper { + continue + } + + eventually, loop := false, false + + ast.Inspect(fn.Body, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.CallExpr: + sel, isSel := x.Fun.(*ast.SelectorExpr) + if !isSel || !strings.HasPrefix(sel.Sel.Name, "Eventually") { + return true + } + + for _, a := range x.Args { + if lit, isLit := a.(*ast.FuncLit); isLit && mentionsReadinessPath(lit) { + eventually = true + } + } + case *ast.ForStmt: + if mentionsReadinessPath(x) { + loop = true + } + } + + return true + }) + + if eventually { + out = append(out, fn.Name.Name+": Eventually polls a readiness path") + } + + if loop && hasFixedDeadline(fn.Body) { + out = append(out, fn.Name.Name+": hand-rolled readiness loop with a fixed deadline") + } + } + + return out +} + +func TestFixedBudgetReadinessWaits(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want int + }{ + { + name: "eventually_health", + src: `package x +func TestA(t *testing.T) { + require.Eventually(t, func() bool { + resp, err := c.Get("http://localhost/_gopherstack/health") + return err == nil && resp.StatusCode == 200 + }, 3*time.Second, 50*time.Millisecond, "not ready") +}`, + want: 1, + }, + { + name: "eventually_with_t_sprintf_init_ready", + src: `package x +func TestA(t *testing.T) { + assert.EventuallyWithT(t, func(c *assert.CollectT) { + _, _ = c.Get(fmt.Sprintf("http://h:%d/_localstack/init/ready", 1)) + }, time.Second, time.Millisecond) +}`, + want: 1, + }, + { + name: "eventually_other_condition", + src: `package x +func TestA(t *testing.T) { + require.Eventually(t, func() bool { _, err := os.ReadFile("m"); return err == nil }, time.Second, time.Millisecond) +}`, + want: 0, + }, + { + name: "helper_use", + src: `package x +func TestA(t *testing.T) { + waitForServerReady(t, 1) +}`, + want: 0, + }, + { + name: "helper_itself", + src: `package x +func waitForServerReady(t *testing.T, port int) { + for { + c.Get("http://h/_gopherstack/health") + select { case <-t.Context().Done(): return; case <-time.After(time.Second): } + } +}`, + want: 0, + }, + { + name: "hand_rolled_fixed_deadline", + src: `package x +func TestA(t *testing.T) { + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + c.Get("http://h/_gopherstack/health") + } +}`, + want: 1, + }, + { + name: "single_request_no_loop", + src: `package x +func TestA(t *testing.T) { + c.Get("http://h/_gopherstack/health") + <-time.After(time.Second) +}`, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Len(t, fixedBudgetReadinessWaits(t, tt.src), tt.want) + }) + } +} + +// TestNoFixedBudgetServerReadinessWaits forbids polling health/ready endpoints under a fixed +// wall-clock budget: full startup under -race outgrows any constant as services are added. +// Use internal/app waitForServerReady, which is bounded by the test context instead. +func TestNoFixedBudgetServerReadinessWaits(t *testing.T) { + t.Parallel() + + var offenders []string + + for _, root := range []string{"internal", "services", "pkgs", "test", "cmd", "modules"} { + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() || !strings.HasSuffix(path, "_test.go") { + return nil + } + + src, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + + for _, w := range fixedBudgetReadinessWaits(t, string(src)) { + offenders = append(offenders, filepath.ToSlash(path)+": "+w) + } + + return nil + }) + if os.IsNotExist(err) { + continue + } + + require.NoError(t, err) + } + + assert.Empty(t, offenders, "use waitForServerReady (internal/app/cli_test.go), bounded by the test context") +} diff --git a/test/guards/weak_hash_guard_test.go b/test/guards/weak_hash_guard_test.go index 84a81b19cd..cda29d36da 100644 --- a/test/guards/weak_hash_guard_test.go +++ b/test/guards/weak_hash_guard_test.go @@ -37,6 +37,9 @@ func allowedWeakHashFiles() map[string]string { // fingerprints are the MD5 of the DER public key; integrity/identifier values, never credentials. "services/ociobjectstorage/store.go": "OCI object md5 and multipart md5 parity", "pkgs/ociauth/ociauth.go": "OCI API key fingerprint is MD5 of the DER public key", + // Identity Platform batchCreate accepts MD5/SHA1/PBKDF_SHA1/HMAC_* password hashes + // (UploadAccountRequest.hashAlgorithm); imported digests, never produced for new credentials. + "services/gcpidentitytoolkit/hashes.go": "Identity Platform legacy password hash import parity", // OCI Vault Encrypt/Decrypt expose RSA_OAEP_SHA_1 as a caller-selectable encryption algorithm. "services/ocivault/seal.go": "OCI Vault RSA_OAEP_SHA_1 encryption algorithm parity", diff --git a/test/integration/acm_test.go b/test/integration/acm_test.go index 312da8dffd..b66b887248 100644 --- a/test/integration/acm_test.go +++ b/test/integration/acm_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func acmPost(t *testing.T, action string, body any) *http.Response { @@ -34,7 +36,7 @@ func acmPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "CertificateManager."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/apigateway_test.go b/test/integration/apigateway_test.go index f8cef956b2..e3874a005a 100644 --- a/test/integration/apigateway_test.go +++ b/test/integration/apigateway_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func apigwPost(t *testing.T, action string, body any) *http.Response { @@ -23,7 +25,7 @@ func apigwPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "APIGateway."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp @@ -253,7 +255,7 @@ func apigwDo(t *testing.T, path string) *http.Response { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, endpoint+path, nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/appsync_test.go b/test/integration/appsync_test.go index 28110399cc..108c515adb 100644 --- a/test/integration/appsync_test.go +++ b/test/integration/appsync_test.go @@ -12,6 +12,8 @@ import ( appsyncsdktypes "github.com/aws/aws-sdk-go-v2/service/appsync/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) const testSchema = `type Query { @@ -265,7 +267,7 @@ func executeGraphQL( req.Header.Set("X-Api-Key", apiKey) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/test/integration/athena_iceberg_parity_test.go b/test/integration/athena_iceberg_parity_test.go new file mode 100644 index 0000000000..cf7bf9f939 --- /dev/null +++ b/test/integration/athena_iceberg_parity_test.go @@ -0,0 +1,319 @@ +package integration_test + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + athenasdk "github.com/aws/aws-sdk-go-v2/service/athena" + athenatypes "github.com/aws/aws-sdk-go-v2/service/athena/types" + gluesdk "github.com/aws/aws-sdk-go-v2/service/glue" + gluetypes "github.com/aws/aws-sdk-go-v2/service/glue/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3tablessdk "github.com/aws/aws-sdk-go-v2/service/s3tables" + s3tablestypes "github.com/aws/aws-sdk-go-v2/service/s3tables/types" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/iceberg" + "github.com/blackbirdworks/gopherstack/pkgs/parquetio" +) + +type athenaLake struct { + athena *athenasdk.Client + glue *gluesdk.Client + s3 *s3.Client + bucket string + db string +} + +func newAthenaLake(t *testing.T, prefix string) *athenaLake { + t.Helper() + + id := strings.ReplaceAll(uuid.NewString()[:8], "-", "") + l := &athenaLake{ + athena: createAthenaClient(t), glue: createGlueClient(t), s3: createS3Client(t), + bucket: prefix + "-" + id, db: prefix + "_" + id, + } + ctx := t.Context() + + _, err := l.s3.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(l.bucket)}) + require.NoError(t, err) + + _, err = l.glue.CreateDatabase(ctx, &gluesdk.CreateDatabaseInput{ + DatabaseInput: &gluetypes.DatabaseInput{Name: aws.String(l.db)}, + }) + require.NoError(t, err) + + return l +} + +func (l *athenaLake) put(t *testing.T, key string, body []byte) { + t.Helper() + + _, err := l.s3.PutObject(t.Context(), &s3.PutObjectInput{ + Bucket: aws.String(l.bucket), Key: aws.String(key), Body: bytes.NewReader(body), + }) + require.NoError(t, err) +} + +func (l *athenaLake) get(t *testing.T, uri string) []byte { + t.Helper() + + bucket, key, _ := strings.Cut(strings.TrimPrefix(uri, "s3://"), "/") + out, err := l.s3.GetObject(t.Context(), &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}) + require.NoError(t, err) + + defer func() { _ = out.Body.Close() }() + + body, err := io.ReadAll(out.Body) + require.NoError(t, err) + + return body +} + +// query runs sql and returns the terminal state, the reason and the data rows. +func (l *athenaLake) query(t *testing.T, catalog, sql string) (athenatypes.QueryExecutionState, string, [][]string) { + t.Helper() + + ctx := t.Context() + qctx := &athenatypes.QueryExecutionContext{Database: aws.String(l.db)} + + if catalog != "" { + qctx.Catalog = aws.String(catalog) + } + + start, err := l.athena.StartQueryExecution(ctx, &athenasdk.StartQueryExecutionInput{ + QueryString: aws.String(sql), + QueryExecutionContext: qctx, + ResultConfiguration: &athenatypes.ResultConfiguration{ + OutputLocation: aws.String("s3://" + l.bucket + "/results/"), + }, + }) + require.NoError(t, err) + + var status *athenatypes.QueryExecutionStatus + + require.Eventually(t, func() bool { + got, gerr := l.athena.GetQueryExecution(ctx, &athenasdk.GetQueryExecutionInput{ + QueryExecutionId: start.QueryExecutionId, + }) + if gerr != nil || got.QueryExecution == nil { + return false + } + + status = got.QueryExecution.Status + + return status.State != athenatypes.QueryExecutionStateQueued && + status.State != athenatypes.QueryExecutionStateRunning + }, 30*time.Second, 50*time.Millisecond, sql) + + reason := aws.ToString(status.StateChangeReason) + if status.State != athenatypes.QueryExecutionStateSucceeded { + return status.State, reason, nil + } + + res, err := l.athena.GetQueryResults(ctx, &athenasdk.GetQueryResultsInput{QueryExecutionId: start.QueryExecutionId}) + require.NoError(t, err) + + var rows [][]string + + for i, r := range res.ResultSet.Rows { + if i == 0 && len(res.ResultSet.ResultSetMetadata.ColumnInfo) > 0 { + continue + } + + cells := make([]string, len(r.Data)) + for j, d := range r.Data { + cells[j] = aws.ToString(d.VarCharValue) + } + + rows = append(rows, cells) + } + + return status.State, reason, rows +} + +func (l *athenaLake) must(t *testing.T, catalog, sql string) [][]string { + t.Helper() + + state, reason, rows := l.query(t, catalog, sql) + require.Equal(t, athenatypes.QueryExecutionStateSucceeded, state, "%s: %s", sql, reason) + + return rows +} + +func TestIntegration_AthenaIceberg_CreateInsertSelectTimeTravel(t *testing.T) { + t.Parallel() + dumpContainerLogsOnFailure(t) + + l := newAthenaLake(t, "itice") + ctx := t.Context() + + l.must(t, "", "CREATE TABLE events (id bigint, category string, ts timestamp) "+ + "PARTITIONED BY (category, bucket(4, id)) LOCATION 's3://"+l.bucket+"/events/' "+ + "TBLPROPERTIES ('table_type'='ICEBERG')") + + tbl, err := l.glue.GetTable(ctx, &gluesdk.GetTableInput{DatabaseName: aws.String(l.db), Name: aws.String("events")}) + require.NoError(t, err) + assert.Equal(t, "ICEBERG", tbl.Table.Parameters["table_type"]) + require.NotEmpty(t, tbl.Table.Parameters["metadata_location"]) + + l.must( + t, + "", + "INSERT INTO events VALUES (1, 'a', TIMESTAMP '2024-01-01 10:00:00'), (2, 'b', TIMESTAMP '2024-01-02 11:30:00')", + ) + l.must(t, "", "INSERT INTO events VALUES (3, 'a', TIMESTAMP '2024-01-03 12:00:00')") + + assert.Equal( + t, + [][]string{ + {"1", "a", "2024-01-01 10:00:00.000"}, + {"2", "b", "2024-01-02 11:30:00.000"}, + {"3", "a", "2024-01-03 12:00:00.000"}, + }, + l.must(t, "", "SELECT id, category, ts FROM events ORDER BY id"), + ) + + tbl, err = l.glue.GetTable(ctx, &gluesdk.GetTableInput{DatabaseName: aws.String(l.db), Name: aws.String("events")}) + require.NoError(t, err) + + meta, err := iceberg.ParseMetadata(l.get(t, tbl.Table.Parameters["metadata_location"])) + require.NoError(t, err) + require.Len(t, meta.Snapshots, 2) + assert.NotEmpty(t, tbl.Table.Parameters["previous_metadata_location"]) + + first := meta.Snapshots[0].SnapshotID + assert.Equal(t, [][]string{{"2"}}, + l.must(t, "", fmt.Sprintf("SELECT count(*) FROM events FOR VERSION AS OF %d", first))) + assert.Equal(t, [][]string{{"2"}}, + l.must(t, "", "SELECT count(*) FROM events FOR TIMESTAMP AS OF TIMESTAMP '"+ + time.UnixMilli(meta.Snapshots[0].TimestampMS).UTC().Format("2006-01-02 15:04:05.000")+" UTC'")) + assert.Equal(t, [][]string{{"2"}}, l.must(t, "", `SELECT count(*) FROM "events$snapshots"`)) + + state, reason, _ := l.query(t, "", "SELECT * FROM events FOR VERSION AS OF 42") + assert.Equal(t, athenatypes.QueryExecutionStateFailed, state) + assert.Contains(t, reason, "Iceberg snapshot ID does not exists") + + l.put(t, "events/metadata/gone", nil) + _, err = l.glue.UpdateTable(ctx, &gluesdk.UpdateTableInput{ + DatabaseName: aws.String(l.db), + TableInput: &gluetypes.TableInput{ + Name: aws.String("events"), TableType: tbl.Table.TableType, StorageDescriptor: tbl.Table.StorageDescriptor, + Parameters: map[string]string{ + "table_type": "ICEBERG", + "metadata_location": "s3://" + l.bucket + "/events/metadata/missing.json", + }, + }, + }) + require.NoError(t, err) + + state, reason, _ = l.query(t, "", "SELECT * FROM events") + assert.Equal(t, athenatypes.QueryExecutionStateFailed, state) + assert.Contains(t, reason, "ICEBERG_MISSING_METADATA") +} + +func TestIntegration_AthenaS3TablesCatalog_QueryAndAppend(t *testing.T) { + t.Parallel() + dumpContainerLogsOnFailure(t) + + l := newAthenaLake(t, "its3t") + tables := createS3TablesClient(t) + ctx := t.Context() + + bucketName := "its3t-" + strings.ReplaceAll(uuid.NewString()[:8], "_", "") + tb, err := tables.CreateTableBucket(ctx, &s3tablessdk.CreateTableBucketInput{Name: aws.String(bucketName)}) + require.NoError(t, err) + + _, err = tables.CreateNamespace(ctx, &s3tablessdk.CreateNamespaceInput{ + TableBucketARN: tb.Arn, Namespace: []string{"sales"}, + }) + require.NoError(t, err) + + _, err = tables.CreateTable(ctx, &s3tablessdk.CreateTableInput{ + TableBucketARN: tb.Arn, + Namespace: aws.String("sales"), + Name: aws.String("orders"), + Format: s3tablestypes.OpenTableFormatIceberg, + Metadata: &s3tablestypes.TableMetadataMemberIceberg{Value: s3tablestypes.IcebergMetadata{ + Schema: &s3tablestypes.IcebergSchema{Fields: []s3tablestypes.SchemaField{ + {Name: aws.String("id"), Type: aws.String("long"), Required: false}, + {Name: aws.String("item"), Type: aws.String("string"), Required: false}, + }}, + }}, + }) + require.NoError(t, err) + + before, err := tables.GetTableMetadataLocation(ctx, &s3tablessdk.GetTableMetadataLocationInput{ + TableBucketARN: tb.Arn, Namespace: aws.String("sales"), Name: aws.String("orders"), + }) + require.NoError(t, err) + + cat := "s3tablescatalog/" + bucketName + + assert.Empty(t, l.must(t, cat, `SELECT * FROM "`+cat+`"."sales"."orders"`)) + l.must(t, cat, `INSERT INTO "`+cat+`"."sales"."orders" VALUES (1, 'pen'), (2, 'ink')`) + assert.Equal( + t, + [][]string{{"1", "pen"}, {"2", "ink"}}, + l.must(t, cat, `SELECT id, item FROM sales.orders ORDER BY id`), + ) + + after, err := tables.GetTableMetadataLocation(ctx, &s3tablessdk.GetTableMetadataLocationInput{ + TableBucketARN: tb.Arn, Namespace: aws.String("sales"), Name: aws.String("orders"), + }) + require.NoError(t, err) + assert.NotEqual(t, aws.ToString(before.MetadataLocation), aws.ToString(after.MetadataLocation)) + + meta, err := iceberg.ParseMetadata(l.get(t, aws.ToString(after.MetadataLocation))) + require.NoError(t, err) + assert.Len(t, meta.Snapshots, 1) + + state, reason, _ := l.query(t, cat, `SELECT * FROM "`+cat+`"."sales"."nope"`) + assert.Equal(t, athenatypes.QueryExecutionStateFailed, state) + assert.Contains(t, reason, "TABLE_NOT_FOUND") +} + +func TestIntegration_AthenaDelta_ReadsTransactionLog(t *testing.T) { + t.Parallel() + dumpContainerLogsOnFailure(t) + + l := newAthenaLake(t, "itdelta") + + var buf bytes.Buffer + + require.NoError(t, parquetio.Write(&buf, + []parquetio.Column{{Name: "id", Type: "bigint"}, {Name: "name", Type: "string"}}, + [][]any{{int64(1), "ann"}, {int64(2), "bob"}}, "SNAPPY")) + l.put(t, "dl/part-0.parquet", buf.Bytes()) + + schema := `{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"long\",\"nullable\":true,\"metadata\":{}},` + + `{\"name\":\"name\",\"type\":\"string\",\"nullable\":true,\"metadata\":{}}]}` + l.put(t, "dl/_delta_log/00000000000000000000.json", []byte(strings.Join([]string{ + `{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}`, + `{"metaData":{"id":"t","format":{"provider":"parquet"},"schemaString":"` + schema + + `","partitionColumns":[],"configuration":{}}}`, + `{"add":{"path":"part-0.parquet","partitionValues":{},"size":1,"dataChange":true}}`, + }, "\n")+"\n")) + + l.must(t, "", "CREATE EXTERNAL TABLE people LOCATION 's3://"+l.bucket+"/dl/' TBLPROPERTIES ('table_type'='DELTA')") + + tbl, err := l.glue.GetTable( + t.Context(), + &gluesdk.GetTableInput{DatabaseName: aws.String(l.db), Name: aws.String("people")}, + ) + require.NoError(t, err) + assert.Len(t, tbl.Table.StorageDescriptor.Columns, 2) + + assert.Equal(t, [][]string{{"1", "ann"}, {"2", "bob"}}, l.must(t, "", "SELECT id, name FROM people ORDER BY id")) + + state, reason, _ := l.query(t, "", "INSERT INTO people VALUES (3, 'cy')") + assert.Equal(t, athenatypes.QueryExecutionStateFailed, state) + assert.Contains(t, reason, "read-only") +} diff --git a/test/integration/awsconfig_test.go b/test/integration/awsconfig_test.go index b8665791ca..91ca4d57dc 100644 --- a/test/integration/awsconfig_test.go +++ b/test/integration/awsconfig_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func awsconfigPost(t *testing.T, action string, body any) *http.Response { @@ -23,7 +25,7 @@ func awsconfigPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "StarlingDoveService."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/azurearm_test.go b/test/integration/azurearm_test.go index 8e6af9ff65..1289bb97b6 100644 --- a/test/integration/azurearm_test.go +++ b/test/integration/azurearm_test.go @@ -28,6 +28,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // armInsecureClient is an *http.Client that trusts azureARMEndpoint's @@ -264,7 +266,7 @@ func TestIntegration_AzureARM_ServiceBusLifecycleAndCrossServiceWiring(t *testin sbReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, azureServiceBusEndpoint+"/"+queue, nil) require.NoError(t, err) - sbResp, err := http.DefaultClient.Do(sbReq) + sbResp, err := testclient.New(t).Do(sbReq) require.NoError(t, err) defer sbResp.Body.Close() diff --git a/test/integration/azureservicebus_test.go b/test/integration/azureservicebus_test.go index 713e3862df..b31dbb5727 100644 --- a/test/integration/azureservicebus_test.go +++ b/test/integration/azureservicebus_test.go @@ -26,6 +26,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // sbBrokerProperties mirrors services/azureservicebus/handler.go's @@ -59,7 +61,7 @@ func sbRequest(t *testing.T, method, path string, body []byte, headers map[strin req.Header.Set(k, v) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) @@ -258,7 +260,7 @@ func TestIntegration_AzureServiceBus_PeekLockLongPoll(t *testing.T) { return } - sendResp, doErr := http.DefaultClient.Do(req) + sendResp, doErr := testclient.New(t).Do(req) if doErr == nil { _ = sendResp.Body.Close() } diff --git a/test/integration/chaos_test.go b/test/integration/chaos_test.go index 446bc37b5c..ccb8436914 100644 --- a/test/integration/chaos_test.go +++ b/test/integration/chaos_test.go @@ -18,6 +18,8 @@ import ( "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // chaosFaultError is the JSON schema for a custom error in a chaos fault rule. @@ -114,7 +116,7 @@ func postChaosRules(t *testing.T, ep string, rules any) { req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() @@ -139,7 +141,7 @@ func postChaosEffects(t *testing.T, ep string, effects chaosNetworkEffects) { req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() diff --git a/test/integration/cloudformation_phase2_test.go b/test/integration/cloudformation_phase2_test.go index 06360fa504..69216a826f 100644 --- a/test/integration/cloudformation_phase2_test.go +++ b/test/integration/cloudformation_phase2_test.go @@ -339,6 +339,7 @@ func TestIntegration_CFN_Phase2_Redshift(t *testing.T) { "Type": "AWS::Redshift::Cluster", "Properties": { "ClusterIdentifier": "cfn-rs-` + cfnStackID() + `", +"ClusterType": "single-node", "NodeType": "dc2.large", "DBName": "mydb", "MasterUsername": "admin" @@ -444,11 +445,29 @@ func TestIntegration_CFN_Phase2_Route53Resolver(t *testing.T) { return `{ "AWSTemplateFormatVersion": "2010-09-09", "Resources": { +"VPC": { +"Type": "AWS::EC2::VPC", +"Properties": {"CidrBlock": "10.40.0.0/16"} +}, +"SubnetA": { +"Type": "AWS::EC2::Subnet", +"Properties": {"VpcId": {"Ref": "VPC"}, "CidrBlock": "10.40.1.0/24", "AvailabilityZone": "us-east-1a"} +}, +"SubnetB": { +"Type": "AWS::EC2::Subnet", +"Properties": {"VpcId": {"Ref": "VPC"}, "CidrBlock": "10.40.2.0/24", "AvailabilityZone": "us-east-1b"} +}, +"SG": { +"Type": "AWS::EC2::SecurityGroup", +"Properties": {"GroupDescription": "resolver endpoint", "VpcId": {"Ref": "VPC"}} +}, "MyEP": { "Type": "AWS::Route53Resolver::ResolverEndpoint", "Properties": { "Name": "cfn-ep-` + cfnStackID() + `", -"Direction": "INBOUND" +"Direction": "INBOUND", +"SecurityGroupIds": [{"Ref": "SG"}], +"IpAddresses": [{"SubnetId": {"Ref": "SubnetA"}}, {"SubnetId": {"Ref": "SubnetB"}}] } } } diff --git a/test/integration/cognito_test.go b/test/integration/cognito_test.go index b4f410dddf..8c032dbfc1 100644 --- a/test/integration/cognito_test.go +++ b/test/integration/cognito_test.go @@ -16,6 +16,8 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // TestIntegration_CognitoIDP_FullFlow exercises the full Cognito IDP lifecycle: @@ -102,7 +104,7 @@ func TestIntegration_CognitoIDP_FullFlow(t *testing.T) { // 7. Fetch the JWKS and use it to verify the tokens. jwksURL := fmt.Sprintf("%s/%s/.well-known/jwks.json", endpoint, poolID) - jwksResp, httpErr := http.Get(jwksURL) + jwksResp, httpErr := testclient.New(t).Get(jwksURL) require.NoError(t, httpErr, "JWKS GET failed") defer jwksResp.Body.Close() require.Equal(t, http.StatusOK, jwksResp.StatusCode, "JWKS endpoint should return 200") diff --git a/test/integration/ecs_docker_container_def_test.go b/test/integration/ecs_docker_container_def_test.go new file mode 100644 index 0000000000..b834c782f3 --- /dev/null +++ b/test/integration/ecs_docker_container_def_test.go @@ -0,0 +1,217 @@ +package integration_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_ECS_DockerContainerDefinition runs real busybox containers against a server started with +// GOPHERSTACK_ECS_RUNTIME=docker and checks that container-definition parameters are honoured: a Secrets Manager +// secret, a bind volume shared by two containers, dependsOn HEALTHY gating, health reporting, and +// essential-container exit. Skipped unless GOPHERSTACK_TEST_ECS_DOCKER_ENDPOINT is set. +func TestIntegration_ECS_DockerContainerDefinition(t *testing.T) { + t.Parallel() + + endpoint := os.Getenv("GOPHERSTACK_TEST_ECS_DOCKER_ENDPOINT") + if endpoint == "" { + t.Skip("skipping docker-runtime ECS test: GOPHERSTACK_TEST_ECS_DOCKER_ENDPOINT not set") + } + + ctx := t.Context() + suffix := uuid.NewString()[:8] + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion("us-east-1"), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + client := ecs.NewFromConfig(cfg, func(o *ecs.Options) { o.BaseEndpoint = &endpoint }) + sm := secretsmanager.NewFromConfig(cfg, func(o *secretsmanager.Options) { o.BaseEndpoint = &endpoint }) + + secret, err := sm.CreateSecret(ctx, &secretsmanager.CreateSecretInput{ + Name: aws.String("ecs-docker-" + suffix), + SecretString: aws.String(`{"password":"hunter2"}`), + }) + require.NoError(t, err) + + clusterName := "docker-def-" + suffix + _, err = client.CreateCluster(ctx, &ecs.CreateClusterInput{ClusterName: aws.String(clusterName)}) + require.NoError(t, err) + + hostDir := t.TempDir() + require.NoError(t, os.Chmod(hostDir, 0o777)) + + reg, err := client.RegisterTaskDefinition(ctx, &ecs.RegisterTaskDefinitionInput{ + Family: aws.String("docker-def-" + suffix), + Volumes: []ecstypes.Volume{{ + Name: aws.String("shared"), + Host: &ecstypes.HostVolumeProperties{SourcePath: aws.String(hostDir)}, + }}, + ContainerDefinitions: []ecstypes.ContainerDefinition{ + { + Name: aws.String("app"), + Image: aws.String("busybox:latest"), + Essential: aws.Bool(true), + Command: []string{"sh", "-c", `echo "$DB_PASS" > /data/app-secret; sleep 300`}, + Secrets: []ecstypes.Secret{{ + Name: aws.String("DB_PASS"), ValueFrom: aws.String(aws.ToString(secret.ARN) + ":password::"), + }}, + MountPoints: []ecstypes.MountPoint{ + {SourceVolume: aws.String("shared"), ContainerPath: aws.String("/data")}, + }, + DependsOn: []ecstypes.ContainerDependency{{ + ContainerName: aws.String("db"), Condition: ecstypes.ContainerConditionHealthy, + }}, + }, + { + Name: aws.String("db"), + Image: aws.String("busybox:latest"), + Essential: aws.Bool(true), + Command: []string{"sh", "-c", "touch /data/db-ready; sleep 300"}, + HealthCheck: &ecstypes.HealthCheck{ + Command: []string{"CMD-SHELL", "test -f /data/db-ready"}, + Interval: aws.Int32(5), Timeout: aws.Int32(3), Retries: aws.Int32(3), + }, + StopTimeout: aws.Int32(2), + MountPoints: []ecstypes.MountPoint{ + {SourceVolume: aws.String("shared"), ContainerPath: aws.String("/data")}, + }, + }, + }, + }) + require.NoError(t, err) + + run, err := client.RunTask(ctx, &ecs.RunTaskInput{ + Cluster: aws.String(clusterName), TaskDefinition: reg.TaskDefinition.TaskDefinitionArn, + }) + require.NoError(t, err) + require.Len(t, run.Tasks, 1) + + taskArn := aws.ToString(run.Tasks[0].TaskArn) + + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) + defer cancel() + + _, _ = client.StopTask(stopCtx, &ecs.StopTaskInput{Cluster: aws.String(clusterName), Task: aws.String(taskArn)}) + }) + + describe := func() ecstypes.Task { + out, derr := client.DescribeTasks(ctx, &ecs.DescribeTasksInput{ + Cluster: aws.String(clusterName), Tasks: []string{taskArn}, + }) + require.NoError(t, derr) + require.Len(t, out.Tasks, 1) + + return out.Tasks[0] + } + + require.Equal(t, "RUNNING", aws.ToString(describe().LastStatus), "RunTask returns once dependsOn gates passed") + + require.Eventually(t, func() bool { + return describe().HealthStatus == ecstypes.HealthStatusHealthy + }, time.Minute, time.Second, "task health follows the essential db container") + + byName := map[string]ecstypes.Container{} + for _, c := range describe().Containers { + byName[aws.ToString(c.Name)] = c + } + + assert.Equal(t, ecstypes.HealthStatusHealthy, byName["db"].HealthStatus) + + require.Eventually(t, func() bool { + b, rerr := os.ReadFile(filepath.Join(hostDir, "app-secret")) + + return rerr == nil && strings.TrimSpace(string(b)) == "hunter2" + }, 30*time.Second, 250*time.Millisecond, "app saw the secret and wrote it to the shared bind volume") + + assert.FileExists(t, filepath.Join(hostDir, "db-ready")) + + // An essential container exiting stops the task. + exitReg, err := client.RegisterTaskDefinition(ctx, &ecs.RegisterTaskDefinitionInput{ + Family: aws.String("docker-exit-" + suffix), + ContainerDefinitions: []ecstypes.ContainerDefinition{ + { + Name: aws.String("main"), Image: aws.String("busybox:latest"), Essential: aws.Bool(true), + Command: []string{"sh", "-c", "exit 3"}, + }, + { + Name: aws.String("side"), Image: aws.String("busybox:latest"), Essential: aws.Bool(false), + Command: []string{"sleep", "300"}, + }, + }, + }) + require.NoError(t, err) + + exitRun, err := client.RunTask(ctx, &ecs.RunTaskInput{ + Cluster: aws.String(clusterName), TaskDefinition: exitReg.TaskDefinition.TaskDefinitionArn, + }) + require.NoError(t, err) + + exitArn := aws.ToString(exitRun.Tasks[0].TaskArn) + + require.Eventually(t, func() bool { + out, derr := client.DescribeTasks( + ctx, + &ecs.DescribeTasksInput{Cluster: aws.String(clusterName), Tasks: []string{exitArn}}, + ) + if derr != nil || len(out.Tasks) != 1 || aws.ToString(out.Tasks[0].LastStatus) != "STOPPED" { + return false + } + + got := out.Tasks[0] + assert.Equal(t, ecstypes.TaskStopCodeEssentialContainerExited, got.StopCode) + assert.Equal(t, "Essential container in task exited", aws.ToString(got.StoppedReason)) + + for _, c := range got.Containers { + if aws.ToString(c.Name) == "main" { + require.NotNil(t, c.ExitCode) + assert.EqualValues(t, 3, *c.ExitCode) + } + } + + return true + }, time.Minute, 500*time.Millisecond) + + // A secret that does not exist fails the task before any container starts. + missing, err := client.RegisterTaskDefinition(ctx, &ecs.RegisterTaskDefinitionInput{ + Family: aws.String("docker-missing-" + suffix), + ContainerDefinitions: []ecstypes.ContainerDefinition{{ + Name: aws.String("x"), Image: aws.String("busybox:latest"), Essential: aws.Bool(true), + Secrets: []ecstypes.Secret{{ + Name: aws.String("S"), + ValueFrom: aws.String("arn:aws:secretsmanager:us-east-1:000000000000:secret:nope-AbCdEf"), + }}, + }}, + }) + require.NoError(t, err) + + missRun, err := client.RunTask(ctx, &ecs.RunTaskInput{ + Cluster: aws.String(clusterName), TaskDefinition: missing.TaskDefinition.TaskDefinitionArn, + }) + require.NoError(t, err) + + out, err := client.DescribeTasks(ctx, &ecs.DescribeTasksInput{ + Cluster: aws.String(clusterName), Tasks: []string{aws.ToString(missRun.Tasks[0].TaskArn)}, + }) + require.NoError(t, err) + require.Len(t, out.Tasks, 1) + assert.Equal(t, "STOPPED", aws.ToString(out.Tasks[0].LastStatus)) + assert.Equal(t, ecstypes.TaskStopCodeTaskFailedToStart, out.Tasks[0].StopCode) + assert.Contains(t, aws.ToString(out.Tasks[0].StoppedReason), "ResourceInitializationError") +} diff --git a/test/integration/ecs_docker_inputs_test.go b/test/integration/ecs_docker_inputs_test.go new file mode 100644 index 0000000000..e61d272148 --- /dev/null +++ b/test/integration/ecs_docker_inputs_test.go @@ -0,0 +1,186 @@ +package integration_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_ECS_DockerEnvironmentFilesAndRegistryAuth drives environmentFiles (S3) and +// repositoryCredentials through the ecs, s3 and secretsmanager SDK clients against a docker-runtime server. +// Skipped unless GOPHERSTACK_TEST_ECS_DOCKER_ENDPOINT is set. +func TestIntegration_ECS_DockerEnvironmentFilesAndRegistryAuth(t *testing.T) { + t.Parallel() + + endpoint := os.Getenv("GOPHERSTACK_TEST_ECS_DOCKER_ENDPOINT") + if endpoint == "" { + t.Skip("skipping docker-runtime ECS test: GOPHERSTACK_TEST_ECS_DOCKER_ENDPOINT not set") + } + + ctx := t.Context() + suffix := uuid.NewString()[:8] + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion("us-east-1"), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + client := ecs.NewFromConfig(cfg, func(o *ecs.Options) { o.BaseEndpoint = &endpoint }) + sm := secretsmanager.NewFromConfig(cfg, func(o *secretsmanager.Options) { o.BaseEndpoint = &endpoint }) + s3c := s3.NewFromConfig(cfg, func(o *s3.Options) { o.BaseEndpoint = &endpoint; o.UsePathStyle = true }) + + bucket := "ecs-env-" + suffix + _, err = s3c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}) + require.NoError(t, err) + + _, err = s3c.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), Key: aws.String("app.env"), + Body: strings.NewReader("# comment\nFROM_FILE=file-value\nOVERRIDE=file-loses\n"), + }) + require.NoError(t, err) + + clusterName := "docker-inputs-" + suffix + _, err = client.CreateCluster(ctx, &ecs.CreateClusterInput{ClusterName: aws.String(clusterName)}) + require.NoError(t, err) + + hostDir := t.TempDir() + require.NoError(t, os.Chmod(hostDir, 0o777)) + + volumes := []ecstypes.Volume{{ + Name: aws.String("shared"), Host: &ecstypes.HostVolumeProperties{SourcePath: aws.String(hostDir)}, + }} + + register := func(name string, cd ecstypes.ContainerDefinition) string { + out, rerr := client.RegisterTaskDefinition(ctx, &ecs.RegisterTaskDefinitionInput{ + Family: aws.String(name + "-" + suffix), + Volumes: volumes, + ContainerDefinitions: []ecstypes.ContainerDefinition{cd}, + }) + require.NoError(t, rerr) + + return aws.ToString(out.TaskDefinition.TaskDefinitionArn) + } + + run := func(td string) string { + out, rerr := client.RunTask(ctx, &ecs.RunTaskInput{ + Cluster: aws.String(clusterName), TaskDefinition: aws.String(td), + }) + require.NoError(t, rerr) + require.Len(t, out.Tasks, 1) + + arn := aws.ToString(out.Tasks[0].TaskArn) + + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) + defer cancel() + + _, _ = client.StopTask(stopCtx, &ecs.StopTaskInput{Cluster: aws.String(clusterName), Task: aws.String(arn)}) + }) + + return arn + } + + stoppedReason := func(arn string) string { + var reason string + + require.Eventually(t, func() bool { + out, derr := client.DescribeTasks(ctx, &ecs.DescribeTasksInput{ + Cluster: aws.String(clusterName), Tasks: []string{arn}, + }) + if derr != nil || len(out.Tasks) != 1 || aws.ToString(out.Tasks[0].LastStatus) != "STOPPED" { + return false + } + + reason = aws.ToString(out.Tasks[0].StoppedReason) + + return true + }, time.Minute, 500*time.Millisecond) + + return reason + } + + const image = "busybox:1.36" + + t.Run("environment_file_applied", func(t *testing.T) { + t.Parallel() + + run(register("envfile", ecstypes.ContainerDefinition{ + Name: aws.String("app"), Image: aws.String(image), Essential: aws.Bool(true), + Command: []string{"sh", "-c", `echo "$FROM_FILE $OVERRIDE" > /data/env-out; sleep 300`}, + Environment: []ecstypes.KeyValuePair{ + {Name: aws.String("OVERRIDE"), Value: aws.String("env-wins")}, + }, + EnvironmentFiles: []ecstypes.EnvironmentFile{{ + Type: ecstypes.EnvironmentFileTypeS3, Value: aws.String("arn:aws:s3:::" + bucket + "/app.env"), + }}, + MountPoints: []ecstypes.MountPoint{ + {SourceVolume: aws.String("shared"), ContainerPath: aws.String("/data")}, + }, + StopTimeout: aws.Int32(2), + })) + + require.Eventually(t, func() bool { + b, rerr := os.ReadFile(filepath.Join(hostDir, "env-out")) + + return rerr == nil && strings.TrimSpace(string(b)) == "file-value env-wins" + }, time.Minute, 500*time.Millisecond) + }) + + t.Run("missing_environment_file_fails_task", func(t *testing.T) { + t.Parallel() + + arn := run(register("envmissing", ecstypes.ContainerDefinition{ + Name: aws.String("app"), Image: aws.String(image), Essential: aws.Bool(true), + EnvironmentFiles: []ecstypes.EnvironmentFile{{ + Type: ecstypes.EnvironmentFileTypeS3, Value: aws.String("arn:aws:s3:::" + bucket + "/absent.env"), + }}, + })) + + assert.Contains(t, stoppedReason(arn), "ResourceInitializationError: failed to download env files") + }) + + t.Run("missing_registry_secret_fails_task", func(t *testing.T) { + t.Parallel() + + arn := run(register("regmissing", ecstypes.ContainerDefinition{ + Name: aws.String("app"), Image: aws.String(image), Essential: aws.Bool(true), + RepositoryCredentials: &ecstypes.RepositoryCredentials{ + CredentialsParameter: aws.String("arn:aws:secretsmanager:us-east-1:000000000000:secret:nope-AbCdEf"), + }, + })) + + assert.Contains(t, stoppedReason(arn), "ResourceInitializationError: unable to pull secrets or registry auth") + }) + + t.Run("malformed_registry_secret_fails_task", func(t *testing.T) { + t.Parallel() + + secret, cerr := sm.CreateSecret(ctx, &secretsmanager.CreateSecretInput{ + Name: aws.String("regcreds-" + suffix), SecretString: aws.String(`{"user":"x"}`), + }) + require.NoError(t, cerr) + + arn := run(register("regbad", ecstypes.ContainerDefinition{ + Name: aws.String("app"), Image: aws.String(image), Essential: aws.Bool(true), + RepositoryCredentials: &ecstypes.RepositoryCredentials{CredentialsParameter: secret.ARN}, + })) + + reason := stoppedReason(arn) + assert.Contains(t, reason, "unable to pull secrets or registry auth") + }) +} diff --git a/test/integration/ecs_test.go b/test/integration/ecs_test.go index 74aa1b0f78..ea8e8d6cca 100644 --- a/test/integration/ecs_test.go +++ b/test/integration/ecs_test.go @@ -886,21 +886,17 @@ func TestIntegration_ECS_ExecuteCommand(t *testing.T) { taskArn := runOut.Tasks[0].TaskArn - // ExecuteCommand on running task. - execOut, err := client.ExecuteCommand(ctx, &ecs.ExecuteCommandInput{ + // The integration server has no docker task runtime, so no exec agent can connect. + _, err = client.ExecuteCommand(ctx, &ecs.ExecuteCommandInput{ Cluster: aws.String(clusterName), Task: taskArn, Container: aws.String("app"), Command: aws.String("/bin/sh"), Interactive: true, }) - require.NoError(t, err) - require.NotNil(t, execOut.Session) - assert.NotEmpty(t, aws.ToString(execOut.Session.SessionId)) - assert.NotEmpty(t, aws.ToString(execOut.Session.StreamUrl)) - assert.NotEmpty(t, aws.ToString(execOut.Session.TokenValue)) - assert.NotEmpty(t, aws.ToString(execOut.ClusterArn)) - assert.Equal(t, aws.ToString(taskArn), aws.ToString(execOut.TaskArn)) + + var notConnected *ecstypes.TargetNotConnectedException + require.ErrorAs(t, err, ¬Connected) } // TestIntegration_ECS_DockerRuntime tests ECS task execution when the Docker diff --git a/test/integration/eks_irsa_test.go b/test/integration/eks_irsa_test.go index 0dc6efaf9f..b980cad68e 100644 --- a/test/integration/eks_irsa_test.go +++ b/test/integration/eks_irsa_test.go @@ -21,6 +21,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) type irsaToken struct { @@ -34,7 +36,7 @@ func mintIRSAToken(t *testing.T, cluster string, body map[string]any) irsaToken raw, err := json.Marshal(body) require.NoError(t, err) - resp, err := http.Post(endpoint+"/_gopherstack/eks/"+cluster+"/serviceaccount-token", + resp, err := testclient.New(t).Post(endpoint+"/_gopherstack/eks/"+cluster+"/serviceaccount-token", "application/json", bytes.NewReader(raw)) require.NoError(t, err) @@ -91,7 +93,8 @@ func TestIntegration_EKS_IRSA(t *testing.T) { host := strings.TrimPrefix(issuer, "https://") id := issuer[strings.LastIndex(issuer, "/")+1:] - disc, err := http.Get(endpoint + "/_gopherstack/eks/oidc/id/" + id + "/.well-known/openid-configuration") + disc, err := testclient.New(t). + Get(endpoint + "/_gopherstack/eks/oidc/id/" + id + "/.well-known/openid-configuration") require.NoError(t, err) defer disc.Body.Close() diff --git a/test/integration/elasticsearch_test.go b/test/integration/elasticsearch_test.go index 416436f86e..3218788db5 100644 --- a/test/integration/elasticsearch_test.go +++ b/test/integration/elasticsearch_test.go @@ -12,6 +12,8 @@ import ( elasticsearchsdk "github.com/aws/aws-sdk-go-v2/service/elasticsearchservice" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // TestIntegration_Elasticsearch_VpcEndpointList_NextToken drives @@ -90,7 +92,7 @@ func doElasticsearchRequest(t *testing.T, method, path string, body any) (int, m req.Header.Set("Content-Type", "application/json") } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/test/integration/firehose_delivery_test.go b/test/integration/firehose_delivery_test.go index 977c76154f..a270dc2cdf 100644 --- a/test/integration/firehose_delivery_test.go +++ b/test/integration/firehose_delivery_test.go @@ -20,6 +20,7 @@ import ( pkglogger "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" firehosepkg "github.com/blackbirdworks/gopherstack/services/firehose" s3pkg "github.com/blackbirdworks/gopherstack/services/s3" ) @@ -448,7 +449,7 @@ func TestIntegration_Firehose_HTTP_UpdateDestination(t *testing.T) { require.NoError(t, reqCreateErr) req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "Firehose_20150804."+action) - resp, reqErr := http.DefaultClient.Do(req) + resp, reqErr := testclient.New(t).Do(req) require.NoError(t, reqErr) defer resp.Body.Close() rb, readErr := io.ReadAll(resp.Body) diff --git a/test/integration/firehose_test.go b/test/integration/firehose_test.go index a5645262bf..79c5815720 100644 --- a/test/integration/firehose_test.go +++ b/test/integration/firehose_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func firehosePost(t *testing.T, action string, body any) *http.Response { @@ -24,7 +26,7 @@ func firehosePost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "Firehose_20150804."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/fis_test.go b/test/integration/fis_test.go index 428c31e4e4..09879c9e05 100644 --- a/test/integration/fis_test.go +++ b/test/integration/fis_test.go @@ -13,6 +13,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // ---------------------------------------- @@ -42,7 +44,7 @@ func fisRequest( req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/gcp_functions_test.go b/test/integration/gcp_functions_test.go index e0ae5e24d8..0c961c45c2 100644 --- a/test/integration/gcp_functions_test.go +++ b/test/integration/gcp_functions_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func newFunctionsClient(t *testing.T) *functions.FunctionClient { @@ -57,7 +59,7 @@ func uploadFunctionSource( req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, u.String(), bytes.NewReader(nodeSourceZip(t))) require.NoError(t, err) - res, err := http.DefaultClient.Do(req) + res, err := testclient.New(t).Do(req) require.NoError(t, err) _ = res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) diff --git a/test/integration/glacier_test.go b/test/integration/glacier_test.go index 24e88ff153..abd34e56a0 100644 --- a/test/integration/glacier_test.go +++ b/test/integration/glacier_test.go @@ -14,6 +14,8 @@ import ( glaciertypes "github.com/aws/aws-sdk-go-v2/service/glacier/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // createGlacierClient returns a Glacier client pointed at the shared test container. @@ -194,7 +196,7 @@ func TestIntegration_Glacier_VaultNotFound(t *testing.T) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/-/vaults/does-not-exist", http.NoBody) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() @@ -219,7 +221,7 @@ func glacierAddTags(t *testing.T, vaultName string, tags map[string]string) erro req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() diff --git a/test/integration/iot_test.go b/test/integration/iot_test.go index c110e9de8c..52d96d6ad5 100644 --- a/test/integration/iot_test.go +++ b/test/integration/iot_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/require" "github.com/aws/aws-sdk-go-v2/service/sqs" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // iotRequest is a helper that issues an HTTP request to the IoT control-plane. @@ -37,7 +39,7 @@ func iotRequest(t *testing.T, method, path string, body any) *http.Response { req.Header.Set("Content-Type", "application/json") } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/lambda_logs_test.go b/test/integration/lambda_logs_test.go index 61e6601eb3..195aa52f67 100644 --- a/test/integration/lambda_logs_test.go +++ b/test/integration/lambda_logs_test.go @@ -18,6 +18,7 @@ import ( "github.com/blackbirdworks/gopherstack/pkgs/logger" "github.com/blackbirdworks/gopherstack/pkgs/portalloc/portalloctest" "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" cwlogspkg "github.com/blackbirdworks/gopherstack/services/cloudwatchlogs" lambdapkg "github.com/blackbirdworks/gopherstack/services/lambda" ) @@ -94,7 +95,7 @@ func TestLambdaCWLogs_WiringProducesLogEntries(t *testing.T) { // Create the function. createBody := `{"FunctionName":"log-test-fn","PackageType":"Image",` + `"Code":{"ImageUri":"test:latest"},"Role":"arn:aws:iam::000000000000:role/r"}` - createResp, createErr := http.Post( + createResp, createErr := testclient.New(t).Post( server.URL+"/2015-03-31/functions", "application/json", strings.NewReader(createBody), @@ -163,7 +164,7 @@ func TestLambdaVersionsAndAliases_Integration(t *testing.T) { `"Code":{"ImageUri":"test:latest"},"Role":"arn:aws:iam::000000000000:role/r"}`, fnName, ) - createResp, err := http.Post( + createResp, err := testclient.New(t).Post( server.URL+"/2015-03-31/functions", "application/json", strings.NewReader(createBody), @@ -173,7 +174,7 @@ func TestLambdaVersionsAndAliases_Integration(t *testing.T) { require.Equal(t, http.StatusCreated, createResp.StatusCode) // Publish version. - verResp, err := http.Post( + verResp, err := testclient.New(t).Post( fmt.Sprintf("%s/2015-03-31/functions/%s/versions", server.URL, fnName), "application/json", strings.NewReader(`{"Description":"first release"}`), @@ -183,13 +184,13 @@ func TestLambdaVersionsAndAliases_Integration(t *testing.T) { require.Equal(t, http.StatusCreated, verResp.StatusCode) // List versions — should have $LATEST + version 1. - listVerResp, err := http.Get(fmt.Sprintf("%s/2015-03-31/functions/%s/versions", server.URL, fnName)) + listVerResp, err := testclient.New(t).Get(fmt.Sprintf("%s/2015-03-31/functions/%s/versions", server.URL, fnName)) require.NoError(t, err) defer listVerResp.Body.Close() require.Equal(t, http.StatusOK, listVerResp.StatusCode) // Create alias pointing to version 1. - aliasResp, err := http.Post( + aliasResp, err := testclient.New(t).Post( fmt.Sprintf("%s/2015-03-31/functions/%s/aliases", server.URL, fnName), "application/json", strings.NewReader(`{"Name":"prod","FunctionVersion":"1","Description":"production"}`), @@ -199,7 +200,7 @@ func TestLambdaVersionsAndAliases_Integration(t *testing.T) { require.Equal(t, http.StatusCreated, aliasResp.StatusCode) // Get alias. - getAliasResp, err := http.Get( + getAliasResp, err := testclient.New(t).Get( fmt.Sprintf("%s/2015-03-31/functions/%s/aliases/prod", server.URL, fnName), ) require.NoError(t, err) @@ -207,7 +208,7 @@ func TestLambdaVersionsAndAliases_Integration(t *testing.T) { require.Equal(t, http.StatusOK, getAliasResp.StatusCode) // List aliases. - listAliasResp, err := http.Get( + listAliasResp, err := testclient.New(t).Get( fmt.Sprintf("%s/2015-03-31/functions/%s/aliases", server.URL, fnName), ) require.NoError(t, err) @@ -224,7 +225,7 @@ func TestLambdaVersionsAndAliases_Integration(t *testing.T) { require.NoError(t, err) updateReq.Header.Set("Content-Type", "application/json") - updateAliasResp, err := http.DefaultClient.Do(updateReq) + updateAliasResp, err := testclient.New(t).Do(updateReq) require.NoError(t, err) defer updateAliasResp.Body.Close() require.Equal(t, http.StatusOK, updateAliasResp.StatusCode) @@ -238,7 +239,7 @@ func TestLambdaVersionsAndAliases_Integration(t *testing.T) { ) require.NoError(t, err) - deleteAliasResp, err := http.DefaultClient.Do(deleteReq) + deleteAliasResp, err := testclient.New(t).Do(deleteReq) require.NoError(t, err) defer deleteAliasResp.Body.Close() require.Equal(t, http.StatusNoContent, deleteAliasResp.StatusCode) diff --git a/test/integration/lambda_test.go b/test/integration/lambda_test.go index 10e535b9a1..61c5494e7e 100644 --- a/test/integration/lambda_test.go +++ b/test/integration/lambda_test.go @@ -224,7 +224,7 @@ func doLambdaRequest( req.Header.Set("Content-Type", contentType) } - return http.DefaultClient.Do(req) //nolint:wrapcheck // test helper + return noKeepAliveClient().Do(req) //nolint:wrapcheck // test helper } // buildEchoLambdaImage builds the gopherstack-lambda-echo-test image from an in-memory build diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 65cc56c76a..a21c5c7de9 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/blackbirdworks/gopherstack/pkgs/dynamoattr" + "github.com/blackbirdworks/gopherstack/pkgs/httpclient" "github.com/blackbirdworks/gopherstack/pkgs/sdktest" "github.com/blackbirdworks/gopherstack/services/dynamodb/models" "github.com/blackbirdworks/gopherstack/test/internal/buildcheck" @@ -1963,7 +1964,7 @@ func resetGopherstackState(ep string) error { return fmt.Errorf("build reset request: %w", err) } - resp, err := http.DefaultClient.Do(req) + resp, err := noKeepAliveClient().Do(req) if err != nil { return fmt.Errorf("call reset endpoint: %w", err) } @@ -2051,3 +2052,10 @@ func createFirehoseClient(t *testing.T) *firehosesdk.Client { o.BaseEndpoint = aws.String(endpoint) }) } + +func noKeepAliveClient() *http.Client { + tr := httpclient.NewTransport() + tr.DisableKeepAlives = true + + return &http.Client{Transport: tr} +} diff --git a/test/integration/opensearch_test.go b/test/integration/opensearch_test.go index e50fc1a877..0031681007 100644 --- a/test/integration/opensearch_test.go +++ b/test/integration/opensearch_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // doOpenSearchRequest performs a raw HTTP request against the OpenSearch API. @@ -34,7 +36,7 @@ func doOpenSearchRequest(t *testing.T, method, path string, body any) (int, map[ req.Header.Set("Content-Type", "application/json") } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/test/integration/pipes_test.go b/test/integration/pipes_test.go index 7cfc8bab0d..badca5b3eb 100644 --- a/test/integration/pipes_test.go +++ b/test/integration/pipes_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func pipesRequest(t *testing.T, method, path string, body any) *http.Response { @@ -29,7 +31,7 @@ func pipesRequest(t *testing.T, method, path string, body any) *http.Response { req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=test/20230101/us-east-1/pipes/aws4_request, "+ "SignedHeaders=host;x-amz-date, Signature=fakesignature") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/ram_test.go b/test/integration/ram_test.go index f9b7bc7b6c..2c311260e9 100644 --- a/test/integration/ram_test.go +++ b/test/integration/ram_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func ramRequest(t *testing.T, method, path string, body any) *http.Response { @@ -29,7 +31,7 @@ func ramRequest(t *testing.T, method, path string, body any) *http.Response { req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=test/20230101/us-east-1/ram/aws4_request, "+ "SignedHeaders=host;x-amz-date, Signature=fakesignature") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/rdsdata_test.go b/test/integration/rdsdata_test.go index add5a8cd33..6e064344df 100644 --- a/test/integration/rdsdata_test.go +++ b/test/integration/rdsdata_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func rdsdataRequest(t *testing.T, path string, body any) *http.Response { @@ -29,7 +31,7 @@ func rdsdataRequest(t *testing.T, path string, body any) *http.Response { req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=test/20230101/us-east-1/rds-data/aws4_request, "+ "SignedHeaders=host;x-amz-date, Signature=fakesignature") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/redshift_test.go b/test/integration/redshift_test.go index f06978d638..4832018cc6 100644 --- a/test/integration/redshift_test.go +++ b/test/integration/redshift_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func redshiftPost(t *testing.T, form url.Values) *http.Response { @@ -22,7 +24,7 @@ func redshiftPost(t *testing.T, form url.Values) *http.Response { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/redshiftdata_test.go b/test/integration/redshiftdata_test.go index 7578d6c6f1..8bac0ad4ab 100644 --- a/test/integration/redshiftdata_test.go +++ b/test/integration/redshiftdata_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func redshiftDataRequest(t *testing.T, target string, body any) *http.Response { @@ -30,7 +32,7 @@ func redshiftDataRequest(t *testing.T, target string, body any) *http.Response { req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=test/20230101/us-east-1/redshift-data/aws4_request, "+ "SignedHeaders=host;x-amz-date, Signature=fakesignature") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/reset_test.go b/test/integration/reset_test.go index f7c64e2885..6ffe967e94 100644 --- a/test/integration/reset_test.go +++ b/test/integration/reset_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // TestIntegration_HealthEndpoint verifies that GET /_gopherstack/health returns @@ -18,7 +20,7 @@ func TestIntegration_HealthEndpoint(t *testing.T) { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, endpoint+"/_gopherstack/health", nil) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() diff --git a/test/integration/resourcegroups_test.go b/test/integration/resourcegroups_test.go index fed819da22..6dea026c60 100644 --- a/test/integration/resourcegroups_test.go +++ b/test/integration/resourcegroups_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func resourceGroupsPost(t *testing.T, action string, body any) *http.Response { @@ -23,7 +25,7 @@ func resourceGroupsPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "ResourceGroups."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/resourcegroupstaggingapi_test.go b/test/integration/resourcegroupstaggingapi_test.go index fdf58591c1..ef2941bd90 100644 --- a/test/integration/resourcegroupstaggingapi_test.go +++ b/test/integration/resourcegroupstaggingapi_test.go @@ -12,6 +12,8 @@ import ( sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // taggingAPIPost sends a POST to the Resource Groups Tagging API with the given @@ -28,7 +30,7 @@ func taggingAPIPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "ResourceGroupsTaggingAPI_20170126."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/route53_test.go b/test/integration/route53_test.go index bae7de6a1c..5c9b765ee8 100644 --- a/test/integration/route53_test.go +++ b/test/integration/route53_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // route53Send sends a request to the Route 53 endpoint in the shared container. @@ -28,7 +30,7 @@ func route53Send(t *testing.T, method, path, body string) *http.Response { req.Header.Set("Content-Type", "application/xml") } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/route53resolver_test.go b/test/integration/route53resolver_test.go index 7899023dd9..182c4bb681 100644 --- a/test/integration/route53resolver_test.go +++ b/test/integration/route53resolver_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func route53resolverPost(t *testing.T, action string, body any) *http.Response { @@ -23,7 +25,7 @@ func route53resolverPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "Route53Resolver."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/s3_cors_test.go b/test/integration/s3_cors_test.go index 9edd8c8e94..8d2a761f15 100644 --- a/test/integration/s3_cors_test.go +++ b/test/integration/s3_cors_test.go @@ -11,6 +11,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // TestIntegration_S3_CORS verifies CORS rule evaluation on OPTIONS preflight requests. @@ -162,7 +164,7 @@ func TestIntegration_S3_CORS(t *testing.T) { req.Header.Set("Access-Control-Request-Headers", tt.reqHeaders) } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/test/integration/s3_presigned_test.go b/test/integration/s3_presigned_test.go index 9d6c12a9cf..fefaf8fe87 100644 --- a/test/integration/s3_presigned_test.go +++ b/test/integration/s3_presigned_test.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func TestIntegration_S3_PresignedURLs(t *testing.T) { @@ -55,7 +57,7 @@ func TestIntegration_S3_PresignedURLs(t *testing.T) { require.NotEmpty(t, presigned.URL) // Fetch via the presigned URL. - resp, err := http.Get(presigned.URL) + resp, err := testclient.New(t).Get(presigned.URL) require.NoError(t, err) defer resp.Body.Close() @@ -150,7 +152,7 @@ func TestIntegration_S3_PresignedURLs(t *testing.T) { var resp *http.Response require.Eventually(t, func() bool { var getErr error - resp, getErr = http.Get(presigned.URL) + resp, getErr = testclient.New(t).Get(presigned.URL) if getErr != nil { return false diff --git a/test/integration/s3control_test.go b/test/integration/s3control_test.go index 0bee0cef91..962fcd2ee5 100644 --- a/test/integration/s3control_test.go +++ b/test/integration/s3control_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) const s3controlPublicAccessBlockPath = "/v20180820/configuration/publicAccessBlock" @@ -31,7 +33,7 @@ func s3controlRequest(t *testing.T, method, accountID, body string) *http.Respon req.Header.Set("Content-Type", "application/xml") } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/scheduler_test.go b/test/integration/scheduler_test.go index 5742755c77..1a1c5a58d0 100644 --- a/test/integration/scheduler_test.go +++ b/test/integration/scheduler_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func schedulerPost(t *testing.T, action string, body any) *http.Response { @@ -23,7 +25,7 @@ func schedulerPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "AWSScheduler."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/secretsmanager_rotation_test.go b/test/integration/secretsmanager_rotation_test.go index cde19ea336..2c694c4496 100644 --- a/test/integration/secretsmanager_rotation_test.go +++ b/test/integration/secretsmanager_rotation_test.go @@ -90,7 +90,7 @@ func putSecretValue(smEndpoint, secretID, token, value string) error { req.Header.Set("X-Amz-Target", "secretsmanager.PutSecretValue") req.Header.Set("Content-Type", "application/x-amz-json-1.1") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) if err != nil { return err } diff --git a/test/integration/servicediscovery_test.go b/test/integration/servicediscovery_test.go index d27a725b20..129815659c 100644 --- a/test/integration/servicediscovery_test.go +++ b/test/integration/servicediscovery_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func servicediscoveryRequest(t *testing.T, op string, body any) *http.Response { @@ -28,7 +30,7 @@ func servicediscoveryRequest(t *testing.T, op string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "Route53AutoNaming_v20170314."+op) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/ses_test.go b/test/integration/ses_test.go index 5ae483346c..7ad58433b1 100644 --- a/test/integration/ses_test.go +++ b/test/integration/ses_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // sesPost sends a form-encoded POST request to the SES endpoint. @@ -25,7 +27,7 @@ func sesPost(t *testing.T, form url.Values) *http.Response { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/sesv2_test.go b/test/integration/sesv2_test.go index 1a9b2b1751..8cb337a866 100644 --- a/test/integration/sesv2_test.go +++ b/test/integration/sesv2_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) // sesv2Do performs a JSON request to the SES v2 REST endpoint. @@ -32,7 +34,7 @@ func sesv2Do(t *testing.T, method, path string, body any) *http.Response { req.Header.Set("Content-Type", "application/json") } - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/swf_test.go b/test/integration/swf_test.go index 5e49346bfb..9301f160ea 100644 --- a/test/integration/swf_test.go +++ b/test/integration/swf_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func swfPost(t *testing.T, action string, body any) *http.Response { @@ -23,7 +25,7 @@ func swfPost(t *testing.T, action string, body any) *http.Response { req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "SimpleWorkflowService."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/integration/tag_routing_test.go b/test/integration/tag_routing_test.go index b2cd2e34fb..b1fea374f1 100644 --- a/test/integration/tag_routing_test.go +++ b/test/integration/tag_routing_test.go @@ -43,6 +43,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/testclient" appconfigsvc "github.com/blackbirdworks/gopherstack/services/appconfig" appconfigdatasvc "github.com/blackbirdworks/gopherstack/services/appconfigdata" inspector2svc "github.com/blackbirdworks/gopherstack/services/inspector2" @@ -1129,7 +1130,7 @@ func registerIoTDataPlaneConnection(ctx context.Context, t *testing.T, clientID ) require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err, "registering iotdataplane connection should succeed") defer resp.Body.Close() require.Equal(t, http.StatusCreated, resp.StatusCode) diff --git a/test/integration/timestream_query_parity_test.go b/test/integration/timestream_query_parity_test.go new file mode 100644 index 0000000000..585fd0c7de --- /dev/null +++ b/test/integration/timestream_query_parity_test.go @@ -0,0 +1,69 @@ +package integration_test + +import ( + "strconv" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + tqsdk "github.com/aws/aws-sdk-go-v2/service/timestreamquery" + twsdk "github.com/aws/aws-sdk-go-v2/service/timestreamwrite" + twtypes "github.com/aws/aws-sdk-go-v2/service/timestreamwrite/types" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_TimestreamQuery_ReadsWrittenRecords writes records through the +// Timestream Write SDK and reads them back through SQL on the Query SDK. +func TestIntegration_TimestreamQuery_ReadsWrittenRecords(t *testing.T) { + t.Parallel() + dumpContainerLogsOnFailure(t) + + write := createTimestreamWriteClient(t) + query := createTimestreamQueryClient(t) + ctx := t.Context() + + db := "tsq-" + uuid.NewString()[:8] + _, err := write.CreateDatabase(ctx, &twsdk.CreateDatabaseInput{DatabaseName: aws.String(db)}) + require.NoError(t, err) + _, err = write.CreateTable(ctx, &twsdk.CreateTableInput{DatabaseName: aws.String(db), TableName: aws.String("cpu")}) + require.NoError(t, err) + + now := time.Now() + rec := func(host, val string, ago time.Duration) twtypes.Record { + return twtypes.Record{ + MeasureName: aws.String( + "cpu", + ), + MeasureValue: aws.String(val), + MeasureValueType: twtypes.MeasureValueTypeDouble, + Time: aws.String(strconv.FormatInt(now.Add(-ago).UnixMilli(), 10)), + TimeUnit: twtypes.TimeUnitMilliseconds, + Dimensions: []twtypes.Dimension{{Name: aws.String("host"), Value: aws.String(host)}}, + } + } + + _, err = write.WriteRecords(ctx, &twsdk.WriteRecordsInput{ + DatabaseName: aws.String(db), + TableName: aws.String("cpu"), + Records: []twtypes.Record{ + rec("a", "10", 3*time.Minute), + rec("a", "30", 2*time.Minute), + rec("b", "5", time.Minute), + }, + }) + require.NoError(t, err) + + out, err := query.Query(ctx, &tqsdk.QueryInput{QueryString: aws.String( + `SELECT host, avg(measure_value::double) AS avg_cpu, count(*) AS n FROM "` + db + `"."cpu" ` + + `WHERE time > ago(1h) GROUP BY host ORDER BY host`)}) + require.NoError(t, err) + require.Len(t, out.Rows, 2) + + assert.Equal(t, "a", aws.ToString(out.Rows[0].Data[0].ScalarValue)) + assert.Equal(t, "20.0", aws.ToString(out.Rows[0].Data[1].ScalarValue)) + assert.Equal(t, "2", aws.ToString(out.Rows[0].Data[2].ScalarValue)) + assert.Equal(t, "DOUBLE", string(out.ColumnInfo[1].Type.ScalarType)) + assert.Equal(t, "BIGINT", string(out.ColumnInfo[2].Type.ScalarType)) +} diff --git a/test/integration/verifiedpermissions_test.go b/test/integration/verifiedpermissions_test.go index 4ee1aa2bcd..26f7cabf2d 100644 --- a/test/integration/verifiedpermissions_test.go +++ b/test/integration/verifiedpermissions_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/testclient" ) func verifiedPermissionsPost(t *testing.T, action string, body any) *http.Response { @@ -28,7 +30,7 @@ func verifiedPermissionsPost(t *testing.T, action string, body any) *http.Respon req.Header.Set("Content-Type", "application/x-amz-json-1.0") req.Header.Set("X-Amz-Target", "VerifiedPermissions."+action) - resp, err := http.DefaultClient.Do(req) + resp, err := testclient.New(t).Do(req) require.NoError(t, err) return resp diff --git a/test/terraform/azure/authorization_test.go b/test/terraform/azure/authorization_test.go new file mode 100644 index 0000000000..633a41ff49 --- /dev/null +++ b/test/terraform/azure/authorization_test.go @@ -0,0 +1,79 @@ +package azure_test + +import "testing" + +const azureAuthorizationFixture = ` +resource "azurerm_resource_group" "auth" { + name = "gopherstack-auth-test-rg" + location = "local" +} + +resource "azurerm_storage_account" "auth" { + name = "gopherstackauthtest" + resource_group_name = azurerm_resource_group.auth.name + location = azurerm_resource_group.auth.location + account_tier = "Standard" + account_replication_type = "LRS" +} + +resource "azurerm_role_assignment" "reader" { + scope = azurerm_resource_group.auth.id + role_definition_name = "Reader" + principal_id = "11111111-1111-1111-1111-111111111111" + principal_type = "ServicePrincipal" + skip_service_principal_aad_check = true +} + +resource "azurerm_role_assignment" "blob" { + scope = azurerm_storage_account.auth.id + role_definition_id = format( + "%s/providers/Microsoft.Authorization/roleDefinitions/%s", + "/subscriptions/00000000-0000-0000-0000-000000000000", + "ba92f5b4-2d11-453d-a403-e96b0029c9fe", + ) + principal_id = "11111111-1111-1111-1111-111111111111" + principal_type = "ServicePrincipal" + skip_service_principal_aad_check = true +} + +resource "azurerm_management_lock" "account" { + name = "keep-account" + scope = azurerm_storage_account.auth.id + lock_level = "CanNotDelete" + notes = "managed by terraform" +} + +resource "azurerm_policy_definition" "audit" { + name = "gopherstack-audit-storage" + policy_type = "Custom" + mode = "All" + display_name = "Audit storage accounts" + + policy_rule = < str: ) def get_oci_endpoint(self, service: str) -> str: - """http URL of an OCI service (objectstorage, vault, streaming, identity, functions, core), usable as the OCI SDK client host.""" + """http URL of an OCI service (objectstorage, vault, streaming, identity, functions, core, queue, notifications), usable as the OCI SDK client host.""" try: port = OCI_PORTS[service.lower()] except KeyError: diff --git a/testdata/routing/corpus.tsv b/testdata/routing/corpus.tsv index 3d08b07860..14576792cc 100644 --- a/testdata/routing/corpus.tsv +++ b/testdata/routing/corpus.tsv @@ -18058,3 +18058,3846 @@ PUT localhost:4566 /views/xinstan connect User-Agent:aws-sdk-go-v2/1.41.0 ua/ PUT localhost:4566 /views/xinstan/xviewid/versions connect User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/connect#1.0.0 Connect Connect PUT localhost:4566 /workspaces/xinstan connect User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/connect#1.0.0 Connect Connect PUT localhost:4566 /workspaces/xinstan/xworksp/pages connect User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/connect#1.0.0 Connect Connect +GET localhost:4566 /2013-04-01/key.txt s3 Route53 Route53 +PUT localhost:4566 /2013-04-01/key.txt s3 application/octet-stream body Route53 Route53 +GET localhost:4566 /2013-04-01/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Route53 Route53 +HEAD localhost:4566 /2013-04-01 s3 S3 S3 +GET localhost:4566 /2015-01-01/key.txt s3 S3 S3 +PUT localhost:4566 /2015-01-01/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /2015-01-01/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /2015-01-01 s3 S3 S3 +GET localhost:4566 /2015-02-01/key.txt s3 S3 S3 +PUT localhost:4566 /2015-02-01/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /2015-02-01/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /2015-02-01 s3 S3 S3 +GET localhost:4566 /2015-03-31/key.txt s3 S3 S3 +PUT localhost:4566 /2015-03-31/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /2015-03-31/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /2015-03-31 s3 S3 S3 +GET localhost:4566 /2017-08-29/key.txt s3 MediaConvert MediaConvert +PUT localhost:4566 /2017-08-29/key.txt s3 application/octet-stream body MediaConvert MediaConvert +GET localhost:4566 /2017-08-29/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaConvert MediaConvert +HEAD localhost:4566 /2017-08-29 s3 S3 S3 +GET localhost:4566 /2018-10-31/key.txt s3 S3 S3 +PUT localhost:4566 /2018-10-31/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /2018-10-31/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /2018-10-31 s3 S3 S3 +GET localhost:4566 /2020-04-22/key.txt s3 S3 S3 +PUT localhost:4566 /2020-04-22/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /2020-04-22/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /2020-04-22 s3 S3 S3 +GET localhost:4566 /2020-05-31/key.txt s3 CloudFront CloudFront +PUT localhost:4566 /2020-05-31/key.txt s3 application/octet-stream body CloudFront CloudFront +GET localhost:4566 /2020-05-31/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 CloudFront CloudFront +HEAD localhost:4566 /2020-05-31 s3 S3 S3 +GET localhost:4566 /@connections/key.txt s3 APIGatewayManagementAPI APIGatewayManagementAPI +PUT localhost:4566 /@connections/key.txt s3 application/octet-stream body APIGatewayManagementAPI APIGatewayManagementAPI +GET localhost:4566 /@connections/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGatewayManagementAPI APIGatewayManagementAPI +HEAD localhost:4566 /@connections s3 S3 S3 +GET localhost:4566 /AssociateDefaultView/key.txt s3 S3 S3 +PUT localhost:4566 /AssociateDefaultView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /AssociateDefaultView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /AssociateDefaultView s3 S3 S3 +GET localhost:4566 /BatchExecute/key.txt s3 S3 S3 +PUT localhost:4566 /BatchExecute/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /BatchExecute/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /BatchExecute s3 S3 S3 +GET localhost:4566 /BatchGetView/key.txt s3 S3 S3 +PUT localhost:4566 /BatchGetView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /BatchGetView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /BatchGetView s3 S3 S3 +GET localhost:4566 /BeginTransaction/key.txt s3 S3 S3 +PUT localhost:4566 /BeginTransaction/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /BeginTransaction/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /BeginTransaction s3 S3 S3 +GET localhost:4566 /CancelTraceRetrieval/key.txt s3 S3 S3 +PUT localhost:4566 /CancelTraceRetrieval/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CancelTraceRetrieval/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CancelTraceRetrieval s3 S3 S3 +GET localhost:4566 /CommitTransaction/key.txt s3 S3 S3 +PUT localhost:4566 /CommitTransaction/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CommitTransaction/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CommitTransaction s3 S3 S3 +GET localhost:4566 /CreateGroup/key.txt s3 S3 S3 +PUT localhost:4566 /CreateGroup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CreateGroup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CreateGroup s3 S3 S3 +GET localhost:4566 /CreateIndex/key.txt s3 S3 S3 +PUT localhost:4566 /CreateIndex/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CreateIndex/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CreateIndex s3 S3 S3 +GET localhost:4566 /CreateResourceExplorerSetup/key.txt s3 S3 S3 +PUT localhost:4566 /CreateResourceExplorerSetup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CreateResourceExplorerSetup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CreateResourceExplorerSetup s3 S3 S3 +GET localhost:4566 /CreateSamplingRule/key.txt s3 S3 S3 +PUT localhost:4566 /CreateSamplingRule/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CreateSamplingRule/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CreateSamplingRule s3 S3 S3 +GET localhost:4566 /CreateVectorBucket/key.txt s3 S3 S3 +PUT localhost:4566 /CreateVectorBucket/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CreateVectorBucket/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CreateVectorBucket s3 S3 S3 +GET localhost:4566 /CreateView/key.txt s3 S3 S3 +PUT localhost:4566 /CreateView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /CreateView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /CreateView s3 S3 S3 +GET localhost:4566 /DeleteGroup/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteGroup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteGroup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteGroup s3 S3 S3 +GET localhost:4566 /DeleteIndex/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteIndex/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteIndex/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteIndex s3 S3 S3 +GET localhost:4566 /DeleteResourceExplorerSetup/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteResourceExplorerSetup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteResourceExplorerSetup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteResourceExplorerSetup s3 S3 S3 +GET localhost:4566 /DeleteResourcePolicy/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteResourcePolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteResourcePolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteResourcePolicy s3 S3 S3 +GET localhost:4566 /DeleteSamplingRule/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteSamplingRule/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteSamplingRule/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteSamplingRule s3 S3 S3 +GET localhost:4566 /DeleteVectorBucket/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteVectorBucket/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteVectorBucket/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteVectorBucket s3 S3 S3 +GET localhost:4566 /DeleteVectorBucketPolicy/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteVectorBucketPolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteVectorBucketPolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteVectorBucketPolicy s3 S3 S3 +GET localhost:4566 /DeleteVectors/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteVectors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteVectors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteVectors s3 S3 S3 +GET localhost:4566 /DeleteView/key.txt s3 S3 S3 +PUT localhost:4566 /DeleteView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DeleteView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DeleteView s3 S3 S3 +GET localhost:4566 /DisassociateDefaultView/key.txt s3 S3 S3 +PUT localhost:4566 /DisassociateDefaultView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /DisassociateDefaultView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /DisassociateDefaultView s3 S3 S3 +GET localhost:4566 /EncryptionConfig/key.txt s3 S3 S3 +PUT localhost:4566 /EncryptionConfig/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /EncryptionConfig/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /EncryptionConfig s3 S3 S3 +GET localhost:4566 /Execute/key.txt s3 S3 S3 +PUT localhost:4566 /Execute/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Execute/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Execute s3 S3 S3 +GET localhost:4566 /GetAccountLevelServiceConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /GetAccountLevelServiceConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetAccountLevelServiceConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetAccountLevelServiceConfiguration s3 S3 S3 +GET localhost:4566 /GetDefaultView/key.txt s3 S3 S3 +PUT localhost:4566 /GetDefaultView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetDefaultView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetDefaultView s3 S3 S3 +GET localhost:4566 /GetGroup/key.txt s3 S3 S3 +PUT localhost:4566 /GetGroup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetGroup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetGroup s3 S3 S3 +GET localhost:4566 /GetIndex/key.txt s3 S3 S3 +PUT localhost:4566 /GetIndex/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetIndex/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetIndex s3 S3 S3 +GET localhost:4566 /GetIndexingRules/key.txt s3 S3 S3 +PUT localhost:4566 /GetIndexingRules/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetIndexingRules/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetIndexingRules s3 S3 S3 +GET localhost:4566 /GetManagedView/key.txt s3 S3 S3 +PUT localhost:4566 /GetManagedView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetManagedView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetManagedView s3 S3 S3 +GET localhost:4566 /GetResourceExplorerSetup/key.txt s3 S3 S3 +PUT localhost:4566 /GetResourceExplorerSetup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetResourceExplorerSetup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetResourceExplorerSetup s3 S3 S3 +GET localhost:4566 /GetRetrievedTracesGraph/key.txt s3 S3 S3 +PUT localhost:4566 /GetRetrievedTracesGraph/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetRetrievedTracesGraph/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetRetrievedTracesGraph s3 S3 S3 +GET localhost:4566 /GetSamplingRules/key.txt s3 S3 S3 +PUT localhost:4566 /GetSamplingRules/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetSamplingRules/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetSamplingRules s3 S3 S3 +GET localhost:4566 /GetServiceIndex/key.txt s3 S3 S3 +PUT localhost:4566 /GetServiceIndex/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetServiceIndex/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetServiceIndex s3 S3 S3 +GET localhost:4566 /GetServiceView/key.txt s3 S3 S3 +PUT localhost:4566 /GetServiceView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetServiceView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetServiceView s3 S3 S3 +GET localhost:4566 /GetTraceSegmentDestination/key.txt s3 S3 S3 +PUT localhost:4566 /GetTraceSegmentDestination/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetTraceSegmentDestination/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetTraceSegmentDestination s3 S3 S3 +GET localhost:4566 /GetVectorBucket/key.txt s3 S3 S3 +PUT localhost:4566 /GetVectorBucket/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetVectorBucket/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetVectorBucket s3 S3 S3 +GET localhost:4566 /GetVectorBucketPolicy/key.txt s3 S3 S3 +PUT localhost:4566 /GetVectorBucketPolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetVectorBucketPolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetVectorBucketPolicy s3 S3 S3 +GET localhost:4566 /GetVectors/key.txt s3 S3 S3 +PUT localhost:4566 /GetVectors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetVectors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetVectors s3 S3 S3 +GET localhost:4566 /GetView/key.txt s3 S3 S3 +PUT localhost:4566 /GetView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /GetView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /GetView s3 S3 S3 +GET localhost:4566 /Groups/key.txt s3 S3 S3 +PUT localhost:4566 /Groups/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Groups/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Groups s3 S3 S3 +GET localhost:4566 /Insight/key.txt s3 S3 S3 +PUT localhost:4566 /Insight/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Insight/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Insight s3 S3 S3 +GET localhost:4566 /ListIndexes/key.txt s3 S3 S3 +PUT localhost:4566 /ListIndexes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListIndexes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListIndexes s3 S3 S3 +GET localhost:4566 /ListIndexesForMembers/key.txt s3 S3 S3 +PUT localhost:4566 /ListIndexesForMembers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListIndexesForMembers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListIndexesForMembers s3 S3 S3 +GET localhost:4566 /ListManagedViews/key.txt s3 S3 S3 +PUT localhost:4566 /ListManagedViews/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListManagedViews/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListManagedViews s3 S3 S3 +GET localhost:4566 /ListResourcePolicies/key.txt s3 S3 S3 +PUT localhost:4566 /ListResourcePolicies/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListResourcePolicies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListResourcePolicies s3 S3 S3 +GET localhost:4566 /ListResources/key.txt s3 S3 S3 +PUT localhost:4566 /ListResources/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListResources/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListResources s3 S3 S3 +GET localhost:4566 /ListRetrievedTraces/key.txt s3 S3 S3 +PUT localhost:4566 /ListRetrievedTraces/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListRetrievedTraces/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListRetrievedTraces s3 S3 S3 +GET localhost:4566 /ListServiceIndexes/key.txt s3 S3 S3 +PUT localhost:4566 /ListServiceIndexes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListServiceIndexes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListServiceIndexes s3 S3 S3 +GET localhost:4566 /ListServiceViews/key.txt s3 S3 S3 +PUT localhost:4566 /ListServiceViews/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListServiceViews/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListServiceViews s3 S3 S3 +GET localhost:4566 /ListStreamingAccessForServices/key.txt s3 S3 S3 +PUT localhost:4566 /ListStreamingAccessForServices/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListStreamingAccessForServices/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListStreamingAccessForServices s3 S3 S3 +GET localhost:4566 /ListSupportedResourceTypes/key.txt s3 S3 S3 +PUT localhost:4566 /ListSupportedResourceTypes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListSupportedResourceTypes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListSupportedResourceTypes s3 S3 S3 +GET localhost:4566 /ListTagsForResource/key.txt s3 S3 S3 +PUT localhost:4566 /ListTagsForResource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListTagsForResource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListTagsForResource s3 S3 S3 +GET localhost:4566 /ListVectorBuckets/key.txt s3 S3 S3 +PUT localhost:4566 /ListVectorBuckets/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListVectorBuckets/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListVectorBuckets s3 S3 S3 +GET localhost:4566 /ListVectors/key.txt s3 S3 S3 +PUT localhost:4566 /ListVectors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListVectors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListVectors s3 S3 S3 +GET localhost:4566 /ListViews/key.txt s3 S3 S3 +PUT localhost:4566 /ListViews/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ListViews/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ListViews s3 S3 S3 +GET localhost:4566 /PutEncryptionConfig/key.txt s3 S3 S3 +PUT localhost:4566 /PutEncryptionConfig/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /PutEncryptionConfig/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /PutEncryptionConfig s3 S3 S3 +GET localhost:4566 /PutResourcePolicy/key.txt s3 S3 S3 +PUT localhost:4566 /PutResourcePolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /PutResourcePolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /PutResourcePolicy s3 S3 S3 +GET localhost:4566 /PutVectorBucketDefaultIndexMode/key.txt s3 S3 S3 +PUT localhost:4566 /PutVectorBucketDefaultIndexMode/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /PutVectorBucketDefaultIndexMode/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /PutVectorBucketDefaultIndexMode s3 S3 S3 +GET localhost:4566 /PutVectorBucketPolicy/key.txt s3 S3 S3 +PUT localhost:4566 /PutVectorBucketPolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /PutVectorBucketPolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /PutVectorBucketPolicy s3 S3 S3 +GET localhost:4566 /PutVectors/key.txt s3 S3 S3 +PUT localhost:4566 /PutVectors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /PutVectors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /PutVectors s3 S3 S3 +GET localhost:4566 /QueryVectors/key.txt s3 S3 S3 +PUT localhost:4566 /QueryVectors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /QueryVectors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /QueryVectors s3 S3 S3 +GET localhost:4566 /RollbackTransaction/key.txt s3 S3 S3 +PUT localhost:4566 /RollbackTransaction/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /RollbackTransaction/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /RollbackTransaction s3 S3 S3 +GET localhost:4566 /SamplingStatisticSummaries/key.txt s3 S3 S3 +PUT localhost:4566 /SamplingStatisticSummaries/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /SamplingStatisticSummaries/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /SamplingStatisticSummaries s3 S3 S3 +GET localhost:4566 /SamplingTargets/key.txt s3 S3 S3 +PUT localhost:4566 /SamplingTargets/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /SamplingTargets/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /SamplingTargets s3 S3 S3 +GET localhost:4566 /Search/key.txt s3 S3 S3 +PUT localhost:4566 /Search/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Search/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Search s3 S3 S3 +GET localhost:4566 /ServiceGraph/key.txt s3 S3 S3 +PUT localhost:4566 /ServiceGraph/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ServiceGraph/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /ServiceGraph s3 S3 S3 +GET localhost:4566 /StartTraceRetrieval/key.txt s3 S3 S3 +PUT localhost:4566 /StartTraceRetrieval/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /StartTraceRetrieval/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /StartTraceRetrieval s3 S3 S3 +GET localhost:4566 /TagResource/key.txt s3 S3 S3 +PUT localhost:4566 /TagResource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /TagResource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /TagResource s3 S3 S3 +GET localhost:4566 /TelemetryRecords/key.txt s3 S3 S3 +PUT localhost:4566 /TelemetryRecords/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /TelemetryRecords/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /TelemetryRecords s3 S3 S3 +GET localhost:4566 /TimeSeriesServiceStatistics/key.txt s3 S3 S3 +PUT localhost:4566 /TimeSeriesServiceStatistics/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /TimeSeriesServiceStatistics/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /TimeSeriesServiceStatistics s3 S3 S3 +GET localhost:4566 /TraceGraph/key.txt s3 S3 S3 +PUT localhost:4566 /TraceGraph/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /TraceGraph/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /TraceGraph s3 S3 S3 +GET localhost:4566 /TraceSegments/key.txt s3 S3 S3 +PUT localhost:4566 /TraceSegments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /TraceSegments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /TraceSegments s3 S3 S3 +GET localhost:4566 /TraceSummaries/key.txt s3 S3 S3 +PUT localhost:4566 /TraceSummaries/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /TraceSummaries/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /TraceSummaries s3 S3 S3 +GET localhost:4566 /Traces/key.txt s3 S3 S3 +PUT localhost:4566 /Traces/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Traces/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Traces s3 S3 S3 +GET localhost:4566 /UntagResource/key.txt s3 S3 S3 +PUT localhost:4566 /UntagResource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UntagResource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UntagResource s3 S3 S3 +GET localhost:4566 /UpdateGroup/key.txt s3 S3 S3 +PUT localhost:4566 /UpdateGroup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UpdateGroup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UpdateGroup s3 S3 S3 +GET localhost:4566 /UpdateIndexMode/key.txt s3 S3 S3 +PUT localhost:4566 /UpdateIndexMode/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UpdateIndexMode/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UpdateIndexMode s3 S3 S3 +GET localhost:4566 /UpdateIndexType/key.txt s3 S3 S3 +PUT localhost:4566 /UpdateIndexType/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UpdateIndexType/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UpdateIndexType s3 S3 S3 +GET localhost:4566 /UpdateIndexingRule/key.txt s3 S3 S3 +PUT localhost:4566 /UpdateIndexingRule/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UpdateIndexingRule/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UpdateIndexingRule s3 S3 S3 +GET localhost:4566 /UpdateSamplingRule/key.txt s3 S3 S3 +PUT localhost:4566 /UpdateSamplingRule/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UpdateSamplingRule/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UpdateSamplingRule s3 S3 S3 +GET localhost:4566 /UpdateTraceSegmentDestination/key.txt s3 S3 S3 +PUT localhost:4566 /UpdateTraceSegmentDestination/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UpdateTraceSegmentDestination/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UpdateTraceSegmentDestination s3 S3 S3 +GET localhost:4566 /UpdateView/key.txt s3 S3 S3 +PUT localhost:4566 /UpdateView/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /UpdateView/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /UpdateView s3 S3 S3 +GET localhost:4566 /_admin/key.txt s3 S3 S3 +PUT localhost:4566 /_admin/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /_admin/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /_admin s3 S3 S3 +GET localhost:4566 /_gopherstack/key.txt s3 - - +PUT localhost:4566 /_gopherstack/key.txt s3 application/octet-stream body - - +GET localhost:4566 /_gopherstack/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 - - +HEAD localhost:4566 /_gopherstack s3 S3 S3 +GET localhost:4566 /acceptPrimaryEmailUpdate/key.txt s3 S3 S3 +PUT localhost:4566 /acceptPrimaryEmailUpdate/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /acceptPrimaryEmailUpdate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /acceptPrimaryEmailUpdate s3 S3 S3 +GET localhost:4566 /acceptresourceshareinvitation/key.txt s3 S3 S3 +PUT localhost:4566 /acceptresourceshareinvitation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /acceptresourceshareinvitation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /acceptresourceshareinvitation s3 S3 S3 +GET localhost:4566 /access-policies/key.txt s3 S3 S3 +PUT localhost:4566 /access-policies/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /access-policies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /access-policies s3 EKS EKS +GET localhost:4566 /access-preview/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /access-preview/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /access-preview/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /access-preview s3 AccessAnalyzer AccessAnalyzer +GET localhost:4566 /accesslogsubscriptions/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /accesslogsubscriptions/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /accesslogsubscriptions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /accesslogsubscriptions s3 VPCLattice VPCLattice +GET localhost:4566 /accessors/key.txt s3 S3 S3 +PUT localhost:4566 /accessors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /accessors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /accessors s3 S3 S3 +GET localhost:4566 /account/key.txt s3 S3 S3 +PUT localhost:4566 /account/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /account/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /account s3 APIGateway APIGateway +GET localhost:4566 /account-settings/key.txt s3 S3 S3 +PUT localhost:4566 /account-settings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /account-settings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /account-settings s3 S3 S3 +GET localhost:4566 /accountpermissions/key.txt s3 S3 S3 +PUT localhost:4566 /accountpermissions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /accountpermissions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /accountpermissions s3 S3 S3 +GET localhost:4566 /accounts/key.txt s3 S3 S3 +PUT localhost:4566 /accounts/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /accounts/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /accounts s3 S3 S3 +GET localhost:4566 /actionTargets/key.txt s3 S3 S3 +PUT localhost:4566 /actionTargets/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /actionTargets/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /actionTargets s3 S3 S3 +GET localhost:4566 /actions/key.txt s3 FIS FIS +PUT localhost:4566 /actions/key.txt s3 application/octet-stream body FIS FIS +GET localhost:4566 /actions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 FIS FIS +HEAD localhost:4566 /actions s3 FIS FIS +GET localhost:4566 /addons/key.txt s3 S3 S3 +PUT localhost:4566 /addons/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /addons/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /addons s3 S3 S3 +GET localhost:4566 /admin/key.txt s3 S3 S3 +PUT localhost:4566 /admin/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /admin/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /admin s3 S3 S3 +GET localhost:4566 /administrator/key.txt s3 S3 S3 +PUT localhost:4566 /administrator/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /administrator/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /administrator s3 S3 S3 +GET localhost:4566 /advanced-prompt-optimization-job/key.txt s3 S3 S3 +PUT localhost:4566 /advanced-prompt-optimization-job/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /advanced-prompt-optimization-job/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /advanced-prompt-optimization-job s3 S3 S3 +GET localhost:4566 /agent-status/key.txt s3 S3 S3 +PUT localhost:4566 /agent-status/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /agent-status/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /agent-status s3 S3 S3 +GET localhost:4566 /agents/key.txt s3 S3 S3 +PUT localhost:4566 /agents/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /agents/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockAgent BedrockAgent +HEAD localhost:4566 /agents s3 S3 S3 +GET localhost:4566 /aggregatorv2/key.txt s3 S3 S3 +PUT localhost:4566 /aggregatorv2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /aggregatorv2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /aggregatorv2 s3 S3 S3 +GET localhost:4566 /alerts/key.txt s3 S3 S3 +PUT localhost:4566 /alerts/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /alerts/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /alerts s3 S3 S3 +GET localhost:4566 /allow-lists/key.txt s3 S3 S3 +PUT localhost:4566 /allow-lists/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /allow-lists/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /allow-lists s3 S3 S3 +GET localhost:4566 /analytics-data/key.txt s3 S3 S3 +PUT localhost:4566 /analytics-data/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /analytics-data/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /analytics-data s3 S3 S3 +GET localhost:4566 /analyzed-resource/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /analyzed-resource/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /analyzed-resource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /analyzed-resource s3 AccessAnalyzer AccessAnalyzer +GET localhost:4566 /analyzer/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /analyzer/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /analyzer/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /analyzer s3 AccessAnalyzer AccessAnalyzer +GET localhost:4566 /annotationStore/key.txt s3 Omics Omics +PUT localhost:4566 /annotationStore/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /annotationStore/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /annotationStore s3 Omics Omics +GET localhost:4566 /api/key.txt s3 - - +PUT localhost:4566 /api/key.txt s3 application/octet-stream body - - +GET localhost:4566 /api/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 - - +HEAD localhost:4566 /api s3 S3 S3 +GET localhost:4566 /apikeys/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /apikeys/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /apikeys/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /apikeys s3 APIGateway APIGateway +GET localhost:4566 /applications/key.txt s3 S3 S3 +PUT localhost:4566 /applications/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /applications/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AppConfig AppConfig +HEAD localhost:4566 /applications s3 S3 S3 +GET localhost:4566 /appmonitor/key.txt s3 S3 S3 +PUT localhost:4566 /appmonitor/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /appmonitor/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /appmonitor s3 S3 S3 +GET localhost:4566 /appmonitors/key.txt s3 S3 S3 +PUT localhost:4566 /appmonitors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /appmonitors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /appmonitors s3 S3 S3 +GET localhost:4566 /apps/key.txt s3 Amplify Amplify +PUT localhost:4566 /apps/key.txt s3 application/octet-stream body Amplify Amplify +GET localhost:4566 /apps/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Amplify Amplify +HEAD localhost:4566 /apps s3 Amplify Amplify +GET localhost:4566 /appsync-api/key.txt s3 Amplify Amplify +PUT localhost:4566 /appsync-api/key.txt s3 application/octet-stream body Amplify Amplify +GET localhost:4566 /appsync-api/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Amplify Amplify +HEAD localhost:4566 /appsync-api s3 Amplify Amplify +GET localhost:4566 /archive-rule/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /archive-rule/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /archive-rule/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /archive-rule s3 AccessAnalyzer AccessAnalyzer +GET localhost:4566 /artifacts/key.txt s3 Amplify Amplify +PUT localhost:4566 /artifacts/key.txt s3 application/octet-stream body Amplify Amplify +GET localhost:4566 /artifacts/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Amplify Amplify +HEAD localhost:4566 /artifacts s3 S3 S3 +GET localhost:4566 /assignment/key.txt s3 S3 S3 +PUT localhost:4566 /assignment/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /assignment/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /assignment s3 S3 S3 +GET localhost:4566 /associate-security-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /associate-security-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /associate-security-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /associate-security-profiles s3 S3 S3 +GET localhost:4566 /associateresourceshare/key.txt s3 S3 S3 +PUT localhost:4566 /associateresourceshare/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /associateresourceshare/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /associateresourceshare s3 S3 S3 +GET localhost:4566 /associations/key.txt s3 S3 S3 +PUT localhost:4566 /associations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /associations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /associations s3 S3 S3 +GET localhost:4566 /async-invoke/key.txt s3 BedrockRuntime BedrockRuntime +PUT localhost:4566 /async-invoke/key.txt s3 application/octet-stream body BedrockRuntime BedrockRuntime +GET localhost:4566 /async-invoke/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockRuntime BedrockRuntime +HEAD localhost:4566 /async-invoke s3 BedrockRuntime BedrockRuntime +GET localhost:4566 /attached-files/key.txt s3 S3 S3 +PUT localhost:4566 /attached-files/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /attached-files/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /attached-files s3 S3 S3 +GET localhost:4566 /attachments/key.txt s3 S3 S3 +PUT localhost:4566 /attachments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /attachments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /attachments s3 S3 S3 +GET localhost:4566 /audit/key.txt s3 S3 S3 +PUT localhost:4566 /audit/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /audit/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /audit s3 S3 S3 +GET localhost:4566 /auth/key.txt s3 S3 S3 +PUT localhost:4566 /auth/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /auth/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /auth s3 S3 S3 +GET localhost:4566 /authpolicy/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /authpolicy/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /authpolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /authpolicy s3 S3 S3 +GET localhost:4566 /automated-discovery/key.txt s3 S3 S3 +PUT localhost:4566 /automated-discovery/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /automated-discovery/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /automated-discovery s3 S3 S3 +GET localhost:4566 /automated-reasoning-policies/key.txt s3 S3 S3 +PUT localhost:4566 /automated-reasoning-policies/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /automated-reasoning-policies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /automated-reasoning-policies s3 S3 S3 +GET localhost:4566 /automationrules/key.txt s3 S3 S3 +PUT localhost:4566 /automationrules/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /automationrules/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /automationrules s3 S3 S3 +GET localhost:4566 /backup/key.txt s3 S3 S3 +PUT localhost:4566 /backup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /backup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /backup s3 S3 S3 +GET localhost:4566 /browser-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /browser-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /browser-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /browser-profiles s3 S3 S3 +GET localhost:4566 /browsers/key.txt s3 S3 S3 +PUT localhost:4566 /browsers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /browsers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /browsers s3 S3 S3 +GET localhost:4566 /buckets/key.txt s3 S3tables S3tables +PUT localhost:4566 /buckets/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /buckets/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /buckets s3 S3tables S3tables +GET localhost:4566 /capacity/key.txt s3 S3 S3 +PUT localhost:4566 /capacity/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /capacity/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /capacity s3 S3 S3 +GET localhost:4566 /capacity-providers/key.txt s3 S3 S3 +PUT localhost:4566 /capacity-providers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /capacity-providers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /capacity-providers s3 S3 S3 +GET localhost:4566 /catalog/key.txt s3 S3 S3 +PUT localhost:4566 /catalog/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /catalog/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /catalog s3 S3 S3 +GET localhost:4566 /cells/key.txt s3 S3 S3 +PUT localhost:4566 /cells/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /cells/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /cells s3 S3 S3 +GET localhost:4566 /channel/key.txt s3 S3 S3 +PUT localhost:4566 /channel/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /channel/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaTailor MediaTailor +HEAD localhost:4566 /channel s3 S3 S3 +GET localhost:4566 /channels/key.txt s3 S3 S3 +PUT localhost:4566 /channels/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /channels/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /channels s3 S3 S3 +GET localhost:4566 /chat-integration-event/key.txt s3 S3 S3 +PUT localhost:4566 /chat-integration-event/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /chat-integration-event/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /chat-integration-event s3 S3 S3 +GET localhost:4566 /cis/key.txt s3 S3 S3 +PUT localhost:4566 /cis/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /cis/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /cis s3 S3 S3 +GET localhost:4566 /classification-export-configuration/key.txt s3 S3 S3 +PUT localhost:4566 /classification-export-configuration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /classification-export-configuration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /classification-export-configuration s3 S3 S3 +GET localhost:4566 /classification-scopes/key.txt s3 S3 S3 +PUT localhost:4566 /classification-scopes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /classification-scopes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /classification-scopes s3 S3 S3 +GET localhost:4566 /client/key.txt s3 S3 S3 +PUT localhost:4566 /client/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /client/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /client s3 S3 S3 +GET localhost:4566 /clientcertificates/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /clientcertificates/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /clientcertificates/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /clientcertificates s3 APIGateway APIGateway +GET localhost:4566 /clitoken/key.txt s3 S3 S3 +PUT localhost:4566 /clitoken/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /clitoken/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /clitoken s3 S3 S3 +GET localhost:4566 /cluster/key.txt s3 DSQL DSQL +PUT localhost:4566 /cluster/key.txt s3 application/octet-stream body DSQL DSQL +GET localhost:4566 /cluster/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 DSQL DSQL +HEAD localhost:4566 /cluster s3 DSQL DSQL +GET localhost:4566 /cluster-registrations/key.txt s3 EKS EKS +PUT localhost:4566 /cluster-registrations/key.txt s3 application/octet-stream body EKS EKS +GET localhost:4566 /cluster-registrations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 EKS EKS +HEAD localhost:4566 /cluster-registrations s3 EKS EKS +GET localhost:4566 /cluster-versions/key.txt s3 S3 S3 +PUT localhost:4566 /cluster-versions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /cluster-versions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /cluster-versions s3 EKS EKS +GET localhost:4566 /clusters/key.txt s3 EKS EKS +PUT localhost:4566 /clusters/key.txt s3 application/octet-stream body EKS EKS +GET localhost:4566 /clusters/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 EKS EKS +HEAD localhost:4566 /clusters s3 EKS EKS +GET localhost:4566 /code-interpreters/key.txt s3 S3 S3 +PUT localhost:4566 /code-interpreters/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /code-interpreters/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /code-interpreters s3 S3 S3 +GET localhost:4566 /codereviews/key.txt s3 S3 S3 +PUT localhost:4566 /codereviews/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /codereviews/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /codereviews s3 S3 S3 +GET localhost:4566 /codesecurity/key.txt s3 S3 S3 +PUT localhost:4566 /codesecurity/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /codesecurity/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /codesecurity s3 S3 S3 +GET localhost:4566 /codesnippet/key.txt s3 S3 S3 +PUT localhost:4566 /codesnippet/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /codesnippet/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /codesnippet s3 S3 S3 +GET localhost:4566 /configuration/key.txt s3 Omics Omics +PUT localhost:4566 /configuration/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /configuration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /configuration s3 Omics Omics +GET localhost:4566 /configuration-bundles/key.txt s3 S3 S3 +PUT localhost:4566 /configuration-bundles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /configuration-bundles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /configuration-bundles s3 S3 S3 +GET localhost:4566 /configurationPolicy/key.txt s3 S3 S3 +PUT localhost:4566 /configurationPolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /configurationPolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /configurationPolicy s3 S3 S3 +GET localhost:4566 /configureLogs/key.txt s3 S3 S3 +PUT localhost:4566 /configureLogs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /configureLogs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaTailor MediaTailor +HEAD localhost:4566 /configureLogs s3 S3 S3 +GET localhost:4566 /connect-attachments/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /connect-attachments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /connect-attachments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /connect-attachments s3 S3 S3 +GET localhost:4566 /connect-peers/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /connect-peers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /connect-peers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /connect-peers s3 S3 S3 +GET localhost:4566 /connections/key.txt s3 S3 S3 +PUT localhost:4566 /connections/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /connections/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoTDataPlane IoTDataPlane +HEAD localhost:4566 /connections s3 S3 S3 +GET localhost:4566 /connector/key.txt s3 S3 S3 +PUT localhost:4566 /connector/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /connector/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /connector s3 S3 S3 +GET localhost:4566 /connectors/key.txt s3 S3 S3 +PUT localhost:4566 /connectors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /connectors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /connectors s3 S3 S3 +GET localhost:4566 /contact/key.txt s3 S3 S3 +PUT localhost:4566 /contact/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /contact/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /contact s3 S3 S3 +GET localhost:4566 /controlpanel/key.txt s3 S3 S3 +PUT localhost:4566 /controlpanel/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /controlpanel/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /controlpanel s3 S3 S3 +GET localhost:4566 /copy-jobs/key.txt s3 S3 S3 +PUT localhost:4566 /copy-jobs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /copy-jobs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /copy-jobs s3 S3 S3 +GET localhost:4566 /core-networks/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /core-networks/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /core-networks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /core-networks s3 S3 S3 +GET localhost:4566 /coverage/key.txt s3 S3 S3 +PUT localhost:4566 /coverage/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /coverage/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /coverage s3 S3 S3 +GET localhost:4566 /create-foundation-model-agreement/key.txt s3 S3 S3 +PUT localhost:4566 /create-foundation-model-agreement/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /create-foundation-model-agreement/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /create-foundation-model-agreement s3 S3 S3 +GET localhost:4566 /createSignalingChannel/key.txt s3 S3 S3 +PUT localhost:4566 /createSignalingChannel/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /createSignalingChannel/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /createSignalingChannel s3 KinesisVideo KinesisVideo +GET localhost:4566 /createStream/key.txt s3 S3 S3 +PUT localhost:4566 /createStream/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /createStream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /createStream s3 KinesisVideo KinesisVideo +GET localhost:4566 /createpermission/key.txt s3 S3 S3 +PUT localhost:4566 /createpermission/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /createpermission/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /createpermission s3 S3 S3 +GET localhost:4566 /createresourceshare/key.txt s3 S3 S3 +PUT localhost:4566 /createresourceshare/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /createresourceshare/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /createresourceshare s3 S3 S3 +GET localhost:4566 /crl/key.txt s3 RolesAnywhere RolesAnywhere +PUT localhost:4566 /crl/key.txt s3 application/octet-stream body RolesAnywhere RolesAnywhere +GET localhost:4566 /crl/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 RolesAnywhere RolesAnywhere +HEAD localhost:4566 /crl s3 S3 S3 +GET localhost:4566 /cross-region-routing/key.txt s3 S3 S3 +PUT localhost:4566 /cross-region-routing/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /cross-region-routing/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /cross-region-routing s3 S3 S3 +GET localhost:4566 /custom-data-identifiers/key.txt s3 S3 S3 +PUT localhost:4566 /custom-data-identifiers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /custom-data-identifiers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /custom-data-identifiers s3 S3 S3 +GET localhost:4566 /custom-models/key.txt s3 S3 S3 +PUT localhost:4566 /custom-models/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /custom-models/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /custom-models s3 S3 S3 +GET localhost:4566 /dashboard/key.txt s3 Dashboard Dashboard +PUT localhost:4566 /dashboard/key.txt s3 application/octet-stream body Dashboard Dashboard +GET localhost:4566 /dashboard/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Dashboard Dashboard +HEAD localhost:4566 /dashboard s3 Dashboard Dashboard +GET localhost:4566 /data-retention/key.txt s3 S3 S3 +PUT localhost:4566 /data-retention/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /data-retention/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /data-retention s3 S3 S3 +GET localhost:4566 /data-tables/key.txt s3 S3 S3 +PUT localhost:4566 /data-tables/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /data-tables/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /data-tables s3 S3 S3 +GET localhost:4566 /dataIntegrations/key.txt s3 S3 S3 +PUT localhost:4566 /dataIntegrations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /dataIntegrations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /dataIntegrations s3 S3 S3 +GET localhost:4566 /datasets/key.txt s3 S3 S3 +PUT localhost:4566 /datasets/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /datasets/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoTAnalytics IoTAnalytics +HEAD localhost:4566 /datasets s3 S3 S3 +GET localhost:4566 /datasources/key.txt s3 S3 S3 +PUT localhost:4566 /datasources/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /datasources/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /datasources s3 S3 S3 +GET localhost:4566 /datastores/key.txt s3 S3 S3 +PUT localhost:4566 /datastores/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /datastores/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoTAnalytics IoTAnalytics +HEAD localhost:4566 /datastores s3 S3 S3 +GET localhost:4566 /default-vocabulary/key.txt s3 S3 S3 +PUT localhost:4566 /default-vocabulary/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /default-vocabulary/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /default-vocabulary s3 S3 S3 +GET localhost:4566 /delegatedadminaccounts/key.txt s3 S3 S3 +PUT localhost:4566 /delegatedadminaccounts/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /delegatedadminaccounts/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /delegatedadminaccounts s3 S3 S3 +GET localhost:4566 /delete-foundation-model-agreement/key.txt s3 S3 S3 +PUT localhost:4566 /delete-foundation-model-agreement/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /delete-foundation-model-agreement/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /delete-foundation-model-agreement s3 S3 S3 +GET localhost:4566 /deleteAlternateContact/key.txt s3 S3 S3 +PUT localhost:4566 /deleteAlternateContact/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /deleteAlternateContact/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /deleteAlternateContact s3 S3 S3 +GET localhost:4566 /deleteEdgeConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /deleteEdgeConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /deleteEdgeConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /deleteEdgeConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /deleteSignalingChannel/key.txt s3 S3 S3 +PUT localhost:4566 /deleteSignalingChannel/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /deleteSignalingChannel/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /deleteSignalingChannel s3 KinesisVideo KinesisVideo +GET localhost:4566 /deleteStream/key.txt s3 S3 S3 +PUT localhost:4566 /deleteStream/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /deleteStream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /deleteStream s3 KinesisVideo KinesisVideo +GET localhost:4566 /deletepermission/key.txt s3 S3 S3 +PUT localhost:4566 /deletepermission/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /deletepermission/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /deletepermission s3 S3 S3 +GET localhost:4566 /deleteresourceshare/key.txt s3 S3 S3 +PUT localhost:4566 /deleteresourceshare/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /deleteresourceshare/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /deleteresourceshare s3 S3 S3 +GET localhost:4566 /deployementstrategies/key.txt s3 AppConfig AppConfig +PUT localhost:4566 /deployementstrategies/key.txt s3 application/octet-stream body AppConfig AppConfig +GET localhost:4566 /deployementstrategies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AppConfig AppConfig +HEAD localhost:4566 /deployementstrategies s3 AppConfig AppConfig +GET localhost:4566 /deploymentstrategies/key.txt s3 AppConfig AppConfig +PUT localhost:4566 /deploymentstrategies/key.txt s3 application/octet-stream body AppConfig AppConfig +GET localhost:4566 /deploymentstrategies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AppConfig AppConfig +HEAD localhost:4566 /deploymentstrategies s3 AppConfig AppConfig +GET localhost:4566 /describeEdgeConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /describeEdgeConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /describeEdgeConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /describeEdgeConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /describeImageGenerationConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /describeImageGenerationConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /describeImageGenerationConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /describeImageGenerationConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /describeMediaStorageConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /describeMediaStorageConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /describeMediaStorageConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /describeMediaStorageConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /describeNotificationConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /describeNotificationConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /describeNotificationConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /describeNotificationConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /describeSignalingChannel/key.txt s3 S3 S3 +PUT localhost:4566 /describeSignalingChannel/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /describeSignalingChannel/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /describeSignalingChannel s3 KinesisVideo KinesisVideo +GET localhost:4566 /describeStream/key.txt s3 S3 S3 +PUT localhost:4566 /describeStream/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /describeStream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /describeStream s3 KinesisVideo KinesisVideo +GET localhost:4566 /destinations/key.txt s3 S3 S3 +PUT localhost:4566 /destinations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /destinations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /destinations s3 S3 S3 +GET localhost:4566 /detector/key.txt s3 S3 S3 +PUT localhost:4566 /detector/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /detector/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 GuardDuty GuardDuty +HEAD localhost:4566 /detector s3 S3 S3 +GET localhost:4566 /device-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /device-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /device-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /device-profiles s3 S3 S3 +GET localhost:4566 /device_authorization/key.txt s3 S3 S3 +PUT localhost:4566 /device_authorization/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /device_authorization/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /device_authorization s3 S3 S3 +GET localhost:4566 /direct-connect-gateway-attachments/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /direct-connect-gateway-attachments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /direct-connect-gateway-attachments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /direct-connect-gateway-attachments s3 S3 S3 +GET localhost:4566 /disable/key.txt s3 S3 S3 +PUT localhost:4566 /disable/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /disable/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /disable s3 S3 S3 +GET localhost:4566 /disableRegion/key.txt s3 S3 S3 +PUT localhost:4566 /disableRegion/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /disableRegion/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /disableRegion s3 S3 S3 +GET localhost:4566 /disassociate-security-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /disassociate-security-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /disassociate-security-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /disassociate-security-profiles s3 S3 S3 +GET localhost:4566 /disassociateresourceshare/key.txt s3 S3 S3 +PUT localhost:4566 /disassociateresourceshare/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /disassociateresourceshare/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /disassociateresourceshare s3 S3 S3 +GET localhost:4566 /domainnames/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /domainnames/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /domainnames/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /domainnames s3 APIGateway APIGateway +GET localhost:4566 /domainverifications/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /domainverifications/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /domainverifications/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /domainverifications s3 VPCLattice VPCLattice +GET localhost:4566 /ec2deepinspection/key.txt s3 S3 S3 +PUT localhost:4566 /ec2deepinspection/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ec2deepinspection/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /ec2deepinspection s3 S3 S3 +GET localhost:4566 /effective-hours-of-operations/key.txt s3 S3 S3 +PUT localhost:4566 /effective-hours-of-operations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /effective-hours-of-operations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /effective-hours-of-operations s3 S3 S3 +GET localhost:4566 /eks-anywhere-subscriptions/key.txt s3 EKS EKS +PUT localhost:4566 /eks-anywhere-subscriptions/key.txt s3 application/octet-stream body EKS EKS +GET localhost:4566 /eks-anywhere-subscriptions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 EKS EKS +HEAD localhost:4566 /eks-anywhere-subscriptions s3 EKS EKS +GET localhost:4566 /email-addresses/key.txt s3 S3 S3 +PUT localhost:4566 /email-addresses/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /email-addresses/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /email-addresses s3 S3 S3 +GET localhost:4566 /enable/key.txt s3 S3 S3 +PUT localhost:4566 /enable/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /enable/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /enable s3 S3 S3 +GET localhost:4566 /enableRegion/key.txt s3 S3 S3 +PUT localhost:4566 /enableRegion/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /enableRegion/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /enableRegion s3 S3 S3 +GET localhost:4566 /enablesharingwithawsorganization/key.txt s3 S3 S3 +PUT localhost:4566 /enablesharingwithawsorganization/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /enablesharingwithawsorganization/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /enablesharingwithawsorganization s3 S3 S3 +GET localhost:4566 /encryptionkey/key.txt s3 S3 S3 +PUT localhost:4566 /encryptionkey/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /encryptionkey/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /encryptionkey s3 S3 S3 +GET localhost:4566 /endpoints/key.txt s3 SageMakerRuntime SageMakerRuntime +PUT localhost:4566 /endpoints/key.txt s3 application/octet-stream body SageMakerRuntime SageMakerRuntime +GET localhost:4566 /endpoints/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SageMakerRuntime SageMakerRuntime +HEAD localhost:4566 /endpoints s3 S3 S3 +GET localhost:4566 /enforcedGuardrailsConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /enforcedGuardrailsConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /enforcedGuardrailsConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /enforcedGuardrailsConfiguration s3 S3 S3 +GET localhost:4566 /entity-security-profiles-summary/key.txt s3 S3 S3 +PUT localhost:4566 /entity-security-profiles-summary/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /entity-security-profiles-summary/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /entity-security-profiles-summary s3 S3 S3 +GET localhost:4566 /environments/key.txt s3 S3 S3 +PUT localhost:4566 /environments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /environments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /environments s3 S3 S3 +GET localhost:4566 /evaluation-forms/key.txt s3 S3 S3 +PUT localhost:4566 /evaluation-forms/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /evaluation-forms/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /evaluation-forms s3 S3 S3 +GET localhost:4566 /evaluation-job/key.txt s3 S3 S3 +PUT localhost:4566 /evaluation-job/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /evaluation-job/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /evaluation-job s3 S3 S3 +GET localhost:4566 /evaluators/key.txt s3 S3 S3 +PUT localhost:4566 /evaluators/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /evaluators/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /evaluators s3 S3 S3 +GET localhost:4566 /event-configurations/key.txt s3 S3 S3 +PUT localhost:4566 /event-configurations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /event-configurations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /event-configurations s3 S3 S3 +GET localhost:4566 /event-source-mappings/key.txt s3 S3 S3 +PUT localhost:4566 /event-source-mappings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /event-source-mappings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /event-source-mappings s3 S3 S3 +GET localhost:4566 /eventIntegrations/key.txt s3 S3 S3 +PUT localhost:4566 /eventIntegrations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /eventIntegrations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /eventIntegrations s3 S3 S3 +GET localhost:4566 /experimentTemplates/key.txt s3 FIS FIS +PUT localhost:4566 /experimentTemplates/key.txt s3 application/octet-stream body FIS FIS +GET localhost:4566 /experimentTemplates/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 FIS FIS +HEAD localhost:4566 /experimentTemplates s3 FIS FIS +GET localhost:4566 /experimentdefinitions/key.txt s3 AppConfig AppConfig +PUT localhost:4566 /experimentdefinitions/key.txt s3 application/octet-stream body AppConfig AppConfig +GET localhost:4566 /experimentdefinitions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AppConfig AppConfig +HEAD localhost:4566 /experimentdefinitions s3 AppConfig AppConfig +GET localhost:4566 /experiments/key.txt s3 FIS FIS +PUT localhost:4566 /experiments/key.txt s3 application/octet-stream body FIS FIS +GET localhost:4566 /experiments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 FIS FIS +HEAD localhost:4566 /experiments s3 FIS FIS +GET localhost:4566 /extensionassociations/key.txt s3 AppConfig AppConfig +PUT localhost:4566 /extensionassociations/key.txt s3 application/octet-stream body AppConfig AppConfig +GET localhost:4566 /extensionassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AppConfig AppConfig +HEAD localhost:4566 /extensionassociations s3 AppConfig AppConfig +GET localhost:4566 /extensions/key.txt s3 AppConfig AppConfig +PUT localhost:4566 /extensions/key.txt s3 application/octet-stream body AppConfig AppConfig +GET localhost:4566 /extensions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AppConfig AppConfig +HEAD localhost:4566 /extensions s3 AppConfig AppConfig +GET localhost:4566 /extraction-definitions/key.txt s3 S3 S3 +PUT localhost:4566 /extraction-definitions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /extraction-definitions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /extraction-definitions s3 S3 S3 +GET localhost:4566 /federation/key.txt s3 S3 S3 +PUT localhost:4566 /federation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /federation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /federation s3 S3 S3 +GET localhost:4566 /feedback/key.txt s3 S3 S3 +PUT localhost:4566 /feedback/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /feedback/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /feedback s3 S3 S3 +GET localhost:4566 /filters/key.txt s3 S3 S3 +PUT localhost:4566 /filters/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /filters/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /filters s3 S3 S3 +GET localhost:4566 /finding/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /finding/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /finding/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /finding s3 AccessAnalyzer AccessAnalyzer +GET localhost:4566 /findingAggregator/key.txt s3 S3 S3 +PUT localhost:4566 /findingAggregator/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /findingAggregator/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /findingAggregator s3 S3 S3 +GET localhost:4566 /findingHistory/key.txt s3 S3 S3 +PUT localhost:4566 /findingHistory/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /findingHistory/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /findingHistory s3 S3 S3 +GET localhost:4566 /findings/key.txt s3 S3 S3 +PUT localhost:4566 /findings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /findings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /findings s3 S3 S3 +GET localhost:4566 /flow-associations/key.txt s3 S3 S3 +PUT localhost:4566 /flow-associations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /flow-associations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /flow-associations s3 S3 S3 +GET localhost:4566 /flows/key.txt s3 S3 S3 +PUT localhost:4566 /flows/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /flows/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockAgent BedrockAgent +HEAD localhost:4566 /flows s3 S3 S3 +GET localhost:4566 /foundation-model-availability/key.txt s3 S3 S3 +PUT localhost:4566 /foundation-model-availability/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /foundation-model-availability/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /foundation-model-availability s3 S3 S3 +GET localhost:4566 /foundation-models/key.txt s3 S3 S3 +PUT localhost:4566 /foundation-models/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /foundation-models/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /foundation-models s3 S3 S3 +GET localhost:4566 /freetrialinfo/key.txt s3 S3 S3 +PUT localhost:4566 /freetrialinfo/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /freetrialinfo/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /freetrialinfo s3 S3 S3 +GET localhost:4566 /function/key.txt s3 S3 S3 +PUT localhost:4566 /function/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /function/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaTailor MediaTailor +HEAD localhost:4566 /function s3 S3 S3 +GET localhost:4566 /functions/key.txt s3 S3 S3 +PUT localhost:4566 /functions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /functions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /functions s3 S3 S3 +GET localhost:4566 /fuota-tasks/key.txt s3 S3 S3 +PUT localhost:4566 /fuota-tasks/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /fuota-tasks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /fuota-tasks s3 S3 S3 +GET localhost:4566 /gateways/key.txt s3 S3 S3 +PUT localhost:4566 /gateways/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /gateways/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /gateways s3 S3 S3 +GET localhost:4566 /get-table/key.txt s3 S3tables S3tables +PUT localhost:4566 /get-table/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /get-table/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /get-table s3 S3tables S3tables +GET localhost:4566 /getAccountInformation/key.txt s3 S3 S3 +PUT localhost:4566 /getAccountInformation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getAccountInformation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getAccountInformation s3 S3 S3 +GET localhost:4566 /getAlternateContact/key.txt s3 S3 S3 +PUT localhost:4566 /getAlternateContact/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getAlternateContact/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getAlternateContact s3 S3 S3 +GET localhost:4566 /getContactInformation/key.txt s3 S3 S3 +PUT localhost:4566 /getContactInformation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getContactInformation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getContactInformation s3 S3 S3 +GET localhost:4566 /getDataEndpoint/key.txt s3 S3 S3 +PUT localhost:4566 /getDataEndpoint/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getDataEndpoint/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getDataEndpoint s3 KinesisVideo KinesisVideo +GET localhost:4566 /getGovCloudAccountInformation/key.txt s3 S3 S3 +PUT localhost:4566 /getGovCloudAccountInformation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getGovCloudAccountInformation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getGovCloudAccountInformation s3 S3 S3 +GET localhost:4566 /getPrimaryEmail/key.txt s3 S3 S3 +PUT localhost:4566 /getPrimaryEmail/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getPrimaryEmail/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getPrimaryEmail s3 S3 S3 +GET localhost:4566 /getRegionOptStatus/key.txt s3 S3 S3 +PUT localhost:4566 /getRegionOptStatus/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getRegionOptStatus/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getRegionOptStatus s3 S3 S3 +GET localhost:4566 /getSignalingChannelEndpoint/key.txt s3 S3 S3 +PUT localhost:4566 /getSignalingChannelEndpoint/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getSignalingChannelEndpoint/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getSignalingChannelEndpoint s3 KinesisVideo KinesisVideo +GET localhost:4566 /getpermission/key.txt s3 S3 S3 +PUT localhost:4566 /getpermission/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getpermission/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getpermission s3 S3 S3 +GET localhost:4566 /getresourcepolicies/key.txt s3 S3 S3 +PUT localhost:4566 /getresourcepolicies/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getresourcepolicies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getresourcepolicies s3 S3 S3 +GET localhost:4566 /getresourceshareassociations/key.txt s3 S3 S3 +PUT localhost:4566 /getresourceshareassociations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getresourceshareassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getresourceshareassociations s3 S3 S3 +GET localhost:4566 /getresourceshareinvitations/key.txt s3 S3 S3 +PUT localhost:4566 /getresourceshareinvitations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getresourceshareinvitations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getresourceshareinvitations s3 S3 S3 +GET localhost:4566 /getresourceshares/key.txt s3 S3 S3 +PUT localhost:4566 /getresourceshares/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /getresourceshares/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /getresourceshares s3 S3 S3 +GET localhost:4566 /global-networks/key.txt s3 S3 S3 +PUT localhost:4566 /global-networks/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /global-networks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /global-networks s3 S3 S3 +GET localhost:4566 /global-settings/key.txt s3 S3 S3 +PUT localhost:4566 /global-settings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /global-settings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /global-settings s3 S3 S3 +GET localhost:4566 /gopherstack.dashboard.v1.DashboardService/key.txt s3 S3 S3 +PUT localhost:4566 /gopherstack.dashboard.v1.DashboardService/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /gopherstack.dashboard.v1.DashboardService/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /gopherstack.dashboard.v1.DashboardService s3 S3 S3 +GET localhost:4566 /governance/key.txt s3 S3 S3 +PUT localhost:4566 /governance/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /governance/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /governance s3 S3 S3 +GET localhost:4566 /graph/key.txt s3 S3 S3 +PUT localhost:4566 /graph/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /graph/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /graph s3 Detective Detective +GET localhost:4566 /graphql/key.txt s3 S3 S3 +PUT localhost:4566 /graphql/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /graphql/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /graphql s3 S3 S3 +GET localhost:4566 /guardrail/key.txt s3 BedrockRuntime BedrockRuntime +PUT localhost:4566 /guardrail/key.txt s3 application/octet-stream body BedrockRuntime BedrockRuntime +GET localhost:4566 /guardrail/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockRuntime BedrockRuntime +HEAD localhost:4566 /guardrail s3 S3 S3 +GET localhost:4566 /guardrails/key.txt s3 S3 S3 +PUT localhost:4566 /guardrails/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /guardrails/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /guardrails s3 S3 S3 +GET localhost:4566 /harnesses/key.txt s3 S3 S3 +PUT localhost:4566 /harnesses/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /harnesses/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /harnesses s3 S3 S3 +GET localhost:4566 /harvest_jobs/key.txt s3 MediaPackage MediaPackage +PUT localhost:4566 /harvest_jobs/key.txt s3 application/octet-stream body MediaPackage MediaPackage +GET localhost:4566 /harvest_jobs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaPackage MediaPackage +HEAD localhost:4566 /harvest_jobs s3 MediaPackage MediaPackage +GET localhost:4566 /hours-of-operations/key.txt s3 S3 S3 +PUT localhost:4566 /hours-of-operations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /hours-of-operations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /hours-of-operations s3 S3 S3 +GET localhost:4566 /hubv2/key.txt s3 S3 S3 +PUT localhost:4566 /hubv2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /hubv2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /hubv2 s3 S3 S3 +GET localhost:4566 /id/key.txt s3 S3 S3 +PUT localhost:4566 /id/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /id/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /id s3 S3 S3 +GET localhost:4566 /identities/key.txt s3 S3 S3 +PUT localhost:4566 /identities/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /identities/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /identities s3 S3 S3 +GET localhost:4566 /import/key.txt s3 S3 S3 +PUT localhost:4566 /import/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /import/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /import s3 S3 S3 +GET localhost:4566 /imported-models/key.txt s3 S3 S3 +PUT localhost:4566 /imported-models/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /imported-models/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /imported-models s3 S3 S3 +GET localhost:4566 /indexes/key.txt s3 S3 S3 +PUT localhost:4566 /indexes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /indexes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /indexes s3 S3 S3 +GET localhost:4566 /inference-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /inference-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /inference-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /inference-profiles s3 S3 S3 +GET localhost:4566 /insights/key.txt s3 S3 S3 +PUT localhost:4566 /insights/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /insights/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /insights s3 S3 S3 +GET localhost:4566 /instance/key.txt s3 S3 S3 +PUT localhost:4566 /instance/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /instance/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /instance s3 S3 S3 +GET localhost:4566 /instanceTypes/key.txt s3 S3 S3 +PUT localhost:4566 /instanceTypes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /instanceTypes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /instanceTypes s3 S3 S3 +GET localhost:4566 /investigations/key.txt s3 S3 S3 +PUT localhost:4566 /investigations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /investigations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /investigations s3 S3 S3 +GET localhost:4566 /invitation/key.txt s3 S3 S3 +PUT localhost:4566 /invitation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /invitation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 GuardDuty GuardDuty +HEAD localhost:4566 /invitation s3 S3 S3 +GET localhost:4566 /invitations/key.txt s3 S3 S3 +PUT localhost:4566 /invitations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /invitations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 GuardDuty GuardDuty +HEAD localhost:4566 /invitations s3 S3 S3 +GET localhost:4566 /invocations/key.txt s3 S3 S3 +PUT localhost:4566 /invocations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /invocations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /invocations s3 S3 S3 +GET localhost:4566 /jobs/key.txt s3 S3 S3 +PUT localhost:4566 /jobs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /jobs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /jobs s3 S3 S3 +GET localhost:4566 /key-value-stores/key.txt s3 CloudFront KeyValueStore CloudFront KeyValueStore +PUT localhost:4566 /key-value-stores/key.txt s3 application/octet-stream body CloudFront KeyValueStore CloudFront KeyValueStore +GET localhost:4566 /key-value-stores/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 CloudFront KeyValueStore CloudFront KeyValueStore +HEAD localhost:4566 /key-value-stores s3 S3 S3 +GET localhost:4566 /knowledgebases/key.txt s3 S3 S3 +PUT localhost:4566 /knowledgebases/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /knowledgebases/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockAgent BedrockAgent +HEAD localhost:4566 /knowledgebases s3 S3 S3 +GET localhost:4566 /layers/key.txt s3 S3 S3 +PUT localhost:4566 /layers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /layers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /layers s3 S3 S3 +GET localhost:4566 /legal-holds/key.txt s3 S3 S3 +PUT localhost:4566 /legal-holds/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /legal-holds/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /legal-holds s3 S3 S3 +GET localhost:4566 /list-foundation-model-agreement-offers/key.txt s3 S3 S3 +PUT localhost:4566 /list-foundation-model-agreement-offers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /list-foundation-model-agreement-offers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /list-foundation-model-agreement-offers s3 S3 S3 +GET localhost:4566 /list-orders/key.txt s3 S3 S3 +PUT localhost:4566 /list-orders/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /list-orders/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /list-orders s3 S3 S3 +GET localhost:4566 /listEdgeAgentConfigurations/key.txt s3 S3 S3 +PUT localhost:4566 /listEdgeAgentConfigurations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listEdgeAgentConfigurations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listEdgeAgentConfigurations s3 KinesisVideo KinesisVideo +GET localhost:4566 /listRegions/key.txt s3 S3 S3 +PUT localhost:4566 /listRegions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listRegions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listRegions s3 S3 S3 +GET localhost:4566 /listSignalingChannels/key.txt s3 S3 S3 +PUT localhost:4566 /listSignalingChannels/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listSignalingChannels/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listSignalingChannels s3 KinesisVideo KinesisVideo +GET localhost:4566 /listStreams/key.txt s3 S3 S3 +PUT localhost:4566 /listStreams/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listStreams/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listStreams s3 KinesisVideo KinesisVideo +GET localhost:4566 /listTagsForResource/key.txt s3 S3 S3 +PUT localhost:4566 /listTagsForResource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listTagsForResource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listTagsForResource s3 S3 S3 +GET localhost:4566 /listTagsForStream/key.txt s3 S3 S3 +PUT localhost:4566 /listTagsForStream/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listTagsForStream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listTagsForStream s3 KinesisVideo KinesisVideo +GET localhost:4566 /listpendinginvitationresources/key.txt s3 S3 S3 +PUT localhost:4566 /listpendinginvitationresources/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listpendinginvitationresources/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listpendinginvitationresources s3 S3 S3 +GET localhost:4566 /listpermissionassociations/key.txt s3 S3 S3 +PUT localhost:4566 /listpermissionassociations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listpermissionassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listpermissionassociations s3 S3 S3 +GET localhost:4566 /listpermissions/key.txt s3 S3 S3 +PUT localhost:4566 /listpermissions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listpermissions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listpermissions s3 S3 S3 +GET localhost:4566 /listpermissionversions/key.txt s3 S3 S3 +PUT localhost:4566 /listpermissionversions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listpermissionversions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listpermissionversions s3 S3 S3 +GET localhost:4566 /listprincipals/key.txt s3 S3 S3 +PUT localhost:4566 /listprincipals/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listprincipals/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listprincipals s3 S3 S3 +GET localhost:4566 /listreplacepermissionassociationswork/key.txt s3 S3 S3 +PUT localhost:4566 /listreplacepermissionassociationswork/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listreplacepermissionassociationswork/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listreplacepermissionassociationswork s3 S3 S3 +GET localhost:4566 /listresources/key.txt s3 S3 S3 +PUT localhost:4566 /listresources/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listresources/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listresources s3 S3 S3 +GET localhost:4566 /listresourcetypes/key.txt s3 S3 S3 +PUT localhost:4566 /listresourcetypes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listresourcetypes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listresourcetypes s3 S3 S3 +GET localhost:4566 /listsourceassociations/key.txt s3 S3 S3 +PUT localhost:4566 /listsourceassociations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listsourceassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listsourceassociations s3 S3 S3 +GET localhost:4566 /listtagsforresource/key.txt s3 S3 S3 +PUT localhost:4566 /listtagsforresource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /listtagsforresource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /listtagsforresource s3 S3 S3 +GET localhost:4566 /log-levels/key.txt s3 S3 S3 +PUT localhost:4566 /log-levels/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /log-levels/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /log-levels s3 S3 S3 +GET localhost:4566 /logging/key.txt s3 S3 S3 +PUT localhost:4566 /logging/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /logging/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /logging s3 S3 S3 +GET localhost:4566 /logically-air-gapped-backup-vaults/key.txt s3 S3 S3 +PUT localhost:4566 /logically-air-gapped-backup-vaults/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /logically-air-gapped-backup-vaults/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /logically-air-gapped-backup-vaults s3 S3 S3 +GET localhost:4566 /logout/key.txt s3 S3 S3 +PUT localhost:4566 /logout/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /logout/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /logout s3 S3 S3 +GET localhost:4566 /macie/key.txt s3 S3 S3 +PUT localhost:4566 /macie/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /macie/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /macie s3 S3 S3 +GET localhost:4566 /malware-protection-plan/key.txt s3 S3 S3 +PUT localhost:4566 /malware-protection-plan/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /malware-protection-plan/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 GuardDuty GuardDuty +HEAD localhost:4566 /malware-protection-plan s3 S3 S3 +GET localhost:4566 /malware-scan/key.txt s3 S3 S3 +PUT localhost:4566 /malware-scan/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /malware-scan/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 GuardDuty GuardDuty +HEAD localhost:4566 /malware-scan s3 S3 S3 +GET localhost:4566 /managed-data-identifiers/key.txt s3 S3 S3 +PUT localhost:4566 /managed-data-identifiers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /managed-data-identifiers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /managed-data-identifiers s3 S3 S3 +GET localhost:4566 /marketplace-model/key.txt s3 S3 S3 +PUT localhost:4566 /marketplace-model/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /marketplace-model/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /marketplace-model s3 S3 S3 +GET localhost:4566 /master/key.txt s3 S3 S3 +PUT localhost:4566 /master/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /master/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /master s3 S3 S3 +GET localhost:4566 /mcp/key.txt s3 S3 S3 +PUT localhost:4566 /mcp/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /mcp/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /mcp s3 S3 S3 +GET localhost:4566 /members/key.txt s3 S3 S3 +PUT localhost:4566 /members/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /members/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /members s3 S3 S3 +GET localhost:4566 /membership/key.txt s3 S3 S3 +PUT localhost:4566 /membership/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /membership/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /membership s3 S3 S3 +GET localhost:4566 /memories/key.txt s3 S3 S3 +PUT localhost:4566 /memories/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /memories/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /memories s3 S3 S3 +GET localhost:4566 /messages/key.txt s3 S3 S3 +PUT localhost:4566 /messages/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /messages/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /messages s3 S3 S3 +GET localhost:4566 /metric-configuration/key.txt s3 S3 S3 +PUT localhost:4566 /metric-configuration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /metric-configuration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /metric-configuration s3 S3 S3 +GET localhost:4566 /metrics/key.txt s3 - - +PUT localhost:4566 /metrics/key.txt s3 application/octet-stream body - - +GET localhost:4566 /metrics/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 - - +HEAD localhost:4566 /metrics s3 S3 S3 +GET localhost:4566 /model/key.txt s3 BedrockRuntime BedrockRuntime +PUT localhost:4566 /model/key.txt s3 application/octet-stream body BedrockRuntime BedrockRuntime +GET localhost:4566 /model/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockRuntime BedrockRuntime +HEAD localhost:4566 /model s3 S3 S3 +GET localhost:4566 /model-copy-jobs/key.txt s3 S3 S3 +PUT localhost:4566 /model-copy-jobs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /model-copy-jobs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /model-copy-jobs s3 S3 S3 +GET localhost:4566 /model-customization/key.txt s3 S3 S3 +PUT localhost:4566 /model-customization/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /model-customization/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /model-customization s3 S3 S3 +GET localhost:4566 /model-import-jobs/key.txt s3 S3 S3 +PUT localhost:4566 /model-import-jobs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /model-import-jobs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /model-import-jobs s3 S3 S3 +GET localhost:4566 /model-invocation-job/key.txt s3 S3 S3 +PUT localhost:4566 /model-invocation-job/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /model-invocation-job/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /model-invocation-job s3 S3 S3 +GET localhost:4566 /monitors/key.txt s3 S3 S3 +PUT localhost:4566 /monitors/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /monitors/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /monitors s3 S3 S3 +GET localhost:4566 /multicast-groups/key.txt s3 S3 S3 +PUT localhost:4566 /multicast-groups/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /multicast-groups/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /multicast-groups s3 S3 S3 +GET localhost:4566 /namespaces/key.txt s3 S3tables S3tables +PUT localhost:4566 /namespaces/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /namespaces/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /namespaces s3 S3tables S3tables +GET localhost:4566 /network-analyzer-configurations/key.txt s3 S3 S3 +PUT localhost:4566 /network-analyzer-configurations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /network-analyzer-configurations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /network-analyzer-configurations s3 S3 S3 +GET localhost:4566 /networks/key.txt s3 S3 S3 +PUT localhost:4566 /networks/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /networks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /networks s3 S3 S3 +GET localhost:4566 /notifications/key.txt s3 S3 S3 +PUT localhost:4566 /notifications/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /notifications/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /notifications s3 S3 S3 +GET localhost:4566 /object-malware-scan/key.txt s3 S3 S3 +PUT localhost:4566 /object-malware-scan/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /object-malware-scan/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 GuardDuty GuardDuty +HEAD localhost:4566 /object-malware-scan s3 S3 S3 +GET localhost:4566 /online-evaluation-configs/key.txt s3 S3 S3 +PUT localhost:4566 /online-evaluation-configs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /online-evaluation-configs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /online-evaluation-configs s3 S3 S3 +GET localhost:4566 /orders/key.txt s3 S3 S3 +PUT localhost:4566 /orders/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /orders/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /orders s3 S3 S3 +GET localhost:4566 /organization/key.txt s3 S3 S3 +PUT localhost:4566 /organization/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /organization/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /organization s3 S3 S3 +GET localhost:4566 /organizationconfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /organizationconfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /organizationconfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /organizationconfiguration s3 S3 S3 +GET localhost:4566 /organizations/key.txt s3 S3 S3 +PUT localhost:4566 /organizations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /organizations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /organizations s3 S3 S3 +GET localhost:4566 /orgs/key.txt s3 S3 S3 +PUT localhost:4566 /orgs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /orgs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /orgs s3 S3 S3 +GET localhost:4566 /origin_endpoints/key.txt s3 MediaPackage MediaPackage +PUT localhost:4566 /origin_endpoints/key.txt s3 application/octet-stream body MediaPackage MediaPackage +GET localhost:4566 /origin_endpoints/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaPackage MediaPackage +HEAD localhost:4566 /origin_endpoints s3 MediaPackage MediaPackage +GET localhost:4566 /outpost/key.txt s3 S3 S3 +PUT localhost:4566 /outpost/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /outpost/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /outpost s3 S3 S3 +GET localhost:4566 /partner-accounts/key.txt s3 S3 S3 +PUT localhost:4566 /partner-accounts/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /partner-accounts/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /partner-accounts s3 S3 S3 +GET localhost:4566 /payments/key.txt s3 S3 S3 +PUT localhost:4566 /payments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /payments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /payments s3 S3 S3 +GET localhost:4566 /peerings/key.txt s3 S3 S3 +PUT localhost:4566 /peerings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /peerings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /peerings s3 S3 S3 +GET localhost:4566 /phone-number/key.txt s3 S3 S3 +PUT localhost:4566 /phone-number/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /phone-number/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /phone-number s3 S3 S3 +GET localhost:4566 /pipelineactivities/key.txt s3 S3 S3 +PUT localhost:4566 /pipelineactivities/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /pipelineactivities/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /pipelineactivities s3 S3 S3 +GET localhost:4566 /pipelines/key.txt s3 S3 S3 +PUT localhost:4566 /pipelines/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /pipelines/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoTAnalytics IoTAnalytics +HEAD localhost:4566 /pipelines s3 S3 S3 +GET localhost:4566 /playbackConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /playbackConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /playbackConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaTailor MediaTailor +HEAD localhost:4566 /playbackConfiguration s3 S3 S3 +GET localhost:4566 /policies/key.txt s3 DLM DLM +PUT localhost:4566 /policies/key.txt s3 application/octet-stream body DLM DLM +GET localhost:4566 /policies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /policies s3 DLM DLM +GET localhost:4566 /policy/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /policy/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /policy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /policy s3 S3 S3 +GET localhost:4566 /policy-engine-summaries/key.txt s3 S3 S3 +PUT localhost:4566 /policy-engine-summaries/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /policy-engine-summaries/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /policy-engine-summaries s3 S3 S3 +GET localhost:4566 /policy-engines/key.txt s3 S3 S3 +PUT localhost:4566 /policy-engines/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /policy-engines/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /policy-engines s3 S3 S3 +GET localhost:4566 /position-configurations/key.txt s3 S3 S3 +PUT localhost:4566 /position-configurations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /position-configurations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /position-configurations s3 S3 S3 +GET localhost:4566 /position-estimate/key.txt s3 S3 S3 +PUT localhost:4566 /position-estimate/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /position-estimate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /position-estimate s3 S3 S3 +GET localhost:4566 /positions/key.txt s3 S3 S3 +PUT localhost:4566 /positions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /positions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /positions s3 S3 S3 +GET localhost:4566 /predefined-attributes/key.txt s3 S3 S3 +PUT localhost:4566 /predefined-attributes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /predefined-attributes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /predefined-attributes s3 S3 S3 +GET localhost:4566 /prefetchSchedule/key.txt s3 S3 S3 +PUT localhost:4566 /prefetchSchedule/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /prefetchSchedule/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaTailor MediaTailor +HEAD localhost:4566 /prefetchSchedule s3 S3 S3 +GET localhost:4566 /prefix-list/key.txt s3 S3 S3 +PUT localhost:4566 /prefix-list/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /prefix-list/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /prefix-list s3 S3 S3 +GET localhost:4566 /prod/key.txt s3 MediaLive MediaLive +PUT localhost:4566 /prod/key.txt s3 application/octet-stream body MediaLive MediaLive +GET localhost:4566 /prod/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaLive MediaLive +HEAD localhost:4566 /prod s3 S3 S3 +GET localhost:4566 /productSubscriptions/key.txt s3 S3 S3 +PUT localhost:4566 /productSubscriptions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /productSubscriptions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /productSubscriptions s3 S3 S3 +GET localhost:4566 /products/key.txt s3 S3 S3 +PUT localhost:4566 /products/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /products/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /products s3 S3 S3 +GET localhost:4566 /profile/key.txt s3 RolesAnywhere RolesAnywhere +PUT localhost:4566 /profile/key.txt s3 application/octet-stream body RolesAnywhere RolesAnywhere +GET localhost:4566 /profile/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 RolesAnywhere RolesAnywhere +HEAD localhost:4566 /profile s3 S3 S3 +GET localhost:4566 /promotepermissioncreatedfrompolicy/key.txt s3 S3 S3 +PUT localhost:4566 /promotepermissioncreatedfrompolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /promotepermissioncreatedfrompolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /promotepermissioncreatedfrompolicy s3 S3 S3 +GET localhost:4566 /promoteresourcesharecreatedfrompolicy/key.txt s3 S3 S3 +PUT localhost:4566 /promoteresourcesharecreatedfrompolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /promoteresourcesharecreatedfrompolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /promoteresourcesharecreatedfrompolicy s3 S3 S3 +GET localhost:4566 /prompt-routers/key.txt s3 S3 S3 +PUT localhost:4566 /prompt-routers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /prompt-routers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /prompt-routers s3 S3 S3 +GET localhost:4566 /prompts/key.txt s3 S3 S3 +PUT localhost:4566 /prompts/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /prompts/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockAgent BedrockAgent +HEAD localhost:4566 /prompts s3 S3 S3 +GET localhost:4566 /provisioned-model-throughput/key.txt s3 S3 S3 +PUT localhost:4566 /provisioned-model-throughput/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /provisioned-model-throughput/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /provisioned-model-throughput s3 S3 S3 +GET localhost:4566 /proxy/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /proxy/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /proxy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /proxy s3 S3 S3 +GET localhost:4566 /push-notification/key.txt s3 S3 S3 +PUT localhost:4566 /push-notification/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /push-notification/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /push-notification s3 S3 S3 +GET localhost:4566 /put-notifications-settings/key.txt s3 S3 S3 +PUT localhost:4566 /put-notifications-settings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /put-notifications-settings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /put-notifications-settings s3 RolesAnywhere RolesAnywhere +GET localhost:4566 /putAccountName/key.txt s3 S3 S3 +PUT localhost:4566 /putAccountName/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /putAccountName/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /putAccountName s3 S3 S3 +GET localhost:4566 /putAlternateContact/key.txt s3 S3 S3 +PUT localhost:4566 /putAlternateContact/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /putAlternateContact/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /putAlternateContact s3 S3 S3 +GET localhost:4566 /putContactInformation/key.txt s3 S3 S3 +PUT localhost:4566 /putContactInformation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /putContactInformation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /putContactInformation s3 S3 S3 +GET localhost:4566 /queues/key.txt s3 S3 S3 +PUT localhost:4566 /queues/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /queues/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /queues s3 S3 S3 +GET localhost:4566 /quick-connects/key.txt s3 S3 S3 +PUT localhost:4566 /quick-connects/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /quick-connects/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /quick-connects s3 S3 S3 +GET localhost:4566 /quotes/key.txt s3 S3 S3 +PUT localhost:4566 /quotes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /quotes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /quotes s3 S3 S3 +GET localhost:4566 /recommendation/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /recommendation/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /recommendation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /recommendation s3 S3 S3 +GET localhost:4566 /recommendedPolicyV2/key.txt s3 S3 S3 +PUT localhost:4566 /recommendedPolicyV2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /recommendedPolicyV2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /recommendedPolicyV2 s3 S3 S3 +GET localhost:4566 /referencestore/key.txt s3 Omics Omics +PUT localhost:4566 /referencestore/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /referencestore/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /referencestore s3 Omics Omics +GET localhost:4566 /registries/key.txt s3 S3 S3 +PUT localhost:4566 /registries/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /registries/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /registries s3 S3 S3 +GET localhost:4566 /rejectresourceshareinvitation/key.txt s3 S3 S3 +PUT localhost:4566 /rejectresourceshareinvitation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /rejectresourceshareinvitation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /rejectresourceshareinvitation s3 S3 S3 +GET localhost:4566 /renewals/key.txt s3 S3 S3 +PUT localhost:4566 /renewals/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /renewals/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /renewals s3 S3 S3 +GET localhost:4566 /replacepermissionassociations/key.txt s3 S3 S3 +PUT localhost:4566 /replacepermissionassociations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /replacepermissionassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /replacepermissionassociations s3 S3 S3 +GET localhost:4566 /replication/key.txt s3 S3 S3 +PUT localhost:4566 /replication/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /replication/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /replication s3 S3 S3 +GET localhost:4566 /replication-status/key.txt s3 S3tables S3tables +PUT localhost:4566 /replication-status/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /replication-status/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /replication-status s3 S3tables S3tables +GET localhost:4566 /reporting/key.txt s3 S3 S3 +PUT localhost:4566 /reporting/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /reporting/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /reporting s3 S3 S3 +GET localhost:4566 /reset-notifications-settings/key.txt s3 S3 S3 +PUT localhost:4566 /reset-notifications-settings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /reset-notifications-settings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /reset-notifications-settings s3 RolesAnywhere RolesAnywhere +GET localhost:4566 /resource/key.txt s3 S3 S3 +PUT localhost:4566 /resource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /resource s3 S3 S3 +GET localhost:4566 /resource-policy/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /resource-policy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resource-policy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Bedrock Bedrock +HEAD localhost:4566 /resource-policy s3 S3 S3 +GET localhost:4566 /resource-positions/key.txt s3 S3 S3 +PUT localhost:4566 /resource-positions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resource-positions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /resource-positions s3 S3 S3 +GET localhost:4566 /resource-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /resource-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resource-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /resource-profiles s3 S3 S3 +GET localhost:4566 /resourceconfigurations/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /resourceconfigurations/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /resourceconfigurations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /resourceconfigurations s3 VPCLattice VPCLattice +GET localhost:4566 /resourceendpointassociations/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /resourceendpointassociations/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /resourceendpointassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /resourceendpointassociations s3 VPCLattice VPCLattice +GET localhost:4566 /resourcegateways/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /resourcegateways/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /resourcegateways/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /resourcegateways s3 VPCLattice VPCLattice +GET localhost:4566 /resourcepolicy/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /resourcepolicy/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /resourcepolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 BedrockAgent BedrockAgent +HEAD localhost:4566 /resourcepolicy s3 S3 S3 +GET localhost:4566 /resources/key.txt s3 S3 S3 +PUT localhost:4566 /resources/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resources/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /resources s3 S3 S3 +GET localhost:4566 /resourcesTrendsv2/key.txt s3 S3 S3 +PUT localhost:4566 /resourcesTrendsv2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resourcesTrendsv2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /resourcesTrendsv2 s3 S3 S3 +GET localhost:4566 /resourcesv2/key.txt s3 S3 S3 +PUT localhost:4566 /resourcesv2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resourcesv2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /resourcesv2 s3 S3 S3 +GET localhost:4566 /restapi/key.txt s3 S3 S3 +PUT localhost:4566 /restapi/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /restapi/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /restapi s3 S3 S3 +GET localhost:4566 /restapis/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /restapis/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /restapis/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /restapis s3 APIGateway APIGateway +GET localhost:4566 /restore-access-backup-vaults/key.txt s3 S3 S3 +PUT localhost:4566 /restore-access-backup-vaults/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /restore-access-backup-vaults/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /restore-access-backup-vaults s3 S3 S3 +GET localhost:4566 /restore-jobs/key.txt s3 S3 S3 +PUT localhost:4566 /restore-jobs/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /restore-jobs/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /restore-jobs s3 S3 S3 +GET localhost:4566 /restore-testing/key.txt s3 S3 S3 +PUT localhost:4566 /restore-testing/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /restore-testing/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /restore-testing s3 S3 S3 +GET localhost:4566 /retainedMessage/key.txt s3 IoTDataPlane IoTDataPlane +PUT localhost:4566 /retainedMessage/key.txt s3 application/octet-stream body IoTDataPlane IoTDataPlane +GET localhost:4566 /retainedMessage/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoTDataPlane IoTDataPlane +HEAD localhost:4566 /retainedMessage s3 IoTDataPlane IoTDataPlane +GET localhost:4566 /reveal-configuration/key.txt s3 S3 S3 +PUT localhost:4566 /reveal-configuration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /reveal-configuration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /reveal-configuration s3 S3 S3 +GET localhost:4566 /routing-policy-label/key.txt s3 S3 S3 +PUT localhost:4566 /routing-policy-label/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /routing-policy-label/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /routing-policy-label s3 S3 S3 +GET localhost:4566 /routing-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /routing-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /routing-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /routing-profiles s3 S3 S3 +GET localhost:4566 /routingcontrol/key.txt s3 S3 S3 +PUT localhost:4566 /routingcontrol/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /routingcontrol/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /routingcontrol s3 S3 S3 +GET localhost:4566 /rules/key.txt s3 IoT IoT +PUT localhost:4566 /rules/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /rules/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /rules s3 S3 S3 +GET localhost:4566 /rummetrics/key.txt s3 S3 S3 +PUT localhost:4566 /rummetrics/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /rummetrics/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /rummetrics s3 S3 S3 +GET localhost:4566 /run/key.txt s3 Omics Omics +PUT localhost:4566 /run/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /run/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /run s3 Omics Omics +GET localhost:4566 /runtimes/key.txt s3 S3 S3 +PUT localhost:4566 /runtimes/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /runtimes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /runtimes s3 S3 S3 +GET localhost:4566 /s3accesspolicy/key.txt s3 Omics Omics +PUT localhost:4566 /s3accesspolicy/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /s3accesspolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /s3accesspolicy s3 Omics Omics +GET localhost:4566 /safetyLevers/key.txt s3 FIS FIS +PUT localhost:4566 /safetyLevers/key.txt s3 application/octet-stream body FIS FIS +GET localhost:4566 /safetyLevers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 FIS FIS +HEAD localhost:4566 /safetyLevers s3 FIS FIS +GET localhost:4566 /safetyrule/key.txt s3 S3 S3 +PUT localhost:4566 /safetyrule/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /safetyrule/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /safetyrule s3 S3 S3 +GET localhost:4566 /sbomexport/key.txt s3 S3 S3 +PUT localhost:4566 /sbomexport/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /sbomexport/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /sbomexport s3 S3 S3 +GET localhost:4566 /scan/key.txt s3 S3 S3 +PUT localhost:4566 /scan/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /scan/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /scan s3 S3 S3 +GET localhost:4566 /scraperconfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /scraperconfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /scraperconfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /scraperconfiguration s3 S3 S3 +GET localhost:4566 /scrapers/key.txt s3 S3 S3 +PUT localhost:4566 /scrapers/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /scrapers/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /scrapers s3 S3 S3 +GET localhost:4566 /sdktypes/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /sdktypes/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /sdktypes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /sdktypes s3 APIGateway APIGateway +GET localhost:4566 /search-/key.txt s3 S3 S3 +PUT localhost:4566 /search-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /search-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /search- s3 S3 S3 +GET localhost:4566 /security-profiles/key.txt s3 IoT IoT +PUT localhost:4566 /security-profiles/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /security-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /security-profiles s3 IoT IoT +GET localhost:4566 /securityControl/key.txt s3 S3 S3 +PUT localhost:4566 /securityControl/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /securityControl/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /securityControl s3 S3 S3 +GET localhost:4566 /sequencestore/key.txt s3 Omics Omics +PUT localhost:4566 /sequencestore/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /sequencestore/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /sequencestore s3 Omics Omics +GET localhost:4566 /service-endpoint/key.txt s3 S3 S3 +PUT localhost:4566 /service-endpoint/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /service-endpoint/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /service-endpoint s3 S3 S3 +GET localhost:4566 /service-linked-analyzer/key.txt s3 AccessAnalyzer AccessAnalyzer +PUT localhost:4566 /service-linked-analyzer/key.txt s3 application/octet-stream body AccessAnalyzer AccessAnalyzer +GET localhost:4566 /service-linked-analyzer/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AccessAnalyzer AccessAnalyzer +HEAD localhost:4566 /service-linked-analyzer s3 AccessAnalyzer AccessAnalyzer +GET localhost:4566 /service-profiles/key.txt s3 S3 S3 +PUT localhost:4566 /service-profiles/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /service-profiles/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /service-profiles s3 S3 S3 +GET localhost:4566 /servicenetworkresourceassociations/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /servicenetworkresourceassociations/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /servicenetworkresourceassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /servicenetworkresourceassociations s3 VPCLattice VPCLattice +GET localhost:4566 /servicenetworks/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /servicenetworks/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /servicenetworks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /servicenetworks s3 VPCLattice VPCLattice +GET localhost:4566 /servicenetworkvpcassociations/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /servicenetworkvpcassociations/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /servicenetworkvpcassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /servicenetworkvpcassociations s3 VPCLattice VPCLattice +GET localhost:4566 /servicenetworkvpcendpointassociations/key.txt s3 S3 S3 +PUT localhost:4566 /servicenetworkvpcendpointassociations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /servicenetworkvpcendpointassociations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /servicenetworkvpcendpointassociations s3 VPCLattice VPCLattice +GET localhost:4566 /services/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /services/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /services/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /services s3 VPCLattice VPCLattice +GET localhost:4566 /setdefaultpermissionversion/key.txt s3 S3 S3 +PUT localhost:4566 /setdefaultpermissionversion/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /setdefaultpermissionversion/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /setdefaultpermissionversion s3 S3 S3 +GET localhost:4566 /settings/key.txt s3 S3 S3 +PUT localhost:4566 /settings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /settings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /settings s3 AppConfig AppConfig +GET localhost:4566 /share/key.txt s3 Omics Omics +PUT localhost:4566 /share/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /share/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /share s3 Omics Omics +GET localhost:4566 /site-to-site-vpn-attachments/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /site-to-site-vpn-attachments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /site-to-site-vpn-attachments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /site-to-site-vpn-attachments s3 S3 S3 +GET localhost:4566 /sites/key.txt s3 S3 S3 +PUT localhost:4566 /sites/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /sites/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /sites s3 S3 S3 +GET localhost:4566 /sourceLocation/key.txt s3 S3 S3 +PUT localhost:4566 /sourceLocation/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /sourceLocation/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 MediaTailor MediaTailor +HEAD localhost:4566 /sourceLocation s3 S3 S3 +GET localhost:4566 /standards/key.txt s3 S3 S3 +PUT localhost:4566 /standards/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /standards/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /standards s3 S3 S3 +GET localhost:4566 /startEdgeConfigurationUpdate/key.txt s3 S3 S3 +PUT localhost:4566 /startEdgeConfigurationUpdate/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /startEdgeConfigurationUpdate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /startEdgeConfigurationUpdate s3 KinesisVideo KinesisVideo +GET localhost:4566 /startPrimaryEmailUpdate/key.txt s3 S3 S3 +PUT localhost:4566 /startPrimaryEmailUpdate/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /startPrimaryEmailUpdate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /startPrimaryEmailUpdate s3 S3 S3 +GET localhost:4566 /status/key.txt s3 S3 S3 +PUT localhost:4566 /status/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /status/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /status s3 S3 S3 +GET localhost:4566 /stream/key.txt s3 DSQL DSQL +PUT localhost:4566 /stream/key.txt s3 application/octet-stream body DSQL DSQL +GET localhost:4566 /stream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 DSQL DSQL +HEAD localhost:4566 /stream s3 S3 S3 +GET localhost:4566 /subject/key.txt s3 RolesAnywhere RolesAnywhere +PUT localhost:4566 /subject/key.txt s3 application/octet-stream body RolesAnywhere RolesAnywhere +GET localhost:4566 /subject/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 RolesAnywhere RolesAnywhere +HEAD localhost:4566 /subject s3 S3 S3 +GET localhost:4566 /supported-resource-types/key.txt s3 S3 S3 +PUT localhost:4566 /supported-resource-types/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /supported-resource-types/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /supported-resource-types s3 S3 S3 +GET localhost:4566 /table-bucket-replication/key.txt s3 S3tables S3tables +PUT localhost:4566 /table-bucket-replication/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /table-bucket-replication/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /table-bucket-replication s3 S3tables S3tables +GET localhost:4566 /table-record-expiration/key.txt s3 S3tables S3tables +PUT localhost:4566 /table-record-expiration/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /table-record-expiration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /table-record-expiration s3 S3tables S3tables +GET localhost:4566 /table-replication/key.txt s3 S3tables S3tables +PUT localhost:4566 /table-replication/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /table-replication/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /table-replication s3 S3tables S3tables +GET localhost:4566 /tables/key.txt s3 S3tables S3tables +PUT localhost:4566 /tables/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /tables/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /tables s3 S3tables S3tables +GET localhost:4566 /tag/key.txt s3 S3tables S3tables +PUT localhost:4566 /tag/key.txt s3 application/octet-stream body S3tables S3tables +GET localhost:4566 /tag/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3tables S3tables +HEAD localhost:4566 /tag s3 S3 S3 +GET localhost:4566 /tagResource/key.txt s3 S3 S3 +PUT localhost:4566 /tagResource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /tagResource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /tagResource s3 S3 S3 +GET localhost:4566 /tagStream/key.txt s3 S3 S3 +PUT localhost:4566 /tagStream/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /tagStream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /tagStream s3 KinesisVideo KinesisVideo +GET localhost:4566 /tagresource/key.txt s3 S3 S3 +PUT localhost:4566 /tagresource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /tagresource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /tagresource s3 S3 S3 +GET localhost:4566 /tags/key.txt s3 S3 S3 +PUT localhost:4566 /tags/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /tags/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 DSQL DSQL +HEAD localhost:4566 /tags s3 S3 S3 +GET localhost:4566 /target-policies/key.txt s3 IoT IoT +PUT localhost:4566 /target-policies/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /target-policies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /target-policies s3 S3 S3 +GET localhost:4566 /targetResourceTypes/key.txt s3 FIS FIS +PUT localhost:4566 /targetResourceTypes/key.txt s3 application/octet-stream body FIS FIS +GET localhost:4566 /targetResourceTypes/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 FIS FIS +HEAD localhost:4566 /targetResourceTypes s3 FIS FIS +GET localhost:4566 /targetgroups/key.txt s3 VPCLattice VPCLattice +PUT localhost:4566 /targetgroups/key.txt s3 application/octet-stream body VPCLattice VPCLattice +GET localhost:4566 /targetgroups/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 VPCLattice VPCLattice +HEAD localhost:4566 /targetgroups s3 VPCLattice VPCLattice +GET localhost:4566 /templates/key.txt s3 S3 S3 +PUT localhost:4566 /templates/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /templates/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Macie2 Macie2 +HEAD localhost:4566 /templates s3 S3 S3 +GET localhost:4566 /test-case/key.txt s3 S3 S3 +PUT localhost:4566 /test-case/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /test-case/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /test-case s3 S3 S3 +GET localhost:4566 /things/key.txt s3 IoT IoT +PUT localhost:4566 /things/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /things/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /things s3 IoT IoT +GET localhost:4566 /ticketsv2/key.txt s3 S3 S3 +PUT localhost:4566 /ticketsv2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /ticketsv2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /ticketsv2 s3 S3 S3 +GET localhost:4566 /tiering-configurations/key.txt s3 S3 S3 +PUT localhost:4566 /tiering-configurations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /tiering-configurations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /tiering-configurations s3 S3 S3 +GET localhost:4566 /token/key.txt s3 S3 S3 +PUT localhost:4566 /token/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /token/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /token s3 S3 S3 +GET localhost:4566 /topics/key.txt s3 IoTDataPlane IoTDataPlane +PUT localhost:4566 /topics/key.txt s3 application/octet-stream body IoTDataPlane IoTDataPlane +GET localhost:4566 /topics/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoTDataPlane IoTDataPlane +HEAD localhost:4566 /topics s3 S3 S3 +GET localhost:4566 /traffic-distribution/key.txt s3 S3 S3 +PUT localhost:4566 /traffic-distribution/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /traffic-distribution/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /traffic-distribution s3 S3 S3 +GET localhost:4566 /transit-gateway-peerings/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /transit-gateway-peerings/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /transit-gateway-peerings/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /transit-gateway-peerings s3 S3 S3 +GET localhost:4566 /transit-gateway-route-table-attachments/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /transit-gateway-route-table-attachments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /transit-gateway-route-table-attachments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /transit-gateway-route-table-attachments s3 S3 S3 +GET localhost:4566 /trustanchor/key.txt s3 RolesAnywhere RolesAnywhere +PUT localhost:4566 /trustanchor/key.txt s3 application/octet-stream body RolesAnywhere RolesAnywhere +GET localhost:4566 /trustanchor/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 RolesAnywhere RolesAnywhere +HEAD localhost:4566 /trustanchor s3 S3 S3 +GET localhost:4566 /untag/key.txt s3 S3 S3 +PUT localhost:4566 /untag/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /untag/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Backup Backup +HEAD localhost:4566 /untag s3 IoT IoT +GET localhost:4566 /untagResource/key.txt s3 S3 S3 +PUT localhost:4566 /untagResource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /untagResource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /untagResource s3 S3 S3 +GET localhost:4566 /untagStream/key.txt s3 S3 S3 +PUT localhost:4566 /untagStream/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /untagStream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /untagStream s3 KinesisVideo KinesisVideo +GET localhost:4566 /untagresource/key.txt s3 S3 S3 +PUT localhost:4566 /untagresource/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /untagresource/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /untagresource s3 S3 S3 +GET localhost:4566 /updateDataRetention/key.txt s3 S3 S3 +PUT localhost:4566 /updateDataRetention/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /updateDataRetention/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /updateDataRetention s3 KinesisVideo KinesisVideo +GET localhost:4566 /updateImageGenerationConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /updateImageGenerationConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /updateImageGenerationConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /updateImageGenerationConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /updateMediaStorageConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /updateMediaStorageConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /updateMediaStorageConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /updateMediaStorageConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /updateNotificationConfiguration/key.txt s3 S3 S3 +PUT localhost:4566 /updateNotificationConfiguration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /updateNotificationConfiguration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /updateNotificationConfiguration s3 KinesisVideo KinesisVideo +GET localhost:4566 /updateSignalingChannel/key.txt s3 S3 S3 +PUT localhost:4566 /updateSignalingChannel/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /updateSignalingChannel/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /updateSignalingChannel s3 KinesisVideo KinesisVideo +GET localhost:4566 /updateStream/key.txt s3 S3 S3 +PUT localhost:4566 /updateStream/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /updateStream/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /updateStream s3 KinesisVideo KinesisVideo +GET localhost:4566 /updateresourceshare/key.txt s3 S3 S3 +PUT localhost:4566 /updateresourceshare/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /updateresourceshare/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /updateresourceshare s3 S3 S3 +GET localhost:4566 /usage/key.txt s3 S3 S3 +PUT localhost:4566 /usage/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /usage/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /usage s3 S3 S3 +GET localhost:4566 /usageplans/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /usageplans/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /usageplans/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /usageplans s3 APIGateway APIGateway +GET localhost:4566 /use-case-for-model-access/key.txt s3 S3 S3 +PUT localhost:4566 /use-case-for-model-access/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /use-case-for-model-access/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /use-case-for-model-access s3 S3 S3 +GET localhost:4566 /user/key.txt s3 S3 S3 +PUT localhost:4566 /user/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /user/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /user s3 S3 S3 +GET localhost:4566 /v1/key.txt s3 S3 S3 +PUT localhost:4566 /v1/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /v1/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Batch Batch +HEAD localhost:4566 /v1 s3 S3 S3 +GET localhost:4566 /v2/key.txt s3 S3 S3 +PUT localhost:4566 /v2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /v2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /v2 s3 S3 S3 +GET localhost:4566 /v20180820/key.txt s3 S3Control S3Control +PUT localhost:4566 /v20180820/key.txt s3 application/octet-stream body S3Control S3Control +GET localhost:4566 /v20180820/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3Control S3Control +HEAD localhost:4566 /v20180820 s3 S3 S3 +GET localhost:4566 /v20190125/key.txt s3 AppMesh AppMesh +PUT localhost:4566 /v20190125/key.txt s3 application/octet-stream body AppMesh AppMesh +GET localhost:4566 /v20190125/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 AppMesh AppMesh +HEAD localhost:4566 /v20190125 s3 S3 S3 +GET localhost:4566 /variantStore/key.txt s3 Omics Omics +PUT localhost:4566 /variantStore/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /variantStore/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /variantStore s3 Omics Omics +GET localhost:4566 /versions/key.txt s3 S3 S3 +PUT localhost:4566 /versions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /versions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /versions s3 Grafana Grafana +GET localhost:4566 /views/key.txt s3 S3 S3 +PUT localhost:4566 /views/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /views/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /views s3 S3 S3 +GET localhost:4566 /vocabulary/key.txt s3 S3 S3 +PUT localhost:4566 /vocabulary/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /vocabulary/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /vocabulary s3 S3 S3 +GET localhost:4566 /vpc-attachments/key.txt s3 NetworkManager NetworkManager +PUT localhost:4566 /vpc-attachments/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /vpc-attachments/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 NetworkManager NetworkManager +HEAD localhost:4566 /vpc-attachments s3 S3 S3 +GET localhost:4566 /vpclinks/key.txt s3 APIGateway APIGateway +PUT localhost:4566 /vpclinks/key.txt s3 application/octet-stream body APIGateway APIGateway +GET localhost:4566 /vpclinks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 APIGateway APIGateway +HEAD localhost:4566 /vpclinks s3 APIGateway APIGateway +GET localhost:4566 /vulnerabilities/key.txt s3 S3 S3 +PUT localhost:4566 /vulnerabilities/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /vulnerabilities/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Inspector2 Inspector2 +HEAD localhost:4566 /vulnerabilities s3 S3 S3 +GET localhost:4566 /webhooks/key.txt s3 Amplify Amplify +PUT localhost:4566 /webhooks/key.txt s3 application/octet-stream body Amplify Amplify +GET localhost:4566 /webhooks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Amplify Amplify +HEAD localhost:4566 /webhooks s3 S3 S3 +GET localhost:4566 /webtoken/key.txt s3 S3 S3 +PUT localhost:4566 /webtoken/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /webtoken/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /webtoken s3 S3 S3 +GET localhost:4566 /wireless-devices/key.txt s3 S3 S3 +PUT localhost:4566 /wireless-devices/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /wireless-devices/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /wireless-devices s3 S3 S3 +GET localhost:4566 /wireless-gateway-task-definitions/key.txt s3 S3 S3 +PUT localhost:4566 /wireless-gateway-task-definitions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /wireless-gateway-task-definitions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /wireless-gateway-task-definitions s3 S3 S3 +GET localhost:4566 /wireless-gateways/key.txt s3 S3 S3 +PUT localhost:4566 /wireless-gateways/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /wireless-gateways/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /wireless-gateways s3 S3 S3 +GET localhost:4566 /wireless_device_import_task/key.txt s3 S3 S3 +PUT localhost:4566 /wireless_device_import_task/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /wireless_device_import_task/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /wireless_device_import_task s3 S3 S3 +GET localhost:4566 /wireless_single_device_import_task/key.txt s3 S3 S3 +PUT localhost:4566 /wireless_single_device_import_task/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /wireless_single_device_import_task/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /wireless_single_device_import_task s3 S3 S3 +GET localhost:4566 /workflow/key.txt s3 Omics Omics +PUT localhost:4566 /workflow/key.txt s3 application/octet-stream body Omics Omics +GET localhost:4566 /workflow/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Omics Omics +HEAD localhost:4566 /workflow s3 Omics Omics +GET localhost:4566 /workspaces/key.txt s3 Grafana Grafana +PUT localhost:4566 /workspaces/key.txt s3 application/octet-stream body Grafana Grafana +GET localhost:4566 /workspaces/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 Grafana Grafana +HEAD localhost:4566 /workspaces s3 Grafana Grafana +GET localhost:4566 /zonalshifts/key.txt s3 S3 S3 +PUT localhost:4566 /zonalshifts/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /zonalshifts/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /zonalshifts s3 S3 S3 +POST localhost:4566 //workspaces/xworksp/alertmanager/definition aps User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/amp#1.0.0 AMP AMP +POST localhost:4566 ///workspaces/xworksp/alertmanager/definition aps User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/amp#1.0.0 AMP AMP +POST localhost:4566 //workspaces/xworksp/alertmanager/definition execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/amp#1.0.0 AMP AMP +POST localhost:4566 ///workspaces/xworksp/alertmanager/definition execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/amp#1.0.0 AMP AMP +DELETE localhost:4566 //practiceruns/xzonals arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 ///practiceruns/xzonals arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 //practiceruns/xzonals execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 ///practiceruns/xzonals execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +POST localhost:4566 //datasets/xdatase/examples/add bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcorecontrol#1.0.0 BedrockAgentCoreControl BedrockAgentCoreControl +POST localhost:4566 ///datasets/xdatase/examples/add bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcorecontrol#1.0.0 BedrockAgentCoreControl BedrockAgentCoreControl +POST localhost:4566 //datasets/xdatase/examples/add execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcorecontrol#1.0.0 BedrockAgentCoreControl BedrockAgentCoreControl +POST localhost:4566 ///datasets/xdatase/examples/add execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcorecontrol#1.0.0 BedrockAgentCoreControl BedrockAgentCoreControl +POST localhost:4566 //memories/xmemory/memoryRecords/batchCreate bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 BedrockAgentCore BedrockAgentCore +POST localhost:4566 ///memories/xmemory/memoryRecords/batchCreate bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 BedrockAgentCore BedrockAgentCore +POST localhost:4566 //memories/xmemory/memoryRecords/batchCreate execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 BedrockAgentCore BedrockAgentCore +POST localhost:4566 ///memories/xmemory/memoryRecords/batchCreate execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 BedrockAgentCore BedrockAgentCore +POST localhost:4566 //associations codeguru-reviewer User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codegurureviewer#1.0.0 CodeGuruReviewer CodeGuruReviewer +POST localhost:4566 ///associations codeguru-reviewer User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codegurureviewer#1.0.0 CodeGuruReviewer CodeGuruReviewer +POST localhost:4566 //associations execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codegurureviewer#1.0.0 CodeGuruReviewer CodeGuruReviewer +POST localhost:4566 ///associations execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codegurureviewer#1.0.0 CodeGuruReviewer CodeGuruReviewer +POST localhost:4566 //policies dlm User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/dlm#1.0.0 Glacier Glacier +POST localhost:4566 //policies execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/dlm#1.0.0 Glacier Glacier +POST localhost:4566 //PutSinkPolicy oam User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/oam#1.0.0 OAM OAM +POST localhost:4566 ///PutSinkPolicy oam User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/oam#1.0.0 OAM OAM +PUT localhost:4566 //tags/xresour oam User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/oam#1.0.0 OAM OAM +PUT localhost:4566 ///tags/xresour oam User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/oam#1.0.0 OAM OAM +POST localhost:4566 //ArchiveApplication mgn User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/mgn#1.0.0 MGN MGN +POST localhost:4566 ///ArchiveApplication mgn User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/mgn#1.0.0 MGN MGN +POST localhost:4566 //ArchiveApplication execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/mgn#1.0.0 MGN MGN +POST localhost:4566 ///ArchiveApplication execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/mgn#1.0.0 MGN MGN +POST localhost:4566 //attachments/xattach/accept networkmanager User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/networkmanager#1.0.0 NetworkManager NetworkManager +POST localhost:4566 ///attachments/xattach/accept networkmanager User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/networkmanager#1.0.0 NetworkManager NetworkManager +POST localhost:4566 //attachments/xattach/accept execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/networkmanager#1.0.0 NetworkManager NetworkManager +POST localhost:4566 ///attachments/xattach/accept execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/networkmanager#1.0.0 NetworkManager NetworkManager +POST localhost:4566 //accept-resource-grouping-recommendations resiliencehub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resiliencehub#1.0.0 ResilienceHub ResilienceHub +POST localhost:4566 ///accept-resource-grouping-recommendations resiliencehub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resiliencehub#1.0.0 ResilienceHub ResilienceHub +POST localhost:4566 //accept-resource-grouping-recommendations execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resiliencehub#1.0.0 ResilienceHub ResilienceHub +POST localhost:4566 ///accept-resource-grouping-recommendations execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resiliencehub#1.0.0 ResilienceHub ResilienceHub +POST localhost:4566 //AssociateDefaultView resource-explorer-2 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resourceexplorer2#1.0.0 ResourceExplorer2 ResourceExplorer2 +POST localhost:4566 ///AssociateDefaultView resource-explorer-2 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resourceexplorer2#1.0.0 ResourceExplorer2 ResourceExplorer2 +POST localhost:4566 //AssociateDefaultView execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resourceexplorer2#1.0.0 ResourceExplorer2 ResourceExplorer2 +POST localhost:4566 ///AssociateDefaultView execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/resourceexplorer2#1.0.0 ResourceExplorer2 ResourceExplorer2 +POST localhost:4566 //cluster route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 ///cluster route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 //cluster execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 ///cluster execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 //cells route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 ///cells route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 //cells execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 ///cells execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 //CreateIndex s3vectors User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3vectors#1.0.0 S3Vectors S3Vectors +POST localhost:4566 ///CreateIndex s3vectors User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3vectors#1.0.0 S3Vectors S3Vectors +POST localhost:4566 //CreateIndex execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3vectors#1.0.0 S3Vectors S3Vectors +POST localhost:4566 ///CreateIndex execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3vectors#1.0.0 S3Vectors S3Vectors +POST localhost:4566 //rummetrics/xappmon/metrics rum User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/rum#1.0.0 RUM RUM +POST localhost:4566 ///rummetrics/xappmon/metrics rum User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/rum#1.0.0 RUM RUM +POST localhost:4566 //rummetrics/xappmon/metrics execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/rum#1.0.0 RUM RUM +POST localhost:4566 ///rummetrics/xappmon/metrics execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/rum#1.0.0 RUM RUM +DELETE localhost:4566 //applications/xarn app-integrations User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/appintegrations#1.0.0 AppIntegrations AppIntegrations +DELETE localhost:4566 ///applications/xarn app-integrations User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/appintegrations#1.0.0 AppIntegrations AppIntegrations +DELETE localhost:4566 //dataIntegrations/xdatain app-integrations User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/appintegrations#1.0.0 AppIntegrations AppIntegrations +DELETE localhost:4566 ///dataIntegrations/xdatain app-integrations User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/appintegrations#1.0.0 AppIntegrations AppIntegrations +POST localhost:4566 /sendPhoneNumberVerification account User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/account#1.0.0 Account Account +POST localhost:4566 /verifyPhoneNumber account User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/account#1.0.0 Account Account +POST localhost:4566 /workspaces/xworksp/alertmanager/definition Grafana Grafana +POST localhost:4566 /workspaces/xworksp/anomalydetectors Grafana Grafana +POST localhost:4566 /workspaces/xworksp/logging Grafana Grafana +POST localhost:4566 /workspaces/xworksp/logging/query Grafana Grafana +POST localhost:4566 /workspaces/xworksp/rulegroupsnamespaces Grafana Grafana +POST localhost:4566 /scrapers S3 S3 +DELETE localhost:4566 /workspaces/xworksp/alertmanager/definition Grafana Grafana +DELETE localhost:4566 /workspaces/xworksp/anomalydetectors/xanomal Grafana Grafana +DELETE localhost:4566 /workspaces/xworksp/logging Grafana Grafana +DELETE localhost:4566 /workspaces/xworksp/logging/query Grafana Grafana +DELETE localhost:4566 /workspaces/xworksp/policy Grafana Grafana +POST localhost:4566 /dataIntegrations S3 S3 +POST localhost:4566 /dataIntegrations/xdatain/associations S3 S3 +POST localhost:4566 /eventIntegrations S3 S3 +DELETE localhost:4566 /applications/xarn AppConfig AppConfig +DELETE localhost:4566 /dataIntegrations/xdatain S3 S3 +DELETE localhost:4566 /eventIntegrations/xname S3 S3 +GET localhost:4566 /applications/xarn AppConfig AppConfig +GET localhost:4566 /dataIntegrations/xidenti S3 S3 +GET localhost:4566 /eventIntegrations/xname S3 S3 +GET localhost:4566 /applications/xapplic/associations AppConfig AppConfig +POST localhost:4566 /v1/canceljobs batch User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/batch#1.0.0 Batch Batch +POST localhost:4566 /v1/canceljobs execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/batch#1.0.0 S3 S3 +POST batch.us-east-1.amazonaws.com /v1/canceljobs batch User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/batch#1.0.0 Batch Batch +POST localhost:4566 /v1/canceljobs?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fbatch%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/batch#1.0.0 Batch Batch +POST localhost:4566 /v1/canceljobs Batch Batch +POST localhost:4566 /v1/terminatejobs batch User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/batch#1.0.0 Batch Batch +POST localhost:4566 /v1/terminateservicejobs batch User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/batch#1.0.0 Batch Batch +POST localhost:4566 /knowledgebases/xknowle/vpcconfigurations/ bedrock User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagent#1.0.0 BedrockAgent BedrockAgent +DELETE localhost:4566 /knowledgebases/xknowle/vpcconfigurations/xvpccon bedrock User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagent#1.0.0 BedrockAgent BedrockAgent +GET localhost:4566 /knowledgebases/xknowle/vpcconfigurations/xvpccon bedrock User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagent#1.0.0 BedrockAgent BedrockAgent +GET localhost:4566 /knowledgebases/xknowle/vpcconfigurations/ bedrock User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagent#1.0.0 BedrockAgent BedrockAgent +POST localhost:4566 /memories/xmemory/memoryRecords/batchCreate S3 S3 +POST localhost:4566 /memories/xmemory/memoryRecords/batchDelete S3 S3 +POST localhost:4566 /memories/xmemory/memoryRecords/batchUpdate S3 S3 +POST localhost:4566 /identities/CompleteResourceTokenAuth bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /identities/CompleteResourceTokenAuth execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST bedrock-agentcore.us-east-1.amazonaws.com /identities/CompleteResourceTokenAuth bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /identities/CompleteResourceTokenAuth?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fbedrock-agentcore%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /identities/CompleteResourceTokenAuth S3 S3 +POST localhost:4566 /ab-tests bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /ab-tests S3 S3 +POST localhost:4566 /memories/xmemory/events S3 S3 +POST localhost:4566 /payments/createPaymentInstrument bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/createPaymentInstrument S3 S3 +POST localhost:4566 /payments/createPaymentSession bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/createPaymentSession S3 S3 +DELETE localhost:4566 /ab-tests/xabtest bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +DELETE localhost:4566 /ab-tests/xabtest S3 S3 +DELETE localhost:4566 /evaluations/batch-evaluate/xbatche bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +DELETE localhost:4566 /evaluations/batch-evaluate/xbatche S3 S3 +DELETE localhost:4566 /capacity-providers/xcapaci/sessions/xsessio bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +DELETE localhost:4566 /capacity-providers/xcapaci/sessions/xsessio S3 S3 +DELETE localhost:4566 /memories/xmemory/actor/xactori/sessions/xsessio/events/xeventi S3 S3 +POST localhost:4566 /payments/deletePaymentInstrument bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/deletePaymentSession bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +DELETE localhost:4566 /recommendations/xrecomm bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /evaluations/evaluate/xevalua bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +GET localhost:4566 /ab-tests/xabtest bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +GET localhost:4566 /evaluations/batch-evaluate/xbatche bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/getPaymentInstrument bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/getPaymentInstrumentBalance bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/getPaymentSession bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +GET localhost:4566 /recommendations/xrecomm bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /identities/payment/token bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /memories/xmemory/ingest bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /runtimes/xagentr/commands bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /browsers/xbrowse/sessions/invoke bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /harnesses/invoke bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 BedrockAgentCoreControl BedrockAgentCoreControl +GET localhost:4566 /ab-tests bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +GET localhost:4566 /evaluations/batch-evaluate bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /memories/xmemory/extractionJobs bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/listPaymentInstruments bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /payments/listPaymentSessions bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +GET localhost:4566 /recommendations bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 Personalize Personalize +POST localhost:4566 /payments/processPayment bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +PUT localhost:4566 /browser-profiles/xprofil/save bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /registry-records/search bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /evaluations/batch-evaluate bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /memories/xmemory/extractionJobs/start bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /recommendations bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 Personalize Personalize +POST localhost:4566 /evaluations/batch-evaluate/xbatche/stop bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +PUT localhost:4566 /ab-tests/xabtest bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +PUT localhost:4566 /browsers/xbrowse/sessions/streams/update bedrock-agentcore User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/bedrockagentcore#1.0.0 S3 S3 +POST localhost:4566 /datasets/xdatase/examples/add IoTAnalytics IoTAnalytics +PUT localhost:4566 /gateways/xgatewa/rate-limits/batch S3 S3 +PUT localhost:4566 /runtimes/ S3 S3 +PUT localhost:4566 /runtimes/xagentr/runtime-endpoints/ S3 S3 +POST localhost:4566 /identities/CreateApiKeyCredentialProvider S3 S3 +PUT localhost:4566 /browsers S3 S3 +PUT localhost:4566 /browser-profiles S3 S3 +PUT localhost:4566 /capacity-providers S3 S3 +PUT localhost:4566 /code-interpreters S3 S3 +POST localhost:4566 /configuration-bundles/create S3 S3 +POST localhost:4566 /identities/CreateConsentPortal S3 S3 +GET localhost:4566 /memberships/xmember/analysislogexports/xanalys cleanrooms User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cleanrooms#1.0.0 CleanRooms CleanRooms +GET localhost:4566 /memberships/xmember/analysislogexports cleanrooms User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cleanrooms#1.0.0 CleanRooms CleanRooms +POST localhost:4566 /memberships/xmember/analysislogexports cleanrooms User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cleanrooms#1.0.0 CleanRooms CleanRooms +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/CreateResourceMetricsConfiguration monitoring application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/CreateResourceMetricsConfiguration execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST monitoring.us-east-1.amazonaws.com /service/GraniteServiceVersion20100801/operation/CreateResourceMetricsConfiguration monitoring application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/CreateResourceMetricsConfiguration?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fmonitoring%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/CreateResourceMetricsConfiguration application/cbor Smithy-Protocol:rpc-v2-cbor CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/DeleteResourceMetricsConfiguration monitoring application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/DeleteResourceMetricsConfiguration application/cbor Smithy-Protocol:rpc-v2-cbor CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/GetResourceMetricsConfiguration monitoring application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/UpdateOTelEnrichment monitoring application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST localhost:4566 /service/GraniteServiceVersion20100801/operation/UpdateResourceMetricsConfiguration monitoring application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cloudwatch#1.0.0 CloudWatch CloudWatch +POST localhost:4566 / codecommit application/x-amz-json-1.1 CodeCommit_20150413.GetBlobDifferences {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codecommit#1.0.0 CodeCommit CodeCommit +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateConnection codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateConnection execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST codeconnections.us-east-1.amazonaws.com /service/CodeConnections_20231201/operation/CreateConnection codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateConnection?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcodeconnections%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateConnection application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateHost codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateHost execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST codeconnections.us-east-1.amazonaws.com /service/CodeConnections_20231201/operation/CreateHost codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateHost?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcodeconnections%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateHost application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateRepositoryLink codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateRepositoryLink execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST codeconnections.us-east-1.amazonaws.com /service/CodeConnections_20231201/operation/CreateRepositoryLink codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateRepositoryLink?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcodeconnections%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateRepositoryLink application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateSyncConfiguration codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateSyncConfiguration execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST codeconnections.us-east-1.amazonaws.com /service/CodeConnections_20231201/operation/CreateSyncConfiguration codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateSyncConfiguration?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcodeconnections%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/CreateSyncConfiguration application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteConnection codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteConnection application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteHost codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteHost application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteRepositoryLink codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteRepositoryLink application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteSyncConfiguration codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/DeleteSyncConfiguration application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetConnection codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetConnection application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetHost codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetHost application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetRepositoryLink codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetRepositoryLink application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetRepositorySyncStatus codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetRepositorySyncStatus application/cbor Smithy-Protocol:rpc-v2-cbor CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetResourceSyncStatus codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetSyncBlockerSummary codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/GetSyncConfiguration codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/ListConnections codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/ListHosts codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/ListRepositoryLinks codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/ListRepositorySyncDefinitions codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/ListSyncConfigurations codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/ListTagsForResource codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/TagResource codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/UntagResource codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/UpdateHost codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/UpdateRepositoryLink codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/UpdateSyncBlocker codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /service/CodeConnections_20231201/operation/UpdateSyncConfiguration codeconnections application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/codeconnections#1.0.0 CodeConnections CodeConnections +POST localhost:4566 /associations SecurityHub SecurityHub +POST localhost:4566 /codereviews S3 S3 +GET localhost:4566 /codereviews/xcodere S3 S3 +GET localhost:4566 /feedback/xcodere S3 S3 +GET localhost:4566 /associations/xassoci SecurityHub SecurityHub +DELETE localhost:4566 /associations/xassoci SecurityHub SecurityHub +GET localhost:4566 /codereviews S3 S3 +GET localhost:4566 /feedback/xcodere/RecommendationFeedback S3 S3 +GET localhost:4566 /codereviews/xcodere/Recommendations S3 S3 +GET localhost:4566 /associations SecurityHub SecurityHub +PUT localhost:4566 /feedback S3 S3 +POST localhost:4566 / cognito-idp application/x-amz-json-1.1 AWSCognitoIdentityProviderService.AdminDeleteSoftwareToken {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cognitoidentityprovider#1.0.0 CognitoIDP CognitoIDP +POST localhost:4566 / application/x-amz-json-1.1 AWSCognitoIdentityProviderService.AdminDeleteSoftwareToken {} CognitoIDP CognitoIDP +POST localhost:4566 / cognito-idp application/x-amz-json-1.1 AWSCognitoIdentityProviderService.DescribeTermsByClient {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cognitoidentityprovider#1.0.0 CognitoIDP CognitoIDP +POST localhost:4566 / cognito-idp application/x-amz-json-1.1 AWSCognitoIdentityProviderService.GetClientToken {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/cognitoidentityprovider#1.0.0 CognitoIDP CognitoIDP +POST localhost:4566 /evaluation-forms/xinstan/xevalua/activate S3 S3 +PUT localhost:4566 /analytics-data/instance/xinstan/association S3 S3 +PUT localhost:4566 /instance/xinstan/approved-origin S3 S3 +PUT localhost:4566 /instance/xinstan/bot S3 S3 +POST localhost:4566 /contacts/xinstan/xcontac/associate-user S3 S3 +PUT localhost:4566 /default-vocabulary/xinstan/xlangua S3 S3 +POST localhost:4566 /email-addresses/xinstan/xemaila/associate-alias S3 S3 +PUT localhost:4566 /flow-associations/xinstan S3 S3 +POST localhost:4566 /hours-of-operations/xinstan/xhourso/associate-hours S3 S3 +PUT localhost:4566 /instance/xinstan/storage-config S3 S3 +PUT localhost:4566 /instance/xinstan/lambda-function S3 S3 +PUT localhost:4566 /instance/xinstan/lex-bot S3 S3 +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.AssociateConnectionsToResiliencyGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / application/x-amz-json-1.1 OvertureService.AssociateConnectionsToResiliencyGroup {} DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.CreateResiliencyGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.DeleteResiliencyGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.DisassociateConnectionsFromResiliencyGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.GetResiliencyGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.ListResiliencyGroupAssociations {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.ListResiliencyGroups {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.UpdateConnectionsBillingMode {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / directconnect application/x-amz-json-1.1 OvertureService.UpdateResiliencyGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/directconnect#1.0.0 DirectConnect DirectConnect +POST localhost:4566 / ec2 application/x-www-form-urlencoded Action=CreateCapacityReservationDateChangeQuote&Version=2016-11-15 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/ec2#1.0.0 EC2 EC2 +POST localhost:4566 / ec2 application/x-www-form-urlencoded Action=DeleteClientVpnEndpointAuthorizationPolicy&Version=2016-11-15 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/ec2#1.0.0 EC2 EC2 +POST localhost:4566 / ec2 application/x-www-form-urlencoded Action=DescribeCapacityReservationDateChangeQuotes&Version=2016-11-15 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/ec2#1.0.0 EC2 EC2 +POST localhost:4566 / ec2 application/x-www-form-urlencoded Action=GetClientVpnEndpointAuthorizationPolicy&Version=2016-11-15 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/ec2#1.0.0 EC2 EC2 +POST localhost:4566 / ec2 application/x-www-form-urlencoded Action=ModifyClientVpnEndpointAuthorizationPolicy&Version=2016-11-15 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/ec2#1.0.0 EC2 EC2 +POST localhost:4566 /clusters/xcluste/assume-role-for-pod-identity eks-auth User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/eksauth#1.0.0 EKS EKS +POST localhost:4566 /clusters/xcluste/assume-role-for-pod-identity execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/eksauth#1.0.0 EKS EKS +POST eks-auth.us-east-1.amazonaws.com /clusters/xcluste/assume-role-for-pod-identity eks-auth User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/eksauth#1.0.0 EKS EKS +POST localhost:4566 /clusters/xcluste/assume-role-for-pod-identity?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Feks-auth%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/eksauth#1.0.0 EKS EKS +POST localhost:4566 /clusters/xcluste/assume-role-for-pod-identity EKS EKS +POST localhost:4566 / glue application/x-amz-json-1.1 AWSGlue.GetSystemLogsForJobRun {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/glue#1.0.0 Glue Glue +POST localhost:4566 / glue application/x-amz-json-1.1 AWSGlue.GetSystemLogsForSession {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/glue#1.0.0 Glue Glue +POST localhost:4566 / glue application/x-amz-json-1.1 AWSGlue.ListIntegrationTableProperties {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/glue#1.0.0 Glue Glue +POST localhost:4566 /custom-detection-rule/association guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +POST localhost:4566 /custom-detection-rule/association execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +POST guardduty.us-east-1.amazonaws.com /custom-detection-rule/association guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +POST localhost:4566 /custom-detection-rule/association?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fguardduty%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +POST localhost:4566 /custom-detection-rule/association S3 S3 +POST localhost:4566 /custom-detection-rule/org-configuration guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +POST localhost:4566 /custom-detection-rule/org-configuration S3 S3 +DELETE localhost:4566 /custom-detection-rule/rule/xruleid/association/xassoci guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +DELETE localhost:4566 /custom-detection-rule/org-configuration/xruleid guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +GET localhost:4566 /custom-detection-rule/rule/xruleid guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +GET localhost:4566 /custom-detection-rule/rule/xruleid/association/xassoci guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +GET localhost:4566 /custom-detection-rule/org-configuration/xruleid guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +GET localhost:4566 /custom-detection-rule/association guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +GET localhost:4566 /custom-detection-rule/org-configuration guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +POST localhost:4566 /custom-detection-rule/rule guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +PUT localhost:4566 /custom-detection-rule/rule/xruleid/association/xassoci guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +PUT localhost:4566 /custom-detection-rule/org-configuration/xruleid guardduty User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/guardduty#1.0.0 S3 S3 +POST localhost:4566 / identitystore application/x-amz-json-1.1 AWSIdentityStore.DescribeIdentityStore {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/identitystore#1.0.0 IdentityStore IdentityStore +POST localhost:4566 / application/x-amz-json-1.1 AWSIdentityStore.DescribeIdentityStore {} IdentityStore IdentityStore +POST localhost:4566 / identitystore application/x-amz-json-1.1 AWSIdentityStore.ListIdentityStores {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/identitystore#1.0.0 IdentityStore IdentityStore +POST localhost:4566 / application/x-amz-json-1.1 AWSIdentityStore.ListIdentityStores {} IdentityStore IdentityStore +POST localhost:4566 / identitystore application/x-amz-json-1.1 AWSIdentityStore.UpdateIdentityStore {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/identitystore#1.0.0 IdentityStore IdentityStore +GET localhost:4566 /things/xthingn/jobs/xjobid iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET localhost:4566 /things/xthingn/jobs/xjobid execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET iot-jobs-data.us-east-1.amazonaws.com /things/xthingn/jobs/xjobid iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET localhost:4566 /things/xthingn/jobs/xjobid?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fiot-jobs-data%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET localhost:4566 /things/xthingn/jobs/xjobid IoT IoT +GET localhost:4566 /things/xthingn/jobs iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET localhost:4566 /things/xthingn/jobs execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET iot-jobs-data.us-east-1.amazonaws.com /things/xthingn/jobs iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET localhost:4566 /things/xthingn/jobs?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fiot-jobs-data%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +GET localhost:4566 /things/xthingn/jobs IoT IoT +POST localhost:4566 /command-executions iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +POST localhost:4566 /command-executions execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +POST iot-jobs-data.us-east-1.amazonaws.com /command-executions iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +POST localhost:4566 /command-executions?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fiot-jobs-data%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +POST localhost:4566 /command-executions IoT IoT +PUT localhost:4566 /things/xthingn/jobs/$next iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +PUT localhost:4566 /things/xthingn/jobs/$next execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +PUT iot-jobs-data.us-east-1.amazonaws.com /things/xthingn/jobs/$next iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +PUT localhost:4566 /things/xthingn/jobs/$next?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fiot-jobs-data%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +PUT localhost:4566 /things/xthingn/jobs/$next IoT IoT +POST localhost:4566 /things/xthingn/jobs/xjobid iot-jobs-data User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/iotjobsdataplane#1.0.0 IoT IoT +POST localhost:4566 /things/xthingn/jobs/xjobid IoT IoT +POST localhost:4566 / kinesis application/x-amz-json-1.1 Kinesis_20131202.UpdateStreamRecordDistributionStrategy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/kinesis#1.0.0 Kinesis Kinesis +POST localhost:4566 / lightsail application/x-amz-json-1.1 Lightsail_20161128.GetProfile {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/lightsail#1.0.0 Lightsail Lightsail +POST localhost:4566 /CreateLink S3 S3 +POST localhost:4566 /CreateSink S3 S3 +POST localhost:4566 /DeleteLink S3 S3 +POST localhost:4566 /DeleteSink S3 S3 +POST localhost:4566 /GetLink S3 S3 +POST localhost:4566 /GetSink S3 S3 +POST localhost:4566 /GetSinkPolicy S3 S3 +POST localhost:4566 /ListAttachedLinks S3 S3 +POST localhost:4566 /ListLinks S3 S3 +POST localhost:4566 /ListSinks S3 S3 +POST localhost:4566 /PutSinkPolicy S3 S3 +POST localhost:4566 /outposts/xoutpos/privateConnectivity outposts User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/outposts#1.0.0 Outposts Outposts +POST localhost:4566 /outposts/xoutpos/privateConnectivity S3 S3 +GET localhost:4566 /outposts/xoutpos/privateConnectivity outposts User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/outposts#1.0.0 Outposts Outposts +POST localhost:4566 /AssociateDefaultView S3 S3 +POST localhost:4566 /BatchGetView S3 S3 +POST localhost:4566 /CreateIndex S3 S3 +POST localhost:4566 /CreateResourceExplorerSetup S3 S3 +POST localhost:4566 /CreateView S3 S3 +POST localhost:4566 /DeleteIndex S3 S3 +POST localhost:4566 /DeleteResourceExplorerSetup S3 S3 +POST localhost:4566 /DeleteView S3 S3 +POST localhost:4566 /DisassociateDefaultView S3 S3 +POST localhost:4566 /GetAccountLevelServiceConfiguration S3 S3 +POST localhost:4566 /GetDefaultView S3 S3 +POST localhost:4566 /GetIndex S3 S3 +POST localhost:4566 /rummetrics/xappmon/metrics S3 S3 +DELETE localhost:4566 /rummetrics/xappmon/metrics S3 S3 +GET localhost:4566 /rummetrics/xappmon/metrics S3 S3 +POST localhost:4566 /appmonitor S3 S3 +DELETE localhost:4566 /appmonitor/xname S3 S3 +DELETE localhost:4566 /appmonitor/xname/policy S3 S3 +DELETE localhost:4566 /rummetrics/xappmon/metricsdestination S3 S3 +GET localhost:4566 /appmonitor/xname S3 S3 +POST localhost:4566 /appmonitor/xname/data S3 S3 +GET localhost:4566 /appmonitor/xname/policy S3 S3 +POST localhost:4566 /appmonitors S3 S3 +GET localhost:4566 /rummetrics/xappmon/metricsdestination S3 S3 +PUT localhost:4566 /access-points S3 S3 +PUT localhost:4566 /file-systems S3 S3 +PUT localhost:4566 /mount-targets S3 S3 +DELETE localhost:4566 /access-points/xaccess S3 S3 +DELETE localhost:4566 /file-systems/xfilesy S3 S3 +DELETE localhost:4566 /file-systems/xfilesy/policy S3 S3 +DELETE localhost:4566 /mount-targets/xmountt S3 S3 +GET localhost:4566 /access-points/xaccess S3 S3 +GET localhost:4566 /file-systems/xfilesy S3 S3 +GET localhost:4566 /file-systems/xfilesy/policy S3 S3 +GET localhost:4566 /mount-targets/xmountt S3 S3 +GET localhost:4566 /file-systems/xfilesy/synchronization-configuration S3 S3 +POST localhost:4566 /CreateIndex S3 S3 +POST localhost:4566 /CreateVectorBucket S3 S3 +POST localhost:4566 /DeleteIndex S3 S3 +POST localhost:4566 /DeleteVectorBucket S3 S3 +POST localhost:4566 /DeleteVectorBucketPolicy S3 S3 +POST localhost:4566 /DeleteVectors S3 S3 +POST localhost:4566 /GetIndex S3 S3 +POST localhost:4566 /GetVectorBucket S3 S3 +POST localhost:4566 /GetVectorBucketPolicy S3 S3 +POST localhost:4566 /GetVectors S3 S3 +POST localhost:4566 /ListIndexes S3 S3 +POST localhost:4566 / sagemaker application/x-amz-json-1.1 SageMaker.AttachClusterNodeNetworkInterface {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sagemaker#1.0.0 SageMaker SageMaker +POST localhost:4566 / application/x-amz-json-1.1 SageMaker.AttachClusterNodeNetworkInterface {} SageMaker SageMaker +POST localhost:4566 / execute-api application/x-amz-json-1.1 SageMaker.AttachClusterNodeNetworkInterface {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sagemaker#1.0.0 SageMaker SageMaker +POST sagemaker.us-east-1.amazonaws.com / sagemaker application/x-amz-json-1.1 SageMaker.AttachClusterNodeNetworkInterface {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sagemaker#1.0.0 SageMaker SageMaker +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fsagemaker%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 SageMaker.AttachClusterNodeNetworkInterface {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sagemaker#1.0.0 SageMaker SageMaker +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.BatchGetSecretValue {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.BatchGetSecretValue {} S3 S3 +POST localhost:4566 / execute-api application/x-amz-json-1.1 Secretsmanager.BatchGetSecretValue {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST secretsmanager.us-east-1.amazonaws.com / secretsmanager application/x-amz-json-1.1 Secretsmanager.BatchGetSecretValue {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fsecretsmanager%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 Secretsmanager.BatchGetSecretValue {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.CancelRotateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.CancelRotateSecret {} S3 S3 +POST localhost:4566 / execute-api application/x-amz-json-1.1 Secretsmanager.CancelRotateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST secretsmanager.us-east-1.amazonaws.com / secretsmanager application/x-amz-json-1.1 Secretsmanager.CancelRotateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fsecretsmanager%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 Secretsmanager.CancelRotateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.CreateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.CreateSecret {} S3 S3 +POST localhost:4566 / execute-api application/x-amz-json-1.1 Secretsmanager.CreateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST secretsmanager.us-east-1.amazonaws.com / secretsmanager application/x-amz-json-1.1 Secretsmanager.CreateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fsecretsmanager%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 Secretsmanager.CreateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.DeleteResourcePolicy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.DeleteResourcePolicy {} S3 S3 +POST localhost:4566 / execute-api application/x-amz-json-1.1 Secretsmanager.DeleteResourcePolicy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST secretsmanager.us-east-1.amazonaws.com / secretsmanager application/x-amz-json-1.1 Secretsmanager.DeleteResourcePolicy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fsecretsmanager%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 Secretsmanager.DeleteResourcePolicy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.DeleteSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.DeleteSecret {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.DescribeSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.DescribeSecret {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.GetRandomPassword {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.GetRandomPassword {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.GetResourcePolicy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.GetResourcePolicy {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.GetSecretValue {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.GetSecretValue {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.ListSecrets {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.ListSecrets {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.ListSecretVersionIds {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.ListSecretVersionIds {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.PutResourcePolicy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.PutResourcePolicy {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.PutSecretValue {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.PutSecretValue {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.RemoveRegionsFromReplication {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.RemoveRegionsFromReplication {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.ReplicateSecretToRegions {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.ReplicateSecretToRegions {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.RestoreSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.RestoreSecret {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.RotateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.RotateSecret {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.StopReplicationToReplica {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.StopReplicationToReplica {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.TagResource {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.TagResource {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.UntagResource {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 Secretsmanager.UntagResource {} S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.UpdateSecret {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.UpdateSecretVersionStage {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 / secretsmanager application/x-amz-json-1.1 Secretsmanager.ValidateResourcePolicy {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/secretsmanager#1.0.0 S3 S3 +POST localhost:4566 /exportjobsv2/xexport/cancel securityhub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/securityhub#1.0.0 SecurityHub SecurityHub +GET localhost:4566 /exportjobsv2/xexport securityhub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/securityhub#1.0.0 SecurityHub SecurityHub +POST localhost:4566 /GetRemediationsV2 securityhub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/securityhub#1.0.0 S3 S3 +GET localhost:4566 /exportjobsv2 securityhub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/securityhub#1.0.0 SecurityHub SecurityHub +POST localhost:4566 /ListExposuresByRemediationV2 securityhub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/securityhub#1.0.0 S3 S3 +POST localhost:4566 /freetrial/statusv2/list securityhub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/securityhub#1.0.0 S3 S3 +POST localhost:4566 /exportjobsv2 securityhub User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/securityhub#1.0.0 SecurityHub SecurityHub +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.AcceptPortfolioShare {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.AssociateBudgetWithResource {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.AssociatePrincipalWithPortfolio {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.AssociateProductWithPortfolio {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.AssociateServiceActionWithProvisioningArtifact {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.AssociateTagOptionWithResource {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.BatchAssociateServiceActionWithProvisioningArtifact {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.BatchDisassociateServiceActionFromProvisioningArtifact {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CopyProduct {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreateConstraint {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreatePortfolio {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreatePortfolioShare {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreateProduct {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreateProvisionedProductPlan {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreateProvisioningArtifact {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreateServiceAction {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.CreateTagOption {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.DeleteConstraint {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.DeletePortfolio {} ServiceCatalog ServiceCatalog +POST localhost:4566 / application/x-amz-json-1.1 AWS242ServiceCatalogService.DeletePortfolioShare {} ServiceCatalog ServiceCatalog +POST localhost:4566 /v2/email/identity/certificates ses User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 /v2/email/identity/certificates execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST ses.us-east-1.amazonaws.com /v2/email/identity/certificates ses User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 /v2/email/identity/certificates?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fses%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 /v2/email/identity/certificates SESv2 SESv2 +POST localhost:4566 /v2/email/identity/certificates/delete ses User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 /v2/email/list-configuration-sets ses User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 /v2/email/list-identities ses User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 /v2/email/identity/certificates/list ses User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 /v2/email/update-configuration-sets ses User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/sesv2#1.0.0 SESv2 SESv2 +POST localhost:4566 / support application/x-amz-json-1.1 AWSSupport_20130415.CompleteAttachmentUpload {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/support#1.0.0 Support Support +POST localhost:4566 / application/x-amz-json-1.1 AWSSupport_20130415.CompleteAttachmentUpload {} Support Support +POST localhost:4566 / execute-api application/x-amz-json-1.1 AWSSupport_20130415.CompleteAttachmentUpload {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/support#1.0.0 Support Support +POST support.us-east-1.amazonaws.com / support application/x-amz-json-1.1 AWSSupport_20130415.CompleteAttachmentUpload {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/support#1.0.0 Support Support +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fsupport%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 AWSSupport_20130415.CompleteAttachmentUpload {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/support#1.0.0 Support Support +POST localhost:4566 / support application/x-amz-json-1.1 AWSSupport_20130415.DescribeAttachmentUploadStatus {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/support#1.0.0 Support Support +POST localhost:4566 / application/x-amz-json-1.1 AWSSupport_20130415.DescribeAttachmentUploadStatus {} Support Support +POST localhost:4566 / support application/x-amz-json-1.1 AWSSupport_20130415.GetAttachmentDownloadLink {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/support#1.0.0 Support Support +POST localhost:4566 / application/x-amz-json-1.1 AWSSupport_20130415.GetAttachmentDownloadLink {} Support Support +POST localhost:4566 / support application/x-amz-json-1.1 AWSSupport_20130415.GetAttachmentUploadLinks {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/support#1.0.0 Support Support +POST localhost:4566 / application/x-amz-json-1.1 AWSSupport_20130415.GetAttachmentUploadLinks {} Support Support +POST localhost:4566 / transcribe application/x-amz-json-1.1 Transcribe.UpdateLanguageModel {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/transcribe#1.0.0 Transcribe Transcribe +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/CreateParallelData translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/CreateParallelData execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST translate.us-east-1.amazonaws.com /service/AWSShineFrontendService_20170701/operation/CreateParallelData translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/CreateParallelData?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Ftranslate%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/CreateParallelData application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteParallelData translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteParallelData execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST translate.us-east-1.amazonaws.com /service/AWSShineFrontendService_20170701/operation/DeleteParallelData translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteParallelData?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Ftranslate%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteParallelData application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteTerminology translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteTerminology execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST translate.us-east-1.amazonaws.com /service/AWSShineFrontendService_20170701/operation/DeleteTerminology translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteTerminology?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Ftranslate%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DeleteTerminology application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DescribeTextTranslationJob translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DescribeTextTranslationJob execute-api application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST translate.us-east-1.amazonaws.com /service/AWSShineFrontendService_20170701/operation/DescribeTextTranslationJob translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DescribeTextTranslationJob?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Ftranslate%2Faws4_request&X-Amz-Signature=00 application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/DescribeTextTranslationJob application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/GetParallelData translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/GetParallelData application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/GetTerminology translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/GetTerminology application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ImportTerminology translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ImportTerminology application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListLanguages translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListLanguages application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListParallelData translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListParallelData application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListTagsForResource translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListTagsForResource application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListTerminologies translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListTerminologies application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListTextTranslationJobs translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/ListTextTranslationJobs application/cbor Smithy-Protocol:rpc-v2-cbor Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/StartTextTranslationJob translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/StopTextTranslationJob translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/TagResource translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/TranslateDocument translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/TranslateText translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/UntagResource translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +POST localhost:4566 /service/AWSShineFrontendService_20170701/operation/UpdateParallelData translate application/cbor Smithy-Protocol:rpc-v2-cbor;User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/translate#1.0.0 Translate Translate +GET localhost:4566 /A/key.txt s3 S3 S3 +PUT localhost:4566 /A/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /A/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /A s3 S3 S3 +GET localhost:4566 /C/key.txt s3 S3 S3 +PUT localhost:4566 /C/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /C/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /C s3 S3 S3 +GET localhost:4566 /Create/key.txt s3 S3 S3 +PUT localhost:4566 /Create/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Create/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Create s3 S3 S3 +GET localhost:4566 /D/key.txt s3 S3 S3 +PUT localhost:4566 /D/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /D/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /D s3 S3 S3 +GET localhost:4566 /Delete/key.txt s3 S3 S3 +PUT localhost:4566 /Delete/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Delete/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Delete s3 S3 S3 +GET localhost:4566 /F/key.txt s3 S3 S3 +PUT localhost:4566 /F/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /F/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /F s3 S3 S3 +GET localhost:4566 /G/key.txt s3 S3 S3 +PUT localhost:4566 /G/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /G/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /G s3 S3 S3 +GET localhost:4566 /Get/key.txt s3 S3 S3 +PUT localhost:4566 /Get/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Get/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Get s3 S3 S3 +GET localhost:4566 /I/key.txt s3 S3 S3 +PUT localhost:4566 /I/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /I/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /I s3 S3 S3 +GET localhost:4566 /L/key.txt s3 S3 S3 +PUT localhost:4566 /L/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /L/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /L s3 S3 S3 +GET localhost:4566 /List/key.txt s3 S3 S3 +PUT localhost:4566 /List/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /List/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /List s3 S3 S3 +GET localhost:4566 /M/key.txt s3 S3 S3 +PUT localhost:4566 /M/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /M/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /M s3 S3 S3 +GET localhost:4566 /P/key.txt s3 S3 S3 +PUT localhost:4566 /P/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /P/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /P s3 S3 S3 +GET localhost:4566 /Put/key.txt s3 S3 S3 +PUT localhost:4566 /Put/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Put/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Put s3 S3 S3 +GET localhost:4566 /R/key.txt s3 S3 S3 +PUT localhost:4566 /R/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /R/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /R s3 S3 S3 +GET localhost:4566 /S/key.txt s3 S3 S3 +PUT localhost:4566 /S/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /S/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /S s3 S3 S3 +GET localhost:4566 /T/key.txt s3 S3 S3 +PUT localhost:4566 /T/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /T/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /T s3 S3 S3 +GET localhost:4566 /U/key.txt s3 S3 S3 +PUT localhost:4566 /U/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /U/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /U s3 S3 S3 +GET localhost:4566 /Update/key.txt s3 S3 S3 +PUT localhost:4566 /Update/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /Update/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /Update s3 S3 S3 +GET localhost:4566 /accept-/key.txt s3 S3 S3 +PUT localhost:4566 /accept-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /accept-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /accept- s3 S3 S3 +GET localhost:4566 /accept-certificate-transfer/key.txt s3 IoT IoT +PUT localhost:4566 /accept-certificate-transfer/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /accept-certificate-transfer/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /accept-certificate-transfer s3 S3 S3 +GET localhost:4566 /active-violations/key.txt s3 S3 S3 +PUT localhost:4566 /active-violations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /active-violations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /active-violations s3 IoT IoT +GET localhost:4566 /add-/key.txt s3 S3 S3 +PUT localhost:4566 /add-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /add-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /add- s3 S3 S3 +GET localhost:4566 /attached-policies/key.txt s3 IoT IoT +PUT localhost:4566 /attached-policies/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /attached-policies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /attached-policies s3 IoT IoT +GET localhost:4566 /authorizer/key.txt s3 IoT IoT +PUT localhost:4566 /authorizer/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /authorizer/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /authorizer s3 S3 S3 +GET localhost:4566 /autoshift/key.txt s3 S3 S3 +PUT localhost:4566 /autoshift/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /autoshift/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /autoshift s3 S3 S3 +GET localhost:4566 /batch-/key.txt s3 S3 S3 +PUT localhost:4566 /batch-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /batch-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /batch- s3 S3 S3 +GET localhost:4566 /billing-groups/key.txt s3 IoT IoT +PUT localhost:4566 /billing-groups/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /billing-groups/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /billing-groups s3 IoT IoT +GET localhost:4566 /cacertificate/key.txt s3 IoT IoT +PUT localhost:4566 /cacertificate/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /cacertificate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /cacertificate s3 IoT IoT +GET localhost:4566 /cancel-certificate-transfer/key.txt s3 IoT IoT +PUT localhost:4566 /cancel-certificate-transfer/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /cancel-certificate-transfer/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /cancel-certificate-transfer s3 S3 S3 +GET localhost:4566 /cellreadiness/key.txt s3 S3 S3 +PUT localhost:4566 /cellreadiness/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /cellreadiness/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /cellreadiness s3 S3 S3 +GET localhost:4566 /certificate/key.txt s3 S3 S3 +PUT localhost:4566 /certificate/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /certificate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /certificate s3 S3 S3 +GET localhost:4566 /command-executions/key.txt s3 IoT IoT +PUT localhost:4566 /command-executions/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /command-executions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /command-executions s3 IoT IoT +GET localhost:4566 /commands/key.txt s3 IoT IoT +PUT localhost:4566 /commands/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /commands/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /commands s3 IoT IoT +GET localhost:4566 /confirmdestination/key.txt s3 IoT IoT +PUT localhost:4566 /confirmdestination/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /confirmdestination/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /confirmdestination s3 S3 S3 +GET localhost:4566 /create-/key.txt s3 S3 S3 +PUT localhost:4566 /create-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /create-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /create- s3 S3 S3 +GET localhost:4566 /crossaccountauthorizations/key.txt s3 S3 S3 +PUT localhost:4566 /crossaccountauthorizations/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /crossaccountauthorizations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /crossaccountauthorizations s3 S3 S3 +GET localhost:4566 /custom-metric/key.txt s3 IoT IoT +PUT localhost:4566 /custom-metric/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /custom-metric/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /custom-metric s3 S3 S3 +GET localhost:4566 /default-authorizer/key.txt s3 S3 S3 +PUT localhost:4566 /default-authorizer/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /default-authorizer/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /default-authorizer s3 IoT IoT +GET localhost:4566 /delete-/key.txt s3 S3 S3 +PUT localhost:4566 /delete-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /delete-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /delete- s3 S3 S3 +GET localhost:4566 /describe-/key.txt s3 S3 S3 +PUT localhost:4566 /describe-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /describe-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /describe- s3 S3 S3 +GET localhost:4566 /detect/key.txt s3 IoT IoT +PUT localhost:4566 /detect/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /detect/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /detect s3 S3 S3 +GET localhost:4566 /dimensions/key.txt s3 IoT IoT +PUT localhost:4566 /dimensions/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /dimensions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /dimensions s3 IoT IoT +GET localhost:4566 /domainConfigurations/key.txt s3 IoT IoT +PUT localhost:4566 /domainConfigurations/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /domainConfigurations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /domainConfigurations s3 IoT IoT +GET localhost:4566 /dynamic-thing-groups/key.txt s3 IoT IoT +PUT localhost:4566 /dynamic-thing-groups/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /dynamic-thing-groups/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /dynamic-thing-groups s3 S3 S3 +GET localhost:4566 /effective-policies/key.txt s3 S3 S3 +PUT localhost:4566 /effective-policies/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /effective-policies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /effective-policies s3 IoT IoT +GET localhost:4566 /encryption-configuration/key.txt s3 S3 S3 +PUT localhost:4566 /encryption-configuration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /encryption-configuration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /encryption-configuration s3 IoT IoT +GET localhost:4566 /endpoint/key.txt s3 S3 S3 +PUT localhost:4566 /endpoint/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /endpoint/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /endpoint s3 IoT IoT +GET localhost:4566 /fleet-metric/key.txt s3 IoT IoT +PUT localhost:4566 /fleet-metric/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /fleet-metric/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /fleet-metric s3 S3 S3 +GET localhost:4566 /import-/key.txt s3 S3 S3 +PUT localhost:4566 /import-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /import-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /import- s3 S3 S3 +GET localhost:4566 /indices/key.txt s3 IoT IoT +PUT localhost:4566 /indices/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /indices/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /indices s3 IoT IoT +GET localhost:4566 /job-templates/key.txt s3 IoT IoT +PUT localhost:4566 /job-templates/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /job-templates/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /job-templates s3 IoT IoT +GET localhost:4566 /keys-and-certificate/key.txt s3 S3 S3 +PUT localhost:4566 /keys-and-certificate/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /keys-and-certificate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /keys-and-certificate s3 IoT IoT +GET localhost:4566 /list-/key.txt s3 S3 S3 +PUT localhost:4566 /list-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /list-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /list- s3 S3 S3 +GET localhost:4566 /loggingOptions/key.txt s3 S3 S3 +PUT localhost:4566 /loggingOptions/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /loggingOptions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /loggingOptions s3 IoT IoT +GET localhost:4566 /managed-job-templates/key.txt s3 IoT IoT +PUT localhost:4566 /managed-job-templates/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /managed-job-templates/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /managed-job-templates s3 IoT IoT +GET localhost:4566 /managedresources/key.txt s3 S3 S3 +PUT localhost:4566 /managedresources/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /managedresources/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /managedresources s3 S3 S3 +GET localhost:4566 /metric-values/key.txt s3 S3 S3 +PUT localhost:4566 /metric-values/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /metric-values/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /metric-values s3 IoT IoT +GET localhost:4566 /mitigationactions/key.txt s3 IoT IoT +PUT localhost:4566 /mitigationactions/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /mitigationactions/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /mitigationactions s3 S3 S3 +GET localhost:4566 /network-migration/key.txt s3 S3 S3 +PUT localhost:4566 /network-migration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /network-migration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /network-migration s3 S3 S3 +GET localhost:4566 /otaUpdates/key.txt s3 IoT IoT +PUT localhost:4566 /otaUpdates/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /otaUpdates/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /otaUpdates s3 IoT IoT +GET localhost:4566 /package-configuration/key.txt s3 S3 S3 +PUT localhost:4566 /package-configuration/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /package-configuration/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /package-configuration s3 IoT IoT +GET localhost:4566 /packages/key.txt s3 IoT IoT +PUT localhost:4566 /packages/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /packages/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /packages s3 IoT IoT +GET localhost:4566 /policy-principals/key.txt s3 S3 S3 +PUT localhost:4566 /policy-principals/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /policy-principals/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /policy-principals s3 IoT IoT +GET localhost:4566 /policy-targets/key.txt s3 IoT IoT +PUT localhost:4566 /policy-targets/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /policy-targets/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /policy-targets s3 S3 S3 +GET localhost:4566 /practiceruns/key.txt s3 S3 S3 +PUT localhost:4566 /practiceruns/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /practiceruns/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /practiceruns s3 S3 S3 +GET localhost:4566 /principal-policies/key.txt s3 IoT IoT +PUT localhost:4566 /principal-policies/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /principal-policies/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /principal-policies s3 IoT IoT +GET localhost:4566 /principal-things/key.txt s3 S3 S3 +PUT localhost:4566 /principal-things/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /principal-things/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /principal-things s3 IoT IoT +GET localhost:4566 /provisioning-templates/key.txt s3 IoT IoT +PUT localhost:4566 /provisioning-templates/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /provisioning-templates/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /provisioning-templates s3 IoT IoT +GET localhost:4566 /publish-/key.txt s3 S3 S3 +PUT localhost:4566 /publish-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /publish-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /publish- s3 S3 S3 +GET localhost:4566 /put-/key.txt s3 S3 S3 +PUT localhost:4566 /put-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /put-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /put- s3 S3 S3 +GET localhost:4566 /readinesschecks/key.txt s3 S3 S3 +PUT localhost:4566 /readinesschecks/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /readinesschecks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /readinesschecks s3 S3 S3 +GET localhost:4566 /recoverygroup/key.txt s3 S3 S3 +PUT localhost:4566 /recoverygroup/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /recoverygroup/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /recoverygroup s3 S3 S3 +GET localhost:4566 /registrationcode/key.txt s3 S3 S3 +PUT localhost:4566 /registrationcode/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /registrationcode/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /registrationcode s3 IoT IoT +GET localhost:4566 /reject-/key.txt s3 S3 S3 +PUT localhost:4566 /reject-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /reject-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /reject- s3 S3 S3 +GET localhost:4566 /reject-certificate-transfer/key.txt s3 IoT IoT +PUT localhost:4566 /reject-certificate-transfer/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /reject-certificate-transfer/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /reject-certificate-transfer s3 S3 S3 +GET localhost:4566 /remove-/key.txt s3 S3 S3 +PUT localhost:4566 /remove-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /remove-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /remove- s3 S3 S3 +GET localhost:4566 /resolve-/key.txt s3 S3 S3 +PUT localhost:4566 /resolve-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resolve-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /resolve- s3 S3 S3 +GET localhost:4566 /resourcePolicy/key.txt s3 S3 S3 +PUT localhost:4566 /resourcePolicy/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resourcePolicy/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /resourcePolicy s3 S3 S3 +GET localhost:4566 /resourcesets/key.txt s3 S3 S3 +PUT localhost:4566 /resourcesets/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /resourcesets/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /resourcesets s3 S3 S3 +GET localhost:4566 /role-aliases/key.txt s3 IoT IoT +PUT localhost:4566 /role-aliases/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /role-aliases/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /role-aliases s3 IoT IoT +GET localhost:4566 /rule-destinations/key.txt s3 IoT IoT +PUT localhost:4566 /rule-destinations/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /rule-destinations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /rule-destinations s3 IoT IoT +GET localhost:4566 /security-profile/key.txt s3 S3 S3 +PUT localhost:4566 /security-profile/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /security-profile/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /security-profile s3 S3 S3 +GET localhost:4566 /start-/key.txt s3 S3 S3 +PUT localhost:4566 /start-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /start-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /start- s3 S3 S3 +GET localhost:4566 /streams/key.txt s3 IoT IoT +PUT localhost:4566 /streams/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /streams/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /streams s3 IoT IoT +GET localhost:4566 /test-authorization/key.txt s3 S3 S3 +PUT localhost:4566 /test-authorization/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /test-authorization/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /test-authorization s3 IoT IoT +GET localhost:4566 /thing-groups/key.txt s3 IoT IoT +PUT localhost:4566 /thing-groups/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /thing-groups/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /thing-groups s3 IoT IoT +GET localhost:4566 /thing-registration-tasks/key.txt s3 IoT IoT +PUT localhost:4566 /thing-registration-tasks/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /thing-registration-tasks/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /thing-registration-tasks s3 IoT IoT +GET localhost:4566 /thing-types/key.txt s3 IoT IoT +PUT localhost:4566 /thing-types/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /thing-types/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /thing-types s3 IoT IoT +GET localhost:4566 /transfer-certificate/key.txt s3 IoT IoT +PUT localhost:4566 /transfer-certificate/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /transfer-certificate/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /transfer-certificate s3 S3 S3 +GET localhost:4566 /update-/key.txt s3 S3 S3 +PUT localhost:4566 /update-/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /update-/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /update- s3 S3 S3 +GET localhost:4566 /v2Logging/key.txt s3 S3 S3 +PUT localhost:4566 /v2Logging/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /v2Logging/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /v2Logging s3 S3 S3 +GET localhost:4566 /violation-events/key.txt s3 S3 S3 +PUT localhost:4566 /violation-events/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /violation-events/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /violation-events s3 IoT IoT +GET localhost:4566 /violations/key.txt s3 IoT IoT +PUT localhost:4566 /violations/key.txt s3 application/octet-stream body IoT IoT +GET localhost:4566 /violations/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 IoT IoT +HEAD localhost:4566 /violations s3 S3 S3 +GET localhost:4566 / memorydb application/x-amz-json-1.1 {} MemoryDB MemoryDB +GET localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fmemorydb%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} MemoryDB MemoryDB +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/ memorydb application/x-amz-json-1.1 {} MemoryDB MemoryDB +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fmemorydb%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} MemoryDB MemoryDB +GET localhost:4566 /x memorydb application/x-amz-json-1.1 {} MemoryDB MemoryDB +GET localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fmemorydb%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} MemoryDB MemoryDB +GET localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 / cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcognito-idp%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/ cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcognito-idp%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcognito-idp%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 / lambda application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Flambda%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/ lambda application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Flambda%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x lambda application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Flambda%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 / s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/ s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 / application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/ application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 / memorydb application/x-amz-json-1.1 {} MemoryDB MemoryDB +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fmemorydb%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} MemoryDB MemoryDB +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/ memorydb application/x-amz-json-1.1 {} MemoryDB MemoryDB +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fmemorydb%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} MemoryDB MemoryDB +POST localhost:4566 /x memorydb application/x-amz-json-1.1 {} MemoryDB MemoryDB +POST localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fmemorydb%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} MemoryDB MemoryDB +POST localhost:4566 / route53-recovery-cluster application/x-amz-json-1.1 {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/ route53-recovery-cluster application/x-amz-json-1.1 {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /x route53-recovery-cluster application/x-amz-json-1.1 {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcognito-idp%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/ cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcognito-idp%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fcognito-idp%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 / lambda application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Flambda%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/ lambda application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Flambda%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x lambda application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Flambda%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 / s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/ s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/ application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /route53-recovery-cluster/abc/us-east-1/?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /x?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2F%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonMemoryDB.DescribeClusters {} MemoryDB MemoryDB +POST localhost:4566 / application/x-amz-json-1.1 ToggleCustomerAPI.GetRoutingControlState {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / s3 application/x-amz-json-1.1 ToggleCustomerAPI.GetRoutingControlState {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / application/x-amz-json-1.1 AWSLambda.ListFunctions {} Lambda Lambda +POST localhost:4566 / s3 application/x-amz-json-1.1 AWSLambda.ListFunctions {} Lambda Lambda +POST localhost:4566 / application/x-amz-json-1.1 AWSCognitoIdentityProviderService.InitiateAuth {} CognitoIDP CognitoIDP +POST localhost:4566 / s3 application/x-amz-json-1.1 AWSCognitoIdentityProviderService.InitiateAuth {} CognitoIDP CognitoIDP +POST localhost:4566 / application/x-amz-json-1.1 AWSLambda20150331.ListFunctions {} Lambda Lambda +POST localhost:4566 / s3 application/x-amz-json-1.1 AWSLambda20150331.ListFunctions {} Lambda Lambda +POST localhost:4566 / application/x-amz-json-1.1 AmazonMemoryDB {} S3 S3 +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonMemoryDB {} S3 S3 +GET localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +GET localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +PUT localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +PUT localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +DELETE localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +DELETE localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +HEAD localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +HEAD localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +GET localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +GET localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +PUT localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +PUT localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +DELETE localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +DELETE localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +HEAD localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +HEAD localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2/1.41.0 api/mediastoredata#1.0.0 MediaStoreData MediaStoreData +GET localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 / application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 / application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 / application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 / application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +GET localhost:4566 / application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +PUT localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +PUT localhost:4566 / application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +DELETE localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +DELETE localhost:4566 / application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +HEAD localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +HEAD localhost:4566 / application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +GET localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +GET localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +PUT localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +PUT localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +DELETE localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +DELETE localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +HEAD localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +HEAD localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:x MEDIASTORE-DATA y MediaStoreData MediaStoreData +GET localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +GET localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +PUT localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +PUT localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +DELETE localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +DELETE localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +HEAD localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +HEAD localhost:4566 / application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +GET localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +GET localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +PUT localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +PUT localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +DELETE localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +DELETE localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +HEAD localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +HEAD localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +GET localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +GET localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +PUT localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +PUT localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +DELETE localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +DELETE localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +HEAD localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +HEAD localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:aws-sdk-go-v2 api/mediastore#1 S3 S3 +GET localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +GET localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +PUT localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +PUT localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +DELETE localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +DELETE localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +HEAD localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +HEAD localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +GET localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +GET localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +PUT localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +PUT localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +DELETE localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +DELETE localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +HEAD localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +HEAD localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 mediastore-data MediaStoreData MediaStoreData +GET localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 / mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 / application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /obj mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /obj application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} X-Amz-User-Agent:aws-sdk-js/3.0 MediaStoreData MediaStoreData MediaStoreData +GET localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +GET localhost:4566 / application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +PUT localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +PUT localhost:4566 / application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +DELETE localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +DELETE localhost:4566 / application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +HEAD localhost:4566 / mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +HEAD localhost:4566 / application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +GET localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +GET localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +PUT localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +PUT localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +DELETE localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +DELETE localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +HEAD localhost:4566 /obj mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +HEAD localhost:4566 /obj application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +GET localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +PUT localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +DELETE localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 mediastore application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +HEAD localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} User-Agent:İmediastoredata MediaStoreData MediaStoreData +GET localhost:4566 /oauth2/token application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /oauth2/token cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /oauth2/token application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /oauth2/token application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /oauth2/token cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /oauth2/token application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /oauth2/token application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /oauth2/token cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /oauth2/token application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /oauth2/authorize?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /oauth2/authorize?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /oauth2/authorize?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /oauth2/authorize?client_id=c application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /oauth2/authorize?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /oauth2/authorize?client_id=c application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /oauth2/authorize?client_id=c application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /oauth2/authorize?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /oauth2/authorize?client_id=c application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /oauth2/userInfo application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /oauth2/userInfo cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /oauth2/userInfo application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /oauth2/userInfo application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /oauth2/userInfo cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /oauth2/userInfo application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /oauth2/userInfo application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /oauth2/userInfo cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /oauth2/userInfo application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /oauth2/revoke application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /oauth2/revoke cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /oauth2/revoke application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /oauth2/revoke application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /oauth2/revoke cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /oauth2/revoke application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /oauth2/revoke application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /oauth2/revoke cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /oauth2/revoke application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /oauth2/idpresponse application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /oauth2/idpresponse cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /oauth2/idpresponse application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /oauth2/idpresponse application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /oauth2/idpresponse cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /oauth2/idpresponse application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /oauth2/idpresponse application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /oauth2/idpresponse cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /oauth2/idpresponse application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /login?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /login?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /login?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /login?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /login?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /login?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /login?client_id=c application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /login?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /login?client_id=c application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /logout?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /logout?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /logout?client_id=c application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /logout?client_id=c application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /logout?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /logout?client_id=c application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /logout?client_id=c application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /logout?client_id=c cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /logout?client_id=c application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /saml2/idpresponse application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /saml2/idpresponse cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /saml2/idpresponse application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /saml2/idpresponse application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /saml2/idpresponse cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /saml2/idpresponse application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /saml2/idpresponse application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /saml2/idpresponse cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /saml2/idpresponse application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /saml2/logout application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /saml2/logout cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /saml2/logout application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /saml2/logout application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /saml2/logout cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /saml2/logout application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /saml2/logout application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /saml2/logout cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /saml2/logout application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /saml2/metadata application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /saml2/metadata cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /saml2/metadata application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /saml2/metadata application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /saml2/metadata cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /saml2/metadata application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /saml2/metadata application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /saml2/metadata cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /saml2/metadata application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /us-east-1_abc/saml2/metadata application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /us-east-1_abc/saml2/metadata cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /us-east-1_abc/saml2/metadata application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /us-east-1_abc/saml2/metadata application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /us-east-1_abc/saml2/metadata cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /us-east-1_abc/saml2/metadata application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /us-east-1_abc/saml2/metadata application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /us-east-1_abc/saml2/metadata cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /us-east-1_abc/saml2/metadata application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /us-east-1_abc/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /us-east-1_abc/.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET auth.localhost:4566 /us-east-1_abc/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /us-east-1_abc/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /us-east-1_abc/.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST auth.localhost:4566 /us-east-1_abc/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /us-east-1_abc/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /us-east-1_abc/.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS auth.localhost:4566 /us-east-1_abc/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /us-east-1_abc/.well-known/openid-configuration application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /us-east-1_abc/.well-known/openid-configuration cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /us-east-1_abc/.well-known/openid-configuration application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /us-east-1_abc/.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /us-east-1_abc/.well-known/openid-configuration cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /us-east-1_abc/.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /us-east-1_abc/.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /us-east-1_abc/.well-known/openid-configuration cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /us-east-1_abc/.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET auth.localhost:4566 /.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST auth.localhost:4566 /.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS auth.localhost:4566 /.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /.well-known/openid-configuration cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 /.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /.well-known/openid-configuration cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 /.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 /.well-known/openid-configuration cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 /.well-known/openid-configuration application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 //oauth2/token application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 //oauth2/token cognito-idp application/x-amz-json-1.1 {} S3 S3 +GET auth.localhost:4566 //oauth2/token application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 //oauth2/token application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 //oauth2/token cognito-idp application/x-amz-json-1.1 {} S3 S3 +POST auth.localhost:4566 //oauth2/token application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 //oauth2/token application/x-amz-json-1.1 {} S3 S3 +OPTIONS localhost:4566 //oauth2/token cognito-idp application/x-amz-json-1.1 {} S3 S3 +OPTIONS auth.localhost:4566 //oauth2/token application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /a/b/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET localhost:4566 /a/b/.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET auth.localhost:4566 /a/b/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /a/b/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST localhost:4566 /a/b/.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +POST auth.localhost:4566 /a/b/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /a/b/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS localhost:4566 /a/b/.well-known/jwks.json cognito-idp application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +OPTIONS auth.localhost:4566 /a/b/.well-known/jwks.json application/x-amz-json-1.1 {} CognitoIDP CognitoIDP +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws / application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws / s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws /a/b?x=1 application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws /a/b?x=1 s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.eu-west-1.localhost:4566 / application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.eu-west-1.localhost:4566 / s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.eu-west-1.localhost:4566 /a/b?x=1 application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.eu-west-1.localhost:4566 /a/b?x=1 s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.eu-west-1.localhost:4566 /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.eu-west-1.localhost:4566 /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET ABCDEF0123456789ABCDEF0123456789.LAMBDA-URL.us-east-1.ON.AWS / application/x-amz-json-1.1 {} Lambda Lambda +POST ABCDEF0123456789ABCDEF0123456789.LAMBDA-URL.us-east-1.ON.AWS / s3 application/x-amz-json-1.1 {} Lambda Lambda +GET ABCDEF0123456789ABCDEF0123456789.LAMBDA-URL.us-east-1.ON.AWS /a/b?x=1 application/x-amz-json-1.1 {} Lambda Lambda +POST ABCDEF0123456789ABCDEF0123456789.LAMBDA-URL.us-east-1.ON.AWS /a/b?x=1 s3 application/x-amz-json-1.1 {} Lambda Lambda +GET ABCDEF0123456789ABCDEF0123456789.LAMBDA-URL.us-east-1.ON.AWS /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +POST ABCDEF0123456789ABCDEF0123456789.LAMBDA-URL.us-east-1.ON.AWS /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws:443 / application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws:443 / s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws:443 /a/b?x=1 application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws:443 /a/b?x=1 s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws:443 /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.on.aws:443 /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.example.com / application/x-amz-json-1.1 {} S3 S3 +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.example.com / s3 application/x-amz-json-1.1 {} S3 S3 +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.example.com /a/b?x=1 application/x-amz-json-1.1 {} S3 S3 +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.example.com /a/b?x=1 s3 application/x-amz-json-1.1 {} S3 S3 +GET abcdef0123456789abcdef0123456789.lambda-url.us-east-1.example.com /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +POST abcdef0123456789abcdef0123456789.lambda-url.us-east-1.example.com /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET lambda-url.us-east-1.on.aws / application/x-amz-json-1.1 {} S3 S3 +POST lambda-url.us-east-1.on.aws / s3 application/x-amz-json-1.1 {} S3 S3 +GET lambda-url.us-east-1.on.aws /a/b?x=1 application/x-amz-json-1.1 {} S3 S3 +POST lambda-url.us-east-1.on.aws /a/b?x=1 s3 application/x-amz-json-1.1 {} S3 S3 +GET lambda-url.us-east-1.on.aws /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +POST lambda-url.us-east-1.on.aws /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET x.lambda-url..on.aws / application/x-amz-json-1.1 {} S3 S3 +POST x.lambda-url..on.aws / s3 application/x-amz-json-1.1 {} S3 S3 +GET x.lambda-url..on.aws /a/b?x=1 application/x-amz-json-1.1 {} S3 S3 +POST x.lambda-url..on.aws /a/b?x=1 s3 application/x-amz-json-1.1 {} S3 S3 +GET x.lambda-url..on.aws /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +POST x.lambda-url..on.aws /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2015-03-31/functions/ application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2015-03-31/functions/ lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2015-03-31/functions/ s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2016-08-19/account-settings application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2016-08-19/account-settings lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2016-08-19/account-settings s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2025-12-01/durable-executions application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2025-12-01/durable-executions lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2025-12-01/durable-executions s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2026-07-09/resource-policy/arn application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2026-07-09/resource-policy/arn lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2026-07-09/resource-policy/arn s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2020-04-22/code-signing-configs application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2020-04-22/code-signing-configs lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2020-04-22/code-signing-configs s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2018-10-31/layers application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2018-10-31/layers lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2018-10-31/layers s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2017-03-31/tags/arn application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2017-03-31/tags/arn lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2017-03-31/tags/arn s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2099-01-01/functions application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /2099-01-01/functions lambda application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /2099-01-01/functions s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /2015-03-31/event-source-mappings application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2015-03-31/event-source-mappings lambda application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /2015-03-31/event-source-mappings s3 application/x-amz-json-1.1 {} Lambda Lambda +GET localhost:4566 /exportjobsv2/key.txt s3 S3 S3 +PUT localhost:4566 /exportjobsv2/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /exportjobsv2/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 SecurityHub SecurityHub +HEAD localhost:4566 /exportjobsv2 s3 S3 S3 +GET localhost:4566 /sendPhoneNumberVerification/key.txt s3 S3 S3 +PUT localhost:4566 /sendPhoneNumberVerification/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /sendPhoneNumberVerification/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /sendPhoneNumberVerification s3 S3 S3 +GET localhost:4566 /verifyPhoneNumber/key.txt s3 S3 S3 +PUT localhost:4566 /verifyPhoneNumber/key.txt s3 application/octet-stream body S3 S3 +GET localhost:4566 /verifyPhoneNumber/key.txt User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/s3#1.0.0 S3 S3 +HEAD localhost:4566 /verifyPhoneNumber s3 S3 S3 +POST localhost:4566 / application/x-amz-json-1.1 AmazonEventBridge.ListRules {} EventBridge EventBridge +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonEventBridge.ListRules {} EventBridge EventBridge +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonEventBridge.ListRules {} EventBridge EventBridge +POST localhost:4566 / application/x-amz-json-1.1 AWSEvents.ListRules {} EventBridge EventBridge +POST localhost:4566 / s3 application/x-amz-json-1.1 AWSEvents.ListRules {} EventBridge EventBridge +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AWSEvents.ListRules {} EventBridge EventBridge +POST localhost:4566 / application/x-amz-json-1.1 AWSSchemas.ListRegistries {} EventBridge EventBridge +POST localhost:4566 / s3 application/x-amz-json-1.1 AWSSchemas.ListRegistries {} EventBridge EventBridge +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AWSSchemas.ListRegistries {} EventBridge EventBridge +POST localhost:4566 / application/x-amz-json-1.1 ResourceGroups.ListGroups {} ResourceGroups ResourceGroups +POST localhost:4566 / s3 application/x-amz-json-1.1 ResourceGroups.ListGroups {} ResourceGroups ResourceGroups +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 ResourceGroups.ListGroups {} ResourceGroups ResourceGroups +POST localhost:4566 / application/x-amz-json-1.1 AmazonPersonalize.ListDatasets {} Personalize Personalize +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonPersonalize.ListDatasets {} Personalize Personalize +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonPersonalize.ListDatasets {} Personalize Personalize +POST localhost:4566 / application/x-amz-json-1.1 AmazonPersonalizeRuntime.GetRecommendations {} Personalize Personalize +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonPersonalizeRuntime.GetRecommendations {} Personalize Personalize +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonPersonalizeRuntime.GetRecommendations {} Personalize Personalize +POST localhost:4566 / application/x-amz-json-1.1 AmazonSSM.ListDocuments {} SSM SSM +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonSSM.ListDocuments {} SSM SSM +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonSSM.ListDocuments {} SSM SSM +POST localhost:4566 / application/x-amz-json-1.1 EC2WindowsMessageDeliveryService.Poll {} SSM SSM +POST localhost:4566 / s3 application/x-amz-json-1.1 EC2WindowsMessageDeliveryService.Poll {} SSM SSM +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 EC2WindowsMessageDeliveryService.Poll {} SSM SSM +POST localhost:4566 / application/x-amz-json-1.1 AmazonEC2ContainerRegistry_V20150921.DescribeRepositories {} ECR ECR +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonEC2ContainerRegistry_V20150921.DescribeRepositories {} ECR ECR +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonEC2ContainerRegistry_V20150921.DescribeRepositories {} ECR ECR +POST localhost:4566 / application/x-amz-json-1.1 AWSScheduler.ListSchedules {} Scheduler Scheduler +POST localhost:4566 / s3 application/x-amz-json-1.1 AWSScheduler.ListSchedules {} Scheduler Scheduler +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AWSScheduler.ListSchedules {} Scheduler Scheduler +POST localhost:4566 / application/x-amz-json-1.1 AmazonSQS.ListQueues {} SQS SQS +POST localhost:4566 / s3 application/x-amz-json-1.1 AmazonSQS.ListQueues {} SQS SQS +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonSQS.ListQueues {} S3 S3 +GET localhost:4566 /v1/registries application/x-amz-json-1.1 {} EventBridge EventBridge +GET localhost:4566 /v1/registries s3 application/x-amz-json-1.1 {} EventBridge EventBridge +POST localhost:4566 /v1/registries application/x-amz-json-1.1 {} Batch Batch +POST localhost:4566 /v1/registries s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v1/registries application/x-amz-json-1.1 {} Batch Batch +PUT localhost:4566 /v1/registries s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v1/registries application/x-amz-json-1.1 {} Batch Batch +DELETE localhost:4566 /v1/registries s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v1/discover application/x-amz-json-1.1 {} Batch Batch +GET localhost:4566 /v1/discover s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v1/discover application/x-amz-json-1.1 {} EventBridge EventBridge +POST localhost:4566 /v1/discover s3 application/x-amz-json-1.1 {} EventBridge EventBridge +PUT localhost:4566 /v1/discover application/x-amz-json-1.1 {} Batch Batch +PUT localhost:4566 /v1/discover s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v1/discover application/x-amz-json-1.1 {} Batch Batch +DELETE localhost:4566 /v1/discover s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 //v1/policy application/x-amz-json-1.1 {} EventBridge EventBridge +GET localhost:4566 //v1/policy s3 application/x-amz-json-1.1 {} EventBridge EventBridge +POST localhost:4566 //v1/policy application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 //v1/policy s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 //v1/policy application/x-amz-json-1.1 {} EventBridge EventBridge +PUT localhost:4566 //v1/policy s3 application/x-amz-json-1.1 {} EventBridge EventBridge +DELETE localhost:4566 //v1/policy application/x-amz-json-1.1 {} EventBridge EventBridge +DELETE localhost:4566 //v1/policy s3 application/x-amz-json-1.1 {} EventBridge EventBridge +GET localhost:4566 /v1/discoverers/id/d1/start application/x-amz-json-1.1 {} Batch Batch +GET localhost:4566 /v1/discoverers/id/d1/start s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v1/discoverers/id/d1/start application/x-amz-json-1.1 {} EventBridge EventBridge +POST localhost:4566 /v1/discoverers/id/d1/start s3 application/x-amz-json-1.1 {} EventBridge EventBridge +PUT localhost:4566 /v1/discoverers/id/d1/start application/x-amz-json-1.1 {} Batch Batch +PUT localhost:4566 /v1/discoverers/id/d1/start s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v1/discoverers/id/d1/start application/x-amz-json-1.1 {} Batch Batch +DELETE localhost:4566 /v1/discoverers/id/d1/start s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v1/registries/name/r/schemas application/x-amz-json-1.1 {} EventBridge EventBridge +GET localhost:4566 /v1/registries/name/r/schemas s3 application/x-amz-json-1.1 {} EventBridge EventBridge +POST localhost:4566 /v1/registries/name/r/schemas application/x-amz-json-1.1 {} Batch Batch +POST localhost:4566 /v1/registries/name/r/schemas s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v1/registries/name/r/schemas application/x-amz-json-1.1 {} Batch Batch +PUT localhost:4566 /v1/registries/name/r/schemas s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v1/registries/name/r/schemas application/x-amz-json-1.1 {} Batch Batch +DELETE localhost:4566 /v1/registries/name/r/schemas s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /groups application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /groups s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /groups application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /groups s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /groups application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /groups s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /groups application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /groups s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /get-group application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /get-group s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /get-group application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /get-group s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /get-group application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /get-group s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /get-group application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /get-group s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /update-group-query application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /update-group-query s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /update-group-query application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /update-group-query s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /update-group-query application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /update-group-query s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /update-group-query application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /update-group-query s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /list-group-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /list-group-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /list-group-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /list-group-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /list-group-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /list-group-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /list-group-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /list-group-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /resources/search application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /resources/search s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /resources/search application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /resources/search s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /resources/search application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /resources/search s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /resources/search application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /resources/search s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /resources/arn:aws:resource-groups:us-east-1:000000000000:group%2Fg/tags s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /ungroup-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /ungroup-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /ungroup-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +POST localhost:4566 /ungroup-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /ungroup-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +PUT localhost:4566 /ungroup-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /ungroup-resources application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +DELETE localhost:4566 /ungroup-resources s3 application/x-amz-json-1.1 {} ResourceGroups ResourceGroups +GET localhost:4566 /recommendations application/x-amz-json-1.1 {} Personalize Personalize +GET localhost:4566 /recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +POST localhost:4566 /recommendations application/x-amz-json-1.1 {} Personalize Personalize +POST localhost:4566 /recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +PUT localhost:4566 /recommendations application/x-amz-json-1.1 {} Personalize Personalize +PUT localhost:4566 /recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +DELETE localhost:4566 /recommendations application/x-amz-json-1.1 {} Personalize Personalize +DELETE localhost:4566 /recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +GET localhost:4566 /personalize-ranking application/x-amz-json-1.1 {} Personalize Personalize +GET localhost:4566 /personalize-ranking s3 application/x-amz-json-1.1 {} Personalize Personalize +POST localhost:4566 /personalize-ranking application/x-amz-json-1.1 {} Personalize Personalize +POST localhost:4566 /personalize-ranking s3 application/x-amz-json-1.1 {} Personalize Personalize +PUT localhost:4566 /personalize-ranking application/x-amz-json-1.1 {} Personalize Personalize +PUT localhost:4566 /personalize-ranking s3 application/x-amz-json-1.1 {} Personalize Personalize +DELETE localhost:4566 /personalize-ranking application/x-amz-json-1.1 {} Personalize Personalize +DELETE localhost:4566 /personalize-ranking s3 application/x-amz-json-1.1 {} Personalize Personalize +GET localhost:4566 /action-recommendations application/x-amz-json-1.1 {} Personalize Personalize +GET localhost:4566 /action-recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +POST localhost:4566 /action-recommendations application/x-amz-json-1.1 {} Personalize Personalize +POST localhost:4566 /action-recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +PUT localhost:4566 /action-recommendations application/x-amz-json-1.1 {} Personalize Personalize +PUT localhost:4566 /action-recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +DELETE localhost:4566 /action-recommendations application/x-amz-json-1.1 {} Personalize Personalize +DELETE localhost:4566 /action-recommendations s3 application/x-amz-json-1.1 {} Personalize Personalize +GET localhost:4566 /ssmmessages/v1/data-channel/x application/x-amz-json-1.1 {} SSM SSM +GET localhost:4566 /ssmmessages/v1/data-channel/x s3 application/x-amz-json-1.1 {} SSM SSM +POST localhost:4566 /ssmmessages/v1/data-channel/x application/x-amz-json-1.1 {} SSM SSM +POST localhost:4566 /ssmmessages/v1/data-channel/x s3 application/x-amz-json-1.1 {} SSM SSM +PUT localhost:4566 /ssmmessages/v1/data-channel/x application/x-amz-json-1.1 {} SSM SSM +PUT localhost:4566 /ssmmessages/v1/data-channel/x s3 application/x-amz-json-1.1 {} SSM SSM +DELETE localhost:4566 /ssmmessages/v1/data-channel/x application/x-amz-json-1.1 {} SSM SSM +DELETE localhost:4566 /ssmmessages/v1/data-channel/x s3 application/x-amz-json-1.1 {} SSM SSM +GET localhost:4566 /v2/ application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/ s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/ application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/ s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/ application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/ s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/ application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/ s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2 s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2 s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2 s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2 s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/_catalog application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/_catalog s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/_catalog application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/_catalog s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/_catalog application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/_catalog s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/_catalog application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/_catalog s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/repo/manifests/latest application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/repo/manifests/latest s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/repo/manifests/latest application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/repo/manifests/latest s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/repo/manifests/latest application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/repo/manifests/latest s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/repo/manifests/latest application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/repo/manifests/latest s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/repo/blobs/sha256:x application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/repo/blobs/sha256:x s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/repo/blobs/sha256:x application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/repo/blobs/sha256:x s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/repo/blobs/sha256:x application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/repo/blobs/sha256:x s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/repo/blobs/sha256:x application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/repo/blobs/sha256:x s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/repo/tags/list application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /v2/repo/tags/list s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/repo/tags/list application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /v2/repo/tags/list s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/repo/tags/list application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /v2/repo/tags/list s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/repo/tags/list application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /v2/repo/tags/list s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /schedules application/x-amz-json-1.1 {} Scheduler Scheduler +GET localhost:4566 /schedules s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /schedules application/x-amz-json-1.1 {} Scheduler Scheduler +POST localhost:4566 /schedules s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /schedules application/x-amz-json-1.1 {} Scheduler Scheduler +PUT localhost:4566 /schedules s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /schedules application/x-amz-json-1.1 {} Scheduler Scheduler +DELETE localhost:4566 /schedules s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /schedules/x application/x-amz-json-1.1 {} Scheduler Scheduler +GET localhost:4566 /schedules/x s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /schedules/x application/x-amz-json-1.1 {} Scheduler Scheduler +POST localhost:4566 /schedules/x s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /schedules/x application/x-amz-json-1.1 {} Scheduler Scheduler +PUT localhost:4566 /schedules/x s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /schedules/x application/x-amz-json-1.1 {} Scheduler Scheduler +DELETE localhost:4566 /schedules/x s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /schedule-groups/g application/x-amz-json-1.1 {} Scheduler Scheduler +GET localhost:4566 /schedule-groups/g s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /schedule-groups/g application/x-amz-json-1.1 {} Scheduler Scheduler +POST localhost:4566 /schedule-groups/g s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /schedule-groups/g application/x-amz-json-1.1 {} Scheduler Scheduler +PUT localhost:4566 /schedule-groups/g s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /schedule-groups/g application/x-amz-json-1.1 {} Scheduler Scheduler +DELETE localhost:4566 /schedule-groups/g s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx application/x-amz-json-1.1 {} Scheduler Scheduler +GET localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx s3 application/x-amz-json-1.1 {} Scheduler Scheduler +POST localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx application/x-amz-json-1.1 {} Scheduler Scheduler +POST localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx s3 application/x-amz-json-1.1 {} Scheduler Scheduler +PUT localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx application/x-amz-json-1.1 {} Scheduler Scheduler +PUT localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx s3 application/x-amz-json-1.1 {} Scheduler Scheduler +DELETE localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx application/x-amz-json-1.1 {} Scheduler Scheduler +DELETE localhost:4566 /tags/arn:aws:scheduler:us-east-1:000000000000:schedule%2Fx s3 application/x-amz-json-1.1 {} Scheduler Scheduler +GET localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t application/x-amz-json-1.1 {} DSQL DSQL +GET localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t application/x-amz-json-1.1 {} DSQL DSQL +POST localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t application/x-amz-json-1.1 {} DSQL DSQL +PUT localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t application/x-amz-json-1.1 {} DSQL DSQL +DELETE localhost:4566 /tags/arn:aws:sns:us-east-1:000000000000:t s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /SimpleNotificationService-abc.pem application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /SimpleNotificationService-abc.pem s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /SimpleNotificationService-abc.pem application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /SimpleNotificationService-abc.pem s3 application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /SimpleNotificationService-abc.pem application/x-amz-json-1.1 {} S3 S3 +PUT localhost:4566 /SimpleNotificationService-abc.pem s3 application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /SimpleNotificationService-abc.pem application/x-amz-json-1.1 {} S3 S3 +DELETE localhost:4566 /SimpleNotificationService-abc.pem s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 / sts application/x-www-form-urlencoded Action=ListQueues&Version=2012-11-05 SQS SQS +POST localhost:4566 / sns application/x-www-form-urlencoded Action=ListQueues&Version=2012-11-05 SQS SQS +POST localhost:4566 / sqs application/x-www-form-urlencoded Action=ListQueues&Version=2012-11-05 SQS SQS +POST localhost:4566 / application/x-www-form-urlencoded Action=ListQueues&Version=2012-11-05 SQS SQS +POST localhost:4566 / sts APPLICATION/X-WWW-FORM-URLENCODED Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sns APPLICATION/X-WWW-FORM-URLENCODED Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sqs APPLICATION/X-WWW-FORM-URLENCODED Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / APPLICATION/X-WWW-FORM-URLENCODED Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sts application/json Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sns application/json Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sqs application/json Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / application/json Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sts Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sns Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sqs Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / Action=ListQueues&Version=2012-11-05 S3 S3 +POST localhost:4566 / sts application/x-www-form-urlencoded Action=GetCallerIdentity&Version=2011-06-15 STS STS +POST localhost:4566 / sns application/x-www-form-urlencoded Action=GetCallerIdentity&Version=2011-06-15 STS STS +POST localhost:4566 / sqs application/x-www-form-urlencoded Action=GetCallerIdentity&Version=2011-06-15 STS STS +POST localhost:4566 / sts APPLICATION/X-WWW-FORM-URLENCODED Action=GetCallerIdentity&Version=2011-06-15 STS STS +POST localhost:4566 / sns APPLICATION/X-WWW-FORM-URLENCODED Action=GetCallerIdentity&Version=2011-06-15 STS STS +POST localhost:4566 / sqs APPLICATION/X-WWW-FORM-URLENCODED Action=GetCallerIdentity&Version=2011-06-15 STS STS +POST localhost:4566 / APPLICATION/X-WWW-FORM-URLENCODED Action=GetCallerIdentity&Version=2011-06-15 STS STS +POST localhost:4566 / sts application/json Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / sns application/json Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / sqs application/json Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / application/json Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / sts Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / sns Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / sqs Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / Action=GetCallerIdentity&Version=2011-06-15 S3 S3 +POST localhost:4566 / sts application/x-www-form-urlencoded Action=ListTopics&Version=2010-03-31 SNS SNS +POST localhost:4566 / sns application/x-www-form-urlencoded Action=ListTopics&Version=2010-03-31 SNS SNS +POST localhost:4566 / sqs application/x-www-form-urlencoded Action=ListTopics&Version=2010-03-31 SNS SNS +POST localhost:4566 / application/x-www-form-urlencoded Action=ListTopics&Version=2010-03-31 SNS SNS +POST localhost:4566 / sts APPLICATION/X-WWW-FORM-URLENCODED Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sns APPLICATION/X-WWW-FORM-URLENCODED Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sqs APPLICATION/X-WWW-FORM-URLENCODED Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / APPLICATION/X-WWW-FORM-URLENCODED Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sts application/json Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sns application/json Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sqs application/json Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / application/json Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sts Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sns Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sqs Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / Action=ListTopics&Version=2010-03-31 S3 S3 +POST localhost:4566 / sts application/x-www-form-urlencoded Action=Bogus S3 S3 +POST localhost:4566 / sns application/x-www-form-urlencoded Action=Bogus S3 S3 +POST localhost:4566 / sqs application/x-www-form-urlencoded Action=Bogus S3 S3 +POST localhost:4566 / sts APPLICATION/X-WWW-FORM-URLENCODED Action=Bogus S3 S3 +POST localhost:4566 / sns APPLICATION/X-WWW-FORM-URLENCODED Action=Bogus S3 S3 +POST localhost:4566 / sqs APPLICATION/X-WWW-FORM-URLENCODED Action=Bogus S3 S3 +POST localhost:4566 / APPLICATION/X-WWW-FORM-URLENCODED Action=Bogus S3 S3 +POST localhost:4566 / sts application/json Action=Bogus S3 S3 +POST localhost:4566 / sns application/json Action=Bogus S3 S3 +POST localhost:4566 / sqs application/json Action=Bogus S3 S3 +POST localhost:4566 / application/json Action=Bogus S3 S3 +POST localhost:4566 / sts Action=Bogus S3 S3 +POST localhost:4566 / sns Action=Bogus S3 S3 +POST localhost:4566 / sqs Action=Bogus S3 S3 +POST localhost:4566 / Action=Bogus S3 S3 +POST localhost:4566 / sts application/x-www-form-urlencoded S3 S3 +POST localhost:4566 / sns application/x-www-form-urlencoded S3 S3 +POST localhost:4566 / sqs application/x-www-form-urlencoded S3 S3 +POST localhost:4566 / sts APPLICATION/X-WWW-FORM-URLENCODED S3 S3 +POST localhost:4566 / sns APPLICATION/X-WWW-FORM-URLENCODED S3 S3 +POST localhost:4566 / sqs APPLICATION/X-WWW-FORM-URLENCODED S3 S3 +POST localhost:4566 / APPLICATION/X-WWW-FORM-URLENCODED S3 S3 +POST localhost:4566 / sts application/json S3 S3 +POST localhost:4566 / sns application/json S3 S3 +POST localhost:4566 / sqs application/json S3 S3 +POST localhost:4566 / application/json S3 S3 +POST localhost:4566 / sns S3 S3 +POST localhost:4566 / sqs S3 S3 +GET localhost:4566 /?Action=ConfirmSubscription&TopicArn=arn%3Aaws%3Asns%3Aus-east-1%3A000000000000%3At&Token=x application/x-amz-json-1.1 {} SNS SNS +GET localhost:4566 /bucket/key?Action=ConfirmSubscription&TopicArn=arn%3Aaws%3Asns%3Aus-east-1%3A000000000000%3At&Token=x s3 application/x-amz-json-1.1 {} SNS SNS +GET localhost:4566 /?Act%69on=ConfirmSubscription&TopicArn=x application/x-amz-json-1.1 {} SNS SNS +GET localhost:4566 /bucket/key?Act%69on=ConfirmSubscription&TopicArn=x s3 application/x-amz-json-1.1 {} SNS SNS +GET localhost:4566 /?Action=ListTopics application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /bucket/key?Action=ListTopics s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /?x=1 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /bucket/key?x=1 s3 application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /?X-Amz-Credential=a%2Fb application/x-amz-json-1.1 {} S3 S3 +GET localhost:4566 /bucket/key?X-Amz-Credential=a%2Fb s3 application/x-amz-json-1.1 {} S3 S3 +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonMemoryDB.DescribeClusters {} MemoryDB MemoryDB +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 ToggleCustomerAPI.GetRoutingControlState {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AWSLambda.ListFunctions {} Lambda Lambda +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AWSCognitoIdentityProviderService.InitiateAuth {} CognitoIDP CognitoIDP +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AWSLambda20150331.ListFunctions {} Lambda Lambda +POST localhost:4566 /q/000000000000/name sqs application/x-amz-json-1.1 AmazonMemoryDB {} S3 S3 diff --git a/ui/package.json b/ui/package.json index 63ed7a98aa..72ae709d06 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,7 +9,7 @@ }, "scripts": { "dev": "vite dev", - "build": "npm run check && vite build", + "build": "npm run check && vite build && node scripts/compress-spa.mjs", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", diff --git a/ui/scripts/compress-spa.mjs b/ui/scripts/compress-spa.mjs new file mode 100644 index 0000000000..c73af39d69 --- /dev/null +++ b/ui/scripts/compress-spa.mjs @@ -0,0 +1,34 @@ +// Gzips the built SPA in place (file -> file.gz). The Go embed serves the +// .gz bytes directly to gzip-capable clients and inflates for the rest. +import { readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync, constants } from "node:zlib"; + +const spaDir = fileURLToPath(new URL("../../dashboard/static/spa", import.meta.url)); +const compressible = new Set([".js", ".css", ".html", ".json", ".svg", ".txt", ".map"]); + +let before = 0; +let after = 0; + +const walk = (dir) => { + for (const name of readdirSync(dir)) { + const file = join(dir, name); + if (statSync(file).isDirectory()) { + walk(file); + continue; + } + if (!compressible.has(extname(name))) { + continue; + } + const raw = readFileSync(file); + const gz = gzipSync(raw, { level: constants.Z_BEST_COMPRESSION }); + writeFileSync(`${file}.gz`, gz); + rmSync(file); + before += raw.length; + after += gz.length; + } +}; + +walk(spaDir); +console.log(`compress-spa: ${before} -> ${after} bytes`); diff --git a/ui/src/lib/azureaks.test.ts b/ui/src/lib/azureaks.test.ts new file mode 100644 index 0000000000..99a26acbed --- /dev/null +++ b/ui/src/lib/azureaks.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + AKS_API_VERSION, + AKS_PROXY_BASE, + credentialsCommand, + deleteCluster, + getUpgradeProfile, + listClusters, + parseCluster, + parsePool, + parseUpgradeProfile, + powerAction, + scalePool, + totalNodes, + validPoolCount, +} from "./azureaks"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; + +const sub = "00000000-0000-0000-0000-000000000000"; +const api = `?api-version=${AKS_API_VERSION}`; +const idOf = (rg: string, name: string) => + `/subscriptions/${sub}/resourceGroups/${rg}/providers/Microsoft.ContainerService/managedClusters/${name}`; +const base = `${AKS_PROXY_BASE}${idOf("demo", "prod")}`; +const target = { resourceGroup: "demo", name: "prod" }; + +const cluster = (name: string, rg = "demo", extra: Record = {}) => ({ + id: idOf(rg, name), + name, + location: "eastus", + sku: { name: "Base", tier: "Free" }, + properties: { + kubernetesVersion: "1.35", + currentKubernetesVersion: "1.35.2", + provisioningState: "Succeeded", + powerState: { code: "Running" }, + fqdn: `${name}.hcp.eastus.azmk8s.io`, + nodeResourceGroup: `MC_${rg}_${name}_eastus`, + networkProfile: { + networkPlugin: "azure", + networkPluginMode: "overlay", + serviceCidr: "10.0.0.0/16", + dnsServiceIP: "10.0.0.10", + loadBalancerSku: "standard", + }, + agentPoolProfiles: [ + { + name: "system", + mode: "System", + vmSize: "Standard_D4s_v3", + count: 2, + availabilityZones: ["1", "2"], + }, + { name: "burst", mode: "User", count: 1, enableAutoScaling: true, minCount: 1, maxCount: 5 }, + ], + ...extra, + }, +}); + +describe("parsing", () => { + it("parses a cluster with network profile and pools", () => { + expect(parseCluster(cluster("prod"))).toMatchObject({ + name: "prod", + resourceGroup: "demo", + version: "1.35", + currentVersion: "1.35.2", + power: "Running", + fqdn: "prod.hcp.eastus.azmk8s.io", + private: false, + nodeResourceGroup: "MC_demo_prod_eastus", + tier: "Free", + network: { plugin: "azure", pluginMode: "overlay", dnsServiceIp: "10.0.0.10" }, + }); + }); + + it("flags a private cluster by its private FQDN", () => { + const c = parseCluster( + cluster("p", "demo", { fqdn: undefined, privateFQDN: "p.privatelink.x" }), + ); + expect(c).toMatchObject({ fqdn: "p.privatelink.x", private: true }); + }); + + it.each([ + [ + { name: "a", properties: { count: 3, enableAutoScaling: true, minCount: 1, maxCount: 4 } }, + 3, + true, + ], + [{ name: "b", mode: "User", count: 2 }, 2, false], + [{ name: "c" }, 0, false], + ])("parses pool %#", (raw, count, autoscale) => { + expect(parsePool(raw)).toMatchObject({ count, autoscale }); + }); + + it("sums pool counts", () => { + expect(totalNodes(parseCluster(cluster("prod")))).toBe(3); + }); + + it("parses an upgrade profile", () => { + const p = parseUpgradeProfile({ + properties: { + controlPlaneProfile: { + kubernetesVersion: "1.35.2", + upgrades: [ + { kubernetesVersion: "1.36.1" }, + { kubernetesVersion: "1.38.0", isPreview: true }, + ], + }, + agentPoolProfiles: [{ name: "system", kubernetesVersion: "1.35.2", upgrades: [] }], + }, + }); + expect(p.controlPlane.upgrades).toEqual([ + { version: "1.36.1", preview: false }, + { version: "1.38.0", preview: true }, + ]); + expect(p.pools).toEqual([{ name: "system", version: "1.35.2", upgrades: [] }]); + }); + + it.each([ + ["0", true], + ["12", true], + ["1000", true], + ["1001", false], + ["-1", false], + ["1.5", false], + ["", false], + ["abc", false], + ])("validates pool count %j", (v, ok) => { + expect(validPoolCount(v)).toBe(ok); + }); + + it("offers the CLI command instead of credentials", () => { + expect(credentialsCommand(target)).toBe( + "az aks get-credentials --resource-group demo --name prod", + ); + }); +}); + +describe("client", () => { + it("lists clusters sorted by resource group and name", async () => { + const fetchFn = vi.fn(() => + Promise.resolve( + json({ value: [cluster("zeta", "b"), cluster("alpha", "b"), cluster("prod")] }), + ), + ); + const out = await listClusters(sub, asFetch(fetchFn)); + expect(out.map((c) => `${c.resourceGroup}/${c.name}`)).toEqual([ + "b/alpha", + "b/zeta", + "demo/prod", + ]); + expect(fetchFn.mock.calls[0]).toEqual([ + `${AKS_PROXY_BASE}/subscriptions/${sub}/providers/Microsoft.ContainerService/managedClusters${api}`, + { method: "GET" }, + ]); + }); + + it("reads the upgrade profile", async () => { + const fetchFn = vi.fn((_u: string) => + Promise.resolve( + json({ properties: { controlPlaneProfile: { kubernetesVersion: "1.35.2" } } }), + ), + ); + expect((await getUpgradeProfile(sub, target, asFetch(fetchFn))).controlPlane.version).toBe( + "1.35.2", + ); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${base}/upgradeProfiles/default${api}`); + }); + + it.each([ + ["start", "POST"], + ["stop", "POST"], + ] as const)("posts %s", async (action, method) => { + const fetchFn = vi.fn((_u: string, _i?: RequestInit) => Promise.resolve(json({}, 202))); + await powerAction(sub, target, action, asFetch(fetchFn)); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${base}/${action}${api}`); + expect(initOf(fetchFn, 0).method).toBe(method); + }); + + it("deletes a cluster", async () => { + const fetchFn = vi.fn((_u: string, _i?: RequestInit) => + Promise.resolve(new Response(null, { status: 202 })), + ); + await deleteCluster(sub, target, asFetch(fetchFn)); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${base}${api}`); + expect(initOf(fetchFn, 0).method).toBe("DELETE"); + }); + + it("scales a pool by writing back its current properties with the new count", async () => { + const pool = { + properties: { mode: "User", vmSize: "Standard_D4s_v3", count: 1, osType: "Linux" }, + }; + const fetchFn = vi.fn((_u: string, init?: RequestInit) => + Promise.resolve(json(init?.method === "PUT" ? {} : pool)), + ); + await scalePool(sub, target, "burst", 6, asFetch(fetchFn)); + const url = `${base}/agentPools/burst${api}`; + expect(fetchFn.mock.calls.map((c) => [c[0], (c[1] as RequestInit).method])).toEqual([ + [url, "GET"], + [url, "PUT"], + ]); + expect(JSON.parse(initOf(fetchFn, 1).body as string)).toEqual({ + properties: { mode: "User", vmSize: "Standard_D4s_v3", count: 6, osType: "Linux" }, + }); + }); + + it("surfaces an ARM failure", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(json({ error: { code: "Conflict", message: "cluster is stopped" } }, 409)); + await expect(scalePool(sub, target, "burst", 2, asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 409, + message: "cluster is stopped", + }); + }); +}); diff --git a/ui/src/lib/azureaks.ts b/ui/src/lib/azureaks.ts new file mode 100644 index 0000000000..67cff6c4fa --- /dev/null +++ b/ui/src/lib/azureaks.ts @@ -0,0 +1,228 @@ +/** Client for Azure Kubernetes Service: managed clusters, agent pools and upgrade profiles via the dashboard's in-process ARM proxy. */ + +import { parseArmId } from "./azurearm.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const AKS_PROXY_BASE = "/dashboard/api/v1/azure/aks"; +export const AKS_API_VERSION = "2025-05-01"; +export const AKS_MAX_POOL_COUNT = 1000; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const strs = (v: unknown): string[] => + Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; + +export type PowerAction = "start" | "stop"; + +export type NetworkProfile = { + plugin: string; + pluginMode: string; + policy: string; + podCidr: string; + serviceCidr: string; + dnsServiceIp: string; + loadBalancerSku: string; + outboundType: string; +}; + +export type AgentPool = { + name: string; + mode: string; + vmSize: string; + count: number; + autoscale: boolean; + minCount: number; + maxCount: number; + zones: string[]; + osType: string; + version: string; + power: string; + state: string; +}; + +export type AksCluster = { + id: string; + name: string; + resourceGroup: string; + location: string; + version: string; + currentVersion: string; + power: string; + state: string; + fqdn: string; + private: boolean; + nodeResourceGroup: string; + tier: string; + network: NetworkProfile; + pools: AgentPool[]; +}; + +export type UpgradeTarget = { version: string; preview: boolean }; + +export type UpgradeProfile = { + controlPlane: { version: string; upgrades: UpgradeTarget[] }; + pools: { name: string; version: string; upgrades: UpgradeTarget[] }[]; +}; + +export function parsePool(raw: Json): AgentPool { + const p = Object.keys(obj(raw.properties)).length > 0 ? obj(raw.properties) : raw; + return { + name: str(raw.name), + mode: str(p.mode), + vmSize: str(p.vmSize), + count: num(p.count), + autoscale: p.enableAutoScaling === true, + minCount: num(p.minCount), + maxCount: num(p.maxCount), + zones: strs(p.availabilityZones), + osType: str(p.osType), + version: str(p.currentOrchestratorVersion) || str(p.orchestratorVersion), + power: str(obj(p.powerState).code), + state: str(p.provisioningState), + }; +} + +export function parseNetwork(raw: unknown): NetworkProfile { + const n = obj(raw); + return { + plugin: str(n.networkPlugin), + pluginMode: str(n.networkPluginMode), + policy: str(n.networkPolicy), + podCidr: str(n.podCidr), + serviceCidr: str(n.serviceCidr), + dnsServiceIp: str(n.dnsServiceIP), + loadBalancerSku: str(n.loadBalancerSku), + outboundType: str(n.outboundType), + }; +} + +export function parseCluster(raw: Json): AksCluster { + const p = obj(raw.properties); + const id = str(raw.id); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + version: str(p.kubernetesVersion), + currentVersion: str(p.currentKubernetesVersion), + power: str(obj(p.powerState).code), + state: str(p.provisioningState), + fqdn: str(p.fqdn) || str(p.privateFQDN), + private: !str(p.fqdn) && !!str(p.privateFQDN), + nodeResourceGroup: str(p.nodeResourceGroup), + tier: str(obj(raw.sku).tier), + network: parseNetwork(p.networkProfile), + pools: list(p.agentPoolProfiles).map((r) => parsePool(r)), + }; +} + +const targets = (v: unknown): UpgradeTarget[] => + list(v).map((u) => ({ version: str(u.kubernetesVersion), preview: u.isPreview === true })); + +export function parseUpgradeProfile(raw: Json): UpgradeProfile { + const p = obj(raw.properties); + const cp = obj(p.controlPlaneProfile); + return { + controlPlane: { version: str(cp.kubernetesVersion), upgrades: targets(cp.upgrades) }, + pools: list(p.agentPoolProfiles).map((r) => ({ + name: str(r.name), + version: str(r.kubernetesVersion), + upgrades: targets(r.upgrades), + })), + }; +} + +const withVersion = (path: string) => `${path}?api-version=${AKS_API_VERSION}`; +const subPath = (s: string) => `/subscriptions/${enc(s)}`; +const clusterPath = (s: string, rg: string, name: string) => + `${subPath(s)}/resourceGroups/${enc(rg)}/providers/Microsoft.ContainerService/managedClusters/${enc(name)}`; + +export async function listClusters(sub: string, fetchFn: FetchFn = fetch): Promise { + const body = await restCall( + AKS_PROXY_BASE, + "GET", + withVersion(`${subPath(sub)}/providers/Microsoft.ContainerService/managedClusters`), + undefined, + fetchFn, + ); + return list(body.value) + .map((r) => parseCluster(r)) + .toSorted( + (a, b) => a.resourceGroup.localeCompare(b.resourceGroup) || a.name.localeCompare(b.name), + ); +} + +export async function getUpgradeProfile( + sub: string, + c: Pick, + fetchFn: FetchFn = fetch, +): Promise { + return parseUpgradeProfile( + await restCall( + AKS_PROXY_BASE, + "GET", + withVersion(`${clusterPath(sub, c.resourceGroup, c.name)}/upgradeProfiles/default`), + undefined, + fetchFn, + ), + ); +} + +export async function powerAction( + sub: string, + c: Pick, + action: PowerAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + AKS_PROXY_BASE, + "POST", + withVersion(`${clusterPath(sub, c.resourceGroup, c.name)}/${action}`), + undefined, + fetchFn, + ); +} + +export async function deleteCluster( + sub: string, + c: Pick, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + AKS_PROXY_BASE, + "DELETE", + withVersion(clusterPath(sub, c.resourceGroup, c.name)), + undefined, + fetchFn, + ); +} + +/** Sets a pool's node count: reads the pool's current properties and writes them back with the new count. */ +export async function scalePool( + sub: string, + c: Pick, + pool: string, + count: number, + fetchFn: FetchFn = fetch, +): Promise { + const path = withVersion(`${clusterPath(sub, c.resourceGroup, c.name)}/agentPools/${enc(pool)}`); + const current = await restCall(AKS_PROXY_BASE, "GET", path, undefined, fetchFn); + const properties = { ...obj(current.properties), count }; + await restCall(AKS_PROXY_BASE, "PUT", path, { properties }, fetchFn); +} + +export const validPoolCount = (v: string): boolean => { + if (!/^\d+$/.test(v.trim())) return false; + const n = Number(v); + return n >= 0 && n <= AKS_MAX_POOL_COUNT; +}; + +/** The CLI command that fetches credentials for a cluster; the dashboard never handles them itself. */ +export const credentialsCommand = (c: Pick): string => + `az aks get-credentials --resource-group ${c.resourceGroup} --name ${c.name}`; + +export const totalNodes = (c: Pick): number => + c.pools.reduce((n, p) => n + p.count, 0); diff --git a/ui/src/lib/azureapim.test.ts b/ui/src/lib/azureapim.test.ts new file mode 100644 index 0000000000..002655643e --- /dev/null +++ b/ui/src/lib/azureapim.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + APIM_PROXY_BASE, + getPolicy, + isActiveSubscription, + listApis, + listNamedValues, + listOperations, + listProducts, + listServices, + listSubscriptions, + operationPath, + parseNamedValue, + parseService, + tryGateway, +} from "./azureapim"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const urlOf = (f: { mock: { calls: unknown[][] } }, i: number): string => + String((f.mock.calls[i] ?? [])[0]); +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; + +const sub = "00000000-0000-0000-0000-000000000000"; +const target = { resourceGroup: "demo", name: "gw1" }; +const base = `${APIM_PROXY_BASE}/subscriptions/${sub}/resourceGroups/demo/providers/Microsoft.ApiManagement/service/gw1`; +const ver = "?api-version=2024-05-01"; + +describe("parsing", () => { + it("parses a service", () => { + expect( + parseService({ + id: `/subscriptions/${sub}/resourceGroups/demo/providers/Microsoft.ApiManagement/service/gw1`, + name: "gw1", + location: "westus", + sku: { name: "Developer", capacity: 1 }, + properties: { provisioningState: "Succeeded", gatewayUrl: "https://gw1.azure-api.net" }, + }), + ).toMatchObject({ + name: "gw1", + resourceGroup: "demo", + sku: "Developer", + capacity: 1, + state: "Succeeded", + gatewayUrl: "https://gw1.azure-api.net", + }); + }); + + it("never carries the value of a secret named value", () => { + expect( + parseNamedValue({ + name: "h", + properties: { displayName: "h", isSecret: true, value: "leak" }, + }), + ).toMatchObject({ secret: true, value: "" }); + expect( + parseNamedValue({ name: "h", properties: { displayName: "h", secret: true, value: "leak" } }) + .value, + ).toBe(""); + expect( + parseNamedValue({ name: "s", properties: { displayName: "s", value: "vis" } }), + ).toMatchObject({ + secret: false, + value: "vis", + }); + expect( + parseNamedValue({ name: "k", properties: { keyVault: { secretIdentifier: "x" } } }).keyVault, + ).toBe(true); + }); + + it("joins an API path and an operation template", () => { + expect(operationPath({ path: "echo" }, { urlTemplate: "/items/{id}" })).toBe( + "/echo/items/{id}", + ); + expect(operationPath({ path: "/echo/" }, { urlTemplate: "items" })).toBe("/echo/items"); + expect(operationPath({ path: "" }, { urlTemplate: "/x" })).toBe("/x"); + }); + + it("recognises active subscriptions case-insensitively", () => { + expect(isActiveSubscription({ state: "active" })).toBe(true); + expect(isActiveSubscription({ state: "Active" })).toBe(true); + expect(isActiveSubscription({ state: "suspended" })).toBe(false); + }); +}); + +describe("requests", () => { + it("lists services sorted", async () => { + const f = vi.fn().mockResolvedValue( + json({ + value: [ + { + id: `/subscriptions/${sub}/resourceGroups/b/providers/Microsoft.ApiManagement/service/z`, + name: "z", + }, + { + id: `/subscriptions/${sub}/resourceGroups/a/providers/Microsoft.ApiManagement/service/y`, + name: "y", + }, + ], + }), + ); + expect((await listServices(sub, asFetch(f))).map((s) => s.name)).toEqual(["y", "z"]); + expect(urlOf(f, 0)).toBe( + `${APIM_PROXY_BASE}/subscriptions/${sub}/providers/Microsoft.ApiManagement/service${ver}`, + ); + }); + + it("reads each child collection", async () => { + const f = vi + .fn() + .mockImplementation(() => + Promise.resolve( + json({ value: [{ name: "x", properties: { displayName: "X", method: "get" } }] }), + ), + ); + await listApis(sub, target, asFetch(f)); + await listOperations(sub, target, "echo", asFetch(f)); + await listProducts(sub, target, asFetch(f)); + await listSubscriptions(sub, target, asFetch(f)); + await listNamedValues(sub, target, asFetch(f)); + expect([0, 1, 2, 3, 4].map((i) => urlOf(f, i))).toEqual([ + `${base}/apis${ver}`, + `${base}/apis/echo/operations${ver}`, + `${base}/products${ver}`, + `${base}/subscriptions${ver}`, + `${base}/namedValues${ver}`, + ]); + expect((await listOperations(sub, target, "echo", asFetch(f)))[0].method).toBe("GET"); + }); + + it("reads policies at each scope and treats 404 as empty", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json({ properties: { value: "" } })) + .mockResolvedValueOnce(json({ properties: { value: "" } })) + .mockResolvedValueOnce(json({ properties: { value: "" } })) + .mockResolvedValueOnce(json({ error: { code: "NotFound", message: "x" } }, 404)); + expect(await getPolicy(sub, target, {}, asFetch(f))).toBe(""); + expect(await getPolicy(sub, target, { api: "echo" }, asFetch(f))).toBe(""); + expect(await getPolicy(sub, target, { api: "echo", operation: "get" }, asFetch(f))).toBe( + "", + ); + expect(await getPolicy(sub, target, {}, asFetch(f))).toBe(""); + expect(urlOf(f, 0)).toBe(`${base}/policies/policy${ver}&format=rawxml`); + expect(urlOf(f, 1)).toBe(`${base}/apis/echo/policies/policy${ver}&format=rawxml`); + expect(urlOf(f, 2)).toBe( + `${base}/apis/echo/operations/get/policies/policy${ver}&format=rawxml`, + ); + }); + + it("rethrows other policy errors", async () => { + const f = vi.fn().mockResolvedValue(json({ error: { code: "Boom", message: "bad" } }, 500)); + await expect(getPolicy(sub, target, {}, asFetch(f))).rejects.toThrow(); + }); + + it("sends try-it with only the subscription id, never a key", async () => { + const f = vi.fn().mockResolvedValue(json({ status: 200, headers: {}, body: "ok" })); + const res = await tryGateway( + sub, + target, + { + method: "GET", + path: "/echo/x", + query: "?q=1", + body: "", + headers: [], + subscription: "k1", + api: "echo", + }, + asFetch(f), + ); + expect(res.status).toBe(200); + expect(urlOf(f, 0)).toBe(`${base}/invoke`); + expect(initOf(f, 0).method).toBe("POST"); + const sent = JSON.parse(String(initOf(f, 0).body)); + expect(sent).toMatchObject({ subscription: "k1", api: "echo", query: "q=1" }); + expect(JSON.stringify(sent)).not.toMatch(/key/i); + }); +}); diff --git a/ui/src/lib/azureapim.ts b/ui/src/lib/azureapim.ts new file mode 100644 index 0000000000..c039714c10 --- /dev/null +++ b/ui/src/lib/azureapim.ts @@ -0,0 +1,267 @@ +/** Client for Azure API Management (services, APIs, products, subscriptions, named values, policies, try-it) via the dashboard's in-process ARM proxy. */ + +import { parseArmId } from "./azurearm.ts"; +import { GcpRestError, restCall, str, type FetchFn, type Json } from "./gcprest.ts"; +import type { TryResponse } from "./ociapigateway.ts"; +import { cleanQuery, readTryResponse, type HeaderPair } from "./tryit.ts"; + +export const APIM_PROXY_BASE = "/dashboard/api/v1/azure/apim"; +export const APIM_API_VERSION = "2024-05-01"; +const Q = `?api-version=${APIM_API_VERSION}`; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const strs = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); + +export type ApimService = { + id: string; + name: string; + resourceGroup: string; + location: string; + sku: string; + capacity: number; + state: string; + gatewayUrl: string; + publisher: string; +}; + +export type ApimApi = { + id: string; + displayName: string; + path: string; + protocols: string[]; + serviceUrl: string; + subscriptionRequired: boolean; +}; + +export type ApimOperation = { + id: string; + displayName: string; + method: string; + urlTemplate: string; +}; + +export type ApimProduct = { + id: string; + displayName: string; + state: string; + subscriptionRequired: boolean; + approvalRequired: boolean; +}; + +export type ApimSubscription = { + id: string; + displayName: string; + scope: string; + state: string; + createdDate: string; +}; + +export type ApimNamedValue = { + id: string; + displayName: string; + secret: boolean; + keyVault: boolean; + value: string; + tags: string[]; +}; + +export type ApimTarget = Pick; + +export type ApimTryRequest = { + method: string; + path: string; + query: string; + body: string; + headers: HeaderPair[]; + subscription: string; + api: string; +}; + +export function parseService(raw: Json): ApimService { + const p = obj(raw.properties); + const id = str(raw.id); + const sku = obj(raw.sku); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + sku: str(sku.name), + capacity: typeof sku.capacity === "number" ? sku.capacity : Number(sku.capacity) || 0, + state: str(p.provisioningState), + gatewayUrl: str(p.gatewayUrl), + publisher: str(p.publisherName), + }; +} + +export const parseApi = (raw: Json): ApimApi => { + const p = obj(raw.properties); + return { + id: str(raw.name), + displayName: str(p.displayName), + path: str(p.path), + protocols: strs(p.protocols), + serviceUrl: str(p.serviceUrl), + subscriptionRequired: p.subscriptionRequired === true, + }; +}; + +export const parseOperation = (raw: Json): ApimOperation => { + const p = obj(raw.properties); + return { + id: str(raw.name), + displayName: str(p.displayName), + method: str(p.method).toUpperCase(), + urlTemplate: str(p.urlTemplate), + }; +}; + +export const parseProduct = (raw: Json): ApimProduct => { + const p = obj(raw.properties); + return { + id: str(raw.name), + displayName: str(p.displayName), + state: str(p.state), + subscriptionRequired: p.subscriptionRequired === true, + approvalRequired: p.approvalRequired === true, + }; +}; + +export const parseSubscription = (raw: Json): ApimSubscription => { + const p = obj(raw.properties); + return { + id: str(raw.name), + displayName: str(p.displayName), + scope: str(p.scope), + state: str(p.state), + createdDate: str(p.createdDate), + }; +}; + +/** A secret named value never carries its value; a plain one shows it. */ +export const parseNamedValue = (raw: Json): ApimNamedValue => { + const p = obj(raw.properties); + const secret = p.isSecret === true || p.secret === true; + return { + id: str(raw.name), + displayName: str(p.displayName), + secret, + keyVault: Object.keys(obj(p.keyVault)).length > 0, + value: secret ? "" : str(p.value), + tags: strs(p.tags), + }; +}; + +const subPath = (s: string) => `/subscriptions/${enc(s)}`; +const svcPath = (sub: string, t: ApimTarget) => + `${subPath(sub)}/resourceGroups/${enc(t.resourceGroup)}/providers/Microsoft.ApiManagement/service/${enc(t.name)}`; + +export async function listServices(sub: string, fetchFn: FetchFn = fetch): Promise { + const body = await restCall( + APIM_PROXY_BASE, + "GET", + `${subPath(sub)}/providers/Microsoft.ApiManagement/service${Q}`, + undefined, + fetchFn, + ); + return list(body.value) + .map((s) => parseService(s)) + .toSorted( + (a, b) => a.resourceGroup.localeCompare(b.resourceGroup) || a.name.localeCompare(b.name), + ); +} + +async function children( + sub: string, + t: ApimTarget, + rest: string, + parse: (r: Json) => T, + fetchFn: FetchFn, +): Promise { + const body = await restCall( + APIM_PROXY_BASE, + "GET", + `${svcPath(sub, t)}/${rest}${Q}`, + undefined, + fetchFn, + ); + return list(body.value).map((r) => parse(r)); +} + +export const listApis = (sub: string, t: ApimTarget, fetchFn: FetchFn = fetch) => + children(sub, t, "apis", parseApi, fetchFn); +export const listOperations = (sub: string, t: ApimTarget, api: string, fetchFn: FetchFn = fetch) => + children(sub, t, `apis/${enc(api)}/operations`, parseOperation, fetchFn); +export const listProducts = (sub: string, t: ApimTarget, fetchFn: FetchFn = fetch) => + children(sub, t, "products", parseProduct, fetchFn); +export const listSubscriptions = (sub: string, t: ApimTarget, fetchFn: FetchFn = fetch) => + children(sub, t, "subscriptions", parseSubscription, fetchFn); +export const listNamedValues = (sub: string, t: ApimTarget, fetchFn: FetchFn = fetch) => + children(sub, t, "namedValues", parseNamedValue, fetchFn); + +export type PolicyScope = { api?: string; operation?: string }; + +/** The XML policy document at a scope; empty when none is set. */ +export async function getPolicy( + sub: string, + t: ApimTarget, + scope: PolicyScope = {}, + fetchFn: FetchFn = fetch, +): Promise { + let rest = ""; + if (scope.api) rest += `/apis/${enc(scope.api)}`; + if (scope.api && scope.operation) rest += `/operations/${enc(scope.operation)}`; + try { + const body = await restCall( + APIM_PROXY_BASE, + "GET", + `${svcPath(sub, t)}${rest}/policies/policy${Q}&format=rawxml`, + undefined, + fetchFn, + ); + return str(obj(body.properties).value); + } catch (e) { + if (e instanceof GcpRestError && e.httpStatus === 404) return ""; + throw e; + } +} + +/** Sends a request through the service gateway; the proxy injects the chosen subscription's key server-side. */ +export async function tryGateway( + sub: string, + t: ApimTarget, + req: ApimTryRequest, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + APIM_PROXY_BASE, + "POST", + `${svcPath(sub, t)}/invoke`, + { + method: req.method, + path: req.path, + query: cleanQuery(req.query), + body: req.body, + headers: req.headers.filter((h) => h.name.trim()), + subscription: req.subscription || undefined, + api: req.api || undefined, + }, + fetchFn, + ); + return readTryResponse(body); +} + +/** Request path for an API operation: the API path joined to the URL template, template parameters left to edit. */ +export function operationPath( + api: Pick, + op: Pick, +): string { + const base = api.path.replaceAll(/^\/+|\/+$/g, ""); + const tpl = op.urlTemplate.startsWith("/") ? op.urlTemplate : `/${op.urlTemplate}`; + return `/${base}${tpl}`.replaceAll(/\/{2,}/g, "/"); +} + +export const isActiveSubscription = (s: Pick): boolean => + s.state.toLowerCase() === "active"; diff --git a/ui/src/lib/azureappservice.test.ts b/ui/src/lib/azureappservice.test.ts new file mode 100644 index 0000000000..b66f552e9c --- /dev/null +++ b/ui/src/lib/azureappservice.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + APPSERVICE_API_VERSION, + APPSERVICE_PROXY_BASE, + getStickySettings, + isWebApp, + listSlots, + listWebApps, + parseSite, + parseSticky, + siteAction, + siteUrl, + stickyCount, + swapSlots, + swapTargets, +} from "./azureappservice"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; + +const sub = "00000000-0000-0000-0000-000000000000"; +const idOf = (rg: string, name: string) => + `/subscriptions/${sub}/resourceGroups/${rg}/providers/Microsoft.Web/sites/${name}`; +const site = (name: string, rg = "demo", extra: Record = {}) => ({ + id: idOf(rg, name.split("/").join("/slots/")), + name, + kind: "app,linux", + location: "eastus", + properties: { + state: "Running", + hostNames: [`${name.split("/")[0]}.azurewebsites.net`], + defaultHostName: `${name.split("/")[0]}.azurewebsites.net`, + siteConfig: { linuxFxVersion: "NODE|20-lts" }, + ...extra, + }, +}); +const api = `?api-version=${APPSERVICE_API_VERSION}`; +const base = `${APPSERVICE_PROXY_BASE}${idOf("demo", "shop")}`; + +describe("parsing", () => { + it("parses a web app", () => { + expect(parseSite(site("shop"))).toMatchObject({ + app: "shop", + slot: "", + resourceGroup: "demo", + state: "Running", + runtime: "NODE|20-lts", + defaultHostName: "shop.azurewebsites.net", + previewTarget: "", + }); + }); + + it("parses a slot with a pending preview and the last swap", () => { + const s = parseSite( + site("shop/staging", "demo", { + targetSwapSlot: "production", + slotSwapStatus: { + sourceSlotName: "staging", + destinationSlotName: "production", + timestampUtc: "t", + }, + }), + ); + expect(s).toMatchObject({ app: "shop", slot: "staging", previewTarget: "production" }); + expect(s.lastSwap).toBe("staging → production at t"); + }); + + it("tells function apps from web apps and builds the hostname link", () => { + expect(isWebApp({ kind: "functionapp,linux" })).toBe(false); + expect(isWebApp({ kind: "app,linux" })).toBe(true); + expect(siteUrl({ defaultHostName: "a.example", httpsOnly: true })).toBe("https://a.example"); + expect(siteUrl({ defaultHostName: "a.example", httpsOnly: false })).toBe("http://a.example"); + expect(siteUrl({ defaultHostName: "", httpsOnly: false })).toBe(""); + }); + + it("parses sticky names", () => { + const sticky = parseSticky({ + properties: { appSettingNames: ["A"], connectionStringNames: ["C", "D"] }, + }); + expect(sticky).toEqual({ appSettings: ["A"], connectionStrings: ["C", "D"], azureStorage: [] }); + expect(stickyCount(sticky)).toBe(3); + expect(stickyCount(parseSticky({}))).toBe(0); + }); + + it("offers every other slot, production included, as swap target", () => { + const slots = [{ slot: "staging" }, { slot: "qa" }]; + expect(swapTargets(slots, "production")).toEqual(["staging", "qa"]); + expect(swapTargets(slots, "staging")).toEqual(["production", "qa"]); + }); +}); + +describe("requests", () => { + it("lists web apps only, without slots and function apps, sorted", async () => { + const fetchFn = vi.fn().mockResolvedValue( + json({ + value: [ + site("zeta", "b-rg"), + { ...site("fn"), kind: "functionapp,linux" }, + site("alpha", "b-rg"), + site("shop"), + site("shop/staging"), + ], + }), + ); + const apps = await listWebApps(sub, asFetch(fetchFn)); + expect(apps.map((a) => `${a.resourceGroup}/${a.app}`)).toEqual([ + "b-rg/alpha", + "b-rg/zeta", + "demo/shop", + ]); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${APPSERVICE_PROXY_BASE}/subscriptions/${sub}/providers/Microsoft.Web/sites${api}`, + ); + }); + + it("lists slots and reads sticky settings", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.includes("slotConfigNames") + ? { properties: { appSettingNames: ["X"] } } + : { value: [site("shop/staging"), site("shop/qa")] }, + ), + ), + ); + const app = { resourceGroup: "demo", app: "shop" }; + expect((await listSlots(sub, app, asFetch(fetchFn))).map((s) => s.slot)).toEqual([ + "qa", + "staging", + ]); + expect((await getStickySettings(sub, app, asFetch(fetchFn))).appSettings).toEqual(["X"]); + expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([ + `${base}/slots${api}`, + `${base}/config/slotConfigNames${api}`, + ]); + }); + + it("posts lifecycle actions to the app and to a slot", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(json({}))); + const app = { resourceGroup: "demo", app: "shop" }; + await siteAction(sub, { ...app, slot: "" }, "stop", asFetch(fetchFn)); + await siteAction(sub, { ...app, slot: "staging" }, "restart", asFetch(fetchFn)); + expect(fetchFn.mock.calls.map((c) => [c[0], (c[1] as RequestInit).method])).toEqual([ + [`${base}/stop${api}`, "POST"], + [`${base}/slots/staging/restart${api}`, "POST"], + ]); + }); + + it("swaps, previews, completes and cancels from the source slot", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(json({}))); + const app = { resourceGroup: "demo", app: "shop" }; + await swapSlots(sub, app, "staging", "production", "swap", asFetch(fetchFn)); + await swapSlots(sub, app, "staging", "production", "preview", asFetch(fetchFn)); + await swapSlots(sub, app, "staging", "production", "complete", asFetch(fetchFn)); + await swapSlots(sub, app, "staging", "production", "cancel", asFetch(fetchFn)); + await swapSlots(sub, app, "production", "staging", "swap", asFetch(fetchFn)); + const calls = fetchFn.mock.calls.map((c) => [c[0], (c[1] as RequestInit).body]); + expect(calls).toEqual([ + [`${base}/slots/staging/slotsswap${api}`, '{"targetSlot":"production"}'], + [`${base}/slots/staging/applySlotConfig${api}`, '{"targetSlot":"production"}'], + [`${base}/slots/staging/slotsswap${api}`, '{"targetSlot":"production"}'], + [`${base}/slots/staging/resetSlotConfig${api}`, undefined], + [`${base}/slotsswap${api}`, '{"targetSlot":"staging"}'], + ]); + }); + + it("surfaces an ARM failure", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(json({ error: { code: "Conflict", message: "swap pending" } }, 409)); + await expect( + siteAction(sub, { resourceGroup: "demo", app: "shop", slot: "" }, "stop", asFetch(fetchFn)), + ).rejects.toMatchObject({ httpStatus: 409, message: "swap pending" }); + }); +}); diff --git a/ui/src/lib/azureappservice.ts b/ui/src/lib/azureappservice.ts new file mode 100644 index 0000000000..af3f44c1da --- /dev/null +++ b/ui/src/lib/azureappservice.ts @@ -0,0 +1,193 @@ +/** Client for Azure App Service web apps and deployment slots: Microsoft.Web via the dashboard's in-process ARM proxy. */ + +import { parseArmId } from "./azurearm.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const APPSERVICE_PROXY_BASE = "/dashboard/api/v1/azure/appservice"; +export const APPSERVICE_API_VERSION = "2023-12-01"; +export const PRODUCTION_SLOT = "production"; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +export type SiteAction = "start" | "stop" | "restart"; + +/** How a swap proceeds: straight through, or in the two phases of a swap with preview. */ +export type SwapMode = "swap" | "preview" | "complete" | "cancel"; + +export type WebSite = { + id: string; + name: string; + app: string; + slot: string; + resourceGroup: string; + location: string; + kind: string; + state: string; + runtime: string; + hostNames: string[]; + defaultHostName: string; + httpsOnly: boolean; + previewTarget: string; + lastSwap: string; +}; + +export type StickySettings = { + appSettings: string[]; + connectionStrings: string[]; + azureStorage: string[]; +}; + +export function parseSite(raw: Json): WebSite { + const p = obj(raw.properties); + const id = str(raw.id); + const name = str(raw.name); + const [app = name, slot = ""] = name.split("/"); + const swap = obj(p.slotSwapStatus); + const cfg = obj(p.siteConfig); + const hosts = Array.isArray(p.hostNames) ? p.hostNames.map(String) : []; + return { + id, + name, + app, + slot, + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + kind: str(raw.kind), + state: str(p.state), + runtime: str(cfg.linuxFxVersion) || str(cfg.windowsFxVersion), + hostNames: hosts, + defaultHostName: str(p.defaultHostName) || (hosts[0] ?? ""), + httpsOnly: p.httpsOnly === true, + previewTarget: str(p.targetSwapSlot), + lastSwap: swap.sourceSlotName + ? `${str(swap.sourceSlotName)} → ${str(swap.destinationSlotName)} at ${str(swap.timestampUtc)}` + : "", + }; +} + +/** Function apps share Microsoft.Web/sites with web apps; the web apps page shows only the latter. */ +export const isWebApp = (s: Pick): boolean => + !s.kind.toLowerCase().includes("functionapp"); + +const names = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); + +export function parseSticky(raw: Json): StickySettings { + const p = obj(raw.properties); + return { + appSettings: names(p.appSettingNames), + connectionStrings: names(p.connectionStringNames), + azureStorage: names(p.azureStorageConfigNames), + }; +} + +/** The URL the app serves from, for linking to its hostname. */ +export const siteUrl = (s: Pick): string => + s.defaultHostName ? `${s.httpsOnly ? "https" : "http"}://${s.defaultHostName}` : ""; + +const withVersion = (path: string) => `${path}?api-version=${APPSERVICE_API_VERSION}`; + +const sitePath = (sub: string, rg: string, app: string) => + `/subscriptions/${enc(sub)}/resourceGroups/${enc(rg)}/providers/Microsoft.Web/sites/${enc(app)}`; + +const targetPath = (sub: string, s: Pick) => + `${sitePath(sub, s.resourceGroup, s.app)}${s.slot ? `/slots/${enc(s.slot)}` : ""}`; + +export async function listWebApps(sub: string, fetchFn: FetchFn = fetch): Promise { + const body = await restCall( + APPSERVICE_PROXY_BASE, + "GET", + withVersion(`/subscriptions/${enc(sub)}/providers/Microsoft.Web/sites`), + undefined, + fetchFn, + ); + return list(body.value) + .map((r) => parseSite(r)) + .filter((s) => isWebApp(s) && !s.slot) + .toSorted( + (a, b) => a.resourceGroup.localeCompare(b.resourceGroup) || a.app.localeCompare(b.app), + ); +} + +export async function listSlots( + sub: string, + app: Pick, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + APPSERVICE_PROXY_BASE, + "GET", + withVersion(`${sitePath(sub, app.resourceGroup, app.app)}/slots`), + undefined, + fetchFn, + ); + return list(body.value) + .map((r) => parseSite(r)) + .toSorted((a, b) => a.slot.localeCompare(b.slot)); +} + +export async function getStickySettings( + sub: string, + app: Pick, + fetchFn: FetchFn = fetch, +): Promise { + return parseSticky( + await restCall( + APPSERVICE_PROXY_BASE, + "GET", + withVersion(`${sitePath(sub, app.resourceGroup, app.app)}/config/slotConfigNames`), + undefined, + fetchFn, + ), + ); +} + +export async function siteAction( + sub: string, + site: Pick, + action: SiteAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + APPSERVICE_PROXY_BASE, + "POST", + withVersion(`${targetPath(sub, site)}/${action}`), + undefined, + fetchFn, + ); +} + +const SWAP_VERB: Record = { + swap: "slotsswap", + preview: "applySlotConfig", + complete: "slotsswap", + cancel: "resetSlotConfig", +}; + +/** Swaps `source` (a slot name, or "production") with `target`, in the given phase. */ +export async function swapSlots( + sub: string, + app: Pick, + source: string, + target: string, + mode: SwapMode, + fetchFn: FetchFn = fetch, +): Promise { + const from = { ...app, slot: source === PRODUCTION_SLOT ? "" : source }; + await restCall( + APPSERVICE_PROXY_BASE, + "POST", + withVersion(`${targetPath(sub, from)}/${SWAP_VERB[mode]}`), + mode === "cancel" ? undefined : { targetSlot: target }, + fetchFn, + ); +} + +/** The slots a given slot can swap with: every other slot, production included. */ +export function swapTargets(slots: Pick[], source: string): string[] { + return [PRODUCTION_SLOT, ...slots.map((s) => s.slot)].filter((n) => n !== source); +} + +export const stickyCount = (s: StickySettings): number => + s.appSettings.length + s.connectionStrings.length + s.azureStorage.length; diff --git a/ui/src/lib/azurecontainerapps.test.ts b/ui/src/lib/azurecontainerapps.test.ts new file mode 100644 index 0000000000..dda8dc4947 --- /dev/null +++ b/ui/src/lib/azurecontainerapps.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ACA_API_VERSION, + ACA_PROXY_BASE, + appAction, + appUrl, + listApps, + listEnvironments, + listExecutions, + listJobs, + listRevisions, + parseApp, + parseRevision, + revisionAction, + revisionActions, + startJob, +} from "./azurecontainerapps"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; + +const sub = "00000000-0000-0000-0000-000000000000"; +const idOf = (rg: string, type: string, name: string) => + `/subscriptions/${sub}/resourceGroups/${rg}/providers/Microsoft.App/${type}/${name}`; +const api = `?api-version=${ACA_API_VERSION}`; +const base = `${ACA_PROXY_BASE}${idOf("demo", "containerApps", "web")}`; + +const app = (name: string, rg = "demo", extra: Record = {}) => ({ + id: idOf(rg, "containerApps", name), + name, + location: "eastus", + properties: { + provisioningState: "Succeeded", + runningStatus: "Running", + managedEnvironmentId: idOf(rg, "managedEnvironments", "env"), + latestRevisionName: `${name}--abc`, + configuration: { + activeRevisionsMode: "Multiple", + ingress: { + fqdn: `${name}.env.example.io`, + external: true, + targetPort: 80, + traffic: [ + { revisionName: `${name}--abc`, weight: 70 }, + { latestRevision: true, weight: 30 }, + ], + }, + }, + template: { scale: { minReplicas: 1, maxReplicas: 3 }, containers: [{ image: "nginx:1" }] }, + ...extra, + }, +}); + +describe("parsing", () => { + it("parses an app with ingress, traffic and scale", () => { + expect(parseApp(app("web"))).toMatchObject({ + name: "web", + resourceGroup: "demo", + runningStatus: "Running", + latestRevision: "web--abc", + fqdn: "web.env.example.io", + external: true, + targetPort: 80, + revisionMode: "Multiple", + minReplicas: 1, + maxReplicas: 3, + images: ["nginx:1"], + traffic: [ + { revision: "web--abc", weight: 70, latest: false }, + { revision: "", weight: 30, latest: true }, + ], + }); + }); + + it("links only external ingress", () => { + expect(appUrl({ fqdn: "a.io", external: true })).toBe("https://a.io"); + expect(appUrl({ fqdn: "a.io", external: false })).toBe(""); + expect(appUrl({ fqdn: "", external: true })).toBe(""); + }); + + it("parses a revision", () => { + expect( + parseRevision({ + name: "web--abc", + properties: { + active: true, + runningState: "Running", + healthState: "Healthy", + replicas: 2, + trafficWeight: 70, + }, + }), + ).toMatchObject({ + name: "web--abc", + active: true, + running: "Running", + replicas: 2, + traffic: 70, + }); + }); + + it.each([ + [true, ["restart", "deactivate"]], + [false, ["activate"]], + ])("revision actions for active=%s", (active, want) => { + expect(revisionActions({ active })).toEqual(want); + }); +}); + +describe("client", () => { + it("lists apps, environments and jobs of a subscription", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json({ + value: url.includes("/containerApps") + ? [app("zeta", "b"), app("alpha", "b"), app("web")] + : url.includes("/jobs") + ? [ + { + id: idOf("demo", "jobs", "j"), + name: "j", + properties: { configuration: { triggerType: "Manual" } }, + }, + ] + : [ + { + id: idOf("demo", "managedEnvironments", "env"), + name: "env", + properties: { defaultDomain: "env.example.io" }, + }, + ], + }), + ), + ); + const apps = await listApps(sub, asFetch(fetchFn)); + expect(apps.map((a) => `${a.resourceGroup}/${a.name}`)).toEqual([ + "b/alpha", + "b/zeta", + "demo/web", + ]); + expect((await listEnvironments(sub, asFetch(fetchFn)))[0]?.defaultDomain).toBe( + "env.example.io", + ); + expect((await listJobs(sub, asFetch(fetchFn)))[0]?.trigger).toBe("Manual"); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${ACA_PROXY_BASE}/subscriptions/${sub}/providers/Microsoft.App/containerApps${api}`, + ); + }); + + it("lists revisions newest first and executions", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json({ + value: url.includes("/executions") + ? [ + { + name: "e1", + properties: { status: "Succeeded", startTime: "2026-10-01T00:00:00Z" }, + }, + ] + : [ + { name: "old", properties: { createdTime: "2026-10-01T00:00:00Z" } }, + { name: "new", properties: { createdTime: "2026-10-02T00:00:00Z" } }, + ], + }), + ), + ); + const target = { resourceGroup: "demo", name: "web" }; + expect((await listRevisions(sub, target, asFetch(fetchFn))).map((r) => r.name)).toEqual([ + "new", + "old", + ]); + expect( + (await listExecutions(sub, { resourceGroup: "demo", name: "j" }, asFetch(fetchFn)))[0] + ?.status, + ).toBe("Succeeded"); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${base}/revisions${api}`); + }); + + it("posts app, revision and job actions", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve(json({ name: "exec-1" })), + ); + const target = { resourceGroup: "demo", name: "web" }; + await appAction(sub, target, "stop", asFetch(fetchFn)); + await revisionAction(sub, target, "web--abc", "restart", asFetch(fetchFn)); + const started = await startJob(sub, { resourceGroup: "demo", name: "j" }, asFetch(fetchFn)); + expect(started).toBe("exec-1"); + expect(fetchFn.mock.calls.map((c) => [c[0], (c[1] as RequestInit).method])).toEqual([ + [`${base}/stop${api}`, "POST"], + [`${base}/revisions/web--abc/restart${api}`, "POST"], + [`${ACA_PROXY_BASE}${idOf("demo", "jobs", "j")}/start${api}`, "POST"], + ]); + }); + + it("surfaces an ARM failure", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(json({ error: { code: "Conflict", message: "already stopped" } }, 409)); + await expect( + appAction(sub, { resourceGroup: "demo", name: "web" }, "stop", asFetch(fetchFn)), + ).rejects.toMatchObject({ + httpStatus: 409, + message: "already stopped", + }); + }); +}); diff --git a/ui/src/lib/azurecontainerapps.ts b/ui/src/lib/azurecontainerapps.ts new file mode 100644 index 0000000000..b5d5f5bdf6 --- /dev/null +++ b/ui/src/lib/azurecontainerapps.ts @@ -0,0 +1,298 @@ +/** Client for Azure Container Apps: Microsoft.App environments, apps, revisions and jobs via the dashboard's in-process ARM proxy. */ + +import { parseArmId } from "./azurearm.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const ACA_PROXY_BASE = "/dashboard/api/v1/azure/containerapps"; +export const ACA_API_VERSION = "2025-07-01"; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); + +export type AppAction = "start" | "stop"; +export type RevisionAction = "activate" | "deactivate" | "restart"; + +export type ContainerEnv = { + id: string; + name: string; + resourceGroup: string; + location: string; + state: string; + defaultDomain: string; + staticIp: string; + zoneRedundant: boolean; +}; + +export type TrafficWeight = { revision: string; weight: number; latest: boolean }; + +export type ContainerApp = { + id: string; + name: string; + resourceGroup: string; + location: string; + state: string; + runningStatus: string; + environment: string; + latestRevision: string; + fqdn: string; + external: boolean; + targetPort: number; + revisionMode: string; + traffic: TrafficWeight[]; + minReplicas: number; + maxReplicas: number; + images: string[]; +}; + +export type Revision = { + name: string; + active: boolean; + state: string; + running: string; + health: string; + replicas: number; + traffic: number; + created: string; + fqdn: string; + error: string; + images: string[]; +}; + +export type ContainerJob = { + id: string; + name: string; + resourceGroup: string; + location: string; + state: string; + trigger: string; + environment: string; + images: string[]; +}; + +export type JobExecution = { name: string; status: string; start: string; end: string }; + +const imagesOf = (tmpl: unknown): string[] => + list(obj(tmpl).containers) + .map((c) => str(c.image)) + .filter(Boolean); + +export function parseEnv(raw: Json): ContainerEnv { + const p = obj(raw.properties); + const id = str(raw.id); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + state: str(p.provisioningState), + defaultDomain: str(p.defaultDomain), + staticIp: str(p.staticIp), + zoneRedundant: p.zoneRedundant === true, + }; +} + +export function parseApp(raw: Json): ContainerApp { + const p = obj(raw.properties); + const id = str(raw.id); + const cfg = obj(p.configuration); + const ingress = obj(cfg.ingress); + const scale = obj(obj(p.template).scale); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + state: str(p.provisioningState), + runningStatus: str(p.runningStatus), + environment: str(p.managedEnvironmentId) || str(p.environmentId), + latestRevision: str(p.latestRevisionName), + fqdn: str(ingress.fqdn) || str(p.latestRevisionFqdn), + external: ingress.external === true, + targetPort: num(ingress.targetPort), + revisionMode: str(cfg.activeRevisionsMode), + traffic: list(ingress.traffic).map((t) => ({ + revision: str(t.revisionName), + weight: num(t.weight), + latest: t.latestRevision === true, + })), + minReplicas: num(scale.minReplicas), + maxReplicas: num(scale.maxReplicas), + images: imagesOf(p.template), + }; +} + +export function parseRevision(raw: Json): Revision { + const p = obj(raw.properties); + return { + name: str(raw.name), + active: p.active === true, + state: str(p.provisioningState), + running: str(p.runningState), + health: str(p.healthState), + replicas: num(p.replicas), + traffic: num(p.trafficWeight), + created: str(p.createdTime), + fqdn: str(p.fqdn), + error: str(p.provisioningError), + images: imagesOf(p.template), + }; +} + +export function parseJob(raw: Json): ContainerJob { + const p = obj(raw.properties); + const id = str(raw.id); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + state: str(p.provisioningState), + trigger: str(obj(p.configuration).triggerType), + environment: str(p.environmentId), + images: imagesOf(p.template), + }; +} + +export function parseExecution(raw: Json): JobExecution { + const p = obj(raw.properties); + return { + name: str(raw.name), + status: str(p.status), + start: str(p.startTime), + end: str(p.endTime), + }; +} + +/** The URL an app's ingress serves from; only apps with external ingress are linkable. */ +export const appUrl = (a: Pick): string => + a.fqdn && a.external ? `https://${a.fqdn}` : ""; + +/** The last segment of an ARM id (the environment's name for a managedEnvironmentId). */ +export const lastSegment = (id: string): string => id.slice(id.lastIndexOf("/") + 1); + +const withVersion = (path: string) => `${path}?api-version=${ACA_API_VERSION}`; +const subPath = (s: string) => `/subscriptions/${enc(s)}`; +const rgPath = (s: string, rg: string, type: string, name: string) => + `${subPath(s)}/resourceGroups/${enc(rg)}/providers/Microsoft.App/${type}/${enc(name)}`; + +async function listType( + subscription: string, + type: string, + parse: (r: Json) => T, + fetchFn: FetchFn, +): Promise { + const body = await restCall( + ACA_PROXY_BASE, + "GET", + withVersion(`${subPath(subscription)}/providers/Microsoft.App/${type}`), + undefined, + fetchFn, + ); + return list(body.value).map((r) => parse(r)); +} + +const byGroupName = (a: T, b: T) => + a.resourceGroup.localeCompare(b.resourceGroup) || a.name.localeCompare(b.name); + +export async function listEnvironments(sub: string, fetchFn: FetchFn = fetch) { + return (await listType(sub, "managedEnvironments", (r) => parseEnv(r), fetchFn)).toSorted( + byGroupName, + ); +} + +export async function listApps(sub: string, fetchFn: FetchFn = fetch) { + return (await listType(sub, "containerApps", (r) => parseApp(r), fetchFn)).toSorted(byGroupName); +} + +export async function listJobs(sub: string, fetchFn: FetchFn = fetch) { + return (await listType(sub, "jobs", (r) => parseJob(r), fetchFn)).toSorted(byGroupName); +} + +export async function listRevisions( + sub: string, + app: Pick, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + ACA_PROXY_BASE, + "GET", + withVersion(`${rgPath(sub, app.resourceGroup, "containerApps", app.name)}/revisions`), + undefined, + fetchFn, + ); + return list(body.value) + .map((r) => parseRevision(r)) + .toSorted((a, b) => b.created.localeCompare(a.created)); +} + +export async function listExecutions( + sub: string, + job: Pick, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + ACA_PROXY_BASE, + "GET", + withVersion(`${rgPath(sub, job.resourceGroup, "jobs", job.name)}/executions`), + undefined, + fetchFn, + ); + return list(body.value) + .map((r) => parseExecution(r)) + .toSorted((a, b) => b.start.localeCompare(a.start)); +} + +export async function appAction( + sub: string, + app: Pick, + action: AppAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + ACA_PROXY_BASE, + "POST", + withVersion(`${rgPath(sub, app.resourceGroup, "containerApps", app.name)}/${action}`), + undefined, + fetchFn, + ); +} + +export async function revisionAction( + sub: string, + app: Pick, + revision: string, + action: RevisionAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + ACA_PROXY_BASE, + "POST", + withVersion( + `${rgPath(sub, app.resourceGroup, "containerApps", app.name)}/revisions/${enc(revision)}/${action}`, + ), + undefined, + fetchFn, + ); +} + +export async function startJob( + sub: string, + job: Pick, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + ACA_PROXY_BASE, + "POST", + withVersion(`${rgPath(sub, job.resourceGroup, "jobs", job.name)}/start`), + {}, + fetchFn, + ); + return str(body.name); +} + +/** Which revision actions a revision allows: an inactive one can only be activated. */ +export function revisionActions(r: Pick): RevisionAction[] { + return r.active ? ["restart", "deactivate"] : ["activate"]; +} diff --git a/ui/src/lib/azureflexdb.test.ts b/ui/src/lib/azureflexdb.test.ts new file mode 100644 index 0000000000..e678500040 --- /dev/null +++ b/ui/src/lib/azureflexdb.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + FLEX_PROXY_BASE, + addDatabase, + addFirewallRule, + canAct, + deleteDatabase, + deleteFirewallRule, + isNonDefault, + listConfigurations, + listDatabases, + listFirewallRules, + listServers, + parseConfig, + parseServer, + powerAction, + validCharset, + validChildName, + validIpv4, +} from "./azureflexdb"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; +const urlOf = (f: { mock: { calls: unknown[][] } }, i: number): string => + String((f.mock.calls[i] ?? [])[0]); + +const sub = "00000000-0000-0000-0000-000000000000"; +const server = (name: string, ns: string, extra: Record = {}) => ({ + id: `/subscriptions/${sub}/resourceGroups/demo/providers/${ns}/flexibleServers/${name}`, + name, + location: "eastus", + sku: { name: "Standard_B1ms", tier: "Burstable" }, + properties: { + state: "Ready", + version: "16", + fullyQualifiedDomainName: `${name}.postgres.database.azure.com`, + administratorLogin: "pgadmin", + storage: { storageSizeGB: 32, iops: 120 }, + highAvailability: { mode: "Disabled", state: "NotEnabled" }, + ...extra, + }, +}); +const target = { engine: "postgresql", resourceGroup: "demo", name: "pgone" } as const; +const base = `${FLEX_PROXY_BASE}/subscriptions/${sub}/resourceGroups/demo/providers/Microsoft.DBforPostgreSQL/flexibleServers/pgone`; + +describe("parsing", () => { + it("parses a server with the gopherstack endpoint", () => { + expect( + parseServer( + server("pgone", "Microsoft.DBforPostgreSQL", { gopherstackEndpoint: "127.0.0.1:5432" }), + "postgresql", + ), + ).toMatchObject({ + name: "pgone", + resourceGroup: "demo", + state: "Ready", + version: "16", + sku: "Standard_B1ms", + tier: "Burstable", + storageGb: 32, + haMode: "Disabled", + fqdn: "pgone.postgres.database.azure.com", + endpoint: "127.0.0.1:5432", + login: "pgadmin", + }); + }); + + it("has no password field", () => { + const s = parseServer( + server("x", "Microsoft.DBforMySQL", { administratorLoginPassword: "leak" }), + "mysql", + ); + expect(JSON.stringify(s)).not.toContain("leak"); + }); + + it.each([ + [{ value: "150", defaultValue: "100", source: "user-override" }, true], + [{ value: "100", defaultValue: "100", source: "system-default" }, false], + [ + { value: "1", defaultValue: "1", source: "system-default", isConfigPendingRestart: "True" }, + true, + ], + [ + { value: "1", defaultValue: "1", source: "system-default", isConfigPendingRestart: false }, + false, + ], + ])("non-default %j", (props, want) => { + expect(isNonDefault(parseConfig({ name: "p", properties: props }))).toBe(want); + }); +}); + +describe("validation", () => { + it.each([ + ["10.0.0.1", true], + ["256.0.0.1", false], + ["1.2.3", false], + ["a.b.c.d", false], + ])("ip %s", (v, want) => expect(validIpv4(v)).toBe(want)); + + it.each([ + ["orders", true], + ["a b", false], + ["", false], + ["-x", false], + ])("name %j", (v, want) => expect(validChildName(v)).toBe(want)); + + it.each([ + ["", true], + ["UTF8", true], + ["a;b", false], + ])("charset %j", (v, want) => expect(validCharset(v)).toBe(want)); + + it("gates power actions on state", () => { + expect(canAct("Ready", "stop")).toBe(true); + expect(canAct("Ready", "start")).toBe(false); + expect(canAct("Stopped", "start")).toBe(true); + expect(canAct("Starting", "restart")).toBe(false); + }); +}); + +describe("requests", () => { + it("merges both engines and sorts", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json({ value: [server("zeta", "Microsoft.DBforPostgreSQL")] })) + .mockResolvedValueOnce(json({ value: [server("alpha", "Microsoft.DBforMySQL")] })); + const out = await listServers(sub, asFetch(f)); + expect(out.map((s) => `${s.engine}:${s.name}`)).toEqual(["mysql:alpha", "postgresql:zeta"]); + expect(urlOf(f, 0)).toBe( + `${FLEX_PROXY_BASE}/subscriptions/${sub}/providers/Microsoft.DBforPostgreSQL/flexibleServers?api-version=2025-08-01`, + ); + expect(urlOf(f, 1)).toContain("Microsoft.DBforMySQL/flexibleServers?api-version=2024-12-30"); + }); + + it("keeps one engine's servers when the other fails, and throws when both fail", async () => { + const one = vi + .fn() + .mockResolvedValueOnce(json({ value: [server("a", "Microsoft.DBforPostgreSQL")] })) + .mockResolvedValueOnce(json({ error: { code: "x", message: "boom" } }, 500)); + expect(await listServers(sub, asFetch(one))).toHaveLength(1); + const none = vi.fn().mockResolvedValue(json({ error: { code: "x", message: "boom" } }, 500)); + await expect(listServers(sub, asFetch(none))).rejects.toThrow("boom"); + }); + + it("lists children", async () => { + const f = vi + .fn() + .mockResolvedValueOnce( + json({ + value: [{ name: "postgres", properties: { charset: "UTF8", collation: "en_US.utf8" } }], + }), + ) + .mockResolvedValueOnce( + json({ + value: [ + { + name: "office", + properties: { startIpAddress: "10.0.0.1", endIpAddress: "10.0.0.9" }, + }, + ], + }), + ) + .mockResolvedValueOnce( + json({ + value: [ + { + name: "max_connections", + properties: { value: "150", defaultValue: "100", source: "user-override" }, + }, + ], + }), + ); + expect((await listDatabases(sub, target, asFetch(f)))[0]).toMatchObject({ + name: "postgres", + charset: "UTF8", + }); + expect((await listFirewallRules(sub, target, asFetch(f)))[0]).toMatchObject({ + start: "10.0.0.1", + end: "10.0.0.9", + }); + expect((await listConfigurations(sub, target, asFetch(f)))[0]).toMatchObject({ + name: "max_connections", + value: "150", + }); + expect(urlOf(f, 1)).toBe(`${base}/firewallRules?api-version=2025-08-01`); + }); + + it.each(["start", "stop", "restart"] as const)("posts %s", async (action) => { + const f = vi.fn().mockImplementation(() => Promise.resolve(json({}, 202))); + await powerAction(sub, target, action, asFetch(f)); + expect(urlOf(f, 0)).toBe(`${base}/${action}?api-version=2025-08-01`); + expect(initOf(f, 0).method).toBe("POST"); + expect(initOf(f, 0).body).toBeUndefined(); + }); + + it("writes databases and firewall rules with fixed bodies", async () => { + const f = vi.fn().mockImplementation(() => Promise.resolve(json({}))); + await addDatabase(sub, target, "orders", " UTF8 ", "", asFetch(f)); + expect(initOf(f, 0).method).toBe("PUT"); + expect(JSON.parse(String(initOf(f, 0).body))).toEqual({ properties: { charset: "UTF8" } }); + await deleteDatabase(sub, target, "orders", asFetch(f)); + expect(initOf(f, 1).method).toBe("DELETE"); + await addFirewallRule(sub, target, "office", " 10.0.0.1", "10.0.0.9 ", asFetch(f)); + expect(JSON.parse(String(initOf(f, 2).body))).toEqual({ + properties: { startIpAddress: "10.0.0.1", endIpAddress: "10.0.0.9" }, + }); + await deleteFirewallRule(sub, target, "office", asFetch(f)); + expect(urlOf(f, 3)).toBe(`${base}/firewallRules/office?api-version=2025-08-01`); + }); +}); diff --git a/ui/src/lib/azureflexdb.ts b/ui/src/lib/azureflexdb.ts new file mode 100644 index 0000000000..34b61888da --- /dev/null +++ b/ui/src/lib/azureflexdb.ts @@ -0,0 +1,276 @@ +/** Client for Azure Database for PostgreSQL / MySQL flexible servers via the dashboard's in-process ARM proxy. */ + +import { parseArmId } from "./azurearm.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const FLEX_PROXY_BASE = "/dashboard/api/v1/azure/flexdb"; + +export type FlexEngine = "postgresql" | "mysql"; + +export const FLEX_ENGINES: Record< + FlexEngine, + { namespace: string; label: string; apiVersion: string } +> = { + postgresql: { + namespace: "Microsoft.DBforPostgreSQL", + label: "PostgreSQL", + apiVersion: "2025-08-01", + }, + mysql: { namespace: "Microsoft.DBforMySQL", label: "MySQL", apiVersion: "2024-12-30" }, +}; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +/** ARM serialises some flags as booleans and some as "True"/"False" strings. */ +const flag = (v: unknown): boolean => + v === true || (typeof v === "string" && v.toLowerCase() === "true"); + +export type PowerAction = "start" | "stop" | "restart"; + +export type FlexServer = { + id: string; + name: string; + engine: FlexEngine; + resourceGroup: string; + location: string; + state: string; + version: string; + sku: string; + tier: string; + storageGb: number; + iops: number; + haMode: string; + haState: string; + fqdn: string; + endpoint: string; + login: string; +}; + +export type FlexDatabase = { name: string; charset: string; collation: string }; +export type FlexFirewallRule = { name: string; start: string; end: string }; +export type FlexConfig = { + name: string; + value: string; + defaultValue: string; + source: string; + pendingRestart: boolean; + readOnly: boolean; + dataType: string; + description: string; +}; + +export type FlexTarget = Pick; + +export function parseServer(raw: Json, engine: FlexEngine): FlexServer { + const p = obj(raw.properties); + const id = str(raw.id); + const storage = obj(p.storage); + const ha = obj(p.highAvailability); + return { + id, + name: str(raw.name), + engine, + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + state: str(p.state), + version: str(p.version), + sku: str(obj(raw.sku).name), + tier: str(obj(raw.sku).tier), + storageGb: num(storage.storageSizeGB), + iops: num(storage.iops), + haMode: str(ha.mode), + haState: str(ha.state), + fqdn: str(p.fullyQualifiedDomainName), + endpoint: str(p.gopherstackEndpoint), + login: str(p.administratorLogin), + }; +} + +export const parseDatabase = (raw: Json): FlexDatabase => ({ + name: str(raw.name), + charset: str(obj(raw.properties).charset), + collation: str(obj(raw.properties).collation), +}); + +export const parseFirewallRule = (raw: Json): FlexFirewallRule => ({ + name: str(raw.name), + start: str(obj(raw.properties).startIpAddress), + end: str(obj(raw.properties).endIpAddress), +}); + +export const parseConfig = (raw: Json): FlexConfig => { + const p = obj(raw.properties); + return { + name: str(raw.name), + value: str(p.value), + defaultValue: str(p.defaultValue), + source: str(p.source), + pendingRestart: flag(p.isConfigPendingRestart), + readOnly: flag(p.isReadOnly), + dataType: str(p.dataType), + description: str(p.description), + }; +}; + +/** A parameter worth showing by default: changed from its default, or waiting on a restart. */ +export const isNonDefault = (c: FlexConfig): boolean => + c.pendingRestart || + c.source === "user-override" || + (c.value !== c.defaultValue && c.source !== "system-default"); + +const version = (engine: FlexEngine) => `?api-version=${FLEX_ENGINES[engine].apiVersion}`; +const subPath = (s: string) => `/subscriptions/${enc(s)}`; +const typePath = (engine: FlexEngine) => + `providers/${FLEX_ENGINES[engine].namespace}/flexibleServers`; +const serverPath = (sub: string, t: FlexTarget) => + `${subPath(sub)}/resourceGroups/${enc(t.resourceGroup)}/${typePath(t.engine)}/${enc(t.name)}`; + +/** Lists the servers of both engines; an engine that fails is skipped unless every engine fails. */ +export async function listServers(sub: string, fetchFn: FetchFn = fetch): Promise { + const engines = Object.keys(FLEX_ENGINES) as FlexEngine[]; + const results = await Promise.allSettled( + engines.map((engine) => + restCall( + FLEX_PROXY_BASE, + "GET", + `${subPath(sub)}/${typePath(engine)}${version(engine)}`, + undefined, + fetchFn, + ), + ), + ); + if (results.every((r) => r.status === "rejected")) + throw (results[0] as PromiseRejectedResult).reason; + const out: FlexServer[] = []; + results.forEach((r, i) => { + if (r.status === "fulfilled") + out.push(...list(r.value.value).map((s) => parseServer(s, engines[i]))); + }); + return out.toSorted( + (a, b) => a.resourceGroup.localeCompare(b.resourceGroup) || a.name.localeCompare(b.name), + ); +} + +const children = async ( + sub: string, + t: FlexTarget, + kind: string, + parse: (r: Json) => T, + fetchFn: FetchFn, +): Promise => { + const body = await restCall( + FLEX_PROXY_BASE, + "GET", + `${serverPath(sub, t)}/${kind}${version(t.engine)}`, + undefined, + fetchFn, + ); + return list(body.value).map((r) => parse(r)); +}; + +export const listDatabases = (sub: string, t: FlexTarget, fetchFn: FetchFn = fetch) => + children(sub, t, "databases", parseDatabase, fetchFn); +export const listFirewallRules = (sub: string, t: FlexTarget, fetchFn: FetchFn = fetch) => + children(sub, t, "firewallRules", parseFirewallRule, fetchFn); +export const listConfigurations = (sub: string, t: FlexTarget, fetchFn: FetchFn = fetch) => + children(sub, t, "configurations", parseConfig, fetchFn); + +export async function powerAction( + sub: string, + t: FlexTarget, + action: PowerAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + FLEX_PROXY_BASE, + "POST", + `${serverPath(sub, t)}/${action}${version(t.engine)}`, + undefined, + fetchFn, + ); +} + +export async function addDatabase( + sub: string, + t: FlexTarget, + name: string, + charset: string, + collation: string, + fetchFn: FetchFn = fetch, +): Promise { + const properties: Record = {}; + if (charset.trim()) properties.charset = charset.trim(); + if (collation.trim()) properties.collation = collation.trim(); + await restCall( + FLEX_PROXY_BASE, + "PUT", + `${serverPath(sub, t)}/databases/${enc(name)}${version(t.engine)}`, + { properties }, + fetchFn, + ); +} + +export async function deleteDatabase( + sub: string, + t: FlexTarget, + name: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + FLEX_PROXY_BASE, + "DELETE", + `${serverPath(sub, t)}/databases/${enc(name)}${version(t.engine)}`, + undefined, + fetchFn, + ); +} + +export async function addFirewallRule( + sub: string, + t: FlexTarget, + name: string, + start: string, + end: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + FLEX_PROXY_BASE, + "PUT", + `${serverPath(sub, t)}/firewallRules/${enc(name)}${version(t.engine)}`, + { properties: { startIpAddress: start.trim(), endIpAddress: end.trim() } }, + fetchFn, + ); +} + +export async function deleteFirewallRule( + sub: string, + t: FlexTarget, + name: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + FLEX_PROXY_BASE, + "DELETE", + `${serverPath(sub, t)}/firewallRules/${enc(name)}${version(t.engine)}`, + undefined, + fetchFn, + ); +} + +export const validIpv4 = (v: string): boolean => { + const parts = v.trim().split("."); + return parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255); +}; +export const validChildName = (v: string): boolean => /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(v); +export const validCharset = (v: string): boolean => + v.trim() === "" || /^[A-Za-z0-9_.-]{1,64}$/.test(v.trim()); + +/** Whether a power action can run in the server's state. */ +export function canAct(state: string, action: PowerAction): boolean { + if (action === "start") return state === "Stopped"; + return state === "Ready"; +} + +export const engineLabel = (e: FlexEngine): string => FLEX_ENGINES[e].label; diff --git a/ui/src/lib/azurevm.test.ts b/ui/src/lib/azurevm.test.ts new file mode 100644 index 0000000000..3eb8d9aabb --- /dev/null +++ b/ui/src/lib/azurevm.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + COMPUTE_API_VERSION, + NETWORK_API_VERSION, + VM_PROXY_BASE, + allowedActions, + listDisks, + listNics, + listNsgs, + listPublicIps, + listVms, + listVnets, + parseNic, + parseNsg, + parseVm, + parseVnet, + powerAction, + powerLabel, + powerStateOf, + vmAddresses, +} from "./azurevm"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; + +const sub = "00000000-0000-0000-0000-000000000000"; +const rgPath = (rg: string, ns: string, type: string, name: string) => + `/subscriptions/${sub}/resourceGroups/${rg}/providers/Microsoft.${ns}/${type}/${name}`; +const vm = (name: string, power = "running", rg = "demo") => ({ + id: rgPath(rg, "Compute", "virtualMachines", name), + name, + location: "eastus", + properties: { + hardwareProfile: { vmSize: "Standard_B2s" }, + osProfile: { computerName: name, adminUsername: "azureuser" }, + storageProfile: { + imageReference: { publisher: "Canonical", offer: "ubuntu", sku: "22_04" }, + osDisk: { + name: `${name}-os`, + osType: "Linux", + diskSizeGB: 30, + managedDisk: { storageAccountType: "Premium_LRS" }, + }, + dataDisks: [{ name: `${name}-data`, diskSizeGB: 64 }], + }, + networkProfile: { + networkInterfaces: [{ id: rgPath(rg, "Network", "networkInterfaces", `${name}-nic`) }], + }, + instanceView: { + statuses: [{ code: "ProvisioningState/succeeded" }, { code: `PowerState/${power}` }], + }, + }, +}); +const nic = (name: string) => ({ + id: rgPath("demo", "Network", "networkInterfaces", name), + name, + properties: { + ipConfigurations: [ + { + properties: { + privateIPAddress: "10.0.0.4", + publicIPAddress: { id: rgPath("demo", "Network", "publicIPAddresses", "pip") }, + subnet: { id: "subnet-id" }, + }, + }, + ], + }, +}); +const computeApi = `?api-version=${COMPUTE_API_VERSION}`; + +describe("parsing", () => { + it("parses a VM with disks and power state", () => { + expect(parseVm(vm("web"))).toMatchObject({ + name: "web", + resourceGroup: "demo", + size: "Standard_B2s", + osType: "Linux", + power: "running", + adminUsername: "azureuser", + image: "Canonical:ubuntu:22_04", + disks: [ + { name: "web-os", kind: "OS", sizeGb: 30, type: "Premium_LRS" }, + { name: "web-data", kind: "Data", sizeGb: 64 }, + ], + }); + }); + + it("reads the power state from the instance view", () => { + expect(powerStateOf(vm("a", "deallocated"))).toBe("deallocated"); + expect(powerStateOf({ properties: {} })).toBe(""); + }); + + it("resolves a VM's addresses through its NICs and public IPs", () => { + const v = parseVm(vm("web")); + const addr = vmAddresses( + v, + [parseNic(nic("web-nic")), parseNic(nic("other"))], + [ + { + id: rgPath("demo", "Network", "publicIPAddresses", "pip"), + name: "pip", + address: "20.1.2.3", + allocation: "Static", + }, + ], + ); + expect(addr).toEqual({ privateIps: ["10.0.0.4"], publicIps: ["20.1.2.3"] }); + }); + + it("parses a network and a security group sorted by priority", () => { + expect( + parseVnet({ + id: rgPath("demo", "Network", "virtualNetworks", "vnet"), + name: "vnet", + properties: { + addressSpace: { addressPrefixes: ["10.0.0.0/16"] }, + subnets: [ + { + name: "default", + properties: { + addressPrefix: "10.0.0.0/24", + networkSecurityGroup: { id: "/x/nsgs/web-nsg" }, + }, + }, + ], + }, + }), + ).toMatchObject({ + prefixes: ["10.0.0.0/16"], + subnets: [{ name: "default", prefix: "10.0.0.0/24", nsg: "web-nsg" }], + }); + const g = parseNsg({ + id: rgPath("demo", "Network", "networkSecurityGroups", "nsg"), + name: "nsg", + properties: { + securityRules: [ + { + name: "b", + properties: { priority: 200, destinationPortRange: "443", access: "Allow" }, + }, + { + name: "a", + properties: { priority: 100, destinationPortRanges: ["22", "80"], access: "Deny" }, + }, + ], + }, + }); + expect(g.rules.map((r) => [r.name, r.ports])).toEqual([ + ["a", "22, 80"], + ["b", "443"], + ]); + }); + + it.each([ + ["running", ["restart", "powerOff", "deallocate"]], + ["stopped", ["start", "deallocate"]], + ["deallocated", ["start"]], + ["", []], + ])("allows %j actions", (power, want) => { + expect(allowedActions(power)).toEqual(want); + }); + + it("labels power states", () => { + expect(powerLabel("running")).toBe("Running"); + expect(powerLabel("")).toBe("Unknown"); + }); +}); + +describe("client", () => { + it("lists VMs with their instance view, sorted", async () => { + const fetchFn = vi.fn((_url: string) => + Promise.resolve(json({ value: [vm("b", "running", "z"), vm("a")] })), + ); + const out = await listVms(sub, asFetch(fetchFn)); + expect(out.map((v) => `${v.resourceGroup}/${v.name}`)).toEqual(["demo/a", "z/b"]); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${VM_PROXY_BASE}/subscriptions/${sub}/providers/Microsoft.Compute/virtualMachines${computeApi}&statusOnly=true`, + ); + }); + + it("lists disks and the network resources", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json({ + value: url.includes("/disks") + ? [ + { + id: rgPath("demo", "Compute", "disks", "d"), + name: "d", + managedBy: "/x/virtualMachines/web", + sku: { name: "Premium_LRS" }, + properties: { diskSizeGB: 30, diskState: "Attached" }, + }, + ] + : [], + }), + ), + ); + expect((await listDisks(sub, asFetch(fetchFn)))[0]).toMatchObject({ + attachedTo: "web", + sku: "Premium_LRS", + sizeGb: 30, + }); + await listNics(sub, asFetch(fetchFn)); + await listPublicIps(sub, asFetch(fetchFn)); + await listVnets(sub, asFetch(fetchFn)); + await listNsgs(sub, asFetch(fetchFn)); + expect(fetchFn.mock.calls.slice(1).map((c) => c[0])).toEqual( + ["networkInterfaces", "publicIPAddresses", "virtualNetworks", "networkSecurityGroups"].map( + (t) => + `${VM_PROXY_BASE}/subscriptions/${sub}/providers/Microsoft.Network/${t}?api-version=${NETWORK_API_VERSION}`, + ), + ); + }); + + it("posts a power action to the VM", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(json({}))); + await powerAction(sub, { resourceGroup: "demo", name: "web" }, "powerOff", asFetch(fetchFn)); + expect([fetchFn.mock.calls[0]?.[0], (fetchFn.mock.calls[0]![1] as RequestInit).method]).toEqual( + [ + `${VM_PROXY_BASE}${rgPath("demo", "Compute", "virtualMachines", "web")}/powerOff${computeApi}`, + "POST", + ], + ); + }); + + it("surfaces an ARM failure", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue( + json({ error: { code: "OperationNotAllowed", message: "VM is stopped" } }, 409), + ); + await expect( + powerAction(sub, { resourceGroup: "demo", name: "web" }, "restart", asFetch(fetchFn)), + ).rejects.toMatchObject({ + httpStatus: 409, + message: "VM is stopped", + }); + }); +}); diff --git a/ui/src/lib/azurevm.ts b/ui/src/lib/azurevm.ts new file mode 100644 index 0000000000..0bf22b33ba --- /dev/null +++ b/ui/src/lib/azurevm.ts @@ -0,0 +1,324 @@ +/** Client for Azure Virtual Machines and the networking they attach to: Microsoft.Compute/Network via the dashboard's in-process ARM proxy. */ + +import { parseArmId } from "./azurearm.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const VM_PROXY_BASE = "/dashboard/api/v1/azure/vm"; +export const COMPUTE_API_VERSION = "2024-07-01"; +export const NETWORK_API_VERSION = "2024-05-01"; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); + +export type PowerAction = "start" | "powerOff" | "deallocate" | "restart"; + +export type VmDisk = { name: string; sizeGb: number; kind: "OS" | "Data"; type: string }; + +export type VirtualMachine = { + id: string; + name: string; + resourceGroup: string; + location: string; + size: string; + osType: string; + power: string; + provisioning: string; + computerName: string; + adminUsername: string; + image: string; + zones: string[]; + nicIds: string[]; + disks: VmDisk[]; +}; + +export type Nic = { + id: string; + name: string; + privateIps: string[]; + publicIpIds: string[]; + subnetId: string; + nsgId: string; + vmId: string; +}; + +export type PublicIp = { id: string; name: string; address: string; allocation: string }; + +export type Subnet = { name: string; prefix: string; nsg: string }; + +export type VirtualNetwork = { + id: string; + name: string; + resourceGroup: string; + location: string; + prefixes: string[]; + state: string; + subnets: Subnet[]; +}; + +export type SecurityRule = { + name: string; + priority: number; + direction: string; + access: string; + protocol: string; + source: string; + destination: string; + ports: string; +}; + +export type SecurityGroup = { + id: string; + name: string; + resourceGroup: string; + location: string; + rules: SecurityRule[]; +}; + +export type Disk = { + id: string; + name: string; + resourceGroup: string; + sizeGb: number; + sku: string; + state: string; + attachedTo: string; +}; + +/** The short name of the resource an ARM id ends with. */ +export const lastSegment = (id: string): string => id.slice(id.lastIndexOf("/") + 1); + +export function powerStateOf(vm: Json): string { + const view = obj(obj(vm.properties).instanceView); + for (const s of list(view.statuses)) { + const code = str(s.code); + if (code.startsWith("PowerState/")) return code.slice("PowerState/".length); + } + return ""; +} + +export function parseVm(raw: Json): VirtualMachine { + const p = obj(raw.properties); + const id = str(raw.id); + const sp = obj(p.storageProfile); + const os = obj(sp.osDisk); + const image = obj(sp.imageReference); + const disk = (d: Json, kind: VmDisk["kind"]): VmDisk => ({ + name: str(d.name), + sizeGb: num(d.diskSizeGB), + kind, + type: str(obj(d.managedDisk).storageAccountType), + }); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + size: str(obj(p.hardwareProfile).vmSize), + osType: str(os.osType), + power: powerStateOf(raw), + provisioning: str(p.provisioningState), + computerName: str(obj(p.osProfile).computerName), + adminUsername: str(obj(p.osProfile).adminUsername), + image: [str(image.publisher), str(image.offer), str(image.sku)].filter(Boolean).join(":"), + zones: Array.isArray(raw.zones) ? raw.zones.map(String) : [], + nicIds: list(obj(p.networkProfile).networkInterfaces).map((n) => str(n.id)), + disks: [ + ...(os.name ? [disk(os, "OS")] : []), + ...list(sp.dataDisks).map((d) => disk(d, "Data")), + ], + }; +} + +export function parseNic(raw: Json): Nic { + const p = obj(raw.properties); + const cfgs = list(p.ipConfigurations).map((c) => obj(c.properties)); + return { + id: str(raw.id), + name: str(raw.name), + privateIps: cfgs.map((c) => str(c.privateIPAddress)).filter(Boolean), + publicIpIds: cfgs.map((c) => str(obj(c.publicIPAddress).id)).filter(Boolean), + subnetId: str(obj(cfgs[0]?.subnet).id), + nsgId: str(obj(p.networkSecurityGroup).id), + vmId: str(obj(p.virtualMachine).id), + }; +} + +export function parsePublicIp(raw: Json): PublicIp { + const p = obj(raw.properties); + return { + id: str(raw.id), + name: str(raw.name), + address: str(p.ipAddress), + allocation: str(p.publicIPAllocationMethod), + }; +} + +export function parseVnet(raw: Json): VirtualNetwork { + const p = obj(raw.properties); + const id = str(raw.id); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + prefixes: Array.isArray(obj(p.addressSpace).addressPrefixes) + ? (obj(p.addressSpace).addressPrefixes as unknown[]).map(String) + : [], + state: str(p.provisioningState), + subnets: list(p.subnets).map((s) => { + const sp = obj(s.properties); + return { + name: str(s.name), + prefix: + str(sp.addressPrefix) || + (Array.isArray(sp.addressPrefixes) ? sp.addressPrefixes.map(String).join(", ") : ""), + nsg: lastSegment(str(obj(sp.networkSecurityGroup).id)), + }; + }), + }; +} + +function portsOf(p: Json): string { + if (Array.isArray(p.destinationPortRanges) && p.destinationPortRanges.length > 0) + return p.destinationPortRanges.map(String).join(", "); + return str(p.destinationPortRange) || "*"; +} + +export function parseNsg(raw: Json): SecurityGroup { + const id = str(raw.id); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + location: str(raw.location), + rules: list(obj(raw.properties).securityRules) + .map((r) => { + const rp = obj(r.properties); + return { + name: str(r.name), + priority: num(rp.priority), + direction: str(rp.direction), + access: str(rp.access), + protocol: str(rp.protocol), + source: str(rp.sourceAddressPrefix) || "*", + destination: str(rp.destinationAddressPrefix) || "*", + ports: portsOf(rp), + }; + }) + .toSorted((a, b) => a.priority - b.priority), + }; +} + +export function parseDisk(raw: Json): Disk { + const p = obj(raw.properties); + const id = str(raw.id); + return { + id, + name: str(raw.name), + resourceGroup: parseArmId(id).resourceGroup, + sizeGb: num(p.diskSizeGB), + sku: str(obj(raw.sku).name), + state: str(p.diskState), + attachedTo: lastSegment(str(raw.managedBy)), + }; +} + +/** Which power actions a power state allows. */ +export function allowedActions(power: string): PowerAction[] { + switch (power) { + case "running": + return ["restart", "powerOff", "deallocate"]; + case "stopped": + return ["start", "deallocate"]; + case "deallocated": + return ["start"]; + default: + return []; + } +} + +const sub = (s: string) => `/subscriptions/${enc(s)}`; +const byGroupName = (a: T, b: T) => + a.resourceGroup.localeCompare(b.resourceGroup) || a.name.localeCompare(b.name); + +async function listType( + subscription: string, + provider: "Compute" | "Network", + type: string, + parse: (r: Json) => T, + query: string, + fetchFn: FetchFn, +): Promise { + const version = provider === "Compute" ? COMPUTE_API_VERSION : NETWORK_API_VERSION; + const body = await restCall( + VM_PROXY_BASE, + "GET", + `${sub(subscription)}/providers/Microsoft.${provider}/${type}?api-version=${version}${query}`, + undefined, + fetchFn, + ); + return list(body.value).map((r) => parse(r)); +} + +export async function listVms(s: string, fetchFn: FetchFn = fetch) { + return ( + await listType(s, "Compute", "virtualMachines", parseVm, "&statusOnly=true", fetchFn) + ).toSorted(byGroupName); +} + +export async function listDisks(s: string, fetchFn: FetchFn = fetch) { + return (await listType(s, "Compute", "disks", parseDisk, "", fetchFn)).toSorted(byGroupName); +} + +export const listNics = (s: string, fetchFn: FetchFn = fetch) => + listType(s, "Network", "networkInterfaces", parseNic, "", fetchFn); + +export const listPublicIps = (s: string, fetchFn: FetchFn = fetch) => + listType(s, "Network", "publicIPAddresses", parsePublicIp, "", fetchFn); + +export async function listVnets(s: string, fetchFn: FetchFn = fetch) { + return (await listType(s, "Network", "virtualNetworks", parseVnet, "", fetchFn)).toSorted( + byGroupName, + ); +} + +export async function listNsgs(s: string, fetchFn: FetchFn = fetch) { + return (await listType(s, "Network", "networkSecurityGroups", parseNsg, "", fetchFn)).toSorted( + byGroupName, + ); +} + +export async function powerAction( + s: string, + vm: Pick, + action: PowerAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + VM_PROXY_BASE, + "POST", + `${sub(s)}/resourceGroups/${enc(vm.resourceGroup)}/providers/Microsoft.Compute/virtualMachines/${enc(vm.name)}/${action}?api-version=${COMPUTE_API_VERSION}`, + undefined, + fetchFn, + ); +} + +/** The private and public addresses of a VM, resolved through its NICs. */ +export function vmAddresses( + vm: Pick, + nics: Nic[], + ips: PublicIp[], +): { privateIps: string[]; publicIps: string[] } { + const mine = nics.filter((n) => vm.nicIds.some((id) => id.toLowerCase() === n.id.toLowerCase())); + const publicIps = mine + .flatMap((n) => n.publicIpIds) + .map((id) => ips.find((p) => p.id.toLowerCase() === id.toLowerCase())?.address ?? "") + .filter(Boolean); + return { privateIps: mine.flatMap((n) => n.privateIps), publicIps }; +} + +export function powerLabel(power: string): string { + return power ? power.charAt(0).toUpperCase() + power.slice(1) : "Unknown"; +} diff --git a/ui/src/lib/cloud.test.ts b/ui/src/lib/cloud.test.ts index 3c69983735..e674616a28 100644 --- a/ui/src/lib/cloud.test.ts +++ b/ui/src/lib/cloud.test.ts @@ -8,6 +8,8 @@ import { availableGcpServices, availableOciServices, azureServiceById, + gcpServiceById, + ociServiceById, categoriesForCloud, CLOUDS, GCP_SERVICES, @@ -232,11 +234,140 @@ describe("new GCP console pages", () => { it("registers Compute Engine and Container Instances", () => { expect(availableGcpServices(["GCPCompute"]).map((s) => s.id)).toEqual(["compute"]); - expect(availableAzureServices(["AzureARM"]).map((s) => s.id)).toEqual(["arm", "aci"]); + expect(availableAzureServices(["AzureARM"]).map((s) => s.id)).toEqual([ + "arm", + "aci", + "appservice", + "containerapps", + "vm", + "aks", + "flexdb", + "apim", + ]); expect(azureServiceById("aci")?.portLabel).toBe("azurearm"); }); it("registers Azure Files", () => { expect(availableAzureServices(["AzureFile"]).map((s) => s.id)).toEqual(["file"]); }); + + it("registers Datastore, Core Services and App Service", () => { + expect(availableGcpServices(["GCPDatastore"]).map((s) => s.id)).toEqual(["datastore"]); + expect(availableOciServices(["OCICore"]).map((s) => s.id)).toEqual(["core"]); + expect(azureServiceById("appservice")?.portLabel).toBe("azurearm"); + expect(gcpServiceById("datastore")?.portLabel).toBe("gcpdatastore"); + expect(ociServiceById("core")?.portLabel).toBe("ocicore"); + }); + + it("registers Kubernetes Engine, Container Apps, Virtual Machines, Queue and Notifications", () => { + expect(availableGcpServices(["GCPContainer"]).map((s) => s.id)).toEqual(["container"]); + expect(availableOciServices(["OCIQueue", "OCINotifications"]).map((s) => s.id)).toEqual([ + "queue", + "notifications", + ]); + expect(availableOciServices(["OCIContainerEngine"]).map((s) => s.id)).toEqual([ + "containerengine", + ]); + expect(azureServiceById("aks")?.portLabel).toBe("azurearm"); + expect(ociServiceById("containerengine")?.portLabel).toBe("ocicontainerengine"); + expect(azureServiceById("containerapps")?.portLabel).toBe("azurearm"); + expect(azureServiceById("vm")?.portLabel).toBe("azurearm"); + expect(gcpServiceById("container")?.portLabel).toBe("gcpcontainer"); + expect(ociServiceById("queue")?.portLabel).toBe("ociqueue"); + expect(ociServiceById("notifications")?.portLabel).toBe("ocinotifications"); + }); + + it("registers MySQL HeatWave, Logging and Monitoring", () => { + expect( + availableOciServices(["OCIMySQL", "OCILogging", "OCIMonitoring"]).map((s) => s.id), + ).toEqual(["mysql", "logging", "monitoring"]); + expect(ociServiceById("mysql")?.portLabel).toBe("ocimysql"); + expect(ociServiceById("logging")?.defaultPort).toBe(8160); + expect(ociServiceById("monitoring")?.healthName).toBe("OCIMonitoring"); + }); + + it("registers NoSQL Database and API Gateway", () => { + expect(availableOciServices(["OCINoSQL", "OCIAPIGateway"]).map((s) => s.id)).toEqual([ + "nosql", + "apigateway", + ]); + expect(ociServiceById("nosql")).toMatchObject({ portLabel: "ocinosql", defaultPort: 8162 }); + expect(ociServiceById("apigateway")).toMatchObject({ + portLabel: "ociapigateway", + defaultPort: 8163, + healthName: "OCIAPIGateway", + }); + }); + + it("registers Events", () => { + expect(availableOciServices(["OCIEvents"]).map((s) => s.id)).toEqual(["events"]); + expect(ociServiceById("events")).toMatchObject({ + portLabel: "ocievents", + defaultPort: 8164, + healthName: "OCIEvents", + }); + }); + + it("registers Load Balancer", () => { + expect(availableOciServices(["OCILoadBalancer"]).map((s) => s.id)).toEqual(["loadbalancer"]); + expect(ociServiceById("loadbalancer")).toMatchObject({ + portLabel: "ociloadbalancer", + defaultPort: 8165, + healthName: "OCILoadBalancer", + }); + }); + + it("registers Artifacts", () => { + expect(availableOciServices(["OCIArtifacts"]).map((s) => s.id)).toEqual(["artifacts"]); + expect(ociServiceById("artifacts")).toMatchObject({ + portLabel: "ociartifacts", + defaultPort: 8166, + healthName: "OCIArtifacts", + }); + }); + + it("registers Container Instances", () => { + expect(availableOciServices(["OCIContainerInstances"]).map((s) => s.id)).toEqual([ + "containerinstances", + ]); + expect(ociServiceById("containerinstances")).toMatchObject({ + portLabel: "ocicontainerinstances", + defaultPort: 8167, + healthName: "OCIContainerInstances", + }); + }); + + it("registers Identity Platform and Flexible Server", () => { + expect(gcpServiceById("identitytoolkit")).toMatchObject({ + portLabel: "gcpidentitytoolkit", + defaultPort: 8145, + healthName: "GCPIdentityToolkit", + }); + expect(azureServiceById("flexdb")?.portLabel).toBe("azurearm"); + }); + + it("registers API Gateway and API Management", () => { + expect(gcpServiceById("apigateway")).toMatchObject({ + portLabel: "gcpapigateway", + defaultPort: 8146, + healthName: "GCPAPIGateway", + }); + expect(azureServiceById("apim")?.portLabel).toBe("azurearm"); + }); + + it("registers Cloud Batch", () => { + expect(gcpServiceById("batch")).toMatchObject({ + portLabel: "gcpbatch", + defaultPort: 8147, + healthName: "GCPBatch", + }); + }); + + it("registers Resource Manager", () => { + expect(gcpServiceById("resourcemanager")).toMatchObject({ + portLabel: "gcpresourcemanager", + defaultPort: 8148, + healthName: "GCPResourceManager", + }); + }); }); diff --git a/ui/src/lib/cloud.ts b/ui/src/lib/cloud.ts index f848279890..a06bf5cb1a 100644 --- a/ui/src/lib/cloud.ts +++ b/ui/src/lib/cloud.ts @@ -137,6 +137,57 @@ export const AZURE_SERVICES: readonly AzureService[] = [ portLabel: "azurearm", defaultPort: 10006, }, + { + id: "appservice", + label: "App Service", + description: "Azure App Service web apps and slots (served by Resource Manager)", + healthName: "AzureARM", + portLabel: "azurearm", + defaultPort: 10006, + }, + { + id: "containerapps", + label: "Container Apps", + description: + "Azure Container Apps environments, apps, revisions and jobs (served by Resource Manager)", + healthName: "AzureARM", + portLabel: "azurearm", + defaultPort: 10006, + }, + { + id: "vm", + label: "Virtual Machines", + description: "Azure virtual machines, disks and networking (served by Resource Manager)", + healthName: "AzureARM", + portLabel: "azurearm", + defaultPort: 10006, + }, + { + id: "aks", + label: "Kubernetes Service", + description: "Azure Kubernetes Service clusters and agent pools (served by Resource Manager)", + healthName: "AzureARM", + portLabel: "azurearm", + defaultPort: 10006, + }, + { + id: "flexdb", + label: "Flexible Server", + description: + "Azure Database for PostgreSQL and MySQL flexible servers (served by Resource Manager)", + healthName: "AzureARM", + portLabel: "azurearm", + defaultPort: 10006, + }, + { + id: "apim", + label: "API Management", + description: + "Azure API Management services, APIs, products and subscriptions (served by Resource Manager)", + healthName: "AzureARM", + portLabel: "azurearm", + defaultPort: 10006, + }, ]; export type GcpService = { @@ -335,6 +386,54 @@ export const GCP_SERVICES: readonly GcpService[] = [ portLabel: "gcpcompute", defaultPort: 8142, }, + { + id: "datastore", + label: "Datastore", + description: "Google Cloud Datastore", + healthName: "GCPDatastore", + portLabel: "gcpdatastore", + defaultPort: 8143, + }, + { + id: "container", + label: "Kubernetes Engine", + description: "Google Kubernetes Engine clusters and node pools", + healthName: "GCPContainer", + portLabel: "gcpcontainer", + defaultPort: 8144, + }, + { + id: "identitytoolkit", + label: "Identity Platform", + description: "Google Cloud Identity Platform users, tenants and sign-in providers", + healthName: "GCPIdentityToolkit", + portLabel: "gcpidentitytoolkit", + defaultPort: 8145, + }, + { + id: "apigateway", + label: "API Gateway", + description: "Google Cloud API Gateway APIs, configs and gateways", + healthName: "GCPAPIGateway", + portLabel: "gcpapigateway", + defaultPort: 8146, + }, + { + id: "batch", + label: "Cloud Batch", + description: "Google Cloud Batch jobs, task groups and tasks", + healthName: "GCPBatch", + portLabel: "gcpbatch", + defaultPort: 8147, + }, + { + id: "resourcemanager", + label: "Resource Manager", + description: "Google Cloud organization, folders, projects, IAM policies and Service Usage", + healthName: "GCPResourceManager", + portLabel: "gcpresourcemanager", + defaultPort: 8148, + }, ]; export type OciService = { @@ -389,6 +488,110 @@ export const OCI_SERVICES: readonly OciService[] = [ portLabel: "ocifunctions", defaultPort: 8154, }, + { + id: "core", + label: "Core Services", + description: "Oracle Cloud networking, compute and block storage", + healthName: "OCICore", + portLabel: "ocicore", + defaultPort: 8155, + }, + { + id: "queue", + label: "Queue", + description: "Oracle Cloud Queue", + healthName: "OCIQueue", + portLabel: "ociqueue", + defaultPort: 8156, + }, + { + id: "notifications", + label: "Notifications", + description: "Oracle Cloud Notifications topics and subscriptions", + healthName: "OCINotifications", + portLabel: "ocinotifications", + defaultPort: 8157, + }, + { + id: "containerengine", + label: "Container Engine", + description: "Oracle Container Engine for Kubernetes clusters and node pools", + healthName: "OCIContainerEngine", + portLabel: "ocicontainerengine", + defaultPort: 8158, + }, + { + id: "mysql", + label: "MySQL HeatWave", + description: "Oracle Cloud MySQL HeatWave DB systems, backups and configurations", + healthName: "OCIMySQL", + portLabel: "ocimysql", + defaultPort: 8159, + }, + { + id: "logging", + label: "Logging", + description: "Oracle Cloud Logging log groups, logs and search", + healthName: "OCILogging", + portLabel: "ocilogging", + defaultPort: 8160, + }, + { + id: "monitoring", + label: "Monitoring", + description: "Oracle Cloud Monitoring metrics, queries and alarms", + healthName: "OCIMonitoring", + portLabel: "ocimonitoring", + defaultPort: 8161, + }, + { + id: "nosql", + label: "NoSQL Database", + description: "Oracle NoSQL Database Cloud tables, indexes, rows and SQL queries", + healthName: "OCINoSQL", + portLabel: "ocinosql", + defaultPort: 8162, + }, + { + id: "apigateway", + label: "API Gateway", + description: "Oracle Cloud API Gateway gateways, deployments and certificates", + healthName: "OCIAPIGateway", + portLabel: "ociapigateway", + defaultPort: 8163, + }, + { + id: "events", + label: "Events", + description: "Oracle Cloud Events rules, actions and the delivery log", + healthName: "OCIEvents", + portLabel: "ocievents", + defaultPort: 8164, + }, + { + id: "loadbalancer", + label: "Load Balancer", + description: "Oracle Cloud Load Balancer listeners, backend sets and backend health", + healthName: "OCILoadBalancer", + portLabel: "ociloadbalancer", + defaultPort: 8165, + }, + { + id: "artifacts", + label: "Artifacts", + description: "Oracle Cloud container registry repositories, images and generic artifacts", + healthName: "OCIArtifacts", + portLabel: "ociartifacts", + defaultPort: 8166, + }, + { + id: "containerinstances", + label: "Container Instances", + description: "Oracle Cloud Container Instances, their containers, logs and work requests", + healthName: "OCIContainerInstances", + portLabel: "ocicontainerinstances", + defaultPort: 8167, + }, ]; export function parseCloud(value: string | null | undefined): Cloud | null { diff --git a/ui/src/lib/components/TryItPanel.svelte b/ui/src/lib/components/TryItPanel.svelte new file mode 100644 index 0000000000..ccf3018cab --- /dev/null +++ b/ui/src/lib/components/TryItPanel.svelte @@ -0,0 +1,78 @@ + + +
+ {#if note}

{note}

{/if} + {@render fields?.()} +
+ + + +
+ + + + {#if error}{/if} + {#if result} +
+

Status {result.status}{#if result.truncated} (body truncated){/if}

+
+ {#each Object.entries(result.headers) as [k, v] (k)}
{k}
{v}
{/each} +
+
{prettyBody(result)}
+
+ {/if} +
diff --git a/ui/src/lib/components/azure/ApiManagementDashboard.svelte b/ui/src/lib/components/azure/ApiManagementDashboard.svelte new file mode 100644 index 0000000000..150f957772 --- /dev/null +++ b/ui/src/lib/components/azure/ApiManagementDashboard.svelte @@ -0,0 +1,95 @@ + + +
+ + + {#if error && services.length === 0} + + {:else if loading} +
+ {:else if services.length === 0} +

No API Management services. Create one through Resource Manager (Microsoft.ApiManagement service).

+ {:else} +
+ + +
+ +
+ + + + + + {#each visible as s (s.id)} + + + + + + + + {/each} + +
ServiceResource groupSKUStateGateway URL
{s.resourceGroup}{s.sku || '—'}{s.capacity ? ` x${s.capacity}` : ''}{s.state || '—'}{s.gatewayUrl || '—'}
+
+ + {#if current} + {#key `${current.id}:${detailKey}`} + + {/key} + {/if} + {/if} +
diff --git a/ui/src/lib/components/azure/ApimServiceDetail.svelte b/ui/src/lib/components/azure/ApimServiceDetail.svelte new file mode 100644 index 0000000000..329efd20d5 --- /dev/null +++ b/ui/src/lib/components/azure/ApimServiceDetail.svelte @@ -0,0 +1,280 @@ + + +
+

{service.name}

+ {#each errors as e (e)}{/each} + (tab = id)} color="sky" /> + + {#if tab === 'apis'} + {#if apis.length === 0} +

No APIs.

+ {:else} + + + + {#each apis as a (a.id)} + + + + + + + + {#if selectedApi === a.id} + + {/if} + {/each} + +
APIPathProtocolsBackendSubscription
{a.displayName && a.displayName !== a.id ? ` (${a.displayName})` : ''}/{a.path}{a.protocols.join(', ') || '—'}{a.serviceUrl || '—'}{a.subscriptionRequired ? 'Required' : 'Open'}
+ {#if opsError}{/if} + {#if operations.length === 0 && !opsError} +

No operations.

+ {:else} + + + + {#each operations as o (o.id)} + + + + + + + {/each} + +
OperationMethodURL templateActions
{o.id}{o.method}{o.urlTemplate}
+ {/if} +
+ {/if} + {:else if tab === 'products'} + {#if products.length === 0} +

No products.

+ {:else} + + + + {#each products as p (p.id)} + + + + + + + {/each} + +
ProductStateSubscriptionApproval
{p.displayName || p.id} ({p.id}){p.state || '—'}{p.subscriptionRequired ? 'Required' : 'Open'}{p.approvalRequired ? 'Required' : 'Automatic'}
+ {/if} + {:else if tab === 'subscriptions'} +

Subscription keys never leave the server. Use "Try it" to send a request with a subscription's key.

+ {#if subs.length === 0} +

No subscriptions.

+ {:else} + + + + {#each subs as s (s.id)} + + + + + + + + {/each} + +
SubscriptionScopeStateCreatedKeys
{s.displayName || s.id} ({s.id}){s.scope || '—'}{s.state || '—'}{s.createdDate || '—'}hidden (server-side)
+ {/if} + {:else if tab === 'named'} + {#if named.length === 0} +

No named values.

+ {:else} + + + + {#each named as n (n.id)} + + + + + + {/each} + +
NameValueTags
{n.displayName || n.id}{#if n.secret}•••••• (secret){:else if n.keyVault}(Key Vault reference){:else}{n.value}{/if}{n.tags.join(', ') || '—'}
+ {/if} + {:else if tab === 'policies'} +
+ + {#if policyApi} + + {/if} +
+ {#if policyError}{/if} + {#if policy} +
{policy}
+ {:else if !policyError} +

No policy at this scope.

+ {/if} + {:else} + tryGateway(subscription, target, { ...r, subscription: trySub, api: tryApi })} + > + {#snippet fields()} +
+ + +
+ {/snippet} +
+ {/if} +
diff --git a/ui/src/lib/components/azure/AppServiceDashboard.svelte b/ui/src/lib/components/azure/AppServiceDashboard.svelte new file mode 100644 index 0000000000..f93635b491 --- /dev/null +++ b/ui/src/lib/components/azure/AppServiceDashboard.svelte @@ -0,0 +1,247 @@ + + +{#snippet hostLink(site: WebSite, testId: string)} + {#if site.defaultHostName} +
{site.defaultHostName} + {:else}—{/if} +{/snippet} + +
+ + + {#if error && apps.length === 0} + + {:else if loading} +
+ {:else if apps.length === 0} +

No web apps. Create one through Resource Manager (Microsoft.Web/sites).

+ {:else} + + +
+ + + + + + {#each shown as a (a.id)} + + + + + + + + + {/each} + +
Web appResource groupStateRuntimeHostnameActions
{a.resourceGroup}{a.state || '—'}{a.runtime || '—'}{@render hostLink(a, 'appservice-host')} + + + +
+
+ + {#if current} +
+

{current.app}

+ {#if current.previewTarget} +

+ Swap preview pending: production with {current.previewTarget} + + +

+ {/if} + {#if current.lastSwap}

Last swap: {current.lastSwap}

{/if} + {#if childError}{/if} + + {#if childLoading && slots.length === 0} +
+ {:else} +
+

Deployment slots

+ {#if slots.length === 0} +

No deployment slots (the production slot only)

+ {:else} +
+ + + + {#each slots as s (s.id)} + + + + + + + + + {/each} + +
SlotStateRuntimeHostnameSwapActions
{s.slot}{s.state || '—'}{s.runtime || '—'}{@render hostLink(s, 'appservice-slot-host')}{s.previewTarget ? `preview with ${s.previewTarget}` : '—'} + + + + {#if s.previewTarget} + + + {/if} +
+
+ {/if} +
+ + {#if slots.length > 0} +
+

Swap slots

+
+ + + + + + +
+
+ {/if} + +
+

Deployment slot settings (sticky) ({stickyCount(sticky)})

+ {#if stickyCount(sticky) === 0} +

No settings stay with their slot; every setting moves with the content on a swap.

+ {:else} +
    + {#each sticky.appSettings as n (`a-${n}`)}
  • app setting: {n}
  • {/each} + {#each sticky.connectionStrings as n (`c-${n}`)}
  • connection string: {n}
  • {/each} + {#each sticky.azureStorage as n (`s-${n}`)}
  • storage mount: {n}
  • {/each} +
+ {/if} +
+ {/if} +
+ {/if} + {/if} +
diff --git a/ui/src/lib/components/azure/ContainerAppsDashboard.svelte b/ui/src/lib/components/azure/ContainerAppsDashboard.svelte new file mode 100644 index 0000000000..aa0ad24f2b --- /dev/null +++ b/ui/src/lib/components/azure/ContainerAppsDashboard.svelte @@ -0,0 +1,262 @@ + + +
+ + + {#if error && apps.length === 0 && envs.length === 0 && jobs.length === 0} + + {:else if loading} +
+ {:else} + (tab = id)} color="sky" /> + {#if childError}{/if} + + {#if tab === 'apps'} + {#if apps.length === 0} +

No container apps. Create one through Resource Manager (Microsoft.App/containerApps).

+ {:else} +
+ + + + {#each apps as a (a.id)} + + + + + + + + + + + {/each} + +
AppResource groupStateEnvironmentLatest revisionReplicasFQDNActions
{a.resourceGroup}{a.runningStatus || a.state || '—'}{lastSegment(a.environment) || '—'}{a.latestRevision || '—'}{a.minReplicas}–{a.maxReplicas} + {#if appUrl(a)} + {a.fqdn} + {:else}{a.fqdn || '—'}{/if} + + + +
+
+ + {#if currentApp} +
+

{currentApp.name} revisions

+

Revision mode: {currentApp.revisionMode || 'Single'}{currentApp.images.length ? ` · image ${currentApp.images.join(', ')}` : ''}

+ {#if revisions.length === 0} +

No revisions

+ {:else} +
+ + + + {#each revisions as r (r.name)} + + + + + + + + + + + {/each} + +
RevisionActiveRunningHealthReplicasTrafficCreatedActions
{r.name}{r.active ? 'yes' : 'no'}{r.running || '—'}{r.health || '—'}{r.replicas}{r.traffic}%{r.created} + {#each revisionActions(r) as act (act)} + + {/each} +
+
+ {/if} +
+ {/if} + {/if} + {:else if tab === 'environments'} + {#if envs.length === 0} +

No managed environments

+ {:else} +
+ + + + {#each envs as e (e.id)} + + + + + + + + + + {/each} + +
EnvironmentResource groupLocationStateDefault domainStatic IPApps
{e.name}{e.resourceGroup}{e.location}{e.state || '—'}{e.defaultDomain || '—'}{e.staticIp || '—'}{apps.filter((a) => a.environment.toLowerCase() === e.id.toLowerCase()).length}
+
+ {/if} + {:else if jobs.length === 0} +

No jobs

+ {:else} +
+ + + + {#each jobs as j (j.id)} + + + + + + + + + {/each} + +
JobResource groupStateTriggerEnvironmentActions
{j.resourceGroup}{j.state || '—'}{j.trigger || '—'}{lastSegment(j.environment) || '—'}
+
+ {#if currentJob} +
+

{currentJob.name} executions

+ {#if executions.length === 0} +

No executions

+ {:else} + + + + {#each executions as x (x.name)} + + {/each} + +
ExecutionStatusStartedFinished
{x.name}{x.status}{x.start}{x.end || '—'}
+ {/if} +
+ {/if} + {/if} + {/if} +
diff --git a/ui/src/lib/components/azure/FlexibleServerDashboard.svelte b/ui/src/lib/components/azure/FlexibleServerDashboard.svelte new file mode 100644 index 0000000000..c863672108 --- /dev/null +++ b/ui/src/lib/components/azure/FlexibleServerDashboard.svelte @@ -0,0 +1,254 @@ + + +
+ + + {#if error && servers.length === 0} + + {:else if loading} +
+ {:else if servers.length === 0} +

No flexible servers. Create one through Resource Manager (Microsoft.DBforPostgreSQL or Microsoft.DBforMySQL flexibleServers).

+ {:else} +
+ + +
+ +
+ + + + + + {#each visible as s (s.id)} + + + + + + + + + + + + + + {/each} + +
ServerEngineResource groupStateVersionSKUStorageHAFQDNEndpointActions
{engineLabel(s.engine)}{s.resourceGroup}{s.state || '—'}{s.version || '—'}{s.sku || '—'}{s.tier ? ` (${s.tier})` : ''}{s.storageGb} GiB{s.iops ? `, ${s.iops} IOPS` : ''}{s.haMode || '—'}{s.fqdn || '—'}{s.endpoint || '—'} + + + +
+
+ + {#if current} +
+

{current.name}

+

Administrator login {current.login || '—'}. The administrator password is never shown.

+ {#if detailError}{/if} + (tab = id)} color="sky" /> + + {#if tab === 'databases'} +
createDb(e, current)} data-testid="flexdb-db-form"> + + + + +
+ + + + {#each databases as d (d.name)} + + {/each} + +
NameCharsetCollationActions
{d.name}{d.charset || '—'}{d.collation || '—'}
+ {:else if tab === 'firewall'} +
createRule(e, current)} data-testid="flexdb-rule-form"> + + + + +
+ {#if rules.length === 0} +

No firewall rules.

+ {:else} + + + + {#each rules as r (r.name)} + + {/each} + +
NameStart IPEnd IPActions
{r.name}{r.start}{r.end}
+ {/if} + {:else} + + {#if shownConfigs.length === 0} +

All parameters are at their defaults.

+ {:else} + + + + {#each shownConfigs as c (c.name)} + + {/each} + +
ParameterValueDefaultSourceRestart
{c.name}{c.value}{c.defaultValue}{c.source}{c.pendingRestart ? 'Pending restart' : '—'}
+ {/if} + {/if} +
+ {/if} + {/if} +
diff --git a/ui/src/lib/components/azure/KubernetesServiceDashboard.svelte b/ui/src/lib/components/azure/KubernetesServiceDashboard.svelte new file mode 100644 index 0000000000..feb1d4f7c2 --- /dev/null +++ b/ui/src/lib/components/azure/KubernetesServiceDashboard.svelte @@ -0,0 +1,201 @@ + + +
+ + + {#if error && clusters.length === 0} + + {:else if loading} +
+ {:else if clusters.length === 0} +

No managed clusters. Create one through Resource Manager (Microsoft.ContainerService/managedClusters).

+ {:else} +
+ + + + + + {#each clusters as c (c.id)} + + + + + + + + + + + {/each} + +
ClusterResource groupPowerVersionFQDNNode resource groupNodesActions
{c.resourceGroup}{c.power || '—'}{c.currentVersion || c.version || '—'}{c.fqdn || '—'}{c.private ? ' (private)' : ''}{c.nodeResourceGroup || '—'}{totalNodes(c)} + + + +
+
+ + {#if current} +
+

{current.name}

+ {#if detailError}{/if} + +
+
Location
{current.location}
+
Tier
{current.tier || '—'}
+
Network plugin
{current.network.plugin || '—'}{current.network.pluginMode ? ` (${current.network.pluginMode})` : ''}
+
Network policy
{current.network.policy || '—'}
+
Service CIDR
{current.network.serviceCidr || '—'}
+
DNS service IP
{current.network.dnsServiceIp || '—'}
+
Pod CIDR
{current.network.podCidr || '—'}
+
Load balancer
{current.network.loadBalancerSku || '—'}
+
Outbound type
{current.network.outboundType || '—'}
+
+ +
+

Credentials

+

The dashboard does not show cluster credentials. Fetch a kubeconfig with the CLI:

+
+ {credentialsCommand(current)} + +
+
+ +

Agent pools

+ {#if current.pools.length === 0} +

This cluster has no agent pools

+ {:else} +
+ + + + {#each current.pools as p (p.name)} + + + + + + + + + + + {/each} + +
PoolModeVM sizeVersionZonesScalingCountScale
{p.name}{p.mode || '—'}{p.vmSize || '—'}{p.version || '—'}{p.zones.length ? p.zones.join(', ') : '—'}{scaling(p)}{p.count} + + +
+
+ {/if} + + {#if upgrades} +
+

Upgrade profile

+

Control plane {upgrades.controlPlane.version || '—'}: {upgrades.controlPlane.upgrades.length ? upgrades.controlPlane.upgrades.map((u) => u.version + (u.preview ? ' (preview)' : '')).join(', ') : 'no upgrades available'}

+ {#each upgrades.pools as up (up.name)} +

Pool {up.name} {up.version || '—'}: {up.upgrades.length ? up.upgrades.map((u) => u.version + (u.preview ? ' (preview)' : '')).join(', ') : 'no upgrades available'}

+ {/each} +
+ {/if} +
+ {/if} + {/if} +
diff --git a/ui/src/lib/components/azure/VirtualMachinesDashboard.svelte b/ui/src/lib/components/azure/VirtualMachinesDashboard.svelte new file mode 100644 index 0000000000..52a3e24370 --- /dev/null +++ b/ui/src/lib/components/azure/VirtualMachinesDashboard.svelte @@ -0,0 +1,214 @@ + + +
+ + + {#if error && vms.length === 0 && vnets.length === 0} + + {:else if loading} +
+ {:else} + (tab = id)} color="sky" /> + + {#if tab === 'vms'} + {#if vms.length === 0} +

No virtual machines. Create one through Resource Manager (Microsoft.Compute/virtualMachines).

+ {:else} +
+ + + + {#each vms as vm (vm.id)} + {@const addr = vmAddresses(vm, nics, ips)} + + + + + + + + + + + {/each} + +
NameResource groupSizePower statePrivate IPPublic IPDisksActions
{vm.resourceGroup}{vm.size || '—'}{powerLabel(vm.power)}{addr.privateIps.join(', ') || '—'}{addr.publicIps.join(', ') || '—'}{vm.disks.length} + {#each allowedActions(vm.power) as a (a)} + + {/each} +
+
+ + {#if current} +
+

{current.name}

+
+
Location
{current.location}{current.zones.length ? ` (zone ${current.zones.join(', ')})` : ''}
+
OS
{current.osType || '—'}{current.image ? ` · ${current.image}` : ''}
+
Computer name
{current.computerName || '—'}
+
Admin user
{current.adminUsername || '—'}
+
+ + + + {#each current.disks as d (d.name)} + + {/each} + +
DiskKindSizeType
{d.name}{d.kind}{d.sizeGb ? `${d.sizeGb} GB` : '—'}{d.type || '—'}
+
+ {/if} + {/if} + {:else if tab === 'vnets'} + {#if vnets.length === 0} +

No virtual networks

+ {:else} +
+ + + + {#each vnets as v (v.id)} + + + + + + + + {/each} + +
NetworkResource groupLocationAddress spaceSubnets
{v.name}{v.resourceGroup}{v.location}{v.prefixes.join(', ') || '—'} + {#each v.subnets as s (s.name)}
{s.name} {s.prefix}{s.nsg ? ` (nsg ${s.nsg})` : ''}
{:else}—{/each} +
+
+ {/if} + {:else if tab === 'nsgs'} + {#if nsgs.length === 0} +

No network security groups

+ {:else} + {#each nsgs as g (g.id)} +
+

{g.name} ({g.resourceGroup}, {g.location})

+ {#if g.rules.length === 0} +

No custom rules

+ {:else} +
+ + + + {#each g.rules as r (r.name)} + + {/each} + +
PriorityNameDirectionAccessProtocolSourceDestinationPorts
{r.priority}{r.name}{r.direction}{r.access}{r.protocol}{r.source}{r.destination}{r.ports}
+
+ {/if} +
+ {/each} + {/if} + {:else if disks.length === 0} +

No managed disks

+ {:else} +
+ + + + {#each disks as d (d.id)} + + {/each} + +
DiskResource groupSizeSKUStateAttached to
{d.name}{d.resourceGroup}{d.sizeGb} GB{d.sku || '—'}{d.state || '—'}{d.attachedTo || '—'}
+
+ {/if} + {/if} +
diff --git a/ui/src/lib/components/gcp/ApiGatewayApis.svelte b/ui/src/lib/components/gcp/ApiGatewayApis.svelte new file mode 100644 index 0000000000..2e08b19c5d --- /dev/null +++ b/ui/src/lib/components/gcp/ApiGatewayApis.svelte @@ -0,0 +1,173 @@ + + +
+ {#if error}{/if} + {#if apis.length === 0} +

No APIs in this project.

+ {:else} +
+ + + + {#each apis as a (a.name)} + + + + + + + {/each} + +
APIDisplay nameStateManaged service
{a.displayName || '—'}{a.state || '—'}{a.managedService || '—'}
+
+ {/if} + + {#if selectedApi} +
+

API configs

+ {#if configs.length === 0} +

No configs for this API.

+ {:else} + + + + {#each configs as c (c.name)} + {@const diags = c.state === 'FAILED' ? diagnosticsFor(c.name, operations) : []} + + + + + + + + {#if diags.length > 0} + + {/if} + {#if creating === c.name} + + {/if} + {/each} + +
ConfigStateService configCreatedActions
{c.id}{c.state || '—'}{c.serviceConfigId || '—'}{c.createTime || '—'} + + +
+
    + {#each diags as d, i (i)}
  • {d.location || 'config'}: {d.message}
  • {/each} +
+
+
create(e, c)} data-testid="agw-create-form"> + + + + +
+
+ {/if} + + {#if viewing} +
+

OpenAPI documents of {viewing.id}

+ {#if viewing.documents.length === 0}

This config has no OpenAPI documents.

{/if} + {#each viewing.documents as d (d.path)} +

{d.path}{d.decoded ? '' : ' (not text, shown as base64)'}

+
{d.contents}
+ {/each} +
+ {/if} +
+ {/if} +
diff --git a/ui/src/lib/components/gcp/ApiGatewayDashboard.svelte b/ui/src/lib/components/gcp/ApiGatewayDashboard.svelte new file mode 100644 index 0000000000..dd3ed50822 --- /dev/null +++ b/ui/src/lib/components/gcp/ApiGatewayDashboard.svelte @@ -0,0 +1,200 @@ + + +
+ + + {#if error && !project} + + {:else if loading} +
+ {:else if !project} +

No API Gateway resources yet. Create an API, a config and a gateway with the Google Cloud API Gateway client.

+ {:else} +
+ + +
+ {#if error}{/if} + + (tab = id)} color="blue" /> + + {#if tab === 'apis'} + { tab = 'gateways'; void refresh(); }} /> + {:else if tab === 'gateways'} + {#if gateways.length === 0} +

No gateways. Create one from an active API config.

+ {:else} +
+ + + + {#each gateways as g (g.name)} + + + + + + + + + {/each} + +
GatewayStateRegionDefault hostnameAPI configActions
{g.id}{g.displayName && g.displayName !== g.id ? ` (${g.displayName})` : ''}{g.state || '—'}{g.location}{g.defaultHostname || '—'}{g.apiConfig ? shortName(g.apiConfig) : '—'} + + +
+
+ {/if} + {:else if tab === 'operations'} + {#if operations.length === 0} +

No operations.

+ {:else} +
+ + + + {#each operations as o (o.name)} + + + + + + + + {/each} + +
OperationActionTargetStatusStarted
{o.id}{o.verb || '—'}{o.target ? o.target.replace(/^projects\/[^/]+\/locations\//, '') : '—'}{o.status}{#if o.statusMessage && o.status === 'FAILED'}{o.statusMessage}{/if}{o.createTime || '—'}
+
+ {/if} + {:else if activeGateways.length === 0} +

No ACTIVE gateway to send requests to.

+ {:else} + tryGateway(tryGatewayName, { ...r, apiKey })} + > + {#snippet fields()} +
+ + +
+ {/snippet} +
+ {/if} + {/if} +
diff --git a/ui/src/lib/components/gcp/BatchDashboard.svelte b/ui/src/lib/components/gcp/BatchDashboard.svelte new file mode 100644 index 0000000000..5b86753c83 --- /dev/null +++ b/ui/src/lib/components/gcp/BatchDashboard.svelte @@ -0,0 +1,228 @@ + + +
+ + + {#if error && !project} + + {:else if loading} +
+ {:else} +
+ {#if project} + + + + + {/if} + +
+ {#if error}{/if} + + {#if creating} +
{ e.preventDefault(); void submit(); }} data-testid="batch-create-form"> +
+ + + +
+ + +
+ + +
+
+

Environment variables (plain values only; secrets are not set from this page)

+ {#each env as row, i (i)} +
+ + + +
+ {/each} + {#if !envOk}

Variable names use letters, digits and underscores.

{/if} + +
+
+ + +
+
+ {/if} + + {#if !project} +

No Batch jobs yet. Create one above or with the Google Cloud Batch client.

+ {:else if jobs.length === 0} +

No jobs in this scope.

+ {:else} +
+ + + + {#each jobs as j (j.name)} + + + + + + + + + + {/each} + +
JobStateTasksCreatedRun durationLabelsActions
{j.location}
{j.state || '—'}{j.taskCount}{j.createTime || '—'}{formatDuration(j.runDuration)}{Object.entries(j.labels).map(([k, v]) => `${k}=${v}`).join(', ') || '—'} + + +
+
+ {/if} + + {#if selectedJob} + + {/if} + {/if} +
diff --git a/ui/src/lib/components/gcp/BatchJobDetail.svelte b/ui/src/lib/components/gcp/BatchJobDetail.svelte new file mode 100644 index 0000000000..a5801f068b --- /dev/null +++ b/ui/src/lib/components/gcp/BatchJobDetail.svelte @@ -0,0 +1,129 @@ + + +
+

{job.id} {job.state}

+ + {#each job.groups as g (g.name)} +
+

Task group {g.name.split('/').pop()}

+

taskCount {g.taskCount} · parallelism {g.parallelism || 'unbounded'} · max retries {g.maxRetryCount}{g.maxRunDuration ? ` · max run ${g.maxRunDuration}` : ''}

+ {#each g.runnables as r, i (i)} +
+

Runnable {i + 1}: {r.kind}{r.background ? ' (background)' : ''}{r.alwaysRun ? ' (always run)' : ''}

+ {#if r.kind === 'container'} +

{r.image}

+ {#if r.commands.length}
{r.commands.join(' ')}
{/if} + {:else if r.kind === 'script'} + {#if r.scriptPath}

{r.scriptPath}

{/if} +
{r.scriptText}
+ {/if} + {#each Object.entries(r.variables) as [k, v] (k)}

{k}={v}

{/each} + {#each r.secretNames as k (k)}

{k}=(secret)

{/each} +
+ {/each} + {#if Object.keys(g.variables).length || g.secretNames.length} +
+

Environment

+ {#each Object.entries(g.variables) as [k, v] (k)}

{k}={v}

{/each} + {#each g.secretNames as k (k)}

{k}=(secret)

{/each} +
+ {/if} +
+ {/each} + +
+

Status events

+
    + {#each job.events as e, i (i)} +
  1. {e.time}{e.description || e.type}
  2. + {:else} +
  3. No events.
  4. + {/each} +
+
+ +
+

Tasks

+ {#if error}{/if} +
+ + + + {#each tasks as t (t.name)} + + + + + + + {#if openTask === t.name} + + {/if} + {/each} + +
TaskStateExit codeAttempts
{t.state}{t.exitCode ?? '—'}{t.attempts}
    {#each t.events as e, i (i)}
  1. {e.time}{e.description}{e.exitCode !== null ? ` (exit ${e.exitCode})` : ''}
  2. {/each}
+
+
+ +
+

Task logs (Cloud Logging, log {'batch_task_logs'})

+ {#if logError} +

Task logs could not be read: {logError}

+ {:else if logs.length === 0} +

No log entries. Tasks write logs only when the docker engine runs them.

+ {:else} +
    + {#each logs as l (l.insertId)} +
  • {l.severity}{l.message}
  • + {/each} +
+ {/if} +
+
diff --git a/ui/src/lib/components/gcp/DatastoreDashboard.svelte b/ui/src/lib/components/gcp/DatastoreDashboard.svelte new file mode 100644 index 0000000000..8979a734db --- /dev/null +++ b/ui/src/lib/components/gcp/DatastoreDashboard.svelte @@ -0,0 +1,259 @@ + + +{#snippet entityTable(items: DsEntity[], testId: string)} +
+ + + + {#each items as e (entityId(e))} + + + + + + {#if expanded === entityId(e)} + + + + {/if} + {/each} + +
KeyPropertiesActions
{e.label}{e.properties.map((p) => p.name).join(', ') || '—'}
+ {#if e.properties.length === 0} +

No properties

+ {:else} + + + + {#each e.properties as p (p.name)} + + + + + + {/each} + +
PropertyTypeValueIndexed
{p.name}{p.type}{p.display}{p.excludeFromIndexes ? 'excluded' : 'indexed'}
+ {/if} +
+
+{/snippet} + +
+ + applyProject(p)} /> + + {#if error} + + {:else if loading} +
+ {:else} +
+ + +
+ +
+

Kinds

+ {#if kinds.length === 0} +

No kinds in this namespace

+ {:else} +
+ {#each kinds as k (k)} + + {/each} +
+ {/if} +
+ +
+ + {#if childError}{/if} + + {#if tab === 'entities'} + {#if !kind} +

Select a kind to browse its entities

+ {:else if childLoading && entities.length === 0} +
+ {:else if entities.length === 0} +

No entities of kind {kind}

+ {:else} + {@render entityTable(entities, 'datastore-entity')} + {#if more}{/if} + {/if} + {:else if tab === 'gql'} +
+ + + +
+ {#if gqlResult} + {#if gqlResult.entities.length === 0} +

The query returned no entities

+ {:else} + {@render entityTable(gqlResult.entities, 'datastore-result')} + {/if} + {/if} + {:else if childLoading} +
+ {:else if indexes.length === 0} +

No composite indexes

+ {:else} +
+ + + + {#each indexes as i (i.id)} + + + + + + + {/each} + +
IndexKindAncestorPropertiesState
{i.id}{i.kind}{i.ancestor}{i.properties.map((p) => `${p.name} ${p.direction === 'DESCENDING' ? 'desc' : 'asc'}`).join(', ')}{i.state}
+
+ {/if} +
+ {/if} +
diff --git a/ui/src/lib/components/gcp/IdentityPlatformDashboard.svelte b/ui/src/lib/components/gcp/IdentityPlatformDashboard.svelte new file mode 100644 index 0000000000..b406bb4441 --- /dev/null +++ b/ui/src/lib/components/gcp/IdentityPlatformDashboard.svelte @@ -0,0 +1,174 @@ + + +
+ + + {#if error && !project} + + {:else if loading} +
+ {:else} +
+ + + + +
+ + {#if error}{/if} + + (tab = id)} color="blue" /> + + {#if tab === 'users'} + + {:else if tab === 'codes'} +
+

No email is sent: password-reset, verification and sign-in links are kept here for you to open.

+ {#if codes.length === 0} +

No stored action codes.

+ {:else} +
+ + + + {#each codes as c (c.oobCode)} + + + + + + + {/each} + +
TypeEmailLinkActions
{c.requestType}{c.email}{c.oobLink}
+
+ {/if} +
+ {:else if tab === 'tenants'} + {#if tenants.length === 0} +

No tenants in this project.

+ {:else} +
+ + + + {#each tenants as t (t.id)} + + + + + + + + + {/each} + +
TenantIDPassword sign-upEmail linkAnonymousScope
{t.displayName || '—'}{t.id}{t.allowPasswordSignup ? 'Yes' : 'No'}{t.enableEmailLinkSignin ? 'Yes' : 'No'}{t.enableAnonymousUser ? 'Yes' : 'No'}
+
+ {/if} + {:else if config} +
+ + +

Authorized domains: {config.authorizedDomains.length ? config.authorizedDomains.join(', ') : '—'}

+
+ {/if} + {/if} +
diff --git a/ui/src/lib/components/gcp/IdentityPlatformUsers.svelte b/ui/src/lib/components/gcp/IdentityPlatformUsers.svelte new file mode 100644 index 0000000000..acc1375075 --- /dev/null +++ b/ui/src/lib/components/gcp/IdentityPlatformUsers.svelte @@ -0,0 +1,180 @@ + + +
+
+ + + + +
+ +
+ + {visible.length} of {total} users +
+ + {#if error && users.length === 0} + + {:else if loading} +
+ {:else if visible.length === 0} +

{users.length === 0 ? 'No users in this scope.' : 'No users match the search.'}

+ {:else} +
+ + + + {#each visible as u (u.uid)} + + + + + + + + + {/each} + +
IdentifierProvidersVerifiedStatusCreatedLast sign-in
{userProviders(u).map(providerLabel).join(', ') || '—'}{u.emailVerified ? 'Yes' : 'No'}{u.disabled ? 'Disabled' : 'Enabled'}{formatTime(u.createdAt)}{formatTime(u.lastLoginAt)}
+
+ {/if} + + {#if current} +
+

{current.email || current.uid}

+
+
UID
{current.uid}
+
Email
{current.email || '—'}
+
Email verified
{current.emailVerified ? 'Yes' : 'No'}
+
Disabled
{current.disabled ? 'Yes' : 'No'}
+
Display name
{current.displayName || '—'}
+
Providers
{userProviders(current).map(providerLabel).join(', ') || '—'}
+
Created
{formatTime(current.createdAt)}
+
Last sign-in
{formatTime(current.lastLoginAt)}
+
Tokens valid since
{current.validSince ? formatTime(current.validSince * 1000) : '—'}
+
+ +
+ + + + +
+ +
+ + + {#if !claimsCheck.ok}{/if} + +
+
+ {/if} +
diff --git a/ui/src/lib/components/gcp/KubernetesDashboard.svelte b/ui/src/lib/components/gcp/KubernetesDashboard.svelte new file mode 100644 index 0000000000..fcc3ec8818 --- /dev/null +++ b/ui/src/lib/components/gcp/KubernetesDashboard.svelte @@ -0,0 +1,196 @@ + + +
+ + + + (tab = id)} color="blue" /> + + {#if error && clusters.length === 0} + + {:else if loading} +
+ {:else if tab === 'clusters'} + {#if clusters.length === 0} +

No clusters in this project. Create one with the Kubernetes Engine API or gcloud.

+ {:else} +
+ + + + + + {#each clusters as c (c.path)} + + + + + + + + + + + + {/each} + +
NameLocationStatusVersionEndpointRelease channelNetworkNodesActions
{c.location}{c.status || '—'}{c.masterVersion || '—'}{c.endpoint || '—'}{releaseChannelLabel(c.releaseChannel)}{c.network || '—'}{c.subnetwork ? ` / ${c.subnetwork}` : ''}{c.currentNodeCount}
+
+ + {#if current} +
+

{current.name} node pools

+ {#if detailError}{/if} + {#if current.pools.length === 0} +

This cluster has no node pools

+ {:else} +
+ + + + {#each current.pools as p (p.name)} + + + + + + + + + + {/each} + +
PoolStatusVersionMachine typeAutoscalingNodes per zoneResize
{p.name}{p.status || '—'}{p.version || '—'}{p.machineType || '—'}{scaling(p)}{sizes[p.name] ?? '—'} + + +
+
+ {/if} +
+ {/if} + {/if} + {:else if operations.length === 0} +

No operations yet

+ {:else} +
+ + + + {#each operations as o (o.name)} + + + + + + + + + + {/each} + +
OperationTypeStatusTargetLocationStartedFinished
{o.name}{o.type}{o.status}{o.target || '—'}{o.location}{o.start || '—'}{o.end || '—'}
+
+ {/if} +
diff --git a/ui/src/lib/components/gcp/ResourceManagerDashboard.svelte b/ui/src/lib/components/gcp/ResourceManagerDashboard.svelte new file mode 100644 index 0000000000..5e6f3546dc --- /dev/null +++ b/ui/src/lib/components/gcp/ResourceManagerDashboard.svelte @@ -0,0 +1,179 @@ + + +
+ + +
+ Enforcement + --gcp-enforce-projects: {enforcement.projects || enforcement.serviceEnablement ? 'on' : 'off'} + --gcp-enforce-service-enablement: {enforcement.serviceEnablement ? 'on' : 'off'} + {enforcement.projects || enforcement.serviceEnablement ? 'Other GCP services reject unknown or deleted projects' : 'Other GCP services accept any project id'}{enforcement.serviceEnablement ? ' and APIs that are not enabled' : ''}. +
+ + {#if error && !roots.length} + + {:else if loading} +
+ {:else} +
+ + +
+ {#if error}{/if} + + {#if form === 'folder'} +
{ e.preventDefault(); void submitFolder(); }} data-testid="rm-folder-form"> + + + + +
+ {:else if form === 'project'} +
{ e.preventDefault(); void submitProject(); }} data-testid="rm-project-form"> +
+ + + +
+
+

Labels

+ {#each labels as l, i (i)} +
+ + + +
+ {/each} + +
+
+ + +
+
+ {/if} + +
+ + {#if current} + + {:else} +

Select an organization, folder or project.

+ {/if} +
+ {/if} +
diff --git a/ui/src/lib/components/gcp/RmApis.svelte b/ui/src/lib/components/gcp/RmApis.svelte new file mode 100644 index 0000000000..3177e0e28c --- /dev/null +++ b/ui/src/lib/components/gcp/RmApis.svelte @@ -0,0 +1,117 @@ + + +
+
+ + + {enabledCount} of {all.length} enabled +
+ + {#if error} + + {:else if loading} +
+ {:else if shown.length === 0} +

No APIs match.

+ {:else} +
+ + + + {#each shown as s (s.id)} + + + + + + {/each} + +
APIStateAction
{s.id}
{s.title}{s.summary ? ` — ${s.summary}` : ''}
{s.state} + {#if s.state === 'ENABLED'} + + {:else} + + {/if} +
+
+ {/if} + + {#if disabling} +
+

Disable {disabling}?

+

Other enabled APIs may depend on this one. Disabling it fails unless dependent services are disabled with it.

+ + {#if disableError}{/if} +
+ + +
+
+ {/if} +
diff --git a/ui/src/lib/components/gcp/RmDetail.svelte b/ui/src/lib/components/gcp/RmDetail.svelte new file mode 100644 index 0000000000..488346e47c --- /dev/null +++ b/ui/src/lib/components/gcp/RmDetail.svelte @@ -0,0 +1,111 @@ + + +
+
+

{node.label}

+ {node.state} + {#if node.kind !== 'organization'} + + {#if isDeleted(node)} + + {:else} + + + {/if} + + {/if} +
+ + {#if moving} +
+ + + + +
+ {/if} + + (tab = id)} color="blue" /> + + {#if tab === 'overview'} +
+ {#if node.kind === 'project'} +
Project ID
{node.id}
+
Project number
{node.number}
+ {:else} +
Name
{node.name}
+ {/if} +
Display name
{node.label}
+
Parent
{parentLabel || '—'}
+
Created
{node.createTime || '—'}
+ {#if isDeleted(node)} +
Delete requested
{node.deleteTime || '—'} (restorable until the retention window passes)
+ {/if} + {#if node.kind === 'project'} +
Labels
{Object.entries(node.labels).map(([k, v]) => `${k}=${v}`).join(', ') || '—'}
+ {/if} +
+ {:else if tab === 'apis' && node.kind === 'project'} + + {:else if tab === 'iam'} + + {:else if tab === 'tags'} + + {/if} +
diff --git a/ui/src/lib/components/gcp/RmIam.svelte b/ui/src/lib/components/gcp/RmIam.svelte new file mode 100644 index 0000000000..e51b97a11b --- /dev/null +++ b/ui/src/lib/components/gcp/RmIam.svelte @@ -0,0 +1,99 @@ + + +
+ {#if error} + + {:else if loading} +
+ {:else} + {#if conflict}{/if} + {#if bindings.length === 0} +

No bindings on this {node.kind}.

+ {:else} +
+ + + + {#each bindings as b, i (`${b.role}/${b.condition}/${i}`)} + + + + + {/each} + +
RoleMembers
{b.role}{#if b.condition}
condition: {b.condition}
{/if}
+ {#each b.members as m (m)} +
+ {m} + {#if !b.condition}{/if} +
+ {/each} +
+
+ {/if} +
{ e.preventDefault(); void add(); }} data-testid="rm-iam-form"> + + + +
+

Writes carry the etag shown at load time; a stale view is rejected and reloaded.

+ {/if} +
diff --git a/ui/src/lib/components/gcp/RmTags.svelte b/ui/src/lib/components/gcp/RmTags.svelte new file mode 100644 index 0000000000..c774b7547f --- /dev/null +++ b/ui/src/lib/components/gcp/RmTags.svelte @@ -0,0 +1,73 @@ + + +
+ {#if error} + + {:else if loading} +
+ {:else} +
+

Tag bindings on this {node.kind}

+ {#if bindings.length === 0} +

No tag bindings.

+ {:else} +
    + {#each bindings as b (b.name)}
  • {b.namespaced || b.tagValue} ({b.tagValue})
  • {/each} +
+ {/if} +
+
+

Tag keys and values

+ {#if keys.length === 0} +

No tag keys are defined for this scope.

+ {:else} +
    + {#each keys as k (k.name)} +
  • + {k.namespacedName || k.shortName} {k.description} +
      + {#each k.values as v (v.name)}
    • {v.shortName}{v.description ? ` — ${v.description}` : ''}
    • {/each} +
    +
  • + {/each} +
+ {/if} +
+ {/if} +
diff --git a/ui/src/lib/components/gcp/RmTree.svelte b/ui/src/lib/components/gcp/RmTree.svelte new file mode 100644 index 0000000000..e1df147d4d --- /dev/null +++ b/ui/src/lib/components/gcp/RmTree.svelte @@ -0,0 +1,37 @@ + + +{#snippet item(n: RmNode)} +
  • + {#if n.name} + {@const Icon = iconOf(n)} + + {:else} +

    {n.label}

    + {/if} + {#if n.children.length} +
      + {#each n.children as c (c.name)} + {@render item(c)} + {/each} +
    + {/if} +
  • +{/snippet} + +{@render item(node)} diff --git a/ui/src/lib/components/oci/APIGatewayDashboard.svelte b/ui/src/lib/components/oci/APIGatewayDashboard.svelte new file mode 100644 index 0000000000..1ba7e15556 --- /dev/null +++ b/ui/src/lib/components/oci/APIGatewayDashboard.svelte @@ -0,0 +1,308 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No gateways yet. Create one with the OCI SDK or CLI.

    + {:else} + + {#if error}{/if} + +
    + + + + {#each gateways as g (g.id)} + + + + + + + + {:else} + + {/each} + +
    GatewayStateHostnameEndpointCreated
    {g.state || '—'}{g.hostname || '—'}{g.endpointType || '—'}{g.created}
    No gateways in this compartment
    +
    + + {#if detailError}{/if} + + {#if detail} +
    +

    {detail.name}

    +
    +
    OCID
    {detail.id}
    +
    Hostname
    {detail.hostname}
    +
    Endpoint type
    {detail.endpointType}
    +
    IP mode
    {detail.ipMode || '—'}
    +
    Subnet
    {detail.subnetId || '—'}
    +
    Certificate
    {detail.certificateId || '—'}
    +
    +
    + {/if} + + (tab = id)} color="red" /> + + {#if tab === 'certificates'} + {#if certificates.length === 0} +

    No certificates

    + {:else} +
    + + + + {#each certificates as c (c.id)} + + + + + + + {/each} + +
    CertificateStateSubjectsExpires
    {c.name}{c.state}{c.subjects.join(', ') || '—'}{c.notAfter || '—'}
    +
    +

    Private keys are discarded by the emulator and are never shown.

    + {/if} + {:else if !detail} +

    Select a gateway to see its {tab === 'try' ? 'data plane' : 'deployments'}.

    + {:else if tab === 'deployments'} + {#if deployments.length === 0} +

    No deployments on this gateway

    + {/if} + {#each deployments as d (d.id)} +
    +
    +

    {d.name}

    + {d.state} + {d.pathPrefix} +
    +

    {d.endpoint}

    + {#if d.policies.length}

    Policies: {d.policies.join(', ')}

    {/if} +
    + + + + {#each d.routes as r, i (i)} + + + + + + + + {/each} + +
    PathMethodsBackendPoliciesActions
    {r.path}{r.methods.join(', ')}{backendLabel(r.backendType)} {r.backend}{r.policies.join(', ') || '—'}
    +
    +
    + {/each} + {:else} +
    + {#if !isServing(detail)}

    Gateway is {detail.state}; requests are served once it is ACTIVE.

    {/if} +

    Sent in-process to the data plane of {detail.name}; the destination cannot be changed. Include the deployment path prefix in the path.

    +
    + + + +
    + + + + {#if tryError}{/if} + {#if result} +
    +

    Status {result.status}{#if result.truncated} (body truncated){/if}

    +
    + {#each Object.entries(result.headers) as [k, v] (k)}
    {k}
    {v}
    {/each} +
    +
    {prettyBody(result)}
    +
    + {/if} +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/ArtifactsDashboard.svelte b/ui/src/lib/components/oci/ArtifactsDashboard.svelte new file mode 100644 index 0000000000..9bb6e427d4 --- /dev/null +++ b/ui/src/lib/components/oci/ArtifactsDashboard.svelte @@ -0,0 +1,542 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else} +
    + +
    + + {#if error}{/if} + + (tab = id)} color="orange" /> + + {#if tab === 'containers'} + {#if config} +
    + Namespace {config.namespace} + +
    + {/if} + +
    + +
    + + {#if showCreate} +
    { e.preventDefault(); void createContainerRepo(); }} data-testid="art-create-form"> + +
    + + +
    + +
    +
    + {/if} + +
    + + + + {#each repos as r (r.id)} + + + + + + + + + + {:else} + + {/each} + +
    RepositoryVisibilityImagesLayersSizeCreatedActions
    + {r.isPublic ? 'Public' : 'Private'} + {r.isImmutable ? 'Immutable' : 'Mutable'} + {r.imageCount}{r.layerCount}{formatBytes(r.sizeBytes)}{when(r.created)} + + + +
    No container repositories in this compartment. Create one above or push an image.
    +
    + + {#if repo} +
    +

    {repo.name}

    + {#if detailError}{/if} +
    +
    OCID
    {repo.id}
    +
    Last pushed
    {when(repo.lastPushed)}
    +
    + + {#if hints} +
    +

    Docker commands

    + {#each [['login', hints.login], ['push', hints.push], ['pull', hints.pull]] as [k, cmd] (k)} +
    {cmd}
    + {/each} +
    + {/if} + + + + +
    +

    Images

    + +
    +
    + + + + {#each images as i (i.id)} + + + + + + + + + + + {:else} + + {/each} + +
    VersionDigestSizeLayersCreatedPullsLast pulledActions
    {imageTags(i).join(', ') || 'untagged'}{formatBytes(i.sizeBytes)}{i.layers.length}{when(i.created)}{i.pullCount}{when(i.lastPulled)} + {#if i.state === 'DELETED'} + + {:else} + + {/if} +
    {showDeleted ? 'No deleted images' : 'No images pushed yet'}
    +
    + + {#if image} +
    +

    Image {image.digest}

    +
    + {#each imageTags(image) as t (t)} + + {/each} +
    +
    + + + + {#each image.layers as l (l.digest)} + + {:else} + + {/each} + +
    Layer digestSize
    {l.digest}{formatBytes(l.sizeBytes)}
    No layers
    +
    +

    Signatures

    +
    + + + + {#each signatures as s (s.id)} + + + + + + + {:else} + + {/each} + +
    KeyAlgorithmDescriptionCreated
    {s.keyId}{s.algorithm}{s.description}{when(s.created)}
    No signatures
    +
    +
    + {/if} +
    + {/if} + {:else} +
    + +
    + + {#if showGenCreate} +
    { e.preventDefault(); void createGeneric(); }} data-testid="art-gen-create-form"> + + + +
    +
    + {/if} + +
    + + + + {#each genRepos as r (r.id)} + + + + + + + + {:else} + + {/each} + +
    RepositoryDescriptionImmutableCreatedActions
    {r.description || '—'}{r.isImmutable ? 'Yes' : 'No'}{when(r.created)}
    No generic repositories in this compartment
    +
    + + {#if gen} +
    +

    {gen.name}

    + {#if detailError}{/if} +

    Upload with the OCI SDK or CLI. Downloads here are attachments up to {ART_DOWNLOAD_LIMIT_MIB} MiB.

    +
    + + + + {#each artifacts as a (a.id)} + + + + + + + + + {:else} + + {/each} + +
    PathVersionSizeSHA-256StateActions
    {a.path}{a.version}{formatBytes(a.sizeBytes)}{a.sha256}{a.state} + Download + +
    No artifacts in this repository
    +
    +
    + {/if} + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/ContainerEngineDashboard.svelte b/ui/src/lib/components/oci/ContainerEngineDashboard.svelte new file mode 100644 index 0000000000..ccfb1c9cc7 --- /dev/null +++ b/ui/src/lib/components/oci/ContainerEngineDashboard.svelte @@ -0,0 +1,244 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No clusters yet. Create one with the OCI SDK or CLI.

    + {:else} + + {#if error}{/if} + +
    + + + + + + {#each clusters as c (c.id)} + + + + + + + + + + {:else} + + {/each} + +
    ClusterStateVersionTypeEndpointVCNActions
    {c.state || '—'}{c.version || '—'}{c.type || '—'}{c.publicEndpoint || c.privateEndpoint || c.kubernetesEndpoint || '—'}{c.vcnId ? shortOcid(c.vcnId) : '—'}
    No clusters in this compartment
    +
    + + {#if current} +
    +

    {current.name}

    + {#if detailError}{/if} +
    +
    OCID
    {current.id}
    +
    VCN
    {current.vcnId || '—'}
    +
    Kubernetes API
    {current.kubernetesEndpoint || '—'}
    +
    Available upgrades
    {current.upgrades.length ? current.upgrades.join(', ') : 'none'}
    +
    +
    +

    Credentials

    +

    The dashboard does not show cluster credentials. Fetch a kubeconfig with the CLI:

    +
    + {kubeconfigCommand(current, endpoint)} + +
    +
    + + (tab = id)} color="red" /> + + {#if tab === 'pools'} + {#if pools.length === 0} +

    This cluster has no node pools

    + {:else} +
    + + + + {#each pools as p (p.id)} + + + + + + + + + + {#each p.nodes as n (n.id)} + + + + + + + {/each} + {/each} + +
    PoolStateShapePlacementsSizeNodesScale
    {p.name}{p.state || '—'}{p.shape}{p.ocpus ? ` (${p.ocpus} OCPU, ${p.memoryGbs} GB)` : ''}{p.placements.length ? p.placements.map((x) => x.availabilityDomain).join(', ') : '—'}{p.size}{p.legacySize ? ' per subnet' : ''}{p.nodes.filter((n) => n.state !== 'DELETED').length} + + +
    {n.name}{n.state}{n.privateIp || '—'}{n.availabilityDomain}
    +
    + {/if} + {:else if requests.length === 0} +

    No work requests

    + {:else} +
    + + + + {#each requests as r (r.id)} + + + + + + + + {/each} + +
    OperationStatusResourceAcceptedFinished
    {r.operation}{r.status}{shortOcid(r.resource)}{r.accepted}{r.finished || '—'}
    +
    + {/if} +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/ContainerInstancesDashboard.svelte b/ui/src/lib/components/oci/ContainerInstancesDashboard.svelte new file mode 100644 index 0000000000..367922c1fa --- /dev/null +++ b/ui/src/lib/components/oci/ContainerInstancesDashboard.svelte @@ -0,0 +1,514 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else} +
    + + Engine: {engine || 'unknown'} + +
    + + {#if error}{/if} + + {#if showCreate} +
    { e.preventDefault(); void create(); }} data-testid="ci-create-form"> + + + + + + + + + + + +
    +

    Environment variables (values are sent to the container and never shown again)

    + {#each fEnv as pair, idx (idx)} +
    + + + +
    + {/each} + +
    +
    + + {#if invalid}{invalid}{/if} +
    +
    + {/if} + +
    + + + + {#each instances as i (i.id)} + + + + + + + + + {:else} + + {/each} + +
    NameStateShapeContainersAvailability domainCreated
    {i.state}{i.shape} · {i.ocpus} OCPU · {i.memoryGBs} GB{i.containerCount}{i.availabilityDomain}{when(i.created)}
    No container instances in this compartment. Create one above.
    +
    + + {#if inst && detail} +
    +
    +

    {detail.name} {detail.state}

    +
    + {#each ['start', 'stop', 'restart'] as const as a (a)} + + {/each} + +
    +
    + {#if detail.details}

    {detail.details}

    {/if} + {#if detailError}{/if} +
    +
    OCID
    {detail.id}
    +
    Shape
    {detail.shape} · {detail.ocpus} OCPU · {detail.memoryGBs} GB
    +
    Availability domain
    {detail.availabilityDomain}
    +
    Restart policy
    {detail.restartPolicy}
    +
    Graceful shutdown
    {detail.gracefulShutdownSeconds === undefined ? 'default' : `${detail.gracefulShutdownSeconds}s`}
    +
    Created
    {when(detail.created)}
    +
    +
    { e.preventDefault(); void rename(inst); }}> + + +
    + + (tab = id)} color="orange" /> + + {#if tab === 'containers'} +
    + + + + {#each containers as c (c.id)} + + + + + + + + + + + + {:else} + + {/each} + +
    NameImageStateExit codeHealthRestartsLimitsEnv namesLogs
    {c.name}{c.image}{c.state}{c.exitCode ?? '—'}{c.health || '—'}{c.restartCount}{c.vcpusLimit ?? '—'} vCPU · {c.memoryLimitGBs ?? '—'} GB{c.envNames.join(', ') || '—'} + + +
    No containers yet
    +
    + + {#if logsFor} +
    +
    +

    {containers.find((c) => c.id === logsFor)?.name ?? 'Container'} logs{logsPrevious ? ' (previous run)' : ''}

    + + +
    + {#if logsError} + + {:else if logs} + {#if logs.truncated}

    Showing the last part of {logs.totalBytes} bytes.

    {/if} +
    {logs.text || '(no output)'}
    + {:else} +

    Loading logs...

    + {/if} +
    + {/if} + {:else if tab === 'config'} +
    +
    +

    VNICs

    +
      + {#each detail.vnicIds as v (v)}
    • {shortOcid(v)}
    • {:else}
    • None
    • {/each} +
    +
    +
    +

    Volumes

    +
    + + + + {#each detail.volumes as v (v.name)} + + + + + + {:else} + + {/each} + +
    NameTypeDetails
    {v.name}{v.type}{#if v.type === 'CONFIGFILE'}{v.files.map((f) => `${f.path ? `${f.path}/` : ''}${f.fileName} (${f.sizeBytes} B)`).join(', ')}{:else}{v.backingStore || '—'}{/if}
    No volumes
    +
    +
    +
    +

    Image pull secrets

    +
      + {#each detail.pullSecrets as s, k (k)}
    • {s.type} · {s.registry}
    • {:else}
    • None
    • {/each} +
    +
    +
    + {:else} +
    + + + + {#each workRequests as w (w.id)} + + + + + + + + + + {#each wrErrors[w.id] ?? [] as e (e.timestamp + e.code)} + + {/each} + {:else} + + {/each} + +
    OperationStatusProgressResourcesAcceptedFinishedErrors
    {w.type}{w.status}{w.percent}%{w.resources}{when(w.accepted)}{when(w.finished)}
    {e.code}: {e.message}
    No work requests
    +
    + {/if} +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/CoreDashboard.svelte b/ui/src/lib/components/oci/CoreDashboard.svelte new file mode 100644 index 0000000000..0d87119d52 --- /dev/null +++ b/ui/src/lib/components/oci/CoreDashboard.svelte @@ -0,0 +1,267 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if compartments.length === 0 && !loading} +

    No Core Services resources yet. Create a VCN with the OCI SDK or CLI.

    + {:else} + + + + {#if error}{/if} + + {#if loading} +
    + {:else} + {#each SECTIONS[tab] ?? [] as s (s.kind)} + {@const items = data[s.kind] ?? []} +
    +

    {s.title} ({items.length})

    + {#if items.length === 0} +

    No {s.noun} in this compartment

    + {:else} +
    + + + {#each s.columns as c (c.label)}{/each} + + + {#each items as r (r.id)} + + + + {#each s.columns as c (c.label)}{/each} + + + {#if expanded === r.id} + + + + {/if} + {/each} + +
    NameState{c.label}Actions
    {r.name}{r.state || '—'}{c.get(r) || '—'} + {#if s.kind === 'instances'} + {#each allowedActions(r.state) as a (a)} + + {/each} + {/if} + +
    + {#if detailError}{/if} + {#if detailLoading} + + {:else if s.kind === 'instances'} +

    VNICs

    + {#if vnics.length === 0} +

    No VNIC attachments

    + {:else} + + + + {#each vnics as v (v.attachmentId)} + + {/each} + +
    VNICStatePrivate IPPublic IPMACPrimary
    {v.name || shortId(v.vnicId)}{v.state}{v.privateIp || '—'}{v.publicIp || '—'}{v.mac || '—'}{v.isPrimary ? 'yes' : 'no'}
    + {/if} + {:else if s.kind === 'routeTables'} +

    Route rules

    +
      + {#each routeRules(r.raw) as rr, i (i)}
    • {rr.destination} → {shortId(rr.target)}
    • {:else}
    • No rules
    • {/each} +
    + {:else if s.kind === 'securityLists' || s.kind === 'networkSecurityGroups'} +

    Security rules

    + {#if rules.length === 0} +

    No rules

    + {:else} + + + + {#each rules as rule, i (i)} + + {/each} + +
    DirectionProtocolSource / destinationPortsStateless
    {rule.direction}{rule.protocol}{rule.peer}{rule.ports}{rule.stateless ? 'yes' : 'no'}
    + {/if} + {/if} +
    {JSON.stringify(r.raw, null, 2)}
    +
    +
    + {/if} +
    + {/each} + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/EventsDashboard.svelte b/ui/src/lib/components/oci/EventsDashboard.svelte new file mode 100644 index 0000000000..7f050e326a --- /dev/null +++ b/ui/src/lib/components/oci/EventsDashboard.svelte @@ -0,0 +1,375 @@ + + +
    + + + {#if error && compartments.length === 0 && rules.length === 0 && !loading} + + {:else if loading} +
    + {:else} + (tab = id)} color="red" /> + + {#if tab === 'rules'} +
    + + +
    + + {#if showCreate} +
    { e.preventDefault(); void create(); }} data-testid="events-create"> +
    + + +
    + + + {#if conditionError}{/if} + +
    + Actions + {#each draft as a, i (i)} +
    + + {#if options(a.type).length > 0} + + {:else} + + {/if} + + +
    + {/each} + +
    + +
    + + +
    + {#if createError}{/if} +
    + {/if} + + {#if error}{/if} + +
    + + + + + + {#each rules as r (r.id)} + + + + + + + + + {:else} + + {/each} + +
    RuleStateEnabledConditionActionsOperations
    {r.state || '—'}{r.enabled ? 'Yes' : 'No'}{summarizeCondition(r.condition)} + {#each r.actions as a (a.id)} +
    {a.type} {shortOcid(a.targetId)} ({a.enabled ? 'enabled' : 'disabled'})
    + {/each} +
    + + +
    No rules in this compartment
    +
    + + {#if detail} +
    +

    {detail.name}

    +
    +
    OCID
    {detail.id}
    +
    State
    {detail.state}
    +
    Enabled
    {detail.enabled ? 'Yes' : 'No'}
    +
    Description
    {detail.description || '—'}
    +
    Created
    {detail.created}
    +
    +
    +

    Condition

    +
    {prettyCondition(detail.condition)}
    +
    +
    + + + + {#each detail.actions as a (a.id)} + + {/each} + +
    TypeTargetStateEnabled
    {actionTypeLabel(a.type)}{a.targetId}{a.state}{a.enabled ? 'Yes' : 'No'}
    +
    +
    + {/if} + {:else} +
    + + +
    + {#if deliveryError}{/if} +
    + + + + + + {#each deliveries as d (d.eventId + d.actionId)} + + + + + + + + + + {:else} + + {/each} + +
    TimeRuleEvent typeActionTargetStatusError
    {d.time}{ruleNames[d.ruleId] ?? shortOcid(d.ruleId)}{d.eventType}{d.actionType}{shortOcid(d.targetId)}{d.status}{d.error}
    No deliveries yet
    +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/LoadBalancerDashboard.svelte b/ui/src/lib/components/oci/LoadBalancerDashboard.svelte new file mode 100644 index 0000000000..8764dae992 --- /dev/null +++ b/ui/src/lib/components/oci/LoadBalancerDashboard.svelte @@ -0,0 +1,558 @@ + + +{#snippet healthBadge(h: LbHealth | undefined, testid: string)} + {h?.status ?? 'UNKNOWN'} +{/snippet} + +
    + + + {#if error && compartments.length === 0 && !showCreate} + + {:else if loading} +
    + {:else} +
    + {#if compartments.length > 0} + + {/if} + +
    + + {#if showCreate} +
    { e.preventDefault(); void create(); }} data-testid="lb-create-form"> + + + + + + + +
    +
    + {/if} + + {#if error}{/if} + + {#if compartments.length === 0} +

    No load balancers yet. Create one above or with the OCI SDK or CLI.

    + {:else} +
    + + + + {#each lbs as lb (lb.id)} + + + + + + + + + {:else} + + {/each} + +
    Load balancerStateShapeIP addressesListeners (gopherstack address)Actions
    {lb.state || '—'}{lb.shape || '—'}{lb.ips.map((i) => `${i.address}${i.isPublic ? '' : ' (private)'}`).join(', ') || '—'} + {#each lb.listeners as l (l.name)}
    {l.name} :{l.port} → {l.address || 'not serving'}
    {:else}—{/each} +
    No load balancers in this compartment
    +
    + {/if} + + {#if current} +
    +
    +

    {current.name}

    + {@render healthBadge(lbHealth, 'lb-health')} +
    + {#if detailError}{/if} + {#if busy}

    A work request is in progress; changes are accepted once it finishes.

    {/if} +
    +
    OCID
    {current.id}
    +
    Visibility
    {current.isPrivate ? 'Private' : 'Public'}
    +
    Subnets
    {current.subnetIds.join(', ') || '—'}
    +
    + + (tab = id)} color="red" /> + + {#if tab === 'listeners'} +
    + + + + {#each current.listeners as l (l.name)} + + + + + + + + + + {:else} + + {/each} + +
    ListenerProtocolPortDefault backend setHostnamesRoutingAddress
    {l.name}{l.protocol}{l.tls ? ' (TLS)' : ''}{l.port}{l.defaultBackendSet}{l.hostnames.join(', ') || '—'}{l.routingPolicy ? `policy ${l.routingPolicy}` : l.pathRouteSet ? `paths ${l.pathRouteSet}` : '—'}{l.ruleSets.length ? `; rules ${l.ruleSets.join(', ')}` : ''}{l.address || 'not serving'}
    No listeners
    +
    + {:else if tab === 'backendsets'} + {#each current.backendSets as set (set.name)} +
    +
    +

    {set.name}

    + {set.policy} + {@render healthBadge(health[set.name], 'lb-set-health')} +
    +

    Health check: {set.healthChecker.protocol}{set.healthChecker.urlPath ? ` ${set.healthChecker.urlPath}` : ''}{set.healthChecker.port ? ` port ${set.healthChecker.port}` : ' backend port'}, every {set.healthChecker.intervalMillis} ms, timeout {set.healthChecker.timeoutMillis} ms, {set.healthChecker.retries} retries, expect {set.healthChecker.returnCode}

    +
    + + + + {#each set.backends as b (b.name)} + + + + + + + + {:else} + + {/each} + +
    BackendHealthWeightStateActions
    {b.name}{@render healthBadge(health[backendKey(set.name, b.name)], 'lb-backend-health')} + + (weights[backendKey(set.name, b.name)] = e.currentTarget.value)} data-testid="lb-backend-weight" aria-label="Weight of {b.name}" /> + + + {[b.offline ? 'offline' : '', b.drain ? 'draining' : '', b.backup ? 'backup' : ''].filter(Boolean).join(', ') || 'in rotation'} + + + +
    No backends
    +
    + {#if newBackend[set.name]} + {@const d = newBackend[set.name]} +
    + + + + +
    + {/if} +
    + {:else} +

    No backend sets

    + {/each} + {:else if tab === 'routing'} +

    Path route sets

    + {#each current.pathRouteSets as s (s.name)} +
    + + + + {#each s.routes as r, i (i)}{/each} + +
    {s.name}: pathMatchBackend set
    {r.path}{r.matchType}{r.backendSet}
    +
    + {:else} +

    No path route sets

    + {/each} +

    Routing policies

    + {#each current.routingPolicies as p (p.name)} +
    + + + + {#each p.rules as r (r.name)}{/each} + +
    {p.name}: ruleConditionBackend sets
    {r.name}{r.condition}{r.backendSets.join(', ')}
    +
    + {:else} +

    No routing policies

    + {/each} + {:else if tab === 'rules'} + {#each current.ruleSets as s (s.name)} +
    +

    {s.name}

    +
      {#each s.items as i, n (n)}
    • {i}
    • {/each}
    +
    + {:else} +

    No rule sets

    + {/each} + {:else if tab === 'certificates'} + {#if current.certificates.length === 0} +

    No certificates

    + {:else} +
    + + + + {#each current.certificates as c (c.name)} + + {/each} + +
    CertificateSubjectsIssuerExpires
    {c.name}{c.subjects.join(', ') || '—'}{c.issuer || '—'}{c.notAfter || '—'}
    +
    +

    Only subjects and expiry are shown; certificate bodies and private keys never leave the emulator.

    + {/if} + {:else if tab === 'requests'} +
    + + + + {#each requests as r (r.id)} + + {:else} + + {/each} + +
    TypeStateAcceptedMessage
    {r.type}{r.state}{r.accepted}{r.message || '—'}
    No work requests
    +
    + {:else} +
    + {#if !isServing(current)}

    Load balancer is {current.state}; requests are served once it is ACTIVE.

    {/if} + {#if tryable.length === 0} +

    No serving plain-text listener to send to.

    + {:else} +

    Sent in-process to the loopback address of the chosen listener; the destination cannot be changed.

    +
    + + + + + +
    + + + + {/if} + {#if tryError}{/if} + {#if result} +
    +

    Status {result.status}{#if result.truncated} (body truncated){/if}

    +
    + {#each Object.entries(result.headers) as [k, v] (k)}
    {k}
    {v}
    {/each} +
    +
    {prettyBody(result)}
    +
    + {/if} +
    + {/if} +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/LoggingDashboard.svelte b/ui/src/lib/components/oci/LoggingDashboard.svelte new file mode 100644 index 0000000000..5a2f2072e9 --- /dev/null +++ b/ui/src/lib/components/oci/LoggingDashboard.svelte @@ -0,0 +1,263 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No log groups yet. Create one with the OCI SDK or CLI.

    + {:else} + + {#if error}{/if} + + (tab = id)} color="red" /> + + {#if tab === 'groups'} +
    + + + + {#each groups as g (g.id)} + + + + + + + {:else} + + {/each} + +
    Log groupStateDescriptionCreated
    {g.state}{g.description || '—'}{g.created}
    No log groups in this compartment
    +
    + + {#if selected} + {@const group = groups.find((g) => g.id === selected)} +
    +

    Logs in {group?.name}

    + {#if logsError}{/if} + {#if logs.length === 0} +

    This log group has no logs

    + {:else} + + + + {#each logs as l (l.id)} + + + + + + + + + {/each} + +
    LogTypeStateEnabledRetentionActions
    {l.name}{l.type}{l.state}{l.enabled ? 'Enabled' : 'Disabled'}{l.retentionDays} days + + {#if group}{/if} +
    + {/if} +
    + {/if} + {:else} +
    + + +
    + + + {#if searching}{/if} +
    + {#if searchError}{/if} + {#if result} +

    {result.count} result{result.count === 1 ? '' : 's'}{result.more ? ' (more available, narrow the query)' : ''}

    + {#if result.rows.length === 0} +

    No matching log entries

    + {:else} +
    + + {#each result.columns as c (c)}{/each} + + {#each result.rows as row, i (i)} + {#each result.columns as c (c)}{/each} + {/each} + +
    {c}
    {row[c]}
    +
    + {/if} + {/if} + +
    +

    Ingest a test entry

    +

    Pick a log with its Search button on the Log groups tab, or choose one here.

    +
    + + + +
    +
    +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/MonitoringDashboard.svelte b/ui/src/lib/components/oci/MonitoringDashboard.svelte new file mode 100644 index 0000000000..8abe30e2f5 --- /dev/null +++ b/ui/src/lib/components/oci/MonitoringDashboard.svelte @@ -0,0 +1,233 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No metrics or alarms yet. Post metric data or create an alarm with the OCI SDK or CLI.

    + {:else} + + {#if error}{/if} + + (tab = id)} color="red" /> + + {#if tab === 'metrics'} +
    +
    + + +
    + {#if shown} +
    + {#each shown.metrics as m (m)} + + {/each} +
    + {:else} +

    No metrics in this compartment

    + {/if} + + + +
    + + + {#if querying}{/if} +
    + {#if queryError}{/if} + {#if series} + {#if series.length === 0} +

    No datapoints in this range

    + {:else} + + {/if} + {/if} +
    + {:else} +
    + + + + {#each alarms as a (a.id)} + + + + + + + + + {:else} + + {/each} + +
    AlarmStatusSeverityQueryEnabledLast triggered
    {a.status || a.state || '—'}{a.severity}{a.query}{a.enabled ? 'Yes' : 'No'}{a.triggered || '—'}
    No alarms in this compartment
    +
    + {#if selectedAlarm} +
    +

    Alarm history

    + {#if historyError}{/if} + {#if history.length === 0} +

    No history yet

    + {:else} + + + {#each history as h (h.timestamp + h.summary)} + + {/each} + +
    {h.timestamp}{h.summary}
    + {/if} +
    + {/if} + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/MySQLDashboard.svelte b/ui/src/lib/components/oci/MySQLDashboard.svelte new file mode 100644 index 0000000000..402c6115c8 --- /dev/null +++ b/ui/src/lib/components/oci/MySQLDashboard.svelte @@ -0,0 +1,250 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No DB systems yet. Create one with the OCI SDK or CLI.

    + {:else} + + {#if error}{/if} + +
    + + + + + + {#each systems as s (s.id)} + {@const a = allowedActions(s)} + + + + + + + + + {:else} + + {/each} + +
    DB systemStateVersionShapeEndpointActions
    {s.state || '—'}{s.version || '—'}{s.shape || '—'}{mysqlEndpoint(s) || '—'} + + + + +
    No DB systems in this compartment
    +
    + + {#if detailError}{/if} + + {#if detail} +
    +

    {detail.name}

    +
    +
    OCID
    {detail.id}
    +
    MySQL version
    {detail.version}
    +
    Shape
    {detail.shape}
    +
    Endpoint
    {mysqlEndpoint(detail) || '—'}{detail.portX ? ` (X protocol ${detail.portX})` : ''}
    +
    Storage
    {detail.storageGb} GB{detail.autoExpand ? `, auto-expand up to ${detail.maxStorageGb} GB` : ''}
    +
    High availability
    {detail.highlyAvailable ? 'Yes' : 'No'}
    +
    Backup policy
    {detail.backupEnabled ? `Enabled, ${detail.backupRetentionDays} days retention, window ${detail.backupWindow}${detail.pitr ? ', point-in-time recovery' : ''}` : 'Disabled'}
    +
    Delete protection
    {detail.deleteProtected ? 'On' : 'Off'}{detail.finalBackup ? ` (final backup: ${detail.finalBackup})` : ''}
    +
    Placement
    {detail.availabilityDomain || '—'} / {detail.faultDomain || '—'}
    +
    Mode
    {detail.databaseMode} / {detail.accessMode}
    +
    Configuration
    {shortOcid(detail.configurationId)}
    +
    Admin password
    Never shown. It is set when the DB system is created.
    +
    +
    + {/if} + + (tab = id)} color="red" /> + + {#if tab === 'backups'} + {#if selected} +
    + + +
    + {:else} +

    Select a DB system to create a backup. Showing all backups in the compartment.

    + {/if} + {#if backups.length === 0} +

    No backups

    + {:else} +
    + + + + {#each backups as b (b.id)} + + + + + + + + + + {/each} + +
    BackupStateTypeCreationSizeRetentionCreated
    {b.name}{b.state}{b.type}{b.creation}{b.sizeGb} GB{b.retentionDays} days{b.created}
    +
    + {/if} + {:else if configurations.length === 0} +

    No configurations

    + {:else} +
    + + + + {#each configurations as c (c.id)} + + + + + + + + {/each} + +
    ConfigurationTypeShapeStateDescription
    {c.name}{c.type}{c.shape}{c.state}{c.description || '—'}
    +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/NoSQLDashboard.svelte b/ui/src/lib/components/oci/NoSQLDashboard.svelte new file mode 100644 index 0000000000..458cf13f6e --- /dev/null +++ b/ui/src/lib/components/oci/NoSQLDashboard.svelte @@ -0,0 +1,422 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No tables yet. Create one with the OCI SDK or CLI.

    + {:else} +
    + + +
    + + {#if showCreate} +
    { e.preventDefault(); void create(); }} data-testid="nosql-create"> + +
    + + {#if mode === 'PROVISIONED'} + + + {/if} + + +
    +
    + {/if} + + {#if error}{/if} + +
    + + + + + + {#each tables as t (t.id)} + + + + + + + + + + {:else} + + {/each} + +
    TableStateCapacityLimitsStorageCreatedActions
    {t.state || '—'}{t.mode || '—'}{t.mode === 'ON_DEMAND' ? '—' : `${t.readUnits} RU / ${t.writeUnits} WU`}{t.storageGb} GB{t.created}
    No tables in this compartment
    +
    + + {#if detailError}{/if} + + (tab = id)} color="red" /> + + {#if tab === 'query'} +
    + + + {#if queryError}{/if} + {#if queryResult} +
    + {queryResult.rows.length} row{queryResult.rows.length === 1 ? '' : 's'} on page {queryIdx + 1}, {queryResult.readUnits} read unit{queryResult.readUnits === 1 ? '' : 's'} + + +
    + {#if queryResult.rows.length === 0} +

    No rows

    + {:else} +
    + + {#each queryResult.columns as c (c)}{/each} + + {#each queryResult.rows as r, i (i)} + {#each queryResult.columns as c (c)}{/each} + {/each} + +
    {c}
    {cellText(r[c])}
    +
    + {/if} + {/if} +
    + {:else if !detail} +

    Select a table to see its {tab}.

    + {:else if tab === 'schema'} +
    +

    {detail.name}

    +
    +
    OCID
    {detail.id}
    +
    Capacity mode
    {detail.mode}
    +
    Limits
    {detail.mode === 'ON_DEMAND' ? 'On demand' : `${detail.readUnits} read units, ${detail.writeUnits} write units`}, {detail.storageGb} GB
    +
    Time to live
    {detail.ttlDays ? `${detail.ttlDays} days` : 'None'}
    +
    Primary key
    {detail.primaryKey.join(', ') || '—'}
    +
    Shard key
    {detail.shardKey.join(', ') || '—'}
    +
    +
    +

    DDL

    +
    {detail.ddl}
    +
    +
    + + + + {#each detail.columns as c (c.name)} + + + + + + + + {/each} + +
    ColumnTypeNullableKeyDefault
    {c.name}{c.type}{c.generated ? ' (generated)' : ''}{c.nullable ? 'Yes' : 'No'}{detail.primaryKey.includes(c.name) ? 'Primary key' : ''}{c.defaultValue || '—'}
    +
    +
    + {:else if tab === 'indexes'} + {#if indexes.length === 0} +

    No indexes

    + {:else} +
    + + + + {#each indexes as i (i.name)} + + {/each} + +
    IndexStateKeys
    {i.name}{i.state}{i.keys.join(', ')}
    +
    + {/if} + {:else if tab === 'rows'} +
    + {#if !rowsUsable(detail)}

    Table is {detail.state}; rows are available once it is ACTIVE.

    {/if} +
    + {#each detail.primaryKey as k (k)} + + {/each} + + +
    + + + {#if rowMessage}

    {rowMessage}

    {/if} + {#if rowError}{/if} +
    + {:else} + {#if usage.length === 0} +

    No usage recorded in the last hour

    + {:else} +
    + + + + {#each usage as u (u.started)} + + {/each} + +
    Period startSecondsRead unitsWrite unitsStorage (GB)
    {u.started}{u.seconds}{u.readUnits}{u.writeUnits}{u.storageGb}
    +
    + {/if} + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/NotificationsDashboard.svelte b/ui/src/lib/components/oci/NotificationsDashboard.svelte new file mode 100644 index 0000000000..c231dfdadb --- /dev/null +++ b/ui/src/lib/components/oci/NotificationsDashboard.svelte @@ -0,0 +1,207 @@ + + +{#snippet subsTable(items: Subscription[], testId: string)} +
    + + + + {#each items as s (s.id)} + + + + + + + + {/each} + +
    TopicProtocolEndpointStateActions
    {topicName(s.topicId)}{s.protocol}{s.endpoint}{s.state} + {#if s.state === 'PENDING'}{/if} +
    +
    +{/snippet} + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No topics yet. Create one with the OCI SDK or CLI.

    + {:else} + + (tab = id)} color="red" /> + {#if error}{/if} + + {#if tab === 'topics'} + {#if topics.length === 0} +

    No topics in this compartment

    + {:else} +
    + + + + {#each topics as t (t.id)} + + + + + + + + {#if expanded === t.id} + + + + {/if} + {/each} + +
    TopicDescriptionStateSubscriptionsActions
    {t.name}{t.description || '—'}{t.state}{subsOf(t).length}
    + {#if subsOf(t).length === 0} +

    No subscriptions

    + {:else} + {@render subsTable(subsOf(t), 'ocinotif-topic-subscriptions')} + {/if} +
    +
    + {/if} + {:else if tab === 'subscriptions'} + {#if subs.length === 0} +

    No subscriptions in this compartment

    + {:else} + {@render subsTable(subs, 'ocinotif-subscriptions')} + {/if} + {:else if visibleDeliveries.length === 0} +

    No deliveries recorded

    + {:else} +
    + + + + {#each visibleDeliveries as d (d.id)} + + + + + + + + + + {/each} + +
    PublishedKindTopicEndpointStatusMessageLinks
    {d.published}{d.kind}{d.topicName}{d.protocol}: {d.endpoint}{d.status}{d.title} {d.body} + {#if d.confirmationUrl && isHttpUrl(d.confirmationUrl)} + Confirmation link + + {/if} +
    +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/components/oci/OciSeriesChart.svelte b/ui/src/lib/components/oci/OciSeriesChart.svelte new file mode 100644 index 0000000000..36c131c2d2 --- /dev/null +++ b/ui/src/lib/components/oci/OciSeriesChart.svelte @@ -0,0 +1,30 @@ + + +
    + {#each series as s, i (i)} +
    +

    {seriesLabel(s)} ({s.resolution || 'raw'}, {s.points.length} points)

    + {#if s.points.length > 0} + + + + + + {#each s.points.toReversed().slice(0, 8) as p (p.t)} + + {/each} + +
    {new Date(p.t).toISOString()}{p.v}
    + {/if} +
    + {/each} +
    diff --git a/ui/src/lib/components/oci/QueueDashboard.svelte b/ui/src/lib/components/oci/QueueDashboard.svelte new file mode 100644 index 0000000000..6f50801343 --- /dev/null +++ b/ui/src/lib/components/oci/QueueDashboard.svelte @@ -0,0 +1,211 @@ + + +
    + + + {#if error && compartments.length === 0} + + {:else if loading} +
    + {:else if compartments.length === 0} +

    No queues yet. Create one with the OCI SDK or CLI.

    + {:else} + + {#if error}{/if} + +
    + + + + + + {#each queues as q (q.id)} + {@const s = stats[q.id]} + + + + + + + + + + {:else} + + {/each} + +
    QueueStateVisibleIn flightDead letterSizeActions
    {q.state || '—'}{s ? s.queue.visible : '—'}{s ? s.queue.inFlight : '—'}{s ? s.dlq.visible + s.dlq.inFlight : '—'}{s ? formatBytes(s.queue.sizeBytes + s.dlq.sizeBytes) : '—'} + + +
    No queues in this compartment
    +
    + + {#if selected} +
    + {#if detailError}{/if} + {#if detail} +

    {detail.name}

    +
    +
    OCID
    {detail.id}
    +
    Retention
    {formatSeconds(detail.retention)}
    +
    Visibility timeout
    {formatSeconds(detail.visibility)}
    +
    Long-poll timeout
    {formatSeconds(detail.timeout)}
    +
    Dead letter after
    {detail.dlqDeliveryCount ? `${detail.dlqDeliveryCount} deliveries` : 'disabled'}
    +
    + {/if} +
    +

    {showDlq ? 'Dead letter messages' : 'Messages'} (peek, up to 20; nothing is received or delivered)

    + +
    + {#if detailLoading && messages.length === 0} + + {:else if messages.length === 0} +

    No messages

    + {:else} +
    + + + + {#each messages as m (m.id)} + + + + + + + + + + {/each} + +
    IDContentStateDeliveriesChannelCreatedExpires
    {m.id}{m.content}{m.inFlight ? 'in flight' : 'visible'}{m.deliveryCount}{m.channel ? shortOcid(m.channel) : '—'}{m.created}{m.expireAfter}
    +
    + {/if} +
    + {/if} + {/if} +
    diff --git a/ui/src/lib/gcpapigateway.test.ts b/ui/src/lib/gcpapigateway.test.ts new file mode 100644 index 0000000000..698a2bce05 --- /dev/null +++ b/ui/src/lib/gcpapigateway.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + GW_PROXY_BASE, + createGateway, + deleteGateway, + diagnosticsFor, + getConfig, + isActive, + listApis, + listConfigs, + listGateways, + listOperations, + listProjects, + parseConfig, + parseGateway, + parseOperation, + tryGateway, + validGatewayId, +} from "./gcpapigateway"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const urlOf = (f: { mock: { calls: unknown[][] } }, i: number): string => + String((f.mock.calls[i] ?? [])[0]); +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; + +const cfgName = "projects/p/locations/global/apis/orders/configs/good"; +const gwName = "projects/p/locations/us-central1/gateways/edge"; + +describe("parsing", () => { + it("decodes the OpenAPI documents of a config", () => { + const cfg = parseConfig({ + name: cfgName, + state: "ACTIVE", + serviceConfigId: "svc-1", + openapiDocuments: [ + { document: { path: "openapi.yaml", contents: btoa("swagger: '2.0'") } }, + { document: { path: "bin", contents: btoa("\u00FF\u00FE") } }, + ], + }); + expect(cfg).toMatchObject({ id: "good", api: "projects/p/locations/global/apis/orders" }); + expect(cfg.documents[0]).toEqual({ + path: "openapi.yaml", + contents: "swagger: '2.0'", + decoded: true, + }); + expect(cfg.documents[1].decoded).toBe(false); + }); + + it("parses a gateway and its region", () => { + expect( + parseGateway({ name: gwName, state: "ACTIVE", defaultHostname: "edge-x.uc.gateway.dev" }), + ).toMatchObject({ + id: "edge", + location: "us-central1", + defaultHostname: "edge-x.uc.gateway.dev", + }); + expect(isActive({ state: "ACTIVE" })).toBe(true); + expect(isActive({ state: "CREATING" })).toBe(false); + }); + + it("derives operation status", () => { + const base = { + name: "projects/p/locations/global/operations/o1", + metadata: { verb: "create" }, + }; + expect(parseOperation(base).status).toBe("RUNNING"); + expect(parseOperation({ ...base, done: true }).status).toBe("SUCCEEDED"); + const failed = parseOperation({ + ...base, + done: true, + error: { code: 3, message: "bad spec" }, + metadata: { verb: "create", target: cfgName, diagnostics: [{ location: "a", message: "m" }] }, + }); + expect(failed).toMatchObject({ status: "FAILED", statusMessage: "bad spec" }); + expect(failed.diagnostics).toEqual([{ location: "a", message: "m" }]); + }); + + it("finds the newest diagnostics for a target", () => { + const op = (id: string, createTime: string, message: string) => + parseOperation({ + name: `projects/p/locations/global/operations/${id}`, + metadata: { target: cfgName, createTime, diagnostics: [{ location: "l", message }] }, + }); + const ops = [op("a", "2026-01-01", "old"), op("b", "2026-02-01", "new")]; + expect(diagnosticsFor(cfgName, ops)[0].message).toBe("new"); + expect(diagnosticsFor("other", ops)).toEqual([]); + }); + + it("validates gateway ids", () => { + expect(validGatewayId("edge-1")).toBe(true); + expect(validGatewayId("Edge")).toBe(false); + expect(validGatewayId("1edge")).toBe(false); + expect(validGatewayId("")).toBe(false); + }); +}); + +describe("requests", () => { + it("lists projects, APIs, configs and gateways", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json({ projects: ["p"] })) + .mockResolvedValueOnce(json({ apis: [{ name: "projects/p/locations/global/apis/orders" }] })) + .mockResolvedValueOnce(json({ apiConfigs: [{ name: cfgName, state: "FAILED" }] })) + .mockResolvedValueOnce(json({ gateways: [{ name: gwName }] })); + expect(await listProjects(asFetch(f))).toEqual(["p"]); + expect((await listApis("p", asFetch(f)))[0].id).toBe("orders"); + expect( + (await listConfigs("projects/p/locations/global/apis/orders", asFetch(f)))[0].state, + ).toBe("FAILED"); + expect((await listGateways("p", asFetch(f)))[0].id).toBe("edge"); + expect(urlOf(f, 0)).toBe(`${GW_PROXY_BASE}/projects`); + expect(urlOf(f, 1)).toBe(`${GW_PROXY_BASE}/projects/p/locations/global/apis`); + expect(urlOf(f, 2)).toBe(`${GW_PROXY_BASE}/projects/p/locations/global/apis/orders/configs`); + expect(urlOf(f, 3)).toBe(`${GW_PROXY_BASE}/projects/p/locations/-/gateways`); + }); + + it("fetches a full config", async () => { + const f = vi.fn().mockResolvedValue(json({ name: cfgName })); + await getConfig(cfgName, asFetch(f)); + expect(urlOf(f, 0)).toBe(`${GW_PROXY_BASE}/${cfgName}`); + }); + + it("creates and deletes a gateway", async () => { + const f = vi.fn().mockImplementation(() => Promise.resolve(json({ name: "operations/x" }))); + await createGateway("p", "us-central1", "edge", " Edge ", cfgName, asFetch(f)); + expect(urlOf(f, 0)).toBe( + `${GW_PROXY_BASE}/projects/p/locations/us-central1/gateways?gatewayId=edge`, + ); + expect(initOf(f, 0).method).toBe("POST"); + expect(JSON.parse(String(initOf(f, 0).body))).toEqual({ + displayName: "Edge", + apiConfig: cfgName, + }); + await deleteGateway(gwName, asFetch(f)); + expect(urlOf(f, 1)).toBe(`${GW_PROXY_BASE}/${gwName}`); + expect(initOf(f, 1).method).toBe("DELETE"); + }); + + it("lists operations of global and every region, newest first", async () => { + const f = vi.fn().mockImplementation((u: string) => + Promise.resolve( + json({ + operations: [ + { + name: `${u.includes("global") ? "g" : "r"}`, + metadata: { createTime: u.includes("global") ? "2026-01-01" : "2026-03-01" }, + }, + ], + }), + ), + ); + const ops = await listOperations("p", ["us-central1", "us-central1"], asFetch(f)); + expect(f).toHaveBeenCalledTimes(2); + expect(ops.map((o) => o.name)).toEqual(["r", "g"]); + }); + + it("sends try-it requests to the selected gateway with the key in the body only", async () => { + const f = vi + .fn() + .mockResolvedValue(json({ status: 200, headers: {}, body: "ok", encoding: "utf8" })); + const res = await tryGateway( + gwName, + { + method: "GET", + path: "/open", + query: "?a=1", + body: "", + headers: [{ name: " ", value: "x" }], + apiKey: "k", + }, + asFetch(f), + ); + expect(res.status).toBe(200); + expect(urlOf(f, 0)).toBe(`${GW_PROXY_BASE}/${gwName}:invoke`); + expect(urlOf(f, 0)).not.toContain("k="); + expect(JSON.parse(String(initOf(f, 0).body))).toEqual({ + method: "GET", + path: "/open", + query: "a=1", + body: "", + headers: [], + apiKey: "k", + }); + }); + + it("omits an empty key", async () => { + const f = vi.fn().mockResolvedValue(json({ status: 200 })); + await tryGateway( + gwName, + { method: "GET", path: "/", query: "", body: "", headers: [], apiKey: "" }, + asFetch(f), + ); + expect(JSON.parse(String(initOf(f, 0).body))).not.toHaveProperty("apiKey"); + }); + + it("surfaces proxy errors", async () => { + const f = vi.fn().mockResolvedValue( + json( + { + error: { + code: 400, + message: "Gateway is in state CREATING", + status: "FAILED_PRECONDITION", + }, + }, + 400, + ), + ); + await expect( + tryGateway( + gwName, + { method: "GET", path: "/", query: "", body: "", headers: [], apiKey: "" }, + asFetch(f), + ), + ).rejects.toThrow(/CREATING/); + }); +}); diff --git a/ui/src/lib/gcpapigateway.ts b/ui/src/lib/gcpapigateway.ts new file mode 100644 index 0000000000..dc2f014857 --- /dev/null +++ b/ui/src/lib/gcpapigateway.ts @@ -0,0 +1,289 @@ +/** Client for the Google Cloud API Gateway emulator (APIs, configs, gateways, operations, data-plane try-it) via the dashboard's in-process proxy. */ + +import { + base64ToUtf8, + listAll, + restCall, + shortName, + str, + strMap, + type FetchFn, + type Json, +} from "./gcprest.ts"; +import type { TryResponse } from "./ociapigateway.ts"; +import { cleanQuery, readTryResponse, type HeaderPair } from "./tryit.ts"; + +export const GW_PROXY_BASE = "/dashboard/api/v1/gcp/apigateway/v1"; +export const GW_PROJECT_KEY = "gopherstack_apigateway_project"; +export const GW_REGIONS = [ + "us-central1", + "us-east1", + "us-east4", + "us-west2", + "us-west3", + "us-west4", + "europe-west1", + "europe-west2", + "asia-northeast1", + "australia-southeast1", +]; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +export type GwApi = { + name: string; + id: string; + displayName: string; + state: string; + managedService: string; + createTime: string; + labels: Record; +}; + +export type GwConfig = { + name: string; + id: string; + api: string; + displayName: string; + state: string; + serviceConfigId: string; + serviceAccount: string; + createTime: string; + documents: GwDocument[]; +}; + +export type GwDocument = { path: string; contents: string; decoded: boolean }; + +export type GwGateway = { + name: string; + id: string; + location: string; + displayName: string; + state: string; + defaultHostname: string; + apiConfig: string; + createTime: string; +}; + +export type GwDiagnostic = { location: string; message: string }; + +export type GwOperation = { + name: string; + id: string; + location: string; + verb: string; + target: string; + done: boolean; + status: "RUNNING" | "SUCCEEDED" | "FAILED"; + statusMessage: string; + createTime: string; + endTime: string; + diagnostics: GwDiagnostic[]; +}; + +export type GwTryRequest = { + method: string; + path: string; + query: string; + body: string; + headers: HeaderPair[]; + apiKey: string; +}; + +export const parseApi = (raw: Json): GwApi => ({ + name: str(raw.name), + id: shortName(str(raw.name)), + displayName: str(raw.displayName), + state: str(raw.state), + managedService: str(raw.managedService), + createTime: str(raw.createTime), + labels: strMap(raw.labels), +}); + +/** Decodes an OpenAPI document's base64 contents; undecodable bytes are kept encoded. */ +export function parseDocument(raw: Json): GwDocument { + const doc = obj(raw.document); + const contents = str(doc.contents); + const text = base64ToUtf8(contents); + return { path: str(doc.path), contents: text ?? contents, decoded: text !== null }; +} + +export function parseConfig(raw: Json): GwConfig { + const name = str(raw.name); + return { + name, + id: shortName(name), + api: name.split("/configs/")[0] ?? "", + displayName: str(raw.displayName), + state: str(raw.state), + serviceConfigId: str(raw.serviceConfigId), + serviceAccount: str(raw.gatewayServiceAccount), + createTime: str(raw.createTime), + documents: list(raw.openapiDocuments).map((d) => parseDocument(d)), + }; +} + +export const parseGateway = (raw: Json): GwGateway => { + const name = str(raw.name); + return { + name, + id: shortName(name), + location: name.split("/")[3] ?? "", + displayName: str(raw.displayName), + state: str(raw.state), + defaultHostname: str(raw.defaultHostname), + apiConfig: str(raw.apiConfig), + createTime: str(raw.createTime), + }; +}; + +export function parseOperation(raw: Json): GwOperation { + const name = str(raw.name); + const meta = obj(raw.metadata); + const done = raw.done === true; + const err = obj(raw.error); + const failed = done && Object.keys(err).length > 0; + return { + name, + id: shortName(name), + location: name.split("/")[3] ?? "", + verb: str(meta.verb), + target: str(meta.target), + done, + status: done ? (failed ? "FAILED" : "SUCCEEDED") : "RUNNING", + statusMessage: str(meta.statusMessage) || str(err.message), + createTime: str(meta.createTime), + endTime: str(meta.endTime), + diagnostics: list(meta.diagnostics).map((d) => ({ + location: str(d.location), + message: str(d.message), + })), + }; +} + +/** Diagnostics of the newest operation that targeted a resource, for a failed config. */ +export function diagnosticsFor(target: string, ops: GwOperation[]): GwDiagnostic[] { + const hits = ops + .filter((o) => o.target === target && o.diagnostics.length > 0) + .toSorted((a, b) => b.createTime.localeCompare(a.createTime)); + return hits[0]?.diagnostics ?? []; +} + +const parent = (project: string, location: string) => + `/projects/${enc(project)}/locations/${enc(location)}`; + +export async function listProjects(fetchFn: FetchFn = fetch): Promise { + const body = await restCall(GW_PROXY_BASE, "GET", "/projects", undefined, fetchFn); + return Array.isArray(body.projects) ? body.projects.map(String) : []; +} + +export async function listApis(project: string, fetchFn: FetchFn = fetch): Promise { + return (await listAll(GW_PROXY_BASE, `${parent(project, "global")}/apis`, "apis", fetchFn)).map( + (a) => parseApi(a), + ); +} + +export async function listConfigs(apiName: string, fetchFn: FetchFn = fetch): Promise { + return (await listAll(GW_PROXY_BASE, `/${apiName}/configs`, "apiConfigs", fetchFn)).map((c) => + parseConfig(c), + ); +} + +/** Full view of one config, including its decoded OpenAPI documents. */ +export async function getConfig(name: string, fetchFn: FetchFn = fetch): Promise { + return parseConfig(await restCall(GW_PROXY_BASE, "GET", `/${name}`, undefined, fetchFn)); +} + +export async function listGateways( + project: string, + fetchFn: FetchFn = fetch, +): Promise { + return ( + await listAll(GW_PROXY_BASE, `${parent(project, "-")}/gateways`, "gateways", fetchFn) + ).map((g) => parseGateway(g)); +} + +export async function createGateway( + project: string, + region: string, + id: string, + displayName: string, + apiConfig: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + GW_PROXY_BASE, + "POST", + `${parent(project, region)}/gateways?gatewayId=${enc(id)}`, + { displayName: displayName.trim() || undefined, apiConfig }, + fetchFn, + ); +} + +export async function deleteGateway(name: string, fetchFn: FetchFn = fetch): Promise { + await restCall(GW_PROXY_BASE, "DELETE", `/${name}`, undefined, fetchFn); +} + +/** Operations of the global location and of each given region, newest first. */ +export async function listOperations( + project: string, + regions: string[], + fetchFn: FetchFn = fetch, +): Promise { + const locations = ["global", ...new Set(regions)]; + const pages = await Promise.all( + locations.map((l) => + listAll(GW_PROXY_BASE, `${parent(project, l)}/operations`, "operations", fetchFn), + ), + ); + return pages + .flat() + .map((o) => parseOperation(o)) + .toSorted((a, b) => b.createTime.localeCompare(a.createTime)); +} + +/** Sends a request through the selected gateway's data plane; the proxy fixes the destination. */ +export async function tryGateway( + gatewayName: string, + req: GwTryRequest, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + GW_PROXY_BASE, + "POST", + `/${gatewayName}:invoke`, + { + method: req.method, + path: req.path, + query: cleanQuery(req.query), + body: req.body, + headers: req.headers.filter((h) => h.name.trim()), + apiKey: req.apiKey || undefined, + }, + fetchFn, + ); + return readTryResponse(body); +} + +export const isActive = (s: { state: string }): boolean => s.state === "ACTIVE"; + +/** Valid gateway and config ids: lowercase letters, digits and dashes, starting with a letter. */ +export const validGatewayId = (v: string): boolean => /^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$/.test(v); + +export function readStoredProject(): string { + try { + return localStorage.getItem(GW_PROJECT_KEY) ?? ""; + } catch { + return ""; + } +} + +export function writeStoredProject(project: string): void { + try { + localStorage.setItem(GW_PROJECT_KEY, project); + } catch { + // storage unavailable: the choice just is not remembered + } +} diff --git a/ui/src/lib/gcpbatch.test.ts b/ui/src/lib/gcpbatch.test.ts new file mode 100644 index 0000000000..132e0b2b3e --- /dev/null +++ b/ui/src/lib/gcpbatch.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BATCH_PROXY_BASE, + buildJobBody, + canCancel, + cancelJob, + createJob, + deleteJob, + formatDuration, + isSettled, + jobLogFilter, + listJobs, + listProjects, + listTasks, + parseJob, + parseTask, + statePill, + validEnvName, + validJobId, +} from "./gcpbatch"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const urlOf = (f: { mock: { calls: unknown[][] } }, i: number): string => + String((f.mock.calls[i] ?? [])[0]); +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; + +const jobName = "projects/p/locations/us-central1/jobs/hello"; + +const rawJob = { + name: jobName, + createTime: "2026-01-01T00:00:00Z", + labels: { team: "data" }, + taskGroups: [ + { + name: `${jobName}/taskGroups/group0`, + taskCount: "3", + parallelism: "2", + taskSpec: { + maxRetryCount: 1, + runnables: [ + { container: { imageUri: "alpine:3", commands: ["echo", "hi"] } }, + { script: { text: "echo script" }, environment: { variables: { X: "1" } } }, + ], + environment: { + variables: { A: "b" }, + secretVariables: { TOKEN: "projects/p/secrets/s/versions/1" }, + }, + }, + }, + ], + status: { + state: "RUNNING", + runDuration: "75s", + statusEvents: [{ type: "STATUS_CHANGED", description: "Job state is set", eventTime: "t1" }], + taskGroups: { group0: { counts: { RUNNING: "2", PENDING: "1" } } }, + }, +}; + +describe("parsing", () => { + it("reads a job with its task group and runnables", () => { + const job = parseJob(rawJob); + expect(job).toMatchObject({ + id: "hello", + project: "p", + location: "us-central1", + state: "RUNNING", + taskCount: 3, + counts: { RUNNING: 2, PENDING: 1 }, + labels: { team: "data" }, + }); + const g = job.groups[0]; + expect(g).toMatchObject({ taskCount: 3, parallelism: 2, maxRetryCount: 1 }); + expect(g.runnables[0]).toMatchObject({ kind: "container", image: "alpine:3" }); + expect(g.runnables[1]).toMatchObject({ kind: "script", scriptText: "echo script" }); + }); + + it("keeps secret variable names but never their references", () => { + const g = parseJob(rawJob).groups[0]; + expect(g.secretNames).toEqual(["TOKEN"]); + expect(JSON.stringify(g)).not.toContain("secrets/s"); + }); + + it("derives exit code and attempts of a task", () => { + const t = parseTask({ + name: `${jobName}/taskGroups/group0/tasks/1`, + status: { + state: "SUCCEEDED", + statusEvents: [ + { type: "STATUS_CHANGED", description: "Task state is updated from ASSIGNED to RUNNING" }, + { type: "TASK_RETRY", description: "retry" }, + { type: "STATUS_CHANGED", description: "to SUCCEEDED", taskExecution: { exitCode: 0 } }, + ], + }, + }); + expect(t).toMatchObject({ index: "1", state: "SUCCEEDED", exitCode: 0, attempts: 2 }); + }); + + it("reads a zero exit code from an empty taskExecution", () => { + const t = parseTask({ + name: "x/0", + status: { state: "SUCCEEDED", statusEvents: [{ type: "STATUS_CHANGED", taskExecution: {} }] }, + }); + expect(t.exitCode).toBe(0); + }); + + it("leaves the exit code empty before a task ends", () => { + expect(parseTask({ name: "x/0", status: { state: "PENDING" } })).toMatchObject({ + exitCode: null, + attempts: 0, + }); + }); +}); + +describe("helpers", () => { + it("validates ids and variable names", () => { + expect(validJobId("my-job1")).toBe(true); + expect(validJobId("My")).toBe(false); + expect(validJobId("1a")).toBe(false); + expect(validEnvName("A_1")).toBe(true); + expect(validEnvName("1A")).toBe(false); + }); + + it("classifies states", () => { + expect(canCancel({ state: "RUNNING" })).toBe(true); + expect(canCancel({ state: "SUCCEEDED" })).toBe(false); + expect(isSettled({ state: "CANCELLATION_IN_PROGRESS" })).toBe(false); + expect(isSettled({ state: "FAILED" })).toBe(true); + expect(statePill("SUCCEEDED")).toContain("green"); + expect(statePill("FAILED")).toContain("red"); + }); + + it("formats durations", () => { + expect(formatDuration("12.5s")).toBe("12.5s"); + expect(formatDuration("75s")).toBe("1m 15s"); + expect(formatDuration("")).toBe("—"); + }); + + it("builds the log filter", () => { + expect(jobLogFilter("p", "hello")).toBe( + 'logName="projects/p/logs/batch_task_logs" AND resource.labels.job="hello"', + ); + }); + + it("builds a body from fixed fields only", () => { + const body = buildJobBody({ + id: "j", + image: " alpine:3 ", + commands: "echo\n\nhi", + taskCount: 3, + parallelism: 2, + env: [ + { name: "A", value: "b" }, + { name: " ", value: "skip" }, + ], + }); + expect(body).toEqual({ + taskGroups: [ + { + taskCount: "3", + parallelism: "2", + taskSpec: { + runnables: [{ container: { imageUri: "alpine:3", commands: ["echo", "hi"] } }], + environment: { variables: { A: "b" } }, + }, + }, + ], + }); + }); +}); + +describe("calls", () => { + it("lists projects, jobs and tasks", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json({ projects: ["p"] })) + .mockResolvedValueOnce(json({ jobs: [rawJob] })) + .mockResolvedValueOnce(json({ tasks: [{ name: `${jobName}/taskGroups/group0/tasks/0` }] })); + expect(await listProjects(asFetch(f))).toEqual(["p"]); + expect((await listJobs("p", "-", asFetch(f)))[0].id).toBe("hello"); + expect(urlOf(f, 1)).toBe(`${BATCH_PROXY_BASE}/projects/p/locations/-/jobs`); + expect(await listTasks(jobName, asFetch(f))).toHaveLength(1); + expect(urlOf(f, 2)).toBe(`${BATCH_PROXY_BASE}/${jobName}/taskGroups/group0/tasks`); + }); + + it("creates, cancels and deletes", async () => { + const f = vi.fn().mockImplementation(() => Promise.resolve(json({}))); + await createJob( + "p", + "us-central1", + { id: "j", image: "alpine:3", commands: "", taskCount: 1, parallelism: 1, env: [] }, + asFetch(f), + ); + expect(urlOf(f, 0)).toBe(`${BATCH_PROXY_BASE}/projects/p/locations/us-central1/jobs?jobId=j`); + expect(initOf(f, 0).method).toBe("POST"); + await cancelJob(jobName, asFetch(f)); + expect(urlOf(f, 1)).toBe(`${BATCH_PROXY_BASE}/${jobName}:cancel`); + await deleteJob(jobName, asFetch(f)); + expect(initOf(f, 2).method).toBe("DELETE"); + }); + + it("surfaces proxy errors", async () => { + const f = vi + .fn() + .mockResolvedValue(json({ error: { message: "nope", status: "NOT_FOUND" } }, 404)); + await expect(deleteJob(jobName, asFetch(f))).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); diff --git a/ui/src/lib/gcpbatch.ts b/ui/src/lib/gcpbatch.ts new file mode 100644 index 0000000000..8b4eb752ce --- /dev/null +++ b/ui/src/lib/gcpbatch.ts @@ -0,0 +1,308 @@ +/** Client for the Google Cloud Batch emulator (jobs, task groups, tasks) via the dashboard's in-process proxy. */ + +import { listAll, restCall, shortName, str, strMap, type FetchFn, type Json } from "./gcprest.ts"; + +export const BATCH_PROXY_BASE = "/dashboard/api/v1/gcp/batch/v1"; +export const BATCH_PROJECT_KEY = "gopherstack_batch_project"; +export const BATCH_ALL_LOCATIONS = "-"; +export const BATCH_LOG_ID = "batch_task_logs"; +export const BATCH_LOCATIONS = [ + "us-central1", + "us-east1", + "us-east4", + "us-west1", + "us-west2", + "europe-west1", + "europe-west2", + "europe-west4", + "asia-northeast1", + "asia-southeast1", + "australia-southeast1", +]; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const num = (v: unknown): number => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +export type BatchEvent = { + type: string; + description: string; + time: string; + exitCode: number | null; +}; + +export type BatchRunnable = { + kind: "container" | "script" | "barrier"; + image: string; + commands: string[]; + scriptText: string; + scriptPath: string; + background: boolean; + alwaysRun: boolean; + variables: Record; + secretNames: string[]; +}; + +export type BatchTaskGroup = { + name: string; + taskCount: number; + parallelism: number; + maxRetryCount: number; + maxRunDuration: string; + runnables: BatchRunnable[]; + variables: Record; + secretNames: string[]; +}; + +export type BatchJob = { + name: string; + id: string; + project: string; + location: string; + state: string; + createTime: string; + runDuration: string; + labels: Record; + taskCount: number; + counts: Record; + events: BatchEvent[]; + groups: BatchTaskGroup[]; +}; + +export type BatchTask = { + name: string; + index: string; + state: string; + exitCode: number | null; + attempts: number; + events: BatchEvent[]; +}; + +export type NewJob = { + id: string; + image: string; + commands: string; + taskCount: number; + parallelism: number; + env: { name: string; value: string }[]; +}; + +export function parseEvent(raw: Json): BatchEvent { + const exec = obj(raw.taskExecution); + return { + type: str(raw.type), + description: str(raw.description), + time: str(raw.eventTime), + // protojson omits a zero exit code but keeps the empty taskExecution + exitCode: raw.taskExecution ? num(exec.exitCode) : null, + }; +} + +function parseRunnable(raw: Json): BatchRunnable { + const box = obj(raw.container); + const script = obj(raw.script); + const env = obj(raw.environment); + const kind = raw.container ? "container" : raw.script ? "script" : "barrier"; + return { + kind, + image: str(box.imageUri), + commands: Array.isArray(box.commands) ? box.commands.map(String) : [], + scriptText: str(script.text), + scriptPath: str(script.path), + background: raw.background === true, + alwaysRun: raw.alwaysRun === true, + variables: strMap(env.variables), + secretNames: secretNames(env.secretVariables), + }; +} + +/** Variable names only: a secret variable's value never reaches the page. */ +export function secretNames(v: unknown): string[] { + return Object.keys(obj(v)).toSorted(); +} + +function parseGroup(raw: Json): BatchTaskGroup { + const spec = obj(raw.taskSpec); + const env = obj(spec.environment); + return { + name: str(raw.name), + taskCount: num(raw.taskCount), + parallelism: num(raw.parallelism), + maxRetryCount: num(spec.maxRetryCount), + maxRunDuration: str(spec.maxRunDuration), + runnables: list(spec.runnables).map((r) => parseRunnable(r)), + variables: strMap(env.variables), + secretNames: secretNames(env.secretVariables), + }; +} + +export function parseJob(raw: Json): BatchJob { + const name = str(raw.name); + const parts = name.split("/"); + const status = obj(raw.status); + const groups = list(raw.taskGroups).map((g) => parseGroup(g)); + const counts: Record = {}; + for (const g of Object.values(obj(status.taskGroups))) { + for (const [k, v] of Object.entries(obj(obj(g).counts))) counts[k] = (counts[k] ?? 0) + num(v); + } + return { + name, + id: shortName(name), + project: parts[1] ?? "", + location: parts[3] ?? "", + state: str(status.state), + createTime: str(raw.createTime), + runDuration: str(status.runDuration), + labels: strMap(raw.labels), + taskCount: groups.reduce((n, g) => n + g.taskCount, 0), + counts, + events: list(status.statusEvents).map((e) => parseEvent(e)), + groups, + }; +} + +export function parseTask(raw: Json): BatchTask { + const name = str(raw.name); + const status = obj(raw.status); + const events = list(status.statusEvents).map((e) => parseEvent(e)); + const exit = events.findLast((e) => e.exitCode !== null)?.exitCode ?? null; + const retries = events.filter((e) => e.type === "TASK_RETRY").length; + const started = events.some((e) => e.description.endsWith("to RUNNING")); + return { + name, + index: shortName(name), + state: str(status.state), + exitCode: exit, + attempts: started ? retries + 1 : retries, + events, + }; +} + +const parent = (project: string, location: string) => + `/projects/${enc(project)}/locations/${enc(location)}`; + +export async function listProjects(fetchFn: FetchFn = fetch): Promise { + const body = await restCall(BATCH_PROXY_BASE, "GET", "/projects", undefined, fetchFn); + return Array.isArray(body.projects) ? body.projects.map(String) : []; +} + +export async function listJobs( + project: string, + location: string, + fetchFn: FetchFn = fetch, +): Promise { + return ( + await listAll(BATCH_PROXY_BASE, `${parent(project, location)}/jobs`, "jobs", fetchFn) + ).map((j) => parseJob(j)); +} + +export async function getJob(name: string, fetchFn: FetchFn = fetch): Promise { + return parseJob(await restCall(BATCH_PROXY_BASE, "GET", `/${name}`, undefined, fetchFn)); +} + +export async function listTasks(jobName: string, fetchFn: FetchFn = fetch): Promise { + const tasks = await listAll( + BATCH_PROXY_BASE, + `/${jobName}/taskGroups/group0/tasks`, + "tasks", + fetchFn, + ); + return tasks.map((t) => parseTask(t)); +} + +export function buildJobBody(job: NewJob): Json { + const variables = Object.fromEntries( + job.env.filter((e) => e.name.trim()).map((e) => [e.name.trim(), e.value]), + ); + const commands = job.commands.split("\n").filter((c) => c.trim() !== ""); + return { + taskGroups: [ + { + taskCount: String(job.taskCount), + parallelism: String(job.parallelism), + taskSpec: { + runnables: [{ container: { imageUri: job.image.trim(), commands } }], + environment: { variables }, + }, + }, + ], + }; +} + +export async function createJob( + project: string, + location: string, + job: NewJob, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + BATCH_PROXY_BASE, + "POST", + `${parent(project, location)}/jobs?jobId=${enc(job.id)}`, + buildJobBody(job), + fetchFn, + ); +} + +export async function cancelJob(name: string, fetchFn: FetchFn = fetch): Promise { + await restCall(BATCH_PROXY_BASE, "POST", `/${name}:cancel`, {}, fetchFn); +} + +export async function deleteJob(name: string, fetchFn: FetchFn = fetch): Promise { + await restCall(BATCH_PROXY_BASE, "DELETE", `/${name}`, undefined, fetchFn); +} + +/** Valid job ids: lowercase letters, digits and dashes, starting with a letter. */ +export const validJobId = (v: string): boolean => /^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$/.test(v); + +export const validEnvName = (v: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(v); + +const ACTIVE = new Set(["QUEUED", "SCHEDULED", "RUNNING"]); +const BUSY = new Set([...ACTIVE, "CANCELLATION_IN_PROGRESS", "DELETION_IN_PROGRESS"]); + +export const canCancel = (j: { state: string }): boolean => ACTIVE.has(j.state); +export const isSettled = (j: { state: string }): boolean => !BUSY.has(j.state); + +/** Tailwind classes for a job or task state badge. */ +export function statePill(s: string): string { + if (s === "SUCCEEDED") + return "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300"; + if (s === "FAILED" || s === "CANCELLED") + return "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300"; + if (s === "RUNNING") return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"; + return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"; +} + +/** Seconds of a protobuf Duration ("12.5s") as a short label. */ +export function formatDuration(d: string): string { + if (!d.endsWith("s")) return "—"; + const s = Number(d.slice(0, -1)); + if (!Number.isFinite(s)) return "—"; + if (s < 60) return `${Math.round(s * 10) / 10}s`; + const m = Math.floor(s / 60); + return `${m}m ${Math.round(s % 60)}s`; +} + +/** Cloud Logging filter for one job's task logs. */ +export const jobLogFilter = (project: string, job: string): string => + `logName="projects/${project}/logs/${BATCH_LOG_ID}" AND resource.labels.job="${job}"`; + +export function readStoredProject(): string { + try { + return localStorage.getItem(BATCH_PROJECT_KEY) ?? ""; + } catch { + return ""; + } +} + +export function writeStoredProject(project: string): void { + try { + localStorage.setItem(BATCH_PROJECT_KEY, project); + } catch { + // storage unavailable: the choice just is not remembered + } +} diff --git a/ui/src/lib/gcpcontainer.test.ts b/ui/src/lib/gcpcontainer.test.ts new file mode 100644 index 0000000000..b50be6ea2e --- /dev/null +++ b/ui/src/lib/gcpcontainer.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + GKE_PROXY_BASE, + deleteCluster, + getPoolSizes, + isBusy, + listClusters, + listOperations, + parseCluster, + parseNodePool, + releaseChannelLabel, + resizeNodePool, + validNodeCount, +} from "./gcpcontainer"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; + +const pool = (name: string, extra: Record = {}) => ({ + name, + status: "RUNNING", + version: "1.30.1-gke.100", + initialNodeCount: 3, + config: { machineType: "e2-standard-4", diskSizeGb: 100 }, + autoscaling: { enabled: true, minNodeCount: 1, maxNodeCount: 5 }, + locations: ["us-central1-a"], + ...extra, +}); +const cluster = (name: string, location = "us-central1-a") => ({ + name, + location, + status: "RUNNING", + currentMasterVersion: "1.30.1-gke.100", + endpoint: "34.1.2.3", + releaseChannel: { channel: "REGULAR" }, + network: "default", + subnetwork: "default", + currentNodeCount: 3, + createTime: "2026-10-01T00:00:00Z", + nodePools: [pool("default-pool")], +}); +const path = "projects/p/locations/us-central1-a/clusters/c1"; + +describe("parsing", () => { + it("parses a cluster with its pools", () => { + expect(parseCluster(cluster("c1"), "p")).toMatchObject({ + name: "c1", + path, + status: "RUNNING", + endpoint: "34.1.2.3", + releaseChannel: "REGULAR", + currentNodeCount: 3, + pools: [{ name: "default-pool", machineType: "e2-standard-4" }], + }); + }); + + it("parses autoscaling and defaults", () => { + expect(parseNodePool(pool("a")).autoscaling).toEqual({ enabled: true, min: 1, max: 5 }); + expect(parseNodePool({ name: "b" }).autoscaling).toEqual({ enabled: false, min: 0, max: 0 }); + }); + + it.each([ + ["REGULAR", "regular"], + ["UNSPECIFIED", "none"], + ["", "none"], + ])("labels release channel %s", (channel, want) => { + expect(releaseChannelLabel(channel)).toBe(want); + }); + + it.each([ + ["RUNNING", false], + ["RECONCILING", true], + ["PROVISIONING", true], + ])("busy state %s", (status, want) => { + expect(isBusy(status)).toBe(want); + }); + + it.each([ + ["3", true], + ["0", true], + ["1000", true], + ["1001", false], + ["-1", false], + ["x", false], + ["", false], + [7, true], + ])("validates node count %j", (value, want) => { + expect(validNodeCount(value)).toBe(want); + }); +}); + +describe("client", () => { + it("lists clusters across locations, sorted", async () => { + const fetchFn = vi.fn(() => + Promise.resolve(json({ clusters: [cluster("b", "us-east1-b"), cluster("a")] })), + ); + const out = await listClusters("p", asFetch(fetchFn)); + expect(out.map((c) => `${c.location}/${c.name}`)).toEqual(["us-central1-a/a", "us-east1-b/b"]); + expect(fetchFn.mock.calls[0]).toEqual([ + `${GKE_PROXY_BASE}/projects/p/locations/-/clusters`, + { method: "GET" }, + ]); + }); + + it("reads pool sizes with the poolSizes verb", async () => { + const fetchFn = vi.fn((_url: string) => Promise.resolve(json({ "default-pool": 4 }))); + expect(await getPoolSizes(path, asFetch(fetchFn))).toEqual({ "default-pool": 4 }); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${GKE_PROXY_BASE}/${path}:poolSizes`); + }); + + it("resizes a pool and deletes a cluster", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve( + json({ name: "operation-1", operationType: "SET_NODE_POOL_SIZE", status: "RUNNING" }), + ), + ); + const op = await resizeNodePool(path, "default-pool", 5, asFetch(fetchFn)); + await deleteCluster(path, asFetch(fetchFn)); + expect(op).toMatchObject({ name: "operation-1", type: "SET_NODE_POOL_SIZE" }); + const [resize, del] = fetchFn.mock.calls; + expect(resize?.[0]).toBe(`${GKE_PROXY_BASE}/${path}/nodePools/default-pool:setSize`); + expect(JSON.parse(String((resize![1] as RequestInit).body))).toEqual({ nodeCount: 5 }); + expect([del?.[0], (del![1] as RequestInit).method]).toEqual([ + `${GKE_PROXY_BASE}/${path}`, + "DELETE", + ]); + }); + + it("lists operations newest first", async () => { + const fetchFn = vi.fn(() => + Promise.resolve( + json({ + operations: [ + { + name: "o1", + startTime: "2026-10-01T00:00:00Z", + status: "DONE", + targetLink: "x/clusters/c1", + }, + { name: "o2", startTime: "2026-10-02T00:00:00Z", status: "RUNNING" }, + ], + }), + ), + ); + const ops = await listOperations("p", asFetch(fetchFn)); + expect(ops.map((o) => o.name)).toEqual(["o2", "o1"]); + expect(ops[1]?.target).toBe("c1"); + }); + + it("surfaces a REST error", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue( + json({ error: { message: "autoscaling enabled", status: "FAILED_PRECONDITION" } }, 400), + ); + await expect(resizeNodePool(path, "p", 1, asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 400, + code: "FAILED_PRECONDITION", + }); + }); +}); diff --git a/ui/src/lib/gcpcontainer.ts b/ui/src/lib/gcpcontainer.ts new file mode 100644 index 0000000000..462573f462 --- /dev/null +++ b/ui/src/lib/gcpcontainer.ts @@ -0,0 +1,210 @@ +/** Client for the Kubernetes Engine emulator (clusters, node pools, operations) via the dashboard's in-process REST proxy. */ + +import { restCall, shortName, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const GKE_PROXY_BASE = "/dashboard/api/v1/gcp/container/v1"; +export const GKE_PROJECT_KEY = "gopherstack_gke_project"; +export const DEFAULT_GKE_PROJECT = "gopherstack-local"; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); + +export type Autoscaling = { enabled: boolean; min: number; max: number }; + +export type NodePool = { + name: string; + status: string; + version: string; + machineType: string; + diskSizeGb: number; + initialNodeCount: number; + autoscaling: Autoscaling; + locations: string[]; +}; + +export type Cluster = { + name: string; + /** Full resource name: projects/p/locations/l/clusters/c. */ + path: string; + location: string; + status: string; + statusMessage: string; + masterVersion: string; + nodeVersion: string; + endpoint: string; + releaseChannel: string; + network: string; + subnetwork: string; + currentNodeCount: number; + created: string; + pools: NodePool[]; +}; + +export type GkeOperation = { + name: string; + type: string; + status: string; + target: string; + location: string; + start: string; + end: string; + detail: string; +}; + +export function parseAutoscaling(raw: unknown): Autoscaling { + const a = obj(raw); + return { enabled: a.enabled === true, min: num(a.minNodeCount), max: num(a.maxNodeCount) }; +} + +export function parseNodePool(raw: Json): NodePool { + const cfg = obj(raw.config); + return { + name: str(raw.name), + status: str(raw.status), + version: str(raw.version), + machineType: str(cfg.machineType), + diskSizeGb: num(cfg.diskSizeGb), + initialNodeCount: num(raw.initialNodeCount), + autoscaling: parseAutoscaling(raw.autoscaling), + locations: Array.isArray(raw.locations) ? raw.locations.map(String) : [], + }; +} + +export function parseCluster(raw: Json, project: string): Cluster { + const name = str(raw.name); + const location = str(raw.location); + return { + name, + path: `projects/${project}/locations/${location}/clusters/${name}`, + location, + status: str(raw.status), + statusMessage: str(raw.statusMessage), + masterVersion: str(raw.currentMasterVersion), + nodeVersion: str(raw.currentNodeVersion), + endpoint: str(raw.endpoint), + releaseChannel: str(obj(raw.releaseChannel).channel), + network: str(raw.network), + subnetwork: str(raw.subnetwork), + currentNodeCount: num(raw.currentNodeCount), + created: str(raw.createTime), + pools: list(raw.nodePools).map((r) => parseNodePool(r)), + }; +} + +export function parseOperation(raw: Json): GkeOperation { + return { + name: str(raw.name), + type: str(raw.operationType), + status: str(raw.status), + target: shortName(str(raw.targetLink)), + location: str(raw.location), + start: str(raw.startTime), + end: str(raw.endTime), + detail: str(raw.statusMessage) || str(obj(raw.error).message), + }; +} + +/** Whether a cluster/pool is in a steady state in which actions make sense. */ +export const isBusy = (status: string): boolean => + ["PROVISIONING", "RECONCILING", "STOPPING"].includes(status); + +export function releaseChannelLabel(channel: string): string { + if (!channel || channel === "UNSPECIFIED") return "none"; + return channel.toLowerCase(); +} + +export async function listClusters(project: string, fetchFn: FetchFn = fetch): Promise { + const body = await restCall( + GKE_PROXY_BASE, + "GET", + `/projects/${enc(project)}/locations/-/clusters`, + undefined, + fetchFn, + ); + return list(body.clusters) + .map((c) => parseCluster(c, project)) + .toSorted((a, b) => a.location.localeCompare(b.location) || a.name.localeCompare(b.name)); +} + +/** Current node count per zone of every pool, by pool name. */ +export async function getPoolSizes( + clusterPath: string, + fetchFn: FetchFn = fetch, +): Promise> { + const body = await restCall( + GKE_PROXY_BASE, + "GET", + `/${clusterPath}:poolSizes`, + undefined, + fetchFn, + ); + return Object.fromEntries(Object.entries(body).map(([k, v]) => [k, num(v)])); +} + +export async function resizeNodePool( + clusterPath: string, + pool: string, + nodeCount: number, + fetchFn: FetchFn = fetch, +): Promise { + return parseOperation( + await restCall( + GKE_PROXY_BASE, + "POST", + `/${clusterPath}/nodePools/${enc(pool)}:setSize`, + { nodeCount }, + fetchFn, + ), + ); +} + +export async function deleteCluster( + clusterPath: string, + fetchFn: FetchFn = fetch, +): Promise { + return parseOperation( + await restCall(GKE_PROXY_BASE, "DELETE", `/${clusterPath}`, undefined, fetchFn), + ); +} + +export async function listOperations( + project: string, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + GKE_PROXY_BASE, + "GET", + `/projects/${enc(project)}/locations/-/operations`, + undefined, + fetchFn, + ); + return list(body.operations) + .map((r) => parseOperation(r)) + .toSorted((a, b) => b.start.localeCompare(a.start)); +} + +/** Whether `value` is a valid node count for SetNodePoolSize. */ +export const validNodeCount = (value: string | number): boolean => { + const text = String(value).trim(); + return /^\d+$/.test(text) && Number(text) <= 1000; +}; + +function readStored(key: string, fallback: string): string { + try { + return localStorage.getItem(key)?.trim() || fallback; + } catch { + return fallback; + } +} + +export const readStoredGkeProject = () => readStored(GKE_PROJECT_KEY, DEFAULT_GKE_PROJECT); + +export function writeStoredGkeProject(project: string): void { + try { + localStorage.setItem(GKE_PROJECT_KEY, project); + } catch { + // storage unavailable: scope applies for this session only + } +} diff --git a/ui/src/lib/gcpdatastore.test.ts b/ui/src/lib/gcpdatastore.test.ts new file mode 100644 index 0000000000..af8dcada24 --- /dev/null +++ b/ui/src/lib/gcpdatastore.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DATASTORE_PROXY_BASE, + formatKey, + formatValue, + listEntities, + listIndexes, + listKinds, + listNamespaces, + nsLabel, + parseEntity, + parseKey, + runGql, + valueType, +} from "./gcpdatastore"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; + +const key = (kind: string, name: string, ns = "") => ({ + partitionId: { projectId: "p", ...(ns ? { namespaceId: ns } : {}) }, + path: [{ kind, name }], +}); +const bodyOf = (call: unknown[] | undefined): Record => + JSON.parse(String((call?.[1] as RequestInit | undefined)?.body)) as Record; + +describe("values and keys", () => { + it("formats keys with names, ids and namespaces", () => { + expect(formatKey(parseKey(key("Task", "t1")))).toBe('Task("t1")'); + expect( + formatKey( + parseKey({ + partitionId: { namespaceId: "tenant" }, + path: [ + { kind: "User", id: "7" }, + { kind: "Task", name: "t" }, + ], + }), + ), + ).toBe('[tenant] User(7) / Task("t")'); + }); + + it.each([ + [{ stringValue: "hi" }, "string", "hi"], + [{ integerValue: "42" }, "integer", "42"], + [{ doubleValue: 1.5 }, "double", "1.5"], + [{ booleanValue: false }, "boolean", "false"], + [{ nullValue: "NULL_VALUE" }, "null", "null"], + [{ timestampValue: "2026-01-01T00:00:00Z" }, "timestamp", "2026-01-01T00:00:00Z"], + [{ geoPointValue: { latitude: 1, longitude: 2 } }, "geopoint", "1, 2"], + [{ keyValue: key("Task", "t") }, "key", 'Task("t")'], + [{ arrayValue: { values: [{ stringValue: "a" }, { integerValue: "2" }] } }, "array", "[a, 2]"], + [{ entityValue: { properties: { x: { stringValue: "y" } } } }, "entity", "{x: y}"], + ])("formats %j", (v, type, text) => { + expect(valueType(v)).toBe(type); + expect(formatValue(v)).toBe(text); + }); + + it("parses an entity with excluded-from-indexes properties", () => { + const e = parseEntity({ + version: "3", + entity: { + key: key("Task", "t1"), + properties: { + title: { stringValue: "write" }, + note: { stringValue: "n", excludeFromIndexes: true }, + }, + }, + }); + expect(e.label).toBe('Task("t1")'); + expect(e.properties.map((p) => [p.name, p.excludeFromIndexes])).toEqual([ + ["note", true], + ["title", false], + ]); + }); + + it("labels the default namespace", () => { + expect(nsLabel("")).toBe("(default)"); + expect(nsLabel("tenant")).toBe("tenant"); + }); +}); + +const entityResults = (names: string[]) => + names.map((n) => ({ entity: { key: key("Task", n), properties: {} } })); + +describe("requests", () => { + it("lists namespaces from the __namespace__ metadata kind", async () => { + const fetchFn = vi.fn().mockResolvedValue( + json({ + batch: { + entityResults: [ + { entity: { key: { path: [{ kind: "__namespace__", id: "1" }] } } }, + { entity: { key: { path: [{ kind: "__namespace__", name: "tenant" }] } } }, + ], + }, + }), + ); + expect(await listNamespaces("p", asFetch(fetchFn))).toEqual(["", "tenant"]); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${DATASTORE_PROXY_BASE}/projects/p:runQuery`); + }); + + it("lists kinds in a namespace", async () => { + const fetchFn = vi.fn().mockResolvedValue( + json({ + batch: { + entityResults: [ + { entity: { key: { path: [{ kind: "__kind__", name: "Task" }] } } }, + { entity: { key: { path: [{ kind: "__kind__", name: "Note" }] } } }, + ], + }, + }), + ); + expect(await listKinds("p", "tenant", asFetch(fetchFn))).toEqual(["Note", "Task"]); + expect(bodyOf(fetchFn.mock.calls[0]).partitionId).toEqual({ + projectId: "p", + namespaceId: "tenant", + }); + }); + + it("pages entities with the end cursor", async () => { + const fetchFn = vi.fn().mockResolvedValue( + json({ + batch: { + entityResults: entityResults(["a"]), + endCursor: "c1", + moreResults: "NOT_FINISHED", + }, + }), + ); + const page = await listEntities("p", "", "Task", "c0", 10, asFetch(fetchFn)); + expect(page).toMatchObject({ endCursor: "c1", moreResults: "NOT_FINISHED" }); + expect(bodyOf(fetchFn.mock.calls[0])).toEqual({ + partitionId: { projectId: "p" }, + query: { kind: [{ name: "Task" }], limit: 10, startCursor: "c0" }, + }); + }); + + it("runs GQL with literals allowed and reports query errors", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(json({ batch: { entityResults: entityResults(["a", "b"]) } })) + .mockResolvedValueOnce( + json({ error: { status: "INVALID_ARGUMENT", message: "bad GQL" } }, 400), + ); + const ok = await runGql("p", "", " SELECT * FROM Task ", asFetch(fetchFn)); + expect(ok.entities).toHaveLength(2); + expect(bodyOf(fetchFn.mock.calls[0]).gqlQuery).toEqual({ + queryString: "SELECT * FROM Task", + allowLiterals: true, + }); + await expect(runGql("p", "", "SELEKT", asFetch(fetchFn))).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + message: "bad GQL", + }); + }); + + it("lists indexes across pages", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce( + json({ + indexes: [ + { + indexId: "1", + kind: "Task", + ancestor: "NONE", + state: "READY", + properties: [{ name: "title", direction: "ASCENDING" }], + }, + ], + nextPageToken: "n", + }), + ) + .mockResolvedValueOnce( + json({ indexes: [{ indexId: "2", kind: "Note", state: "CREATING" }] }), + ); + const idx = await listIndexes("p", asFetch(fetchFn)); + expect(idx.map((i) => [i.id, i.kind, i.state])).toEqual([ + ["1", "Task", "READY"], + ["2", "Note", "CREATING"], + ]); + expect(fetchFn.mock.calls[1]?.[0]).toBe( + `${DATASTORE_PROXY_BASE}/projects/p/indexes?pageToken=n`, + ); + }); +}); diff --git a/ui/src/lib/gcpdatastore.ts b/ui/src/lib/gcpdatastore.ts new file mode 100644 index 0000000000..63dbb7a942 --- /dev/null +++ b/ui/src/lib/gcpdatastore.ts @@ -0,0 +1,262 @@ +/** Client for the Datastore emulator (namespaces, kinds, entities, GQL, indexes) via the dashboard's in-process REST proxy. */ + +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const DATASTORE_PROXY_BASE = "/dashboard/api/v1/gcp/datastore/v1"; +export const DS_PROJECT_KEY = "gopherstack_datastore_project"; +export const DEFAULT_DS_PROJECT = "gopherstack-local"; +export const DEFAULT_PAGE_SIZE = 50; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +export type PathElement = { kind: string; id: string; name: string }; + +export type DsKey = { namespace: string; path: PathElement[] }; + +export type DsProperty = { + name: string; + type: string; + display: string; + excludeFromIndexes: boolean; + meaning: number; +}; + +export type DsEntity = { key: DsKey; label: string; properties: DsProperty[]; version: string }; + +export type QueryPage = { entities: DsEntity[]; endCursor: string; moreResults: string }; + +export type IndexInfo = { + id: string; + kind: string; + ancestor: string; + state: string; + properties: { name: string; direction: string }[]; +}; + +export function parseKey(raw: unknown): DsKey { + const k = obj(raw); + return { + namespace: str(obj(k.partitionId).namespaceId), + path: list(k.path).map((p) => ({ kind: str(p.kind), id: str(p.id), name: str(p.name) })), + }; +} + +/** Human form of a key: Kind("name") / Kind(123), prefixed with its namespace when set. */ +export function formatKey(key: DsKey): string { + const path = key.path.map((p) => (p.name ? `${p.kind}("${p.name}")` : `${p.kind}(${p.id})`)); + const text = path.join(" / "); + return key.namespace ? `[${key.namespace}] ${text}` : text; +} + +const VALUE_TYPES: Record = { + nullValue: "null", + booleanValue: "boolean", + integerValue: "integer", + doubleValue: "double", + timestampValue: "timestamp", + keyValue: "key", + stringValue: "string", + blobValue: "blob", + geoPointValue: "geopoint", + entityValue: "entity", + arrayValue: "array", +}; + +export function valueType(v: Json): string { + for (const field of Object.keys(VALUE_TYPES)) + if (field in v) return VALUE_TYPES[field] ?? "unknown"; + return "unknown"; +} + +/** Short text for a Datastore JSON Value. */ +export function formatValue(raw: unknown): string { + const v = obj(raw); + switch (valueType(v)) { + case "null": + return "null"; + case "key": + return formatKey(parseKey(v.keyValue)); + case "geopoint": { + const g = obj(v.geoPointValue); + return `${String(g.latitude ?? 0)}, ${String(g.longitude ?? 0)}`; + } + case "entity": { + const props = obj(obj(v.entityValue).properties); + return `{${Object.entries(props) + .map(([k, x]) => `${k}: ${formatValue(x)}`) + .join(", ")}}`; + } + case "array": + return `[${list(obj(v.arrayValue).values) + .map((x) => formatValue(x)) + .join(", ")}]`; + case "blob": + return `blob(${str(v.blobValue).length} base64 chars)`; + case "unknown": + return ""; + default: + return String(Object.values(v).find((x) => typeof x !== "object" && x !== undefined) ?? ""); + } +} + +export function parseEntity(raw: Json): DsEntity { + const e = obj(raw.entity); + const key = parseKey(e.key); + const props = obj(e.properties); + return { + key, + label: formatKey(key), + version: str(raw.version), + properties: Object.entries(props) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([name, value]) => { + const v = obj(value); + return { + name, + type: valueType(v), + display: formatValue(v), + excludeFromIndexes: v.excludeFromIndexes === true, + meaning: typeof v.meaning === "number" ? v.meaning : 0, + }; + }), + }; +} + +function parsePage(body: Json): QueryPage { + const batch = obj(body.batch); + return { + entities: list(batch.entityResults).map((r) => parseEntity(r)), + endCursor: str(batch.endCursor), + moreResults: str(batch.moreResults), + }; +} + +const projectPath = (project: string, verb: string) => `/projects/${enc(project)}:${verb}`; + +const partition = (project: string, namespace: string): Json => ({ + projectId: project, + ...(namespace ? { namespaceId: namespace } : {}), +}); + +/** Namespaces: the empty string stands for the default namespace. */ +export async function listNamespaces(project: string, fetchFn: FetchFn = fetch): Promise { + const body = await restCall( + DATASTORE_PROXY_BASE, + "POST", + projectPath(project, "runQuery"), + { partitionId: { projectId: project }, query: { kind: [{ name: "__namespace__" }] } }, + fetchFn, + ); + const names = parsePage(body).entities.map((e) => e.key.path[0]?.name ?? ""); + return [...new Set(names)].toSorted(); +} + +export async function listKinds( + project: string, + namespace: string, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + DATASTORE_PROXY_BASE, + "POST", + projectPath(project, "runQuery"), + { partitionId: partition(project, namespace), query: { kind: [{ name: "__kind__" }] } }, + fetchFn, + ); + return parsePage(body) + .entities.map((e) => e.key.path[0]?.name ?? "") + .filter(Boolean) + .toSorted(); +} + +export async function listEntities( + project: string, + namespace: string, + kind: string, + startCursor = "", + limit = DEFAULT_PAGE_SIZE, + fetchFn: FetchFn = fetch, +): Promise { + const query: Json = { kind: [{ name: kind }], limit }; + if (startCursor) query.startCursor = startCursor; + return parsePage( + await restCall( + DATASTORE_PROXY_BASE, + "POST", + projectPath(project, "runQuery"), + { partitionId: partition(project, namespace), query }, + fetchFn, + ), + ); +} + +export async function runGql( + project: string, + namespace: string, + gql: string, + fetchFn: FetchFn = fetch, +): Promise { + return parsePage( + await restCall( + DATASTORE_PROXY_BASE, + "POST", + projectPath(project, "runQuery"), + { + partitionId: partition(project, namespace), + gqlQuery: { queryString: gql.trim(), allowLiterals: true }, + }, + fetchFn, + ), + ); +} + +export async function listIndexes(project: string, fetchFn: FetchFn = fetch): Promise { + const out: IndexInfo[] = []; + let token = ""; + do { + const qs = token ? `?pageToken=${enc(token)}` : ""; + const body = await restCall( + DATASTORE_PROXY_BASE, + "GET", + `/projects/${enc(project)}/indexes${qs}`, + undefined, + fetchFn, + ); + for (const i of list(body.indexes)) { + out.push({ + id: str(i.indexId), + kind: str(i.kind), + ancestor: str(i.ancestor), + state: str(i.state), + properties: list(i.properties).map((p) => ({ + name: str(p.name), + direction: str(p.direction), + })), + }); + } + token = str(body.nextPageToken); + } while (token); + return out; +} + +export const nsLabel = (ns: string) => ns || "(default)"; + +function readStored(key: string, fallback: string): string { + try { + return localStorage.getItem(key)?.trim() || fallback; + } catch { + return fallback; + } +} + +export const readStoredDsProject = () => readStored(DS_PROJECT_KEY, DEFAULT_DS_PROJECT); + +export function writeStoredDsProject(project: string): void { + try { + localStorage.setItem(DS_PROJECT_KEY, project); + } catch { + // storage unavailable: scope applies for this session only + } +} diff --git a/ui/src/lib/gcpidentitytoolkit.test.ts b/ui/src/lib/gcpidentitytoolkit.test.ts new file mode 100644 index 0000000000..9ab42f91b0 --- /dev/null +++ b/ui/src/lib/gcpidentitytoolkit.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + IDP_PROXY_BASE, + createUser, + deleteUser, + filterUsers, + formatTime, + getConfig, + listOobCodes, + listProjects, + listTenants, + listUsers, + parseClaims, + parseUser, + providerLabel, + setClaims, + setSignIn, + userAction, + userProviders, + validEmail, + validPassword, + validateClaims, +} from "./gcpidentitytoolkit"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; +const urlOf = (f: { mock: { calls: unknown[][] } }, i: number): string => + String((f.mock.calls[i] ?? [])[0]); + +const ada = { + localId: "uid-ada", + email: "ada@example.com", + displayName: "Ada", + emailVerified: true, + hasPassword: true, + createdAt: "1700000000000", + lastLoginAt: "1700000100000", + customAttributes: '{"role":"admin"}', + providers: [{ providerId: "password", rawId: "ada@example.com", email: "ada@example.com" }], +}; + +describe("parsing", () => { + it("parses a user including claims and times", () => { + expect(parseUser(ada)).toMatchObject({ + uid: "uid-ada", + email: "ada@example.com", + emailVerified: true, + disabled: false, + hasPassword: true, + claims: { role: "admin" }, + createdAt: 1700000000000, + lastLoginAt: 1700000100000, + }); + }); + + it.each([ + ["", {}], + ["not json", {}], + ["[1]", {}], + ['{"a":1}', { a: 1 }], + ])("parses claims %j", (raw, want) => { + expect(parseClaims(raw)).toEqual(want); + }); + + it("never exposes hash fields even if the wire carried them", () => { + const u = parseUser({ ...ada, passwordHash: "abc", salt: "def" }); + expect(JSON.stringify(u)).not.toMatch(/passwordHash|salt|abc|def/); + }); + + it("derives providers", () => { + expect(userProviders(parseUser(ada))).toEqual(["password"]); + expect(userProviders(parseUser({ localId: "x" }))).toEqual(["anonymous"]); + expect(userProviders(parseUser({ localId: "x", email: "a@b.co", hasPassword: true }))).toEqual([ + "password", + ]); + expect(providerLabel("password")).toBe("Email/Password"); + expect(providerLabel("google.com")).toBe("google.com"); + }); + + it("formats times", () => { + expect(formatTime(0)).toBe("—"); + expect(formatTime(1700000000000)).toBe("2023-11-14 22:13:20Z"); + }); +}); + +describe("validation", () => { + it.each([ + ["a@b.co", true], + ["nope", false], + ["a b@c.de", false], + ])("email %s", (v, want) => expect(validEmail(v)).toBe(want)); + + it.each([ + ["12345", false], + ["123456", true], + ])("password %s", (v, want) => expect(validPassword(v)).toBe(want)); + + it("accepts object claims and treats empty as {}", () => { + expect(validateClaims('{"a":1}')).toEqual({ ok: true, claims: { a: 1 } }); + expect(validateClaims(" ")).toEqual({ ok: true, claims: {} }); + }); + + it.each(["[1]", "3", "null", "{bad"])("rejects claims %s", (text) => { + expect(validateClaims(text).ok).toBe(false); + }); + + it("rejects oversized claims", () => { + const res = validateClaims(JSON.stringify({ big: "x".repeat(1100) })); + expect(res).toMatchObject({ ok: false }); + }); +}); + +describe("filterUsers", () => { + const users = [parseUser(ada), parseUser({ localId: "uid-bob", email: "bob@x.io" })]; + + it.each([ + ["", 2], + ["ADA@", 1], + ["uid-bob", 1], + ["ada", 1], + ["zzz", 0], + ])("query %j", (q, n) => expect(filterUsers(users, q)).toHaveLength(n)); +}); + +describe("requests", () => { + it("lists projects and users", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json({ projects: ["p1", "p2"] })) + .mockResolvedValueOnce(json({ users: [ada], total: 7 })); + expect(await listProjects(asFetch(f))).toEqual(["p1", "p2"]); + const res = await listUsers("p1", "t-1", asFetch(f)); + expect(res.total).toBe(7); + expect(res.users[0].uid).toBe("uid-ada"); + expect(urlOf(f, 1)).toBe(`${IDP_PROXY_BASE}/projects/p1/users?limit=500&tenantId=t-1`); + }); + + it("creates a user sending the password once and returning only the uid", async () => { + const f = vi.fn().mockResolvedValue(json({ localId: "new-uid" })); + const uid = await createUser("p1", "", { email: "a@b.co", password: "hunter22" }, asFetch(f)); + expect(uid).toBe("new-uid"); + expect(initOf(f, 0).method).toBe("POST"); + expect(JSON.parse(String(initOf(f, 0).body))).toEqual({ + email: "a@b.co", + password: "hunter22", + displayName: "", + }); + expect(urlOf(f, 0)).toBe(`${IDP_PROXY_BASE}/projects/p1/users`); + }); + + it.each(["disable", "enable", "revoke"] as const)("runs %s", async (action) => { + const f = vi.fn().mockImplementation(() => Promise.resolve(json({ localId: "u" }))); + await userAction("p1", "t1", "u/1", action, asFetch(f)); + expect(urlOf(f, 0)).toBe(`${IDP_PROXY_BASE}/projects/p1/users/u%2F1/${action}?tenantId=t1`); + expect(initOf(f, 0).method).toBe("POST"); + }); + + it("sets claims, deletes and reads codes, tenants and config", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json({ localId: "u" })) + .mockResolvedValueOnce(json({})) + .mockResolvedValueOnce( + json({ + oobCodes: [ + { email: "a@b.co", oobCode: "c", oobLink: "http://x/y", requestType: "PASSWORD_RESET" }, + ], + }), + ) + .mockResolvedValueOnce( + json({ tenants: [{ id: "t-1", displayName: "Acme", allowPasswordSignup: true }] }), + ) + .mockResolvedValueOnce( + json({ emailEnabled: true, anonymousEnabled: false, authorizedDomains: ["localhost"] }), + ) + .mockResolvedValueOnce( + json({ emailEnabled: false, anonymousEnabled: false, authorizedDomains: [] }), + ); + await setClaims("p1", "", "u", { role: "x" }, asFetch(f)); + expect(JSON.parse(String(initOf(f, 0).body))).toEqual({ claims: { role: "x" } }); + await deleteUser("p1", "", "u", asFetch(f)); + expect(initOf(f, 1).method).toBe("DELETE"); + expect((await listOobCodes("p1", "", asFetch(f)))[0]).toMatchObject({ + requestType: "PASSWORD_RESET", + }); + expect((await listTenants("p1", asFetch(f)))[0]).toMatchObject({ + id: "t-1", + allowPasswordSignup: true, + }); + expect(await getConfig("p1", asFetch(f))).toMatchObject({ + emailEnabled: true, + anonymousEnabled: false, + }); + expect((await setSignIn("p1", { emailEnabled: false }, asFetch(f))).emailEnabled).toBe(false); + expect(initOf(f, 5).method).toBe("PATCH"); + expect(JSON.parse(String(initOf(f, 5).body))).toEqual({ emailEnabled: false }); + }); + + it("surfaces proxy errors", async () => { + const f = vi + .fn() + .mockResolvedValue( + json({ error: { message: "EMAIL_EXISTS", status: "INVALID_ARGUMENT" } }, 400), + ); + await expect( + createUser("p1", "", { email: "a@b.co", password: "123456" }, asFetch(f)), + ).rejects.toThrow("EMAIL_EXISTS"); + }); +}); diff --git a/ui/src/lib/gcpidentitytoolkit.ts b/ui/src/lib/gcpidentitytoolkit.ts new file mode 100644 index 0000000000..69b1eb4bba --- /dev/null +++ b/ui/src/lib/gcpidentitytoolkit.ts @@ -0,0 +1,327 @@ +/** Client for the Identity Platform emulator (users, tenants, codes, sign-in config) via the dashboard's in-process proxy. */ + +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const IDP_PROXY_BASE = "/dashboard/api/v1/gcp/identitytoolkit"; +export const IDP_PROJECT_KEY = "gopherstack_idp_project"; +export const IDP_USER_LIMIT = 500; +export const IDP_MAX_CLAIMS_BYTES = 1000; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); + +export type IdpProvider = { providerId: string; rawId: string; email: string }; + +export type IdpUser = { + uid: string; + email: string; + displayName: string; + phoneNumber: string; + emailVerified: boolean; + disabled: boolean; + hasPassword: boolean; + providers: IdpProvider[]; + claims: Record; + createdAt: number; + lastLoginAt: number; + lastRefreshAt: number; + validSince: number; +}; + +export type IdpTenant = { + id: string; + displayName: string; + allowPasswordSignup: boolean; + enableEmailLinkSignin: boolean; + enableAnonymousUser: boolean; +}; + +export type IdpOobCode = { + email: string; + oobCode: string; + oobLink: string; + requestType: string; +}; + +export type IdpConfig = { + emailEnabled: boolean; + anonymousEnabled: boolean; + authorizedDomains: string[]; +}; + +export type UserList = { users: IdpUser[]; total: number }; + +export type UserAction = "disable" | "enable" | "revoke"; + +/** Milliseconds since the epoch from a string or number; 0 when absent. */ +const millis = (v: unknown): number => { + const n = typeof v === "number" ? v : Number(str(v)); + if (Number.isFinite(n) && n > 0) return n; + const t = typeof v === "string" ? Date.parse(v) : NaN; + return Number.isFinite(t) ? t : 0; +}; + +export function parseClaims(raw: unknown): Record { + if (typeof raw !== "string" || raw === "") return {}; + try { + const v: unknown = JSON.parse(raw); + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; + } catch { + return {}; + } +} + +export function parseUser(raw: Json): IdpUser { + return { + uid: str(raw.localId), + email: str(raw.email), + displayName: str(raw.displayName), + phoneNumber: str(raw.phoneNumber), + emailVerified: raw.emailVerified === true, + disabled: raw.disabled === true, + hasPassword: raw.hasPassword === true, + providers: list(raw.providers).map((p) => ({ + providerId: str(p.providerId), + rawId: str(p.rawId), + email: str(p.email), + })), + claims: parseClaims(raw.customAttributes), + createdAt: millis(raw.createdAt), + lastLoginAt: millis(raw.lastLoginAt), + lastRefreshAt: millis(raw.lastRefreshAt), + validSince: millis(raw.validSince), + }; +} + +export function parseTenant(raw: Json): IdpTenant { + return { + id: str(raw.id), + displayName: str(raw.displayName), + allowPasswordSignup: raw.allowPasswordSignup === true, + enableEmailLinkSignin: raw.enableEmailLinkSignin === true, + enableAnonymousUser: raw.enableAnonymousUser === true, + }; +} + +export const parseOobCode = (raw: Json): IdpOobCode => ({ + email: str(raw.email), + oobCode: str(raw.oobCode), + oobLink: str(raw.oobLink), + requestType: str(raw.requestType), +}); + +export function parseConfig(raw: Json): IdpConfig { + return { + emailEnabled: raw.emailEnabled === true, + anonymousEnabled: raw.anonymousEnabled === true, + authorizedDomains: Array.isArray(raw.authorizedDomains) + ? raw.authorizedDomains.filter((d): d is string => typeof d === "string") + : [], + }; +} + +export function readStoredProject(): string { + try { + return localStorage.getItem(IDP_PROJECT_KEY) ?? ""; + } catch { + return ""; + } +} + +export function writeStoredProject(project: string): void { + try { + localStorage.setItem(IDP_PROJECT_KEY, project); + } catch { + // storage unavailable: the choice just is not remembered + } +} + +const projectPath = (project: string) => `/projects/${enc(project)}`; +const tenantQuery = (tenant: string, extra: Record = {}): string => { + const q = new URLSearchParams(extra); + if (tenant) q.set("tenantId", tenant); + const s = q.toString(); + return s ? `?${s}` : ""; +}; + +export async function listProjects(fetchFn: FetchFn = fetch): Promise { + const body = await restCall(IDP_PROXY_BASE, "GET", "/projects", undefined, fetchFn); + return Array.isArray(body.projects) + ? body.projects.filter((p): p is string => typeof p === "string") + : []; +} + +export async function listUsers( + project: string, + tenant: string, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + IDP_PROXY_BASE, + "GET", + `${projectPath(project)}/users${tenantQuery(tenant, { limit: String(IDP_USER_LIMIT) })}`, + undefined, + fetchFn, + ); + const users = list(body.users).map((u) => parseUser(u)); + return { users, total: typeof body.total === "number" ? body.total : users.length }; +} + +/** Case-insensitive match on email, display name or uid; an empty query keeps everything. */ +export function filterUsers(users: IdpUser[], query: string): IdpUser[] { + const q = query.trim().toLowerCase(); + if (!q) return users; + return users.filter( + (u) => + u.email.toLowerCase().includes(q) || + u.uid.toLowerCase().includes(q) || + u.displayName.toLowerCase().includes(q), + ); +} + +/** The password is sent once to the backend and never returned or kept. */ +export async function createUser( + project: string, + tenant: string, + input: { email: string; password: string; displayName?: string }, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + IDP_PROXY_BASE, + "POST", + `${projectPath(project)}/users${tenantQuery(tenant)}`, + { email: input.email, password: input.password, displayName: input.displayName ?? "" }, + fetchFn, + ); + return str(body.localId); +} + +export async function userAction( + project: string, + tenant: string, + uid: string, + action: UserAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + IDP_PROXY_BASE, + "POST", + `${projectPath(project)}/users/${enc(uid)}/${action}${tenantQuery(tenant)}`, + undefined, + fetchFn, + ); +} + +export async function setClaims( + project: string, + tenant: string, + uid: string, + claims: Record, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + IDP_PROXY_BASE, + "POST", + `${projectPath(project)}/users/${enc(uid)}/claims${tenantQuery(tenant)}`, + { claims }, + fetchFn, + ); +} + +export async function deleteUser( + project: string, + tenant: string, + uid: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + IDP_PROXY_BASE, + "DELETE", + `${projectPath(project)}/users/${enc(uid)}${tenantQuery(tenant)}`, + undefined, + fetchFn, + ); +} + +export async function listOobCodes( + project: string, + tenant: string, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + IDP_PROXY_BASE, + "GET", + `${projectPath(project)}/oobcodes${tenantQuery(tenant)}`, + undefined, + fetchFn, + ); + return list(body.oobCodes).map((c) => parseOobCode(c)); +} + +export async function listTenants(project: string, fetchFn: FetchFn = fetch): Promise { + const body = await restCall( + IDP_PROXY_BASE, + "GET", + `${projectPath(project)}/tenants`, + undefined, + fetchFn, + ); + return list(body.tenants).map((t) => parseTenant(t)); +} + +export async function getConfig(project: string, fetchFn: FetchFn = fetch): Promise { + return parseConfig( + await restCall(IDP_PROXY_BASE, "GET", `${projectPath(project)}/config`, undefined, fetchFn), + ); +} + +export async function setSignIn( + project: string, + toggles: { emailEnabled?: boolean; anonymousEnabled?: boolean }, + fetchFn: FetchFn = fetch, +): Promise { + return parseConfig( + await restCall(IDP_PROXY_BASE, "PATCH", `${projectPath(project)}/config`, toggles, fetchFn), + ); +} + +export type ClaimsCheck = + | { ok: true; claims: Record } + | { ok: false; error: string }; + +/** Validates the claims editor text: a JSON object under the backend's size limit. */ +export function validateClaims(text: string): ClaimsCheck { + const t = text.trim() || "{}"; + let v: unknown; + try { + v = JSON.parse(t); + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Invalid JSON" }; + } + if (!v || typeof v !== "object" || Array.isArray(v)) { + return { ok: false, error: "Claims must be a JSON object" }; + } + if (new TextEncoder().encode(JSON.stringify(v)).length > IDP_MAX_CLAIMS_BYTES) { + return { ok: false, error: `Claims must be under ${IDP_MAX_CLAIMS_BYTES} bytes` }; + } + return { ok: true, claims: v as Record }; +} + +export const validEmail = (v: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()); +export const validPassword = (v: string): boolean => v.length >= 6; + +export const providerLabel = (id: string): string => + ({ password: "Email/Password", anonymous: "Anonymous", phone: "Phone", custom: "Custom token" })[ + id + ] ?? id; + +export function userProviders(u: IdpUser): string[] { + const ids = u.providers.map((p) => p.providerId); + if (u.hasPassword && !ids.includes("password")) ids.unshift("password"); + if (ids.length === 0 && !u.email) ids.push("anonymous"); + return ids; +} + +export function formatTime(ms: number): string { + return ms > 0 ? new Date(ms).toISOString().replace("T", " ").slice(0, 19) + "Z" : "—"; +} diff --git a/ui/src/lib/gcpresourcemanager.test.ts b/ui/src/lib/gcpresourcemanager.test.ts new file mode 100644 index 0000000000..4394310a58 --- /dev/null +++ b/ui/src/lib/gcpresourcemanager.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + addMember, + buildProjectBody, + createFolder, + deleteNode, + disableService, + enableService, + filterServices, + flatten, + getEnforcement, + getIamPolicy, + groupBindings, + listOrphanProjects, + listServices, + listTagBindings, + listTagKeys, + loadTree, + memberKind, + moveNode, + nodePath, + orphanRoot, + parentOptions, + parseFolder, + parseOrganization, + parsePolicy, + parseProject, + parseService, + removeMember, + statePill, + undeleteNode, + validFolderName, + validMember, + validProjectId, + validProjectName, + validRole, + type RmNode, +} from "./gcpresourcemanager"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const urlOf = (f: { mock: { calls: unknown[][] } }, i: number): string => + String((f.mock.calls[i] ?? [])[0]); +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; + +const org = parseOrganization({ name: "organizations/1", displayName: "gopherstack.local" }); +const project = parseProject({ + name: "projects/123456789012", + projectId: "alpha-proj", + displayName: "Alpha", + parent: "folders/10", + state: "DELETE_REQUESTED", + deleteTime: "2026-01-02T00:00:00Z", + labels: { env: "dev" }, +}); + +describe("parsers", () => { + it("parses organizations, folders and projects", () => { + expect(org).toMatchObject({ kind: "organization", id: "1", label: "gopherstack.local" }); + expect( + parseFolder({ name: "folders/10", displayName: "Team", parent: "organizations/1" }), + ).toMatchObject({ + kind: "folder", + id: "10", + state: "ACTIVE", + parent: "organizations/1", + }); + expect(project).toMatchObject({ + kind: "project", + id: "alpha-proj", + number: "123456789012", + state: "DELETE_REQUESTED", + labels: { env: "dev" }, + }); + }); + + it("parses services and policies", () => { + expect( + parseService({ + name: "projects/1/services/pubsub.googleapis.com", + config: { + name: "pubsub.googleapis.com", + title: "Pub/Sub", + documentation: { summary: "s" }, + }, + state: "ENABLED", + }), + ).toEqual({ + name: "projects/1/services/pubsub.googleapis.com", + id: "pubsub.googleapis.com", + title: "Pub/Sub", + summary: "s", + state: "ENABLED", + }); + const pol = parsePolicy({ + etag: "BwX=", + bindings: [ + { role: "roles/viewer", members: ["user:a@x.io"] }, + { role: "roles/editor", members: ["group:g@x.io"], condition: { title: "temp" } }, + ], + }); + expect(pol.etag).toBe("BwX="); + expect(groupBindings(pol).map((b) => b.role)).toEqual(["roles/viewer", "roles/editor"]); + expect(pol.bindings[1]?.condition).toBe("temp"); + }); +}); + +describe("validation", () => { + it.each([ + ["alpha-proj", true], + ["ab", false], + ["Alpha-proj", false], + ["1alpha-proj", false], + ["alpha-proj-", false], + ["my-google-project", false], + ["a".repeat(31), false], + ])("project id %s", (v, ok) => { + expect(validProjectId(v)).toBe(ok); + }); + + it.each([ + ["Team Alpha", true], + ["ab", false], + [" Team", false], + ["Team-1_x", true], + ["Team!", false], + ])("folder name %s", (v, ok) => { + expect(validFolderName(v)).toBe(ok); + }); + + it("project names, roles and members", () => { + expect(validProjectName("")).toBe(true); + expect(validProjectName("abc")).toBe(false); + expect(validProjectName("My Project!")).toBe(true); + expect(validRole("roles/viewer")).toBe(true); + expect(validRole("projects/p/roles/custom.one")).toBe(true); + expect(validRole("viewer")).toBe(false); + expect(validMember("user:a@x.io")).toBe(true); + expect(validMember("user: a")).toBe(false); + expect(memberKind("serviceAccount:x@y")).toBe("serviceAccount"); + expect(memberKind("allUsers")).toBe("allUsers"); + }); + + it("state pills", () => { + expect(statePill("DELETE_REQUESTED")).toContain("red"); + expect(statePill("ACTIVE")).toContain("green"); + expect(statePill("")).toContain("gray"); + }); +}); + +describe("tree", () => { + const folder: RmNode = { + ...parseFolder({ name: "folders/10", displayName: "Team", parent: "organizations/1" }), + }; + const dead: RmNode = { + ...parseFolder({ name: "folders/11", displayName: "Old", state: "DELETE_REQUESTED" }), + }; + const root: RmNode = { ...org, children: [{ ...folder, children: [{ ...project }] }, dead] }; + + it("flattens and builds parent options without deleted nodes or the skipped subtree", () => { + expect(flatten(root).map((n) => n.name)).toEqual([ + "organizations/1", + "folders/10", + "projects/123456789012", + "folders/11", + ]); + expect(parentOptions(root)).toEqual([ + { name: "organizations/1", label: "gopherstack.local" }, + { name: "folders/10", label: "gopherstack.local / Team" }, + ]); + expect(parentOptions(root, "folders/10").map((o) => o.name)).toEqual(["organizations/1"]); + }); + + it("orphan root is unselectable", () => { + expect(orphanRoot([project]).name).toBe(""); + expect(orphanRoot([project]).children).toHaveLength(1); + }); + + it("loads the subtree recursively", async () => { + const fetchMock = vi.fn((url: string) => { + if (url.includes("/folders?parent=organizations%2F1")) { + return Promise.resolve(json({ folders: [{ name: "folders/10", displayName: "Team" }] })); + } + if (url.includes("/projects?parent=folders%2F10")) { + return Promise.resolve(json({ projects: [{ name: "projects/5", projectId: "p-five" }] })); + } + return Promise.resolve(json({})); + }); + const tree = await loadTree(org, asFetch(fetchMock)); + expect(tree.children.map((c) => c.id)).toEqual(["10"]); + expect(tree.children[0]?.children.map((c) => c.id)).toEqual(["p-five"]); + }); + + it("keeps only parentless projects", async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + json({ + projects: [ + { name: "projects/1", projectId: "solo-proj" }, + { name: "projects/2", parent: "folders/1" }, + ], + }), + ), + ); + const got = await listOrphanProjects(asFetch(fetchMock)); + expect(got.map((p) => p.id)).toEqual(["solo-proj"]); + }); +}); + +describe("services", () => { + const all = [ + { + name: "n1", + id: "pubsub.googleapis.com", + title: "Pub/Sub", + summary: "messaging", + state: "ENABLED", + }, + { + name: "n2", + id: "run.googleapis.com", + title: "Cloud Run", + summary: "containers", + state: "DISABLED", + }, + ]; + + it("filters by text and state", () => { + expect(filterServices(all, "run", "").map((s) => s.id)).toEqual(["run.googleapis.com"]); + expect(filterServices(all, "", "ENABLED").map((s) => s.id)).toEqual(["pubsub.googleapis.com"]); + expect(filterServices(all, "MESSAGING", "DISABLED")).toEqual([]); + }); + + it("lists with a state filter and pages", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + json({ services: [{ name: "a", state: "ENABLED" }], nextPageToken: "t2" }), + ) + .mockResolvedValueOnce(json({ services: [{ name: "b", state: "ENABLED" }] })); + const got = await listServices("p1", "ENABLED", asFetch(fetchMock)); + expect(got).toHaveLength(2); + expect(urlOf(fetchMock, 0)).toContain("/projects/p1/services?pageSize=200&state=ENABLED"); + expect(urlOf(fetchMock, 1)).toContain("pageToken=t2"); + }); + + it("enables and disables with fixed bodies", async () => { + const fetchMock = vi.fn(() => Promise.resolve(json({}))); + await enableService("p1", "pubsub.googleapis.com", asFetch(fetchMock)); + await disableService("p1", "pubsub.googleapis.com", true, asFetch(fetchMock)); + expect(urlOf(fetchMock, 0)).toMatch( + /\/projects\/p1\/services\/pubsub\.googleapis\.com:enable$/, + ); + expect(urlOf(fetchMock, 1)).toMatch(/:disable$/); + expect(JSON.parse(String(initOf(fetchMock, 1).body))).toEqual({ + disableDependentServices: true, + }); + }); + + it("surfaces the precondition message", async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + json({ error: { message: "depends on this", status: "FAILED_PRECONDITION" } }, 400), + ), + ); + await expect( + disableService("p1", "compute.googleapis.com", false, asFetch(fetchMock)), + ).rejects.toMatchObject({ + code: "FAILED_PRECONDITION", + message: "depends on this", + }); + }); +}); + +describe("mutations", () => { + it("builds project bodies from fixed fields", () => { + expect( + buildProjectBody({ + projectId: " my-proj-1 ", + displayName: "", + parent: "folders/1", + labels: [ + { key: "a", value: "b" }, + { key: " ", value: "x" }, + ], + }), + ).toEqual({ projectId: "my-proj-1", parent: "folders/1", labels: { a: "b" } }); + }); + + it("calls the expected verbs", async () => { + const fetchMock = vi.fn(() => Promise.resolve(json({}))); + const f = asFetch(fetchMock); + await createFolder("organizations/1", "Team", f); + await moveNode(project, "organizations/1", f); + await deleteNode(project, f); + await undeleteNode(project, f); + expect(initOf(fetchMock, 0).method).toBe("POST"); + expect(JSON.parse(String(initOf(fetchMock, 0).body))).toEqual({ + parent: "organizations/1", + displayName: "Team", + }); + expect(urlOf(fetchMock, 1)).toMatch(/\/projects\/alpha-proj:move$/); + expect(initOf(fetchMock, 2).method).toBe("DELETE"); + expect(urlOf(fetchMock, 3)).toMatch(/:undelete$/); + expect(nodePath(org)).toBe("organizations/1"); + }); +}); + +describe("iam", () => { + it("reads and edits with the etag", async () => { + const fetchMock = vi.fn(() => Promise.resolve(json({ etag: "e2", bindings: [] }))); + const f = asFetch(fetchMock); + await getIamPolicy(project, f); + const next = await addMember(project, "roles/viewer", "user:a@x.io", "e1", f); + await removeMember(project, "roles/viewer", "user:a@x.io", "e2", f); + expect(urlOf(fetchMock, 0)).toMatch(/:getIamPolicy$/); + expect(JSON.parse(String(initOf(fetchMock, 1).body))).toEqual({ + role: "roles/viewer", + member: "user:a@x.io", + etag: "e1", + }); + expect(urlOf(fetchMock, 2)).toMatch(/:removeMember$/); + expect(next.etag).toBe("e2"); + }); + + it("passes an etag conflict through", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(json({ error: { message: "policy changed", status: "ABORTED" } }, 409)), + ); + await expect( + addMember(project, "roles/viewer", "user:a", "old", asFetch(fetchMock)), + ).rejects.toMatchObject({ + httpStatus: 409, + code: "ABORTED", + }); + }); +}); + +describe("tags and enforcement", () => { + it("lists keys with their values and bindings across parent forms", async () => { + const fetchMock = vi.fn((url: string) => { + if (url.includes("/tagKeys")) + return Promise.resolve(json({ tagKeys: [{ name: "tagKeys/1", shortName: "env" }] })); + if (url.includes("/tagValues")) + return Promise.resolve(json({ tagValues: [{ name: "tagValues/2", shortName: "prod" }] })); + return Promise.resolve(json({ tagBindings: [{ name: "b1", tagValue: "tagValues/2" }] })); + }); + const f = asFetch(fetchMock); + const keys = await listTagKeys("organizations/1", f); + expect(keys[0]?.values.map((v) => v.shortName)).toEqual(["prod"]); + const bindings = await listTagBindings(["//x/projects/a", "//x/projects/1"], f); + expect(bindings).toHaveLength(2); + }); + + it("reads the enforcement switches", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(json({ projects: true, serviceEnablement: false })), + ); + expect(await getEnforcement(asFetch(fetchMock))).toEqual({ + projects: true, + serviceEnablement: false, + }); + }); +}); diff --git a/ui/src/lib/gcpresourcemanager.ts b/ui/src/lib/gcpresourcemanager.ts new file mode 100644 index 0000000000..e48b574fd7 --- /dev/null +++ b/ui/src/lib/gcpresourcemanager.ts @@ -0,0 +1,483 @@ +/** Client for the Resource Manager and Service Usage emulator via the dashboard's in-process proxy. */ + +import { listAll, restCall, shortName, str, strMap, type FetchFn, type Json } from "./gcprest.ts"; + +export const RM_PROXY_BASE = "/dashboard/api/v1/gcp/resourcemanager/v1"; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +export type RmKind = "organizations" | "folders" | "projects"; + +export type RmEnforcement = { projects: boolean; serviceEnablement: boolean }; + +export type RmNode = { + kind: "organization" | "folder" | "project"; + /** Full resource name: organizations/N, folders/N or projects/NUMBER. */ + name: string; + /** Path id used with the proxy: org/folder number or the project id. */ + id: string; + label: string; + parent: string; + state: string; + createTime: string; + deleteTime: string; + number: string; + labels: Record; + etag: string; + children: RmNode[]; +}; + +export type RmService = { + name: string; + id: string; + title: string; + summary: string; + state: string; +}; + +export type IamBinding = { + role: string; + members: string[]; + condition: string; +}; + +export type IamPolicy = { etag: string; bindings: IamBinding[] }; + +export type TagKey = { + name: string; + shortName: string; + namespacedName: string; + description: string; + values: TagValue[]; +}; + +export type TagValue = { + name: string; + shortName: string; + namespacedName: string; + description: string; +}; + +export type TagBinding = { name: string; parent: string; tagValue: string; namespaced: string }; + +export function parseOrganization(raw: Json): RmNode { + const name = str(raw.name); + return { + kind: "organization", + name, + id: shortName(name), + label: str(raw.displayName) || shortName(name), + parent: "", + state: str(raw.state) || "ACTIVE", + createTime: str(raw.createTime), + deleteTime: str(raw.deleteTime), + number: "", + labels: {}, + etag: str(raw.etag), + children: [], + }; +} + +export function parseFolder(raw: Json): RmNode { + const name = str(raw.name); + return { + kind: "folder", + name, + id: shortName(name), + label: str(raw.displayName) || shortName(name), + parent: str(raw.parent), + state: str(raw.state) || "ACTIVE", + createTime: str(raw.createTime), + deleteTime: str(raw.deleteTime), + number: "", + labels: {}, + etag: str(raw.etag), + children: [], + }; +} + +export function parseProject(raw: Json): RmNode { + const name = str(raw.name); + const id = str(raw.projectId) || shortName(name); + return { + kind: "project", + name, + id, + label: str(raw.displayName) || id, + parent: str(raw.parent), + state: str(raw.state) || "ACTIVE", + createTime: str(raw.createTime), + deleteTime: str(raw.deleteTime), + number: shortName(name), + labels: strMap(raw.labels), + etag: str(raw.etag), + children: [], + }; +} + +const kindPath = (n: Pick): string => + `${n.kind === "organization" ? "organizations" : n.kind === "folder" ? "folders" : "projects"}/${enc(n.id)}`; + +export const nodePath = kindPath; + +export async function getEnforcement(fetchFn: FetchFn = fetch): Promise { + const body = await restCall(RM_PROXY_BASE, "GET", "/enforcement", undefined, fetchFn); + return { projects: body.projects === true, serviceEnablement: body.serviceEnablement === true }; +} + +export async function listOrganizations(fetchFn: FetchFn = fetch): Promise { + return (await listAll(RM_PROXY_BASE, "/organizations", "organizations", fetchFn)).map((o) => + parseOrganization(o), + ); +} + +export async function listFolders(parent: string, fetchFn: FetchFn = fetch): Promise { + return ( + await listAll(RM_PROXY_BASE, "/folders", "folders", fetchFn, `parent=${enc(parent)}`) + ).map((f) => parseFolder(f)); +} + +export async function listProjects(parent: string, fetchFn: FetchFn = fetch): Promise { + return ( + await listAll(RM_PROXY_BASE, "/projects", "projects", fetchFn, `parent=${enc(parent)}`) + ).map((p) => parseProject(p)); +} + +/** Projects that have no organization or folder parent. */ +export async function listOrphanProjects(fetchFn: FetchFn = fetch): Promise { + return (await listAll(RM_PROXY_BASE, "/projects", "projects", fetchFn)) + .map((p) => parseProject(p)) + .filter((p) => !p.parent); +} + +/** Pseudo-root that holds projects without a parent; its empty name keeps it unselectable. */ +export function orphanRoot(projects: RmNode[]): RmNode { + return { + kind: "organization", + name: "", + id: "", + label: "No organization", + parent: "", + state: "ACTIVE", + createTime: "", + deleteTime: "", + number: "", + labels: {}, + etag: "", + children: projects, + }; +} + +/** Loads the whole organization subtree: folders recursively, then each level's projects. */ +export async function loadTree(org: RmNode, fetchFn: FetchFn = fetch): Promise { + const root = { ...org, children: [] as RmNode[] }; + const walk = async (node: RmNode): Promise => { + const [folders, projects] = await Promise.all([ + listFolders(node.name, fetchFn), + listProjects(node.name, fetchFn), + ]); + node.children = [...folders, ...projects]; + await Promise.all(folders.map((f) => walk(f))); + }; + await walk(root); + return root; +} + +export function flatten(root: RmNode): RmNode[] { + return [root, ...root.children.flatMap((c) => flatten(c))]; +} + +export type ParentOption = { name: string; label: string }; + +/** Org and active folders as "Org / Folder / Sub" choices, optionally skipping a subtree (for moves). */ +export function parentOptions(root: RmNode, skip = ""): ParentOption[] { + const out: ParentOption[] = []; + const walk = (n: RmNode, path: string) => { + if (n.kind === "project" || n.state !== "ACTIVE" || n.name === skip) return; + const here = path ? `${path} / ${n.label}` : n.label; + out.push({ name: n.name, label: here }); + for (const c of n.children) walk(c, here); + }; + walk(root, ""); + return out; +} + +export async function getProject(id: string, fetchFn: FetchFn = fetch): Promise { + return parseProject( + await restCall(RM_PROXY_BASE, "GET", `/projects/${enc(id)}`, undefined, fetchFn), + ); +} + +export function parseService(raw: Json): RmService { + const cfg = obj(raw.config); + const name = str(cfg.name) || shortName(str(raw.name)); + return { + name: str(raw.name), + id: name, + title: str(cfg.title) || name, + summary: str(obj(cfg.documentation).summary), + state: str(raw.state) || "DISABLED", + }; +} + +export async function listServices( + project: string, + state: "" | "ENABLED" | "DISABLED" = "", + fetchFn: FetchFn = fetch, +): Promise { + const out = await listAll( + RM_PROXY_BASE, + `/projects/${enc(project)}/services`, + "services", + fetchFn, + `pageSize=200${state ? `&state=${state}` : ""}`, + ); + return out.map((s) => parseService(s)); +} + +export function filterServices(all: RmService[], query: string, state: string): RmService[] { + const q = query.trim().toLowerCase(); + return all.filter( + (s) => + (!state || s.state === state) && + (!q || `${s.id} ${s.title} ${s.summary}`.toLowerCase().includes(q)), + ); +} + +export async function enableService( + project: string, + service: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + RM_PROXY_BASE, + "POST", + `/projects/${enc(project)}/services/${enc(service)}:enable`, + {}, + fetchFn, + ); +} + +export async function disableService( + project: string, + service: string, + disableDependentServices: boolean, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + RM_PROXY_BASE, + "POST", + `/projects/${enc(project)}/services/${enc(service)}:disable`, + { disableDependentServices }, + fetchFn, + ); +} + +export function parsePolicy(raw: Json): IamPolicy { + return { + etag: str(raw.etag), + bindings: list(raw.bindings).map((b) => { + const cond = obj(b.condition); + return { + role: str(b.role), + members: Array.isArray(b.members) ? b.members.map(String) : [], + condition: b.condition ? str(cond.title) || str(cond.expression) || "conditional" : "", + }; + }), + }; +} + +export async function getIamPolicy(node: RmNode, fetchFn: FetchFn = fetch): Promise { + return parsePolicy( + await restCall(RM_PROXY_BASE, "POST", `/${kindPath(node)}:getIamPolicy`, {}, fetchFn), + ); +} + +export async function addMember( + node: RmNode, + role: string, + member: string, + etag: string, + fetchFn: FetchFn = fetch, +): Promise { + return parsePolicy( + await restCall( + RM_PROXY_BASE, + "POST", + `/${kindPath(node)}:addMember`, + { role, member, etag }, + fetchFn, + ), + ); +} + +export async function removeMember( + node: RmNode, + role: string, + member: string, + etag: string, + fetchFn: FetchFn = fetch, +): Promise { + return parsePolicy( + await restCall( + RM_PROXY_BASE, + "POST", + `/${kindPath(node)}:removeMember`, + { role, member, etag }, + fetchFn, + ), + ); +} + +export async function createFolder( + parent: string, + displayName: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall(RM_PROXY_BASE, "POST", "/folders", { parent, displayName }, fetchFn); +} + +export type NewProject = { + projectId: string; + displayName: string; + parent: string; + labels: { key: string; value: string }[]; +}; + +export function buildProjectBody(p: NewProject): Json { + const labels = Object.fromEntries( + p.labels.filter((l) => l.key.trim()).map((l) => [l.key.trim(), l.value]), + ); + const body: Json = { projectId: p.projectId.trim(), parent: p.parent }; + if (p.displayName.trim()) body.displayName = p.displayName.trim(); + if (Object.keys(labels).length > 0) body.labels = labels; + return body; +} + +export async function createProject(p: NewProject, fetchFn: FetchFn = fetch): Promise { + await restCall(RM_PROXY_BASE, "POST", "/projects", buildProjectBody(p), fetchFn); +} + +export async function moveNode( + node: RmNode, + destinationParent: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall(RM_PROXY_BASE, "POST", `/${kindPath(node)}:move`, { destinationParent }, fetchFn); +} + +export async function deleteNode(node: RmNode, fetchFn: FetchFn = fetch): Promise { + await restCall(RM_PROXY_BASE, "DELETE", `/${kindPath(node)}`, undefined, fetchFn); +} + +export async function undeleteNode(node: RmNode, fetchFn: FetchFn = fetch): Promise { + await restCall(RM_PROXY_BASE, "POST", `/${kindPath(node)}:undelete`, {}, fetchFn); +} + +/** Full resource name tag bindings are keyed on. */ +export const bindingParent = (kind: RmKind, id: string): string => + `//cloudresourcemanager.googleapis.com/${kind}/${id}`; + +function parseTagValue(raw: Json): TagValue { + const name = str(raw.name); + return { + name, + shortName: str(raw.shortName) || shortName(name), + namespacedName: str(raw.namespacedName), + description: str(raw.description), + }; +} + +export async function listTagKeys(parent: string, fetchFn: FetchFn = fetch): Promise { + const keys = await listAll( + RM_PROXY_BASE, + "/tagKeys", + "tagKeys", + fetchFn, + `parent=${enc(parent)}`, + ); + return Promise.all( + keys.map(async (k) => { + const name = str(k.name); + const values = await listAll( + RM_PROXY_BASE, + "/tagValues", + "tagValues", + fetchFn, + `parent=${enc(name)}`, + ); + return { + name, + shortName: str(k.shortName) || shortName(name), + namespacedName: str(k.namespacedName), + description: str(k.description), + values: values.map((v) => parseTagValue(v)), + }; + }), + ); +} + +export async function listTagBindings( + parents: string[], + fetchFn: FetchFn = fetch, +): Promise { + const pages = await Promise.all( + parents.map((p) => + listAll(RM_PROXY_BASE, "/tagBindings", "tagBindings", fetchFn, `parent=${enc(p)}`), + ), + ); + return pages.flat().map((b) => ({ + name: str(b.name), + parent: str(b.parent), + tagValue: str(b.tagValue), + namespaced: str(b.tagValueNamespacedName), + })); +} + +/** Project id rules mirrored from the server: 6-30 chars, lowercase letters/digits/hyphens, starts with a letter. */ +export function validProjectId(v: string): boolean { + return /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(v) && !/google|ssl/.test(v); +} + +/** Display names of projects: 4-30 of letters, digits, hyphen, quotes, space and '!'. */ +export function validProjectName(v: string): boolean { + return v === "" || /^[\p{L}\p{N}\-'" !]{4,30}$/u.test(v); +} + +/** Folder display names: 3-30 chars, letters/digits/underscore/hyphen/space, no space at either end. */ +export function validFolderName(v: string): boolean { + return /^[\p{L}\p{N}][\p{L}\p{N}_ -]{1,28}[\p{L}\p{N}]$/u.test(v); +} + +export const isDeleted = (n: { state: string }): boolean => n.state === "DELETE_REQUESTED"; + +/** Tailwind classes for a lifecycle state badge. */ +export function statePill(s: string): string { + if (s === "ACTIVE" || s === "ENABLED") + return "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300"; + if (s === "DELETE_REQUESTED") + return "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300"; + return "bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-gray-300"; +} + +export function memberKind(m: string): string { + const i = m.indexOf(":"); + return i > 0 ? m.slice(0, i) : m; +} + +/** Roles sorted, each with its members, unconditioned bindings first. */ +export function groupBindings(policy: IamPolicy): IamBinding[] { + return policy.bindings.toSorted( + (a, b) => + Number(Boolean(a.condition)) - Number(Boolean(b.condition)) || a.role.localeCompare(b.role), + ); +} + +export const validRole = (v: string): boolean => + /^(roles\/[A-Za-z0-9_.]{1,128}|(projects|organizations)\/[A-Za-z0-9_-]{1,64}\/roles\/[A-Za-z0-9_.]{1,128})$/.test( + v, + ); + +export const validMember = (v: string): boolean => /^\S{1,256}$/.test(v); diff --git a/ui/src/lib/gcprest.ts b/ui/src/lib/gcprest.ts index 45490659b4..b9e11119af 100644 --- a/ui/src/lib/gcprest.ts +++ b/ui/src/lib/gcprest.ts @@ -19,9 +19,13 @@ async function failure(res: Response): Promise { let code = ""; let message = res.statusText || `HTTP ${res.status}`; try { - const body = (await res.json()) as { error?: { message?: string; status?: string } }; - message = body.error?.message ?? message; - code = body.error?.status ?? ""; + const body = (await res.json()) as { + error?: { message?: string; status?: string }; + code?: string; + message?: string; + }; + message = body.error?.message ?? body.message ?? message; + code = body.error?.status ?? body.code ?? ""; } catch { // non-JSON error body: keep the status text } diff --git a/ui/src/lib/icon-manifest.ts b/ui/src/lib/icon-manifest.ts index 09fe48bbab..b560d0ad53 100644 --- a/ui/src/lib/icon-manifest.ts +++ b/ui/src/lib/icon-manifest.ts @@ -22,19 +22,25 @@ export const ICON_IDS: ReadonlySet = new Set([ "awsconfig", "azure", "azure-aci", + "azure-aks", + "azure-apim", "azure-appconfig", + "azure-appservice", "azure-arm", "azure-blob", + "azure-containerapps", "azure-cosmosdb", "azure-eventgrid", "azure-eventhubs", "azure-file", + "azure-flexdb", "azure-functions", "azure-keyvault", "azure-monitor", "azure-queue", "azure-servicebus", "azure-table", + "azure-vm", "backup", "batch", "bedrock", @@ -92,22 +98,28 @@ export const ICON_IDS: ReadonlySet = new Set([ "forecast", "fsx", "gcp", + "gcp-apigateway", "gcp-artifactregistry", + "gcp-batch", "gcp-bigquery", "gcp-bigtable", "gcp-cloudbuild", "gcp-cloudtasks", "gcp-compute", + "gcp-container", + "gcp-datastore", "gcp-dns", "gcp-eventarc", "gcp-firestore", "gcp-functions", "gcp-iam", + "gcp-identitytoolkit", "gcp-kms", "gcp-logging", "gcp-monitoring", "gcp-pubsub", "gcp-redis", + "gcp-resourcemanager", "gcp-run", "gcp-scheduler", "gcp-secretmanager", @@ -150,9 +162,22 @@ export const ICON_IDS: ReadonlySet = new Set([ "neptune", "networkmanager", "oci", + "oci-apigateway", + "oci-artifacts", + "oci-containerengine", + "oci-containerinstances", + "oci-core", + "oci-events", "oci-functions", "oci-identity", + "oci-loadbalancer", + "oci-logging", + "oci-monitoring", + "oci-mysql", + "oci-nosql", + "oci-notifications", "oci-objectstorage", + "oci-queue", "oci-streaming", "oci-vault", "opensearch", diff --git a/ui/src/lib/ociapigateway.test.ts b/ui/src/lib/ociapigateway.test.ts new file mode 100644 index 0000000000..8d7b2b705f --- /dev/null +++ b/ui/src/lib/ociapigateway.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + APIGW_PROXY_BASE, + backendLabel, + getGateway, + isServing, + joinPath, + listCertificates, + listCompartments, + listDeployments, + listGateways, + parseCertificate, + parseDeployment, + parseGateway, + parseRoute, + prettyBody, + summarizePolicies, + tryIt, +} from "./ociapigateway"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; +const q = `compartmentId=${encodeURIComponent(comp)}`; + +const deployment = { + id: "d1", + displayName: "orders", + lifecycleState: "ACTIVE", + gatewayId: "g1", + pathPrefix: "/v1", + endpoint: "https://gw.example/v1", + specification: { + requestPolicies: { rateLimiting: { rateInRequestsPerSecond: 5, rateKey: "CLIENT_IP" } }, + routes: [ + { + path: "/hello", + methods: ["GET", "POST"], + backend: { type: "STOCK_RESPONSE_BACKEND", status: 200, body: "hi" }, + requestPolicies: { + cors: { allowedOrigins: ["*"] }, + headerTransformations: { setHeaders: { items: [{ name: "a" }, { name: "b" }] } }, + }, + responsePolicies: { headerTransformations: { filterHeaders: { items: [{ name: "x" }] } } }, + }, + { + path: "/up", + methods: ["ANY"], + backend: { type: "HTTP_BACKEND", url: "https://up.example" }, + }, + ], + }, +}; + +describe("parsing", () => { + it("parses a gateway", () => { + expect( + parseGateway({ + id: "g1", + displayName: "edge", + lifecycleState: "ACTIVE", + hostname: "h.apigateway.us-ashburn-1.oci.customer-oci.com", + endpointType: "PUBLIC", + }), + ).toMatchObject({ + name: "edge", + state: "ACTIVE", + endpointType: "PUBLIC", + hostname: expect.any(String), + }); + expect(isServing({ state: "ACTIVE" })).toBe(true); + expect(isServing({ state: "CREATING" })).toBe(false); + }); + + it("parses routes with backends and policy summaries", () => { + const d = parseDeployment(deployment); + expect(d.policies).toEqual(["rate 5/s client_ip"]); + expect(d.routes[0]).toEqual({ + path: "/hello", + methods: ["GET", "POST"], + backendType: "STOCK_RESPONSE_BACKEND", + backend: "HTTP 200", + policies: ["CORS", "2 request header rules", "1 response header rule"], + }); + expect(d.routes[1]).toMatchObject({ backend: "https://up.example" }); + expect( + parseRoute({ path: "/x", backend: { type: "ORACLE_FUNCTIONS_BACKEND", functionId: "fn" } }) + .backend, + ).toBe("fn"); + expect( + parseRoute({ + path: "/x", + backend: { type: "DYNAMIC_ROUTING_BACKEND", routingBackends: [{}, {}] }, + }).backend, + ).toBe("2 backends"); + }); + + it("summarizes authentication and authorization", () => { + expect( + summarizePolicies({ + authentication: { type: "JWT_AUTHENTICATION" }, + authorization: { type: "ANY_OF" }, + }), + ).toEqual(["JWT auth", "authz any_of"]); + expect(summarizePolicies({})).toEqual([]); + }); + + it("labels backends", () => { + expect(backendLabel("HTTP_BACKEND")).toBe("HTTP"); + expect(backendLabel("")).toBe("—"); + }); + + it("parses certificates without key material", () => { + const c = parseCertificate({ + id: "c1", + displayName: "tls", + lifecycleState: "ACTIVE", + subjectNames: ["a.example", "b.example"], + timeNotValidAfter: "2030-01-01T00:00:00Z", + privateKey: "must-not-surface", + }); + expect(c).toEqual({ + id: "c1", + name: "tls", + state: "ACTIVE", + subjects: ["a.example", "b.example"], + notAfter: "2030-01-01T00:00:00Z", + created: "", + }); + expect(JSON.stringify(c)).not.toContain("must-not-surface"); + }); + + it("joins prefixes and route paths", () => { + expect(joinPath("/v1", "/hello")).toBe("/v1/hello"); + expect(joinPath("/v1/", "hello")).toBe("/v1/hello"); + expect(joinPath("/", "/x")).toBe("/x"); + expect(joinPath("/", "/")).toBe("/"); + }); + + it("pretty-prints JSON and flags binary bodies", () => { + expect(prettyBody({ body: '{"a":1}', encoding: "utf8" })).toBe('{\n "a": 1\n}'); + expect(prettyBody({ body: "plain", encoding: "utf8" })).toBe("plain"); + expect(prettyBody({ body: "AAE=", encoding: "base64" })).toContain("binary"); + }); +}); + +describe("client", () => { + it("lists compartments, gateways and certificates", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.endsWith("/compartments") + ? { compartments: [comp] } + : { items: [{ id: "x", displayName: "n" }] }, + ), + ), + ); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + expect(await listGateways(comp, asFetch(fetchFn))).toHaveLength(1); + expect(await listCertificates(comp, asFetch(fetchFn))).toHaveLength(1); + expect(fetchFn.mock.calls[1]?.[0]).toBe(`${APIGW_PROXY_BASE}/api/gateways?${q}`); + }); + + it("reads one gateway", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ id: "g1", displayName: "edge" }))); + expect((await getGateway("g1", asFetch(fetchFn))).name).toBe("edge"); + }); + + it("fetches each deployment of a gateway for its routes", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json(url.includes("/deployments/d1") ? deployment : { items: [{ id: "d1" }] }), + ), + ); + const out = await listDeployments(comp, "g1", asFetch(fetchFn)); + expect(out).toHaveLength(1); + expect(out[0]?.routes).toHaveLength(2); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${APIGW_PROXY_BASE}/api/deployments?${q}&gatewayId=g1`, + ); + }); + + it("sends a try-it request to the selected gateway only", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve( + json({ + status: 200, + headers: { "Content-Type": "text/plain" }, + body: "hi", + encoding: "utf8", + truncated: false, + }), + ), + ); + const res = await tryIt( + "ocid1.apigateway.oc1..g", + { + method: "POST", + path: "/v1/hello", + query: "?a=1", + body: "x", + headers: [ + { name: "X-A", value: "1" }, + { name: " ", value: "skip" }, + ], + }, + asFetch(fetchFn), + ); + expect(res).toEqual({ + status: 200, + headers: { "Content-Type": "text/plain" }, + body: "hi", + encoding: "utf8", + truncated: false, + }); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${APIGW_PROXY_BASE}/api/gateways/ocid1.apigateway.oc1..g/invoke`, + ); + expect(JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body))).toEqual({ + method: "POST", + path: "/v1/hello", + query: "a=1", + body: "x", + headers: [{ name: "X-A", value: "1" }], + }); + }); + + it("surfaces proxy errors from try-it", async () => { + const fetchFn = vi.fn(() => + Promise.resolve(json({ code: "InvalidParameter", message: "bad" }, 400)), + ); + await expect( + tryIt("g", { method: "GET", path: "x", query: "", body: "", headers: [] }, asFetch(fetchFn)), + ).rejects.toMatchObject({ httpStatus: 400, code: "InvalidParameter" }); + }); +}); diff --git a/ui/src/lib/ociapigateway.ts b/ui/src/lib/ociapigateway.ts new file mode 100644 index 0000000000..920e27fdd3 --- /dev/null +++ b/ui/src/lib/ociapigateway.ts @@ -0,0 +1,301 @@ +/** Client for the Oracle Cloud API Gateway emulator: gateways, deployments, certificates and a data-plane "try it" call via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + ociPostOne, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { str, type FetchFn, type Json } from "./gcprest.ts"; + +export const APIGW_PROXY_BASE = "/dashboard/api/v1/oci/apigateway"; +export const APIGW_COMPARTMENT_KEY = "gopherstack_ociapigateway_compartment"; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const strs = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); + +export type ApigwGateway = { + id: string; + name: string; + state: string; + hostname: string; + endpointType: string; + subnetId: string; + certificateId: string; + ipMode: string; + created: string; +}; + +export type ApigwRoute = { + path: string; + methods: string[]; + backendType: string; + backend: string; + policies: string[]; +}; + +export type ApigwDeployment = { + id: string; + name: string; + state: string; + gatewayId: string; + pathPrefix: string; + endpoint: string; + policies: string[]; + routes: ApigwRoute[]; + created: string; +}; + +export type ApigwCertificate = { + id: string; + name: string; + state: string; + subjects: string[]; + notAfter: string; + created: string; +}; + +export type TryRequest = { + method: string; + path: string; + query: string; + body: string; + headers: { name: string; value: string }[]; +}; + +export type TryResponse = { + status: number; + headers: Record; + body: string; + encoding: string; + truncated: boolean; +}; + +export const TRY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +export function parseGateway(raw: Json): ApigwGateway { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + hostname: str(raw.hostname), + endpointType: str(raw.endpointType), + subnetId: str(raw.subnetId), + certificateId: str(raw.certificateId), + ipMode: str(raw.ipMode), + created: str(raw.timeCreated), + }; +} + +const BACKEND_LABEL: Record = { + HTTP_BACKEND: "HTTP", + ORACLE_FUNCTIONS_BACKEND: "Functions", + STOCK_RESPONSE_BACKEND: "Stock response", + DYNAMIC_ROUTING_BACKEND: "Dynamic routing", +}; + +/** Human label for a backend discriminator. */ +export const backendLabel = (type: string): string => BACKEND_LABEL[type] ?? (type || "—"); + +function backendDetail(b: Json): string { + switch (str(b.type)) { + case "HTTP_BACKEND": + return str(b.url); + case "ORACLE_FUNCTIONS_BACKEND": + return str(b.functionId); + case "STOCK_RESPONSE_BACKEND": + return `HTTP ${num(b.status)}`; + case "DYNAMIC_ROUTING_BACKEND": + return `${list(b.routingBackends).length} backends`; + default: + return ""; + } +} + +const AUTH_LABEL: Record = { + JWT_AUTHENTICATION: "JWT auth", + TOKEN_AUTHENTICATION: "Token auth", + CUSTOM_AUTHENTICATION: "Custom auth", +}; + +function transformCount(t: Json): number { + return Object.values(t).reduce((n, v) => n + list(obj(v).items).length, 0); +} + +/** One-line labels for the request/response policies in a policy block (deployment or route level). */ +export function summarizePolicies(request: Json, response: Json = {}): string[] { + const out: string[] = []; + const auth = obj(request.authentication); + if (str(auth.type)) out.push(AUTH_LABEL[str(auth.type)] ?? str(auth.type)); + const authz = obj(request.authorization); + if (str(authz.type)) out.push(`authz ${str(authz.type).toLowerCase()}`); + const rate = obj(request.rateLimiting); + if (rate.rateInRequestsPerSecond !== undefined) { + out.push( + `rate ${num(rate.rateInRequestsPerSecond)}/s ${str(rate.rateKey).toLowerCase()}`.trim(), + ); + } + if (request.cors) out.push("CORS"); + const reqT = transformCount(obj(request.headerTransformations)); + const qT = transformCount(obj(request.queryParameterTransformations)); + const resT = transformCount(obj(response.headerTransformations)); + if (reqT) out.push(`${reqT} request header rule${reqT === 1 ? "" : "s"}`); + if (qT) out.push(`${qT} query rule${qT === 1 ? "" : "s"}`); + if (resT) out.push(`${resT} response header rule${resT === 1 ? "" : "s"}`); + return out; +} + +export function parseRoute(raw: Json): ApigwRoute { + const backend = obj(raw.backend); + return { + path: str(raw.path), + methods: strs(raw.methods), + backendType: str(backend.type), + backend: backendDetail(backend), + policies: summarizePolicies(obj(raw.requestPolicies), obj(raw.responsePolicies)), + }; +} + +export function parseDeployment(raw: Json): ApigwDeployment { + const spec = obj(raw.specification); + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + gatewayId: str(raw.gatewayId), + pathPrefix: str(raw.pathPrefix), + endpoint: str(raw.endpoint), + policies: summarizePolicies(obj(spec.requestPolicies)), + routes: list(spec.routes).map((r) => parseRoute(r)), + created: str(raw.timeCreated), + }; +} + +/** Reads a certificate summary; the list answer carries subjects and expiry, never key material. */ +export function parseCertificate(raw: Json): ApigwCertificate { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + subjects: strs(raw.subjectNames), + notAfter: str(raw.timeNotValidAfter), + created: str(raw.timeCreated), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(APIGW_PROXY_BASE, fetchFn); + +const cq = (compartment: string) => `compartmentId=${enc(compartment)}`; + +export async function listGateways( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + APIGW_PROXY_BASE, + "/api/gateways", + cq(compartment), + "items", + fetchFn, + ); + return items.map((r) => parseGateway(r)); +} + +export async function getGateway(id: string, fetchFn: FetchFn = fetch): Promise { + return parseGateway(await ociGetOne(APIGW_PROXY_BASE, `/api/gateways/${enc(id)}`, fetchFn)); +} + +/** Deployments of one gateway with their routes (the list answer omits the specification, so each is fetched). */ +export async function listDeployments( + compartment: string, + gatewayId: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + APIGW_PROXY_BASE, + "/api/deployments", + `${cq(compartment)}&gatewayId=${enc(gatewayId)}`, + "items", + fetchFn, + ); + return Promise.all( + items.map(async (d) => + parseDeployment( + await ociGetOne(APIGW_PROXY_BASE, `/api/deployments/${enc(str(d.id))}`, fetchFn), + ), + ), + ); +} + +export async function listCertificates( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + APIGW_PROXY_BASE, + "/api/certificates", + cq(compartment), + "items", + fetchFn, + ); + return items.map((r) => parseCertificate(r)); +} + +/** Request path through a deployment: its prefix joined to a route path. */ +export function joinPath(prefix: string, route: string): string { + const p = prefix.replace(/\/+$/, ""); + const r = route.startsWith("/") ? route : `/${route}`; + return `${p}${r}` || "/"; +} + +/** Sends a request to the selected gateway's in-process data plane; the proxy fixes the destination. */ +export async function tryIt( + gatewayId: string, + req: TryRequest, + fetchFn: FetchFn = fetch, +): Promise { + const { body } = await ociPostOne( + APIGW_PROXY_BASE, + `/api/gateways/${enc(gatewayId)}/invoke`, + { + method: req.method, + path: req.path, + query: req.query.replace(/^\?/, ""), + body: req.body, + headers: req.headers.filter((h) => h.name.trim()), + }, + fetchFn, + ); + const b = obj(body); + const headers: Record = {}; + for (const [k, v] of Object.entries(obj(b.headers))) headers[k] = String(v); + return { + status: num(b.status), + headers, + body: str(b.body), + encoding: str(b.encoding) || "utf8", + truncated: b.truncated === true, + }; +} + +/** Pretty-prints a response body when it is JSON. */ +export function prettyBody(r: Pick): string { + if (r.encoding === "base64") return `(binary, base64)\n${r.body}`; + try { + return JSON.stringify(JSON.parse(r.body), null, 2); + } catch { + return r.body; + } +} + +export const isServing = (g: Pick): boolean => g.state === "ACTIVE"; + +export const readStoredApigwCompartment = () => readStoredValue(APIGW_COMPARTMENT_KEY); +export const writeStoredApigwCompartment = (id: string) => + writeStoredValue(APIGW_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ociartifacts.test.ts b/ui/src/lib/ociartifacts.test.ts new file mode 100644 index 0000000000..7398023c7d --- /dev/null +++ b/ui/src/lib/ociartifacts.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ART_PROXY_BASE, + artifactDownloadUrl, + createGenericRepo, + createRepo, + deleteArtifact, + deleteGenericRepo, + deleteImage, + deleteRepo, + formatBytes, + getConfig, + imageTags, + listArtifacts, + listCompartments, + listGenericRepos, + listImages, + listRepos, + listSignatures, + parseArtifact, + parseImage, + parseRepo, + parseSignature, + registryHints, + restoreImage, + setCreatedOnFirstPush, + shortDigest, + updateRepo, + validName, +} from "./ociartifacts"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; + +const repoRaw = { + id: "repo1", + displayName: "team/web", + lifecycleState: "AVAILABLE", + isPublic: true, + isImmutable: false, + imageCount: 2, + layerCount: 5, + layersSizeInBytes: 2048, + timeCreated: "2026-01-01T00:00:00Z", + readme: { content: "# hi", format: "TEXT_MARKDOWN" }, +}; + +const imageRaw = { + id: "img1", + digest: "sha256:0123456789abcdef0123456789abcdef", + lifecycleState: "AVAILABLE", + repositoryId: "repo1", + repositoryName: "team/web", + versions: [{ version: "v1", timeCreated: "2026-01-02T00:00:00Z" }, { version: "latest" }], + layers: [{ digest: "sha256:aa", sizeInBytes: 10 }], + layersSizeInBytes: 10, + pullCount: 3, + timeLastPulled: "2026-01-03T00:00:00Z", +}; + +describe("parsers", () => { + it("parses a repository", () => { + expect(parseRepo(repoRaw)).toMatchObject({ + name: "team/web", + isPublic: true, + isImmutable: false, + imageCount: 2, + layerCount: 5, + sizeBytes: 2048, + readme: "# hi", + }); + expect(parseRepo({ id: "x" }).name).toBe("x"); + }); + + it("parses an image with versions and layers", () => { + const img = parseImage(imageRaw); + expect(imageTags(img)).toEqual(["v1", "latest"]); + expect(img.layers).toEqual([{ digest: "sha256:aa", sizeBytes: 10 }]); + expect(img.pullCount).toBe(3); + expect(img.lastPulled).toBe("2026-01-03T00:00:00Z"); + expect(parseImage({}).versions).toEqual([]); + }); + + it("parses a signature without its message or signature blobs", () => { + const sig = parseSignature({ + id: "s1", + kmsKeyId: "key", + signingAlgorithm: "SHA_256_RSA_PKCS_PSS", + displayName: "desc", + message: "SECRET", + signature: "SECRET", + }); + expect(sig).toMatchObject({ + keyId: "key", + algorithm: "SHA_256_RSA_PKCS_PSS", + description: "desc", + }); + expect(JSON.stringify(sig)).not.toContain("SECRET"); + }); + + it("parses an artifact", () => { + expect( + parseArtifact({ + id: "a", + artifactPath: "d/f.bin", + version: "1", + sizeInBytes: 4, + sha256: "AB", + }), + ).toMatchObject({ path: "d/f.bin", version: "1", sizeBytes: 4, sha256: "AB" }); + }); +}); + +describe("reads", () => { + it("lists compartments", async () => { + const f = vi.fn().mockResolvedValue(json({ compartments: ["c1", "c2"] })); + expect(await listCompartments(asFetch(f))).toEqual(["c1", "c2"]); + expect(f.mock.calls[0][0]).toBe(`${ART_PROXY_BASE}/compartments`); + }); + + it("lists repositories across pages with full records", async () => { + const f = vi.fn().mockImplementation((url: string) => { + if (url.includes("/containerRepositories/")) { + return Promise.resolve(json({ ...repoRaw, id: url.split("/").pop(), isImmutable: true })); + } + return Promise.resolve( + url.includes("page=p2") + ? json({ items: [{ id: "repo2" }] }) + : json({ items: [{ id: "repo1" }] }, 200, { "opc-next-page": "p2" }), + ); + }); + const repos = await listRepos(comp, asFetch(f)); + expect(repos.map((r) => [r.id, r.isImmutable])).toEqual([ + ["repo1", true], + ["repo2", true], + ]); + expect(f.mock.calls[0][0]).toBe( + `${ART_PROXY_BASE}/api/containerRepositories?compartmentId=${encodeURIComponent(comp)}`, + ); + }); + + it("lists images with details and can ask for deleted ones", async () => { + const f = vi + .fn() + .mockImplementation((url: string) => + Promise.resolve( + url.includes("/containerImages/") ? json(imageRaw) : json({ items: [{ id: "img1" }] }), + ), + ); + const imgs = await listImages(comp, "repo1", true, asFetch(f)); + expect(imgs).toHaveLength(1); + expect(imgs[0].layers).toHaveLength(1); + expect(f.mock.calls[0][0]).toContain("repositoryId=repo1"); + expect(f.mock.calls[0][0]).toContain("lifecycleState=DELETED"); + }); + + it("lists signatures of an image", async () => { + const f = vi.fn().mockResolvedValue(json({ items: [{ id: "s", kmsKeyId: "k" }] })); + expect((await listSignatures(comp, "img1", asFetch(f)))[0].keyId).toBe("k"); + expect(f.mock.calls[0][0]).toContain("imageId=img1"); + }); + + it("reads the container configuration", async () => { + const f = vi + .fn() + .mockResolvedValue(json({ namespace: "ns", isRepositoryCreatedOnFirstPush: true })); + expect(await getConfig(comp, asFetch(f))).toEqual({ + namespace: "ns", + createdOnFirstPush: true, + }); + }); + + it("lists generic repositories and artifacts", async () => { + const f = vi + .fn() + .mockResolvedValueOnce( + json({ items: [{ id: "g", displayName: "files", isImmutable: true }] }), + ) + .mockResolvedValueOnce(json({ items: [{ id: "a", artifactPath: "p", version: "1" }] })); + expect((await listGenericRepos(comp, asFetch(f)))[0]).toMatchObject({ + name: "files", + isImmutable: true, + }); + expect((await listArtifacts(comp, "g", asFetch(f)))[0].path).toBe("p"); + expect(f.mock.calls[1][0]).toContain("repositoryId=g"); + }); + + it("surfaces proxy errors", async () => { + const f = vi + .fn() + .mockResolvedValue(json({ code: "NotAuthorizedOrNotFound", message: "gone" }, 404)); + await expect(listRepos(comp, asFetch(f))).rejects.toMatchObject({ + httpStatus: 404, + code: "NotAuthorizedOrNotFound", + }); + }); +}); + +const ok = () => vi.fn().mockImplementation(() => Promise.resolve(json({}))); + +describe("writes", () => { + it("creates a repository with an optional readme", async () => { + const f = ok(); + await createRepo( + { compartmentId: comp, displayName: "a/b", isPublic: true, isImmutable: false, readme: "" }, + asFetch(f), + ); + expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ + compartmentId: comp, + displayName: "a/b", + isPublic: true, + isImmutable: false, + }); + await createRepo( + { compartmentId: comp, displayName: "a/b", isPublic: false, isImmutable: true, readme: "x" }, + asFetch(f), + ); + expect(JSON.parse(f.mock.calls[1][1].body).readme).toEqual({ + content: "x", + format: "TEXT_MARKDOWN", + }); + }); + + it("updates only the given repository fields", async () => { + const f = ok(); + await updateRepo("repo1", { isPublic: false }, asFetch(f)); + expect(f.mock.calls[0][1].method).toBe("PUT"); + expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ isPublic: false }); + await updateRepo("repo1", { readme: "r" }, asFetch(f)); + expect(JSON.parse(f.mock.calls[1][1].body)).toEqual({ + readme: { content: "r", format: "TEXT_MARKDOWN" }, + }); + }); + + it("deletes and restores", async () => { + const f = ok(); + await deleteRepo("r 1", asFetch(f)); + await deleteImage("i1", asFetch(f)); + await restoreImage("i1", asFetch(f)); + await deleteGenericRepo("g1", asFetch(f)); + await deleteArtifact("a1", asFetch(f)); + expect(f.mock.calls.map((c) => [c[1].method, c[0].replace(ART_PROXY_BASE, "")])).toEqual([ + ["DELETE", "/api/containerRepositories/r%201"], + ["DELETE", "/api/containerImages/i1"], + ["POST", "/api/containerImages/i1/restore"], + ["DELETE", "/api/genericRepositories/g1"], + ["DELETE", "/api/genericArtifacts/a1"], + ]); + }); + + it("toggles repository creation on first push", async () => { + const f = ok(); + await setCreatedOnFirstPush(comp, false, asFetch(f)); + expect(f.mock.calls[0][1].method).toBe("PUT"); + expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ isRepositoryCreatedOnFirstPush: false }); + }); + + it("creates a generic repository", async () => { + const f = ok(); + await createGenericRepo( + { compartmentId: comp, displayName: "files", description: "d", isImmutable: false }, + asFetch(f), + ); + expect(f.mock.calls[0][0]).toBe(`${ART_PROXY_BASE}/api/genericRepositories`); + expect(JSON.parse(f.mock.calls[0][1].body).displayName).toBe("files"); + }); +}); + +describe("helpers", () => { + it("builds the download url through the proxy", () => { + expect(artifactDownloadUrl("a/b")).toBe(`${ART_PROXY_BASE}/api/genericArtifacts/a%2Fb/content`); + }); + + it("formats sizes and digests", () => { + expect(formatBytes(12)).toBe("12 B"); + expect(formatBytes(1536)).toBe("1.5 KiB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB"); + expect(shortDigest("sha256:0123456789abcdef0123")).toBe("sha256:0123456789ab"); + expect(shortDigest("plain")).toBe("plain"); + }); + + it("builds docker commands without any credential value", () => { + const h = registryHints("localhost", 8166, "ns", "team/web", "v1"); + expect(h.repo).toBe("localhost:8166/ns/team/web"); + expect(h.push).toContain("docker push localhost:8166/ns/team/web:v1"); + expect(h.pull).toBe("docker pull localhost:8166/ns/team/web:v1"); + expect(h.login).toContain("docker login localhost:8166 -u ns/"); + expect(h.login).not.toMatch(/(-p|--password)\s/); + expect(registryHints("h", undefined, "ns", "r", "").pull).toBe("docker pull h/ns/r:latest"); + }); + + it("validates repository names", () => { + expect(validName("team/web-1.x")).toBe(true); + expect(validName("Upper")).toBe(false); + expect(validName("")).toBe(false); + expect(validName("a//b")).toBe(false); + }); +}); diff --git a/ui/src/lib/ociartifacts.ts b/ui/src/lib/ociartifacts.ts new file mode 100644 index 0000000000..149d88f3ef --- /dev/null +++ b/ui/src/lib/ociartifacts.ts @@ -0,0 +1,425 @@ +/** Client for the Oracle Cloud Artifacts and Container Registry emulator through the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const ART_PROXY_BASE = "/dashboard/api/v1/oci/artifacts"; +export const ART_COMPARTMENT_KEY = "gopherstack_ociart_compartment"; +export const ART_DOWNLOAD_LIMIT_MIB = 64; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); + +export type ContainerRepo = { + id: string; + name: string; + state: string; + isPublic: boolean; + isImmutable: boolean; + imageCount: number; + layerCount: number; + sizeBytes: number; + created: string; + lastPushed: string; + readme: string; + readmeFormat: string; +}; + +export type ImageVersion = { version: string; created: string }; +export type ImageLayer = { digest: string; sizeBytes: number }; + +export type ContainerImage = { + id: string; + digest: string; + name: string; + state: string; + repositoryId: string; + repositoryName: string; + versions: ImageVersion[]; + layers: ImageLayer[]; + sizeBytes: number; + created: string; + pullCount: number; + lastPulled: string; +}; + +export type ImageSignature = { + id: string; + keyId: string; + keyVersionId: string; + algorithm: string; + description: string; + created: string; +}; + +export type ContainerConfig = { namespace: string; createdOnFirstPush: boolean }; + +export type GenericRepo = { + id: string; + name: string; + description: string; + isImmutable: boolean; + created: string; +}; + +export type GenericArtifact = { + id: string; + path: string; + version: string; + sizeBytes: number; + sha256: string; + state: string; + created: string; +}; + +export function parseRepo(raw: Json): ContainerRepo { + const readme = obj(raw.readme); + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + isPublic: raw.isPublic === true, + isImmutable: raw.isImmutable === true, + imageCount: num(raw.imageCount), + layerCount: num(raw.layerCount), + sizeBytes: num(raw.layersSizeInBytes), + created: str(raw.timeCreated), + lastPushed: str(raw.timeLastPushed), + readme: str(readme.content), + readmeFormat: str(readme.format), + }; +} + +export function parseImage(raw: Json): ContainerImage { + return { + id: str(raw.id), + digest: str(raw.digest), + name: str(raw.displayName), + state: str(raw.lifecycleState), + repositoryId: str(raw.repositoryId), + repositoryName: str(raw.repositoryName), + versions: list(raw.versions).map((v) => ({ + version: str(v.version), + created: str(v.timeCreated), + })), + layers: list(raw.layers).map((l) => ({ + digest: str(l.digest), + sizeBytes: num(l.sizeInBytes), + })), + sizeBytes: num(raw.layersSizeInBytes), + created: str(raw.timeCreated), + pullCount: num(raw.pullCount), + lastPulled: str(raw.timeLastPulled), + }; +} + +/** Version tags of an image, in push order. */ +export function imageTags(img: Pick): string[] { + return img.versions.map((v) => v.version); +} + +export function parseSignature(raw: Json): ImageSignature { + return { + id: str(raw.id), + keyId: str(raw.kmsKeyId), + keyVersionId: str(raw.kmsKeyVersionId), + algorithm: str(raw.signingAlgorithm), + description: str(raw.displayName), + created: str(raw.timeCreated), + }; +} + +export function parseGenericRepo(raw: Json): GenericRepo { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + description: str(raw.description), + isImmutable: raw.isImmutable === true, + created: str(raw.timeCreated), + }; +} + +export function parseArtifact(raw: Json): GenericArtifact { + return { + id: str(raw.id), + path: str(raw.artifactPath), + version: str(raw.version), + sizeBytes: num(raw.sizeInBytes), + sha256: str(raw.sha256), + state: str(raw.lifecycleState), + created: str(raw.timeCreated), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(ART_PROXY_BASE, fetchFn); + +const compQuery = (compartment: string) => `compartmentId=${enc(compartment)}`; + +/** Repositories of one compartment. The list answer omits isImmutable and the readme, so each is fetched. */ +export async function listRepos( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + ART_PROXY_BASE, + "/api/containerRepositories", + compQuery(compartment), + "items", + fetchFn, + ); + return Promise.all(items.map((i) => getRepo(str(i.id), fetchFn))); +} + +export async function getRepo(id: string, fetchFn: FetchFn = fetch): Promise { + return parseRepo( + await ociGetOne(ART_PROXY_BASE, `/api/containerRepositories/${enc(id)}`, fetchFn), + ); +} + +/** Images of one repository, optionally the soft-deleted ones. Full records are fetched for layers, versions and pulls. */ +export async function listImages( + compartment: string, + repositoryId: string, + deleted = false, + fetchFn: FetchFn = fetch, +): Promise { + const q = [ + compQuery(compartment), + `repositoryId=${enc(repositoryId)}`, + deleted ? "lifecycleState=DELETED" : "", + ] + .filter(Boolean) + .join("&"); + const items = await ociListPages(ART_PROXY_BASE, "/api/containerImages", q, "items", fetchFn); + return Promise.all( + items.map(async (i) => + parseImage( + await ociGetOne(ART_PROXY_BASE, `/api/containerImages/${enc(str(i.id))}`, fetchFn), + ), + ), + ); +} + +export async function listSignatures( + compartment: string, + imageId: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + ART_PROXY_BASE, + "/api/containerImageSignatures", + `${compQuery(compartment)}&imageId=${enc(imageId)}`, + "items", + fetchFn, + ); + return items.map((i) => parseSignature(i)); +} + +export async function getConfig( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const raw = await ociGetOne( + ART_PROXY_BASE, + `/api/containerConfiguration?${compQuery(compartment)}`, + fetchFn, + ); + return { + namespace: str(raw.namespace), + createdOnFirstPush: raw.isRepositoryCreatedOnFirstPush === true, + }; +} + +export async function setCreatedOnFirstPush( + compartment: string, + value: boolean, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + ART_PROXY_BASE, + "PUT", + `/api/containerConfiguration?${compQuery(compartment)}`, + { isRepositoryCreatedOnFirstPush: value }, + fetchFn, + ); +} + +export type NewRepo = { + compartmentId: string; + displayName: string; + isPublic: boolean; + isImmutable: boolean; + readme: string; +}; + +export async function createRepo(r: NewRepo, fetchFn: FetchFn = fetch): Promise { + const { readme, ...rest } = r; + await restCall( + ART_PROXY_BASE, + "POST", + "/api/containerRepositories", + readme.trim() === "" ? rest : { ...rest, readme: { content: readme, format: "TEXT_MARKDOWN" } }, + fetchFn, + ); +} + +export type RepoChange = { isPublic?: boolean; isImmutable?: boolean; readme?: string }; + +export async function updateRepo( + id: string, + change: RepoChange, + fetchFn: FetchFn = fetch, +): Promise { + const { readme, ...rest } = change; + await restCall( + ART_PROXY_BASE, + "PUT", + `/api/containerRepositories/${enc(id)}`, + readme === undefined ? rest : { ...rest, readme: { content: readme, format: "TEXT_MARKDOWN" } }, + fetchFn, + ); +} + +export async function deleteRepo(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall( + ART_PROXY_BASE, + "DELETE", + `/api/containerRepositories/${enc(id)}`, + undefined, + fetchFn, + ); +} + +export async function deleteImage(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall(ART_PROXY_BASE, "DELETE", `/api/containerImages/${enc(id)}`, undefined, fetchFn); +} + +export async function restoreImage(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall( + ART_PROXY_BASE, + "POST", + `/api/containerImages/${enc(id)}/restore`, + undefined, + fetchFn, + ); +} + +export async function listGenericRepos( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + ART_PROXY_BASE, + "/api/genericRepositories", + compQuery(compartment), + "items", + fetchFn, + ); + return items.map((i) => parseGenericRepo(i)); +} + +export type NewGenericRepo = { + compartmentId: string; + displayName: string; + description: string; + isImmutable: boolean; +}; + +export async function createGenericRepo( + r: NewGenericRepo, + fetchFn: FetchFn = fetch, +): Promise { + await restCall(ART_PROXY_BASE, "POST", "/api/genericRepositories", r, fetchFn); +} + +export async function deleteGenericRepo(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall( + ART_PROXY_BASE, + "DELETE", + `/api/genericRepositories/${enc(id)}`, + undefined, + fetchFn, + ); +} + +export async function listArtifacts( + compartment: string, + repositoryId: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + ART_PROXY_BASE, + "/api/genericArtifacts", + `${compQuery(compartment)}&repositoryId=${enc(repositoryId)}`, + "items", + fetchFn, + ); + return items.map((i) => parseArtifact(i)); +} + +export async function deleteArtifact(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall(ART_PROXY_BASE, "DELETE", `/api/genericArtifacts/${enc(id)}`, undefined, fetchFn); +} + +/** Download URL served by the proxy as an attachment (size-capped, never rendered inline). */ +export const artifactDownloadUrl = (id: string): string => + `${ART_PROXY_BASE}/api/genericArtifacts/${enc(id)}/content`; + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`; +} + +export const shortDigest = (digest: string): string => + digest.startsWith("sha256:") ? `sha256:${digest.slice(7, 19)}` : digest.slice(0, 19); + +export type RegistryHints = { + host: string; + repo: string; + login: string; + push: string; + pull: string; +}; + +/** `docker` commands for a repository. No credential value is ever included: the password is an OCI auth token the user holds. */ +export function registryHints( + hostname: string, + port: number | undefined, + namespace: string, + repoName: string, + tag: string, +): RegistryHints { + const host = port ? `${hostname}:${port}` : hostname; + const repo = `${host}/${namespace}/${repoName}`; + const t = tag || "latest"; + return { + host, + repo, + login: `docker login ${host} -u ${namespace}/ # password: your OCI auth token`, + push: `docker tag ${repo}:${t} && docker push ${repo}:${t}`, + pull: `docker pull ${repo}:${t}`, + }; +} + +export const validName = (v: string): boolean => + /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*$/.test(v.trim()) && + v.trim().length <= 255; + +export const readStoredArtCompartment = () => readStoredValue(ART_COMPARTMENT_KEY); +export const writeStoredArtCompartment = (id: string) => writeStoredValue(ART_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocicontainerengine.test.ts b/ui/src/lib/ocicontainerengine.test.ts new file mode 100644 index 0000000000..0a43a16bfd --- /dev/null +++ b/ui/src/lib/ocicontainerengine.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + OKE_PROXY_BASE, + deleteCluster, + kubeconfigCommand, + listClusters, + listCompartments, + listNodePools, + listWorkRequests, + parseCluster, + parseNodePool, + parseWorkRequest, + scaleNodePool, + validPoolSize, +} from "./ocicontainerengine"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const initOf = (f: { mock: { calls: unknown[][] } }, i: number): RequestInit => + (f.mock.calls[i] ?? [])[1] as RequestInit; + +const comp = "ocid1.compartment.oc1..aaaa"; +const cluster = (id: string, name: string) => ({ + id, + name, + compartmentId: comp, + lifecycleState: "ACTIVE", + kubernetesVersion: "v1.34.2", + type: "BASIC_CLUSTER", + vcnId: "ocid1.vcn.oc1.iad.vcna", + endpoints: { kubernetes: "10.0.0.5:6443", publicEndpoint: "203.0.113.7:6443" }, + availableKubernetesUpgrades: ["v1.35.2"], + metadata: { timeCreated: "2026-10-01T00:00:00Z" }, +}); +const pool = (id: string, name: string, extra: Record = {}) => ({ + id, + clusterId: "c1", + name, + lifecycleState: "ACTIVE", + kubernetesVersion: "v1.34.2", + nodeShape: "VM.Standard.E4.Flex", + nodeShapeConfig: { ocpus: 2, memoryInGBs: 16 }, + nodeConfigDetails: { + size: 2, + placementConfigs: [{ availabilityDomain: "Uocm:US-ASHBURN-AD-1", subnetId: "s1" }], + }, + nodes: [ + { id: "n1", name: "node-1", lifecycleState: "ACTIVE", privateIp: "10.0.1.4" }, + { id: "n2", name: "node-2", lifecycleState: "ACTIVE" }, + ], + ...extra, +}); + +describe("parsing", () => { + it("parses a cluster", () => { + expect(parseCluster(cluster("c1", "prod"))).toMatchObject({ + id: "c1", + name: "prod", + state: "ACTIVE", + version: "v1.34.2", + publicEndpoint: "203.0.113.7:6443", + kubernetesEndpoint: "10.0.0.5:6443", + upgrades: ["v1.35.2"], + created: "2026-10-01T00:00:00Z", + }); + }); + + it("parses a node pool with placements and nodes", () => { + const p = parseNodePool(pool("p1", "workers")); + expect(p).toMatchObject({ + shape: "VM.Standard.E4.Flex", + ocpus: 2, + memoryGbs: 16, + size: 2, + legacySize: false, + }); + expect(p.placements).toEqual([{ availabilityDomain: "Uocm:US-ASHBURN-AD-1", subnetId: "s1" }]); + expect(p.nodes.map((n) => n.name)).toEqual(["node-1", "node-2"]); + }); + + it("reads quantityPerSubnet for a legacy pool", () => { + const p = parseNodePool({ id: "p", name: "old", quantityPerSubnet: 3, subnetIds: ["s"] }); + expect(p).toMatchObject({ size: 3, legacySize: true }); + }); + + it("parses a work request", () => { + expect( + parseWorkRequest({ + id: "wr", + operationType: "CLUSTER_CREATE", + status: "SUCCEEDED", + timeAccepted: "a", + timeFinished: "b", + resources: [{ identifier: "c1" }], + }), + ).toEqual({ + id: "wr", + operation: "CLUSTER_CREATE", + status: "SUCCEEDED", + accepted: "a", + finished: "b", + resource: "c1", + }); + }); + + it.each([ + ["0", true], + ["5", true], + ["1000", true], + ["1001", false], + ["-2", false], + ["2.5", false], + ["", false], + ])("validates pool size %j", (v, ok) => { + expect(validPoolSize(v)).toBe(ok); + }); + + it("offers the CLI command instead of credentials", () => { + expect(kubeconfigCommand({ id: "ocid1.cluster.oc1..c" }, "http://localhost:8158")).toBe( + "oci ce cluster create-kubeconfig --cluster-id ocid1.cluster.oc1..c --file $HOME/.kube/config --endpoint http://localhost:8158", + ); + }); +}); + +describe("client", () => { + it("lists compartments", async () => { + const fetchFn = vi.fn((_u: string) => Promise.resolve(json({ compartments: [comp] }))); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${OKE_PROXY_BASE}/compartments`); + }); + + it("follows opc-next-page when listing clusters", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(json([cluster("c1", "a")], 200, { "opc-next-page": "tok" })) + .mockResolvedValueOnce(json([cluster("c2", "b")])); + const out = await listClusters(comp, asFetch(fetchFn)); + expect(out.map((c) => c.name)).toEqual(["a", "b"]); + expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([ + `${OKE_PROXY_BASE}/api/clusters?compartmentId=${encodeURIComponent(comp)}`, + `${OKE_PROXY_BASE}/api/clusters?compartmentId=${encodeURIComponent(comp)}&page=tok`, + ]); + }); + + it("reads each pool to get its nodes, sorted by name", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + url.includes("/api/nodePools/") + ? json(pool(url.endsWith("/p2") ? "p2" : "p1", url.endsWith("/p2") ? "alpha" : "zeta")) + : json([{ id: "p1" }, { id: "p2" }]), + ), + ); + const out = await listNodePools(comp, "c1", asFetch(fetchFn)); + expect(out.map((p) => p.name)).toEqual(["alpha", "zeta"]); + expect(out[0]?.nodes).toHaveLength(2); + expect(fetchFn.mock.calls[0]?.[0]).toContain("clusterId=c1"); + }); + + it("lists work requests of a cluster newest first", async () => { + const fetchFn = vi.fn((_u: string) => + Promise.resolve(json([{ id: "wr", operationType: "CLUSTER_CREATE" }])), + ); + expect((await listWorkRequests(comp, "c1", asFetch(fetchFn)))[0]?.operation).toBe( + "CLUSTER_CREATE", + ); + expect(fetchFn.mock.calls[0]?.[0]).toContain( + "clusterId=c1&sortBy=TIME_ACCEPTED&sortOrder=DESC", + ); + }); + + it("deletes a cluster", async () => { + const fetchFn = vi.fn((_u: string, _i?: RequestInit) => + Promise.resolve(new Response(null, { status: 202 })), + ); + await deleteCluster("c1", asFetch(fetchFn)); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${OKE_PROXY_BASE}/api/clusters/c1`); + expect(initOf(fetchFn, 0).method).toBe("DELETE"); + }); + + it.each([ + [false, { nodeConfigDetails: { size: 4 } }], + [true, { quantityPerSubnet: 4 }], + ])("scales a pool (legacy=%s) sending only the size", async (legacySize, want) => { + const fetchFn = vi.fn((_u: string, _i?: RequestInit) => + Promise.resolve(new Response(null, { status: 202 })), + ); + await scaleNodePool({ id: "p1", legacySize }, 4, asFetch(fetchFn)); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${OKE_PROXY_BASE}/api/nodePools/p1`); + expect(initOf(fetchFn, 0).method).toBe("PUT"); + expect(JSON.parse(initOf(fetchFn, 0).body as string)).toEqual(want); + }); + + it("surfaces an OCI failure", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(json({ code: "Conflict", message: "pool is updating" }, 409)); + await expect( + scaleNodePool({ id: "p1", legacySize: false }, 1, asFetch(fetchFn)), + ).rejects.toMatchObject({ + httpStatus: 409, + message: "pool is updating", + }); + }); +}); diff --git a/ui/src/lib/ocicontainerengine.ts b/ui/src/lib/ocicontainerengine.ts new file mode 100644 index 0000000000..39a307212a --- /dev/null +++ b/ui/src/lib/ocicontainerengine.ts @@ -0,0 +1,214 @@ +/** Client for the Oracle Container Engine (OKE) emulator: clusters, node pools and work requests via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const OKE_PROXY_BASE = "/dashboard/api/v1/oci/containerengine"; +export const OKE_COMPARTMENT_KEY = "gopherstack_ociokes_compartment"; +export const OKE_MAX_POOL_SIZE = 1000; + +const enc = encodeURIComponent; +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); + +export type OkeCluster = { + id: string; + name: string; + state: string; + details: string; + version: string; + type: string; + vcnId: string; + kubernetesEndpoint: string; + publicEndpoint: string; + privateEndpoint: string; + created: string; + upgrades: string[]; +}; + +export type OkeNode = { + id: string; + name: string; + state: string; + version: string; + availabilityDomain: string; + privateIp: string; +}; + +export type OkePlacement = { availabilityDomain: string; subnetId: string }; + +export type OkeNodePool = { + id: string; + clusterId: string; + name: string; + state: string; + version: string; + shape: string; + ocpus: number; + memoryGbs: number; + size: number; + legacySize: boolean; + placements: OkePlacement[]; + nodes: OkeNode[]; +}; + +export type OkeWorkRequest = { + id: string; + operation: string; + status: string; + accepted: string; + finished: string; + resource: string; +}; + +export function parseCluster(raw: Json): OkeCluster { + const ep = obj(raw.endpoints); + return { + id: str(raw.id), + name: str(raw.name), + state: str(raw.lifecycleState), + details: str(raw.lifecycleDetails), + version: str(raw.kubernetesVersion), + type: str(raw.type), + vcnId: str(raw.vcnId), + kubernetesEndpoint: str(ep.kubernetes), + publicEndpoint: str(ep.publicEndpoint), + privateEndpoint: str(ep.privateEndpoint), + created: str(obj(raw.metadata).timeCreated), + upgrades: list(raw.availableKubernetesUpgrades).map(String), + }; +} + +export function parseNode(raw: Json): OkeNode { + return { + id: str(raw.id), + name: str(raw.name), + state: str(raw.lifecycleState), + version: str(raw.kubernetesVersion), + availabilityDomain: str(raw.availabilityDomain), + privateIp: str(raw.privateIp), + }; +} + +export function parseNodePool(raw: Json): OkeNodePool { + const cfg = obj(raw.nodeConfigDetails); + const shape = obj(raw.nodeShapeConfig); + const nodes = list(raw.nodes).map((n) => parseNode(n)); + const legacy = Object.keys(cfg).length === 0; + const perSubnet = num(raw.quantityPerSubnet); + return { + id: str(raw.id), + clusterId: str(raw.clusterId), + name: str(raw.name), + state: str(raw.lifecycleState), + version: str(raw.kubernetesVersion), + shape: str(raw.nodeShape), + ocpus: num(shape.ocpus), + memoryGbs: num(shape.memoryInGBs), + size: legacy ? perSubnet : num(cfg.size), + legacySize: legacy, + placements: list(cfg.placementConfigs).map((p) => ({ + availabilityDomain: str(p.availabilityDomain), + subnetId: str(p.subnetId), + })), + nodes, + }; +} + +export function parseWorkRequest(raw: Json): OkeWorkRequest { + const res = obj(list(raw.resources)[0]); + return { + id: str(raw.id), + operation: str(raw.operationType), + status: str(raw.status), + accepted: str(raw.timeAccepted), + finished: str(raw.timeFinished), + resource: str(res.identifier), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(OKE_PROXY_BASE, fetchFn); + +export async function listClusters( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + OKE_PROXY_BASE, + "/api/clusters", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return items.map((r) => parseCluster(r)); +} + +/** Pools of a compartment; each is read individually because only that body carries the nodes. */ +export async function listNodePools( + compartment: string, + clusterId: string, + fetchFn: FetchFn = fetch, +): Promise { + const summaries = await ociListPages( + OKE_PROXY_BASE, + "/api/nodePools", + `compartmentId=${enc(compartment)}&clusterId=${enc(clusterId)}`, + "", + fetchFn, + ); + const pools = await Promise.all( + summaries.map(async (s) => + parseNodePool(await ociGetOne(OKE_PROXY_BASE, `/api/nodePools/${enc(str(s.id))}`, fetchFn)), + ), + ); + return pools.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +export async function listWorkRequests( + compartment: string, + clusterId: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + OKE_PROXY_BASE, + "/api/workRequests", + `compartmentId=${enc(compartment)}&clusterId=${enc(clusterId)}&sortBy=TIME_ACCEPTED&sortOrder=DESC`, + "", + fetchFn, + ); + return items.map((r) => parseWorkRequest(r)); +} + +export async function deleteCluster(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall(OKE_PROXY_BASE, "DELETE", `/api/clusters/${enc(id)}`, undefined, fetchFn); +} + +/** Sets a pool's node count; pools created with quantityPerSubnet are resized per subnet. */ +export async function scaleNodePool( + pool: Pick, + size: number, + fetchFn: FetchFn = fetch, +): Promise { + const body = pool.legacySize ? { quantityPerSubnet: size } : { nodeConfigDetails: { size } }; + await restCall(OKE_PROXY_BASE, "PUT", `/api/nodePools/${enc(pool.id)}`, body, fetchFn); +} + +export const validPoolSize = (v: string): boolean => { + if (!/^\d+$/.test(v.trim())) return false; + return Number(v) <= OKE_MAX_POOL_SIZE; +}; + +/** The CLI command that fetches a kubeconfig; the dashboard never handles cluster credentials itself. */ +export const kubeconfigCommand = (c: Pick, endpoint: string): string => + `oci ce cluster create-kubeconfig --cluster-id ${c.id} --file $HOME/.kube/config --endpoint ${endpoint}`; + +export const readStoredOkeCompartment = () => readStoredValue(OKE_COMPARTMENT_KEY); +export const writeStoredOkeCompartment = (id: string) => writeStoredValue(OKE_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocicontainerinstances.test.ts b/ui/src/lib/ocicontainerinstances.test.ts new file mode 100644 index 0000000000..37e739a034 --- /dev/null +++ b/ui/src/lib/ocicontainerinstances.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CI_PROXY_BASE, + actionEnabled, + createBody, + createInstance, + deleteInstance, + getEngine, + getInstance, + inProgress, + instanceAction, + isBusy, + listAvailabilityDomains, + listContainers, + listInstances, + listShapes, + listSubnets, + listWorkRequestErrors, + listWorkRequests, + memoryRange, + overallHealth, + parseContainer, + parseDetail, + parseShape, + renameInstance, + retrieveLogs, + splitArgs, + validateNew, + type NewInstance, +} from "./ocicontainerinstances"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; + +const instRaw = { + id: "ci1", + displayName: "web", + lifecycleState: "ACTIVE", + shape: "CI.Standard.E4.Flex", + shapeConfig: { ocpus: 2, memoryInGBs: 8 }, + containerCount: 1, + volumeCount: 1, + containerRestartPolicy: "ALWAYS", + availabilityDomain: "AD-1", + timeCreated: "2026-10-31T00:00:00Z", + gracefulShutdownTimeoutInSeconds: 30, + containers: [{ containerId: "c1", displayName: "main" }], + vnics: [{ vnicId: "vnic1" }], + volumes: [ + { + name: "cfg", + volumeType: "CONFIGFILE", + configs: [{ fileName: "a.conf", path: "etc", sizeInBytes: 12 }], + }, + { name: "tmp", volumeType: "EMPTYDIR", backingStore: "MEMORY" }, + ], + imagePullSecrets: [{ secretType: "BASIC", registryEndpoint: "r.example.com" }], +}; + +const ctrRaw = { + id: "c1", + displayName: "main", + imageUrl: "alpine:3.20", + lifecycleState: "ACTIVE", + exitCode: 0, + containerRestartAttemptCount: 2, + resourceConfig: { vcpusLimit: 0.5, memoryLimitInGBs: 1 }, + command: ["sh"], + arguments: ["-c", "echo hi"], + environmentVariableNames: ["B", "A"], + volumeMounts: [{ volumeName: "cfg", mountPath: "/etc/app", isReadOnly: true }], + healthChecks: [ + { name: "h1", healthCheckType: "HTTP", status: "HEALTHY" }, + { name: "h2", healthCheckType: "TCP", status: "UNHEALTHY", statusDetails: "refused" }, + ], +}; + +describe("parsing", () => { + it("parses an instance with volume files and pull secrets", () => { + const d = parseDetail(instRaw); + expect(d).toMatchObject({ + id: "ci1", + name: "web", + state: "ACTIVE", + ocpus: 2, + memoryGBs: 8, + gracefulShutdownSeconds: 30, + vnicIds: ["vnic1"], + containers: [{ id: "c1", name: "main" }], + pullSecrets: [{ type: "BASIC", registry: "r.example.com" }], + }); + expect(d.volumes[0]).toEqual({ + name: "cfg", + type: "CONFIGFILE", + backingStore: "", + files: [{ fileName: "a.conf", path: "etc", sizeBytes: 12 }], + }); + expect(d.volumes[1].backingStore).toBe("MEMORY"); + }); + + it("leaves the graceful shutdown unset when absent", () => { + expect(parseDetail({ id: "x" }).gracefulShutdownSeconds).toBeUndefined(); + }); + + it("parses a container with names only for environment", () => { + const c = parseContainer(ctrRaw); + expect(c).toMatchObject({ + name: "main", + image: "alpine:3.20", + exitCode: 0, + restartCount: 2, + vcpusLimit: 0.5, + memoryLimitGBs: 1, + envNames: ["B", "A"], + health: "UNHEALTHY", + }); + expect(c.mounts).toEqual([{ volume: "cfg", path: "/etc/app", readOnly: true }]); + expect(JSON.stringify(c)).not.toContain("environmentVariables"); + }); + + it("combines health statuses", () => { + expect(overallHealth([])).toBe(""); + expect(overallHealth([{ status: "HEALTHY" }])).toBe("HEALTHY"); + expect(overallHealth([{ status: "HEALTHY" }, { status: "UNKNOWN" }])).toBe("UNKNOWN"); + expect(overallHealth([{ status: "UNKNOWN" }, { status: "UNHEALTHY" }])).toBe("UNHEALTHY"); + }); + + it("parses shapes and computes the memory range", () => { + const s = parseShape({ + name: "CI.Standard.E4.Flex", + ocpuOptions: { min: 1, max: 64 }, + memoryOptions: { minInGBs: 1, maxInGBs: 1024, defaultPerOcpuInGBs: 16, maxPerOcpuInGBs: 64 }, + }); + expect(memoryRange(s, 1)).toEqual({ min: 1, max: 64 }); + expect(memoryRange(s, 32)).toEqual({ min: 32, max: 1024 }); + }); +}); + +describe("state helpers", () => { + it("enables actions by state and work request", () => { + expect(actionEnabled("stop", "ACTIVE", false)).toBe(true); + expect(actionEnabled("restart", "ACTIVE", false)).toBe(true); + expect(actionEnabled("start", "ACTIVE", false)).toBe(false); + expect(actionEnabled("start", "INACTIVE", false)).toBe(true); + expect(actionEnabled("stop", "INACTIVE", false)).toBe(false); + expect(actionEnabled("stop", "ACTIVE", true)).toBe(false); + expect(actionEnabled("start", "INACTIVE", true)).toBe(false); + }); + + it("detects busy states and work in progress", () => { + expect(isBusy("UPDATING")).toBe(true); + expect(isBusy("ACTIVE")).toBe(false); + expect(inProgress([{ status: "SUCCEEDED" }, { status: "IN_PROGRESS" }])).toBe(true); + expect(inProgress([{ status: "SUCCEEDED" }, { status: "FAILED" }])).toBe(false); + }); +}); + +describe("create form", () => { + const shape = parseShape({ + name: "CI.Standard.E4.Flex", + ocpuOptions: { min: 1, max: 4 }, + memoryOptions: { minInGBs: 1, maxInGBs: 64, defaultPerOcpuInGBs: 16, maxPerOcpuInGBs: 16 }, + }); + const base: NewInstance = { + compartmentId: comp, + availabilityDomain: "AD-1", + displayName: " web ", + shape: shape.name, + ocpus: 1, + memoryInGBs: 8, + restartPolicy: "ALWAYS", + subnetId: " ocid1.subnet.oc1..s ", + image: " alpine:3.20 ", + containerName: "", + command: "sh", + args: `-c 'echo hello; sleep 3600'`, + env: [ + { name: "TOKEN", value: "s3cret" }, + { name: " ", value: "ignored" }, + ], + }; + + it("splits command lines with quotes", () => { + expect(splitArgs(`-c 'echo hello; sleep 3600'`)).toEqual(["-c", "echo hello; sleep 3600"]); + expect(splitArgs(`a "b c" '' d`)).toEqual(["a", "b c", "", "d"]); + expect(splitArgs("")).toEqual([]); + }); + + it("builds the request body from fixed fields", () => { + expect(createBody(base)).toEqual({ + compartmentId: comp, + availabilityDomain: "AD-1", + shape: shape.name, + displayName: "web", + shapeConfig: { ocpus: 1, memoryInGBs: 8 }, + containerRestartPolicy: "ALWAYS", + vnics: [{ subnetId: "ocid1.subnet.oc1..s" }], + containers: [ + { + imageUrl: "alpine:3.20", + command: ["sh"], + arguments: ["-c", "echo hello; sleep 3600"], + environmentVariables: { TOKEN: "s3cret" }, + }, + ], + }); + }); + + it("validates ranges and required fields", () => { + expect(validateNew(base, shape)).toBe(""); + expect(validateNew(base, void 0)).toMatch(/shape/i); + expect(validateNew({ ...base, image: " " }, shape)).toMatch(/image/i); + expect(validateNew({ ...base, subnetId: "" }, shape)).toMatch(/subnet/i); + expect(validateNew({ ...base, availabilityDomain: "" }, shape)).toMatch(/availability/i); + expect(validateNew({ ...base, ocpus: 5 }, shape)).toMatch(/OCPUs/); + expect(validateNew({ ...base, memoryInGBs: 17 }, shape)).toMatch( + /Memory must be between 1 and 16/, + ); + expect(validateNew({ ...base, env: [{ name: "a b", value: "" }] }, shape)).toMatch( + /environment/i, + ); + }); +}); + +describe("requests", () => { + it("lists instances across pages and hides deleted ones", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json({ items: [instRaw] }, 200, { "opc-next-page": "p2" })) + .mockResolvedValueOnce( + json({ items: [{ ...instRaw, id: "ci2", lifecycleState: "DELETED" }] }), + ); + const out = await listInstances(comp, asFetch(f)); + expect(out.map((i) => i.id)).toEqual(["ci1"]); + expect(f.mock.calls[0][0]).toBe( + `${CI_PROXY_BASE}/api/containerInstances?compartmentId=${encodeURIComponent(comp)}`, + ); + expect(f.mock.calls[1][0]).toContain("page=p2"); + }); + + it("reads one instance", async () => { + const f = vi.fn().mockResolvedValue(json(instRaw)); + expect((await getInstance("ci1", asFetch(f))).name).toBe("web"); + expect(f.mock.calls[0][0]).toBe(`${CI_PROXY_BASE}/api/containerInstances/ci1`); + }); + + it("lists containers with their full record", async () => { + const f = vi.fn((url: string) => + Promise.resolve( + url.includes("/api/containers/c1") ? json(ctrRaw) : json({ items: [{ id: "c1" }] }), + ), + ); + const out = await listContainers(comp, "ci1", asFetch(f)); + expect(out[0].envNames).toEqual(["B", "A"]); + expect(f.mock.calls[0][0]).toContain("containerInstanceId=ci1"); + }); + + it("lists work requests for the instance and their errors", async () => { + const f = vi + .fn() + .mockResolvedValueOnce( + json({ + items: [ + { + id: "w1", + operationType: "CREATE_CONTAINER_INSTANCE", + status: "FAILED", + percentComplete: 50, + resources: [{ actionType: "CREATED", entityType: "containerInstance" }], + }, + ], + }), + ) + .mockResolvedValueOnce( + json({ items: [{ code: "ImagePull", message: "denied", timestamp: "t" }] }), + ); + const wrs = await listWorkRequests(comp, "ci1", asFetch(f)); + expect(wrs[0]).toMatchObject({ + status: "FAILED", + percent: 50, + resources: "CREATED containerInstance", + }); + expect(f.mock.calls[0][0]).toContain("resourceId=ci1"); + expect(await listWorkRequestErrors("w1", asFetch(f))).toEqual([ + { code: "ImagePull", message: "denied", timestamp: "t" }, + ]); + }); + + it("lists shapes, subnets, domains and the engine", async () => { + const f = vi.fn((url: string) => { + if (url.includes("containerInstanceShapes")) + return Promise.resolve(json({ items: [{ name: "S" }] })); + if (url.includes("/subnets")) + return Promise.resolve( + json({ items: [{ id: "s1", displayName: "a", cidrBlock: "10.0.0.0/24", vcnId: "v" }] }), + ); + if (url.includes("/availabilitydomains")) + return Promise.resolve(json({ items: [{ name: "AD-1" }] })); + return Promise.resolve(json({ engine: "docker" })); + }); + expect((await listShapes(comp, asFetch(f)))[0].name).toBe("S"); + expect(await listSubnets(comp, asFetch(f))).toEqual([ + { id: "s1", name: "a", cidr: "10.0.0.0/24", vcnId: "v" }, + ]); + expect(await listAvailabilityDomains(asFetch(f))).toEqual(["AD-1"]); + expect(await getEngine(asFetch(f))).toBe("docker"); + }); + + it("sends actions, rename, delete and create", async () => { + const f = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(json({}, 202))); + await instanceAction("ci1", "stop", asFetch(f)); + await renameInstance("ci1", "new", asFetch(f)); + await deleteInstance("ci1", asFetch(f)); + await createInstance( + { + compartmentId: comp, + availabilityDomain: "AD", + displayName: "", + shape: "S", + ocpus: 1, + memoryInGBs: 2, + restartPolicy: "NEVER", + subnetId: "s", + image: "i", + containerName: "", + command: "", + args: "", + env: [], + }, + asFetch(f), + ); + expect(f.mock.calls.map((c) => [c[1]?.method, c[0]])).toEqual([ + ["POST", `${CI_PROXY_BASE}/api/containerInstances/ci1/actions/stop`], + ["PUT", `${CI_PROXY_BASE}/api/containerInstances/ci1`], + ["DELETE", `${CI_PROXY_BASE}/api/containerInstances/ci1`], + ["POST", `${CI_PROXY_BASE}/api/containerInstances`], + ]); + expect(JSON.parse(String(f.mock.calls[1][1]?.body))).toEqual({ displayName: "new" }); + }); + + it("surfaces proxy errors", async () => { + const f = vi + .fn() + .mockResolvedValue( + json({ error: { code: 503, status: "UNAVAILABLE", message: "not enabled" } }, 503), + ); + await expect(listInstances(comp, asFetch(f))).rejects.toMatchObject({ + httpStatus: 503, + message: "not enabled", + }); + }); +}); + +describe("logs", () => { + it("returns text with the truncation headers", async () => { + const f = vi.fn().mockResolvedValue( + new Response("hello\n", { + status: 200, + headers: { "X-Logs-Truncated": "true", "X-Logs-Total-Bytes": "999" }, + }), + ); + const r = await retrieveLogs("c1", true, asFetch(f)); + expect(r).toEqual({ text: "hello\n", truncated: true, totalBytes: 999 }); + expect(f.mock.calls[0][0]).toBe( + `${CI_PROXY_BASE}/api/containers/c1/actions/retrieveLogs?isPrevious=true`, + ); + expect(f.mock.calls[0][1]).toMatchObject({ method: "POST" }); + }); + + it("raises the state-engine 409 message", async () => { + const f = vi + .fn() + .mockResolvedValue( + json({ code: "IncorrectState", message: "container logs need the docker engine" }, 409), + ); + await expect(retrieveLogs("c1", false, asFetch(f))).rejects.toMatchObject({ + httpStatus: 409, + code: "IncorrectState", + message: "container logs need the docker engine", + }); + }); + + it("falls back to the status text for a non-JSON failure", async () => { + const f = vi + .fn() + .mockResolvedValue(new Response("boom", { status: 502, statusText: "Bad Gateway" })); + await expect(retrieveLogs("c1", false, asFetch(f))).rejects.toMatchObject({ + httpStatus: 502, + message: "Bad Gateway", + }); + }); +}); diff --git a/ui/src/lib/ocicontainerinstances.ts b/ui/src/lib/ocicontainerinstances.ts new file mode 100644 index 0000000000..7a96e44a3e --- /dev/null +++ b/ui/src/lib/ocicontainerinstances.ts @@ -0,0 +1,515 @@ +/** Client for the Oracle Cloud Container Instances emulator through the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { GcpRestError, restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const CI_PROXY_BASE = "/dashboard/api/v1/oci/containerinstances"; +export const CI_COMPARTMENT_KEY = "gopherstack_ocici_compartment"; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const strs = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); + +export type InstanceSummary = { + id: string; + name: string; + state: string; + details: string; + shape: string; + ocpus: number; + memoryGBs: number; + containerCount: number; + volumeCount: number; + restartPolicy: string; + availabilityDomain: string; + created: string; +}; + +export type ConfigFileInfo = { fileName: string; path: string; sizeBytes: number }; +export type VolumeInfo = { + name: string; + type: string; + backingStore: string; + files: ConfigFileInfo[]; +}; +export type PullSecretInfo = { type: string; registry: string }; + +export type InstanceDetail = InstanceSummary & { + gracefulShutdownSeconds: number | undefined; + containers: { id: string; name: string }[]; + vnicIds: string[]; + volumes: VolumeInfo[]; + pullSecrets: PullSecretInfo[]; +}; + +export type HealthCheckInfo = { name: string; type: string; status: string; details: string }; + +export type ContainerInfo = { + id: string; + name: string; + image: string; + state: string; + details: string; + exitCode: number | undefined; + health: string; + healthChecks: HealthCheckInfo[]; + restartCount: number; + vcpusLimit: number | undefined; + memoryLimitGBs: number | undefined; + command: string[]; + args: string[]; + envNames: string[]; + mounts: { volume: string; path: string; readOnly: boolean }[]; +}; + +export type WorkRequest = { + id: string; + type: string; + status: string; + percent: number; + accepted: string; + finished: string; + resources: string; +}; + +export type WorkRequestError = { code: string; message: string; timestamp: string }; + +export type Shape = { + name: string; + processor: string; + ocpuMin: number; + ocpuMax: number; + memoryMinGBs: number; + memoryMaxGBs: number; + defaultPerOcpu: number; + maxPerOcpu: number; +}; + +export type Subnet = { id: string; name: string; cidr: string; vcnId: string }; + +export function parseSummary(raw: Json): InstanceSummary { + const sc = obj(raw.shapeConfig); + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + details: str(raw.lifecycleDetails), + shape: str(raw.shape), + ocpus: num(sc.ocpus), + memoryGBs: num(sc.memoryInGBs), + containerCount: num(raw.containerCount), + volumeCount: num(raw.volumeCount), + restartPolicy: str(raw.containerRestartPolicy), + availabilityDomain: str(raw.availabilityDomain), + created: str(raw.timeCreated), + }; +} + +export function parseDetail(raw: Json): InstanceDetail { + return { + ...parseSummary(raw), + gracefulShutdownSeconds: + raw.gracefulShutdownTimeoutInSeconds === undefined + ? undefined + : num(raw.gracefulShutdownTimeoutInSeconds), + containers: list(raw.containers).map((c) => ({ + id: str(c.containerId), + name: str(c.displayName), + })), + vnicIds: list(raw.vnics).map((v) => str(v.vnicId)), + volumes: list(raw.volumes).map((v) => ({ + name: str(v.name), + type: str(v.volumeType), + backingStore: str(v.backingStore), + files: list(v.configs).map((f) => ({ + fileName: str(f.fileName), + path: str(f.path), + sizeBytes: num(f.sizeInBytes), + })), + })), + pullSecrets: list(raw.imagePullSecrets).map((s) => ({ + type: str(s.secretType), + registry: str(s.registryEndpoint), + })), + }; +} + +/** Worst health across a container's checks: UNHEALTHY beats UNKNOWN beats HEALTHY; none means no checks. */ +export function overallHealth(checks: readonly Pick[]): string { + if (checks.length === 0) return ""; + if (checks.some((c) => c.status === "UNHEALTHY")) return "UNHEALTHY"; + if (checks.some((c) => c.status !== "HEALTHY")) return "UNKNOWN"; + return "HEALTHY"; +} + +export function parseContainer(raw: Json): ContainerInfo { + const rc = obj(raw.resourceConfig); + const checks = list(raw.healthChecks).map((h) => ({ + name: str(h.name), + type: str(h.healthCheckType), + status: str(h.status), + details: str(h.statusDetails), + })); + return { + id: str(raw.id), + name: str(raw.displayName), + image: str(raw.imageUrl), + state: str(raw.lifecycleState), + details: str(raw.lifecycleDetails), + exitCode: raw.exitCode === undefined ? undefined : num(raw.exitCode), + health: overallHealth(checks), + healthChecks: checks, + restartCount: num(raw.containerRestartAttemptCount), + vcpusLimit: rc.vcpusLimit === undefined ? undefined : num(rc.vcpusLimit), + memoryLimitGBs: rc.memoryLimitInGBs === undefined ? undefined : num(rc.memoryLimitInGBs), + command: strs(raw.command), + args: strs(raw.arguments), + envNames: strs(raw.environmentVariableNames), + mounts: list(raw.volumeMounts).map((m) => ({ + volume: str(m.volumeName), + path: str(m.mountPath), + readOnly: m.isReadOnly === true, + })), + }; +} + +export function parseWorkRequest(raw: Json): WorkRequest { + return { + id: str(raw.id), + type: str(raw.operationType), + status: str(raw.status), + percent: num(raw.percentComplete), + accepted: str(raw.timeAccepted), + finished: str(raw.timeFinished), + resources: list(raw.resources) + .map((r) => `${str(r.actionType)} ${str(r.entityType)}`.trim()) + .join(", "), + }; +} + +export function parseShape(raw: Json): Shape { + const o = obj(raw.ocpuOptions); + const m = obj(raw.memoryOptions); + return { + name: str(raw.name), + processor: str(raw.processorDescription), + ocpuMin: num(o.min), + ocpuMax: num(o.max), + memoryMinGBs: num(m.minInGBs), + memoryMaxGBs: num(m.maxInGBs), + defaultPerOcpu: num(m.defaultPerOcpuInGBs), + maxPerOcpu: num(m.maxPerOcpuInGBs), + }; +} + +/** Memory range allowed for a shape at an OCPU count: at least 1 GB per OCPU, at most the per-OCPU and shape maximum. */ +export function memoryRange(shape: Shape, ocpus: number): { min: number; max: number } { + return { + min: Math.max(shape.memoryMinGBs, ocpus), + max: Math.min(shape.memoryMaxGBs, shape.maxPerOcpu * ocpus), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(CI_PROXY_BASE, fetchFn); + +export async function getEngine(fetchFn: FetchFn = fetch): Promise { + return str((await restCall(CI_PROXY_BASE, "GET", "/engine", undefined, fetchFn)).engine); +} + +export async function listAvailabilityDomains(fetchFn: FetchFn = fetch): Promise { + const body = await restCall(CI_PROXY_BASE, "GET", "/availabilitydomains", undefined, fetchFn); + return list(body.items).map((i) => str(i.name)); +} + +export async function listSubnets( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + CI_PROXY_BASE, + "GET", + `/subnets?compartmentId=${enc(compartment)}`, + undefined, + fetchFn, + ); + return list(body.items).map((s) => ({ + id: str(s.id), + name: str(s.displayName) || str(s.id), + cidr: str(s.cidrBlock), + vcnId: str(s.vcnId), + })); +} + +const compQuery = (compartment: string) => `compartmentId=${enc(compartment)}`; + +export async function listInstances( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + CI_PROXY_BASE, + "/api/containerInstances", + compQuery(compartment), + "items", + fetchFn, + ); + return items.map((i) => parseSummary(i)).filter((i) => i.state !== "DELETED"); +} + +export async function getInstance(id: string, fetchFn: FetchFn = fetch): Promise { + return parseDetail(await ociGetOne(CI_PROXY_BASE, `/api/containerInstances/${enc(id)}`, fetchFn)); +} + +export async function listContainers( + compartment: string, + instanceId: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + CI_PROXY_BASE, + "/api/containers", + `${compQuery(compartment)}&containerInstanceId=${enc(instanceId)}`, + "items", + fetchFn, + ); + return Promise.all( + items.map(async (i) => + parseContainer(await ociGetOne(CI_PROXY_BASE, `/api/containers/${enc(str(i.id))}`, fetchFn)), + ), + ); +} + +export async function listWorkRequests( + compartment: string, + instanceId: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + CI_PROXY_BASE, + "/api/workRequests", + `${compQuery(compartment)}&resourceId=${enc(instanceId)}`, + "items", + fetchFn, + ); + return items.map((w) => parseWorkRequest(w)); +} + +export async function listWorkRequestErrors( + id: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + CI_PROXY_BASE, + `/api/workRequests/${enc(id)}/errors`, + "", + "items", + fetchFn, + ); + return items.map((e) => ({ + code: str(e.code), + message: str(e.message), + timestamp: str(e.timestamp), + })); +} + +export async function listShapes(compartment: string, fetchFn: FetchFn = fetch): Promise { + const items = await ociListPages( + CI_PROXY_BASE, + "/api/containerInstanceShapes", + compQuery(compartment), + "items", + fetchFn, + ); + return items.map((s) => parseShape(s)); +} + +export type InstanceAction = "start" | "stop" | "restart"; + +/** Start, stop and restart are only offered from the states the service accepts them in. */ +export function actionEnabled( + action: InstanceAction, + state: string, + workRequestInProgress: boolean, +): boolean { + if (workRequestInProgress) return false; + return action === "start" ? state === "INACTIVE" : state === "ACTIVE"; +} + +export const isBusy = (state: string): boolean => + state === "CREATING" || state === "UPDATING" || state === "DELETING"; + +export const inProgress = (wrs: readonly Pick[]): boolean => + wrs.some((w) => w.status === "IN_PROGRESS" || w.status === "ACCEPTED"); + +export async function instanceAction( + id: string, + action: InstanceAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + CI_PROXY_BASE, + "POST", + `/api/containerInstances/${enc(id)}/actions/${action}`, + undefined, + fetchFn, + ); +} + +export async function deleteInstance(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall(CI_PROXY_BASE, "DELETE", `/api/containerInstances/${enc(id)}`, undefined, fetchFn); +} + +export async function renameInstance( + id: string, + displayName: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + CI_PROXY_BASE, + "PUT", + `/api/containerInstances/${enc(id)}`, + { displayName }, + fetchFn, + ); +} + +export type EnvPair = { name: string; value: string }; + +export type NewInstance = { + compartmentId: string; + availabilityDomain: string; + displayName: string; + shape: string; + ocpus: number; + memoryInGBs: number; + restartPolicy: string; + subnetId: string; + image: string; + containerName: string; + command: string; + args: string; + env: EnvPair[]; +}; + +/** Splits a command line on whitespace, honouring single and double quotes. */ +export function splitArgs(line: string): string[] { + const out: string[] = []; + let cur = ""; + let quote = ""; + let has = false; + for (const ch of line) { + if (quote) { + if (ch === quote) quote = ""; + else cur += ch; + } else if (ch === '"' || ch === "'") { + quote = ch; + has = true; + } else if (/\s/.test(ch)) { + if (has || cur) out.push(cur); + cur = ""; + has = false; + } else { + cur += ch; + } + } + if (has || cur) out.push(cur); + return out; +} + +export const validEnvName = (n: string): boolean => /^[A-Za-z_][A-Za-z0-9_.-]{0,127}$/.test(n); + +export function createBody(n: NewInstance): Json { + const env: Record = {}; + for (const p of n.env) if (p.name.trim() !== "") env[p.name.trim()] = p.value; + const command = splitArgs(n.command); + const args = splitArgs(n.args); + const container: Json = { imageUrl: n.image.trim() }; + if (n.containerName.trim() !== "") container.displayName = n.containerName.trim(); + if (command.length > 0) container.command = command; + if (args.length > 0) container.arguments = args; + if (Object.keys(env).length > 0) container.environmentVariables = env; + const body: Json = { + compartmentId: n.compartmentId, + availabilityDomain: n.availabilityDomain, + shape: n.shape, + shapeConfig: { ocpus: n.ocpus, memoryInGBs: n.memoryInGBs }, + containerRestartPolicy: n.restartPolicy, + vnics: [{ subnetId: n.subnetId.trim() }], + containers: [container], + }; + if (n.displayName.trim() !== "") body.displayName = n.displayName.trim(); + return body; +} + +export function validateNew(n: NewInstance, shape: Shape | undefined): string { + if (!n.shape || !shape) return "Pick a shape"; + if (!n.availabilityDomain.trim()) return "Availability domain is required"; + if (!n.subnetId.trim()) return "Subnet is required"; + if (!n.image.trim()) return "Container image is required"; + if (!(n.ocpus >= shape.ocpuMin && n.ocpus <= shape.ocpuMax)) { + return `OCPUs must be between ${shape.ocpuMin} and ${shape.ocpuMax}`; + } + const r = memoryRange(shape, n.ocpus); + if (!(n.memoryInGBs >= r.min && n.memoryInGBs <= r.max)) { + return `Memory must be between ${r.min} and ${r.max} GB`; + } + const bad = n.env.find((p) => p.name.trim() !== "" && !validEnvName(p.name.trim())); + if (bad) return `Invalid environment variable name: ${bad.name}`; + return ""; +} + +export async function createInstance(n: NewInstance, fetchFn: FetchFn = fetch): Promise { + await restCall(CI_PROXY_BASE, "POST", "/api/containerInstances", createBody(n), fetchFn); +} + +export type LogsResult = { text: string; truncated: boolean; totalBytes: number }; + +/** Container logs as text. A 409 (no docker engine, or still creating) throws with the service's own message. */ +export async function retrieveLogs( + containerId: string, + previous = false, + fetchFn: FetchFn = fetch, +): Promise { + let res: Response; + try { + res = await fetchFn( + `${CI_PROXY_BASE}/api/containers/${enc(containerId)}/actions/retrieveLogs${previous ? "?isPrevious=true" : ""}`, + { method: "POST" }, + ); + } catch (e) { + throw new GcpRestError(0, "NetworkError", e instanceof Error ? e.message : String(e)); + } + const text = await res.text(); + if (!res.ok) { + let body: Json = {}; + try { + body = obj(JSON.parse(text)); + } catch { + // non-JSON error body: fall back to the status text + } + const proxy = obj(body.error); + throw new GcpRestError( + res.status, + str(proxy.status) || str(body.code), + str(proxy.message) || str(body.message) || res.statusText || `HTTP ${res.status}`, + ); + } + const total = Number(res.headers.get("X-Logs-Total-Bytes")); + return { + text, + truncated: res.headers.get("X-Logs-Truncated") === "true", + totalBytes: total > 0 ? total : text.length, + }; +} + +export const readStoredCiCompartment = () => readStoredValue(CI_COMPARTMENT_KEY); +export const writeStoredCiCompartment = (id: string) => writeStoredValue(CI_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocicore.test.ts b/ui/src/lib/ocicore.test.ts new file mode 100644 index 0000000000..b57f547ba2 --- /dev/null +++ b/ui/src/lib/ocicore.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CORE_PROXY_BASE, + allowedActions, + instanceAction, + instanceShape, + listCompartments, + listInstanceVnics, + listNsgRules, + listResources, + parseResource, + protocolName, + routeRules, + securityListRules, + shortId, +} from "./ocicore"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; + +const comp = "ocid1.compartment.oc1..aaaa"; + +describe("parsing", () => { + it("parses a resource and shortens OCIDs", () => { + expect( + parseResource({ + id: "ocid1.vcn.oc1.iad.xyz", + displayName: "main", + lifecycleState: "AVAILABLE", + }), + ).toMatchObject({ name: "main", state: "AVAILABLE" }); + expect(parseResource({ id: "ocid1.vcn" }).name).toBe("ocid1.vcn"); + expect(shortId("short")).toBe("short"); + expect(shortId("ocid1.subnet.oc1.iad.abcdefghijklmnop")).toBe("…efghijklmnop"); + }); + + it.each([ + ["6", "TCP"], + ["17", "UDP"], + ["1", "ICMP"], + ["all", "ALL"], + ["99", "99"], + ])("names protocol %s", (p, want) => { + expect(protocolName(p)).toBe(want); + }); + + it("flattens security list rules with ports", () => { + const rules = securityListRules({ + ingressSecurityRules: [ + { + protocol: "6", + source: "0.0.0.0/0", + tcpOptions: { destinationPortRange: { min: 22, max: 22 } }, + }, + { + protocol: "6", + source: "10.0.0.0/8", + tcpOptions: { destinationPortRange: { min: 8000, max: 8100 } }, + }, + ], + egressSecurityRules: [{ protocol: "all", destination: "0.0.0.0/0", isStateless: true }], + }); + expect(rules.map((r) => [r.direction, r.protocol, r.peer, r.ports, r.stateless])).toEqual([ + ["INGRESS", "TCP", "0.0.0.0/0", "22", false], + ["INGRESS", "TCP", "10.0.0.0/8", "8000-8100", false], + ["EGRESS", "ALL", "0.0.0.0/0", "all", true], + ]); + }); + + it("reads route rules and shapes", () => { + expect( + routeRules({ routeRules: [{ destination: "0.0.0.0/0", networkEntityId: "ocid1.ig" }] }), + ).toEqual([{ destination: "0.0.0.0/0", target: "ocid1.ig" }]); + const inst = parseResource({ + id: "i", + shape: "VM.Standard.E4.Flex", + shapeConfig: { ocpus: 2, memoryInGBs: 32 }, + }); + expect(instanceShape(inst)).toBe("VM.Standard.E4.Flex (2 OCPU, 32 GB)"); + }); + + it("allows power actions by state", () => { + expect(allowedActions("RUNNING")).toEqual(["STOP", "SOFTRESET"]); + expect(allowedActions("STOPPED")).toEqual(["START"]); + expect(allowedActions("STOPPING")).toEqual([]); + }); +}); + +describe("requests", () => { + it("lists compartments", async () => { + const fetchFn = vi.fn().mockResolvedValue(json({ compartments: [comp] })); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${CORE_PROXY_BASE}/compartments`); + }); + + it("follows opc-next-page across list pages", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + url.includes("page=p2") + ? json([{ id: "b", displayName: "b" }]) + : json([{ id: "a", displayName: "a" }], 200, { "opc-next-page": "p2" }), + ), + ); + const vcns = await listResources("vcns", comp, "", asFetch(fetchFn)); + expect(vcns.map((v) => v.id)).toEqual(["a", "b"]); + expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([ + `${CORE_PROXY_BASE}/api/vcns?compartmentId=${encodeURIComponent(comp)}`, + `${CORE_PROXY_BASE}/api/vcns?compartmentId=${encodeURIComponent(comp)}&page=p2`, + ]); + }); + + it("joins VNIC attachments with their VNICs", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + url.includes("/vnics/") + ? json({ privateIp: "10.0.0.5", publicIp: "1.2.3.4", macAddress: "aa", isPrimary: true }) + : json([{ id: "att1", vnicId: "vnic1", lifecycleState: "ATTACHED", subnetId: "s" }]), + ), + ); + const vnics = await listInstanceVnics(comp, "inst1", asFetch(fetchFn)); + expect(vnics).toEqual([ + { + attachmentId: "att1", + vnicId: "vnic1", + state: "ATTACHED", + name: "", + privateIp: "10.0.0.5", + publicIp: "1.2.3.4", + mac: "aa", + subnetId: "s", + isPrimary: true, + }, + ]); + expect(String(fetchFn.mock.calls[0]?.[0])).toContain("instanceId=inst1"); + }); + + it("reads NSG rules and posts instance actions", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + url.includes("securityRules") + ? json([{ direction: "INGRESS", protocol: "6", source: "0.0.0.0/0" }]) + : json({ id: "i", displayName: "web", lifecycleState: "STOPPING" }), + ), + ); + expect((await listNsgRules("nsg1", asFetch(fetchFn)))[0]).toMatchObject({ + direction: "INGRESS", + protocol: "TCP", + }); + const r = await instanceAction("inst 1", "STOP", asFetch(fetchFn)); + expect(r.state).toBe("STOPPING"); + expect(fetchFn.mock.calls[1]).toEqual([ + `${CORE_PROXY_BASE}/api/instances/inst%201?action=STOP`, + { method: "POST" }, + ]); + }); + + it("reports the emulator's flat error body", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(json({ code: "NotAuthorizedOrNotFound", message: "missing" }, 404)); + await expect(listResources("vcns", comp, "", asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 404, + code: "NotAuthorizedOrNotFound", + message: "missing", + }); + }); +}); diff --git a/ui/src/lib/ocicore.ts b/ui/src/lib/ocicore.ts new file mode 100644 index 0000000000..453eda293a --- /dev/null +++ b/ui/src/lib/ocicore.ts @@ -0,0 +1,265 @@ +/** Client for the Oracle Cloud Core Services emulator (networking, compute, block storage) via the dashboard's in-process proxy. */ + +import { GcpRestError, restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const CORE_PROXY_BASE = "/dashboard/api/v1/oci/core"; +export const CORE_COMPARTMENT_KEY = "gopherstack_ocicore_compartment"; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const arr = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const rec = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +export type InstanceAction = "START" | "STOP" | "SOFTRESET"; + +export type CoreResource = { + id: string; + name: string; + state: string; + created: string; + raw: Json; +}; + +export type CoreKind = + | "vcns" + | "subnets" + | "routeTables" + | "securityLists" + | "networkSecurityGroups" + | "instances" + | "volumes" + | "bootVolumes" + | "volumeAttachments" + | "bootVolumeAttachments" + | "volumeBackups"; + +export type InstanceVnic = { + attachmentId: string; + vnicId: string; + state: string; + name: string; + privateIp: string; + publicIp: string; + mac: string; + subnetId: string; + isPrimary: boolean; +}; + +export type RuleView = { + id: string; + direction: string; + protocol: string; + peer: string; + stateless: boolean; + ports: string; + description: string; +}; + +export function parseResource(raw: Json): CoreResource { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + created: str(raw.timeCreated), + raw, + }; +} + +/** Last segment of an OCID, for compact display. */ +export const shortId = (ocid: string): string => (ocid.length > 20 ? `…${ocid.slice(-12)}` : ocid); + +/** OCI protocol numbers to names; "all" is protocol 'all'. */ +export function protocolName(p: string): string { + switch (p) { + case "1": + return "ICMP"; + case "6": + return "TCP"; + case "17": + return "UDP"; + case "58": + return "ICMPv6"; + case "all": + return "ALL"; + default: + return p; + } +} + +function portRange(opts: unknown): string { + const o = rec(opts); + const r = rec(o.destinationPortRange); + if (r.min === undefined) return "all"; + return num(r.min) === num(r.max) ? String(num(r.min)) : `${num(r.min)}-${num(r.max)}`; +} + +function ruleOf(raw: Json, direction: string): RuleView { + const tcp = raw.tcpOptions ?? raw.udpOptions; + return { + id: str(raw.id), + direction, + protocol: protocolName(str(raw.protocol)), + peer: str(raw.source) || str(raw.destination) || "—", + stateless: raw.isStateless === true, + ports: tcp ? portRange(tcp) : "all", + description: str(raw.description), + }; +} + +/** Flattens a security list's ingress and egress rules. */ +export function securityListRules(raw: Json): RuleView[] { + return [ + ...arr(raw.ingressSecurityRules).map((r) => ruleOf(r, "INGRESS")), + ...arr(raw.egressSecurityRules).map((r) => ruleOf(r, "EGRESS")), + ]; +} + +/** Flattens a network security group's rules from the NSG rule listing. */ +export function nsgRules(items: Json[]): RuleView[] { + return items.map((r) => ruleOf(r, str(r.direction))); +} + +export function routeRules(raw: Json): { destination: string; target: string }[] { + return arr(raw.routeRules).map((r) => ({ + destination: str(r.destination) || str(r.cidrBlock), + target: str(r.networkEntityId), + })); +} + +function failure(res: Response, body: Json): GcpRestError { + return new GcpRestError( + res.status, + str(body.code), + str(body.message) || res.statusText || `HTTP ${res.status}`, + ); +} + +async function coreGet(path: string, fetchFn: FetchFn): Promise<{ body: unknown; next: string }> { + let res: Response; + try { + res = await fetchFn(`${CORE_PROXY_BASE}${path}`); + } catch (e) { + throw new GcpRestError(0, "NetworkError", e instanceof Error ? e.message : String(e)); + } + const text = await res.text(); + let body: unknown = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + // non-JSON error body: keep the status text + } + if (!res.ok) throw failure(res, rec(body)); + return { body, next: res.headers.get("opc-next-page") ?? "" }; +} + +async function listPages(path: string, query: string, fetchFn: FetchFn): Promise { + const out: Json[] = []; + let page = ""; + do { + const qs = [query, page ? `page=${enc(page)}` : ""].filter(Boolean).join("&"); + const { body, next } = await coreGet(`${path}?${qs}`, fetchFn); + out.push(...arr(body)); + page = next; + } while (page); + return out; +} + +export async function listCompartments(fetchFn: FetchFn = fetch): Promise { + const body = await restCall(CORE_PROXY_BASE, "GET", "/compartments", undefined, fetchFn); + return (Array.isArray(body.compartments) ? body.compartments : []).map(String); +} + +export async function listResources( + kind: CoreKind, + compartment: string, + extra = "", + fetchFn: FetchFn = fetch, +): Promise { + const query = [`compartmentId=${enc(compartment)}`, extra].filter(Boolean).join("&"); + return (await listPages(`/api/${kind}`, query, fetchFn)).map((r) => parseResource(r)); +} + +export async function listNsgRules(nsgId: string, fetchFn: FetchFn = fetch): Promise { + return nsgRules( + await listPages(`/api/networkSecurityGroups/${enc(nsgId)}/securityRules`, "", fetchFn), + ); +} + +/** The VNICs attached to an instance with their addresses. */ +export async function listInstanceVnics( + compartment: string, + instanceId: string, + fetchFn: FetchFn = fetch, +): Promise { + const attachments = await listPages( + "/api/vnicAttachments", + `compartmentId=${enc(compartment)}&instanceId=${enc(instanceId)}`, + fetchFn, + ); + const out: InstanceVnic[] = []; + for (const a of attachments) { + const vnicId = str(a.vnicId); + const vnic = vnicId ? rec((await coreGet(`/api/vnics/${enc(vnicId)}`, fetchFn)).body) : {}; + out.push({ + attachmentId: str(a.id), + vnicId, + state: str(a.lifecycleState), + name: str(vnic.displayName), + privateIp: str(vnic.privateIp), + publicIp: str(vnic.publicIp), + mac: str(vnic.macAddress), + subnetId: str(a.subnetId), + isPrimary: vnic.isPrimary === true, + }); + } + return out; +} + +export async function instanceAction( + id: string, + action: InstanceAction, + fetchFn: FetchFn = fetch, +): Promise { + return parseResource( + await restCall( + CORE_PROXY_BASE, + "POST", + `/api/instances/${enc(id)}?action=${action}`, + undefined, + fetchFn, + ), + ); +} + +/** Which power actions an instance state allows. */ +export function allowedActions(state: string): InstanceAction[] { + if (state === "RUNNING") return ["STOP", "SOFTRESET"]; + if (state === "STOPPED") return ["START"]; + return []; +} + +export function instanceShape(i: CoreResource): string { + const cfg = rec(i.raw.shapeConfig); + const base = str(i.raw.shape); + return cfg.ocpus === undefined + ? base + : `${base} (${num(cfg.ocpus)} OCPU, ${num(cfg.memoryInGBs)} GB)`; +} + +function readStored(key: string): string { + try { + return localStorage.getItem(key)?.trim() ?? ""; + } catch { + return ""; + } +} + +export const readStoredCompartment = () => readStored(CORE_COMPARTMENT_KEY); + +export function writeStoredCompartment(id: string): void { + try { + localStorage.setItem(CORE_COMPARTMENT_KEY, id); + } catch { + // storage unavailable: selection applies for this session only + } +} diff --git a/ui/src/lib/ocievents.test.ts b/ui/src/lib/ocievents.test.ts new file mode 100644 index 0000000000..d95a365572 --- /dev/null +++ b/ui/src/lib/ocievents.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + EVENTS_PROXY_BASE, + actionTypeLabel, + createRule, + deleteRule, + getRule, + listCompartments, + listDeliveries, + listFunctionOptions, + listRules, + listStreamOptions, + listTopicOptions, + parseAction, + parseDelivery, + parseRule, + prettyCondition, + setRuleEnabled, + summarizeCondition, + validateCondition, +} from "./ocievents"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; +const cond = '{"eventType":["com.oraclecloud.objectstorage.createobject"]}'; + +const rule = { + id: "r1", + displayName: "uploads", + lifecycleState: "ACTIVE", + isEnabled: true, + description: "d", + condition: cond, + timeCreated: "2026-01-01T00:00:00Z", + actions: { + actions: [ + { id: "a1", actionType: "ONS", topicId: "t1", isEnabled: true, lifecycleState: "ACTIVE" }, + { id: "a2", actionType: "OSS", streamId: "s1", isEnabled: false }, + { id: "a3", actionType: "FAAS", functionId: "f1", isEnabled: true }, + ], + }, +}; + +describe("parsing", () => { + it("reads a rule with its actions", () => { + const r = parseRule(rule); + expect(r).toMatchObject({ id: "r1", name: "uploads", state: "ACTIVE", enabled: true }); + expect(r.actions.map((a) => a.targetId)).toEqual(["t1", "s1", "f1"]); + expect(parseAction(rule.actions.actions[1]!).enabled).toBe(false); + }); + + it("tolerates missing fields", () => { + expect(parseRule({ id: "x" })).toMatchObject({ name: "x", actions: [], enabled: false }); + expect(parseDelivery({}).status).toBe(""); + }); + + it("reads a delivery", () => { + expect( + parseDelivery({ time: "t", eventId: "e", ruleId: "r", status: "FAILED", error: "boom" }), + ).toMatchObject({ eventId: "e", ruleId: "r", status: "FAILED", error: "boom" }); + }); +}); + +describe("conditions", () => { + it("pretty-prints JSON and leaves other text", () => { + expect(prettyCondition('{"a":1}')).toBe('{\n "a": 1\n}'); + expect(prettyCondition("nope")).toBe("nope"); + }); + + it("summarizes leaves", () => { + expect(summarizeCondition(cond)).toBe("eventType=com.oraclecloud.objectstorage.createobject"); + expect(summarizeCondition('{"a":{"b":"x"},"c":["1","2"]}')).toBe("a.b=x, c=1|2"); + expect(summarizeCondition("{}")).toBe("matches every event"); + expect(summarizeCondition("x".repeat(200), 10)).toHaveLength(10); + expect(summarizeCondition(`{"k":"${"v".repeat(200)}"}`, 12)).toHaveLength(12); + }); + + it.each([ + ["", "condition is required"], + ["{", "not valid JSON"], + ["[]", "must be a JSON object"], + ["3", "must be a JSON object"], + ['{"a*":"x"}', "wildcards are allowed in values only"], + ['{"a":[]}', "at least one value"], + ['{"a":[{"b":1}]}', "array elements"], + ['{"a":null}', "null is not a valid"], + ['{"a":{"b":null}}', "condition.a.b"], + ])("rejects %j", (text, msg) => { + expect(validateCondition(text)).toContain(msg); + }); + + it.each(["{}", cond, '{"data":{"resourceName":"a*"}}', '{"n":1,"b":true}'])("accepts %j", (t) => { + expect(validateCondition(t)).toBe(""); + }); + + it("labels action types", () => { + expect(actionTypeLabel("OSS")).toBe("Streaming stream"); + expect(actionTypeLabel("")).toBe("—"); + }); +}); + +describe("client", () => { + it("lists compartments", async () => { + const f = vi.fn().mockResolvedValue(json({ compartments: [comp] })); + expect(await listCompartments(asFetch(f))).toEqual([comp]); + expect(f.mock.calls[0]?.[0]).toBe(`${EVENTS_PROXY_BASE}/compartments`); + }); + + it("lists rules then fetches each for its actions", async () => { + const f = vi.fn((url: string) => + Promise.resolve(url.includes("/api/rules/r1") ? json(rule) : json([{ id: "r1" }], 200)), + ); + const rules = await listRules(comp, asFetch(f)); + expect(rules[0]?.actions).toHaveLength(3); + expect(f.mock.calls[0]?.[0]).toBe( + `${EVENTS_PROXY_BASE}/api/rules?compartmentId=${encodeURIComponent(comp)}`, + ); + }); + + it("follows opc-next-page", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json([{ id: "r1" }], 200, { "opc-next-page": "p2" })) + .mockResolvedValueOnce(json([{ id: "r2" }])) + .mockImplementation(() => Promise.resolve(json(rule))); + expect(await listRules(comp, asFetch(f))).toHaveLength(2); + expect(String(f.mock.calls[1]?.[0])).toContain("page=p2"); + }); + + it("gets a rule", async () => { + const f = vi.fn().mockResolvedValue(json(rule)); + expect((await getRule("r/1", asFetch(f))).name).toBe("uploads"); + expect(f.mock.calls[0]?.[0]).toBe(`${EVENTS_PROXY_BASE}/api/rules/r%2F1`); + }); + + it("creates a rule with a fixed body per action type", async () => { + const f = vi.fn().mockResolvedValue(json(rule)); + await createRule( + comp, + { + name: " n ", + description: " d ", + condition: cond, + enabled: true, + actions: [ + { type: "ONS", targetId: " t1 ", enabled: true, description: " x " }, + { type: "OSS", targetId: "s1", enabled: false }, + { type: "FAAS", targetId: "f1", enabled: true }, + ], + }, + asFetch(f), + ); + const init = f.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + compartmentId: comp, + displayName: "n", + description: "d", + condition: cond, + isEnabled: true, + actions: { + actions: [ + { actionType: "ONS", isEnabled: true, topicId: "t1", description: "x" }, + { actionType: "OSS", isEnabled: false, streamId: "s1" }, + { actionType: "FAAS", isEnabled: true, functionId: "f1" }, + ], + }, + }); + }); + + it("surfaces backend validation errors", async () => { + const f = vi + .fn() + .mockResolvedValue(json({ code: "InvalidParameter", message: "condition bad" }, 400)); + await expect( + createRule( + comp, + { name: "n", description: "", condition: "{", enabled: true, actions: [] }, + asFetch(f), + ), + ).rejects.toThrow("condition bad"); + }); + + it("toggles and deletes", async () => { + const f = vi + .fn() + .mockResolvedValueOnce(json(rule)) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + await setRuleEnabled("r1", false, asFetch(f)); + const put = f.mock.calls[0]?.[1] as RequestInit; + expect(put.method).toBe("PUT"); + expect(JSON.parse(String(put.body))).toEqual({ isEnabled: false }); + await deleteRule("r1", asFetch(f)); + const del = f.mock.calls[1]?.[1] as RequestInit; + expect(del.method).toBe("DELETE"); + }); + + it("lists deliveries newest first", async () => { + const f = vi.fn().mockResolvedValue(json([{ eventId: "old" }, { eventId: "new" }])); + expect((await listDeliveries(asFetch(f))).map((d) => d.eventId)).toEqual(["new", "old"]); + expect(f.mock.calls[0]?.[0]).toBe(`${EVENTS_PROXY_BASE}/deliveries`); + }); +}); + +const reply = (url: string) => { + if (url.includes("/notifications/")) + return json([ + { topicId: "t1", name: "alerts", lifecycleState: "ACTIVE" }, + { topicId: "t2", name: "pending", lifecycleState: "CREATING" }, + ]); + if (url.includes("/streaming/")) + return json({ + streams: [ + { id: "s1", name: "feed", lifecycleState: "ACTIVE" }, + { id: "s2", name: "busy", lifecycleState: "CREATING" }, + ], + }); + if (url.endsWith("/applications")) return json({ applications: [{ id: "app1" }] }); + return json({ + functions: [ + { id: "f1", displayName: "fn", lifecycleState: "ACTIVE" }, + { id: "f2", displayName: "gone", lifecycleState: "DELETED" }, + ], + }); +}; + +describe("target options", () => { + it("lists topics, streams and functions", async () => { + const f = vi.fn((url: string) => Promise.resolve(reply(url))); + expect(await listTopicOptions(comp, asFetch(f))).toEqual([{ id: "t1", label: "alerts" }]); + expect(await listStreamOptions(asFetch(f))).toEqual([{ id: "s1", label: "feed" }]); + expect(await listFunctionOptions(asFetch(f))).toEqual([{ id: "f1", label: "fn" }]); + }); + + it("is empty when a proxy is unavailable", async () => { + const f = vi.fn().mockResolvedValue(json({ error: { message: "down" } }, 503)); + expect(await listTopicOptions(comp, asFetch(f))).toEqual([]); + expect(await listStreamOptions(asFetch(f))).toEqual([]); + expect(await listFunctionOptions(asFetch(f))).toEqual([]); + }); +}); diff --git a/ui/src/lib/ocievents.ts b/ui/src/lib/ocievents.ts new file mode 100644 index 0000000000..59df5b4673 --- /dev/null +++ b/ui/src/lib/ocievents.ts @@ -0,0 +1,310 @@ +/** Client for the Oracle Cloud Events emulator: rules, their actions and the delivery log via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; +import { listApplications, listFunctions } from "./ocifunctions.ts"; +import { listTopics } from "./ocinotifications.ts"; +import { listStreams } from "./ocistreaming.ts"; + +export const EVENTS_PROXY_BASE = "/dashboard/api/v1/oci/events"; +export const EVENTS_COMPARTMENT_KEY = "gopherstack_ocievents_compartment"; +export const DELIVERY_REFRESH_MS = 3000; + +const enc = encodeURIComponent; +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); + +export type ActionType = "ONS" | "OSS" | "FAAS"; + +export const ACTION_TYPES: { id: ActionType; label: string }[] = [ + { id: "ONS", label: "Notifications topic" }, + { id: "OSS", label: "Streaming stream" }, + { id: "FAAS", label: "Functions function" }, +]; + +export type EventsAction = { + id: string; + type: string; + state: string; + targetId: string; + enabled: boolean; + description: string; +}; + +export type EventsRule = { + id: string; + name: string; + state: string; + enabled: boolean; + description: string; + condition: string; + actions: EventsAction[]; + created: string; +}; + +export type EventsDelivery = { + time: string; + eventId: string; + eventType: string; + ruleId: string; + actionId: string; + actionType: string; + targetId: string; + status: string; + error: string; +}; + +export type ActionInput = { + type: ActionType; + targetId: string; + enabled: boolean; + description?: string; +}; + +export type RuleInput = { + name: string; + description: string; + condition: string; + enabled: boolean; + actions: ActionInput[]; +}; + +export type TargetOption = { id: string; label: string }; + +export function parseAction(raw: Json): EventsAction { + return { + id: str(raw.id), + type: str(raw.actionType), + state: str(raw.lifecycleState), + targetId: str(raw.topicId) || str(raw.streamId) || str(raw.functionId), + enabled: raw.isEnabled === true, + description: str(raw.description), + }; +} + +export function parseRule(raw: Json): EventsRule { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + enabled: raw.isEnabled === true, + description: str(raw.description), + condition: str(raw.condition), + actions: list(obj(raw.actions).actions).map((a) => parseAction(a)), + created: str(raw.timeCreated), + }; +} + +export function parseDelivery(raw: Json): EventsDelivery { + return { + time: str(raw.time), + eventId: str(raw.eventId), + eventType: str(raw.eventType), + ruleId: str(raw.ruleId), + actionId: str(raw.actionId), + actionType: str(raw.actionType), + targetId: str(raw.targetId), + status: str(raw.status), + error: str(raw.error), + }; +} + +/** Pretty-prints a rule condition; text that is not JSON is returned unchanged. */ +export function prettyCondition(condition: string): string { + try { + return JSON.stringify(JSON.parse(condition), null, 2); + } catch { + return condition; + } +} + +function leaves(prefix: string, v: unknown, out: string[]): void { + if (Array.isArray(v)) { + out.push(`${prefix}=${v.map(String).join("|")}`); + } else if (v && typeof v === "object") { + for (const [k, child] of Object.entries(v as Json)) { + leaves(prefix ? `${prefix}.${k}` : k, child, out); + } + } else { + out.push(`${prefix}=${String(v)}`); + } +} + +/** One-line summary of a condition: its leaf filters as key=value. */ +export function summarizeCondition(condition: string, max = 90): string { + let parsed: unknown; + try { + parsed = JSON.parse(condition); + } catch { + return condition.length > max ? `${condition.slice(0, max - 1)}…` : condition; + } + const out: string[] = []; + leaves("", parsed, out); + const text = out.length > 0 ? out.join(", ") : "matches every event"; + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +function checkNode(path: string, v: unknown): string { + if (Array.isArray(v)) { + if (v.length === 0) return `${path}: an array must list at least one value`; + if (v.some((e) => e === null || typeof e === "object")) { + return `${path}: array elements must be strings, numbers or booleans`; + } + return ""; + } + if (v === null) return `${path}: null is not a valid filter value`; + if (typeof v === "object") { + for (const [k, child] of Object.entries(v as Json)) { + if (k.includes("*")) { + return `${path}: wildcards are allowed in values only, not in the field name "${k}"`; + } + const err = checkNode(`${path}.${k}`, child); + if (err) return err; + } + } + return ""; +} + +/** Client-side check of a condition (JSON object, scalar or non-empty scalar-array leaves); "" when valid. */ +export function validateCondition(text: string): string { + if (!text.trim()) return "condition is required"; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (e) { + return `condition is not valid JSON: ${e instanceof Error ? e.message : String(e)}`; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return "condition must be a JSON object"; + } + return checkNode("condition", parsed); +} + +export const actionTypeLabel = (type: string): string => + ACTION_TYPES.find((t) => t.id === type)?.label ?? (type || "—"); + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(EVENTS_PROXY_BASE, fetchFn); + +/** Rules of a compartment with their actions (the list answer omits actions, so each rule is fetched). */ +export async function listRules( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + EVENTS_PROXY_BASE, + "/api/rules", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return Promise.all( + items.map(async (r) => + parseRule(await ociGetOne(EVENTS_PROXY_BASE, `/api/rules/${enc(str(r.id))}`, fetchFn)), + ), + ); +} + +export async function getRule(id: string, fetchFn: FetchFn = fetch): Promise { + return parseRule(await ociGetOne(EVENTS_PROXY_BASE, `/api/rules/${enc(id)}`, fetchFn)); +} + +function actionBody(a: ActionInput): Json { + const body: Json = { actionType: a.type, isEnabled: a.enabled }; + const target = a.targetId.trim(); + if (a.type === "ONS") body.topicId = target; + else if (a.type === "OSS") body.streamId = target; + else body.functionId = target; + if (a.description?.trim()) body.description = a.description.trim(); + return body; +} + +export async function createRule( + compartment: string, + input: RuleInput, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + EVENTS_PROXY_BASE, + "POST", + "/api/rules", + { + compartmentId: compartment, + displayName: input.name.trim(), + description: input.description.trim(), + condition: input.condition, + isEnabled: input.enabled, + actions: { actions: input.actions.map(actionBody) }, + }, + fetchFn, + ); +} + +export async function setRuleEnabled( + id: string, + enabled: boolean, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + EVENTS_PROXY_BASE, + "PUT", + `/api/rules/${enc(id)}`, + { isEnabled: enabled }, + fetchFn, + ); +} + +export async function deleteRule(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall(EVENTS_PROXY_BASE, "DELETE", `/api/rules/${enc(id)}`, undefined, fetchFn); +} + +/** The delivery log, newest first. */ +export async function listDeliveries(fetchFn: FetchFn = fetch): Promise { + const items = await ociListPages(EVENTS_PROXY_BASE, "/deliveries", "", "", fetchFn); + return items.map((d) => parseDelivery(d)).toReversed(); +} + +const settled = async (load: () => Promise): Promise => { + try { + return await load(); + } catch { + return []; + } +}; + +/** ACTIVE topics of a compartment (the backend rejects other targets); empty when the Notifications proxy is unavailable. */ +export const listTopicOptions = (compartment: string, fetchFn: FetchFn = fetch) => + settled(async () => + (await listTopics(compartment, fetchFn)) + .filter((t) => t.state === "ACTIVE") + .map((t) => ({ id: t.id, label: t.name || t.id })), + ); + +/** ACTIVE streams of the Streaming service; empty when its proxy is unavailable. */ +export const listStreamOptions = (fetchFn: FetchFn = fetch) => + settled(async () => + (await listStreams("", fetchFn)) + .filter((s) => s.state === "ACTIVE") + .map((s) => ({ id: s.id, label: s.name || s.id })), + ); + +/** ACTIVE functions across every application; empty when the Functions proxy is unavailable. */ +export const listFunctionOptions = (fetchFn: FetchFn = fetch) => + settled(async () => { + const apps = await listApplications(fetchFn); + const groups = await Promise.all(apps.map((a) => listFunctions(a.id, fetchFn))); + return groups + .flat() + .filter((f) => f.state === "ACTIVE") + .map((f) => ({ id: f.id, label: f.name || f.id })); + }); + +export const readStoredEventsCompartment = () => readStoredValue(EVENTS_COMPARTMENT_KEY); +export const writeStoredEventsCompartment = (id: string) => + writeStoredValue(EVENTS_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ociloadbalancer.test.ts b/ui/src/lib/ociloadbalancer.test.ts new file mode 100644 index 0000000000..60ac82faea --- /dev/null +++ b/ui/src/lib/ociloadbalancer.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + LB_PROXY_BASE, + addBackend, + backendKey, + canTry, + createLoadBalancer, + deleteBackend, + deleteLoadBalancer, + getBackendHealth, + getBackendSetHealth, + getLoadBalancer, + healthTone, + isServing, + listCompartments, + listLoadBalancers, + listWorkRequests, + loadHealth, + parseCertificate, + parseLoadBalancer, + prettyBody, + tryIt, + updateBackend, + validBandwidth, + validIp, + validPort, + validWeight, +} from "./ociloadbalancer"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; + +const lbRaw = { + id: "lb1", + displayName: "edge", + lifecycleState: "ACTIVE", + shapeName: "flexible", + shapeDetails: { minimumBandwidthInMbps: 10, maximumBandwidthInMbps: 100 }, + isPrivate: false, + ipAddresses: [{ ipAddress: "198.18.0.4", isPublic: true }], + subnetIds: ["s1"], + listeners: { + http: { + name: "http", + protocol: "HTTP", + port: 80, + defaultBackendSetName: "web", + hostnameNames: ["h1"], + gopherstackAddress: "127.0.0.1:40001", + }, + tls: { + name: "tls", + protocol: "HTTP", + port: 443, + defaultBackendSetName: "web", + sslConfiguration: { certificateName: "c" }, + gopherstackAddress: "127.0.0.1:40002", + }, + }, + backendSets: { + web: { + name: "web", + policy: "ROUND_ROBIN", + healthChecker: { + protocol: "HTTP", + port: 0, + urlPath: "/", + returnCode: 200, + retries: 1, + timeoutInMillis: 3000, + intervalInMillis: 1000, + }, + backends: [ + { name: "127.0.0.1:9001", ipAddress: "127.0.0.1", port: 9001, weight: 2, offline: true }, + { + name: "127.0.0.1:9002", + ipAddress: "127.0.0.1", + port: 9002, + weight: 1, + maxConnections: 5, + }, + ], + }, + }, + pathRouteSets: { + p: { + name: "p", + pathRoutes: [ + { path: "/a", pathMatchType: { matchType: "PREFIX_MATCH" }, backendSetName: "web" }, + ], + }, + }, + routingPolicies: { + r: { + name: "r", + rules: [ + { + name: "r1", + condition: "all(http.request.url.path eq '/x')", + actions: [{ backendSetName: "web" }], + }, + ], + }, + }, + ruleSets: { rs: { name: "rs", items: [{ action: "ADD_HTTP_REQUEST_HEADER", header: "X-A" }] } }, + certificates: { + c: { + certificateName: "c", + subjectNames: ["lb.example"], + issuer: "ca", + notAfter: "2030-01-01T00:00:00Z", + }, + }, +}; + +describe("parsing", () => { + it("parses a load balancer with all sub-resources", () => { + const lb = parseLoadBalancer(lbRaw); + expect(lb).toMatchObject({ + id: "lb1", + name: "edge", + state: "ACTIVE", + shape: "flexible 10-100 Mbps", + }); + expect(lb.ips).toEqual([{ address: "198.18.0.4", isPublic: true }]); + expect(lb.listeners.map((l) => [l.name, l.address, l.tls])).toEqual([ + ["http", "127.0.0.1:40001", false], + ["tls", "127.0.0.1:40002", true], + ]); + expect(lb.listeners[0]?.hostnames).toEqual(["h1"]); + expect(lb.backendSets[0]?.healthChecker).toMatchObject({ + protocol: "HTTP", + intervalMillis: 1000, + }); + expect(lb.backendSets[0]?.backends[0]).toMatchObject({ + offline: true, + drain: false, + maxConnections: null, + }); + expect(lb.backendSets[0]?.backends[1]?.maxConnections).toBe(5); + expect(lb.pathRouteSets[0]?.routes[0]).toEqual({ + path: "/a", + matchType: "PREFIX_MATCH", + backendSet: "web", + }); + expect(lb.routingPolicies[0]?.rules[0]?.backendSets).toEqual(["web"]); + expect(lb.ruleSets[0]?.items).toEqual(["ADD_HTTP_REQUEST_HEADER X-A"]); + expect(lb.certificates[0]?.subjects).toEqual(["lb.example"]); + }); + + it("copes with an empty load balancer", () => { + expect(parseLoadBalancer({ id: "x" })).toMatchObject({ + name: "x", + listeners: [], + backendSets: [], + ips: [], + }); + }); + + it("parses certificates without key material", () => { + const c = parseCertificate({ + certificateName: "c", + subjectNames: ["a"], + publicCertificate: "must-not-surface", + privateKey: "must-not-surface", + }); + expect(c.subjects).toEqual(["a"]); + expect(JSON.stringify(c)).not.toContain("must-not-surface"); + }); + + it("maps health statuses to tones", () => { + expect(healthTone("OK")).toBe("ok"); + expect(healthTone("WARNING")).toBe("warn"); + expect(healthTone("CRITICAL")).toBe("bad"); + expect(healthTone("UNKNOWN")).toBe("unknown"); + expect(healthTone("")).toBe("unknown"); + }); + + it("flags servable listeners", () => { + expect(canTry({ address: "127.0.0.1:1", tls: false })).toBe(true); + expect(canTry({ address: "127.0.0.1:1", tls: true })).toBe(false); + expect(canTry({ address: "", tls: false })).toBe(false); + expect(isServing({ state: "ACTIVE" })).toBe(true); + expect(isServing({ state: "CREATING" })).toBe(false); + }); + + it("validates form input", () => { + expect(validPort("80")).toBe(true); + expect(validPort("0")).toBe(false); + expect(validPort("70000")).toBe(false); + expect(validPort("8x")).toBe(false); + expect(validWeight("3")).toBe(true); + expect(validWeight("0")).toBe(false); + expect(validBandwidth("10")).toBe(true); + expect(validBandwidth("9")).toBe(false); + expect(validBandwidth("8001")).toBe(false); + expect(validIp("127.0.0.1")).toBe(true); + expect(validIp("999.0.0.1")).toBe(false); + expect(validIp("::1")).toBe(true); + expect(validIp("host")).toBe(false); + }); + + it("pretty-prints JSON and flags binary bodies", () => { + expect(prettyBody({ body: '{"a":1}', encoding: "utf8" })).toBe('{\n "a": 1\n}'); + expect(prettyBody({ body: "plain", encoding: "utf8" })).toBe("plain"); + expect(prettyBody({ body: "AAE=", encoding: "base64" })).toContain("binary"); + }); +}); + +describe("client", () => { + it("lists compartments and fetches each load balancer's detail", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.endsWith("/compartments") + ? { compartments: [comp] } + : url.includes("/loadBalancers/lb1") + ? lbRaw + : [{ id: "lb1" }], + ), + ), + ); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + const out = await listLoadBalancers(comp, asFetch(fetchFn)); + expect(out[0]?.listeners).toHaveLength(2); + expect(fetchFn.mock.calls[1]?.[0]).toBe( + `${LB_PROXY_BASE}/api/loadBalancers?compartmentId=${encodeURIComponent(comp)}`, + ); + }); + + it("reads one load balancer and its work requests", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.endsWith("/workRequests") + ? [{ id: "w", type: "CreateLoadBalancer", lifecycleState: "SUCCEEDED" }] + : lbRaw, + ), + ), + ); + expect((await getLoadBalancer("lb1", asFetch(fetchFn))).name).toBe("edge"); + const wr = await listWorkRequests("lb1", asFetch(fetchFn)); + expect(wr[0]).toMatchObject({ type: "CreateLoadBalancer", state: "SUCCEEDED" }); + }); + + it("reads backend and backend set health, escaping the backend name", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.endsWith("/health") && url.includes("/backends/") + ? { status: "OK", healthCheckResults: [{ healthCheckStatus: "OK" }] } + : { + status: "WARNING", + totalBackendCount: 2, + warningStateBackendNames: ["a"], + criticalStateBackendNames: [], + }, + ), + ), + ); + expect(await getBackendHealth("lb1", "web", "127.0.0.1:9001", asFetch(fetchFn))).toEqual({ + status: "OK", + detail: "OK", + }); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${LB_PROXY_BASE}/api/loadBalancers/lb1/backendSets/web/backends/127.0.0.1%3A9001/health`, + ); + expect((await getBackendSetHealth("lb1", "web", asFetch(fetchFn))).status).toBe("WARNING"); + }); + + it("loads health for every set and backend", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ status: "OK" }))); + const lb = parseLoadBalancer(lbRaw); + const h = await loadHealth(lb, asFetch(fetchFn)); + expect(Object.keys(h).toSorted()).toEqual( + ["web", backendKey("web", "127.0.0.1:9001"), backendKey("web", "127.0.0.1:9002")].toSorted(), + ); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it("adds, updates and removes backends", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(json({}))); + await addBackend( + "lb1", + "web", + { ipAddress: "127.0.0.1", port: 9003, weight: 1 }, + asFetch(fetchFn), + ); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${LB_PROXY_BASE}/api/loadBalancers/lb1/backendSets/web/backends`, + ); + expect(fetchFn.mock.calls[0]?.[1]?.method).toBe("POST"); + + const be = parseLoadBalancer(lbRaw).backendSets[0]!.backends[1]!; + await updateBackend("lb1", "web", be, { offline: true }, asFetch(fetchFn)); + expect(fetchFn.mock.calls[1]?.[1]?.method).toBe("PUT"); + expect(JSON.parse(String(fetchFn.mock.calls[1]?.[1]?.body))).toEqual({ + weight: 1, + backup: false, + drain: false, + offline: true, + maxConnections: 5, + }); + + await deleteBackend("lb1", "web", "127.0.0.1:9002", asFetch(fetchFn)); + expect(fetchFn.mock.calls[2]?.[0]).toContain("/backends/127.0.0.1%3A9002"); + expect(fetchFn.mock.calls[2]?.[1]?.method).toBe("DELETE"); + }); + + it("creates and deletes load balancers", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(json({}))); + const base = { + compartmentId: comp, + displayName: "n", + subnetId: "s", + isPrivate: false, + minimumBandwidthInMbps: 10, + maximumBandwidthInMbps: 10, + }; + await createLoadBalancer({ ...base, listenerPort: null }, asFetch(fetchFn)); + expect(JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body))).not.toHaveProperty("listenerPort"); + await createLoadBalancer({ ...base, listenerPort: 80 }, asFetch(fetchFn)); + expect(JSON.parse(String(fetchFn.mock.calls[1]?.[1]?.body))).toMatchObject({ + listenerPort: 80, + }); + await deleteLoadBalancer("lb1", asFetch(fetchFn)); + expect(fetchFn.mock.calls[2]?.[1]?.method).toBe("DELETE"); + }); + + it("sends a try-it request to the selected listener only", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve( + json({ + status: 200, + headers: { "X-Backend": "alpha" }, + body: "hi", + encoding: "utf8", + truncated: false, + }), + ), + ); + const res = await tryIt( + "lb1", + "http", + { + method: "GET", + path: "/x", + query: "?a=1", + body: "", + virtualHost: " app.test ", + headers: [ + { name: "X-A", value: "1" }, + { name: " ", value: "drop" }, + ], + }, + asFetch(fetchFn), + ); + expect(res).toMatchObject({ status: 200, body: "hi", headers: { "X-Backend": "alpha" } }); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${LB_PROXY_BASE}/api/loadBalancers/lb1/listeners/http/invoke`, + ); + expect(JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body))).toEqual({ + method: "GET", + path: "/x", + query: "a=1", + body: "", + virtualHost: "app.test", + headers: [{ name: "X-A", value: "1" }], + }); + }); + + it("surfaces proxy errors", async () => { + const fetchFn = vi.fn(() => + Promise.resolve( + json( + { error: { status: "IncorrectState", message: "Load balancer is in state CREATING" } }, + 409, + ), + ), + ); + await expect( + tryIt( + "lb1", + "http", + { method: "GET", path: "/", query: "", body: "", virtualHost: "", headers: [] }, + asFetch(fetchFn), + ), + ).rejects.toThrow(/CREATING/); + }); +}); diff --git a/ui/src/lib/ociloadbalancer.ts b/ui/src/lib/ociloadbalancer.ts new file mode 100644 index 0000000000..0f27eeab87 --- /dev/null +++ b/ui/src/lib/ociloadbalancer.ts @@ -0,0 +1,492 @@ +/** Client for the Oracle Cloud Load Balancer emulator: load balancers, backend health and a listener "try it" call via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + ociPostOne, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const LB_PROXY_BASE = "/dashboard/api/v1/oci/loadbalancer"; +export const LB_COMPARTMENT_KEY = "gopherstack_ocilb_compartment"; +export const LB_MIN_MBPS = 10; +export const LB_MAX_MBPS = 8000; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const strs = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); +const sortedValues = (v: unknown): Json[] => + Object.entries(obj(v)) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([, x]) => obj(x)); + +export type LbListener = { + name: string; + protocol: string; + port: number; + defaultBackendSet: string; + hostnames: string[]; + pathRouteSet: string; + routingPolicy: string; + ruleSets: string[]; + tls: boolean; + address: string; +}; + +export type LbBackend = { + name: string; + ipAddress: string; + port: number; + weight: number; + backup: boolean; + drain: boolean; + offline: boolean; + maxConnections: number | null; +}; + +export type LbHealthChecker = { + protocol: string; + port: number; + urlPath: string; + returnCode: number; + retries: number; + timeoutMillis: number; + intervalMillis: number; +}; + +export type LbBackendSet = { + name: string; + policy: string; + healthChecker: LbHealthChecker; + backends: LbBackend[]; +}; + +export type LbPathRoute = { path: string; matchType: string; backendSet: string }; +export type LbPathRouteSet = { name: string; routes: LbPathRoute[] }; +export type LbRoutingRule = { name: string; condition: string; backendSets: string[] }; +export type LbRoutingPolicy = { name: string; rules: LbRoutingRule[] }; +export type LbRuleSet = { name: string; items: string[] }; +export type LbCertificate = { + name: string; + subjects: string[]; + issuer: string; + notBefore: string; + notAfter: string; +}; + +export type LbIp = { address: string; isPublic: boolean }; + +export type LoadBalancer = { + id: string; + name: string; + state: string; + shape: string; + isPrivate: boolean; + ips: LbIp[]; + subnetIds: string[]; + created: string; + listeners: LbListener[]; + backendSets: LbBackendSet[]; + pathRouteSets: LbPathRouteSet[]; + routingPolicies: LbRoutingPolicy[]; + ruleSets: LbRuleSet[]; + certificates: LbCertificate[]; +}; + +export type LbWorkRequest = { + id: string; + type: string; + state: string; + message: string; + accepted: string; + finished: string; +}; + +export type LbHealth = { status: string; detail: string }; + +export type TryRequest = { + method: string; + path: string; + query: string; + body: string; + virtualHost: string; + headers: { name: string; value: string }[]; +}; + +export type TryResponse = { + status: number; + headers: Record; + body: string; + encoding: string; + truncated: boolean; +}; + +export const TRY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +export function parseListener(raw: Json): LbListener { + return { + name: str(raw.name), + protocol: str(raw.protocol), + port: num(raw.port), + defaultBackendSet: str(raw.defaultBackendSetName), + hostnames: strs(raw.hostnameNames), + pathRouteSet: str(raw.pathRouteSetName), + routingPolicy: str(raw.routingPolicyName), + ruleSets: strs(raw.ruleSetNames), + tls: Boolean(raw.sslConfiguration), + address: str(raw.gopherstackAddress), + }; +} + +export function parseBackend(raw: Json): LbBackend { + return { + name: str(raw.name), + ipAddress: str(raw.ipAddress), + port: num(raw.port), + weight: num(raw.weight) || 1, + backup: raw.backup === true, + drain: raw.drain === true, + offline: raw.offline === true, + maxConnections: raw.maxConnections === undefined ? null : num(raw.maxConnections), + }; +} + +export function parseBackendSet(raw: Json): LbBackendSet { + const hc = obj(raw.healthChecker); + return { + name: str(raw.name), + policy: str(raw.policy), + healthChecker: { + protocol: str(hc.protocol), + port: num(hc.port), + urlPath: str(hc.urlPath), + returnCode: num(hc.returnCode), + retries: num(hc.retries), + timeoutMillis: num(hc.timeoutInMillis), + intervalMillis: num(hc.intervalInMillis), + }, + backends: list(raw.backends).map((b) => parseBackend(b)), + }; +} + +function ruleSummary(r: Json): string { + const a = str(r.action); + const detail = str(r.header) || str(r.prefix) || str(r.suffix) || ""; + return detail ? `${a} ${detail}` : a; +} + +export function parseCertificate(raw: Json): LbCertificate { + return { + name: str(raw.certificateName), + subjects: strs(raw.subjectNames), + issuer: str(raw.issuer), + notBefore: str(raw.notBefore), + notAfter: str(raw.notAfter), + }; +} + +export function shapeLabel(raw: Json): string { + const name = str(raw.shapeName); + const d = obj(raw.shapeDetails); + if (d.minimumBandwidthInMbps === undefined) return name; + return `${name} ${num(d.minimumBandwidthInMbps)}-${num(d.maximumBandwidthInMbps)} Mbps`; +} + +export function parseLoadBalancer(raw: Json): LoadBalancer { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + shape: shapeLabel(raw), + isPrivate: raw.isPrivate === true, + ips: list(raw.ipAddresses).map((i) => ({ + address: str(i.ipAddress), + isPublic: i.isPublic === true, + })), + subnetIds: strs(raw.subnetIds), + created: str(raw.timeCreated), + listeners: sortedValues(raw.listeners).map((l) => parseListener(l)), + backendSets: sortedValues(raw.backendSets).map((s) => parseBackendSet(s)), + pathRouteSets: sortedValues(raw.pathRouteSets).map((s) => ({ + name: str(s.name), + routes: list(s.pathRoutes).map((r) => ({ + path: str(r.path), + matchType: str(obj(r.pathMatchType).matchType), + backendSet: str(r.backendSetName), + })), + })), + routingPolicies: sortedValues(raw.routingPolicies).map((p) => ({ + name: str(p.name), + rules: list(p.rules).map((r) => ({ + name: str(r.name), + condition: str(r.condition), + backendSets: list(r.actions).map((a) => str(a.backendSetName)), + })), + })), + ruleSets: sortedValues(raw.ruleSets).map((s) => ({ + name: str(s.name), + items: list(s.items).map((i) => ruleSummary(i)), + })), + certificates: sortedValues(raw.certificates).map((c) => parseCertificate(c)), + }; +} + +export function parseWorkRequest(raw: Json): LbWorkRequest { + return { + id: str(raw.id), + type: str(raw.type), + state: str(raw.lifecycleState), + message: str(raw.message), + accepted: str(raw.timeAccepted), + finished: str(raw.timeFinished), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(LB_PROXY_BASE, fetchFn); + +export async function getLoadBalancer(id: string, fetchFn: FetchFn = fetch): Promise { + return parseLoadBalancer( + await ociGetOne(LB_PROXY_BASE, `/api/loadBalancers/${enc(id)}`, fetchFn), + ); +} + +/** Load balancers of one compartment with listeners and backend sets (the list answer omits them, so each is fetched). */ +export async function listLoadBalancers( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + LB_PROXY_BASE, + "/api/loadBalancers", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return Promise.all(items.map((i) => getLoadBalancer(str(i.id), fetchFn))); +} + +export async function listWorkRequests( + id: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + LB_PROXY_BASE, + `/api/loadBalancers/${enc(id)}/workRequests`, + "", + "", + fetchFn, + ); + return items.map((r) => parseWorkRequest(r)); +} + +const setPath = (lbId: string, set: string) => + `/api/loadBalancers/${enc(lbId)}/backendSets/${enc(set)}`; +const backendPath = (lbId: string, set: string, backend: string) => + `${setPath(lbId, set)}/backends/${enc(backend)}`; + +export async function getBackendSetHealth( + lbId: string, + set: string, + fetchFn: FetchFn = fetch, +): Promise { + const h = await ociGetOne(LB_PROXY_BASE, `${setPath(lbId, set)}/health`, fetchFn); + const bad = strs(h.criticalStateBackendNames).length + strs(h.warningStateBackendNames).length; + return { + status: str(h.status) || "UNKNOWN", + detail: `${num(h.totalBackendCount)} backends, ${bad} unhealthy`, + }; +} + +export async function getBackendHealth( + lbId: string, + set: string, + backend: string, + fetchFn: FetchFn = fetch, +): Promise { + const h = await ociGetOne(LB_PROXY_BASE, `${backendPath(lbId, set, backend)}/health`, fetchFn); + const first = obj(list(h.healthCheckResults)[0]); + return { status: str(h.status) || "UNKNOWN", detail: str(first.healthCheckStatus) }; +} + +export async function getLoadBalancerHealth( + lbId: string, + fetchFn: FetchFn = fetch, +): Promise { + const h = await ociGetOne(LB_PROXY_BASE, `/api/loadBalancers/${enc(lbId)}/health`, fetchFn); + return { + status: str(h.status) || "UNKNOWN", + detail: `${num(h.totalBackendSetCount)} backend sets`, + }; +} + +export const backendKey = (set: string, backend: string): string => `${set}\u0000${backend}`; + +/** Health of every backend of the load balancer, keyed by `backendKey`, plus each set's aggregate under its name. */ +export async function loadHealth( + lb: Pick, + fetchFn: FetchFn = fetch, +): Promise> { + const out: Record = {}; + await Promise.all( + lb.backendSets.map(async (s) => { + out[s.name] = await getBackendSetHealth(lb.id, s.name, fetchFn); + await Promise.all( + s.backends.map(async (b) => { + out[backendKey(s.name, b.name)] = await getBackendHealth(lb.id, s.name, b.name, fetchFn); + }), + ); + }), + ); + return out; +} + +export type NewBackend = { ipAddress: string; port: number; weight: number }; + +export async function addBackend( + lbId: string, + set: string, + b: NewBackend, + fetchFn: FetchFn = fetch, +): Promise { + await restCall(LB_PROXY_BASE, "POST", `${setPath(lbId, set)}/backends`, b, fetchFn); +} + +/** UpdateBackend replaces weight, backup, drain and offline together, so the full current state is sent. */ +export async function updateBackend( + lbId: string, + set: string, + current: LbBackend, + change: Partial>, + fetchFn: FetchFn = fetch, +): Promise { + const next = { ...current, ...change }; + const body: Json = { + weight: next.weight, + backup: next.backup, + drain: next.drain, + offline: next.offline, + }; + if (next.maxConnections !== null) body.maxConnections = next.maxConnections; + await restCall(LB_PROXY_BASE, "PUT", backendPath(lbId, set, current.name), body, fetchFn); +} + +export async function deleteBackend( + lbId: string, + set: string, + backend: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall(LB_PROXY_BASE, "DELETE", backendPath(lbId, set, backend), undefined, fetchFn); +} + +export type NewLoadBalancer = { + compartmentId: string; + displayName: string; + subnetId: string; + isPrivate: boolean; + minimumBandwidthInMbps: number; + maximumBandwidthInMbps: number; + listenerPort: number | null; +}; + +export async function createLoadBalancer( + lb: NewLoadBalancer, + fetchFn: FetchFn = fetch, +): Promise { + const { listenerPort, ...rest } = lb; + await restCall( + LB_PROXY_BASE, + "POST", + "/api/loadBalancers", + listenerPort === null ? rest : { ...rest, listenerPort }, + fetchFn, + ); +} + +export async function deleteLoadBalancer(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall(LB_PROXY_BASE, "DELETE", `/api/loadBalancers/${enc(id)}`, undefined, fetchFn); +} + +export const validPort = (v: string): boolean => + /^\d+$/.test(v.trim()) && Number(v) >= 1 && Number(v) <= 65535; +export const validWeight = (v: string): boolean => /^\d+$/.test(v.trim()) && Number(v) >= 1; +export const validBandwidth = (v: string): boolean => + /^\d+$/.test(v.trim()) && Number(v) >= LB_MIN_MBPS && Number(v) <= LB_MAX_MBPS; +export const validIp = (v: string): boolean => { + const s = v.trim(); + return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) + ? s.split(".").every((p) => Number(p) <= 255) + : /^[0-9a-fA-F:]+$/.test(s) && s.includes(":"); +}; + +/** Sends a request to the selected listener's in-process data plane; the proxy fixes the destination. */ +export async function tryIt( + lbId: string, + listener: string, + req: TryRequest, + fetchFn: FetchFn = fetch, +): Promise { + const { body } = await ociPostOne( + LB_PROXY_BASE, + `/api/loadBalancers/${enc(lbId)}/listeners/${enc(listener)}/invoke`, + { + method: req.method, + path: req.path, + query: req.query.replace(/^\?/, ""), + body: req.body, + virtualHost: req.virtualHost.trim(), + headers: req.headers.filter((h) => h.name.trim()), + }, + fetchFn, + ); + const b = obj(body); + const headers: Record = {}; + for (const [k, v] of Object.entries(obj(b.headers))) headers[k] = String(v); + return { + status: num(b.status), + headers, + body: str(b.body), + encoding: str(b.encoding) || "utf8", + truncated: b.truncated === true, + }; +} + +/** Pretty-prints a response body when it is JSON. */ +export function prettyBody(r: Pick): string { + if (r.encoding === "base64") return `(binary, base64)\n${r.body}`; + try { + return JSON.stringify(JSON.parse(r.body), null, 2); + } catch { + return r.body; + } +} + +export const isServing = (lb: Pick): boolean => lb.state === "ACTIVE"; + +/** A listener the try-it panel can reach: serving, plain text. */ +export const canTry = (l: Pick): boolean => + l.address !== "" && !l.tls; + +export type HealthTone = "ok" | "warn" | "bad" | "unknown"; + +export function healthTone(status: string): HealthTone { + switch (status) { + case "OK": + return "ok"; + case "WARNING": + return "warn"; + case "CRITICAL": + return "bad"; + default: + return "unknown"; + } +} + +export const readStoredLbCompartment = () => readStoredValue(LB_COMPARTMENT_KEY); +export const writeStoredLbCompartment = (id: string) => writeStoredValue(LB_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocilogging.test.ts b/ui/src/lib/ocilogging.test.ts new file mode 100644 index 0000000000..10383b24e4 --- /dev/null +++ b/ui/src/lib/ocilogging.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + LOGGING_PROXY_BASE, + cellText, + defaultQuery, + getLog, + listCompartments, + listLogGroups, + listLogs, + parseLog, + parseLogGroup, + parseSearch, + pushTestEntry, + searchLogs, + setLogEnabled, +} from "./ocilogging"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; + +describe("parsing", () => { + it("parses a log group and a log", () => { + expect( + parseLogGroup({ id: "g1", displayName: "apps", lifecycleState: "ACTIVE", description: "d" }), + ).toMatchObject({ id: "g1", name: "apps", state: "ACTIVE", description: "d" }); + expect( + parseLog({ + id: "l1", + logGroupId: "g1", + displayName: "web", + logType: "CUSTOM", + lifecycleState: "ACTIVE", + isEnabled: true, + retentionDuration: 30, + }), + ).toMatchObject({ name: "web", type: "CUSTOM", enabled: true, retentionDays: 30 }); + }); + + it("renders cell values", () => { + expect(cellText("x")).toBe("x"); + expect(cellText({ a: 1 })).toBe('{"a":1}'); + expect(cellText(null)).toBe(""); + }); + + it("builds table columns from the declared fields and formats datetime", () => { + const r = parseSearch({ + results: [{ data: { datetime: 1_700_000_000_000, logContent: { level: "ERROR" } } }], + fields: [{ fieldName: "datetime" }, { fieldName: "logContent" }], + summary: { resultCount: 1 }, + }); + expect(r.columns).toEqual(["datetime", "logContent"]); + expect(r.rows[0]).toEqual({ + datetime: "2023-11-14T22:13:20.000Z", + logContent: '{"level":"ERROR"}', + }); + expect(r.count).toBe(1); + }); + + it("falls back to the union of row keys without declared fields", () => { + const r = parseSearch({ results: [{ data: { a: 1 } }, { data: { b: "x" } }] }, true); + expect(r.columns).toEqual(["a", "b"]); + expect(r.count).toBe(2); + expect(r.more).toBe(true); + }); + + it.each([ + [["c"], 'search "c" | sort by datetime desc'], + [["c", "g"], 'search "c/g" | sort by datetime desc'], + [["c", "g", "l"], 'search "c/g/l" | sort by datetime desc'], + ])("builds a default query for %j", (args, want) => { + expect(defaultQuery(args[0] ?? "", args[1], args[2])).toBe(want); + }); +}); + +describe("client", () => { + it("lists compartments, groups and logs", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.endsWith("/compartments") + ? { compartments: [comp] } + : url.includes("/logs") + ? [{ id: "l1", displayName: "web" }] + : [{ id: "g1", displayName: "apps" }], + ), + ), + ); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + expect((await listLogGroups(comp, asFetch(fetchFn)))[0]?.name).toBe("apps"); + expect((await listLogs("g1", asFetch(fetchFn)))[0]?.name).toBe("web"); + expect(fetchFn.mock.calls[2]?.[0]).toBe(`${LOGGING_PROXY_BASE}/api/logGroups/g1/logs`); + }); + + it("reads one log", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ id: "l1", displayName: "web" }))); + expect((await getLog("g1", "l1", asFetch(fetchFn))).id).toBe("l1"); + }); + + it("enables and disables a log with only the flag", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve(new Response(null, { status: 202 })), + ); + await setLogEnabled("g1", "l1", false, asFetch(fetchFn)); + const [url, init] = fetchFn.mock.calls[0] ?? []; + expect(url).toBe(`${LOGGING_PROXY_BASE}/api/logGroups/g1/logs/l1`); + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toEqual({ isEnabled: false }); + }); + + it("posts a search with the time range and a result limit", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve( + json({ results: [{ data: { logContent: "hi" } }], summary: { resultCount: 1 } }, 200, { + "opc-next-page": "n", + }), + ), + ); + const start = new Date("2026-01-01T00:00:00Z"); + const end = new Date("2026-01-01T01:00:00Z"); + const r = await searchLogs('search "c"', start, end, asFetch(fetchFn)); + expect(r.more).toBe(true); + expect(r.rows[0]?.logContent).toBe("hi"); + const [url, init] = fetchFn.mock.calls[0] ?? []; + expect(url).toBe(`${LOGGING_PROXY_BASE}/api/search?limit=100`); + expect(JSON.parse(String(init?.body))).toEqual({ + searchQuery: 'search "c"', + timeStart: start.toISOString(), + timeEnd: end.toISOString(), + }); + }); + + it("reports a search error from the proxy", async () => { + const fetchFn = vi.fn(() => + Promise.resolve(json({ code: "InvalidParameter", message: "bad query" }, 400)), + ); + await expect(searchLogs("x", new Date(), new Date(), asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 400, + code: "InvalidParameter", + }); + }); + + it("pushes a test entry as data only", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve(new Response(null, { status: 200 })), + ); + await pushTestEntry("l1", "hello", asFetch(fetchFn)); + const [url, init] = fetchFn.mock.calls[0] ?? []; + expect(url).toBe(`${LOGGING_PROXY_BASE}/api/logs/l1/actions/push`); + expect(JSON.parse(String(init?.body))).toEqual({ data: "hello" }); + }); +}); diff --git a/ui/src/lib/ocilogging.ts b/ui/src/lib/ocilogging.ts new file mode 100644 index 0000000000..03e74cb83a --- /dev/null +++ b/ui/src/lib/ocilogging.ts @@ -0,0 +1,202 @@ +/** Client for the Oracle Cloud Logging emulator: log groups, logs, search and test ingestion via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + ociPostOne, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const LOGGING_PROXY_BASE = "/dashboard/api/v1/oci/logging"; +export const LOGGING_COMPARTMENT_KEY = "gopherstack_ocilogging_compartment"; +export const SEARCH_LIMIT = 100; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +export type LogGroup = { + id: string; + name: string; + description: string; + state: string; + created: string; +}; + +export type LogInfo = { + id: string; + groupId: string; + name: string; + type: string; + state: string; + enabled: boolean; + retentionDays: number; + created: string; +}; + +export type SearchResult = { + columns: string[]; + rows: Record[]; + count: number; + more: boolean; +}; + +export type TimeRange = { id: string; label: string; seconds: number }; + +export const TIME_RANGES: TimeRange[] = [ + { id: "15m", label: "Last 15 minutes", seconds: 900 }, + { id: "1h", label: "Last hour", seconds: 3600 }, + { id: "24h", label: "Last 24 hours", seconds: 86400 }, + { id: "7d", label: "Last 7 days", seconds: 604800 }, +]; + +export function parseLogGroup(raw: Json): LogGroup { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + description: str(raw.description), + state: str(raw.lifecycleState), + created: str(raw.timeCreated), + }; +} + +export function parseLog(raw: Json): LogInfo { + return { + id: str(raw.id), + groupId: str(raw.logGroupId), + name: str(raw.displayName) || str(raw.id), + type: str(raw.logType), + state: str(raw.lifecycleState), + enabled: raw.isEnabled === true, + retentionDays: num(raw.retentionDuration), + created: str(raw.timeCreated), + }; +} + +/** Cell text for a search value: strings as-is, everything else as compact JSON. */ +export function cellText(v: unknown): string { + if (v === null || v === undefined) return ""; + return typeof v === "string" ? v : JSON.stringify(v); +} + +/** Turns a SearchLogs response into table columns and rows; `datetime` (epoch ms) becomes an ISO time. */ +export function parseSearch(body: Json, more = false): SearchResult { + const results = list(body.results).map((r) => obj(r.data)); + const declared = list(body.fields) + .map((f) => str(f.fieldName)) + .filter(Boolean); + const columns = + declared.length > 0 ? declared : [...new Set(results.flatMap((r) => Object.keys(r)))]; + const rows = results.map((r) => + Object.fromEntries( + columns.map((c) => [ + c, + c === "datetime" && typeof r[c] === "number" + ? new Date(r[c] as number).toISOString() + : cellText(r[c]), + ]), + ), + ); + const total = num(obj(body.summary).resultCount); + return { columns, rows, count: total > 0 ? total : rows.length, more }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(LOGGING_PROXY_BASE, fetchFn); + +export async function listLogGroups( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + LOGGING_PROXY_BASE, + "/api/logGroups", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return items.map((r) => parseLogGroup(r)); +} + +export async function listLogs(groupId: string, fetchFn: FetchFn = fetch): Promise { + const items = await ociListPages( + LOGGING_PROXY_BASE, + `/api/logGroups/${enc(groupId)}/logs`, + "", + "", + fetchFn, + ); + return items.map((r) => parseLog(r)); +} + +export async function getLog( + groupId: string, + logId: string, + fetchFn: FetchFn = fetch, +): Promise { + return parseLog( + await ociGetOne( + LOGGING_PROXY_BASE, + `/api/logGroups/${enc(groupId)}/logs/${enc(logId)}`, + fetchFn, + ), + ); +} + +export async function setLogEnabled( + groupId: string, + logId: string, + enabled: boolean, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + LOGGING_PROXY_BASE, + "PUT", + `/api/logGroups/${enc(groupId)}/logs/${enc(logId)}`, + { isEnabled: enabled }, + fetchFn, + ); +} + +/** A query that selects everything below a compartment, a group or a log, newest first. */ +export function defaultQuery(compartment: string, group = "", log = ""): string { + const parts = group ? (log ? [compartment, group, log] : [compartment, group]) : [compartment]; + return `search "${parts.join("/")}" | sort by datetime desc`; +} + +export async function searchLogs( + query: string, + start: Date, + end: Date, + fetchFn: FetchFn = fetch, +): Promise { + const { body, next } = await ociPostOne( + LOGGING_PROXY_BASE, + `/api/search?limit=${SEARCH_LIMIT}`, + { searchQuery: query, timeStart: start.toISOString(), timeEnd: end.toISOString() }, + fetchFn, + ); + return parseSearch(obj(body), next !== ""); +} + +export async function pushTestEntry( + logId: string, + data: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + LOGGING_PROXY_BASE, + "POST", + `/api/logs/${enc(logId)}/actions/push`, + { data }, + fetchFn, + ); +} + +export const readStoredLoggingCompartment = () => readStoredValue(LOGGING_COMPARTMENT_KEY); +export const writeStoredLoggingCompartment = (id: string) => + writeStoredValue(LOGGING_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocimonitoring.test.ts b/ui/src/lib/ocimonitoring.test.ts new file mode 100644 index 0000000000..2989eca0d3 --- /dev/null +++ b/ui/src/lib/ocimonitoring.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + MONITORING_PROXY_BASE, + chartPoints, + getAlarmHistory, + groupNamespaces, + listAlarms, + listCompartments, + listMetrics, + parseAlarm, + parseHistory, + parseMetric, + parseSeries, + seriesLabel, + summarizeMetrics, +} from "./ocimonitoring"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; + +describe("parsing", () => { + it("parses metrics and groups them by namespace", () => { + const defs = [ + { namespace: "b", name: "z", dimensions: { host: "a" } }, + { namespace: "a", name: "y" }, + { namespace: "b", name: "x" }, + { namespace: "b", name: "x" }, + ].map((m) => parseMetric(m)); + expect(defs[0]?.dimensions).toEqual({ host: "a" }); + expect(groupNamespaces(defs)).toEqual([ + { namespace: "a", metrics: ["y"] }, + { namespace: "b", metrics: ["x", "z"] }, + ]); + }); + + it("parses a series and drops unparsable timestamps", () => { + const s = parseSeries({ + name: "cpu", + namespace: "app", + resolution: "1m", + dimensions: { host: "a" }, + aggregatedDatapoints: [ + { timestamp: "2026-01-01T00:00:00Z", value: 4 }, + { timestamp: "nope", value: 1 }, + ], + }); + expect(s.points).toEqual([{ t: Date.parse("2026-01-01T00:00:00Z"), v: 4 }]); + expect(seriesLabel(s)).toBe("cpu {host=a}"); + expect(seriesLabel({ name: "cpu", dimensions: {} })).toBe("cpu"); + }); + + it("merges an alarm with its status", () => { + const a = parseAlarm( + { id: "a1", displayName: "hi", severity: "CRITICAL", query: "q", isEnabled: true }, + { status: "FIRING", timestampTriggered: "t" }, + ); + expect(a).toMatchObject({ name: "hi", status: "FIRING", triggered: "t", enabled: true }); + expect(parseAlarm({ id: "a2" }).status).toBe(""); + }); + + it("parses a history entry", () => { + expect(parseHistory({ timestamp: "t", summary: "s", timestampTriggered: "x" })).toEqual({ + timestamp: "t", + summary: "s", + triggered: "x", + }); + }); + + it("scales chart points into the box with zero in range", () => { + expect(chartPoints([], 100, 50)).toBe(""); + const pts = chartPoints( + [ + { t: 0, v: 0 }, + { t: 10, v: 10 }, + ], + 108, + 58, + ); + expect(pts).toBe("4.0,54.0 104.0,4.0"); + expect(chartPoints([{ t: 5, v: 3 }], 108, 58)).toMatch(/^4\.0,4\.0$/); + }); +}); + +describe("client", () => { + it("lists compartments", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ compartments: [comp] }))); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + }); + + it("pages ListMetrics through POST", async () => { + const fetchFn = vi + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValueOnce(json([{ namespace: "a", name: "x" }], 200, { "opc-next-page": "p2" })) + .mockResolvedValueOnce(json([{ namespace: "a", name: "y" }])); + const out = await listMetrics(comp, asFetch(fetchFn)); + expect(out.map((m) => m.name)).toEqual(["x", "y"]); + const base = `${MONITORING_PROXY_BASE}/api/metrics/actions/listMetrics?compartmentId=${encodeURIComponent(comp)}`; + expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([base, `${base}&page=p2`]); + expect(fetchFn.mock.calls[0]?.[1]?.method).toBe("POST"); + }); + + it("summarizes with the namespace, query and range ending shortly after now", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve( + json([ + { name: "cpu", aggregatedDatapoints: [{ timestamp: "2026-01-01T00:00:00Z", value: 1 }] }, + ]), + ), + ); + const now = new Date("2026-01-01T01:00:00Z"); + const out = await summarizeMetrics(comp, "app", "cpu[1m].max()", 3600, asFetch(fetchFn), now); + expect(out[0]?.points).toHaveLength(1); + const [url, init] = fetchFn.mock.calls[0] ?? []; + expect(url).toContain("/summarizeMetricsData?compartmentId="); + expect(JSON.parse(String(init?.body))).toEqual({ + namespace: "app", + query: "cpu[1m].max()", + startTime: "2026-01-01T00:00:00.000Z", + endTime: "2026-01-01T01:01:00.000Z", + }); + }); + + it("surfaces an MQL error", async () => { + const fetchFn = vi.fn(() => + Promise.resolve(json({ code: "InvalidParameter", message: "bad MQL" }, 400)), + ); + await expect(summarizeMetrics(comp, "app", "x", 60, asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 400, + }); + }); + + it("joins alarms with their statuses", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.includes("/alarms/status") + ? [{ id: "a1", status: "FIRING" }] + : [ + { id: "a1", displayName: "hi" }, + { id: "a2", displayName: "lo" }, + ], + ), + ), + ); + const out = await listAlarms(comp, asFetch(fetchFn)); + expect(out.map((a) => [a.name, a.status])).toEqual([ + ["hi", "FIRING"], + ["lo", ""], + ]); + }); + + it("reads alarm history entries", async () => { + const fetchFn = vi.fn((_url: string) => + Promise.resolve(json({ alarmId: "a1", entries: [{ timestamp: "t", summary: "s" }] })), + ); + expect(await getAlarmHistory("a1", asFetch(fetchFn))).toHaveLength(1); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${MONITORING_PROXY_BASE}/api/alarms/a1/history`); + }); +}); diff --git a/ui/src/lib/ocimonitoring.ts b/ui/src/lib/ocimonitoring.ts new file mode 100644 index 0000000000..42797373f6 --- /dev/null +++ b/ui/src/lib/ocimonitoring.ts @@ -0,0 +1,205 @@ +/** Client for the Oracle Cloud Monitoring emulator: metrics, MQL queries and alarms via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + ociPostOne, + ociPostPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { str, strMap, type FetchFn, type Json } from "./gcprest.ts"; + +export const MONITORING_PROXY_BASE = "/dashboard/api/v1/oci/monitoring"; +export const MONITORING_COMPARTMENT_KEY = "gopherstack_ocimonitoring_compartment"; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); + +export type MetricDef = { + namespace: string; + name: string; + resourceGroup: string; + dimensions: Record; +}; + +export type NamespaceInfo = { namespace: string; metrics: string[] }; + +export type Point = { t: number; v: number }; + +export type Series = { + name: string; + namespace: string; + dimensions: Record; + resolution: string; + points: Point[]; +}; + +export type AlarmInfo = { + id: string; + name: string; + state: string; + severity: string; + namespace: string; + query: string; + enabled: boolean; + status: string; + triggered: string; + destinations: string[]; +}; + +export type HistoryEntry = { timestamp: string; summary: string; triggered: string }; + +export type TimeRange = { id: string; label: string; seconds: number }; + +export const TIME_RANGES: TimeRange[] = [ + { id: "15m", label: "Last 15 minutes", seconds: 900 }, + { id: "1h", label: "Last hour", seconds: 3600 }, + { id: "6h", label: "Last 6 hours", seconds: 21600 }, + { id: "24h", label: "Last 24 hours", seconds: 86400 }, +]; + +export function parseMetric(raw: Json): MetricDef { + return { + namespace: str(raw.namespace), + name: str(raw.name), + resourceGroup: str(raw.resourceGroup), + dimensions: strMap(raw.dimensions), + }; +} + +/** Distinct namespaces with their sorted distinct metric names. */ +export function groupNamespaces(defs: MetricDef[]): NamespaceInfo[] { + const by = new Map>(); + for (const d of defs) { + if (!by.has(d.namespace)) by.set(d.namespace, new Set()); + by.get(d.namespace)?.add(d.name); + } + return [...by.entries()] + .map(([namespace, names]) => ({ namespace, metrics: [...names].toSorted() })) + .toSorted((a, b) => a.namespace.localeCompare(b.namespace)); +} + +export function parseSeries(raw: Json): Series { + return { + name: str(raw.name), + namespace: str(raw.namespace), + dimensions: strMap(raw.dimensions), + resolution: str(raw.resolution), + points: list(raw.aggregatedDatapoints) + .map((p) => ({ t: Date.parse(str(p.timestamp)), v: num(p.value) })) + .filter((p) => !Number.isNaN(p.t)), + }; +} + +export function parseAlarm(raw: Json, status?: Json): AlarmInfo { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + severity: str(raw.severity), + namespace: str(raw.namespace), + query: str(raw.query), + enabled: raw.isEnabled === true, + status: str(status?.status), + triggered: str(status?.timestampTriggered), + destinations: list(raw.destinations).map(String), + }; +} + +export function parseHistory(raw: Json): HistoryEntry { + return { + timestamp: str(raw.timestamp), + summary: str(raw.summary), + triggered: str(raw.timestampTriggered), + }; +} + +/** Label for a series: its dimensions, or the metric name when it has none. */ +export function seriesLabel(s: Pick): string { + const dims = Object.entries(s.dimensions).map(([k, v]) => `${k}=${v}`); + return dims.length > 0 ? `${s.name} {${dims.join(", ")}}` : s.name; +} + +/** SVG polyline points for a series scaled into a w x h box (value axis includes zero). */ +export function chartPoints(points: Point[], w: number, h: number, pad = 4): string { + if (points.length === 0) return ""; + const ts = points.map((p) => p.t); + const vs = points.map((p) => p.v); + const t0 = Math.min(...ts); + const tSpan = Math.max(...ts) - t0 || 1; + const v0 = Math.min(...vs, 0); + const vSpan = Math.max(...vs, 0) - v0 || 1; + return points + .map( + (p) => + `${(((p.t - t0) / tSpan) * (w - 2 * pad) + pad).toFixed(1)},${(h - pad - ((p.v - v0) / vSpan) * (h - 2 * pad)).toFixed(1)}`, + ) + .join(" "); +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(MONITORING_PROXY_BASE, fetchFn); + +export async function listMetrics( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociPostPages( + MONITORING_PROXY_BASE, + "/api/metrics/actions/listMetrics", + `compartmentId=${enc(compartment)}`, + {}, + fetchFn, + ); + return items.map((r) => parseMetric(r)); +} + +export async function summarizeMetrics( + compartment: string, + namespace: string, + query: string, + rangeSeconds: number, + fetchFn: FetchFn = fetch, + now: Date = new Date(), +): Promise { + const { body } = await ociPostOne( + MONITORING_PROXY_BASE, + `/api/metrics/actions/summarizeMetricsData?compartmentId=${enc(compartment)}`, + { + namespace, + query, + startTime: new Date(now.getTime() - rangeSeconds * 1000).toISOString(), + endTime: new Date(now.getTime() + 60_000).toISOString(), + }, + fetchFn, + ); + return list(body).map((r) => parseSeries(r)); +} + +export async function listAlarms( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const q = `compartmentId=${enc(compartment)}`; + const [alarms, statuses] = await Promise.all([ + ociListPages(MONITORING_PROXY_BASE, "/api/alarms", q, "", fetchFn), + ociListPages(MONITORING_PROXY_BASE, "/api/alarms/status", q, "", fetchFn), + ]); + const byId = new Map(statuses.map((s) => [str(s.id), s])); + return alarms.map((a) => parseAlarm(a, byId.get(str(a.id)))); +} + +export async function getAlarmHistory( + id: string, + fetchFn: FetchFn = fetch, +): Promise { + const body = await ociGetOne(MONITORING_PROXY_BASE, `/api/alarms/${enc(id)}/history`, fetchFn); + return list(body.entries).map((e) => parseHistory(e)); +} + +export const readStoredMonitoringCompartment = () => readStoredValue(MONITORING_COMPARTMENT_KEY); +export const writeStoredMonitoringCompartment = (id: string) => + writeStoredValue(MONITORING_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocimysql.test.ts b/ui/src/lib/ocimysql.test.ts new file mode 100644 index 0000000000..137492f978 --- /dev/null +++ b/ui/src/lib/ocimysql.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + MYSQL_PROXY_BASE, + allowedActions, + createBackup, + deleteSystem, + getSystem, + listBackups, + listCompartments, + listConfigurations, + listSystems, + mysqlEndpoint, + parseBackup, + parseConfiguration, + parseSystem, + systemAction, +} from "./ocimysql"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; + +const system = { + id: "s1", + displayName: "orders", + lifecycleState: "ACTIVE", + mysqlVersion: "9.4.0", + shapeName: "MySQL.2", + isHighlyAvailable: true, + endpoints: [{ ipAddress: "10.0.1.5", port: 3306, portX: 33060 }], + dataStorage: { + dataStorageSizeInGBs: 50, + maxStorageSizeInGBs: 100, + isAutoExpandStorageEnabled: true, + }, + backupPolicy: { + isEnabled: true, + retentionInDays: 7, + windowStartTime: "03:00", + pitrPolicy: { isEnabled: true }, + }, + deletionPolicy: { isDeleteProtected: true, finalBackup: "SKIP_FINAL_BACKUP" }, + adminPassword: "must-not-surface", +}; + +describe("parsing", () => { + it("parses a full DB system and never carries the password", () => { + const s = parseSystem(system); + expect(s).toMatchObject({ + id: "s1", + name: "orders", + state: "ACTIVE", + version: "9.4.0", + shape: "MySQL.2", + ip: "10.0.1.5", + port: 3306, + portX: 33060, + storageGb: 50, + maxStorageGb: 100, + autoExpand: true, + highlyAvailable: true, + backupEnabled: true, + backupRetentionDays: 7, + backupWindow: "03:00", + pitr: true, + deleteProtected: true, + }); + expect(JSON.stringify(s)).not.toContain("must-not-surface"); + }); + + it("parses a summary without storage or endpoint details", () => { + const s = parseSystem({ id: "s2", lifecycleState: "CREATING" }); + expect(s).toMatchObject({ name: "s2", ip: "", storageGb: 0, deleteProtected: false }); + expect(mysqlEndpoint(s)).toBe(""); + }); + + it("parses a backup and a configuration", () => { + expect( + parseBackup({ + id: "b1", + displayName: "nightly", + lifecycleState: "ACTIVE", + dbSystemId: "s1", + backupType: "FULL", + creationType: "MANUAL", + backupSizeInGBs: 3, + retentionInDays: 7, + }), + ).toMatchObject({ name: "nightly", type: "FULL", creation: "MANUAL", sizeGb: 3 }); + expect( + parseConfiguration({ id: "c1", displayName: "std", type: "DEFAULT", shapeName: "MySQL.2" }), + ).toMatchObject({ name: "std", type: "DEFAULT", shape: "MySQL.2" }); + }); + + it("derives the actions each state allows", () => { + expect(allowedActions({ state: "ACTIVE", deleteProtected: false })).toEqual({ + start: false, + stop: true, + restart: true, + remove: true, + }); + expect(allowedActions({ state: "INACTIVE", deleteProtected: true })).toMatchObject({ + start: true, + stop: false, + remove: false, + }); + expect(allowedActions({ state: "DELETING", deleteProtected: false }).remove).toBe(false); + }); + + it("formats the endpoint", () => { + expect(mysqlEndpoint({ ip: "10.0.1.5", port: 3306 })).toBe("10.0.1.5:3306"); + }); +}); + +describe("client", () => { + it("lists compartments", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ compartments: [comp] }))); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + }); + + it("follows opc-next-page when listing DB systems", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(json([system], 200, { "opc-next-page": "p2" })) + .mockResolvedValueOnce(json([{ id: "s2" }])); + const out = await listSystems(comp, asFetch(fetchFn)); + expect(out.map((s) => s.id)).toEqual(["s1", "s2"]); + expect(fetchFn.mock.calls[1]?.[0]).toBe( + `${MYSQL_PROXY_BASE}/api/dbSystems?compartmentId=${encodeURIComponent(comp)}&page=p2`, + ); + }); + + it("reads one system, its backups and the configurations", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve(json(url.includes("/dbSystems/") ? system : [{ id: "x" }])), + ); + expect((await getSystem("s1", asFetch(fetchFn))).name).toBe("orders"); + expect(await listBackups(comp, "s1", asFetch(fetchFn))).toHaveLength(1); + expect(await listConfigurations(comp, asFetch(fetchFn))).toHaveLength(1); + expect(fetchFn.mock.calls[1]?.[0]).toContain("&dbSystemId=s1"); + }); + + it("posts lifecycle actions, deletes and creates backups", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve(new Response(null, { status: 202 })), + ); + await systemAction("s1", "stop", asFetch(fetchFn)); + await deleteSystem("s1", asFetch(fetchFn)); + await createBackup("s1", " manual ", asFetch(fetchFn)); + await createBackup("s1", "", asFetch(fetchFn)); + const calls = fetchFn.mock.calls; + expect(calls[0]?.[0]).toBe(`${MYSQL_PROXY_BASE}/api/dbSystems/s1/actions/stop`); + expect(calls[0]?.[1]?.method).toBe("POST"); + expect(calls[1]?.[1]?.method).toBe("DELETE"); + expect(JSON.parse(String(calls[2]?.[1]?.body))).toEqual({ + dbSystemId: "s1", + displayName: "manual", + }); + expect(JSON.parse(String(calls[3]?.[1]?.body))).toEqual({ dbSystemId: "s1" }); + }); + + it("surfaces an error code from the proxy", async () => { + const fetchFn = vi.fn(() => + Promise.resolve(json({ code: "NotAuthorizedOrNotFound", message: "gone" }, 404)), + ); + await expect(getSystem("s1", asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 404, + code: "NotAuthorizedOrNotFound", + }); + }); +}); diff --git a/ui/src/lib/ocimysql.ts b/ui/src/lib/ocimysql.ts new file mode 100644 index 0000000000..5b9e6f5bc9 --- /dev/null +++ b/ui/src/lib/ocimysql.ts @@ -0,0 +1,236 @@ +/** Client for the Oracle Cloud MySQL HeatWave emulator: DB systems, backups and configurations via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const MYSQL_PROXY_BASE = "/dashboard/api/v1/oci/mysql"; +export const MYSQL_COMPARTMENT_KEY = "gopherstack_ocimysql_compartment"; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); + +export type MysqlSystem = { + id: string; + name: string; + state: string; + details: string; + version: string; + shape: string; + ip: string; + port: number; + portX: number; + storageGb: number; + maxStorageGb: number; + autoExpand: boolean; + highlyAvailable: boolean; + backupEnabled: boolean; + backupRetentionDays: number; + backupWindow: string; + pitr: boolean; + deleteProtected: boolean; + finalBackup: string; + availabilityDomain: string; + faultDomain: string; + databaseMode: string; + accessMode: string; + configurationId: string; + subnetId: string; + created: string; +}; + +export type MysqlBackup = { + id: string; + name: string; + state: string; + dbSystemId: string; + type: string; + creation: string; + sizeGb: number; + retentionDays: number; + version: string; + created: string; +}; + +export type MysqlConfiguration = { + id: string; + name: string; + type: string; + shape: string; + state: string; + description: string; + created: string; +}; + +/** Reads a DB system summary or full body; the admin password never appears in either. */ +export function parseSystem(raw: Json): MysqlSystem { + const ep = obj(list(raw.endpoints)[0]); + const storage = obj(raw.dataStorage); + const backup = obj(raw.backupPolicy); + const del = obj(raw.deletionPolicy); + const place = obj(raw.currentPlacement); + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + details: str(raw.lifecycleDetails), + version: str(raw.mysqlVersion), + shape: str(raw.shapeName), + ip: str(ep.ipAddress) || str(raw.ipAddress), + port: num(ep.port) || num(raw.port), + portX: num(ep.portX) || num(raw.portX), + storageGb: num(storage.dataStorageSizeInGBs) || num(raw.dataStorageSizeInGBs), + maxStorageGb: num(storage.maxStorageSizeInGBs), + autoExpand: storage.isAutoExpandStorageEnabled === true, + highlyAvailable: raw.isHighlyAvailable === true, + backupEnabled: backup.isEnabled === true, + backupRetentionDays: num(backup.retentionInDays), + backupWindow: str(backup.windowStartTime), + pitr: obj(backup.pitrPolicy).isEnabled === true, + deleteProtected: del.isDeleteProtected === true, + finalBackup: str(del.finalBackup), + availabilityDomain: str(place.availabilityDomain) || str(raw.availabilityDomain), + faultDomain: str(place.faultDomain) || str(raw.faultDomain), + databaseMode: str(raw.databaseMode), + accessMode: str(raw.accessMode), + configurationId: str(raw.configurationId), + subnetId: str(raw.subnetId), + created: str(raw.timeCreated), + }; +} + +export function parseBackup(raw: Json): MysqlBackup { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + dbSystemId: str(raw.dbSystemId), + type: str(raw.backupType), + creation: str(raw.creationType), + sizeGb: num(raw.backupSizeInGBs), + retentionDays: num(raw.retentionInDays), + version: str(raw.mysqlVersion), + created: str(raw.timeCreated), + }; +} + +export function parseConfiguration(raw: Json): MysqlConfiguration { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + type: str(raw.type), + shape: str(raw.shapeName), + state: str(raw.lifecycleState), + description: str(raw.description), + created: str(raw.timeCreated), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(MYSQL_PROXY_BASE, fetchFn); + +export async function listSystems( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + MYSQL_PROXY_BASE, + "/api/dbSystems", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return items.map((r) => parseSystem(r)); +} + +export async function getSystem(id: string, fetchFn: FetchFn = fetch): Promise { + return parseSystem(await ociGetOne(MYSQL_PROXY_BASE, `/api/dbSystems/${enc(id)}`, fetchFn)); +} + +export async function listBackups( + compartment: string, + dbSystemId = "", + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + MYSQL_PROXY_BASE, + "/api/backups", + `compartmentId=${enc(compartment)}${dbSystemId ? `&dbSystemId=${enc(dbSystemId)}` : ""}`, + "", + fetchFn, + ); + return items.map((r) => parseBackup(r)); +} + +export async function listConfigurations( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + MYSQL_PROXY_BASE, + "/api/configurations", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return items.map((r) => parseConfiguration(r)); +} + +export type SystemAction = "start" | "stop" | "restart"; + +export async function systemAction( + id: string, + action: SystemAction, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + MYSQL_PROXY_BASE, + "POST", + `/api/dbSystems/${enc(id)}/actions/${action}`, + {}, + fetchFn, + ); +} + +export async function deleteSystem(id: string, fetchFn: FetchFn = fetch): Promise { + await restCall(MYSQL_PROXY_BASE, "DELETE", `/api/dbSystems/${enc(id)}`, undefined, fetchFn); +} + +export async function createBackup( + dbSystemId: string, + displayName: string, + fetchFn: FetchFn = fetch, +): Promise { + const body: Json = { dbSystemId }; + if (displayName.trim()) body.displayName = displayName.trim(); + await restCall(MYSQL_PROXY_BASE, "POST", "/api/backups", body, fetchFn); +} + +/** Which lifecycle actions a state allows. */ +export function allowedActions(s: Pick): { + start: boolean; + stop: boolean; + restart: boolean; + remove: boolean; +} { + return { + start: s.state === "INACTIVE", + stop: s.state === "ACTIVE", + restart: s.state === "ACTIVE", + remove: !s.deleteProtected && s.state !== "DELETING" && s.state !== "DELETED", + }; +} + +export const mysqlEndpoint = (s: Pick): string => + s.ip ? `${s.ip}:${s.port}` : ""; + +export const readStoredMysqlCompartment = () => readStoredValue(MYSQL_COMPARTMENT_KEY); +export const writeStoredMysqlCompartment = (id: string) => + writeStoredValue(MYSQL_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocinosql.test.ts b/ui/src/lib/ocinosql.test.ts new file mode 100644 index 0000000000..a5ebeb4877 --- /dev/null +++ b/ui/src/lib/ocinosql.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + NOSQL_PROXY_BASE, + canDrop, + cellText, + createTable, + ddlTableName, + deleteRow, + dropTable, + getRow, + getTable, + keyParams, + listCompartments, + listIndexes, + listTables, + listUsage, + parseIndex, + parseTable, + putRow, + resultColumns, + rowsUsable, + runQuery, +} from "./ocinosql"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; +const q = `compartmentId=${encodeURIComponent(comp)}`; + +const table = { + id: "t1", + name: "users", + lifecycleState: "ACTIVE", + schemaState: "MUTABLE", + isAutoReclaimable: false, + ddlStatement: "CREATE TABLE users (id INTEGER, PRIMARY KEY(id))", + tableLimits: { + capacityMode: "PROVISIONED", + maxReadUnits: 10, + maxWriteUnits: 5, + maxStorageInGBs: 2, + }, + schema: { + columns: [ + { name: "id", type: "INTEGER", isNullable: false }, + { name: "uid", type: "STRING", isAsUuid: true, defaultValue: "x" }, + ], + primaryKey: ["id"], + shardKey: ["id"], + ttl: 3, + }, +}; + +describe("parsing", () => { + it("parses a table with schema and limits", () => { + expect(parseTable(table)).toMatchObject({ + name: "users", + state: "ACTIVE", + mode: "PROVISIONED", + readUnits: 10, + writeUnits: 5, + storageGb: 2, + ttlDays: 3, + primaryKey: ["id"], + shardKey: ["id"], + }); + expect(parseTable(table).columns).toEqual([ + { name: "id", type: "INTEGER", nullable: false, generated: false, defaultValue: "" }, + { name: "uid", type: "STRING", nullable: true, generated: true, defaultValue: "x" }, + ]); + }); + + it("tolerates a summary without schema", () => { + expect(parseTable({ id: "t2" })).toMatchObject({ name: "t2", columns: [], primaryKey: [] }); + }); + + it("parses an index with json paths", () => { + expect( + parseIndex({ + name: "ix", + lifecycleState: "ACTIVE", + keys: [{ columnName: "name" }, { columnName: "doc", jsonPath: "a.b" }], + }), + ).toEqual({ name: "ix", state: "ACTIVE", keys: ["name", "doc.a.b"] }); + }); + + it("extracts the table name from DDL", () => { + expect(ddlTableName("CREATE TABLE IF NOT EXISTS users (id INTEGER)")).toBe("users"); + expect(ddlTableName(' create table "My Tbl"(id integer)')).toBe("My Tbl"); + expect(ddlTableName("DROP TABLE users")).toBe(""); + }); + + it("formats results", () => { + expect(resultColumns([{ a: 1 }, { b: 2, a: 3 }])).toEqual(["a", "b"]); + expect(cellText(null)).toBe("NULL"); + expect(cellText({ x: 1 })).toBe('{"x":1}'); + expect(cellText(7)).toBe("7"); + expect(keyParams({ id: "1", k: "", n: "a b" })).toBe("key=id%3A1&key=n%3Aa%20b"); + }); + + it("derives table actions", () => { + expect(canDrop({ state: "ACTIVE" })).toBe(true); + expect(canDrop({ state: "DELETING" })).toBe(false); + expect(rowsUsable({ state: "CREATING" })).toBe(false); + }); +}); + +describe("client", () => { + it("lists compartments", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ compartments: [comp] }))); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + }); + + it("follows opc-next-page when listing tables", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(json({ items: [table] }, 200, { "opc-next-page": "p2" })) + .mockResolvedValueOnce(json({ items: [{ id: "t2", name: "b" }] })); + expect((await listTables(comp, asFetch(fetchFn))).map((t) => t.name)).toEqual(["users", "b"]); + expect(fetchFn.mock.calls[1]?.[0]).toBe(`${NOSQL_PROXY_BASE}/api/tables?${q}&page=p2`); + }); + + it("reads a table, its indexes and usage", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.includes("/indexes") + ? { items: [{ name: "ix", keys: [] }] } + : url.includes("/usage") + ? { items: [{ timeStarted: "t", readUnits: 4, secondsInPeriod: 60 }] } + : table, + ), + ), + ); + expect((await getTable(comp, "users", asFetch(fetchFn))).ddl).toContain("CREATE TABLE"); + expect(await listIndexes(comp, "users", asFetch(fetchFn))).toHaveLength(1); + expect((await listUsage(comp, "users", asFetch(fetchFn)))[0]).toMatchObject({ readUnits: 4 }); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${NOSQL_PROXY_BASE}/api/tables/users?${q}`); + }); + + it("creates provisioned and on-demand tables and drops one", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve(new Response(null, { status: 202 })), + ); + const ddl = "CREATE TABLE t (id INTEGER, PRIMARY KEY(id))"; + await createTable( + comp, + ddl, + { mode: "PROVISIONED", readUnits: 3, writeUnits: 4, storageGb: 1 }, + asFetch(fetchFn), + ); + await createTable( + comp, + ddl, + { mode: "ON_DEMAND", readUnits: 3, writeUnits: 4, storageGb: 1 }, + asFetch(fetchFn), + ); + await dropTable(comp, "t", asFetch(fetchFn)); + const calls = fetchFn.mock.calls; + expect(JSON.parse(String(calls[0]?.[1]?.body))).toEqual({ + compartmentId: comp, + name: "t", + ddlStatement: ddl, + tableLimits: { + capacityMode: "PROVISIONED", + maxStorageInGBs: 1, + maxReadUnits: 3, + maxWriteUnits: 4, + }, + }); + expect(JSON.parse(String(calls[1]?.[1]?.body)).tableLimits).toEqual({ + capacityMode: "ON_DEMAND", + maxStorageInGBs: 1, + }); + expect(calls[2]?.[1]?.method).toBe("DELETE"); + await expect( + createTable( + comp, + "DROP TABLE t", + { mode: "ON_DEMAND", readUnits: 0, writeUnits: 0, storageGb: 1 }, + asFetch(fetchFn), + ), + ).rejects.toThrow("CREATE TABLE"); + }); + + it("runs a query page and hands back the next token", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve( + json({ items: [{ id: 1, name: "ada" }], usage: { readUnitsConsumed: 2 } }, 200, { + "opc-next-page": "tok", + }), + ), + ); + const res = await runQuery(comp, "SELECT * FROM users", "p1", 5, asFetch(fetchFn)); + expect(res).toEqual({ + rows: [{ id: 1, name: "ada" }], + columns: ["id", "name"], + next: "tok", + readUnits: 2, + }); + expect(fetchFn.mock.calls[0]?.[0]).toBe(`${NOSQL_PROXY_BASE}/api/query?limit=5&page=p1`); + expect(JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body))).toEqual({ + compartmentId: comp, + statement: "SELECT * FROM users", + }); + }); + + it("gets, puts and deletes rows", async () => { + const fetchFn = vi.fn((_url: string, init?: RequestInit) => + Promise.resolve( + json( + init?.method === "DELETE" + ? { isSuccess: true } + : init?.method === "PUT" + ? { version: "v" } + : { value: { id: 1 }, usage: { readUnitsConsumed: 1 } }, + ), + ), + ); + expect((await getRow(comp, "users", { id: "1" }, asFetch(fetchFn)))?.value).toEqual({ id: 1 }); + expect(fetchFn.mock.calls[0]?.[0]).toBe( + `${NOSQL_PROXY_BASE}/api/tables/users/rows?${q}&key=id%3A1`, + ); + await putRow(comp, "users", '{"id":1}', asFetch(fetchFn)); + expect(JSON.parse(String(fetchFn.mock.calls[1]?.[1]?.body))).toEqual({ + compartmentId: comp, + value: { id: 1 }, + }); + expect(await deleteRow(comp, "users", { id: "1" }, asFetch(fetchFn))).toBe(true); + }); + + it("returns null for a missing row and rejects bad row JSON", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ value: null }))); + expect(await getRow(comp, "users", { id: "9" }, asFetch(fetchFn))).toBeNull(); + await expect(putRow(comp, "users", "{nope", asFetch(fetchFn))).rejects.toThrow("valid JSON"); + await expect(putRow(comp, "users", "[1]", asFetch(fetchFn))).rejects.toThrow("JSON object"); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("reads the proxy's own error envelope", async () => { + const fetchFn = vi.fn(() => + Promise.resolve( + json( + { + error: { + message: "invalid NoSQL proxy request body", + status: "InvalidParameter", + code: 400, + }, + }, + 400, + ), + ), + ); + await expect(runQuery(comp, "DROP TABLE x", "", 5, asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 400, + code: "InvalidParameter", + message: "invalid NoSQL proxy request body", + }); + }); + + it("surfaces an error code from the proxy", async () => { + const fetchFn = vi.fn(() => + Promise.resolve(json({ code: "NotAuthorizedOrNotFound", message: "gone" }, 404)), + ); + await expect(getTable(comp, "x", asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 404, + code: "NotAuthorizedOrNotFound", + }); + }); +}); diff --git a/ui/src/lib/ocinosql.ts b/ui/src/lib/ocinosql.ts new file mode 100644 index 0000000000..cb5306df8f --- /dev/null +++ b/ui/src/lib/ocinosql.ts @@ -0,0 +1,334 @@ +/** Client for the Oracle NoSQL Database emulator: tables, indexes, rows and SQL queries via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + ociPostOne, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const NOSQL_PROXY_BASE = "/dashboard/api/v1/oci/nosql"; +export const NOSQL_COMPARTMENT_KEY = "gopherstack_ocinosql_compartment"; +export const NOSQL_PAGE_SIZE = 20; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const obj = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); +const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const strs = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); + +export type NosqlColumn = { + name: string; + type: string; + nullable: boolean; + generated: boolean; + defaultValue: string; +}; + +export type NosqlTable = { + id: string; + name: string; + state: string; + schemaState: string; + mode: string; + readUnits: number; + writeUnits: number; + storageGb: number; + ttlDays: number; + autoReclaimable: boolean; + ddl: string; + columns: NosqlColumn[]; + primaryKey: string[]; + shardKey: string[]; + created: string; +}; + +export type NosqlIndex = { name: string; state: string; keys: string[] }; + +export type NosqlUsage = { + started: string; + seconds: number; + readUnits: number; + writeUnits: number; + storageGb: number; +}; + +export type NosqlQueryPage = { rows: Json[]; columns: string[]; next: string; readUnits: number }; + +export type NosqlRow = { value: Json; expires: string; readUnits: number }; + +export function parseColumn(raw: Json): NosqlColumn { + return { + name: str(raw.name), + type: str(raw.type), + nullable: raw.isNullable !== false, + generated: raw.isGenerated === true || raw.isAsUuid === true, + defaultValue: str(raw.defaultValue), + }; +} + +export function parseTable(raw: Json): NosqlTable { + const limits = obj(raw.tableLimits); + const schema = obj(raw.schema); + return { + id: str(raw.id), + name: str(raw.name) || str(raw.id), + state: str(raw.lifecycleState), + schemaState: str(raw.schemaState), + mode: str(limits.capacityMode), + readUnits: num(limits.maxReadUnits), + writeUnits: num(limits.maxWriteUnits), + storageGb: num(limits.maxStorageInGBs), + ttlDays: num(schema.ttl), + autoReclaimable: raw.isAutoReclaimable === true, + ddl: str(raw.ddlStatement), + columns: list(schema.columns).map((c) => parseColumn(c)), + primaryKey: strs(schema.primaryKey), + shardKey: strs(schema.shardKey), + created: str(raw.timeCreated), + }; +} + +export function parseIndex(raw: Json): NosqlIndex { + return { + name: str(raw.name), + state: str(raw.lifecycleState), + keys: list(raw.keys).map((k) => { + const path = str(k.jsonPath); + return path ? `${str(k.columnName)}.${path}` : str(k.columnName); + }), + }; +} + +export function parseUsage(raw: Json): NosqlUsage { + return { + started: str(raw.timeStarted), + seconds: num(raw.secondsInPeriod), + readUnits: num(raw.readUnits), + writeUnits: num(raw.writeUnits), + storageGb: num(raw.storageInGBs), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(NOSQL_PROXY_BASE, fetchFn); + +const cq = (compartment: string) => `compartmentId=${enc(compartment)}`; +const tablePath = (compartment: string, table: string, sub = "") => + `/api/tables/${enc(table)}${sub}?${cq(compartment)}`; + +export async function listTables( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + NOSQL_PROXY_BASE, + "/api/tables", + cq(compartment), + "items", + fetchFn, + ); + return items.map((r) => parseTable(r)); +} + +export async function getTable( + compartment: string, + table: string, + fetchFn: FetchFn = fetch, +): Promise { + return parseTable(await ociGetOne(NOSQL_PROXY_BASE, tablePath(compartment, table), fetchFn)); +} + +export async function listIndexes( + compartment: string, + table: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + NOSQL_PROXY_BASE, + `/api/tables/${enc(table)}/indexes`, + cq(compartment), + "items", + fetchFn, + ); + return items.map((r) => parseIndex(r)); +} + +export async function listUsage( + compartment: string, + table: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + NOSQL_PROXY_BASE, + `/api/tables/${enc(table)}/usage`, + cq(compartment), + "items", + fetchFn, + ); + return items.map((r) => parseUsage(r)); +} + +export type TableLimitsInput = { + mode: "PROVISIONED" | "ON_DEMAND"; + readUnits: number; + writeUnits: number; + storageGb: number; +}; + +/** The table name a CREATE TABLE statement declares, or "". */ +export function ddlTableName(ddl: string): string { + const m = /^\s*create\s+table\s+(?:if\s+not\s+exists\s+)?("[^"]+"|[A-Za-z_][\w.]*)/i.exec(ddl); + return m ? m[1].replaceAll(/^"|"$/g, "") : ""; +} + +export async function createTable( + compartment: string, + ddl: string, + limits: TableLimitsInput, + fetchFn: FetchFn = fetch, +): Promise { + const name = ddlTableName(ddl); + if (!name) throw new Error("The DDL must be a CREATE TABLE statement"); + const tableLimits: Json = { capacityMode: limits.mode, maxStorageInGBs: limits.storageGb }; + if (limits.mode === "PROVISIONED") { + tableLimits.maxReadUnits = limits.readUnits; + tableLimits.maxWriteUnits = limits.writeUnits; + } + await restCall( + NOSQL_PROXY_BASE, + "POST", + "/api/tables", + { compartmentId: compartment, name, ddlStatement: ddl.trim(), tableLimits }, + fetchFn, + ); +} + +export async function dropTable( + compartment: string, + table: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall(NOSQL_PROXY_BASE, "DELETE", tablePath(compartment, table), undefined, fetchFn); +} + +/** One page of SELECT results; `next` is the opc-next-page token for the following page. */ +export async function runQuery( + compartment: string, + statement: string, + page = "", + limit = NOSQL_PAGE_SIZE, + fetchFn: FetchFn = fetch, +): Promise { + const qs = [`limit=${limit}`, page ? `page=${enc(page)}` : ""].filter(Boolean).join("&"); + const { body, next } = await ociPostOne( + NOSQL_PROXY_BASE, + `/api/query?${qs}`, + { compartmentId: compartment, statement }, + fetchFn, + ); + const b = obj(body); + const rows = list(b.items); + return { + rows, + columns: resultColumns(rows), + next, + readUnits: num(obj(b.usage).readUnitsConsumed), + }; +} + +/** Union of the keys of all rows, in first-seen order. */ +export function resultColumns(rows: Json[]): string[] { + const seen = new Set(); + for (const r of rows) for (const k of Object.keys(r)) seen.add(k); + return [...seen]; +} + +/** Renders a cell value for the results grid. */ +export function cellText(v: unknown): string { + if (v === null || v === undefined) return "NULL"; + return typeof v === "object" ? JSON.stringify(v) : String(v); +} + +/** Builds the `col:value` key parameters of a GetRow/DeleteRow call. */ +export function keyParams(key: Record): string { + return Object.entries(key) + .filter(([, v]) => v !== "") + .map(([k, v]) => `key=${enc(`${k}:${v}`)}`) + .join("&"); +} + +export async function getRow( + compartment: string, + table: string, + key: Record, + fetchFn: FetchFn = fetch, +): Promise { + const body = await ociGetOne( + NOSQL_PROXY_BASE, + `/api/tables/${enc(table)}/rows?${cq(compartment)}&${keyParams(key)}`, + fetchFn, + ); + if (body.value === null || body.value === undefined) return null; + return { + value: obj(body.value), + expires: str(body.timeOfExpiration), + readUnits: num(obj(body.usage).readUnitsConsumed), + }; +} + +/** Puts a row; `json` must be a JSON object. */ +export async function putRow( + compartment: string, + table: string, + json: string, + fetchFn: FetchFn = fetch, +): Promise { + let value: unknown; + try { + value = JSON.parse(json); + } catch { + throw new Error("Row is not valid JSON"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Row must be a JSON object"); + } + await restCall( + NOSQL_PROXY_BASE, + "PUT", + `/api/tables/${enc(table)}/rows`, + { compartmentId: compartment, value }, + fetchFn, + ); +} + +/** Deletes a row by primary key; resolves to whether a row was removed. */ +export async function deleteRow( + compartment: string, + table: string, + key: Record, + fetchFn: FetchFn = fetch, +): Promise { + const body = await restCall( + NOSQL_PROXY_BASE, + "DELETE", + `/api/tables/${enc(table)}/rows?${cq(compartment)}&${keyParams(key)}`, + undefined, + fetchFn, + ); + return body.isSuccess === true; +} + +/** Which actions a table state allows. */ +export const canDrop = (t: Pick): boolean => + t.state === "ACTIVE" || t.state === "UPDATING"; +export const rowsUsable = (t: Pick): boolean => t.state === "ACTIVE"; + +export const defaultQuery = (table: string): string => `SELECT * FROM ${table} LIMIT 50`; + +export const readStoredNosqlCompartment = () => readStoredValue(NOSQL_COMPARTMENT_KEY); +export const writeStoredNosqlCompartment = (id: string) => + writeStoredValue(NOSQL_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ocinotifications.test.ts b/ui/src/lib/ocinotifications.test.ts new file mode 100644 index 0000000000..204a2e5b2b --- /dev/null +++ b/ui/src/lib/ocinotifications.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + NOTIF_PROXY_BASE, + confirmSubscription, + confirmationQuery, + isHttpUrl, + listDeliveries, + listSubscriptions, + listTopics, + parseDelivery, + parseSubscription, + parseTopic, + resendConfirmation, +} from "./ocinotifications"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const asFetch = (f: unknown) => f as typeof fetch; +const comp = "ocid1.compartment.oc1..aaaa"; +const cq = `compartmentId=${encodeURIComponent(comp)}`; + +describe("parsing", () => { + it("parses a topic", () => { + expect( + parseTopic({ topicId: "t1", name: "alerts", lifecycleState: "ACTIVE", description: "d" }), + ).toMatchObject({ id: "t1", name: "alerts", state: "ACTIVE", description: "d" }); + }); + + it("parses a subscription with its epoch-millisecond creation time", () => { + expect( + parseSubscription({ + id: "s1", + topicId: "t1", + protocol: "EMAIL", + endpoint: "a@example.com", + lifecycleState: "PENDING", + createdTime: 1_700_000_000_000, + }), + ).toMatchObject({ + protocol: "EMAIL", + endpoint: "a@example.com", + state: "PENDING", + created: "2023-11-14T22:13:20.000Z", + }); + }); + + it("parses a delivery", () => { + expect( + parseDelivery({ + id: "d1", + kind: "CONFIRMATION", + status: "RECORDED", + attempts: 1, + topicName: "alerts", + }), + ).toMatchObject({ id: "d1", kind: "CONFIRMATION", status: "RECORDED", attempts: 1 }); + }); + + it.each([ + ["http://localhost:8157/x", true], + ["https://example.com", true], + ["javascript:alert(1)", false], + ["", false], + ])("isHttpUrl %j", (u, want) => { + expect(isHttpUrl(u)).toBe(want); + }); + + it("extracts the confirmation query", () => { + expect( + confirmationQuery("http://h/20181201/subscriptions/s1/confirmation?token=abc&protocol=EMAIL"), + ).toBe("token=abc&protocol=EMAIL"); + expect(confirmationQuery("not a url")).toBe(""); + }); +}); + +describe("client", () => { + it("lists topics and subscriptions of a compartment", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.includes("/topics") ? [{ topicId: "t1", name: "a" }] : [{ id: "s1", topicId: "t1" }], + ), + ), + ); + expect((await listTopics(comp, asFetch(fetchFn))).map((t) => t.id)).toEqual(["t1"]); + expect((await listSubscriptions(comp, asFetch(fetchFn))).map((s) => s.id)).toEqual(["s1"]); + expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([ + `${NOTIF_PROXY_BASE}/api/topics?${cq}`, + `${NOTIF_PROXY_BASE}/api/subscriptions?${cq}`, + ]); + }); + + it("lists deliveries newest first", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json([{ id: "d1" }, { id: "d2" }]))); + expect((await listDeliveries(asFetch(fetchFn))).map((d) => d.id)).toEqual(["d2", "d1"]); + }); + + it("confirms with the link's query and resends a confirmation", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(json({}))); + await confirmSubscription("s1", "token=abc&protocol=EMAIL", asFetch(fetchFn)); + await resendConfirmation("s1", asFetch(fetchFn)); + expect( + fetchFn.mock.calls.map((c) => [c[0], (c[1] as RequestInit | undefined)?.method]), + ).toEqual([ + [`${NOTIF_PROXY_BASE}/api/subscriptions/s1/confirmation?token=abc&protocol=EMAIL`, undefined], + [`${NOTIF_PROXY_BASE}/api/subscriptions/s1/resendConfirmation`, "POST"], + ]); + }); + + it("surfaces a service error", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(json({ code: "NotAuthorizedOrNotFound", message: "bad token" }, 404)); + await expect(confirmSubscription("s1", "token=x", asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 404, + message: "bad token", + }); + }); +}); diff --git a/ui/src/lib/ocinotifications.ts b/ui/src/lib/ocinotifications.ts new file mode 100644 index 0000000000..d706f86469 --- /dev/null +++ b/ui/src/lib/ocinotifications.ts @@ -0,0 +1,176 @@ +/** Client for the Oracle Cloud Notifications emulator (topics, subscriptions, recorded deliveries) via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const NOTIF_PROXY_BASE = "/dashboard/api/v1/oci/notifications"; +export const NOTIF_COMPARTMENT_KEY = "gopherstack_ocinotifications_compartment"; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); + +export type Topic = { + id: string; + name: string; + description: string; + state: string; + created: string; + endpoint: string; +}; + +export type Subscription = { + id: string; + topicId: string; + protocol: string; + endpoint: string; + state: string; + created: string; +}; + +export type Delivery = { + id: string; + kind: string; + status: string; + topicId: string; + topicName: string; + subscriptionId: string; + protocol: string; + endpoint: string; + title: string; + body: string; + attempts: number; + published: string; + lastError: string; + confirmationUrl: string; + unsubscribeUrl: string; +}; + +export function parseTopic(raw: Json): Topic { + return { + id: str(raw.topicId), + name: str(raw.name), + description: str(raw.description), + state: str(raw.lifecycleState), + created: str(raw.timeCreated), + endpoint: str(raw.apiEndpoint), + }; +} + +export function parseSubscription(raw: Json): Subscription { + const created = num(raw.createdTime); + return { + id: str(raw.id), + topicId: str(raw.topicId), + protocol: str(raw.protocol), + endpoint: str(raw.endpoint), + state: str(raw.lifecycleState), + created: created ? new Date(created).toISOString() : "", + }; +} + +export function parseDelivery(raw: Json): Delivery { + return { + id: str(raw.id), + kind: str(raw.kind), + status: str(raw.status), + topicId: str(raw.topicId), + topicName: str(raw.topicName), + subscriptionId: str(raw.subscriptionId), + protocol: str(raw.protocol), + endpoint: str(raw.endpoint), + title: str(raw.title), + body: str(raw.body), + attempts: num(raw.attempts), + published: str(raw.published), + lastError: str(raw.lastError), + confirmationUrl: str(raw.confirmationUrl), + unsubscribeUrl: str(raw.unsubscribeUrl), + }; +} + +/** Only http(s) links from deliveries are rendered as anchors. */ +export const isHttpUrl = (u: string): boolean => /^https?:\/\//i.test(u); + +/** The query (token and protocol) of a delivery's confirmation link, without the leading "?". */ +export function confirmationQuery(url: string): string { + try { + return new URL(url).search.replace(/^\?/, ""); + } catch { + return ""; + } +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(NOTIF_PROXY_BASE, fetchFn); + +export async function listTopics(compartment: string, fetchFn: FetchFn = fetch): Promise { + const items = await ociListPages( + NOTIF_PROXY_BASE, + "/api/topics", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return items.map((r) => parseTopic(r)); +} + +export async function listSubscriptions( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + NOTIF_PROXY_BASE, + "/api/subscriptions", + `compartmentId=${enc(compartment)}`, + "", + fetchFn, + ); + return items.map((r) => parseSubscription(r)); +} + +export async function listDeliveries(fetchFn: FetchFn = fetch): Promise { + const body = await restCall( + NOTIF_PROXY_BASE, + "GET", + "/api/deliveries", + undefined, + fetchFn, + ); + return (Array.isArray(body) ? (body as Json[]) : []).map((r) => parseDelivery(r)).toReversed(); +} + +/** Confirms a subscription with the query of its confirmation link. */ +export async function confirmSubscription( + subscriptionId: string, + query: string, + fetchFn: FetchFn = fetch, +): Promise { + await ociGetOne( + NOTIF_PROXY_BASE, + `/api/subscriptions/${enc(subscriptionId)}/confirmation?${query}`, + fetchFn, + ); +} + +export async function resendConfirmation( + subscriptionId: string, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + NOTIF_PROXY_BASE, + "POST", + `/api/subscriptions/${enc(subscriptionId)}/resendConfirmation`, + undefined, + fetchFn, + ); +} + +export const readStoredNotifCompartment = () => readStoredValue(NOTIF_COMPARTMENT_KEY); +export const writeStoredNotifCompartment = (id: string) => + writeStoredValue(NOTIF_COMPARTMENT_KEY, id); diff --git a/ui/src/lib/ociqueue.test.ts b/ui/src/lib/ociqueue.test.ts new file mode 100644 index 0000000000..f29f1dc86b --- /dev/null +++ b/ui/src/lib/ociqueue.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + QUEUE_PROXY_BASE, + formatBytes, + formatSeconds, + getQueue, + getStats, + listCompartments, + listQueues, + parsePeeked, + parseQueue, + parseStats, + peekMessages, + purgeQueue, +} from "./ociqueue"; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +const asFetch = (f: unknown) => f as typeof fetch; + +const comp = "ocid1.compartment.oc1..aaaa"; +const queue = (id: string, name: string) => ({ + id, + displayName: name, + lifecycleState: "ACTIVE", + messagesEndpoint: "http://localhost:8156", + retentionInSeconds: 86400, + visibilityInSeconds: 30, + timeoutInSeconds: 30, + deadLetterQueueDeliveryCount: 5, +}); + +describe("parsing", () => { + it("parses a queue", () => { + expect(parseQueue(queue("q1", "jobs"))).toMatchObject({ + id: "q1", + name: "jobs", + state: "ACTIVE", + retention: 86400, + dlqDeliveryCount: 5, + }); + }); + + it("parses stats for the queue and its dead letter queue", () => { + expect( + parseStats({ + queue: { visibleMessages: 3, inFlightMessages: 1, sizeInBytes: 40 }, + dlq: { visibleMessages: 2 }, + }), + ).toEqual({ + queue: { visible: 3, inFlight: 1, sizeBytes: 40 }, + dlq: { visible: 2, inFlight: 0, sizeBytes: 0 }, + }); + }); + + it("parses a peeked message without a receipt", () => { + const m = parsePeeked({ + id: 7, + content: "hello", + deliveryCount: 2, + inFlight: true, + channelId: "c", + }); + expect(m).toMatchObject({ + id: 7, + content: "hello", + deliveryCount: 2, + inFlight: true, + channel: "c", + }); + expect(m).not.toHaveProperty("receipt"); + }); + + it.each([ + [10, "10 B"], + [2048, "2.0 KB"], + [3 * 1024 * 1024, "3.0 MB"], + ])("formats %d bytes", (n, want) => { + expect(formatBytes(n)).toBe(want); + }); + + it.each([ + [30, "30s"], + [90, "1m 30s"], + [3600, "1h"], + [86400, "1d"], + ])("formats %d seconds", (n, want) => { + expect(formatSeconds(n)).toBe(want); + }); +}); + +describe("client", () => { + it("lists compartments", async () => { + const fetchFn = vi.fn(() => Promise.resolve(json({ compartments: [comp] }))); + expect(await listCompartments(asFetch(fetchFn))).toEqual([comp]); + }); + + it("follows opc-next-page when listing queues", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(json({ items: [queue("q1", "a")] }, 200, { "opc-next-page": "p2" })) + .mockResolvedValueOnce(json({ items: [queue("q2", "b")] })); + const out = await listQueues(comp, asFetch(fetchFn)); + expect(out.map((q) => q.id)).toEqual(["q1", "q2"]); + expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([ + `${QUEUE_PROXY_BASE}/api/queues?compartmentId=${encodeURIComponent(comp)}`, + `${QUEUE_PROXY_BASE}/api/queues?compartmentId=${encodeURIComponent(comp)}&page=p2`, + ]); + }); + + it("reads a queue, its stats and a peek of the dead letter queue", async () => { + const fetchFn = vi.fn((url: string) => + Promise.resolve( + json( + url.endsWith("/stats") + ? { queue: { visibleMessages: 1 }, dlq: {} } + : url.includes("/peek") + ? { messages: [{ id: 1, content: "x" }] } + : queue("q1", "jobs"), + ), + ), + ); + expect((await getQueue("q1", asFetch(fetchFn))).name).toBe("jobs"); + expect((await getStats("q1", asFetch(fetchFn))).queue.visible).toBe(1); + expect(await peekMessages("q1", true, 5, asFetch(fetchFn))).toHaveLength(1); + expect(fetchFn.mock.calls[2]?.[0]).toBe( + `${QUEUE_PROXY_BASE}/api/queues/q1/peek?limit=5&dlq=true`, + ); + }); + + it("posts a purge with its type", async () => { + const fetchFn = vi.fn((_url: string, _init?: RequestInit) => + Promise.resolve(new Response(null, { status: 202 })), + ); + await purgeQueue("q1", "BOTH", asFetch(fetchFn)); + const [url, init] = fetchFn.mock.calls[0] ?? []; + expect(url).toBe(`${QUEUE_PROXY_BASE}/api/queues/q1/actions/purge`); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ purgeType: "BOTH" }); + }); + + it("surfaces a service error", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(json({ code: "NotFound", message: "no such queue" }, 404)); + await expect(getQueue("x", asFetch(fetchFn))).rejects.toMatchObject({ + httpStatus: 404, + code: "NotFound", + }); + }); +}); diff --git a/ui/src/lib/ociqueue.ts b/ui/src/lib/ociqueue.ts new file mode 100644 index 0000000000..f10f8d8eb2 --- /dev/null +++ b/ui/src/lib/ociqueue.ts @@ -0,0 +1,164 @@ +/** Client for the Oracle Cloud Queue emulator (queues, stats, message peek, purge) via the dashboard's in-process proxy. */ + +import { + ociCompartments, + ociGetOne, + ociListPages, + readStoredValue, + writeStoredValue, +} from "./ocirest.ts"; +import { restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +export const QUEUE_PROXY_BASE = "/dashboard/api/v1/oci/queue"; +export const QUEUE_COMPARTMENT_KEY = "gopherstack_ociqueue_compartment"; + +const enc = encodeURIComponent; +const num = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0); +const rec = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +export type PurgeType = "NORMAL" | "DLQ" | "BOTH"; + +export type QueueInfo = { + id: string; + name: string; + state: string; + created: string; + endpoint: string; + retention: number; + visibility: number; + timeout: number; + dlqDeliveryCount: number; +}; + +export type Counts = { visible: number; inFlight: number; sizeBytes: number }; + +export type QueueStats = { queue: Counts; dlq: Counts }; + +export type PeekedMessage = { + id: number; + content: string; + channel: string; + deliveryCount: number; + inFlight: boolean; + created: string; + visibleAfter: string; + expireAfter: string; + properties: Record; +}; + +export function parseQueue(raw: Json): QueueInfo { + return { + id: str(raw.id), + name: str(raw.displayName) || str(raw.id), + state: str(raw.lifecycleState), + created: str(raw.timeCreated), + endpoint: str(raw.messagesEndpoint), + retention: num(raw.retentionInSeconds), + visibility: num(raw.visibilityInSeconds), + timeout: num(raw.timeoutInSeconds), + dlqDeliveryCount: num(raw.deadLetterQueueDeliveryCount), + }; +} + +const counts = (v: unknown): Counts => { + const c = rec(v); + return { + visible: num(c.visibleMessages), + inFlight: num(c.inFlightMessages), + sizeBytes: num(c.sizeInBytes), + }; +}; + +export function parseStats(raw: Json): QueueStats { + return { queue: counts(raw.queue), dlq: counts(raw.dlq) }; +} + +export function parsePeeked(raw: Json): PeekedMessage { + const props = rec(raw.customProperties); + return { + id: num(raw.id), + content: str(raw.content), + channel: str(raw.channelId), + deliveryCount: num(raw.deliveryCount), + inFlight: raw.inFlight === true, + created: str(raw.createdAt), + visibleAfter: str(raw.visibleAfter), + expireAfter: str(raw.expireAfter), + properties: Object.fromEntries( + Object.entries(props).filter((e): e is [string, string] => typeof e[1] === "string"), + ), + }; +} + +export const listCompartments = (fetchFn: FetchFn = fetch) => + ociCompartments(QUEUE_PROXY_BASE, fetchFn); + +export async function listQueues( + compartment: string, + fetchFn: FetchFn = fetch, +): Promise { + const items = await ociListPages( + QUEUE_PROXY_BASE, + "/api/queues", + `compartmentId=${enc(compartment)}`, + "items", + fetchFn, + ); + return items.map((r) => parseQueue(r)); +} + +export async function getQueue(id: string, fetchFn: FetchFn = fetch): Promise { + return parseQueue(await ociGetOne(QUEUE_PROXY_BASE, `/api/queues/${enc(id)}`, fetchFn)); +} + +export async function getStats(id: string, fetchFn: FetchFn = fetch): Promise { + return parseStats(await ociGetOne(QUEUE_PROXY_BASE, `/api/queues/${enc(id)}/stats`, fetchFn)); +} + +/** Messages of a queue (or its dead letter queue) without receiving them: visibility and delivery counts stay as they are. */ +export async function peekMessages( + id: string, + dlq: boolean, + limit = 20, + fetchFn: FetchFn = fetch, +): Promise { + const body = await ociGetOne( + QUEUE_PROXY_BASE, + `/api/queues/${enc(id)}/peek?limit=${limit}&dlq=${dlq}`, + fetchFn, + ); + return (Array.isArray(body.messages) ? (body.messages as Json[]) : []).map((r) => parsePeeked(r)); +} + +export async function purgeQueue( + id: string, + purgeType: PurgeType, + fetchFn: FetchFn = fetch, +): Promise { + await restCall( + QUEUE_PROXY_BASE, + "POST", + `/api/queues/${enc(id)}/actions/purge`, + { purgeType }, + fetchFn, + ); +} + +export const readStoredQueueCompartment = () => readStoredValue(QUEUE_COMPARTMENT_KEY); +export const writeStoredQueueCompartment = (id: string) => + writeStoredValue(QUEUE_COMPARTMENT_KEY, id); + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Seconds as a compact duration (90 -> "1m 30s"). */ +export function formatSeconds(s: number): string { + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""}`; + if (s < 86400) + return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60) ? ` ${Math.floor((s % 3600) / 60)}m` : ""}`; + return `${Math.floor(s / 86400)}d`; +} diff --git a/ui/src/lib/ocirest.ts b/ui/src/lib/ocirest.ts new file mode 100644 index 0000000000..12f1fb23ce --- /dev/null +++ b/ui/src/lib/ocirest.ts @@ -0,0 +1,128 @@ +/** Shared client plumbing for the dashboard's in-process Oracle Cloud proxies (opc-next-page paging, compartments). */ + +import { GcpRestError, restCall, str, type FetchFn, type Json } from "./gcprest.ts"; + +const enc = encodeURIComponent; +const arr = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []); +const rec = (v: unknown): Json => (v && typeof v === "object" ? (v as Json) : {}); + +async function ociGet( + base: string, + path: string, + fetchFn: FetchFn, + post?: unknown, +): Promise<{ body: unknown; next: string }> { + let res: Response; + try { + res = await fetchFn( + `${base}${path}`, + post === undefined + ? undefined + : { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(post), + }, + ); + } catch (e) { + throw new GcpRestError(0, "NetworkError", e instanceof Error ? e.message : String(e)); + } + const text = await res.text(); + let body: unknown = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + // non-JSON error body: keep the status text + } + if (!res.ok) { + const b = rec(body); + const proxy = rec(b.error); + throw new GcpRestError( + res.status, + str(proxy.status) || str(b.code), + str(proxy.message) || str(b.message) || res.statusText || `HTTP ${res.status}`, + ); + } + return { body, next: res.headers.get("opc-next-page") ?? "" }; +} + +/** Follows opc-next-page through a list endpoint; `key` names the array when the body is an envelope. */ +export async function ociListPages( + base: string, + path: string, + query: string, + key: string, + fetchFn: FetchFn = fetch, +): Promise { + const out: Json[] = []; + let page = ""; + do { + const qs = [query, page ? `page=${enc(page)}` : ""].filter(Boolean).join("&"); + const { body, next } = await ociGet(base, qs ? `${path}?${qs}` : path, fetchFn); + out.push(...arr(key ? rec(body)[key] : body)); + page = next; + } while (page); + return out; +} + +/** Like `ociListPages` for a list that is a POST with a JSON body (Monitoring ListMetrics). */ +export async function ociPostPages( + base: string, + path: string, + query: string, + body: unknown, + fetchFn: FetchFn = fetch, +): Promise { + const out: Json[] = []; + let page = ""; + do { + const qs = [query, page ? `page=${enc(page)}` : ""].filter(Boolean).join("&"); + const res = await ociGet(base, qs ? `${path}?${qs}` : path, fetchFn, body); + out.push(...arr(res.body)); + page = res.next; + } while (page); + return out; +} + +/** One POST whose JSON answer is returned with the opc-next-page token (log search, metric queries). */ +export function ociPostOne( + base: string, + path: string, + body: unknown, + fetchFn: FetchFn = fetch, +): Promise<{ body: unknown; next: string }> { + return ociGet(base, path, fetchFn, body); +} + +export async function ociGetOne( + base: string, + path: string, + fetchFn: FetchFn = fetch, +): Promise { + return rec((await ociGet(base, path, fetchFn)).body); +} + +export async function ociCompartments(base: string, fetchFn: FetchFn = fetch): Promise { + const body = await restCall(base, "GET", "/compartments", undefined, fetchFn); + return (Array.isArray(body.compartments) ? body.compartments : []).map(String); +} + +export function readStoredValue(key: string): string { + try { + return localStorage.getItem(key)?.trim() ?? ""; + } catch { + return ""; + } +} + +export function writeStoredValue(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // storage unavailable: selection applies for this session only + } +} + +/** Compact display form of an OCID. */ +export const shortOcid = (ocid: string): string => + ocid.length > 20 ? `…${ocid.slice(-12)}` : ocid; diff --git a/ui/src/lib/tryit.test.ts b/ui/src/lib/tryit.test.ts new file mode 100644 index 0000000000..71bce4503b --- /dev/null +++ b/ui/src/lib/tryit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { cleanQuery, parseHeaderLines, readTryResponse } from "./tryit"; + +describe("tryit helpers", () => { + it("parses header lines", () => { + expect(parseHeaderLines("A: b\n\n C : d: e \nnocolon")).toEqual([ + { name: "A", value: "b" }, + { name: "C", value: "d: e" }, + { name: "nocolon", value: "" }, + ]); + }); + + it("normalises the proxy answer", () => { + expect( + readTryResponse({ status: 201, headers: { A: 1 }, body: "x", encoding: "", truncated: true }), + ).toEqual({ status: 201, headers: { A: "1" }, body: "x", encoding: "utf8", truncated: true }); + expect(readTryResponse({ status: "404" }).status).toBe(404); + expect(readTryResponse({}).headers).toEqual({}); + }); + + it("strips a leading question mark", () => { + expect(cleanQuery(" ?a=1&b=2 ")).toBe("a=1&b=2"); + expect(cleanQuery("")).toBe(""); + }); +}); diff --git a/ui/src/lib/tryit.ts b/ui/src/lib/tryit.ts new file mode 100644 index 0000000000..d148162eac --- /dev/null +++ b/ui/src/lib/tryit.ts @@ -0,0 +1,37 @@ +/** Shared helpers for the API gateway "try it" panels (request headers in, proxy response out). */ + +import { str, type Json } from "./gcprest.ts"; +import type { TryResponse } from "./ociapigateway.ts"; + +export type HeaderPair = { name: string; value: string }; + +/** Parses "Name: value" lines; blank lines are skipped and a line without a colon becomes an empty value. */ +export function parseHeaderLines(text: string): HeaderPair[] { + return text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => { + const i = l.indexOf(":"); + return i < 0 + ? { name: l, value: "" } + : { name: l.slice(0, i).trim(), value: l.slice(i + 1).trim() }; + }); +} + +/** Normalises the proxy's invoke answer. */ +export function readTryResponse(b: Json): TryResponse { + const headers: Record = {}; + const raw = b.headers && typeof b.headers === "object" ? (b.headers as Json) : {}; + for (const [k, v] of Object.entries(raw)) headers[k] = String(v); + return { + status: typeof b.status === "number" ? b.status : Number(b.status) || 0, + headers, + body: str(b.body), + encoding: str(b.encoding) || "utf8", + truncated: b.truncated === true, + }; +} + +/** Request path/query as the proxy expects them: no leading "?" on the query. */ +export const cleanQuery = (q: string): string => q.trim().replace(/^\?/, ""); diff --git a/ui/src/routes/azure/[service]/+page.svelte b/ui/src/routes/azure/[service]/+page.svelte index 1c2355fbee..377153d37d 100644 --- a/ui/src/routes/azure/[service]/+page.svelte +++ b/ui/src/routes/azure/[service]/+page.svelte @@ -2,7 +2,12 @@ import { onMount } from 'svelte'; import { page } from '$app/state'; import { azureServiceById } from '#lib/cloud.ts'; + import ApiManagementDashboard from '#lib/components/azure/ApiManagementDashboard.svelte'; + import AppServiceDashboard from '#lib/components/azure/AppServiceDashboard.svelte'; import ArmDashboard from '#lib/components/azure/ArmDashboard.svelte'; + import FlexibleServerDashboard from '#lib/components/azure/FlexibleServerDashboard.svelte'; + import KubernetesServiceDashboard from '#lib/components/azure/KubernetesServiceDashboard.svelte'; + import ContainerAppsDashboard from '#lib/components/azure/ContainerAppsDashboard.svelte'; import ContainerInstancesDashboard from '#lib/components/azure/ContainerInstancesDashboard.svelte'; import EventHubsDashboard from '#lib/components/azure/EventHubsDashboard.svelte'; import EventGridDashboard from '#lib/components/azure/EventGridDashboard.svelte'; @@ -16,6 +21,7 @@ import QueueDashboard from '#lib/components/azure/QueueDashboard.svelte'; import ServiceBusDashboard from '#lib/components/azure/ServiceBusDashboard.svelte'; import TableDashboard from '#lib/components/azure/TableDashboard.svelte'; + import VirtualMachinesDashboard from '#lib/components/azure/VirtualMachinesDashboard.svelte'; import { backendInfo, loadBackendInfo, servedAzureServices } from '#lib/cloud.svelte.ts'; const service = $derived(azureServiceById(page.params.service ?? '')); @@ -56,6 +62,18 @@ {:else if service?.id === 'aci'} +{:else if service?.id === 'appservice'} + +{:else if service?.id === 'containerapps'} + +{:else if service?.id === 'vm'} + +{:else if service?.id === 'aks'} + +{:else if service?.id === 'flexdb'} + +{:else if service?.id === 'apim'} + {:else}
    {#if !service} diff --git a/ui/src/routes/gcp/[service]/+page.svelte b/ui/src/routes/gcp/[service]/+page.svelte index e67470dc6d..17a345dda4 100644 --- a/ui/src/routes/gcp/[service]/+page.svelte +++ b/ui/src/routes/gcp/[service]/+page.svelte @@ -24,6 +24,12 @@ import WorkflowsDashboard from '#lib/components/gcp/WorkflowsDashboard.svelte'; import DnsDashboard from '#lib/components/gcp/DnsDashboard.svelte'; import ComputeDashboard from '#lib/components/gcp/ComputeDashboard.svelte'; + import DatastoreDashboard from '#lib/components/gcp/DatastoreDashboard.svelte'; + import ResourceManagerDashboard from '#lib/components/gcp/ResourceManagerDashboard.svelte'; + import ApiGatewayDashboard from '#lib/components/gcp/ApiGatewayDashboard.svelte'; + import BatchDashboard from '#lib/components/gcp/BatchDashboard.svelte'; + import IdentityPlatformDashboard from '#lib/components/gcp/IdentityPlatformDashboard.svelte'; + import KubernetesDashboard from '#lib/components/gcp/KubernetesDashboard.svelte'; import SecretManagerDashboard from '#lib/components/gcp/SecretManagerDashboard.svelte'; import { backendInfo, loadBackendInfo, servedGcpServices } from '#lib/cloud.svelte.ts'; @@ -83,6 +89,18 @@ {:else if service?.id === 'compute'} +{:else if service?.id === 'datastore'} + +{:else if service?.id === 'container'} + +{:else if service?.id === 'identitytoolkit'} + +{:else if service?.id === 'apigateway'} + +{:else if service?.id === 'batch'} + +{:else if service?.id === 'resourcemanager'} + {:else}
    {#if !service} diff --git a/ui/src/routes/oci/[service]/+page.svelte b/ui/src/routes/oci/[service]/+page.svelte index 2c7db4489c..8655a75611 100644 --- a/ui/src/routes/oci/[service]/+page.svelte +++ b/ui/src/routes/oci/[service]/+page.svelte @@ -2,9 +2,22 @@ import { onMount } from 'svelte'; import { page } from '$app/state'; import { ociServiceById } from '#lib/cloud.ts'; + import APIGatewayDashboard from '#lib/components/oci/APIGatewayDashboard.svelte'; + import ArtifactsDashboard from '#lib/components/oci/ArtifactsDashboard.svelte'; + import ContainerInstancesDashboard from '#lib/components/oci/ContainerInstancesDashboard.svelte'; + import ContainerEngineDashboard from '#lib/components/oci/ContainerEngineDashboard.svelte'; + import CoreDashboard from '#lib/components/oci/CoreDashboard.svelte'; + import EventsDashboard from '#lib/components/oci/EventsDashboard.svelte'; import FunctionsDashboard from '#lib/components/oci/FunctionsDashboard.svelte'; import IdentityDashboard from '#lib/components/oci/IdentityDashboard.svelte'; + import LoadBalancerDashboard from '#lib/components/oci/LoadBalancerDashboard.svelte'; + import LoggingDashboard from '#lib/components/oci/LoggingDashboard.svelte'; + import MonitoringDashboard from '#lib/components/oci/MonitoringDashboard.svelte'; + import MySQLDashboard from '#lib/components/oci/MySQLDashboard.svelte'; + import NoSQLDashboard from '#lib/components/oci/NoSQLDashboard.svelte'; + import NotificationsDashboard from '#lib/components/oci/NotificationsDashboard.svelte'; import ObjectStorageDashboard from '#lib/components/oci/ObjectStorageDashboard.svelte'; + import QueueDashboard from '#lib/components/oci/QueueDashboard.svelte'; import StreamingDashboard from '#lib/components/oci/StreamingDashboard.svelte'; import VaultDashboard from '#lib/components/oci/VaultDashboard.svelte'; import { backendInfo, loadBackendInfo, servedOciServices } from '#lib/cloud.svelte.ts'; @@ -29,6 +42,32 @@ {:else if service?.id === 'functions'} +{:else if service?.id === 'core'} + +{:else if service?.id === 'queue'} + +{:else if service?.id === 'notifications'} + +{:else if service?.id === 'containerengine'} + +{:else if service?.id === 'mysql'} + +{:else if service?.id === 'logging'} + +{:else if service?.id === 'monitoring'} + +{:else if service?.id === 'nosql'} + +{:else if service?.id === 'apigateway'} + +{:else if service?.id === 'events'} + +{:else if service?.id === 'loadbalancer'} + +{:else if service?.id === 'artifacts'} + +{:else if service?.id === 'containerinstances'} + {:else}
    {#if !service}